diff --git a/packages/cli/package.json b/packages/cli/package.json index e3556c168..c934cbe3b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,7 @@ "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", "cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", + "proto:snake": "tsx --import ./script/require-shim.mjs script/prototype-init-snake.ts", "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", "build": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", diff --git a/packages/cli/script/prototype-init-snake.ts b/packages/cli/script/prototype-init-snake.ts new file mode 100644 index 000000000..a130800cf --- /dev/null +++ b/packages/cli/script/prototype-init-snake.ts @@ -0,0 +1,53 @@ +/** + * Prototype harness: drives the real `sentry init` Ink UI through a fake, + * offline workflow so the waiting-screen Snake game can be tried locally. + * No network calls, no auth, no file changes. + * + * pnpm --filter sentry run proto:snake + * PROTO_STEP_MS=5000 pnpm --filter sentry run proto:snake + */ + +import { setTimeout as sleep } from "node:timers/promises"; +import { createInkUI } from "../src/lib/init/ui/ink-ui.js"; + +const STEP_MS = Number(process.env.PROTO_STEP_MS ?? 20_000); + +const STEPS: Array<{ id: string; message: string; files?: string[] }> = [ + { + id: "discover-context", + message: "Reading project files...", + files: ["package.json", "src/index.ts", "src/server.ts", "tsconfig.json"], + }, + { id: "detect-platform", message: "Detecting platform..." }, + { id: "ensure-sentry-project", message: "Creating Sentry project..." }, + { id: "plan-codemods", message: "Planning SDK setup..." }, + { id: "apply-codemods", message: "Applying changes..." }, + { id: "verify-changes", message: "Verifying setup..." }, +]; + +const ui = await createInkUI(); +ui.setIntroMode?.(false); +const spin = ui.spinner(); + +for (const [index, step] of STEPS.entries()) { + ui.setStep?.(step.id, "in_progress"); + spin.start(step.message); + if (step.files) { + ui.recordFilesReading?.(step.files); + } + await sleep(STEP_MS); + spin.stop(`${step.message.replace("...", "")} done`, 0); + ui.setStep?.(step.id, "completed"); + + // Mid-run prompt checks that the game yields the pane and keys. + if (index === 1) { + const answer = await ui.confirm({ + message: "Prototype prompt: does the game get out of the way?", + initialValue: true, + }); + ui.log.info(`Prompt answered: ${String(answer)}`); + } +} + +ui.outro("Prototype finished. Press any key to exit."); +await ui[Symbol.asyncDispose](); diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index e0983fa81..96b5dc901 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -183,6 +183,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Control the TUI (terminal user interface) for `sentry init`. Set to `0` to disable the interactive TUI and use plain text logging output instead. Useful in CI/CD pipelines or environments without full terminal support.", example: "0", }, + { + name: "SENTRY_INIT_GAME", + description: + "Control the optional Snake game that `sentry init` offers while it waits for setup to finish. Set to `0` to hide it. The game is always hidden when an AI agent runs the CLI.", + example: "0", + }, // -- TLS / Certificates -- { name: "NODE_EXTRA_CA_CERTS", diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index b64e7fa6a..54541642a 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -62,6 +62,7 @@ import { ShortcutHintProvider, useInkShortcuts, } from "./ink-shortcuts.js"; +import { createSnakeSession, SnakeGame, SnakeInvite } from "./ink-snake.js"; import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js"; import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; import type { @@ -96,6 +97,10 @@ const COLOR_INFO = "#9C84D4"; const COLOR_WARN = "#FDB81B"; const COLOR_ERROR = "#fe4144"; const COLOR_SUCCESS = "#83da90"; + +/** Below this height the invite and board crowd out the activity log. */ +const MIN_SNAKE_ROWS = 20; + const ACTIVE_TASK_PULSE_INTERVAL_MS = 600; const ICON_BY_SEVERITY: Record = @@ -196,11 +201,40 @@ function AppBody({ store }: AppProps): React.ReactNode { ); const { columns, rows } = useInkFrameSize(); const [activeTab, setActiveTab] = useState(0); + const [snakeSession] = useState(createSnakeSession); + // Latest warning/error log id when the game opened; null while closed. + const [snakeAlertBaseline, setSnakeAlertBaseline] = useState( + null, + ); const width = getInkFrameWidth(columns); const contentHeight = Math.max(5, rows - 4); const isWide = width >= 80; + const canPlaySnake = + snapshot.snakeEnabled && + snapshot.layout === "workflow" && + snapshot.prompt === null && + snapshot.outroState === null && + snapshot.summary === null && + activeTab === 0 && + rows >= MIN_SNAKE_ROWS; + const latestAlertId = latestAlertLogId(snapshot.logs); + const showSnake = + snakeAlertBaseline !== null && + snakeAlertBaseline === latestAlertId && + canPlaySnake; + // A prompt, a new warning or error, or the end of the run takes the pane + // back from the game so the user sees what needs attention. + useEffect(() => { + if (snakeAlertBaseline !== null && !showSnake) { + setSnakeAlertBaseline(null); + } + }, [showSnake, snakeAlertBaseline]); + const closeSnake = useCallback(() => setSnakeAlertBaseline(null), []); + const requestCancel = snapshot.requestCancel; + const cancelFromSnake = useCallback(() => requestCancel?.(), [requestCancel]); + const tabs = useMemo( () => [ { id: "status", label: "Status" }, @@ -234,13 +268,23 @@ function AppBody({ store }: AppProps): React.ReactNode { }, }, ]; + if (canPlaySnake) { + bindings.push({ + key: "g", + action: "play snake", + priority: 20, + match: (input) => input === "g", + run: () => setSnakeAlertBaseline(latestAlertId), + }); + } return bindings; - }, [snapshot.requestCancel, tabs.length]); + }, [canPlaySnake, latestAlertId, snapshot.requestCancel, tabs.length]); useInkShortcuts("init-app", appShortcuts, { isActive: snapshot.layout === "workflow" && snapshot.prompt === null && - snapshot.outroState === null, + snapshot.outroState === null && + !showSnake, }); if (snapshot.outroState?.kind === "success") { @@ -308,14 +352,39 @@ function AppBody({ store }: AppProps): React.ReactNode { overflow="hidden" > - {activeTab === 0 ? ( - + {showSnake ? ( + + {snapshot.spinner.active ? ( + + ) : ( + // Holds the spinner rows so the board keeps its size between steps. + + )} + + + ) : activeTab === 0 ? ( + <> + + {canPlaySnake ? ( + + ) : null} + ) : ( = 0; index--) { + const entry = logs[index]; + if (entry?.severity === "warn" || entry?.severity === "error") { + return entry.id; + } + } + return 0; +} + // ────────────────────────────── Sidebar ─────────────────────────────── function Sidebar({ diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx new file mode 100644 index 000000000..c72a1cb6a --- /dev/null +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -0,0 +1,250 @@ +import { Box, type DOMElement, Text, useBoxMetrics } from "ink"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { type ShortcutBinding, useInkShortcuts } from "./ink-shortcuts.js"; +import { + createSnake, + pauseSnake, + renderSnake, + resizeSnake, + type SnakeDirection, + type SnakeState, + type SnakeStatus, + snakeTickMs, + stepSnake, + togglePause, + turnSnake, +} from "./snake-game.js"; + +type InkKey = Parameters[1]; + +/** + * Lives above the game view so a prompt can unmount the board without + * losing the run in progress. + */ +export type SnakeSession = { state: SnakeState | null; best: number }; + +export function createSnakeSession(): SnakeSession { + return { state: null, best: 0 }; +} + +const MAX_BOARD_COLS = 60; +const MAX_BOARD_TERMINAL_ROWS = 14; +/** Border (2) plus the score line under the board. */ +const BOARD_CHROME_ROWS = 3; + +/** Board size in cells for a pane of `cols` × `rows` terminal cells. */ +function snakeBoardSize( + cols: number, + rows: number, +): { width: number; height: number } { + const width = Math.max(10, Math.min(MAX_BOARD_COLS, cols - 2)); + const terminalRows = Math.max( + 3, + Math.min(MAX_BOARD_TERMINAL_ROWS, rows - BOARD_CHROME_ROWS), + ); + return { width, height: terminalRows * 2 }; +} + +function steerDirection(input: string, key: InkKey): SnakeDirection | null { + if (key.upArrow || input === "w" || input === "k") { + return "up"; + } + if (key.downArrow || input === "s" || input === "j") { + return "down"; + } + if (key.leftArrow || input === "a" || input === "h") { + return "left"; + } + if (key.rightArrow || input === "d" || input === "l") { + return "right"; + } + return null; +} + +const STATUS_HINT: Record = { + ready: "Press an arrow key to start", + playing: "", + paused: "Paused · p to resume", + over: "Game over · r to retry", +}; + +type SnakeGameProps = { + accent: string; + muted: string; + onCancel: () => void; + onExit: () => void; + session: SnakeSession; +}; + +export function SnakeGame(props: SnakeGameProps): React.ReactNode { + const ref = useRef(null); + const pane = useBoxMetrics(ref); + const board = pane.hasMeasured + ? snakeBoardSize(pane.width, pane.height) + : null; + return ( + + {board ? ( + + ) : null} + + ); +} + +function SnakeBoard({ + accent, + height, + muted, + onCancel, + onExit, + session, + width, +}: SnakeGameProps & { height: number; width: number }): React.ReactNode { + const [, redraw] = useReducer((frame: number) => frame + 1, 0); + session.state = session.state + ? resizeSnake(session.state, width, height) + : createSnake(width, height); + const state = session.state; + + const apply = useCallback( + (update: (current: SnakeState) => SnakeState) => { + if (!session.state) { + return; + } + const next = update(session.state); + if (next === session.state) { + return; + } + session.state = next; + session.best = Math.max(session.best, next.score); + redraw(); + }, + [session], + ); + + useEffect(() => { + if (state.status !== "playing") { + return; + } + const timer = setTimeout(() => apply(stepSnake), snakeTickMs(state)); + return () => clearTimeout(timer); + }, [apply, state]); + + useEffect( + () => () => { + if (session.state) { + session.state = pauseSnake(session.state); + } + }, + [session], + ); + + const bindings = useMemo( + () => [ + { + key: "ctrl+c", + action: "cancel", + priority: 0, + showInFooter: false, + match: (input, key) => key.ctrl && input === "c", + run: onCancel, + }, + { + key: "←↑↓→", + action: "steer", + priority: 10, + match: (input, key) => steerDirection(input, key) !== null, + run: (input, key) => { + const direction = steerDirection(input, key); + if (direction) { + apply((current) => turnSnake(current, direction)); + } + }, + }, + { + key: "p", + action: "pause", + priority: 20, + match: (input) => input === "p" || input === " ", + run: () => apply(togglePause), + }, + { + key: "r", + action: "restart", + priority: 30, + match: (input) => input === "r", + run: () => apply(() => createSnake(width, height)), + }, + { + key: "esc", + action: "back to setup", + priority: 40, + match: (input, key) => key.escape || input === "q", + run: () => { + // Pause before the board unmounts so the invite renders the resume copy. + apply(pauseSnake); + onExit(); + }, + }, + ], + [apply, height, onCancel, onExit, width], + ); + useInkShortcuts("snake-game", bindings); + + const frame = useMemo(() => renderSnake(state).join("\n"), [state]); + + return ( + + + {frame} + + + Bugs squashed {state.score} + Best {session.best} + {STATUS_HINT[state.status]} + + + ); +} + +export function SnakeInvite({ + accent, + muted, + session, +}: { + accent: string; + muted: string; + session: SnakeSession; +}): React.ReactNode { + const paused = session.state?.status === "paused"; + return ( + + + {paused + ? `Your snake is waiting (${session.state?.score ?? 0} bugs squashed).` + : "Got a minute? Squash some bugs while we work."} + + + Press{" "} + + g + {" "} + to {paused ? "resume" : "play"} Snake {"→"} + + + ); +} diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index 7109657db..9f1501027 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -57,6 +57,8 @@ import { addBreadcrumb, setTag } from "@sentry/node-core/light"; import { FULL_BANNER_LINES } from "../../banner.js"; import { openBrowser } from "../../browser.js"; import { CLI_VERSION } from "../../constants.js"; +import { detectAgent } from "../../detect-agent.js"; +import { getEnv } from "../../env.js"; import { stripAnsi } from "../../formatters/plain-detect.js"; import { createWizardPromptTelemetry, @@ -326,6 +328,7 @@ export async function createInkUI( // Seed with the full banner; IntroScreen re-fits it to the live terminal // width on every render (shrinking or growing) so it never wraps. bannerRows: FULL_BANNER_LINES, + snakeEnabled: isSnakeEnabled(), }); const initialWelcome = opts.initialWelcome ? createPendingWelcome() @@ -378,6 +381,11 @@ export async function createInkUI( } } +/** Exported for the test suite. */ +export function isSnakeEnabled(): boolean { + return getEnv().SENTRY_INIT_GAME !== "0" && !detectAgent(); +} + /** * Subset of the Ink `Instance` type we actually use. * diff --git a/packages/cli/src/lib/init/ui/snake-game.ts b/packages/cli/src/lib/init/ui/snake-game.ts new file mode 100644 index 000000000..a9c01ae6f --- /dev/null +++ b/packages/cli/src/lib/init/ui/snake-game.ts @@ -0,0 +1,315 @@ +/** + * Snake engine for the `sentry init` waiting screen. + * + * Pure state transitions plus a string renderer so the Ink component + * only owns timing and input. The board uses half-block glyphs: one + * terminal row holds two board rows, which makes cells roughly square + * and keeps the whole frame a single `` node for Ink. + */ + +export type SnakeDirection = "up" | "down" | "left" | "right"; + +export type SnakeStatus = "ready" | "playing" | "paused" | "over"; + +export type SnakePoint = { x: number; y: number }; + +export type SnakeState = { + width: number; + height: number; + /** Head first. */ + body: SnakePoint[]; + direction: SnakeDirection; + /** Turns pressed faster than the tick rate, applied one per step. */ + queuedTurns: SnakeDirection[]; + food: SnakePoint | null; + score: number; + status: SnakeStatus; +}; + +export type RandomSource = () => number; + +const MAX_QUEUED_TURNS = 2; +const START_LENGTH = 3; + +const OPPOSITE: Record = { + up: "down", + down: "up", + left: "right", + right: "left", +}; + +const DELTA: Record = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +}; + +export function createSnake( + width: number, + height: number, + random: RandomSource = Math.random, +): SnakeState { + const y = Math.floor(height / 2); + const headX = Math.min(width - 1, Math.floor(width / 3) + START_LENGTH); + const body: SnakePoint[] = []; + for (let i = 0; i < START_LENGTH; i++) { + body.push({ x: Math.max(0, headX - i), y }); + } + const state: SnakeState = { + width, + height, + body, + direction: "right", + queuedTurns: [], + food: null, + score: 0, + status: "ready", + }; + return { ...state, food: placeFood(state, random) }; +} + +/** Milliseconds between steps; the snake speeds up as it grows. */ +export function snakeTickMs(state: SnakeState): number { + return Math.max(55, 110 - state.score * 3); +} + +export function turnSnake( + state: SnakeState, + direction: SnakeDirection, +): SnakeState { + if (state.status === "over") { + return state; + } + // Any steering key also starts a ready game or resumes a paused one. + const started: SnakeState = + state.status === "playing" ? state : { ...state, status: "playing" }; + const last = state.queuedTurns.at(-1) ?? state.direction; + if ( + direction === last || + direction === OPPOSITE[last] || + state.queuedTurns.length >= MAX_QUEUED_TURNS + ) { + return started; + } + return { ...started, queuedTurns: [...state.queuedTurns, direction] }; +} + +export function togglePause(state: SnakeState): SnakeState { + if (state.status === "playing") { + return { ...state, status: "paused" }; + } + if (state.status === "paused") { + return { ...state, status: "playing" }; + } + return state; +} + +export function pauseSnake(state: SnakeState): SnakeState { + return state.status === "playing" ? { ...state, status: "paused" } : state; +} + +export function stepSnake( + state: SnakeState, + random: RandomSource = Math.random, +): SnakeState { + if (state.status !== "playing") { + return state; + } + const [nextTurn, ...queuedTurns] = state.queuedTurns; + const direction = nextTurn ?? state.direction; + const head = state.body[0]; + if (!head) { + return { ...state, status: "over" }; + } + const delta = DELTA[direction]; + const next = { x: head.x + delta.x, y: head.y + delta.y }; + const eats = state.food !== null && samePoint(next, state.food); + // The tail moves away this step unless the snake grows, so the head may + // enter the cell the tail is leaving. + const blockers = eats ? state.body : state.body.slice(0, -1); + if ( + next.x < 0 || + next.y < 0 || + next.x >= state.width || + next.y >= state.height || + blockers.some((part) => samePoint(part, next)) + ) { + return { ...state, direction, queuedTurns, status: "over" }; + } + const body = [next, ...blockers]; + const moved: SnakeState = { ...state, body, direction, queuedTurns }; + if (!eats) { + return moved; + } + const grown = { ...moved, score: state.score + 1 }; + const food = placeFood(grown, random); + return food ? { ...grown, food } : { ...grown, food, status: "over" }; +} + +/** + * Fit a run to a new board size. The snake keeps its shape and moves only + * as far as it must to stay on the board. A playing run pauses so the new + * walls do not end it by surprise. A snake too long for the board starts over. + */ +export function resizeSnake( + state: SnakeState, + width: number, + height: number, + random: RandomSource = Math.random, +): SnakeState { + if (state.width === width && state.height === height) { + return state; + } + const xs = state.body.map((part) => part.x); + const ys = state.body.map((part) => part.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + if (maxX - minX >= width || maxY - minY >= height) { + return createSnake(width, height, random); + } + const dx = Math.min(0, width - 1 - maxX); + const dy = Math.min(0, height - 1 - maxY); + const shift = (point: SnakePoint): SnakePoint => ({ + x: point.x + dx, + y: point.y + dy, + }); + const resized: SnakeState = { + ...pauseSnake(state), + width, + height, + body: state.body.map(shift), + }; + const food = state.food ? shift(state.food) : null; + if (food && food.x >= 0 && food.y >= 0 && food.x < width && food.y < height) { + return { ...resized, food }; + } + const placed = placeFood(resized, random); + return placed + ? { ...resized, food: placed } + : { ...resized, food: null, status: "over" }; +} + +function placeFood(state: SnakeState, random: RandomSource): SnakePoint | null { + const occupied = new Set(state.body.map((part) => pointKey(part))); + const free = state.width * state.height - occupied.size; + if (free <= 0) { + return null; + } + let pick = Math.floor(random() * free); + for (let y = 0; y < state.height; y++) { + for (let x = 0; x < state.width; x++) { + if (occupied.has(pointKey({ x, y }))) { + continue; + } + if (pick === 0) { + return { x, y }; + } + pick -= 1; + } + } + return null; +} + +function samePoint(a: SnakePoint, b: SnakePoint): boolean { + return a.x === b.x && a.y === b.y; +} + +function pointKey(point: SnakePoint): string { + return `${point.x},${point.y}`; +} + +// ────────────────────────────── Rendering ───────────────────────────── + +const EMPTY = 0; +const BODY = 1; +const HEAD = 2; +const FOOD = 3; + +type CellKind = typeof EMPTY | typeof BODY | typeof HEAD | typeof FOOD; + +export type SnakePalette = Record< + Exclude, + [number, number, number] +>; + +export const DEFAULT_SNAKE_PALETTE: SnakePalette = { + [BODY]: [139, 106, 200], + [HEAD]: [196, 176, 255], + [FOOD]: [255, 90, 122], +}; + +/** Terminal rows needed to draw a board of `height` cells. */ +export function snakeTerminalRows(height: number): number { + return Math.ceil(height / 2); +} + +/** + * Draw the board as `snakeTerminalRows(height)` lines, each exactly + * `width` columns wide. Colors are 24-bit SGR sequences that change + * only between runs of different styles. + */ +export function renderSnake( + state: SnakeState, + palette: SnakePalette = DEFAULT_SNAKE_PALETTE, +): string[] { + const cells = new Uint8Array(state.width * state.height); + if (state.food) { + cells[state.food.y * state.width + state.food.x] = FOOD; + } + state.body.forEach((part, index) => { + cells[part.y * state.width + part.x] = index === 0 ? HEAD : BODY; + }); + + const lines: string[] = []; + for (let row = 0; row < snakeTerminalRows(state.height); row++) { + let line = ""; + let activeStyle = ""; + for (let x = 0; x < state.width; x++) { + const top = cells[row * 2 * state.width + x] as CellKind; + const bottomY = row * 2 + 1; + const bottom = + bottomY < state.height + ? (cells[bottomY * state.width + x] as CellKind) + : EMPTY; + const [style, glyph] = halfBlock(top, bottom, palette); + if (style !== activeStyle) { + line += activeStyle ? `\x1b[0m${style}` : style; + activeStyle = style; + } + line += glyph; + } + lines.push(activeStyle ? `${line}\x1b[0m` : line); + } + return lines; +} + +function halfBlock( + top: CellKind, + bottom: CellKind, + palette: SnakePalette, +): [string, string] { + if (top === EMPTY && bottom === EMPTY) { + return ["", " "]; + } + if (bottom === EMPTY) { + return [fg(palette[top as Exclude]), "▀"]; + } + if (top === EMPTY) { + return [fg(palette[bottom]), "▄"]; + } + if (top === bottom) { + return [fg(palette[top]), "█"]; + } + return [`${fg(palette[top])}${bg(palette[bottom])}`, "▀"]; +} + +function fg([r, g, b]: [number, number, number]): string { + return `\x1b[38;2;${r};${g};${b}m`; +} + +function bg([r, g, b]: [number, number, number]): string { + return `\x1b[48;2;${r};${g};${b}m`; +} diff --git a/packages/cli/src/lib/init/ui/wizard-store.ts b/packages/cli/src/lib/init/ui/wizard-store.ts index 2f0584dd7..d8a4bd3fc 100644 --- a/packages/cli/src/lib/init/ui/wizard-store.ts +++ b/packages/cli/src/lib/init/ui/wizard-store.ts @@ -204,6 +204,8 @@ export type WizardSnapshot = { prompt: ActivePrompt | null; /** Index of the currently-displayed Sentry tip in the sidebar. */ tipIndex: number; + /** Offer the waiting-screen Snake game. Off for agents and `SENTRY_INIT_GAME=0`. */ + snakeEnabled: boolean; /** Final structured summary, rendered after the workflow completes. */ summary: WizardSummary | null; /** @@ -281,6 +283,7 @@ function baseSnapshot(): WizardSnapshot { spinner: { active: false, frame: 0, message: "" }, prompt: null, tipIndex: 0, + snakeEnabled: false, summary: null, filesRead: [], steps: defaultChecklistSteps(), diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 547895f2e..f514be6d8 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -130,6 +130,7 @@ async function renderApp( instance.unmount(); // waitUntilExit() hangs in CI — race with a short unref'd timeout. await Promise.race([ + // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. instance.waitUntilExit().catch(() => { // Ink may reject on unmount — ignore. }), @@ -1383,3 +1384,77 @@ describe("completion screen", () => { expect(text).toContain("Opened Sentry in your browser."); }); }); + +describe("snake waiting game", () => { + async function settledFrame(out: CaptureStream): Promise { + await sleep(FRAME_SETTLE_MS); + out.settledOutput = out.allOutput(); + return stripAnsi(out.latestFrame()); + } + + test("stays hidden when the game is disabled", async () => { + const store = new WizardStore(); + store.startSpinner("Verifying setup..."); + const out = await renderApp(store, 110, { rows: 32, input: ["g"] }); + const frame = stripAnsi(out.latestFrame()); + expect(frame).toContain("Verifying setup..."); + expect(frame).not.toContain("Snake"); + expect(frame).not.toContain("Bugs squashed"); + }); + + function renderLive(store: WizardStore) { + const out = new CaptureStream(110, 32); + const stdin = makeStdin(); + const instance = render(createElement(App, { store }), { + stdout: out as unknown as NodeJS.WriteStream, + stderr: out as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + // Ink writes no intermediate frames in CI unless forced interactive. + interactive: true, + }); + return { instance, out, stdin }; + } + + async function startGame(): Promise< + ReturnType & { + store: WizardStore; + } + > { + const store = new WizardStore({ snakeEnabled: true }); + store.startSpinner("Verifying setup..."); + const live = renderLive(store); + expect(await settledFrame(live.out)).toContain("to play Snake"); + live.stdin.push("g"); + await sleep(20); + live.stdin.push("\u001B[A"); + expect(await settledFrame(live.out)).toContain("Bugs squashed"); + return { ...live, store }; + } + + test("a new warning pauses the game and shows the activity log", async () => { + const { instance, out, store } = await startGame(); + try { + store.appendLog("warn", "Could not verify setup: app failed to start"); + const frame = await settledFrame(out); + expect(frame).toContain("Could not verify setup"); + expect(frame).not.toContain("Bugs squashed"); + expect(frame).toContain("to resume Snake"); + } finally { + instance.unmount(); + } + }); + + test("esc pauses the game and offers to resume it", async () => { + const { instance, out, stdin } = await startGame(); + try { + stdin.push("\u001B"); + const frame = await settledFrame(out); + expect(frame).not.toContain("Bugs squashed"); + expect(frame).toContain("to resume Snake"); + } finally { + instance.unmount(); + } + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-ui-snake.test.ts b/packages/cli/test/lib/init/ui/ink-ui-snake.test.ts new file mode 100644 index 000000000..a62df5008 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-ui-snake.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; +import { withEnv } from "../../../../src/lib/env.js"; +import { isSnakeEnabled } from "../../../../src/lib/init/ui/ink-ui.js"; + +describe("isSnakeEnabled", () => { + test("offers the game to a person at a terminal", () => { + expect(withEnv({}, isSnakeEnabled)).toBe(true); + }); + + test("SENTRY_INIT_GAME=0 turns the game off", () => { + expect(withEnv({ SENTRY_INIT_GAME: "0" }, isSnakeEnabled)).toBe(false); + }); + + test("hides the game when an AI agent runs the CLI", () => { + expect(withEnv({ AI_AGENT: "claude-code" }, isSnakeEnabled)).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/init/ui/snake-game.test.ts b/packages/cli/test/lib/init/ui/snake-game.test.ts new file mode 100644 index 000000000..1e53c697c --- /dev/null +++ b/packages/cli/test/lib/init/ui/snake-game.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "vitest"; +import { stripAnsi } from "../../../../src/lib/formatters/plain-detect.js"; +import { + createSnake, + pauseSnake, + renderSnake, + resizeSnake, + type SnakeState, + snakeTerminalRows, + stepSnake, + togglePause, + turnSnake, +} from "../../../../src/lib/init/ui/snake-game.js"; + +const firstFreeCell = () => 0; + +function playing(overrides: Partial = {}): SnakeState { + return { + ...createSnake(10, 6, firstFreeCell), + status: "playing", + ...overrides, + }; +} + +describe("snake engine", () => { + test("starts ready and moves only once a turn starts it", () => { + const ready = createSnake(10, 6, firstFreeCell); + expect(ready.status).toBe("ready"); + expect(stepSnake(ready)).toBe(ready); + + const started = turnSnake(ready, "up"); + expect(started.status).toBe("playing"); + expect(stepSnake(started).body[0]).toEqual({ + x: ready.body[0]?.x, + y: (ready.body[0]?.y ?? 0) - 1, + }); + }); + + test("ignores a reversal into its own neck", () => { + const state = playing(); + expect(turnSnake(state, "left")).toBe(state); + }); + + test("queues quick turns and applies one per step", () => { + const state = turnSnake(turnSnake(playing(), "up"), "left"); + expect(state.queuedTurns).toEqual(["up", "left"]); + const once = stepSnake(state); + expect(once.direction).toBe("up"); + expect(stepSnake(once).direction).toBe("left"); + }); + + test("grows and scores when it eats food", () => { + const state = playing(); + const head = state.body[0]; + const ate = stepSnake({ + ...state, + food: { x: (head?.x ?? 0) + 1, y: head?.y ?? 0 }, + }); + expect(ate.score).toBe(1); + expect(ate.body).toHaveLength(state.body.length + 1); + expect(ate.food).not.toBeNull(); + }); + + test("ends the game at a wall", () => { + const state = playing({ + body: [ + { x: 9, y: 2 }, + { x: 8, y: 2 }, + ], + food: { x: 0, y: 0 }, + }); + expect(stepSnake(state).status).toBe("over"); + }); + + test("may follow its tail into the cell the tail leaves", () => { + const loop = playing({ + body: [ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, + { x: 1, y: 2 }, + ], + direction: "left", + queuedTurns: ["down"], + food: { x: 9, y: 5 }, + }); + expect(stepSnake(loop).status).toBe("playing"); + }); + + test("pause stops steps and a turn resumes", () => { + const paused = togglePause(playing()); + expect(paused.status).toBe("paused"); + expect(stepSnake(paused)).toBe(paused); + expect(pauseSnake(paused)).toBe(paused); + expect(turnSnake(paused, "up").status).toBe("playing"); + }); +}); + +describe("renderSnake", () => { + test("packs two board rows into each terminal row at a fixed width", () => { + const state = createSnake(12, 7, firstFreeCell); + const lines = renderSnake(state); + expect(lines).toHaveLength(snakeTerminalRows(7)); + for (const line of lines) { + expect(stripAnsi(line)).toHaveLength(12); + } + }); + + test("draws stacked cells with half and full blocks", () => { + const state: SnakeState = { + ...createSnake(3, 2, firstFreeCell), + body: [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + ], + food: { x: 2, y: 1 }, + }; + expect(stripAnsi(renderSnake(state)[0] ?? "")).toBe("▀ ▄"); + }); +}); + +describe("resizeSnake", () => { + const snake = playing({ + body: [ + { x: 8, y: 4 }, + { x: 7, y: 4 }, + { x: 6, y: 4 }, + ], + food: { x: 9, y: 5 }, + score: 4, + }); + + test("keeps the run on a larger board and pauses it", () => { + const resized = resizeSnake(snake, 20, 10, firstFreeCell); + expect(resized).toMatchObject({ + width: 20, + height: 10, + body: snake.body, + food: snake.food, + score: 4, + status: "paused", + }); + }); + + test("shifts the snake inside a smaller board", () => { + const resized = resizeSnake(snake, 6, 4, firstFreeCell); + expect(resized.body).toEqual([ + { x: 5, y: 3 }, + { x: 4, y: 3 }, + { x: 3, y: 3 }, + ]); + expect(resized.score).toBe(4); + expect(resized.food).not.toBeNull(); + const food = resized.food as { x: number; y: number }; + expect(food.x).toBeLessThan(6); + expect(food.y).toBeLessThan(4); + }); + + test("starts over when the snake no longer fits", () => { + const resized = resizeSnake(snake, 2, 4, firstFreeCell); + expect(resized.score).toBe(0); + expect(resized.status).toBe("ready"); + }); + + test("returns the same state when the size does not change", () => { + expect(resizeSnake(snake, snake.width, snake.height)).toBe(snake); + }); +});