Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5abe599
feat(cli): add cancel REST client
ferrandiaz Apr 27, 2026
2253695
feat(cli): support cancellation status in reporters
ferrandiaz Apr 27, 2026
4ea4906
feat(cli): add --detach flag with SIGINT cancel flow
ferrandiaz Apr 27, 2026
c312b86
feat(cli): wire cancel handler in test, pw-test, and trigger commands
ferrandiaz Apr 27, 2026
cf8c900
test(cli): add tests for cancellation flow
ferrandiaz Apr 27, 2026
f78059f
feat(cli/reporters): suppress live panel while cancel prompt is open
ferrandiaz May 7, 2026
aa77ca7
fix(cli/runner): close SIGINT race and add cancel feedback
ferrandiaz May 7, 2026
c738314
feat(cli/commands): forward cancel prompt events to reporters
ferrandiaz May 7, 2026
e8189c1
test(cli/reporters): cover AbstractListReporter cancellation lifecycle
ferrandiaz May 7, 2026
9ab0950
fix(cli/runner): drain buffered ^C before opening cancel prompt
ferrandiaz May 8, 2026
e8ca35d
fix: tests
ferrandiaz May 8, 2026
a071d01
feat: rebuild
ferrandiaz May 8, 2026
89ff910
fix: sigint handling
ferrandiaz May 8, 2026
15beb10
fix: flip detached
ferrandiaz May 13, 2026
ee87ab4
fix: skip question for non terminal
ferrandiaz May 13, 2026
505be4d
fix: tests
ferrandiaz May 13, 2026
e895913
fix: prompt
ferrandiaz May 14, 2026
d550d3f
fix: tests
ferrandiaz May 14, 2026
6ee1447
chore: remove unused cancel-prompt e2e test and fixture
sorccu May 20, 2026
e5ab7af
fix: add .js suffixes to ESM imports in new test files
sorccu May 20, 2026
6947d93
ci: trigger CI after rebase onto next/v8
sorccu May 20, 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 => {
if (!testSessionId) return
await api.cancel.cancelTestSession({ testSessionId })
})

runner.on(Events.CANCEL_PROMPT_SHOWN, () => reporters.forEach(r => r.onCancelPromptShown()))
runner.on(Events.CANCEL_PROMPT_HIDDEN, () => reporters.forEach(r => r.onCancelPromptHidden()))

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 @@ -118,6 +118,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 @@ -153,6 +158,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 @@ -366,8 +372,17 @@ export default class Test extends AuthCommand {
testRetryStrategy,
undefined,
refreshCache,
detach,
)

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

runner.on(Events.CANCEL_PROMPT_SHOWN, () => reporters.forEach(r => r.onCancelPromptShown()))
runner.on(Events.CANCEL_PROMPT_HIDDEN, () => reporters.forEach(r => r.onCancelPromptHidden()))

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 @@ -97,6 +97,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 @@ -116,6 +121,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 @@ -152,6 +158,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 @@ -192,6 +199,12 @@ export default class Trigger extends AuthCommand {
reporters.forEach(r => r.onError(err))
process.exitCode = 1
})
runner.on(Events.CANCEL, async testSessionId => {
if (!testSessionId) return
await api.cancel.cancelTestSession({ testSessionId })
})
runner.on(Events.CANCEL_PROMPT_SHOWN, () => reporters.forEach(r => r.onCancelPromptShown()))
runner.on(Events.CANCEL_PROMPT_HIDDEN, () => reporters.forEach(r => r.onCancelPromptHidden()))
await runner.run()
}

Expand Down
234 changes: 234 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,234 @@
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 cancellation lifecycle', () => {
beforeEach(() => {
printLnMock.mockClear()
})

describe('happy paths', () => {
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 call printLn with the ANSI clear sequence when onCancelPromptShown is called', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()
const clearString = reporter._clearString

printLnMock.mockClear()
reporter.onCancelPromptShown()

const calls = printLnMock.mock.calls.map(([text]: [string]) => text)
expect(calls).toContain(clearString)
})

it('should set _clearString to empty string after onCancelPromptShown', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()

reporter.onCancelPromptShown()

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

it('should call printLn with summary content after onCancelPromptHidden', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()
reporter.onCancelPromptShown()

printLnMock.mockClear()
reporter.onCancelPromptHidden()

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 repaint summary after full cancel-then-hidden cycle when new check ends', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()
reporter.onCancelPromptShown()
reporter.onCancelPromptHidden()

printLnMock.mockClear()
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)
})
})

describe('edge cases', () => {
it('should not call printLn when _clearSummary is called with empty _clearString', () => {
const reporter = new ListReporter(PUBLIC_RUN_LOCATION, false)
// No _printSummary called — _clearString stays ''

reporter._clearSummary()

expect(printLnMock).not.toHaveBeenCalled()
})

it('should not call printLn with new summary when _printSummary is called while isCancelling is true', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()
reporter.onCancelPromptShown()

printLnMock.mockClear()
reporter._printSummary()

expect(printLnMock).not.toHaveBeenCalled()
})

it('should not call printLn with log message when onStreamLogs is called while isCancelling is true', () => {
const { reporter, check } = makeReporterWithOneCheck()
reporter.onCancelPromptShown()

printLnMock.mockClear()
reporter.onStreamLogs(check, SEQUENCE_ID, [{ timestamp: 0, message: 'test log' }])

const calls = printLnMock.mock.calls.map(([text]: [string]) => text)
expect(calls.some(text => text.includes('test log'))).toBe(false)
})

it('should call printLn with log message when onStreamLogs is called while isCancelling is false', () => {
const { reporter, check } = makeReporterWithOneCheck()

reporter.onStreamLogs(check, SEQUENCE_ID, [{ timestamp: 0, message: 'test log' }])

const calls = printLnMock.mock.calls.map(([text]: [string]) => text)
expect(calls.some(text => text.includes('test log'))).toBe(true)
})

it('should not throw when onCancelPromptHidden is called without prior onCancelPromptShown', () => {
const { reporter } = makeReporterWithOneCheck()

expect(() => reporter.onCancelPromptHidden()).not.toThrow()
})

it('should call printLn with summary content when onCancelPromptHidden is called without prior onCancelPromptShown', () => {
const { reporter } = makeReporterWithOneCheck()

reporter.onCancelPromptHidden()

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

it('should call printLn for the clear exactly once when onCancelPromptShown is called twice', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()
const clearString = reporter._clearString

reporter.onCancelPromptShown()
const clearCallsAfterFirst = printLnMock.mock.calls.filter(
([text]: [string]) => text === clearString,
).length

printLnMock.mockClear()
reporter.onCancelPromptShown()

const clearCallsAfterSecond = printLnMock.mock.calls.filter(
([text]: [string]) => text === clearString,
).length

expect(clearCallsAfterFirst).toBe(1)
expect(clearCallsAfterSecond).toBe(0)
})

it('should repaint normally after full cancel-hidden cycle followed by another onCheckEnd', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()
reporter.onCancelPromptShown()
reporter.onCancelPromptHidden()

printLnMock.mockClear()
reporter.onCheckEnd(SEQUENCE_ID, makePassingResult())

expect(printLnMock).toHaveBeenCalled()
})

it('should not print summary content during isCancelling when onCheckEnd is called', () => {
const { reporter } = makeReporterWithOneCheck()
reporter._printSummary()
reporter.onCancelPromptShown()

printLnMock.mockClear()
reporter.onCheckEnd(SEQUENCE_ID, makePassingResult())

// Summary content has the check name in counts line or status; _printSummary early-returns
// so no summary render call happens. Only inline check detail from ListReporter may print.
// The key assertion: _clearString remains empty (no new summary was written).
expect(reporter._clearString).toBe('')
})
})
})
Loading
Loading