Skip to content

Commit dfb257f

Browse files
committed
fix: satisfy CI gates on #1596 (format, fallow, coverage)
- oxfmt formatting on the new integration test file. - Register the two exit-flush regression fixtures (support/exit-naive.ts, support/exit-after-flush.ts) as fallow entry points: they're run as real subprocesses via a string path (runCmdSync), which fallow's static dependency analysis can't follow, same as the existing test/contention-retry-fixtures/* entries. exit-payload.ts becomes reachable transitively through their static imports. Also switched the integration test's local PAYLOAD_MARKER duplicate to import the one fallow flagged as unused from exit-payload.ts. - Added real unit coverage for the new exitAfterFlush code paths, since node --test integration files aren't measured by the vitest coverage gate: src/utils/__tests__/process-exit.test.ts exercises the already-drained, backlogged-then-drains, and never-drains/timeout branches directly against a fake stream; src/__tests__/cli-exit-paths.test.ts drives runCli() for --version, bare help, no-command, and web to cover their exitAfterFlush call sites, plus a --debug case with a >64KB seeded daemon.log proving printDaemonLogTailOnError's new byte cap actually trims the oldest lines. Changed-line coverage gate now passes at 92.59% (was 59.26%); the two remaining uncovered lines are the bottom-of-file `isDirectRun` catch handler, which only runs when cli.ts is executed as the literal entry script and is not reachable by importing it as a module in a test (the same shape as bin.ts's already-excluded top-level fast paths).
1 parent 29ad1b9 commit dfb257f

4 files changed

Lines changed: 358 additions & 4 deletions

File tree

‎.fallowrc.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
"scripts/vitest-runner-timeout-setup.ts",
2424
"test/contention-retry-fixtures/vitest.fixture.config.ts",
2525
"test/contention-retry-fixtures/timeout-provenance.fixture.ts",
26+
// #1596 regression fixtures: run as real `node --experimental-strip-types`
27+
// subprocesses (test/integration/daemon-replace-exit-flush.test.ts), so
28+
// dependency analysis cannot follow the runCmdSync string path to either.
29+
"test/integration/support/exit-naive.ts",
30+
"test/integration/support/exit-after-flush.ts",
2631
"src/utils/update-check-entry.ts",
2732
"examples/sdk/client-session.ts",
2833
"examples/sdk/metro-runtime.ts",
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { afterEach, test, vi } from 'vitest';
5+
import assert from 'node:assert/strict';
6+
7+
vi.mock('../cli/commands/web.ts', async (importOriginal) => {
8+
const actual = await importOriginal<typeof import('../cli/commands/web.ts')>();
9+
return { ...actual, runWebCommand: vi.fn(async () => 0) };
10+
});
11+
12+
import { runCli } from '../cli.ts';
13+
import { runWebCommand } from '../cli/commands/web.ts';
14+
import { installIsolatedCliTestEnv } from './cli-test-env.ts';
15+
import { resolveDaemonPaths } from '../daemon/config.ts';
16+
import type { DaemonResponse } from '../daemon/client/daemon-client.ts';
17+
18+
afterEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
function installExitSpy(): { calls: number[]; restore: () => void } {
23+
const originalExit = process.exit;
24+
const calls: number[] = [];
25+
(process as any).exit = ((code?: number) => {
26+
calls.push(code ?? 0);
27+
}) as typeof process.exit;
28+
return { calls, restore: () => (process.exit = originalExit) };
29+
}
30+
31+
class ProcessExitSentinel extends Error {
32+
code: number;
33+
constructor(code: number) {
34+
super(`process.exit(${code})`);
35+
this.code = code;
36+
}
37+
}
38+
39+
// `parseCliInputOrExit`'s early-exit branches run before `runCli`'s own
40+
// try/catch, so — unlike `installExitSpy` above — the mock here must behave
41+
// like a real `process.exit()` and actually stop execution (by throwing),
42+
// or the function falls through past its `return exitAfterFlush(...)` into
43+
// code that assumes a command was parsed.
44+
function installTerminatingExitSpy(): { restore: () => void } {
45+
const originalExit = process.exit;
46+
(process as any).exit = ((code?: number) => {
47+
throw new ProcessExitSentinel(code ?? 0);
48+
}) as typeof process.exit;
49+
return { restore: () => (process.exit = originalExit) };
50+
}
51+
52+
async function runCliExpectingExit(
53+
argv: string[],
54+
deps: { sendToDaemon: (...args: any[]) => Promise<DaemonResponse> },
55+
): Promise<number> {
56+
try {
57+
await runCli(argv, deps as any);
58+
} catch (error) {
59+
if (error instanceof ProcessExitSentinel) return error.code;
60+
throw error;
61+
}
62+
throw new Error('expected runCli to exit');
63+
}
64+
65+
function captureStdout(): { read: () => string; restore: () => void } {
66+
const originalWrite = process.stdout.write.bind(process.stdout);
67+
let captured = '';
68+
(process.stdout as { write: typeof process.stdout.write }).write = ((chunk: unknown) => {
69+
captured += String(chunk);
70+
return true;
71+
}) as typeof process.stdout.write;
72+
return {
73+
read: () => captured,
74+
restore: () => {
75+
process.stdout.write = originalWrite;
76+
},
77+
};
78+
}
79+
80+
function captureStderr(): { read: () => string; restore: () => void } {
81+
const originalWrite = process.stderr.write.bind(process.stderr);
82+
let captured = '';
83+
(process.stderr as { write: typeof process.stderr.write }).write = ((chunk: unknown) => {
84+
captured += String(chunk);
85+
return true;
86+
}) as typeof process.stderr.write;
87+
return {
88+
read: () => captured,
89+
restore: () => {
90+
process.stderr.write = originalWrite;
91+
},
92+
};
93+
}
94+
95+
test('--version exits 0 and prints the version, without touching the daemon', async () => {
96+
const restoreEnv = installIsolatedCliTestEnv();
97+
const exitSpy = installTerminatingExitSpy();
98+
const stdout = captureStdout();
99+
const sendToDaemon = async (): Promise<DaemonResponse> => {
100+
throw new Error('sendToDaemon should not be called for --version');
101+
};
102+
103+
let exitCode: number;
104+
try {
105+
exitCode = await runCliExpectingExit(['--version'], { sendToDaemon });
106+
} finally {
107+
stdout.restore();
108+
exitSpy.restore();
109+
restoreEnv();
110+
}
111+
112+
assert.equal(exitCode, 0);
113+
assert.ok(stdout.read().trim().length > 0);
114+
});
115+
116+
test('bare `help` with no target exits 0 and prints usage', async () => {
117+
const restoreEnv = installIsolatedCliTestEnv();
118+
const exitSpy = installTerminatingExitSpy();
119+
const stdout = captureStdout();
120+
const sendToDaemon = async (): Promise<DaemonResponse> => {
121+
throw new Error('sendToDaemon should not be called for help');
122+
};
123+
124+
let exitCode: number;
125+
try {
126+
exitCode = await runCliExpectingExit(['help'], { sendToDaemon });
127+
} finally {
128+
stdout.restore();
129+
exitSpy.restore();
130+
restoreEnv();
131+
}
132+
133+
assert.equal(exitCode, 0);
134+
assert.ok(stdout.read().includes('agent-device'));
135+
});
136+
137+
test('no command exits 1 and prints usage', async () => {
138+
const restoreEnv = installIsolatedCliTestEnv();
139+
const exitSpy = installTerminatingExitSpy();
140+
const stdout = captureStdout();
141+
const sendToDaemon = async (): Promise<DaemonResponse> => {
142+
throw new Error('sendToDaemon should not be called with no command');
143+
};
144+
145+
let exitCode: number;
146+
try {
147+
exitCode = await runCliExpectingExit([], { sendToDaemon });
148+
} finally {
149+
stdout.restore();
150+
exitSpy.restore();
151+
restoreEnv();
152+
}
153+
154+
assert.equal(exitCode, 1);
155+
assert.ok(stdout.read().length > 0);
156+
});
157+
158+
test("web command exits with runWebCommand's status code", async () => {
159+
const restoreEnv = installIsolatedCliTestEnv();
160+
const exitSpy = installExitSpy();
161+
const sendToDaemon = async (): Promise<DaemonResponse> => {
162+
throw new Error('sendToDaemon should not be called for web');
163+
};
164+
vi.mocked(runWebCommand).mockResolvedValueOnce(0);
165+
166+
try {
167+
await runCli(['web', 'status'], { sendToDaemon });
168+
} finally {
169+
exitSpy.restore();
170+
restoreEnv();
171+
}
172+
173+
assert.deepEqual(exitSpy.calls, [0]);
174+
assert.equal(vi.mocked(runWebCommand).mock.calls.length, 1);
175+
assert.deepEqual(vi.mocked(runWebCommand).mock.calls[0]?.[0], ['status']);
176+
});
177+
178+
// #1596: printDaemonLogTailOnError's --debug dump must stay bounded even when
179+
// the daemon log itself is large — otherwise the dump risks the same
180+
// process.exit()-truncates-a-pipe-write failure exitAfterFlush exists to fix.
181+
test('a --debug failure caps the daemon-log-tail dump instead of printing it unbounded', async () => {
182+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-log-tail-'));
183+
const stateDir = path.join(tempRoot, 'state');
184+
fs.mkdirSync(stateDir, { recursive: true });
185+
const { logPath } = resolveDaemonPaths(stateDir);
186+
187+
// 200 lines * 500 bytes = 100,000 bytes: within the existing 200-line cap,
188+
// but past the 64,000-byte cap this change adds — so only the byte cap can
189+
// explain the head line being absent from the captured output below.
190+
const headMarker = 'HEAD_OF_SEEDED_LOG_LINE_0000';
191+
const tailMarker = 'TAIL_OF_SEEDED_LOG_LINE_0199';
192+
const lines: string[] = [];
193+
for (let i = 0; i < 200; i += 1) {
194+
const marker = i === 0 ? headMarker : i === 199 ? tailMarker : `line-${i}`;
195+
lines.push(`${marker}-${'x'.repeat(500 - marker.length - 1)}`);
196+
}
197+
fs.writeFileSync(logPath, `${lines.join('\n')}\n`);
198+
199+
const restoreEnv = installIsolatedCliTestEnv();
200+
const exitSpy = installExitSpy();
201+
const stderr = captureStderr();
202+
const sendToDaemon = async (): Promise<DaemonResponse> => ({
203+
ok: false,
204+
error: { code: 'SESSION_NOT_FOUND', message: 'No active session' },
205+
});
206+
207+
try {
208+
await runCli(['session', 'list', '--state-dir', stateDir, '--debug'], { sendToDaemon });
209+
} finally {
210+
stderr.restore();
211+
exitSpy.restore();
212+
restoreEnv();
213+
fs.rmSync(tempRoot, { recursive: true, force: true });
214+
}
215+
216+
assert.deepEqual(exitSpy.calls, [1]);
217+
const output = stderr.read();
218+
assert.ok(output.includes('[daemon log]'), 'expected the daemon-log-tail block to be printed');
219+
assert.ok(output.includes(tailMarker), 'expected the most recent line to survive the byte cap');
220+
assert.ok(
221+
!output.includes(headMarker),
222+
'expected the byte cap to drop the oldest lines, not just the 200-line cap',
223+
);
224+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, test, vi } from 'vitest';
3+
4+
vi.mock('../timeouts.ts', async (importOriginal) => {
5+
const actual = await importOriginal<typeof import('../timeouts.ts')>();
6+
return { ...actual, sleep: vi.fn(async () => {}) };
7+
});
8+
9+
import { exitAfterFlush } from '../process-exit.ts';
10+
import { sleep } from '../timeouts.ts';
11+
12+
type FakeWriteStream = {
13+
writableLength: number;
14+
once: ReturnType<typeof vi.fn>;
15+
};
16+
17+
function installFakeStdio(streams: {
18+
stdout: FakeWriteStream;
19+
stderr: FakeWriteStream;
20+
}): () => void {
21+
const originalStdout = Object.getOwnPropertyDescriptor(process, 'stdout');
22+
const originalStderr = Object.getOwnPropertyDescriptor(process, 'stderr');
23+
Object.defineProperty(process, 'stdout', { value: streams.stdout, configurable: true });
24+
Object.defineProperty(process, 'stderr', { value: streams.stderr, configurable: true });
25+
return () => {
26+
if (originalStdout) Object.defineProperty(process, 'stdout', originalStdout);
27+
if (originalStderr) Object.defineProperty(process, 'stderr', originalStderr);
28+
};
29+
}
30+
31+
function installExitSpy(): { calls: number[]; restore: () => void } {
32+
const originalExit = process.exit;
33+
const calls: number[] = [];
34+
(process as any).exit = ((code?: number) => {
35+
calls.push(code ?? 0);
36+
}) as typeof process.exit;
37+
return { calls, restore: () => (process.exit = originalExit) };
38+
}
39+
40+
function drainedStream(): FakeWriteStream {
41+
return { writableLength: 0, once: vi.fn() };
42+
}
43+
44+
afterEach(() => {
45+
vi.clearAllMocks();
46+
});
47+
48+
test('exitAfterFlush exits immediately when both streams are already drained', async () => {
49+
const restoreStdio = installFakeStdio({ stdout: drainedStream(), stderr: drainedStream() });
50+
const exitSpy = installExitSpy();
51+
52+
try {
53+
await exitAfterFlush(1);
54+
} finally {
55+
restoreStdio();
56+
exitSpy.restore();
57+
}
58+
59+
assert.deepEqual(exitSpy.calls, [1]);
60+
});
61+
62+
// #1596: proves the queued-write branch — a stream with buffered data must be
63+
// let drain before the process exits, not raced against immediately.
64+
test('exitAfterFlush waits for a backlogged stream to drain before exiting', async () => {
65+
// The FLUSH_TIMEOUT_MS race's other arm is `sleep`, mocked module-wide to
66+
// resolve immediately; override it here to a promise that never resolves so
67+
// this test can only pass via the drain callback, not the timeout racing
68+
// ahead of it.
69+
vi.mocked(sleep).mockImplementationOnce(() => new Promise(() => {}));
70+
let drainCallback: (() => void) | undefined;
71+
const backloggedStderr: FakeWriteStream = {
72+
writableLength: 1024,
73+
once: vi.fn((event: string, cb: () => void) => {
74+
if (event === 'drain') drainCallback = cb;
75+
}),
76+
};
77+
const restoreStdio = installFakeStdio({ stdout: drainedStream(), stderr: backloggedStderr });
78+
const exitSpy = installExitSpy();
79+
80+
try {
81+
const pending = exitAfterFlush(2);
82+
// Give the microtask queue a turn: exit must not have fired yet, since the
83+
// backlogged stream hasn't drained.
84+
await Promise.resolve();
85+
await Promise.resolve();
86+
assert.deepEqual(exitSpy.calls, [], 'exit fired before the backlogged stream drained');
87+
assert.equal(typeof drainCallback, 'function');
88+
89+
drainCallback?.();
90+
await pending;
91+
} finally {
92+
restoreStdio();
93+
exitSpy.restore();
94+
}
95+
96+
assert.deepEqual(exitSpy.calls, [2]);
97+
});
98+
99+
// A stream that never emits 'drain' (a stalled or broken pipe) must not hang
100+
// the process — the FLUSH_TIMEOUT_MS race still exits. `sleep` is mocked
101+
// above so this resolves instantly instead of waiting the real timeout.
102+
test('exitAfterFlush still exits when a stream never drains', async () => {
103+
const neverDrains: FakeWriteStream = { writableLength: 1024, once: vi.fn() };
104+
const restoreStdio = installFakeStdio({ stdout: drainedStream(), stderr: neverDrains });
105+
const exitSpy = installExitSpy();
106+
107+
try {
108+
await exitAfterFlush(3);
109+
} finally {
110+
restoreStdio();
111+
exitSpy.restore();
112+
}
113+
114+
assert.deepEqual(exitSpy.calls, [3]);
115+
});

0 commit comments

Comments
 (0)