test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ===')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ===')
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user