diff --git a/packages/daemon/src/__tests__/actions-routes.test.ts b/packages/daemon/src/__tests__/actions-routes.test.ts new file mode 100644 index 0000000..0b39dfe --- /dev/null +++ b/packages/daemon/src/__tests__/actions-routes.test.ts @@ -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(); + 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); + }); +}); diff --git a/packages/daemon/src/routes/actions.ts b/packages/daemon/src/routes/actions.ts new file mode 100644 index 0000000..e8d1a6d --- /dev/null +++ b/packages/daemon/src/routes/actions.ts @@ -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 { + 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; + }; +} diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index c2cbc89..2fe3a28 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -2,6 +2,7 @@ import http from 'node:http'; import { healthRoute } from './routes/health.js'; import { pageRoutes } from './routes/pages.js'; import { contentRoutes } from './routes/content.js'; +import { actionRoutes } from './routes/actions.js'; import type { BrowserManager } from './browser-manager.js'; export function startServer(port: number, browserManager?: BrowserManager): Promise { @@ -9,6 +10,7 @@ export function startServer(port: number, browserManager?: BrowserManager): Prom if (browserManager) { routes.push(pageRoutes(browserManager)); routes.push(contentRoutes(browserManager)); + routes.push(actionRoutes(browserManager)); } return new Promise((resolve) => {