singularity-forge/src/tests/integration/web-mode-cli.test.ts

748 lines
26 KiB
TypeScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
const projectRoot = process.cwd()
const cliWeb = await import('../../cli-web-branch.ts')
const webMode = await import('../../web-mode.ts')
test('parseCliArgs recognizes --web explicitly', () => {
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web'])
assert.equal(flags.web, true)
assert.equal(flags.print, undefined)
assert.equal(flags.mode, undefined)
})
test('package hooks declare a concrete staged web host', () => {
const rootPackage = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8'))
assert.equal(rootPackage.scripts['stage:web-host'], 'node scripts/stage-web-standalone.cjs')
assert.equal(rootPackage.scripts['build:web-host'], 'npm --prefix web run build && npm run stage:web-host')
assert.equal(rootPackage.scripts['gsd'], 'node scripts/dev-cli.js')
assert.equal(rootPackage.scripts['gsd:web'], 'npm run build:pi && npm run copy-resources && node scripts/build-web-if-stale.cjs && node scripts/dev-cli.js --web')
assert.equal(rootPackage.scripts['gsd:web:stop'], 'node scripts/dev-cli.js web stop')
assert.ok(rootPackage.files.includes('dist/web'))
const webPackage = JSON.parse(readFileSync(join(projectRoot, 'web', 'package.json'), 'utf-8'))
assert.equal(webPackage.scripts['start:standalone'], 'node .next/standalone/web/server.js')
})
test('web mode launcher defines or imports a browser opener', () => {
const source = readFileSync(join(projectRoot, 'src', 'web-mode.ts'), 'utf-8')
// openBrowser is now defined directly in web-mode.ts (was previously imported from onboarding.js)
assert.match(source, /openBrowser/)
})
test('cli.ts branches to web mode before interactive startup and preserves cwd-scoped launch inputs', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-cli-'))
const cwd = join(tmp, 'project space')
mkdirSync(cwd, { recursive: true })
let launchInputs: { cwd: string; projectSessionsDir: string; agentDir: string } | undefined
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const cliSource = readFileSync(join(projectRoot, 'src', 'cli.ts'), 'utf-8')
const branchIndex = cliSource.indexOf('const webBranch = await runWebCliBranch')
const modelRegistryIndex = cliSource.indexOf('const modelRegistry =')
assert.ok(branchIndex !== -1, 'cli.ts contains an explicit web branch handoff')
assert.ok(modelRegistryIndex !== -1, 'cli.ts still contains the model-registry startup path')
assert.ok(branchIndex < modelRegistryIndex, 'web branch runs before interactive startup state is constructed')
const result = await cliWeb.runWebCliBranch(cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web']), {
cwd: () => cwd,
runWebMode: async (options) => {
launchInputs = options
return {
mode: 'web',
ok: true,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host: '127.0.0.1',
port: 43123,
url: 'http://127.0.0.1:43123',
hostKind: 'source-dev',
hostPath: '/tmp/fake-web/package.json',
hostRoot: '/tmp/fake-web',
}
},
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected --web branch to be handled')
assert.equal(result.exitCode, 0)
assert.deepEqual(launchInputs, {
cwd,
projectSessionsDir: cliWeb.getProjectSessionsDir(cwd),
agentDir: join(process.env.HOME || '', '.gsd', 'agent'),
host: undefined,
port: undefined,
allowedOrigins: undefined,
})
})
test('launchWebMode prefers the packaged standalone host and opens the resolved URL', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-host-'))
const standaloneRoot = join(tmp, 'dist', 'web', 'standalone')
const serverPath = join(standaloneRoot, 'server.js')
mkdirSync(standaloneRoot, { recursive: true })
writeFileSync(serverPath, 'console.log("stub")\n')
let initResourcesCalled = false
let unrefCalled = false
let openedUrl = ''
let stderrOutput = ''
let spawnInvocation:
| { command: string; args: readonly string[]; options: Record<string, any> }
| undefined
let writtenPid: { path: string; pid: number } | undefined
const pidFilePath = join(tmp, 'web-server.pid')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const status = await webMode.launchWebMode(
{
cwd: '/tmp/current-project',
projectSessionsDir: '/tmp/.gsd/sessions/--tmp-current-project--',
agentDir: '/tmp/.gsd/agent',
packageRoot: tmp,
},
{
initResources: () => {
initResourcesCalled = true
},
resolvePort: async () => 45123,
execPath: '/custom/node',
env: { TEST_ENV: '1' },
spawn: (command, args, options) => {
spawnInvocation = { command, args, options: options as Record<string, any> }
return {
pid: 99999,
once: () => undefined,
unref: () => {
unrefCalled = true
},
} as any
},
waitForBootReady: async () => undefined,
openBrowser: (url) => {
openedUrl = url
},
pidFilePath,
writePidFile: (path, pid) => {
writtenPid = { path, pid }
webMode.writePidFile(path, pid)
},
stderr: {
write(chunk: string) {
stderrOutput += chunk
return true
},
},
},
)
assert.equal(status.ok, true)
if (!status.ok) throw new Error('expected successful web launch status')
assert.equal(status.hostKind, 'packaged-standalone')
assert.equal(status.hostPath, serverPath)
assert.equal(status.url, 'http://127.0.0.1:45123')
assert.equal(initResourcesCalled, true)
assert.equal(unrefCalled, true)
// The browser URL now includes a random auth token as a fragment
assert.match(openedUrl, /^http:\/\/127\.0\.0\.1:45123\/#token=[a-f0-9]{64}$/)
// Extract the auth token the launcher generated so we can verify it was
// passed consistently to both the env and the browser URL.
const authToken = openedUrl.replace('http://127.0.0.1:45123/#token=', '')
assert.deepEqual(spawnInvocation, {
command: '/custom/node',
args: [serverPath],
options: {
cwd: standaloneRoot,
detached: true,
stdio: 'ignore',
windowsHide: true,
shell: false,
env: {
TEST_ENV: '1',
HOSTNAME: '127.0.0.1',
PORT: '45123',
GSD_WEB_HOST: '127.0.0.1',
GSD_WEB_PORT: '45123',
GSD_WEB_AUTH_TOKEN: authToken,
GSD_WEB_PROJECT_CWD: '/tmp/current-project',
GSD_WEB_PROJECT_SESSIONS_DIR: '/tmp/.gsd/sessions/--tmp-current-project--',
GSD_WEB_PACKAGE_ROOT: tmp,
GSD_WEB_HOST_KIND: 'packaged-standalone',
},
},
})
assert.match(stderrOutput, /status=started/)
assert.match(stderrOutput, /port=45123/)
// PID file must be written with the spawned process's PID
assert.deepEqual(writtenPid, { path: pidFilePath, pid: 99999 })
assert.equal(webMode.readPidFile(pidFilePath), 99999)
})
test('stopWebMode kills process by PID and removes PID file', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stop-'))
const pidFilePath = join(tmp, 'web-server.pid')
let stderrOutput = ''
let killedPid: number | undefined
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
webMode.writePidFile(pidFilePath, 12345)
const result = webMode.stopWebMode({
pidFilePath,
readPidFile: webMode.readPidFile,
deletePidFile: webMode.deletePidFile,
stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } },
// Override process.kill to avoid killing a real process in tests
})
// Since PID 12345 is almost certainly dead, stopWebMode should succeed by treating ESRCH as "already gone"
assert.equal(result.ok, true)
assert.match(stderrOutput, /pid=12345/)
})
test('stopWebMode reports error when no PID file exists', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stop-nopid-'))
const pidFilePath = join(tmp, 'web-server.pid')
let stderrOutput = ''
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const result = webMode.stopWebMode({
pidFilePath,
readPidFile: webMode.readPidFile,
deletePidFile: webMode.deletePidFile,
stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } },
})
assert.equal(result.ok, false)
assert.equal(result.reason, 'no-pid-file')
assert.match(stderrOutput, /not running/)
})
test('runWebCliBranch handles "web stop" subcommand without --web flag', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-branch-stop-'))
const pidFilePath = join(tmp, 'web-server.pid')
let stderrOutput = ''
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'stop'])
assert.equal(flags.web, undefined)
assert.deepEqual(flags.messages, ['web', 'stop'])
const result = await cliWeb.runWebCliBranch(flags, {
stopWebMode: (deps) => {
return webMode.stopWebMode({ ...deps, pidFilePath })
},
stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } },
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected web stop to be handled')
assert.equal(result.exitCode, 1) // no PID file — expected failure
if (result.action !== 'stop') throw new Error('expected action=stop')
assert.equal(result.stopResult.ok, false)
})
// ─── Path argument tests ──────────────────────────────────────────────
test('parseCliArgs captures --web <path>', () => {
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '/tmp/my-project'])
assert.equal(flags.web, true)
assert.equal(flags.webPath, '/tmp/my-project')
assert.deepEqual(flags.messages, [])
})
test('parseCliArgs captures --web with relative path', () => {
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '../other-project'])
assert.equal(flags.web, true)
assert.equal(flags.webPath, '../other-project')
})
test('parseCliArgs does not capture --web followed by a flag as path', () => {
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '--model', 'test'])
assert.equal(flags.web, true)
assert.equal(flags.webPath, undefined)
assert.equal(flags.model, 'test')
})
test('gsd web <path> is handled as web start with path', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-path-'))
const projectDir = join(tmp, 'my-project')
mkdirSync(projectDir, { recursive: true })
let launchedCwd = ''
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', projectDir])
assert.deepEqual(flags.messages, ['web', projectDir])
const result = await cliWeb.runWebCliBranch(flags, {
runWebMode: async (options) => {
launchedCwd = options.cwd
return {
mode: 'web',
ok: true,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host: '127.0.0.1',
port: 43124,
url: 'http://127.0.0.1:43124',
hostKind: 'source-dev',
hostPath: '/tmp/fake-web/package.json',
hostRoot: '/tmp/fake-web',
}
},
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected web branch to be handled')
assert.equal(result.exitCode, 0)
assert.equal(launchedCwd, projectDir)
})
test('gsd web start <path> resolves path and launches', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-start-path-'))
const projectDir = join(tmp, 'another-project')
mkdirSync(projectDir, { recursive: true })
let launchedCwd = ''
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'start', projectDir])
assert.deepEqual(flags.messages, ['web', 'start', projectDir])
const result = await cliWeb.runWebCliBranch(flags, {
runWebMode: async (options) => {
launchedCwd = options.cwd
return {
mode: 'web',
ok: true,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host: '127.0.0.1',
port: 43125,
url: 'http://127.0.0.1:43125',
hostKind: 'source-dev',
hostPath: '/tmp/fake-web/package.json',
hostRoot: '/tmp/fake-web',
}
},
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected web branch to be handled')
assert.equal(result.exitCode, 0)
assert.equal(launchedCwd, projectDir)
})
test('gsd --web <path> resolves path and launches', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-flag-path-'))
const projectDir = join(tmp, 'flagged-project')
mkdirSync(projectDir, { recursive: true })
let launchedCwd = ''
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', projectDir])
assert.equal(flags.web, true)
assert.equal(flags.webPath, projectDir)
const result = await cliWeb.runWebCliBranch(flags, {
runWebMode: async (options) => {
launchedCwd = options.cwd
return {
mode: 'web',
ok: true,
cwd: options.cwd,
projectSessionsDir: options.projectSessionsDir,
host: '127.0.0.1',
port: 43126,
url: 'http://127.0.0.1:43126',
hostKind: 'source-dev',
hostPath: '/tmp/fake-web/package.json',
hostRoot: '/tmp/fake-web',
}
},
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected web branch to be handled')
assert.equal(result.exitCode, 0)
assert.equal(launchedCwd, projectDir)
})
test('gsd --web <nonexistent-path> fails with clear error', async () => {
let stderrOutput = ''
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', '--web', '/tmp/nonexistent-gsd-test-path-xyz'])
const result = await cliWeb.runWebCliBranch(flags, {
stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } },
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected web branch to be handled')
assert.equal(result.exitCode, 1)
if (result.action !== 'start') throw new Error('expected action=start')
assert.equal(result.status.ok, false)
if (result.status.ok) throw new Error('expected failed status')
assert.match(result.status.failureReason, /does not exist/)
assert.match(stderrOutput, /does not exist/)
})
test('launch failure surfaces status and reason before browser open', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-missing-host-'))
let openedUrl = ''
let stderrOutput = ''
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const status = await webMode.launchWebMode(
{
cwd: '/tmp/current-project',
projectSessionsDir: '/tmp/.gsd/sessions/--tmp-current-project--',
agentDir: '/tmp/.gsd/agent',
packageRoot: tmp,
},
{
openBrowser: (url) => {
openedUrl = url
},
stderr: {
write(chunk: string) {
stderrOutput += chunk
return true
},
},
},
)
assert.equal(status.ok, false)
if (status.ok) throw new Error('expected failed web launch status')
assert.equal(status.hostPath, null)
assert.equal(status.url, null)
assert.equal(openedUrl, '')
assert.match(status.failureReason, /host bootstrap not found/)
assert.match(stderrOutput, /status=failed/)
assert.match(stderrOutput, /reason=host bootstrap not found/)
})
// ─── Instance registry tests ─────────────────────────────────────────
test('registerInstance and readInstanceRegistry round-trip', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-registry-'))
const registryPath = join(tmp, 'web-instances.json')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
webMode.registerInstance('/tmp/project-a', { pid: 1001, port: 3000, url: 'http://127.0.0.1:3000' }, registryPath)
webMode.registerInstance('/tmp/project-b', { pid: 1002, port: 3001, url: 'http://127.0.0.1:3001' }, registryPath)
const registry = webMode.readInstanceRegistry(registryPath)
assert.equal(Object.keys(registry).length, 2)
assert.equal(registry[resolve('/tmp/project-a')]?.pid, 1001)
assert.equal(registry[resolve('/tmp/project-b')]?.port, 3001)
assert.ok(registry[resolve('/tmp/project-a')]?.startedAt)
})
test('unregisterInstance removes a single entry', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-unreg-'))
const registryPath = join(tmp, 'web-instances.json')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
webMode.registerInstance('/tmp/project-a', { pid: 1001, port: 3000, url: 'http://127.0.0.1:3000' }, registryPath)
webMode.registerInstance('/tmp/project-b', { pid: 1002, port: 3001, url: 'http://127.0.0.1:3001' }, registryPath)
webMode.unregisterInstance('/tmp/project-a', registryPath)
const registry = webMode.readInstanceRegistry(registryPath)
assert.equal(Object.keys(registry).length, 1)
assert.equal(registry[resolve('/tmp/project-a')], undefined)
assert.equal(registry[resolve('/tmp/project-b')]?.pid, 1002)
})
test('stopWebMode with projectCwd reports not-found when not in registry', () => {
let stderrOutput = ''
const result = webMode.stopWebMode(
{ stderr: { write: (chunk: string) => { stderrOutput += chunk; return true } } },
{ projectCwd: '/tmp/nonexistent-project-for-stop-test' },
)
assert.equal(result.ok, false)
assert.equal(result.reason, 'not-found')
assert.match(stderrOutput, /No web server running/)
})
test('gsd web stop all is parsed and dispatched', async () => {
let stopOptions: { projectCwd?: string; all?: boolean } | undefined
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'stop', 'all'])
assert.deepEqual(flags.messages, ['web', 'stop', 'all'])
const result = await cliWeb.runWebCliBranch(flags, {
stopWebMode: (_deps, opts) => {
stopOptions = opts
return { ok: true, stoppedCount: 2 }
},
stderr: { write: () => true },
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected handled')
assert.equal(result.exitCode, 0)
assert.equal(stopOptions?.all, true)
assert.equal(stopOptions?.projectCwd, undefined)
})
test('gsd web stop <path> is parsed and dispatched with resolved path', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stop-path-'))
let stopOptions: { projectCwd?: string; all?: boolean } | undefined
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const flags = cliWeb.parseCliArgs(['node', 'dist/loader.js', 'web', 'stop', tmp])
const result = await cliWeb.runWebCliBranch(flags, {
cwd: () => '/',
stopWebMode: (_deps, opts) => {
stopOptions = opts
return { ok: true, stoppedCount: 1 }
},
stderr: { write: () => true },
})
assert.equal(result.handled, true)
if (!result.handled) throw new Error('expected handled')
assert.equal(result.exitCode, 0)
assert.equal(stopOptions?.projectCwd, tmp)
assert.equal(stopOptions?.all, false)
})
// ─── Context-aware launch detection tests ──────────────────────────────
test('resolveContextAwareCwd returns project cwd when inside a project under dev root', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-'))
const devRoot = join(tmp, 'devroot')
const projectA = join(devRoot, 'projectA')
const prefsPath = join(tmp, 'web-preferences.json')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
mkdirSync(projectA, { recursive: true })
writeFileSync(prefsPath, JSON.stringify({ devRoot }))
const result = cliWeb.resolveContextAwareCwd(projectA, prefsPath)
assert.equal(result, projectA)
})
test('resolveContextAwareCwd returns cwd unchanged when AT dev root', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-'))
const devRoot = join(tmp, 'devroot')
const prefsPath = join(tmp, 'web-preferences.json')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
mkdirSync(devRoot, { recursive: true })
writeFileSync(prefsPath, JSON.stringify({ devRoot }))
const result = cliWeb.resolveContextAwareCwd(devRoot, prefsPath)
assert.equal(result, devRoot)
})
test('resolveContextAwareCwd returns cwd unchanged when no dev root configured', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-'))
const prefsPath = join(tmp, 'web-preferences.json')
const cwd = join(tmp, 'somedir')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
mkdirSync(cwd, { recursive: true })
writeFileSync(prefsPath, JSON.stringify({ theme: 'dark' }))
const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath)
assert.equal(result, cwd)
})
test('resolveContextAwareCwd returns cwd unchanged when prefs file missing', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-'))
const prefsPath = join(tmp, 'nonexistent-prefs.json')
const cwd = join(tmp, 'somedir')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
mkdirSync(cwd, { recursive: true })
const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath)
assert.equal(result, cwd)
})
test('resolveContextAwareCwd returns cwd unchanged when dev root path is stale', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-'))
const prefsPath = join(tmp, 'web-preferences.json')
const cwd = join(tmp, 'somedir')
const staleDevRoot = join(tmp, 'nonexistent-devroot')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
mkdirSync(cwd, { recursive: true })
writeFileSync(prefsPath, JSON.stringify({ devRoot: staleDevRoot }))
const result = cliWeb.resolveContextAwareCwd(cwd, prefsPath)
assert.equal(result, cwd)
})
test('resolveContextAwareCwd resolves nested cwd to one-level-deep project', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-'))
const devRoot = join(tmp, 'devroot')
const projectA = join(devRoot, 'projectA')
const nested = join(projectA, 'src', 'components', 'deep')
const prefsPath = join(tmp, 'web-preferences.json')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
mkdirSync(nested, { recursive: true })
writeFileSync(prefsPath, JSON.stringify({ devRoot }))
const result = cliWeb.resolveContextAwareCwd(nested, prefsPath)
assert.equal(result, projectA)
})
test('resolveContextAwareCwd returns cwd unchanged when outside dev root', (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-ctx-aware-'))
const devRoot = join(tmp, 'devroot')
const outsideDir = join(tmp, 'elsewhere')
const prefsPath = join(tmp, 'web-preferences.json')
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
mkdirSync(devRoot, { recursive: true })
mkdirSync(outsideDir, { recursive: true })
writeFileSync(prefsPath, JSON.stringify({ devRoot }))
const result = cliWeb.resolveContextAwareCwd(outsideDir, prefsPath)
assert.equal(result, outsideDir)
})
// ─── Stale instance cleanup tests ─────────────────────────────────────
test('launchWebMode kills stale instance for same cwd before spawning', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-stale-'))
const standaloneRoot = join(tmp, 'dist', 'web', 'standalone')
const serverPath = join(standaloneRoot, 'server.js')
mkdirSync(standaloneRoot, { recursive: true })
writeFileSync(serverPath, 'console.log("stub")\n')
const registryPath = join(tmp, 'web-instances.json')
const pidFilePath = join(tmp, 'web-server.pid')
const cwd = '/tmp/stale-project'
// Pre-register a stale instance for the same cwd
webMode.registerInstance(cwd, { pid: 77777, port: 3000, url: 'http://127.0.0.1:3000' }, registryPath)
let stderrOutput = ''
let spawnCalled = false
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const status = await webMode.launchWebMode(
{
cwd,
projectSessionsDir: '/tmp/.gsd/sessions/stale',
agentDir: '/tmp/.gsd/agent',
packageRoot: tmp,
},
{
initResources: () => {},
resolvePort: async () => 45200,
execPath: '/custom/node',
env: { TEST_ENV: '1' },
spawn: (command, args, options) => {
spawnCalled = true
return {
pid: 88888,
once: () => undefined,
unref: () => {},
} as any
},
waitForBootReady: async () => undefined,
openBrowser: () => {},
pidFilePath,
writePidFile: webMode.writePidFile,
registryPath,
stderr: {
write(chunk: string) {
stderrOutput += chunk
return true
},
},
},
)
assert.equal(status.ok, true)
assert.equal(spawnCalled, true)
// Stale instance for same cwd should have been cleaned up
assert.match(stderrOutput, /Cleaning up stale/)
// New instance should be registered
const registry = webMode.readInstanceRegistry(registryPath)
assert.equal(registry[resolve(cwd)]?.pid, 88888)
})
test('launchWebMode does not log cleanup when no stale instance exists', async (t) => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-no-stale-'))
const standaloneRoot = join(tmp, 'dist', 'web', 'standalone')
const serverPath = join(standaloneRoot, 'server.js')
mkdirSync(standaloneRoot, { recursive: true })
writeFileSync(serverPath, 'console.log("stub")\n')
const registryPath = join(tmp, 'web-instances.json')
const pidFilePath = join(tmp, 'web-server.pid')
let stderrOutput = ''
t.after(() => { rmSync(tmp, { recursive: true, force: true }) });
const status = await webMode.launchWebMode(
{
cwd: '/tmp/clean-project',
projectSessionsDir: '/tmp/.gsd/sessions/clean',
agentDir: '/tmp/.gsd/agent',
packageRoot: tmp,
},
{
initResources: () => {},
resolvePort: async () => 45201,
execPath: '/custom/node',
env: { TEST_ENV: '1' },
spawn: () => ({
pid: 88889,
once: () => undefined,
unref: () => {},
} as any),
waitForBootReady: async () => undefined,
openBrowser: () => {},
pidFilePath,
writePidFile: webMode.writePidFile,
registryPath,
stderr: {
write(chunk: string) {
stderrOutput += chunk
return true
},
},
},
)
assert.equal(status.ok, true)
// No cleanup message when no stale instance exists
assert.equal(stderrOutput.includes('Cleaning up stale'), false)
})