From 41d75cacb75425eeb2607c36335addb61532248e Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 11:20:22 +0100 Subject: [PATCH 01/21] feat(cli): add checks list/get commands with typed formatters Add `checkly checks list` and `checkly checks get` commands with REST clients, formatters, and e2e tests. Formatters use typed field definitions (DetailField, ColumnDef) with generic renderDetailFields/renderTable primitives, defining fields once and rendering them for both terminal and markdown output formats. --- packages/cli/e2e/__tests__/checks-get.spec.ts | 61 +++ .../cli/e2e/__tests__/checks-list.spec.ts | 50 ++ packages/cli/package.json | 3 + .../commands/checks/__tests__/list.spec.ts | 100 ++++ packages/cli/src/commands/checks/get.ts | 220 +++++++++ packages/cli/src/commands/checks/list.ts | 211 +++++++++ .../check-result-detail.spec.ts.snap | 216 +++++++++ .../__snapshots__/checks.spec.ts.snap | 102 ++++ .../__tests__/check-result-detail.spec.ts | 136 ++++++ .../src/formatters/__tests__/checks.spec.ts | 292 ++++++++++++ .../cli/src/formatters/__tests__/fixtures.ts | 378 +++++++++++++++ .../src/formatters/__tests__/render.spec.ts | 381 +++++++++++++++ .../cli/src/formatters/check-result-detail.ts | 447 ++++++++++++++++++ packages/cli/src/formatters/checks.ts | 350 ++++++++++++++ packages/cli/src/formatters/render.ts | 195 ++++++++ packages/cli/src/helpers/command-style.ts | 13 + packages/cli/src/rest/api.ts | 10 + packages/cli/src/rest/check-groups.ts | 24 + packages/cli/src/rest/check-results.ts | 149 ++++++ packages/cli/src/rest/check-statuses.ts | 40 ++ packages/cli/src/rest/checks.ts | 91 ++++ packages/cli/src/rest/error-groups.ts | 29 ++ 22 files changed, 3498 insertions(+) create mode 100644 packages/cli/e2e/__tests__/checks-get.spec.ts create mode 100644 packages/cli/e2e/__tests__/checks-list.spec.ts create mode 100644 packages/cli/src/commands/checks/__tests__/list.spec.ts create mode 100644 packages/cli/src/commands/checks/get.ts create mode 100644 packages/cli/src/commands/checks/list.ts create mode 100644 packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap create mode 100644 packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap create mode 100644 packages/cli/src/formatters/__tests__/check-result-detail.spec.ts create mode 100644 packages/cli/src/formatters/__tests__/checks.spec.ts create mode 100644 packages/cli/src/formatters/__tests__/fixtures.ts create mode 100644 packages/cli/src/formatters/__tests__/render.spec.ts create mode 100644 packages/cli/src/formatters/check-result-detail.ts create mode 100644 packages/cli/src/formatters/checks.ts create mode 100644 packages/cli/src/formatters/render.ts create mode 100644 packages/cli/src/rest/check-groups.ts create mode 100644 packages/cli/src/rest/check-results.ts create mode 100644 packages/cli/src/rest/check-statuses.ts create mode 100644 packages/cli/src/rest/checks.ts create mode 100644 packages/cli/src/rest/error-groups.ts diff --git a/packages/cli/e2e/__tests__/checks-get.spec.ts b/packages/cli/e2e/__tests__/checks-get.spec.ts new file mode 100644 index 000000000..733df9b78 --- /dev/null +++ b/packages/cli/e2e/__tests__/checks-get.spec.ts @@ -0,0 +1,61 @@ +import config from 'config' +import { describe, it, expect, beforeAll } from 'vitest' + +import { runChecklyCli } from '../run-checkly' + +describe('checkly checks get', () => { + let checkId: string + + beforeAll(async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--output', 'json', '--limit', '1'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + const parsed = JSON.parse(result.stdout) + expect(parsed.data.length).toBeGreaterThan(0) + checkId = parsed.data[0].id + }) + + it('should get check detail with default output', async () => { + const result = await runChecklyCli({ + args: ['checks', 'get', checkId], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + expect(result.stdout).toBeTruthy() + }) + + it('should output valid JSON with --output json', async () => { + const result = await runChecklyCli({ + args: ['checks', 'get', checkId, '--output', 'json'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + const parsed = JSON.parse(result.stdout) + expect(parsed.check.id).toBe(checkId) + }) + + it('should output markdown with --output md', async () => { + const result = await runChecklyCli({ + args: ['checks', 'get', checkId, '--output', 'md'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('#') + expect(result.stdout).toContain('| Field |') + }) + + it('should fail for nonexistent check ID', async () => { + const result = await runChecklyCli({ + args: ['checks', 'get', 'nonexistent-check-id-00000'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).not.toBe(0) + }) +}) diff --git a/packages/cli/e2e/__tests__/checks-list.spec.ts b/packages/cli/e2e/__tests__/checks-list.spec.ts new file mode 100644 index 000000000..8d5f4d1d0 --- /dev/null +++ b/packages/cli/e2e/__tests__/checks-list.spec.ts @@ -0,0 +1,50 @@ +import config from 'config' +import { describe, it, expect } from 'vitest' + +import { runChecklyCli } from '../run-checkly' + +describe('checkly checks list', () => { + it('should list checks with default output', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + expect(result.stdout).toBeTruthy() + }) + + it('should output valid JSON with --output json', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--output', 'json'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + const parsed = JSON.parse(result.stdout) + expect(parsed).toHaveProperty('data') + expect(Array.isArray(parsed.data)).toBe(true) + }) + + it('should output markdown with --output md', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--output', 'md'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('| Name |') + expect(result.stdout).toContain('| --- |') + }) + + it('should respect --limit flag', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--output', 'json', '--limit', '1'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(result.status).toBe(0) + const parsed = JSON.parse(result.stdout) + expect(parsed.data.length).toBeLessThanOrEqual(1) + }) +}) diff --git a/packages/cli/package.json b/packages/cli/package.json index 3ebe6bb44..eb007cb0d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,6 +64,9 @@ "message": "<%= config.name %> update available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>. To update, run `npm install -D checkly@latest`" }, "topics": { + "checks": { + "description": "List and inspect checks in your Checkly account." + }, "env": { "description": "Manage Checkly environment variables." }, diff --git a/packages/cli/src/commands/checks/__tests__/list.spec.ts b/packages/cli/src/commands/checks/__tests__/list.spec.ts new file mode 100644 index 000000000..131a337eb --- /dev/null +++ b/packages/cli/src/commands/checks/__tests__/list.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { buildActiveCheckIds } from '../list' +import type { Check } from '../../../rest/checks' +import type { CheckGroup } from '../../../rest/check-groups' + +function makeCheck (overrides: Partial = {}): Check { + return { + id: 'check-1', + name: 'Test Check', + checkType: 'API', + activated: true, + muted: false, + frequency: 10, + locations: ['eu-west-1'], + tags: [], + groupId: null, + groupOrder: null, + runtimeId: null, + scriptPath: null, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: null, + ...overrides, + } +} + +function makeGroup (overrides: Partial = {}): CheckGroup { + return { + id: 1, + name: 'Group 1', + activated: true, + muted: false, + locations: [], + tags: [], + concurrency: 1, + ...overrides, + } +} + +describe('buildActiveCheckIds', () => { + it('includes activated non-heartbeat checks', () => { + const checks = [ + makeCheck({ id: 'c1', checkType: 'API', activated: true }), + makeCheck({ id: 'c2', checkType: 'BROWSER', activated: true }), + ] + const result = buildActiveCheckIds(checks, []) + expect(result.has('c1')).toBe(true) + expect(result.has('c2')).toBe(true) + expect(result.size).toBe(2) + }) + + it('excludes deactivated checks', () => { + const checks = [ + makeCheck({ id: 'c1', activated: true }), + makeCheck({ id: 'c2', activated: false }), + ] + const result = buildActiveCheckIds(checks, []) + expect(result.has('c1')).toBe(true) + expect(result.has('c2')).toBe(false) + }) + + it('excludes heartbeat checks', () => { + const checks = [ + makeCheck({ id: 'c1', checkType: 'API' }), + makeCheck({ id: 'c2', checkType: 'HEARTBEAT' }), + ] + const result = buildActiveCheckIds(checks, []) + expect(result.has('c1')).toBe(true) + expect(result.has('c2')).toBe(false) + }) + + it('excludes checks in deactivated groups', () => { + const checks = [ + makeCheck({ id: 'c1', groupId: 1 }), + makeCheck({ id: 'c2', groupId: 2 }), + makeCheck({ id: 'c3', groupId: null }), + ] + const groups = [ + makeGroup({ id: 1, activated: true }), + makeGroup({ id: 2, activated: false }), + ] + const result = buildActiveCheckIds(checks, groups) + expect(result.has('c1')).toBe(true) + expect(result.has('c2')).toBe(false) + expect(result.has('c3')).toBe(true) + }) + + it('returns empty set when no checks match', () => { + const checks = [ + makeCheck({ id: 'c1', activated: false }), + makeCheck({ id: 'c2', checkType: 'HEARTBEAT' }), + ] + const result = buildActiveCheckIds(checks, []) + expect(result.size).toBe(0) + }) + + it('handles empty inputs', () => { + const result = buildActiveCheckIds([], []) + expect(result.size).toBe(0) + }) +}) diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts new file mode 100644 index 000000000..99e110cf0 --- /dev/null +++ b/packages/cli/src/commands/checks/get.ts @@ -0,0 +1,220 @@ +import { Args, Flags } from '@oclif/core' +import chalk from 'chalk' +import { AuthCommand } from '../authCommand' +import * as api from '../../rest/api' +import type { CheckWithStatus } from '../../formatters/checks' +import type { OutputFormat } from '../../formatters/render' +import { + formatCheckDetail, + formatResults, + formatErrorGroups, +} from '../../formatters/checks' +import { formatResultDetail } from '../../formatters/check-result-detail' + +export default class ChecksGet extends AuthCommand { + static hidden = false + static description = 'Get details of a specific check, including recent results. Use --result to drill into a specific result.' + + static args = { + id: Args.string({ + description: 'The ID of the check to retrieve.', + required: true, + }), + } + + static flags = { + 'result': Flags.string({ + char: 'r', + description: 'Show details for a specific result ID.', + }), + 'error-group': Flags.string({ + char: 'e', + description: 'Show full details for a specific error group ID.', + }), + 'results-limit': Flags.integer({ + description: 'Number of recent results to show.', + default: 10, + }), + 'results-cursor': Flags.string({ + description: 'Cursor for results pagination (from previous output).', + }), + 'output': Flags.string({ + char: 'o', + description: 'Output format.', + options: ['detail', 'json', 'md'], + default: 'detail', + }), + } + + async run (): Promise { + const { args, flags } = await this.parse(ChecksGet) + this.style.outputFormat = flags.output + + try { + // Result detail mode: drill into a specific result + if (flags.result) { + return await this.showResultDetail(args.id, flags.result, flags.output ?? 'detail') + } + + // Error group detail mode + if (flags['error-group']) { + return await this.showErrorGroupDetail(args.id, flags['error-group'], flags.output ?? 'detail') + } + + const [{ data: check }, statusResp, resultsResp, errorGroupsResp] = await Promise.all([ + api.checks.get(args.id), + api.checkStatuses.get(args.id).catch(() => ({ data: undefined })), + api.checkResults.getAll(args.id, { + limit: flags['results-limit'], + nextId: flags['results-cursor'], + }).catch(() => ({ data: { entries: [], nextId: null, length: 0 } })), + api.errorGroups.getByCheckId(args.id).catch(() => ({ data: [] })), + ]) + + const status = statusResp.data + const { entries: results, nextId } = resultsResp.data + const errorGroups = errorGroupsResp.data + + if (flags.output === 'json') { + this.log(JSON.stringify({ check, status, results, nextId, errorGroups }, null, 2)) + return + } + + const checkWithStatus: CheckWithStatus = { ...check, status } + const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal' + + if (fmt === 'md') { + const lines = [ + formatCheckDetail(checkWithStatus, fmt), + ] + const errorGroupsOutput = formatErrorGroups(errorGroups, fmt) + if (errorGroupsOutput) { + lines.push('') + lines.push(errorGroupsOutput) + } + if (results.length > 0) { + lines.push('') + lines.push('## Recent Results') + lines.push('') + lines.push(formatResults(results, fmt)) + } + this.log(lines.join('\n')) + return + } + + // Detail output + const output: string[] = [] + + output.push(formatCheckDetail(checkWithStatus, fmt)) + output.push('') + + const errorGroupsOutput = formatErrorGroups(errorGroups, fmt) + if (errorGroupsOutput) { + output.push(errorGroupsOutput) + output.push('') + } + + if (results.length > 0) { + output.push(chalk.bold('RECENT RESULTS')) + output.push(formatResults(results, fmt)) + } else { + output.push(chalk.dim('No recent results.')) + } + + // Navigation hints + output.push('') + if (errorGroups.length > 0) { + const firstActive = errorGroups.find(eg => !eg.archivedUntilNextEvent) + if (firstActive) { + output.push(` ${chalk.dim('View error:')} checkly checks get ${args.id} --error-group ${firstActive.id}`) + } + } + if (results.length > 0) { + output.push(` ${chalk.dim('View result:')} checkly checks get ${args.id} --result ${results[0].id}`) + } + if (nextId) { + output.push(` ${chalk.dim('More results:')} checkly checks get ${args.id} --results-cursor ${nextId}`) + } + output.push(` ${chalk.dim('Back to list:')} checkly checks list`) + + this.log(output.join('\n')) + } catch (err: any) { + this.style.longError('Failed to get check details.', err) + this.exit(1) + } + } + + private async showErrorGroupDetail (checkId: string, errorGroupId: string, outputFormat: string): Promise { + const [{ data: errorGroup }, { data: check }] = await Promise.all([ + api.errorGroups.get(errorGroupId), + api.checks.get(checkId), + ]) + + if (outputFormat === 'json') { + this.log(JSON.stringify(errorGroup, null, 2)) + return + } + + // eslint-disable-next-line no-control-regex + const ansiRegex = /\u001B\[[0-9;]*m/g + const cleanMsg = errorGroup.cleanedErrorMessage + .replace(ansiRegex, '') + .replace(/\s+/g, ' ') + .trim() + + // Show full raw message if available and different from cleaned + const rawMsg = errorGroup.rawErrorMessage + ? errorGroup.rawErrorMessage.replace(ansiRegex, '').trim() + : null + + const output: string[] = [] + + output.push(chalk.bold('ERROR GROUP')) + output.push('') + output.push(chalk.red(cleanMsg)) + + if (rawMsg && rawMsg !== cleanMsg) { + output.push('') + output.push(chalk.bold('FULL ERROR')) + // Show the raw message with original newlines preserved + const fullMsg = errorGroup.rawErrorMessage!.replace(ansiRegex, '').trim() + output.push(fullMsg) + } + + output.push('') + output.push(`${chalk.dim('First seen:')} ${new Date(errorGroup.firstSeen).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`) + output.push(`${chalk.dim('Last seen:')} ${new Date(errorGroup.lastSeen).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`) + output.push(`${chalk.dim('Error group:')} ${errorGroup.id}`) + + if (check.scriptPath) { + output.push(`${chalk.dim('Source file:')} ${chalk.cyan(check.scriptPath)}`) + } + + output.push('') + output.push(` ${chalk.dim('Back to check:')} checkly checks get ${checkId}`) + output.push(` ${chalk.dim('Back to list:')} checkly checks list`) + + this.log(output.join('\n')) + } + + private async showResultDetail (checkId: string, resultId: string, outputFormat: string): Promise { + const { data: result } = await api.checkResults.get(checkId, resultId) + + if (outputFormat === 'json') { + this.log(JSON.stringify(result, null, 2)) + return + } + + const fmt: OutputFormat = outputFormat === 'md' ? 'md' : 'terminal' + + const output: string[] = [] + output.push(formatResultDetail(result, fmt)) + + // Navigation hints + output.push('') + output.push(` ${chalk.dim('Back to check:')} checkly checks get ${checkId}`) + output.push(` ${chalk.dim('Back to list:')} checkly checks list`) + + this.log(output.join('\n')) + } +} diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts new file mode 100644 index 000000000..058029e64 --- /dev/null +++ b/packages/cli/src/commands/checks/list.ts @@ -0,0 +1,211 @@ +import { Flags } from '@oclif/core' +import chalk from 'chalk' +import { AuthCommand } from '../authCommand' +import * as api from '../../rest/api' +import type { Check } from '../../rest/checks' +import type { CheckStatus } from '../../rest/check-statuses' +import type { CheckGroup } from '../../rest/check-groups' +import type { CheckWithStatus } from '../../formatters/checks' +import type { OutputFormat } from '../../formatters/render' +import { + formatChecks, + formatSummaryBar, + formatPaginationInfo, + formatNavigationHints, +} from '../../formatters/checks' + +/** + * Match webapp logic: a check is effectively active only if both the check + * AND its parent group (if any) are activated. + */ +/** + * Match webapp: heartbeats are excluded from the home dashboard summary + * (they have their own page), and checks in deactivated groups are treated + * as inactive. + */ +export function buildActiveCheckIds (checks: Check[], groups: CheckGroup[]): Set { + const deactivatedGroups = new Set(groups.filter(g => !g.activated).map(g => g.id)) + return new Set( + checks + .filter(c => + c.activated + && c.checkType !== 'HEARTBEAT' + && !(c.groupId && deactivatedGroups.has(c.groupId)), + ) + .map(c => c.id), + ) +} + +export default class ChecksList extends AuthCommand { + static hidden = false + static description = 'List all checks in your account.' + + static flags = { + 'limit': Flags.integer({ + char: 'l', + description: 'Number of checks to return (1-100).', + default: 25, + }), + 'page': Flags.integer({ + char: 'p', + description: 'Page number.', + default: 1, + }), + 'tag': Flags.string({ + char: 't', + description: 'Filter by tag. Can be specified multiple times.', + multiple: true, + }), + 'search': Flags.string({ + char: 's', + description: 'Filter checks by name (case-insensitive).', + }), + 'status': Flags.string({ + description: 'Filter by status.', + options: ['passing', 'degraded', 'failing'], + }), + 'type': Flags.string({ + description: 'Filter by check type.', + options: ['API', 'BROWSER', 'MULTI_STEP', 'HEARTBEAT', 'PLAYWRIGHT', 'TCP'], + }), + 'hide-id': Flags.boolean({ + description: 'Hide check IDs in table output.', + default: false, + }), + 'output': Flags.string({ + char: 'o', + description: 'Output format.', + options: ['table', 'json', 'md'], + default: 'table', + }), + } + + async run (): Promise { + const { flags } = await this.parse(ChecksList) + this.style.outputFormat = flags.output + const { page, limit } = flags + + // Client-side filters require fetching all checks (same approach as the webapp) + const needsAllChecks = !!(flags.search || flags.status || flags.type) + + let filteredChecks: CheckWithStatus[] + let totalChecks: number + let statuses: CheckStatus[] + let activeCheckIds: Set | undefined + + try { + if (needsAllChecks) { + const [checkList, allStatuses, groupsResp] = await Promise.all([ + api.checks.fetchAll({ tag: flags.tag }), + api.checkStatuses.fetchAll(), + api.checkGroups.getAll(), + ]) + statuses = allStatuses + totalChecks = checkList.length + activeCheckIds = buildActiveCheckIds(checkList, groupsResp.data) + + const statusMap = new Map(statuses.map(s => [s.checkId, s])) + let merged: CheckWithStatus[] = checkList.map(c => ({ ...c, status: statusMap.get(c.id) })) + + if (flags.search) { + const term = flags.search.toLowerCase() + merged = merged.filter(c => c.name.toLowerCase().includes(term)) + } + if (flags.status) { + merged = merged.filter(c => { + if (!c.status) return false + if (flags.status === 'failing') return c.status.hasFailures || c.status.hasErrors + if (flags.status === 'degraded') return c.status.isDegraded + if (flags.status === 'passing') return !c.status.hasFailures && !c.status.hasErrors && !c.status.isDegraded + return true + }) + } + if (flags.type) { + merged = merged.filter(c => c.checkType === flags.type) + } + + filteredChecks = merged + } else { + const [paginated, allStatuses] = await Promise.all([ + api.checks.getAllPaginated({ limit, page, tag: flags.tag }), + api.checkStatuses.fetchAll(), + ]) + statuses = allStatuses + totalChecks = paginated.total + // On the paginated path, skip the extra fetchAll + groups call. + // The summary bar will count all statuses (including heartbeats). + activeCheckIds = undefined + + const statusMap = new Map(statuses.map(s => [s.checkId, s])) + filteredChecks = paginated.checks.map(c => ({ ...c, status: statusMap.get(c.id) })) + } + + // Build active filters for display + const activeFilters: string[] = [] + if (flags.tag) activeFilters.push(...flags.tag.map(t => `tag=${t}`)) + if (flags.search) activeFilters.push(`search="${flags.search}"`) + if (flags.status) activeFilters.push(`status=${flags.status}`) + if (flags.type) activeFilters.push(`type=${flags.type}`) + + // When filtering all checks, paginate the filtered results client-side + const filteredTotal = needsAllChecks ? filteredChecks.length : totalChecks + const displayChecks = needsAllChecks + ? filteredChecks.slice((page - 1) * limit, page * limit) + : filteredChecks + + const pagination = { page, limit, total: filteredTotal } + + // JSON output + if (flags.output === 'json') { + const totalPages = Math.ceil(filteredTotal / limit) + this.log(JSON.stringify({ + data: displayChecks, + pagination: { page, limit, total: filteredTotal, totalPages }, + }, null, 2)) + return + } + + if (filteredTotal === 0 && activeFilters.length === 0) { + this.log('No checks found.') + return + } + + const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal' + + // Markdown output + if (fmt === 'md') { + this.log(formatChecks(displayChecks, fmt, { pagination })) + return + } + + // Table output + const output: string[] = [] + + output.push(formatSummaryBar(statuses, totalChecks, activeCheckIds)) + output.push('') + + if (displayChecks.length === 0) { + const filterDesc = activeFilters.join(', ') + output.push(chalk.dim(`No checks matching: ${filterDesc}`)) + output.push('') + output.push(chalk.dim('Try:')) + output.push(` checkly checks list ${chalk.dim('(show all)')}`) + if (flags.status) { + const otherStatuses = ['passing', 'degraded', 'failing'].filter(s => s !== flags.status) + output.push(` checkly checks list --status ${otherStatuses[0]} ${chalk.dim(`(try ${otherStatuses[0]})`)}`) + } + } else { + output.push(formatChecks(displayChecks, fmt, { showId: !flags['hide-id'] })) + output.push('') + output.push(formatPaginationInfo(pagination)) + output.push('') + output.push(formatNavigationHints(pagination, activeFilters)) + } + + this.log(output.join('\n')) + } catch (err: any) { + this.style.longError('Failed to list checks.', err) + this.exit(1) + } + } +} diff --git a/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap b/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap new file mode 100644 index 000000000..06ef7fe81 --- /dev/null +++ b/packages/cli/src/formatters/__tests__/__snapshots__/check-result-detail.spec.ts.snap @@ -0,0 +1,216 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`formatResultDetail > API check result > renders markdown output > api-result-detail-md 1`] = ` +"# My API Check + +| Field | Value | +| --- | --- | +| Status | passing | +| Location | eu-west-1 | +| Response time | 245ms | +| Started | 2025-06-15T12:00:00.000Z | +| Stopped | 2025-06-15T12:00:01.000Z | +| Attempts | 1 | +| Result type | FINAL | +| ID | result-1 | + +## Request +\`GET https://api.example.com/health\` + +## Response +**200 OK** + +\`\`\`json +{"status":"ok","version":"1.2.3"} +\`\`\` + +## Timing +| Phase | Duration | +| --- | --- | +| DNS | 5ms | +| TCP | 10ms | +| TLS | 15ms | +| TTFB | 200ms | +| Download | 15ms | +| **Total** | **245ms** | + +## Assertions +- STATUS_CODE EQUALS 200 +- JSON_BODY.$.status EQUALS ok" +`; + +exports[`formatResultDetail > API check result > renders terminal output > api-result-detail-terminal 1`] = ` +"My API Check + +Status: passing +Location: eu-west-1 +Response time: 245ms +Started: 2025-06-15 12:00:00 UTC +Stopped: 2025-06-15 12:00:01 UTC +Attempts: 1 +Result type: FINAL +ID: result-1 + +REQUEST +Method: GET +URL: https://api.example.com/health +Headers: + Accept: application/json + +RESPONSE +Status: 200 OK +Headers: + content-type: application/json +Body: + { + "status": "ok", + "version": "1.2.3" + } + +TIMING + DNS: 5ms + TCP: 10ms + TLS: 15ms + TTFB: 200ms + Download: 15ms + Total: 245ms + +ASSERTIONS + · STATUS_CODE EQUALS 200 + · JSON_BODY.$.status EQUALS ok" +`; + +exports[`formatResultDetail > API check result with requestError and jobLog > renders terminal snapshot > api-result-with-error-terminal 1`] = ` +"My API Check + +Status: error +Location: eu-west-1 +Response time: 245ms +Started: 2025-06-15 12:00:00 UTC +Stopped: 2025-06-15 12:00:01 UTC +Attempts: 1 +Result type: FINAL +ID: result-5 + +REQUEST +Method: GET +URL: https://api.example.com/health +Headers: + Accept: application/json +Body: + { + "query": "test", + "filters": { + "active": true + } + } + +RESPONSE +Status: 0 + +ASSERTIONS + · STATUS_CODE EQUALS 200 + · JSON_BODY.$.status EQUALS ok + +ERROR + ECONNREFUSED: Connection refused to https://api.example.com/health + +JOB LOG + INFO Setting up request + ERROR ECONNREFUSED" +`; + +exports[`formatResultDetail > Browser check result > renders markdown output > browser-result-detail-md 1`] = ` +"# Failing Browser Check + +| Field | Value | +| --- | --- | +| Status | failing | +| Location | us-east-1 | +| Response time | 5.20s | +| Started | 2025-06-15T12:00:00.000Z | +| Stopped | 2025-06-15T12:00:05.000Z | +| Attempts | 1 | +| Result type | FINAL | +| ID | result-2 | + +## Browser Result (playwright) + +### Web Vitals +| Metric | Value | Score | +| --- | --- | --- | +| LCP | 1.20s | GOOD | +| FCP | 800ms | GOOD | +| CLS | 0.150 | NEEDS_IMPROVEMENT | +| TBT | 600ms | POOR | +| TTFB | 120ms | GOOD | + +### Errors +- TimeoutError: page.click: Timeout 30000ms exceeded" +`; + +exports[`formatResultDetail > Browser check result > renders terminal output > browser-result-detail-terminal 1`] = ` +"Failing Browser Check + +Status: failing +Location: us-east-1 +Response time: 5.20s +Started: 2025-06-15 12:00:00 UTC +Stopped: 2025-06-15 12:00:05 UTC +Attempts: 1 +Result type: FINAL +ID: result-2 + +BROWSER RESULT +Framework: playwright +Runtime: 2024.02 +Errors: 2 console, 1 network + +ERRORS + TimeoutError: page.click: Timeout 30000ms exceeded + +WEB VITALS + LCP: 1.20s good + FCP: 800ms good + CLS: 0.150 needs improvement + TBT: 600ms poor + TTFB: 120ms good + +JOB LOG + INFO Starting browser check + ERROR TimeoutError: page.click: Timeout 30000ms exceeded + +ASSETS + 1 screenshot(s), 1 trace(s), 1 video(s) + Use --output json to get asset URLs" +`; + +exports[`formatResultDetail > Multi-step check result > renders terminal output > multistep-result-detail-terminal 1`] = ` +"Multi-Step Checkout Flow + +Status: error +Location: ap-southeast-1 +Response time: 8.00s +Started: 2025-06-15 12:00:00 UTC +Stopped: 2025-06-15 12:00:08 UTC +Attempts: 2 +Result type: FINAL +ID: result-3 + +MULTI-STEP RESULT +Runtime: 2024.02 + +ERRORS + Error: Payment step failed + AssertionError: Expected cart total to match + +JOB LOG + INFO Step 1: Navigate to homepage + INFO Step 2: Add item to cart + INFO Step 3: Checkout + ERROR Error: Payment step failed + +ASSETS + 1 screenshot(s), 1 trace(s) + Use --output json to get asset URLs" +`; diff --git a/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap b/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap new file mode 100644 index 000000000..b250427ec --- /dev/null +++ b/packages/cli/src/formatters/__tests__/__snapshots__/checks.spec.ts.snap @@ -0,0 +1,102 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`formatCheckDetail > renders markdown detail > check-detail-md 1`] = ` +"# My API Check + +| Field | Value | +| --- | --- | +| Type | API | +| Status | passing | +| Active | yes | +| Muted | - | +| Frequency | Every 10m | +| Locations | eu-west-1, us-east-1 | +| Tags | production, api | +| SSL | 90 days remaining | +| Deployed | Jun 15, 2025 | +| Group | - | +| Created | 2025-01-01 | +| ID | check-1 |" +`; + +exports[`formatCheckDetail > renders terminal detail > check-detail-terminal 1`] = ` +"My API Check + +Type: API +Status: passing +Active: yes +Muted: - +Frequency: Every 10m +Locations: eu-west-1, us-east-1 +Tags: production, api +Source: UI +URL: https://api.example.com/health +SSL: 90 days remaining +Deployed: Jun 15, 2025 +Group: - +Created: 2025-01-01 +ID: check-1" +`; + +exports[`formatChecks > renders markdown table > checks-table-md 1`] = ` +"| Name | Type | Status | Freq | Tags | ID | +| --- | --- | --- | --- | --- | --- | +| My API Check | API | passing | 10m | production, api | check-1 | +| Failing Browser Check | BROWSER | failing | 10m | production, browser | check-2 | +| Inactive Check | API | inactive | 10m | - | check-3 | +| Muted Check | API | passing (muted) | 10m | production, api | check-4 |" +`; + +exports[`formatChecks > renders markdown with pagination > checks-table-md-paginated 1`] = ` +"| Name | Type | Status | Freq | Tags | ID | +| --- | --- | --- | --- | --- | --- | +| My API Check | API | passing | 10m | production, api | check-1 | +| Failing Browser Check | BROWSER | failing | 10m | production, browser | check-2 | +| Inactive Check | API | inactive | 10m | - | check-3 | +| Muted Check | API | passing (muted) | 10m | production, api | check-4 | + +*Showing page 1/3 (25 total checks)*" +`; + +exports[`formatChecks > renders terminal table > checks-table-terminal 1`] = ` +"NAME TYPE STATUS FREQ TAGS +My API Check API passing 10m production, api +Failing Browser Check BROWSER failing 10m production, browser +Inactive Check API inactive 10m - +Muted Check API passing 10m production, api" +`; + +exports[`formatChecks > renders terminal table with showId > checks-table-terminal-with-id 1`] = ` +"NAME TYPE STATUS FREQ TAGS ID +My API Check API passing 10m production, api check-1 +Failing Browser Check BROWSER failing 10m production, browser check-2 +Inactive Check API inactive 10m - check-3 +Muted Check API passing 10m production, api check-4" +`; + +exports[`formatErrorGroups > renders markdown error groups > error-groups-md 1`] = ` +"## Error Groups + +| Error | First Seen | Last Seen | ID | +| --- | --- | --- | --- | +| TimeoutError: page.click: Timeout 30000ms exceeded | 2025-06-01T00:00:00.000Z | 2025-06-15T12:00:00.000Z | eg-1 |" +`; + +exports[`formatErrorGroups > renders terminal error groups > error-groups-terminal 1`] = ` +"ERROR GROUPS +ERROR FIRST SEEN LAST SEEN +TimeoutError: page.click: Timeout 30000ms exceeded 14d ago 5m ago" +`; + +exports[`formatResults > renders markdown table > results-table-md 1`] = ` +"| Time | Location | Status | Response Time | ID | +| --- | --- | --- | --- | --- | +| 2025-06-15T12:00:00.000Z | eu-west-1 | passing | 245ms | result-1 | +| 2025-06-15T12:00:00.000Z | us-east-1 | failing | 5.20s | result-2 |" +`; + +exports[`formatResults > renders terminal table > results-table-terminal 1`] = ` +"TIME LOCATION STATUS RESPONSE TIME RESULT ID +5m ago eu-west-1 passing 245ms result-1 +5m ago us-east-1 failing 5.20s result-2" +`; diff --git a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts new file mode 100644 index 000000000..0ab066dba --- /dev/null +++ b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { stripAnsi } from '../render' +import { formatResultDetail } from '../check-result-detail' +import { + apiCheckResult, + apiCheckResultWithError, + browserCheckResult, + multiStepCheckResult, + minimalCheckResult, +} from './fixtures' + +// Pin time for formatDate used in result detail +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-06-15T12:05:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('formatResultDetail', () => { + describe('API check result', () => { + it('renders terminal output', () => { + const result = stripAnsi(formatResultDetail(apiCheckResult, 'terminal')) + expect(result).toMatchSnapshot('api-result-detail-terminal') + }) + + it('renders markdown output', () => { + const result = formatResultDetail(apiCheckResult, 'md') + expect(result).toMatchSnapshot('api-result-detail-md') + }) + + it('contains request and response sections in terminal', () => { + const result = stripAnsi(formatResultDetail(apiCheckResult, 'terminal')) + expect(result).toContain('REQUEST') + expect(result).toContain('RESPONSE') + expect(result).toContain('GET') + expect(result).toContain('https://api.example.com/health') + expect(result).toContain('200') + expect(result).toContain('TIMING') + expect(result).toContain('ASSERTIONS') + }) + }) + + describe('API check result with requestError and jobLog', () => { + it('renders ERROR section when requestError is set', () => { + const result = stripAnsi(formatResultDetail(apiCheckResultWithError, 'terminal')) + expect(result).toContain('ERROR') + expect(result).toContain('ECONNREFUSED') + }) + + it('renders request body when present', () => { + const result = stripAnsi(formatResultDetail(apiCheckResultWithError, 'terminal')) + expect(result).toContain('Body:') + expect(result).toContain('"query"') + }) + + it('renders jobLog from object with phases', () => { + const result = stripAnsi(formatResultDetail(apiCheckResultWithError, 'terminal')) + expect(result).toContain('JOB LOG') + expect(result).toContain('Setting up request') + expect(result).toContain('ECONNREFUSED') + }) + + it('omits TIMING section when timingPhases is null', () => { + const result = stripAnsi(formatResultDetail(apiCheckResultWithError, 'terminal')) + expect(result).not.toContain('TIMING') + }) + + it('renders terminal snapshot', () => { + const result = stripAnsi(formatResultDetail(apiCheckResultWithError, 'terminal')) + expect(result).toMatchSnapshot('api-result-with-error-terminal') + }) + }) + + describe('Browser check result', () => { + it('renders terminal output', () => { + const result = stripAnsi(formatResultDetail(browserCheckResult, 'terminal')) + expect(result).toMatchSnapshot('browser-result-detail-terminal') + }) + + it('renders markdown output', () => { + const result = formatResultDetail(browserCheckResult, 'md') + expect(result).toMatchSnapshot('browser-result-detail-md') + }) + + it('includes web vitals, errors, and assets in terminal', () => { + const result = stripAnsi(formatResultDetail(browserCheckResult, 'terminal')) + expect(result).toContain('BROWSER RESULT') + expect(result).toContain('WEB VITALS') + expect(result).toContain('ERRORS') + expect(result).toContain('ASSETS') + expect(result).toContain('screenshot') + expect(result).toContain('trace') + expect(result).toContain('video') + }) + }) + + describe('Multi-step check result', () => { + it('renders terminal output', () => { + const result = stripAnsi(formatResultDetail(multiStepCheckResult, 'terminal')) + expect(result).toMatchSnapshot('multistep-result-detail-terminal') + }) + + it('includes errors, job log, and assets in terminal', () => { + const result = stripAnsi(formatResultDetail(multiStepCheckResult, 'terminal')) + expect(result).toContain('MULTI-STEP RESULT') + expect(result).toContain('ERRORS') + expect(result).toContain('Payment step failed') + expect(result).toContain('JOB LOG') + expect(result).toContain('ASSETS') + }) + }) + + describe('Minimal result (no sub-result)', () => { + it('renders only top-level fields in terminal', () => { + const result = stripAnsi(formatResultDetail(minimalCheckResult, 'terminal')) + expect(result).toContain('Heartbeat Check') + expect(result).toContain('passing') + expect(result).toContain('eu-west-1') + expect(result).toContain('50ms') + expect(result).not.toContain('REQUEST') + expect(result).not.toContain('BROWSER RESULT') + expect(result).not.toContain('MULTI-STEP RESULT') + }) + + it('renders only top-level fields in markdown', () => { + const result = formatResultDetail(minimalCheckResult, 'md') + expect(result).toContain('# Heartbeat Check') + expect(result).toContain('passing') + expect(result).not.toContain('## Request') + expect(result).not.toContain('## Browser') + }) + }) +}) diff --git a/packages/cli/src/formatters/__tests__/checks.spec.ts b/packages/cli/src/formatters/__tests__/checks.spec.ts new file mode 100644 index 000000000..ae5355e46 --- /dev/null +++ b/packages/cli/src/formatters/__tests__/checks.spec.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { stripAnsi } from '../render' +import { + formatSummaryBar, + formatPaginationInfo, + formatNavigationHints, + formatChecks, + formatCheckDetail, + formatResults, + formatErrorGroups, +} from '../checks' +import { + passingCheck, + failingCheck, + inactiveCheck, + mutedCheck, + errorCheck, + degradedCheck, + mutedFailingCheck, + mutedDegradedCheck, + checkWithPrivateLocations, + checkWithLowSsl, + checkWithMediumSsl, + checkWithNoSsl, + passingStatus, + failingStatus, + degradedStatus, + errorStatus, + apiCheckResult, + browserCheckResult, + activeErrorGroup, + archivedErrorGroup, +} from './fixtures' + +// Pin time for timeAgo used in results/error groups +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-06-15T12:05:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('formatSummaryBar', () => { + it('shows counts for passing, degraded, and failing', () => { + const statuses = [passingStatus, failingStatus, degradedStatus] + const result = stripAnsi(formatSummaryBar(statuses, 10)) + expect(result).toContain('1 passing') + expect(result).toContain('1 degraded') + expect(result).toContain('1 failing') + expect(result).toContain('10 total checks') + }) + + it('counts hasErrors status as failing', () => { + const statuses = [passingStatus, errorStatus] + const result = stripAnsi(formatSummaryBar(statuses, 2)) + expect(result).toContain('1 passing') + expect(result).toContain('1 failing') + }) + + it('filters by activeCheckIds when provided', () => { + const statuses = [passingStatus, failingStatus, degradedStatus] + const activeIds = new Set(['check-1']) + const result = stripAnsi(formatSummaryBar(statuses, 3, activeIds)) + expect(result).toContain('1 passing') + expect(result).not.toContain('failing') + expect(result).not.toContain('degraded') + }) +}) + +describe('formatPaginationInfo', () => { + it('shows correct range and page numbers', () => { + const result = stripAnsi(formatPaginationInfo({ page: 2, limit: 10, total: 35 })) + expect(result).toContain('11-20') + expect(result).toContain('35 checks') + expect(result).toContain('page 2/4') + }) + + it('clamps end to total on last page', () => { + const result = stripAnsi(formatPaginationInfo({ page: 4, limit: 10, total: 35 })) + expect(result).toContain('31-35') + }) +}) + +describe('formatNavigationHints', () => { + it('shows next page hint when not on last page', () => { + const result = stripAnsi(formatNavigationHints({ page: 1, limit: 10, total: 25 }, [])) + expect(result).toContain('--page 2') + expect(result).toContain('View check') + expect(result).toContain('Filter') + }) + + it('shows prev page hint when not on first page', () => { + const result = stripAnsi(formatNavigationHints({ page: 2, limit: 10, total: 25 }, [])) + expect(result).toContain('--page 1') + expect(result).toContain('--page 3') + }) + + it('omits filter hint when filters are active', () => { + const result = stripAnsi(formatNavigationHints({ page: 1, limit: 10, total: 25 }, ['--tag prod'])) + expect(result).not.toContain('Filter') + }) +}) + +describe('formatChecks — status edge cases', () => { + it('renders hasErrors check as failing in terminal', () => { + const result = stripAnsi(formatChecks([errorCheck], 'terminal')) + expect(result).toContain('failing') + }) + + it('renders hasErrors check as failing in md', () => { + const result = formatChecks([errorCheck], 'md') + expect(result).toContain('failing') + }) + + it('renders degraded check', () => { + const result = stripAnsi(formatChecks([degradedCheck], 'terminal')) + expect(result).toContain('degraded') + }) + + it('renders muted failing check as failing (muted) in md', () => { + const result = formatChecks([mutedFailingCheck], 'md') + expect(result).toContain('failing (muted)') + }) + + it('renders muted degraded check as degraded (muted) in md', () => { + const result = formatChecks([mutedDegradedCheck], 'md') + expect(result).toContain('degraded (muted)') + }) + + it('renders muted failing check in terminal', () => { + const result = stripAnsi(formatChecks([mutedFailingCheck], 'terminal')) + expect(result).toContain('failing') + }) + + it('renders muted degraded check in terminal', () => { + const result = stripAnsi(formatChecks([mutedDegradedCheck], 'terminal')) + expect(result).toContain('degraded') + }) +}) + +describe('formatChecks', () => { + const checks = [passingCheck, failingCheck, inactiveCheck, mutedCheck] + + it('renders terminal table', () => { + const result = stripAnsi(formatChecks(checks, 'terminal')) + expect(result).toMatchSnapshot('checks-table-terminal') + }) + + it('renders terminal table with showId', () => { + const result = stripAnsi(formatChecks(checks, 'terminal', { showId: true })) + expect(result).toMatchSnapshot('checks-table-terminal-with-id') + }) + + it('renders markdown table', () => { + const result = formatChecks(checks, 'md') + expect(result).toMatchSnapshot('checks-table-md') + }) + + it('renders markdown with pagination', () => { + const result = formatChecks(checks, 'md', { pagination: { page: 1, limit: 10, total: 25 } }) + expect(result).toMatchSnapshot('checks-table-md-paginated') + }) + + it('contains expected header columns in terminal', () => { + const result = stripAnsi(formatChecks(checks, 'terminal')) + expect(result).toContain('NAME') + expect(result).toContain('TYPE') + expect(result).toContain('STATUS') + expect(result).toContain('FREQ') + expect(result).toContain('TAGS') + }) + + it('contains expected header columns in markdown', () => { + const result = formatChecks(checks, 'md') + expect(result).toContain('| Name |') + expect(result).toContain('| --- |') + }) +}) + +describe('formatCheckDetail', () => { + it('renders terminal detail', () => { + const result = stripAnsi(formatCheckDetail(passingCheck, 'terminal')) + expect(result).toMatchSnapshot('check-detail-terminal') + }) + + it('renders markdown detail', () => { + const result = formatCheckDetail(passingCheck, 'md') + expect(result).toMatchSnapshot('check-detail-md') + }) + + it('contains key fields in terminal output', () => { + const result = stripAnsi(formatCheckDetail(passingCheck, 'terminal')) + expect(result).toContain('My API Check') + expect(result).toContain('API') + expect(result).toContain('passing') + expect(result).toContain('eu-west-1') + expect(result).toContain('check-1') + }) + + it('shows inactive status for deactivated check', () => { + const result = stripAnsi(formatCheckDetail(inactiveCheck, 'terminal')) + expect(result).toContain('inactive') + }) + + it('shows scriptPath when present', () => { + const checkWithScript = { ...passingCheck, scriptPath: '__checks__/api.check.ts' } + const result = stripAnsi(formatCheckDetail(checkWithScript, 'terminal')) + expect(result).toContain('__checks__/api.check.ts') + }) + + it('shows hasErrors status as failing', () => { + const result = stripAnsi(formatCheckDetail(errorCheck, 'terminal')) + expect(result).toContain('failing') + }) + + it('merges privateLocations with (private) suffix', () => { + const result = stripAnsi(formatCheckDetail(checkWithPrivateLocations, 'terminal')) + expect(result).toContain('eu-west-1') + expect(result).toContain('on-prem-dc-1 (private)') + expect(result).toContain('on-prem-dc-2 (private)') + }) + + it('shows privateLocations in md', () => { + const result = formatCheckDetail(checkWithPrivateLocations, 'md') + expect(result).toContain('on-prem-dc-1 (private)') + }) + + it('shows SSL days remaining with low threshold', () => { + const result = stripAnsi(formatCheckDetail(checkWithLowSsl, 'terminal')) + expect(result).toContain('7 days remaining') + }) + + it('shows SSL days remaining with medium threshold', () => { + const result = stripAnsi(formatCheckDetail(checkWithMediumSsl, 'terminal')) + expect(result).toContain('20 days remaining') + }) + + it('omits SSL line when sslDaysRemaining is null in terminal', () => { + const result = stripAnsi(formatCheckDetail(checkWithNoSsl, 'terminal')) + expect(result).not.toContain('days remaining') + }) + + it('shows SSL as dash when null in md', () => { + const result = formatCheckDetail(checkWithNoSsl, 'md') + expect(result).toContain('| SSL | - |') + }) +}) + +describe('formatResults', () => { + const results = [apiCheckResult, browserCheckResult] + + it('renders terminal table', () => { + const result = stripAnsi(formatResults(results, 'terminal')) + expect(result).toMatchSnapshot('results-table-terminal') + }) + + it('renders markdown table', () => { + const result = formatResults(results, 'md') + expect(result).toMatchSnapshot('results-table-md') + }) + + it('contains expected header columns in terminal', () => { + const result = stripAnsi(formatResults(results, 'terminal')) + expect(result).toContain('TIME') + expect(result).toContain('LOCATION') + expect(result).toContain('STATUS') + expect(result).toContain('RESPONSE TIME') + }) +}) + +describe('formatErrorGroups', () => { + it('renders terminal error groups', () => { + const result = stripAnsi(formatErrorGroups([activeErrorGroup, archivedErrorGroup], 'terminal')) + expect(result).toMatchSnapshot('error-groups-terminal') + }) + + it('renders markdown error groups', () => { + const result = formatErrorGroups([activeErrorGroup, archivedErrorGroup], 'md') + expect(result).toMatchSnapshot('error-groups-md') + }) + + it('returns empty string when all groups are archived', () => { + expect(formatErrorGroups([archivedErrorGroup], 'terminal')).toBe('') + expect(formatErrorGroups([archivedErrorGroup], 'md')).toBe('') + }) + + it('returns empty string for empty array', () => { + expect(formatErrorGroups([], 'terminal')).toBe('') + }) +}) diff --git a/packages/cli/src/formatters/__tests__/fixtures.ts b/packages/cli/src/formatters/__tests__/fixtures.ts new file mode 100644 index 000000000..7de8e126f --- /dev/null +++ b/packages/cli/src/formatters/__tests__/fixtures.ts @@ -0,0 +1,378 @@ +import type { Check } from '../../rest/checks' +import type { CheckStatus } from '../../rest/check-statuses' +import type { CheckResult, ApiCheckResult, BrowserCheckResult, MultiStepCheckResult } from '../../rest/check-results' +import type { ErrorGroup } from '../../rest/error-groups' +import type { CheckWithStatus } from '../checks' + +// --- Check statuses --- + +export const passingStatus: CheckStatus = { + name: 'My API Check', + checkId: 'check-1', + hasFailures: false, + hasErrors: false, + isDegraded: false, + longestRun: 500, + shortestRun: 100, + lastRunLocation: 'eu-west-1', + lastCheckRunId: 'run-1', + sslDaysRemaining: 90, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-06-15T12:00:00.000Z', +} + +export const failingStatus: CheckStatus = { + ...passingStatus, + name: 'Failing Check', + checkId: 'check-2', + hasFailures: true, + hasErrors: false, + isDegraded: false, +} + +export const degradedStatus: CheckStatus = { + ...passingStatus, + name: 'Degraded Check', + checkId: 'check-3', + hasFailures: false, + hasErrors: false, + isDegraded: true, +} + +// --- Base check --- + +const baseCheck: Check = { + id: 'check-1', + name: 'My API Check', + checkType: 'API', + activated: true, + muted: false, + frequency: 10, + frequencyOffset: 0, + locations: ['eu-west-1', 'us-east-1'], + privateLocations: [], + tags: ['production', 'api'], + groupId: null, + groupOrder: null, + runtimeId: '2024.02', + scriptPath: null, + request: { url: 'https://api.example.com/health', method: 'GET' }, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-06-15T12:00:00.000Z', +} + +// --- Checks with status --- + +export const passingCheck: CheckWithStatus = { + ...baseCheck, + status: passingStatus, +} + +export const failingCheck: CheckWithStatus = { + ...baseCheck, + id: 'check-2', + name: 'Failing Browser Check', + checkType: 'BROWSER', + tags: ['production', 'browser'], + status: failingStatus, +} + +export const inactiveCheck: CheckWithStatus = { + ...baseCheck, + id: 'check-3', + name: 'Inactive Check', + activated: false, + tags: [], + status: undefined, +} + +export const mutedCheck: CheckWithStatus = { + ...baseCheck, + id: 'check-4', + name: 'Muted Check', + muted: true, + status: passingStatus, +} + +export const errorStatus: CheckStatus = { + ...passingStatus, + name: 'Error Check', + checkId: 'check-5', + hasFailures: false, + hasErrors: true, + isDegraded: false, +} + +// --- Additional checks with status --- + +export const errorCheck: CheckWithStatus = { + ...baseCheck, + id: 'check-5', + name: 'Error Check', + checkType: 'API', + tags: ['production'], + status: errorStatus, +} + +export const degradedCheck: CheckWithStatus = { + ...baseCheck, + id: 'check-6', + name: 'Degraded Check', + checkType: 'API', + tags: ['staging'], + status: degradedStatus, +} + +export const mutedFailingCheck: CheckWithStatus = { + ...baseCheck, + id: 'check-7', + name: 'Muted Failing Check', + muted: true, + status: failingStatus, +} + +export const mutedDegradedCheck: CheckWithStatus = { + ...baseCheck, + id: 'check-8', + name: 'Muted Degraded Check', + muted: true, + status: degradedStatus, +} + +export const checkWithPrivateLocations: CheckWithStatus = { + ...baseCheck, + id: 'check-9', + name: 'Private Location Check', + locations: ['eu-west-1'], + privateLocations: ['on-prem-dc-1', 'on-prem-dc-2'], + status: passingStatus, +} + +export const checkWithLowSsl: CheckWithStatus = { + ...baseCheck, + id: 'check-10', + name: 'Low SSL Check', + status: { + ...passingStatus, + checkId: 'check-10', + sslDaysRemaining: 7, + }, +} + +export const checkWithMediumSsl: CheckWithStatus = { + ...baseCheck, + id: 'check-11', + name: 'Medium SSL Check', + status: { + ...passingStatus, + checkId: 'check-11', + sslDaysRemaining: 20, + }, +} + +export const checkWithNoSsl: CheckWithStatus = { + ...baseCheck, + id: 'check-12', + name: 'No SSL Check', + status: { + ...passingStatus, + checkId: 'check-12', + sslDaysRemaining: null, + }, +} + +// --- Check results --- + +export const apiCheckResult: CheckResult = { + id: 'result-1', + checkId: 'check-1', + name: 'My API Check', + hasFailures: false, + hasErrors: false, + isDegraded: false, + overMaxResponseTime: false, + runLocation: 'eu-west-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:01.000Z', + created_at: '2025-06-15T12:00:01.000Z', + responseTime: 245, + checkRunId: 1001, + attempts: 1, + resultType: 'FINAL', + apiCheckResult: { + assertions: [ + { source: 'STATUS_CODE', comparison: 'EQUALS', target: 200 }, + { source: 'JSON_BODY', property: '$.status', comparison: 'EQUALS', target: 'ok' }, + ], + request: { + method: 'GET', + url: 'https://api.example.com/health', + data: '', + headers: { Accept: 'application/json' }, + params: {}, + }, + response: { + status: 200, + statusText: '200 OK', + body: '{"status":"ok","version":"1.2.3"}', + headers: { 'content-type': 'application/json' }, + timings: { socket: 1, lookup: 5, connect: 10, response: 200, end: 245 }, + timingPhases: { dns: 5, tcp: 10, wait: 15, firstByte: 200, download: 15, total: 245 }, + }, + requestError: null, + jobLog: null, + jobAssets: null, + pcapDataUrl: null, + } as ApiCheckResult, +} + +export const browserCheckResult: CheckResult = { + id: 'result-2', + checkId: 'check-2', + name: 'Failing Browser Check', + hasFailures: true, + hasErrors: false, + isDegraded: false, + overMaxResponseTime: false, + runLocation: 'us-east-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:05.000Z', + created_at: '2025-06-15T12:00:05.000Z', + responseTime: 5200, + checkRunId: 1002, + attempts: 1, + resultType: 'FINAL', + browserCheckResult: { + type: 'playwright', + traceSummary: { + consoleErrors: 2, + networkErrors: 1, + documentErrors: 0, + userScriptErrors: 0, + }, + errors: ['TimeoutError: page.click: Timeout 30000ms exceeded'], + pages: [ + { + url: 'https://app.example.com/dashboard', + webVitals: { + LCP: { score: 'GOOD', value: 1200 }, + FCP: { score: 'GOOD', value: 800 }, + CLS: { score: 'NEEDS_IMPROVEMENT', value: 0.15 }, + TBT: { score: 'POOR', value: 600 }, + TTFB: { score: 'GOOD', value: 120 }, + }, + }, + ], + startTime: 0, + endTime: 5200, + runtimeVersion: '2024.02', + jobLog: [ + { time: 0, msg: 'Starting browser check', level: 'INFO' }, + { time: 5000, msg: 'TimeoutError: page.click: Timeout 30000ms exceeded', level: 'ERROR' }, + ], + jobAssets: ['screenshot-1.png'], + playwrightTestTraces: ['trace-1.zip'], + playwrightTestVideos: ['video-1.webm'], + } as BrowserCheckResult, +} + +export const multiStepCheckResult: CheckResult = { + id: 'result-3', + checkId: 'check-5', + name: 'Multi-Step Checkout Flow', + hasFailures: false, + hasErrors: true, + isDegraded: null, + overMaxResponseTime: false, + runLocation: 'ap-southeast-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:08.000Z', + created_at: '2025-06-15T12:00:08.000Z', + responseTime: 8000, + checkRunId: 1003, + attempts: 2, + resultType: 'FINAL', + multiStepCheckResult: { + errors: ['Error: Payment step failed', 'AssertionError: Expected cart total to match'], + startTime: 0, + endTime: 8000, + runtimeVersion: '2024.02', + jobLog: [ + { time: 0, msg: 'Step 1: Navigate to homepage', level: 'INFO' }, + { time: 2000, msg: 'Step 2: Add item to cart', level: 'INFO' }, + { time: 5000, msg: 'Step 3: Checkout', level: 'INFO' }, + { time: 7500, msg: 'Error: Payment step failed', level: 'ERROR' }, + ], + jobAssets: ['screenshot-failure.png'], + playwrightTestTraces: ['trace-checkout.zip'], + } as MultiStepCheckResult, +} + +export const apiCheckResultWithError: CheckResult = { + ...apiCheckResult, + id: 'result-5', + hasErrors: true, + apiCheckResult: { + ...apiCheckResult.apiCheckResult!, + requestError: 'ECONNREFUSED: Connection refused to https://api.example.com/health', + request: { + ...apiCheckResult.apiCheckResult!.request, + data: '{"query":"test","filters":{"active":true}}', + }, + response: { + ...apiCheckResult.apiCheckResult!.response, + status: 0, + statusText: '', + body: '', + headers: null, + timingPhases: null, + }, + jobLog: { + setup: [{ time: 0, msg: 'Setting up request', level: 'INFO' }], + request: [{ time: 100, msg: 'ECONNREFUSED', level: 'ERROR' }], + teardown: [], + }, + } as ApiCheckResult, +} + +export const minimalCheckResult: CheckResult = { + id: 'result-4', + checkId: 'check-6', + name: 'Heartbeat Check', + hasFailures: false, + hasErrors: false, + isDegraded: null, + overMaxResponseTime: null, + runLocation: 'eu-west-1', + startedAt: '2025-06-15T12:00:00.000Z', + stoppedAt: '2025-06-15T12:00:00.500Z', + created_at: '2025-06-15T12:00:00.500Z', + responseTime: 50, + checkRunId: 1004, + attempts: 1, + resultType: 'FINAL', +} + +// --- Error groups --- + +export const activeErrorGroup: ErrorGroup = { + id: 'eg-1', + checkId: 'check-2', + errorHash: 'abc123', + rawErrorMessage: 'TimeoutError: page.click: Timeout 30000ms exceeded', + cleanedErrorMessage: 'TimeoutError: page.click: Timeout 30000ms exceeded', + firstSeen: '2025-06-01T00:00:00.000Z', + lastSeen: '2025-06-15T12:00:00.000Z', + archivedUntilNextEvent: false, +} + +export const archivedErrorGroup: ErrorGroup = { + id: 'eg-2', + checkId: 'check-2', + errorHash: 'def456', + rawErrorMessage: 'NetworkError: Failed to fetch', + cleanedErrorMessage: 'NetworkError: Failed to fetch', + firstSeen: '2025-05-01T00:00:00.000Z', + lastSeen: '2025-05-15T00:00:00.000Z', + archivedUntilNextEvent: true, +} diff --git a/packages/cli/src/formatters/__tests__/render.spec.ts b/packages/cli/src/formatters/__tests__/render.spec.ts new file mode 100644 index 000000000..4291cf4fa --- /dev/null +++ b/packages/cli/src/formatters/__tests__/render.spec.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + stripAnsi, + visWidth, + padColumn, + truncateToWidth, + heading, + formatMs, + timeAgo, + formatFrequency, + formatCheckType, + formatDate, + resolveResultStatus, + truncateError, + renderDetailFields, + renderTable, + type DetailField, + type ColumnDef, +} from '../render' + +describe('stripAnsi', () => { + it('removes ANSI escape codes', () => { + expect(stripAnsi('\u001B[1mBold\u001B[0m')).toBe('Bold') + expect(stripAnsi('\u001B[31mred\u001B[39m text')).toBe('red text') + }) + + it('returns clean strings unchanged', () => { + expect(stripAnsi('hello world')).toBe('hello world') + }) +}) + +describe('visWidth', () => { + it('returns correct width for plain text', () => { + expect(visWidth('hello')).toBe(5) + }) + + it('ignores ANSI codes in width calculation', () => { + expect(visWidth('\u001B[1mBold\u001B[0m')).toBe(4) + }) +}) + +describe('padColumn', () => { + it('pads value to target width', () => { + const result = padColumn('hi', 10) + expect(result).toBe('hi ') + expect(result.length).toBe(10) + }) + + it('does not truncate values wider than target', () => { + const result = padColumn('hello world', 5) + expect(result).toBe('hello world') + }) +}) + +describe('truncateToWidth', () => { + it('returns value unchanged when it fits', () => { + expect(truncateToWidth('short', 10)).toBe('short') + }) + + it('truncates with ellipsis when too long', () => { + const result = truncateToWidth('this is a very long string', 10) + expect(result.length).toBeLessThanOrEqual(11) // ellipsis is multi-byte + expect(result).toContain('…') + }) +}) + +describe('heading', () => { + it('returns markdown heading with # prefix', () => { + expect(heading('Title', 1, 'md')).toBe('# Title') + expect(heading('Sub', 2, 'md')).toBe('## Sub') + expect(heading('Deep', 3, 'md')).toBe('### Deep') + }) + + it('returns bold text for terminal format', () => { + const result = heading('Title', 1, 'terminal') + expect(stripAnsi(result)).toBe('Title') + // chalk.bold wraps text; in non-TTY it may strip ANSI, so just verify content + expect(result).toContain('Title') + }) +}) + +describe('formatMs', () => { + it('formats milliseconds under 1000', () => { + expect(formatMs(245)).toBe('245ms') + expect(formatMs(0)).toBe('0ms') + expect(formatMs(999)).toBe('999ms') + }) + + it('formats boundary value 999.6 as seconds not 1000ms', () => { + expect(formatMs(999.6)).toBe('1.00s') + }) + + it('formats milliseconds 1000+ as seconds', () => { + expect(formatMs(1000)).toBe('1.00s') + expect(formatMs(1500)).toBe('1.50s') + expect(formatMs(12345)).toBe('12.35s') + }) +}) + +describe('timeAgo', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-06-15T12:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shows seconds ago', () => { + expect(timeAgo('2025-06-15T11:59:30.000Z')).toBe('30s ago') + }) + + it('shows minutes ago', () => { + expect(timeAgo('2025-06-15T11:55:00.000Z')).toBe('5m ago') + }) + + it('shows hours ago', () => { + expect(timeAgo('2025-06-15T09:00:00.000Z')).toBe('3h ago') + }) + + it('shows days ago', () => { + expect(timeAgo('2025-06-10T12:00:00.000Z')).toBe('5d ago') + }) + + it('shows months ago', () => { + expect(timeAgo('2025-03-15T12:00:00.000Z')).toBe('3mo ago') + }) +}) + +describe('formatFrequency', () => { + it('returns dash for null/undefined', () => { + expect(formatFrequency(null)).toBe('-') + expect(formatFrequency(undefined)).toBe('-') + }) + + it('returns <1m for zero', () => { + expect(formatFrequency(0)).toBe('<1m') + }) + + it('returns minutes for values under 60', () => { + expect(formatFrequency(5)).toBe('5m') + expect(formatFrequency(30)).toBe('30m') + }) + + it('returns hours for values evenly divisible by 60', () => { + expect(formatFrequency(60)).toBe('1h') + expect(formatFrequency(120)).toBe('2h') + }) + + it('returns minutes for values not evenly divisible by 60', () => { + expect(formatFrequency(90)).toBe('90m') + }) +}) + +describe('formatCheckType', () => { + it('maps known check types', () => { + expect(formatCheckType('API')).toBe('API') + expect(formatCheckType('BROWSER')).toBe('BROWSER') + expect(formatCheckType('MULTI_STEP')).toBe('MULTISTEP') + expect(formatCheckType('HEARTBEAT')).toBe('HEARTBEAT') + expect(formatCheckType('TCP')).toBe('TCP') + }) + + it('falls back to input for unknown types', () => { + expect(formatCheckType('UNKNOWN_TYPE')).toBe('UNKNOWN_TYPE') + }) +}) + +describe('formatDate', () => { + it('formats a valid date string', () => { + const result = formatDate('2025-06-15T12:30:45.123Z', 'terminal') + expect(stripAnsi(result)).toBe('2025-06-15 12:30:45 UTC') + }) + + it('returns dash for null/undefined in terminal', () => { + const result = formatDate(null, 'terminal') + expect(stripAnsi(result)).toBe('-') + }) + + it('returns dash for null/undefined in md', () => { + expect(formatDate(undefined, 'md')).toBe('-') + }) + + it('formats consistently in both formats', () => { + const date = '2025-01-01T00:00:00.000Z' + const termResult = stripAnsi(formatDate(date, 'terminal')) + const mdResult = formatDate(date, 'md') + expect(termResult).toBe(mdResult) + }) +}) + +describe('resolveResultStatus', () => { + it('returns error for hasErrors', () => { + const result = { hasErrors: true, hasFailures: false, isDegraded: false } + expect(resolveResultStatus(result, 'md')).toBe('error') + expect(stripAnsi(resolveResultStatus(result, 'terminal'))).toBe('error') + }) + + it('returns failing for hasFailures', () => { + const result = { hasErrors: false, hasFailures: true, isDegraded: false } + expect(resolveResultStatus(result, 'md')).toBe('failing') + expect(stripAnsi(resolveResultStatus(result, 'terminal'))).toBe('failing') + }) + + it('returns degraded for isDegraded', () => { + const result = { hasErrors: false, hasFailures: false, isDegraded: true } + expect(resolveResultStatus(result, 'md')).toBe('degraded') + expect(stripAnsi(resolveResultStatus(result, 'terminal'))).toBe('degraded') + }) + + it('returns passing when all false', () => { + const result = { hasErrors: false, hasFailures: false, isDegraded: false } + expect(resolveResultStatus(result, 'md')).toBe('passing') + expect(stripAnsi(resolveResultStatus(result, 'terminal'))).toBe('passing') + }) + + it('returns plain labels in terminal format', () => { + const passing = resolveResultStatus({ hasErrors: false, hasFailures: false, isDegraded: false }, 'terminal') + const failing = resolveResultStatus({ hasErrors: false, hasFailures: true, isDegraded: false }, 'terminal') + expect(stripAnsi(passing)).toBe('passing') + expect(stripAnsi(failing)).toBe('failing') + }) + + it('hasErrors takes precedence over hasFailures and isDegraded', () => { + const result = { hasErrors: true, hasFailures: true, isDegraded: true } + expect(resolveResultStatus(result, 'md')).toBe('error') + }) + + it('hasFailures takes precedence over isDegraded', () => { + const result = { hasErrors: false, hasFailures: true, isDegraded: true } + expect(resolveResultStatus(result, 'md')).toBe('failing') + }) + + it('resolves passing when isDegraded is null', () => { + const result = { hasErrors: false, hasFailures: false, isDegraded: null } + expect(resolveResultStatus(result, 'md')).toBe('passing') + }) +}) + +describe('truncateError', () => { + it('returns short messages unchanged', () => { + expect(truncateError('short error', 50)).toBe('short error') + }) + + it('truncates long messages with ellipsis', () => { + const long = 'a'.repeat(100) + const result = truncateError(long, 50) + expect(result.length).toBe(50) + expect(result.endsWith('…')).toBe(true) + }) + + it('strips ANSI codes and normalizes whitespace', () => { + const input = '\u001B[31mError:\u001B[0m\nmulti\n line message' + const result = truncateError(input, 100) + expect(result).toBe('Error: multi line message') + }) +}) + +describe('renderDetailFields', () => { + interface TestItem { name: string, age: number, optional?: string } + + const fields: DetailField[] = [ + { label: 'Name', value: item => item.name }, + { label: 'Age', value: item => String(item.age) }, + { label: 'Optional', value: item => item.optional ?? null }, + ] + + it('renders terminal detail with title and aligned labels', () => { + const result = stripAnsi(renderDetailFields('Test Title', fields, { name: 'Alice', age: 30 }, 'terminal')) + expect(result).toContain('Test Title') + expect(result).toContain('Name:') + expect(result).toContain('Alice') + expect(result).toContain('Age:') + expect(result).toContain('30') + }) + + it('omits fields that return null in terminal', () => { + const result = stripAnsi(renderDetailFields('Test', fields, { name: 'Alice', age: 30 }, 'terminal')) + expect(result).not.toContain('Optional') + }) + + it('includes fields when value is not null in terminal', () => { + const result = stripAnsi(renderDetailFields('Test', fields, { name: 'Bob', age: 25, optional: 'present' }, 'terminal')) + expect(result).toContain('Optional:') + expect(result).toContain('present') + }) + + it('renders markdown detail with table format', () => { + const result = renderDetailFields('Test Title', fields, { name: 'Alice', age: 30 }, 'md') + expect(result).toContain('# Test Title') + expect(result).toContain('| Field | Value |') + expect(result).toContain('| --- | --- |') + expect(result).toContain('| Name | Alice |') + expect(result).toContain('| Age | 30 |') + }) + + it('omits fields that return null in markdown', () => { + const result = renderDetailFields('Test', fields, { name: 'Alice', age: 30 }, 'md') + expect(result).not.toContain('Optional') + }) + + it('aligns terminal labels to consistent column', () => { + const result = stripAnsi(renderDetailFields('T', fields, { name: 'A', age: 1, optional: 'x' }, 'terminal')) + const lines = result.split('\n').slice(2) // skip title + empty line + const valuePositions = lines.map(l => { + const match = l.match(/:\s+/) + return match ? l.indexOf(match[0]) + match[0].length : -1 + }) + // All values should start at the same column + expect(new Set(valuePositions).size).toBe(1) + }) +}) + +describe('renderTable', () => { + interface Row { name: string, score: number } + + const columns: ColumnDef[] = [ + { header: 'Name', width: 12, value: r => r.name }, + { header: 'Score', value: r => String(r.score) }, + ] + + const rows: Row[] = [ + { name: 'Alice', score: 95 }, + { name: 'Bob', score: 87 }, + ] + + it('renders terminal table with uppercased bold headers', () => { + const result = stripAnsi(renderTable(columns, rows, 'terminal')) + expect(result).toContain('NAME') + expect(result).toContain('SCORE') + }) + + it('renders terminal table with padded columns and data', () => { + const result = stripAnsi(renderTable(columns, rows, 'terminal')) + expect(result).toContain('Alice') + expect(result).toContain('95') + expect(result).toContain('Bob') + expect(result).toContain('87') + }) + + it('renders markdown table with header and separator', () => { + const result = renderTable(columns, rows, 'md') + expect(result).toContain('| Name | Score |') + expect(result).toContain('| --- | --- |') + expect(result).toContain('| Alice | 95 |') + expect(result).toContain('| Bob | 87 |') + }) + + it('handles empty rows in terminal', () => { + const result = stripAnsi(renderTable(columns, [], 'terminal')) + const lines = result.split('\n') + expect(lines).toHaveLength(1) // header only + expect(lines[0]).toContain('NAME') + }) + + it('handles empty rows in markdown', () => { + const result = renderTable(columns, [], 'md') + const lines = result.split('\n') + expect(lines).toHaveLength(2) // header + separator + }) + + it('last column has no padding in terminal', () => { + const result = stripAnsi(renderTable(columns, rows, 'terminal')) + const lines = result.split('\n') + // Last column value should not have trailing spaces + expect(lines[1].trimEnd()).toBe(lines[1]) + }) +}) + +describe('field-count parity', () => { + it('checkDetailFields has expected field count', async () => { + const { checkDetailFields } = await import('../checks') + expect(checkDetailFields).toHaveLength(14) + }) + + it('resultDetailFields has expected field count', async () => { + const { resultDetailFields } = await import('../check-result-detail') + expect(resultDetailFields).toHaveLength(8) + }) +}) diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts new file mode 100644 index 000000000..5e853bc0d --- /dev/null +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -0,0 +1,447 @@ +import chalk from 'chalk' +import type { + CheckResult, + ApiCheckResult, + BrowserCheckResult, + MultiStepCheckResult, + WebVitalEntry, +} from '../rest/check-results' +import { + type OutputFormat, + type DetailField, + formatMs, + formatDate, + resolveResultStatus, + heading, + renderDetailFields, +} from './render' + +// --- Helpers --- + +function label (text: string, width = 16): string { + return chalk.dim(text.padEnd(width)) +} + +// --- Top-level result detail fields --- + +export const resultDetailFields: DetailField[] = [ + { label: 'Status', value: (r, fmt) => resolveResultStatus(r, fmt) }, + { label: 'Location', value: r => r.runLocation }, + { label: 'Response time', value: r => formatMs(r.responseTime) }, + { label: 'Started', value: (r, fmt) => fmt === 'md' ? (r.startedAt || '-') : formatDate(r.startedAt, fmt) }, + { label: 'Stopped', value: (r, fmt) => fmt === 'md' ? (r.stoppedAt || '-') : formatDate(r.stoppedAt, fmt) }, + { label: 'Attempts', value: r => String(r.attempts) }, + { label: 'Result type', value: r => r.resultType }, + { label: 'ID', value: r => r.id }, +] + +export function formatResultDetail (result: CheckResult, format: OutputFormat): string { + const parts: string[] = [renderDetailFields(result.name, resultDetailFields, result, format)] + + if (result.apiCheckResult) { + const subLines = format === 'md' + ? formatApiResultMd(result.apiCheckResult) + : formatApiResultTerminal(result.apiCheckResult) + parts.push(subLines.join('\n')) + } + + if (result.browserCheckResult) { + const subLines = format === 'md' + ? formatBrowserResultMd(result.browserCheckResult) + : formatBrowserResultTerminal(result.browserCheckResult) + parts.push(subLines.join('\n')) + } + + if (result.multiStepCheckResult && format === 'terminal') { + parts.push(formatMultiStepResultTerminal(result.multiStepCheckResult).join('\n')) + } + + return parts.join('\n\n') +} + +// --- API check result (terminal) --- + +function formatApiResultTerminal (api: ApiCheckResult): string[] { + const lines: string[] = [] + + lines.push(heading('REQUEST', 2, 'terminal')) + lines.push(`${label('Method:')}${api.request.method}`) + lines.push(`${label('URL:')}${api.request.url}`) + + const reqHeaders = Object.entries(api.request.headers || {}) + if (reqHeaders.length > 0) { + lines.push(`${label('Headers:')}`) + for (const [key, val] of reqHeaders) { + lines.push(` ${chalk.dim(key + ':')} ${val}`) + } + } + + if (api.request.data) { + lines.push(`${label('Body:')}`) + lines.push(formatBody(api.request.data, ' ')) + } + + lines.push('') + lines.push(heading('RESPONSE', 2, 'terminal')) + const reasonPhrase = (api.response.statusText || '').replace(/^\d+\s*/, '') + lines.push(`${label('Status:')}${colorStatus(api.response.status)} ${reasonPhrase}`) + + const respHeaders = Object.entries(api.response.headers || {}) + if (respHeaders.length > 0) { + lines.push(`${label('Headers:')}`) + for (const [key, val] of respHeaders) { + lines.push(` ${chalk.dim(key + ':')} ${val}`) + } + } + + if (api.response.body) { + lines.push(`${label('Body:')}`) + lines.push(formatBody(api.response.body, ' ')) + } + + if (api.response.timingPhases) { + lines.push('') + lines.push(heading('TIMING', 2, 'terminal')) + lines.push(formatTimingBar(api.response.timingPhases)) + } + + if (api.assertions && api.assertions.length > 0) { + lines.push('') + lines.push(heading('ASSERTIONS', 2, 'terminal')) + for (const a of api.assertions) { + const src = a.property ? `${a.source}.${a.property}` : a.source + lines.push(` ${chalk.dim('·')} ${src} ${a.comparison} ${a.target}`) + } + } + + if (api.requestError) { + lines.push('') + lines.push(heading('ERROR', 2, 'terminal')) + lines.push(chalk.red(` ${api.requestError}`)) + } + + if (api.jobLog) { + lines.push('') + lines.push(...formatJobLogObject(api.jobLog)) + } + + return lines +} + +// --- API check result (markdown) --- + +function formatApiResultMd (api: ApiCheckResult): string[] { + const lines: string[] = [] + + lines.push('## Request') + lines.push(`\`${api.request.method} ${api.request.url}\``) + + lines.push('') + lines.push('## Response') + const mdReason = (api.response.statusText || '').replace(/^\d+\s*/, '') + lines.push(`**${api.response.status} ${mdReason}**`) + + if (api.response.body) { + lines.push('') + lines.push('```json') + lines.push(api.response.body.slice(0, 2000)) + lines.push('```') + } + + if (api.response.timingPhases) { + const tp = api.response.timingPhases + lines.push('') + lines.push('## Timing') + lines.push('| Phase | Duration |') + lines.push('| --- | --- |') + lines.push(`| DNS | ${formatMs(tp.dns)} |`) + lines.push(`| TCP | ${formatMs(tp.tcp)} |`) + lines.push(`| TLS | ${formatMs(tp.wait)} |`) + lines.push(`| TTFB | ${formatMs(tp.firstByte)} |`) + lines.push(`| Download | ${formatMs(tp.download)} |`) + lines.push(`| **Total** | **${formatMs(tp.total)}** |`) + } + + if (api.assertions && api.assertions.length > 0) { + lines.push('') + lines.push('## Assertions') + for (const a of api.assertions) { + const src = a.property ? `${a.source}.${a.property}` : a.source + lines.push(`- ${src} ${a.comparison} ${a.target}`) + } + } + + return lines +} + +// --- Browser check result (terminal) --- + +function formatBrowserResultTerminal (browser: BrowserCheckResult): string[] { + const lines: string[] = [] + + lines.push(heading('BROWSER RESULT', 2, 'terminal')) + lines.push(`${label('Framework:')}${browser.type}`) + lines.push(`${label('Runtime:')}${browser.runtimeVersion}`) + + const ts = browser.traceSummary + const errorCounts = ts + ? [ + ts.userScriptErrors > 0 ? `${ts.userScriptErrors} script` : null, + ts.consoleErrors > 0 ? `${ts.consoleErrors} console` : null, + ts.networkErrors > 0 ? `${ts.networkErrors} network` : null, + ts.documentErrors > 0 ? `${ts.documentErrors} document` : null, + ].filter(Boolean) + : [] + + if (errorCounts.length > 0) { + lines.push(`${label('Errors:')}${chalk.red(errorCounts.join(', '))}`) + } else { + lines.push(`${label('Errors:')}${chalk.green('none')}`) + } + + if (browser.errors.length > 0) { + lines.push('') + lines.push(heading('ERRORS', 2, 'terminal')) + for (const err of browser.errors) { + lines.push(chalk.red(` ${err}`)) + } + } + + if (browser.pages && browser.pages.length > 0) { + const vitalLines: string[] = [] + for (const page of browser.pages) { + const pageLines: string[] = [] + const vitals = page.webVitals + if (vitals) { + const entries = Object.entries(vitals) as Array<[string, WebVitalEntry]> + for (const [name, v] of entries) { + if (v && v.value != null) { + pageLines.push(` ${label(name + ':', 8)}${formatVitalValue(name, v)} ${vitalScore(v.score)}`) + } + } + } + if (pageLines.length > 0) { + if (browser.pages.length > 1) { + vitalLines.push(` ${chalk.dim(page.url)}`) + } + vitalLines.push(...pageLines) + } + } + if (vitalLines.length > 0) { + lines.push('') + lines.push(heading('WEB VITALS', 2, 'terminal')) + lines.push(...vitalLines) + } + } + + if (browser.jobLog && browser.jobLog.length > 0) { + lines.push('') + lines.push(...formatJobLogArray(browser.jobLog)) + } + + const assets: string[] = [] + if (browser.jobAssets && browser.jobAssets.length > 0) { + assets.push(`${browser.jobAssets.length} screenshot(s)`) + } + if (browser.playwrightTestTraces && browser.playwrightTestTraces.length > 0) { + assets.push(`${browser.playwrightTestTraces.length} trace(s)`) + } + if (browser.playwrightTestVideos && browser.playwrightTestVideos.length > 0) { + assets.push(`${browser.playwrightTestVideos.length} video(s)`) + } + if (assets.length > 0) { + lines.push('') + lines.push(heading('ASSETS', 2, 'terminal')) + lines.push(` ${assets.join(', ')}`) + lines.push(chalk.dim(' Use --output json to get asset URLs')) + } + + return lines +} + +// --- Browser check result (markdown) --- + +function formatBrowserResultMd (browser: BrowserCheckResult): string[] { + const lines: string[] = [] + + lines.push(`## Browser Result (${browser.type})`) + + if (browser.pages && browser.pages.length > 0) { + lines.push('') + lines.push('### Web Vitals') + lines.push('| Metric | Value | Score |') + lines.push('| --- | --- | --- |') + for (const page of browser.pages) { + if (page.webVitals) { + for (const [name, v] of Object.entries(page.webVitals) as Array<[string, WebVitalEntry]>) { + if (v && v.value != null) { + lines.push(`| ${name} | ${formatVitalValue(name, v)} | ${v.score} |`) + } + } + } + } + } + + if (browser.errors.length > 0) { + lines.push('') + lines.push('### Errors') + for (const err of browser.errors) { + lines.push(`- ${err}`) + } + } + + return lines +} + +// --- Multi-step check result (terminal only — markdown has no special multi-step section) --- + +function formatMultiStepResultTerminal (ms: MultiStepCheckResult): string[] { + const lines: string[] = [] + + lines.push(heading('MULTI-STEP RESULT', 2, 'terminal')) + lines.push(`${label('Runtime:')}${ms.runtimeVersion}`) + + if (ms.errors.length > 0) { + lines.push('') + lines.push(heading('ERRORS', 2, 'terminal')) + for (const err of ms.errors) { + lines.push(chalk.red(` ${err}`)) + } + } + + if (ms.jobLog && ms.jobLog.length > 0) { + lines.push('') + lines.push(...formatJobLogArray(ms.jobLog)) + } + + const assets: string[] = [] + if (ms.jobAssets && ms.jobAssets.length > 0) { + assets.push(`${ms.jobAssets.length} screenshot(s)`) + } + if (ms.playwrightTestTraces && ms.playwrightTestTraces.length > 0) { + assets.push(`${ms.playwrightTestTraces.length} trace(s)`) + } + if (assets.length > 0) { + lines.push('') + lines.push(heading('ASSETS', 2, 'terminal')) + lines.push(` ${assets.join(', ')}`) + lines.push(chalk.dim(' Use --output json to get asset URLs')) + } + + return lines +} + +// --- Shared internal helpers --- + +function colorStatus (code: number): string { + if (code >= 200 && code < 300) return chalk.green(String(code)) + if (code >= 300 && code < 400) return chalk.yellow(String(code)) + return chalk.red(String(code)) +} + +function formatBody (body: string, indent: string): string { + try { + const parsed = JSON.parse(body) + const pretty = JSON.stringify(parsed, null, 2) + const lines = pretty.split('\n') + if (lines.length > 30) { + return lines.slice(0, 30).map(l => indent + l).join('\n') + `\n${indent}${chalk.dim(`... (${lines.length - 30} more lines)`)}` + } + return lines.map(l => indent + l).join('\n') + } catch { + const lines = body.split('\n') + if (lines.length > 30) { + return lines.slice(0, 30).map(l => indent + l).join('\n') + `\n${indent}${chalk.dim(`... (${lines.length - 30} more lines)`)}` + } + return lines.map(l => indent + l).join('\n') + } +} + +function formatTimingBar (tp: NonNullable): string { + const phases: Array<{ name: string, ms: number, color: (s: string) => string }> = [ + { name: 'DNS', ms: tp.dns, color: chalk.cyan }, + { name: 'TCP', ms: tp.tcp, color: chalk.blue }, + { name: 'TLS', ms: tp.wait, color: chalk.magenta }, + { name: 'TTFB', ms: tp.firstByte, color: chalk.yellow }, + { name: 'Download', ms: tp.download, color: chalk.green }, + ] + + const lines: string[] = [] + for (const phase of phases) { + if (phase.ms > 0) { + lines.push(` ${label(phase.name + ':', 12)}${phase.color(formatMs(phase.ms))}`) + } + } + lines.push(` ${label('Total:', 12)}${chalk.bold(formatMs(tp.total))}`) + return lines.join('\n') +} + +function formatVitalValue (name: string, v: WebVitalEntry): string { + if (v.value == null) return '-' + if (name === 'CLS') return String(v.value.toFixed(3)) + return formatMs(v.value) +} + +function vitalScore (score: string): string { + switch (score) { + case 'GOOD': return chalk.green('good') + case 'NEEDS_IMPROVEMENT': return chalk.yellow('needs improvement') + case 'POOR': return chalk.red('poor') + default: return chalk.dim(score) + } +} + +// --- Job log formatting --- + +function formatJobLogArray (log: Array<{ time: number, msg: string, level: string }>): string[] { + const lines: string[] = [heading('JOB LOG', 2, 'terminal')] + const maxLines = 50 + const entries = log.slice(0, maxLines) + + for (const entry of entries) { + const level = entry.level === 'ERROR' ? chalk.red(entry.level) : chalk.dim(entry.level) + lines.push(` ${level} ${entry.msg}`) + } + + if (log.length > maxLines) { + lines.push(chalk.dim(` ... (${log.length - maxLines} more entries)`)) + } + + return lines +} + +function formatJobLogObject (log: unknown): string[] { + if (!log) return [] + + if (Array.isArray(log)) { + return formatJobLogArray(log) + } + + if (typeof log === 'object' && log !== null) { + const obj = log as Record + const phases = ['setup', 'request', 'teardown'] as const + const allEntries: Array<{ time: number, msg: string, level: string }> = [] + for (const phase of phases) { + const entries = obj[phase] + if (Array.isArray(entries)) { + allEntries.push(...entries) + } + } + if (allEntries.length > 0) { + return formatJobLogArray(allEntries) + } + } + + const lines: string[] = [heading('JOB LOG', 2, 'terminal')] + try { + const pretty = JSON.stringify(log, null, 2) + const jsonLines = pretty.split('\n').slice(0, 50) + for (const l of jsonLines) { + lines.push(` ${l}`) + } + } catch { + lines.push(` ${String(log)}`) + } + + return lines +} diff --git a/packages/cli/src/formatters/checks.ts b/packages/cli/src/formatters/checks.ts new file mode 100644 index 000000000..a598b046d --- /dev/null +++ b/packages/cli/src/formatters/checks.ts @@ -0,0 +1,350 @@ +import chalk from 'chalk' +import logSymbols from 'log-symbols' +import type { Check } from '../rest/checks' +import type { CheckStatus } from '../rest/check-statuses' +import type { CheckResult } from '../rest/check-results' +import type { ErrorGroup } from '../rest/error-groups' +import { + type OutputFormat, + type DetailField, + type ColumnDef, + truncateToWidth, + visWidth, + formatFrequency, + formatCheckType, + formatMs, + timeAgo, + stripAnsi, + truncateError, + resolveResultStatus, + renderDetailFields, + renderTable, +} from './render' + +export { formatFrequency, formatCheckType } from './render' + +export type CheckWithStatus = Check & { status?: CheckStatus } + +export interface PaginationInfo { + page: number + limit: number + total: number +} + +function resolveStatus (check: CheckWithStatus, format: OutputFormat): string { + if (!check.activated) return format === 'terminal' ? chalk.dim('inactive') : 'inactive' + if (!check.status) return format === 'terminal' ? chalk.dim('-') : 'unknown' + const failing = check.status.hasFailures || check.status.hasErrors + const degraded = check.status.isDegraded + const muted = check.muted + + if (format === 'md') { + const label = failing ? 'failing' : degraded ? 'degraded' : 'passing' + return muted ? `${label} (muted)` : label + } + + if (failing) return muted ? chalk.dim('failing') : chalk.red('failing') + if (degraded) return muted ? chalk.dim('degraded') : chalk.yellow('degraded') + return muted ? chalk.dim('passing') : chalk.green('passing') +} + +function boolSymbol (value: boolean, format: OutputFormat): string { + if (format === 'md') return value ? 'yes' : '-' + return value ? chalk.green('yes') : chalk.dim('-') +} + +// --- Summary bar (terminal only) --- + +export function formatSummaryBar (statuses: CheckStatus[], totalChecks: number, activeCheckIds?: Set): string { + const counted = activeCheckIds + ? statuses.filter(s => activeCheckIds.has(s.checkId)) + : statuses + const passing = counted.filter(s => !s.hasFailures && !s.hasErrors && !s.isDegraded).length + const degraded = counted.filter(s => s.isDegraded && !s.hasFailures && !s.hasErrors).length + const failing = counted.filter(s => s.hasFailures || s.hasErrors).length + + const parts: string[] = [] + if (passing > 0) parts.push(chalk.green(`${logSymbols.success} ${passing} passing`)) + if (degraded > 0) parts.push(chalk.yellow(`${logSymbols.warning} ${degraded} degraded`)) + if (failing > 0) parts.push(chalk.red(`${logSymbols.error} ${failing} failing`)) + + const total = chalk.dim(`(${totalChecks} total checks)`) + return parts.join(' ') + ' ' + total +} + +// --- Pagination info (terminal only) --- + +export function formatPaginationInfo (pagination: PaginationInfo): string { + const { page, limit, total } = pagination + const start = (page - 1) * limit + 1 + const end = Math.min(page * limit, total) + const totalPages = Math.ceil(total / limit) + + return chalk.dim(`Showing ${start}-${end} of ${total} checks (page ${page}/${totalPages})`) +} + +// --- Navigation hints (terminal only) --- + +export function formatNavigationHints (pagination: PaginationInfo, activeFilters: string[]): string { + const { page, limit, total } = pagination + const totalPages = Math.ceil(total / limit) + const lines: string[] = [] + + if (page < totalPages) { + lines.push(` ${chalk.dim('Next page:')} checkly checks list --page ${page + 1}`) + } + if (page > 1) { + lines.push(` ${chalk.dim('Prev page:')} checkly checks list --page ${page - 1}`) + } + lines.push(` ${chalk.dim('View check:')} checkly checks get `) + if (activeFilters.length === 0) { + lines.push(` ${chalk.dim('Filter:')} checkly checks list --tag --type --search `) + } + + return lines.join('\n') +} + +// --- Check detail fields --- + +export const checkDetailFields: DetailField[] = [ + { label: 'Type', value: c => formatCheckType(c.checkType) }, + { label: 'Status', value: (c, fmt) => resolveStatus(c, fmt) }, + { label: 'Active', value: (c, fmt) => boolSymbol(c.activated, fmt) }, + { label: 'Muted', value: (c, fmt) => boolSymbol(c.muted, fmt) }, + { label: 'Frequency', value: c => `Every ${formatFrequency(c.frequency)}` }, + { + label: 'Locations', + value: (c, fmt) => { + const locations = [ + ...(c.locations || []), + ...(c.privateLocations || []).map(l => `${l} (private)`), + ] + if (locations.length === 0) return fmt === 'terminal' ? chalk.dim('-') : '-' + return locations.join(', ') + }, + }, + { + label: 'Tags', + value: (c, fmt) => { + if (c.tags.length === 0) return fmt === 'terminal' ? chalk.dim('-') : '-' + return c.tags.join(', ') + }, + }, + { + label: 'Source', + value: (c, fmt) => { + if (fmt === 'md') return null + if (c.scriptPath) return `${chalk.cyan('code')} ${chalk.dim('→')} ${c.scriptPath}` + return chalk.dim('UI') + }, + }, + { + label: 'URL', + value: (c, fmt) => { + if (fmt === 'md') return null + return c.request?.url ?? null + }, + }, + { + label: 'SSL', + value: (c, fmt) => { + if (c.status?.sslDaysRemaining == null) { + return fmt === 'md' ? '-' : null + } + const ssl = c.status.sslDaysRemaining + const display = `${ssl} days remaining` + if (fmt === 'md') return display + return ssl <= 14 ? chalk.red(display) : ssl <= 30 ? chalk.yellow(display) : chalk.green(display) + }, + }, + { + label: 'Deployed', + value: (c, fmt) => { + if (!c.updated_at) return fmt === 'md' ? '-' : null + return new Date(c.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + }, + }, + { + label: 'Group', + value: (c, fmt) => c.groupId != null ? String(c.groupId) : (fmt === 'terminal' ? chalk.dim('-') : '-'), + }, + { + label: 'Created', + value: (c, fmt) => { + if (!c.created_at) return fmt === 'terminal' ? chalk.dim('-') : '-' + return new Date(c.created_at).toISOString().slice(0, 10) + }, + }, + { label: 'ID', value: c => c.id }, +] + +export function formatCheckDetail (check: CheckWithStatus, format: OutputFormat): string { + return renderDetailFields(check.name, checkDetailFields, check, format) +} + +// --- Checks table --- + +export interface TableOptions { + showId?: boolean +} + +function buildCheckColumns ( + checks: CheckWithStatus[], format: OutputFormat, options: TableOptions = {}, +): ColumnDef[] { + if (format === 'md') { + return [ + { header: 'Name', value: c => c.name }, + { header: 'Type', value: c => formatCheckType(c.checkType) }, + { header: 'Status', value: (c, fmt) => resolveStatus(c, fmt) }, + { header: 'Freq', value: c => formatFrequency(c.frequency) }, + { header: 'Tags', value: c => c.tags.length > 0 ? c.tags.join(', ') : '-' }, + { header: 'ID', value: c => c.id }, + ] + } + + const { showId = false } = options + const termWidth = process.stdout.columns || 120 + const fixedWidth = 12 + 10 + 6 + const idReserve = showId ? 38 : 0 + const available = termWidth - fixedWidth - idReserve + const longestName = Math.max(4, ...checks.map(c => visWidth(c.name))) + const nameWidth = Math.min(longestName + 2, 42) + const tagWidth = Math.max(8, available - nameWidth) + + const columns: ColumnDef[] = [ + { + header: 'Name', + width: nameWidth, + value: c => truncateToWidth(c.name, nameWidth - 2), + }, + { + header: 'Type', + width: 12, + value: c => formatCheckType(c.checkType), + }, + { + header: 'Status', + width: 10, + value: (c, fmt) => resolveStatus(c, fmt), + }, + { + header: 'Freq', + width: 6, + value: c => formatFrequency(c.frequency), + }, + ] + + if (showId) { + columns.push({ + header: 'Tags', + width: tagWidth, + value: c => { + const tags = c.tags.length > 0 ? c.tags.join(', ') : chalk.dim('-') + return truncateToWidth(tags, tagWidth - 2) + }, + }) + columns.push({ + header: 'ID', + value: c => chalk.dim(c.id), + }) + } else { + columns.push({ + header: 'Tags', + value: c => { + const tags = c.tags.length > 0 ? c.tags.join(', ') : chalk.dim('-') + return truncateToWidth(tags, tagWidth - 2) + }, + }) + } + + return columns +} + +export function formatChecks ( + checks: CheckWithStatus[], format: OutputFormat, + options: TableOptions & { pagination?: PaginationInfo } = {}, +): string { + const columns = buildCheckColumns(checks, format, options) + let result = renderTable(columns, checks, format) + + if (format === 'md' && options.pagination) { + const { page, limit, total } = options.pagination + const totalPages = Math.ceil(total / limit) + result += `\n\n*Showing page ${page}/${totalPages} (${total} total checks)*` + } + + return result +} + +// --- Results table --- + +function buildResultColumns (format: OutputFormat): ColumnDef[] { + if (format === 'md') { + return [ + { header: 'Time', value: r => r.startedAt }, + { header: 'Location', value: r => r.runLocation }, + { header: 'Status', value: (r, fmt) => resolveResultStatus(r, fmt) }, + { header: 'Response Time', value: r => formatMs(r.responseTime) }, + { header: 'ID', value: r => r.id }, + ] + } + + return [ + { header: 'Time', width: 14, value: r => timeAgo(r.startedAt) }, + { header: 'Location', width: 16, value: r => r.runLocation }, + { header: 'Status', width: 10, value: (r, fmt) => resolveResultStatus(r, fmt) }, + { header: 'Response Time', width: 16, value: r => formatMs(r.responseTime) }, + { header: 'Result ID', value: r => chalk.dim(r.id) }, + ] +} + +export function formatResults (results: CheckResult[], format: OutputFormat): string { + return renderTable(buildResultColumns(format), results, format) +} + +// --- Error groups --- + +function buildErrorGroupColumns (format: OutputFormat): ColumnDef[] { + if (format === 'md') { + return [ + { + header: 'Error', + value: eg => { + const msg = stripAnsi(eg.cleanedErrorMessage).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() + return msg.length > 80 ? msg.substring(0, 79) + '…' : msg + }, + }, + { header: 'First Seen', value: eg => eg.firstSeen }, + { header: 'Last Seen', value: eg => eg.lastSeen }, + { header: 'ID', value: eg => eg.id }, + ] + } + + return [ + { + header: 'Error', + width: 60, + value: eg => chalk.red(truncateError(eg.cleanedErrorMessage, 58)), + }, + { + header: 'First Seen', + width: 14, + value: eg => chalk.dim(timeAgo(eg.firstSeen)), + }, + { + header: 'Last Seen', + value: eg => chalk.dim(timeAgo(eg.lastSeen)), + }, + ] +} + +export function formatErrorGroups (errorGroups: ErrorGroup[], format: OutputFormat): string { + const active = errorGroups.filter(eg => !eg.archivedUntilNextEvent) + if (active.length === 0) return '' + + const columns = buildErrorGroupColumns(format) + const title = format === 'md' + ? '## Error Groups\n\n' + : chalk.bold('ERROR GROUPS') + '\n' + return title + renderTable(columns, active, format) +} diff --git a/packages/cli/src/formatters/render.ts b/packages/cli/src/formatters/render.ts new file mode 100644 index 000000000..0c689250b --- /dev/null +++ b/packages/cli/src/formatters/render.ts @@ -0,0 +1,195 @@ +import chalk from 'chalk' +import stringWidth from 'string-width' + +export type OutputFormat = 'terminal' | 'md' + +// eslint-disable-next-line no-control-regex +const ANSI_REGEX = /\u001B\[[0-9;]*m/g + +export function stripAnsi (value: string): string { + return value.replace(ANSI_REGEX, '') +} + +export function visWidth (value: string): number { + return stringWidth(stripAnsi(value)) +} + +export function padColumn (value: string, width: number): string { + const padding = Math.max(0, width - visWidth(value)) + return value + ' '.repeat(padding) +} + +export function truncateToWidth (value: string, maxWidth: number): string { + const stripped = stripAnsi(value) + if (visWidth(stripped) <= maxWidth) return value + let result = '' + let width = 0 + for (const char of stripped) { + const charWidth = stringWidth(char) + if (width + charWidth + 1 > maxWidth) break + result += char + width += charWidth + } + return result + '…' +} + +export function heading (text: string, level: number, format: OutputFormat): string { + if (format === 'md') return `${'#'.repeat(level)} ${text}` + return chalk.bold(text) +} + +export function formatMs (ms: number): string { + const rounded = Math.round(ms) + if (rounded < 1000) return `${rounded}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +export function timeAgo (dateStr: string): string { + const now = Date.now() + const then = new Date(dateStr).getTime() + const diffMs = now - then + const diffSec = Math.floor(diffMs / 1000) + + if (diffSec < 60) return `${diffSec}s ago` + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHrs = Math.floor(diffMin / 60) + if (diffHrs < 24) return `${diffHrs}h ago` + const diffDays = Math.floor(diffHrs / 24) + if (diffDays < 30) return `${diffDays}d ago` + const diffMonths = Math.floor(diffDays / 30) + return `${diffMonths}mo ago` +} + +export function formatFrequency (minutes: number | undefined | null): string { + if (minutes === undefined || minutes === null) return '-' + if (minutes === 0) return '<1m' + if (minutes < 60) return `${minutes}m` + if (minutes % 60 === 0) return `${minutes / 60}h` + return `${minutes}m` +} + +export function formatCheckType (checkType: string): string { + const typeMap: Record = { + API: 'API', + BROWSER: 'BROWSER', + MULTI_STEP: 'MULTISTEP', + HEARTBEAT: 'HEARTBEAT', + PLAYWRIGHT: 'PLAYWRIGHT', + TCP: 'TCP', + URL: 'URL', + DNS: 'DNS', + ICMP: 'ICMP', + } + return typeMap[checkType] ?? checkType +} + +export function formatDate (dateStr: string | null | undefined, format: OutputFormat): string { + if (!dateStr) return format === 'terminal' ? chalk.dim('-') : '-' + const d = new Date(dateStr) + return d.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC') +} + +export function resolveResultStatus ( + result: { hasErrors: boolean, hasFailures: boolean, isDegraded: boolean | null }, + format: OutputFormat, +): string { + const label = result.hasErrors ? 'error' : result.hasFailures ? 'failing' : result.isDegraded ? 'degraded' : 'passing' + if (format === 'md') return label + if (label === 'error' || label === 'failing') return chalk.red(label) + if (label === 'degraded') return chalk.yellow(label) + return chalk.green(label) +} + +export function truncateError (msg: string, maxLen: number): string { + const clean = stripAnsi(msg).replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() + if (clean.length <= maxLen) return clean + return clean.substring(0, maxLen - 1) + '…' +} + +// --- Typed field/column definitions --- + +export interface DetailField { + label: string + value: (item: T, format: OutputFormat) => string | null +} + +export interface ColumnDef { + header: string + width?: number + value: (item: T, format: OutputFormat) => string +} + +export function renderDetailFields ( + title: string, + fields: DetailField[], + item: T, + format: OutputFormat, +): string { + if (format === 'md') { + const lines = [ + `# ${title}`, + '', + '| Field | Value |', + '| --- | --- |', + ] + for (const field of fields) { + const val = field.value(item, format) + if (val != null) { + lines.push(`| ${field.label} | ${val} |`) + } + } + return lines.join('\n') + } + + // Terminal: compute visible fields, then align labels + const resolved: Array<{ label: string, value: string }> = [] + for (const field of fields) { + const val = field.value(item, format) + if (val != null) { + resolved.push({ label: field.label, value: val }) + } + } + + const maxLabelLen = Math.max(0, ...resolved.map(f => f.label.length)) + const padWidth = maxLabelLen + 3 // "label:" + at least 2 spaces + + const lines = [chalk.bold(title), ''] + for (const { label: lbl, value } of resolved) { + const labelStr = `${lbl}:` + const padding = ' '.repeat(padWidth - labelStr.length) + lines.push(`${chalk.dim(labelStr)}${padding}${value}`) + } + + return lines.join('\n') +} + +export function renderTable ( + columns: ColumnDef[], + rows: T[], + format: OutputFormat, +): string { + if (format === 'md') { + const header = '| ' + columns.map(c => c.header).join(' | ') + ' |' + const separator = '| ' + columns.map(() => '---').join(' | ') + ' |' + const dataRows = rows.map(row => + '| ' + columns.map(c => c.value(row, format)).join(' | ') + ' |', + ) + return [header, separator, ...dataRows].join('\n') + } + + // Terminal + const headerParts = columns.map((col, i) => { + const text = chalk.bold(col.header.toUpperCase()) + return (i < columns.length - 1 && col.width) ? padColumn(text, col.width) : text + }) + + const dataRows = rows.map(row => + columns.map((col, i) => { + const val = col.value(row, format) + return (i < columns.length - 1 && col.width) ? padColumn(val, col.width) : val + }).join(''), + ) + + return [headerParts.join(''), ...dataRows].join('\n') +} diff --git a/packages/cli/src/helpers/command-style.ts b/packages/cli/src/helpers/command-style.ts index bcff7dd4e..92516d1b1 100644 --- a/packages/cli/src/helpers/command-style.ts +++ b/packages/cli/src/helpers/command-style.ts @@ -17,6 +17,7 @@ const errorWrapOptions = { export class CommandStyle { private c: BaseCommand + outputFormat?: string constructor (command: BaseCommand) { this.c = command @@ -84,6 +85,10 @@ export class CommandStyle { } longError (title: string, message: string | Error) { + if (this.outputFormat === 'json') { + this.c.log(JSON.stringify({ error: title, detail: this.#plainDescription(message) })) + return + } this.c.log(`${logSymbols.error} ${title}`) this.c.log() this.c.log(chalk.red(this.#formatDescription(message))) @@ -91,6 +96,10 @@ export class CommandStyle { } shortError (message: string) { + if (this.outputFormat === 'json') { + this.c.log(JSON.stringify({ error: message })) + return + } this.c.log(`${logSymbols.error} ${chalk.red(message)}`) this.c.log() } @@ -109,4 +118,8 @@ export class CommandStyle { return wrap(message, errorWrapOptions) } + + #plainDescription (description: string | Error): string { + return typeof description === 'string' ? description : description.message + } } diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index 008809d63..c69783932 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -13,6 +13,11 @@ import TestSessions from './test-sessions' import EnvironmentVariables from './environment-variables' import HeartbeatChecks from './heartbeat-checks' import ChecklyStorage from './checkly-storage' +import Checks from './checks' +import CheckStatuses from './check-statuses' +import CheckResults from './check-results' +import CheckGroups from './check-groups' +import ErrorGroups from './error-groups' import { handleErrorResponse, UnauthorizedError } from './errors' export function getDefaults () { @@ -117,3 +122,8 @@ export const testSessions = new TestSessions(api) export const environmentVariables = new EnvironmentVariables(api) export const heartbeatCheck = new HeartbeatChecks(api) export const checklyStorage = new ChecklyStorage(api) +export const checks = new Checks(api) +export const checkStatuses = new CheckStatuses(api) +export const checkResults = new CheckResults(api) +export const checkGroups = new CheckGroups(api) +export const errorGroups = new ErrorGroups(api) diff --git a/packages/cli/src/rest/check-groups.ts b/packages/cli/src/rest/check-groups.ts new file mode 100644 index 000000000..7b2868918 --- /dev/null +++ b/packages/cli/src/rest/check-groups.ts @@ -0,0 +1,24 @@ +import type { AxiosInstance } from 'axios' + +export interface CheckGroup { + id: number + name: string + activated: boolean + muted: boolean + locations: string[] + tags: string[] + concurrency: number +} + +class CheckGroups { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + getAll () { + return this.api.get('/v1/check-groups') + } +} + +export default CheckGroups diff --git a/packages/cli/src/rest/check-results.ts b/packages/cli/src/rest/check-results.ts new file mode 100644 index 000000000..ddaf462a8 --- /dev/null +++ b/packages/cli/src/rest/check-results.ts @@ -0,0 +1,149 @@ +import type { AxiosInstance } from 'axios' + +export interface CheckResult { + id: string + checkId: string + name: string + hasFailures: boolean + hasErrors: boolean + isDegraded: boolean | null + overMaxResponseTime: boolean | null + runLocation: string + startedAt: string + stoppedAt: string + created_at: string + responseTime: number + checkRunId: number + attempts: number + resultType: 'FINAL' | 'ATTEMPT' + sequenceId?: string | null + apiCheckResult?: ApiCheckResult | null + browserCheckResult?: BrowserCheckResult | null + multiStepCheckResult?: MultiStepCheckResult | null +} + +// --- API check result --- + +export interface ApiCheckAssertion { + source: string + comparison: string + target: string | number + property?: string +} + +export interface ApiCheckResult { + assertions: ApiCheckAssertion[] | null + request: { + method: string + url: string + data: string + headers: Record + params: Record + } + response: { + status: number + statusText: string + body: string + headers: Record | null + timings: { + socket: number + lookup: number + connect: number + response: number + end: number + } | null + timingPhases: { + wait: number + dns: number + tcp: number + firstByte: number + download: number + total: number + } | null + } + requestError: string | null + jobLog: unknown | null + jobAssets: string[] | null + pcapDataUrl: string | null +} + +// --- Browser check result --- + +export interface WebVitalEntry { + score: 'GOOD' | 'NEEDS_IMPROVEMENT' | 'POOR' + value: number +} + +export interface BrowserCheckResult { + type: string + traceSummary: { + consoleErrors: number + networkErrors: number + documentErrors: number + userScriptErrors: number + } + errors: string[] + pages: Array<{ + url: string + webVitals: { + CLS?: WebVitalEntry + FCP?: WebVitalEntry + LCP?: WebVitalEntry + TBT?: WebVitalEntry + TTFB?: WebVitalEntry + } + }> + startTime: number + endTime: number + runtimeVersion: string + jobLog: Array<{ time: number, msg: string, level: string }> | null + jobAssets: string[] | null + playwrightTestVideos?: string[] + playwrightTestTraces?: string[] + playwrightTestJsonReportFile?: string +} + +// --- Multi-step check result --- + +export interface MultiStepCheckResult { + errors: string[] + startTime: number + endTime: number + runtimeVersion: string + jobLog: Array<{ time: number, msg: string, level: string }> | null + jobAssets: string[] | null + playwrightTestTraces?: string[] + playwrightTestJsonReportFile?: string +} + +export interface CheckResultsPage { + length: number + entries: CheckResult[] + nextId: string | null +} + +export interface ListCheckResultsParams { + limit?: number + nextId?: string + from?: number + to?: number + hasFailures?: boolean + resultType?: 'FINAL' | 'ATTEMPT' +} + +class CheckResults { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + getAll (checkId: string, params: ListCheckResultsParams = {}) { + return this.api.get(`/v2/check-results/${checkId}`, { params }) + } + + get (checkId: string, checkResultId: string) { + return this.api.get(`/v1/check-results/${checkId}/${checkResultId}`) + } +} + +export default CheckResults diff --git a/packages/cli/src/rest/check-statuses.ts b/packages/cli/src/rest/check-statuses.ts new file mode 100644 index 000000000..e5a62845c --- /dev/null +++ b/packages/cli/src/rest/check-statuses.ts @@ -0,0 +1,40 @@ +import type { AxiosInstance } from 'axios' + +export interface CheckStatus { + name: string + checkId: string + hasFailures: boolean + hasErrors: boolean + isDegraded: boolean + longestRun: number + shortestRun: number + lastRunLocation: string + lastCheckRunId: string + sslDaysRemaining: number | null + created_at: string + updated_at: string | null +} + +class CheckStatuses { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + getAll () { + return this.api.get('/v1/check-statuses') + } + + async fetchAll (): Promise { + // The check-statuses endpoint returns all entries regardless of limit param, + // so a single request is sufficient. + const resp = await this.api.get('/v1/check-statuses') + return resp.data + } + + get (checkId: string) { + return this.api.get(`/v1/check-statuses/${checkId}`) + } +} + +export default CheckStatuses diff --git a/packages/cli/src/rest/checks.ts b/packages/cli/src/rest/checks.ts new file mode 100644 index 000000000..ca1f6bc7a --- /dev/null +++ b/packages/cli/src/rest/checks.ts @@ -0,0 +1,91 @@ +import type { AxiosInstance } from 'axios' + +export interface Check { + id: string + name: string + checkType: string + activated: boolean + muted: boolean + frequency: number | null + frequencyOffset?: number + locations: string[] + privateLocations?: string[] + tags: string[] + groupId: number | null + groupOrder: number | null + runtimeId: string | null + scriptPath: string | null + request?: { url: string, method: string } + created_at: string + updated_at: string | null +} + +export interface ListChecksParams { + limit?: number + page?: number + tag?: string[] +} + +export interface PaginatedChecks { + checks: Check[] + total: number + page: number + limit: number +} + +function parseTotalFromContentRange (header: string | undefined): number | null { + if (!header) return null + // Content-Range format: "0-24/300" or "*/0" + const match = header.match(/\/(\d+)$/) + return match ? parseInt(match[1], 10) : null +} + +class Checks { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + getAll (params: ListChecksParams = {}) { + return this.api.get('/v1/checks', { params }) + } + + async getAllPaginated (params: ListChecksParams = {}): Promise { + const response = await this.api.get('/v1/checks', { params }) + const total = parseTotalFromContentRange(response.headers['content-range']) ?? response.data.length + return { + checks: response.data, + total, + page: params.page ?? 1, + limit: params.limit ?? 10, + } + } + + async fetchAll (params: Omit = {}): Promise { + const pageSize = 100 + const first = await this.getAllPaginated({ ...params, page: 1, limit: pageSize }) + const allChecks = [...first.checks] + + if (first.total <= pageSize) return allChecks + + const totalPages = Math.ceil(first.total / pageSize) + const remaining = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, i) => + this.api.get('/v1/checks', { + params: { ...params, page: i + 2, limit: pageSize }, + }).then(r => r.data), + ), + ) + for (const page of remaining) { + allChecks.push(...page) + } + + return allChecks + } + + get (id: string) { + return this.api.get(`/v1/checks/${id}`) + } +} + +export default Checks diff --git a/packages/cli/src/rest/error-groups.ts b/packages/cli/src/rest/error-groups.ts new file mode 100644 index 000000000..50c386c19 --- /dev/null +++ b/packages/cli/src/rest/error-groups.ts @@ -0,0 +1,29 @@ +import type { AxiosInstance } from 'axios' + +export interface ErrorGroup { + id: string + checkId: string + errorHash: string + rawErrorMessage: string | null + cleanedErrorMessage: string + firstSeen: string + lastSeen: string + archivedUntilNextEvent: boolean +} + +class ErrorGroups { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + getByCheckId (checkId: string) { + return this.api.get(`/v1/error-groups/checks/${checkId}`) + } + + get (id: string) { + return this.api.get(`/v1/error-groups/${id}`) + } +} + +export default ErrorGroups From 6e79a2356474cf0246f46a750edbc68cb332b85e Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 11:41:27 +0100 Subject: [PATCH 02/21] refactor(cli): reduce formatter code duplication - Extract appendErrors/appendAssets helpers in check-result-detail - Collapse duplicated formatBody try/catch branches - De-duplicate Tags column definition in checks table - Simplify formatCheckType to pass-through - Use stripAnsi/formatDate in get.ts instead of inline regex/formatting --- packages/cli/src/commands/checks/get.ts | 17 ++-- .../src/formatters/__tests__/render.spec.ts | 2 +- .../cli/src/formatters/check-result-detail.ts | 89 +++++++------------ packages/cli/src/formatters/checks.ts | 29 ++---- packages/cli/src/formatters/render.ts | 13 +-- 5 files changed, 51 insertions(+), 99 deletions(-) diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 99e110cf0..34a669fc6 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -3,7 +3,7 @@ import chalk from 'chalk' import { AuthCommand } from '../authCommand' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' -import type { OutputFormat } from '../../formatters/render' +import { type OutputFormat, stripAnsi, formatDate } from '../../formatters/render' import { formatCheckDetail, formatResults, @@ -155,16 +155,13 @@ export default class ChecksGet extends AuthCommand { return } - // eslint-disable-next-line no-control-regex - const ansiRegex = /\u001B\[[0-9;]*m/g - const cleanMsg = errorGroup.cleanedErrorMessage - .replace(ansiRegex, '') + const cleanMsg = stripAnsi(errorGroup.cleanedErrorMessage) .replace(/\s+/g, ' ') .trim() // Show full raw message if available and different from cleaned const rawMsg = errorGroup.rawErrorMessage - ? errorGroup.rawErrorMessage.replace(ansiRegex, '').trim() + ? stripAnsi(errorGroup.rawErrorMessage).trim() : null const output: string[] = [] @@ -176,14 +173,12 @@ export default class ChecksGet extends AuthCommand { if (rawMsg && rawMsg !== cleanMsg) { output.push('') output.push(chalk.bold('FULL ERROR')) - // Show the raw message with original newlines preserved - const fullMsg = errorGroup.rawErrorMessage!.replace(ansiRegex, '').trim() - output.push(fullMsg) + output.push(stripAnsi(errorGroup.rawErrorMessage!).trim()) } output.push('') - output.push(`${chalk.dim('First seen:')} ${new Date(errorGroup.firstSeen).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`) - output.push(`${chalk.dim('Last seen:')} ${new Date(errorGroup.lastSeen).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC')}`) + output.push(`${chalk.dim('First seen:')} ${formatDate(errorGroup.firstSeen, 'terminal')}`) + output.push(`${chalk.dim('Last seen:')} ${formatDate(errorGroup.lastSeen, 'terminal')}`) output.push(`${chalk.dim('Error group:')} ${errorGroup.id}`) if (check.scriptPath) { diff --git a/packages/cli/src/formatters/__tests__/render.spec.ts b/packages/cli/src/formatters/__tests__/render.spec.ts index 4291cf4fa..e527fea7e 100644 --- a/packages/cli/src/formatters/__tests__/render.spec.ts +++ b/packages/cli/src/formatters/__tests__/render.spec.ts @@ -157,7 +157,7 @@ describe('formatCheckType', () => { it('maps known check types', () => { expect(formatCheckType('API')).toBe('API') expect(formatCheckType('BROWSER')).toBe('BROWSER') - expect(formatCheckType('MULTI_STEP')).toBe('MULTISTEP') + expect(formatCheckType('MULTI_STEP')).toBe('MULTI_STEP') expect(formatCheckType('HEARTBEAT')).toBe('HEARTBEAT') expect(formatCheckType('TCP')).toBe('TCP') }) diff --git a/packages/cli/src/formatters/check-result-detail.ts b/packages/cli/src/formatters/check-result-detail.ts index 5e853bc0d..3fd9e2607 100644 --- a/packages/cli/src/formatters/check-result-detail.ts +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -199,13 +199,7 @@ function formatBrowserResultTerminal (browser: BrowserCheckResult): string[] { lines.push(`${label('Errors:')}${chalk.green('none')}`) } - if (browser.errors.length > 0) { - lines.push('') - lines.push(heading('ERRORS', 2, 'terminal')) - for (const err of browser.errors) { - lines.push(chalk.red(` ${err}`)) - } - } + appendErrors(lines, browser.errors) if (browser.pages && browser.pages.length > 0) { const vitalLines: string[] = [] @@ -239,22 +233,7 @@ function formatBrowserResultTerminal (browser: BrowserCheckResult): string[] { lines.push(...formatJobLogArray(browser.jobLog)) } - const assets: string[] = [] - if (browser.jobAssets && browser.jobAssets.length > 0) { - assets.push(`${browser.jobAssets.length} screenshot(s)`) - } - if (browser.playwrightTestTraces && browser.playwrightTestTraces.length > 0) { - assets.push(`${browser.playwrightTestTraces.length} trace(s)`) - } - if (browser.playwrightTestVideos && browser.playwrightTestVideos.length > 0) { - assets.push(`${browser.playwrightTestVideos.length} video(s)`) - } - if (assets.length > 0) { - lines.push('') - lines.push(heading('ASSETS', 2, 'terminal')) - lines.push(` ${assets.join(', ')}`) - lines.push(chalk.dim(' Use --output json to get asset URLs')) - } + appendAssets(lines, browser) return lines } @@ -301,32 +280,14 @@ function formatMultiStepResultTerminal (ms: MultiStepCheckResult): string[] { lines.push(heading('MULTI-STEP RESULT', 2, 'terminal')) lines.push(`${label('Runtime:')}${ms.runtimeVersion}`) - if (ms.errors.length > 0) { - lines.push('') - lines.push(heading('ERRORS', 2, 'terminal')) - for (const err of ms.errors) { - lines.push(chalk.red(` ${err}`)) - } - } + appendErrors(lines, ms.errors) if (ms.jobLog && ms.jobLog.length > 0) { lines.push('') lines.push(...formatJobLogArray(ms.jobLog)) } - const assets: string[] = [] - if (ms.jobAssets && ms.jobAssets.length > 0) { - assets.push(`${ms.jobAssets.length} screenshot(s)`) - } - if (ms.playwrightTestTraces && ms.playwrightTestTraces.length > 0) { - assets.push(`${ms.playwrightTestTraces.length} trace(s)`) - } - if (assets.length > 0) { - lines.push('') - lines.push(heading('ASSETS', 2, 'terminal')) - lines.push(` ${assets.join(', ')}`) - lines.push(chalk.dim(' Use --output json to get asset URLs')) - } + appendAssets(lines, ms) return lines } @@ -339,22 +300,40 @@ function colorStatus (code: number): string { return chalk.red(String(code)) } +function appendErrors (lines: string[], errors: string[]): void { + if (errors.length === 0) return + lines.push('', heading('ERRORS', 2, 'terminal')) + for (const err of errors) lines.push(chalk.red(` ${err}`)) +} + +function appendAssets ( + lines: string[], + obj: { jobAssets?: string[] | null, playwrightTestTraces?: string[], playwrightTestVideos?: string[] }, +): void { + const parts: string[] = [] + if (obj.jobAssets?.length) parts.push(`${obj.jobAssets.length} screenshot(s)`) + if (obj.playwrightTestTraces?.length) parts.push(`${obj.playwrightTestTraces.length} trace(s)`) + if (obj.playwrightTestVideos?.length) parts.push(`${obj.playwrightTestVideos.length} video(s)`) + if (parts.length > 0) { + lines.push('', heading('ASSETS', 2, 'terminal')) + lines.push(` ${parts.join(', ')}`) + lines.push(chalk.dim(' Use --output json to get asset URLs')) + } +} + function formatBody (body: string, indent: string): string { + let text: string try { - const parsed = JSON.parse(body) - const pretty = JSON.stringify(parsed, null, 2) - const lines = pretty.split('\n') - if (lines.length > 30) { - return lines.slice(0, 30).map(l => indent + l).join('\n') + `\n${indent}${chalk.dim(`... (${lines.length - 30} more lines)`)}` - } - return lines.map(l => indent + l).join('\n') + text = JSON.stringify(JSON.parse(body), null, 2) } catch { - const lines = body.split('\n') - if (lines.length > 30) { - return lines.slice(0, 30).map(l => indent + l).join('\n') + `\n${indent}${chalk.dim(`... (${lines.length - 30} more lines)`)}` - } - return lines.map(l => indent + l).join('\n') + text = body + } + const lines = text.split('\n') + if (lines.length > 30) { + return lines.slice(0, 30).map(l => indent + l).join('\n') + + `\n${indent}${chalk.dim(`... (${lines.length - 30} more lines)`)}` } + return lines.map(l => indent + l).join('\n') } function formatTimingBar (tp: NonNullable): string { diff --git a/packages/cli/src/formatters/checks.ts b/packages/cli/src/formatters/checks.ts index a598b046d..b1e5402ea 100644 --- a/packages/cli/src/formatters/checks.ts +++ b/packages/cli/src/formatters/checks.ts @@ -234,27 +234,16 @@ function buildCheckColumns ( }, ] + columns.push({ + header: 'Tags', + ...(showId && { width: tagWidth }), + value: c => { + const tags = c.tags.length > 0 ? c.tags.join(', ') : chalk.dim('-') + return truncateToWidth(tags, tagWidth - 2) + }, + }) if (showId) { - columns.push({ - header: 'Tags', - width: tagWidth, - value: c => { - const tags = c.tags.length > 0 ? c.tags.join(', ') : chalk.dim('-') - return truncateToWidth(tags, tagWidth - 2) - }, - }) - columns.push({ - header: 'ID', - value: c => chalk.dim(c.id), - }) - } else { - columns.push({ - header: 'Tags', - value: c => { - const tags = c.tags.length > 0 ? c.tags.join(', ') : chalk.dim('-') - return truncateToWidth(tags, tagWidth - 2) - }, - }) + columns.push({ header: 'ID', value: c => chalk.dim(c.id) }) } return columns diff --git a/packages/cli/src/formatters/render.ts b/packages/cli/src/formatters/render.ts index 0c689250b..5182d1215 100644 --- a/packages/cli/src/formatters/render.ts +++ b/packages/cli/src/formatters/render.ts @@ -70,18 +70,7 @@ export function formatFrequency (minutes: number | undefined | null): string { } export function formatCheckType (checkType: string): string { - const typeMap: Record = { - API: 'API', - BROWSER: 'BROWSER', - MULTI_STEP: 'MULTISTEP', - HEARTBEAT: 'HEARTBEAT', - PLAYWRIGHT: 'PLAYWRIGHT', - TCP: 'TCP', - URL: 'URL', - DNS: 'DNS', - ICMP: 'ICMP', - } - return typeMap[checkType] ?? checkType + return checkType } export function formatDate (dateStr: string | null | undefined, format: OutputFormat): string { From 482d32e2035bf2850d3854ed475953c9a72a7afc Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 11:53:33 +0100 Subject: [PATCH 03/21] refactor(cli): move test fixtures to __fixtures__ directory --- .../cli/src/formatters/__tests__/{ => __fixtures__}/fixtures.ts | 0 .../cli/src/formatters/__tests__/check-result-detail.spec.ts | 2 +- packages/cli/src/formatters/__tests__/checks.spec.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/cli/src/formatters/__tests__/{ => __fixtures__}/fixtures.ts (100%) diff --git a/packages/cli/src/formatters/__tests__/fixtures.ts b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts similarity index 100% rename from packages/cli/src/formatters/__tests__/fixtures.ts rename to packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts 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 0ab066dba..3f1d3fb5a 100644 --- a/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts +++ b/packages/cli/src/formatters/__tests__/check-result-detail.spec.ts @@ -7,7 +7,7 @@ import { browserCheckResult, multiStepCheckResult, minimalCheckResult, -} from './fixtures' +} from './__fixtures__/fixtures' // Pin time for formatDate used in result detail beforeEach(() => { diff --git a/packages/cli/src/formatters/__tests__/checks.spec.ts b/packages/cli/src/formatters/__tests__/checks.spec.ts index ae5355e46..6e9321aca 100644 --- a/packages/cli/src/formatters/__tests__/checks.spec.ts +++ b/packages/cli/src/formatters/__tests__/checks.spec.ts @@ -30,7 +30,7 @@ import { browserCheckResult, activeErrorGroup, archivedErrorGroup, -} from './fixtures' +} from './__fixtures__/fixtures' // Pin time for timeAgo used in results/error groups beforeEach(() => { From 63174a43259d53a113ea22b4b275d08f05a145e5 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 12:18:16 +0100 Subject: [PATCH 04/21] fix(cli): add checks topic to help e2e test expectations --- packages/cli/e2e/__tests__/help.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/e2e/__tests__/help.spec.ts b/packages/cli/e2e/__tests__/help.spec.ts index f901c6c97..a8357f570 100644 --- a/packages/cli/e2e/__tests__/help.spec.ts +++ b/packages/cli/e2e/__tests__/help.spec.ts @@ -49,6 +49,7 @@ describe('help', () => { trigger Trigger your existing checks on Checkly.`) expect(stdout).toContain(`ADDITIONAL COMMANDS + checks List and inspect checks in your Checkly account. destroy Destroy your project with all its related resources. env Manage Checkly environment variables. help Display help for checkly. From 7718b6f0a97810db0a56a0c85eb69dd2b29f94ba Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 12:28:59 +0100 Subject: [PATCH 05/21] fix(cli): fix duplicate error output and status filter including inactive checks - Replace this.exit(1) with process.exitCode = 1 to avoid oclif's global handler printing the error a second time - Exclude inactive checks from --status filter results - Extract filterByStatus() and add unit tests --- .../commands/checks/__tests__/list.spec.ts | 84 ++++++++++++++++++- packages/cli/src/commands/checks/get.ts | 2 +- packages/cli/src/commands/checks/list.ts | 21 +++-- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/checks/__tests__/list.spec.ts b/packages/cli/src/commands/checks/__tests__/list.spec.ts index 131a337eb..5e2886d90 100644 --- a/packages/cli/src/commands/checks/__tests__/list.spec.ts +++ b/packages/cli/src/commands/checks/__tests__/list.spec.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest' -import { buildActiveCheckIds } from '../list' +import { buildActiveCheckIds, filterByStatus } from '../list' import type { Check } from '../../../rest/checks' +import type { CheckStatus } from '../../../rest/check-statuses' import type { CheckGroup } from '../../../rest/check-groups' +import type { CheckWithStatus } from '../../../formatters/checks' function makeCheck (overrides: Partial = {}): Check { return { @@ -36,6 +38,86 @@ function makeGroup (overrides: Partial = {}): CheckGroup { } } +const baseStatus: CheckStatus = { + name: 'Test', + checkId: 'check-1', + hasFailures: false, + hasErrors: false, + isDegraded: false, + longestRun: 100, + shortestRun: 50, + lastRunLocation: 'eu-west-1', + lastCheckRunId: 'run-1', + sslDaysRemaining: null, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: null, +} + +function makeCheckWithStatus (overrides: Partial = {}, statusOverrides?: Partial): CheckWithStatus { + return { + ...makeCheck(overrides), + status: statusOverrides !== undefined ? { ...baseStatus, ...statusOverrides } : undefined, + } +} + +describe('filterByStatus', () => { + it('excludes inactive checks when filtering for failing', () => { + const checks = [ + makeCheckWithStatus({ id: 'c1', activated: false }, { hasFailures: true }), + makeCheckWithStatus({ id: 'c2', activated: true }, { hasFailures: true }), + ] + const result = filterByStatus(checks, 'failing') + expect(result.map(c => c.id)).toEqual(['c2']) + }) + + it('excludes inactive checks when filtering for passing', () => { + const checks = [ + makeCheckWithStatus({ id: 'c1', activated: false }, {}), + makeCheckWithStatus({ id: 'c2', activated: true }, {}), + ] + const result = filterByStatus(checks, 'passing') + expect(result.map(c => c.id)).toEqual(['c2']) + }) + + it('includes checks with errors when filtering for failing', () => { + const checks = [ + makeCheckWithStatus({ id: 'c1' }, { hasErrors: true }), + makeCheckWithStatus({ id: 'c2' }, { hasFailures: true }), + makeCheckWithStatus({ id: 'c3' }, {}), + ] + const result = filterByStatus(checks, 'failing') + expect(result.map(c => c.id)).toEqual(['c1', 'c2']) + }) + + it('filters for degraded status', () => { + const checks = [ + makeCheckWithStatus({ id: 'c1' }, { isDegraded: true }), + makeCheckWithStatus({ id: 'c2' }, {}), + ] + const result = filterByStatus(checks, 'degraded') + expect(result.map(c => c.id)).toEqual(['c1']) + }) + + it('filters for passing status', () => { + const checks = [ + makeCheckWithStatus({ id: 'c1' }, {}), + makeCheckWithStatus({ id: 'c2' }, { hasFailures: true }), + makeCheckWithStatus({ id: 'c3' }, { isDegraded: true }), + ] + const result = filterByStatus(checks, 'passing') + expect(result.map(c => c.id)).toEqual(['c1']) + }) + + it('excludes checks without status data', () => { + const checks = [ + makeCheckWithStatus({ id: 'c1' }), + makeCheckWithStatus({ id: 'c2' }, { hasFailures: true }), + ] + const result = filterByStatus(checks, 'failing') + expect(result.map(c => c.id)).toEqual(['c2']) + }) +}) + describe('buildActiveCheckIds', () => { it('includes activated non-heartbeat checks', () => { const checks = [ diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 34a669fc6..b81d35b70 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -140,7 +140,7 @@ export default class ChecksGet extends AuthCommand { this.log(output.join('\n')) } catch (err: any) { this.style.longError('Failed to get check details.', err) - this.exit(1) + process.exitCode = 1 } } diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index 058029e64..acde031be 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -23,6 +23,17 @@ import { * (they have their own page), and checks in deactivated groups are treated * as inactive. */ +export function filterByStatus (checks: CheckWithStatus[], status: string): CheckWithStatus[] { + return checks.filter(c => { + if (!c.activated) return false + if (!c.status) return false + if (status === 'failing') return c.status.hasFailures || c.status.hasErrors + if (status === 'degraded') return c.status.isDegraded + if (status === 'passing') return !c.status.hasFailures && !c.status.hasErrors && !c.status.isDegraded + return true + }) +} + export function buildActiveCheckIds (checks: Check[], groups: CheckGroup[]): Set { const deactivatedGroups = new Set(groups.filter(g => !g.activated).map(g => g.id)) return new Set( @@ -112,13 +123,7 @@ export default class ChecksList extends AuthCommand { merged = merged.filter(c => c.name.toLowerCase().includes(term)) } if (flags.status) { - merged = merged.filter(c => { - if (!c.status) return false - if (flags.status === 'failing') return c.status.hasFailures || c.status.hasErrors - if (flags.status === 'degraded') return c.status.isDegraded - if (flags.status === 'passing') return !c.status.hasFailures && !c.status.hasErrors && !c.status.isDegraded - return true - }) + merged = filterByStatus(merged, flags.status) } if (flags.type) { merged = merged.filter(c => c.checkType === flags.type) @@ -205,7 +210,7 @@ export default class ChecksList extends AuthCommand { this.log(output.join('\n')) } catch (err: any) { this.style.longError('Failed to list checks.', err) - this.exit(1) + process.exitCode = 1 } } } From 93dbf06b395fda1af7e9f7865598a58f69f2acb1 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 12:46:25 +0100 Subject: [PATCH 06/21] fix(cli): avoid leading whitespace in summary bar when no statuses --- packages/cli/src/formatters/checks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/formatters/checks.ts b/packages/cli/src/formatters/checks.ts index b1e5402ea..f1f5c99a2 100644 --- a/packages/cli/src/formatters/checks.ts +++ b/packages/cli/src/formatters/checks.ts @@ -69,6 +69,7 @@ export function formatSummaryBar (statuses: CheckStatus[], totalChecks: number, if (failing > 0) parts.push(chalk.red(`${logSymbols.error} ${failing} failing`)) const total = chalk.dim(`(${totalChecks} total checks)`) + if (parts.length === 0) return total return parts.join(' ') + ' ' + total } From 5f03031a0db74e912899331b7122a831b1f5bcf2 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 12:49:52 +0100 Subject: [PATCH 07/21] fix(cli): handle empty account gracefully in checks list Add .catch() fallbacks for check-statuses and check-groups API calls so accounts with no checks don't crash with a 404. --- packages/cli/src/commands/checks/list.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index acde031be..b49309ef9 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -108,8 +108,8 @@ export default class ChecksList extends AuthCommand { if (needsAllChecks) { const [checkList, allStatuses, groupsResp] = await Promise.all([ api.checks.fetchAll({ tag: flags.tag }), - api.checkStatuses.fetchAll(), - api.checkGroups.getAll(), + api.checkStatuses.fetchAll().catch(() => []), + api.checkGroups.getAll().catch(() => ({ data: [] })), ]) statuses = allStatuses totalChecks = checkList.length @@ -133,7 +133,7 @@ export default class ChecksList extends AuthCommand { } else { const [paginated, allStatuses] = await Promise.all([ api.checks.getAllPaginated({ limit, page, tag: flags.tag }), - api.checkStatuses.fetchAll(), + api.checkStatuses.fetchAll().catch(() => []), ]) statuses = allStatuses totalChecks = paginated.total From 95db6bb52e9cb71b9b588d3f927d4380ca1196cb Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 12:57:41 +0100 Subject: [PATCH 08/21] test(cli): add e2e tests for empty account edge cases Test checks list and get commands against an account with no checks to verify graceful handling of empty responses and 404 fallbacks. --- .github/workflows/test.yml | 2 + .../__tests__/checks-empty-account.spec.ts | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 packages/cli/e2e/__tests__/checks-empty-account.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c97b55929..8e4472a2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,8 @@ jobs: CHECKLY_ACCOUNT_NAME: ${{ secrets.E2E_CHECKLY_ACCOUNT_NAME }} CHECKLY_ACCOUNT_ID: ${{ secrets.E2E_CHECKLY_ACCOUNT_ID }} CHECKLY_API_KEY: ${{ secrets.E2E_CHECKLY_API_KEY }} + CHECKLY_EMPTY_ACCOUNT_ID: ${{ secrets.E2E_EMPTY_CHECKLY_ACCOUNT_ID }} + CHECKLY_EMPTY_API_KEY: ${{ secrets.E2E_EMPTY_CHECKLY_API_KEY }} - name: Save LLM rules as an artifact uses: actions/upload-artifact@v4 with: diff --git a/packages/cli/e2e/__tests__/checks-empty-account.spec.ts b/packages/cli/e2e/__tests__/checks-empty-account.spec.ts new file mode 100644 index 000000000..f710af3af --- /dev/null +++ b/packages/cli/e2e/__tests__/checks-empty-account.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' + +import { runChecklyCli } from '../run-checkly' + +const apiKey = process.env.CHECKLY_EMPTY_API_KEY +const accountId = process.env.CHECKLY_EMPTY_ACCOUNT_ID + +describe('checks commands on empty account', () => { + it('should show "No checks found." for checks list', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list'], + apiKey, + accountId, + }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('No checks found.') + }) + + it('should return empty JSON array for checks list --output json', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--output', 'json'], + apiKey, + accountId, + }) + expect(result.status).toBe(0) + const parsed = JSON.parse(result.stdout) + expect(parsed.data).toEqual([]) + expect(parsed.pagination.total).toBe(0) + }) + + it('should show "No checks found." for checks list --output md', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--output', 'md'], + apiKey, + accountId, + }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('No checks found.') + }) + + it('should handle --status filter gracefully on empty account', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--status', 'failing'], + apiKey, + accountId, + }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('No checks matching') + }) + + it('should handle --search filter gracefully on empty account', async () => { + const result = await runChecklyCli({ + args: ['checks', 'list', '--search', 'anything'], + apiKey, + accountId, + }) + expect(result.status).toBe(0) + expect(result.stdout).toContain('No checks matching') + }) + + it('should fail gracefully for checks get on empty account', async () => { + const result = await runChecklyCli({ + args: ['checks', 'get', '00000000-0000-0000-0000-000000000000'], + apiKey, + accountId, + }) + expect(result.status).toBe(1) + expect(result.stdout).toContain('Failed to get check details') + }) +}) From 00b045970cf572c243beac7a85cc07e8e55f529d Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 13:12:52 +0100 Subject: [PATCH 09/21] fix(cli): support --output md for error group detail and align e2e config - Add markdown output branch to showErrorGroupDetail (was rendering terminal/chalk output regardless of --output flag) - Switch empty account e2e tests to use node-config pattern with describe.skipIf for graceful skipping when creds aren't set - Add emptyApiKey/emptyAccountId to e2e config default.js - Document optional empty account test setup in e2e README --- packages/cli/e2e/README.md | 6 ++++ .../__tests__/checks-empty-account.spec.ts | 7 +++-- packages/cli/e2e/config/default.js | 4 +++ packages/cli/src/commands/checks/get.ts | 30 +++++++++++++++++-- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/cli/e2e/README.md b/packages/cli/e2e/README.md index a9bd91d7c..718f9d2e2 100644 --- a/packages/cli/e2e/README.md +++ b/packages/cli/e2e/README.md @@ -6,3 +6,9 @@ Since the E2E tests rely on test accounts in Checkly, some configuration is need To run the E2E tests locally, create a file `local.js` in [./config](./config). This configuration file should override all of the options from [./config/default.js](./config/default.js). +### Empty account tests (optional) + +Some tests verify edge-case behavior against an account with no checks. These are skipped automatically when credentials aren't configured. + +To run them locally, add `emptyApiKey` and `emptyAccountId` to your `config/local.js`, or set `CHECKLY_EMPTY_API_KEY` and `CHECKLY_EMPTY_ACCOUNT_ID` in your environment. In CI, these are provided via GitHub secrets. + diff --git a/packages/cli/e2e/__tests__/checks-empty-account.spec.ts b/packages/cli/e2e/__tests__/checks-empty-account.spec.ts index f710af3af..e7b13736c 100644 --- a/packages/cli/e2e/__tests__/checks-empty-account.spec.ts +++ b/packages/cli/e2e/__tests__/checks-empty-account.spec.ts @@ -1,11 +1,12 @@ +import config from 'config' import { describe, it, expect } from 'vitest' import { runChecklyCli } from '../run-checkly' -const apiKey = process.env.CHECKLY_EMPTY_API_KEY -const accountId = process.env.CHECKLY_EMPTY_ACCOUNT_ID +const apiKey: string | undefined = config.get('emptyApiKey') +const accountId: string | undefined = config.get('emptyAccountId') -describe('checks commands on empty account', () => { +describe.skipIf(!apiKey || !accountId)('checks commands on empty account', () => { it('should show "No checks found." for checks list', async () => { const result = await runChecklyCli({ args: ['checks', 'list'], diff --git a/packages/cli/e2e/config/default.js b/packages/cli/e2e/config/default.js index 55fe40e7d..cb4cf812b 100644 --- a/packages/cli/e2e/config/default.js +++ b/packages/cli/e2e/config/default.js @@ -7,4 +7,8 @@ config.apiKey = process.env.CHECKLY_API_KEY config.accountId = process.env.CHECKLY_ACCOUNT_ID config.baseURL = process.env.CHECKLY_BASE_URL || 'https://api.checklyhq.com' +// Optional: empty account for edge-case testing (no checks, no groups, etc.) +config.emptyApiKey = process.env.CHECKLY_EMPTY_API_KEY +config.emptyAccountId = process.env.CHECKLY_EMPTY_ACCOUNT_ID + module.exports = config diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index b81d35b70..73aca24ad 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -155,6 +155,8 @@ export default class ChecksGet extends AuthCommand { return } + const fmt: OutputFormat = outputFormat === 'md' ? 'md' : 'terminal' + const cleanMsg = stripAnsi(errorGroup.cleanedErrorMessage) .replace(/\s+/g, ' ') .trim() @@ -164,6 +166,30 @@ export default class ChecksGet extends AuthCommand { ? stripAnsi(errorGroup.rawErrorMessage).trim() : null + if (fmt === 'md') { + const lines = [ + '# Error Group', + '', + `\`\`\`\n${cleanMsg}\n\`\`\``, + ] + if (rawMsg && rawMsg !== cleanMsg) { + lines.push('', '## Full Error', '', `\`\`\`\n${stripAnsi(errorGroup.rawErrorMessage!).trim()}\n\`\`\``) + } + lines.push( + '', + `| Field | Value |`, + `| --- | --- |`, + `| First seen | ${formatDate(errorGroup.firstSeen, fmt)} |`, + `| Last seen | ${formatDate(errorGroup.lastSeen, fmt)} |`, + `| Error group | ${errorGroup.id} |`, + ) + if (check.scriptPath) { + lines.push(`| Source file | ${check.scriptPath} |`) + } + this.log(lines.join('\n')) + return + } + const output: string[] = [] output.push(chalk.bold('ERROR GROUP')) @@ -177,8 +203,8 @@ export default class ChecksGet extends AuthCommand { } output.push('') - output.push(`${chalk.dim('First seen:')} ${formatDate(errorGroup.firstSeen, 'terminal')}`) - output.push(`${chalk.dim('Last seen:')} ${formatDate(errorGroup.lastSeen, 'terminal')}`) + output.push(`${chalk.dim('First seen:')} ${formatDate(errorGroup.firstSeen, fmt)}`) + output.push(`${chalk.dim('Last seen:')} ${formatDate(errorGroup.lastSeen, fmt)}`) output.push(`${chalk.dim('Error group:')} ${errorGroup.id}`) if (check.scriptPath) { From f7c6b16db7c9f2e231c12bacec735de9d69cd7df Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Mon, 23 Feb 2026 13:51:03 +0100 Subject: [PATCH 10/21] feat(cli): add type breakdown line to checks list summary bar Show per-type counts (e.g. BROWSER: 140 API: 77 ...) on a dim line below the status summary. Filters by activeCheckIds so the subcounts sum to the displayed total. --- .../commands/checks/__tests__/list.spec.ts | 5 +- packages/cli/src/commands/checks/list.ts | 28 ++++---- .../src/formatters/__tests__/checks.spec.ts | 72 +++++++++++++++++++ packages/cli/src/formatters/checks.ts | 17 ++++- 4 files changed, 105 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/checks/__tests__/list.spec.ts b/packages/cli/src/commands/checks/__tests__/list.spec.ts index 5e2886d90..c6edcda75 100644 --- a/packages/cli/src/commands/checks/__tests__/list.spec.ts +++ b/packages/cli/src/commands/checks/__tests__/list.spec.ts @@ -140,14 +140,14 @@ describe('buildActiveCheckIds', () => { expect(result.has('c2')).toBe(false) }) - it('excludes heartbeat checks', () => { + it('includes heartbeat checks', () => { const checks = [ makeCheck({ id: 'c1', checkType: 'API' }), makeCheck({ id: 'c2', checkType: 'HEARTBEAT' }), ] const result = buildActiveCheckIds(checks, []) expect(result.has('c1')).toBe(true) - expect(result.has('c2')).toBe(false) + expect(result.has('c2')).toBe(true) }) it('excludes checks in deactivated groups', () => { @@ -169,7 +169,6 @@ describe('buildActiveCheckIds', () => { it('returns empty set when no checks match', () => { const checks = [ makeCheck({ id: 'c1', activated: false }), - makeCheck({ id: 'c2', checkType: 'HEARTBEAT' }), ] const result = buildActiveCheckIds(checks, []) expect(result.size).toBe(0) diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index b49309ef9..795558c46 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -10,18 +10,14 @@ import type { OutputFormat } from '../../formatters/render' import { formatChecks, formatSummaryBar, + formatTypeBreakdown, formatPaginationInfo, formatNavigationHints, } from '../../formatters/checks' /** - * Match webapp logic: a check is effectively active only if both the check - * AND its parent group (if any) are activated. - */ -/** - * Match webapp: heartbeats are excluded from the home dashboard summary - * (they have their own page), and checks in deactivated groups are treated - * as inactive. + * A check is effectively active only if both the check AND its parent group + * (if any) are activated. Checks in deactivated groups are treated as inactive. */ export function filterByStatus (checks: CheckWithStatus[], status: string): CheckWithStatus[] { return checks.filter(c => { @@ -40,7 +36,6 @@ export function buildActiveCheckIds (checks: Check[], groups: CheckGroup[]): Set checks .filter(c => c.activated - && c.checkType !== 'HEARTBEAT' && !(c.groupId && deactivatedGroups.has(c.groupId)), ) .map(c => c.id), @@ -77,7 +72,7 @@ export default class ChecksList extends AuthCommand { }), 'type': Flags.string({ description: 'Filter by check type.', - options: ['API', 'BROWSER', 'MULTI_STEP', 'HEARTBEAT', 'PLAYWRIGHT', 'TCP'], + options: ['API', 'BROWSER', 'MULTI_STEP', 'HEARTBEAT', 'PLAYWRIGHT', 'TCP', 'DNS', 'ICMP', 'URL'], }), 'hide-id': Flags.boolean({ description: 'Hide check IDs in table output.', @@ -100,6 +95,7 @@ export default class ChecksList extends AuthCommand { const needsAllChecks = !!(flags.search || flags.status || flags.type) let filteredChecks: CheckWithStatus[] + let allChecksList: Check[] let totalChecks: number let statuses: CheckStatus[] let activeCheckIds: Set | undefined @@ -113,6 +109,7 @@ export default class ChecksList extends AuthCommand { ]) statuses = allStatuses totalChecks = checkList.length + allChecksList = checkList activeCheckIds = buildActiveCheckIds(checkList, groupsResp.data) const statusMap = new Map(statuses.map(s => [s.checkId, s])) @@ -123,6 +120,9 @@ export default class ChecksList extends AuthCommand { merged = merged.filter(c => c.name.toLowerCase().includes(term)) } if (flags.status) { + // Exclude heartbeats and deactivated-group checks to match the + // summary bar counts (same logic as buildActiveCheckIds). + merged = merged.filter(c => activeCheckIds!.has(c.id)) merged = filterByStatus(merged, flags.status) } if (flags.type) { @@ -131,15 +131,16 @@ export default class ChecksList extends AuthCommand { filteredChecks = merged } else { - const [paginated, allStatuses] = await Promise.all([ + const [paginated, allStatuses, allChecks, groupsResp] = await Promise.all([ api.checks.getAllPaginated({ limit, page, tag: flags.tag }), api.checkStatuses.fetchAll().catch(() => []), + api.checks.fetchAll({ tag: flags.tag }), + api.checkGroups.getAll().catch(() => ({ data: [] })), ]) statuses = allStatuses totalChecks = paginated.total - // On the paginated path, skip the extra fetchAll + groups call. - // The summary bar will count all statuses (including heartbeats). - activeCheckIds = undefined + allChecksList = allChecks + activeCheckIds = buildActiveCheckIds(allChecks, groupsResp.data) const statusMap = new Map(statuses.map(s => [s.checkId, s])) filteredChecks = paginated.checks.map(c => ({ ...c, status: statusMap.get(c.id) })) @@ -187,6 +188,7 @@ export default class ChecksList extends AuthCommand { const output: string[] = [] output.push(formatSummaryBar(statuses, totalChecks, activeCheckIds)) + output.push(formatTypeBreakdown(allChecksList, activeCheckIds)) output.push('') if (displayChecks.length === 0) { diff --git a/packages/cli/src/formatters/__tests__/checks.spec.ts b/packages/cli/src/formatters/__tests__/checks.spec.ts index 6e9321aca..bade1e3c5 100644 --- a/packages/cli/src/formatters/__tests__/checks.spec.ts +++ b/packages/cli/src/formatters/__tests__/checks.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { stripAnsi } from '../render' import { formatSummaryBar, + formatTypeBreakdown, formatPaginationInfo, formatNavigationHints, formatChecks, @@ -69,6 +70,77 @@ describe('formatSummaryBar', () => { }) }) +describe('formatTypeBreakdown', () => { + it('shows counts per check type sorted by count descending', () => { + const checks = [ + { ...passingCheck, checkType: 'BROWSER' }, + { ...passingCheck, checkType: 'BROWSER' }, + { ...passingCheck, checkType: 'BROWSER' }, + { ...passingCheck, checkType: 'API' }, + { ...passingCheck, checkType: 'API' }, + { ...passingCheck, checkType: 'HEARTBEAT' }, + ] + const result = stripAnsi(formatTypeBreakdown(checks)) + expect(result).toContain('BROWSER: 3') + expect(result).toContain('API: 2') + expect(result).toContain('HEARTBEAT: 1') + // BROWSER (3) should come before API (2) + expect(result.indexOf('BROWSER: 3')).toBeLessThan(result.indexOf('API: 2')) + }) + + it('omits types with zero count', () => { + const checks = [ + { ...passingCheck, checkType: 'API' }, + ] + const result = stripAnsi(formatTypeBreakdown(checks)) + expect(result).toContain('API: 1') + expect(result).not.toContain('BROWSER') + }) + + it('filters by activeCheckIds when provided', () => { + const checks = [ + { ...passingCheck, id: 'a', checkType: 'API' }, + { ...passingCheck, id: 'b', checkType: 'API' }, + { ...passingCheck, id: 'c', checkType: 'BROWSER' }, + ] + const activeIds = new Set(['a', 'c']) + const result = stripAnsi(formatTypeBreakdown(checks, activeIds)) + expect(result).toContain('API: 1') + expect(result).toContain('BROWSER: 1') + }) + + it('type counts sum to number of input checks', () => { + const checks = [ + { ...passingCheck, checkType: 'BROWSER' }, + { ...passingCheck, checkType: 'BROWSER' }, + { ...passingCheck, checkType: 'API' }, + { ...passingCheck, checkType: 'HEARTBEAT' }, + { ...passingCheck, checkType: 'TCP' }, + ] + const result = stripAnsi(formatTypeBreakdown(checks)) + const sum = [...result.matchAll(/(\d+)/g)].reduce((s, m) => s + Number(m[1]), 0) + expect(sum).toBe(checks.length) + }) + + it('type counts sum to activeCheckIds size when filtered', () => { + const checks = [ + { ...passingCheck, id: 'a', checkType: 'API' }, + { ...passingCheck, id: 'b', checkType: 'BROWSER' }, + { ...passingCheck, id: 'c', checkType: 'BROWSER' }, + { ...passingCheck, id: 'd', checkType: 'TCP' }, + ] + const activeIds = new Set(['a', 'b', 'd']) + const result = stripAnsi(formatTypeBreakdown(checks, activeIds)) + const sum = [...result.matchAll(/(\d+)/g)].reduce((s, m) => s + Number(m[1]), 0) + expect(sum).toBe(activeIds.size) + }) + + it('returns empty string for empty list', () => { + const result = formatTypeBreakdown([]) + expect(stripAnsi(result)).toBe('') + }) +}) + describe('formatPaginationInfo', () => { it('shows correct range and page numbers', () => { const result = stripAnsi(formatPaginationInfo({ page: 2, limit: 10, total: 35 })) diff --git a/packages/cli/src/formatters/checks.ts b/packages/cli/src/formatters/checks.ts index f1f5c99a2..0fcc8b393 100644 --- a/packages/cli/src/formatters/checks.ts +++ b/packages/cli/src/formatters/checks.ts @@ -68,11 +68,26 @@ export function formatSummaryBar (statuses: CheckStatus[], totalChecks: number, if (degraded > 0) parts.push(chalk.yellow(`${logSymbols.warning} ${degraded} degraded`)) if (failing > 0) parts.push(chalk.red(`${logSymbols.error} ${failing} failing`)) - const total = chalk.dim(`(${totalChecks} total checks)`) + const displayTotal = activeCheckIds ? activeCheckIds.size : totalChecks + const total = chalk.dim(`(${displayTotal} total checks)`) if (parts.length === 0) return total return parts.join(' ') + ' ' + total } +// --- Type breakdown (terminal only) --- + +export function formatTypeBreakdown (checks: Check[], activeCheckIds?: Set): string { + const filtered = activeCheckIds ? checks.filter(c => activeCheckIds.has(c.id)) : checks + const counts = new Map() + for (const check of filtered) { + counts.set(check.checkType, (counts.get(check.checkType) || 0) + 1) + } + const parts = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => `${type}: ${count}`) + return chalk.dim(parts.join(' ')) +} + // --- Pagination info (terminal only) --- export function formatPaginationInfo (pagination: PaginationInfo): string { From 372cbe2a1ed9de84a39b46f079e605d7caffa244 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Tue, 24 Feb 2026 16:26:14 +0100 Subject: [PATCH 11/21] feat(cli): use server-side checkType and search filters in checks list Replace client-side filtering that fetched all checks (5+ API calls for large accounts) with server-side query params on GET /v1/checks. The command now makes just 2 API calls regardless of filters applied. Remove --status flag (not supported server-side), client-side filter helpers (filterByStatus, buildActiveCheckIds), and their tests. --- .../commands/checks/__tests__/list.spec.ts | 181 ------------------ packages/cli/src/commands/checks/list.ts | 134 +++---------- packages/cli/src/rest/checks.ts | 2 + 3 files changed, 26 insertions(+), 291 deletions(-) delete mode 100644 packages/cli/src/commands/checks/__tests__/list.spec.ts diff --git a/packages/cli/src/commands/checks/__tests__/list.spec.ts b/packages/cli/src/commands/checks/__tests__/list.spec.ts deleted file mode 100644 index c6edcda75..000000000 --- a/packages/cli/src/commands/checks/__tests__/list.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { buildActiveCheckIds, filterByStatus } from '../list' -import type { Check } from '../../../rest/checks' -import type { CheckStatus } from '../../../rest/check-statuses' -import type { CheckGroup } from '../../../rest/check-groups' -import type { CheckWithStatus } from '../../../formatters/checks' - -function makeCheck (overrides: Partial = {}): Check { - return { - id: 'check-1', - name: 'Test Check', - checkType: 'API', - activated: true, - muted: false, - frequency: 10, - locations: ['eu-west-1'], - tags: [], - groupId: null, - groupOrder: null, - runtimeId: null, - scriptPath: null, - created_at: '2025-01-01T00:00:00.000Z', - updated_at: null, - ...overrides, - } -} - -function makeGroup (overrides: Partial = {}): CheckGroup { - return { - id: 1, - name: 'Group 1', - activated: true, - muted: false, - locations: [], - tags: [], - concurrency: 1, - ...overrides, - } -} - -const baseStatus: CheckStatus = { - name: 'Test', - checkId: 'check-1', - hasFailures: false, - hasErrors: false, - isDegraded: false, - longestRun: 100, - shortestRun: 50, - lastRunLocation: 'eu-west-1', - lastCheckRunId: 'run-1', - sslDaysRemaining: null, - created_at: '2025-01-01T00:00:00.000Z', - updated_at: null, -} - -function makeCheckWithStatus (overrides: Partial = {}, statusOverrides?: Partial): CheckWithStatus { - return { - ...makeCheck(overrides), - status: statusOverrides !== undefined ? { ...baseStatus, ...statusOverrides } : undefined, - } -} - -describe('filterByStatus', () => { - it('excludes inactive checks when filtering for failing', () => { - const checks = [ - makeCheckWithStatus({ id: 'c1', activated: false }, { hasFailures: true }), - makeCheckWithStatus({ id: 'c2', activated: true }, { hasFailures: true }), - ] - const result = filterByStatus(checks, 'failing') - expect(result.map(c => c.id)).toEqual(['c2']) - }) - - it('excludes inactive checks when filtering for passing', () => { - const checks = [ - makeCheckWithStatus({ id: 'c1', activated: false }, {}), - makeCheckWithStatus({ id: 'c2', activated: true }, {}), - ] - const result = filterByStatus(checks, 'passing') - expect(result.map(c => c.id)).toEqual(['c2']) - }) - - it('includes checks with errors when filtering for failing', () => { - const checks = [ - makeCheckWithStatus({ id: 'c1' }, { hasErrors: true }), - makeCheckWithStatus({ id: 'c2' }, { hasFailures: true }), - makeCheckWithStatus({ id: 'c3' }, {}), - ] - const result = filterByStatus(checks, 'failing') - expect(result.map(c => c.id)).toEqual(['c1', 'c2']) - }) - - it('filters for degraded status', () => { - const checks = [ - makeCheckWithStatus({ id: 'c1' }, { isDegraded: true }), - makeCheckWithStatus({ id: 'c2' }, {}), - ] - const result = filterByStatus(checks, 'degraded') - expect(result.map(c => c.id)).toEqual(['c1']) - }) - - it('filters for passing status', () => { - const checks = [ - makeCheckWithStatus({ id: 'c1' }, {}), - makeCheckWithStatus({ id: 'c2' }, { hasFailures: true }), - makeCheckWithStatus({ id: 'c3' }, { isDegraded: true }), - ] - const result = filterByStatus(checks, 'passing') - expect(result.map(c => c.id)).toEqual(['c1']) - }) - - it('excludes checks without status data', () => { - const checks = [ - makeCheckWithStatus({ id: 'c1' }), - makeCheckWithStatus({ id: 'c2' }, { hasFailures: true }), - ] - const result = filterByStatus(checks, 'failing') - expect(result.map(c => c.id)).toEqual(['c2']) - }) -}) - -describe('buildActiveCheckIds', () => { - it('includes activated non-heartbeat checks', () => { - const checks = [ - makeCheck({ id: 'c1', checkType: 'API', activated: true }), - makeCheck({ id: 'c2', checkType: 'BROWSER', activated: true }), - ] - const result = buildActiveCheckIds(checks, []) - expect(result.has('c1')).toBe(true) - expect(result.has('c2')).toBe(true) - expect(result.size).toBe(2) - }) - - it('excludes deactivated checks', () => { - const checks = [ - makeCheck({ id: 'c1', activated: true }), - makeCheck({ id: 'c2', activated: false }), - ] - const result = buildActiveCheckIds(checks, []) - expect(result.has('c1')).toBe(true) - expect(result.has('c2')).toBe(false) - }) - - it('includes heartbeat checks', () => { - const checks = [ - makeCheck({ id: 'c1', checkType: 'API' }), - makeCheck({ id: 'c2', checkType: 'HEARTBEAT' }), - ] - const result = buildActiveCheckIds(checks, []) - expect(result.has('c1')).toBe(true) - expect(result.has('c2')).toBe(true) - }) - - it('excludes checks in deactivated groups', () => { - const checks = [ - makeCheck({ id: 'c1', groupId: 1 }), - makeCheck({ id: 'c2', groupId: 2 }), - makeCheck({ id: 'c3', groupId: null }), - ] - const groups = [ - makeGroup({ id: 1, activated: true }), - makeGroup({ id: 2, activated: false }), - ] - const result = buildActiveCheckIds(checks, groups) - expect(result.has('c1')).toBe(true) - expect(result.has('c2')).toBe(false) - expect(result.has('c3')).toBe(true) - }) - - it('returns empty set when no checks match', () => { - const checks = [ - makeCheck({ id: 'c1', activated: false }), - ] - const result = buildActiveCheckIds(checks, []) - expect(result.size).toBe(0) - }) - - it('handles empty inputs', () => { - const result = buildActiveCheckIds([], []) - expect(result.size).toBe(0) - }) -}) diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index 795558c46..df6b6cefb 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -2,46 +2,15 @@ import { Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' import * as api from '../../rest/api' -import type { Check } from '../../rest/checks' -import type { CheckStatus } from '../../rest/check-statuses' -import type { CheckGroup } from '../../rest/check-groups' import type { CheckWithStatus } from '../../formatters/checks' import type { OutputFormat } from '../../formatters/render' import { formatChecks, formatSummaryBar, - formatTypeBreakdown, formatPaginationInfo, formatNavigationHints, } from '../../formatters/checks' -/** - * A check is effectively active only if both the check AND its parent group - * (if any) are activated. Checks in deactivated groups are treated as inactive. - */ -export function filterByStatus (checks: CheckWithStatus[], status: string): CheckWithStatus[] { - return checks.filter(c => { - if (!c.activated) return false - if (!c.status) return false - if (status === 'failing') return c.status.hasFailures || c.status.hasErrors - if (status === 'degraded') return c.status.isDegraded - if (status === 'passing') return !c.status.hasFailures && !c.status.hasErrors && !c.status.isDegraded - return true - }) -} - -export function buildActiveCheckIds (checks: Check[], groups: CheckGroup[]): Set { - const deactivatedGroups = new Set(groups.filter(g => !g.activated).map(g => g.id)) - return new Set( - checks - .filter(c => - c.activated - && !(c.groupId && deactivatedGroups.has(c.groupId)), - ) - .map(c => c.id), - ) -} - export default class ChecksList extends AuthCommand { static hidden = false static description = 'List all checks in your account.' @@ -66,10 +35,6 @@ export default class ChecksList extends AuthCommand { char: 's', description: 'Filter checks by name (case-insensitive).', }), - 'status': Flags.string({ - description: 'Filter by status.', - options: ['passing', 'degraded', 'failing'], - }), 'type': Flags.string({ description: 'Filter by check type.', options: ['API', 'BROWSER', 'MULTI_STEP', 'HEARTBEAT', 'PLAYWRIGHT', 'TCP', 'DNS', 'ICMP', 'URL'], @@ -91,87 +56,41 @@ export default class ChecksList extends AuthCommand { this.style.outputFormat = flags.output const { page, limit } = flags - // Client-side filters require fetching all checks (same approach as the webapp) - const needsAllChecks = !!(flags.search || flags.status || flags.type) - - let filteredChecks: CheckWithStatus[] - let allChecksList: Check[] - let totalChecks: number - let statuses: CheckStatus[] - let activeCheckIds: Set | undefined - try { - if (needsAllChecks) { - const [checkList, allStatuses, groupsResp] = await Promise.all([ - api.checks.fetchAll({ tag: flags.tag }), - api.checkStatuses.fetchAll().catch(() => []), - api.checkGroups.getAll().catch(() => ({ data: [] })), - ]) - statuses = allStatuses - totalChecks = checkList.length - allChecksList = checkList - activeCheckIds = buildActiveCheckIds(checkList, groupsResp.data) - - const statusMap = new Map(statuses.map(s => [s.checkId, s])) - let merged: CheckWithStatus[] = checkList.map(c => ({ ...c, status: statusMap.get(c.id) })) - - if (flags.search) { - const term = flags.search.toLowerCase() - merged = merged.filter(c => c.name.toLowerCase().includes(term)) - } - if (flags.status) { - // Exclude heartbeats and deactivated-group checks to match the - // summary bar counts (same logic as buildActiveCheckIds). - merged = merged.filter(c => activeCheckIds!.has(c.id)) - merged = filterByStatus(merged, flags.status) - } - if (flags.type) { - merged = merged.filter(c => c.checkType === flags.type) - } - - filteredChecks = merged - } else { - const [paginated, allStatuses, allChecks, groupsResp] = await Promise.all([ - api.checks.getAllPaginated({ limit, page, tag: flags.tag }), - api.checkStatuses.fetchAll().catch(() => []), - api.checks.fetchAll({ tag: flags.tag }), - api.checkGroups.getAll().catch(() => ({ data: [] })), - ]) - statuses = allStatuses - totalChecks = paginated.total - allChecksList = allChecks - activeCheckIds = buildActiveCheckIds(allChecks, groupsResp.data) - - const statusMap = new Map(statuses.map(s => [s.checkId, s])) - filteredChecks = paginated.checks.map(c => ({ ...c, status: statusMap.get(c.id) })) - } + // All filtering is server-side — single paginated API call + const [paginated, statuses] = await Promise.all([ + api.checks.getAllPaginated({ + limit, + page, + tag: flags.tag, + checkType: flags.type, + search: flags.search, + }), + api.checkStatuses.fetchAll().catch(() => []), + ]) + + const statusMap = new Map(statuses.map(s => [s.checkId, s])) + const checks: CheckWithStatus[] = paginated.checks.map(c => ({ ...c, status: statusMap.get(c.id) })) + const totalChecks = paginated.total + const pagination = { page, limit, total: totalChecks } // Build active filters for display const activeFilters: string[] = [] if (flags.tag) activeFilters.push(...flags.tag.map(t => `tag=${t}`)) if (flags.search) activeFilters.push(`search="${flags.search}"`) - if (flags.status) activeFilters.push(`status=${flags.status}`) if (flags.type) activeFilters.push(`type=${flags.type}`) - // When filtering all checks, paginate the filtered results client-side - const filteredTotal = needsAllChecks ? filteredChecks.length : totalChecks - const displayChecks = needsAllChecks - ? filteredChecks.slice((page - 1) * limit, page * limit) - : filteredChecks - - const pagination = { page, limit, total: filteredTotal } - // JSON output if (flags.output === 'json') { - const totalPages = Math.ceil(filteredTotal / limit) + const totalPages = Math.ceil(totalChecks / limit) this.log(JSON.stringify({ - data: displayChecks, - pagination: { page, limit, total: filteredTotal, totalPages }, + data: checks, + pagination: { page, limit, total: totalChecks, totalPages }, }, null, 2)) return } - if (filteredTotal === 0 && activeFilters.length === 0) { + if (totalChecks === 0 && activeFilters.length === 0) { this.log('No checks found.') return } @@ -180,29 +99,24 @@ export default class ChecksList extends AuthCommand { // Markdown output if (fmt === 'md') { - this.log(formatChecks(displayChecks, fmt, { pagination })) + this.log(formatChecks(checks, fmt, { pagination })) return } // Table output const output: string[] = [] - output.push(formatSummaryBar(statuses, totalChecks, activeCheckIds)) - output.push(formatTypeBreakdown(allChecksList, activeCheckIds)) + output.push(formatSummaryBar(statuses, totalChecks)) output.push('') - if (displayChecks.length === 0) { + if (checks.length === 0) { const filterDesc = activeFilters.join(', ') output.push(chalk.dim(`No checks matching: ${filterDesc}`)) output.push('') output.push(chalk.dim('Try:')) output.push(` checkly checks list ${chalk.dim('(show all)')}`) - if (flags.status) { - const otherStatuses = ['passing', 'degraded', 'failing'].filter(s => s !== flags.status) - output.push(` checkly checks list --status ${otherStatuses[0]} ${chalk.dim(`(try ${otherStatuses[0]})`)}`) - } } else { - output.push(formatChecks(displayChecks, fmt, { showId: !flags['hide-id'] })) + output.push(formatChecks(checks, fmt, { showId: !flags['hide-id'] })) output.push('') output.push(formatPaginationInfo(pagination)) output.push('') diff --git a/packages/cli/src/rest/checks.ts b/packages/cli/src/rest/checks.ts index ca1f6bc7a..be0c49a73 100644 --- a/packages/cli/src/rest/checks.ts +++ b/packages/cli/src/rest/checks.ts @@ -24,6 +24,8 @@ export interface ListChecksParams { limit?: number page?: number tag?: string[] + checkType?: string + search?: string } export interface PaginatedChecks { From 771423e1fa875cad969dee468bd7ed8f15812380 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 10:42:27 +0100 Subject: [PATCH 12/21] fix(cli): update e2e test expectation for checks output format --- packages/cli/e2e/__tests__/checks-get.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/e2e/__tests__/checks-get.spec.ts b/packages/cli/e2e/__tests__/checks-get.spec.ts index 733df9b78..ee6aa9f83 100644 --- a/packages/cli/e2e/__tests__/checks-get.spec.ts +++ b/packages/cli/e2e/__tests__/checks-get.spec.ts @@ -46,8 +46,7 @@ describe('checkly checks get', () => { accountId: config.get('accountId'), }) expect(result.status).toBe(0) - expect(result.stdout).toContain('#') - expect(result.stdout).toContain('| Field |') + expect(result.stdout).toContain('| Field | Value |') }) it('should fail for nonexistent check ID', async () => { From bd84adcf47a9b99877a35673c05d5c2450bbf969 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 10:49:00 +0100 Subject: [PATCH 13/21] test(cli): enhance e2e tests for checks list output and limit functionality --- .../cli/e2e/__tests__/checks-list.spec.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/cli/e2e/__tests__/checks-list.spec.ts b/packages/cli/e2e/__tests__/checks-list.spec.ts index 8d5f4d1d0..7af9cbe31 100644 --- a/packages/cli/e2e/__tests__/checks-list.spec.ts +++ b/packages/cli/e2e/__tests__/checks-list.spec.ts @@ -33,18 +33,26 @@ describe('checkly checks list', () => { accountId: config.get('accountId'), }) expect(result.status).toBe(0) - expect(result.stdout).toContain('| Name |') - expect(result.stdout).toContain('| --- |') + expect(result.stdout).toContain('| Name | Type | Status |') }) it('should respect --limit flag', async () => { - const result = await runChecklyCli({ + const allResult = await runChecklyCli({ + args: ['checks', 'list', '--output', 'json'], + apiKey: config.get('apiKey'), + accountId: config.get('accountId'), + }) + expect(allResult.status).toBe(0) + const allParsed = JSON.parse(allResult.stdout) + expect(allParsed.data.length).toBeGreaterThan(1) + + const limitedResult = await runChecklyCli({ args: ['checks', 'list', '--output', 'json', '--limit', '1'], apiKey: config.get('apiKey'), accountId: config.get('accountId'), }) - expect(result.status).toBe(0) - const parsed = JSON.parse(result.stdout) - expect(parsed.data.length).toBeLessThanOrEqual(1) + expect(limitedResult.status).toBe(0) + const limitedParsed = JSON.parse(limitedResult.stdout) + expect(limitedParsed.data).toHaveLength(1) }) }) From 2aadd760b84fc067c59738466aef13964fe66bb4 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 12:01:04 +0100 Subject: [PATCH 14/21] refactor(cli): centralize output flag for checks commands Extract shared outputFlag factory to flags.ts. Also improve e2e test assertions per PR review feedback. --- packages/cli/src/commands/checks/flags.ts | 10 ++++++++++ packages/cli/src/commands/checks/get.ts | 10 +++------- packages/cli/src/commands/checks/list.ts | 8 ++------ 3 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/commands/checks/flags.ts diff --git a/packages/cli/src/commands/checks/flags.ts b/packages/cli/src/commands/checks/flags.ts new file mode 100644 index 000000000..d34894392 --- /dev/null +++ b/packages/cli/src/commands/checks/flags.ts @@ -0,0 +1,10 @@ +import { Flags } from '@oclif/core' + +export function outputFlag (opts: { default: string, options?: string[] }) { + return Flags.string({ + char: 'o', + description: 'Output format.', + options: opts.options ?? [opts.default, 'json', 'md'], + default: opts.default, + }) +} diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 73aca24ad..29835b5ff 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -1,6 +1,7 @@ import { Args, Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' +import { outputFlag } from './flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import { type OutputFormat, stripAnsi, formatDate } from '../../formatters/render' @@ -38,12 +39,7 @@ export default class ChecksGet extends AuthCommand { 'results-cursor': Flags.string({ description: 'Cursor for results pagination (from previous output).', }), - 'output': Flags.string({ - char: 'o', - description: 'Output format.', - options: ['detail', 'json', 'md'], - default: 'detail', - }), + 'output': outputFlag({ default: 'detail' }), } async run (): Promise { @@ -51,7 +47,7 @@ export default class ChecksGet extends AuthCommand { this.style.outputFormat = flags.output try { - // Result detail mode: drill into a specific result + // Result detail mode: drill into a specific result if (flags.result) { return await this.showResultDetail(args.id, flags.result, flags.output ?? 'detail') } diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index df6b6cefb..e9bd55bde 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -1,6 +1,7 @@ import { Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' +import { outputFlag } from './flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import type { OutputFormat } from '../../formatters/render' @@ -43,12 +44,7 @@ export default class ChecksList extends AuthCommand { description: 'Hide check IDs in table output.', default: false, }), - 'output': Flags.string({ - char: 'o', - description: 'Output format.', - options: ['table', 'json', 'md'], - default: 'table', - }), + 'output': outputFlag({ default: 'table' }), } async run (): Promise { From 81fb68294932b81838bcaf6a1bd7cd9dc4f71137 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 12:05:18 +0100 Subject: [PATCH 15/21] fix(cli): move shared flags out of commands directory oclif auto-discovers all files in the commands directory as commands, causing flags.ts to be registered as checks:flags. Move to src/flags/. --- packages/cli/src/commands/checks/get.ts | 2 +- packages/cli/src/commands/checks/list.ts | 2 +- packages/cli/src/{commands/checks/flags.ts => flags/checks.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/cli/src/{commands/checks/flags.ts => flags/checks.ts} (100%) diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 29835b5ff..e8808fe77 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -1,7 +1,7 @@ import { Args, Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from './flags' +import { outputFlag } from '../../flags/checks' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import { type OutputFormat, stripAnsi, formatDate } from '../../formatters/render' diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index e9bd55bde..24083df20 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -1,7 +1,7 @@ import { Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from './flags' +import { outputFlag } from '../../flags/checks' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import type { OutputFormat } from '../../formatters/render' diff --git a/packages/cli/src/commands/checks/flags.ts b/packages/cli/src/flags/checks.ts similarity index 100% rename from packages/cli/src/commands/checks/flags.ts rename to packages/cli/src/flags/checks.ts From 60856d22459bb3e951c267cde8c141b99904fba6 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 12:10:17 +0100 Subject: [PATCH 16/21] fix(cli): use oclif globPatterns to exclude flags from command discovery Move flags.ts back to commands/checks/ where it belongs and configure oclif to skip files named flags.* via globPatterns negation. --- packages/cli/package.json | 9 ++++++++- .../src/{flags/checks.ts => commands/checks/flags.ts} | 0 packages/cli/src/commands/checks/get.ts | 2 +- packages/cli/src/commands/checks/list.ts | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) rename packages/cli/src/{flags/checks.ts => commands/checks/flags.ts} (100%) diff --git a/packages/cli/package.json b/packages/cli/package.json index eb007cb0d..2e4440af1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,7 +45,14 @@ ], "oclif": { "bin": "checkly", - "commands": "./dist/commands", + "commands": { + "strategy": "pattern", + "target": "./dist/commands", + "globPatterns": [ + "**/*.+(js|cjs|mjs|ts|tsx|mts|cts)", + "!**/flags.+(js|cjs|mjs|ts|tsx|mts|cts)" + ] + }, "topicSeparator": " ", "additionalHelpFlags": [ "-h" diff --git a/packages/cli/src/flags/checks.ts b/packages/cli/src/commands/checks/flags.ts similarity index 100% rename from packages/cli/src/flags/checks.ts rename to packages/cli/src/commands/checks/flags.ts diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index e8808fe77..29835b5ff 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -1,7 +1,7 @@ import { Args, Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from '../../flags/checks' +import { outputFlag } from './flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import { type OutputFormat, stripAnsi, formatDate } from '../../formatters/render' diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index 24083df20..e9bd55bde 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -1,7 +1,7 @@ import { Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from '../../flags/checks' +import { outputFlag } from './flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import type { OutputFormat } from '../../formatters/render' From e25c6526e1714e7719672b51d605edf1fc17a9f7 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 12:11:54 +0100 Subject: [PATCH 17/21] fix(cli): narrow oclif glob exclusion to checks/flags only --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 2e4440af1..89551a87c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,7 +50,7 @@ "target": "./dist/commands", "globPatterns": [ "**/*.+(js|cjs|mjs|ts|tsx|mts|cts)", - "!**/flags.+(js|cjs|mjs|ts|tsx|mts|cts)" + "!**/checks/flags.+(js|cjs|mjs|ts|tsx|mts|cts)" ] }, "topicSeparator": " ", From ed903bfcf0bdd6b719ee6243d5bb8e629277981d Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 12:13:14 +0100 Subject: [PATCH 18/21] refactor(cli): prefix shared flags file with underscore convention --- packages/cli/package.json | 2 +- packages/cli/src/commands/checks/{flags.ts => _flags.ts} | 0 packages/cli/src/commands/checks/get.ts | 2 +- packages/cli/src/commands/checks/list.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/cli/src/commands/checks/{flags.ts => _flags.ts} (100%) diff --git a/packages/cli/package.json b/packages/cli/package.json index 89551a87c..66a199e82 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,7 +50,7 @@ "target": "./dist/commands", "globPatterns": [ "**/*.+(js|cjs|mjs|ts|tsx|mts|cts)", - "!**/checks/flags.+(js|cjs|mjs|ts|tsx|mts|cts)" + "!**/_*.+(js|cjs|mjs|ts|tsx|mts|cts)" ] }, "topicSeparator": " ", diff --git a/packages/cli/src/commands/checks/flags.ts b/packages/cli/src/commands/checks/_flags.ts similarity index 100% rename from packages/cli/src/commands/checks/flags.ts rename to packages/cli/src/commands/checks/_flags.ts diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 29835b5ff..0c749bb97 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -1,7 +1,7 @@ import { Args, Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from './flags' +import { outputFlag } from './_flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import { type OutputFormat, stripAnsi, formatDate } from '../../formatters/render' diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index e9bd55bde..3aac7b8e4 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -1,7 +1,7 @@ import { Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from './flags' +import { outputFlag } from './_flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import type { OutputFormat } from '../../formatters/render' From 20d75e3d941d6e0ab5ca38485d0176e39d76e592 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 12:14:58 +0100 Subject: [PATCH 19/21] fix(cli): remove TS extensions from oclif globPatterns --- packages/cli/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 66a199e82..8a7422110 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,8 +49,8 @@ "strategy": "pattern", "target": "./dist/commands", "globPatterns": [ - "**/*.+(js|cjs|mjs|ts|tsx|mts|cts)", - "!**/_*.+(js|cjs|mjs|ts|tsx|mts|cts)" + "**/*.+(js|cjs|mjs)", + "!**/_*.+(js|cjs|mjs)" ] }, "topicSeparator": " ", From 763fae8a4294b3f033aa6e2f72a10996dc86c046 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 12:17:23 +0100 Subject: [PATCH 20/21] refactor(cli): move shared flags to helpers directory Revert oclif globPatterns config and move flags.ts to src/helpers/ where it won't interfere with oclif command discovery. --- packages/cli/package.json | 9 +-------- packages/cli/src/commands/checks/get.ts | 2 +- packages/cli/src/commands/checks/list.ts | 2 +- .../src/{commands/checks/_flags.ts => helpers/flags.ts} | 0 4 files changed, 3 insertions(+), 10 deletions(-) rename packages/cli/src/{commands/checks/_flags.ts => helpers/flags.ts} (100%) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8a7422110..eb007cb0d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,14 +45,7 @@ ], "oclif": { "bin": "checkly", - "commands": { - "strategy": "pattern", - "target": "./dist/commands", - "globPatterns": [ - "**/*.+(js|cjs|mjs)", - "!**/_*.+(js|cjs|mjs)" - ] - }, + "commands": "./dist/commands", "topicSeparator": " ", "additionalHelpFlags": [ "-h" diff --git a/packages/cli/src/commands/checks/get.ts b/packages/cli/src/commands/checks/get.ts index 0c749bb97..01cff4448 100644 --- a/packages/cli/src/commands/checks/get.ts +++ b/packages/cli/src/commands/checks/get.ts @@ -1,7 +1,7 @@ import { Args, Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from './_flags' +import { outputFlag } from '../../helpers/flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import { type OutputFormat, stripAnsi, formatDate } from '../../formatters/render' diff --git a/packages/cli/src/commands/checks/list.ts b/packages/cli/src/commands/checks/list.ts index 3aac7b8e4..1950a733f 100644 --- a/packages/cli/src/commands/checks/list.ts +++ b/packages/cli/src/commands/checks/list.ts @@ -1,7 +1,7 @@ import { Flags } from '@oclif/core' import chalk from 'chalk' import { AuthCommand } from '../authCommand' -import { outputFlag } from './_flags' +import { outputFlag } from '../../helpers/flags' import * as api from '../../rest/api' import type { CheckWithStatus } from '../../formatters/checks' import type { OutputFormat } from '../../formatters/render' diff --git a/packages/cli/src/commands/checks/_flags.ts b/packages/cli/src/helpers/flags.ts similarity index 100% rename from packages/cli/src/commands/checks/_flags.ts rename to packages/cli/src/helpers/flags.ts From 80ecd783d284768282007a6bfa5f4caa8e76f452 Mon Sep 17 00:00:00 2001 From: MichaelHogers Date: Wed, 25 Feb 2026 14:11:30 +0100 Subject: [PATCH 21/21] test(cli): remove --status flag e2e test The --status flag was removed in 372cbe2 when we moved to server-side filtering, but this test was left behind causing CI failures. --- .../cli/e2e/__tests__/checks-empty-account.spec.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/cli/e2e/__tests__/checks-empty-account.spec.ts b/packages/cli/e2e/__tests__/checks-empty-account.spec.ts index e7b13736c..a43fb990b 100644 --- a/packages/cli/e2e/__tests__/checks-empty-account.spec.ts +++ b/packages/cli/e2e/__tests__/checks-empty-account.spec.ts @@ -39,16 +39,6 @@ describe.skipIf(!apiKey || !accountId)('checks commands on empty account', () => expect(result.stdout).toContain('No checks found.') }) - it('should handle --status filter gracefully on empty account', async () => { - const result = await runChecklyCli({ - args: ['checks', 'list', '--status', 'failing'], - apiKey, - accountId, - }) - expect(result.status).toBe(0) - expect(result.stdout).toContain('No checks matching') - }) - it('should handle --search filter gracefully on empty account', async () => { const result = await runChecklyCli({ args: ['checks', 'list', '--search', 'anything'],