Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CheckResult } from '../../rest/check-results.js'

vi.mock('../../rest/api.js', () => ({
checks: { get: vi.fn() },
checkStatuses: { get: vi.fn() },
checkResults: { get: vi.fn(), getAll: vi.fn() },
errorGroups: { getByCheckId: vi.fn(), get: vi.fn() },
analytics: { get: vi.fn() },
}))

import * as api from '../../rest/api.js'
import ChecksGet from '../checks/get.js'

// The fixed, internal projection the recent-results table needs. Exactly the
// fields formatResults() and resolveResultStatus() read — kept in sync with
// RECENT_RESULTS_FIELDS in checks/get.ts.
const EXPECTED_FIELDS = ['id', 'startedAt', 'runLocation', 'hasErrors', 'hasFailures', 'isDegraded', 'responseTime']

function makeCheck () {
return {
id: 'check-1',
name: 'My API Check',
description: null,
checkType: 'API',
activated: true,
muted: false,
frequency: 10,
locations: ['eu-west-1'],
privateLocations: [],
tags: [],
groupId: null,
scriptPath: null,
request: { url: 'https://example.com', method: 'GET' },
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
}
}

function makeResult (overrides: Partial<CheckResult> = {}): CheckResult {
return {
id: 'r1',
checkId: 'check-1',
name: 'My API Check',
hasFailures: false,
hasErrors: false,
isDegraded: false,
overMaxResponseTime: false,
runLocation: 'eu-west-1',
startedAt: '2026-05-20T08:00:00.000Z',
stoppedAt: '2026-05-20T08:00:04.000Z',
created_at: '2026-05-20T08:00:04.000Z',
responseTime: 4000,
checkRunId: 1,
attempts: 1,
resultType: 'FINAL',
sequenceId: 'seq-1',
...overrides,
}
}

function createCommandContext (parsed: unknown) {
const logged: string[] = []
return Object.assign(Object.create(ChecksGet.prototype), {
parse: vi.fn().mockResolvedValue(parsed),
log: vi.fn((msg?: string) => {
if (msg) logged.push(msg)
}),
style: { outputFormat: undefined, longError: vi.fn() },
logged,
})
}

describe('checks get recent-results projection', () => {
beforeEach(() => {
vi.clearAllMocks()
process.exitCode = undefined
vi.mocked(api.checks.get).mockResolvedValue({ data: makeCheck() } as any)
vi.mocked(api.checkStatuses.get).mockResolvedValue({ data: undefined } as any)
vi.mocked(api.errorGroups.getByCheckId).mockResolvedValue({ data: [] } as any)
vi.mocked(api.analytics.get).mockResolvedValue({ data: undefined } as any)
vi.mocked(api.checkResults.getAll).mockResolvedValue({
data: { entries: [makeResult()], nextId: null, length: 1 },
} as any)
})

it('requests only the narrow projection for default (detail) output', async () => {
const ctx = createCommandContext({
args: { id: 'check-1' },
flags: { 'results-limit': 10, 'output': 'detail', 'stats-range': 'last24Hours' },
})

await ChecksGet.prototype.run.call(ctx as any)

expect(api.checkResults.getAll).toHaveBeenCalledWith('check-1', {
limit: 10,
nextId: undefined,
fields: EXPECTED_FIELDS,
})
})

it('forwards a results cursor unchanged alongside the projection', async () => {
const ctx = createCommandContext({
args: { id: 'check-1' },
flags: { 'results-limit': 25, 'results-cursor': 'cursor-abc', 'output': 'detail', 'stats-range': 'last24Hours' },
})

await ChecksGet.prototype.run.call(ctx as any)

expect(api.checkResults.getAll).toHaveBeenCalledWith('check-1', {
limit: 25,
nextId: 'cursor-abc',
fields: EXPECTED_FIELDS,
})
})

it('requests the narrow projection for markdown output', async () => {
const ctx = createCommandContext({
args: { id: 'check-1' },
flags: { 'results-limit': 10, 'output': 'md', 'stats-range': 'last24Hours' },
})

await ChecksGet.prototype.run.call(ctx as any)

expect(api.checkResults.getAll).toHaveBeenCalledWith('check-1', {
limit: 10,
nextId: undefined,
fields: EXPECTED_FIELDS,
})
})

it('does not project fields for json output (preserves full results entries)', async () => {
const ctx = createCommandContext({
args: { id: 'check-1' },
flags: { 'results-limit': 10, 'output': 'json', 'stats-range': 'last24Hours' },
})

await ChecksGet.prototype.run.call(ctx as any)

expect(api.checkResults.getAll).toHaveBeenCalledTimes(1)
const callArgs = vi.mocked(api.checkResults.getAll).mock.calls[0][1]
expect(callArgs).not.toHaveProperty('fields')
expect(callArgs).toMatchObject({ limit: 10, nextId: undefined })
})

it('--result drilldown uses the detail endpoint and never lists with projection', async () => {
vi.mocked(api.checkResults.get).mockResolvedValue({
data: makeResult({ id: 'res-42', resultType: 'FINAL', attempts: 1 }),
} as any)
const ctx = createCommandContext({
args: { id: 'check-1' },
flags: { result: 'res-42', output: 'detail' },
})

await ChecksGet.prototype.run.call(ctx as any)

expect(api.checkResults.get).toHaveBeenCalledWith('check-1', 'res-42')
expect(api.checkResults.getAll).not.toHaveBeenCalled()
})
})
31 changes: 26 additions & 5 deletions packages/cli/src/commands/checks/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,22 @@ import {
formatAttemptsSection,
groupAttemptsBySequence,
} from '../../formatters/check-result-detail.js'
import type { CheckResult } from '../../rest/check-results.js'
import type { CheckResult, CheckResultField, ListCheckResultsParams } from '../../rest/check-results.js'
import { formatRcaDetail, formatRcaHint, transformErrorGroupForJson } from '../../formatters/rca.js'
import { quickRangeValues, type QuickRange, type GroupBy } from '../../rest/analytics.js'
import { formatAnalyticsSection } from '../../formatters/analytics.js'

// Internal, fixed projection for the embedded recent-results table. These are
// exactly the fields formatResults() and resolveResultStatus() read; requesting
// the wide result bodies (apiCheckResult, browserCheckResult, metadata, assets,
// …) would make the backend select and decorate payloads this view never
// renders. This is intentionally not a user-facing flag: `checks get` aggregates
// check details, status, analytics, error groups, and results, so a generic
// `--fields` would be ambiguous.
const RECENT_RESULTS_FIELDS: CheckResultField[] = [
'id', 'startedAt', 'runLocation', 'hasErrors', 'hasFailures', 'isDegraded', 'responseTime',
]

export default class ChecksGet extends AuthCommand {
static hidden = false
static readOnly = true
Expand Down Expand Up @@ -96,13 +107,23 @@ export default class ChecksGet extends AuthCommand {
// Fetch check first (need checkType for analytics)
const { data: check } = await api.checks.get(args.id)

// The recent-results table only needs a narrow column set, so project to
// those fields and let the backend skip wide result payloads. JSON output
// is the exception: it exposes full `results` entries, so we omit `fields`
// there to preserve backwards compatibility for existing consumers.
const resultsParams: ListCheckResultsParams = {
limit: flags['results-limit'],
nextId: flags['results-cursor'],
}
if (flags.output !== 'json') {
resultsParams.fields = RECENT_RESULTS_FIELDS
}

// Fetch remaining data in parallel
const [statusResp, resultsResp, errorGroupsResp, analyticsResp] = await Promise.all([
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.checkResults.getAll(args.id, resultsParams)
.catch(() => ({ data: { entries: [], nextId: null, length: 0 } })),
api.errorGroups.getByCheckId(args.id).catch(() => ({ data: [] })),
api.analytics.get(args.id, check.checkType, {
quickRange: (flags['stats-range'] ?? 'last24Hours') as QuickRange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ describe('PlaywrightCheck', () => {
'teardown.ts',
'tsconfig.playwright.json',
])
})
}, DEFAULT_TEST_TIMEOUT)
})

describe('test-global-files-bundling-without-projects', () => {
Expand Down Expand Up @@ -411,7 +411,7 @@ describe('PlaywrightCheck', () => {
'teardown.ts',
'tsconfig.playwright.json',
])
})
}, DEFAULT_TEST_TIMEOUT)
})

describe('headless', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/rest/__tests__/check-results.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { AxiosInstance } from 'axios'
import CheckResults from '../check-results.js'

function makeAxiosMock (): AxiosInstance {
return {
get: vi.fn().mockResolvedValue({ data: { entries: [], nextId: null, length: 0 } }),
} as unknown as AxiosInstance
}

describe('CheckResults.getAll()', () => {
let api: AxiosInstance
let checkResults: CheckResults

beforeEach(() => {
api = makeAxiosMock()
checkResults = new CheckResults(api)
})

it('serializes the fields array as a comma-separated query string', async () => {
await checkResults.getAll('check-1', { limit: 10, fields: ['id', 'startedAt', 'responseTime'] })

expect(api.get).toHaveBeenCalledWith('/v2/check-results/check-1', {
params: { limit: 10, fields: 'id,startedAt,responseTime' },
})
})

it('does not send a fields param when fields is omitted', async () => {
await checkResults.getAll('check-1', { limit: 5 })

const [, config] = vi.mocked(api.get).mock.calls[0]
expect((config as any).params.fields).toBeUndefined()
})

it('passes other params through untouched', async () => {
await checkResults.getAll('check-1', { from: 100, to: 200, resultType: 'ALL', nextId: 'cursor' })

const [, config] = vi.mocked(api.get).mock.calls[0]
expect((config as any).params).toMatchObject({ from: 100, to: 200, resultType: 'ALL', nextId: 'cursor' })
})
})
39 changes: 38 additions & 1 deletion packages/cli/src/rest/check-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,13 +174,45 @@ export interface CheckResultsPage {
nextId: string | null
}

// Result fields the backend `GET /v2/check-results/{checkId}` endpoint accepts
// for projection via the `fields` query parameter. Requesting a narrow subset
// lets the backend skip selecting and decorating wide payloads (metadata,
// assets, apiCheckResult, browserCheckResult, …) that a given view never needs.
export type CheckResultField =
| 'id'
| 'name'
| 'checkId'
| 'hasFailures'
| 'hasErrors'
| 'isDegraded'
| 'isCancelled'
| 'overMaxResponseTime'
| 'runLocation'
| 'startedAt'
| 'stoppedAt'
| 'created_at'
| 'createdAt'
| 'responseTime'
| 'apiCheckResult'
| 'browserCheckResult'
| 'multiStepCheckResult'
| 'agenticCheckResult'
| 'playwrightCheckResult'
| 'checkRunId'
| 'attempts'
| 'resultType'
| 'sequenceId'
| 'traceId'
| 'errorGroupIds'

export interface ListCheckResultsParams {
limit?: number
nextId?: string
from?: number
to?: number
hasFailures?: boolean
resultType?: 'FINAL' | 'ATTEMPT' | 'ALL'
fields?: CheckResultField[]
}

class CheckResults {
Expand All @@ -190,7 +222,12 @@ class CheckResults {
}

getAll (checkId: string, params: ListCheckResultsParams = {}) {
return this.api.get<CheckResultsPage>(`/v2/check-results/${checkId}`, { params })
// Serialize `fields` as a single comma-separated value rather than relying on
// Axios array serialization. The backend accepts both `fields=id,startedAt`
// and repeated `fields=id&fields=startedAt`, and the comma form is the
// safest, most predictable shape across HTTP clients.
const requestParams = { ...params, fields: params.fields?.join(',') }
return this.api.get<CheckResultsPage>(`/v2/check-results/${checkId}`, { params: requestParams })
}

get (checkId: string, checkResultId: string) {
Comment thread
thebiglabasky marked this conversation as resolved.
Expand Down
Loading