Files
iot-dc3/dc3-cli/test/device-wait.test.ts
pnokerandClaude Code b588204110 feat(cli): adopt the remaining guardian tests and auth store fix
adopts the remaining uncommitted work from the parallel session:
five new guardian test files (client-errors, device-import-template,
entity-id-guard, jwt-guard, option-hygiene) that lock in the error
taxonomy, id encoding, and cli option hygiene; an attachment file
cleaner in the agentic service; attachment controller and service
tests; a salt-type fix in the auth store; ci workflow typecheck step;
and the cli test report. 818 tests total across 37 files.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 21:26:15 +08:00

322 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Command } from 'commander';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerDeviceCommand, waitForOperation } from '../src/commands/device.js';
type FetchInit = { method?: string; headers?: unknown; body?: unknown };
type FetchCall = { url: string; init: FetchInit; atMs: number };
const fetchCalls: FetchCall[] = [];
vi.mock('../src/core/config-manager.js', () => ({
configManager: {
getActiveProfile: vi.fn(async () => ({ gateway: 'http://gw.test/', tenant: 't', username: 'u' })),
getActiveProfileName: vi.fn(async () => 'default'),
load: vi.fn(async () => ({ current_profile: 'default' })),
getSettings: vi.fn(async () => ({ renewal_threshold_hours: 12 })),
},
}));
vi.mock('../src/core/token-manager.js', () => ({
tokenManager: {
getState: vi.fn(async () => null),
needsRenewal: vi.fn(async () => false),
},
}));
const ACCEPTED = { operationId: 'op-1', statusUri: '/api/v3/manager/operations/get_by_id?id=op-1' };
function runningOperation(overrides: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
operationId: 'op-1',
status: 'RUNNING',
progress: 10,
result: null,
error: null,
createdAt: '1970-01-01T00:00:00Z',
updatedAt: '1970-01-01T00:00:01Z',
expiresAt: null,
...overrides,
}),
{ status: 200 },
);
}
/**
* Fake the clock at epoch 0. Only setTimeout/clearTimeout/Date are faked so
* promise microtasks and the libuv thread pool keep running for real: the
* action startup (commander parse + readFile) needs real event-loop rounds,
* so tests drive the clock adaptively instead of assuming a fixed startup lag.
*/
function useWaitFakeTimers(): void {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'], now: 0 });
}
/**
* Advance the faked clock until the condition holds or the budget runs out.
* @param condition - checked after every step
* @param maxFakeMs - total fake-millisecond budget
* @param stepMs - fake-millisecond step per iteration
* @returns whether the condition eventually held
*/
async function advanceUntil(condition: () => boolean, maxFakeMs: number, stepMs: number): Promise<boolean> {
for (let advanced = 0; advanced < maxFakeMs && !condition(); advanced += stepMs) {
await vi.advanceTimersByTimeAsync(stepMs);
}
return condition();
}
/** Sentinel while the driven execution has not settled yet. */
const PENDING = Symbol('pending');
/**
* Advance the faked clock until the execution settles. If it never settles
* the final await outlives the vitest timeout — which is exactly the
* regression signal these guard tests exist to raise.
* @param execution - the promise under test
* @param maxFakeMs - total fake-millisecond budget
* @param stepMs - fake-millisecond step per iteration
* @returns the settled value (resolved value or rejection reason), or undefined
*/
async function driveUntilSettled(execution: Promise<unknown>, maxFakeMs: number, stepMs: number): Promise<unknown> {
let outcome: unknown = PENDING;
const watched = execution.then(
(value) => {
outcome = { value };
return value;
},
(error) => {
outcome = { value: error };
return error;
},
);
for (let advanced = 0; advanced < maxFakeMs && outcome === PENDING; advanced += stepMs) {
await vi.advanceTimersByTimeAsync(stepMs);
}
await watched;
return outcome === PENDING ? undefined : (outcome as { value: unknown }).value;
}
/**
* True once the import POST reached the stubbed fetch.
* @returns whether the import request was issued
*/
function importSubmitted(): boolean {
return fetchCalls.some((call) => call.url.includes('/device/import'));
}
/**
* Status-poll timestamps recorded by the fetch stub, normalized to the first poll.
* @returns per-poll offsets in fake milliseconds
*/
function pollOffsets(): number[] {
const times = fetchCalls.filter((call) => !call.url.includes('/device/import')).map((call) => call.atMs);
return times.map((at) => at - times[0]);
}
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
registerDeviceCommand(program);
return program;
}
/**
* Poll on REAL timers until the process SIGINT listener count settles back to
* the expected value. Asserting the exact count on the settlement tick is
* load-flaky: the wait removes its listener in a finally block that can land
* a scheduler tick after the awaited promise resolves.
* @param expected - listener count to wait for
* @param deadlineMs - real-millisecond budget before giving up
* @returns whether the expected count was observed in time
*/
async function waitForSigintListenerCount(expected: number, deadlineMs: number): Promise<boolean> {
const startedAt = Date.now();
while (process.listenerCount('SIGINT') !== expected) {
if (Date.now() - startedAt >= deadlineMs) {
return false;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
return true;
}
function stubAlwaysRunning(expiresAtFromNowMs?: number): void {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: FetchInit) => {
fetchCalls.push({ url, init: init ?? {}, atMs: Date.now() });
if (String(url).includes('/device/import')) {
return new Response(JSON.stringify(ACCEPTED), { status: 202 });
}
const expiresAt =
expiresAtFromNowMs === undefined ? null : new Date(Date.now() + expiresAtFromNowMs).toISOString();
return runningOperation({ expiresAt });
}),
);
}
describe('device import wait loop (F010)', () => {
let directory: string;
let file: string;
beforeEach(async () => {
fetchCalls.length = 0;
directory = await mkdtemp(join(tmpdir(), 'dc3-cli-wait-'));
file = join(directory, 'devices.xlsx');
await writeFile(file, Buffer.from([1, 2, 3, 4]));
});
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
await rm(directory, { recursive: true, force: true });
});
it('ends with a structured timeout error instead of hanging on a never-terminal operation', async () => {
useWaitFakeTimers();
stubAlwaysRunning();
const execution = buildProgram().parseAsync(
['device', 'import', file, '--driver-id', '11', '--profile-id', '12', '--wait-timeout', '1', '--poll-interval', '100'],
{ from: 'user' },
);
expect(await advanceUntil(importSubmitted, 1_000_000, 100)).toBe(true);
const failure = await driveUntilSettled(execution, 10_000, 50);
// The command must TERMINATE (an unbounded loop outlives the vitest
// timeout) with the structured TimeoutError coordinates.
expect(failure).toMatchObject({
kind: 'timeout',
code: 'TIMEOUT',
exitCode: 1,
operationId: 'op-1',
lastStatus: 'RUNNING',
statusUri: ACCEPTED.statusUri,
});
expect((failure as { elapsedMs: number }).elapsedMs).toBeGreaterThanOrEqual(1000);
expect((failure as { message: string }).message).toContain('timed out waiting for operation op-1');
// Poll spacing grows exponentially from the base interval: 100 -> 200 ->
// 400, with the final sleep clamped by the remaining deadline. Offsets are
// normalized to the first poll so the real startup lag cannot skew them.
expect(pollOffsets()).toEqual([0, 100, 300, 700, 1000]);
});
it('clamps the deadline to the operation expiresAt plus a grace window', async () => {
useWaitFakeTimers();
// The gateway keeps answering RUNNING but reports the operation as
// expiring one second after each poll.
stubAlwaysRunning(1_000);
const execution = buildProgram().parseAsync(
['device', 'import', file, '--driver-id', '11', '--profile-id', '12', '--wait-timeout', '600'],
{ from: 'user' },
);
expect(await advanceUntil(importSubmitted, 1_000_000, 100)).toBe(true);
const failure = await driveUntilSettled(execution, 45_000, 250);
// The 600s user timeout must NOT govern: the expiresAt clamp (1s + 30s
// grace) ends the wait roughly 31s after the first poll.
expect(failure).toMatchObject({ kind: 'timeout', code: 'TIMEOUT' });
const elapsedMs = (failure as { elapsedMs: number }).elapsedMs;
expect(elapsedMs).toBeGreaterThanOrEqual(30_000);
expect(elapsedMs).toBeLessThan(34_000);
});
it('caps the exponential backoff at 10 seconds between polls', async () => {
useWaitFakeTimers();
stubAlwaysRunning();
const execution = buildProgram().parseAsync(
['device', 'import', file, '--driver-id', '11', '--profile-id', '12', '--wait-timeout', '45', '--poll-interval', '100'],
{ from: 'user' },
);
expect(await advanceUntil(importSubmitted, 1_000_000, 100)).toBe(true);
const failure = await driveUntilSettled(execution, 60_000, 250);
expect(failure).toMatchObject({ kind: 'timeout' });
const offsets = pollOffsets();
const gaps = offsets.slice(1).map((at, i) => at - offsets[i]);
// Backoff doubles from 100ms, is capped at 10s, and never exceeds the cap
// even for the sleeps the doubling would have made longer.
expect(Math.max(...gaps)).toBe(10_000);
expect(gaps.slice(0, 6)).toEqual([100, 200, 400, 800, 1600, 3200]);
});
it('installs and removes the SIGINT listener around the wait', async () => {
useWaitFakeTimers();
stubAlwaysRunning();
const baseline = process.listenerCount('SIGINT');
const execution = buildProgram().parseAsync(
['device', 'import', file, '--driver-id', '11', '--profile-id', '12', '--wait-timeout', '600'],
{ from: 'user' },
);
expect(
await advanceUntil(() => process.listenerCount('SIGINT') >= baseline + 1, 1_000_000, 100),
).toBe(true);
await driveUntilSettled(execution, 700_000, 1_000);
// The fake clock has done its job (the wait settled); give removal a
// load-tolerant real-time window instead of demanding the exact count on
// the settlement tick. A genuine leak exhausts the deadline and fails.
vi.useRealTimers();
expect(
await waitForSigintListenerCount(baseline, 5_000),
`SIGINT listener count ${process.listenerCount('SIGINT')} never returned to baseline ${baseline}`,
).toBe(true);
});
});
describe('waitForOperation interrupt signal (F010)', () => {
beforeEach(() => {
fetchCalls.length = 0;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('an aborted wait reports the last status and the status URI', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: FetchInit) => {
fetchCalls.push({ url, init: init ?? {}, atMs: Date.now() });
return runningOperation();
}),
);
const controller = new AbortController();
const wait = waitForOperation(ACCEPTED, 5_000, 600_000, controller.signal);
await new Promise((resolve) => setTimeout(resolve, 20));
controller.abort();
const failure = await wait.then(() => null, (error: unknown) => error);
expect(failure).toMatchObject({
kind: 'timeout',
code: 'INTERRUPTED',
exitCode: 1,
});
expect((failure as { message: string }).message).toContain('op-1');
expect((failure as { message: string }).message).toContain('RUNNING');
expect((failure as { message: string }).message).toContain(ACCEPTED.statusUri);
expect(fetchCalls.length).toBeGreaterThanOrEqual(1);
});
it('returns the terminal operation view untouched', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => runningOperation({ status: 'SUCCEEDED', progress: 100 })),
);
const operation = await waitForOperation(ACCEPTED, 100, 600_000);
expect(operation).toMatchObject({ operationId: 'op-1', status: 'SUCCEEDED' });
});
});