Merge remote-tracking branch 'origin/main' into ponytail-inspiration-review

# Conflicts:
#	CHANGELOG.md
#	TODOS.md
#	VERSION
#	docs/TESTING_INTERNALS.md
#	package.json
#	scripts/test-free-shards.ts
#	test/helpers/llm-judge.ts
#	test/helpers/touchfiles-data.ts
This commit is contained in:
Garry Tan
2026-08-29 16:11:58 +00:00
173 changed files with 6796 additions and 1346 deletions
+22 -2
View File
@@ -186,12 +186,32 @@ export async function resolveDisconnectCause(browser: Browser | null): Promise<'
}
/**
* Headless `launch()` disconnect handler. Exits 0 on clean user-quit, 1 on
* crash. Inlined into the launch() body via a one-line dispatch so
* Exit-on-disconnect is DAEMON-ONLY semantics. The standalone server
* entrypoint opts in via markDaemonProcess() (under its import.meta.main
* gate, same contract as its signal handlers); embedders — gbrowser
* phoenix, and every test that launches a BrowserManager in-process —
* must never have a Chromium crash process.exit() their HOST. Observed
* live before this flag: a test-launched browser died mid-suite and the
* exit(1) killed the whole bun shard with no terminal summary (the
* truncation class the strict runner exists to catch).
*/
let daemonProcess = false;
export function markDaemonProcess(): void {
daemonProcess = true;
}
/**
* Headless `launch()` disconnect handler. In the standalone daemon: exits 0
* on clean user-quit, 1 on crash. Embedded contexts get the log line only.
* Inlined into the launch() body via a one-line dispatch so
* browser-manager's flow stays grep-friendly.
*/
export async function handleChromiumDisconnect(browser: Browser | null): Promise<void> {
const cause = await resolveDisconnectCause(browser);
if (!daemonProcess) {
console.error(`[browse] Chromium disconnected (${cause}) in an embedded context — host process continues.`);
return;
}
if (cause === 'clean') {
console.error('[browse] Chromium closed cleanly (user-initiated quit). Server exiting (0).');
process.exit(0);
+21
View File
@@ -155,8 +155,29 @@ export function restrictFilePermissions(filePath: string): void {
* (CI = container inherit) inherit the single-user-full ACL — important
* because child creations in `fs.writeFileSync(...)` without explicit
* `restrictFilePermissions` still end up owner-only.
*
* Symlinked dirs are warned about and SKIPPED, never followed: both
* `chmod` and `icacls` dereference the link, so restricting through a
* symlink hardens whatever the link points at — a target the caller never
* vetted (and, with `/inheritance:r`, one we could lock its real owner out
* of). Skipping is best-effort-consistent with the rest of this module:
* the filesystem stays functional, we just don't hit the hardening target.
*/
export function restrictDirectoryPermissions(dirPath: string): void {
try {
if (fs.lstatSync(dirPath).isSymbolicLink()) {
// biome-ignore lint/suspicious/noConsole: intentional user-facing warning
console.warn(
`[gstack] Refusing to restrict permissions through symlink ${dirPath} — skipping.\n` +
` Restricting through a symlink would alter the link target instead. ` +
`Harden the real directory directly.`
);
return;
}
} catch {
// Path doesn't exist (or lstat failed) — fall through; both platform
// branches below already swallow failures on missing paths.
}
if (process.platform === 'win32') {
try {
const user = currentUserPrincipal();
+15 -2
View File
@@ -13,7 +13,7 @@
* Port: random 10000-60000 (or BROWSE_PORT env for debug override)
*/
import { BrowserManager } from './browser-manager';
import { BrowserManager, markDaemonProcess } from './browser-manager';
import { handleReadCommand, hasOutArg } from './read-commands';
import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands';
@@ -812,8 +812,17 @@ function parentWatchdogTick(parentPid: number = BROWSE_PARENT_PID): void {
}
}
}
// Poll cadence. Env-overridable as a test seam: watchdog.test.ts shrinks it
// (250ms) so a free-tier test can observe a real tick deciding on a dead
// parent instead of sleeping through the 15s production cadence. Production
// launchers never set this; unparsable or non-positive values fall back to 15s.
const rawWatchdogIntervalMs = parseInt(process.env.BROWSE_PARENT_WATCHDOG_INTERVAL_MS || '', 10);
const PARENT_WATCHDOG_INTERVAL_MS =
Number.isFinite(rawWatchdogIntervalMs) && rawWatchdogIntervalMs > 0
? rawWatchdogIntervalMs
: 15_000;
if (BROWSE_PARENT_PID > 0 && !IS_HEADED_WATCHDOG) {
setInterval(parentWatchdogTick, 15_000);
setInterval(parentWatchdogTick, PARENT_WATCHDOG_INTERVAL_MS);
} else if (IS_HEADED_WATCHDOG) {
console.log('[browse] Parent-process watchdog disabled (headed mode)');
} else if (BROWSE_PARENT_PID === 0) {
@@ -1385,6 +1394,10 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
// server.ts as a submodule can register their own signal handlers without
// fighting with gstack's. CLI path is unchanged.
if (import.meta.main) {
// Standalone daemon: a Chromium crash must exit THIS process (its
// supervisor/user notices); embedders and in-process test launches must
// never be exited by browser-manager's disconnect handler.
markDaemonProcess();
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
process.on('SIGINT', () => activeShutdown?.());
// SIGHUP (terminal hangup): with handleSIGHUP:false at the three launch
+22 -1
View File
@@ -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) =>
+12 -2
View File
@@ -342,8 +342,15 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
it('timeout fires, exit code 124, token revoked', async () => {
const dir = makeSkillDir(tiers.bundled, 'sleeper',
'name: sleeper\nhost: x.com\ntrusted: true',
// Sleep longer than the test timeout; the spawn should kill us.
`await new Promise(r => setTimeout(r, 30000)); console.log("done");`,
// The child's self-lifetime is a bound, not a wait — the test blocks
// only for the 1s spawn timeout that kills it. 8s is sized to be far
// above that 1s (the kill always lands first) but below this test's
// 10s ceiling: if the timeout-kill ever regresses, the child completes,
// prints "done", and the assertions below fail cleanly in-budget
// instead of the test opaquely timing out while the child lingers.
// (runToFiles gives skill children no stdin pipe, so a parent-death
// EOF lifetime isn't available here — self-timing is required.)
`await new Promise(r => setTimeout(r, 8000)); console.log("done");`,
);
const skill = readBrowserSkill('sleeper', tiers)!;
const result = await spawnSkill({
@@ -351,6 +358,9 @@ describe.skipIf(SKIP_SPAWN)('spawnSkill: lifecycle', () => {
});
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(124);
// The kill must land before the script completes — "done" ever appearing
// means the child outlived its timeout.
expect(result.stdout).not.toContain('done');
expect(listTokens().filter(t => t.clientId.startsWith('skill:sleeper:'))).toEqual([]);
}, 10_000);
+5 -2
View File
@@ -24,14 +24,15 @@ const TMP_HOME = path.join(os.tmpdir(), `gstack-cdp-e2e-${process.pid}-${Date.no
// which then got baked into artifacts that outlived it (dangling symlinks
// into a deleted render dir). Save + restore in afterAll.
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_TELEMETRY_OFF = '1'; // don't pollute analytics during tests
const ORIGINAL_TELEMETRY_OFF = process.env.GSTACK_TELEMETRY_OFF;
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
let baseUrl: string;
beforeAll(async () => {
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_TELEMETRY_OFF = '1'; // don't pollute analytics during tests
await fs.rm(TMP_HOME, { recursive: true, force: true });
await fs.mkdir(TMP_HOME, { recursive: true });
testServer = startTestServer(0);
@@ -44,6 +45,8 @@ beforeAll(async () => {
afterAll(async () => {
if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
if (ORIGINAL_TELEMETRY_OFF === undefined) delete process.env.GSTACK_TELEMETRY_OFF;
else process.env.GSTACK_TELEMETRY_OFF = ORIGINAL_TELEMETRY_OFF;
try { await bm.cleanup?.(); } catch {}
try { testServer.server.stop(); } catch {}
await fs.rm(TMP_HOME, { recursive: true, force: true });
+20 -1
View File
@@ -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';
@@ -26,6 +27,24 @@ import * as os from 'os';
const tmpp = (name: string) => path.join(os.tmpdir(), name);
// 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);
+20 -1
View File
@@ -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
+20 -1
View File
@@ -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');
+12 -2
View File
@@ -17,8 +17,12 @@ import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
const TMP_HOME = path.join(os.tmpdir(), `gstack-domain-e2e-${process.pid}-${Date.now()}`);
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_PROJECT_SLUG = 'e2e-test-slug';
// Scoped to this file's execution window — module-scope env assignment
// leaks into sibling files in the shard process (see
// test/gstack-home-module-scope.test.ts).
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
const ORIGINAL_PROJECT_SLUG = process.env.GSTACK_PROJECT_SLUG;
let testServer: ReturnType<typeof startTestServer>;
let bm: BrowserManager;
@@ -32,6 +36,8 @@ async function fakeBodyPipe(body: string): Promise<string> {
}
beforeAll(async () => {
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_PROJECT_SLUG = 'e2e-test-slug';
await fs.rm(TMP_HOME, { recursive: true, force: true });
await fs.mkdir(path.join(TMP_HOME, 'projects', 'e2e-test-slug'), { recursive: true });
testServer = startTestServer(0);
@@ -41,6 +47,10 @@ beforeAll(async () => {
});
afterAll(async () => {
if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
if (ORIGINAL_PROJECT_SLUG === undefined) delete process.env.GSTACK_PROJECT_SLUG;
else process.env.GSTACK_PROJECT_SLUG = ORIGINAL_PROJECT_SLUG;
try { await bm.cleanup?.(); } catch {}
try { testServer.server.stop(); } catch {}
await fs.rm(TMP_HOME, { recursive: true, force: true });
+14 -2
View File
@@ -1,10 +1,22 @@
import { describe, it, expect, beforeEach } from 'bun:test';
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
const TMP_HOME = path.join(os.tmpdir(), `gstack-test-${process.pid}-${Date.now()}`);
process.env.GSTACK_HOME = TMP_HOME;
// Scoped to this file's execution window — module-scope env assignment
// leaks into sibling files in the shard process (see
// test/gstack-home-module-scope.test.ts). freshImport() below runs inside
// tests, so the beforeAll value is what ../src/domain-skills reads.
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
beforeAll(() => {
process.env.GSTACK_HOME = TMP_HOME;
});
afterAll(() => {
if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
});
// Re-import after env var set so module reads updated GSTACK_HOME
async function freshImport() {
+2 -10
View File
@@ -190,11 +190,7 @@ describe('background.js onMessage listener (behavioral)', () => {
expect(r.response!.error).toBeUndefined();
});
// QUARANTINED (pre-existing): fails identically on origin/main v1.64.1.0,
// solo, on dev machines (blame protocol, 2026-08 test-infra pass). Main's
// CI lane skip-lists this whole FILE; we quarantine only this test so the
// rest keeps guarding. Un-skip when the underlying env dependency is fixed.
test.skip('own content script: every privileged type is denied with no token/port fields', () => {
test('own content script: every privileged type is denied with no token/port fields', () => {
for (const type of PRIVILEGED) {
const r = dispatch(listener, { type }, CONTENT_SCRIPT_SENDER);
expect(r.responded).toBe(true); // the gate answers, it does not go silent
@@ -208,11 +204,7 @@ describe('background.js onMessage listener (behavioral)', () => {
}
});
// QUARANTINED (pre-existing): fails identically on origin/main v1.64.1.0,
// solo, on dev machines (blame protocol, 2026-08 test-infra pass). Main's
// CI lane skip-lists this whole FILE; we quarantine only this test so the
// rest keeps guarding. Un-skip when the underlying env dependency is fixed.
test.skip('missing sender.url: every privileged type is denied', () => {
test('missing sender.url: every privileged type is denied', () => {
for (const type of PRIVILEGED) {
const r = dispatch(listener, { type }, NO_URL_SENDER);
expect(r.responded).toBe(true);
+46
View File
@@ -9,6 +9,11 @@
* we verify the helper doesn't throw and the file ends up accessible
* to the current user — the "doesn't crash, file still usable"
* contract the callers rely on.
* - Every `mode & 0o777` bitmask assertion is platform-guarded: Windows
* fakes POSIX mode bits (chmod is ~a no-op; dirs stat as 0o777), so a
* bitmask expectation on win32 tests the runner, not our code. Symlink
* fixtures are created in try/catch — Windows runners without Developer
* Mode / admin can't create symlinks, and the test skips gracefully.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
@@ -107,6 +112,47 @@ describe('restrictDirectoryPermissions', () => {
expect(() => restrictDirectoryPermissions(d)).not.toThrow();
});
test('warns and skips a symlinked dir without throwing', () => {
const real = path.join(tmpDir, 'real-target');
fs.mkdirSync(real);
if (process.platform !== 'win32') {
// chmod, not mkdir({ mode }), so a restrictive umask can't skew the
// starting bits we later assert were left untouched.
fs.chmodSync(real, 0o755);
}
const link = path.join(tmpDir, 'linked');
try {
fs.symlinkSync(real, link, 'dir');
} catch {
// Windows runners without Developer Mode / admin can't create
// symlinks (house pattern: security-audit-r2.test.ts skips the same
// way). Nothing to test without the link.
// biome-ignore lint/suspicious/noConsole: test-skip diagnostics
console.warn('Skipping: symlink creation failed (no symlink privilege)');
return;
}
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); };
try {
expect(() => restrictDirectoryPermissions(link)).not.toThrow();
} finally {
console.warn = originalWarn;
}
expect(warnings.some((w) => w.includes('symlink'))).toBe(true);
// The skip must leave the link target untouched. Mode bits are only
// meaningful on POSIX — Windows fakes stat().mode (dirs report 0o777
// no matter what), so asserting 0o755 there fails on runner semantics,
// not on our behavior. The no-throw + warn + still-usable checks are
// the meaningful win32 contract.
if (process.platform !== 'win32') {
expect(fs.statSync(real).mode & 0o777).toBe(0o755);
}
expect(() => fs.readdirSync(real)).not.toThrow();
});
test('on Windows, the directory stays usable by the calling process', () => {
if (process.platform !== 'win32') return;
const d = path.join(tmpDir, 'still-usable');
+22 -1
View File
@@ -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);
+22 -1
View File
@@ -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);
+294
View File
@@ -0,0 +1,294 @@
/**
* Unit tests for browse/src/media-extract.ts the media-discovery logic
* shared by the `media` and `scrape` commands.
*
* All of extractMedia's logic lives inside the page.evaluate() callback.
* Playwright serializes that callback to the browser, but the function itself
* is pure over the DOM globals it touches (`document`, `getComputedStyle`),
* so instead of exporting internals or launching a browser these tests pass a
* fake target whose evaluate() invokes the real callback in-process against a
* minimal mock DOM. No product code was modified.
*
* Globals are installed/restored inside each run (never at module scope) so
* nothing leaks to sibling test files sharing this shard process.
*/
import { describe, test, expect } from 'bun:test';
import { extractMedia, type MediaResult } from '../src/media-extract';
type Dom = Record<string, any[]>;
/** querySelector/querySelectorAll over a selector → elements map. */
function queryable(map: Dom) {
return {
querySelectorAll: (sel: string) => map[sel] ?? [],
querySelector: (sel: string) => (map[sel] ?? [])[0] ?? null,
};
}
const VISIBLE_RECT = { width: 100, height: 50, bottom: 400, right: 300 };
const HIDDEN_RECT = { width: 0, height: 0, bottom: 0, right: 0 };
function imgEl(overrides: Record<string, unknown> = {}, attrs: Record<string, string> = {}, rect = VISIBLE_RECT) {
return {
src: '', srcset: '', currentSrc: '', alt: '',
width: 0, height: 0, naturalWidth: 0, naturalHeight: 0, loading: '',
getAttribute: (name: string) => attrs[name] ?? null,
getBoundingClientRect: () => rect,
...overrides,
};
}
function videoEl(overrides: Record<string, unknown> = {}, sources: Array<{ src?: string; type?: string }> = []) {
return {
src: '', currentSrc: '', poster: '',
videoWidth: 0, width: 0, videoHeight: 0, height: 0, duration: 0,
querySelectorAll: (sel: string) => (sel === 'source' ? sources : []),
...overrides,
};
}
function audioEl(overrides: Record<string, unknown> = {}, source: { src?: string; type?: string } | null = null) {
return {
src: '', currentSrc: '', duration: 0,
querySelector: (sel: string) => (sel === 'source' ? source : null),
...overrides,
};
}
/** An element visible only to the background-image pass (`*` + getComputedStyle). */
function bgEl(backgroundImage: string, opts: { tagName?: string; id?: string; className?: unknown } = {}) {
return {
tagName: opts.tagName ?? 'DIV',
id: opts.id ?? '',
className: opts.className ?? '',
__backgroundImage: backgroundImage,
};
}
/**
* Run the REAL extractMedia against a mock document. The fake target's
* evaluate() calls the callback with its argument, exactly as Playwright does
* in the browser resolving `document`/`getComputedStyle` to our shims.
*/
async function extract(
dom: Dom,
options?: Parameters<typeof extractMedia>[1],
): Promise<MediaResult> {
const g = globalThis as any;
const savedDocument = g.document;
const savedGcs = g.getComputedStyle;
g.document = queryable(dom);
g.getComputedStyle = (el: any) => ({ backgroundImage: el.__backgroundImage ?? 'none' });
try {
const target = { evaluate: (fn: any, arg: any) => Promise.resolve(fn(arg)) } as any;
return await extractMedia(target, options);
} finally {
g.document = savedDocument;
g.getComputedStyle = savedGcs;
}
}
describe('extractMedia: images', () => {
test('collects attributes, dimensions, and the lazy-load data-src fallback chain', async () => {
const result = await extract({
img: [
imgEl({
src: 'https://cdn.example.com/hero.jpg',
srcset: 'hero-2x.jpg 2x',
currentSrc: 'https://cdn.example.com/hero-2x.jpg',
alt: 'Hero',
width: 640, height: 480, naturalWidth: 1280, naturalHeight: 960,
loading: 'lazy',
}, { 'data-lazy-src': 'lazy.jpg' }),
],
});
expect(result.images).toHaveLength(1);
const img = result.images[0];
expect(img.index).toBe(0);
expect(img.src).toBe('https://cdn.example.com/hero.jpg');
expect(img.srcset).toBe('hero-2x.jpg 2x');
expect(img.currentSrc).toBe('https://cdn.example.com/hero-2x.jpg');
expect(img.alt).toBe('Hero');
expect(img.naturalWidth).toBe(1280);
expect(img.loading).toBe('lazy');
// No data-src → falls through to data-lazy-src.
expect(img.dataSrc).toBe('lazy.jpg');
expect(img.visible).toBe(true);
expect(result.total).toBe(1);
});
test('data-src wins over the later fallbacks, data-original is last', async () => {
const first = await extract({ img: [imgEl({}, { 'data-src': 'a.jpg', 'data-lazy-src': 'b.jpg', 'data-original': 'c.jpg' })] });
expect(first.images[0].dataSrc).toBe('a.jpg');
const last = await extract({ img: [imgEl({}, { 'data-original': 'c.jpg' })] });
expect(last.images[0].dataSrc).toBe('c.jpg');
const none = await extract({ img: [imgEl()] });
expect(none.images[0].dataSrc).toBe('');
});
test('a zero-size or fully offscreen rect marks the image not visible', async () => {
const result = await extract({
img: [
imgEl({}, {}, HIDDEN_RECT),
// Above/left of the viewport: bottom and right are negative.
imgEl({}, {}, { width: 10, height: 10, bottom: -5, right: -5 }),
imgEl({}, {}, VISIBLE_RECT),
],
});
expect(result.images.map(index => index.visible)).toEqual([false, false, true]);
});
});
describe('extractMedia: videos', () => {
test('detects HLS from either the mime type or an .m3u8 source URL', async () => {
const result = await extract({
video: [
videoEl({}, [{ src: 'https://v.example.com/stream.m3u8', type: '' }]),
videoEl({}, [{ src: 'https://v.example.com/stream', type: 'application/x-mpegURL' }]),
videoEl({ src: 'plain.mp4' }, [{ src: 'plain.mp4', type: 'video/mp4' }]),
],
});
expect(result.videos.map(v => v.isHLS)).toEqual([true, true, false]);
expect(result.videos[2].type).toBe('video/mp4');
});
test('detects DASH from either the mime type or an .mpd source URL', async () => {
const result = await extract({
video: [
videoEl({}, [{ src: 'https://v.example.com/manifest.mpd', type: '' }]),
videoEl({}, [{ src: 'https://v.example.com/manifest', type: 'application/dash+xml' }]),
],
});
expect(result.videos.map(v => v.isDASH)).toEqual([true, true]);
});
test('an Infinity duration (live stream) is reported as 0; intrinsic size beats attributes', async () => {
const result = await extract({
video: [videoEl({ duration: Infinity, videoWidth: 1920, width: 640, videoHeight: 1080, height: 360 })],
});
expect(result.videos[0].duration).toBe(0);
expect(result.videos[0].width).toBe(1920);
expect(result.videos[0].height).toBe(1080);
});
test('collects every <source> child with src and type', async () => {
const sources = [
{ src: 'a.webm', type: 'video/webm' },
{ src: 'a.mp4', type: 'video/mp4' },
];
const result = await extract({ video: [videoEl({ poster: 'poster.jpg' }, sources)] });
expect(result.videos[0].sources).toEqual(sources);
expect(result.videos[0].poster).toBe('poster.jpg');
expect(result.videos[0].type).toBe('video/webm'); // first source's type
});
});
describe('extractMedia: audio', () => {
test('falls back to the <source> child when the element has no src, NaN duration → 0', async () => {
const result = await extract({
audio: [audioEl({ duration: NaN }, { src: 'track.ogg', type: 'audio/ogg' })],
});
expect(result.audio[0].src).toBe('track.ogg');
expect(result.audio[0].type).toBe('audio/ogg');
expect(result.audio[0].duration).toBe(0);
});
test('element src wins over the source child', async () => {
const result = await extract({
audio: [audioEl({ src: 'direct.mp3', duration: 12.5 }, { src: 'child.ogg', type: 'audio/ogg' })],
});
expect(result.audio[0].src).toBe('direct.mp3');
expect(result.audio[0].duration).toBe(12.5);
});
});
describe('extractMedia: CSS background images', () => {
test('parses url(...) in quoted and unquoted forms, skipping none and data: URIs', async () => {
const result = await extract({
'*': [
bgEl('url("https://cdn.example.com/bg.png")'),
bgEl("url('https://cdn.example.com/bg2.png')"),
bgEl('url(https://cdn.example.com/bg3.png)'),
bgEl('none'),
bgEl('url(data:image/png;base64,AAAA)'),
],
});
expect(result.backgroundImages.map(b => b.url)).toEqual([
'https://cdn.example.com/bg.png',
'https://cdn.example.com/bg2.png',
'https://cdn.example.com/bg3.png',
]);
expect(result.backgroundImages.map(b => b.index)).toEqual([0, 1, 2]);
});
test('builds a tag#id.class selector; a non-string className (SVG) contributes no class part', async () => {
const result = await extract({
'*': [
bgEl('url(a.png)', { tagName: 'SECTION', id: 'hero', className: ' banner large ' }),
bgEl('url(b.png)', { tagName: 'SVG', className: { baseVal: 'svg-class' } }),
],
});
expect(result.backgroundImages[0].selector).toBe('section#hero.banner.large');
expect(result.backgroundImages[0].element).toBe('section');
expect(result.backgroundImages[1].selector).toBe('svg');
});
test('caps background-image extraction at 500 elements', async () => {
const many = Array.from({ length: 520 }, (_, i) => bgEl(`url(bg-${i}.png)`));
const result = await extract({ '*': many });
expect(result.backgroundImages).toHaveLength(500);
expect(result.backgroundImages[499].url).toBe('bg-499.png');
expect(result.total).toBe(500);
});
});
describe('extractMedia: filter and scope options', () => {
const FULL_DOM: Dom = {
img: [imgEl({ src: 'i.png' })],
video: [videoEl({ src: 'v.mp4' })],
audio: [audioEl({ src: 'a.mp3' })],
'*': [bgEl('url(bg.png)')],
};
test('no filter returns every category and total sums them', async () => {
const result = await extract(FULL_DOM);
expect(result.images).toHaveLength(1);
expect(result.videos).toHaveLength(1);
expect(result.audio).toHaveLength(1);
expect(result.backgroundImages).toHaveLength(1);
expect(result.total).toBe(4);
});
test("filter: 'videos' excludes images, audio, and background images", async () => {
const result = await extract(FULL_DOM, { filter: 'videos' });
expect(result.videos).toHaveLength(1);
expect(result.images).toEqual([]);
expect(result.audio).toEqual([]);
expect(result.backgroundImages).toEqual([]);
expect(result.total).toBe(1);
});
test("filter: 'images' includes background images (they are image media)", async () => {
const result = await extract(FULL_DOM, { filter: 'images' });
expect(result.images).toHaveLength(1);
expect(result.backgroundImages).toHaveLength(1);
expect(result.videos).toEqual([]);
expect(result.audio).toEqual([]);
expect(result.total).toBe(2);
});
test('a selector scopes extraction to the matching subtree', async () => {
const scoped = {
...queryable({ img: [imgEl({ src: 'scoped.png' })] }),
};
const result = await extract({ img: [imgEl({ src: 'global.png' })], '#gallery': [scoped] }, { selector: '#gallery' });
expect(result.images).toHaveLength(1);
expect(result.images[0].src).toBe('scoped.png');
});
test('a selector matching nothing falls back to the whole document', async () => {
const result = await extract({ img: [imgEl({ src: 'global.png' })] }, { selector: '#missing' });
expect(result.images).toHaveLength(1);
expect(result.images[0].src).toBe('global.png');
});
});
+19 -1
View File
@@ -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,
+159
View File
@@ -0,0 +1,159 @@
/**
* Unit tests for browse/src/session-cookie-store.ts the factory behind
* pty-session-cookie.ts and sse-session-cookie.ts.
*
* sse-session-cookie.test.ts pins the SSE instantiation (flags, entropy,
* cross-endpoint isolation). This file tests the FACTORY's own contract with
* custom options the instantiations never vary: the cookieName knob in
* extract/buildSetCookie, the ttlMs knob in expiry and Max-Age, the
* maxSessions hard cap, and isolation between independently created stores.
*
* The store is purely in-memory (a Map keyed by token) there is no on-disk
* state, so no temp dirs or permission cases apply.
*/
import { describe, test, expect } from 'bun:test';
import { createSessionCookieStore } from '../src/session-cookie-store';
const NAME = 'gstack_test_session';
function makeStore(opts: Partial<Parameters<typeof createSessionCookieStore>[0]> = {}) {
return createSessionCookieStore({ cookieName: NAME, ttlMs: 60_000, ...opts });
}
function requestWithCookies(cookieHeader: string | null): Request {
return new Request('http://127.0.0.1/sse', {
headers: cookieHeader === null ? {} : { cookie: cookieHeader },
});
}
describe('session-cookie-store: mint + validate round-trip', () => {
test('a minted token validates until revoked', () => {
const store = makeStore();
const { token, expiresAt } = store.mint();
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); // 32 bytes base64url, no padding
expect(expiresAt).toBeGreaterThan(Date.now());
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 60_000);
expect(store.validate(token)).toBe(true);
store.revoke(token);
expect(store.validate(token)).toBe(false);
});
test('unknown, null, undefined, and empty tokens never validate', () => {
const store = makeStore();
store.mint();
expect(store.validate('forged-token')).toBe(false);
expect(store.validate(null)).toBe(false);
expect(store.validate(undefined)).toBe(false);
expect(store.validate('')).toBe(false);
});
test('revoke of an unknown/null token is a no-op, not an error', () => {
const store = makeStore();
const { token } = store.mint();
expect(() => store.revoke('never-minted')).not.toThrow();
expect(() => store.revoke(null)).not.toThrow();
expect(() => store.revoke(undefined)).not.toThrow();
expect(store.validate(token)).toBe(true); // untouched
});
test('a token expires after ttlMs and validate deletes it', async () => {
const store = makeStore({ ttlMs: 5 });
const { token, expiresAt } = store.mint();
expect(expiresAt - Date.now()).toBeLessThanOrEqual(5);
await new Promise(resolve => setTimeout(resolve, 25));
expect(store.validate(token)).toBe(false);
expect(store.validate(token)).toBe(false); // still gone after deletion
});
test('two stores are fully isolated — a token minted in one never validates in the other', () => {
const a = makeStore();
const b = makeStore();
const { token } = a.mint();
expect(b.validate(token)).toBe(false);
expect(a.validate(token)).toBe(true);
});
test('__reset clears every session', () => {
const store = makeStore();
const first = store.mint().token;
const second = store.mint().token;
store.__reset();
expect(store.validate(first)).toBe(false);
expect(store.validate(second)).toBe(false);
});
});
describe('session-cookie-store: maxSessions hard cap', () => {
test('minting past the cap evicts the oldest sessions', () => {
const store = makeStore({ maxSessions: 3 });
const tokens = Array.from({ length: 5 }, () => store.mint().token);
// Insertion order eviction: the two oldest are gone, the newest three live.
expect(store.validate(tokens[0])).toBe(false);
expect(store.validate(tokens[1])).toBe(false);
expect(store.validate(tokens[2])).toBe(true);
expect(store.validate(tokens[3])).toBe(true);
expect(store.validate(tokens[4])).toBe(true);
});
});
describe('session-cookie-store: extract (cookie header parsing)', () => {
test('finds the configured cookie among others, with surrounding whitespace', () => {
const store = makeStore();
const req = requestWithCookies(`other=1; ${NAME}=tok-value ; trailing=2`);
// Each `name=value` part is trimmed as a whole before splitting.
expect(store.extract(req)).toBe('tok-value');
});
test('a cookie value containing = survives intact', () => {
const store = makeStore();
const req = requestWithCookies(`${NAME}=abc=def==`);
expect(store.extract(req)).toBe('abc=def==');
});
test('only the EXACT cookie name matches — no prefix/suffix confusion', () => {
const store = makeStore();
expect(store.extract(requestWithCookies(`x${NAME}=evil`))).toBeNull();
expect(store.extract(requestWithCookies(`${NAME}x=evil`))).toBeNull();
});
test('missing header and empty value both yield null', () => {
const store = makeStore();
expect(store.extract(requestWithCookies(null))).toBeNull();
expect(store.extract(requestWithCookies(`${NAME}=`))).toBeNull();
expect(store.extract(requestWithCookies('unrelated=1'))).toBeNull();
});
test('two stores with different cookie names read different cookies from one header', () => {
const ptyLike = createSessionCookieStore({ cookieName: 'pty_session', ttlMs: 1000 });
const sseLike = createSessionCookieStore({ cookieName: 'sse_session', ttlMs: 1000 });
const req = requestWithCookies('pty_session=pty-tok; sse_session=sse-tok');
expect(ptyLike.extract(req)).toBe('pty-tok');
expect(sseLike.extract(req)).toBe('sse-tok');
});
});
describe('session-cookie-store: buildSetCookie', () => {
test('emits the exact security flags with Max-Age derived from ttlMs', () => {
const store = makeStore({ ttlMs: 90_500 }); // floor(90.5s) = 90
expect(store.buildSetCookie('tok123')).toBe(
`${NAME}=tok123; HttpOnly; SameSite=Strict; Path=/; Max-Age=90`,
);
});
test('never emits Secure — the daemon serves plain HTTP on loopback', () => {
const store = makeStore();
expect(store.buildSetCookie('t')).not.toContain('Secure');
});
test('a minted token round-trips: Set-Cookie → request header → extract → validate', () => {
const store = makeStore();
const { token } = store.mint();
const setCookie = store.buildSetCookie(token);
// The browser echoes back only the name=value pair.
const pair = setCookie.split(';')[0];
const req = requestWithCookies(pair);
const extracted = store.extract(req);
expect(extracted).toBe(token);
expect(store.validate(extracted)).toBe(true);
});
});
+19 -1
View File
@@ -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 }); });
+24 -16
View File
@@ -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) =>
@@ -222,11 +242,7 @@ describe('Ref staleness detection', () => {
expect(bm.getRefCount()).toBeGreaterThan(0);
});
// QUARANTINED (pre-existing): fails identically on origin/main v1.64.1.0,
// solo, on dev machines (blame protocol, 2026-08 test-infra pass). Main's
// CI lane skip-lists this whole FILE; we quarantine only this test so the
// rest keeps guarding. Un-skip when the underlying env dependency is fixed.
test.skip('stale ref after DOM removal gives descriptive error', async () => {
test('stale ref after DOM removal gives descriptive error', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
// Find a button ref
@@ -276,11 +292,7 @@ describe('Snapshot diff', () => {
expect(result).toContain('baseline');
});
// QUARANTINED (pre-existing): fails identically on origin/main v1.64.1.0,
// solo, on dev machines (blame protocol, 2026-08 test-infra pass). Main's
// CI lane skip-lists this whole FILE; we quarantine only this test so the
// rest keeps guarding. Un-skip when the underlying env dependency is fixed.
test.skip('snapshot -D shows diff after change', async () => {
test('snapshot -D shows diff after change', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
// Take first snapshot
await handleMetaCommand('snapshot', [], bm, shutdown);
@@ -367,11 +379,7 @@ describe('Annotated screenshots', () => {
if (fs.existsSync(screenshotPath)) fs.unlinkSync(screenshotPath);
});
// QUARANTINED (pre-existing): fails identically on origin/main v1.64.1.0,
// solo, on dev machines (blame protocol, 2026-08 test-infra pass). Main's
// CI lane skip-lists this whole FILE; we quarantine only this test so the
// rest keeps guarding. Un-skip when the underlying env dependency is fixed.
test.skip('annotation overlays are cleaned up', async () => {
test('annotation overlays are cleaned up', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
await handleMetaCommand('snapshot', ['-a'], bm, shutdown);
// Check that overlays are removed
+10 -1
View File
@@ -109,7 +109,16 @@ describe('stop --force-restart on a LIVE daemon', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stop-force-'));
const stateFile = path.join(tmpDir, 'browse.json');
// Portable long-lived child standing in for the wedged daemon process.
const wedged = spawn('bun', ['-e', 'await Bun.sleep(300000)'], { stdio: 'ignore' });
// Its lifetime is tied to this test process instead of a fixed sleep: it
// blocks until its stdin (a pipe we hold open) hits EOF. That means it
// can never self-exit mid-test — which would let the "pid is dead"
// assertion below pass without the CLI having killed anything — and it
// reaps itself the moment the test process dies, even on a hard kill
// where the finally block never runs.
const wedged = spawn('bun', ['-e',
"process.stdin.resume(); const bye = () => process.exit(0); "
+ "process.stdin.on('end', bye); process.stdin.on('error', bye); process.stdin.on('close', bye);",
], { stdio: ['pipe', 'ignore', 'ignore'] });
try {
const port = await closedPort();
fs.writeFileSync(stateFile, JSON.stringify({
+16 -3
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterAll } from 'bun:test';
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -8,8 +8,21 @@ const TELEMETRY_FILE = path.join(TMP_HOME, 'analytics', 'browse-telemetry.jsonl'
// Use GSTACK_HOME env to redirect telemetry writes (read each call,
// not cached at module-load).
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_TELEMETRY_OFF = '0';
// Scoped to this file's execution window — module-scope env assignment
// leaks into sibling files in the shard process (see
// test/gstack-home-module-scope.test.ts).
const ORIGINAL_GSTACK_HOME = process.env.GSTACK_HOME;
const ORIGINAL_TELEMETRY_OFF = process.env.GSTACK_TELEMETRY_OFF;
beforeAll(() => {
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_TELEMETRY_OFF = '0';
});
afterAll(() => {
if (ORIGINAL_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIGINAL_GSTACK_HOME;
if (ORIGINAL_TELEMETRY_OFF === undefined) delete process.env.GSTACK_TELEMETRY_OFF;
else process.env.GSTACK_TELEMETRY_OFF = ORIGINAL_TELEMETRY_OFF;
});
beforeEach(async () => {
await fs.rm(TMP_HOME, { recursive: true, force: true });
@@ -44,9 +44,16 @@ describe('terminal-agent owner lifecycle', () => {
// process.execPath (the running bun) instead of `sleep`: coreutils are
// not guaranteed on a bare windows-latest runner, and this test is on the
// Windows CI curated list — the owner-orphan leak it pins is a Windows bug.
// The owner's lifetime is tied to this test process instead of a fixed
// 30s sleep: it blocks until its stdin (a pipe we hold open) hits EOF, so
// it is guaranteed alive until the SIGTERM below no matter how slow the
// runner is, and it reaps itself if the test process dies without running
// afterEach. Node-compatible stdin APIs, not Bun.stdin — Windows-portable.
const owner = Bun.spawn(
[process.execPath, '-e', 'await Bun.sleep(30000)'],
{ stdio: ['ignore', 'ignore', 'ignore'] },
[process.execPath, '-e',
"process.stdin.resume(); const bye = () => process.exit(0); "
+ "process.stdin.on('end', bye); process.stdin.on('error', bye); process.stdin.on('close', bye);"],
{ stdio: ['pipe', 'ignore', 'ignore'] },
);
spawned.push(owner);
const agent = Bun.spawn(['bun', 'run', AGENT_SCRIPT], {
+29 -11
View File
@@ -27,8 +27,10 @@ import { resolveConfig } from '../src/config';
// seam as idleCheckTick) and tunnelActive is simulated via setTunnelActive.
//
// Each test spawns the real server.ts. Tests 1 and 2 verify behavior via
// stdout log line (fast). Test 3 waits for the watchdog poll cycle to confirm
// the server REMAINS alive after parent death (slow — ~20s observation window).
// stdout log line (fast). Test 3 shrinks the poll cadence via
// BROWSE_PARENT_WATCHDOG_INTERVAL_MS (test seam in server.ts), waits for the
// tick's one-time "parent exited (server stays alive" log to prove a tick
// observed the death, then confirms the server REMAINS alive.
const ROOT = path.resolve(import.meta.dir, '..');
const SERVER_SCRIPT = path.join(ROOT, 'src', 'server.ts');
@@ -139,21 +141,37 @@ describe('parent-process watchdog (v0.18.1.0)', () => {
const parentPid = parentProc.pid!;
// Default headless: no BROWSE_HEADED, real parent PID — watchdog active.
serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid) }, 34903);
// The poll cadence is shrunk via the server's env seam (250ms instead of
// the production 15s) so observing a real tick doesn't cost a 20s sleep.
serverProc = spawnServer({
BROWSE_PARENT_PID: String(parentPid),
BROWSE_PARENT_WATCHDOG_INTERVAL_MS: '250',
}, 34903);
const serverPid = serverProc.pid!;
// Give the server a moment to start and register the watchdog interval.
await Bun.sleep(2000);
// Startup barrier: poll stdout for the listen line instead of a fixed 2s
// sleep. The watchdog interval is registered at module load, before this
// line prints, so once we see it the ticks are running.
const bootOut = await readStdoutUntil(serverProc, 'Server running on', 15_000);
expect(bootOut).toContain('Server running on');
expect(isProcessAlive(serverPid)).toBe(true);
// Kill the parent. The watchdog polls every 15s, so first tick after
// parent death lands within ~15s. Pre-#994 the server would shutdown
// here. Post-#994 the server logs the parent exit and stays alive.
// Kill the parent, then wait for a tick to OBSERVE the death: the
// stay-alive branch logs a one-time latched line. Seeing it proves a tick
// ran after parent death and chose NOT to shut down — pre-#994 the same
// tick called shutdown instead. (Await exited first so the PID is reaped
// and the tick's kill(pid, 0) probe sees ESRCH, not a zombie.)
parentProc.kill('SIGKILL');
await parentProc.exited;
const marker = `Parent process ${parentPid} exited (server stays alive`;
const out = await readStdoutUntil(serverProc, marker, 15_000);
expect(out).toContain(marker);
expect(out).not.toContain('shutting down');
// Wait long enough for at least one watchdog tick (15s) plus margin.
// Server should still be alive — that's the whole point of #994.
await Bun.sleep(20_000);
// Let several more ticks land (4+ at 250ms — the old fixed 20s sleep
// covered ~1 production tick) and confirm the server is still alive —
// that's the whole point of #994.
await Bun.sleep(1_000);
expect(isProcessAlive(serverPid)).toBe(true);
}, 45_000);
});