Skip to content
Merged
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
67 changes: 66 additions & 1 deletion packages/cli/src/rest/__tests__/test-sessions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('TestSessions REST client', () => {
shouldRecord: true,
} as any)

expect(api.post).toHaveBeenCalledWith('/next/test-sessions/run', expect.objectContaining({
expect(api.post).toHaveBeenCalledWith('/v1/test-sessions/run', expect.objectContaining({
repoInfo: githubRepoInfo,
}), expect.any(Object))
})
Expand Down Expand Up @@ -126,6 +126,71 @@ describe('TestSessions REST client', () => {
})
})

it('runs a test session via the async v1 endpoint', async () => {
const api = {
post: vi.fn().mockResolvedValue({ data: { schedulingId: 'op-id', sequenceIds: {} } }),
}
const testSessions = new TestSessions(api as any)

const payload = { name: 'project', checkRunJobs: [], project: { logicalId: 'p' }, runLocation: 'eu-central-1', shouldRecord: false }
await testSessions.run(payload as any)

expect(api.post).toHaveBeenCalledWith('/v1/test-sessions/run', payload, expect.objectContaining({
transformRequest: expect.any(Function),
}))
})

it('gets scheduling completion by ID', async () => {
const api = {
get: vi.fn().mockResolvedValue({ data: { schedulingId: 'op-id', status: 'SUCCEEDED', error: null } }),
}
const testSessions = new TestSessions(api as any)
const signal = new AbortController().signal

await testSessions.getSchedulingCompletion('op-id', { signal })

expect(api.get).toHaveBeenCalledWith('/v1/test-sessions/scheduling/op-id/completion', {
params: { maxWaitSeconds: 30 },
signal,
})
})

it('polls scheduling completion timeouts until complete', async () => {
const completed = { schedulingId: 'op-id', status: 'FAILED', error: { code: 'SCHEDULING_ERROR', message: 'nope' } }
const api = {
get: vi.fn()
.mockRejectedValueOnce(new RequestTimeoutError({
statusCode: 408,
error: 'Request Timeout',
message: 'Scheduling is still in progress, but maximum per-request wait time was exceeded. Please retry.',
}))
.mockResolvedValueOnce({ data: completed }),
}
const testSessions = new TestSessions(api as any)

const result = await testSessions.pollSchedulingUntilComplete('op-id')

expect(result).toEqual(completed)
expect(api.get).toHaveBeenCalledTimes(2)
})

it('stops polling scheduling when the signal is aborted', async () => {
const api = {
get: vi.fn().mockRejectedValue(new RequestTimeoutError({
statusCode: 408,
error: 'Request Timeout',
message: 'Still in progress.',
})),
}
const testSessions = new TestSessions(api as any)
const controller = new AbortController()
controller.abort()

await expect(testSessions.pollSchedulingUntilComplete('op-id', { signal: controller.signal }))
.rejects.toBeInstanceOf(RequestTimeoutError)
expect(api.get).toHaveBeenCalledTimes(1)
})

it('lists public test sessions with filters', async () => {
const api = {
get: vi.fn().mockResolvedValue({ data: { length: 0, entries: [], nextId: null } }),
Expand Down
72 changes: 71 additions & 1 deletion packages/cli/src/rest/test-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,45 @@ export type TriggerTestSessionResponse = {
sequenceIds: Record<string, SequenceId>
}

export type RunTestSessionResponse = {
/** The scheduling operation dispatching this run's check runs in the background. */
schedulingId: string
/** Only present for recorded runs (shouldRecord: true). */
testSessionId?: string
testResultIds?: Record<string, string>
sequenceIds: Record<string, SequenceId>
}

export type TestSessionSchedulingStatus = 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED'

export type TestSessionSchedulingOperation = {
schedulingId: string
testSessionId: string | null
status: TestSessionSchedulingStatus
checksTotal: number
error: { code: string, message: string } | null
createdAt: string
startedAt: string | null
endedAt: string | null
}

/**
* The background dispatch of the test session's check runs failed (or the
* worker died — errorCode ABANDONED): no results will arrive for the
* undispatched checks, so the run should fail immediately instead of waiting
* out the per-check result timeouts.
*/
export class TestSessionSchedulingFailedError extends Error {
/** The operation's error code (e.g. SCHEDULING_ERROR, ABANDONED), when known. */
code?: string

constructor (message: string, options?: ErrorOptions & { code?: string }) {
super(message, options)
this.name = 'TestSessionSchedulingFailedError'
this.code = options?.code
}
}

export type TestSessionMetadata = {
environment?: string
repoUrl?: string
Expand Down Expand Up @@ -157,11 +196,42 @@ class TestSessions {
}

async run (payload: RunTestSessionRequest) {
return await this.api.post('/next/test-sessions/run', payload, {
return await this.api.post<RunTestSessionResponse>('/v1/test-sessions/run', payload, {
transformRequest: compressJSONPayload,
})
}

/**
* Long-poll the dispatch status of an asynchronously started test-session
* run. The server blocks for up to `maxWaitSeconds` and returns the
* operation once it reaches a terminal state, or responds 408
* ({@link RequestTimeoutError}) while dispatch is still in progress — the
* retry cadence lives in the caller.
*/
getSchedulingCompletion (schedulingId: string, options?: { maxWaitSeconds?: number, signal?: AbortSignal }) {
return this.api.get<TestSessionSchedulingOperation>(`/v1/test-sessions/scheduling/${schedulingId}/completion`, {
params: { maxWaitSeconds: options?.maxWaitSeconds ?? COMPLETION_MAX_WAIT_SECONDS },
signal: options?.signal,
})
}

async pollSchedulingUntilComplete (
schedulingId: string,
options?: { signal?: AbortSignal },
): Promise<TestSessionSchedulingOperation> {
while (true) {
try {
const { data } = await this.getSchedulingCompletion(schedulingId, options)
return data
} catch (err) {
if (err instanceof RequestTimeoutError && !options?.signal?.aborted) {
continue
}
throw err
}
}
}

/**
* @throws {NoMatchingChecksError} If no checks matched the request.
*/
Expand Down
135 changes: 135 additions & 0 deletions packages/cli/src/services/__tests__/abstract-check-runner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock('../../rest/api.js', () => ({
testSessions: {
run: vi.fn().mockResolvedValue({ data: { testSessionId: 'ts-123', sequenceIds: {} } }),
getResultShortLinks: vi.fn().mockResolvedValue({ data: {} }),
pollSchedulingUntilComplete: vi.fn(),
},
assets: {
getLogs: vi.fn().mockResolvedValue([]),
Expand All @@ -32,6 +33,8 @@ vi.mock('../socket-client.js', () => ({
// ---------------------------------------------------------------------------

import { SocketClient } from '../socket-client.js'
import { testSessions } from '../../rest/api.js'
import { TestSessionSchedulingFailedError } from '../../rest/test-sessions.js'

/** Minimal concrete subclass — scheduleChecks immediately returns with zero checks so the runner exits cleanly. */
class StubCheckRunner extends AbstractCheckRunner {
Expand Down Expand Up @@ -240,3 +243,135 @@ describe('AbstractCheckRunner — SocketClient lifecycle', () => {
expect(mockClient.endAsync).toHaveBeenCalledTimes(1)
})
})

// ---------------------------------------------------------------------------
// Scheduling watch
// ---------------------------------------------------------------------------

/** Returns a schedulingId and one pending check, so the run only settles via a
* check result or a scheduling failure. */
class SchedulingStubRunner extends AbstractCheckRunner {
checksToSchedule: Array<{ check: any, sequenceId: SequenceId }>

constructor (checks: Array<{ check: any, sequenceId: SequenceId }>) {
super('acc-1', 60, false, false)
this.checksToSchedule = checks
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
scheduleChecks (_checkRunSuiteId: string): Promise<{
testSessionId?: string
schedulingId?: string
checks: Array<{ check: any, sequenceId: SequenceId }>
}> {
return Promise.resolve({ testSessionId: 'ts-stub', schedulingId: 'op-1', checks: this.checksToSchedule })
}
}

describe('AbstractCheckRunner — scheduling watch', () => {
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)
vi.spyOn(process, 'on').mockReturnValue(process)
vi.spyOn(process, 'off').mockReturnValue(process)
})

afterEach(() => {
vi.restoreAllMocks()
})

it('fails the run immediately when the scheduling operation reports FAILED', async () => {
vi.mocked(testSessions.pollSchedulingUntilComplete).mockResolvedValue({
schedulingId: 'op-1',
testSessionId: 'ts-stub',
status: 'FAILED',
checksTotal: 1,
error: { code: 'SCHEDULING_ERROR', message: 'Unable to find private location' },
createdAt: '2026-01-01T00:00:00.000Z',
startedAt: null,
endedAt: null,
})

const runner = new SchedulingStubRunner([{ check: {}, sequenceId: 'seq-1' }])
const errors: Error[] = []
runner.on(Events.ERROR, err => errors.push(err))

await runner.run()

expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(TestSessionSchedulingFailedError)
expect(errors[0].message).toEqual('Unable to find private location')
expect(testSessions.pollSchedulingUntilComplete).toHaveBeenCalledWith('op-1', expect.objectContaining({
signal: expect.any(AbortSignal),
}))
})

it('does not settle the run when the scheduling operation succeeds', async () => {
vi.mocked(testSessions.pollSchedulingUntilComplete).mockResolvedValue({
schedulingId: 'op-1',
testSessionId: 'ts-stub',
status: 'SUCCEEDED',
checksTotal: 1,
error: null,
createdAt: '2026-01-01T00:00:00.000Z',
startedAt: null,
endedAt: null,
})

// One pending check: the run may only settle once that check finishes —
// the SUCCEEDED watch must neither error nor resolve the race early.
const runner = new SchedulingStubRunner([{ check: {}, sequenceId: 'seq-1' }])
const errors: Error[] = []
let finished = false
runner.on(Events.ERROR, err => errors.push(err))
runner.on(Events.RUN_FINISHED, () => {
finished = true
})

const runPromise = runner.run()
// Let the (mock-resolved) scheduling watch settle before the check does.
await new Promise(resolve => setTimeout(resolve, 20))
expect(finished).toBe(false)
runner.emit(Events.CHECK_FINISHED)
await runPromise
;(runner as any).disableAllTimeouts()

expect(errors).toHaveLength(0)
expect(finished).toBe(true)
})

it('fails a detached run when dispatch fails', async () => {
vi.mocked(testSessions.pollSchedulingUntilComplete).mockResolvedValue({
schedulingId: 'op-1',
testSessionId: 'ts-stub',
status: 'FAILED',
checksTotal: 1,
error: { code: 'ABANDONED', message: 'the worker stopped reporting progress' },
createdAt: '2026-01-01T00:00:00.000Z',
startedAt: null,
endedAt: null,
})

const runner = new SchedulingStubRunner([{ check: {}, sequenceId: 'seq-1' }])
;(runner as any).detach = true
const errors: Array<Error & { code?: string }> = []
let detached = false
runner.on(Events.ERROR, err => errors.push(err))
runner.on(Events.DETACH, () => {
detached = true
})

await runner.run()

expect(detached).toBe(false)
expect(errors).toHaveLength(1)
expect(errors[0]).toBeInstanceOf(TestSessionSchedulingFailedError)
expect(errors[0].code).toEqual('ABANDONED')
})
})
Loading
Loading