// Action 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 cookies: any[] = []; const mockContext = { async cookies(): Promise { return [...cookies]; }, async addCookies(cs: any[]): Promise { for (const c of cs) { const idx = cookies.findIndex((x: any) => x.name === c.name); if (idx >= 0) cookies[idx] = c; else cookies.push(c); } }, async clearCookies(): Promise { cookies.length = 0; }, }; let lastClicked: string | null = null; let lastFilled: { selector: string; text: string } | null = null; let lastScrolled: { deltaY: number; toBottom: boolean } | null = null; let lastEvaluated: string | null = null; let lastWaited: { selector?: string; timeout?: number } | null = null; let lastNavigated: string | null = null; const mockPage = { async click(selector: string) { lastClicked = selector; }, async fill(selector: string, text: string) { lastFilled = { selector, text }; }, async evaluate(fnOrStr: any, arg?: any): Promise { if (typeof fnOrStr === 'function') { const src = fnOrStr.toString(); if (arg && src.includes('scrollBy')) { lastScrolled = { deltaY: arg.deltaY || 0, toBottom: arg.toBottom || false }; } else { lastEvaluated = src; } // Call the function with the arg if it's a function if (arg) { const mockWindow = { scrollBy: () => {}, scrollTo: () => {} }; try { return fnOrStr(arg); } catch { return null; } } return null; } lastEvaluated = String(fnOrStr); // Evaluate string code try { return new Function(`return (${fnOrStr})`)(); } catch { return null; } }, async waitForSelector(selector: string, _opts?: any) { lastWaited = { selector }; }, async waitForTimeout(ms: number) { lastWaited = { timeout: ms }; }, async goto(pageUrl: string, _opts?: any) { lastNavigated = pageUrl; }, async title(): Promise { return 'Mock Page Title'; }, }; 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: mockContext }; }, 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, mockContext, cookies, getLastClicked: () => lastClicked, resetLastClicked: () => { lastClicked = null; }, getLastFilled: () => lastFilled, resetLastFilled: () => { lastFilled = null; }, getLastScrolled: () => lastScrolled, resetLastScrolled: () => { lastScrolled = null; }, getLastEvaluated: () => lastEvaluated, resetLastEvaluated: () => { lastEvaluated = null; }, getLastWaited: () => lastWaited, resetLastWaited: () => { lastWaited = null; }, getLastNavigated: () => lastNavigated, resetLastNavigated: () => { lastNavigated = null; }, }; } describe('action routes', () => { let server: http.Server; let baseUrl: string; const port = 19531; const mockData = createMockBM(); beforeAll(async () => { server = await startServer(port, mockData.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(); mockData.cookies.length = 0; mockData.resetLastClicked(); mockData.resetLastFilled(); mockData.resetLastScrolled(); mockData.resetLastEvaluated(); mockData.resetLastWaited(); mockData.resetLastNavigated(); mockData.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; }; mockData.mock.getPage = function (idOrAlias: string) { const id = mockAliases.get(idOrAlias) || idOrAlias; const info = mockPages.get(id); if (!info) return undefined; return { info, page: mockData.mockPage, context: mockData.mockContext }; }; mockData.mock.listPages = function (): PageInfo[] { return Array.from(mockPages.values()); }; mockData.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(); }); /** Helper: create a page and return its id / 创建页面并返回其 ID */ async function createPage(alias?: string): Promise { const body: Record = { url: 'https://example.com' }; if (alias) body.alias = alias; const res = await fetch(`${baseUrl}/pages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const { data } = await res.json(); return data.id; } // ========== POST /pages/:id/click ========== it('POST /pages/:id/click returns success for valid selector', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/click`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selector: '#btn' }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); it('POST /pages/:id/click returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/click`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selector: '#btn' }), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); it('POST /pages/:id/click accessible by alias', async () => { await createPage('myAlias'); const res = await fetch(`${baseUrl}/pages/myAlias/click`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selector: '#btn' }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); // ========== POST /pages/:id/type ========== it('POST /pages/:id/type fills text into selector', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/type`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selector: 'input', text: 'hello' }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); it('POST /pages/:id/type returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/type`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selector: 'input', text: 'hello' }), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); // ========== POST /pages/:id/scroll ========== it('POST /pages/:id/scroll with deltaY succeeds', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/scroll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deltaY: 500 }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); it('POST /pages/:id/scroll toBottom succeeds', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/scroll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toBottom: true }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); it('POST /pages/:id/scroll returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/scroll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deltaY: 100 }), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); // ========== POST /pages/:id/eval ========== it('POST /pages/:id/eval returns result', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/eval`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: 'document.title' }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data).toHaveProperty('result'); }); it('POST /pages/:id/eval returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/eval`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: 'document.title' }), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); // ========== POST /pages/:id/wait ========== it('POST /pages/:id/wait with selector succeeds', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/wait`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selector: '.loaded' }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); it('POST /pages/:id/wait with timeout-only succeeds', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/wait`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timeout: 500 }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); it('POST /pages/:id/wait returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/wait`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selector: '.loaded' }), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); // ========== POST /pages/:id/navigate ========== it('POST /pages/:id/navigate returns url and title', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/navigate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://new-page.com' }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.url).toBe('https://new-page.com'); expect(json.data.title).toBe('Mock Page Title'); }); it('POST /pages/:id/navigate returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/navigate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://new-page.com' }), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); // ========== Cookie endpoints / Cookie 端点 ========== it('GET /pages/:id/cookies returns empty array for new page', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/cookies`); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.cookies).toEqual([]); }); it('GET /pages/:id/cookies returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/cookies`); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); it('POST /pages/:id/cookies adds a cookie', async () => { const id = await createPage(); const addRes = await fetch(`${baseUrl}/pages/${id}/cookies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'session', value: 'abc123' }), }); expect(addRes.status).toBe(200); const addJson = await addRes.json(); expect(addJson.ok).toBe(true); expect(addJson.data.success).toBe(true); // Verify cookie is returned in GET / 验证 cookie 通过 GET 返回 const getRes = await fetch(`${baseUrl}/pages/${id}/cookies`); const getJson = await getRes.json(); expect(getJson.data.cookies).toHaveLength(1); expect(getJson.data.cookies[0].name).toBe('session'); expect(getJson.data.cookies[0].value).toBe('abc123'); }); it('POST /pages/:id/cookies with optional fields succeeds', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/cookies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'auth', value: 'token123', domain: '.example.com', path: '/', httpOnly: true, secure: true, sameSite: 'Lax', }), }); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); expect(json.data.success).toBe(true); }); it('POST /pages/:id/cookies returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/cookies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'test', value: '1' }), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); it('DELETE /pages/:id/cookies/:name removes a specific cookie', async () => { const id = await createPage(); // Add two cookies / 添加两个 cookie await fetch(`${baseUrl}/pages/${id}/cookies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'keep', value: 'keepVal' }), }); await fetch(`${baseUrl}/pages/${id}/cookies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'remove', value: 'removeVal' }), }); // Delete one / 删除一个 const delRes = await fetch(`${baseUrl}/pages/${id}/cookies/remove`, { method: 'DELETE' }); expect(delRes.status).toBe(200); const delJson = await delRes.json(); expect(delJson.ok).toBe(true); expect(delJson.data.success).toBe(true); // Verify only the kept cookie remains / 验证仅保留指定 cookie const getRes = await fetch(`${baseUrl}/pages/${id}/cookies`); const getJson = await getRes.json(); expect(getJson.data.cookies).toHaveLength(1); expect(getJson.data.cookies[0].name).toBe('keep'); }); it('DELETE /pages/:id/cookies/:name returns 404 for unknown page', async () => { const res = await fetch(`${baseUrl}/pages/nonexistent/cookies/test`, { method: 'DELETE' }); expect(res.status).toBe(404); const json = await res.json(); expect(json.error.code).toBe('PAGE_NOT_FOUND'); }); // ========== Edge cases / 边界情况 ========== it('unknown action returns false (falls through to 404)', async () => { const id = await createPage(); const res = await fetch(`${baseUrl}/pages/${id}/unknownAction`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); expect(res.status).toBe(404); const json = await res.json(); expect(json.ok).toBe(false); }); });