From 7032512412eb13d19c39bb0aa51d817e24118f5c Mon Sep 17 00:00:00 2001 From: miliberlin Date: Wed, 10 Jun 2026 10:22:09 +0200 Subject: [PATCH 1/9] feat: show per-attempt retry detail in checks get [AI-340] --- .../references/investigate-checks.md | 13 ++ packages/cli/src/commands/checks/get.ts | 109 +++++++++++++- .../__tests__/check-result-detail.spec.ts | 126 +++++++++++++++- .../cli/src/formatters/check-result-detail.ts | 141 ++++++++++++++++++ packages/cli/src/reporters/util.ts | 95 ++++++++++++ packages/cli/src/rest/check-results.ts | 10 +- 6 files changed, 484 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ai-context/references/investigate-checks.md b/packages/cli/src/ai-context/references/investigate-checks.md index 01bdfbe23..a668d3077 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 — with the `FINAL` row marked `final`: + +```bash +npx checkly checks get --result --include-attempts +``` + ### View an error group ```bash diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index b952b0848..86ef667f2 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,11 @@ 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'], + default: false, + }), 'error-group': Flags.string({ char: 'e', description: 'Show full details for a specific error group ID.', @@ -76,7 +86,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 +293,102 @@ 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) + // A result with no retries (attempts === 0) has no ATTEMPT rows, so skip the + // extra list call entirely and let renderAttemptsSection show "ran once". + const attempts = includeAttempts && result.sequenceId && (result.attempts ?? 0) > 0 + ? 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, result, resultId, fmt)) + } + + const hints: CommandHint[] = [] + if (!includeAttempts && (result.attempts ?? 0) > 0) { + hints.push({ + label: 'Show attempts', + command: `checkly checks get ${checkId} --result ${resultId} --include-attempts`, + }) + } + if (includeAttempts) { + for (const attempt of attempts) { + if (attempt.id !== resultId && attempt.resultType === 'ATTEMPT') { + hints.push({ label: 'View attempt', command: `checkly checks get ${checkId} --result ${attempt.id}` }) + } + } + } + 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)) + } + + private renderAttemptsSection ( + attempts: CheckResult[], + result: CheckResult, + resultId: string, + fmt: OutputFormat, + ): string { + if (attempts.length > 1) { + return formatAttemptsSection(attempts, fmt, { + finalId: attempts.find(a => a.resultType === 'FINAL')?.id, + requestedId: resultId, + }) + } + + // The result claims retries but we couldn't reconstruct the sequence (it may + // have aged out of the data-retention window, or sat outside the fetch window). + if ((result.attempts ?? 0) > 0) { + const msg = 'Retry attempts are no longer available for this result.' + return fmt === 'md' ? `_${msg}_` : chalk.dim(msg) + } + + const msg = 'Ran once, no retry attempts.' + return fmt === 'md' ? `_${msg}_` : chalk.dim(msg) + } + + /** + * Fetches the retry sequence for a result and groups it client-side. + * + * Attempts reuse the result's `sequenceId` and cluster within minutes of each + * other, but the list endpoint has no server-side sequenceId filter, so we + * fetch a bounded time window around the result (newest-first) and group + * locally. The window is anchored just after the result so the sequence sits + * at the top of the page for the common case (drilling into a FINAL result). + */ + private async fetchAttempts (checkId: string, result: CheckResult): Promise { + if (!result.sequenceId) { + return [] + } + + const WINDOW_BEFORE_SECONDS = 30 * 60 + const WINDOW_AFTER_SECONDS = 5 * 60 + const anchorSeconds = Math.floor(new Date(result.startedAt).getTime() / 1000) + + const empty = { data: { entries: [] as CheckResult[], nextId: null, length: 0 } } + const resp = await api.checkResults.getAll(checkId, { + resultType: 'ALL', + from: anchorSeconds - WINDOW_BEFORE_SECONDS, + to: anchorSeconds + WINDOW_AFTER_SECONDS, + limit: Math.max((result.attempts ?? 0) + 10, 50), + }).catch(() => empty) + + return groupAttemptsBySequence(resp.data.entries, 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..ee0e45bef 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('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..1b8076166 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -14,12 +14,14 @@ import { type OutputFormat, type DetailField, type CommandHint, + type ColumnDef, formatMs, formatDate, resolveResultStatus, heading, renderDetailFields, renderCommandHints, + renderAdaptiveTable, } from './render.js' // --- Helpers --- @@ -32,9 +34,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 +53,137 @@ export function formatResultDetailWithNavigation ( return output.join('\n') } +// --- Retry attempts --- + +/** + * Groups a flat list of check results (as returned by the list endpoint with + * resultType=ALL) into the ordered sequence of runs that share a sequenceId. + * + * The backend mints one sequenceId per logical run and reuses it across + * retries, persisting one FINAL result plus zero or more earlier ATTEMPT + * results. There is no server-side sequenceId filter, so callers fetch a window + * of results and group here. The returned list is ordered oldest-first + * (ascending startedAt) so the positional 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 { + /** ID of the FINAL result in the sequence; that row is marked "final". */ + finalId?: string + /** ID the user drilled into via --result (may be an ATTEMPT); marked as current. */ + requestedId?: string +} + +interface AttemptRow { + result: CheckResult + runNumber: number + isFinal: boolean + isRequested: boolean +} + +/** + * Extracts a short, single-line error summary from a result's type-specific + * payload, falling back to the coarse status flags when no message is present. + * List rows are not hydrated with logs/assets, so any message here is + * best-effort; an empty string means "no error detail available". + */ +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 per-attempt retry table for a result sequence. `attempts` must be + * ordered oldest-first (see groupAttemptsBySequence). Returns an empty string + * when there is nothing to show. + */ +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: 10, + 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 truncateSingleLine(msg, 80).replace(/\|/g, '\\|') +} + // --- Top-level result detail fields --- export const resultDetailFields: DetailField[] = [ diff --git a/packages/cli/src/reporters/util.ts b/packages/cli/src/reporters/util.ts index 533ba4909..10de39d6e 100644 --- a/packages/cli/src/reporters/util.ts +++ b/packages/cli/src/reporters/util.ts @@ -268,6 +268,19 @@ export function formatCheckResult (checkResult: any) { } } } + if ( + checkResult.checkType === 'BROWSER' + || checkResult.checkType === 'MULTI_STEP' + || checkResult.checkType === 'PLAYWRIGHT' + ) { + const errorSummary = formatBrowserFamilyErrors(checkResult) + if (errorSummary) { + result.push([ + formatSectionTitle('Errors'), + errorSummary, + ]) + } + } if (checkResult.logs?.length) { result.push([ formatSectionTitle('Logs'), @@ -713,6 +726,88 @@ function toString (val: any): string { } } +// Strips ANSI escape codes so we can apply our own coloring to error messages. +// Live BROWSER/MULTI_STEP errors are usually plain strings, but Playwright +// per-test errors often arrive with embedded ANSI from the runner. +function stripAnsiCodes (input: string): string { + return input.replace( + // eslint-disable-next-line no-control-regex + /[›][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') +} + +function errorEntryToString (err: unknown): string { + if (typeof err === 'string') return err + if (err && typeof err === 'object') { + const obj = err as Record + if (typeof obj.message === 'string') return obj.message + if (typeof obj.value === 'string') return obj.value + return JSON.stringify(err) + } + return String(err) +} + +/** + * Extracts the per-test error messages a live BROWSER/MULTI_STEP/PLAYWRIGHT + * result carries on `metadata.errors`. Returns cleaned, non-empty strings. + * Used by both the list reporter (terminal output) and the JSON reporter so + * the two surfaces stay in sync. + */ +export function getCheckResultErrors (checkResult: any): string[] { + const rawErrors = checkResult?.metadata?.errors + if (!Array.isArray(rawErrors)) { + return [] + } + return rawErrors + .map(errorEntryToString) + .map(stripAnsiCodes) + .map(msg => msg.trim()) + .filter(msg => msg.length > 0) +} + +// One-line count summary derived from a browser check's trace summary, +// e.g. "2 console, 1 network". Returns undefined when no errors were counted. +function formatTraceSummaryCounts (traceSummary: any): string | undefined { + if (!traceSummary) { + return undefined + } + const parts = [ + traceSummary.userScriptErrors > 0 ? `${traceSummary.userScriptErrors} script` : undefined, + traceSummary.consoleErrors > 0 ? `${traceSummary.consoleErrors} console` : undefined, + traceSummary.networkErrors > 0 ? `${traceSummary.networkErrors} network` : undefined, + traceSummary.documentErrors > 0 ? `${traceSummary.documentErrors} document` : undefined, + ].filter(Boolean) + return parts.length > 0 ? parts.join(', ') : undefined +} + +// Builds a concise error section body for BROWSER/MULTI_STEP/PLAYWRIGHT +// results: a trace-summary count line (when available) followed by the +// individual error messages, each truncated and capped to stay readable. +function formatBrowserFamilyErrors (checkResult: any): string | undefined { + const lines: string[] = [] + + const counts = formatTraceSummaryCounts(checkResult?.metadata?.traceSummary) + if (counts) { + lines.push(chalk.red(counts)) + } + + const errors = getCheckResultErrors(checkResult) + const maxErrors = 5 + for (const error of errors.slice(0, maxErrors)) { + const { result: message } = truncate(error, { + chars: 300, + lines: 5, + ending: chalk.magenta('\n...truncated...'), + }) + lines.push(chalk.red(`${logSymbols.error} ${message}`)) + } + if (errors.length > maxErrors) { + const remaining = errors.length - maxErrors + lines.push(chalk.dim(`... (${remaining} more error${remaining === 1 ? '' : 's'})`)) + } + + return lines.length > 0 ? lines.join('\n') : undefined +} + export function resultToCheckStatus (checkResult: any): CheckStatus { if (checkResult.isCancelled) { return CheckStatus.CANCELLED diff --git a/packages/cli/src/rest/check-results.ts b/packages/cli/src/rest/check-results.ts index df69d7bf9..a38a87fe4 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 @@ -176,10 +177,17 @@ export interface CheckResultsPage { export interface ListCheckResultsParams { limit?: number nextId?: string + /** Lower bound on startedAt (>=), as a UNIX timestamp in seconds. */ from?: number + /** Upper bound on startedAt (<), as a UNIX timestamp in seconds. */ to?: number hasFailures?: boolean - resultType?: 'FINAL' | 'ATTEMPT' + /** + * FINAL (default) returns only the decisive result per run; ATTEMPT returns + * only earlier failed retries; ALL returns both. Attempts of a single run + * share a `sequenceId`. + */ + resultType?: 'FINAL' | 'ATTEMPT' | 'ALL' } class CheckResults { From de4a417b43a3b7a496ce06ee35553ea75298affe Mon Sep 17 00:00:00 2001 From: miliberlin Date: Wed, 10 Jun 2026 10:50:50 +0200 Subject: [PATCH 2/9] feat: change final copy --- .../cli/src/formatters/__tests__/check-result-detail.spec.ts | 2 +- packages/cli/src/formatters/check-result-detail.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 ee0e45bef..4fe0c3402 100644 --- a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts +++ b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts @@ -304,7 +304,7 @@ describe('formatResultDetail', () => { expect(out).toContain('ATTEMPTS') expect(out).toContain('1') expect(out).toContain('2') - expect(out).toContain('final') + expect(out).toContain('3 (final)') expect(out).toContain('failing') expect(out).toContain('passing') expect(out).toContain('Timeout 30000ms exceeded') diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index 1b8076166..dd19718bf 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -155,9 +155,9 @@ function buildAttemptColumns (format: OutputFormat): ColumnDef[] { return [ { header: '#', - width: 10, + width: 14, value: row => { - const marker = row.isFinal ? chalk.dim(' final') : '' + const marker = row.isFinal ? chalk.dim(' (final)') : '' const current = row.isRequested ? chalk.cyan(' ‹') : '' return String(row.runNumber) + marker + current }, From 6cb2706ba9bd022626a735dd850d911869433b58 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Wed, 10 Jun 2026 11:00:56 +0200 Subject: [PATCH 3/9] feat: reduce comments --- packages/cli/src/commands/checks/get.ts | 18 +++-------- .../cli/src/formatters/check-result-detail.ts | 32 ++++--------------- packages/cli/src/rest/check-results.ts | 12 ++----- 3 files changed, 15 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 86ef667f2..e02e1c1e2 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -301,8 +301,7 @@ export default class ChecksGet extends AuthCommand { ): Promise { const { data: result } = await api.checkResults.get(checkId, resultId) - // A result with no retries (attempts === 0) has no ATTEMPT rows, so skip the - // extra list call entirely and let renderAttemptsSection show "ran once". + // No retries means no ATTEMPT rows, so skip the extra list call. const attempts = includeAttempts && result.sequenceId && (result.attempts ?? 0) > 0 ? await this.fetchAttempts(checkId, result) : [] @@ -352,8 +351,8 @@ export default class ChecksGet extends AuthCommand { }) } - // The result claims retries but we couldn't reconstruct the sequence (it may - // have aged out of the data-retention window, or sat outside the fetch window). + // Claims retries but the sequence couldn't be reconstructed (aged out, or + // outside the fetch window). if ((result.attempts ?? 0) > 0) { const msg = 'Retry attempts are no longer available for this result.' return fmt === 'md' ? `_${msg}_` : chalk.dim(msg) @@ -363,15 +362,8 @@ export default class ChecksGet extends AuthCommand { return fmt === 'md' ? `_${msg}_` : chalk.dim(msg) } - /** - * Fetches the retry sequence for a result and groups it client-side. - * - * Attempts reuse the result's `sequenceId` and cluster within minutes of each - * other, but the list endpoint has no server-side sequenceId filter, so we - * fetch a bounded time window around the result (newest-first) and group - * locally. The window is anchored just after the result so the sequence sits - * at the top of the page for the common case (drilling into a FINAL result). - */ + // Fetches a bounded window of results around the result and groups by + // sequenceId locally (the list endpoint has no server-side sequenceId filter). private async fetchAttempts (checkId: string, result: CheckResult): Promise { if (!result.sequenceId) { return [] diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index dd19718bf..1ba854229 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -55,16 +55,8 @@ export function formatResultDetailWithNavigation ( // --- Retry attempts --- -/** - * Groups a flat list of check results (as returned by the list endpoint with - * resultType=ALL) into the ordered sequence of runs that share a sequenceId. - * - * The backend mints one sequenceId per logical run and reuses it across - * retries, persisting one FINAL result plus zero or more earlier ATTEMPT - * results. There is no server-side sequenceId filter, so callers fetch a window - * of results and group here. The returned list is ordered oldest-first - * (ascending startedAt) so the positional index is the run number. - */ +// 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) @@ -72,10 +64,8 @@ export function groupAttemptsBySequence (results: CheckResult[], sequenceId: str } export interface AttemptsContext { - /** ID of the FINAL result in the sequence; that row is marked "final". */ - finalId?: string - /** ID the user drilled into via --result (may be an ATTEMPT); marked as current. */ - requestedId?: string + finalId?: string // marked "final" + requestedId?: string // the --result row, marked as current } interface AttemptRow { @@ -85,12 +75,8 @@ interface AttemptRow { isRequested: boolean } -/** - * Extracts a short, single-line error summary from a result's type-specific - * payload, falling back to the coarse status flags when no message is present. - * List rows are not hydrated with logs/assets, so any message here is - * best-effort; an empty string means "no error detail available". - */ +// 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 @@ -113,11 +99,7 @@ function firstErrorMessage (result: CheckResult): string { return '' } -/** - * Renders the per-attempt retry table for a result sequence. `attempts` must be - * ordered oldest-first (see groupAttemptsBySequence). Returns an empty string - * when there is nothing to show. - */ +// Renders the retry table for a sequence; `attempts` must be oldest-first. export function formatAttemptsSection ( attempts: CheckResult[], format: OutputFormat, diff --git a/packages/cli/src/rest/check-results.ts b/packages/cli/src/rest/check-results.ts index a38a87fe4..97bb6e6c8 100644 --- a/packages/cli/src/rest/check-results.ts +++ b/packages/cli/src/rest/check-results.ts @@ -177,16 +177,10 @@ export interface CheckResultsPage { export interface ListCheckResultsParams { limit?: number nextId?: string - /** Lower bound on startedAt (>=), as a UNIX timestamp in seconds. */ - from?: number - /** Upper bound on startedAt (<), as a UNIX timestamp in seconds. */ - to?: number + from?: number // startedAt lower bound (>=), UNIX seconds + to?: number // startedAt upper bound (<), UNIX seconds hasFailures?: boolean - /** - * FINAL (default) returns only the decisive result per run; ATTEMPT returns - * only earlier failed retries; ALL returns both. Attempts of a single run - * share a `sequenceId`. - */ + // FINAL (default) = decisive result only; ATTEMPT = retries only; ALL = both. resultType?: 'FINAL' | 'ATTEMPT' | 'ALL' } From 2e19c0a19491985e70e78b04928ad230917801a8 Mon Sep 17 00:00:00 2001 From: Michelle Liebheit <54396648+miliberlin@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:02:19 +0200 Subject: [PATCH 4/9] Update investigate-checks.md --- packages/cli/src/ai-context/references/investigate-checks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ai-context/references/investigate-checks.md b/packages/cli/src/ai-context/references/investigate-checks.md index a668d3077..8ac59e969 100644 --- a/packages/cli/src/ai-context/references/investigate-checks.md +++ b/packages/cli/src/ai-context/references/investigate-checks.md @@ -56,7 +56,7 @@ 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 — with the `FINAL` row marked `final`: +error summary: ```bash npx checkly checks get --result --include-attempts From 0f1403008b704898d91fbbafbcc03516e95b4af1 Mon Sep 17 00:00:00 2001 From: Michelle Liebheit <54396648+miliberlin@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:04:07 +0200 Subject: [PATCH 5/9] Update check-results.ts --- packages/cli/src/rest/check-results.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/rest/check-results.ts b/packages/cli/src/rest/check-results.ts index 97bb6e6c8..3074a4ed4 100644 --- a/packages/cli/src/rest/check-results.ts +++ b/packages/cli/src/rest/check-results.ts @@ -177,10 +177,9 @@ export interface CheckResultsPage { export interface ListCheckResultsParams { limit?: number nextId?: string - from?: number // startedAt lower bound (>=), UNIX seconds - to?: number // startedAt upper bound (<), UNIX seconds + from?: number + to?: number hasFailures?: boolean - // FINAL (default) = decisive result only; ATTEMPT = retries only; ALL = both. resultType?: 'FINAL' | 'ATTEMPT' | 'ALL' } From b8e5f7857dccdf052c77a8b7c30e25f8afb6c5a7 Mon Sep 17 00:00:00 2001 From: miliberlin Date: Wed, 10 Jun 2026 11:12:59 +0200 Subject: [PATCH 6/9] test(cli): update browser-check reporter snapshots for error section --- .../__tests__/__snapshots__/util.spec.ts.snap | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap index ac84bcfaf..ce90b9f46 100644 --- a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap +++ b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap @@ -211,7 +211,10 @@ The homepage loaded successfully and all assertions passed. `; exports[`formatCheckResult() > Browser Check result > formats a Browser Check result with a scheduleError > browser-check-result-schedule-error-format 1`] = ` -"──Logs────────────────────────────────────────────────────────────────────── +"──Errors──────────────────────────────────────────────────────────────────── +1 console, 1 network + +──Logs────────────────────────────────────────────────────────────────────── 10:53:15 DEBUG Starting job 10:53:15 DEBUG Compiling environment variables 10:53:15 DEBUG Creating runtime using version 2022.10 @@ -230,7 +233,10 @@ There was a scheduling error" `; exports[`formatCheckResult() > Browser Check result > formats a Browser Check result with logs > browser-check-result-logs-format 1`] = ` -"──Logs────────────────────────────────────────────────────────────────────── +"──Errors──────────────────────────────────────────────────────────────────── +1 console, 1 network + +──Logs────────────────────────────────────────────────────────────────────── 10:53:15 DEBUG Starting job 10:53:15 DEBUG Compiling environment variables 10:53:15 DEBUG Creating runtime using version 2022.10 @@ -245,4 +251,7 @@ exports[`formatCheckResult() > Browser Check result > formats a Browser Check re 10:53:22 DEBUG Uploading log file" `; -exports[`formatCheckResult() > Browser Check result > formats a basic Browser Check result > browser-check-result-basic-format 1`] = `""`; +exports[`formatCheckResult() > Browser Check result > formats a basic Browser Check result > browser-check-result-basic-format 1`] = ` +"──Errors──────────────────────────────────────────────────────────────────── +1 console, 1 network" +`; From 6233ccb4f1685057b91393d23122a52c7bb61780 Mon Sep 17 00:00:00 2001 From: rca-bot Date: Thu, 11 Jun 2026 07:44:32 +0000 Subject: [PATCH 7/9] =?UTF-8?q?feat(cli):=20add=20per-attempt=20retry=20de?= =?UTF-8?q?tail=20(AI-340)=20=E2=80=94=20Michelle's=20local=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply Michelle's latest local edits to the AI-340 branch: - packages/cli/src/commands/checks/get.ts - packages/cli/src/formatters/check-result-detail.ts - packages/cli/src/formatters/__tests__/check-result-detail.spec.ts - packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts (new) - packages/cli/src/commands/__tests__/checks-get-flags.spec.ts (new) Committed by Angie on behalf of Michelle (AI-340). --- .../__tests__/checks-get-attempts.spec.ts | 215 ++++++++++++++++++ .../__tests__/checks-get-flags.spec.ts | 34 +++ packages/cli/src/commands/checks/get.ts | 116 +++++++--- .../__tests__/check-result-detail.spec.ts | 4 +- .../cli/src/formatters/check-result-detail.ts | 4 +- 5 files changed, 335 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts create mode 100644 packages/cli/src/commands/__tests__/checks-get-flags.spec.ts 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..02176259b --- /dev/null +++ b/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts @@ -0,0 +1,215 @@ +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('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 e02e1c1e2..10faf9d85 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -47,7 +47,6 @@ export default class ChecksGet extends AuthCommand { 'include-attempts': Flags.boolean({ description: 'Show individual retry attempts for the result (use with --result).', dependsOn: ['result'], - default: false, }), 'error-group': Flags.string({ char: 'e', @@ -301,8 +300,7 @@ export default class ChecksGet extends AuthCommand { ): Promise { const { data: result } = await api.checkResults.get(checkId, resultId) - // No retries means no ATTEMPT rows, so skip the extra list call. - const attempts = includeAttempts && result.sequenceId && (result.attempts ?? 0) > 0 + const attempts = includeAttempts && result.sequenceId ? await this.fetchAttempts(checkId, result) : [] @@ -315,21 +313,39 @@ export default class ChecksGet extends AuthCommand { const sections: string[] = [] if (includeAttempts) { - sections.push(this.renderAttemptsSection(attempts, result, resultId, fmt)) + 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[] = [] - if (!includeAttempts && (result.attempts ?? 0) > 0) { + // 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) { - for (const attempt of attempts) { - if (attempt.id !== resultId && attempt.resultType === 'ATTEMPT') { - hints.push({ label: 'View attempt', command: `checkly checks get ${checkId} --result ${attempt.id}` }) + 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}` }) @@ -338,49 +354,81 @@ export default class ChecksGet extends AuthCommand { this.log(formatResultDetailWithNavigation(result, fmt, hints, sections)) } - private renderAttemptsSection ( - attempts: CheckResult[], - result: CheckResult, - resultId: string, - fmt: OutputFormat, - ): string { - if (attempts.length > 1) { + // 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, }) } - // Claims retries but the sequence couldn't be reconstructed (aged out, or - // outside the fetch window). - if ((result.attempts ?? 0) > 0) { - const msg = 'Retry attempts are no longer available for this result.' - return fmt === 'md' ? `_${msg}_` : chalk.dim(msg) - } - const msg = 'Ran once, no retry attempts.' return fmt === 'md' ? `_${msg}_` : chalk.dim(msg) } - // Fetches a bounded window of results around the result and groups by - // sequenceId locally (the list endpoint has no server-side sequenceId filter). + // 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 WINDOW_BEFORE_SECONDS = 30 * 60 - const WINDOW_AFTER_SECONDS = 5 * 60 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 empty = { data: { entries: [] as CheckResult[], nextId: null, length: 0 } } - const resp = await api.checkResults.getAll(checkId, { - resultType: 'ALL', - from: anchorSeconds - WINDOW_BEFORE_SECONDS, - to: anchorSeconds + WINDOW_AFTER_SECONDS, - limit: Math.max((result.attempts ?? 0) + 10, 50), - }).catch(() => empty) + 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 }).catch(() => null) + if (!resp) { + break + } + collected.push(...resp.data.entries) + cursor = resp.data.nextId ?? undefined + if (!cursor) { + break + } + } - return groupAttemptsBySequence(resp.data.entries, result.sequenceId) + 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 4fe0c3402..6ba9f4196 100644 --- a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts +++ b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts @@ -304,7 +304,7 @@ describe('formatResultDetail', () => { expect(out).toContain('ATTEMPTS') expect(out).toContain('1') expect(out).toContain('2') - expect(out).toContain('3 (final)') + expect(out).toContain('3 (FINAL)') expect(out).toContain('failing') expect(out).toContain('passing') expect(out).toContain('Timeout 30000ms exceeded') @@ -314,7 +314,7 @@ describe('formatResultDetail', () => { 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('3 (FINAL)') expect(out).toContain('Timeout 30000ms exceeded') expect(out).toContain('—') }) diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index 1ba854229..543896ddb 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -125,7 +125,7 @@ export function formatAttemptsSection ( function buildAttemptColumns (format: OutputFormat): ColumnDef[] { if (format === 'md') { return [ - { header: '#', value: row => row.isFinal ? `${row.runNumber} (final)` : String(row.runNumber) }, + { 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) }, @@ -139,7 +139,7 @@ function buildAttemptColumns (format: OutputFormat): ColumnDef[] { header: '#', width: 14, value: row => { - const marker = row.isFinal ? chalk.dim(' (final)') : '' + const marker = row.isFinal ? chalk.dim(' (FINAL)') : '' const current = row.isRequested ? chalk.cyan(' ‹') : '' return String(row.runNumber) + marker + current }, From 828ef1ecbb4ca61b7775feccf9ba387e8589c8ad Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Thu, 11 Jun 2026 16:21:32 +0200 Subject: [PATCH 8/9] fix(cli): tighten retry attempt detail --- .../__tests__/checks-get-attempts.spec.ts | 75 +++++++++++++++ packages/cli/src/commands/checks/get.ts | 5 +- .../__tests__/__snapshots__/util.spec.ts.snap | 15 +-- packages/cli/src/reporters/util.ts | 95 ------------------- 4 files changed, 79 insertions(+), 111 deletions(-) diff --git a/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts b/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts index 02176259b..f36280715 100644 --- a/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts +++ b/packages/cli/src/commands/__tests__/checks-get-attempts.spec.ts @@ -86,6 +86,81 @@ describe('checks get --include-attempts', () => { 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 }) diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 10faf9d85..ff2d71c6e 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -418,10 +418,7 @@ export default class ChecksGet extends AuthCommand { 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 }).catch(() => null) - if (!resp) { - break - } + const resp = await api.checkResults.getAll(checkId, { ...window, nextId: cursor }) collected.push(...resp.data.entries) cursor = resp.data.nextId ?? undefined if (!cursor) { diff --git a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap index ce90b9f46..ac84bcfaf 100644 --- a/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap +++ b/packages/cli/src/reporters/__tests__/__snapshots__/util.spec.ts.snap @@ -211,10 +211,7 @@ The homepage loaded successfully and all assertions passed. `; exports[`formatCheckResult() > Browser Check result > formats a Browser Check result with a scheduleError > browser-check-result-schedule-error-format 1`] = ` -"──Errors──────────────────────────────────────────────────────────────────── -1 console, 1 network - -──Logs────────────────────────────────────────────────────────────────────── +"──Logs────────────────────────────────────────────────────────────────────── 10:53:15 DEBUG Starting job 10:53:15 DEBUG Compiling environment variables 10:53:15 DEBUG Creating runtime using version 2022.10 @@ -233,10 +230,7 @@ There was a scheduling error" `; exports[`formatCheckResult() > Browser Check result > formats a Browser Check result with logs > browser-check-result-logs-format 1`] = ` -"──Errors──────────────────────────────────────────────────────────────────── -1 console, 1 network - -──Logs────────────────────────────────────────────────────────────────────── +"──Logs────────────────────────────────────────────────────────────────────── 10:53:15 DEBUG Starting job 10:53:15 DEBUG Compiling environment variables 10:53:15 DEBUG Creating runtime using version 2022.10 @@ -251,7 +245,4 @@ exports[`formatCheckResult() > Browser Check result > formats a Browser Check re 10:53:22 DEBUG Uploading log file" `; -exports[`formatCheckResult() > Browser Check result > formats a basic Browser Check result > browser-check-result-basic-format 1`] = ` -"──Errors──────────────────────────────────────────────────────────────────── -1 console, 1 network" -`; +exports[`formatCheckResult() > Browser Check result > formats a basic Browser Check result > browser-check-result-basic-format 1`] = `""`; diff --git a/packages/cli/src/reporters/util.ts b/packages/cli/src/reporters/util.ts index 10de39d6e..533ba4909 100644 --- a/packages/cli/src/reporters/util.ts +++ b/packages/cli/src/reporters/util.ts @@ -268,19 +268,6 @@ export function formatCheckResult (checkResult: any) { } } } - if ( - checkResult.checkType === 'BROWSER' - || checkResult.checkType === 'MULTI_STEP' - || checkResult.checkType === 'PLAYWRIGHT' - ) { - const errorSummary = formatBrowserFamilyErrors(checkResult) - if (errorSummary) { - result.push([ - formatSectionTitle('Errors'), - errorSummary, - ]) - } - } if (checkResult.logs?.length) { result.push([ formatSectionTitle('Logs'), @@ -726,88 +713,6 @@ function toString (val: any): string { } } -// Strips ANSI escape codes so we can apply our own coloring to error messages. -// Live BROWSER/MULTI_STEP errors are usually plain strings, but Playwright -// per-test errors often arrive with embedded ANSI from the runner. -function stripAnsiCodes (input: string): string { - return input.replace( - // eslint-disable-next-line no-control-regex - /[›][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') -} - -function errorEntryToString (err: unknown): string { - if (typeof err === 'string') return err - if (err && typeof err === 'object') { - const obj = err as Record - if (typeof obj.message === 'string') return obj.message - if (typeof obj.value === 'string') return obj.value - return JSON.stringify(err) - } - return String(err) -} - -/** - * Extracts the per-test error messages a live BROWSER/MULTI_STEP/PLAYWRIGHT - * result carries on `metadata.errors`. Returns cleaned, non-empty strings. - * Used by both the list reporter (terminal output) and the JSON reporter so - * the two surfaces stay in sync. - */ -export function getCheckResultErrors (checkResult: any): string[] { - const rawErrors = checkResult?.metadata?.errors - if (!Array.isArray(rawErrors)) { - return [] - } - return rawErrors - .map(errorEntryToString) - .map(stripAnsiCodes) - .map(msg => msg.trim()) - .filter(msg => msg.length > 0) -} - -// One-line count summary derived from a browser check's trace summary, -// e.g. "2 console, 1 network". Returns undefined when no errors were counted. -function formatTraceSummaryCounts (traceSummary: any): string | undefined { - if (!traceSummary) { - return undefined - } - const parts = [ - traceSummary.userScriptErrors > 0 ? `${traceSummary.userScriptErrors} script` : undefined, - traceSummary.consoleErrors > 0 ? `${traceSummary.consoleErrors} console` : undefined, - traceSummary.networkErrors > 0 ? `${traceSummary.networkErrors} network` : undefined, - traceSummary.documentErrors > 0 ? `${traceSummary.documentErrors} document` : undefined, - ].filter(Boolean) - return parts.length > 0 ? parts.join(', ') : undefined -} - -// Builds a concise error section body for BROWSER/MULTI_STEP/PLAYWRIGHT -// results: a trace-summary count line (when available) followed by the -// individual error messages, each truncated and capped to stay readable. -function formatBrowserFamilyErrors (checkResult: any): string | undefined { - const lines: string[] = [] - - const counts = formatTraceSummaryCounts(checkResult?.metadata?.traceSummary) - if (counts) { - lines.push(chalk.red(counts)) - } - - const errors = getCheckResultErrors(checkResult) - const maxErrors = 5 - for (const error of errors.slice(0, maxErrors)) { - const { result: message } = truncate(error, { - chars: 300, - lines: 5, - ending: chalk.magenta('\n...truncated...'), - }) - lines.push(chalk.red(`${logSymbols.error} ${message}`)) - } - if (errors.length > maxErrors) { - const remaining = errors.length - maxErrors - lines.push(chalk.dim(`... (${remaining} more error${remaining === 1 ? '' : 's'})`)) - } - - return lines.length > 0 ? lines.join('\n') : undefined -} - export function resultToCheckStatus (checkResult: any): CheckStatus { if (checkResult.isCancelled) { return CheckStatus.CANCELLED From 6413494719348f5303ed207bb02aec91fbc6fc1c Mon Sep 17 00:00:00 2001 From: Herve Labas Date: Thu, 11 Jun 2026 16:41:39 +0200 Subject: [PATCH 9/9] refactor(cli): share single-line truncation helper --- packages/cli/src/formatters/check-result-detail.ts | 10 +++------- packages/cli/src/formatters/render.ts | 6 ++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index 543896ddb..6d3fa2175 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -19,9 +19,11 @@ import { formatDate, resolveResultStatus, heading, + escapeMdCell, renderDetailFields, renderCommandHints, renderAdaptiveTable, + truncateSingleLine, } from './render.js' // --- Helpers --- @@ -163,7 +165,7 @@ function buildAttemptColumns (format: OutputFormat): ColumnDef[] { function mdErrorCell (result: CheckResult): string { const msg = extractResultErrorSummary(result) if (!msg) return '—' - return truncateSingleLine(msg, 80).replace(/\|/g, '\\|') + return escapeMdCell(truncateSingleLine(msg, 80)) } // --- Top-level result detail fields --- @@ -674,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 {