test: harden daemon lifecycle and e2e reliability across app/cli/server

This commit is contained in:
Mohamed Boudra
2026-02-25 10:21:46 +07:00
parent 094ce49d4f
commit a060ae1256
98 changed files with 5716 additions and 1771 deletions
+3 -1
View File
@@ -17,7 +17,9 @@
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json --incremental false",
"prepack": "npm run build",
"typecheck": "tsc --noEmit",
"test:e2e": "npx zx tests/run-all.ts",
"test": "npm run test:local",
"test:local": "tsx tests/run-all.ts",
"test:e2e": "npm run test:local",
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
},
"dependencies": {
+108 -24
View File
@@ -1,8 +1,9 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, existsSync, openSync, readFileSync, rmSync } from 'node:fs'
import { closeSync, existsSync, openSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
export interface DaemonStartOptions {
port?: string
@@ -219,19 +220,65 @@ function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
}
}
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
}
try {
return signalProcess(pid, signal)
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'EPERM') {
return true
}
throw err
}
}
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
}
if (process.platform === 'win32') {
return signalProcessSafely(pid, signal)
}
try {
process.kill(-pid, signal)
return true
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'ESRCH') {
return signalProcessSafely(pid, signal)
}
if (code === 'EPERM') {
return true
}
throw err
}
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
return true
}
await sleep(PID_POLL_INTERVAL_MS)
}
return !isProcessRunning(pid)
}
type LifecycleShutdownAttempt =
| { requested: true }
| { requested: false; reason: string }
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export function resolveLocalPaseoHome(home?: string): string {
return resolvePaseoHome(envWithHome(home))
}
@@ -384,6 +431,41 @@ export function startLocalDaemonForeground(options: DaemonStartOptions): number
return result.status ?? 1
}
async function requestLifecycleShutdown(
state: LocalDaemonState,
timeoutMs: number
): Promise<LifecycleShutdownAttempt> {
const host = resolveTcpHostFromListen(state.listen)
if (!host) {
return {
requested: false,
reason: 'daemon listen target is not TCP, falling back to owner PID signal',
}
}
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) })
if (!client) {
return {
requested: false,
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
}
}
try {
await client.shutdownServer()
return { requested: true }
} catch (error) {
return {
requested: false,
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
error
)}), falling back to owner PID signal`,
}
} finally {
await client.close().catch(() => undefined)
}
}
export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {}
): Promise<StopLocalDaemonResult> {
@@ -405,28 +487,28 @@ export async function stopLocalDaemon(
}
const pid = state.pidInfo.pid
const signaled = signalProcess(pid, 'SIGTERM')
if (!signaled) {
return {
action: 'not_running',
home: state.home,
pid,
forced: false,
message: 'Daemon process was already stopped',
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs)
const lifecycleRequested = shutdownAttempt.requested
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason
let forced = false
if (!lifecycleRequested) {
const signaled = signalProcessSafely(pid, 'SIGTERM')
if (!signaled) {
return {
action: 'not_running',
home: state.home,
pid,
forced: false,
message: 'Daemon process was already stopped',
}
}
}
let forced = false
let stopped = await waitForExit(pid, timeoutMs)
let stopped = await waitForPidExit(pid, timeoutMs)
if (!stopped && options.force) {
forced = true
const killSent = signalProcess(pid, 'SIGKILL')
if (killSent) {
stopped = await waitForExit(pid, KILL_TIMEOUT_MS)
} else {
stopped = true
}
signalProcessGroupSafely(pid, 'SIGKILL')
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS)
}
if (!stopped) {
@@ -435,13 +517,15 @@ export async function stopLocalDaemon(
)
}
rmSync(state.pidPath, { force: true })
return {
action: 'stopped',
home: state.home,
pid,
forced,
message: forced ? 'Daemon was force-stopped' : 'Daemon stopped gracefully',
message: forced
? 'Daemon owner process was force-stopped'
: lifecycleRequested
? 'Daemon stopped gracefully'
: fallbackMessage ?? 'Daemon stopped via owner PID signal',
}
}
+191 -110
View File
@@ -4,7 +4,8 @@
* Phase 15: Provider Command Tests
*
* Tests provider commands for listing providers and models.
* Provider data is static and doesn't require a running daemon.
* Provider ls data is static, while provider models are fetched via daemon integration.
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
*
* Tests:
* - provider --help shows subcommands
@@ -19,129 +20,209 @@
*/
import assert from 'node:assert'
import { $ } from 'zx'
$.verbose = false
import { createE2ETestContext } from './helpers/test-daemon.ts'
console.log('=== Provider Commands ===\n')
// Test 1: provider --help shows subcommands
{
console.log('Test 1: provider --help shows subcommands')
const result = await $`npx paseo provider --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls')
assert(result.stdout.includes('models'), 'help should mention models')
console.log('✓ provider --help shows subcommands\n')
type ProviderModel = {
model: string
id: string
description?: string
}
// Test 2: provider ls lists all providers
{
console.log('Test 2: provider ls lists all providers')
const result = await $`npx paseo provider ls`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
assert(result.stdout.includes('claude'), 'output should include claude')
assert(result.stdout.includes('codex'), 'output should include codex')
assert(result.stdout.includes('opencode'), 'output should include opencode')
assert(result.stdout.includes('available'), 'output should show available status')
console.log('✓ provider ls lists all providers\n')
let claudeModelIdsFromJson: string[] = []
let claudeModelsFromJson: ProviderModel[] = []
const ctx = await createE2ETestContext({ timeout: 120000 })
async function runProviderModelsJson(
provider: 'claude' | 'codex' | 'opencode'
): Promise<ProviderModel[]> {
const transientNeedles = ['transport closed', 'timed out', 'timeout', 'socket', 'econn']
for (let attempt = 1; attempt <= 3; attempt++) {
const result = await ctx.paseo(['provider', 'models', provider, '--json'])
if (result.exitCode === 0) {
return JSON.parse(result.stdout.trim()) as ProviderModel[]
}
const combined = `${result.stdout}\n${result.stderr}`
const normalized = combined.toLowerCase()
const isTransient = transientNeedles.some((needle) => normalized.includes(needle))
if (!isTransient || attempt === 3) {
assert.fail(`provider models ${provider} should exit 0\n${combined}`)
}
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
}
assert.fail(`provider models ${provider} exhausted retries`)
}
// Test 3: provider ls --json outputs valid JSON
{
console.log('Test 3: provider ls --json outputs valid JSON')
const result = await $`npx paseo provider ls --json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 providers')
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
console.log('✓ provider ls --json outputs valid JSON\n')
}
try {
// Test 1: provider --help shows subcommands
{
console.log('Test 1: provider --help shows subcommands')
const result = await ctx.paseo(['provider', '--help'])
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls')
assert(result.stdout.includes('models'), 'help should mention models')
console.log('✓ provider --help shows subcommands\n')
}
// Test 4: provider ls --quiet outputs provider names only
{
console.log('Test 4: provider ls --quiet outputs provider names only')
const result = await $`npx paseo provider ls --quiet`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude'), 'should include claude')
assert(lines.includes('codex'), 'should include codex')
assert(lines.includes('opencode'), 'should include opencode')
console.log('✓ provider ls --quiet outputs provider names only\n')
}
// Test 2: provider ls lists all providers
{
console.log('Test 2: provider ls lists all providers')
const result = await ctx.paseo(['provider', 'ls'])
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
assert(result.stdout.includes('claude'), 'output should include claude')
assert(result.stdout.includes('codex'), 'output should include codex')
assert(result.stdout.includes('opencode'), 'output should include opencode')
assert(result.stdout.includes('available'), 'output should show available status')
console.log('✓ provider ls lists all providers\n')
}
// Test 5: provider models claude lists claude models
{
console.log('Test 5: provider models claude lists claude models')
const result = await $`npx paseo provider models claude`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models claude should exit 0')
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude-sonnet-4')
assert(result.stdout.includes('claude-opus-4-20250514'), 'output should include claude-opus-4')
assert(result.stdout.includes('claude-3-5-haiku-20241022'), 'output should include claude-haiku')
console.log('✓ provider models claude lists claude models\n')
}
// Test 3: provider ls --json outputs valid JSON
{
console.log('Test 3: provider ls --json outputs valid JSON')
const result = await ctx.paseo(['provider', 'ls', '--json'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 providers')
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
console.log('✓ provider ls --json outputs valid JSON\n')
}
// Test 6: provider models codex lists codex models
{
console.log('Test 6: provider models codex lists codex models')
const result = await $`npx paseo provider models codex`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models codex should exit 0')
assert(result.stdout.includes('o3-mini'), 'output should include o3-mini')
assert(result.stdout.includes('o4-mini'), 'output should include o4-mini')
console.log('✓ provider models codex lists codex models\n')
}
// Test 4: provider ls --quiet outputs provider names only
{
console.log('Test 4: provider ls --quiet outputs provider names only')
const result = await ctx.paseo(['provider', 'ls', '--quiet'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude'), 'should include claude')
assert(lines.includes('codex'), 'should include codex')
assert(lines.includes('opencode'), 'should include opencode')
console.log('✓ provider ls --quiet outputs provider names only\n')
}
// Test 7: provider models opencode lists opencode models
{
console.log('Test 7: provider models opencode lists opencode models')
const result = await $`npx paseo provider models opencode`.nothrow()
assert.strictEqual(result.exitCode, 0, 'provider models opencode should exit 0')
// opencode supports both claude and codex models
assert(result.stdout.includes('claude-sonnet-4-20250514'), 'output should include claude models')
assert(result.stdout.includes('o3-mini'), 'output should include codex models')
console.log('✓ provider models opencode lists opencode models\n')
}
// Test 5: provider models claude lists canonical model aliases
{
console.log('Test 5: provider models claude lists canonical model aliases')
const data = await runProviderModelsJson('claude')
assert.strictEqual(data.length, 3, 'should have exactly 3 claude models')
const byId = new Map(data.map((model) => [model.id, model]))
const ids = [...byId.keys()].sort()
assert.deepStrictEqual(ids, ['default', 'haiku', 'opus'], 'claude IDs should be default/opus/haiku')
assert.strictEqual(byId.get('default')?.model, 'Sonnet 4.5', 'default claude alias should map to Sonnet 4.5')
assert.strictEqual(byId.get('opus')?.model, 'Opus 4.6', 'opus claude alias should map to Opus 4.6')
assert.strictEqual(byId.get('haiku')?.model, 'Haiku 4.5', 'haiku claude alias should map to Haiku 4.5')
assert(
(byId.get('default')?.description ?? '').includes('Best for everyday tasks'),
'default claude description should mention everyday tasks'
)
assert(
(byId.get('opus')?.description ?? '').includes('Most capable'),
'opus claude description should mention most capable'
)
assert(
(byId.get('haiku')?.description ?? '').includes('Fastest'),
'haiku claude description should mention fastest'
)
console.log('✓ provider models claude lists canonical model aliases\n')
}
// Test 8: provider models unknown fails with error
{
console.log('Test 8: provider models unknown fails with error')
const result = await $`npx paseo provider models unknown`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
const output = result.stdout + result.stderr
assert(
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
'error should mention unknown provider'
)
console.log('✓ provider models unknown fails with error\n')
}
// Test 6: provider models codex includes concrete codex model IDs
{
console.log('Test 6: provider models codex includes concrete codex model IDs')
const data = await runProviderModelsJson('codex')
assert(data.length >= 6, 'codex model list should include current codex lineup')
const ids = data.map((m) => m.id)
assert.strictEqual(new Set(ids).size, ids.length, 'codex model IDs should be unique')
assert(ids.includes('gpt-5.3-codex'), 'codex output should include gpt-5.3-codex')
assert(ids.includes('gpt-5.3-codex-spark'), 'codex output should include gpt-5.3-codex-spark')
assert(ids.includes('gpt-5.1-codex-max'), 'codex output should include gpt-5.1-codex-max')
assert(ids.includes('gpt-5.1-codex-mini'), 'codex output should include gpt-5.1-codex-mini')
console.log('✓ provider models codex includes concrete codex model IDs\n')
}
// Test 9: provider models --json outputs valid JSON
{
console.log('Test 9: provider models --json outputs valid JSON')
const result = await $`npx paseo provider models claude --json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 models for claude')
assert(data.every((m: { model: string; id: string }) => m.model && m.id), 'each model should have name and id')
console.log('✓ provider models --json outputs valid JSON\n')
}
// Test 7: provider models opencode returns namespaced model IDs
{
console.log('Test 7: provider models opencode returns namespaced model IDs')
const data = await runProviderModelsJson('opencode')
assert(data.length >= 3, 'opencode model list should not be empty')
const ids = data.map((m) => m.id)
assert(data.every((m) => m.id.includes('/')), 'opencode model IDs should be provider-namespaced')
assert(ids.includes('opencode/gpt-5-nano'), 'opencode output should include opencode/gpt-5-nano')
assert(ids.includes('openai/o3-mini'), 'opencode output should include openai/o3-mini')
assert(
ids.includes('openai/gpt-5.3-codex-spark'),
'opencode output should include openai/gpt-5.3-codex-spark'
)
console.log('✓ provider models opencode returns namespaced model IDs\n')
}
// Test 10: provider models --quiet outputs model IDs only
{
console.log('Test 10: provider models --quiet outputs model IDs only')
const result = await $`npx paseo provider models claude --quiet`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude-sonnet-4-20250514'), 'should include claude-sonnet-4')
assert(lines.includes('claude-opus-4-20250514'), 'should include claude-opus-4')
assert(lines.includes('claude-3-5-haiku-20241022'), 'should include claude-haiku')
console.log('✓ provider models --quiet outputs model IDs only\n')
// Test 8: provider models unknown fails with error
{
console.log('Test 8: provider models unknown fails with error')
const result = await ctx.paseo(['provider', 'models', 'unknown'])
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
const output = result.stdout + result.stderr
assert(
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
'error should mention unknown provider'
)
console.log('✓ provider models unknown fails with error\n')
}
// Test 9: provider models --json outputs valid JSON
{
console.log('Test 9: provider models --json outputs valid JSON')
const data = await runProviderModelsJson('claude')
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have exactly 3 models for claude')
assert(data.every((m) => m.model && m.id), 'each model should have name and id')
const byId = new Map(data.map((model) => [model.id, model]))
const ids = [...byId.keys()].sort()
assert.deepStrictEqual(ids, ['default', 'haiku', 'opus'], 'claude JSON IDs should be deterministic')
assert.strictEqual(byId.get('default')?.model, 'Sonnet 4.5', 'default claude alias should map to Sonnet 4.5')
assert.strictEqual(byId.get('opus')?.model, 'Opus 4.6', 'opus claude alias should map to Opus 4.6')
assert.strictEqual(byId.get('haiku')?.model, 'Haiku 4.5', 'haiku claude alias should map to Haiku 4.5')
claudeModelIdsFromJson = data.map((m) => m.id)
claudeModelsFromJson = data
console.log('✓ provider models --json outputs valid JSON\n')
}
// Test 10: provider models --quiet outputs model IDs only
{
console.log('Test 10: provider models --quiet outputs model IDs only')
assert(claudeModelIdsFromJson.length > 0, 'claude model IDs should be captured from --json output')
const result = await ctx.paseo(['provider', 'models', 'claude', '--quiet'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n').filter(Boolean)
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert.deepStrictEqual(
[...lines].sort(),
[...claudeModelIdsFromJson].sort(),
'--quiet should print the same model IDs returned by --json'
)
assert.deepStrictEqual(
[...lines].sort(),
['default', 'haiku', 'opus'],
'--quiet should print canonical claude IDs'
)
assert(
claudeModelsFromJson.some((m) => m.id === 'default'),
'captured --json output should still include default claude model id'
)
console.log('✓ provider models --quiet outputs model IDs only\n')
}
} finally {
await ctx.stop()
}
console.log('=== All provider tests passed ===')
+15 -12
View File
@@ -8,7 +8,7 @@
* - Help and argument parsing
* - Validation for required update fields
* - Graceful daemon connection errors
* - Top-level alias support (`paseo update`)
* - Top-level daemon update alias behavior (`paseo update`)
*/
import assert from 'node:assert'
@@ -16,13 +16,14 @@ import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
console.log('=== Agent Update Command Tests ===\n')
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
// Resolve an available local port so daemon-not-running checks stay deterministic.
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
try {
@@ -104,25 +105,27 @@ try {
console.log('✓ agent --help shows update subcommand\n')
}
// Test 7: top-level update alias --help works
// Test 7: top-level update alias --help shows daemon update options
{
console.log('Test 7: top-level update --help works')
console.log('Test 7: top-level update --help shows daemon update options')
const result = await $`npx paseo update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag')
console.log('✓ top-level update --help works\n')
assert(result.stdout.includes('--home'), 'help should mention --home flag')
assert(result.stdout.includes('--yes'), 'help should mention --yes flag')
assert(result.stdout.includes('daemon update'), 'help should mention daemon update alias')
console.log('✓ top-level update --help shows daemon update options\n')
}
// Test 8: top-level update alias accepts flags
// Test 8: top-level update alias accepts daemon update flags
{
console.log('Test 8: top-level update alias accepts flags')
console.log('Test 8: top-level update alias accepts daemon update flags')
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo update abc123 --name "Alias Name" --host localhost:${port}`.nothrow()
await $`PASEO_HOME=${paseoHome} npx paseo update --home ${paseoHome} --yes --help`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept top-level update flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ top-level update alias accepts flags\n')
assert.strictEqual(result.exitCode, 0, 'update alias help with flags should exit 0')
console.log('✓ top-level update alias accepts daemon update flags\n')
}
} finally {
// Clean up temp directory
+10 -9
View File
@@ -5,24 +5,25 @@ import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
function randomPort(): number {
return 10000 + Math.floor(Math.random() * 50000)
}
console.log('=== Onboarding Command ===\n')
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
const port = randomPort()
const port = await getAvailablePort()
try {
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
const onboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npm run -s cli --`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow()
assert.strictEqual(onboard.exitCode, 0, `onboard should succeed: ${onboard.stderr}`)
assert.strictEqual(
onboard.exitCode,
0,
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`
)
assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header')
assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header')
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL')
@@ -34,7 +35,7 @@ try {
assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
const status =
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon status --home ${paseoHome}`.nothrow()
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow()
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n')
@@ -57,7 +58,7 @@ try {
)
console.log('✓ non-interactive run persisted voice disabled choices\n')
} finally {
await $`PASEO_HOME=${paseoHome} npm run -s cli -- daemon stop --home ${paseoHome} --force`.nothrow()
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
@@ -11,10 +11,16 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
@@ -68,7 +74,7 @@ type DaemonStatus = {
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
@@ -104,7 +110,7 @@ async function waitFor(
console.log('=== Daemon Stop (supervisor regression) ===\n')
const port = 10000 + Math.floor(Math.random() * 50000)
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
@@ -118,6 +124,7 @@ try {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: 'false',
@@ -159,7 +166,7 @@ try {
console.log('Test 2: `paseo daemon stop` should stop without respawn')
const stopResult =
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown }
assert.strictEqual(stopJson.action, 'stopped', 'stop should report stopped action')
@@ -189,6 +196,14 @@ try {
const statusAfterStop = await readDaemonStatus(paseoHome)
assert.strictEqual(statusAfterStop.status, 'stopped', 'daemon should remain stopped after stop command')
assert(
recentSupervisorLogs.includes('Shutdown requested by worker. Stopping worker...'),
`stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`
)
assert(
!recentSupervisorLogs.includes('cli_shutdown'),
`supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`
)
console.log('✓ stop leaves supervised daemon stopped (no respawn)\n')
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
@@ -200,7 +215,7 @@ try {
)
}
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
@@ -0,0 +1,217 @@
#!/usr/bin/env npx tsx
/**
* Regression: a single SIGINT sent to a supervised daemon-runner must allow
* graceful daemon lifecycle shutdown to complete (no early forced exit path).
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function signalProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
if (process.platform === 'win32') {
try {
process.kill(pid, signal)
return true
} catch {
return false
}
}
try {
process.kill(-pid, signal)
return true
} catch {
return false
}
}
type DaemonStatus = {
status: string | null
pid: number | null
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
} catch {
return { status: null, pid: null }
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await check()) {
return
}
await sleep(pollIntervalMs)
}
throw new Error(message)
}
type ExitResult = {
code: number | null
signal: NodeJS.Signals | null
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('timed out waiting for process exit'))
}, timeoutMs)
processRef.once('exit', (code, signal) => {
clearTimeout(timeout)
resolve({ code, signal })
})
})
}
console.log('=== Daemon SIGINT (supervisor regression) ===\n')
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-sigint-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
let supervisorProcess: ChildProcess | null = null
let recentSupervisorLogs = ''
try {
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
})
supervisorProcess.stdout?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stderr?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
},
120000,
'daemon did not become running in time'
)
console.log('✓ supervised daemon started\n')
console.log('Test 2: single SIGINT should shutdown gracefully without forced exit')
const exitPromise = waitForProcessExit(supervisorProcess, 30000)
const signaledGroup = signalProcessGroup(supervisorProcess.pid ?? -1, 'SIGINT')
if (!signaledGroup) {
supervisorProcess.kill('SIGINT')
}
const exit = await exitPromise
assert.strictEqual(exit.signal, null, `supervisor should exit cleanly, got signal=${exit.signal}`)
assert.strictEqual(exit.code, 0, `supervisor should exit with status 0, got code=${exit.code}`)
await waitFor(async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'stopped'
}, 15000, 'daemon status did not transition to stopped after SIGINT')
assert(
!recentSupervisorLogs.includes('Forcing exit...'),
`worker entered forced-exit path during single SIGINT:\n${recentSupervisorLogs}`
)
assert(
!recentSupervisorLogs.includes("Forcing shutdown - HTTP server didn't close in time"),
`worker hit shutdown timeout during single SIGINT:\n${recentSupervisorLogs}`
)
console.log('✓ single SIGINT completed graceful shutdown without forced exit\n')
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
const signaledGroup = signalProcessGroup(supervisorProcess.pid, 'SIGTERM')
if (!signaledGroup) {
supervisorProcess.kill('SIGTERM')
}
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
() => {
const killedGroup = signalProcessGroup(supervisorProcess!.pid ?? -1, 'SIGKILL')
if (!killedGroup) {
supervisorProcess?.kill('SIGKILL')
}
}
)
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
if (recentSupervisorLogs.trim().length === 0) {
console.log('(no supervisor logs captured)')
}
console.log('=== Supervisor SIGINT regression test passed ===')
@@ -0,0 +1,115 @@
#!/usr/bin/env npx tsx
/**
* Regression: `paseo daemon stop` must only act on daemon ownership state and
* must not discover/kill processes via home-scoped `ps` command heuristics.
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
$.verbose = false
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitForRunning(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (isProcessRunning(pid)) {
return
}
await sleep(50)
}
throw new Error(`Process ${pid} did not become running in time`)
}
console.log('=== Daemon Stop Ownership Regression ===\n')
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-ownership-'))
let decoyProcess: ChildProcess | null = null
try {
console.log('Test 1: start decoy process with daemon-like command markers')
decoyProcess = spawn(
process.execPath,
[
'-e',
// Keep the process alive long enough for stop command assertions.
'setInterval(() => {}, 1000)',
'daemon-runner.ts',
],
{
env: {
...process.env,
PASEO_HOME: paseoHome,
},
stdio: 'ignore',
detached: process.platform !== 'win32',
}
)
decoyProcess.unref()
const decoyPid = decoyProcess.pid
assert(Number.isInteger(decoyPid) && (decoyPid ?? 0) > 0, 'decoy pid should exist')
await waitForRunning(decoyPid!, 5000)
console.log(`✓ decoy process started (${decoyPid})\n`)
console.log('Test 2: daemon stop should report not_running and leave decoy untouched')
const stopResult =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
const parsed = JSON.parse(stopResult.stdout) as { action?: unknown }
assert.strictEqual(parsed.action, 'not_running', `stop should not target decoy process: ${stopResult.stdout}`)
assert(isProcessRunning(decoyPid!), 'decoy process must remain alive after stop')
console.log('✓ stop is ownership-driven and does not kill decoy process\n')
} finally {
if (decoyProcess?.pid && isProcessRunning(decoyProcess.pid)) {
try {
process.kill(decoyProcess.pid, 'SIGTERM')
} catch {
// ignore
}
await sleep(100)
if (isProcessRunning(decoyProcess.pid)) {
try {
process.kill(decoyProcess.pid, 'SIGKILL')
} catch {
// ignore
}
}
}
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== Daemon stop ownership regression test passed ===')
@@ -0,0 +1,214 @@
#!/usr/bin/env npx tsx
/**
* Regression: app-style restart requests must trigger supervised worker restart
* (worker PID changes) while keeping the daemon healthy.
*/
import assert from 'node:assert'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { tryConnectToDaemon } from '../src/utils/client.ts'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function readWorkerPid(supervisorPid: number): number | null {
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
return null
}
const result = spawnSync('ps', ['ax', '-o', 'pid=,ppid='], { encoding: 'utf8' })
if (result.status !== 0 || result.error) {
return null
}
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) {
continue
}
const [pidToken, ppidToken] = trimmed.split(/\s+/)
const pid = Number.parseInt(pidToken ?? '', 10)
const ppid = Number.parseInt(ppidToken ?? '', 10)
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
continue
}
if (ppid === supervisorPid && pid > 0) {
return pid
}
}
return null
}
type DaemonStatus = {
status: string | null
pid: number | null
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
} catch {
return { status: null, pid: null }
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await check()) {
return
}
await sleep(pollIntervalMs)
}
throw new Error(message)
}
console.log('=== Daemon Restart (supervisor regression) ===\n')
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
const host = `127.0.0.1:${port}`
let supervisorProcess: ChildProcess | null = null
let recentSupervisorLogs = ''
try {
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
supervisorProcess.stdout?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stderr?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
},
120000,
'daemon did not become running in time'
)
const statusBeforeRestart = await readDaemonStatus(paseoHome)
const supervisorPid = statusBeforeRestart.pid
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
assert(supervisorPid !== null, 'supervisor pid should exist once daemon starts')
assert(isProcessRunning(supervisorPid), 'supervisor process should be running')
const workerPidBeforeRestart = readWorkerPid(supervisorPid)
assert(workerPidBeforeRestart !== null, 'supervisor should have a worker process before restart')
assert(isProcessRunning(workerPidBeforeRestart), 'worker process should be running before restart')
console.log(`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`)
console.log('Test 2: app-style restart request should restart worker and keep daemon healthy')
const client = await tryConnectToDaemon({ host, timeout: 5000 })
assert(client, 'daemon client should connect')
try {
const restartAck = await client.restartServer('settings_update')
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
} finally {
await client?.close().catch(() => undefined)
}
await waitFor(() => {
const workerPid = readWorkerPid(supervisorPid)
return workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
}, 20000, 'worker pid did not change after restart request')
const workerPidAfterRestart = readWorkerPid(supervisorPid)
assert(workerPidAfterRestart !== null, 'worker process should exist after restart')
assert.notStrictEqual(
workerPidAfterRestart,
workerPidBeforeRestart,
'worker pid should change after restart'
)
const statusAfterRestart = await readDaemonStatus(paseoHome)
assert.strictEqual(statusAfterRestart.status, 'running', 'daemon should stay running after restart')
assert.strictEqual(statusAfterRestart.pid, supervisorPid, 'supervisor pid should remain stable across restart')
assert(
recentSupervisorLogs.includes('Restart requested by worker. Stopping worker for restart...'),
`restart should route through supervisor restart intent, logs:\n${recentSupervisorLogs}`
)
console.log('✓ app-style restart keeps daemon healthy and restarts worker\n')
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
supervisorProcess.kill('SIGTERM')
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
() => {
supervisorProcess?.kill('SIGKILL')
}
)
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
}
if (recentSupervisorLogs.trim().length === 0) {
console.log('(no supervisor logs captured)')
}
console.log('=== Supervisor restart regression test passed ===')
@@ -0,0 +1,202 @@
#!/usr/bin/env npx tsx
/**
* Regression: unsupervised restart request should gracefully stop and exit 0,
* so an external owner can decide whether to respawn.
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { tryConnectToDaemon } from '../src/utils/client.ts'
import { getAvailablePort } from './helpers/network.ts'
$.verbose = false
const pollIntervalMs = 100
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
type DaemonStatus = {
status: string | null
pid: number | null
}
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
if (result.exitCode !== 0) {
return { status: null, pid: null }
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
} catch {
return { status: null, pid: null }
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await check()) {
return
}
await sleep(pollIntervalMs)
}
throw new Error(message)
}
type ExitResult = {
code: number | null
signal: NodeJS.Signals | null
}
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('timed out waiting for process exit'))
}, timeoutMs)
processRef.once('exit', (code, signal) => {
clearTimeout(timeout)
resolve({ code, signal })
})
})
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, 'paseo.pid')
try {
const content = await readFile(pidPath, 'utf-8')
const parsed = JSON.parse(content) as { pid?: unknown }
if (typeof parsed.pid !== 'number' || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null
}
return parsed.pid
} catch {
return null
}
}
console.log('=== Daemon Restart (unsupervised regression) ===\n')
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-unsupervised-'))
const cliRoot = join(import.meta.dirname, '..')
const host = `127.0.0.1:${port}`
let daemonProcess: ChildProcess | null = null
try {
console.log('Test 1: start unsupervised daemon worker directly')
daemonProcess = spawn(
process.execPath,
[...process.execArgv, '--import', 'tsx', '../server/src/server/index.ts'],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
// This test validates direct unsupervised worker ownership semantics.
// Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external,
// which would delegate lock ownership away from this process and make
// daemon status checks fail to observe a running owner PID.
PASEO_PID_LOCK_MODE: 'self',
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
}
)
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
},
120000,
'daemon did not become running in time'
)
const statusBeforeRestart = await readDaemonStatus(paseoHome)
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
assert(daemonProcess.pid, 'unsupervised daemon process pid should exist')
assert.strictEqual(statusBeforeRestart.pid, daemonProcess.pid, 'status pid should match daemon process pid')
const lockPid = await readPidLockPid(paseoHome)
assert.strictEqual(lockPid, daemonProcess.pid, 'unsupervised worker should own pid lock')
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`)
console.log('Test 2: restart request should gracefully stop and exit code 0')
const client = await tryConnectToDaemon({ host, timeout: 5000 })
assert(client, 'daemon client should connect')
const exitPromise = waitForProcessExit(daemonProcess, 30000)
try {
const restartAck = await client.restartServer('settings_update')
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
} finally {
await client?.close().catch(() => undefined)
}
const exit = await exitPromise
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`)
assert.strictEqual(exit.code, 0, `daemon should exit with status 0, got code=${exit.code}`)
await waitFor(async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'stopped'
}, 15000, 'daemon status did not transition to stopped after unsupervised restart request')
console.log('✓ unsupervised restart exited cleanly with code 0\n')
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill('SIGTERM')
await waitFor(() => !isProcessRunning(daemonProcess!.pid ?? -1), 5000, 'daemon cleanup timed out').catch(
() => {
daemonProcess?.kill('SIGKILL')
}
)
}
await rm(paseoHome, { recursive: true, force: true })
}
console.log('=== Unsupervised restart regression test passed ===')
+32
View File
@@ -0,0 +1,32 @@
import { createServer } from 'node:net'
/**
* Reserve and release an ephemeral TCP port, returning the port number.
* Tests can use this to reduce port collision flakiness when spawning daemons.
*/
export async function getAvailablePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.unref()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => {
reject(new Error('Failed to resolve an available TCP port'))
})
return
}
const { port } = address
server.close((error) => {
if (error) {
reject(error)
return
}
resolve(port)
})
})
})
}
+145 -39
View File
@@ -2,10 +2,10 @@
* Test Daemon Helper
*
* Provides utilities for launching real Paseo daemons in E2E tests.
* Each test gets an isolated daemon on a random port with its own PASEO_HOME.
* Each test gets an isolated daemon on an available local port with its own PASEO_HOME.
*
* CRITICAL RULES (from design doc):
* 1. Port: Random port in 20000-30000 range - NEVER use 6767 (production)
* 1. Port: Use an available ephemeral local port - NEVER use 6767 (production)
* 2. Protocol: WebSocket ONLY - daemon has no HTTP endpoints
* 3. Temp dirs: Create temp directories for PASEO_HOME and agent --cwd
* 4. Model: Always use claude provider with haiku model for fast, cheap tests
@@ -17,9 +17,10 @@ import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { ChildProcess, spawn } from 'child_process'
import { getAvailablePort } from './network.ts'
export interface TestDaemonContext {
/** Random port for test daemon (never 6767) */
/** Available local port for test daemon (never 6767) */
port: number
/** WebSocket URL for connecting to daemon */
wsUrl: string
@@ -35,6 +36,117 @@ export interface TestDaemonContext {
stop: () => Promise<void>
}
const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
PASEO_RELAY_ENABLED: 'false',
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
const TEST_DAEMON_HOST = '127.0.0.1'
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
process.env.PASEO_TEST_OUTPUT_CAPTURE_BYTES ?? `${DEFAULT_OUTPUT_CAPTURE_LIMIT}`,
10
)
type OutputCapture = {
value: string
truncated: boolean
}
function createOutputCapture(): OutputCapture {
return { value: '', truncated: false }
}
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
const next = target.value + chunk.toString()
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
target.value = next
return
}
target.truncated = true
target.value = next.slice(next.length - TEST_OUTPUT_CAPTURE_LIMIT)
}
function formatOutputCapture(target: OutputCapture): string {
if (!target.truncated) {
return target.value
}
return `[truncated; showing last ${TEST_OUTPUT_CAPTURE_LIMIT} chars]\n${target.value}`
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined
}
return typeof (error as { code?: unknown }).code === 'string'
? ((error as { code: string }).code)
: undefined
}
function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
if (process.platform !== 'win32') {
try {
process.kill(-pid, signal)
return true
} catch (error) {
const code = readNodeErrnoCode(error)
if (code === 'ESRCH') {
return false
}
}
}
try {
process.kill(pid, signal)
return true
} catch (error) {
const code = readNodeErrnoCode(error)
if (code === 'ESRCH') {
return false
}
throw error
}
}
async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number): Promise<void> {
const pid = processRef.pid
if (!Number.isInteger(pid) || pid <= 0) {
return
}
if (processRef.exitCode !== null || processRef.signalCode !== null) {
return
}
signalProcessTree(pid, 'SIGTERM')
await new Promise<void>((resolve) => {
let settled = false
const finish = () => {
if (settled) {
return
}
settled = true
clearTimeout(timeoutId)
resolve()
}
const timeoutId = setTimeout(() => {
signalProcessTree(pid, 'SIGKILL')
finish()
}, timeoutMs)
processRef.once('exit', () => {
finish()
})
})
}
/**
* Generate a random port for test daemon
* Uses range 20000-30000 to avoid conflicts
@@ -73,7 +185,7 @@ async function waitForDaemonReady(
const { exitCode } = await runPaseoCli(
{
port,
wsUrl: `ws://127.0.0.1:${port}`,
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
paseoHome: '',
workDir: '',
process: null,
@@ -110,13 +222,13 @@ export async function startTestDaemon(options?: {
workDir?: string
timeout?: number
}): Promise<TestDaemonContext> {
const port = options?.port ?? getRandomPort()
const port = options?.port ?? await getAvailablePort()
const { paseoHome, workDir } = options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs()
const timeout = options?.timeout ?? 30000
const wsUrl = `ws://127.0.0.1:${port}`
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`
// Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, '..', '..')
@@ -126,43 +238,30 @@ export async function startTestDaemon(options?: {
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
env: {
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
// Disable relay for tests
PASEO_RELAY_ENABLED: 'false',
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
// Force no TTY to prevent QR code output
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
detached: process.platform !== 'win32',
})
let stdout = ''
let stderr = ''
const stdout = createOutputCapture()
const stderr = createOutputCapture()
daemonProcess.stdout?.on('data', (data) => {
stdout += data.toString()
appendOutputCapture(stdout, data)
})
daemonProcess.stderr?.on('data', (data) => {
stderr += data.toString()
appendOutputCapture(stderr, data)
})
const cleanup = async () => {
if (daemonProcess && !daemonProcess.killed) {
daemonProcess.kill('SIGTERM')
// Wait for process to exit
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
daemonProcess.kill('SIGKILL')
resolve()
}, 5000)
daemonProcess.on('exit', () => {
clearTimeout(timeoutId)
resolve()
})
})
if (daemonProcess) {
await terminateProcessTree(daemonProcess, 5000)
}
// Clean up temp directories
@@ -191,8 +290,9 @@ export async function startTestDaemon(options?: {
daemonProcess.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Daemon process exited with code ${code}`)
if (stderr) {
console.error('Daemon stderr:', stderr)
const stderrText = formatOutputCapture(stderr)
if (stderrText) {
console.error('Daemon stderr:', stderrText)
}
}
})
@@ -215,7 +315,9 @@ export async function startTestDaemon(options?: {
// Daemon failed to start - clean up and rethrow
await cleanup()
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to start test daemon: ${message}\nStdout: ${stdout}\nStderr: ${stderr}`)
throw new Error(
`Failed to start test daemon: ${message}\nStdout: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`
)
}
return ctx
@@ -245,26 +347,30 @@ export async function runPaseoCli(
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
env: {
...process.env,
PASEO_HOST: `localhost:${ctx.port}`,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`,
PASEO_HOME: ctx.paseoHome,
},
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
})
let stdout = ''
let stderr = ''
const stdout = createOutputCapture()
const stderr = createOutputCapture()
proc.stdout?.on('data', (data) => {
stdout += data.toString()
appendOutputCapture(stdout, data)
})
proc.stderr?.on('data', (data) => {
stderr += data.toString()
appendOutputCapture(stderr, data)
})
const timeoutId = setTimeout(() => {
proc.kill('SIGKILL')
if (proc.pid) {
signalProcessTree(proc.pid, 'SIGKILL')
}
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
}, timeout)
@@ -272,8 +378,8 @@ export async function runPaseoCli(
clearTimeout(timeoutId)
resolve({
exitCode: code ?? 1,
stdout,
stderr,
stdout: formatOutputCapture(stdout),
stderr: formatOutputCapture(stderr),
})
})
+70 -4
View File
@@ -8,11 +8,31 @@
*/
import { $ } from 'zx'
import { readdir } from 'fs/promises'
import { readdir, writeFile } from 'fs/promises'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const testEnvDefaults = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
let jsonOutputPath: string | null = null
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--json-output') {
const value = args[i + 1]
if (!value) {
throw new Error('--json-output requires a file path')
}
jsonOutputPath = value
i++
continue
}
}
$.verbose = false
@@ -26,8 +46,29 @@ const testFiles = files
.sort()
if (testFiles.length === 0) {
console.log('⚠️ No test files found')
process.exit(0)
console.log('❌ No test files found')
if (jsonOutputPath) {
await writeFile(
jsonOutputPath,
JSON.stringify(
{
suite: 'cli-local',
command: 'npm run test:local --workspace=@getpaseo/cli',
counts: {
passed: 0,
failed: 0,
skipped: 0,
},
skippedTests: [],
failures: [],
error: 'No test files found',
},
null,
2
) + '\n'
)
}
process.exit(1)
}
console.log(`Found ${testFiles.length} test file(s):\n`)
@@ -49,7 +90,7 @@ for (const testFile of testFiles) {
console.log('─'.repeat(50))
try {
const result = await $`npx tsx ${testPath}`.nothrow()
const result = await $`PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnvDefaults.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnvDefaults.PASEO_VOICE_MODE_ENABLED} npx tsx ${testPath}`.nothrow()
if (result.exitCode === 0) {
console.log(`\n✅ ${testName} PASSED`)
passed++
@@ -89,4 +130,29 @@ if (failures.length > 0) {
}
console.log()
if (jsonOutputPath) {
await writeFile(
jsonOutputPath,
JSON.stringify(
{
suite: 'cli-local',
command: 'npm run test:local --workspace=@getpaseo/cli',
counts: {
passed,
failed,
skipped: 0,
},
skippedTests: [],
failures: failures.map(({ test, error }) => ({
test,
error: error.split('\n')[0] ?? '',
})),
},
null,
2
) + '\n'
)
}
process.exit(failed > 0 ? 1 : 0)
+42 -3
View File
@@ -14,6 +14,39 @@ import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
const TEST_ENV_DEFAULTS = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
function killPidTree(pid: number, signal: NodeJS.Signals): void {
if (!Number.isInteger(pid) || pid <= 0) {
return
}
if (process.platform !== 'win32') {
try {
process.kill(-pid, signal)
return
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ESRCH') {
return
}
}
}
try {
process.kill(pid, signal)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'ESRCH') {
throw error
}
}
}
export interface TestContext {
/** Random port for test daemon (never 6767) */
port: number
@@ -72,7 +105,7 @@ export async function startDaemon(
paseoHome: string
): Promise<ProcessPromise> {
$.verbose = false
const daemon = $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} paseo daemon start --foreground`.nothrow()
const daemon = $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow()
return daemon
}
@@ -86,13 +119,19 @@ export async function createTestContext(): Promise<TestContext> {
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false
return $`PASEO_HOST=localhost:${port} paseo ${args}`.nothrow()
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow()
}
// Cleanup function
const cleanup = async (): Promise<void> => {
if (ctx.daemon) {
ctx.daemon.kill()
if (typeof ctx.daemon.pid === 'number') {
killPidTree(ctx.daemon.pid, 'SIGTERM')
await sleep(250)
killPidTree(ctx.daemon.pid, 'SIGKILL')
} else {
ctx.daemon.kill()
}
}
await rm(paseoHome, { recursive: true, force: true })
await rm(workDir, { recursive: true, force: true })