From 9ab64eaf0f915bb279e656f786d0ee69cefec7c6 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:45:51 -0800 Subject: [PATCH 01/11] chore: Bump playwright dependencies --- packages/extension/package.json | 4 +-- packages/omnium-gatherum/package.json | 4 +-- packages/repo-tools/package.json | 2 +- packages/streams/package.json | 2 +- yarn.lock | 40 +++++++++++++-------------- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index bac0ca824..3cd23f4d3 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -63,7 +63,7 @@ "@ocap/cli": "workspace:^", "@ocap/kernel-test": "workspace:^", "@ocap/repo-tools": "workspace:^", - "@playwright/test": "^1.55.1", + "@playwright/test": "^1.57.0", "@testing-library/jest-dom": "^6.6.3", "@types/chrome": "^0.0.313", "@types/react": "^18.3.18", @@ -83,7 +83,7 @@ "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-promise": "^7.2.1", "jsdom": "^27.4.0", - "playwright": "^1.55.1", + "playwright": "^1.57.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", "tsx": "^4.20.6", diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index cf8fd859b..7ab25f69d 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -70,7 +70,7 @@ "@metamask/eslint-config-typescript": "^15.0.0", "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", - "@playwright/test": "^1.55.1", + "@playwright/test": "^1.57.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -94,7 +94,7 @@ "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-promise": "^7.2.1", "jsdom": "^27.4.0", - "playwright": "^1.55.1", + "playwright": "^1.57.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", "tsx": "^4.20.6", diff --git a/packages/repo-tools/package.json b/packages/repo-tools/package.json index 7022ea338..a90574aef 100644 --- a/packages/repo-tools/package.json +++ b/packages/repo-tools/package.json @@ -47,7 +47,7 @@ "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", "@metamask/superstruct": "^3.2.1", - "@playwright/test": "^1.55.1", + "@playwright/test": "^1.57.0", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", "@typescript-eslint/utils": "^8.29.0", diff --git a/packages/streams/package.json b/packages/streams/package.json index 0cfd55100..932b72d47 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -102,7 +102,7 @@ "eslint-plugin-n": "^17.17.0", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-promise": "^7.2.1", - "playwright": "^1.55.1", + "playwright": "^1.57.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", "ses": "^1.14.0", diff --git a/yarn.lock b/yarn.lock index 084a341ef..c5cbd044b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2931,7 +2931,7 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" - playwright: "npm:^1.55.1" + playwright: "npm:^1.57.0" prettier: "npm:^3.5.3" rimraf: "npm:^6.0.1" ses: "npm:^1.14.0" @@ -3477,7 +3477,7 @@ __metadata: "@ocap/cli": "workspace:^" "@ocap/kernel-test": "workspace:^" "@ocap/repo-tools": "workspace:^" - "@playwright/test": "npm:^1.55.1" + "@playwright/test": "npm:^1.57.0" "@testing-library/jest-dom": "npm:^6.6.3" "@types/chrome": "npm:^0.0.313" "@types/react": "npm:^18.3.18" @@ -3497,7 +3497,7 @@ __metadata: eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" jsdom: "npm:^27.4.0" - playwright: "npm:^1.55.1" + playwright: "npm:^1.57.0" prettier: "npm:^3.5.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" @@ -3942,7 +3942,7 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ocap/cli": "workspace:^" "@ocap/repo-tools": "workspace:^" - "@playwright/test": "npm:^1.55.1" + "@playwright/test": "npm:^1.57.0" "@testing-library/dom": "npm:^10.4.0" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.3.0" @@ -3967,7 +3967,7 @@ __metadata: eslint-plugin-promise: "npm:^7.2.1" immer: "npm:^10.1.1" jsdom: "npm:^27.4.0" - playwright: "npm:^1.55.1" + playwright: "npm:^1.57.0" prettier: "npm:^3.5.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" @@ -4035,7 +4035,7 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/superstruct": "npm:^3.2.1" - "@playwright/test": "npm:^1.55.1" + "@playwright/test": "npm:^1.57.0" "@typescript-eslint/eslint-plugin": "npm:^8.29.0" "@typescript-eslint/parser": "npm:^8.29.0" "@typescript-eslint/utils": "npm:^8.29.0" @@ -4556,14 +4556,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:^1.55.1": - version: 1.58.0 - resolution: "@playwright/test@npm:1.58.0" +"@playwright/test@npm:^1.57.0": + version: 1.57.0 + resolution: "@playwright/test@npm:1.57.0" dependencies: - playwright: "npm:1.58.0" + playwright: "npm:1.57.0" bin: playwright: cli.js - checksum: 10/1ab8c4d408c919e1357bb43e5682d1ce66bb792516fd6269dd5cff9c9b7cc82e026b3fc864ba723714337dc610ac46110ed8307d610feb92f5002abbb03a6392 + checksum: 10/07f5ba4841b2db1dea70d821004c5156b692488e13523c096ce3487d30f95f34ccf30ba6467ece60c86faac27ae382213b7eacab48a695550981b2e811e5e579 languageName: node linkType: hard @@ -12484,27 +12484,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.58.0": - version: 1.58.0 - resolution: "playwright-core@npm:1.58.0" +"playwright-core@npm:1.57.0": + version: 1.57.0 + resolution: "playwright-core@npm:1.57.0" bin: playwright-core: cli.js - checksum: 10/718549cdcd22e55f42887e7b832276c9a50e112b278d22a2242bb00c401021623fedf9554c7090f132e389b5bbe11a987ce71e3c877e767eed4dd9bfe573019c + checksum: 10/ec066602f0196f036006caee14a30d0a57533a76673bb9a0c609ef56e21decf018f0e8d402ba2fb18251393be6a1c9e193c83266f1670fe50838c5340e220de0 languageName: node linkType: hard -"playwright@npm:1.58.0, playwright@npm:^1.55.1": - version: 1.58.0 - resolution: "playwright@npm:1.58.0" +"playwright@npm:1.57.0, playwright@npm:^1.57.0": + version: 1.57.0 + resolution: "playwright@npm:1.57.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.58.0" + playwright-core: "npm:1.57.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10/5ef5d0977906046400cee9a359f18c87eafb3e3193a33a22351cc84740c0dffb3cff7b9f2320d1e200817dbf24e63b747a0546b6c671a9fd95937e2402682ad9 + checksum: 10/241559210f98ef11b6bd6413f2d29da7ef67c7865b72053192f0d164fab9e0d3bd47913b3351d5de6433a8aff2d8424d4b8bd668df420bf4dda7ae9fcd37b942 languageName: node linkType: hard From 9855dbf574ddd03eb3988eea814562097c4d3124 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:32:45 -0800 Subject: [PATCH 02/11] feat(repo-tools): Capture extension console logs during Playwright e2e tests Add log capture for background service worker, offscreen document, and popup page console output during e2e tests. Logs are written to a timestamped file in /logs/ and the path is attached to Playwright test results for easy access. Co-Authored-By: Claude --- .../repo-tools/src/test-utils/extension.ts | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/repo-tools/src/test-utils/extension.ts b/packages/repo-tools/src/test-utils/extension.ts index 065107f52..35156cf25 100644 --- a/packages/repo-tools/src/test-utils/extension.ts +++ b/packages/repo-tools/src/test-utils/extension.ts @@ -1,5 +1,7 @@ -import { chromium } from '@playwright/test'; -import type { BrowserContext, Page } from '@playwright/test'; +import { chromium, test } from '@playwright/test'; +import type { BrowserContext, ConsoleMessage, Page } from '@playwright/test'; +import { appendFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -21,7 +23,7 @@ type Options = { * @param options.extensionPath - The path to the extension dist folder. * @param options.onPageLoad - Optional callback to run after the extension is loaded. Useful for * e.g. waiting for components to be visible before proceeding with a test. - * @returns The extension context, extension ID, and popup page + * @returns The extension context, extension ID, popup page, and log file path */ export const makeLoadExtension = async ({ contextId, @@ -31,6 +33,7 @@ export const makeLoadExtension = async ({ browserContext: BrowserContext; extensionId: string; popupPage: Page; + logFilePath: string; }> => { const workerIndex = process.env.TEST_WORKER_INDEX ?? '0'; // Use provided contextId or fall back to workerIndex for separate user data dirs @@ -38,6 +41,33 @@ export const makeLoadExtension = async ({ const userDataDir = path.join(sessionPath, effectiveContextId); await rm(userDataDir, { recursive: true, force: true }); + // Set up log file for capturing console output from extension contexts + const packageRoot = path.dirname(extensionPath); // extensionPath is /dist + const logsDir = path.join(packageRoot, 'logs'); + await mkdir(logsDir, { recursive: true }); + const timestamp = new Date() + .toISOString() + .slice(0, -5) // Remove ".123Z" + .replace(/[:.]/gu, '-'); // Make filename-safe + const logFilePath = path.join(logsDir, `e2e-${timestamp}.log`); + + // Attach log file path to test results (viewable in Playwright HTML report) + await test.info().attach('console-logs', { + body: logFilePath, + contentType: 'text/plain', + }); + + const writeLog = (source: string, consoleMessage: ConsoleMessage): void => { + const logTimestamp = new Date().toISOString().slice(0, -5); + const text = consoleMessage.text(); + const type = consoleMessage.type(); + // eslint-disable-next-line n/no-sync + appendFileSync( + logFilePath, + `[${logTimestamp}] [${source}] [${type}] ${text}\n`, + ); + }; + const browserArgs = [ `--disable-features=ExtensionDisableUnsupportedDeveloper`, `--disable-extensions-except=${extensionPath}`, @@ -56,6 +86,23 @@ export const makeLoadExtension = async ({ args: browserArgs, }); + // Capture background service worker console logs + browserContext.on('serviceworker', (worker) => { + worker.on('console', (consoleMessage) => + writeLog('background', consoleMessage), + ); + }); + + // Capture console logs from extension pages (offscreen document, etc.) + // Note: Pages may start at about:blank, so we attach the listener and check URL in the handler + browserContext.on('page', (page) => { + page.on('console', (consoleMessage) => { + if (page.url().includes('offscreen.html')) { + writeLog('offscreen', consoleMessage); + } + }); + }); + // Wait for the extension to be loaded await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -70,8 +117,11 @@ export const makeLoadExtension = async ({ } const popupPage = await browserContext.newPage(); + popupPage.on('console', (consoleMessage) => + writeLog('popup', consoleMessage), + ); await popupPage.goto(`chrome-extension://${extensionId}/popup.html`); await onPageLoad(popupPage); - return { browserContext, extensionId, popupPage }; + return { browserContext, extensionId, popupPage, logFilePath }; }; From e78f6df67860c57e9be9fa784cb42f673aa14815 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:36:09 -0800 Subject: [PATCH 03/11] feat(repo-tools): Include test name and run ID in extension log filenames Log files are now named with a run ID (timestamp of Playwright invocation) and test name to allow grouping logs from the same test run and easily identifying which test produced which logs. Format: -.log Co-Authored-By: Claude --- packages/repo-tools/src/test-utils/extension.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/repo-tools/src/test-utils/extension.ts b/packages/repo-tools/src/test-utils/extension.ts index 35156cf25..380ec767e 100644 --- a/packages/repo-tools/src/test-utils/extension.ts +++ b/packages/repo-tools/src/test-utils/extension.ts @@ -8,6 +8,13 @@ import path from 'node:path'; export const sessionPath = path.resolve(os.tmpdir(), 'ocap-test'); +// Run ID is generated once per Playwright invocation (per worker process) +// This allows associating all test log files from the same run +const runId = new Date() + .toISOString() + .slice(0, -5) // Remove ".123Z" + .replace(/[:.]/gu, '-'); // Make filename-safe + type Options = { contextId?: string | undefined; extensionPath: string; @@ -45,11 +52,11 @@ export const makeLoadExtension = async ({ const packageRoot = path.dirname(extensionPath); // extensionPath is /dist const logsDir = path.join(packageRoot, 'logs'); await mkdir(logsDir, { recursive: true }); - const timestamp = new Date() - .toISOString() - .slice(0, -5) // Remove ".123Z" - .replace(/[:.]/gu, '-'); // Make filename-safe - const logFilePath = path.join(logsDir, `e2e-${timestamp}.log`); + const testTitle = test + .info() + .titlePath.join('-') + .replace(/[^a-zA-Z0-9-]/gu, '_'); // Make filename-safe + const logFilePath = path.join(logsDir, `${runId}-${testTitle}.log`); // Attach log file path to test results (viewable in Playwright HTML report) await test.info().attach('console-logs', { From ee9b2999f1543a4c6c09b67b2e2f04d3a05e6944 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:41:50 -0800 Subject: [PATCH 04/11] chore: Add ./logs to workspace clean scripts Update yarn constraints to enforce ./logs in all workspace clean scripts, ensuring e2e test log files are cleaned up with other build artifacts. Co-Authored-By: Claude --- packages/brow-2-brow/package.json | 2 +- packages/cli/package.json | 2 +- packages/create-package/package.json | 2 +- packages/extension/package.json | 2 +- packages/kernel-agents-repl/package.json | 2 +- packages/kernel-agents/package.json | 2 +- packages/kernel-browser-runtime/package.json | 2 +- packages/kernel-errors/package.json | 2 +- packages/kernel-language-model-service/package.json | 2 +- packages/kernel-platforms/package.json | 2 +- packages/kernel-rpc-methods/package.json | 2 +- packages/kernel-shims/package.json | 2 +- packages/kernel-store/package.json | 2 +- packages/kernel-test-local/package.json | 2 +- packages/kernel-test/package.json | 2 +- packages/kernel-ui/package.json | 2 +- packages/kernel-utils/package.json | 2 +- packages/logger/package.json | 2 +- packages/nodejs-test-workers/package.json | 2 +- packages/nodejs/package.json | 2 +- packages/ocap-kernel/package.json | 2 +- packages/omnium-gatherum/package.json | 2 +- packages/remote-iterables/package.json | 2 +- packages/repo-tools/package.json | 2 +- packages/streams/package.json | 2 +- packages/template-package/package.json | 2 +- yarn.config.cjs | 8 ++++++-- 27 files changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/brow-2-brow/package.json b/packages/brow-2-brow/package.json index f431495cb..dbf420ed2 100644 --- a/packages/brow-2-brow/package.json +++ b/packages/brow-2-brow/package.json @@ -13,7 +13,7 @@ "build:dev": "mkdir -p dist && ln -fs ../src/index.html dist/index.html", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/brow-2-brow", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/cli/package.json b/packages/cli/package.json index 85451356f..df380b7aa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,7 +18,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/cli", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/create-package/package.json b/packages/create-package/package.json index 2f9281791..739eddaad 100644 --- a/packages/create-package/package.json +++ b/packages/create-package/package.json @@ -31,7 +31,7 @@ "scripts": { "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/create-package", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/extension/package.json b/packages/extension/package.json index 3cd23f4d3..ccff569f1 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -21,7 +21,7 @@ "build:browser": "OPEN_BROWSER=true yarn build:dev --watch", "build:vite": "vite build --configLoader runner --config vite.config.ts", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/extension", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-agents-repl/package.json b/packages/kernel-agents-repl/package.json index bc217fd60..9a027f43b 100644 --- a/packages/kernel-agents-repl/package.json +++ b/packages/kernel-agents-repl/package.json @@ -32,7 +32,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-agents-repl", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-agents/package.json b/packages/kernel-agents/package.json index c5ce31169..59432ea55 100644 --- a/packages/kernel-agents/package.json +++ b/packages/kernel-agents/package.json @@ -132,7 +132,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-agents", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index 89597fa7b..c92b5c998 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -48,7 +48,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-browser-runtime", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-browser-runtime", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-errors/package.json b/packages/kernel-errors/package.json index 607715278..d2d76ee28 100644 --- a/packages/kernel-errors/package.json +++ b/packages/kernel-errors/package.json @@ -42,7 +42,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-errors", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-errors", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json index 68b51265e..77e79a90d 100644 --- a/packages/kernel-language-model-service/package.json +++ b/packages/kernel-language-model-service/package.json @@ -52,7 +52,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-language-model-service", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-platforms/package.json b/packages/kernel-platforms/package.json index 277f8b671..72b9e66a0 100644 --- a/packages/kernel-platforms/package.json +++ b/packages/kernel-platforms/package.json @@ -52,7 +52,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-platforms", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-rpc-methods/package.json b/packages/kernel-rpc-methods/package.json index 06f145ac5..ec4c596c3 100644 --- a/packages/kernel-rpc-methods/package.json +++ b/packages/kernel-rpc-methods/package.json @@ -42,7 +42,7 @@ "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-rpc-methods", "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-rpc-methods", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-shims/package.json b/packages/kernel-shims/package.json index e11f3c1c7..cb366270d 100644 --- a/packages/kernel-shims/package.json +++ b/packages/kernel-shims/package.json @@ -35,7 +35,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-shims", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-shims", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-store/package.json b/packages/kernel-store/package.json index 743a9dffc..1bdf824a3 100644 --- a/packages/kernel-store/package.json +++ b/packages/kernel-store/package.json @@ -63,7 +63,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-store", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-store", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-test-local/package.json b/packages/kernel-test-local/package.json index 45438e657..4dbce2c09 100644 --- a/packages/kernel-test-local/package.json +++ b/packages/kernel-test-local/package.json @@ -13,7 +13,7 @@ }, "type": "module", "scripts": { - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index b5f09cbbd..6b73472b0 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -31,7 +31,7 @@ ], "scripts": { "build": "ocap bundle src/vats", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo './src/**/*.bundle'", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo './src/**/*.bundle' ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-ui/package.json b/packages/kernel-ui/package.json index a39668e32..9e34ed5fe 100644 --- a/packages/kernel-ui/package.json +++ b/packages/kernel-ui/package.json @@ -45,7 +45,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-ui", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-ui", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/kernel-utils/package.json b/packages/kernel-utils/package.json index 0c269fb5c..4754ef085 100644 --- a/packages/kernel-utils/package.json +++ b/packages/kernel-utils/package.json @@ -62,7 +62,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-utils", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-utils", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/logger/package.json b/packages/logger/package.json index 7e5358973..43a32dfd5 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -42,7 +42,7 @@ "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/logger", "changelog:update": "../../scripts/update-changelog.sh @metamask/logger", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/nodejs-test-workers/package.json b/packages/nodejs-test-workers/package.json index 27752f6a1..1a4a93fbc 100644 --- a/packages/nodejs-test-workers/package.json +++ b/packages/nodejs-test-workers/package.json @@ -32,7 +32,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/nodejs-test-workers", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index f314bd381..9409e8d83 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -32,7 +32,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/nodejs", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/ocap-kernel/package.json b/packages/ocap-kernel/package.json index 0e12041ae..0c4b13e4a 100644 --- a/packages/ocap-kernel/package.json +++ b/packages/ocap-kernel/package.json @@ -53,7 +53,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/ocap-kernel", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ocap-kernel", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 7ab25f69d..36bf371b4 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -23,7 +23,7 @@ "build:caplets": "ocap bundle src/caplets/echo", "build:vite": "vite build --configLoader runner --config vite.config.ts", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/omnium-gatherum", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/remote-iterables/package.json b/packages/remote-iterables/package.json index 0b565c7fb..d94032059 100644 --- a/packages/remote-iterables/package.json +++ b/packages/remote-iterables/package.json @@ -32,7 +32,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/remote-iterables", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/repo-tools/package.json b/packages/repo-tools/package.json index a90574aef..ac802a298 100644 --- a/packages/repo-tools/package.json +++ b/packages/repo-tools/package.json @@ -29,7 +29,7 @@ "dist/" ], "scripts": { - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/streams/package.json b/packages/streams/package.json index 932b72d47..a640802b5 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -53,7 +53,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/streams", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/streams", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/packages/template-package/package.json b/packages/template-package/package.json index be5ffa909..b3dd07130 100644 --- a/packages/template-package/package.json +++ b/packages/template-package/package.json @@ -32,7 +32,7 @@ "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/template-package", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", "lint:eslint": "eslint . --cache", diff --git a/yarn.config.cjs b/yarn.config.cjs index 872cb184d..2f1a4055e 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -204,8 +204,12 @@ module.exports = defineConfig({ expectWorkspaceField(workspace, 'scripts.build:docs', 'typedoc'); } - // All packages except the root must have a "clean" script. - expectWorkspaceField(workspace, 'scripts.clean'); + // All packages except the root must have a "clean" script that includes ./logs. + expectWorkspaceField(workspace, 'scripts.clean', (currentValue) => + typeof currentValue === 'string' && !currentValue.includes('./logs') + ? `${currentValue} ./logs` + : currentValue, + ); // No non-root packages may have a "prepack" script. workspace.unset('scripts.prepack'); From 9432fd34f138c89fd31d9e394394d6deda72ae61 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:07:31 -0800 Subject: [PATCH 05/11] refactor: Extract console-forwarding to kernel-browser-runtime utils Move console message forwarding functionality from the extension package to @metamask/kernel-browser-runtime as reusable utilities. This enables log capture from offscreen documents and other contexts via DuplexStream. - Create console-forwarding utilities with types and validators - Update extension to import from kernel-browser-runtime - Add console-forwarding exports to kernel-browser-runtime index Co-Authored-By: Claude --- packages/extension/src/background.ts | 8 +- packages/extension/src/offscreen.ts | 4 + .../kernel-browser-runtime/src/index.test.ts | 3 + .../src/utils/console-forwarding.ts | 94 +++++++++++++++++++ .../src/utils/index.test.ts | 3 + .../kernel-browser-runtime/src/utils/index.ts | 1 + 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/utils/console-forwarding.ts diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index b1a11267c..4d1a17320 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -4,6 +4,8 @@ import { makeCapTPNotification, isCapTPNotification, getCapTPMessage, + isConsoleForwardMessage, + handleConsoleForwardMessage, } from '@metamask/kernel-browser-runtime'; import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster'; @@ -107,9 +109,11 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globalThis.kernel = kernelP; - // Handle incoming CapTP messages from the kernel + // Handle incoming messages from offscreen (CapTP and console-forward) const drainPromise = offscreenStream.drain((message) => { - if (isCapTPNotification(message)) { + if (isConsoleForwardMessage(message)) { + handleConsoleForwardMessage(message, '[offscreen]'); + } else if (isCapTPNotification(message)) { const captpMessage = getCapTPMessage(message); backgroundCapTP.dispatch(captpMessage); } else { diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index c09ec2772..aa11ac189 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -2,6 +2,7 @@ import { makeIframeVatWorker, PlatformServicesServer, createRelayQueryString, + setupConsoleForwarding, } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; @@ -31,6 +32,9 @@ async function main(): Promise { JsonRpcMessage >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); + // Set up console forwarding to background for Playwright capture + setupConsoleForwarding(backgroundStream); + const kernelStream = await makeKernelWorker(); // Handle messages from the background script / kernel diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index f52b98667..0a4415bdc 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -11,7 +11,9 @@ describe('index', () => { 'createRelayQueryString', 'getCapTPMessage', 'getRelaysFromCurrentLocation', + 'handleConsoleForwardMessage', 'isCapTPNotification', + 'isConsoleForwardMessage', 'makeBackgroundCapTP', 'makeCapTPNotification', 'makeIframeVatWorker', @@ -19,6 +21,7 @@ describe('index', () => { 'receiveInternalConnections', 'rpcHandlers', 'rpcMethodSpecs', + 'setupConsoleForwarding', ]); }); }); diff --git a/packages/kernel-browser-runtime/src/utils/console-forwarding.ts b/packages/kernel-browser-runtime/src/utils/console-forwarding.ts new file mode 100644 index 000000000..8df0d4be4 --- /dev/null +++ b/packages/kernel-browser-runtime/src/utils/console-forwarding.ts @@ -0,0 +1,94 @@ +import type { JsonRpcMessage } from '@metamask/kernel-utils'; +import type { DuplexStream } from '@metamask/streams'; +import type { JsonRpcNotification } from '@metamask/utils'; + +/** + * Message type for forwarding console output from one context to another. + * Used to capture console logs from offscreen documents in Playwright tests. + */ +export type ConsoleForwardMessage = JsonRpcNotification & { + method: 'console-forward'; + params: { + method: 'log' | 'debug' | 'info' | 'warn' | 'error'; + args: string[]; + }; +}; + +/** + * Type guard for console-forward messages. + * + * @param value - The value to check. + * @returns Whether the value is a ConsoleForwardMessage. + */ +export const isConsoleForwardMessage = ( + value: unknown, +): value is ConsoleForwardMessage => + typeof value === 'object' && + value !== null && + 'method' in value && + (value as { method: unknown }).method === 'console-forward'; + +/** + * Wraps console methods to forward messages to background via a stream. + * This enables capturing console output from contexts that Playwright cannot + * directly access (like offscreen documents). + * + * Call this early after the stream is created. After setup, console output + * will be forwarded to the stream recipient where it can be replayed. + * + * @param stream - The stream to write console messages to. + */ +export function setupConsoleForwarding( + stream: DuplexStream, +): void { + const originalConsole = { ...console }; + const consoleMethods = ['log', 'debug', 'info', 'warn', 'error'] as const; + + consoleMethods.forEach((consoleMethod) => { + // eslint-disable-next-line no-console + console[consoleMethod] = (...args: unknown[]) => { + // Call original console method + originalConsole[consoleMethod](...args); + + // Forward to background via stream + const message: ConsoleForwardMessage = { + jsonrpc: '2.0', + method: 'console-forward', + params: { + method: consoleMethod, + args: args.map((arg) => { + if (typeof arg === 'string') { + return arg; + } + if (typeof arg === 'number' || typeof arg === 'boolean') { + return String(arg); + } + // Objects, arrays, null, undefined, functions, symbols, etc. + return JSON.stringify(arg); + }), + }, + }; + stream.write(message).catch(() => { + // Ignore errors if stream isn't ready + }); + }; + }); + + harden(globalThis.console); +} + +/** + * Handles a console-forward message by replaying it to the local console. + * Use this in the stream handler to replay forwarded console output. + * + * @param message - The console-forward message to handle. + * @param prefix - Optional prefix to add to the message (e.g., '[offscreen]'). + */ +export function handleConsoleForwardMessage( + message: ConsoleForwardMessage, + prefix?: string, +): void { + const { method, args } = message.params; + // eslint-disable-next-line no-console + console[method](...(prefix ? [prefix, ...args] : args)); +} diff --git a/packages/kernel-browser-runtime/src/utils/index.test.ts b/packages/kernel-browser-runtime/src/utils/index.test.ts index 1defa9bf6..9d50bd8d2 100644 --- a/packages/kernel-browser-runtime/src/utils/index.test.ts +++ b/packages/kernel-browser-runtime/src/utils/index.test.ts @@ -7,7 +7,10 @@ describe('index', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'createRelayQueryString', 'getRelaysFromCurrentLocation', + 'handleConsoleForwardMessage', + 'isConsoleForwardMessage', 'parseRelayQueryString', + 'setupConsoleForwarding', ]); }); }); diff --git a/packages/kernel-browser-runtime/src/utils/index.ts b/packages/kernel-browser-runtime/src/utils/index.ts index c77e8b124..4e189403c 100644 --- a/packages/kernel-browser-runtime/src/utils/index.ts +++ b/packages/kernel-browser-runtime/src/utils/index.ts @@ -1 +1,2 @@ +export * from './console-forwarding.ts'; export * from './relay-query-string.ts'; From 1dd7a789366c1bbd6b15ec30ebe79a1dcaea890c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:42:53 -0800 Subject: [PATCH 06/11] feat(console-forwarding): Capture all offscreen console logs via prelude script Implement a prelude script that wraps console methods BEFORE module code runs, enabling capture of early initialization logs that were previously lost. Uses chrome.runtime.sendMessage for immediate forwarding to background without waiting for stream setup. Changes: - Add console-forwarding-prelude.js to kernel-browser-runtime/src/static - Extend html-trusted-prelude Vite plugin to accept additional preludes - Configure extension to inject prelude before endoify.js - Update background.ts to handle console-forward-prelude messages - Remove stream-based console forwarding from offscreen.ts (now handled by prelude) - Configure eslint to ignore static/*.js files Co-Authored-By: Claude --- eslint.config.mjs | 9 ++- packages/extension/src/background.ts | 50 ++++++++++-- packages/extension/src/offscreen.ts | 4 - packages/extension/vite.config.ts | 6 +- .../src/static/console-forwarding-prelude.js | 81 +++++++++++++++++++ .../src/vite-plugins/html-trusted-prelude.ts | 36 ++++++++- 6 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c956602d..ebf055985 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,7 +16,14 @@ const config = createConfig([ }, { - ignores: ['**/coverage', '**/dist', '**/docs', '**/node_modules'], + ignores: [ + '**/coverage', + '**/dist', + '**/docs', + '**/node_modules', + // Static JS files that run before lockdown (intentionally ES5/ES6 compatible) + '**/static/*.js', + ], }, { diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 4d1a17320..404b974da 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -4,8 +4,6 @@ import { makeCapTPNotification, isCapTPNotification, getCapTPMessage, - isConsoleForwardMessage, - handleConsoleForwardMessage, } from '@metamask/kernel-browser-runtime'; import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster'; @@ -16,6 +14,34 @@ import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; defineGlobals(); +/** + * Type for console-forward-prelude messages sent via chrome.runtime.sendMessage. + */ +type ConsoleForwardPreludeMessage = { + type: 'console-forward-prelude'; + method: 'log' | 'debug' | 'info' | 'warn' | 'error'; + args: string[]; +}; + +/** + * Type guard for console-forward-prelude messages. + * + * @param value - The value to check. + * @returns Whether the value is a ConsoleForwardPreludeMessage. + */ +function isConsoleForwardPreludeMessage( + value: unknown, +): value is ConsoleForwardPreludeMessage { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + (value as { type: unknown }).type === 'console-forward-prelude' && + 'method' in value && + 'args' in value + ); +} + const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); let bootPromise: Promise | null = null; @@ -37,7 +63,19 @@ chrome.runtime.onStartup.addListener(() => { }); // Messages or connections can also kick us awake -chrome.runtime.onMessage.addListener((_msg, _sender, sendResponse) => { +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // Handle console-forward-prelude messages from offscreen/popup + if ( + isConsoleForwardPreludeMessage(message) && + sender.id === chrome.runtime.id + ) { + const { method, args } = message; + // eslint-disable-next-line no-console + console[method]('[offscreen]', ...args); + sendResponse(true); + return false; + } + start(); sendResponse(true); return false; @@ -109,11 +147,9 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globalThis.kernel = kernelP; - // Handle incoming messages from offscreen (CapTP and console-forward) + // Handle incoming CapTP messages from offscreen const drainPromise = offscreenStream.drain((message) => { - if (isConsoleForwardMessage(message)) { - handleConsoleForwardMessage(message, '[offscreen]'); - } else if (isCapTPNotification(message)) { + if (isCapTPNotification(message)) { const captpMessage = getCapTPMessage(message); backgroundCapTP.dispatch(captpMessage); } else { diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index aa11ac189..c09ec2772 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -2,7 +2,6 @@ import { makeIframeVatWorker, PlatformServicesServer, createRelayQueryString, - setupConsoleForwarding, } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; @@ -32,9 +31,6 @@ async function main(): Promise { JsonRpcMessage >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); - // Set up console forwarding to background for Playwright capture - setupConsoleForwarding(backgroundStream); - const kernelStream = await makeKernelWorker(); // Handle messages from the background script / kernel diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 91ed7d421..30eb1db00 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -36,6 +36,8 @@ const staticCopyTargets: readonly (string | Target)[] = [ 'packages/extension/src/manifest.json', // Trusted prelude-related 'packages/kernel-shims/dist/endoify.js', + // Console forwarding prelude for Playwright log capture + 'packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js', ]; // https://vitejs.dev/config/ @@ -91,7 +93,9 @@ export default defineConfig(({ mode }) => { }, plugins: [ react(), - htmlTrustedPrelude(), + htmlTrustedPrelude({ + preludes: ['/console-forwarding-prelude.js'], + }), jsTrustedPrelude({ trustedPreludes }), viteStaticCopy({ targets: staticCopyTargets.map((src) => diff --git a/packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js b/packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js new file mode 100644 index 000000000..d7bce64c7 --- /dev/null +++ b/packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js @@ -0,0 +1,81 @@ +/** + * Console forwarding prelude script. + * + * This script MUST run BEFORE the main bundle to capture all console output, + * including logs during module imports and initialization. + * + * It wraps console methods to forward messages to the background service worker + * via chrome.runtime.sendMessage, which is available immediately without needing + * to wait for stream setup. + */ +(function () { + 'use strict'; + + // Only run in extension context with chrome.runtime available + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + return; + } + + const originalConsole = { + log: console.log, + debug: console.debug, + info: console.info, + warn: console.warn, + error: console.error, + }; + + const methods = ['log', 'debug', 'info', 'warn', 'error']; + + /** + * Serialize an argument for transmission. + * + * @param {unknown} arg - The argument to serialize. + * @returns {string} The serialized argument. + */ + function serialize(arg) { + if (typeof arg === 'string') { + return arg; + } + if (typeof arg === 'number' || typeof arg === 'boolean') { + return String(arg); + } + if (arg === null) { + return 'null'; + } + if (arg === undefined) { + return 'undefined'; + } + try { + return JSON.stringify(arg); + } catch (_error) { + return '[unserializable]'; + } + } + + methods.forEach(function (method) { + /** + * + */ + console[method] = function () { + // Call original console method for local output + originalConsole[method].apply(console, arguments); + + // Serialize arguments for transmission + const args = []; + for (let i = 0; i < arguments.length; i++) { + args.push(serialize(arguments[i])); + } + + // Forward to background via chrome.runtime.sendMessage + try { + chrome.runtime.sendMessage({ + type: 'console-forward-prelude', + method, + args, + }); + } catch (_error) { + // Ignore errors - background may not be ready yet + } + }; + }); +})(); diff --git a/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts b/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts index 5d1124113..bdf006fd0 100644 --- a/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts +++ b/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts @@ -3,14 +3,35 @@ import { format as prettierFormat } from 'prettier'; import type { Plugin as VitePlugin } from 'vite'; /** - * Vite plugin to insert the endoify script before the first script in the head element. + * Options for the HTML trusted prelude plugin. + */ +export type HtmlTrustedPreludeOptions = { + /** + * Additional prelude scripts to inject BEFORE endoify.js. + * These are injected as regular scripts (not type="module") so they + * execute synchronously before any module scripts. + * + * Use this for scripts that must run before lockdown, such as + * console-forwarding-prelude.js for Playwright log capture. + */ + preludes?: string[]; +}; + +/** + * Vite plugin to insert trusted prelude scripts before the first script in the head element. + * Injects optional preludes first, then endoify.js. * Assumes that `endoify.js` is located in the root of the web app. * + * @param options - Plugin options. * @throws If the HTML document already references the endoify script or lacks the expected * structure. * @returns The Vite plugin. */ -export function htmlTrustedPrelude(): VitePlugin { +export function htmlTrustedPrelude( + options: HtmlTrustedPreludeOptions = {}, +): VitePlugin { + const { preludes = [] } = options; + return { name: 'ocap-kernel:html-trusted-prelude', async transformIndexHtml(htmlString): Promise { @@ -34,11 +55,18 @@ export function htmlTrustedPrelude(): VitePlugin { ); } + // Build the prelude elements: additional preludes first, then endoify + // Preludes are regular scripts (not modules) so they execute synchronously + const preludeElements = preludes.map( + (src) => ``, + ); const endoifyElement = ``; + const allElements = [...preludeElements, endoifyElement].join('\n'); + if (htmlDoc('head > script').length >= 1) { - htmlDoc(endoifyElement).insertBefore('head:first script:first'); + htmlDoc(allElements).insertBefore('head:first script:first'); } else { - htmlDoc(endoifyElement).appendTo('head:first'); + htmlDoc(allElements).appendTo('head:first'); } return await prettierFormat(htmlDoc.html(), { From 998efc94efdd415bd41b14f886dc0f36cd62a0ac Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:10:41 -0800 Subject: [PATCH 07/11] Revert "feat(console-forwarding): Capture all offscreen console logs via prelude script" This reverts commit 20105ded24b211b08faab6d03d1efb6523c15d1f. --- eslint.config.mjs | 9 +-- packages/extension/src/background.ts | 50 ++---------- packages/extension/src/offscreen.ts | 4 + packages/extension/vite.config.ts | 6 +- .../src/static/console-forwarding-prelude.js | 81 ------------------- .../src/vite-plugins/html-trusted-prelude.ts | 36 +-------- 6 files changed, 17 insertions(+), 169 deletions(-) delete mode 100644 packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js diff --git a/eslint.config.mjs b/eslint.config.mjs index ebf055985..1c956602d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,14 +16,7 @@ const config = createConfig([ }, { - ignores: [ - '**/coverage', - '**/dist', - '**/docs', - '**/node_modules', - // Static JS files that run before lockdown (intentionally ES5/ES6 compatible) - '**/static/*.js', - ], + ignores: ['**/coverage', '**/dist', '**/docs', '**/node_modules'], }, { diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 404b974da..4d1a17320 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -4,6 +4,8 @@ import { makeCapTPNotification, isCapTPNotification, getCapTPMessage, + isConsoleForwardMessage, + handleConsoleForwardMessage, } from '@metamask/kernel-browser-runtime'; import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster'; @@ -14,34 +16,6 @@ import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; defineGlobals(); -/** - * Type for console-forward-prelude messages sent via chrome.runtime.sendMessage. - */ -type ConsoleForwardPreludeMessage = { - type: 'console-forward-prelude'; - method: 'log' | 'debug' | 'info' | 'warn' | 'error'; - args: string[]; -}; - -/** - * Type guard for console-forward-prelude messages. - * - * @param value - The value to check. - * @returns Whether the value is a ConsoleForwardPreludeMessage. - */ -function isConsoleForwardPreludeMessage( - value: unknown, -): value is ConsoleForwardPreludeMessage { - return ( - typeof value === 'object' && - value !== null && - 'type' in value && - (value as { type: unknown }).type === 'console-forward-prelude' && - 'method' in value && - 'args' in value - ); -} - const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); let bootPromise: Promise | null = null; @@ -63,19 +37,7 @@ chrome.runtime.onStartup.addListener(() => { }); // Messages or connections can also kick us awake -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - // Handle console-forward-prelude messages from offscreen/popup - if ( - isConsoleForwardPreludeMessage(message) && - sender.id === chrome.runtime.id - ) { - const { method, args } = message; - // eslint-disable-next-line no-console - console[method]('[offscreen]', ...args); - sendResponse(true); - return false; - } - +chrome.runtime.onMessage.addListener((_msg, _sender, sendResponse) => { start(); sendResponse(true); return false; @@ -147,9 +109,11 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globalThis.kernel = kernelP; - // Handle incoming CapTP messages from offscreen + // Handle incoming messages from offscreen (CapTP and console-forward) const drainPromise = offscreenStream.drain((message) => { - if (isCapTPNotification(message)) { + if (isConsoleForwardMessage(message)) { + handleConsoleForwardMessage(message, '[offscreen]'); + } else if (isCapTPNotification(message)) { const captpMessage = getCapTPMessage(message); backgroundCapTP.dispatch(captpMessage); } else { diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index c09ec2772..aa11ac189 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -2,6 +2,7 @@ import { makeIframeVatWorker, PlatformServicesServer, createRelayQueryString, + setupConsoleForwarding, } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; @@ -31,6 +32,9 @@ async function main(): Promise { JsonRpcMessage >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); + // Set up console forwarding to background for Playwright capture + setupConsoleForwarding(backgroundStream); + const kernelStream = await makeKernelWorker(); // Handle messages from the background script / kernel diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 30eb1db00..91ed7d421 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -36,8 +36,6 @@ const staticCopyTargets: readonly (string | Target)[] = [ 'packages/extension/src/manifest.json', // Trusted prelude-related 'packages/kernel-shims/dist/endoify.js', - // Console forwarding prelude for Playwright log capture - 'packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js', ]; // https://vitejs.dev/config/ @@ -93,9 +91,7 @@ export default defineConfig(({ mode }) => { }, plugins: [ react(), - htmlTrustedPrelude({ - preludes: ['/console-forwarding-prelude.js'], - }), + htmlTrustedPrelude(), jsTrustedPrelude({ trustedPreludes }), viteStaticCopy({ targets: staticCopyTargets.map((src) => diff --git a/packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js b/packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js deleted file mode 100644 index d7bce64c7..000000000 --- a/packages/kernel-browser-runtime/src/static/console-forwarding-prelude.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Console forwarding prelude script. - * - * This script MUST run BEFORE the main bundle to capture all console output, - * including logs during module imports and initialization. - * - * It wraps console methods to forward messages to the background service worker - * via chrome.runtime.sendMessage, which is available immediately without needing - * to wait for stream setup. - */ -(function () { - 'use strict'; - - // Only run in extension context with chrome.runtime available - if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { - return; - } - - const originalConsole = { - log: console.log, - debug: console.debug, - info: console.info, - warn: console.warn, - error: console.error, - }; - - const methods = ['log', 'debug', 'info', 'warn', 'error']; - - /** - * Serialize an argument for transmission. - * - * @param {unknown} arg - The argument to serialize. - * @returns {string} The serialized argument. - */ - function serialize(arg) { - if (typeof arg === 'string') { - return arg; - } - if (typeof arg === 'number' || typeof arg === 'boolean') { - return String(arg); - } - if (arg === null) { - return 'null'; - } - if (arg === undefined) { - return 'undefined'; - } - try { - return JSON.stringify(arg); - } catch (_error) { - return '[unserializable]'; - } - } - - methods.forEach(function (method) { - /** - * - */ - console[method] = function () { - // Call original console method for local output - originalConsole[method].apply(console, arguments); - - // Serialize arguments for transmission - const args = []; - for (let i = 0; i < arguments.length; i++) { - args.push(serialize(arguments[i])); - } - - // Forward to background via chrome.runtime.sendMessage - try { - chrome.runtime.sendMessage({ - type: 'console-forward-prelude', - method, - args, - }); - } catch (_error) { - // Ignore errors - background may not be ready yet - } - }; - }); -})(); diff --git a/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts b/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts index bdf006fd0..5d1124113 100644 --- a/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts +++ b/packages/repo-tools/src/vite-plugins/html-trusted-prelude.ts @@ -3,35 +3,14 @@ import { format as prettierFormat } from 'prettier'; import type { Plugin as VitePlugin } from 'vite'; /** - * Options for the HTML trusted prelude plugin. - */ -export type HtmlTrustedPreludeOptions = { - /** - * Additional prelude scripts to inject BEFORE endoify.js. - * These are injected as regular scripts (not type="module") so they - * execute synchronously before any module scripts. - * - * Use this for scripts that must run before lockdown, such as - * console-forwarding-prelude.js for Playwright log capture. - */ - preludes?: string[]; -}; - -/** - * Vite plugin to insert trusted prelude scripts before the first script in the head element. - * Injects optional preludes first, then endoify.js. + * Vite plugin to insert the endoify script before the first script in the head element. * Assumes that `endoify.js` is located in the root of the web app. * - * @param options - Plugin options. * @throws If the HTML document already references the endoify script or lacks the expected * structure. * @returns The Vite plugin. */ -export function htmlTrustedPrelude( - options: HtmlTrustedPreludeOptions = {}, -): VitePlugin { - const { preludes = [] } = options; - +export function htmlTrustedPrelude(): VitePlugin { return { name: 'ocap-kernel:html-trusted-prelude', async transformIndexHtml(htmlString): Promise { @@ -55,18 +34,11 @@ export function htmlTrustedPrelude( ); } - // Build the prelude elements: additional preludes first, then endoify - // Preludes are regular scripts (not modules) so they execute synchronously - const preludeElements = preludes.map( - (src) => ``, - ); const endoifyElement = ``; - const allElements = [...preludeElements, endoifyElement].join('\n'); - if (htmlDoc('head > script').length >= 1) { - htmlDoc(allElements).insertBefore('head:first script:first'); + htmlDoc(endoifyElement).insertBefore('head:first script:first'); } else { - htmlDoc(allElements).appendTo('head:first'); + htmlDoc(endoifyElement).appendTo('head:first'); } return await prettierFormat(htmlDoc.html(), { From e5b503d99e2eb20646ebab0fd0063b40a6eb297d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:11:53 -0800 Subject: [PATCH 08/11] feat(repo-tools): Capture iframe and worker console logs via CDP Enhance Playwright test logging to capture console output from all extension contexts including vat iframes and kernel workers. Uses Chrome DevTools Protocol (CDP) to access execution contexts that Playwright doesn't expose directly through its API. Changes: - Add CDP-based console capture for iframe logs (vat iframes) - Add worker.on('console') listener for kernel worker logs - Add writeRawLog helper for CDP events without ConsoleMessage objects - Track CDP sessions for proper cleanup - Add CDP type definitions for Runtime domain events Co-Authored-By: Claude Sonnet 4.5 --- .../repo-tools/src/test-utils/extension.ts | 135 +++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/repo-tools/src/test-utils/extension.ts b/packages/repo-tools/src/test-utils/extension.ts index 380ec767e..28100f7e0 100644 --- a/packages/repo-tools/src/test-utils/extension.ts +++ b/packages/repo-tools/src/test-utils/extension.ts @@ -1,11 +1,38 @@ import { chromium, test } from '@playwright/test'; -import type { BrowserContext, ConsoleMessage, Page } from '@playwright/test'; +import type { + BrowserContext, + CDPSession, + ConsoleMessage, + Page, +} from '@playwright/test'; import { appendFileSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +// CDP event types for Runtime domain +// These are simplified versions of the Chrome DevTools Protocol types +type CdpRemoteObject = { + type: string; + value?: unknown; + description?: string; +}; + +type CdpExecutionContextCreatedEvent = { + context: { + id: number; + origin: string; + auxData?: { frameId?: string }; + }; +}; + +type CdpConsoleAPICalledEvent = { + type: string; + args: CdpRemoteObject[]; + executionContextId: number; +}; + export const sessionPath = path.resolve(os.tmpdir(), 'ocap-test'); // Run ID is generated once per Playwright invocation (per worker process) @@ -75,6 +102,22 @@ export const makeLoadExtension = async ({ ); }; + /** + * Write a raw log entry (for CDP events where we don't have a ConsoleMessage). + * + * @param source - The source identifier for the log. + * @param type - The console method type. + * @param text - The log message text. + */ + const writeRawLog = (source: string, type: string, text: string): void => { + const logTimestamp = new Date().toISOString().slice(0, -5); + // eslint-disable-next-line n/no-sync + appendFileSync( + logFilePath, + `[${logTimestamp}] [${source}] [${type}] ${text}\n`, + ); + }; + const browserArgs = [ `--disable-features=ExtensionDisableUnsupportedDeveloper`, `--disable-extensions-except=${extensionPath}`, @@ -100,6 +143,9 @@ export const makeLoadExtension = async ({ ); }); + // Track CDP sessions for cleanup + const cdpSessions: CDPSession[] = []; + // Capture console logs from extension pages (offscreen document, etc.) // Note: Pages may start at about:blank, so we attach the listener and check URL in the handler browserContext.on('page', (page) => { @@ -108,6 +154,20 @@ export const makeLoadExtension = async ({ writeLog('offscreen', consoleMessage); } }); + + // Capture Web Worker console logs (e.g., kernel worker) + page.on('worker', (worker) => { + worker.on('console', (consoleMessage) => { + writeLog('kernel-worker', consoleMessage); + }); + }); + + // Set up CDP to capture iframe console logs (vat iframes) + // We need to do this because Playwright doesn't have frame.on('console') + setupCdpForIframeConsoleLogs(page, writeRawLog, cdpSessions).catch( + // eslint-disable-next-line no-console + (error) => console.warn('Failed to set up CDP for iframe logs:', error), + ); }); // Wait for the extension to be loaded @@ -132,3 +192,76 @@ export const makeLoadExtension = async ({ return { browserContext, extensionId, popupPage, logFilePath }; }; + +/** + * Sets up Chrome DevTools Protocol (CDP) to capture console logs from iframes. + * Playwright doesn't provide `frame.on('console')`, so we use CDP's Runtime domain + * to listen for console API calls from all execution contexts including iframes. + * + * @param page - The Playwright page to set up CDP for. + * @param writeRawLog - Function to write raw log entries. + * @param cdpSessions - Array to track CDP sessions for cleanup. + */ +async function setupCdpForIframeConsoleLogs( + page: Page, + writeRawLog: (source: string, type: string, text: string) => void, + cdpSessions: CDPSession[], +): Promise { + // Only set up CDP for pages that might have iframes (offscreen document) + if (!page.url().includes('offscreen.html')) { + return; + } + + const cdpSession = await page.context().newCDPSession(page); + cdpSessions.push(cdpSession); + + // Enable Runtime domain to receive console events + await cdpSession.send('Runtime.enable'); + + // Track execution contexts to identify iframe sources + const executionContexts = new Map(); + + // Listen for new execution contexts (iframes get their own context) + cdpSession.on( + 'Runtime.executionContextCreated', + (event: CdpExecutionContextCreatedEvent) => { + const { id, origin, auxData } = event.context; + // auxData.frameId can help identify the iframe + const frameId = auxData?.frameId; + const source = frameId ? `iframe-${frameId.slice(0, 8)}` : `ctx-${id}`; + executionContexts.set(id, origin.includes('iframe') ? source : origin); + }, + ); + + // Listen for console API calls from all contexts (including iframes) + cdpSession.on( + 'Runtime.consoleAPICalled', + (event: CdpConsoleAPICalledEvent) => { + const { type, args, executionContextId } = event; + + // Format args into a readable string + const text = args + .map((arg) => { + if (arg.value !== undefined) { + return typeof arg.value === 'string' + ? arg.value + : JSON.stringify(arg.value); + } + return arg.description ?? arg.type; + }) + .join(' '); + + // Determine the source based on execution context + const contextSource = executionContexts.get(executionContextId); + const source = contextSource?.startsWith('iframe') + ? contextSource + : `iframe-ctx-${executionContextId}`; + + // Only log if it looks like an iframe context (avoid duplicating main page logs) + // Main page logs are already captured via page.on('console') + if (contextSource?.startsWith('iframe') || executionContextId > 1) { + writeRawLog(source, type, text); + } + }, + ); +} From c420645a46b7d2cc79e154893a48e7ad5aa8b32f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:41:29 -0800 Subject: [PATCH 09/11] feat(console-forwarding): Consolidate all console logs in background service worker Add source field to ConsoleForwardMessage to identify log origins (offscreen, kernel-worker, vat-*). Forward console logs from kernel worker and vat iframes through offscreen to background, enabling centralized logging for production and simplified Playwright capture. - Add source field to ConsoleForwardMessage type - Add source parameter to setupConsoleForwarding() - Extract stringifyConsoleArg() as public utility - Forward kernel worker logs via stream to background - Forward vat iframe logs via postMessage to offscreen - Add unit tests for console-forwarding module Co-Authored-By: Claude --- packages/extension/src/background.ts | 2 +- packages/extension/src/offscreen.ts | 27 ++- .../kernel-browser-runtime/src/index.test.ts | 1 + .../src/kernel-worker/kernel-worker.ts | 4 + .../src/utils/console-forwarding.test.ts | 200 ++++++++++++++++++ .../src/utils/console-forwarding.ts | 38 ++-- .../src/utils/index.test.ts | 1 + .../kernel-browser-runtime/src/vat/iframe.ts | 37 ++++ 8 files changed, 294 insertions(+), 16 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 4d1a17320..2893e3062 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -112,7 +112,7 @@ async function main(): Promise { // Handle incoming messages from offscreen (CapTP and console-forward) const drainPromise = offscreenStream.drain((message) => { if (isConsoleForwardMessage(message)) { - handleConsoleForwardMessage(message, '[offscreen]'); + handleConsoleForwardMessage(message); } else if (isCapTPNotification(message)) { const captpMessage = getCapTPMessage(message); backgroundCapTP.dispatch(captpMessage); diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index aa11ac189..97caed5d2 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -3,7 +3,9 @@ import { PlatformServicesServer, createRelayQueryString, setupConsoleForwarding, + stringifyConsoleArg, } from '@metamask/kernel-browser-runtime'; +import type { ConsoleForwardMessage } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; @@ -33,7 +35,30 @@ async function main(): Promise { >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); // Set up console forwarding to background for Playwright capture - setupConsoleForwarding(backgroundStream); + setupConsoleForwarding(backgroundStream, 'offscreen'); + + // Listen for console messages from vat iframes and forward to background + window.addEventListener('message', (event) => { + if ( + event.data !== null && + typeof event.data === 'object' && + event.data.type === 'console-forward' + ) { + const { source, method, args } = event.data as { + source: string; + method: 'log' | 'debug' | 'info' | 'warn' | 'error'; + args: unknown[]; + }; + const message: ConsoleForwardMessage = { + jsonrpc: '2.0', + method: 'console-forward', + params: { source, method, args: args.map(stringifyConsoleArg) }, + }; + backgroundStream.write(message).catch(() => { + // Ignore errors if stream isn't ready + }); + } + }); const kernelStream = await makeKernelWorker(); diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index 0a4415bdc..8608f0901 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -22,6 +22,7 @@ describe('index', () => { 'rpcHandlers', 'rpcMethodSpecs', 'setupConsoleForwarding', + 'stringifyConsoleArg', ]); }); }); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index ecc9b8ad8..f1bace721 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -17,6 +17,7 @@ import { import type { CapTPMessage } from '../background-captp.ts'; import { receiveInternalConnections } from '../internal-comms/internal-connections.ts'; import { PlatformServicesClient } from '../PlatformServicesClient.ts'; +import { setupConsoleForwarding } from '../utils/console-forwarding.ts'; import { makeKernelCapTP } from './captp/index.ts'; import { makeLoggingMiddleware } from './middleware/logging.ts'; import { makePanelMessageMiddleware } from './middleware/panel-message.ts'; @@ -46,6 +47,9 @@ async function main(): Promise { makeSQLKernelDatabase({ dbFilename: DB_FILENAME }), ]); + // Set up console forwarding - messages flow through offscreen to background + setupConsoleForwarding(messageStream, 'kernel-worker'); + const resetStorage = new URLSearchParams(globalThis.location.search).get('reset-storage') === 'true'; diff --git a/packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts b/packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts new file mode 100644 index 000000000..2940888f6 --- /dev/null +++ b/packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + isConsoleForwardMessage, + stringifyConsoleArg, + setupConsoleForwarding, + handleConsoleForwardMessage, +} from './console-forwarding.ts'; +import type { ConsoleForwardMessage } from './console-forwarding.ts'; + +// Mock harden to do nothing since we're not in SES +vi.stubGlobal( + 'harden', + vi.fn((obj: unknown) => obj), +); + +describe('console-forwarding', () => { + describe('isConsoleForwardMessage', () => { + it('returns true for valid console-forward message', () => { + const message: ConsoleForwardMessage = { + jsonrpc: '2.0', + method: 'console-forward', + params: { + source: 'offscreen', + method: 'log', + args: ['test'], + }, + }; + expect(isConsoleForwardMessage(message)).toBe(true); + }); + + it.each([ + { name: 'null', value: null }, + { name: 'undefined', value: undefined }, + { name: 'string', value: 'test' }, + { name: 'number', value: 123 }, + { name: 'array', value: [] }, + { name: 'object without method', value: { jsonrpc: '2.0' } }, + { name: 'object with different method', value: { method: 'other' } }, + ])('returns false for $name', ({ value }) => { + expect(isConsoleForwardMessage(value)).toBe(false); + }); + }); + + describe('stringifyConsoleArg', () => { + it.each([ + { name: 'string', input: 'hello', expected: 'hello' }, + { name: 'number', input: 42, expected: '42' }, + { name: 'boolean true', input: true, expected: 'true' }, + { name: 'boolean false', input: false, expected: 'false' }, + { name: 'null', input: null, expected: 'null' }, + { name: 'undefined', input: undefined, expected: undefined }, + { name: 'object', input: { foo: 'bar' }, expected: '{"foo":"bar"}' }, + { name: 'array', input: [1, 2, 3], expected: '[1,2,3]' }, + { + name: 'nested object', + input: { a: { b: 1 } }, + expected: '{"a":{"b":1}}', + }, + ])('stringifies $name correctly', ({ input, expected }) => { + expect(stringifyConsoleArg(input)).toBe(expected); + }); + }); + + describe('setupConsoleForwarding', () => { + let originalConsole: typeof console; + let mockStream: { + write: ReturnType; + }; + + beforeEach(() => { + originalConsole = { ...console }; + mockStream = { + write: vi.fn().mockResolvedValue(undefined), + }; + }); + + afterEach(() => { + // Restore original console methods + Object.assign(console, originalConsole); + }); + + it('wraps all console methods', () => { + setupConsoleForwarding(mockStream as never, 'test-source'); + + const methods = ['log', 'debug', 'info', 'warn', 'error'] as const; + for (const method of methods) { + expect(console[method]).not.toBe(originalConsole[method]); + } + }); + + it.each(['log', 'debug', 'info', 'warn', 'error'] as const)( + 'forwards %s method to stream with source', + (method) => { + setupConsoleForwarding(mockStream as never, 'test-source'); + + console[method]('test message', 123); + + expect(mockStream.write).toHaveBeenCalledWith({ + jsonrpc: '2.0', + method: 'console-forward', + params: { + source: 'test-source', + method, + args: ['test message', '123'], + }, + }); + }, + ); + + it('calls original console method', () => { + // Spy on console.log BEFORE setupConsoleForwarding captures it + const originalLog = vi.spyOn(console, 'log'); + setupConsoleForwarding(mockStream as never, 'test-source'); + + console.log('test'); + + expect(originalLog).toHaveBeenCalledWith('test'); + originalLog.mockRestore(); + }); + + it('ignores stream write errors', async () => { + mockStream.write.mockRejectedValue(new Error('Stream not ready')); + setupConsoleForwarding(mockStream as never, 'test-source'); + + // Should not throw + + expect(() => console.log('test')).not.toThrow(); + }); + }); + + describe('handleConsoleForwardMessage', () => { + let consoleSpy: ReturnType; + + afterEach(() => { + consoleSpy?.mockRestore(); + }); + + it.each(['log', 'debug', 'info', 'warn', 'error'] as const)( + 'calls console.%s with source prefix and args', + (method) => { + consoleSpy = vi + .spyOn(console, method) + .mockImplementation(() => undefined); + + const message: ConsoleForwardMessage = { + jsonrpc: '2.0', + method: 'console-forward', + params: { + source: 'offscreen', + method, + args: ['arg1', 'arg2'], + }, + }; + + handleConsoleForwardMessage(message); + + expect(consoleSpy).toHaveBeenCalledWith('[offscreen]', 'arg1', 'arg2'); + }, + ); + + it('uses source from message for prefix', () => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const message: ConsoleForwardMessage = { + jsonrpc: '2.0', + method: 'console-forward', + params: { + source: 'kernel-worker', + method: 'log', + args: ['test'], + }, + }; + + handleConsoleForwardMessage(message); + + expect(consoleSpy).toHaveBeenCalledWith('[kernel-worker]', 'test'); + }); + + it('handles vat source prefixes', () => { + consoleSpy = vi + .spyOn(console, 'info') + .mockImplementation(() => undefined); + + const message: ConsoleForwardMessage = { + jsonrpc: '2.0', + method: 'console-forward', + params: { + source: 'vat-v1', + method: 'info', + args: ['vat message'], + }, + }; + + handleConsoleForwardMessage(message); + + expect(consoleSpy).toHaveBeenCalledWith('[vat-v1]', 'vat message'); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/utils/console-forwarding.ts b/packages/kernel-browser-runtime/src/utils/console-forwarding.ts index 8df0d4be4..081bffbda 100644 --- a/packages/kernel-browser-runtime/src/utils/console-forwarding.ts +++ b/packages/kernel-browser-runtime/src/utils/console-forwarding.ts @@ -9,6 +9,7 @@ import type { JsonRpcNotification } from '@metamask/utils'; export type ConsoleForwardMessage = JsonRpcNotification & { method: 'console-forward'; params: { + source: string; method: 'log' | 'debug' | 'info' | 'warn' | 'error'; args: string[]; }; @@ -28,6 +29,23 @@ export const isConsoleForwardMessage = ( 'method' in value && (value as { method: unknown }).method === 'console-forward'; +/** + * Stringifies an argument for console forwarding. + * + * @param arg - The argument to stringify. + * @returns The stringified argument. + */ +export function stringifyConsoleArg(arg: unknown): string { + if (typeof arg === 'string') { + return arg; + } + if (typeof arg === 'number' || typeof arg === 'boolean') { + return String(arg); + } + // Objects, arrays, null, undefined, functions, symbols, etc. + return JSON.stringify(arg); +} + /** * Wraps console methods to forward messages to background via a stream. * This enables capturing console output from contexts that Playwright cannot @@ -37,9 +55,11 @@ export const isConsoleForwardMessage = ( * will be forwarded to the stream recipient where it can be replayed. * * @param stream - The stream to write console messages to. + * @param source - The source identifier for this context (e.g., 'offscreen', 'kernel-worker'). */ export function setupConsoleForwarding( stream: DuplexStream, + source: string, ): void { const originalConsole = { ...console }; const consoleMethods = ['log', 'debug', 'info', 'warn', 'error'] as const; @@ -55,17 +75,9 @@ export function setupConsoleForwarding( jsonrpc: '2.0', method: 'console-forward', params: { + source, method: consoleMethod, - args: args.map((arg) => { - if (typeof arg === 'string') { - return arg; - } - if (typeof arg === 'number' || typeof arg === 'boolean') { - return String(arg); - } - // Objects, arrays, null, undefined, functions, symbols, etc. - return JSON.stringify(arg); - }), + args: args.map(stringifyConsoleArg), }, }; stream.write(message).catch(() => { @@ -82,13 +94,11 @@ export function setupConsoleForwarding( * Use this in the stream handler to replay forwarded console output. * * @param message - The console-forward message to handle. - * @param prefix - Optional prefix to add to the message (e.g., '[offscreen]'). */ export function handleConsoleForwardMessage( message: ConsoleForwardMessage, - prefix?: string, ): void { - const { method, args } = message.params; + const { source, method, args } = message.params; // eslint-disable-next-line no-console - console[method](...(prefix ? [prefix, ...args] : args)); + console[method](`[${source}]`, ...args); } diff --git a/packages/kernel-browser-runtime/src/utils/index.test.ts b/packages/kernel-browser-runtime/src/utils/index.test.ts index 9d50bd8d2..211f96294 100644 --- a/packages/kernel-browser-runtime/src/utils/index.test.ts +++ b/packages/kernel-browser-runtime/src/utils/index.test.ts @@ -11,6 +11,7 @@ describe('index', () => { 'isConsoleForwardMessage', 'parseRelayQueryString', 'setupConsoleForwarding', + 'stringifyConsoleArg', ]); }); }); diff --git a/packages/kernel-browser-runtime/src/vat/iframe.ts b/packages/kernel-browser-runtime/src/vat/iframe.ts index 2e914fa6a..05eaff9fa 100644 --- a/packages/kernel-browser-runtime/src/vat/iframe.ts +++ b/packages/kernel-browser-runtime/src/vat/iframe.ts @@ -8,8 +8,42 @@ import { } from '@metamask/streams/browser'; import { makePlatform } from '@ocap/kernel-platforms/browser'; +import { stringifyConsoleArg } from '../utils/console-forwarding.ts'; + const logger = new Logger('vat-iframe'); +/** + * Sets up console forwarding from a vat iframe to the parent window (offscreen). + * Uses postMessage instead of streams since the iframe doesn't have a direct + * stream connection to offscreen. + * + * @param vatId - The vat identifier to use as the source. + */ +function setupIframeConsoleForwarding(vatId: string): void { + const originalConsole = { ...console }; + const consoleMethods = ['log', 'debug', 'info', 'warn', 'error'] as const; + + consoleMethods.forEach((consoleMethod) => { + // eslint-disable-next-line no-console + console[consoleMethod] = (...args: unknown[]) => { + originalConsole[consoleMethod](...args); + + // Post to parent (offscreen document) + window.parent.postMessage( + { + type: 'console-forward', + source: `vat-${vatId}`, + method: consoleMethod, + args: args.map(stringifyConsoleArg), + }, + '*', + ); + }; + }); + + harden(globalThis.console); +} + main().catch(logger.error); /** @@ -29,6 +63,9 @@ async function main(): Promise { const urlParams = new URLSearchParams(window.location.search); const vatId = urlParams.get('vatId') ?? 'unknown'; + // Set up console forwarding to parent (offscreen) for Playwright capture + setupIframeConsoleForwarding(vatId); + // eslint-disable-next-line no-new new VatSupervisor({ id: vatId, From 94711788cc37dbc463c8bbfc97521135b312d478 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:44:02 -0800 Subject: [PATCH 10/11] refactor(console-forwarding): Standardize postMessage format for iframe logs Move setupIframeConsoleForwarding to console-forwarding.ts as setupPostMessageConsoleForwarding and use the standard ConsoleForwardMessage format. This allows offscreen to validate messages using isConsoleForwardMessage and simplifies the relay logic. - Add setupPostMessageConsoleForwarding() for iframe console forwarding - Use standard ConsoleForwardMessage format (jsonrpc + params) via postMessage - Simplify offscreen message handler to use isConsoleForwardMessage - Add unit tests for setupPostMessageConsoleForwarding Co-Authored-By: Claude --- packages/extension/src/offscreen.ts | 21 +---- .../kernel-browser-runtime/src/index.test.ts | 1 + .../src/utils/console-forwarding.test.ts | 78 +++++++++++++++++++ .../src/utils/console-forwarding.ts | 35 +++++++++ .../src/utils/index.test.ts | 1 + .../kernel-browser-runtime/src/vat/iframe.ts | 36 +-------- 6 files changed, 120 insertions(+), 52 deletions(-) diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 97caed5d2..2d5dd2f2c 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -3,9 +3,8 @@ import { PlatformServicesServer, createRelayQueryString, setupConsoleForwarding, - stringifyConsoleArg, + isConsoleForwardMessage, } from '@metamask/kernel-browser-runtime'; -import type { ConsoleForwardMessage } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; @@ -39,22 +38,8 @@ async function main(): Promise { // Listen for console messages from vat iframes and forward to background window.addEventListener('message', (event) => { - if ( - event.data !== null && - typeof event.data === 'object' && - event.data.type === 'console-forward' - ) { - const { source, method, args } = event.data as { - source: string; - method: 'log' | 'debug' | 'info' | 'warn' | 'error'; - args: unknown[]; - }; - const message: ConsoleForwardMessage = { - jsonrpc: '2.0', - method: 'console-forward', - params: { source, method, args: args.map(stringifyConsoleArg) }, - }; - backgroundStream.write(message).catch(() => { + if (isConsoleForwardMessage(event.data)) { + backgroundStream.write(event.data).catch(() => { // Ignore errors if stream isn't ready }); } diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index 8608f0901..0ed42d670 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -22,6 +22,7 @@ describe('index', () => { 'rpcHandlers', 'rpcMethodSpecs', 'setupConsoleForwarding', + 'setupPostMessageConsoleForwarding', 'stringifyConsoleArg', ]); }); diff --git a/packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts b/packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts index 2940888f6..901d8a0a9 100644 --- a/packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts +++ b/packages/kernel-browser-runtime/src/utils/console-forwarding.test.ts @@ -4,6 +4,7 @@ import { isConsoleForwardMessage, stringifyConsoleArg, setupConsoleForwarding, + setupPostMessageConsoleForwarding, handleConsoleForwardMessage, } from './console-forwarding.ts'; import type { ConsoleForwardMessage } from './console-forwarding.ts'; @@ -197,4 +198,81 @@ describe('console-forwarding', () => { expect(consoleSpy).toHaveBeenCalledWith('[vat-v1]', 'vat message'); }); }); + + describe('setupPostMessageConsoleForwarding', () => { + let originalConsole: typeof console; + let mockPostMessage: ReturnType; + + beforeEach(() => { + originalConsole = { ...console }; + mockPostMessage = vi.fn(); + vi.stubGlobal('window', { + parent: { + postMessage: mockPostMessage, + }, + }); + }); + + afterEach(() => { + // Restore original console methods + Object.assign(console, originalConsole); + vi.unstubAllGlobals(); + // Re-stub harden since unstubAllGlobals removes it + vi.stubGlobal( + 'harden', + vi.fn((obj: unknown) => obj), + ); + }); + + it('wraps all console methods', () => { + setupPostMessageConsoleForwarding('vat-v1'); + + const methods = ['log', 'debug', 'info', 'warn', 'error'] as const; + for (const method of methods) { + expect(console[method]).not.toBe(originalConsole[method]); + } + }); + + it.each(['log', 'debug', 'info', 'warn', 'error'] as const)( + 'posts %s method to parent window with standard message format', + (method) => { + setupPostMessageConsoleForwarding('vat-v1'); + + console[method]('test message', 123); + + expect(mockPostMessage).toHaveBeenCalledWith( + { + jsonrpc: '2.0', + method: 'console-forward', + params: { + source: 'vat-v1', + method, + args: ['test message', '123'], + }, + }, + '*', + ); + }, + ); + + it('sends messages that pass isConsoleForwardMessage check', () => { + setupPostMessageConsoleForwarding('vat-v1'); + + console.log('test'); + + const sentMessage = mockPostMessage.mock.calls[0][0]; + expect(isConsoleForwardMessage(sentMessage)).toBe(true); + }); + + it('calls original console method', () => { + // Spy on console.log BEFORE setupPostMessageConsoleForwarding captures it + const originalLog = vi.spyOn(console, 'log'); + setupPostMessageConsoleForwarding('vat-v1'); + + console.log('test'); + + expect(originalLog).toHaveBeenCalledWith('test'); + originalLog.mockRestore(); + }); + }); }); diff --git a/packages/kernel-browser-runtime/src/utils/console-forwarding.ts b/packages/kernel-browser-runtime/src/utils/console-forwarding.ts index 081bffbda..e2909fdbb 100644 --- a/packages/kernel-browser-runtime/src/utils/console-forwarding.ts +++ b/packages/kernel-browser-runtime/src/utils/console-forwarding.ts @@ -102,3 +102,38 @@ export function handleConsoleForwardMessage( // eslint-disable-next-line no-console console[method](`[${source}]`, ...args); } + +/** + * Wraps console methods to forward messages to parent window via postMessage. + * Use this in iframes that don't have a direct stream connection to background. + * + * Messages are sent in the standard ConsoleForwardMessage format so they can + * be validated with isConsoleForwardMessage on the receiving end. + * + * @param source - The source identifier for this context (e.g., 'vat-v1'). + */ +export function setupPostMessageConsoleForwarding(source: string): void { + const originalConsole = { ...console }; + const consoleMethods = ['log', 'debug', 'info', 'warn', 'error'] as const; + + consoleMethods.forEach((consoleMethod) => { + // eslint-disable-next-line no-console + console[consoleMethod] = (...args: unknown[]) => { + originalConsole[consoleMethod](...args); + + // Post to parent window using standard ConsoleForwardMessage format + const message: ConsoleForwardMessage = { + jsonrpc: '2.0', + method: 'console-forward', + params: { + source, + method: consoleMethod, + args: args.map(stringifyConsoleArg), + }, + }; + window.parent.postMessage(message, '*'); + }; + }); + + harden(globalThis.console); +} diff --git a/packages/kernel-browser-runtime/src/utils/index.test.ts b/packages/kernel-browser-runtime/src/utils/index.test.ts index 211f96294..5110d3345 100644 --- a/packages/kernel-browser-runtime/src/utils/index.test.ts +++ b/packages/kernel-browser-runtime/src/utils/index.test.ts @@ -11,6 +11,7 @@ describe('index', () => { 'isConsoleForwardMessage', 'parseRelayQueryString', 'setupConsoleForwarding', + 'setupPostMessageConsoleForwarding', 'stringifyConsoleArg', ]); }); diff --git a/packages/kernel-browser-runtime/src/vat/iframe.ts b/packages/kernel-browser-runtime/src/vat/iframe.ts index 05eaff9fa..f4333d4f1 100644 --- a/packages/kernel-browser-runtime/src/vat/iframe.ts +++ b/packages/kernel-browser-runtime/src/vat/iframe.ts @@ -8,42 +8,10 @@ import { } from '@metamask/streams/browser'; import { makePlatform } from '@ocap/kernel-platforms/browser'; -import { stringifyConsoleArg } from '../utils/console-forwarding.ts'; +import { setupPostMessageConsoleForwarding } from '../utils/console-forwarding.ts'; const logger = new Logger('vat-iframe'); -/** - * Sets up console forwarding from a vat iframe to the parent window (offscreen). - * Uses postMessage instead of streams since the iframe doesn't have a direct - * stream connection to offscreen. - * - * @param vatId - The vat identifier to use as the source. - */ -function setupIframeConsoleForwarding(vatId: string): void { - const originalConsole = { ...console }; - const consoleMethods = ['log', 'debug', 'info', 'warn', 'error'] as const; - - consoleMethods.forEach((consoleMethod) => { - // eslint-disable-next-line no-console - console[consoleMethod] = (...args: unknown[]) => { - originalConsole[consoleMethod](...args); - - // Post to parent (offscreen document) - window.parent.postMessage( - { - type: 'console-forward', - source: `vat-${vatId}`, - method: consoleMethod, - args: args.map(stringifyConsoleArg), - }, - '*', - ); - }; - }); - - harden(globalThis.console); -} - main().catch(logger.error); /** @@ -64,7 +32,7 @@ async function main(): Promise { const vatId = urlParams.get('vatId') ?? 'unknown'; // Set up console forwarding to parent (offscreen) for Playwright capture - setupIframeConsoleForwarding(vatId); + setupPostMessageConsoleForwarding(`vat-${vatId}`); // eslint-disable-next-line no-new new VatSupervisor({ From fbaaaef590c1d83c2f883f3df983f548e97ba180 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:54:31 -0800 Subject: [PATCH 11/11] feat(repo-tools): Auto-attach console logs to Playwright test results Add attachLogs() function that reads the log file and attaches it to test results. The extension helper now wraps browserContext.close() to auto-attach logs before closing, so tests don't need to be modified. This fixes the issue where logs were written to packages/extension/logs/ but not attached to test-results for viewing in the Playwright HTML report. Co-Authored-By: Claude --- packages/extension/test/helpers.ts | 11 ++++++- .../repo-tools/src/test-utils/extension.ts | 29 +++++++++++++------ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/extension/test/helpers.ts b/packages/extension/test/helpers.ts index f7ff7a931..fe6dd63a4 100644 --- a/packages/extension/test/helpers.ts +++ b/packages/extension/test/helpers.ts @@ -11,7 +11,7 @@ const extensionPath = path.resolve( ); export const loadExtension = async (contextId?: string) => { - return makeLoadExtension({ + const result = await makeLoadExtension({ contextId, extensionPath, onPageLoad: async (popupPage) => { @@ -21,4 +21,13 @@ export const loadExtension = async (contextId?: string) => { ).toBeVisible(); }, }); + + // Wrap browserContext.close to auto-attach logs + const originalClose = result.browserContext.close.bind(result.browserContext); + result.browserContext.close = async () => { + await result.attachLogs(); + return originalClose(); + }; + + return result; }; diff --git a/packages/repo-tools/src/test-utils/extension.ts b/packages/repo-tools/src/test-utils/extension.ts index 28100f7e0..6fbdbecec 100644 --- a/packages/repo-tools/src/test-utils/extension.ts +++ b/packages/repo-tools/src/test-utils/extension.ts @@ -6,8 +6,7 @@ import type { Page, } from '@playwright/test'; import { appendFileSync } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; -import { rm } from 'node:fs/promises'; +import { mkdir, rm, readFile, access } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -57,7 +56,7 @@ type Options = { * @param options.extensionPath - The path to the extension dist folder. * @param options.onPageLoad - Optional callback to run after the extension is loaded. Useful for * e.g. waiting for components to be visible before proceeding with a test. - * @returns The extension context, extension ID, popup page, and log file path + * @returns The extension context, extension ID, popup page, log file path, and cleanup function */ export const makeLoadExtension = async ({ contextId, @@ -68,6 +67,7 @@ export const makeLoadExtension = async ({ extensionId: string; popupPage: Page; logFilePath: string; + attachLogs: () => Promise; }> => { const workerIndex = process.env.TEST_WORKER_INDEX ?? '0'; // Use provided contextId or fall back to workerIndex for separate user data dirs @@ -85,11 +85,22 @@ export const makeLoadExtension = async ({ .replace(/[^a-zA-Z0-9-]/gu, '_'); // Make filename-safe const logFilePath = path.join(logsDir, `${runId}-${testTitle}.log`); - // Attach log file path to test results (viewable in Playwright HTML report) - await test.info().attach('console-logs', { - body: logFilePath, - contentType: 'text/plain', - }); + /** + * Attaches the log file to test results. Call this at the end of your test + * to include console logs in the Playwright HTML report. + */ + const attachLogs = async (): Promise => { + try { + await access(logFilePath); + const content = await readFile(logFilePath, 'utf-8'); + await test.info().attach('console-logs', { + body: content, + contentType: 'text/plain', + }); + } catch { + // File doesn't exist, nothing to attach + } + }; const writeLog = (source: string, consoleMessage: ConsoleMessage): void => { const logTimestamp = new Date().toISOString().slice(0, -5); @@ -190,7 +201,7 @@ export const makeLoadExtension = async ({ await popupPage.goto(`chrome-extension://${extensionId}/popup.html`); await onPageLoad(popupPage); - return { browserContext, extensionId, popupPage, logFilePath }; + return { browserContext, extensionId, popupPage, logFilePath, attachLogs }; }; /**