mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
a1d279649f
* test(e2e): make beta recovery fixture version-independent * test(daemon): await restart analytics recovery --------- Co-authored-by: Amy <1184569493@qq.com>
644 lines
22 KiB
TypeScript
644 lines
22 KiB
TypeScript
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { readFileSync } from 'node:fs';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import Database from 'better-sqlite3';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const daemonRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
|
|
|
|
describe('CLI startup boundaries', () => {
|
|
it.each([
|
|
['doctor', ['doctor', '--help']],
|
|
['config', ['config', 'get', 'apiProtocol', '--daemon-url', 'http://127.0.0.1:9']],
|
|
['diagnostics', ['diagnostics', 'export', '--daemon-url', 'http://127.0.0.1:9']],
|
|
['amr', ['amr', 'status', '--daemon-url', 'http://127.0.0.1:9']],
|
|
])('initializes flag constants before dispatching od %s', async (_name, args) => {
|
|
let output = '';
|
|
try {
|
|
const result = await execFileAsync(
|
|
process.execPath,
|
|
['--import', 'tsx', cliEntry, ...args],
|
|
{
|
|
cwd: daemonRoot,
|
|
env: { ...process.env },
|
|
},
|
|
);
|
|
output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
} catch (error: unknown) {
|
|
const failed = error as { stdout?: string; stderr?: string };
|
|
output = `${failed.stdout ?? ''}${failed.stderr ?? ''}`;
|
|
}
|
|
|
|
expect(output).not.toContain('ReferenceError');
|
|
expect(output).not.toContain('before initialization');
|
|
expect(output).not.toContain('CONFIG_STRING_FLAGS');
|
|
expect(output).not.toContain('DIAGNOSTICS_STRING_FLAGS');
|
|
expect(output).not.toContain('AMR_STRING_FLAGS');
|
|
});
|
|
|
|
it('keeps od daemon start alive until SIGTERM and reports the actual listening port', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'od-cli-daemon-start-'));
|
|
const dataDir = join(root, 'data');
|
|
await mkdir(dataDir);
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
'--import',
|
|
'tsx',
|
|
cliEntry,
|
|
'daemon',
|
|
'start',
|
|
'--headless',
|
|
'--port',
|
|
'0',
|
|
],
|
|
{
|
|
cwd: daemonRoot,
|
|
env: {
|
|
...process.env,
|
|
OD_BIND_HOST: '127.0.0.1',
|
|
OD_DATA_DIR: dataDir,
|
|
},
|
|
},
|
|
);
|
|
|
|
try {
|
|
const line = await waitForStdoutLine(child, /\[od\] listening on (http:\/\/[^\s]+) \(headless\)/u);
|
|
const match = line.match(/(http:\/\/[^\s]+)/u);
|
|
const daemonUrl = match?.[1];
|
|
expect(daemonUrl).toBeTruthy();
|
|
const parsed = new URL(daemonUrl!);
|
|
expect(Number(parsed.port)).toBeGreaterThan(0);
|
|
|
|
const healthResp = await fetch(`${daemonUrl}/api/health`);
|
|
expect(healthResp.status).toBe(200);
|
|
|
|
const statusResp = await fetch(`${daemonUrl}/api/daemon/status`);
|
|
expect(statusResp.status).toBe(200);
|
|
const status = await statusResp.json() as { bindHost: string; port: number };
|
|
expect(status.bindHost).toBe('127.0.0.1');
|
|
expect(status.port).toBe(Number(parsed.port));
|
|
expect(child.exitCode).toBeNull();
|
|
} finally {
|
|
await terminateChild(child);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('reconciles a durable running message after a real daemon process restart', { timeout: 60_000 }, async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'od-cli-daemon-restart-'));
|
|
const dataDir = join(root, 'data');
|
|
await mkdir(dataDir);
|
|
const port = await findFreePort();
|
|
const env = {
|
|
...process.env,
|
|
OD_BIND_HOST: '127.0.0.1',
|
|
OD_DATA_DIR: dataDir,
|
|
POSTHOG_KEY: '',
|
|
POSTHOG_HOST: '',
|
|
OPEN_DESIGN_VELA_TELEMETRY: 'off',
|
|
OPEN_DESIGN_TELEMETRY_RELAY_URL: '',
|
|
LANGFUSE_PUBLIC_KEY: '',
|
|
LANGFUSE_SECRET_KEY: '',
|
|
};
|
|
const args = [
|
|
'--import', 'tsx', cliEntry,
|
|
'daemon', 'start', '--headless', '--port', String(port),
|
|
];
|
|
const first = spawn(process.execPath, args, { cwd: daemonRoot, env });
|
|
const runId = 'run-real-process-restart';
|
|
const messageId = 'message-real-process-restart';
|
|
const conversationId = 'conversation-real-process-restart';
|
|
const projectId = 'project-real-process-restart';
|
|
const runDir = join(dataDir, 'runs', runId);
|
|
const statePath = join(runDir, 'state.json');
|
|
|
|
try {
|
|
await waitForStdoutLine(first, /\[od\] listening on (http:\/\/[^\s]+)/u);
|
|
const db = new Database(join(dataDir, 'app.sqlite'));
|
|
try {
|
|
const now = Date.now();
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.prepare(
|
|
`INSERT INTO projects (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
|
).run(projectId, 'restart fixture', now, now);
|
|
db.prepare(
|
|
`INSERT INTO conversations (id, project_id, title, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
).run(conversationId, projectId, 'restart fixture', now, now);
|
|
db.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, role, content, run_id, run_status,
|
|
events_json, position, created_at, started_at)
|
|
VALUES (?, ?, 'assistant', '', ?, 'running', '[]', 0, ?, ?)`,
|
|
).run(messageId, conversationId, runId, now, now);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
await mkdir(runDir, { recursive: true });
|
|
await writeFile(statePath, `${JSON.stringify({
|
|
schemaVersion: 1,
|
|
id: runId,
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId: messageId,
|
|
agentId: 'claude',
|
|
status: 'running',
|
|
createdAt: Date.now() - 1_000,
|
|
updatedAt: Date.now(),
|
|
analyticsRecovery: {
|
|
context: {},
|
|
properties: { project_id: projectId, conversation_id: conversationId, run_id: runId },
|
|
insertId: 'restart-fixture-created',
|
|
},
|
|
})}\n`);
|
|
|
|
// SIGKILL models the process-loss case; graceful SIGTERM would run the
|
|
// normal shutdown path and would not exercise boot reconciliation.
|
|
first.kill('SIGKILL');
|
|
await waitForExit(first);
|
|
|
|
const second = spawn(process.execPath, args, { cwd: daemonRoot, env });
|
|
try {
|
|
const line = await waitForStdoutLine(second, /\[od\] listening on (http:\/\/[^\s]+)/u);
|
|
expect(line).toContain(`127.0.0.1:${port}`);
|
|
await waitFor(() => {
|
|
const state = JSON.parse(readFileSync(statePath, 'utf8')) as {
|
|
status?: string;
|
|
analyticsRecovery?: { completedAt?: number };
|
|
};
|
|
const checkDb = new Database(join(dataDir, 'app.sqlite'), { readonly: true });
|
|
try {
|
|
const row = checkDb.prepare(`SELECT run_status AS status FROM messages WHERE id = ?`).get(messageId) as { status?: string } | undefined;
|
|
return state.status === 'failed'
|
|
&& row?.status === 'failed'
|
|
&& typeof state.analyticsRecovery?.completedAt === 'number';
|
|
} finally {
|
|
checkDb.close();
|
|
}
|
|
});
|
|
const recoveredState = JSON.parse(await readFile(statePath, 'utf8')) as {
|
|
status: string;
|
|
errorCode?: string;
|
|
terminalRecoveryReason?: string;
|
|
analyticsRecovery?: { completedAt?: number };
|
|
};
|
|
expect(recoveredState).toMatchObject({
|
|
status: 'failed',
|
|
errorCode: 'DAEMON_RESTARTED',
|
|
terminalRecoveryReason: 'daemon_restart',
|
|
analyticsRecovery: { completedAt: expect.any(Number) },
|
|
});
|
|
|
|
const checkpoint = recoveredState.analyticsRecovery?.completedAt;
|
|
await terminateChild(second);
|
|
const reconciliationSentinelId = 'run-third-boot-reconciliation-sentinel';
|
|
const reconciliationSentinelDir = join(dataDir, 'runs', reconciliationSentinelId);
|
|
const reconciliationSentinelPath = join(reconciliationSentinelDir, 'state.json');
|
|
await mkdir(reconciliationSentinelDir, { recursive: true });
|
|
await writeFile(reconciliationSentinelPath, `${JSON.stringify({
|
|
schemaVersion: 1,
|
|
id: reconciliationSentinelId,
|
|
projectId: null,
|
|
conversationId: null,
|
|
assistantMessageId: null,
|
|
agentId: null,
|
|
status: 'running',
|
|
createdAt: Date.now() - 1_000,
|
|
updatedAt: Date.now(),
|
|
langfuseCompletedAt: Date.now(),
|
|
})}\n`);
|
|
const third = spawn(process.execPath, args, { cwd: daemonRoot, env });
|
|
try {
|
|
await Promise.all([
|
|
waitForStdoutLine(third, /\[od\] listening on (http:\/\/[^\s]+)/u),
|
|
waitForStdoutLine(third, /\[runs\] reconciled interrupted run terminals/u),
|
|
]);
|
|
const replayedState = JSON.parse(await readFile(statePath, 'utf8')) as {
|
|
status?: string;
|
|
analyticsRecovery?: { completedAt?: number };
|
|
};
|
|
const sentinelState = JSON.parse(await readFile(reconciliationSentinelPath, 'utf8')) as {
|
|
status?: string;
|
|
};
|
|
expect(sentinelState.status).toBe('failed');
|
|
expect(replayedState.status).toBe('failed');
|
|
expect(replayedState.analyticsRecovery?.completedAt).toBe(checkpoint);
|
|
} finally {
|
|
await terminateChild(third);
|
|
}
|
|
} finally {
|
|
await terminateChild(second);
|
|
}
|
|
} finally {
|
|
await terminateChild(first);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not import daemon startup code for media client commands', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'od-cli-media-'));
|
|
const dataDir = join(root, 'data');
|
|
await mkdir(dataDir);
|
|
await chmod(dataDir, 0o500);
|
|
|
|
try {
|
|
await execFileAsync(
|
|
process.execPath,
|
|
[
|
|
'--import',
|
|
'tsx',
|
|
cliEntry,
|
|
'media',
|
|
'generate',
|
|
'--project',
|
|
'repro',
|
|
'--surface',
|
|
'image',
|
|
'--model',
|
|
'gpt-image-2',
|
|
'--prompt',
|
|
'test',
|
|
'--daemon-url',
|
|
'http://127.0.0.1:59999',
|
|
],
|
|
{
|
|
cwd: daemonRoot,
|
|
env: {
|
|
...process.env,
|
|
OD_DATA_DIR: dataDir,
|
|
},
|
|
},
|
|
);
|
|
throw new Error('media command unexpectedly succeeded');
|
|
} catch (error: unknown) {
|
|
const failed = error as { code?: number; stderr?: string };
|
|
const stderr = failed.stderr ?? '';
|
|
expect(failed.code).toBe(3);
|
|
expect(stderr).toContain('failed to reach daemon');
|
|
expect(stderr).not.toContain('OD_DATA_DIR');
|
|
} finally {
|
|
await chmod(dataDir, 0o700).catch(() => undefined);
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses the token-gated media endpoint without falling back when policy denies generation', async () => {
|
|
const seen: Array<{ url: string | undefined; authorization: string | undefined }> = [];
|
|
const server = http.createServer((req, res) => {
|
|
seen.push({
|
|
url: req.url,
|
|
authorization: req.headers.authorization,
|
|
});
|
|
req.resume();
|
|
if (req.url === '/api/tools/media/generate') {
|
|
res.writeHead(403, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
error: {
|
|
code: 'MEDIA_EXECUTION_DISABLED',
|
|
message: 'media generation is disabled for this run',
|
|
},
|
|
}));
|
|
return;
|
|
}
|
|
res.writeHead(500, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'unexpected fallback' }));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
const daemonUrl = `http://127.0.0.1:${port}`;
|
|
|
|
try {
|
|
await execFileAsync(
|
|
process.execPath,
|
|
[
|
|
'--import',
|
|
'tsx',
|
|
cliEntry,
|
|
'media',
|
|
'generate',
|
|
'--project',
|
|
'project-1',
|
|
'--surface',
|
|
'image',
|
|
'--model',
|
|
'gpt-image-2',
|
|
'--prompt',
|
|
'test',
|
|
'--daemon-url',
|
|
daemonUrl,
|
|
],
|
|
{
|
|
cwd: daemonRoot,
|
|
env: {
|
|
...process.env,
|
|
OD_TOOL_TOKEN: 'run-token',
|
|
},
|
|
},
|
|
);
|
|
throw new Error('media command unexpectedly succeeded');
|
|
} catch (error: unknown) {
|
|
const failed = error as { code?: number; stderr?: string };
|
|
expect(failed.code).toBe(4);
|
|
expect(failed.stderr ?? '').toContain('MEDIA_EXECUTION_DISABLED');
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
expect(seen).toEqual([
|
|
{
|
|
url: '/api/tools/media/generate',
|
|
authorization: 'Bearer run-token',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('prints AMR status JSON from the daemon status endpoint without wallet fallback', async () => {
|
|
const seen: Array<{ method: string | undefined; url: string | undefined }> = [];
|
|
const server = http.createServer((req, res) => {
|
|
seen.push({ method: req.method, url: req.url });
|
|
req.resume();
|
|
if (req.method === 'GET' && req.url === '/api/integrations/vela/status') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
loggedIn: true,
|
|
profile: 'local',
|
|
user: { email: 'amr@example.com' },
|
|
account: { plan: 'plus', balanceUsd: '9.5000' },
|
|
configPath: '/Users/test/.amr/config.json',
|
|
}));
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'unexpected' }));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
const daemonUrl = `http://127.0.0.1:${port}`;
|
|
|
|
try {
|
|
const result = await execFileAsync(
|
|
process.execPath,
|
|
[
|
|
'--import',
|
|
'tsx',
|
|
cliEntry,
|
|
'amr',
|
|
'status',
|
|
'--daemon-url',
|
|
daemonUrl,
|
|
'--json',
|
|
],
|
|
{
|
|
cwd: daemonRoot,
|
|
env: { ...process.env },
|
|
},
|
|
);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
loggedIn: true,
|
|
profile: 'local',
|
|
user: { email: 'amr@example.com' },
|
|
account: { plan: 'plus', balanceUsd: '9.5000' },
|
|
});
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
expect(seen).toEqual([
|
|
{ method: 'GET', url: '/api/integrations/vela/status' },
|
|
]);
|
|
});
|
|
|
|
it('prints AMR wallet fallback status JSON when daemon status has no account balance', async () => {
|
|
const seen: Array<{ method: string | undefined; url: string | undefined }> = [];
|
|
const server = http.createServer((req, res) => {
|
|
seen.push({ method: req.method, url: req.url });
|
|
req.resume();
|
|
if (req.method === 'GET' && req.url === '/api/integrations/vela/status') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
loggedIn: true,
|
|
profile: 'local',
|
|
user: { email: 'amr@example.com' },
|
|
configPath: '/Users/test/.amr/config.json',
|
|
}));
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && req.url === '/api/integrations/vela/wallet?refresh=1') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
status: 'available',
|
|
profile: 'local',
|
|
user: { email: 'amr@example.com' },
|
|
balanceUsd: '0.1000',
|
|
updatedAt: '2026-06-23T06:05:18.782Z',
|
|
fetchedAt: '2026-06-23T06:05:19.000Z',
|
|
stale: false,
|
|
source: 'vela_api',
|
|
}));
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'unexpected' }));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
const daemonUrl = `http://127.0.0.1:${port}`;
|
|
|
|
try {
|
|
const result = await execFileAsync(
|
|
process.execPath,
|
|
[
|
|
'--import',
|
|
'tsx',
|
|
cliEntry,
|
|
'amr',
|
|
'status',
|
|
'--daemon-url',
|
|
daemonUrl,
|
|
'--refresh',
|
|
'--json',
|
|
],
|
|
{
|
|
cwd: daemonRoot,
|
|
env: { ...process.env },
|
|
},
|
|
);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
loggedIn: true,
|
|
user: { email: 'amr@example.com' },
|
|
account: { balanceUsd: '0.1000' },
|
|
wallet: {
|
|
status: 'available',
|
|
balanceUsd: '0.1000',
|
|
source: 'vela_api',
|
|
},
|
|
});
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
expect(seen).toEqual([
|
|
{ method: 'GET', url: '/api/integrations/vela/status' },
|
|
{ method: 'GET', url: '/api/integrations/vela/wallet?refresh=1' },
|
|
]);
|
|
});
|
|
|
|
it('prints AMR text status with fallback balance and no fabricated plan', async () => {
|
|
const server = http.createServer((req, res) => {
|
|
req.resume();
|
|
if (req.method === 'GET' && req.url === '/api/integrations/vela/status') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
loggedIn: true,
|
|
profile: 'local',
|
|
user: { email: 'amr@example.com' },
|
|
configPath: '/Users/test/.amr/config.json',
|
|
}));
|
|
return;
|
|
}
|
|
if (req.method === 'GET' && req.url === '/api/integrations/vela/wallet') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
status: 'available',
|
|
profile: 'local',
|
|
user: { email: 'amr@example.com' },
|
|
balanceUsd: '0.1000',
|
|
updatedAt: '2026-06-23T06:05:18.782Z',
|
|
fetchedAt: '2026-06-23T06:05:19.000Z',
|
|
stale: false,
|
|
source: 'vela_api',
|
|
}));
|
|
return;
|
|
}
|
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'unexpected' }));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
const daemonUrl = `http://127.0.0.1:${port}`;
|
|
|
|
try {
|
|
const result = await execFileAsync(
|
|
process.execPath,
|
|
[
|
|
'--import',
|
|
'tsx',
|
|
cliEntry,
|
|
'amr',
|
|
'status',
|
|
'--daemon-url',
|
|
daemonUrl,
|
|
],
|
|
{
|
|
cwd: daemonRoot,
|
|
env: { ...process.env },
|
|
},
|
|
);
|
|
expect(result.stdout).toContain('AMR account\tamr@example.com');
|
|
expect(result.stdout).toContain('Profile\tlocal');
|
|
expect(result.stdout).toContain('Wallet balance\t$0.1000');
|
|
expect(result.stdout).toContain('Source\tvela_api');
|
|
expect(result.stdout).not.toContain('Plan\t');
|
|
} finally {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|
|
|
|
function waitForStdoutLine(
|
|
child: ChildProcessWithoutNullStreams,
|
|
pattern: RegExp,
|
|
timeoutMs = 15_000,
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
let output = '';
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error(`timed out waiting for stdout ${pattern}; output:\n${output}`));
|
|
}, timeoutMs);
|
|
const onData = (chunk: Buffer) => {
|
|
output += chunk.toString('utf8');
|
|
const line = output.split(/\r?\n/u).find((candidate) => pattern.test(candidate));
|
|
if (line) {
|
|
cleanup();
|
|
resolve(line);
|
|
}
|
|
};
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
cleanup();
|
|
reject(new Error(`child exited before stdout matched ${pattern}: code=${code} signal=${signal}; output:\n${output}`));
|
|
};
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
child.stdout.off('data', onData);
|
|
child.stderr.off('data', onData);
|
|
child.off('error', onError);
|
|
child.off('exit', onExit);
|
|
};
|
|
child.stdout.on('data', onData);
|
|
child.stderr.on('data', onData);
|
|
child.on('error', onError);
|
|
child.on('exit', onExit);
|
|
});
|
|
}
|
|
|
|
async function findFreePort(): Promise<number> {
|
|
const server = net.createServer();
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => resolve());
|
|
});
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
if (!port) throw new Error('failed to allocate a free TCP port');
|
|
return port;
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
throw new Error('timed out waiting for daemon restart reconciliation');
|
|
}
|
|
|
|
async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
await new Promise<void>((resolve) => child.once('exit', () => resolve()));
|
|
}
|
|
|
|
async function terminateChild(child: ChildProcessWithoutNullStreams): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
const exited = new Promise<void>((resolve) => {
|
|
child.once('exit', () => resolve());
|
|
});
|
|
child.kill('SIGTERM');
|
|
const timeout = new Promise<void>((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
child.kill('SIGKILL');
|
|
resolve();
|
|
}, 5_000);
|
|
timer.unref?.();
|
|
});
|
|
await Promise.race([exited, timeout]);
|
|
}
|