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 pageStatus 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}