mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-17 10:25:33 +02:00
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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }); });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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], {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user