diff --git a/packages/cli/e2e/__tests__/cancel-prompt.spec.ts b/packages/cli/e2e/__tests__/cancel-prompt.spec.ts new file mode 100644 index 000000000..04ba4c6c3 --- /dev/null +++ b/packages/cli/e2e/__tests__/cancel-prompt.spec.ts @@ -0,0 +1,91 @@ +import path from 'node:path' +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' + +import { describe, it, expect } from 'vitest' + +const fixturePath = path.resolve( + __dirname, + '../../src/services/__tests__/fixtures/cancel-prompt-fixture.ts', +) + +function waitForStdoutLine ( + proc: ChildProcessWithoutNullStreams, + needle: string, + timeout: number, +): Promise { + return new Promise((resolve, reject) => { + let buffer = '' + const timer = setTimeout(() => { + reject(new Error(`Timed out after ${timeout}ms waiting for stdout line containing "${needle}"`)) + }, timeout) + + const onData = (chunk: Buffer): void => { + buffer += chunk.toString() + if (buffer.includes(needle)) { + clearTimeout(timer) + proc.stdout.off('data', onData) + resolve() + } + } + + proc.stdout.on('data', onData) + }) +} + +function waitForExit (proc: ChildProcessWithoutNullStreams, timeout: number): Promise { + return new Promise((resolve, reject) => { + if (proc.exitCode !== null) { + resolve(proc.exitCode) + return + } + + const timer = setTimeout(() => { + proc.kill() + reject(new Error(`Process did not exit within ${timeout}ms`)) + }, timeout) + + proc.once('exit', code => { + clearTimeout(timer) + resolve(code ?? 1) + }) + }) +} + +describe('cancel-prompt fixture', () => { + it('buffered ^C byte before prompt does not auto-abort', async () => { + // Arrange + const proc = spawn('npx', ['tsx', fixturePath], { + env: { ...process.env, FORCE_RAW: '1' }, + stdio: 'pipe', + }) + + // Act — write ^C immediately so it is buffered before the prompt opens + proc.stdin.write('\x03') + await waitForStdoutLine(proc, 'PROMPT_OPEN', 5000) + + // Assert — process must still be alive 200ms after prompt opened + await new Promise(resolve => setTimeout(resolve, 200)) + expect(proc.exitCode).toBeNull() + + // Clean up — answer 'n' so the process exits cleanly + proc.stdin.write('n\n') + const exitCode = await waitForExit(proc, 5000) + expect(exitCode).toBe(1) + }, 10000) + + it('typed y after prompt opens confirms and exits 0', async () => { + // Arrange + const proc = spawn('npx', ['tsx', fixturePath], { + env: { ...process.env, FORCE_RAW: '1' }, + stdio: 'pipe', + }) + + // Act + await waitForStdoutLine(proc, 'PROMPT_OPEN', 5000) + proc.stdin.write('y\n') + + // Assert + const exitCode = await waitForExit(proc, 5000) + expect(exitCode).toBe(0) + }, 10000) +}) diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index 47d10763e..f31e5f2d6 100644 --- a/packages/cli/src/commands/pw-test.ts +++ b/packages/cli/src/commands/pw-test.ts @@ -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 { @@ -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) @@ -315,6 +321,7 @@ export default class PwTestCommand extends AuthCommand { null, // testRetryStrategy streamLogs, refreshCache, + detach, ) runner.on(Events.RUN_STARTED, @@ -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() runner.on(Events.CHECK_SUCCESSFUL, diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index c4b71098a..4f755d4f3 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -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 = { @@ -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[] @@ -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)), diff --git a/packages/cli/src/commands/trigger.ts b/packages/cli/src/commands/trigger.ts index 97c9b6dd0..e8cf4d8e4 100644 --- a/packages/cli/src/commands/trigger.ts +++ b/packages/cli/src/commands/trigger.ts @@ -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 { @@ -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) @@ -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, @@ -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() } diff --git a/packages/cli/src/reporters/__tests__/abstract-list.spec.ts b/packages/cli/src/reporters/__tests__/abstract-list.spec.ts new file mode 100644 index 000000000..046aafb60 --- /dev/null +++ b/packages/cli/src/reporters/__tests__/abstract-list.spec.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import ListReporter from '../list' +import type { SequenceId } from '../../services/abstract-check-runner' + +vi.mock('../../rest/api', () => ({ + 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', async () => { + const actual = await vi.importActual('../util') + return { + ...actual, + printLn: (...args: Parameters) => 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('') + }) + }) +}) diff --git a/packages/cli/src/reporters/__tests__/util.spec.ts b/packages/cli/src/reporters/__tests__/util.spec.ts index c4f13b4ee..c41bbe37b 100644 --- a/packages/cli/src/reporters/__tests__/util.spec.ts +++ b/packages/cli/src/reporters/__tests__/util.spec.ts @@ -143,4 +143,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('⊘') + }) }) diff --git a/packages/cli/src/reporters/abstract-list.ts b/packages/cli/src/reporters/abstract-list.ts index 1b3b66727..6aacd2647 100644 --- a/packages/cli/src/reporters/abstract-list.ts +++ b/packages/cli/src/reporters/abstract-list.ts @@ -26,6 +26,7 @@ export type checkFilesMap = Map | undefined) { + if (this.isCancelling) return const checkFile = this.checkFilesMap!.get(check.getSourceFile?.())!.get(sequenceId)! const logList = logs || [] @@ -143,15 +145,33 @@ export default abstract class AbstractListReporter implements Reporter { this._printSummary() } + onCancelPromptShown (): void { + // clear before setting flag so `_clearString` is still valid + this._clearSummary() + this.isCancelling = true + } + + onCancelPromptHidden (): void { + this.isCancelling = false + 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 } + if (this.isCancelling) return + 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:')) @@ -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++ } @@ -185,6 +207,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(', ')) @@ -203,7 +226,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()) { @@ -213,6 +236,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++ } @@ -223,6 +248,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(', ')) diff --git a/packages/cli/src/reporters/reporter.ts b/packages/cli/src/reporters/reporter.ts index 861d7544d..ac5e03f58 100644 --- a/packages/cli/src/reporters/reporter.ts +++ b/packages/cli/src/reporters/reporter.ts @@ -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 + onCancelPromptShown(): void + onCancelPromptHidden(): void } export type ReporterType = 'list' | 'dot' | 'ci' | 'github' | 'json' diff --git a/packages/cli/src/reporters/util.ts b/packages/cli/src/reporters/util.ts index 423bbb9e9..97c89c780 100644 --- a/packages/cli/src/reporters/util.ts +++ b/packages/cli/src/reporters/util.ts @@ -16,6 +16,7 @@ export enum CheckStatus { FAILED, SUCCESSFUL, DEGRADED, + CANCELLED, } export function formatDuration (ms: number): string { @@ -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 @@ -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 diff --git a/packages/cli/src/rest/__tests__/cancel.spec.ts b/packages/cli/src/rest/__tests__/cancel.spec.ts new file mode 100644 index 000000000..14ce23a5c --- /dev/null +++ b/packages/cli/src/rest/__tests__/cancel.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { AxiosInstance } from 'axios' +import Cancel from '../cancel' + +function makeAxiosMock (): AxiosInstance { + return { + post: vi.fn().mockResolvedValue({ data: {} }), + } as unknown as AxiosInstance +} + +describe('Cancel', () => { + let api: AxiosInstance + let cancel: Cancel + + beforeEach(() => { + api = makeAxiosMock() + cancel = new Cancel(api) + }) + + describe('cancelTestSession()', () => { + it('calls POST /v1/cancel with the testSessionId payload', async () => { + await cancel.cancelTestSession({ testSessionId: 'ts-abc' }) + + expect(api.post).toHaveBeenCalledWith('/v1/cancel', { testSessionId: 'ts-abc' }) + }) + + it('calls POST /v1/cancel with only the testSessionId field (not checkSessionId)', async () => { + await cancel.cancelTestSession({ testSessionId: 'ts-xyz' }) + + const [, payload] = vi.mocked(api.post).mock.calls[0] + expect(payload).toEqual({ testSessionId: 'ts-xyz' }) + expect(payload).not.toHaveProperty('checkSessionId') + }) + }) + + describe('cancelCheckSession()', () => { + it('calls POST /v1/cancel with the checkSessionId payload', async () => { + await cancel.cancelCheckSession({ checkSessionId: 'cs-abc' }) + + expect(api.post).toHaveBeenCalledWith('/v1/cancel', { checkSessionId: 'cs-abc' }) + }) + + it('calls POST /v1/cancel with only the checkSessionId field (not testSessionId)', async () => { + await cancel.cancelCheckSession({ checkSessionId: 'cs-xyz' }) + + const [, payload] = vi.mocked(api.post).mock.calls[0] + expect(payload).toEqual({ checkSessionId: 'cs-xyz' }) + expect(payload).not.toHaveProperty('testSessionId') + }) + }) +}) diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index 6e59ca848..2f93345e8 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -24,6 +24,7 @@ import Analytics from './analytics' import BatchAnalytics from './batch-analytics' import Entitlements from './entitlements' import Rca from './rca' +import Cancel from './cancel' import { handleErrorResponse, UnauthorizedError } from './errors' import { detectOperator } from '../helpers/cli-mode' @@ -127,3 +128,4 @@ export const analytics = new Analytics(api) export const batchAnalytics = new BatchAnalytics(api) export const entitlements = new Entitlements(api) export const rca = new Rca(api) +export const cancel = new Cancel(api) diff --git a/packages/cli/src/rest/cancel.ts b/packages/cli/src/rest/cancel.ts new file mode 100644 index 000000000..fc21c38bf --- /dev/null +++ b/packages/cli/src/rest/cancel.ts @@ -0,0 +1,26 @@ +import { type AxiosInstance } from 'axios' + +type CancelCheckSessionRequest = { + checkSessionId: string +} +type CancelTestSessionRequest = { + testSessionId: string +} + +class Cancel { + api: AxiosInstance + + constructor (api: AxiosInstance) { + this.api = api + } + + async cancelTestSession (payload: CancelTestSessionRequest) { + return await this.api.post('/v1/cancel', payload) + } + + async cancelCheckSession (payload: CancelCheckSessionRequest) { + return await this.api.post('/v1/cancel', payload) + } +} + +export default Cancel diff --git a/packages/cli/src/services/__tests__/abstract-check-runner.spec.ts b/packages/cli/src/services/__tests__/abstract-check-runner.spec.ts new file mode 100644 index 000000000..3c5fdfb9a --- /dev/null +++ b/packages/cli/src/services/__tests__/abstract-check-runner.spec.ts @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import AbstractCheckRunner, { Events, SequenceId } from '../abstract-check-runner' + +// --------------------------------------------------------------------------- +// Module mocks — must be hoisted before any imports that pull these in +// --------------------------------------------------------------------------- + +vi.mock('prompts', () => ({ + default: vi.fn(), +})) + +vi.mock('../../rest/api', () => ({ + testSessions: { + run: vi.fn().mockResolvedValue({ data: { testSessionId: 'ts-123', sequenceIds: {} } }), + getResultShortLinks: vi.fn().mockResolvedValue({ data: {} }), + }, + assets: { + getLogs: vi.fn().mockResolvedValue([]), + getCheckRunData: vi.fn().mockResolvedValue({}), + }, + getDefaults: vi.fn().mockReturnValue({ baseURL: 'https://api.checkly.com', accountId: 'acc-1' }), +})) + +vi.mock('../socket-client', () => ({ + SocketClient: { + connect: vi.fn().mockResolvedValue({ + on: vi.fn(), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + endAsync: vi.fn().mockResolvedValue(undefined), + }), + }, +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import prompts from 'prompts' +import { SocketClient } from '../socket-client' + +/** Minimal concrete subclass — scheduleChecks immediately returns with zero checks so the runner exits cleanly. */ +class StubCheckRunner extends AbstractCheckRunner { + constructor (accountId: string, timeout: number, verbose: boolean, detach: boolean = false) { + super(accountId, timeout, verbose, detach) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + scheduleChecks (_checkRunSuiteId: string): Promise<{ + testSessionId?: string + checks: Array<{ check: any, sequenceId: SequenceId }> + }> { + return Promise.resolve({ testSessionId: 'ts-stub', checks: [] }) + } +} + +function makeRunner (detach = false): StubCheckRunner { + return new StubCheckRunner('acc-1', 60, false, detach) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AbstractCheckRunner — SIGINT / cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + // vi.restoreAllMocks() in afterEach calls mockReset() which clears mockResolvedValue implementations. + // Re-establish SocketClient.connect so run() can proceed past the socket setup and reach process.on('SIGINT', ...). + vi.mocked(SocketClient.connect).mockResolvedValue({ + on: vi.fn(), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + endAsync: vi.fn().mockResolvedValue(undefined), + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('registers a SIGINT handler during run() when detach is true', async () => { + const onSpy = vi.spyOn(process, 'on').mockReturnValue(process) + vi.spyOn(process, 'off').mockReturnValue(process) + + const runner = makeRunner(true) + await runner.run() + + const sigintCalls = onSpy.mock.calls.filter(([event]) => event === 'SIGINT') + expect(sigintCalls).toHaveLength(1) + }) + + it('does not register a SIGINT handler during run() when detach is false', async () => { + const onSpy = vi.spyOn(process, 'on').mockReturnValue(process) + vi.spyOn(process, 'off').mockReturnValue(process) + + const runner = makeRunner(false) + await runner.run() + + const sigintCalls = onSpy.mock.calls.filter(([event]) => event === 'SIGINT') + expect(sigintCalls).toHaveLength(0) + }) + + it('removes the SIGINT handler in the finally block after run() completes', async () => { + const onSpy = vi.spyOn(process, 'on').mockReturnValue(process) + const offSpy = vi.spyOn(process, 'off').mockReturnValue(process) + + const runner = makeRunner(true) + await runner.run() + + const registeredHandler = onSpy.mock.calls.find(([e]) => e === 'SIGINT')?.[1] as (() => void) | undefined + const removedHandlers = offSpy.mock.calls + .filter(([event]) => event === 'SIGINT') + .map(([, listener]) => listener) + + expect(registeredHandler).toBeDefined() + expect(removedHandlers).toContain(registeredHandler) + }) + + it('emits Events.CANCEL with testSessionId and resumes the queue when user confirms cancellation', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ confirmed: true }) + + const onSpy = vi.spyOn(process, 'on').mockReturnValue(process) + vi.spyOn(process, 'off').mockReturnValue(process) + + const runner = makeRunner(true) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-cancel', checks: [] }) + + const cancelEvents: unknown[] = [] + runner.on(Events.CANCEL, id => cancelEvents.push(id)) + + // Spy on queue.start to verify the queue is resumed (not cleared) after cancellation + await runner.run() + const queueStartSpy = vi.spyOn(runner.queue, 'start') + + const sigintHandler = onSpy.mock.calls.find(([e]) => e === 'SIGINT')?.[1] as (() => void) | undefined + + // Simulate SIGINT + sigintHandler?.() + + // Wait for the async prompt resolution + await vi.waitFor(() => expect(cancelEvents).toHaveLength(1)) + expect(cancelEvents[0]).toBe('ts-cancel') + expect(queueStartSpy).toHaveBeenCalledTimes(1) + }) + + it('calls process.kill(process.pid, SIGINT) on a second SIGINT while cancel prompt is active', async () => { + // prompts never resolves (simulating a hanging prompt) + vi.mocked(prompts).mockImplementation(() => new Promise(() => {})) + + const onSpy = vi.spyOn(process, 'on').mockReturnValue(process) + vi.spyOn(process, 'off').mockReturnValue(process) + // forceQuit calls process.removeAllListeners then process.kill — mock kill to avoid terminating the test process + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never) + vi.spyOn(process, 'removeAllListeners').mockReturnValue(process) + + const runner = makeRunner(true) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-double', checks: [] }) + + await runner.run() + + const sigintHandler = onSpy.mock.calls.find(([e]) => e === 'SIGINT')?.[1] as (() => void) | undefined + + // First SIGINT — starts the prompt + sigintHandler?.() + + // Wait past the 100ms debounce window before the second deliberate press + await new Promise(resolve => setTimeout(resolve, 110)) + + // Second SIGINT — must call process.kill(pid, 'SIGINT') + sigintHandler?.() + expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGINT') + }) + + it('does NOT emit Events.CANCEL when user declines the confirmation', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ confirmed: false }) + + let sigintHandler: (() => void) | undefined + vi.spyOn(process, 'on').mockImplementation((event: string | symbol, listener: any) => { + if (event === 'SIGINT') sigintHandler = listener + return process + }) + vi.spyOn(process, 'off').mockReturnValue(process) + vi.spyOn(process, 'once').mockReturnValue(process) + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + + const runner = makeRunner(true) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-decline', checks: [] }) + + const cancelEvents: unknown[] = [] + runner.on(Events.CANCEL, id => cancelEvents.push(id)) + + await runner.run() + sigintHandler?.() + + // Wait for the two setImmediate hops + prompt resolution to fully settle + await vi.waitFor(() => expect(cancelEvents).toHaveLength(0), { timeout: 500 }) + expect(cancelEvents).toHaveLength(0) + }) + + it('treats undefined prompt result as decline (no forceQuit, no CANCEL emit)', async () => { + vi.mocked(prompts).mockResolvedValueOnce({ confirmed: undefined }) + + const onSpy = vi.spyOn(process, 'on').mockReturnValue(process) + vi.spyOn(process, 'off').mockReturnValue(process) + vi.spyOn(process, 'once').mockReturnValue(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never) + vi.spyOn(process, 'removeAllListeners').mockReturnValue(process) + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + + const runner = makeRunner(true) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-undefined', checks: [] }) + + const cancelEvents: unknown[] = [] + runner.on(Events.CANCEL, id => cancelEvents.push(id)) + + await runner.run() + const sigintHandler = onSpy.mock.calls.find(([e]) => e === 'SIGINT')?.[1] as (() => void) | undefined + sigintHandler?.() + + // Wait for the two setImmediate hops + prompt resolution to fully settle + await vi.waitFor(() => { + expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGINT') + expect(cancelEvents).toHaveLength(0) + }, { timeout: 500 }) + }) + + it('outer SIGINT handler force-quits on second SIGINT while prompt is open', async () => { + // prompt never resolves — simulates the prompt staying open indefinitely + vi.mocked(prompts).mockImplementation(() => new Promise(() => {})) + + let sigintHandler: (() => void) | undefined + vi.spyOn(process, 'on').mockImplementation((event: string | symbol, listener: any) => { + if (event === 'SIGINT') sigintHandler = listener + return process + }) + vi.spyOn(process, 'off').mockReturnValue(process) + vi.spyOn(process, 'removeAllListeners').mockReturnValue(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as never) + + const runner = makeRunner(true) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-x', checks: [] }) + + await runner.run() + + // First SIGINT — opens prompt (isAskingToCancel becomes true) + sigintHandler?.() + + // Wait past the 100ms debounce window before the second deliberate press + await new Promise(resolve => setTimeout(resolve, 110)) + + // Second SIGINT — outer handler hits isAskingToCancel === true branch → forceQuit + sigintHandler?.() + + await vi.waitFor(() => expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGINT')) + }) + + it('debounces duplicate SIGINTs delivered within 100ms', async () => { + // Some terminal stacks (Ghostty + fish + Node 22.14+) fire SIGINT twice for + // a single Ctrl+C. The handler ignores the second signal if it arrives within + // 100ms of the first. + vi.mocked(prompts).mockResolvedValue({ confirmed: false }) + + let sigintHandler: (() => void) | undefined + vi.spyOn(process, 'on').mockImplementation((event: string | symbol, listener: any) => { + if (event === 'SIGINT') sigintHandler = listener + return process + }) + vi.spyOn(process, 'off').mockReturnValue(process) + + const runner = makeRunner(true) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-debounce', checks: [] }) + + await runner.run() + + const promptsMock = vi.mocked(prompts) + promptsMock.mockClear() + + // Fire SIGINT twice within 10ms — second must be swallowed + sigintHandler?.() + sigintHandler?.() + + await vi.waitFor(() => expect(promptsMock).toHaveBeenCalledTimes(1), { timeout: 500 }) + expect(promptsMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('AbstractCheckRunner — SocketClient lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('connects SocketClient at the start of run()', async () => { + vi.spyOn(process, 'on').mockReturnValue(process) + vi.spyOn(process, 'off').mockReturnValue(process) + + const runner = makeRunner() + await runner.run() + + expect(SocketClient.connect).toHaveBeenCalledTimes(1) + }) + + it('calls endAsync on the socket client in the finally block', async () => { + vi.spyOn(process, 'on').mockReturnValue(process) + vi.spyOn(process, 'off').mockReturnValue(process) + + const mockClient = { + on: vi.fn(), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + endAsync: vi.fn().mockResolvedValue(undefined), + } + vi.mocked(SocketClient.connect).mockResolvedValueOnce(mockClient as any) + + const runner = makeRunner() + await runner.run() + + expect(mockClient.endAsync).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/cli/src/services/__tests__/fixtures/cancel-prompt-fixture.ts b/packages/cli/src/services/__tests__/fixtures/cancel-prompt-fixture.ts new file mode 100644 index 000000000..1be4874c2 --- /dev/null +++ b/packages/cli/src/services/__tests__/fixtures/cancel-prompt-fixture.ts @@ -0,0 +1,40 @@ +import prompts from 'prompts' + +async function main (): Promise { + if (process.env.FORCE_RAW === '1' && typeof process.stdin.setRawMode === 'function') { + process.stdin.setRawMode(true) + } + + await new Promise(resolve => { + const swallow = (): void => { /* discard pre-buffered bytes */ } + process.stdin.on('data', swallow) + setImmediate(() => setImmediate(() => { + process.stdin.removeListener('data', swallow) + resolve() + })) + }) + + // Signal to the test harness that the prompt is about to open. + process.stdout.write('PROMPT_OPEN\n') + + const killHandler = (): void => process.exit(2) + process.once('SIGINT', killHandler) + + try { + const { confirmed } = await prompts({ + type: 'confirm', + name: 'confirmed', + message: 'Stop running checks?', + initial: true, + }) + + if (confirmed === undefined) { + process.exit(1) + } + process.exit(confirmed ? 0 : 1) + } finally { + process.off('SIGINT', killHandler) + } +} + +main() diff --git a/packages/cli/src/services/abstract-check-runner.ts b/packages/cli/src/services/abstract-check-runner.ts index 62bbac3de..c9566547a 100644 --- a/packages/cli/src/services/abstract-check-runner.ts +++ b/packages/cli/src/services/abstract-check-runner.ts @@ -1,4 +1,6 @@ +import prompts from 'prompts' import { assets, testSessions } from '../rest/api' +import { printLn } from '../reporters/util' import { SocketClient } from './socket-client' import PQueue from 'p-queue' import * as uuid from 'uuid' @@ -21,6 +23,9 @@ export enum Events { ERROR = 'ERROR', MAX_SCHEDULING_DELAY_EXCEEDED = 'MAX_SCHEDULING_DELAY_EXCEEDED', STREAM_LOGS = 'STREAM_LOGS', + CANCEL = 'CANCEL', + CANCEL_PROMPT_SHOWN = 'CANCEL_PROMPT_SHOWN', + CANCEL_PROMPT_HIDDEN = 'CANCEL_PROMPT_HIDDEN', } export type PrivateRunLocation = { @@ -52,12 +57,14 @@ export default abstract class AbstractCheckRunner extends EventEmitter { accountId: string timeout: number verbose: boolean + protected detach: boolean queue: PQueue constructor ( accountId: string, timeout: number, verbose: boolean, + detach: boolean = false, ) { super() this.checks = new Map() @@ -66,6 +73,7 @@ export default abstract class AbstractCheckRunner extends EventEmitter { this.timeout = timeout this.verbose = verbose this.accountId = accountId + this.detach = detach } abstract scheduleChecks (checkRunSuiteId: string): Promise<{ @@ -75,6 +83,7 @@ export default abstract class AbstractCheckRunner extends EventEmitter { async run () { let socketClient = null + let sigintHandler: (() => void) | null = null try { socketClient = await SocketClient.connect() @@ -87,6 +96,41 @@ export default abstract class AbstractCheckRunner extends EventEmitter { this.checks = new Map( checks.map(({ check, sequenceId }) => [sequenceId, { check }]), ) + let isAskingToCancel = false + let lastSigintAt = 0 + + if (this.detach) { + sigintHandler = () => { + const now = Date.now() + if (now - lastSigintAt < 100) { + return // ignore duplicate SIGINT within 100ms (some terminals/shells deliver two signals for one Ctrl+C) + } + lastSigintAt = now + + if (isAskingToCancel) { + // Second CTRL+C while prompt is active — force quit immediately + this.forceQuit() + } else { + isAskingToCancel = true + // emit before pause closes the race against in-flight handlers + this.emit(Events.CANCEL_PROMPT_SHOWN) + this.queue.pause() + this.askCancelConfirmation(testSessionId).then(cancelled => { + if (!cancelled) { + // User chose to continue — reset so next CTRL+C asks again + isAskingToCancel = false + } + this.emit(Events.CANCEL_PROMPT_HIDDEN) + this.queue.start() + }).catch(err => { + printLn(`Failed to cancel: ${err.message}`) + process.exit(1) + }) + } + } + + process.on('SIGINT', sigintHandler) + } // `processMessage()` assumes that `this.timeouts` always has an entry for non-timed-out checks. // To ensure that this is the case, we call `setAllTimeouts()` before `queue.start()`. @@ -110,6 +154,9 @@ export default abstract class AbstractCheckRunner extends EventEmitter { this.disableAllTimeouts() this.emit(Events.ERROR, err) } finally { + if (sigintHandler) { + process.off('SIGINT', sigintHandler) + } if (socketClient) { await socketClient.endAsync() } @@ -275,6 +322,30 @@ export default abstract class AbstractCheckRunner extends EventEmitter { this.timeouts.delete(timeoutKey) } + private forceQuit (): void { + process.removeAllListeners('SIGINT') + process.kill(process.pid, 'SIGINT') + } + + private async askCancelConfirmation (testSessionId: string | undefined): Promise { + printLn('') + const { confirmed } = await prompts({ + type: 'confirm', + name: 'confirmed', + message: 'Stop running checks?', + initial: true, + }) + if (confirmed === undefined) { + return false + } + if (confirmed) { + this.emit(Events.CANCEL, testSessionId) + printLn('Cancelling test session...', 2) + return true + } + return false + } + private async getShortLinks (testResultId: string): Promise { try { if (!this.testSessionId) { diff --git a/packages/cli/src/services/test-runner.ts b/packages/cli/src/services/test-runner.ts index c556dde74..04a6d7522 100644 --- a/packages/cli/src/services/test-runner.ts +++ b/packages/cli/src/services/test-runner.ts @@ -39,8 +39,9 @@ export default class TestRunner extends AbstractCheckRunner { testRetryStrategy: RetryStrategy | null, streamLogs?: boolean, refreshCache?: boolean, + detach?: boolean, ) { - super(accountId, timeout, verbose) + super(accountId, timeout, verbose, detach ?? false) this.projectBundle = projectBundle this.checkBundles = checkBundles this.sharedFiles = sharedFiles diff --git a/packages/cli/src/services/trigger-runner.ts b/packages/cli/src/services/trigger-runner.ts index cf9113d70..7d98c67e1 100644 --- a/packages/cli/src/services/trigger-runner.ts +++ b/packages/cli/src/services/trigger-runner.ts @@ -27,8 +27,9 @@ export default class TriggerRunner extends AbstractCheckRunner { testSessionName: string | undefined, testRetryStrategy: RetryStrategy | null, refreshCache?: boolean, + detach?: boolean, ) { - super(accountId, timeout, verbose) + super(accountId, timeout, verbose, detach ?? false) this.shouldRecord = shouldRecord this.location = location this.targetTags = targetTags