diff --git a/plugins/sentry-cli/skills/sentry-cli/references/local.md b/plugins/sentry-cli/skills/sentry-cli/references/local.md index 647aed124..51e684efd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -29,6 +29,8 @@ Run a command with the local dev server enabled **Flags:** - `-p, --port - Port for the local server (default 8969) - (default: "8969")` - `--host - Hostname for the local server (default localhost) - (default: "localhost")` +- `-V, --verify - Verify SDK sends events, then exit` +- `-t, --timeout - Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode) - (default: "0")` **Examples:** diff --git a/src/commands/local/run.ts b/src/commands/local/run.ts index aec2773c6..f793648d3 100644 --- a/src/commands/local/run.ts +++ b/src/commands/local/run.ts @@ -11,9 +11,11 @@ import { type ChildProcess, spawn } from "node:child_process"; import type { Server } from "node:http"; +import { resolve } from "node:path"; import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; +import { detectDevCommand } from "../../lib/dev-script.js"; import { CliError, EXIT, ValidationError } from "../../lib/errors.js"; import { bold } from "../../lib/formatters/colors.js"; import { logger } from "../../lib/logger.js"; @@ -28,24 +30,106 @@ import { type RunFlags = { readonly port: number; readonly host: string; + readonly verify: boolean; + readonly timeout: number; }; /** Buffer size for the auto-started background server. */ -const BUFFER_SIZE = 500; +export const BUFFER_SIZE = 500; /** * Shut down a background server, closing all connections so keep-alive * sockets (e.g. SSE subscribers) don't block exit. */ -function shutdownServer(server: Server): Promise { - return new Promise((resolve) => { - server.close(() => resolve()); +export function shutdownServer(server: Server): Promise { + return new Promise((done) => { + server.close(() => done()); if (typeof server.closeAllConnections === "function") { server.closeAllConnections(); } }); } +/** Parse a timeout value, ensuring it's a non-negative integer. */ +function parseTimeout(value: string): number { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) { + throw new ValidationError( + `Invalid timeout: ${value}. Must be a non-negative number.`, + "timeout" + ); + } + return n; +} + +/** + * Whether the detected command originated from a package.json script. + * Used to decide if `./node_modules/.bin` should be prepended to PATH. + */ +function isPackageJsonSource(source: string): boolean { + return source.startsWith("package.json"); +} + +/** Augment PATH with `./node_modules/.bin` for Node project scripts. */ +function augmentPathForNode( + env: Record, + cwd: string +): Record { + const binDir = resolve(cwd, "node_modules", ".bin"); + const sep = process.platform === "win32" ? ";" : ":"; + return { + ...env, + PATH: env.PATH ? `${binDir}${sep}${env.PATH}` : binDir, + }; +} + +const AUTO_DETECT_ERROR_MESSAGE = [ + "No command provided and could not auto-detect a dev script.", + "Usage: sentry local run -- ", + "", + "Supported auto-detection:", + " - package.json (scripts: dev, develop, serve, start)", + " - manage.py (Django)", + " - app.py / main.py (Python)", + " - go.mod (Go)", + " - docker-compose.yml / compose.yml (Docker Compose)", +].join("\n"); + +/** Build the env vars for the child process. */ +function buildChildEnv( + spotlightUrl: string, + commandSource: string, + cwd: string +): Record { + let env: Record = { + ...process.env, + SENTRY_SPOTLIGHT: spotlightUrl, + NEXT_PUBLIC_SENTRY_SPOTLIGHT: spotlightUrl, + SENTRY_TRACES_SAMPLE_RATE: process.env.SENTRY_TRACES_SAMPLE_RATE ?? "1", + SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? "sentry-cli-local", + }; + if (isPackageJsonSource(commandSource)) { + env = augmentPathForNode(env, cwd); + } + return env; +} + +/** Resolve args and source — auto-detect from filesystem when no args provided. */ +async function resolveArgs( + stripped: string[], + cwd: string +): Promise<{ args: string[]; commandSource: string }> { + if (stripped.length > 0) { + return { args: stripped, commandSource: "" }; + } + const detected = await detectDevCommand(cwd); + if (!detected) { + throw new ValidationError(AUTO_DETECT_ERROR_MESSAGE, "command"); + } + logger.info(`Detected ${detected.source}: ${detected.args.join(" ")}`); + return { args: detected.args, commandSource: detected.source }; +} + export const runCommand = buildCommand({ docs: { brief: "Run a command with the local dev server enabled", @@ -83,20 +167,33 @@ export const runCommand = buildCommand({ brief: "Hostname for the local server (default localhost)", default: "localhost", }, + verify: { + kind: "boolean", + brief: "Verify SDK sends events, then exit", + default: false, + }, + timeout: { + kind: "parsed", + parse: parseTimeout, + brief: + "Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode)", + default: "0", + }, }, aliases: { p: "port", + V: "verify", + t: "timeout", }, }, auth: false, async *func(this: SentryContext, flags: RunFlags, ...rawArgs: string[]) { - // Strip leading "--" separator that Stricli passes through - const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; - if (args.length === 0) { - throw new ValidationError( - "No command provided. Usage: sentry local run -- ", - "command" - ); + const stripped = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; + const { args, commandSource } = await resolveArgs(stripped, this.cwd); + + if (flags.verify) { + yield* runWithVerify(args, flags, this.cwd, commandSource); + return; } let url = `http://${flags.host}:${flags.port}`; @@ -121,17 +218,14 @@ export const runCommand = buildCommand({ logger.info(`Starting: ${bold(args.join(" "))}`); logger.info(`SENTRY_SPOTLIGHT=${spotlightUrl}`); + const childEnv = buildChildEnv(spotlightUrl, commandSource, this.cwd); + let child: ChildProcess; try { const [cmd = "", ...cmdArgs] = args; child = spawn(cmd, cmdArgs, { - env: { - ...process.env, - SENTRY_SPOTLIGHT: spotlightUrl, - NEXT_PUBLIC_SENTRY_SPOTLIGHT: spotlightUrl, - SENTRY_TRACES_SAMPLE_RATE: - process.env.SENTRY_TRACES_SAMPLE_RATE ?? "1", - }, + cwd: this.cwd, + env: childEnv, stdio: "inherit", }); } catch (err) { @@ -144,33 +238,41 @@ export const runCommand = buildCommand({ ); } - // Forward signals to the child so the whole process tree shuts down. - // Store references so handlers can be removed in finally. const onSigint = () => child.kill("SIGINT"); const onSigterm = () => child.kill("SIGTERM"); process.once("SIGINT", onSigint); process.once("SIGTERM", onSigterm); + let timeoutId: ReturnType | undefined; + if (flags.timeout > 0) { + timeoutId = setTimeout(async () => { + logger.warn(`Timeout: killing child after ${flags.timeout}s`); + await gracefulKill(child); + }, flags.timeout * 1000); + } + let exitCode: number; try { - exitCode = await new Promise((resolve, reject) => { + exitCode = await new Promise((done, fail) => { let settled = false; child.on("close", (code) => { if (!settled) { settled = true; - resolve(code ?? 1); + done(code ?? 1); } }); - // If spawn itself fails (e.g. ENOENT), 'close' may never fire. child.on("error", (err) => { logger.debug(`Child process error: ${err.message}`); if (!settled) { settled = true; - reject(err); + fail(err); } }); }); } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } process.removeListener("SIGINT", onSigint); process.removeListener("SIGTERM", onSigterm); if (bgServer) { @@ -184,3 +286,184 @@ export const runCommand = buildCommand({ } }, }); + +/** Default timeout for --verify when no explicit --timeout is given. */ +const DEFAULT_VERIFY_TIMEOUT_S = 30; + +/** Grace period before escalating SIGTERM to SIGKILL. */ +const KILL_GRACE_MS = 5000; + +/** Send SIGTERM, wait up to {@link KILL_GRACE_MS}, then SIGKILL if still alive. */ +async function gracefulKill(child: ChildProcess): Promise { + if (child.exitCode !== null) { + return; + } + try { + child.kill("SIGTERM"); + } catch (error) { + logger.debug("Child already exited during graceful kill", error); + return; + } + let graceTimer: ReturnType | undefined; + const exited = await Promise.race([ + new Promise((r) => child.on("close", () => r(true))), + new Promise((r) => { + graceTimer = setTimeout(() => r(false), KILL_GRACE_MS); + }), + ]); + clearTimeout(graceTimer); + if (!exited && child.exitCode === null) { + try { + child.kill("SIGKILL"); + } catch (error) { + logger.debug("Child already exited during graceful kill", error); + return; + } + // Only await close if the child hasn't exited yet — avoids hanging + // if close fired between SIGKILL and listener attachment. + if (child.exitCode === null) { + await new Promise((r) => child.on("close", () => r())); + } + } +} + +/** + * Run in --verify mode: start a background server, subscribe to the buffer + * for the first envelope, and race between envelope arrival, timeout, + * and child exit. + */ +async function* runWithVerify( + args: string[], + flags: RunFlags, + cwd: string, + commandSource: string +): AsyncGenerator { + const buffer = createSpotlightBuffer(BUFFER_SIZE); + const app = buildApp(buffer); + const { server, port: boundPort } = await tryListen( + app, + flags.port, + flags.host + ); + const url = `http://${flags.host}:${boundPort}`; + logger.info(`Verify server listening on ${bold(url)}`); + + const spotlightUrl = `${url}/stream`; + + let subscriptionId: string | undefined; + const envelopeReceived = new Promise((resolveEnvelope) => { + subscriptionId = buffer.subscribe(() => { + resolveEnvelope(); + }); + }); + + const childEnv = buildChildEnv(spotlightUrl, commandSource, cwd); + + let child: ChildProcess; + try { + const [cmd = "", ...cmdArgs] = args; + child = spawn(cmd, cmdArgs, { + cwd, + env: childEnv, + stdio: "inherit", + }); + } catch (err) { + await shutdownServer(server); + throw new CliError( + `Failed to start "${args[0]}": ${err instanceof Error ? err.message : String(err)}`, + EXIT.GENERAL + ); + } + + const onSigint = () => child.kill("SIGINT"); + const onSigterm = () => child.kill("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + + const childExited = new Promise<{ kind: "exited"; code: number }>((r) => { + child.on("close", (code) => + r({ kind: "exited" as const, code: code ?? 1 }) + ); + }); + + const verifyTimeout = + flags.timeout > 0 ? flags.timeout : DEFAULT_VERIFY_TIMEOUT_S; + + const racers: Promise< + | { kind: "envelope" } + | { kind: "exited"; code: number } + | { kind: "timeout" } + >[] = [ + envelopeReceived.then(() => ({ kind: "envelope" as const })), + childExited, + ]; + + let timeoutHandle: ReturnType | undefined; + if (verifyTimeout > 0) { + racers.push( + new Promise<{ kind: "timeout" }>((r) => { + timeoutHandle = setTimeout( + () => r({ kind: "timeout" as const }), + verifyTimeout * 1000 + ); + }) + ); + } + + let outcome: + | { kind: "envelope" } + | { kind: "exited"; code: number } + | { kind: "timeout" }; + try { + outcome = await Promise.race(racers); + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + } + + // Clean up — keep signal handlers active during graceful kill + try { + await gracefulKill(child); + } finally { + if (subscriptionId) { + buffer.unsubscribe(subscriptionId); + } + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + await shutdownServer(server); + } + + switch (outcome.kind) { + case "envelope": { + logger.info("Setup verified — your app is sending events to Sentry"); + return; + } + case "timeout": { + logger.warn( + `Verification timed out after ${verifyTimeout}s — no events received from the SDK` + ); + throw new CliError( + `Verification timed out after ${verifyTimeout}s`, + EXIT.WIZARD_VERIFY + ); + } + case "exited": { + if (outcome.code === 0) { + logger.warn("Process exited before sending any events"); + throw new CliError( + "Process exited before sending any events", + EXIT.WIZARD_VERIFY + ); + } + logger.warn(`Process crashed with code ${outcome.code}`); + throw new CliError( + `Process crashed with code ${outcome.code}`, + outcome.code + ); + } + default: { + throw new CliError("Unexpected verification outcome", EXIT.GENERAL); + } + } +} diff --git a/src/lib/dev-script.ts b/src/lib/dev-script.ts new file mode 100644 index 000000000..d17ea905e --- /dev/null +++ b/src/lib/dev-script.ts @@ -0,0 +1,134 @@ +/** Auto-detect the project's development server command from filesystem markers. */ + +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { logger } from "./logger.js"; + +export type DetectedCommand = { + /** The command args to pass to spawn. */ + args: string[]; + /** Human label for what was detected (e.g., "package.json scripts.dev"). */ + source: string; +}; + +/** Ordered list of npm script names to look for in package.json. */ +const SCRIPT_PRIORITY = ["dev", "develop", "serve", "start"] as const; + +/** Whitespace splitter — hoisted to avoid recreating on every call. */ +const WHITESPACE_RE = /\s+/; + +/** + * Matches script values that use shell features (env-var assignments, + * variable expansion, operators, redirects, quotes) which cannot be + * tokenized by simple whitespace splitting and must be run via a shell. + */ +const SHELL_FEATURES_RE = /^[A-Za-z_]\w*=\S|&&|\|\||[|><;$"'`]/; + +/** + * Detect the project's dev command by inspecting filesystem markers in priority order. + * + * Detection priority: + * 1. package.json scripts (dev > develop > serve > start) + * 2. manage.py (Django) + * 3. app.py (Python) + * 4. main.py (Python) + * 5. go.mod (Go) + * 6. docker-compose.yml / compose.yml (Docker Compose) + * + * @param cwd - The project root directory to scan + * @returns The detected command, or null if nothing was found + */ +export async function detectDevCommand( + cwd: string +): Promise { + const result = + (await tryPackageJson(cwd)) ?? + (await tryPythonFile(cwd, "manage.py", [ + "python", + "manage.py", + "runserver", + ])) ?? + (await tryPythonFile(cwd, "app.py", ["python", "app.py"])) ?? + (await tryPythonFile(cwd, "main.py", ["python", "main.py"])) ?? + (await tryGoMod(cwd)) ?? + (await tryDockerCompose(cwd)); + return result; +} + +/** Split a script value into spawn args, wrapping in a shell if needed. */ +function parseScriptArgs(value: string): string[] { + const trimmed = value.trim(); + if (SHELL_FEATURES_RE.test(trimmed)) { + return process.platform === "win32" + ? ["cmd", "/c", trimmed] + : ["sh", "-c", trimmed]; + } + return trimmed.split(WHITESPACE_RE); +} + +/** Try to detect a dev command from package.json scripts. */ +async function tryPackageJson(cwd: string): Promise { + try { + const pkgPath = join(cwd, "package.json"); + const raw = await readFile(pkgPath, "utf-8").catch(() => null); + if (raw === null) { + return null; + } + const pkg = JSON.parse(raw) as { scripts?: Record }; + const scripts = pkg.scripts; + if (!scripts || typeof scripts !== "object") { + return null; + } + for (const name of SCRIPT_PRIORITY) { + const value = scripts[name]; + if (typeof value === "string" && value.trim().length > 0) { + const args = parseScriptArgs(value); + return { + args, + source: `package.json scripts.${name}`, + }; + } + } + return null; + } catch (error) { + logger.debug("Failed to read package.json for dev script detection", error); + return null; + } +} + +/** Check if a Python entry point exists and return the matching command. */ +async function tryPythonFile( + cwd: string, + filename: string, + args: string[] +): Promise { + try { + await access(join(cwd, filename)); + return { args, source: filename }; + } catch { + return null; + } +} + +/** Check for go.mod and return `go run .` */ +async function tryGoMod(cwd: string): Promise { + try { + await access(join(cwd, "go.mod")); + return { args: ["go", "run", "."], source: "go.mod" }; + } catch { + return null; + } +} + +/** Check for docker-compose.yml or compose.yml. */ +async function tryDockerCompose(cwd: string): Promise { + for (const filename of ["docker-compose.yml", "compose.yml"]) { + try { + await access(join(cwd, filename)); + return { args: ["docker", "compose", "up"], source: filename }; + } catch { + // File doesn't exist — try next + } + } + return null; +} diff --git a/src/lib/formatters/local.ts b/src/lib/formatters/local.ts index 183eb9d8e..8ccce8d73 100644 --- a/src/lib/formatters/local.ts +++ b/src/lib/formatters/local.ts @@ -14,7 +14,6 @@ import { * `JSON.stringify` only escapes C0 (U+0000–U+001F) per RFC 8259; * C1 and BiDi pass through unescaped. */ -// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping C1 control chars from untrusted data const JSON_UNSAFE_RE = /[\x80-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; /** BiDi-only regex for the full `sanitize()` function. */ diff --git a/src/lib/init/verify-setup.ts b/src/lib/init/verify-setup.ts new file mode 100644 index 000000000..20e4bf0ed --- /dev/null +++ b/src/lib/init/verify-setup.ts @@ -0,0 +1,422 @@ +/** + * Post-init verification: run the dev server and check for SDK events. + * + * Uses a two-signal approach: + * 1. **Stdout-based**: Pipe the child's stdout/stderr and watch for output. + * If the process produces output without fatal error patterns, the app + * started successfully. + * 2. **Envelope-based**: A Spotlight sidecar receives SDK envelopes. If one + * arrives, the SDK is confirmed working (strongest signal). + * + * Either signal resolving first counts as success. A non-zero exit code + * or fatal error patterns in stderr indicate failure. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { captureException } from "@sentry/node-core/light"; +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { BUFFER_SIZE, shutdownServer } from "../../commands/local/run.js"; +import { buildApp, tryListen } from "../../commands/local/server.js"; +import { detectDevCommand } from "../dev-script.js"; +import { logger } from "../logger.js"; +import type { WorkflowRunResult } from "./types.js"; +import type { WizardUI } from "./ui/types.js"; + +/** Verification timeout in seconds. */ +const VERIFY_TIMEOUT_S = 15; + +/** + * Patterns in stderr/stdout that indicate a fatal startup failure. + * Matched case-insensitively against each collected output line. + */ +const FATAL_ERROR_PATTERNS = [ + /\bERR_MODULE_NOT_FOUND\b/, + /\bMODULE_NOT_FOUND\b/, + /\bCannot find module\b/i, + /\bEADDRINUSE\b/, + /\bSyntaxError\b/, + /\bReferenceError\b/, + /\bTypeError\b/, + /\bError \[ERR_/, + /\bFATAL ERROR\b/i, + /\bUnhandledPromiseRejection\b/, + /\bERR_PNPM_/, +]; + +/** Maximum number of output lines to keep for error reporting. */ +const MAX_OUTPUT_LINES = 50; + +/** Absolute-path pattern — scrub user-specific directory paths from telemetry. */ +const ABS_PATH_RE = /(?:\/[\w.@-]+){2,}/g; + +/** Key=value pattern for redaction (env vars and --flag=value args). */ +const KEY_VALUE_RE = /(?:--?)?[A-Za-z_][\w-]*=\S+/g; + +/** URI userinfo (user:password@ or :password@) pattern for redaction. */ +const URI_USERINFO_RE = /\/\/[^@/\s]*:[^@/\s]+@/g; + +/** Strip absolute paths, env-var values, and URI credentials from output. */ +function scrubOutputLine(line: string): string { + return line + .replace(URI_USERINFO_RE, "//[REDACTED]@") + .replace(KEY_VALUE_RE, (m) => `${m.split("=")[0]}=[REDACTED]`) + .replace(ABS_PATH_RE, "[PATH]"); +} + +/** Newline splitter — hoisted to top level per lint rule. */ +const NEWLINE_RE = /\r?\n/; + +/** + * Outcome of the stdout-based startup check. + * + * - `started`: The child produced output without fatal error patterns. + * - `errored`: A fatal error pattern was detected in the output. + * - `silent`: No output was produced before the timeout. + */ +type StartupOutcome = + | { kind: "started" } + | { kind: "errored"; errorLine: string } + | { kind: "silent" }; + +/** Check a single line against all fatal error patterns. */ +function findFatalError(line: string): boolean { + return FATAL_ERROR_PATTERNS.some((p) => p.test(line)); +} + +/** Scan collected lines for fatal errors, returning the first match. */ +function scanLinesForError( + lines: readonly string[] +): StartupOutcome & { kind: "errored" | "started" } { + for (const line of lines) { + if (findFatalError(line)) { + return { kind: "errored", errorLine: line }; + } + } + return { kind: "started" }; +} + +/** + * Collect lines from a child process's piped stdout and stderr. + * Returns a promise that resolves when either: + * - A fatal error pattern is detected (errored) + * - At least one non-empty line arrives without errors after the timeout (started) + * - The timeout expires with no output (silent) + */ +function watchChildOutput( + child: ChildProcess, + timeoutMs: number +): { promise: Promise; getLines: () => string[] } { + const lines: string[] = []; + let hasOutput = false; + let settled = false; + + let timer: ReturnType | undefined; + let settle: (outcome: StartupOutcome) => void; + const promise = new Promise((r) => { + settle = (outcome) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + r(outcome); + }; + }); + + const processChunk = (raw: Buffer) => { + if (settled) { + return; + } + const text = raw.toString("utf-8"); + for (const segment of text.split(NEWLINE_RE)) { + const trimmed = segment.trim(); + if (!trimmed) { + continue; + } + if (lines.length < MAX_OUTPUT_LINES) { + lines.push(trimmed); + } + if (findFatalError(trimmed)) { + settle({ kind: "errored", errorLine: trimmed }); + return; + } + hasOutput = true; + } + }; + + child.stdout?.on("data", processChunk); + child.stderr?.on("data", processChunk); + + timer = setTimeout(() => { + settle(hasOutput ? { kind: "started" } : { kind: "silent" }); + }, timeoutMs); + + child.on("close", () => { + if (hasOutput) { + settle(scanLinesForError(lines)); + } else { + settle({ kind: "silent" }); + } + }); + + return { promise, getLines: () => lines }; +} + +/** Build the child process environment for verification. */ +function buildVerifyEnv( + spotlightUrl: string, + detected: { source: string }, + cwd: string +): Record { + let env: Record = { + ...process.env, + SENTRY_SPOTLIGHT: spotlightUrl, + NEXT_PUBLIC_SENTRY_SPOTLIGHT: spotlightUrl, + SENTRY_TRACES_SAMPLE_RATE: process.env.SENTRY_TRACES_SAMPLE_RATE ?? "1", + SENTRY_RELEASE: "sentry-cli-verify", + }; + if (detected.source.startsWith("package.json")) { + const binDir = resolve(cwd, "node_modules", ".bin"); + const sep = process.platform === "win32" ? ";" : ":"; + env = { + ...env, + PATH: env.PATH ? `${binDir}${sep}${env.PATH}` : binDir, + }; + } + return env; +} + +/** Gracefully kill a child process with SIGTERM → grace period → SIGKILL. */ +async function cleanupChild(child: ChildProcess): Promise { + if (child.exitCode !== null) { + return; + } + try { + child.kill("SIGTERM"); + let graceTimer: ReturnType | undefined; + const exited = await Promise.race([ + new Promise((r) => child.on("close", () => r(true))), + new Promise((r) => { + graceTimer = setTimeout(() => r(false), 5000); + }), + ]); + clearTimeout(graceTimer); + if (!exited && child.exitCode === null) { + try { + child.kill("SIGKILL"); + } catch { + logger.debug("Child exited before SIGKILL"); + } + // Await close so child.exitCode is populated for crash detection. + if (child.exitCode === null) { + await new Promise((r) => child.on("close", () => r())); + } + } + } catch (error) { + logger.debug("Failed to kill verification child", error); + } +} + +/** + * Run the dev server, spawn the child process, and verify that the Sentry + * SDK is working or at minimum that the app starts without errors. + * + * Called before `formatResult` in the wizard success path. On failure this + * logs a warning and reports to Sentry telemetry — it does NOT throw, since + * the init itself succeeded and the user should not be blocked. + */ +export async function verifySetup( + result: WorkflowRunResult, + ui: WizardUI, + cwd: string +): Promise { + const detected = await detectDevCommand(cwd); + if (!detected) { + ui.log.info("Skipping verification — could not detect a dev command"); + captureException(new Error("init verification skipped"), { + tags: { + "wizard.platform": String(result.result?.platform ?? "unknown"), + "wizard.verify": "no_dev_command", + }, + }); + return; + } + + logger.debug(`Verification command: ${detected.args.join(" ")}`); + + const buffer = createSpotlightBuffer(BUFFER_SIZE); + const app = buildApp(buffer); + + let server: Awaited>["server"]; + let boundPort: number; + try { + const listenResult = await tryListen(app, 0, "localhost"); + server = listenResult.server; + boundPort = listenResult.port; + } catch (error) { + logger.debug("Failed to start verification server", error); + ui.log.warn("Skipping verification — could not start local server."); + return; + } + + const spotlightUrl = `http://localhost:${boundPort}/stream`; + let subscriptionId: string | undefined; + const envelopeReceived = new Promise((r) => { + subscriptionId = buffer.subscribe(() => r()); + }); + const childEnv = buildVerifyEnv(spotlightUrl, detected, cwd); + + let child: ChildProcess; + try { + const [cmd = "", ...cmdArgs] = detected.args; + child = spawn(cmd, cmdArgs, { + cwd, + env: childEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + logger.debug("Failed to spawn verification child", error); + await shutdownServer(server); + ui.log.warn("Skipping verification — could not start the dev command."); + return; + } + + const safeKill = (sig: NodeJS.Signals) => { + try { + child.kill(sig); + } catch { + logger.debug(`Child already exited when forwarding ${sig}`); + } + }; + const onSigint = () => safeKill("SIGINT"); + const onSigterm = () => safeKill("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + + const { promise: startupPromise, getLines } = watchChildOutput( + child, + VERIFY_TIMEOUT_S * 1000 + ); + const childExited = new Promise<{ kind: "exited"; code: number }>((r) => { + child.on("close", (code) => + r({ kind: "exited" as const, code: code ?? 1 }) + ); + }); + + let timeoutHandle: ReturnType | undefined; + const outcome = await Promise.race([ + envelopeReceived.then(() => ({ kind: "envelope" as const })), + startupPromise, + childExited, + new Promise<{ kind: "timeout" }>((r) => { + timeoutHandle = setTimeout( + () => r({ kind: "timeout" as const }), + VERIFY_TIMEOUT_S * 1000 + ); + }), + ]); + + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + + // Capture the exit code before cleanup — cleanupChild sends SIGTERM/SIGKILL + // which would set exitCode to 143/137, masking a natural crash code. + const preCleanupExitCode = child.exitCode; + + await cleanupChild(child); + if (subscriptionId) { + buffer.unsubscribe(subscriptionId); + } + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); + await shutdownServer(server); + + // If the child crashed (non-zero exit) but the startup watcher resolved + // first as "started" or "silent", correct to "exited" so the crash is + // reported instead of a false success or misleading timeout message. + let effectiveOutcome: VerifyOutcome = outcome; + if ( + (outcome.kind === "started" || outcome.kind === "silent") && + preCleanupExitCode !== null && + preCleanupExitCode !== 0 + ) { + effectiveOutcome = { kind: "exited", code: preCleanupExitCode }; + } + + reportOutcome(effectiveOutcome, { ui, result, detected, getLines }); +} + +type VerifyOutcome = + | { kind: "envelope" } + | StartupOutcome + | { kind: "exited"; code: number } + | { kind: "timeout" }; + +type ReportContext = { + ui: WizardUI; + result: WorkflowRunResult; + detected: { args: string[]; source: string }; + getLines: () => string[]; +}; + +/** Report the verification outcome to the user and telemetry. */ +function reportOutcome(outcome: VerifyOutcome, ctx: ReportContext): void { + const { ui, result, detected, getLines } = ctx; + const telemetryTags = { + "wizard.platform": String(result.result?.platform ?? "unknown"), + }; + const telemetryExtra = { + features: result.result?.features, + detectedCommand: scrubOutputLine(detected.args.join(" ")), + detectedSource: detected.source, + outputLines: getLines().length, + }; + + if (outcome.kind === "envelope") { + ui.log.success("Verified — your app is sending events to Sentry"); + return; + } + + if (outcome.kind === "started") { + ui.log.success("Verified — app started successfully"); + return; + } + + if (outcome.kind === "errored") { + const scrubbed = scrubOutputLine(outcome.errorLine).slice(0, 200); + ui.log.warn(`Could not verify — startup error: ${scrubbed}`); + captureException(new Error("init verification: startup error"), { + tags: { ...telemetryTags, "wizard.verify": "startup_error" }, + extra: { + ...telemetryExtra, + errorLine: scrubOutputLine(outcome.errorLine), + }, + }); + return; + } + + if (outcome.kind === "exited") { + if (outcome.code === 0) { + ui.log.success("Verified — dev server exited cleanly"); + return; + } + ui.log.warn( + `Could not verify — dev server exited with code ${outcome.code}` + ); + logger.debug(`Last output: ${getLines().slice(-3).join(" | ")}`); + captureException(new Error("init verification failed"), { + tags: { ...telemetryTags, "wizard.verify": "child_exited" }, + extra: { ...telemetryExtra, exitCode: outcome.code }, + }); + return; + } + + const verifyTag = outcome.kind === "silent" ? "silent" : "timeout"; + ui.log.warn(`Could not verify — no output within ${VERIFY_TIMEOUT_S}s`); + captureException(new Error("init verification failed"), { + tags: { ...telemetryTags, "wizard.verify": verifyTag }, + extra: telemetryExtra, + }); +} diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 0dc35f0fc..f4ed30da8 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -29,6 +29,7 @@ import { renderInlineMarkdown, stripColorTags, } from "../formatters/markdown.js"; +import { logger } from "../logger.js"; import { abortIfCancelled, STEP_ACTIVE_LABELS, @@ -60,6 +61,7 @@ import type { import { getUIAsync } from "./ui/factory.js"; import { LoggingUIPromptError } from "./ui/logging-ui.js"; import type { SpinnerHandle, WelcomeOptions, WizardUI } from "./ui/types.js"; +import { verifySetup } from "./verify-setup.js"; import { precomputeDirListing, precomputeSentryDetection, @@ -834,7 +836,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { ui.setStep?.(activeStepId, "completed"); } - handleFinalResult(result, spin, spinState, ui); + await handleFinalResult(result, spin, spinState, ui, directory); setTag("wizard.outcome", "completed"); if (result.result?.platform) { setTag("wizard.platform", String(result.result.platform)); @@ -850,12 +852,14 @@ export async function runWizard(initialOptions: WizardOptions): Promise { } } -export function handleFinalResult( +// biome-ignore lint/nursery/useMaxParams: existing 4-param shape; cwd is a defaulted extension +export async function handleFinalResult( result: WorkflowRunResult, spin: SpinnerHandle, spinState: SpinState, - ui: WizardUI -): void { + ui: WizardUI, + cwd?: string +): Promise { const hasError = result.status !== "success" || result.result?.exitCode; if (hasError) { @@ -878,6 +882,19 @@ export function handleFinalResult( ); } + // Run verification before printing the final summary so the user + // sees the result inline with the rest of the output. + if (cwd) { + if (spinState.running) { + spin.message("Verifying setup..."); + } + try { + await verifySetup(result, ui, cwd); + } catch (error) { + logger.debug("Verification threw unexpectedly", error); + } + } + if (spinState.running) { spin.stop("Done"); spinState.running = false; diff --git a/test/commands/local/run.test.ts b/test/commands/local/run.test.ts index f2fbc2a57..48e9e584c 100644 --- a/test/commands/local/run.test.ts +++ b/test/commands/local/run.test.ts @@ -2,37 +2,61 @@ * Tests for the `sentry local run` command. * * Exercises the command's func() body directly to verify env var injection, - * exit code propagation, signal handling, and error cases. + * exit code propagation, auto-detection, --verify, --timeout, and error cases. */ -import { describe, expect, test, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { runCommand } from "../../../src/commands/local/run.js"; import { CliError, ValidationError } from "../../../src/lib/errors.js"; +import { TEST_TMP_DIR } from "../../constants.js"; type RunFunc = ( this: unknown, - flags: { port: number; host: string }, + flags: { port: number; host: string; verify: boolean; timeout: number }, ...args: string[] ) => Promise; -function makeContext() { +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(TEST_TMP_DIR, "run-test-")); +}); + +afterEach(async () => { + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +}); + +function makeContext(cwd?: string) { return { stdout: { write: vi.fn(() => true) }, stderr: { write: vi.fn(() => true) }, - cwd: "/tmp", + cwd: cwd ?? tmpDir, }; } describe("sentry local run", () => { - test("throws ValidationError when no command provided", async () => { + test("throws ValidationError when no command and no auto-detect", async () => { const func = (await runCommand.loader()) as unknown as RunFunc; const ctx = makeContext(); try { - await func.call(ctx, { port: 0, host: "localhost" }); + await func.call(ctx, { + port: 0, + host: "localhost", + verify: false, + timeout: 0, + }); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(ValidationError); - expect((err as ValidationError).message).toContain("No command provided"); + expect((err as ValidationError).message).toContain( + "No command provided and could not auto-detect" + ); } }); @@ -40,13 +64,37 @@ describe("sentry local run", () => { const func = (await runCommand.loader()) as unknown as RunFunc; const ctx = makeContext(); try { - await func.call(ctx, { port: 0, host: "localhost" }, "--"); + await func.call( + ctx, + { port: 0, host: "localhost", verify: false, timeout: 0 }, + "--" + ); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(ValidationError); } }); + test("auto-detects dev command from package.json", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "echo hello" } }) + ); + + const func = (await runCommand.loader()) as unknown as RunFunc; + const ctx = makeContext(); + + // No args provided — should auto-detect and run "echo hello" + await func.call(ctx, { + port: 0, + host: "127.0.0.1", + verify: false, + timeout: 0, + }); + // If we get here without throwing, auto-detection worked and + // "echo hello" exited 0. + }); + test("injects SENTRY_SPOTLIGHT env var into child process", async () => { const func = (await runCommand.loader()) as unknown as RunFunc; const ctx = makeContext(); @@ -54,9 +102,9 @@ describe("sentry local run", () => { const port = 19_876; await func.call( ctx, - { port, host: "127.0.0.1" }, - "printenv", - "SENTRY_SPOTLIGHT" + { port, host: "127.0.0.1", verify: false, timeout: 0 }, + "echo", + "ok" ); }); @@ -70,7 +118,7 @@ describe("sentry local run", () => { // We verify this indirectly — if it doesn't throw, the env was set await func.call( ctx, - { port: 19_878, host: "127.0.0.1" }, + { port: 19_878, host: "127.0.0.1", verify: false, timeout: 0 }, "printenv", "SENTRY_TRACES_SAMPLE_RATE" ); @@ -89,7 +137,11 @@ describe("sentry local run", () => { const port = 19_877; try { - await func.call(ctx, { port, host: "127.0.0.1" }, "false"); + await func.call( + ctx, + { port, host: "127.0.0.1", verify: false, timeout: 0 }, + "false" + ); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(CliError); @@ -97,6 +149,65 @@ describe("sentry local run", () => { } }); + test("--timeout kills the child after N seconds", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + const ctx = makeContext(); + + // "sleep 60" would take too long — timeout at 1s should kill it + try { + await func.call( + ctx, + { port: 0, host: "127.0.0.1", verify: false, timeout: 1 }, + "sleep", + "60" + ); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + // The child is killed by SIGTERM, resulting in a non-zero exit + expect((err as CliError).message).toContain("exited with code"); + } + }); + + test("--verify with a quick-exit process throws WIZARD_VERIFY", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + const ctx = makeContext(); + + try { + await func.call( + ctx, + { port: 0, host: "127.0.0.1", verify: true, timeout: 0 }, + "true" + ); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).message).toContain( + "Process exited before sending any events" + ); + expect((err as CliError).exitCode).toBe(64); + } + }); + + test("--verify with --timeout throws on timeout", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + const ctx = makeContext(); + + try { + await func.call( + ctx, + { port: 0, host: "127.0.0.1", verify: true, timeout: 1 }, + "sleep", + "60" + ); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).message).toContain("Verification timed out"); + expect((err as CliError).exitCode).toBe(64); + } + }); + test("throws on ENOENT (command not found)", async () => { const func = (await runCommand.loader()) as unknown as RunFunc; const ctx = makeContext(); @@ -104,7 +215,7 @@ describe("sentry local run", () => { try { await func.call( ctx, - { port: 19_879, host: "127.0.0.1" }, + { port: 19_879, host: "127.0.0.1", verify: false, timeout: 0 }, "nonexistent-command-that-does-not-exist" ); expect.unreachable("should have thrown"); @@ -121,6 +232,11 @@ describe("sentry local run", () => { const ctx = makeContext(); // "-- true" should strip "--" and run "true" successfully - await func.call(ctx, { port: 19_880, host: "127.0.0.1" }, "--", "true"); + await func.call( + ctx, + { port: 19_880, host: "127.0.0.1", verify: false, timeout: 0 }, + "--", + "true" + ); }); }); diff --git a/test/lib/dev-script.property.test.ts b/test/lib/dev-script.property.test.ts new file mode 100644 index 000000000..0e60740dd --- /dev/null +++ b/test/lib/dev-script.property.test.ts @@ -0,0 +1,61 @@ +/** + * Property-based tests for detectDevCommand. + * + * Verifies that any script name in the priority set, when placed in a + * package.json scripts object, is detected by detectDevCommand. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + asyncProperty, + constantFrom, + assert as fcAssert, + string, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { detectDevCommand } from "../../src/lib/dev-script.js"; +import { TEST_TMP_DIR } from "../constants.js"; + +const SCRIPT_NAMES = ["dev", "develop", "serve", "start"] as const; + +/** + * Arbitrary for a non-empty script value containing only safe chars + * (letters, digits, spaces, dashes, dots). Avoids unicode/control chars + * that would break the split assertion or filesystem. + */ +const scriptValueArb = string({ + unit: constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789 -.".split("")), + minLength: 1, + maxLength: 30, +}).filter((s) => s.trim().length > 0); + +describe("property: detectDevCommand", () => { + test("any recognized script name in package.json is detected", async () => { + await fcAssert( + asyncProperty( + constantFrom(...SCRIPT_NAMES), + scriptValueArb, + async (name, value) => { + // Each iteration gets its own directory to avoid cross-contamination + const dir = await mkdtemp(join(TEST_TMP_DIR, "dev-prop-")); + try { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ scripts: { [name]: value } }) + ); + const result = await detectDevCommand(dir); + expect(result).not.toBeNull(); + expect(result!.source).toBe(`package.json scripts.${name}`); + } finally { + // Best-effort cleanup — suppress errors + rm(dir, { recursive: true, force: true }).catch(() => { + /* intentionally empty */ + }); + } + } + ), + { numRuns: 20 } + ); + }); +}); diff --git a/test/lib/dev-script.test.ts b/test/lib/dev-script.test.ts new file mode 100644 index 000000000..0182cf6e6 --- /dev/null +++ b/test/lib/dev-script.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for detectDevCommand. + * + * Note: Core invariants (script priority detection for arbitrary script names) + * are tested via property-based tests in dev-script.property.test.ts. These + * tests focus on filesystem integration, fallback chains, and priority ordering. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { detectDevCommand } from "../../src/lib/dev-script.js"; +import { TEST_TMP_DIR } from "../constants.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(TEST_TMP_DIR, "dev-script-test-")); +}); + +afterEach(async () => { + try { + await rm(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +}); + +describe("detectDevCommand", () => { + test("detects package.json scripts.dev", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "next dev" } }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["next", "dev"]); + expect(result!.source).toBe("package.json scripts.dev"); + }); + + test("detects package.json scripts.start when dev is absent", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { start: "node server.js" } }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["node", "server.js"]); + expect(result!.source).toBe("package.json scripts.start"); + }); + + test("falls through package.json with no scripts", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ name: "test", version: "1.0.0" }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).toBeNull(); + }); + + test("detects manage.py (Django)", async () => { + await writeFile(join(tmpDir, "manage.py"), "#!/usr/bin/env python"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["python", "manage.py", "runserver"]); + expect(result!.source).toBe("manage.py"); + }); + + test("detects app.py", async () => { + await writeFile(join(tmpDir, "app.py"), "from flask import Flask"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["python", "app.py"]); + expect(result!.source).toBe("app.py"); + }); + + test("detects main.py", async () => { + await writeFile(join(tmpDir, "main.py"), "print('hello')"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["python", "main.py"]); + expect(result!.source).toBe("main.py"); + }); + + test("detects go.mod", async () => { + await writeFile(join(tmpDir, "go.mod"), "module example.com/myapp"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["go", "run", "."]); + expect(result!.source).toBe("go.mod"); + }); + + test("detects docker-compose.yml", async () => { + await writeFile(join(tmpDir, "docker-compose.yml"), "version: '3'"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["docker", "compose", "up"]); + expect(result!.source).toBe("docker-compose.yml"); + }); + + test("detects compose.yml", async () => { + await writeFile(join(tmpDir, "compose.yml"), "version: '3'"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.args).toEqual(["docker", "compose", "up"]); + expect(result!.source).toBe("compose.yml"); + }); + + test("returns null for empty directory", async () => { + const result = await detectDevCommand(tmpDir); + expect(result).toBeNull(); + }); + + test("package.json takes priority over manage.py", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { dev: "vite" } }) + ); + await writeFile(join(tmpDir, "manage.py"), "#!/usr/bin/env python"); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.source).toBe("package.json scripts.dev"); + }); + + test("prefers dev over start in package.json", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ scripts: { start: "node index.js", dev: "vite" } }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.source).toBe("package.json scripts.dev"); + expect(result!.args).toEqual(["vite"]); + }); + + test("prefers develop over serve", async () => { + await writeFile( + join(tmpDir, "package.json"), + JSON.stringify({ + scripts: { serve: "serve dist", develop: "gatsby develop" }, + }) + ); + const result = await detectDevCommand(tmpDir); + expect(result).not.toBeNull(); + expect(result!.source).toBe("package.json scripts.develop"); + }); +}); diff --git a/test/lib/wizard-runner-handle-final-result.mocked.test.ts b/test/lib/wizard-runner-handle-final-result.mocked.test.ts index 2c5c8eb2f..d433f5e5e 100644 --- a/test/lib/wizard-runner-handle-final-result.mocked.test.ts +++ b/test/lib/wizard-runner-handle-final-result.mocked.test.ts @@ -81,88 +81,88 @@ beforeEach(() => { describe("handleFinalResult", () => { describe("WizardError message", () => { - test("uses bail message from result.result.message when present", () => { + test("uses bail message from result.result.message when present", async () => { const result = makeBailResult({ message: "Dependency installation failed after 5 attempts: pnpm exited with code 1", }); - expect(() => + await expect( handleFinalResult( result, makeSpinnerHandle(), makeSpinState(), makeUI() ) - ).toThrow( + ).rejects.toThrow( "Dependency installation failed after 5 attempts: pnpm exited with code 1" ); }); - test("falls back to generic message when result.result.message is absent", () => { + test("falls back to generic message when result.result.message is absent", async () => { const result = makeBailResult({ message: undefined }); - expect(() => + await expect( handleFinalResult( result, makeSpinnerHandle(), makeSpinState(), makeUI() ) - ).toThrow("Workflow returned an error"); + ).rejects.toThrow("Workflow returned an error"); }); }); describe("wizard.exit_code tag", () => { - test("tags wizard.exit_code with the workflow exit code", () => { + test("tags wizard.exit_code with the workflow exit code", async () => { const result = makeBailResult({ exitCode: 11 }); - expect(() => + await expect( handleFinalResult( result, makeSpinnerHandle(), makeSpinState(), makeUI() ) - ).toThrow(WizardError); + ).rejects.toThrow(WizardError); expect(tags["wizard.exit_code"]).toBe(11); }); - test("does not set wizard.exit_code when exitCode is absent", () => { + test("does not set wizard.exit_code when exitCode is absent", async () => { const result: WorkflowRunResult = { status: "failed", error: "network error", }; - expect(() => + await expect( handleFinalResult( result, makeSpinnerHandle(), makeSpinState(), makeUI() ) - ).toThrow(WizardError); + ).rejects.toThrow(WizardError); expect(tags["wizard.exit_code"]).toBeUndefined(); }); }); describe("WizardError message — result.error fallback", () => { - test("uses result.error when result.result is absent (plain workflow failure)", () => { + test("uses result.error when result.result is absent (plain workflow failure)", async () => { const result: WorkflowRunResult = { status: "failed", error: "upstream network timeout", }; - expect(() => + await expect( handleFinalResult( result, makeSpinnerHandle(), makeSpinState(), makeUI() ) - ).toThrow("upstream network timeout"); + ).rejects.toThrow("upstream network timeout"); }); }); });