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/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 new file mode 100644 index 000000000..a43fb990b --- /dev/null +++ b/packages/cli/e2e/__tests__/checks-empty-account.spec.ts @@ -0,0 +1,61 @@ +import config from 'config' +import { describe, it, expect } from 'vitest' + +import { runChecklyCli } from '../run-checkly' + +const apiKey: string | undefined = config.get('emptyApiKey') +const accountId: string | undefined = config.get('emptyAccountId') + +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'], + 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 --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') + }) +}) 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..ee6aa9f83 --- /dev/null +++ b/packages/cli/e2e/__tests__/checks-get.spec.ts @@ -0,0 +1,60 @@ +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('| Field | Value |') + }) + + 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..7af9cbe31 --- /dev/null +++ b/packages/cli/e2e/__tests__/checks-list.spec.ts @@ -0,0 +1,58 @@ +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 | Type | Status |') + }) + + it('should respect --limit flag', async () => { + 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(limitedResult.status).toBe(0) + const limitedParsed = JSON.parse(limitedResult.stdout) + expect(limitedParsed.data).toHaveLength(1) + }) +}) 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. 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/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/get.ts b/packages/cli/src/commands/checks/get.ts new file mode 100644 index 000000000..01cff4448 --- /dev/null +++ b/packages/cli/src/commands/checks/get.ts @@ -0,0 +1,237 @@ +import { Args, Flags } from '@oclif/core' +import chalk from 'chalk' +import { AuthCommand } from '../authCommand' +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' +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': outputFlag({ 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) + process.exitCode = 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 + } + + const fmt: OutputFormat = outputFormat === 'md' ? 'md' : 'terminal' + + const cleanMsg = stripAnsi(errorGroup.cleanedErrorMessage) + .replace(/\s+/g, ' ') + .trim() + + // Show full raw message if available and different from cleaned + const rawMsg = errorGroup.rawErrorMessage + ? 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')) + output.push('') + output.push(chalk.red(cleanMsg)) + + if (rawMsg && rawMsg !== cleanMsg) { + output.push('') + output.push(chalk.bold('FULL ERROR')) + output.push(stripAnsi(errorGroup.rawErrorMessage!).trim()) + } + + output.push('') + 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) { + 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..1950a733f --- /dev/null +++ b/packages/cli/src/commands/checks/list.ts @@ -0,0 +1,128 @@ +import { Flags } from '@oclif/core' +import chalk from 'chalk' +import { AuthCommand } from '../authCommand' +import { outputFlag } from '../../helpers/flags' +import * as api from '../../rest/api' +import type { CheckWithStatus } from '../../formatters/checks' +import type { OutputFormat } from '../../formatters/render' +import { + formatChecks, + formatSummaryBar, + formatPaginationInfo, + formatNavigationHints, +} from '../../formatters/checks' + +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).', + }), + 'type': Flags.string({ + description: 'Filter by check type.', + options: ['API', 'BROWSER', 'MULTI_STEP', 'HEARTBEAT', 'PLAYWRIGHT', 'TCP', 'DNS', 'ICMP', 'URL'], + }), + 'hide-id': Flags.boolean({ + description: 'Hide check IDs in table output.', + default: false, + }), + 'output': outputFlag({ default: 'table' }), + } + + async run (): Promise { + const { flags } = await this.parse(ChecksList) + this.style.outputFormat = flags.output + const { page, limit } = flags + + try { + // 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.type) activeFilters.push(`type=${flags.type}`) + + // JSON output + if (flags.output === 'json') { + const totalPages = Math.ceil(totalChecks / limit) + this.log(JSON.stringify({ + data: checks, + pagination: { page, limit, total: totalChecks, totalPages }, + }, null, 2)) + return + } + + if (totalChecks === 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(checks, fmt, { pagination })) + return + } + + // Table output + const output: string[] = [] + + output.push(formatSummaryBar(statuses, totalChecks)) + output.push('') + + 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)')}`) + } else { + output.push(formatChecks(checks, 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) + process.exitCode = 1 + } + } +} diff --git a/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts b/packages/cli/src/formatters/__tests__/__fixtures__/fixtures.ts new file mode 100644 index 000000000..7de8e126f --- /dev/null +++ b/packages/cli/src/formatters/__tests__/__fixtures__/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__/__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..3f1d3fb5a --- /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__/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..bade1e3c5 --- /dev/null +++ b/packages/cli/src/formatters/__tests__/checks.spec.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { stripAnsi } from '../render' +import { + formatSummaryBar, + formatTypeBreakdown, + 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__/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('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 })) + 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__/render.spec.ts b/packages/cli/src/formatters/__tests__/render.spec.ts new file mode 100644 index 000000000..e527fea7e --- /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('MULTI_STEP') + 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..3fd9e2607 --- /dev/null +++ b/packages/cli/src/formatters/check-result-detail.ts @@ -0,0 +1,426 @@ +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')}`) + } + + appendErrors(lines, browser.errors) + + 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)) + } + + appendAssets(lines, browser) + + 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}`) + + appendErrors(lines, ms.errors) + + if (ms.jobLog && ms.jobLog.length > 0) { + lines.push('') + lines.push(...formatJobLogArray(ms.jobLog)) + } + + appendAssets(lines, ms) + + 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 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 { + text = JSON.stringify(JSON.parse(body), null, 2) + } catch { + 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 { + 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..0fcc8b393 --- /dev/null +++ b/packages/cli/src/formatters/checks.ts @@ -0,0 +1,355 @@ +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 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 { + 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), + }, + ] + + 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: 'ID', value: c => chalk.dim(c.id) }) + } + + 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..5182d1215 --- /dev/null +++ b/packages/cli/src/formatters/render.ts @@ -0,0 +1,184 @@ +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 { + return 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/helpers/flags.ts b/packages/cli/src/helpers/flags.ts new file mode 100644 index 000000000..d34894392 --- /dev/null +++ b/packages/cli/src/helpers/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/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..be0c49a73 --- /dev/null +++ b/packages/cli/src/rest/checks.ts @@ -0,0 +1,93 @@ +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[] + checkType?: string + search?: 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