Skip to content

Commit 025fbcc

Browse files
feat(cli): support agentic test session cancellation (#1354)
1 parent 730778a commit 025fbcc

6 files changed

Lines changed: 216 additions & 15 deletions

File tree

packages/cli/src/commands/pw-test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { cased } from '../sourcegen/index.js'
3939
import { shellQuote } from '../services/shell.js'
4040
import { Runtime } from '../runtimes/index.js'
4141
import { Bundler } from '../services/check-parser/bundler.js'
42+
import { registerTestSessionCancelHandler } from '../services/test-session-cancel.js'
4243

4344
export default class PwTestCommand extends AuthCommand {
4445
static coreCommand = true
@@ -348,11 +349,7 @@ export default class PwTestCommand extends AuthCommand {
348349
}, links))
349350
})
350351

351-
runner.on(Events.CANCEL, async testSessionId => {
352-
reporters.forEach(r => r.onCancel())
353-
if (!testSessionId) return
354-
await api.cancel.cancelTestSession({ testSessionId })
355-
})
352+
registerTestSessionCancelHandler(runner, reporters)
356353

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

packages/cli/src/commands/test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { BrowserCheckBundle } from '../constructs/browser-check-bundle.js'
2525
import { prepareReportersTypes, prepareRunLocation, validateDetachReporterTypes } from '../helpers/test-helper.js'
2626
import { Runtime } from '../runtimes/index.js'
2727
import { Bundler } from '../services/check-parser/bundler.js'
28+
import { registerTestSessionCancelHandler } from '../services/test-session-cancel.js'
2829

2930
const MAX_RETRIES = 3
3031

@@ -379,11 +380,7 @@ export default class Test extends AuthCommand {
379380
detach,
380381
)
381382

382-
runner.on(Events.CANCEL, async testSessionId => {
383-
reporters.forEach(r => r.onCancel())
384-
if (!testSessionId) return
385-
await api.cancel.cancelTestSession({ testSessionId })
386-
})
383+
registerTestSessionCancelHandler(runner, reporters)
387384

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

packages/cli/src/commands/trigger.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { NoMatchingChecksError, TestResultsShortLinks } from '../rest/test-sessi
2121
import { Session, RetryStrategyBuilder } from '../constructs/index.js'
2222
import { DEFAULT_REGION } from '../helpers/constants.js'
2323
import { validateDetachReporterTypes } from '../helpers/test-helper.js'
24+
import { registerTestSessionCancelHandler } from '../services/test-session-cancel.js'
2425

2526
const MAX_RETRIES = 3
2627

@@ -214,11 +215,7 @@ export default class Trigger extends AuthCommand {
214215
reporters.forEach(r => r.onError(err))
215216
process.exitCode = 1
216217
})
217-
runner.on(Events.CANCEL, async testSessionId => {
218-
reporters.forEach(r => r.onCancel())
219-
if (!testSessionId) return
220-
await api.cancel.cancelTestSession({ testSessionId })
221-
})
218+
registerTestSessionCancelHandler(runner, reporters)
222219
runner.on(Events.DETACH, () => reporters.forEach(r => r.onDetach()))
223220
await runner.run()
224221
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { EventEmitter } from 'node:events'
3+
4+
import { Events } from '../abstract-check-runner.js'
5+
import { registerTestSessionCancelHandler } from '../test-session-cancel.js'
6+
7+
function makeReporter () {
8+
return {
9+
onBegin: vi.fn(),
10+
onCheckInProgress: vi.fn(),
11+
onCheckAttemptResult: vi.fn(),
12+
onCheckEnd: vi.fn(),
13+
onEnd: vi.fn(),
14+
onError: vi.fn(),
15+
onSchedulingDelayExceeded: vi.fn(),
16+
onStreamLogs: vi.fn(),
17+
onCancel: vi.fn(),
18+
onDetach: vi.fn(),
19+
}
20+
}
21+
22+
describe('registerTestSessionCancelHandler', () => {
23+
it('cancels the whole test session for an agentic-shaped run', async () => {
24+
const runner = new EventEmitter()
25+
const reporter = makeReporter()
26+
const cancelClient = {
27+
cancelTestSession: vi.fn().mockResolvedValue(undefined),
28+
}
29+
30+
registerTestSessionCancelHandler(runner, [reporter], cancelClient)
31+
runner.emit(Events.CANCEL, 'ts-agentic')
32+
await vi.waitFor(() => expect(cancelClient.cancelTestSession).toHaveBeenCalled())
33+
34+
expect(reporter.onCancel).toHaveBeenCalledTimes(1)
35+
expect(cancelClient.cancelTestSession).toHaveBeenCalledWith({ testSessionId: 'ts-agentic' })
36+
expect(cancelClient.cancelTestSession).not.toHaveBeenCalledWith(
37+
expect.objectContaining({ sequenceId: expect.anything() }),
38+
)
39+
})
40+
41+
it('still marks reporters as cancelling when no test session was created', () => {
42+
const runner = new EventEmitter()
43+
const reporter = makeReporter()
44+
const cancelClient = {
45+
cancelTestSession: vi.fn().mockResolvedValue(undefined),
46+
}
47+
48+
registerTestSessionCancelHandler(runner, [reporter], cancelClient)
49+
runner.emit(Events.CANCEL)
50+
51+
expect(reporter.onCancel).toHaveBeenCalledTimes(1)
52+
expect(cancelClient.cancelTestSession).not.toHaveBeenCalled()
53+
})
54+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import type { RunLocation } from '../abstract-check-runner.js'
4+
import TestRunner from '../test-runner.js'
5+
import TriggerRunner from '../trigger-runner.js'
6+
import { testSessions } from '../../rest/api.js'
7+
8+
vi.mock('../../rest/api.js', () => ({
9+
testSessions: {
10+
run: vi.fn(),
11+
trigger: vi.fn(),
12+
},
13+
}))
14+
15+
const RUN_LOCATION: RunLocation = { type: 'PUBLIC', region: 'eu-west-1' }
16+
17+
describe('test-session runners', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
})
21+
22+
it('schedules local Agentic checks as cancellable test-session jobs', async () => {
23+
vi.mocked(testSessions.run).mockResolvedValue({
24+
data: {
25+
testSessionId: 'ts-agentic',
26+
sequenceIds: {
27+
'agentic-logical-id': 'seq-agentic',
28+
},
29+
},
30+
} as any)
31+
32+
const agenticCheck = {
33+
logicalId: 'agentic-logical-id',
34+
groupId: undefined,
35+
getSourceFile: () => 'agentic.check.ts',
36+
}
37+
const agenticBundle = {
38+
synthesize: vi.fn(() => ({
39+
checkType: 'AGENTIC',
40+
name: 'Agentic Check',
41+
})),
42+
}
43+
const projectBundle = {
44+
project: { name: 'Agentic Project', logicalId: 'agentic-project' },
45+
data: {
46+
'check-group': {},
47+
},
48+
}
49+
50+
const runner = new TestRunner(
51+
'account-id',
52+
projectBundle as any,
53+
[{ construct: agenticCheck, bundle: agenticBundle }] as any,
54+
[],
55+
RUN_LOCATION,
56+
60,
57+
false,
58+
true,
59+
null,
60+
null,
61+
false,
62+
'.',
63+
null,
64+
)
65+
66+
const scheduled = await runner.scheduleChecks('suite-id')
67+
const payload = vi.mocked(testSessions.run).mock.calls[0][0]
68+
69+
expect(payload.checkRunJobs[0]).toMatchObject({
70+
checkType: 'AGENTIC',
71+
logicalId: 'agentic-logical-id',
72+
filePath: 'agentic.check.ts',
73+
sourceInfo: {
74+
checkRunSuiteId: 'suite-id',
75+
updateSnapshots: false,
76+
},
77+
})
78+
expect(scheduled).toEqual({
79+
testSessionId: 'ts-agentic',
80+
checks: [{ check: agenticCheck, sequenceId: 'seq-agentic' }],
81+
})
82+
})
83+
84+
it('maps triggered Agentic checks back to the test-session sequence IDs', async () => {
85+
const agenticCheck = {
86+
id: 'agentic-check-id',
87+
name: 'Triggered Agentic Check',
88+
checkType: 'AGENTIC',
89+
}
90+
vi.mocked(testSessions.trigger).mockResolvedValue({
91+
data: {
92+
checks: [agenticCheck],
93+
testSessionId: 'ts-trigger-agentic',
94+
sequenceIds: {
95+
'agentic-check-id': 'seq-trigger-agentic',
96+
},
97+
},
98+
} as any)
99+
100+
const runner = new TriggerRunner(
101+
'account-id',
102+
60,
103+
false,
104+
true,
105+
RUN_LOCATION,
106+
[],
107+
['agentic-check-id'],
108+
[],
109+
null,
110+
null,
111+
undefined,
112+
null,
113+
)
114+
115+
const scheduled = await runner.scheduleChecks('suite-id')
116+
117+
expect(testSessions.trigger).toHaveBeenCalledWith(expect.objectContaining({
118+
checkRunSuiteId: 'suite-id',
119+
checkId: ['agentic-check-id'],
120+
}))
121+
expect(scheduled).toEqual({
122+
testSessionId: 'ts-trigger-agentic',
123+
checks: [{ check: agenticCheck, sequenceId: 'seq-trigger-agentic' }],
124+
})
125+
})
126+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Reporter } from '../reporters/reporter.js'
2+
import { cancel } from '../rest/api.js'
3+
import { Events } from './abstract-check-runner.js'
4+
5+
type TestSessionCancelEmitter = {
6+
on(event: Events.CANCEL, listener: (testSessionId?: string) => Promise<void>): unknown
7+
}
8+
9+
type TestSessionCancelClient = {
10+
cancelTestSession(input: { testSessionId: string }): Promise<unknown>
11+
}
12+
13+
export function registerTestSessionCancelHandler (
14+
runner: TestSessionCancelEmitter,
15+
reporters: Reporter[],
16+
cancelClient: TestSessionCancelClient = cancel,
17+
) {
18+
runner.on(Events.CANCEL, async testSessionId => {
19+
reporters.forEach(r => r.onCancel())
20+
21+
if (!testSessionId) {
22+
return
23+
}
24+
25+
// Cancellation is intentionally test-session scoped. The backend resolves
26+
// which running results are cancellable (currently Playwright and Agentic),
27+
// so the CLI must not narrow the request by check type or sequence ID here.
28+
await cancelClient.cancelTestSession({ testSessionId })
29+
})
30+
}

0 commit comments

Comments
 (0)