feat(daemon): implement content routes for screenshot/text/html retrieval (Task 9)

- Add contentRoutes handler factory in routes/content.ts
- GET /pages/:id/screenshot returns PNG as base64
- GET /pages/:id/text returns document.body.innerText
- GET /pages/:id/html returns document.documentElement.outerHTML
- All use safeStringify, 404 on PAGE_NOT_FOUND
- Register contentRoutes in server.ts
- Add 8 integration tests with mock BrowserManager
This commit is contained in:
2026-08-12 20:58:37 +08:00
parent dd79194179
commit 55fc75eb54
3 changed files with 304 additions and 0 deletions
@@ -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<string, PageInfo>();
const aliases = new Map<string, string>();
const mockPage = {
async screenshot(_opts: any): Promise<Buffer> {
return Buffer.from('fake-png-data');
},
async evaluate(fn: any): Promise<any> {
const src = fn.toString();
if (src.includes('body.innerText')) return 'Hello World';
if (src.includes('documentElement.outerHTML')) return '<html><head></head><body>Hello World</body></html>';
return null;
},
};
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: mockPage, 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, 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<void>((resolve) => server.close(() => resolve()));
});
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: {
async screenshot(_opts: any): Promise<Buffer> {
return Buffer.from('fake-png-data');
},
async evaluate(fn: any): Promise<any> {
const src = fn.toString();
if (src.includes('body.innerText')) return 'Hello World';
if (src.includes('documentElement.outerHTML')) return '<html><head></head><body>Hello World</body></html>';
return 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);
};
}
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('<html><head></head><body>Hello World</body></html>');
});
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);
});
});
+63
View File
@@ -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;
};
}
+2
View File
@@ -1,12 +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 { pageRoutes } from './routes/pages.js';
import { contentRoutes } from './routes/content.js';
import type { BrowserManager } from './browser-manager.js'; import type { BrowserManager } from './browser-manager.js';
export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> { export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> {
const routes = [healthRoute]; const routes = [healthRoute];
if (browserManager) { if (browserManager) {
routes.push(pageRoutes(browserManager)); routes.push(pageRoutes(browserManager));
routes.push(contentRoutes(browserManager));
} }
return new Promise((resolve) => { return new Promise((resolve) => {