Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5e8f832
feat(cli): add cancel REST client
ferrandiaz Apr 27, 2026
1555ba8
feat(cli): support cancellation status in reporters
ferrandiaz Apr 27, 2026
cc9fd3c
feat(cli): add --detach flag with SIGINT cancel flow
ferrandiaz Apr 27, 2026
5e74f02
feat(cli): wire cancel handler in test, pw-test, and trigger commands
ferrandiaz Apr 27, 2026
df068a5
test(cli): add tests for cancellation flow
ferrandiaz Apr 27, 2026
374e5c6
feat(cli/reporters): suppress live panel while cancel prompt is open
ferrandiaz May 7, 2026
f4863ab
fix(cli/runner): close SIGINT race and add cancel feedback
ferrandiaz May 7, 2026
8524677
feat(cli/commands): forward cancel prompt events to reporters
ferrandiaz May 7, 2026
eb3a539
test(cli/reporters): cover AbstractListReporter cancellation lifecycle
ferrandiaz May 7, 2026
7f0caaf
fix(cli/runner): drain buffered ^C before opening cancel prompt
ferrandiaz May 8, 2026
1d634a1
fix: tests
ferrandiaz May 8, 2026
86a9897
feat: rebuild
ferrandiaz May 8, 2026
72a73b9
fix: sigint handling
ferrandiaz May 8, 2026
11d1505
fix: flip detached
ferrandiaz May 13, 2026
f8a0bf9
fix: skip question for non terminal
ferrandiaz May 13, 2026
8e5de70
fix: tests
ferrandiaz May 13, 2026
c3d35ee
fix: prompt
ferrandiaz May 14, 2026
0196f96
fix: tests
ferrandiaz May 14, 2026
d6154ca
chore: remove unused cancel-prompt e2e test and fixture
sorccu May 20, 2026
924e19d
fix: add .js suffixes to ESM imports in new test files
sorccu May 20, 2026
f383bcb
ci: trigger CI after rebase onto next/v8
sorccu May 20, 2026
a60773f
fix: replace interactive cancel prompt with immediate cancellation
sorccu May 20, 2026
68dacb2
fix: always cancel immediately on SIGINT instead of showing interacti…
sorccu May 20, 2026
73089ba
feat: show detach message on SIGINT when --detach is used
sorccu May 20, 2026
17561a2
fix: handle 403 from /v1/cancel gracefully
sorccu May 20, 2026
f85d46b
fix: update cancel REST client for resource-oriented endpoints
sorccu May 21, 2026
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
15 changes: 15 additions & 0 deletions packages/cli/src/commands/pw-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export default class PwTestCommand extends AuthCommand {
description: 'Force a fresh install of dependencies and update the cached version.',
default: false,
}),
'detach': Flags.boolean({
char: 'd',
description: 'Keep checks running in the cloud after cancelling the CLI process.',
default: false,
}),
}

async run (): Promise<void> {
Expand All @@ -143,6 +148,7 @@ export default class PwTestCommand extends AuthCommand {
'frequency': frequency,
'install-command': installCommand,
'refresh-cache': refreshCache,
'detach': detach,
} = flags
const { configDirectory, configFilenames } = splitConfigFilePath(configFilename)
const pwPathFlag = this.getConfigPath(playwrightFlags)
Expand Down Expand Up @@ -315,6 +321,7 @@ export default class PwTestCommand extends AuthCommand {
null, // testRetryStrategy
streamLogs,
refreshCache,
detach,
)

runner.on(Events.RUN_STARTED,
Expand All @@ -338,6 +345,14 @@ export default class PwTestCommand extends AuthCommand {
}, links))
})

runner.on(Events.CANCEL, async testSessionId => {
reporters.forEach(r => r.onCancel())
if (!testSessionId) return
await api.cancel.cancelTestSession({ testSessionId })
})

runner.on(Events.DETACH, () => reporters.forEach(r => r.onDetach()))

const noTestsFoundChecks = new Set<string>()

runner.on(Events.CHECK_SUCCESSFUL,
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ export default class Test extends AuthCommand {
description: 'Force a fresh install of dependencies and update the cached version.',
default: false,
}),
'detach': Flags.boolean({
char: 'd',
description: 'Keep checks running in the cloud after cancelling the CLI process.',
default: false,
}),
}

static args = {
Expand Down Expand Up @@ -154,6 +159,7 @@ export default class Test extends AuthCommand {
retries,
'verify-runtime-dependencies': verifyRuntimeDependencies,
'refresh-cache': refreshCache,
'detach': detach,
} = flags
const filePatterns = argv as string[]

Expand Down Expand Up @@ -367,8 +373,17 @@ export default class Test extends AuthCommand {
testRetryStrategy,
undefined,
refreshCache,
detach,
)

runner.on(Events.CANCEL, async testSessionId => {
reporters.forEach(r => r.onCancel())
if (!testSessionId) return
await api.cancel.cancelTestSession({ testSessionId })
})

runner.on(Events.DETACH, () => reporters.forEach(r => r.onDetach()))

runner.on(Events.RUN_STARTED,
(checks: Array<{ check: any, sequenceId: SequenceId }>, testSessionId: string) =>
reporters.forEach(r => r.onBegin(checks, testSessionId)),
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/commands/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ export default class Trigger extends AuthCommand {
description: 'Force a fresh install of dependencies and update the cached version.',
default: false,
}),
'detach': Flags.boolean({
char: 'd',
description: 'Keep checks running in the cloud after cancelling the CLI process.',
default: false,
}),
}

async run (): Promise<void> {
Expand All @@ -117,6 +122,7 @@ export default class Trigger extends AuthCommand {
'test-session-name': testSessionName,
retries,
'refresh-cache': refreshCache,
'detach': detach,
} = flags
const envVars = await getEnvs(envFile, env)
const { configDirectory, configFilenames } = splitConfigFilePath(configFilename)
Expand Down Expand Up @@ -153,6 +159,7 @@ export default class Trigger extends AuthCommand {
testSessionName,
testRetryStrategy,
refreshCache,
detach,
)
// TODO: This is essentially the same for `checkly test`. Maybe reuse code.
runner.on(Events.RUN_STARTED,
Expand Down Expand Up @@ -193,6 +200,12 @@ export default class Trigger extends AuthCommand {
reporters.forEach(r => r.onError(err))
process.exitCode = 1
})
runner.on(Events.CANCEL, async testSessionId => {
reporters.forEach(r => r.onCancel())
if (!testSessionId) return
await api.cancel.cancelTestSession({ testSessionId })
})
runner.on(Events.DETACH, () => reporters.forEach(r => r.onDetach()))
await runner.run()
}

Expand Down
101 changes: 101 additions & 0 deletions packages/cli/src/reporters/__tests__/abstract-list.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import ListReporter from '../list.js'
import type { SequenceId } from '../../services/abstract-check-runner.js'

vi.mock('../../rest/api.js', () => ({
getDefaults: () => ({
baseURL: 'https://api.checklyhq.com',
accountId: 'test-account-123',
Authorization: 'Bearer test-key',
apiKey: 'test-key',
}),
testSessions: {
getShortLink: vi.fn(),
},
}))

const printLnMock = vi.fn()

vi.mock('../util.js', async () => {
const actual = await vi.importActual<typeof import('../util.js')>('../util.js')
return {
...actual,
printLn: (...args: Parameters<typeof actual.printLn>) => printLnMock(...args),
}
})

const PUBLIC_RUN_LOCATION = { type: 'PUBLIC' as const, region: 'eu-west-1' }

const SOURCE_FILE = 'folder/api.check.ts'
const SEQUENCE_ID: SequenceId = 'seq-001'

function makeCheck (sourceFile = SOURCE_FILE) {
return {
name: 'My API Check',
getSourceFile: () => sourceFile,
}
}

function makePassingResult (sourceFile = SOURCE_FILE) {
return {
name: 'My API Check',
sourceFile,
hasFailures: false,
isDegraded: false,
isCancelled: false,
}
}

function makeReporterWithOneCheck () {
const reporter = new ListReporter(PUBLIC_RUN_LOCATION, false)
const check = makeCheck()
reporter.onBegin([{ check, sequenceId: SEQUENCE_ID }])
return { reporter, check }
}

describe('AbstractListReporter', () => {
beforeEach(() => {
printLnMock.mockClear()
})

it('should call printLn with check status output after onCheckEnd', () => {
const { reporter, check } = makeReporterWithOneCheck()

reporter.onCheckInProgress(check, SEQUENCE_ID)
reporter.onCheckEnd(SEQUENCE_ID, makePassingResult())

expect(printLnMock).toHaveBeenCalled()
const calls = printLnMock.mock.calls.map(([text]: [string]) => text)
expect(calls.some(text => text.includes('My API Check'))).toBe(true)
})

it('should populate _clearString after _printSummary runs', () => {
const { reporter } = makeReporterWithOneCheck()

reporter._printSummary()

expect(reporter._clearString).not.toBe('')
})

it('should include cancellation message with --detach hint after onCancel', () => {
const { reporter } = makeReporterWithOneCheck()

reporter.onCancel()

const calls = printLnMock.mock.calls.map(([text]: [string]) => text)
const summary = calls.join('\n')
expect(summary).toContain('Cancelling checks')
expect(summary).toContain('--detach')
})

it('should include detach message after onDetach', () => {
const { reporter } = makeReporterWithOneCheck()

reporter.onDetach()

const calls = printLnMock.mock.calls.map(([text]: [string]) => text)
const summary = calls.join('\n')
expect(summary).toContain('continue running')
})
})
15 changes: 15 additions & 0 deletions packages/cli/src/reporters/__tests__/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,19 @@ describe('resultToCheckStatus()', () => {
expect(resultToCheckStatus({ hasFailures: false, isDegraded: true, hasErrors: false }))
.toBe(CheckStatus.DEGRADED)
})
it('returns cancelled when isCancelled is true', () => {
expect(resultToCheckStatus({ isCancelled: true }))
.toBe(CheckStatus.CANCELLED)
})
it('returns cancelled when isCancelled is true even if hasFailures is also true', () => {
expect(resultToCheckStatus({ isCancelled: true, hasFailures: true }))
.toBe(CheckStatus.CANCELLED)
})
})

describe('formatCheckTitle() with CANCELLED status', () => {
it('should use the ⊘ symbol for a cancelled check title', () => {
const result = stripAnsi(formatCheckTitle(CheckStatus.CANCELLED, simpleCheckFixture))
expect(result).toContain('⊘')
})
})
42 changes: 39 additions & 3 deletions packages/cli/src/reporters/abstract-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type checkFilesMap = Map<string | undefined, Map<SequenceId, {

export default abstract class AbstractListReporter implements Reporter {
_clearString = ''
private _isCancelling = false
private _isDetaching = false
runLocation: RunLocation
checkFilesMap?: checkFilesMap
numChecks?: number
Expand Down Expand Up @@ -143,15 +145,33 @@ export default abstract class AbstractListReporter implements Reporter {
this._printSummary()
}

onCancel (): void {
this._isCancelling = true
this._clearSummary()
this._printSummary()
}

onDetach (): void {
this._isDetaching = true
this._clearSummary()
this._printSummary()
}

// Clear the summary which was printed by _printStatus from stdout
// TODO: Rather than clearing the whole status bar, we could overwrite the exact lines that changed.
// This might look a bit smoother and reduce the flickering effects.
_clearSummary () {
printLn(this._clearString)
if (this._clearString) {
printLn(this._clearString)
this._clearString = ''
}
}

_printSummary (opts: { skipCheckCount?: boolean } = {}) {
const counts = { numFailed: 0, numPassed: 0, numDegraded: 0, numRunning: 0, numRetrying: 0, scheduling: 0 }
const counts = {
numFailed: 0, numPassed: 0, numDegraded: 0,
numRunning: 0, numRetrying: 0, scheduling: 0, numCancelled: 0,
}
const status = []
if (this.checkFilesMap!.size === 1 && this.checkFilesMap!.has(undefined)) {
status.push(chalk.bold('Summary:'))
Expand All @@ -169,6 +189,8 @@ export default abstract class AbstractListReporter implements Reporter {
counts.numFailed++
} else if (result.isDegraded) {
counts.numDegraded++
} else if (result.isCancelled) {
counts.numCancelled++
} else {
counts.numPassed++
}
Expand All @@ -177,6 +199,16 @@ export default abstract class AbstractListReporter implements Reporter {
}

if (!opts.skipCheckCount) {
if (this._isCancelling) {
status.push('')
status.push(chalk.yellow('Cancelling checks... Use --detach to keep checks running in the cloud.'))
}

if (this._isDetaching) {
status.push('')
status.push(chalk.yellow('Checks will continue running in the cloud.'))
}

status.push('')
status.push([
counts.scheduling ? chalk.bold.blue(`${counts.scheduling} scheduling`) : undefined,
Expand All @@ -185,6 +217,7 @@ export default abstract class AbstractListReporter implements Reporter {
counts.numFailed ? chalk.bold.red(`${counts.numFailed} failed`) : undefined,
counts.numDegraded ? chalk.bold.yellow(`${counts.numDegraded} degraded`) : undefined,
counts.numPassed ? chalk.bold.green(`${counts.numPassed} passed`) : undefined,
counts.numCancelled ? chalk.bold.grey(`${counts.numCancelled} cancelled`) : undefined,
`${this.numChecks} total`,
].filter(Boolean).join(', '))

Expand All @@ -203,7 +236,7 @@ export default abstract class AbstractListReporter implements Reporter {
}

_printBriefSummary () {
const counts = { numFailed: 0, numDegraded: 0, numPassed: 0, numPending: 0 }
const counts = { numFailed: 0, numDegraded: 0, numPassed: 0, numPending: 0, numCancelled: 0 }
const status = []
for (const [, checkMap] of this.checkFilesMap!.entries()) {
for (const [, { result }] of checkMap.entries()) {
Expand All @@ -213,6 +246,8 @@ export default abstract class AbstractListReporter implements Reporter {
counts.numFailed++
} else if (result.isDegraded) {
counts.numDegraded++
} else if (result.isCancelled) {
counts.numCancelled++
} else {
counts.numPassed++
}
Expand All @@ -223,6 +258,7 @@ export default abstract class AbstractListReporter implements Reporter {
counts.numFailed ? chalk.bold.red(`${counts.numFailed} failed`) : undefined,
counts.numDegraded ? chalk.bold.yellow(`${counts.numDegraded} degraded`) : undefined,
counts.numPassed ? chalk.bold.green(`${counts.numPassed} passed`) : undefined,
counts.numCancelled ? chalk.bold.grey(`${counts.numCancelled} cancelled`) : undefined,
counts.numPending ? chalk.bold.magenta(`${counts.numPending} pending`) : undefined,
`${this.numChecks} total`,
].filter(Boolean).join(', '))
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/reporters/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface Reporter {
onError(err: Error): void
onSchedulingDelayExceeded(): void
onStreamLogs(check: any, sequenceId: SequenceId, logs: Array<{ timestamp: number, message: string }>): void
onCancel(): void
onDetach(): void
}

export type ReporterType = 'list' | 'dot' | 'ci' | 'github' | 'json'
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/reporters/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export enum CheckStatus {
FAILED,
SUCCESSFUL,
DEGRADED,
CANCELLED,
}

export function formatDuration (ms: number): string {
Expand Down Expand Up @@ -56,6 +57,9 @@ export function formatCheckTitle (
} else if (status === CheckStatus.RETRIED) {
statusString = '↺'
format = chalk.bold
} else if (status === CheckStatus.CANCELLED) {
statusString = '⊘'
format = chalk.bold
} else {
statusString = '-'
format = chalk.bold.dim
Expand Down Expand Up @@ -710,6 +714,9 @@ function toString (val: any): string {
}

export function resultToCheckStatus (checkResult: any): CheckStatus {
if (checkResult.isCancelled) {
return CheckStatus.CANCELLED
}
return checkResult.hasFailures
? CheckStatus.FAILED
: checkResult.isDegraded
Expand Down
Loading
Loading