diff --git a/packages/daemon/src/__tests__/content-routes.test.ts b/packages/daemon/src/__tests__/content-routes.test.ts new file mode 100644 index 0000000..c0b4df1 --- /dev/null +++ b/packages/daemon/src/__tests__/content-routes.test.ts @@ -0,0 +1,239 @@ +// Content routes integration tests with mock BrowserManager / 内容路由集成测试,使用模拟浏览器管理器 +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'; + +function createMockBM() { + const pages = new Map(); + const aliases = new Map(); + + const mockPage = { + async screenshot(_opts: any): Promise { + return Buffer.from('fake-png-data'); + }, + async evaluate(fn: any): Promise { + const src = fn.toString(); + if (src.includes('body.innerText')) return 'Hello World'; + if (src.includes('documentElement.outerHTML')) return 'Hello World'; + return null; + }, + }; + + 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: mockPage, 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, aliases, mockPage }; +} + +describe('content routes', () => { + let server: http.Server; + let baseUrl: string; + const port = 19530; + + 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())); + }); + + 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: { + async screenshot(_opts: any): Promise { + return Buffer.from('fake-png-data'); + }, + async evaluate(fn: any): Promise { + const src = fn.toString(); + if (src.includes('body.innerText')) return 'Hello World'; + if (src.includes('documentElement.outerHTML')) return 'Hello World'; + return 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); + }; + } + + beforeEach(() => { + resetMock(); + }); + + it('GET /pages/:id/screenshot returns base64 png', 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}/screenshot`); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.data.base64).toBe('ZmFrZS1wbmctZGF0YQ=='); // base64 of 'fake-png-data' + expect(json.data.mime).toBe('image/png'); + }); + + it('GET /pages/:id/text returns body text', 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}/text`); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.data.text).toBe('Hello World'); + }); + + it('GET /pages/:id/html returns full HTML', 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}/html`); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.data.html).toBe('Hello World'); + }); + + it('GET /pages/:id/screenshot returns 404 for unknown page', async () => { + const res = await fetch(`${baseUrl}/pages/nonexistent/screenshot`); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.ok).toBe(false); + expect(json.error.code).toBe('PAGE_NOT_FOUND'); + }); + + it('GET /pages/:id/text returns 404 for unknown page', async () => { + const res = await fetch(`${baseUrl}/pages/nonexistent/text`); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.error.code).toBe('PAGE_NOT_FOUND'); + }); + + it('GET /pages/:id/html returns 404 for unknown page', async () => { + const res = await fetch(`${baseUrl}/pages/nonexistent/html`); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.error.code).toBe('PAGE_NOT_FOUND'); + }); + + it('screenshot accessible by alias', async () => { + await fetch(`${baseUrl}/pages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com', alias: 'home' }), + }); + + const res = await fetch(`${baseUrl}/pages/home/screenshot`); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.data.base64).toBe('ZmFrZS1wbmctZGF0YQ=='); + }); + + it('unknown content action returns false (falls through to 404)', 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}/unknown`); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.ok).toBe(false); + }); +}); diff --git a/packages/daemon/src/routes/content.ts b/packages/daemon/src/routes/content.ts new file mode 100644 index 0000000..e2c1441 --- /dev/null +++ b/packages/daemon/src/routes/content.ts @@ -0,0 +1,63 @@ +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 contentRoutes(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'); + + if (req.method !== 'GET' || segments[0] !== 'pages' || segments.length !== 3) { + return false; + } + + const id = segments[1]; + const action = segments[2]; // screenshot | text | html + + 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; + } + + if (action === 'screenshot') { + entry.page.screenshot({ type: 'png' }).then((buffer) => { + res.writeHead(200); + res.end(safeStringify({ ok: true, data: { base64: buffer.toString('base64'), mime: 'image/png' } })); + }).catch((err: any) => { + res.writeHead(500); + res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } })); + }); + return true; + } + + if (action === 'text') { + entry.page.evaluate(() => document.body.innerText).then((text) => { + res.writeHead(200); + res.end(safeStringify({ ok: true, data: { text } })); + }).catch((err: any) => { + res.writeHead(500); + res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } })); + }); + return true; + } + + if (action === 'html') { + entry.page.evaluate(() => document.documentElement.outerHTML).then((html) => { + res.writeHead(200); + res.end(safeStringify({ ok: true, data: { html } })); + }).catch((err: any) => { + res.writeHead(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 3ce0bc5..c2cbc89 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -1,12 +1,14 @@ import http from 'node:http'; import { healthRoute } from './routes/health.js'; import { pageRoutes } from './routes/pages.js'; +import { contentRoutes } from './routes/content.js'; import type { BrowserManager } from './browser-manager.js'; export function startServer(port: number, browserManager?: BrowserManager): Promise { const routes = [healthRoute]; if (browserManager) { routes.push(pageRoutes(browserManager)); + routes.push(contentRoutes(browserManager)); } return new Promise((resolve) => {