mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* fix(browse): record .gstack/ in info/exclude, never the tracked .gitignore (#3560) The browse daemon's state-dir setup appended .gstack/ to the worktree's .gitignore when the repository tracks files under .gstack/, so gstack-pr-shots changed the tree after pregate and /ship's receipt refused with PREGATE_STALE. The ignore line now goes to the repository's info/exclude (git rev-parse --git-path), which is outside the tree. Fixes #3560 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): pr-shots no longer edits .gitignore (#3560) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pr-shots): clear BROWSE_STATE_FILE so the #3560 case runs under the shard runner Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
524 lines
23 KiB
TypeScript
524 lines
23 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { canRevokeWrites } from '../../test/helpers/fs-caps';
|
|
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', timeout: 30_000 });
|
|
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 });
|
|
});
|
|
|
|
// A repository that tracks files under .gstack/ (pregate.json, ship-policy.json) is the case
|
|
// where git does not already read .gstack/ as ignored, so the ignore line must land somewhere.
|
|
const trackedGstackRepo = (prefix: string) => {
|
|
const { spawnSync } = require('child_process');
|
|
const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
const git = (...args: string[]) => spawnSync('git', args, { cwd: tmpDir, timeout: 30_000, encoding: 'utf-8' });
|
|
git('init', '-q');
|
|
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/tmp/\n');
|
|
fs.mkdirSync(path.join(tmpDir, '.gstack'));
|
|
fs.writeFileSync(path.join(tmpDir, '.gstack', 'pregate.json'), '{}\n');
|
|
git('add', '-A');
|
|
git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init');
|
|
const exclude = path.resolve(tmpDir, git('rev-parse', '--git-path', 'info/exclude').stdout.trim());
|
|
return { tmpDir, git, exclude };
|
|
};
|
|
|
|
test('records .gstack/ in .git/info/exclude and leaves the tracked .gitignore unchanged (#3560)', () => {
|
|
const { tmpDir, git, exclude } = trackedGstackRepo('browse-exclude-test-');
|
|
fs.writeFileSync(exclude, '# local\n');
|
|
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
|
|
ensureStateDir(config);
|
|
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n');
|
|
expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n.gstack/\n');
|
|
expect(git('status', '--porcelain').stdout).toBe('');
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('does not duplicate .gstack/ in info/exclude, adds a missing trailing newline, and creates info/ when absent', () => {
|
|
const dup = trackedGstackRepo('browse-exclude-dup-');
|
|
fs.writeFileSync(dup.exclude, 'build/');
|
|
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(dup.tmpDir, '.gstack', 'browse.json') });
|
|
ensureStateDir(config);
|
|
ensureStateDir(config);
|
|
expect(fs.readFileSync(dup.exclude, 'utf-8')).toBe('build/\n.gstack/\n');
|
|
fs.rmSync(dup.tmpDir, { recursive: true, force: true });
|
|
|
|
const bare = trackedGstackRepo('browse-exclude-noinfo-');
|
|
fs.rmSync(path.dirname(bare.exclude), { recursive: true, force: true });
|
|
ensureStateDir(resolveConfig({ BROWSE_STATE_FILE: path.join(bare.tmpDir, '.gstack', 'browse.json') }));
|
|
expect(fs.readFileSync(bare.exclude, 'utf-8')).toBe('.gstack/\n');
|
|
fs.rmSync(bare.tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('logs warning to browse-server.log when info/exclude cannot be written', () => {
|
|
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
|
|
const { tmpDir, exclude } = trackedGstackRepo('browse-exclude-ro-');
|
|
fs.writeFileSync(exclude, '# local\n');
|
|
fs.chmodSync(exclude, 0o444);
|
|
const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') });
|
|
ensureStateDir(config); // should not throw
|
|
const logContent = fs.readFileSync(path.join(config.stateDir, 'browse-server.log'), 'utf-8');
|
|
expect(logContent).toContain(`Warning: could not update ${exclude}`);
|
|
expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n');
|
|
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n');
|
|
fs.chmodSync(exclude, 0o644);
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('outside a git repository nothing is written beyond the state dir', () => {
|
|
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);
|
|
expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
|
|
expect(fs.readdirSync(tmpDir).sort()).toEqual(['.gitignore', '.gstack']);
|
|
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, timeout: 30_000 });
|
|
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, timeout: 30_000 });
|
|
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, timeout: 30_000 });
|
|
|
|
// 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, timeout: 30_000 });
|
|
|
|
// .gitignore exists but does NOT contain .gstack/
|
|
fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
|
|
spawnSync('git', ['add', '.gitignore'], { cwd: tmpDir, timeout: 30_000 });
|
|
spawnSync('git', ['commit', '-qm', 'init'], { cwd: tmpDir, timeout: 30_000 });
|
|
|
|
// Verify git knows .gstack/ is ignored
|
|
const check = spawnSync('git', ['check-ignore', '-q', '.gstack/'], { cwd: tmpDir, timeout: 30_000 });
|
|
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 the project state dir, not the machine-wide gstack home (D5, #2492)', () => {
|
|
const origEnv = process.env.CHROMIUM_PROFILE;
|
|
const origHome = process.env.GSTACK_HOME;
|
|
const origState = process.env.BROWSE_STATE_FILE;
|
|
delete process.env.CHROMIUM_PROFILE;
|
|
process.env.GSTACK_HOME = '/tmp/fallback-gstack';
|
|
process.env.BROWSE_STATE_FILE = '/tmp/project-a/.gstack/browse.json';
|
|
try {
|
|
expect(resolveChromiumProfile()).toBe(path.join('/tmp/project-a/.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;
|
|
if (origState === undefined) delete process.env.BROWSE_STATE_FILE;
|
|
else process.env.BROWSE_STATE_FILE = origState;
|
|
}
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|