Files
gstack/browse/test/config.test.ts
Garry Tan 0c88517a0f v1.34.0.0 feat: gstack consumable as submodule (factory-export API + AUTH_TOKEN env + import.meta.main gate) (#1472)
* feat(config): add resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks

Three new exported helpers in browse/src/config.ts:

- resolveGstackHome(): honors GSTACK_HOME env, falls back to os.homedir()/.gstack
  Matches the existing convention in browse/src/telemetry.ts:26 and
  browse/src/domain-skills.ts:66.

- resolveChromiumProfile(explicit?): explicit arg wins -> CHROMIUM_PROFILE env
  -> resolveGstackHome()/chromium-profile. Lets gbrowser pass per-workspace
  profile paths through ServerConfig instead of relying on ambient env state.

- cleanSingletonLocks(dir): removes SingletonLock/Socket/Cookie via safeUnlinkQuiet.
  Defensive guard refuses to operate unless dir basename is 'chromium-profile'
  OR matches explicit CHROMIUM_PROFILE env value, preventing accidental
  deletion in unrelated directories.

Extends browse/test/config.test.ts with 12 tests covering env precedence,
guard behavior, ENOENT swallowing, and CHROMIUM_PROFILE override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security-classifier): TDZ when claude CLI is missing from PATH

The checkTranscript Promise executor in browse/src/security-classifier.ts
referenced `finish()` at the !claude early-return guard before declaring
it 5 lines later. JavaScript throws ReferenceError: Cannot access 'finish'
before initialization (TDZ) for that path, but the path is only reachable
when resolveClaudeCommand returns null inside the spawn block (a TOCTOU
window vs. the outer checkHaikuAvailable cache).

Fix: hoist `let stdout = ''`, `let done = false`, and `const finish` block
above `const claude = resolveClaudeCommand()` so finish is in scope before
any reference to it. Behavior is identical when claude is on PATH; the
fix only matters for the dormant missing-CLI degraded path.

Adds browse/test/security-classifier-tdz.test.ts as the regression guard:
clears PATH + override env vars, calls checkTranscript, asserts the result
serializes with degraded:true and a meaningful reason field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(browser-manager): isCustomChromium gate + per-workspace profile + lock cleanup

Three fold-ins so gbrowser can become a thin overlay instead of forking
browse-server:

- Export isCustomChromium(): detects custom Chromium builds that bake the
  extension in as a component extension. Prefers explicit
  GSTACK_CHROMIUM_KIND=custom-extension-baked signal; falls back to
  GSTACK_CHROMIUM_PATH substring containing 'GBrowser' / 'gbrowser'.
  Gates the --load-extension push at launchHeaded so we don't trigger
  ServiceWorkerState::SetWorkerId DCHECK when two copies of the same
  service worker race to register.

- Swap hardcoded path.join(HOME, '.gstack', 'chromium-profile') in
  launchHeaded for resolveChromiumProfile() so phoenix can pass a
  per-workspace profile via CHROMIUM_PROFILE env (one daemon per gbd
  workspace, each with a distinct profile dir).

- Call cleanSingletonLocks(userDataDir) immediately after mkdirSync.
  Chromium's ProcessSingleton refuses to start when stale
  SingletonLock/Socket/Cookie files survive a SIGKILL or hard crash;
  pre-launch cleanup defends against the crash case. Safe under external
  coordination (gbd.lock for gbrowser, single-instance CLI check for
  gstack).

The existing .auth.json write at L291-302 is preserved — extensions
still need it for bootstrap even when component-baked.

Adds browse/test/browser-manager-custom-chromium.test.ts with 8 tests
covering both the env-kind and path-substring signals plus stock /
playwright-bundled Chromium negative cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server): factory-export API surface + import.meta.main gate

Surfaces the embedder API gbrowser (phoenix) needs to consume gstack as a
submodule, and gates module-load side effects so the file is safe to
import without auto-starting a daemon.

Changes to browse/src/server.ts:

- AUTH_TOKEN now honors process.env.AUTH_TOKEN (trimmed) before falling
  back to crypto.randomUUID(). Whitespace-only values are rejected so the
  security boundary can't be silently weakened.

- New exported types: ServerConfig and ServerHandle. ServerConfig documents
  the full factory contract (authToken, browsePort, idleTimeoutMs, config,
  browserManager, chromiumProfile, xvfb, proxyBridge, startTime, beforeRoute).
  ServerHandle documents the return shape (fetchLocal, fetchTunnel,
  shutdown, stopListeners). Caller-owned lifecycle annotations on xvfb and
  proxyBridge prevent double-close bugs from surprise ownership.

- New exported function: resolveConfigFromEnv() builds a ServerConfig-shaped
  object from process.env for CLI use. Embedders construct their own
  ServerConfig explicitly.

- start() is now exported. Embedders can call it with env vars set as a
  v1 escape hatch until full buildFetchHandler extraction lands.

- Signal handlers (SIGINT, SIGTERM, Windows exit, uncaughtException,
  unhandledRejection) and the auto-kickoff at module bottom are now wrapped
  in `if (import.meta.main)`. CLI path is unchanged. Embedders register
  their own handlers.

- shutdown() and emergencyCleanup() now call cleanSingletonLocks(
  resolveChromiumProfile()) instead of inline path+loop. Single
  implementation, defensive guard, honors per-workspace CHROMIUM_PROFILE.

New tests:
- browse/test/server-no-import-side-effects.test.ts: spawns a fresh Bun
  subprocess that imports server.ts, asserts no signal handlers registered,
  no state-dir populated. Guards the core refactor invariant from
  regression.
- browse/test/server-factory.test.ts: 12 tests covering AUTH_TOKEN env
  behavior (honored, whitespace-rejected, trimmed), preserved exports
  (TUNNEL_COMMANDS, canDispatchOverTunnel), and ServerConfig/ServerHandle
  type compatibility.

Deferred to follow-up PR: full buildFetchHandler extraction that hoists
the 13 module-level mutables + helpers into a factory closure. Phoenix
can ship v0.6.0.0 against the start()+env surface today; the cleaner
factory comes next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden auth-token validation, TDZ try/catch, lockfile path safety

Three security hardening fixes from /ship adversarial review:

1. AUTH_TOKEN unicode-whitespace bypass (server.ts:67-83).
   Old: `process.env.AUTH_TOKEN?.trim() || randomUUID()` only stripped
   ASCII whitespace. A misconfigured embedder shipping AUTH_TOKEN=$''
   (BOM) or $'​' (zero-width space) would silently get a
   one-character bearer secret. New `sanitizeAuthToken()` strips all
   unicode whitespace via regex and requires >= 16 chars after stripping;
   anything shorter falls back to crypto.randomUUID(). Same sanitizer
   used by `resolveConfigFromEnv()` so the embedder path is hardened too.

2. security-classifier.ts checkTranscript safety net.
   `resolveClaudeCommand()` and `spawn()` can throw under transient
   conditions (PATH probe failure, posix_spawn ENOMEM). Old code let the
   throw propagate and rejected the Promise with a raw exception. Now
   wrapped in try/catch that calls finish() with a degraded signal,
   matching the graceful-degradation contract the layer already promises
   for missing-CLI / exit-nonzero / parse-error.

3. cleanSingletonLocks defensive guard tightened (config.ts).
   Old: basename === 'chromium-profile' OR userDataDir === $CHROMIUM_PROFILE.
   The second branch was env-controlled and the first was bypassable by
   passing a relative path that resolved to chromium-profile via CWD
   drift. New guard: refuses relative paths outright, resolves both
   sides via path.resolve(), and only accepts the env-match path when
   $CHROMIUM_PROFILE is itself absolute.

Test updates: replace the old `.trim()` test with three new cases
covering unicode-whitespace stripping, short-token rejection, and
zero-width-only rejection (server-factory.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:22:30 -04:00

446 lines
18 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('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 });
});
});
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 });
});
});