Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,15 +803,14 @@ export class BrowserManager {
this.tabSessions.delete(tabId);
this.tabOwnership.delete(tabId);

// Switch to another tab if we closed the active one
if (tabId === this.activeTabId) {
// Never leave zero tabs, regardless of which tab was closed — activeTabId
// can point at an already-removed id, and the old active-only guard then
// skipped auto-create when the last tab closed.
if (this.pages.size === 0) {
await this.newTab();
} else if (tabId === this.activeTabId) {
const remaining = [...this.pages.keys()];
if (remaining.length > 0) {
this.activeTabId = remaining[remaining.length - 1];
} else {
// No tabs left — create a new blank one
await this.newTab();
}
this.activeTabId = remaining[remaining.length - 1];
}
}

Expand Down
18 changes: 17 additions & 1 deletion browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ export interface ServerConfig {
* reference is the PID record + the file paths.
*/
ownsTerminalAgent?: boolean;
/**
* Process-exit seam. shutdown() calls this at the very end of its async
* teardown; defaults to process.exit. In-process test runs inject a
* recording stub here — the fire-and-forget shutdown path otherwise races
* per-test process.exit stubs, and a straggler real exit(0) truncates the
* whole bun test run with a green exit code and no failure tally.
*/
exitFn?: (code?: number) => void;
}

/**
Expand Down Expand Up @@ -664,6 +672,11 @@ export const __testInternals__ = {
// need shutdown to fire. Without this, the second test's shutdown
// returns early at the `if (isShuttingDown) return;` guard.
resetShutdownState: () => { isShuttingDown = false; },
// Stop the module-level 60s idle interval. In-process test runs import this
// module once for the whole suite; the interval otherwise outlives the test
// file and can fire a real process.exit(0) mid-suite, silently truncating
// the run with a green exit code.
stopIdleTimer: () => { clearInterval(idleCheckInterval); },
};

// ─── Parent-Process Watchdog ────────────────────────────────────────
Expand Down Expand Up @@ -1489,6 +1502,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// to gstack-owns. Matches the "default-true preserves CLI bit-for-bit"
// premise even under malformed cfg.
const ownsTerminalAgent = cfg.ownsTerminalAgent === false ? false : true;
// Exit seam: production defaults to process.exit; tests inject a stub so
// in-process shutdowns can never terminate the test runner itself.
const exitFn = cfg.exitFn ?? ((code?: number) => process.exit(code));

// ─── Terminal-Agent Watchdog (v1.44+) ─────────────────────────────
//
Expand Down Expand Up @@ -1614,7 +1630,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {

cleanSingletonLocks(resolveChromiumProfile());
safeUnlinkQuiet(config.stateFile);
process.exit(exitCode);
exitFn(exitCode);
}

// Named lifecycle helper (matches closeTunnel style). Logs failures so
Expand Down
11 changes: 9 additions & 2 deletions browse/test/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,16 @@ beforeAll(async () => {
// The test needs to start a server. Let's use the existing server infrastructure.
});

afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close the real browser (capped at 5s — close can hang) instead of a
// delayed process.exit(0): that timer fired ~500ms into the NEXT test
// file in a full-suite run and killed the whole bun process with a green
// exit code and no tally, silently truncating the run.
await Promise.race([
bm.close().catch(() => {}),
new Promise<void>(r => setTimeout(r, 5000)),
]);
});

// We need a running browse server for HTTP tests.
Expand Down
6 changes: 6 additions & 0 deletions browse/test/cdp-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';

const TMP_HOME = path.join(os.tmpdir(), `gstack-cdp-e2e-${process.pid}-${Date.now()}`);
const ORIG_GSTACK_HOME = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_TELEMETRY_OFF = '1'; // don't pollute analytics during tests

Expand All @@ -36,6 +37,11 @@ beforeAll(async () => {
});

afterAll(async () => {
// Un-leak GSTACK_HOME: it outranks GSTACK_STATE_DIR in bin/gstack-config,
// so leaving it set breaks later test files in-process.
if (ORIG_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIG_GSTACK_HOME;

try { await bm.cleanup?.(); } catch {}
try { testServer.server.stop(); } catch {}
await fs.rm(TMP_HOME, { recursive: true, force: true });
Expand Down
11 changes: 9 additions & 2 deletions browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,18 @@ beforeAll(async () => {
await bm.launch();
});

afterAll(() => {
afterAll(async () => {
// Force kill browser instead of graceful close (avoids hang)
try { testServer.server.stop(); } catch {}
// bm.close() can hang — just let process exit handle it
setTimeout(() => process.exit(0), 500);
// Close the real browser (capped at 5s — close can hang) instead of a
// delayed process.exit(0): that timer fired ~500ms into the NEXT test
// file in a full-suite run and killed the whole bun process with a green
// exit code and no tally, silently truncating the run.
await Promise.race([
bm.close().catch(() => {}),
new Promise<void>(r => setTimeout(r, 5000)),
]);
});

// ─── Navigation ─────────────────────────────────────────────────
Expand Down
11 changes: 9 additions & 2 deletions browse/test/compare-board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,17 @@ beforeAll(async () => {
await handleWriteCommand('goto', [boardUrl], bm);
});

afterAll(() => {
afterAll(async () => {
try { server.stop(); } catch {}
fs.rmSync(tmpDir, { recursive: true, force: true });
setTimeout(() => process.exit(0), 500);
// Close the real browser (capped at 5s — close can hang) instead of a
// delayed process.exit(0): that timer fired ~500ms into the NEXT test
// file in a full-suite run and killed the whole bun process with a green
// exit code and no tally, silently truncating the run.
await Promise.race([
bm.close().catch(() => {}),
new Promise<void>(r => setTimeout(r, 5000)),
]);
});

// ─── DOM Structure ──────────────────────────────────────────────
Expand Down
11 changes: 9 additions & 2 deletions browse/test/content-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,9 +422,16 @@ describe('Hidden element stripping', () => {
await bm.launch();
});

afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close the real browser (capped at 5s — close can hang) instead of a
// delayed process.exit(0): that timer fired ~500ms into the NEXT test
// file in a full-suite run and killed the whole bun process with a green
// exit code and no tally, silently truncating the run.
await Promise.race([
bm.close().catch(() => {}),
new Promise<void>(r => setTimeout(r, 5000)),
]);
});

test('detects CSS-hidden elements on injection-hidden page', async () => {
Expand Down
6 changes: 6 additions & 0 deletions browse/test/domain-skills-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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()}`);
const ORIG_GSTACK_HOME = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = TMP_HOME;
process.env.GSTACK_PROJECT_SLUG = 'e2e-test-slug';

Expand All @@ -41,6 +42,11 @@ beforeAll(async () => {
});

afterAll(async () => {
// Un-leak GSTACK_HOME: it outranks GSTACK_STATE_DIR in bin/gstack-config,
// so leaving it set breaks later test files in-process.
if (ORIG_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIG_GSTACK_HOME;

try { await bm.cleanup?.(); } catch {}
try { testServer.server.stop(); } catch {}
await fs.rm(TMP_HOME, { recursive: true, force: true });
Expand Down
10 changes: 9 additions & 1 deletion browse/test/domain-skills-storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { describe, it, expect, beforeEach } from 'bun:test';
import { describe, it, expect, 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()}`);
const ORIG_GSTACK_HOME = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = TMP_HOME;

afterAll(() => {
// Un-leak GSTACK_HOME: it outranks GSTACK_STATE_DIR in bin/gstack-config,
// so leaving it set breaks later test files in-process.
if (ORIG_GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = ORIG_GSTACK_HOME;
});

// Re-import after env var set so module reads updated GSTACK_HOME
async function freshImport() {
// Bun caches modules; force reload by appending a query-string-like hack via dynamic import URL
Expand Down
5 changes: 5 additions & 0 deletions browse/test/gstack-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ function run(args: string[] = [], extraEnv: Record<string, string> = {}) {
const result = Bun.spawnSync(['bash', SCRIPT, ...args], {
env: {
...process.env,
// Set all three state-dir vars: other test files leak GSTACK_HOME /
// GSTACK_STATE_ROOT into process.env at module scope, and those
// outrank GSTACK_STATE_DIR in the script's resolution order.
GSTACK_STATE_DIR: stateDir,
GSTACK_STATE_ROOT: stateDir,
GSTACK_HOME: stateDir,
...extraEnv,
},
stdout: 'pipe',
Expand Down
11 changes: 9 additions & 2 deletions browse/test/handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@ beforeAll(async () => {
await bm.launch();
});

afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close the real browser (capped at 5s — close can hang) instead of a
// delayed process.exit(0): that timer fired ~500ms into the NEXT test
// file in a full-suite run and killed the whole bun process with a green
// exit code and no tally, silently truncating the run.
await Promise.race([
bm.close().catch(() => {}),
new Promise<void>(r => setTimeout(r, 5000)),
]);
});

// ─── Unit Tests: Failure Tracking (no browser needed) ────────────
Expand Down
11 changes: 9 additions & 2 deletions browse/test/security-live-playwright.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,16 @@ describe('defense-in-depth — live Playwright fixture', () => {
await bm.launch();
});

afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close the real browser (capped at 5s — close can hang) instead of a
// delayed process.exit(0): that timer fired ~500ms into the NEXT test
// file in a full-suite run and killed the whole bun process with a green
// exit code and no tally, silently truncating the run.
await Promise.race([
bm.close().catch(() => {}),
new Promise<void>(r => setTimeout(r, 5000)),
]);
});

test('L2 — content-security.ts hidden-element stripper detects the .sneaky div', async () => {
Expand Down
Loading