diff --git a/TODOS.md b/TODOS.md index 3675715ac..54afab27f 100644 --- a/TODOS.md +++ b/TODOS.md @@ -514,6 +514,18 @@ coverage fill. Remaining, in rough priority order: post-migration flake data exists (the Codex outside-voice's "green means green is not delivered while paid stays advisory" point — correct, and deliberately a branch-protection decision, not repo YAML). Effort S. +- **P2 — browse daemon lifecycle vs in-suite browsers (top remaining free-suite + flake).** The post-#994 daemon deliberately outlives its parent and lingers + across test FILES in a shard process; a later file's browser use can then + fight it ('[browse] FATAL: Chromium process crashed' + 5s element-wait + timeouts). Receipts: commands+snapshot in one bun process fails identically + WITH and WITHOUT per-file CHROMIUM_PROFILE isolation (pre-existing; PR + #2721 triage), and CI shard 1 on d9b78b5a died at model-overlay-sonnet-5 + after a daemon-spawning file. Per-shard + per-file profile isolation + (landed) removed the cross-shard kills; the intra-shard daemon handoff + needs a real design: tests that spawn the daemon should stop it in + afterAll, or the daemon should detect a foreign CHROMIUM_PROFILE env and + refuse reuse. Effort M. - **P2 — browse daemon /tmp-namespace hardening.** Every file-path transport to the daemon (eval , load-html --from-file, pdf output, upload, cookie-import) assumes client and daemon share one /tmp view; a sandboxed diff --git a/browse/test/batch.test.ts b/browse/test/batch.test.ts index 452f60e08..f8d924f5b 100644 --- a/browse/test/batch.test.ts +++ b/browse/test/batch.test.ts @@ -5,7 +5,10 @@ * newtab/closetab handling, and batch validation. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { startTestServer } from './test-server'; import { BrowserManager } from '../src/browser-manager'; @@ -62,6 +65,24 @@ import { handleMetaCommand } from '../src/meta-commands'; import { handleSnapshot } from '../src/snapshot'; import { READ_COMMANDS, WRITE_COMMANDS } from '../src/commands'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) => _handleReadCommand(cmd, args, b.getActiveSession()); const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) => diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 83ae4707c..762dda674 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -5,7 +5,8 @@ * A real browse server is started and commands are sent via the CLI HTTP interface. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as os from 'os'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { startTestServer } from './test-server'; import { BrowserManager } from '../src/browser-manager'; import { resolveServerScript } from '../src/cli'; @@ -18,6 +19,24 @@ import * as fs from 'fs'; import { spawn } from 'child_process'; import * as path from 'path'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + // Thin wrappers that bridge old test calls (bm as 3rd arg) to new signatures (session + bm) const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) => _handleReadCommand(cmd, args, b.getActiveSession(), b); diff --git a/browse/test/compare-board.test.ts b/browse/test/compare-board.test.ts index 10130d94b..664e3e380 100644 --- a/browse/test/compare-board.test.ts +++ b/browse/test/compare-board.test.ts @@ -10,7 +10,8 @@ * No LLM involved — this is a deterministic functional test. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as os from 'os'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { BrowserManager } from '../src/browser-manager'; import { handleReadCommand as _handleReadCommand } from '../src/read-commands'; import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands'; @@ -23,6 +24,24 @@ import { generateCompareHtml } from '../../design/src/compare'; import * as fs from 'fs'; import * as path from 'path'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + // QUARANTINED (opt-in via GSTACK_COMPARE_BOARD_TESTS=1): all 16 tests fail // identically on origin/main v1.64.1.0, solo, on dev machines — verified per // the blame protocol during the 2026-08 test-infra pass. Main's own CI lane diff --git a/browse/test/content-security.test.ts b/browse/test/content-security.test.ts index 69de52497..823a90294 100644 --- a/browse/test/content-security.test.ts +++ b/browse/test/content-security.test.ts @@ -11,7 +11,8 @@ * 7. Chain security (domain + tab enforcement) */ -import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import * as os from 'os'; +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import { startTestServer } from './test-server'; @@ -25,6 +26,24 @@ import { } from '../src/content-security'; import { generateInstructionBlock } from '../src/cli'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + // Source-level tests const SERVER_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/server.ts'), 'utf-8'); const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8'); diff --git a/browse/test/fill-change-event.test.ts b/browse/test/fill-change-event.test.ts index c11c88e10..6209b67d5 100644 --- a/browse/test/fill-change-event.test.ts +++ b/browse/test/fill-change-event.test.ts @@ -8,11 +8,32 @@ * the framework's own validator still reports a mismatch. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { startTestServer } from './test-server'; import { BrowserManager } from '../src/browser-manager'; import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) => _handleWriteCommand(cmd, args, b.getActiveSession(), b); diff --git a/browse/test/handoff.test.ts b/browse/test/handoff.test.ts index 22d87b3af..bbdee9e51 100644 --- a/browse/test/handoff.test.ts +++ b/browse/test/handoff.test.ts @@ -5,12 +5,33 @@ * Integration tests cover the full handoff flow with real Playwright browsers. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { startTestServer } from './test-server'; import { BrowserManager, type BrowserState } from '../src/browser-manager'; import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands'; import { handleMetaCommand } from '../src/meta-commands'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) => _handleWriteCommand(cmd, args, b.getActiveSession(), b); diff --git a/browse/test/security-live-playwright.test.ts b/browse/test/security-live-playwright.test.ts index 415073977..131f58441 100644 --- a/browse/test/security-live-playwright.test.ts +++ b/browse/test/security-live-playwright.test.ts @@ -20,12 +20,30 @@ * CI). To prime: `bun run browse/src/sidebar-agent.ts` for ~30s and kill it. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { startTestServer } from './test-server'; import { BrowserManager } from '../src/browser-manager'; + +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + import { markHiddenElements, getCleanTextWithStripping, diff --git a/browse/test/session-persist.test.ts b/browse/test/session-persist.test.ts index 88f98a26b..bf7bfa8bd 100644 --- a/browse/test/session-persist.test.ts +++ b/browse/test/session-persist.test.ts @@ -15,7 +15,7 @@ * shutdown, and the gate is BROWSE_PERSIST_STATE (default off). */ -import { describe, test, expect, afterAll } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { canRevokeWrites } from '../../test/helpers/fs-caps'; import * as fs from 'fs'; import * as os from 'os'; @@ -26,6 +26,24 @@ import { } from '../src/session-persist'; import type { BrowserState } from '../src/browser-manager'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-persist-')); afterAll(() => { fs.rmSync(tmpRoot, { recursive: true, force: true }); }); diff --git a/browse/test/snapshot.test.ts b/browse/test/snapshot.test.ts index 85150308a..61a28290e 100644 --- a/browse/test/snapshot.test.ts +++ b/browse/test/snapshot.test.ts @@ -5,7 +5,9 @@ * ref invalidation on navigation, and ref resolution in commands. */ -import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as path from 'path'; +import * as os from 'os'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { startTestServer } from './test-server'; import { BrowserManager } from '../src/browser-manager'; import { handleReadCommand as _handleReadCommand } from '../src/read-commands'; @@ -13,6 +15,24 @@ import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands import { handleMetaCommand } from '../src/meta-commands'; import * as fs from 'fs'; +// Per-FILE Chromium profile: this file launches an in-process persistent +// context (BrowserManager.launch()), and sharing a profile dir with the +// long-lived browse daemon a sibling file may have spawned kills one side's +// Chromium (ProcessSingleton on user-data-dir). Scoped via hooks, never +// module scope (see test/gstack-home-module-scope.test.ts's rationale). +const ORIGINAL_CHROMIUM_PROFILE = process.env.CHROMIUM_PROFILE; +let CHROMIUM_PROFILE_DIR: string | undefined; +beforeAll(() => { + CHROMIUM_PROFILE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-profile-')); + process.env.CHROMIUM_PROFILE = CHROMIUM_PROFILE_DIR; +}); +afterAll(() => { + if (ORIGINAL_CHROMIUM_PROFILE === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = ORIGINAL_CHROMIUM_PROFILE; + if (CHROMIUM_PROFILE_DIR) { try { fs.rmSync(CHROMIUM_PROFILE_DIR, { recursive: true, force: true }); } catch {} } +}); + + const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) => _handleReadCommand(cmd, args, b.getActiveSession(), b); const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>