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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user