diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index f06860d862..4c8fcbaaa0 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -246,6 +246,67 @@ async function closeServer(server) { await new Promise((resolve) => server.close(resolve)); } +// browser_logs must report what the server answered: a 404 may not look like a 200. +async function verifyNetworkLogsReportStatus(client) { + const server = createServer((request, response) => { + response.writeHead(request.url === "/missing" ? 404 : 200, { + "content-type": "text/html; charset=utf-8", + }); + response.end("Status page

Status page heading

"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string", "Status page did not bind a TCP port"); + let browserId = null; + try { + const created = await callBrowserTool(client, "browser_new_tab", { + url: `http://127.0.0.1:${address.port}/`, + }); + browserId = created.browserId; + assert( + typeof browserId === "string", + "browser_new_tab returned no browserId for the status page", + ); + await callBrowserTool(client, "browser_wait", { + browserId, + text: "Status page heading", + timeoutMs: 15_000, + }); + const fetched = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: + "async () => { const response = await fetch('/missing'); await response.text(); return response.status; }", + }); + assert( + JSON.parse(fetched.resultJson) === 404, + `The status page did not answer 404: ${fetched.resultJson}`, + ); + // The browser records a request's timing entry a moment after the response completes. + const deadline = Date.now() + 5_000; + let seen = []; + while (Date.now() < deadline) { + const logs = await callBrowserTool(client, "browser_logs", { browserId }); + seen = logs.network.map((entry) => ({ + pathname: new URL(entry.url).pathname, + status: entry.status ?? null, + })); + const reported = (pathname, status) => + seen.some((entry) => entry.pathname === pathname && entry.status === status); + if (reported("/missing", 404) && reported("/", 200)) return; + await delay(100); + } + throw new Error( + `browser_logs did not report 404 for /missing and 200 for the page: ${JSON.stringify(seen)}`, + ); + } finally { + if (browserId) await callBrowserTool(client, "browser_close_tab", { browserId }); + await closeServer(server); + } +} + function mcpPayload(result, command) { const payload = result.structuredContent; assert( @@ -1020,6 +1081,7 @@ async function runRegression({ JSON.parse(readyStateResult.resultJson) === "complete", "Local browser document did not finish loading", ); + await verifyNetworkLogsReportStatus(client); // Reproduce the report's mismatch: the guest is complete, but the pane's // last loading signal says it is not ready. await page.evaluate((id) => { diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index 6bc054c6a5..dfaa5331d7 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -1,4 +1,5 @@ import { resolve as resolvePath } from "node:path"; +import { createContext, runInContext } from "node:vm"; import { describe, expect, test, vi } from "vitest"; import type { @@ -434,6 +435,26 @@ function containsScript(tab: FakeTab, ...parts: string[]): boolean { return tab.scripts.some((script) => parts.every((part) => script.includes(part))); } +/** + * A browser whose tab runs the network script the host injects against `performance`, the way a + * page runs it, so the test sees what the script reads from Chromium's timing entries. + */ +function browserWithTimingEntries(entries: { resource: unknown[]; navigation: unknown[] }) { + const browser = new BrowserAutomationHarness(); + const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab); + const page = createContext({ + performance: { + getEntriesByType: (type: string) => + type === "navigation" ? entries.navigation : entries.resource, + }, + }); + browser.tab.executeJavaScript = async (script: string) => + script.includes("performance.getEntriesByType") + ? runInContext(script, page) + : fakeAnswer(script); + return browser; +} + function requireSnapshotRefs(result: Awaited>) { expect(result).toEqual({ requestId: "req-snapshot", @@ -1406,6 +1427,72 @@ describe("executeAutomationCommand", () => { }); }); + test("logs reports the HTTP status the browser recorded, and none it did not", async () => { + const timing = (name: string, initiatorType: string, responseStatus?: number) => ({ + name, + initiatorType, + startTime: 1, + duration: 2, + transferSize: 3, + ...(responseStatus === undefined ? {} : { responseStatus }), + }); + const browser = browserWithTimingEntries({ + resource: [ + timing("https://a.test/api/orders", "fetch", 404), + // Chromium reports 0 for a cross-origin response that sends no Timing-Allow-Origin. + timing("https://cdn.test/lib.js", "script", 0), + // A browser without the field. + timing("https://a.test/old.css", "css"), + ], + navigation: [timing("https://a.test/form", "navigation", 200)], + }); + + const result = await browser.execute({ command: "logs", args: { browserId: BROWSER_A } }); + + expect(result).toEqual({ + requestId: "req-logs", + ok: true, + result: { + command: "logs", + browserId: BROWSER_A, + console: [], + network: [ + { + url: "https://a.test/api/orders", + type: "fetch", + status: 404, + startTime: 1, + duration: 2, + transferSize: 3, + }, + { + url: "https://cdn.test/lib.js", + type: "script", + startTime: 1, + duration: 2, + transferSize: 3, + }, + { + url: "https://a.test/old.css", + type: "css", + startTime: 1, + duration: 2, + transferSize: 3, + }, + { + url: "https://a.test/form", + method: "GET", + type: "navigation", + status: 200, + startTime: 1, + duration: 2, + transferSize: 3, + }, + ], + }, + }); + }); + test("evaluate returns primitive JSON from the page context", async () => { const browser = new BrowserAutomationHarness(); browser.tab.evaluateScriptResult = { ok: true, resultJson: "42" }; diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index fe07adecd3..fd5b12f83b 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -1628,6 +1628,8 @@ const NETWORK_PERFORMANCE_SCRIPT = String.raw`(() => { url: entry.name, method: entry.initiatorType === 'navigation' ? 'GET' : undefined, type: entry.initiatorType, + // 0 when the browser does not say (a cross-origin response without Timing-Allow-Origin). + status: typeof entry.responseStatus === 'number' && entry.responseStatus > 0 ? entry.responseStatus : undefined, startTime: entry.startTime, duration: entry.duration, transferSize: typeof entry.transferSize === 'number' ? entry.transferSize : undefined, diff --git a/public-docs/browser-tools.md b/public-docs/browser-tools.md index b97669e372..f1d586d3c0 100644 --- a/public-docs/browser-tools.md +++ b/public-docs/browser-tools.md @@ -33,7 +33,7 @@ Arguments marked `?` are optional. | -------------------- | -------------------------------------- | ------------------------------------------------------------------------------------- | | `browser_snapshot` | `browserId` | Return the page as an accessibility tree with element refs. | | `browser_screenshot` | `browserId, fullPage?` | Capture a PNG of the viewport, or the full page with `fullPage`. | -| `browser_logs` | `browserId, maxEntries?` | Read recent console messages and network timing entries. | +| `browser_logs` | `browserId, maxEntries?` | Read recent console messages and network entries: URL, timing, size and HTTP status. | | `browser_wait` | `browserId, text? \| url?, timeoutMs?` | Wait until the page contains text or reaches a URL fragment (exactly one of the two). | ## Interacting diff --git a/public-docs/browser-tools.zh-CN.md b/public-docs/browser-tools.zh-CN.md index b302a3267f..9b72216445 100644 --- a/public-docs/browser-tools.zh-CN.md +++ b/public-docs/browser-tools.zh-CN.md @@ -31,7 +31,7 @@ nav: 工具参考 | -------------------- | -------------------------------------- | ------------------------------------------------------------------- | | `browser_snapshot` | `browserId` | 以带元素引用的无障碍树形式返回页面。 | | `browser_screenshot` | `browserId, fullPage?` | 截取视口的 PNG;传入 `fullPage` 时截取整个页面。 | -| `browser_logs` | `browserId, maxEntries?` | 读取最近的控制台消息和网络耗时条目。 | +| `browser_logs` | `browserId, maxEntries?` | 读取最近的控制台消息和网络条目:URL、耗时、大小和 HTTP 状态码。 | | `browser_wait` | `browserId, text? \| url?, timeoutMs?` | 等待页面包含某段文本或到达某个 URL 片段(两者必须且只能提供一个)。 | ## 交互 {#interacting}