feat(daemon): implement action routes and cookie management (Task 10)
- Add action endpoints: click/type/scroll/eval/wait/navigate - Add cookie endpoints: GET/POST/DELETE /pages/:id/cookies - Register actionRoutes in server.ts - 23 integration tests with mock BrowserManager
This commit is contained in:
@@ -0,0 +1,538 @@
|
|||||||
|
// 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<string, PageInfo>();
|
||||||
|
const aliases = new Map<string, string>();
|
||||||
|
const cookies: any[] = [];
|
||||||
|
|
||||||
|
const mockContext = {
|
||||||
|
async cookies(): Promise<any[]> {
|
||||||
|
return [...cookies];
|
||||||
|
},
|
||||||
|
async addCookies(cs: any[]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<any> {
|
||||||
|
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<string> {
|
||||||
|
return 'Mock Page Title';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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: mockContext };
|
||||||
|
},
|
||||||
|
|
||||||
|
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, 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<void>((resolve) => server.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
let mockPages: Map<string, PageInfo>;
|
||||||
|
let mockAliases: Map<string, string>;
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<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();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Helper: create a page and return its id / 创建页面并返回其 ID */
|
||||||
|
async function createPage(alias?: string): Promise<string> {
|
||||||
|
const body: Record<string, string> = { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// Action routes — click/type/scroll/eval/wait/navigate + cookie management / 操作路由,点击/输入/滚动/执行/等待/导航 + Cookie 管理
|
||||||
|
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;
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
req.on('end', () => resolve(body));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionRoutes(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');
|
||||||
|
|
||||||
|
// Only handle /pages/:id/* routes / 只处理 /pages/:id/* 路由
|
||||||
|
if (segments[0] !== 'pages' || segments.length < 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = segments[1];
|
||||||
|
const action = segments[2];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== POST actions / POST 操作 ==========
|
||||||
|
|
||||||
|
// POST /pages/:id/click — click on a selector / 点击选择器
|
||||||
|
if (req.method === 'POST' && action === 'click' && segments.length === 3) {
|
||||||
|
readBody(req).then(async (body) => {
|
||||||
|
try {
|
||||||
|
const { selector } = JSON.parse(body);
|
||||||
|
await entry.page.click(selector);
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { success: true } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /pages/:id/type — fill text into a selector / 向选择器输入文本
|
||||||
|
if (req.method === 'POST' && action === 'type' && segments.length === 3) {
|
||||||
|
readBody(req).then(async (body) => {
|
||||||
|
try {
|
||||||
|
const { selector, text } = JSON.parse(body);
|
||||||
|
await entry.page.fill(selector, text);
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { success: true } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /pages/:id/scroll — scroll page by deltaY or to bottom / 滚动页面向下或到底部
|
||||||
|
if (req.method === 'POST' && action === 'scroll' && segments.length === 3) {
|
||||||
|
readBody(req).then(async (body) => {
|
||||||
|
try {
|
||||||
|
const { deltaY, toBottom } = JSON.parse(body);
|
||||||
|
await entry.page.evaluate(({ deltaY: dy, toBottom: bottom }) => {
|
||||||
|
if (bottom) {
|
||||||
|
window.scrollTo(0, document.body.scrollHeight);
|
||||||
|
} else {
|
||||||
|
window.scrollBy(0, dy || 0);
|
||||||
|
}
|
||||||
|
}, { deltaY: deltaY || 0, toBottom });
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { success: true } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /pages/:id/eval — evaluate JavaScript in page context / 在页面中执行 JavaScript
|
||||||
|
if (req.method === 'POST' && action === 'eval' && segments.length === 3) {
|
||||||
|
readBody(req).then(async (body) => {
|
||||||
|
try {
|
||||||
|
const { code } = JSON.parse(body);
|
||||||
|
const result = await entry.page.evaluate(code);
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { result } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /pages/:id/wait — wait for selector or timeout / 等待选择器出现或超时
|
||||||
|
if (req.method === 'POST' && action === 'wait' && segments.length === 3) {
|
||||||
|
readBody(req).then(async (body) => {
|
||||||
|
try {
|
||||||
|
const { selector, timeout } = JSON.parse(body);
|
||||||
|
if (selector) {
|
||||||
|
await entry.page.waitForSelector(selector, { timeout: timeout || 30000 });
|
||||||
|
} else {
|
||||||
|
await entry.page.waitForTimeout(timeout || 1000);
|
||||||
|
}
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { success: true } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /pages/:id/navigate — navigate page to a new URL / 导航到新 URL
|
||||||
|
if (req.method === 'POST' && action === 'navigate' && segments.length === 3) {
|
||||||
|
readBody(req).then(async (body) => {
|
||||||
|
try {
|
||||||
|
const { url: pageUrl } = JSON.parse(body);
|
||||||
|
await entry.page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
const title = await entry.page.title();
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { url: pageUrl, title } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Cookie management / Cookie 管理 ==========
|
||||||
|
|
||||||
|
// GET /pages/:id/cookies — get all cookies for the page context / 获取页面上下文的所有 cookie
|
||||||
|
if (req.method === 'GET' && action === 'cookies' && segments.length === 3) {
|
||||||
|
entry.context.cookies().then((cookies) => {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { cookies } }));
|
||||||
|
}).catch((err: any) => {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /pages/:id/cookies — add a cookie to the page context / 向页面上下文添加 cookie
|
||||||
|
if (req.method === 'POST' && action === 'cookies' && segments.length === 3) {
|
||||||
|
readBody(req).then(async (body) => {
|
||||||
|
try {
|
||||||
|
const { name, value, domain, path: cookiePath, httpOnly, secure, sameSite } = JSON.parse(body);
|
||||||
|
const cookie: any = { name, value };
|
||||||
|
if (domain !== undefined) cookie.domain = domain;
|
||||||
|
if (cookiePath !== undefined) cookie.path = cookiePath;
|
||||||
|
if (httpOnly !== undefined) cookie.httpOnly = httpOnly;
|
||||||
|
if (secure !== undefined) cookie.secure = secure;
|
||||||
|
if (sameSite !== undefined) cookie.sameSite = sameSite;
|
||||||
|
await entry.context.addCookies([cookie]);
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { success: true } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /pages/:id/cookies/:name — delete a specific cookie by name / 按名称删除特定 cookie
|
||||||
|
if (req.method === 'DELETE' && action === 'cookies' && segments.length === 4) {
|
||||||
|
const cookieName = segments[3];
|
||||||
|
Promise.resolve().then(async () => {
|
||||||
|
try {
|
||||||
|
const existingCookies = await entry.context.cookies();
|
||||||
|
const filtered = existingCookies.filter((c) => c.name !== cookieName);
|
||||||
|
await entry.context.clearCookies();
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
await entry.context.addCookies(filtered);
|
||||||
|
}
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(safeStringify({ ok: true, data: { success: true } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(safeStringify({ ok: false, error: { code: err.code || 'INTERNAL', message: err.message } }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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 { contentRoutes } from './routes/content.js';
|
||||||
|
import { actionRoutes } from './routes/actions.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> {
|
||||||
@@ -9,6 +10,7 @@ export function startServer(port: number, browserManager?: BrowserManager): Prom
|
|||||||
if (browserManager) {
|
if (browserManager) {
|
||||||
routes.push(pageRoutes(browserManager));
|
routes.push(pageRoutes(browserManager));
|
||||||
routes.push(contentRoutes(browserManager));
|
routes.push(contentRoutes(browserManager));
|
||||||
|
routes.push(actionRoutes(browserManager));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user