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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/cli/e2e/__tests__/cancel-prompt.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise((resolve, reject) => {
let buffer = ''
const timer = setTimeout(() => {
reject(new Error(`Timed out after ${timeout}ms waiting for stdout line containing "${needle}"`))

Check failure on line 19 in packages/cli/e2e/__tests__/cancel-prompt.spec.ts

View workflow job for this annotation

GitHub Actions / test-e2e - windows-latest-x64

e2e/__tests__/cancel-prompt.spec.ts > cancel-prompt fixture > typed y after prompt opens confirms and exits 0

Error: Timed out after 5000ms waiting for stdout line containing "PROMPT_OPEN" ❯ Timeout._onTimeout e2e/__tests__/cancel-prompt.spec.ts:19:14

Check failure on line 19 in packages/cli/e2e/__tests__/cancel-prompt.spec.ts

View workflow job for this annotation

GitHub Actions / test-e2e - windows-latest-x64

e2e/__tests__/cancel-prompt.spec.ts > cancel-prompt fixture > buffered ^C byte before prompt does not auto-abort

Error: Timed out after 5000ms waiting for stdout line containing "PROMPT_OPEN" ❯ Timeout._onTimeout e2e/__tests__/cancel-prompt.spec.ts:19:14
}, 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<number> {
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<void>(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)
})
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
Loading
Loading