Files
gstack/browse/test/config.test.ts
T
Garry TanandClaude Fable 5 c86e6472eb v1.67.1.0 fix: external-contributor security sweep — 6 findings hardened, regression-pinned (#2605)
* fix(redact): block real all-caps URL passwords, not just shape-match

urlPasswordIsPlaceholder skipped any password matching /^[A-Z][A-Z0-9_]*$/,
so a real DSN like postgres://admin:PROD2026SECRET@db-prod.internal/app slipped
the HIGH pre-push block. Replace the shape rule with an anchored, exact-match
set of doc-convention placeholder tokens (PASSWORD, PASS, CHANGEME, ...),
compared case-sensitively and never as a substring (PROD2026SECRET must not
match SECRET). The USER:PASSWORD doc convention still suppresses; real all-caps
and lowercase passwords block. Regression cases pinned both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): write self-contained .gstack/.gitignore unconditionally

ensureStateDir only appended .gstack/ to the project .gitignore when that file
already existed, skipped silently on ENOENT, and swallowed other append
failures. With BROWSE_PERSIST_STATE=1, session-state.json (live cookies +
localStorage/sessionStorage tokens) and browse-network.log / browse-audit.jsonl
(request headers) then sat git-add-able under <git-root>/.gstack/. Write a
self-contained <stateDir>/.gitignore containing "*" unconditionally, before
return, so the state dir's contents can never be committed regardless of the
project .gitignore. The project-.gitignore append is kept as redundant safety.

The no-import-side-effects guard is relaxed to allow exactly this lone
.gitignore guard file (still fails on browse.json / session-state.json / logs /
listener binds) — the guard is written eagerly by ensureStateDir at import and
is not leaked state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): restore Bun.spawn exited/drain/OOM-cap contract on Node polyfill

The v1.65 fork-port squash silently dropped the `exited` promise, eager
stdout/stderr drain, and 16MB GSTACK_SPAWN_MAX_BUFFER cap that v1.64 added
(#2571), plus the five tests pinning them. On the Windows Node fallback,
`await proc.exited` then resolved to undefined immediately — cookie-import,
isBrowserRunning, and browser-skill children all read stdout before the child
produced it, a silent failure. Re-land the block (keeping v1.65's windowsHide
comment improvements) and re-add the pinning tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ios-qa): compile the private-API touch bridge out of Release builds

PR #2264 claimed DebugBridgeTouch.m (KIF-derived in-process touch synthesis
using private UIKit/IOKit symbols: _touchesEvent, IOHIDEventCreateDigitizer*,
_AXSSetAutomationEnabled) was "compiled out in Release," but the body was gated
only by TARGET_OS_IOS, so a Release iOS build carried the private symbols (App
Store rejection risk). The safety half of the fix (closed PR #2269) never
landed. Gate the body on `#if TARGET_OS_IOS && DEBUG` and add the cSettings
DEBUG define to the DebugBridgeTouch target so `#if DEBUG` is true in debug and
false in release (mirrors the Core/UI swiftSettings). A free static tripwire
pins both halves; the nm/strings symbol proof needs an iOS-SDK build and belongs
in the device/periodic tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(egress): state truncation/deletion of the ledger are out of scope

gstack-egress verify catches in-place edits, reordering, and mid-chain deletion
(the hash chain breaks) but not tail-truncation, whole-file re-fabrication, or
deletion — a same-user local actor who owns the ledger defeats those and verify
still exits 0. That matches the stated threat model (forensic observability, not
an exfiltration control). Document it in the header threat model and the usage
text rather than adding a count-sidecar, which would false-positive on every
legitimate rotation and barely raise the bar. Head-anchoring stays the tracked
rotation TODO in lib/egress-receipt.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ship): scope the App Store Connect key to one app and disclose it at exit

The release flow minted a non-expiring APP_MANAGER key with allAppsVisible:true
(standing authority over every app on the team) and was told never to mention
any credential to the user, so the durable key never reached their revocation
checklist. Scope the key to the app being released via the apps relationship
(allAppsVisible:false + an explicit apps association — required, since a
no-app key can see nothing and uploads fail), and disclose the key once in the
closing report with its ASC revocation path. Carve the exit disclosure as the
explicit exception to the mid-run no-credential-talk rule so the
one-authorization-moment contract still holds. Edited the .tmpl source and
regenerated the section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* harden(browse): constant-time bearer-token comparison in validateAuth

The loopback auth check compared the Authorization header with `===`, whose
byte-by-byte early exit leaks the token prefix through response timing. Use
crypto.timingSafeEqual with a length gate (the length is not secret). Behavior
is unchanged for valid/invalid tokens; auth tests unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin the security-property regression guards from pre-landing review

The pre-landing review found the fixes were correct but three regression guards
were missing — each pins a property whose silent revert would keep behavior
identical while reopening the hole:
- validateAuth: a static tripwire asserting crypto.timingSafeEqual + the
  got.length===want.length gate + the null-header guard (a revert to `===`
  keeps accept/reject green but restores the timing side-channel).
- redact: a table-driven loop over the exported URL_PASSWORD_PLACEHOLDER_WORDS
  so a typo or dropped entry can't silently start blocking a doc placeholder;
  plus a substring-can't-rescue-a-real-secret assertion.
- config: assert the self-contained .gitignore is written even when git already
  ignores .gstack/, proving the write precedes the isIgnoredByGit early return.
- bun-polyfill: cover the 128+signal exit branch (POSIX only).

URL_PASSWORD_PLACEHOLDER_WORDS is exported so the table test can't drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.66.2.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: sync egress-verify scope and layered iOS Release guard into user docs

ARCHITECTURE.md and README.md now carry the same gstack-egress verify
scope disclosure the CLI ships (edits/reordering/mid-chain deletion
detected; tail-truncation and ledger deletion out of scope for a
forensic log). docs/howto-ios-testing-with-gstack.md documents the
second Release-build guard: DebugBridgeTouch.m compiles out behind
#if TARGET_OS_IOS && DEBUG via the cSettings DEBUG define.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(ios-qa): call the DebugBridge targets SwiftPM targets, not Swift targets

DebugBridgeTouch is Objective-C (the same sentence says so); "Swift
targets" was the wrong word. Cross-model doc review catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): describe the all-caps DSN examples without a scannable URL shape

The v1.66.2.0 entry quoted its own headline fix as three literal
postgres://user:PASSWORD@host examples — which the branch's stricter HIGH
gate now correctly flags, failing CI's quality scan on this very PR (the
local pre-push hook passed because the installed gstack still runs the old
engine). Rewrite the three mentions: the reproduce command uses a
fully-braced shell interpolation (suppressed in the diff scan by design,
expands to the real all-caps password at runtime, still exits 3 — verified),
and the table row + Fixed bullet name the password token without the URL
shape. Gate scan on the amended diff: 0 high.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(evals): pre-seed one-time preamble markers for PTY smokes

Root cause of the documented intermittent scope-gate-question-NOT-observed
failure (test/skill-e2e-plan-mode-no-op.test.ts, also PR #2593 rounds 3/11):
on a fresh runner every one-time preamble marker is missing, so each PTY
child runs first-run feature discovery before the behavior under test, and
touching .feature-prompted-model-overlay under ~/.claude/skills/gstack/
trips Claude Code's sensitive-file permission prompt — the run stalls on
that dialog (classified outcome=asked) and the scope gate never renders.
Dev machines never reproduce it because the operator's markers exist.

Seed ~/.gstack one-time markers (.activated, .first-loop-tip-shown,
.telemetry-prompted, .proactive-prompted, .completeness-intro-seen,
.plan-tune-nudge-shown) and both .feature-prompted-* markers (via the
gstack root symlink into the checkout) in the PTY-smoke registration step,
so no first-run prompt can preempt the assertion under test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: re-version release as v1.67.1.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: restore main's dependency manifest clobbered by the merge resolution

The v1.67.0.0 merge resolved the package.json conflict wholesale --ours,
which kept this branch's version stamp but erased main's dependency work
(playwright 1.58->1.62 + its patchedDependencies entry, transformers 4.1->4.2,
cross-spawn added, puppeteer-core removed — which is also why main dropped the
basic-ftp pin test: the pinned package left the tree with it — marked/socks
bumps, adm-zip override) while bun.lock auto-merged to main's side. Every CI
job that runs `bun install --frozen-lockfile` failed on the mismatch
(check-freshness, quality, free-tests, gate, windows x2).

Take main's package.json + bun.lock verbatim, re-stamp the version through
gstack-version-bump (1.67.1.0). bun.lock is now byte-identical to main's;
frozen install verified locally; full free suite green for the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 07:50:37 -07:00

514 lines
22 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug, resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks } from '../src/config';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
describe('config', () => {
describe('getGitRoot', () => {
test('returns a path when in a git repo', () => {
const root = getGitRoot();
expect(root).not.toBeNull();
expect(fs.existsSync(path.join(root!, '.git'))).toBe(true);
});
});
describe('resolveConfig', () => {
test('uses git root by default', () => {
const config = resolveConfig({});
const gitRoot = getGitRoot();
expect(gitRoot).not.toBeNull();
expect(config.projectDir).toBe(gitRoot);
expect(config.stateDir).toBe(path.join(gitRoot!, '.gstack'));
expect(config.stateFile).toBe(path.join(gitRoot!, '.gstack', 'browse.json'));
});
test('derives paths from BROWSE_STATE_FILE when set', () => {
const stateFile = '/tmp/test-config/.gstack/browse.json';
const config = resolveConfig({ BROWSE_STATE_FILE: stateFile });
expect(config.stateFile).toBe(stateFile);
expect(config.stateDir).toBe('/tmp/test-config/.gstack');
expect(config.projectDir).toBe('/tmp/test-config');
});
test('log paths are in stateDir', () => {
const config = resolveConfig({});
expect(config.consoleLog).toBe(path.join(config.stateDir, 'browse-console.log'));
expect(config.networkLog).toBe(path.join(config.stateDir, 'browse-network.log'));
expect(config.dialogLog).toBe(path.join(config.stateDir, 'browse-dialog.log'));
});
});
describe('ensureStateDir', () => {
test('creates directory if it does not exist', () => {
const tmpDir = path.join(os.tmpdir(), `browse-config-test-${Date.now()}`);
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
expect(fs.existsSync(config.stateDir)).toBe(false);
ensureStateDir(config);
expect(fs.existsSync(config.stateDir)).toBe(true);
// Cleanup
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('is a no-op if directory already exists', () => {
const tmpDir = path.join(os.tmpdir(), `browse-config-test-${Date.now()}`);
const stateDir = path.join(tmpDir, '.gstack');
fs.mkdirSync(stateDir, { recursive: true });
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(stateDir, 'browse.json') });
ensureStateDir(config); // should not throw
expect(fs.existsSync(config.stateDir)).toBe(true);
// Cleanup
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('writes a self-contained .gstack/.gitignore with * unconditionally', () => {
// Even with NO project .gitignore, the state dir must carry its own
// ignore so persisted cookies / network+audit logs can never be git-added.
const tmpDir = path.join(os.tmpdir(), `browse-selfignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const selfIgnore = path.join(config.stateDir, '.gitignore');
expect(fs.existsSync(selfIgnore)).toBe(true);
expect(fs.readFileSync(selfIgnore, 'utf-8')).toBe('*\n');
// No nesting: the ignore is directly inside the state dir, not .gstack/.gstack/.
expect(fs.existsSync(path.join(config.stateDir, '.gstack'))).toBe(false);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('writes the self-contained .gitignore even when git already ignores .gstack/ (before the early return)', () => {
// Pins the load-bearing property: the state-dir ignore is written
// UNCONDITIONALLY, before the `if (isIgnoredByGit(...)) return` early exit.
// A git repo whose root .gitignore already lists .gstack/ makes
// isIgnoredByGit true, so the early return fires — moving the write below
// it (the exact bug the fix removed) would skip the guard here.
const tmpDir = path.join(os.tmpdir(), `browse-gitignored-repo-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
Bun.spawnSync(['git', 'init'], { cwd: tmpDir, stdout: 'ignore', stderr: 'ignore' });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.gstack/\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const selfIgnore = path.join(config.stateDir, '.gitignore');
expect(fs.existsSync(selfIgnore)).toBe(true);
expect(fs.readFileSync(selfIgnore, 'utf-8')).toBe('*\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('adds .gstack/ to .gitignore if not present', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toContain('.gstack/');
expect(content).toBe('node_modules/\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('does not duplicate .gstack/ in .gitignore', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/\n');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules/\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('handles .gitignore without trailing newline', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules');
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules\n.gstack/\n');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('logs warning to browse-server.log on non-ENOENT gitignore error', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Create a read-only .gitignore (no .gstack/ entry → would try to append)
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o444);
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config); // should not throw
// Verify warning was written to server log
const logPath = path.join(config.stateDir, 'browse-server.log');
expect(fs.existsSync(logPath)).toBe(true);
const logContent = fs.readFileSync(logPath, 'utf-8');
expect(logContent).toContain('Warning: could not update .gitignore');
// .gitignore should remain unchanged
const gitignoreContent = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(gitignoreContent).toBe('node_modules/\n');
// Cleanup
fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o644);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('skips if no .gitignore exists', () => {
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('leaves .gitignore alone when git already ignores .gstack/ globally', () => {
const { spawnSync } = require('child_process');
const tmpDir = path.join(os.tmpdir(), `browse-gitignore-global-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Set up a real git repo
spawnSync('git', ['init', '-q'], { cwd: tmpDir });
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir });
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
// Write a global excludes file that ignores .gstack/
const excludesFile = path.join(tmpDir, 'global-gitignore');
fs.writeFileSync(excludesFile, '.gstack/\n');
spawnSync('git', ['config', 'core.excludesFile', excludesFile], { cwd: tmpDir });
// .gitignore exists but does NOT contain .gstack/
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
spawnSync('git', ['add', '.gitignore'], { cwd: tmpDir });
spawnSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir });
// Verify git knows .gstack/ is ignored
const check = spawnSync('git', ['check-ignore', '-q', '.gstack/'], { cwd: tmpDir });
expect(check.status).toBe(0);
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
ensureStateDir(config);
// .gitignore must NOT have been modified
const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
expect(content).toBe('node_modules/\n');
expect(fs.existsSync(path.join(tmpDir, '.gstack'))).toBe(true);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});
describe('getRemoteSlug', () => {
test('returns owner-repo format for current repo', () => {
const slug = getRemoteSlug();
// This repo has an origin remote — should return a slug
expect(slug).toBeTruthy();
expect(slug).toMatch(/^[a-zA-Z0-9._-]+-[a-zA-Z0-9._-]+$/);
});
test('parses SSH remote URLs', () => {
// Test the regex directly since we can't mock Bun.spawnSync easily
const url = 'git@github.com:garrytan/gstack.git';
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
expect(match).not.toBeNull();
expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
});
test('parses HTTPS remote URLs', () => {
const url = 'https://github.com/garrytan/gstack.git';
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
expect(match).not.toBeNull();
expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
});
test('parses HTTPS remote URLs without .git suffix', () => {
const url = 'https://github.com/garrytan/gstack';
const match = url.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
expect(match).not.toBeNull();
expect(`${match![1]}-${match![2]}`).toBe('garrytan-gstack');
});
});
describe('readVersionHash', () => {
test('returns null when .version file does not exist', () => {
const result = readVersionHash('/nonexistent/path/browse');
expect(result).toBeNull();
});
test('reads version from .version file adjacent to execPath', () => {
const tmpDir = path.join(os.tmpdir(), `browse-version-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const versionFile = path.join(tmpDir, '.version');
fs.writeFileSync(versionFile, 'abc123def\n');
const result = readVersionHash(path.join(tmpDir, 'browse'));
expect(result).toBe('abc123def');
// Cleanup
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});
});
describe('resolveServerScript', () => {
// Import the function from cli.ts
const { resolveServerScript } = require('../src/cli');
test('uses BROWSE_SERVER_SCRIPT env when set', () => {
const result = resolveServerScript({ BROWSE_SERVER_SCRIPT: '/custom/server.ts' }, '', '');
expect(result).toBe('/custom/server.ts');
});
test('finds server.ts adjacent to cli.ts in dev mode', () => {
const srcDir = path.resolve(__dirname, '../src');
const result = resolveServerScript({}, srcDir, '');
expect(result).toBe(path.join(srcDir, 'server.ts'));
});
test('throws when server.ts cannot be found', () => {
expect(() => resolveServerScript({}, '/nonexistent/$bunfs', '/nonexistent/browse'))
.toThrow('Cannot find server.ts');
});
});
describe('resolveNodeServerScript', () => {
const { resolveNodeServerScript } = require('../src/cli');
test('finds server-node.mjs in dist from dev mode', () => {
const srcDir = path.resolve(__dirname, '../src');
const distFile = path.resolve(srcDir, '..', 'dist', 'server-node.mjs');
const fs = require('fs');
// Only test if the file exists (it may not be built yet)
if (fs.existsSync(distFile)) {
const result = resolveNodeServerScript(srcDir, '');
expect(result).toBe(distFile);
}
});
test('returns null when server-node.mjs does not exist', () => {
const result = resolveNodeServerScript('/nonexistent/$bunfs', '/nonexistent/browse');
expect(result).toBeNull();
});
test('finds server-node.mjs adjacent to compiled binary', () => {
const distDir = path.resolve(__dirname, '../dist');
const distFile = path.join(distDir, 'server-node.mjs');
const fs = require('fs');
if (fs.existsSync(distFile)) {
const result = resolveNodeServerScript('/$bunfs/something', path.join(distDir, 'browse'));
expect(result).toBe(distFile);
}
});
});
describe('version mismatch detection', () => {
test('detects when versions differ', () => {
const stateVersion = 'abc123';
const currentVersion = 'def456';
expect(stateVersion !== currentVersion).toBe(true);
});
test('no mismatch when versions match', () => {
const stateVersion = 'abc123';
const currentVersion = 'abc123';
expect(stateVersion !== currentVersion).toBe(false);
});
test('no mismatch when either version is null', () => {
const currentVersion: string | null = null;
const stateVersion: string | undefined = 'abc123';
// Version mismatch only triggers when both are present
const shouldRestart = currentVersion !== null && stateVersion !== undefined && currentVersion !== stateVersion;
expect(shouldRestart).toBe(false);
});
});
describe('isServerHealthy', () => {
const { isServerHealthy } = require('../src/cli');
const http = require('http');
test('returns true for a healthy server', async () => {
const server = http.createServer((_req: any, res: any) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'healthy' }));
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = server.address().port;
try {
expect(await isServerHealthy(port)).toBe(true);
} finally {
server.close();
}
});
test('returns false for an unhealthy server', async () => {
const server = http.createServer((_req: any, res: any) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'unhealthy' }));
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = server.address().port;
try {
expect(await isServerHealthy(port)).toBe(false);
} finally {
server.close();
}
});
test('returns false when server is not running', async () => {
// Use a port that's almost certainly not in use
expect(await isServerHealthy(59999)).toBe(false);
});
test('returns false on non-200 response', async () => {
const server = http.createServer((_req: any, res: any) => {
res.writeHead(500);
res.end('Internal Server Error');
});
await new Promise<void>(resolve => server.listen(0, resolve));
const port = server.address().port;
try {
expect(await isServerHealthy(port)).toBe(false);
} finally {
server.close();
}
});
});
describe('startup error log', () => {
test('write and read error log', () => {
const tmpDir = path.join(os.tmpdir(), `browse-error-log-test-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const errorLogPath = path.join(tmpDir, 'browse-startup-error.log');
const errorMsg = 'Cannot find module playwright';
fs.writeFileSync(errorLogPath, `2026-03-23T00:00:00.000Z ${errorMsg}\n`);
const content = fs.readFileSync(errorLogPath, 'utf-8').trim();
expect(content).toContain(errorMsg);
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T/); // ISO timestamp prefix
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});
describe('resolveGstackHome', () => {
test('honors GSTACK_HOME env var when set', () => {
const orig = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = '/tmp/custom-gstack-home';
try {
expect(resolveGstackHome()).toBe('/tmp/custom-gstack-home');
} finally {
if (orig === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = orig;
}
});
test('falls back to os.homedir() + /.gstack when env unset', () => {
const orig = process.env.GSTACK_HOME;
delete process.env.GSTACK_HOME;
try {
expect(resolveGstackHome()).toBe(path.join(os.homedir(), '.gstack'));
} finally {
if (orig !== undefined) process.env.GSTACK_HOME = orig;
}
});
});
describe('resolveChromiumProfile', () => {
test('explicit arg wins over env and default', () => {
const orig = process.env.CHROMIUM_PROFILE;
process.env.CHROMIUM_PROFILE = '/tmp/env-profile';
try {
expect(resolveChromiumProfile('/tmp/explicit-profile')).toBe('/tmp/explicit-profile');
} finally {
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = orig;
}
});
test('CHROMIUM_PROFILE env honored when no explicit arg', () => {
const orig = process.env.CHROMIUM_PROFILE;
process.env.CHROMIUM_PROFILE = '/tmp/env-profile';
try {
expect(resolveChromiumProfile()).toBe('/tmp/env-profile');
} finally {
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = orig;
}
});
test('falls back to resolveGstackHome()/chromium-profile when nothing set', () => {
const origEnv = process.env.CHROMIUM_PROFILE;
const origHome = process.env.GSTACK_HOME;
delete process.env.CHROMIUM_PROFILE;
process.env.GSTACK_HOME = '/tmp/fallback-gstack';
try {
expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile');
} finally {
if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv;
if (origHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = origHome;
}
});
test('ignores empty-string explicit arg, falls through to env/default', () => {
const orig = process.env.CHROMIUM_PROFILE;
process.env.CHROMIUM_PROFILE = '/tmp/env-profile';
try {
expect(resolveChromiumProfile('')).toBe('/tmp/env-profile');
} finally {
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = orig;
}
});
});
describe('cleanSingletonLocks', () => {
test('removes SingletonLock/Socket/Cookie when basename is chromium-profile', () => {
const tmpDir = path.join(os.tmpdir(), `clean-locks-${Date.now()}`, 'chromium-profile');
fs.mkdirSync(tmpDir, { recursive: true });
for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
fs.writeFileSync(path.join(tmpDir, f), 'stale');
}
cleanSingletonLocks(tmpDir);
for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
expect(fs.existsSync(path.join(tmpDir, f))).toBe(false);
}
fs.rmSync(path.dirname(tmpDir), { recursive: true, force: true });
});
test('refuses to clean unrecognized profile dir basename', () => {
const tmpDir = path.join(os.tmpdir(), `unrelated-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const lockFile = path.join(tmpDir, 'SingletonLock');
fs.writeFileSync(lockFile, 'should-survive');
const origWarn = console.warn;
let warned = '';
console.warn = (msg: string) => { warned = msg; };
try {
cleanSingletonLocks(tmpDir);
expect(warned).toContain('refusing to clean unrecognized profile dir');
expect(fs.existsSync(lockFile)).toBe(true); // not deleted
} finally {
console.warn = origWarn;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('respects explicit CHROMIUM_PROFILE env even with non-standard basename', () => {
const tmpDir = path.join(os.tmpdir(), `custom-name-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'SingletonLock'), 'stale');
const orig = process.env.CHROMIUM_PROFILE;
process.env.CHROMIUM_PROFILE = tmpDir;
try {
cleanSingletonLocks(tmpDir);
expect(fs.existsSync(path.join(tmpDir, 'SingletonLock'))).toBe(false);
} finally {
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = orig;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test('second call on empty dir does not throw (ENOENT swallowed)', () => {
const tmpDir = path.join(os.tmpdir(), `empty-locks-${Date.now()}`, 'chromium-profile');
fs.mkdirSync(tmpDir, { recursive: true });
expect(() => cleanSingletonLocks(tmpDir)).not.toThrow();
expect(() => cleanSingletonLocks(tmpDir)).not.toThrow();
fs.rmSync(path.dirname(tmpDir), { recursive: true, force: true });
});
});