feat(daemon): implement page CRUD routes (Task 8)

- Add routes/pages.ts with pageRoutes handler for POST/GET/DELETE /pages
- Handle POST /pages (create), GET /pages (list), GET /pages/:id, DELETE /pages/:id
- Use safeStringify for all JSON output, proper error codes for 400/404/409/500
- Update server.ts to accept optional BrowserManager and register pageRoutes
- Add 13 unit tests with mock BrowserManager covering all endpoints
This commit is contained in:
2026-08-12 20:55:36 +08:00
parent a8fb781a7c
commit ac503a5ca3
3 changed files with 369 additions and 2 deletions
@@ -0,0 +1,291 @@
// Page CRUD routes integration tests with mock BrowserManager / 页面 CRUD 路由集成测试,使用模拟浏览器管理器
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import { startServer } from '../server.js';
import type { PageInfo } from '@visionl/core';
// Mock BrowserManager — mimics BrowserManager behavior without Playwright / 模拟浏览器管理器
function createMockBM() {
const pages = new Map<string, PageInfo>();
const aliases = new Map<string, string>();
const mock = {
async createPage(pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && aliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
pages.set(id, info);
if (alias) aliases.set(alias, id);
return info;
},
getPage(idOrAlias: string) {
const id = aliases.get(idOrAlias) || idOrAlias;
const info = pages.get(id);
if (!info) return undefined;
return { info, page: null, context: null };
},
listPages(): PageInfo[] {
return Array.from(pages.values());
},
async closePage(idOrAlias: string): Promise<void> {
const id = aliases.get(idOrAlias) || idOrAlias;
if (!pages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = pages.get(id)!;
if (info.alias) aliases.delete(info.alias);
pages.delete(id);
},
};
return { mock, pages };
}
describe('page CRUD routes', () => {
let server: http.Server;
let baseUrl: string;
const port = 19529;
const { mock } = createMockBM();
beforeAll(async () => {
server = await startServer(port, mock as any);
baseUrl = `http://127.0.0.1:${port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
// Each test gets a fresh mock state via re-creating mock internals / 每个测试用例使用全新模拟状态
let mockPages: Map<string, PageInfo>;
let mockAliases: Map<string, string>;
function resetMock() {
mockPages = new Map();
mockAliases = new Map();
mock.createPage = async function (pageUrl: string, alias?: string): Promise<PageInfo> {
if (alias && mockAliases.has(alias)) {
throw Object.assign(new Error(`Alias "${alias}" already exists`), { code: 'ALIAS_EXISTS' });
}
try {
new URL(pageUrl);
} catch {
throw Object.assign(new Error(`Invalid URL: ${pageUrl}`), { code: 'INVALID_URL' });
}
const id = 'p_' + Math.random().toString(16).slice(2, 10);
const info: PageInfo = { id, url: pageUrl, alias, title: 'Test Page', status: 'active', profile: 'fp_test' };
mockPages.set(id, info);
if (alias) mockAliases.set(alias, id);
return info;
};
mock.getPage = function (idOrAlias: string) {
const id = mockAliases.get(idOrAlias) || idOrAlias;
const info = mockPages.get(id);
if (!info) return undefined;
return { info, page: null, context: null };
};
mock.listPages = function (): PageInfo[] {
return Array.from(mockPages.values());
};
mock.closePage = async function (idOrAlias: string): Promise<void> {
const id = mockAliases.get(idOrAlias) || idOrAlias;
if (!mockPages.has(id)) {
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
}
const info = mockPages.get(id)!;
if (info.alias) mockAliases.delete(info.alias);
mockPages.delete(id);
};
}
// Reset mock state before each test / 每个测试前重置模拟状态
beforeEach(() => {
resetMock();
});
it('POST /pages creates a page and returns 200', async () => {
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'test' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.id).toMatch(/^p_/);
expect(json.data.url).toBe('https://example.com');
expect(json.data.alias).toBe('test');
expect(json.data.status).toBe('active');
});
it('POST /pages with alias creates page accessible by alias', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'home' }),
});
const createJson = await createRes.json();
const getRes = await fetch(`${baseUrl}/pages/home`);
expect(getRes.status).toBe(200);
const getJson = await getRes.json();
expect(getJson.data.id).toBe(createJson.data.id);
expect(getJson.data.alias).toBe('home');
});
it('POST /pages without alias works', async () => {
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.alias).toBeUndefined();
});
it('POST /pages with invalid URL returns 400', async () => {
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'not-a-valid-url' }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('INVALID_URL');
});
it('POST /pages with duplicate alias returns 409', async () => {
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'dup' }),
});
const res = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://other.com', alias: 'dup' }),
});
expect(res.status).toBe(409);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('ALIAS_EXISTS');
});
it('GET /pages lists all created pages', async () => {
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://other.com' }),
});
const res = await fetch(`${baseUrl}/pages`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data).toHaveLength(2);
});
it('GET /pages returns empty array when no pages', async () => {
const res = await fetch(`${baseUrl}/pages`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data).toEqual([]);
});
it('GET /pages/:id returns page by ID', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const res = await fetch(`${baseUrl}/pages/${created.id}`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.ok).toBe(true);
expect(json.data.id).toBe(created.id);
expect(json.data.url).toBe('https://example.com');
});
it('GET /pages/:id returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent`);
expect(res.status).toBe(404);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('DELETE /pages/:id removes page and returns 200', async () => {
const createRes = await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
const { data: created } = await createRes.json();
const deleteRes = await fetch(`${baseUrl}/pages/${created.id}`, { method: 'DELETE' });
expect(deleteRes.status).toBe(200);
const deleteJson = await deleteRes.json();
expect(deleteJson.ok).toBe(true);
expect(deleteJson.data).toBeNull();
// Verify page is gone
const getRes = await fetch(`${baseUrl}/pages/${created.id}`);
expect(getRes.status).toBe(404);
});
it('DELETE /pages/:id returns 404 for unknown page', async () => {
const res = await fetch(`${baseUrl}/pages/nonexistent`, { method: 'DELETE' });
expect(res.status).toBe(404);
const json = await res.json();
expect(json.ok).toBe(false);
expect(json.error.code).toBe('PAGE_NOT_FOUND');
});
it('DELETE /pages by alias works', async () => {
await fetch(`${baseUrl}/pages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com', alias: 'myalias' }),
});
const deleteRes = await fetch(`${baseUrl}/pages/myalias`, { method: 'DELETE' });
expect(deleteRes.status).toBe(200);
const getRes = await fetch(`${baseUrl}/pages/myalias`);
expect(getRes.status).toBe(404);
});
it('health check still works alongside page routes', async () => {
const res = await fetch(`${baseUrl}/health`);
expect(res.status).toBe(200);
const json = await res.json();
expect(json).toEqual({ status: 'ok' });
});
});
+71
View File
@@ -0,0 +1,71 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { BrowserManager } from '../browser-manager.js';
import { safeStringify } from '@visionl/core';
type RouteHandler = (req: IncomingMessage, res: ServerResponse) => boolean;
export function pageRoutes(bm: BrowserManager): RouteHandler {
return (req, res) => {
const url = new URL(req.url || '/', 'http://localhost');
const path = url.pathname;
const segments = path.split('/').filter(Boolean);
res.setHeader('Content-Type', 'application/json');
// POST /pages
if (req.method === 'POST' && path === '/pages') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', async () => {
try {
const { url: pageUrl, alias } = JSON.parse(body);
const info = await bm.createPage(pageUrl, alias);
res.writeHead(200);
res.end(safeStringify({ ok: true, data: info }));
} catch (err: any) {
res.writeHead(err.code === 'ALIAS_EXISTS' ? 409 : 400);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
}
});
return true;
}
// GET /pages
if (req.method === 'GET' && path === '/pages') {
const pages = bm.listPages();
res.writeHead(200);
res.end(safeStringify({ ok: true, data: pages }));
return true;
}
// GET /pages/:id
// DELETE /pages/:id
if (segments[0] === 'pages' && segments.length === 2) {
const id = segments[1];
if (req.method === 'GET') {
const entry = bm.getPage(id);
if (!entry) {
res.writeHead(404);
res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } }));
return true;
}
res.writeHead(200);
res.end(safeStringify({ ok: true, data: entry.info }));
return true;
}
if (req.method === 'DELETE') {
bm.closePage(id).then(() => {
res.writeHead(200);
res.end(safeStringify({ ok: true, data: null }));
}).catch((err: any) => {
res.writeHead(err.code === 'PAGE_NOT_FOUND' ? 404 : 500);
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
});
return true;
}
}
return false;
};
}
+7 -2
View File
@@ -1,9 +1,14 @@
import http from 'node:http'; import http from 'node:http';
import { healthRoute } from './routes/health.js'; import { healthRoute } from './routes/health.js';
import { pageRoutes } from './routes/pages.js';
import type { BrowserManager } from './browser-manager.js';
const routes = [healthRoute]; export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> {
const routes = [healthRoute];
if (browserManager) {
routes.push(pageRoutes(browserManager));
}
export function startServer(port: number): Promise<http.Server> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
for (const route of routes) { for (const route of routes) {