Skip to content
Open
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
181 changes: 181 additions & 0 deletions src/commands/test.run.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3666,6 +3666,187 @@ describe('runTestRunAll — batch fresh run', () => {
};
}

it.each(['SIGINT', 'SIGTERM'] as const)(
'names unverified conflicts in text when no runs were accepted on %s',
async signal => {
const { credentialsPath } = makeCreds();
const shutdown = new ShutdownController();
const stdout: string[] = [];
const stderr: string[] = [];
const methods: string[] = [];
const error: unknown = await runTestRunAll(
{
profile: 'default',
output: 'text',
debug: false,
projectId: 'project_be',
wait: true,
timeoutSeconds: 60,
maxConcurrency: 5,
},
{
credentialsPath,
shutdown,
fetchImpl: makeFetch((url, init) => {
methods.push(init.method ?? 'GET');
if (init.method === 'POST') {
return {
body: {
...BATCH_FRESH_RESP,
accepted: [],
conflicts: [
{ testId: 'test_busy', currentRunId: 'run_busy' },
{ testId: 'test_busy_2', currentRunId: 'run_busy_2' },
{ testId: 'test_unknown', reason: 'in_flight' },
],
},
};
}
if (url.includes('/runs/')) {
shutdown.interrupt(signal);
throw shutdown.signal.reason;
}
return { body: { environments: [] } };
}),
stdout: line => stdout.push(line),
stderr: line => stderr.push(line),
},
).catch((caught: unknown) => caught);
expect(error).toBe(shutdown.signal.reason);
expect(shutdown.isArmed).toBe(false);
expect(stdout.join('\n')).toBe(
'run_busy unverified (not attached)\nrun_busy_2 unverified (not attached)',
);
expect(stderr.join('\n')).not.toContain('[auto-resume]');
expect(methods.filter(method => method !== 'GET')).toEqual(['POST']);
},
);

it.each(['SIGINT', 'SIGTERM'] as const)(
'keeps unsent filtered test IDs when dispatch stops before metadata interruption on %s',
async signal => {
const { credentialsPath } = makeCreds();
const shutdown = new ShutdownController();
const stdout: string[] = [];
const methods: string[] = [];
const tests = Array.from({ length: 51 }, (_, i) => ({
id: `test_${i}`,
name: `selected ${i}`,
projectId: 'project_be',
type: i >= 2 && i < 50 ? 'frontend' : 'backend',
}));
let dispatches = 0;
const error: unknown = await runTestRunAll(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'project_be',
nameFilter: 'selected',
wait: true,
timeoutSeconds: 60,
maxConcurrency: 5,
},
{
credentialsPath,
shutdown,
fetchImpl: makeFetch((url, init) => {
methods.push(init.method ?? 'GET');
if (url.includes('/tests?')) return { body: { items: tests, nextToken: null } };
if (init.method === 'POST') {
dispatches++;
if (dispatches > 1) return errorBody('PRECONDITION_FAILED');
return {
body: {
...BATCH_FRESH_RESP,
accepted: [
{ testId: 'test_0', runId: 'run_new', enqueuedAt: '2026-06-09T10:00:00Z' },
],
conflicts: [{ testId: 'test_1', currentRunId: 'run_busy' }],
skippedFrontend: tests.slice(2, 50).map(test => test.id),
},
};
}
if (url.includes('/runs/run_busy')) {
shutdown.interrupt(signal);
throw shutdown.signal.reason;
}
return { body: { environments: [] } };
}),
stdout: line => stdout.push(line),
stderr: () => {},
},
).catch((caught: unknown) => caught);
expect(error).toBe(shutdown.signal.reason);
expect(shutdown.isArmed).toBe(false);
expect(JSON.parse(stdout.join('\n'))).toMatchObject({
accepted: [{ testId: 'test_0', runId: 'run_new', status: 'running' }],
conflicts: [{ testId: 'test_1', currentRunId: 'run_busy' }],
notRunTestIds: ['test_50'],
skippedFrontend: tests.slice(2, 50).map(test => test.id),
});
expect(methods.filter(method => method !== 'GET')).toEqual(['POST', 'POST']);
},
);

it.each(['SIGINT', 'SIGTERM'] as const)(
'preserves batch receipts and disarms the conflict metadata scope on %s',
async signal => {
const { credentialsPath } = makeCreds();
const shutdown = new ShutdownController();
const stdout: string[] = [];
const stderr: string[] = [];
let armedAtRead = false;
const response: BatchRunFreshResponse = {
...BATCH_FRESH_RESP,
conflicts: [{ testId: 'test_busy', currentRunId: 'run_busy', reason: 'in_flight' }],
skippedFrontend: ['test_frontend'],
skippedIntegration: [{ testId: 'test_integration' }],
};
const error: unknown = await runTestRunAll(
{
profile: 'default',
output: 'json',
debug: false,
projectId: 'project_be',
wait: true,
timeoutSeconds: 60,
maxConcurrency: 5,
},
{
credentialsPath,
shutdown,
fetchImpl: makeFetch((url, init) => {
if (init.method === 'POST') return { body: response };
if (url.includes('/runs/run_busy')) {
armedAtRead = shutdown.isArmed;
shutdown.interrupt(signal);
throw shutdown.signal.reason;
}
return { body: { environments: [] } };
}),
stdout: line => stdout.push(line),
stderr: line => stderr.push(line),
},
).catch((caught: unknown) => caught);
expect(error).toBe(shutdown.signal.reason);
expect(armedAtRead).toBe(true);
expect(shutdown.isArmed).toBe(false);
expect(JSON.parse(stdout.join('\n'))).toEqual({
accepted: response.accepted.map(e => ({
testId: e.testId,
runId: e.runId,
status: 'running',
})),
conflicts: response.conflicts,
deferred: [],
skippedFrontend: response.skippedFrontend,
skippedIntegration: response.skippedIntegration,
});
expect(stderr.join('\n')).toContain('testsprite test wait run_fresh_01 run_fresh_02');
},
);

it('run --all --wait: a NOT_FOUND member poll → exit 4 with error.code (was folded to exit 1 before this PR)', async () => {
const { credentialsPath } = makeCreds();
let caughtError: unknown;
Expand Down
41 changes: 40 additions & 1 deletion src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10986,6 +10986,7 @@ export async function runTestRunAll(
// resolvable in-flight run stay hard (nothing to wait on).
// An in-flight run may belong to another deployment/environment. CI can
// only use runs accepted by this invocation to judge its own change.
const metadataConflicts = conflicts;
// A `no_producer` / `producer_in_flight` conflict's `currentRunId` is the
// result the server seeded BLOCKED before dispatch. It is reported with the
// server's reason and exit 6, as a single `test run` reports it, not polled.
Expand Down Expand Up @@ -11025,6 +11026,8 @@ export async function runTestRunAll(
}
return listing;
};
// Keep the metadata stage graceful even between tracked HTTP requests.
const disarmResolve = shutdownOf(deps).arm();
const resumedOrRefused = await Promise.all(
resumableConflicts.map(
async (c): Promise<{ entry: BatchRunFreshAccepted } | { refused: RunConflict }> => {
Expand Down Expand Up @@ -11091,7 +11094,43 @@ export async function runTestRunAll(
return { entry: { testId: c.testId, runId: inFlightRunId, enqueuedAt: notBefore } };
},
),
);
)
.catch((err: unknown) => {
if (err instanceof InterruptError) {
const partialResults: CliBatchRunFreshResult[] = accepted.map(e => ({
testId: e.testId,
runId: e.runId,
status: 'running',
}));
out.print(
{
accepted: partialResults,
conflicts: metadataConflicts,
deferred,
skippedFrontend,
skippedIntegration,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
...(notRunTestIds.length > 0 ? { notRunTestIds } : {}),
},
() =>
partialResults.length > 0
? partialResults.map(r => `${r.runId} ${r.status}`).join('\n')
: resumableConflicts
.map(c => `${c.currentRunId} unverified (not attached)`)
.join('\n'),
);
if (accepted.length > 0) {
err.runWaitContext = true;
stderrFn(
interruptDetachMessage(
err,
accepted.map(e => e.runId),
),
);
}
}
throw err;
})
.finally(disarmResolve);
const resumableEntries: BatchRunFreshAccepted[] = [];
for (const outcome of resumedOrRefused) {
if ('entry' in outcome) {
Expand Down
Loading
Loading