diff --git a/packages/cli/src/commands/pw-test.ts b/packages/cli/src/commands/pw-test.ts index 3ef717cb1..c2d8cb152 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 => { + 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() runner.on(Events.CHECK_SUCCESSFUL, diff --git a/packages/cli/src/commands/test.ts b/packages/cli/src/commands/test.ts index 4a920d824..2af6f0d34 100644 --- a/packages/cli/src/commands/test.ts +++ b/packages/cli/src/commands/test.ts @@ -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 = { @@ -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[] @@ -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)), diff --git a/packages/cli/src/commands/trigger.ts b/packages/cli/src/commands/trigger.ts index 5494457e8..506597265 100644 --- a/packages/cli/src/commands/trigger.ts +++ b/packages/cli/src/commands/trigger.ts @@ -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 { @@ -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) @@ -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, @@ -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() } 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..671687da1 --- /dev/null +++ b/packages/cli/src/reporters/__tests__/abstract-list.spec.ts @@ -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('../util.js') + 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', () => { + 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') + }) +}) diff --git a/packages/cli/src/reporters/__tests__/util.spec.ts b/packages/cli/src/reporters/__tests__/util.spec.ts index 9f9ba960f..1f232e7a2 100644 --- a/packages/cli/src/reporters/__tests__/util.spec.ts +++ b/packages/cli/src/reporters/__tests__/util.spec.ts @@ -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('⊘') + }) }) diff --git a/packages/cli/src/reporters/abstract-list.ts b/packages/cli/src/reporters/abstract-list.ts index 006eb5318..60596a10f 100644 --- a/packages/cli/src/reporters/abstract-list.ts +++ b/packages/cli/src/reporters/abstract-list.ts @@ -26,6 +26,8 @@ export type checkFilesMap = Map): void + onCancel(): void + onDetach(): 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 e475c444d..533ba4909 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..b2b08e16a --- /dev/null +++ b/packages/cli/src/rest/__tests__/cancel.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { AxiosInstance } from 'axios' +import Cancel from '../cancel.js' + +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/test-sessions/:id/cancel', async () => { + await cancel.cancelTestSession({ testSessionId: 'ts-abc' }) + + expect(api.post).toHaveBeenCalledWith('/v1/test-sessions/ts-abc/cancel', undefined) + }) + + it('passes sequenceId in the body when provided', async () => { + await cancel.cancelTestSession({ testSessionId: 'ts-abc', sequenceId: ['seq-1', 'seq-2'] }) + + expect(api.post).toHaveBeenCalledWith('/v1/test-sessions/ts-abc/cancel', { sequenceId: ['seq-1', 'seq-2'] }) + }) + + it('silently ignores a 403 response', async () => { + vi.mocked(api.post).mockRejectedValueOnce({ response: { status: 403 } }) + + await expect(cancel.cancelTestSession({ testSessionId: 'ts-forbidden' })).resolves.not.toThrow() + }) + + it('re-throws non-403 errors', async () => { + vi.mocked(api.post).mockRejectedValueOnce({ response: { status: 500 } }) + + await expect(cancel.cancelTestSession({ testSessionId: 'ts-error' })).rejects.toEqual({ response: { status: 500 } }) + }) + }) + + describe('cancelCheckSession()', () => { + it('calls POST /v1/check-sessions/:id/cancel', async () => { + await cancel.cancelCheckSession({ checkSessionId: 'cs-abc' }) + + expect(api.post).toHaveBeenCalledWith('/v1/check-sessions/cs-abc/cancel', undefined) + }) + + it('passes sequenceId in the body when provided', async () => { + await cancel.cancelCheckSession({ checkSessionId: 'cs-abc', sequenceId: ['seq-1'] }) + + expect(api.post).toHaveBeenCalledWith('/v1/check-sessions/cs-abc/cancel', { sequenceId: ['seq-1'] }) + }) + }) +}) diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index f48374687..72fb789fb 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -25,6 +25,7 @@ import BatchAnalytics from './batch-analytics.js' import Entitlements from './entitlements.js' import AccountMembers from './account-members.js' import Rca from './rca.js' +import Cancel from './cancel.js' import { handleErrorResponse, UnauthorizedError } from './errors.js' import { detectOperator } from '../helpers/cli-mode.js' @@ -129,3 +130,4 @@ export const batchAnalytics = new BatchAnalytics(api) export const entitlements = new Entitlements(api) export const accountMembers = new AccountMembers(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..8339d358d --- /dev/null +++ b/packages/cli/src/rest/cancel.ts @@ -0,0 +1,29 @@ +import { type AxiosInstance } from 'axios' + +class Cancel { + api: AxiosInstance + + constructor (api: AxiosInstance) { + this.api = api + } + + async cancelTestSession ({ testSessionId, sequenceId }: { testSessionId: string, sequenceId?: string[] }) { + try { + return await this.api.post(`/v1/test-sessions/${testSessionId}/cancel`, sequenceId ? { sequenceId } : undefined) + } catch (err: any) { + if (err?.response?.status === 403) return + throw err + } + } + + async cancelCheckSession ({ checkSessionId, sequenceId }: { checkSessionId: string, sequenceId?: string[] }) { + try { + return await this.api.post(`/v1/check-sessions/${checkSessionId}/cancel`, sequenceId ? { sequenceId } : undefined) + } catch (err: any) { + if (err?.response?.status === 403) return + throw err + } + } +} + +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..e51f31e0d --- /dev/null +++ b/packages/cli/src/services/__tests__/abstract-check-runner.spec.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import AbstractCheckRunner, { Events, SequenceId } from '../abstract-check-runner.js' + +// --------------------------------------------------------------------------- +// Module mocks — must be hoisted before any imports that pull these in +// --------------------------------------------------------------------------- + +vi.mock('../../rest/api.js', () => ({ + 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.js', () => ({ + SocketClient: { + connect: vi.fn().mockResolvedValue({ + on: vi.fn(), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + endAsync: vi.fn().mockResolvedValue(undefined), + }), + }, +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { SocketClient } from '../socket-client.js' + +/** 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.mocked(SocketClient.connect).mockResolvedValue({ + on: vi.fn(), + subscribeAsync: vi.fn().mockResolvedValue(undefined), + endAsync: vi.fn().mockResolvedValue(undefined), + } as any) + vi.spyOn(process, 'rawListeners').mockReturnValue([]) + vi.spyOn(process, 'removeAllListeners').mockReturnValue(process) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('registers 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(1) + }) + + 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('emits Events.DETACH and exits with 0 on SIGINT when detach is true', async () => { + 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 exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + + const runner = makeRunner(true) + + const detachEvents: unknown[] = [] + runner.on(Events.DETACH, () => detachEvents.push(true)) + + await runner.run() + + sigintHandler?.() + + expect(detachEvents).toHaveLength(1) + expect(exitSpy).toHaveBeenCalledWith(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(false) + 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 on first SIGINT', async () => { + 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(false) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-cancel', checks: [] }) + + const cancelEvents: unknown[] = [] + runner.on(Events.CANCEL, id => cancelEvents.push(id)) + + await runner.run() + + sigintHandler?.() + + expect(cancelEvents).toHaveLength(1) + expect(cancelEvents[0]).toBe('ts-cancel') + }) + + it('calls process.exit(1) on second SIGINT after cancellation', async () => { + 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 exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + + const runner = makeRunner(false) + await runner.run() + + sigintHandler?.() + + await new Promise(resolve => setTimeout(resolve, 110)) + + sigintHandler?.() + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it('debounces duplicate SIGINTs delivered within 100ms', async () => { + 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(false) + runner.scheduleChecks = vi.fn().mockResolvedValue({ testSessionId: 'ts-debounce', checks: [] }) + + const cancelEvents: unknown[] = [] + runner.on(Events.CANCEL, id => cancelEvents.push(id)) + + await runner.run() + + sigintHandler?.() + sigintHandler?.() + + expect(cancelEvents).toHaveLength(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/abstract-check-runner.ts b/packages/cli/src/services/abstract-check-runner.ts index b7b8baf90..f54c039bc 100644 --- a/packages/cli/src/services/abstract-check-runner.ts +++ b/packages/cli/src/services/abstract-check-runner.ts @@ -21,6 +21,8 @@ export enum Events { ERROR = 'ERROR', MAX_SCHEDULING_DELAY_EXCEEDED = 'MAX_SCHEDULING_DELAY_EXCEEDED', STREAM_LOGS = 'STREAM_LOGS', + CANCEL = 'CANCEL', + DETACH = 'DETACH', } export type PrivateRunLocation = { @@ -52,12 +54,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 +70,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 +80,8 @@ export default abstract class AbstractCheckRunner extends EventEmitter { async run () { let socketClient = null + let sigintHandler: (() => void) | null = null + let previousSigintListeners: Array<(...args: any[]) => void> = [] try { socketClient = await SocketClient.connect() @@ -87,6 +94,37 @@ export default abstract class AbstractCheckRunner extends EventEmitter { this.checks = new Map( checks.map(({ check, sequenceId }) => [sequenceId, { check }]), ) + let lastSigintAt = 0 + let hasCancelled = false + + // Remove pre-existing SIGINT listeners (e.g. from `when-exit`, a transitive + // dependency via conf → atomically) that would re-raise the signal and + // terminate the process — especially on Windows where process.kill(pid, 'SIGINT') + // is a hard kill. The listeners are restored in the finally block. + previousSigintListeners = process.rawListeners('SIGINT') as Array<(...args: any[]) => void> + process.removeAllListeners('SIGINT') + + sigintHandler = () => { + const now = Date.now() + // Ignore duplicate SIGINTs within 100ms — some terminals/shells deliver + // two signals for one Ctrl+C. + if (now - lastSigintAt < 100) { + return + } + lastSigintAt = now + + if (this.detach) { + this.emit(Events.DETACH) + process.exit(0) + } else if (hasCancelled) { + process.exit(1) + } else { + hasCancelled = true + this.emit(Events.CANCEL, testSessionId) + } + } + + 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 +148,12 @@ export default abstract class AbstractCheckRunner extends EventEmitter { this.disableAllTimeouts() this.emit(Events.ERROR, err) } finally { + if (sigintHandler) { + process.off('SIGINT', sigintHandler) + for (const listener of previousSigintListeners) { + process.on('SIGINT', listener) + } + } if (socketClient) { await socketClient.endAsync() } diff --git a/packages/cli/src/services/test-runner.ts b/packages/cli/src/services/test-runner.ts index 83ccc53e7..16c3c03de 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 02ec118e5..bd3891bef 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