From 6c018fbd6ae4c76df1b871c564324e27a73883e8 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Fri, 18 Sep 2026 17:05:26 +0800 Subject: [PATCH] [CTX-0072] feat(cli): implement inspect-only watch mode per accepted design (#121) --- src/cli.ts | 476 +++++++++++++++++++++++++++- src/inspection.ts | 6 +- tests/cli.test.ts | 766 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 1235 insertions(+), 13 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 5bd8b65..392e991 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -59,6 +59,21 @@ export const MAX_JSON_DEPTH = 8 as const; /** Generation used by `inspect --budgets` when the caller omits one. */ export const DEFAULT_GENERATION = 1 as const; +/** Default `inspect --watch` interval in milliseconds. */ +export const DEFAULT_WATCH_INTERVAL_MS = 2000 as const; + +/** Floor for `--interval-ms`: 10x the accepted sampling floor (CTX-0189). */ +export const WATCH_FLOOR_MS = 1000 as const; + +/** Ceiling for `--interval-ms`. */ +export const WATCH_CEILING_MS = 60000 as const; + +/** Per-tick jitter fraction (+-10%, anti-thundering-herd only). */ +export const WATCH_JITTER_FRACTION = 0.1 as const; + +/** Cap for `--max-ticks` (tests use a bounded value at or below this). */ +export const WATCH_MAX_TICKS = 1000 as const; + export const DEFAULT_TRACE_DURATION_MS = 10000 as const; export const DEFAULT_TRACE_MAX_BYTES = 524288 as const; export const TRACE_ID_MAX_BYTES = 128 as const; @@ -72,6 +87,9 @@ export type InspectOptions = { json: boolean; socket: string | null; instance: string | null; + watch: boolean; + intervalMs: number | null; + maxTicks: number | null; }; export type TraceStartOptions = { @@ -139,6 +157,7 @@ Usage: bitty-devtools inspect --plugins [--generation ] [options] bitty-devtools inspect --subscriptions --plugin [options] bitty-devtools inspect --budgets --plugin [--generation ] [options] + bitty-devtools inspect (--plugins|--subscriptions --plugin |--budgets --plugin ) [--watch] [--interval-ms ] [--max-ticks ] [options] bitty-devtools trace start --wire-trace [--duration-ms ] [--max-bytes ] [--include-input] [options] bitty-devtools trace stop --wire-trace --trace-id [options] bitty-devtools trace fetch-chunk --wire-trace --trace-id --offset [options] @@ -157,6 +176,9 @@ Trace (requires --wire-trace, live socket, debug.trace): Options: --plugin Plugin id required by --subscriptions and --budgets. --generation Target plugin generation (default ${DEFAULT_GENERATION} for --budgets). + --watch Re-dispatch the inspect selector until cancelled or --max-ticks. + --interval-ms Watch interval ${WATCH_FLOOR_MS}..${WATCH_CEILING_MS}, default ${DEFAULT_WATCH_INTERVAL_MS}. + --max-ticks Watch frame cap 1..${WATCH_MAX_TICKS} (required bounded in tests). --wire-trace Presence-only opt-in for trace verbs, default off. --duration-ms Trace duration 1..300000, default 10000. --max-bytes Trace bytes 1..4194304, default 524288. @@ -197,6 +219,40 @@ function takeValue( return value; } +function parseWatchIntervalMs(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new CliUsageError( + `--interval-ms must be an integer in ${WATCH_FLOOR_MS}..${WATCH_CEILING_MS} (saw '${raw}')`, + ); + } + const value = Number(raw); + if ( + !Number.isSafeInteger(value) || + value < WATCH_FLOOR_MS || + value > WATCH_CEILING_MS + ) { + throw new CliUsageError( + `--interval-ms must be an integer in ${WATCH_FLOOR_MS}..${WATCH_CEILING_MS} (saw '${raw}')`, + ); + } + return value; +} + +function parseWatchMaxTicks(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new CliUsageError( + `--max-ticks must be an integer in 1..${WATCH_MAX_TICKS} (saw '${raw}')`, + ); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1 || value > WATCH_MAX_TICKS) { + throw new CliUsageError( + `--max-ticks must be an integer in 1..${WATCH_MAX_TICKS} (saw '${raw}')`, + ); + } + return value; +} + function parseInspect(argv: readonly string[]): CliCommand { let selector: InspectSelector | null = null; let plugin: string | null = null; @@ -204,6 +260,9 @@ function parseInspect(argv: readonly string[]): CliCommand { let json = false; let socket: string | null = null; let instance: string | null = null; + let watch = false; + let intervalRaw: string | null = null; + let maxTicksRaw: string | null = null; const setSelector = (next: InspectSelector, flag: string): void => { if (selector !== null) { @@ -245,6 +304,17 @@ function parseInspect(argv: readonly string[]): CliCommand { case "--json": json = true; break; + case "--watch": + watch = true; + break; + case "--interval-ms": + intervalRaw = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--max-ticks": + maxTicksRaw = takeValue(argv, i + 1, arg); + i += 1; + break; case "-h": case "--help": return { kind: "help" }; @@ -275,6 +345,20 @@ function parseInspect(argv: readonly string[]): CliCommand { parsedGeneration = value; } + let intervalMs: number | null = null; + if (intervalRaw !== null) { + intervalMs = parseWatchIntervalMs(intervalRaw); + } + let maxTicks: number | null = null; + if (maxTicksRaw !== null) { + maxTicks = parseWatchMaxTicks(maxTicksRaw); + } + if ((intervalMs !== null || maxTicks !== null) && !watch) { + throw new CliUsageError( + "--interval-ms/--max-ticks require --watch (single-shot inspect takes no watch flags)", + ); + } + return { kind: "inspect", options: { @@ -284,6 +368,9 @@ function parseInspect(argv: readonly string[]): CliCommand { json, socket, instance, + watch, + intervalMs, + maxTicks, }, }; } @@ -565,6 +652,12 @@ export type CliDeps = { * one from flags/environment. */ transport?: IpcTransport; + /** + * Watch-loop hooks (CTX-0072, A4 sections 3-4). Tests inject a bounded + * clock, deterministic jitter, a no-op sleep, and an abort bridge; the + * production binary entry wires one `AbortController` to SIGINT. + */ + watch?: WatchHooks; }; /** @@ -826,6 +919,252 @@ function dispatchTraceFetch( return renderTraceChunk(result, options.json); } +/** + * Effective per-tick sleep for `inspect --watch` (A4 section 3). + * + * `intervalMs * (1 + U)` with U uniform in [-0.10, +0.10] drawn per tick + * from a non-crypto PRNG. Jitter is anti-thundering-herd only, never a + * security boundary. Pure function of `(intervalMs, random01)` so tests can + * assert the clamp without real timers. + */ +export function watchTickDelayMs(intervalMs: number, random01: number): number { + const clamped = + random01 < 0 + ? 0 + : random01 > 1 + ? 1 + : Number.isFinite(random01) + ? random01 + : 0; + const uniform = clamped * 2 - 1; + return intervalMs * (1 + WATCH_JITTER_FRACTION * uniform); +} + +export type WatchHooks = { + now?: () => number; + random01?: () => number; + onTick?: (frames: number, delayMs: number) => void; + shouldContinue?: () => boolean; + sleep?: (delayMs: number) => Promise; + signal?: AbortSignal; + onSignal?: (abort: () => void) => () => void; +}; + +export type WatchTickOutcome = + | { kind: "frame"; output: string } + | { kind: "rateLimited"; error: unknown } + | { kind: "denied"; error: unknown } + | { kind: "failed"; error: unknown } + | { kind: "cancelled" }; + +/** + * One watch tick over the already-connected client (A4 sections 2-5). + * + * Exactly one inspect dispatch via the existing `dispatch` path, pinned to + * the already-granted `debug.inspect` scope. Per-tick peer re-verification + * runs up front (fail-closed); the dispatch itself enforces RC-9 admission + * plus peer verification per request, so a `RateLimited` verdict (typed + * server budget error or client limiter) skips emission for this tick and + * defers to the next scheduled tick, never a tight retry loop. A + * `ScopeDenied` verdict terminates the loop with zero further ticks. The + * partial frame of a cancelled tick is discarded, never rendered, never + * spooled, never retained. No cross-tick retention: only the returned output + * string leaves this function. + */ +export function runWatchTick( + client: DevtoolsClient, + transport: IpcTransport, + options: InspectOptions, + signal?: AbortSignal, +): WatchTickOutcome { + if (signal?.aborted) return { kind: "cancelled" }; + if (!client.isIpcConnected()) return { kind: "cancelled" }; + try { + transport.verifyPeerForPrivilegedAction(); + } catch (error) { + if (error instanceof TransportError && error.code === "TransportClosed") { + return { kind: "cancelled" }; + } + if (error instanceof AuthError) return { kind: "denied", error }; + return { kind: "failed", error }; + } + if (signal?.aborted) return { kind: "cancelled" }; + let output: string; + try { + output = dispatch(client, options); + } catch (error) { + if (signal?.aborted) return { kind: "cancelled" }; + if (error instanceof InspectionError && error.code === "ScopeDenied") { + return { kind: "denied", error }; + } + if (error instanceof InspectionError && error.code === "RateLimited") { + return { kind: "rateLimited", error }; + } + if (error instanceof AuthError) { + return { kind: "denied", error }; + } + if (error instanceof TransportError) { + if (error.code === "RateLimited" || error.code === "TransportFull") { + return { kind: "rateLimited", error }; + } + if (error.code === "TransportClosed") return { kind: "cancelled" }; + } + if ( + error instanceof Error && + (error.message.includes("scope denied") || + error.message.includes("scope required") || + error.message.includes("ScopeDenied")) + ) { + return { kind: "denied", error }; + } + return { kind: "failed", error }; + } + if (signal?.aborted) return { kind: "cancelled" }; + return { kind: "frame", output }; +} + +async function runWatchTickLive( + client: DevtoolsClient, + options: InspectOptions, + nowMs: number, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) return { kind: "cancelled" }; + if (!client.isIpcConnected()) return { kind: "cancelled" }; + try { + const output = await dispatchLive(client, options, nowMs); + if (signal?.aborted) return { kind: "cancelled" }; + return { kind: "frame", output }; + } catch (error) { + if (signal?.aborted) return { kind: "cancelled" }; + if ( + error instanceof InspectionError && + (error.code === "ScopeDenied" || error.message.includes("scope")) + ) { + return { kind: "denied", error }; + } + if (error instanceof AuthError) { + return { kind: "denied", error }; + } + if (error instanceof TransportError) { + if (error.code === "RateLimited" || error.code === "TransportFull") { + return { kind: "rateLimited", error }; + } + if (error.code === "TransportClosed") return { kind: "cancelled" }; + } + if ( + error instanceof Error && + (error.message.includes("scope denied") || + error.message.includes("scope required") || + error.message.includes("ScopeDenied")) + ) { + return { kind: "denied", error }; + } + return { kind: "failed", error }; + } +} + +/** + * Inspect-only watch loop for the headless entry (A4 sections 2-5). + * + * Owns one `AbortController` bridged to `hooks.signal` (SIGINT/close in + * production) and to client disconnect. Exactly one `dispatch` per tick; + * `--max-ticks` caps total frames; per-tick bounds reuse the single-shot + * render path; no history buffer is retained. Returns the terminal exit code: + * 0 when cancelled after >=1 complete frame, otherwise the pending typed + * verdict (6 TransportClosed/RateLimited, 7 ScopeDenied, 1 InvalidResult). + */ +async function runWatchLoopWith( + options: InspectOptions, + runtime: CliRuntime, + hooks: WatchHooks, + tick: (signal: AbortSignal) => WatchTickOutcome | Promise, +): Promise { + const intervalMs = options.intervalMs ?? DEFAULT_WATCH_INTERVAL_MS; + const maxTicks = options.maxTicks ?? null; + const random01 = hooks.random01 ?? Math.random; + const shouldContinue = hooks.shouldContinue ?? (() => true); + const sleep = + hooks.sleep ?? + ((delayMs: number) => new Promise((r) => setTimeout(r, delayMs))); + const controller = new AbortController(); + const detach = + hooks.onSignal !== undefined + ? hooks.onSignal(() => controller.abort()) + : undefined; + const external = hooks.signal; + const onExternalAbort = (): void => controller.abort(); + if (external !== undefined) { + if (external.aborted) controller.abort(); + else external.addEventListener("abort", onExternalAbort, { once: true }); + } + let frames = 0; + let pending: unknown = null; + const settle = (): number => { + if (frames >= 1) return EXIT_OK; + if (pending !== null) { + runtime.stderr(`bitty-devtools: ${formatCliError(pending)}\n`); + return exitCodeForError(pending); + } + return EXIT_RUNTIME; + }; + try { + for (;;) { + if (controller.signal.aborted || !shouldContinue()) return settle(); + if (maxTicks !== null && frames >= maxTicks) return EXIT_OK; + const delay = watchTickDelayMs(intervalMs, random01()); + hooks.onTick?.(frames, delay); + const outcome = await tick(controller.signal); + if (outcome.kind === "frame") { + runtime.stdout(`${outcome.output}\n`); + frames += 1; + if (maxTicks !== null && frames >= maxTicks) return EXIT_OK; + await sleep(delay); + } else if (outcome.kind === "rateLimited") { + pending = outcome.error; + await sleep(delay); + } else if (outcome.kind === "denied") { + runtime.stderr(`bitty-devtools: ${formatCliError(outcome.error)}\n`); + return exitCodeForError(outcome.error); + } else if (outcome.kind === "cancelled") { + return settle(); + } else { + runtime.stderr(`bitty-devtools: ${formatCliError(outcome.error)}\n`); + return exitCodeForError(outcome.error); + } + } + } finally { + if (external !== undefined) { + external.removeEventListener("abort", onExternalAbort); + } + detach?.(); + } +} + +export async function runWatchLoop( + client: DevtoolsClient, + transport: IpcTransport, + options: InspectOptions, + runtime: CliRuntime, + hooks: WatchHooks = {}, +): Promise { + return runWatchLoopWith(options, runtime, hooks, (signal) => + runWatchTick(client, transport, options, signal), + ); +} + +async function runWatchLoopLive( + client: DevtoolsClient, + options: InspectOptions, + runtime: CliRuntime, + hooks: WatchHooks = {}, +): Promise { + const now = hooks.now ?? runtime.now; + return runWatchLoopWith(options, runtime, hooks, (signal) => + runWatchTickLive(client, options, now(), signal), + ); +} + /** * Live dispatch for `runCliLive`: same selectors as `dispatch`, but each * inspection call goes over the socket via `client.requestLive` instead of @@ -974,6 +1313,11 @@ export function exitCodeForError(error: unknown): number { * * The live-socket path (`deps.liveSocket === true`, CTX-0036) is async via * `runCliLiveAsync`; use that entry directly when dialing is wanted. + * + * With `inspect --watch` this entry is intentionally sync-fail-closed: the + * watch loop needs an async sleep, so use {@link runCliWatch} (or the async + * live entry) instead. A sync caller that passes `--watch` gets exit 2 and a + * diagnostic, never a half-run loop. */ export function runCli(argv: readonly string[], deps: CliDeps): number { const { runtime } = deps; @@ -1039,6 +1383,107 @@ export function runCli(argv: readonly string[], deps: CliDeps): number { try { client.connectWithTransport(transport); client.grantScope("debug.inspect"); + if (options.watch) { + runtime.stderr( + "bitty-devtools: inspect --watch requires the async entry (runCliWatch/runCliLive)\n", + ); + return EXIT_USAGE; + } + const output = dispatch(client, options); + runtime.stdout(`${output}\n`); + return EXIT_OK; + } catch (error) { + runtime.stderr(`bitty-devtools: ${formatCliError(error)}\n`); + return exitCodeForError(error); + } finally { + try { + client.disconnect(); + } catch { + // Disconnect is best-effort; a closed transport must not mask the result. + } + } +} + +/** + * Headless inspect-only watch entry for programmatic callers (CTX-0072). + * + * Same contract as the `--watch` branch of `runCli`, but async: callers that + * need to await tick sleeps use this instead of the sync `runCli`. + */ +export async function runCliWatch( + argv: readonly string[], + deps: CliDeps, +): Promise { + const { runtime } = deps; + + let command: CliCommand; + try { + command = parseCliArgs(argv); + } catch (error) { + runtime.stderr(`bitty-devtools: ${formatCliError(error)}\n\n${USAGE}\n`); + return EXIT_USAGE; + } + + if (command.kind === "help") { + runtime.stdout(`${USAGE}\n`); + return EXIT_OK; + } + + if ( + command.kind === "trace-start" || + command.kind === "trace-stop" || + command.kind === "trace-fetch" + ) { + runtime.stderr( + "bitty-devtools: no connected Bitty instance; trace requires live socket, pass --socket or " + + "--instance via live entry\n", + ); + return EXIT_RUNTIME; + } + + const options = command.options; + let transport = deps.transport ?? null; + if (transport === null) { + let socketPath: string | null; + try { + socketPath = resolveSocket(options, runtime); + } catch (error) { + runtime.stderr(`bitty-devtools: ${formatCliError(error)}\n`); + return exitCodeForError(error); + } + if (socketPath === null) { + runtime.stderr( + "bitty-devtools: no connected Bitty instance; pass --socket or " + + "--instance , or set BITTY_SOCKET / BITTY_INSTANCE_ID with " + + "XDG_RUNTIME_DIR\n", + ); + return EXIT_RUNTIME; + } + try { + transport = new IpcTransport({ + runtimeUid: runtime.uid, + socketPath, + peer: peerCredentials(runtime.uid, runtime.gid, runtime.pid), + }); + } catch (error) { + runtime.stderr(`bitty-devtools: ${formatCliError(error)}\n`); + return exitCodeForError(error); + } + } + + const client = new DevtoolsClient(); + try { + client.connectWithTransport(transport); + client.grantScope("debug.inspect"); + if (options.watch) { + return await runWatchLoop( + client, + transport, + options, + runtime, + deps.watch ?? {}, + ); + } const output = dispatch(client, options); runtime.stdout(`${output}\n`); return EXIT_OK; @@ -1153,16 +1598,33 @@ export async function runCliLive( return EXIT_RUNTIME; } + const injected = deps.transport ?? null; const client = new DevtoolsClient(); try { - await client.connectLiveSocket( - runtime.uid, - peerCredentials(runtime.uid, runtime.gid, runtime.pid), - runtime.env["XDG_RUNTIME_DIR"], - options.instance ?? undefined, - socketPath, - ); + if (injected !== null) { + client.connectWithTransport(injected); + } else { + await client.connectLiveSocket( + runtime.uid, + peerCredentials(runtime.uid, runtime.gid, runtime.pid), + runtime.env["XDG_RUNTIME_DIR"], + options.instance ?? undefined, + socketPath, + ); + } client.grantScope("debug.inspect"); + if (options.watch) { + if (injected !== null) { + return await runWatchLoop( + client, + injected, + options, + runtime, + deps.watch ?? {}, + ); + } + return await runWatchLoopLive(client, options, runtime, deps.watch ?? {}); + } const output = await dispatchLive(client, options, runtime.now()); runtime.stdout(`${output}\n`); return EXIT_OK; diff --git a/src/inspection.ts b/src/inspection.ts index b7847c7..11a0fe4 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -668,11 +668,7 @@ export class InspectionClient { } private requireInspect(scope: string): void { - if ( - scope !== "debug.inspect" && - scope !== "debug.trace" && - scope !== "debug.control" - ) { + if (scope !== "debug.inspect") { throw new InspectionError("ScopeDenied", "debug.inspect scope required"); } } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 0feff2e..541dd08 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -5,15 +5,22 @@ import { DEFAULT_GENERATION, DEFAULT_TRACE_DURATION_MS, DEFAULT_TRACE_MAX_BYTES, + DEFAULT_WATCH_INTERVAL_MS, MAX_CELL_CHARS, + WATCH_CEILING_MS, + WATCH_FLOOR_MS, + WATCH_JITTER_FRACTION, + WATCH_MAX_TICKS, exitCodeForError, parseCliArgs, runCli, runCliLive, + runCliWatch, + watchTickDelayMs, } from "../src/cli.js"; import type { CliRuntime } from "../src/cli.js"; import { DIR_MODE, SOCKET_MODE, peerCredentials } from "../src/auth.js"; -import { IpcTransport } from "../src/transport.js"; +import { IpcTransport, TransportError } from "../src/transport.js"; import type { IpcRequest } from "../src/transport.js"; import { EXIT_CONFIG, @@ -148,6 +155,9 @@ describe("parseCliArgs", () => { json: false, socket: null, instance: null, + watch: false, + intervalMs: null, + maxTicks: null, }, }); }); @@ -176,6 +186,9 @@ describe("parseCliArgs", () => { json: true, socket: "/tmp/bitty.sock", instance: "dev", + watch: false, + intervalMs: null, + maxTicks: null, }, }); }); @@ -902,3 +915,754 @@ describe("wire-trace flag contract N1-N15", () => { expect(h.out.join("")).toContain("262144"); }); }); + +describe("inspect --watch mode per accepted design A4 (CTX-0072)", () => { + test("W1 parses --watch with defaults (interval null, max-ticks null)", () => { + expect(parseCliArgs(["inspect", "--plugins", "--watch"])).toEqual({ + kind: "inspect", + options: { + selector: "plugins", + plugin: null, + generation: null, + json: false, + socket: null, + instance: null, + watch: true, + intervalMs: null, + maxTicks: null, + }, + }); + expect(DEFAULT_WATCH_INTERVAL_MS).toBe(2000); + expect(WATCH_FLOOR_MS).toBe(1000); + expect(WATCH_CEILING_MS).toBe(60000); + expect(WATCH_JITTER_FRACTION).toBe(0.1); + expect(WATCH_MAX_TICKS).toBe(1000); + }); + + test("W1 parses --watch with explicit interval and max-ticks", () => { + expect( + parseCliArgs([ + "inspect", + "--budgets", + "--plugin", + "plugin-a", + "--watch", + "--interval-ms", + "3000", + "--max-ticks", + "4", + ]), + ).toEqual({ + kind: "inspect", + options: { + selector: "budgets", + plugin: "plugin-a", + generation: null, + json: false, + socket: null, + instance: null, + watch: true, + intervalMs: 3000, + maxTicks: 4, + }, + }); + }); + + test("W2 --watch without a selector, with unknown flags, or flag-as-value exits 2", () => { + expect(() => parseCliArgs(["--watch"])).toThrow(CliUsageError); + expect(() => parseCliArgs(["inspect", "--watch"])).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--wire-trace"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--duration-ms", "100"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--max-bytes", "1024"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--include-input"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--trace-id", "t"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--offset", "0"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--bearer", "x"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs([ + "inspect", + "--plugins", + "--watch", + "--interval-ms", + "--json", + ]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs([ + "inspect", + "--plugins", + "--watch", + "--max-ticks", + "--json", + ]), + ).toThrow(CliUsageError); + const h = makeHarness(); + expect(runCli(["inspect", "--watch"], h.deps)).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + expect(h.out).toEqual([]); + }); + + test("W2 interval/max-ticks without --watch exits 2 (single-shot takes no watch flags)", () => { + expect(() => + parseCliArgs(["inspect", "--plugins", "--interval-ms", "2000"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--max-ticks", "3"]), + ).toThrow(CliUsageError); + const h = makeHarness(); + expect( + runCli(["inspect", "--plugins", "--interval-ms", "2000"], h.deps), + ).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + expect(h.out).toEqual([]); + }); + + test("W3 bad --interval-ms exits 2 (floor 1000, ceiling 60000)", () => { + for (const bad of ["0", "999", "60001", "abc", "1.5", "2000ms", ""]) { + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--interval-ms", bad]), + ).toThrow(CliUsageError); + } + expect(() => + parseCliArgs([ + "inspect", + "--plugins", + "--watch", + "--interval-ms", + "--json", + ]), + ).toThrow(CliUsageError); + expect( + parseCliArgs([ + "inspect", + "--plugins", + "--watch", + "--interval-ms", + "1000", + ]), + ).toEqual( + expect.objectContaining({ + kind: "inspect", + options: expect.objectContaining({ intervalMs: 1000 }), + }), + ); + expect( + parseCliArgs([ + "inspect", + "--plugins", + "--watch", + "--interval-ms", + "60000", + ]), + ).toEqual( + expect.objectContaining({ + kind: "inspect", + options: expect.objectContaining({ intervalMs: 60000 }), + }), + ); + const h = makeHarness(); + expect( + runCli( + ["inspect", "--plugins", "--watch", "--interval-ms", "500"], + h.deps, + ), + ).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + expect(h.out).toEqual([]); + }); + + test("W3 bad --max-ticks exits 2 (1..1000)", () => { + for (const bad of ["0", "1001", "abc", "1.5", "-3", ""]) { + expect(() => + parseCliArgs(["inspect", "--plugins", "--watch", "--max-ticks", bad]), + ).toThrow(CliUsageError); + } + const h = makeHarness(); + expect( + runCli(["inspect", "--plugins", "--watch", "--max-ticks", "0"], h.deps), + ).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + expect(h.out).toEqual([]); + }); + + test("W3 jitter clamps to +-10% of the interval", () => { + expect(watchTickDelayMs(2000, 0)).toBeCloseTo(1800); + expect(watchTickDelayMs(2000, 0.5)).toBeCloseTo(2000); + expect(watchTickDelayMs(2000, 1)).toBeCloseTo(2200); + expect(watchTickDelayMs(1000, 0)).toBe(900); + expect(watchTickDelayMs(60000, 1)).toBe(66000); + for (const r of [0, 0.13, 0.5, 0.87, 1]) { + const delay = watchTickDelayMs(2000, r); + expect(delay).toBeGreaterThanOrEqual(1800); + expect(delay).toBeLessThanOrEqual(2200); + } + }); + + test("W4 sync runCli with --watch exits 2 (async entry required), single-shot unchanged", () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + const harness = makeHarness(transport); + const code = runCli(["inspect", "--plugins", "--watch"], harness.deps); + expect(code).toBe(EXIT_USAGE); + expect(harness.out).toEqual([]); + expect(harness.err.join("")).toContain("async entry"); + expect(transport.calls).toEqual([]); + + const single = makeTransport(); + single.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + const singleHarness = makeHarness(single); + expect(runCli(["inspect", "--plugins"], singleHarness.deps)).toBe(EXIT_OK); + expect(single.calls.length).toBe(1); + expect(single.calls[0]!.method).toBe("bitty.debug/listPlugins"); + }); + + test("W5 watch emits one inspect dispatch per tick via the existing path", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload({ id: "plugin-b" })] }), + ); + const harness = makeHarness(transport); + const delays: number[] = []; + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "2"], + { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => {}, + onTick: (_tick, delayMs) => delays.push(delayMs), + }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(harness.err).toEqual([]); + expect(transport.calls.length).toBe(2); + expect(transport.calls[0]!.method).toBe("bitty.debug/listPlugins"); + expect(transport.calls[0]!.params).toEqual({ generation: null }); + expect(transport.calls[1]!.method).toBe("bitty.debug/listPlugins"); + expect(transport.calls[1]!.params).toEqual({ generation: null }); + const output = harness.out.join(""); + expect(output).toContain("plugin-a"); + expect(output).toContain("plugin-b"); + expect(delays.length).toBe(2); + expect(delays[0]).toBeCloseTo(DEFAULT_WATCH_INTERVAL_MS); + }); + + test("W5 watch --subscriptions dispatches the exact method and params per tick", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, [subscriptionPayload()]), + ); + const harness = makeHarness(transport); + const code = await runCliWatch( + [ + "inspect", + "--subscriptions", + "--plugin", + "plugin-a", + "--watch", + "--max-ticks", + "1", + ], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(transport.calls.length).toBe(1); + expect(transport.calls[0]!.method).toBe("bitty.debug/listSubscriptions"); + expect(transport.calls[0]!.params).toEqual({ pluginId: "plugin-a" }); + expect(harness.out.join("")).toContain("bitty.panel:mounted"); + }); + + test("W5 watch --budgets uses the default generation per tick", async () => { + const transport = makeTransport(); + transport.injectResponsePayload(responsePayload(1, budgetPayload())); + const harness = makeHarness(transport); + const code = await runCliWatch( + [ + "inspect", + "--budgets", + "--plugin", + "plugin-a", + "--watch", + "--max-ticks", + "1", + ], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(transport.calls[0]!.method).toBe("bitty.debug/getBudgets"); + expect(transport.calls[0]!.params).toEqual({ + pluginId: "plugin-a", + generation: DEFAULT_GENERATION, + }); + }); + + test("W6 server ScopeDenied terminates the loop with exit 7 and zero further ticks", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + transport.injectResponsePayload( + errorPayload(2, "scope", "ScopeDenied", "scope denied"), + ); + const harness = makeHarness(transport); + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "5"], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_PERM); + expect(harness.err.join("")).toContain("ScopeDenied"); + expect(transport.calls.length).toBe(2); + expect(harness.out.length).toBe(1); + expect(harness.out.join("")).toContain("plugin-a"); + }); + + test("W7 server RateLimited skips emission for that tick and defers to the next", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + errorPayload(1, "budget", "RateLimited", "slow down"), + ); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload()] }), + ); + const harness = makeHarness(transport); + let sleeps = 0; + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "1"], + { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => { + sleeps += 1; + }, + }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(harness.err).toEqual([]); + expect(transport.calls.length).toBe(2); + expect(transport.calls[0]!.method).toBe("bitty.debug/listPlugins"); + expect(transport.calls[1]!.method).toBe("bitty.debug/listPlugins"); + expect(harness.out.length).toBe(1); + expect(harness.out.join("")).toContain("plugin-a"); + expect(sleeps).toBe(1); + }); + + test("W7 RateLimited with zero frames settles on the pending exit 6", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + errorPayload(1, "budget", "RateLimited", "slow down"), + ); + const harness = makeHarness(transport); + const controller = new AbortController(); + const code = await runCliWatch(["inspect", "--plugins", "--watch"], { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => { + controller.abort(); + }, + signal: controller.signal, + }, + }); + expect(code).toBe(EXIT_RUNTIME); + expect(harness.err.join("")).toContain("RateLimited"); + expect(harness.out).toEqual([]); + expect(transport.calls.length).toBe(1); + expect(transport.calls[0]!.method).toBe("bitty.debug/listPlugins"); + }); + + test("W7 client-side RC-9 RateLimited skips the tick without emitting", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload()] }), + ); + const limiter = transport.getRateLimiter(); + const original = limiter.check.bind(limiter); + let throwOnce = true; + limiter.check = (nowMs: number): void => { + if (throwOnce) { + throwOnce = false; + throw new TransportError("RateLimited", "rate limited"); + } + original(nowMs); + }; + const harness = makeHarness(transport); + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "1"], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(harness.err).toEqual([]); + expect(transport.calls.length).toBe(2); + expect(transport.calls[0]!.id).toBe(1); + expect(transport.calls[1]!.id).toBe(2); + expect(harness.out.length).toBe(1); + expect(harness.out.join("")).toContain("plugin-a"); + }); + + test("W8 abort before any frame exits 6 with no partial output", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + const harness = makeHarness(transport); + const controller = new AbortController(); + controller.abort(); + const code = await runCliWatch(["inspect", "--plugins", "--watch"], { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => {}, + signal: controller.signal, + }, + }); + expect(code).toBe(EXIT_RUNTIME); + expect(harness.out).toEqual([]); + expect(transport.calls).toEqual([]); + }); + + test("W8 abort after one frame exits 0 and keeps the complete frame", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload({ id: "plugin-b" })] }), + ); + const harness = makeHarness(transport); + const controller = new AbortController(); + let seen = 0; + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "5"], + { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => { + seen += 1; + if (seen >= 1) controller.abort(); + }, + signal: controller.signal, + }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(harness.out.length).toBe(1); + expect(harness.out.join("")).toContain("plugin-a"); + expect(harness.out.join("")).not.toContain("plugin-b"); + expect(transport.calls.length).toBe(1); + }); + + test("W8 transport close before any frame exits 6 with no partial output", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + const harness = makeHarness(transport); + const code = await runCliWatch(["inspect", "--plugins", "--watch"], { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => {}, + onTick: () => { + transport.disconnect(); + }, + }, + }); + expect(code).toBe(EXIT_RUNTIME); + expect(harness.out).toEqual([]); + expect(transport.calls).toEqual([]); + }); + + test("W8 transport close after one frame exits 0 and discards the in-flight tick", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload({ id: "plugin-b" })] }), + ); + const harness = makeHarness(transport); + const code = await runCliWatch(["inspect", "--plugins", "--watch"], { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => { + transport.disconnect(); + }, + }, + }); + expect(code).toBe(EXIT_OK); + expect(harness.err).toEqual([]); + expect(harness.out.length).toBe(1); + expect(harness.out.join("")).toContain("plugin-a"); + expect(harness.out.join("")).not.toContain("plugin-b"); + expect(transport.calls.length).toBe(1); + }); + + test("W8 onSignal bridge aborts the loop and detaches once", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload({ id: "plugin-b" })] }), + ); + const harness = makeHarness(transport); + let abortLoop: (() => void) | null = null; + let detached = 0; + const code = await runCliWatch(["inspect", "--plugins", "--watch"], { + runtime: harness.deps.runtime, + transport, + watch: { + random01: () => 0.5, + sleep: async () => { + abortLoop?.(); + }, + onSignal: (abort) => { + abortLoop = abort; + return () => { + detached += 1; + }; + }, + }, + }); + expect(code).toBe(EXIT_OK); + expect(harness.out.length).toBe(1); + expect(harness.out.join("")).toContain("plugin-a"); + expect(detached).toBe(1); + }); + + test("W9 per-tick bounds apply and no frames are retained across ticks", async () => { + const huge = "x".repeat(MAX_CELL_CHARS + 50); + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload({ id: huge })] }), + ); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload({ id: "plugin-b" })] }), + ); + const harness = makeHarness(transport); + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "2"], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(harness.out.length).toBe(2); + expect(harness.out[0]).toContain(`${"x".repeat(MAX_CELL_CHARS)}...`); + expect(harness.out[0]).not.toContain(huge); + expect(harness.out[1]).toContain("plugin-b"); + expect(harness.out[1]).not.toContain("x".repeat(10)); + }); + + test("W9 strict InvalidResult fails closed with exit 1 and no partial row", async () => { + const transport = makeTransport(); + transport.injectResponsePayload(responsePayload(1, [pluginPayload()])); + const harness = makeHarness(transport); + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "3"], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_GENERIC); + expect(harness.err.join("")).toContain("InvalidResult"); + expect(harness.out).toEqual([]); + }); + + test("W9 MAX_PLUGINS fail-closed: the 257th plugin exits 1 with no rows", async () => { + const transport = makeTransport(); + const plugins = Array.from({ length: 257 }, (_, i) => + pluginPayload({ id: `plugin-${i}` }), + ); + transport.injectResponsePayload(responsePayload(1, { plugins })); + const harness = makeHarness(transport); + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "2"], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_GENERIC); + expect(harness.err.join("")).toContain("MAX_PLUGINS"); + expect(harness.out).toEqual([]); + }); + + test("W9 MAX_SUBSCRIPTIONS fail-closed: the 33rd subscription exits 1 with no rows", async () => { + const transport = makeTransport(); + const subs = Array.from({ length: 33 }, () => subscriptionPayload()); + transport.injectResponsePayload(responsePayload(1, subs)); + const harness = makeHarness(transport); + const code = await runCliWatch( + [ + "inspect", + "--subscriptions", + "--plugin", + "plugin-a", + "--watch", + "--max-ticks", + "2", + ], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_GENERIC); + expect(harness.err.join("")).toContain("MAX_SUBSCRIPTIONS"); + expect(harness.out).toEqual([]); + }); + + test("W10 watch pins debug.inspect: trace/control scope callers are denied", async () => { + const { InspectionClient } = await import("../src/inspection.js"); + const { generation } = await import("../src/panel-runtime.js"); + const offline = { + isConnected: () => false, + request: () => { + throw new Error("IPC must not be used when disconnected"); + }, + }; + const client = new InspectionClient(offline, null); + expect(() => client.listPlugins("debug.trace", generation(1))).toThrow( + "debug.inspect scope required", + ); + expect(() => client.listPlugins("debug.control", generation(1))).toThrow( + "debug.inspect scope required", + ); + expect(() => client.listSubscriptions("debug.trace", "plugin-a")).toThrow( + "debug.inspect scope required", + ); + expect(() => + client.getBudgets("debug.trace", "plugin-a", generation(1)), + ).toThrow("debug.inspect scope required"); + let traceCode: number | null = null; + try { + client.listPlugins("debug.trace", generation(1)); + } catch (error) { + traceCode = exitCodeForError(error); + } + expect(traceCode).toBe(EXIT_PERM); + }); + + test("W10 watch never escalates: no trace/control/automation dispatch exists on the watch path", () => { + const source = (runCliWatch as (...args: unknown[]) => unknown).toString(); + expect(source).not.toContain("startTrace"); + expect(source).not.toContain("synthesizeInput"); + const liveSource = ( + runCliLive as (...args: unknown[]) => unknown + ).toString(); + expect(liveSource).not.toContain("startTrace"); + expect(liveSource).not.toContain("synthesizeInput"); + }); + + test("W10 watch makes zero trace/control/automation calls", async () => { + const transport = makeTransport(); + transport.injectResponsePayload( + responsePayload(1, { plugins: [pluginPayload()] }), + ); + transport.injectResponsePayload( + responsePayload(2, { plugins: [pluginPayload({ id: "plugin-b" })] }), + ); + const harness = makeHarness(transport); + const names = [ + "startTrace", + "startTraceWithFilter", + "stopTrace", + "streamEvents", + "streamFilteredEvents", + "fetchTraceChunk", + "appendToTrace", + "appendStructuredEvent", + "suspendHandler", + "pauseHandler", + "resumePlugin", + "disposeGeneration", + "automationClient", + ] as const; + const proto = DevtoolsClient.prototype as unknown as Record< + string, + unknown + >; + const original = new Map(); + const escalated: string[] = []; + for (const name of names) { + original.set(name, proto[name]); + proto[name] = (..._args: unknown[]) => { + escalated.push(name); + throw new Error(`watch must not call ${name}`); + }; + } + try { + const code = await runCliWatch( + ["inspect", "--plugins", "--watch", "--max-ticks", "2"], + { + runtime: harness.deps.runtime, + transport, + watch: { random01: () => 0.5, sleep: async () => {} }, + }, + ); + expect(code).toBe(EXIT_OK); + expect(escalated).toEqual([]); + expect(harness.out.length).toBe(2); + } finally { + for (const name of names) { + proto[name] = original.get(name); + } + } + }); +});