diff --git a/packages/daemon/src/__tests__/pages-routes.test.ts b/packages/daemon/src/__tests__/pages-routes.test.ts new file mode 100644 index 0000000..eb189c0 --- /dev/null +++ b/packages/daemon/src/__tests__/pages-routes.test.ts @@ -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(); + const aliases = new Map(); + + const mock = { + async createPage(pageUrl: string, alias?: string): Promise { + 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 { + 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((resolve) => server.close(() => resolve())); + }); + + // Each test gets a fresh mock state via re-creating mock internals / 每个测试用例使用全新模拟状态 + let mockPages: Map; + let mockAliases: Map; + + function resetMock() { + mockPages = new Map(); + mockAliases = new Map(); + + mock.createPage = async function (pageUrl: string, alias?: string): Promise { + 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 { + 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' }); + }); +}); diff --git a/packages/daemon/src/routes/pages.ts b/packages/daemon/src/routes/pages.ts new file mode 100644 index 0000000..abd1210 --- /dev/null +++ b/packages/daemon/src/routes/pages.ts @@ -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; + }; +} diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 05cb832..3ce0bc5 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -1,9 +1,14 @@ import http from 'node:http'; 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 { + const routes = [healthRoute]; + if (browserManager) { + routes.push(pageRoutes(browserManager)); + } -export function startServer(port: number): Promise { return new Promise((resolve) => { const server = http.createServer((req, res) => { for (const route of routes) {