diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2434400d1..1e351ceb0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -35,6 +35,7 @@ Use this page as a command reference. If you are setting up a project for the fi | `void project rollback` | Roll back to a previous deployment | | `void project cancel` | Cancel an active deployment | | `void project purge-cache` | Purge all cached pages | +| `void build logs` | Stream, tail, or download build logs | | `void mcp` | Start the Void MCP server | | `void init` | Setup wizard for new or existing projects | @@ -651,6 +652,40 @@ void github update my-app --executor github_actions **Project resolution** follows the same order as deploy: positional / `--project`, `VOID_PROJECT`, linked project (`.void/project.json`). +## Build + +Inspect Deploy-on-GitHub builds. + +### `void build logs` + +``` +void build logs [build] [--follow] [--output ] [--project ] +``` + +Stream, tail, or download the build logs for a **container** build. With no +`[build]` argument, targets the project's most recent build. + +| Flag | Purpose | Default | +| ------------------------ | ----------------------------------------------------------------- | ------- | +| `--follow`, `-f` | Live-tail: poll until the build reaches a terminal status. | off | +| `--output `, `-o` | Write logs to a file instead of stdout (appends while following). | stdout | +| `--project `, `-p` | Target project. | linked | + +**Project resolution** follows the same order as deploy: positional / `--project`, +`VOID_PROJECT`, linked project (`.void/project.json`). + +Builds run on **GitHub Actions** keep their logs on GitHub — the command prints +the Actions run URL instead of streaming. Only the last 10,000 log lines of a +container build are retained. + +Examples: + +``` +void build logs # print the latest build's logs +void build logs -f # follow the latest build until it finishes +void build logs bld_123 -o build.log # download a specific build's logs +``` + ## Custom Domains ### `void domain add` diff --git a/packages/void/src/cli/build-args.ts b/packages/void/src/cli/build-args.ts new file mode 100644 index 000000000..5b461335a --- /dev/null +++ b/packages/void/src/cli/build-args.ts @@ -0,0 +1,66 @@ +import type { SubcommandDef } from './subcommand-prompt.ts'; + +export const buildSubcommands: Array = [ + { + name: 'logs', + arg: '[build]', + description: 'Stream, tail, or download logs for a build', + }, +]; + +export type BuildArgs = { + subcommand: 'logs'; + buildId?: string; + follow: boolean; + output?: string; + projectName?: string; +}; + +export function parseBuildArgs(args: Array): BuildArgs { + const subcommand = args[0]; + const rest = args.slice(1); + + if (subcommand !== 'logs') { + throw new Error( + 'build: Usage: void build logs [build] [--follow] [--output ] [--project ].', + ); + } + + let buildId: string | undefined; + let follow = false; + let output: string | undefined; + let projectName: string | undefined; + + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]!; + if (arg === '-f' || arg === '--follow') { + follow = true; + } else if (arg === '-o' || arg === '--output') { + if (i + 1 >= rest.length) { + throw new Error('build: --output requires a value.'); + } + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('build: --output requires a value.'); + } + output = value; + } else if (arg === '-p' || arg === '--project') { + if (i + 1 >= rest.length) { + throw new Error('build: --project requires a value.'); + } + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('build: --project requires a value.'); + } + projectName = value; + } else if (arg.startsWith('-')) { + throw new Error(`build: Unexpected argument '${arg}'.`); + } else if (buildId) { + throw new Error(`build: Unexpected argument '${arg}'.`); + } else { + buildId = arg; + } + } + + return { subcommand, buildId, follow, output, projectName }; +} diff --git a/packages/void/src/cli/build-cmd.ts b/packages/void/src/cli/build-cmd.ts new file mode 100644 index 000000000..8e4bc7e1c --- /dev/null +++ b/packages/void/src/cli/build-cmd.ts @@ -0,0 +1,212 @@ +import { appendFileSync, writeFileSync } from 'node:fs'; +import { intro, log, outro } from '@clack/prompts'; +import { getToken } from './auth.ts'; +import type { BuildArgs } from './build-args.ts'; +import { PlatformApiError, PlatformClient, type BuildLogsResponse } from './client.ts'; +import { cliTitle, dim, pc } from './output.ts'; +import { readProjectConfig } from './project.ts'; +import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; + +const POLL_MS = 1000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Classifies a `getBuildLogs` failure during a follow/first-page fetch as fatal + * (surface + exit) vs transient (retry). A client error (4xx) is permanent — + * the build is gone, access was revoked, or the CLI is too old (426 + * `cli_outdated`) — so retrying can't fix it; it is fatal, matching how the rest + * of the CLI surfaces 426 (see `deploy-errors.ts`, `github-cmd.ts`). The sole + * exception is 429 (rate limit), which IS transient. Everything else + * (5xx/timeout/DO-read blip) is transient and retried through. + */ +export function isFatalFollowError(err: unknown): boolean { + return ( + err instanceof PlatformApiError && err.status >= 400 && err.status < 500 && err.status !== 429 + ); +} + +/** + * Pure follow-loop: repeatedly fetch pages since a cursor, write each new + * line, and stop when the build reaches a terminal status. `fetchPage`, + * `write`, and `sleep` are injected so this is unit-testable. Returns the + * final status + terminal flag. + * + * A transient `fetchPage` failure (502/timeout/DO-read blip) must NOT kill the + * live tail: on catch we consult the injected `isFatal` — a fatal error + * (e.g. 401/403/404) rethrows, otherwise we call `onRetry` and retry from the + * SAME cursor on the next tick (no cursor advance, no write), so the tail + * survives blips instead of exiting the CLI. + */ +export async function followBuildLogs(opts: { + fetchPage: (since: number) => Promise; + write: (line: string) => void; + sleep: (ms: number) => Promise; + since: number; + status: string; + pollMs?: number; + isFatal?: (err: unknown) => boolean; + onRetry?: (err: unknown) => void; +}): Promise<{ status: string; terminal: boolean }> { + let cursor = opts.since; + let status = opts.status; + const pollMs = opts.pollMs ?? POLL_MS; + for (;;) { + await opts.sleep(pollMs); + let pageResult: BuildLogsResponse; + try { + pageResult = await opts.fetchPage(cursor); + } catch (err) { + if (opts.isFatal?.(err)) { + throw err; + } + opts.onRetry?.(err); + // Retry from the same cursor after the next sleep; do not advance or write. + continue; + } + for (const l of pageResult.lines) { + opts.write(l.line); + } + cursor = pageResult.lastSeq; + status = pageResult.status; + if (pageResult.terminal) { + return { status, terminal: true }; + } + } +} + +export async function runBuildCommand(root: string, args: BuildArgs): Promise { + if (args.subcommand === 'logs') { + return runBuildLogsCommand(root, args); + } +} + +async function runBuildLogsCommand( + root: string, + args: Extract, +): Promise { + console.log(); + intro(cliTitle('build logs')); + + const token = getToken(root); + if (!token) { + log.error('Not logged in. Run `void auth login` first.'); + process.exit(1); + } + + const client = new PlatformClient(token, { root }); + + // Resolve project: --project/positional slug -> VOID_PROJECT -> linked config. + let projectId: string; + const requestedSlug = getRequestedProjectSlug({ projectSlug: args.projectName }); + if (requestedSlug) { + const projects = await client.listProjects(); + const resolved = resolveProjectBySlug(projects, requestedSlug); + if (!resolved) { + log.error(`build: No project found with slug '${requestedSlug}'.`); + process.exit(1); + } + projectId = resolved.projectId; + } else { + const linked = readProjectConfig(root); + if (!linked) { + log.error( + 'build: No project specified. Pass a project with --project , set VOID_PROJECT, or link one with `void project link`.', + ); + process.exit(1); + } + projectId = linked.projectId; + } + + // Resolve build: explicit id or the newest build. + let buildId = args.buildId; + if (!buildId) { + const builds = await client.listBuilds(projectId, 1); + if (builds.length === 0) { + outro('No builds found. Push to your connected branch to trigger one.'); + return; + } + buildId = builds[0]!.id; + } + + // Writer: append to a file, or print to stdout. + const write = args.output + ? (line: string): void => appendFileSync(args.output!, `${line}\n`) + : (line: string): void => console.log(line); + + // `isFatalFollowError` decides fatal vs transient (see its doc). The SAME + // policy governs both the first-page fetch below and the follow loop. + const onRetry = (err: unknown): void => + log.warn( + `build: transient error while following (${err instanceof Error ? err.message : String(err)}); retrying…`, + ); + + // First page (since=0). A transient failure on the FIRST poll must not kill + // `--follow` either: on a fatal error we exit with a friendly message, on a + // non-fatal error we exit immediately ONLY for the one-shot (non-follow) + // command; under --follow we onRetry + sleep and re-request since=0. + let first: BuildLogsResponse; + for (;;) { + try { + first = await client.getBuildLogs(projectId, buildId, 0); + break; + } catch (err) { + if (isFatalFollowError(err)) { + if (err instanceof PlatformApiError && err.status === 404) { + log.error(`build: Build '${buildId}' not found for this project.`); + } else { + log.error(err instanceof Error ? err.message : String(err)); + } + process.exit(1); + } + if (!args.follow) { + log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + onRetry(err); + await sleep(POLL_MS); + } + } + + // github_actions builds keep their logs on GitHub. + if (first.external) { + log.message(`This build ran on GitHub Actions. View logs:\n${pc.cyan(first.external)}`); + return; + } + + // Truncate/create the output file ONCE before the first line. The `write` + // writer appends (so follow-mode appends work), but the initial dump must not + // concatenate onto a stale file from a previous run. Placed after the external + // early-return so an external build never creates an empty file. + if (args.output) { + writeFileSync(args.output, ''); + } + + for (const l of first.lines) { + write(l.line); + } + + let status = first.status; + let terminal = first.terminal; + + if (args.follow && !terminal) { + ({ status, terminal } = await followBuildLogs({ + fetchPage: (since) => client.getBuildLogs(projectId, buildId!, since), + write, + sleep, + since: first.lastSeq, + status, + isFatal: isFatalFollowError, + onRetry, + })); + } + + if (!terminal) { + outro(dim('Build still running — pass -f to follow.')); + return; + } + + outro(dim(`Build ${status}.`)); +} diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 637d0465b..3b0498946 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -6,6 +6,7 @@ import { join } from 'pathe'; import { getPackageDir } from './agents.ts'; import { parseDeployArgs } from './args.ts'; import { authSubcommands, parseAuthArgs } from './auth-args.ts'; +import { buildSubcommands, parseBuildArgs } from './build-args.ts'; import { dbSubcommands, parseDbArgs } from './db-args.ts'; import { domainSubcommands, parseDomainArgs } from './domain-args.ts'; import { envSubcommands, parseEnvArgs } from './env-args.ts'; @@ -137,6 +138,10 @@ if (helpPath) { runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (root, parsed) => import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(root, parsed)), ); +} else if (command === 'build') { + runSubcommandGroup('build', buildSubcommands, parseBuildArgs, (root, parsed) => + import('./build-cmd.ts').then(({ runBuildCommand }) => runBuildCommand(root, parsed)), + ); } else if (command === 'prepare') { const root = process.cwd(); let prepareArgs; @@ -204,6 +209,11 @@ if (helpPath) { hint: 'install | installations | connect | update | link', description: 'Connect a GitHub repository for automatic deploys', }, + { + name: 'build', + hint: 'logs', + description: 'Inspect remote builds', + }, { name: 'db', hint: 'status | reset | seed | execute | migrate | studio | export | rename-migrations', @@ -275,6 +285,10 @@ if (helpPath) { return runSubcommandGroup('github', githubSubcommands, parseGithubArgs, (r, p) => import('./github-cmd.ts').then(({ runGithubCommand }) => runGithubCommand(r, p)), ); + case 'build': + return runSubcommandGroup('build', buildSubcommands, parseBuildArgs, (r, p) => + import('./build-cmd.ts').then(({ runBuildCommand }) => runBuildCommand(r, p)), + ); case 'db': return runSubcommandGroup('db', dbSubcommands, parseDbArgs, (r, p) => import('./db.ts').then(({ runDbCommand }) => runDbCommand(r, p)), diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 1d5b53fd9..9f312efdb 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -157,6 +157,35 @@ export type LogEntry = { status: string; }; +export type BuildSummary = { + id: string; + status: string; + executor: 'container' | 'github_actions'; + commit_sha: string | null; + branch: string | null; + exit_code: number | null; + error: string | null; + logs_url: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; +}; + +export type BuildLogRow = { + seq: number; + line: string; + stream: 'stdout' | 'stderr'; + ts: number; +}; + +export type BuildLogsResponse = { + lines: Array; + lastSeq: number; + status: string; + terminal: boolean; + external: string | null; +}; + export type DomainResponse = { id: string; project_id: string; @@ -877,6 +906,40 @@ export class PlatformClient { return json.data; } + async listBuilds(projectId: string, limit = 20): Promise> { + const params = new URLSearchParams({ limit: String(limit) }); + const res = await fetch(`${this.baseUrl}/projects/${projectId}/builds?${params}`, { + headers: { + ...this.authHeaders, + }, + }); + + if (!res.ok) { + await throwPlatformApiError('Failed to list builds', res); + } + + const json = (await res.json()) as { builds: Array }; + return json.builds; + } + + async getBuildLogs(projectId: string, buildId: string, since = 0): Promise { + const params = new URLSearchParams({ since: String(since) }); + const res = await fetch( + `${this.baseUrl}/projects/${projectId}/builds/${buildId}/logs?${params}`, + { + headers: { + ...this.authHeaders, + }, + }, + ); + + if (!res.ok) { + await throwPlatformApiError('Failed to fetch build logs', res); + } + + return res.json(); + } + async getGithubInstallUrl(): Promise<{ url: string }> { const res = await fetch(`${this.baseUrl}/github/install-url`, { headers: { diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index fa5490141..203385417 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -267,6 +267,38 @@ const HELP_TREE: HelpTree = { }, }, }, + build: { + summary: 'Inspect remote builds.', + usage: ['void build '], + commands: [ + { command: 'logs [build]', description: 'Stream, tail, or download logs for a build' }, + ], + children: { + logs: { + summary: 'Stream, tail, or download logs for a build.', + usage: ['void build logs [build] [options]'], + options: [ + { command: '--follow, -f', description: 'Live-tail until the build finishes' }, + { command: '--output , -o', description: 'Write logs to a file instead of stdout' }, + { + command: '--project , -p', + description: 'Target project (else linked / VOID_PROJECT)', + }, + ], + examples: [ + { command: 'void build logs', description: "Print the latest build's logs" }, + { + command: 'void build logs -f', + description: 'Follow the latest build until it finishes', + }, + { + command: 'void build logs bld_123 -o build.log', + description: "Download a build's logs to a file", + }, + ], + }, + }, + }, db: { summary: 'Manage local and remote database state.', usage: ['void db '], @@ -486,6 +518,8 @@ export function printGlobalHelp(): void { '', ...globalCommandGroup('github'), '', + ...globalCommandGroup('build'), + '', categoryTitle('database'), ...globalCommandLines('db', { execute: 'execute ', diff --git a/packages/void/test/unit/build-args.test.ts b/packages/void/test/unit/build-args.test.ts new file mode 100644 index 000000000..4da9f03bf --- /dev/null +++ b/packages/void/test/unit/build-args.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parseBuildArgs } from '../../src/cli/build-args.ts'; + +describe('parseBuildArgs', () => { + it('parses bare logs', () => { + expect(parseBuildArgs(['logs'])).toEqual({ + subcommand: 'logs', + buildId: undefined, + follow: false, + output: undefined, + projectName: undefined, + }); + }); + + it('captures a positional build id', () => { + expect(parseBuildArgs(['logs', 'bld_1']).buildId).toBe('bld_1'); + }); + + it('parses -f and --follow', () => { + expect(parseBuildArgs(['logs', '-f']).follow).toBe(true); + expect(parseBuildArgs(['logs', '--follow']).follow).toBe(true); + }); + + it('parses -o/--output and -p/--project values', () => { + const a = parseBuildArgs(['logs', '-o', 'out.log', '-p', 'my-app']); + expect(a.output).toBe('out.log'); + expect(a.projectName).toBe('my-app'); + const b = parseBuildArgs(['logs', '--output', 'x.log', '--project', 'app2']); + expect(b.output).toBe('x.log'); + expect(b.projectName).toBe('app2'); + }); + + it('parses a full combination', () => { + expect(parseBuildArgs(['logs', 'bld_9', '-f', '-o', 'f.log', '-p', 'app'])).toEqual({ + subcommand: 'logs', + buildId: 'bld_9', + follow: true, + output: 'f.log', + projectName: 'app', + }); + }); + + it('throws when --output has no value', () => { + expect(() => parseBuildArgs(['logs', '-o'])).toThrow('--output requires a value'); + }); + + it('throws when --project has no value', () => { + expect(() => parseBuildArgs(['logs', '--project'])).toThrow('--project requires a value'); + }); + + it('throws on an unknown flag', () => { + expect(() => parseBuildArgs(['logs', '--nope'])).toThrow("Unexpected argument '--nope'"); + }); + + it('throws on a second positional', () => { + expect(() => parseBuildArgs(['logs', 'bld_1', 'bld_2'])).toThrow("Unexpected argument 'bld_2'"); + }); + + it('throws on an unknown subcommand', () => { + expect(() => parseBuildArgs(['bogus'])).toThrow('Usage: void build logs'); + }); +}); diff --git a/packages/void/test/unit/build-client.test.ts b/packages/void/test/unit/build-client.test.ts new file mode 100644 index 000000000..1dba3f793 --- /dev/null +++ b/packages/void/test/unit/build-client.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { PlatformClient } from '../../src/cli/client.ts'; + +const fetchMock = vi.fn(); +let originalFetch: typeof globalThis.fetch | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + fetchMock.mockReset(); +}); +afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } +}); + +const apiUrl = 'https://api.test'; +const token = 'auth-token-x'; +const projectId = 'proj_test'; + +function makeClient(): PlatformClient { + return new PlatformClient(token, { apiUrl }); +} +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('PlatformClient.listBuilds', () => { + it('GETs /builds with the limit param and unwraps { builds }', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ builds: [{ id: 'b1' }, { id: 'b2' }] })); + const result = await makeClient().listBuilds(projectId, 5); + const [url, init] = fetchMock.mock.calls[0]!; + const parsed = new URL(url as string); + expect(parsed.pathname).toBe('/projects/proj_test/builds'); + expect(parsed.searchParams.get('limit')).toBe('5'); + expect((init as RequestInit).headers).toMatchObject({ Authorization: 'Bearer auth-token-x' }); + expect(result.map((b) => b.id)).toEqual(['b1', 'b2']); + }); + + it('maps a non-ok response to PlatformApiError', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'forbidden' }, 403)); + await expect(makeClient().listBuilds(projectId)).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 403, + }); + }); +}); + +describe('PlatformClient.getBuildLogs', () => { + it('GETs /builds/:id/logs with since and returns the envelope', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + lines: [{ seq: 1, line: 'a', stream: 'stdout', ts: 1 }], + lastSeq: 1, + status: 'building', + terminal: false, + external: null, + }), + ); + const result = await makeClient().getBuildLogs(projectId, 'b1', 0); + const [url] = fetchMock.mock.calls[0]!; + const parsed = new URL(url as string); + expect(parsed.pathname).toBe('/projects/proj_test/builds/b1/logs'); + expect(parsed.searchParams.get('since')).toBe('0'); + expect(result.lastSeq).toBe(1); + expect(result.terminal).toBe(false); + expect(result.lines[0]!.line).toBe('a'); + }); + + it('maps a non-ok response to PlatformApiError', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'build not found' }, 404)); + await expect(makeClient().getBuildLogs(projectId, 'nope', 0)).rejects.toMatchObject({ + name: 'PlatformApiError', + status: 404, + }); + }); +}); diff --git a/packages/void/test/unit/build-logs-follow.test.ts b/packages/void/test/unit/build-logs-follow.test.ts new file mode 100644 index 000000000..9c39ad6b3 --- /dev/null +++ b/packages/void/test/unit/build-logs-follow.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { followBuildLogs, isFatalFollowError } from '../../src/cli/build-cmd.ts'; +import { PlatformApiError, type BuildLogsResponse } from '../../src/cli/client.ts'; + +function page(partial: Partial): BuildLogsResponse { + return { lines: [], lastSeq: 0, status: 'building', terminal: false, external: null, ...partial }; +} + +function apiError(status: number, body = '{}'): PlatformApiError { + return new PlatformApiError('build logs', status, body); +} + +describe('isFatalFollowError', () => { + it('treats every 4xx except 429 as fatal (including 426 cli_outdated)', () => { + // 426 is the reviewer's case: a too-old CLI must exit, not retry forever. + for (const status of [400, 401, 403, 404, 409, 410, 422, 426]) { + expect(isFatalFollowError(apiError(status))).toBe(true); + } + }); + + it('treats 429 (rate limit) as transient — the tail must survive it', () => { + expect(isFatalFollowError(apiError(429))).toBe(false); + }); + + it('treats 5xx and non-PlatformApiError failures as transient', () => { + expect(isFatalFollowError(apiError(500))).toBe(false); + expect(isFatalFollowError(apiError(502))).toBe(false); + expect(isFatalFollowError(apiError(503))).toBe(false); + expect(isFatalFollowError(new Error('network blip'))).toBe(false); + expect(isFatalFollowError('nope')).toBe(false); + }); +}); + +describe('followBuildLogs', () => { + it('polls from the cursor, writes new lines in order, and stops when terminal', async () => { + const pages: Array = [ + page({ + lines: [{ seq: 2, line: 'b', stream: 'stdout', ts: 2 }], + lastSeq: 2, + status: 'building', + terminal: false, + }), + page({ + lines: [{ seq: 3, line: 'c', stream: 'stderr', ts: 3 }], + lastSeq: 3, + status: 'complete', + terminal: true, + }), + ]; + const sinceCalls: Array = []; + const written: Array = []; + let i = 0; + + const result = await followBuildLogs({ + fetchPage: (since) => { + sinceCalls.push(since); + return Promise.resolve(pages[i++]!); + }, + write: (line) => written.push(line), + sleep: () => Promise.resolve(), + since: 1, + status: 'building', + pollMs: 0, + }); + + expect(sinceCalls).toEqual([1, 2]); // advances cursor to each page's lastSeq + expect(written).toEqual(['b', 'c']); + expect(result).toEqual({ status: 'complete', terminal: true }); + }); + + it('survives a transient fetch failure: onRetry once, no cursor advance, then continues', async () => { + const sinceCalls: Array = []; + const written: Array = []; + const retries: Array = []; + let call = 0; + + const result = await followBuildLogs({ + fetchPage: (since) => { + sinceCalls.push(since); + call += 1; + if (call === 1) { + // Transient blip on the first poll (e.g. a 502/timeout). + return Promise.reject(new Error('boom')); + } + return Promise.resolve( + page({ + lines: [{ seq: 3, line: 'c', stream: 'stdout', ts: 3 }], + lastSeq: 3, + status: 'complete', + terminal: true, + }), + ); + }, + write: (line) => written.push(line), + sleep: () => Promise.resolve(), + since: 2, + status: 'building', + pollMs: 0, + isFatal: () => false, + onRetry: (err) => retries.push(err), + }); + + // The failed attempt did NOT advance the cursor: the retry re-requests since=2. + expect(sinceCalls).toEqual([2, 2]); + expect(retries).toHaveLength(1); // onRetry called exactly once + expect((retries[0] as Error).message).toBe('boom'); + // Nothing written on the failed attempt; terminal page's lines written after. + expect(written).toEqual(['c']); + expect(result).toEqual({ status: 'complete', terminal: true }); + }); + + it('rethrows a fatal fetch failure (isFatal → true)', async () => { + await expect( + followBuildLogs({ + fetchPage: () => Promise.reject(new Error('unauthorized')), + write: () => {}, + sleep: () => Promise.resolve(), + since: 1, + status: 'building', + pollMs: 0, + isFatal: () => true, + }), + ).rejects.toThrow('unauthorized'); + }); + + it('under the real predicate, a 426 cli_outdated rethrows (surfacing the upgrade message)', async () => { + await expect( + followBuildLogs({ + fetchPage: () => + Promise.reject( + apiError(426, '{"error":"cli_outdated","message":"Upgrade: npm i -g void@latest"}'), + ), + write: () => {}, + sleep: () => Promise.resolve(), + since: 1, + status: 'building', + pollMs: 0, + isFatal: isFatalFollowError, + }), + ).rejects.toThrow('Upgrade: npm i -g void@latest'); + }); + + it('under the real predicate, a 429 is retried (not fatal)', async () => { + let call = 0; + const retries: Array = []; + const result = await followBuildLogs({ + fetchPage: () => { + call += 1; + if (call === 1) { + return Promise.reject(apiError(429, '{"error":"rate_limited"}')); + } + return Promise.resolve(page({ lastSeq: 5, status: 'complete', terminal: true })); + }, + write: () => {}, + sleep: () => Promise.resolve(), + since: 4, + status: 'building', + pollMs: 0, + isFatal: isFatalFollowError, + onRetry: (err) => retries.push(err), + }); + + expect(retries).toHaveLength(1); // the 429 was retried, not thrown + expect(result).toEqual({ status: 'complete', terminal: true }); + }); +});