diff --git a/packages/cli/src/ai-context/references/investigate-checks.md b/packages/cli/src/ai-context/references/investigate-checks.md index 01bdfbe23..8ac59e969 100644 --- a/packages/cli/src/ai-context/references/investigate-checks.md +++ b/packages/cli/src/ai-context/references/investigate-checks.md @@ -32,6 +32,7 @@ Shows check configuration, recent results, error groups, and analytics stats. Flags: - `-r, --result ` — drill into a specific result (see below) +- `--include-attempts` — with `--result`, also list the individual retry attempts for that result (see below) - `-e, --error-group ` — show full details for a specific error group - `--results-limit ` — number of recent results to show (default 10) - `--results-cursor ` — paginate results using the cursor from previous output @@ -49,6 +50,18 @@ Flags: npx checkly checks get --result ``` +### View retry attempts for a result + +When a check has retries enabled, a single run produces one `FINAL` result plus +one `ATTEMPT` result for each earlier failed try (all sharing a `sequenceId`). +By default only the `FINAL` result is shown. Add `--include-attempts` to list the +full retry sequence — attempt number, status, location, duration, and a short +error summary: + +```bash +npx checkly checks get --result --include-attempts +``` + ### View an error group ```bash diff --git a/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts b/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts new file mode 100644 index 000000000..f36280715 --- /dev/null +++ b/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CheckResult } from '../../rest/check-results.js' + +vi.mock('../../rest/api.js', () => ({ + checks: { get: vi.fn() }, + checkStatuses: { get: vi.fn() }, + checkResults: { get: vi.fn(), getAll: vi.fn() }, + errorGroups: { getByCheckId: vi.fn(), get: vi.fn() }, + analytics: { get: vi.fn() }, +})) + +import * as api from '../../rest/api.js' +import ChecksGet from '../checks/get.js' +import { stripAnsi } from '../../formatters/render.js' + +function makeResult (overrides: Partial): CheckResult { + return { + id: 'r', + checkId: 'check-1', + name: 'Login flow', + hasFailures: false, + hasErrors: false, + isDegraded: false, + overMaxResponseTime: false, + runLocation: 'eu-west-1', + startedAt: '2026-05-20T08:00:00.000Z', + stoppedAt: '2026-05-20T08:00:04.000Z', + created_at: '2026-05-20T08:00:04.000Z', + responseTime: 4000, + checkRunId: 1, + attempts: 1, + resultType: 'FINAL', + sequenceId: 'seq-1', + ...overrides, + } +} + +function createCommandContext (parsed: unknown) { + const logged: string[] = [] + return Object.assign(Object.create(ChecksGet.prototype), { + parse: vi.fn().mockResolvedValue(parsed), + log: vi.fn((msg?: string) => { + if (msg) logged.push(msg) + }), + style: { outputFormat: undefined, longError: vi.fn() }, + logged, + }) +} + +// A sequence of two failed attempts followed by a passing final run. +const attempt1 = makeResult({ id: 'a1', resultType: 'ATTEMPT', hasFailures: true, attempts: 1, startedAt: '2026-05-20T08:00:00.000Z' }) +const attempt2 = makeResult({ id: 'a2', resultType: 'ATTEMPT', hasFailures: true, attempts: 2, startedAt: '2026-05-20T08:00:30.000Z' }) +const finalRun = makeResult({ id: 'final', resultType: 'FINAL', attempts: 3, startedAt: '2026-05-20T08:01:00.000Z' }) + +describe('checks get --include-attempts', () => { + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + // List returns the whole sequence newest-first (as the API does). + vi.mocked(api.checkResults.getAll).mockResolvedValue({ + data: { entries: [finalRun, attempt2, attempt1], nextId: null, length: 3 }, + } as any) + }) + + it('renders the full sequence even when drilling into the first attempt', async () => { + vi.mocked(api.checkResults.get).mockResolvedValue({ data: attempt1 } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'a1', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).toContain('ATTEMPTS') + // every run in the sequence is present, not just the one we drilled into + expect(out).toContain('a1') + expect(out).toContain('a2') + expect(out).toContain('final') + expect(out).toContain('(FINAL)') + // viewing an attempt: jump to the final, plus a generic View attempt hint + expect(out).toContain('Show final result') + expect(out).toContain('--result final') + expect(out).toContain('View attempt') + expect(out).toContain('--result ') + expect(out.match(/View attempt/g) ?? []).toHaveLength(1) + }) + + it('maps include-attempts to an ALL result query around the requested result', async () => { + vi.mocked(api.checkResults.get).mockResolvedValue({ data: attempt1 } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'a1', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const anchorSeconds = Math.floor(new Date(attempt1.startedAt).getTime() / 1000) + expect(api.checkResults.getAll).toHaveBeenCalledWith('check-1', expect.objectContaining({ + resultType: 'ALL', + from: anchorSeconds - 30 * 60, + to: anchorSeconds + 30 * 60, + limit: 100, + })) + }) + + it('continues fetching attempt pages until the sequence window is exhausted', async () => { + vi.mocked(api.checkResults.get).mockResolvedValue({ data: finalRun } as any) + vi.mocked(api.checkResults.getAll) + .mockResolvedValueOnce({ + data: { entries: [finalRun], nextId: 'cursor-2', length: 1 }, + } as any) + .mockResolvedValueOnce({ + data: { entries: [attempt2, attempt1], nextId: null, length: 2 }, + } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'final', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + expect(api.checkResults.getAll).toHaveBeenCalledTimes(2) + expect(api.checkResults.getAll).toHaveBeenNthCalledWith(2, 'check-1', expect.objectContaining({ + resultType: 'ALL', + nextId: 'cursor-2', + })) + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).toContain('a1') + expect(out).toContain('a2') + expect(out).toContain('final') + }) + + it('wraps result and attempts in a stable JSON envelope', async () => { + vi.mocked(api.checkResults.get).mockResolvedValue({ data: finalRun } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'final', 'include-attempts': true, 'output': 'json' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const payload = JSON.parse(ctx.logged[0]) + expect(payload.result.id).toBe('final') + expect(payload.attempts.map((r: CheckResult) => r.id)).toEqual(['a1', 'a2', 'final']) + }) + + it('reports an error instead of pretending there are no retries when fetching attempts fails', async () => { + const error = new Error('attempt list failed') + vi.mocked(api.checkResults.get).mockResolvedValue({ data: finalRun } as any) + vi.mocked(api.checkResults.getAll).mockRejectedValue(error) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'final', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + expect(process.exitCode).toBe(1) + expect(ctx.logged).toEqual([]) + expect(ctx.style.longError).toHaveBeenCalledWith('Failed to get check details.', error) + }) + + it('viewing an attempt with no other attempts shows only the final hint', async () => { + const onlyAttempt = makeResult({ id: 'a1', resultType: 'ATTEMPT', hasFailures: true, attempts: 1 }) + const onlyFinal = makeResult({ id: 'final', resultType: 'FINAL', attempts: 2 }) + vi.mocked(api.checkResults.get).mockResolvedValue({ data: onlyAttempt } as any) + vi.mocked(api.checkResults.getAll).mockResolvedValue({ + data: { entries: [onlyFinal, onlyAttempt], nextId: null, length: 2 }, + } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'a1', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).toContain('Show final result') + expect(out).toContain('--result final') + expect(out).not.toContain('View attempt') + }) + + it('viewing the final links to the lone attempt directly', async () => { + const oneAttempt = makeResult({ id: 'a1', resultType: 'ATTEMPT', hasFailures: true, attempts: 1 }) + const theFinal = makeResult({ id: 'final', resultType: 'FINAL', attempts: 2 }) + vi.mocked(api.checkResults.get).mockResolvedValue({ data: theFinal } as any) + vi.mocked(api.checkResults.getAll).mockResolvedValue({ + data: { entries: [theFinal, oneAttempt], nextId: null, length: 2 }, + } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'final', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).not.toContain('Show final result') + expect(out).toContain('View attempt') + expect(out).toContain('--result a1') + }) + + it('uses a generic placeholder for View attempt when 2+ other attempts exist', async () => { + const attempt3 = makeResult({ id: 'a3', resultType: 'ATTEMPT', hasFailures: true, attempts: 3, startedAt: '2026-05-20T08:00:45.000Z' }) + const finalRun4 = makeResult({ id: 'final', resultType: 'FINAL', attempts: 4, startedAt: '2026-05-20T08:01:00.000Z' }) + vi.mocked(api.checkResults.get).mockResolvedValue({ data: attempt1 } as any) + vi.mocked(api.checkResults.getAll).mockResolvedValue({ + data: { entries: [finalRun4, attempt3, attempt2, attempt1], nextId: null, length: 4 }, + } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'a1', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + // viewing a1, two other attempts (a2, a3) remain → placeholder, single hint + expect(out).toContain('Show final result') + expect(out).toContain('--result ') + expect(out.match(/View attempt/g) ?? []).toHaveLength(1) + }) + + it('flags an attempt result and suggests the full sequence when viewed without --include-attempts', async () => { + vi.mocked(api.checkResults.get).mockResolvedValue({ data: attempt1 } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { result: 'a1', output: 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).toContain('intermediate retry attempt') + expect(out).toContain('Show attempts') + expect(out).toContain('--include-attempts') + // no list call should happen on the plain attempt view + expect(api.checkResults.getAll).not.toHaveBeenCalled() + }) + + it('notes the retry count on a retried final viewed without --include-attempts', async () => { + const retriedFinal = makeResult({ id: 'final', resultType: 'FINAL', attempts: 3 }) + vi.mocked(api.checkResults.get).mockResolvedValue({ data: retriedFinal } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { result: 'final', output: 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).toContain('this run was retried 2 times before this final result') + expect(out).toContain('Show attempts') + expect(api.checkResults.getAll).not.toHaveBeenCalled() + }) + + it('shows no retry note on a single-run final', async () => { + const single = makeResult({ id: 'only', resultType: 'FINAL', attempts: 1 }) + vi.mocked(api.checkResults.get).mockResolvedValue({ data: single } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { result: 'only', output: 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).not.toContain('was retried') + expect(out).not.toContain('Show attempts') + }) + + it('says "ran once" when the sequence has no attempt rows', async () => { + const single = makeResult({ id: 'only', resultType: 'FINAL', attempts: 1 }) + vi.mocked(api.checkResults.get).mockResolvedValue({ data: single } as any) + vi.mocked(api.checkResults.getAll).mockResolvedValue({ + data: { entries: [single], nextId: null, length: 1 }, + } as any) + const ctx = createCommandContext({ + args: { id: 'check-1' }, + flags: { 'result': 'only', 'include-attempts': true, 'output': 'detail' }, + }) + + await ChecksGet.prototype.run.call(ctx as any) + + const out = stripAnsi(ctx.logged.join('\n')) + expect(out).toContain('Ran once, no retry attempts.') + expect(out).not.toContain('ATTEMPTS') + }) +}) diff --git a/packages/cli/src/commands/__tests__/checks-get-flags.spec.ts b/packages/cli/src/commands/__tests__/checks-get-flags.spec.ts new file mode 100644 index 000000000..68edf05b6 --- /dev/null +++ b/packages/cli/src/commands/__tests__/checks-get-flags.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { Parser } from '@oclif/core' +import ChecksGet from '../checks/get.js' + +// Exercises oclif's real flag-relationship validation (dependsOn/exclusive), +// which the API-mocking command specs bypass by stubbing `parse`. +function parseChecksGet (argv: string[]) { + return Parser.parse(argv, { + flags: ChecksGet.flags as any, + args: ChecksGet.args as any, + strict: ChecksGet.strict, + }) +} + +describe('checks get flag parsing', () => { + it('allows `checks get ` with no flags', async () => { + // Regression: `--include-attempts` having a `default` made oclif treat it as + // always-provided, so its `dependsOn: result` wrongly required --result on + // every invocation ("All of the following must be provided ...: --result"). + const { args, flags } = await parseChecksGet(['1d46f688-28ab-4f7f-8572-fe7d207d0594']) + expect(args.id).toBe('1d46f688-28ab-4f7f-8572-fe7d207d0594') + expect(flags['include-attempts']).toBeFalsy() + }) + + it('requires --result when --include-attempts is passed', async () => { + await expect(parseChecksGet(['some-id', '--include-attempts'])).rejects.toThrow(/--result/) + }) + + it('accepts --include-attempts together with --result', async () => { + const { flags } = await parseChecksGet(['some-id', '--result', 'r-1', '--include-attempts']) + expect(flags['include-attempts']).toBe(true) + expect(flags.result).toBe('r-1') + }) +}) diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index b952b0848..ff2d71c6e 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -16,7 +16,12 @@ import { formatResults, formatErrorGroups, } from '../../formatters/checks.js' -import { formatResultDetailWithNavigation } from '../../formatters/check-result-detail.js' +import { + formatResultDetailWithNavigation, + formatAttemptsSection, + groupAttemptsBySequence, +} from '../../formatters/check-result-detail.js' +import type { CheckResult } from '../../rest/check-results.js' import { formatRcaDetail, formatRcaHint, transformErrorGroupForJson } from '../../formatters/rca.js' import { quickRangeValues, type QuickRange, type GroupBy } from '../../rest/analytics.js' import { formatAnalyticsSection } from '../../formatters/analytics.js' @@ -39,6 +44,10 @@ export default class ChecksGet extends AuthCommand { char: 'r', description: 'Show details for a specific result ID.', }), + 'include-attempts': Flags.boolean({ + description: 'Show individual retry attempts for the result (use with --result).', + dependsOn: ['result'], + }), 'error-group': Flags.string({ char: 'e', description: 'Show full details for a specific error group ID.', @@ -76,7 +85,7 @@ export default class ChecksGet extends AuthCommand { try { // Result detail mode: drill into a specific result if (flags.result) { - return await this.showResultDetail(args.id, flags.result, flags.output ?? 'detail') + return await this.showResultDetail(args.id, flags.result, flags.output ?? 'detail', flags['include-attempts']) } // Error group detail mode @@ -283,19 +292,140 @@ export default class ChecksGet extends AuthCommand { this.log(output.join('\n')) } - private async showResultDetail (checkId: string, resultId: string, outputFormat: string): Promise { + private async showResultDetail ( + checkId: string, + resultId: string, + outputFormat: string, + includeAttempts: boolean, + ): Promise { const { data: result } = await api.checkResults.get(checkId, resultId) + const attempts = includeAttempts && result.sequenceId + ? await this.fetchAttempts(checkId, result) + : [] + if (outputFormat === 'json') { - this.log(JSON.stringify(result, null, 2)) + this.log(JSON.stringify(includeAttempts ? { result, attempts } : result, null, 2)) return } const fmt: OutputFormat = outputFormat === 'md' ? 'md' : 'terminal' - this.log(formatResultDetailWithNavigation(result, fmt, [ - { label: 'Back to check', command: `checkly checks get ${checkId}` }, - { label: 'Back to list', command: 'checkly checks list' }, - ])) + const sections: string[] = [] + if (includeAttempts) { + sections.push(this.renderAttemptsSection(attempts, resultId, fmt)) + } else { + const note = this.retryContextNote(result) + if (note) { + sections.push(fmt === 'md' ? `_${note}_` : chalk.dim(note)) + } + } + + const hints: CommandHint[] = [] + // Suggest the full sequence for an attempt (always part of a retried run) or a + // final that was retried (`attempts` is 1-based, so > 1 means it retried). + if (!includeAttempts && (result.resultType === 'ATTEMPT' || (result.attempts ?? 1) > 1)) { + hints.push({ + label: 'Show attempts', + command: `checkly checks get ${checkId} --result ${resultId} --include-attempts`, + }) + } + if (includeAttempts) { + const otherAttempts = attempts.filter(a => a.resultType === 'ATTEMPT' && a.id !== resultId) + if (result.resultType === 'ATTEMPT') { + // Viewing an attempt: jump to the final, and (if any) to the other + // attempts via a generic placeholder (their IDs are listed in the table). + const final = attempts.find(a => a.resultType === 'FINAL') + if (final) { + hints.push({ label: 'Show final result', command: `checkly checks get ${checkId} --result ${final.id}` }) + } + if (otherAttempts.length > 0) { + hints.push({ label: 'View attempt', command: `checkly checks get ${checkId} --result ` }) + } + } else if (otherAttempts.length > 0) { + // Viewing the final: link to the lone attempt directly, else a placeholder. + const target = otherAttempts.length === 1 ? otherAttempts[0].id : '' + hints.push({ label: 'View attempt', command: `checkly checks get ${checkId} --result ${target}` }) + } + } + hints.push({ label: 'Back to check', command: `checkly checks get ${checkId}` }) + hints.push({ label: 'Back to list', command: 'checkly checks list' }) + + this.log(formatResultDetailWithNavigation(result, fmt, hints, sections)) + } + + // Plain (non --include-attempts) note giving retry context: that an attempt + // isn't the final result, or that a final masks earlier failed attempts. + private retryContextNote (result: CheckResult): string | undefined { + if (result.resultType === 'ATTEMPT') { + return 'Note: this is an intermediate retry attempt, not the run\'s final result. ' + + 'To view the full sequence including the final result, retrieve all attempts.' + } + const retries = (result.attempts ?? 1) - 1 + if (retries > 0) { + return `Note: this run was retried ${retries} time${retries === 1 ? '' : 's'} before this final result. ` + + 'Retrieve all attempts to inspect them.' + } + return undefined + } + + private renderAttemptsSection (attempts: CheckResult[], resultId: string, fmt: OutputFormat): string { + // Decide off the actual ATTEMPT rows, not the `attempts` counter (which is + // 1-based, so it can't reliably tell "ran once" from "retried"). + if (attempts.some(a => a.resultType === 'ATTEMPT')) { + return formatAttemptsSection(attempts, fmt, { + finalId: attempts.find(a => a.resultType === 'FINAL')?.id, + requestedId: resultId, + }) + } + + const msg = 'Ran once, no retry attempts.' + return fmt === 'md' ? `_${msg}_` : chalk.dim(msg) + } + + // The longest a retry sequence can span end to end: the backend caps total + // retry time at FALLBACK_MAX_DURATION_SECONDS (10 min) and a single backoff at + // MAX_BACKOFF_SECONDS (15 min). Querying ±this around any one member is + // therefore guaranteed to cover the whole sequence — it can't clip it. + private static readonly MAX_SEQUENCE_SPAN_SECONDS = 30 * 60 + + // Runaway guard only: with limit 100 over the span window this is never reached + // for real checks (it would take >25 results/sec), so it never truncates a + // sequence — it just bounds a pathological loop. + private static readonly MAX_RESULT_PAGES = 15 + + // Collects every result in the drilled-into result's retry sequence and groups + // them locally (the list endpoint has no server-side sequenceId filter). + // + // We query a span-sized window centred on the result and page through it + // (newest-first). The window is centred rather than one-sided because the + // result may be the final run or any earlier attempt, and anchoring on its + // timestamp keeps this O(1) page even for old results (no scanning back from + // now). See MAX_SEQUENCE_SPAN_SECONDS for why the window can't clip a sequence. + private async fetchAttempts (checkId: string, result: CheckResult): Promise { + if (!result.sequenceId) { + return [] + } + + const anchorSeconds = Math.floor(new Date(result.startedAt).getTime() / 1000) + const window = { + resultType: 'ALL' as const, + from: anchorSeconds - ChecksGet.MAX_SEQUENCE_SPAN_SECONDS, + to: anchorSeconds + ChecksGet.MAX_SEQUENCE_SPAN_SECONDS, + limit: 100, + } + + const collected: CheckResult[] = [] + let cursor: string | undefined + for (let page = 0; page < ChecksGet.MAX_RESULT_PAGES; page++) { + const resp = await api.checkResults.getAll(checkId, { ...window, nextId: cursor }) + collected.push(...resp.data.entries) + cursor = resp.data.nextId ?? undefined + if (!cursor) { + break + } + } + + return groupAttemptsBySequence(collected, result.sequenceId) } } diff --git a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts index 9fbe82237..6ba9f4196 100644 --- a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts +++ b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { stripAnsi } from '../render.js' -import { formatResultDetail } from '../check-result-detail.js' +import { + formatResultDetail, + groupAttemptsBySequence, + extractResultErrorSummary, + formatAttemptsSection, +} from '../check-result-detail.js' +import type { CheckResult } from '../../rest/check-results.js' import { apiCheckResult, apiCheckResultWithError, @@ -201,6 +207,124 @@ describe('formatResultDetail', () => { }) }) + describe('Retry attempts', () => { + const baseRow = (overrides: Partial): CheckResult => ({ + id: 'r', + checkId: 'check-1', + name: 'Login flow', + hasFailures: false, + hasErrors: false, + isDegraded: null, + overMaxResponseTime: null, + runLocation: 'eu-west-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:04.000Z', + created_at: '2025-06-15T12:00:04.000Z', + responseTime: 4000, + checkRunId: 1, + attempts: 0, + resultType: 'FINAL', + sequenceId: 'seq-1', + ...overrides, + }) + + describe('groupAttemptsBySequence', () => { + it('keeps only matching sequenceId, ordered oldest-first', () => { + const rows = [ + baseRow({ id: 'final', resultType: 'FINAL', startedAt: '2025-06-15T12:00:08.000Z' }), + baseRow({ id: 'attempt-1', resultType: 'ATTEMPT', startedAt: '2025-06-15T12:00:00.000Z' }), + baseRow({ id: 'other-seq', sequenceId: 'seq-2', startedAt: '2025-06-15T12:00:02.000Z' }), + baseRow({ id: 'attempt-2', resultType: 'ATTEMPT', startedAt: '2025-06-15T12:00:04.000Z' }), + ] + const grouped = groupAttemptsBySequence(rows, 'seq-1') + expect(grouped.map(r => r.id)).toEqual(['attempt-1', 'attempt-2', 'final']) + }) + + it('returns an empty array when no rows match', () => { + expect(groupAttemptsBySequence([baseRow({ sequenceId: 'seq-9' })], 'seq-1')).toEqual([]) + }) + }) + + describe('extractResultErrorSummary', () => { + it('prefers an API requestError', () => { + const row = baseRow({ apiCheckResult: { requestError: 'connect ECONNREFUSED' } as any }) + expect(extractResultErrorSummary(row)).toBe('connect ECONNREFUSED') + }) + + it('falls back to the first browser error', () => { + const row = baseRow({ browserCheckResult: { errors: ['Timeout 30000ms exceeded'] } as any }) + expect(extractResultErrorSummary(row)).toBe('Timeout 30000ms exceeded') + }) + + it('reads agentic error messages', () => { + const row = baseRow({ + agenticCheckResult: { errors: [{ error: { message: '503 Service Unavailable' } }] } as any, + }) + expect(extractResultErrorSummary(row)).toBe('503 Service Unavailable') + }) + + it('falls back to status flags when no message is present', () => { + expect(extractResultErrorSummary(baseRow({ hasFailures: true }))).toBe('failed') + expect(extractResultErrorSummary(baseRow({ hasErrors: true }))).toBe('error') + }) + + it('returns an empty string for a clean result', () => { + expect(extractResultErrorSummary(baseRow({}))).toBe('') + }) + }) + + describe('formatAttemptsSection', () => { + const sequence = [ + baseRow({ + id: 'attempt-1', + resultType: 'ATTEMPT', + hasFailures: true, + responseTime: 3900, + startedAt: '2025-06-15T12:00:00.000Z', + browserCheckResult: { errors: ['Timeout 30000ms exceeded'] } as any, + }), + baseRow({ + id: 'attempt-2', + resultType: 'ATTEMPT', + hasFailures: true, + responseTime: 4000, + startedAt: '2025-06-15T12:00:05.000Z', + browserCheckResult: { errors: ['Timeout 30000ms exceeded'] } as any, + }), + baseRow({ + id: 'final', + resultType: 'FINAL', + responseTime: 4200, + startedAt: '2025-06-15T12:00:10.000Z', + }), + ] + + it('renders a terminal table with run numbers, statuses, and a final marker', () => { + const out = stripAnsi(formatAttemptsSection(sequence, 'terminal', { finalId: 'final' })) + expect(out).toContain('ATTEMPTS') + expect(out).toContain('1') + expect(out).toContain('2') + expect(out).toContain('3 (FINAL)') + expect(out).toContain('failing') + expect(out).toContain('passing') + expect(out).toContain('Timeout 30000ms exceeded') + expect(out).toContain('eu-west-1') + }) + + it('renders a markdown table with a (final) marker', () => { + const out = formatAttemptsSection(sequence, 'md', { finalId: 'final' }) + expect(out).toContain('## Attempts') + expect(out).toContain('3 (FINAL)') + expect(out).toContain('Timeout 30000ms exceeded') + expect(out).toContain('—') + }) + + it('returns an empty string for no attempts', () => { + expect(formatAttemptsSection([], 'terminal')).toBe('') + }) + }) + }) + describe('Minimal result (no sub-result)', () => { it('renders only top-level fields in terminal', () => { const result = stripAnsi(formatResultDetail(minimalCheckResult, 'terminal')) diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index 60030f06f..6d3fa2175 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -14,12 +14,16 @@ import { type OutputFormat, type DetailField, type CommandHint, + type ColumnDef, formatMs, formatDate, resolveResultStatus, heading, + escapeMdCell, renderDetailFields, renderCommandHints, + renderAdaptiveTable, + truncateSingleLine, } from './render.js' // --- Helpers --- @@ -32,9 +36,17 @@ export function formatResultDetailWithNavigation ( result: CheckResult, format: OutputFormat, hints: CommandHint[], + extraSections: string[] = [], ): string { const output = [formatResultDetail(result, format)] + for (const section of extraSections) { + if (section) { + output.push('') + output.push(section) + } + } + if (hints.length > 0) { output.push('') output.push(renderCommandHints(hints)) @@ -43,6 +55,119 @@ export function formatResultDetailWithNavigation ( return output.join('\n') } +// --- Retry attempts --- + +// Picks the runs sharing a sequenceId out of a resultType=ALL page (there's no +// server-side sequenceId filter), oldest-first so the index is the run number. +export function groupAttemptsBySequence (results: CheckResult[], sequenceId: string): CheckResult[] { + return results + .filter(r => r.sequenceId === sequenceId) + .sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime()) +} + +export interface AttemptsContext { + finalId?: string // marked "final" + requestedId?: string // the --result row, marked as current +} + +interface AttemptRow { + result: CheckResult + runNumber: number + isFinal: boolean + isRequested: boolean +} + +// Best-effort short error summary; list rows aren't hydrated with logs/assets, +// so fall back to the status flags. Empty string means "no error detail". +export function extractResultErrorSummary (result: CheckResult): string { + const raw = firstErrorMessage(result) + if (raw) return raw + if (result.hasErrors) return 'error' + if (result.hasFailures) return 'failed' + return '' +} + +function firstErrorMessage (result: CheckResult): string { + const api = result.apiCheckResult + if (api?.requestError) return api.requestError + const browserErr = result.browserCheckResult?.errors?.find(Boolean) + if (browserErr) return formatErrorEntry(browserErr) + const multiStepErr = result.multiStepCheckResult?.errors?.find(Boolean) + if (multiStepErr) return formatErrorEntry(multiStepErr) + const agenticErr = result.agenticCheckResult?.errors + ?.map(e => e?.error?.message ?? '') + .find(m => m.length > 0) + if (agenticErr) return agenticErr + return '' +} + +// Renders the retry table for a sequence; `attempts` must be oldest-first. +export function formatAttemptsSection ( + attempts: CheckResult[], + format: OutputFormat, + context: AttemptsContext = {}, +): string { + if (attempts.length === 0) return '' + + const rows: AttemptRow[] = attempts.map((result, i) => ({ + result, + runNumber: i + 1, + isFinal: result.resultType === 'FINAL' || result.id === context.finalId, + isRequested: result.id === context.requestedId, + })) + + const columns = buildAttemptColumns(format) + const table = renderAdaptiveTable(columns, rows, format) + + return format === 'md' + ? '## Attempts\n\n' + table + : chalk.bold('ATTEMPTS') + '\n' + table +} + +function buildAttemptColumns (format: OutputFormat): ColumnDef[] { + if (format === 'md') { + return [ + { header: '#', value: row => row.isFinal ? `${row.runNumber} (FINAL)` : String(row.runNumber) }, + { header: 'Status', value: (row, fmt) => resolveResultStatus(row.result, fmt) }, + { header: 'Location', value: row => row.result.runLocation }, + { header: 'Duration', value: row => formatMs(row.result.responseTime) }, + { header: 'Error', value: row => mdErrorCell(row.result) }, + { header: 'Result ID', value: row => row.result.id }, + ] + } + + return [ + { + header: '#', + width: 14, + value: row => { + const marker = row.isFinal ? chalk.dim(' (FINAL)') : '' + const current = row.isRequested ? chalk.cyan(' ‹') : '' + return String(row.runNumber) + marker + current + }, + }, + { header: 'Status', width: 10, value: (row, fmt) => resolveResultStatus(row.result, fmt) }, + { header: 'Location', minWidth: 8, maxWidth: 16, value: row => row.result.runLocation }, + { header: 'Duration', width: 10, value: row => formatMs(row.result.responseTime) }, + { + header: 'Error', + minWidth: 12, + maxWidth: 50, + value: row => { + const msg = extractResultErrorSummary(row.result) + return msg ? chalk.red(truncateSingleLine(msg, 50)) : chalk.dim('—') + }, + }, + { header: 'Result ID', minWidth: 12, maxWidth: 38, value: row => chalk.dim(row.result.id) }, + ] +} + +function mdErrorCell (result: CheckResult): string { + const msg = extractResultErrorSummary(result) + if (!msg) return '—' + return escapeMdCell(truncateSingleLine(msg, 80)) +} + // --- Top-level result detail fields --- export const resultDetailFields: DetailField[] = [ @@ -551,12 +676,6 @@ function wrapText (text: string, indent: string, width: number): string[] { return lines } -function truncateSingleLine (text: string, max: number): string { - const singleLine = text.replace(/\s+/g, ' ').trim() - if (singleLine.length <= max) return singleLine - return singleLine.slice(0, max - 3) + '...' -} - function formatBody (body: string, indent: string): string { let text: string try { diff --git a/packages/cli/src/formatters/render.ts b/packages/cli/src/formatters/render.ts index 323a86f21..6c0414118 100644 --- a/packages/cli/src/formatters/render.ts +++ b/packages/cli/src/formatters/render.ts @@ -108,6 +108,12 @@ export function truncateError (msg: string, maxLen: number): string { return clean.substring(0, maxLen - 1) + '…' } +export function truncateSingleLine (text: string, max: number): string { + const singleLine = text.replace(/\s+/g, ' ').trim() + if (singleLine.length <= max) return singleLine + return singleLine.slice(0, max - 3) + '...' +} + // --- Typed field/column definitions --- export interface DetailField { diff --git a/packages/cli/src/rest/check-results.ts b/packages/cli/src/rest/check-results.ts index df69d7bf9..3074a4ed4 100644 --- a/packages/cli/src/rest/check-results.ts +++ b/packages/cli/src/rest/check-results.ts @@ -17,6 +17,7 @@ export interface CheckResult { attempts: number resultType: 'FINAL' | 'ATTEMPT' sequenceId?: string | null + errorGroupIds?: string[] | null apiCheckResult?: ApiCheckResult | null browserCheckResult?: BrowserCheckResult | null multiStepCheckResult?: MultiStepCheckResult | null @@ -179,7 +180,7 @@ export interface ListCheckResultsParams { from?: number to?: number hasFailures?: boolean - resultType?: 'FINAL' | 'ATTEMPT' + resultType?: 'FINAL' | 'ATTEMPT' | 'ALL' } class CheckResults {