diff --git a/scripts/dev/tui-pty-test-watch.ts b/scripts/dev/tui-pty-test-watch.ts index a604897b85e7..0b2863c74083 100644 --- a/scripts/dev/tui-pty-test-watch.ts +++ b/scripts/dev/tui-pty-test-watch.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url"; type Options = { altScreen: boolean; + help: boolean; mirrorPath: string; mode: "fake" | "local" | "all"; vitestArgs: string[]; @@ -26,6 +27,12 @@ const CHILD_SIGTERM_GRACE_MS = 500; const CHILD_SIGKILL_GRACE_MS = 5_000; const MIRROR_READ_CHUNK_BYTES = 1024 * 1024; const CHILD_OUTPUT_TAIL_BYTES = 128 * 1024; +const BOOLEAN_OPTIONS = new Set(["--help", "-h", "--no-alt-screen"]); +const VALUE_OPTIONS = new Set(["--mode", "--mirror-path"]); + +class CliArgumentError extends Error { + override name = "CliArgumentError"; +} type KillableChild = { pid?: number; @@ -48,7 +55,11 @@ function readOption(args: string[], name: string): string | undefined { if (idx < 0) { return undefined; } - return args[idx + 1]?.trim() || undefined; + const value = args[idx + 1]; + if (!value || value.startsWith("--")) { + throw new CliArgumentError(`${name} requires a value`); + } + return value.trim(); } function readMode(args: string[]): Options["mode"] { @@ -56,20 +67,46 @@ function readMode(args: string[]): Options["mode"] { if (mode === "fake" || mode === "local" || mode === "all") { return mode; } - throw new Error(`--mode must be fake, local, or all; got ${JSON.stringify(mode)}`); + throw new CliArgumentError(`--mode must be fake, local, or all; got ${JSON.stringify(mode)}`); +} + +function usage(): string { + return [ + "Usage: node --import tsx scripts/dev/tui-pty-test-watch.ts [options] [-- vitest args...]", + "", + "Options:", + " --mode Select TUI PTY test group (default: fake)", + " --mirror-path Write/read mirrored ANSI output at this path", + " --no-alt-screen Print without switching to the terminal alt screen", + " -h, --help Show this help", + ].join("\n"); +} + +function validateOwnArgs(args: string[]): void { + for (let idx = 0; idx < args.length; idx += 1) { + const arg = args[idx] ?? ""; + if (BOOLEAN_OPTIONS.has(arg)) { + continue; + } + if (VALUE_OPTIONS.has(arg)) { + idx += 1; + continue; + } + throw new CliArgumentError(`Unknown argument: ${arg}`); + } } function parseOptions(args = process.argv.slice(2)): Options { const separator = args.indexOf("--"); const ownArgs = separator >= 0 ? args.slice(0, separator) : args; const vitestArgs = separator >= 0 ? args.slice(separator + 1) : []; - const mirrorPath = - readOption(ownArgs, "--mirror-path") !== undefined - ? path.resolve(readOption(ownArgs, "--mirror-path") ?? "") - : DEFAULT_MIRROR_PATH; + validateOwnArgs(ownArgs); + const mirrorPathOption = readOption(ownArgs, "--mirror-path"); return { altScreen: !ownArgs.includes("--no-alt-screen"), - mirrorPath, + help: ownArgs.includes("--help") || ownArgs.includes("-h"), + mirrorPath: + mirrorPathOption !== undefined ? path.resolve(mirrorPathOption) : DEFAULT_MIRROR_PATH, mode: readMode(ownArgs), vitestArgs, }; @@ -209,6 +246,10 @@ async function drainNewMirrorData( async function main(): Promise { const options = parseOptions(); + if (options.help) { + process.stdout.write(`${usage()}\n`); + return; + } const useAltScreen = shouldUseAltScreen(options); await createMirrorFile(options.mirrorPath); @@ -422,6 +463,10 @@ async function main(): Promise { if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { main().catch((error: unknown) => { + if (error instanceof CliArgumentError) { + process.stderr.write(`${error.message}\n`); + process.exit(1); + } process.stderr.write( `${error instanceof Error ? error.stack || error.message : String(error)}\n`, ); @@ -433,6 +478,8 @@ export const testing = { appendBufferTail, createChildStopper, drainNewMirrorData, + parseOptions, readNewMirrorData, signalChildProcessTree, + usage, }; diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index a53590be1e6e..51191d41a21a 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -1,4 +1,5 @@ // Dev Tooling Safety tests cover dev tooling safety script behavior. +import { spawnSync } from "node:child_process"; import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; @@ -205,6 +206,45 @@ describe("script-specific dev tooling hardening", () => { expect(calls).toBe(1); }); + it("prints TUI PTY watch usage without launching the watcher", () => { + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/dev/tui-pty-test-watch.ts", "--help"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Usage: node --import tsx scripts/dev/tui-pty-test-watch.ts"); + expect(result.stderr).toBe(""); + }); + + it("rejects unknown TUI PTY watch args before launching the watcher", () => { + expect(() => tuiPtyWatchTesting.parseOptions(["--wat"])).toThrow("Unknown argument: --wat"); + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "scripts/dev/tui-pty-test-watch.ts", "--wat"], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr.trim()).toBe("Unknown argument: --wat"); + expect(result.stdout).toBe(""); + }); + + it("keeps TUI PTY watch vitest args behind the separator", () => { + expect(tuiPtyWatchTesting.parseOptions(["--mode", "all", "--", "--help"])).toMatchObject({ + mode: "all", + vitestArgs: ["--help"], + }); + }); + it("escalates stalled TUI PTY watch children after interrupt cleanup", async () => { vi.useFakeTimers(); const signals: NodeJS.Signals[] = [];