Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cff19ae
fix(rerun): emit partial stdout on TimeoutError in single-FE rerun --…
Jul 3, 2026
95e3978
Merge branch 'main' into fix/rerun-wait-timeout-stdout-pr
Awad-de Jul 9, 2026
7975f6f
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
51af939
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
7dd2576
Add newline at end of test.rerun.spec.ts
Awad-de Jul 18, 2026
15ee2dc
Add newline at end of test.rerun.spec.ts
Awad-de Jul 18, 2026
770cd47
Refactor runTestRerun call with new parameters
Awad-de Jul 18, 2026
f7120c2
Add newline at end of test.rerun.spec.ts
Awad-de Jul 18, 2026
0b34b1a
Add newline at end of test.rerun.spec.ts
Awad-de Jul 18, 2026
5e2bf88
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
98f9393
Refactor test parameters for runTestRerun function
Awad-de Jul 18, 2026
c4bef00
Fix formatting and comments in test.rerun.spec.ts
Awad-de Jul 18, 2026
0a3bfe3
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
9a6a48d
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
1937605
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
175018a
Merge branch 'main' into fix/rerun-wait-timeout-stdout-pr
Awad-de Jul 18, 2026
f6aea0e
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
ad0dd10
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
8177812
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
bdec093
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
6b080ac
Update test.rerun.spec.ts
Awad-de Jul 18, 2026
44501e0
Refactor batch rerun tests for RequestTimeoutError
Awad-de Jul 18, 2026
7774c29
Refactor test.rerun.spec.ts imports
Awad-de Jul 18, 2026
73441e4
Refactor fetch implementation and error handling
Awad-de Jul 18, 2026
0081f3f
Fix formatting
Awad-de Jul 18, 2026
c286766
Fix Prettier formatting strictly
Awad-de Jul 18, 2026
774db84
Re-run failed jobs
Awad-de Jul 18, 2026
943b761
Re-run failed jobs
Awad-de Jul 18, 2026
8e79ee0
test(rerun): restore DEV-331 InterruptError batch --wait regression
Jul 24, 2026
208eee2
Merge branch 'main' into fix/rerun-wait-timeout-stdout-pr
Awad-de Aug 13, 2026
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
85 changes: 78 additions & 7 deletions src/commands/test.rerun.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@
): { credentialsPath: string } {
const dir = mkdtempSync(join(tmpdir(), 'cli-m34-rerun-'));
const credentialsPath = join(dir, 'credentials');
mkdirSync(dir, { recursive: true });

Check failure on line 95 in src/commands/test.rerun.spec.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found mkdirSync from package "node:fs" with non literal argument at index 0
writeFileSync(credentialsPath, `[default]\napi_url = ${apiUrl}\napi_key = ${apiKey}\n`, {

Check failure on line 96 in src/commands/test.rerun.spec.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found writeFileSync from package "node:fs" with non literal argument at index 0
mode: 0o600,
});
return { credentialsPath };
Expand Down Expand Up @@ -204,7 +204,7 @@
UNAVAILABLE: 503,
};
return {
status: statusMap[code] ?? 400,

Check warning on line 207 in src/commands/test.rerun.spec.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Generic Object Injection Sink
body: {
error: {
code,
Expand Down Expand Up @@ -5521,7 +5521,6 @@
// ---------------------------------------------------------------------------
// Batch --all --wait fan-out: RequestTimeoutError must not leave stdout empty
// ---------------------------------------------------------------------------

describe('[finding-5] batch rerun --wait: RequestTimeoutError during fan-out poll writes JSON stdout + exit 7', () => {
it('stdout contains accepted[] with runIds when member polls throw RequestTimeoutError', async () => {
const creds = makeCreds();
Expand All @@ -5534,6 +5533,7 @@
conflicts: [],
closure: { byProject: [] },
};

const fetchImpl = makeFetch(url => {
if (url.includes('/tests/batch/rerun')) {
return { status: 202, body: batchResp };
Expand All @@ -5543,8 +5543,8 @@
}
return errorBody('NOT_FOUND');
});
const stdoutLines: string[] = [];

const stdoutLines: string[] = [];
const err = await runTestRerun(
{
testIds: ['test_1', 'test_2'],
Expand All @@ -5554,12 +5554,10 @@
autoHeal: false,
autoHealExplicit: false,
skipDependencies: false,
maxConcurrency: 10,
output: 'json',
maxConcurrency: 1,
profile: 'default',
dryRun: false,
output: 'json',
debug: false,
verbose: false,
},
{
...creds,
Expand All @@ -5571,7 +5569,6 @@
).catch(e => e);

expect(err).toMatchObject({ exitCode: 7 });
expect(stdoutLines.length).toBeGreaterThan(0);
const parsed = JSON.parse(stdoutLines.join('\n')) as {
accepted: Array<{ testId: string; runId: string; status: string }>;
};
Expand All @@ -5581,6 +5578,80 @@
});
});

// ---------------------------------------------------------------------------
// TimeoutError on single FE rerun --wait: partial stdout + exit 7
// ---------------------------------------------------------------------------
describe('[finding-4] single FE rerun --wait: TimeoutError writes partial JSON to stdout', () => {
it('exit 7 AND stdout contains {runId, status:"running"} when --timeout polling deadline is exceeded', async () => {
const creds = makeCreds();
const rerunResp = makeFeRerunResp();

let fetchCallCount = 0;
const fetchImpl: typeof globalThis.fetch = async (input, _init) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: (input as { url: string }).url;
fetchCallCount++;
if (url.includes('/tests/test_fe_01/runs/rerun')) {
return new Response(JSON.stringify(rerunResp), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/runs/')) {
const runningRun: RunResponse = {
...makeTerminalRun(rerunResp.runId, 'passed'),
status: 'running',
finishedAt: null,
};
return new Response(JSON.stringify(runningRun), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: { code: 'NOT_FOUND' } }), { status: 404 });
};

const stdoutLines: string[] = [];

const err = await runTestRerun(
{
testIds: ['test_fe_01'],
all: false,
wait: true,
timeoutSeconds: 0,
autoHeal: false,
autoHealExplicit: false,
skipDependencies: false,
maxConcurrency: 10,
output: 'json',
profile: 'default',
dryRun: false,
debug: false,
verbose: false,
},
{
...creds,
sleep: instantSleep,
fetchImpl: fetchImpl as unknown as FetchImpl,
stdout: line => stdoutLines.push(line),
stderr: () => undefined,
},
).catch(e => e);

expect(err).toMatchObject({ exitCode: 7 });
expect(stdoutLines.length).toBeGreaterThan(0);
const parsed = JSON.parse(stdoutLines.join('\n')) as { runId: string; status: string };
expect(parsed.runId).toBe(rerunResp.runId);
expect(parsed.status).toBe('running');

void fetchCallCount;
});
});

// ---------------------------------------------------------------------------
// DEV-331 piece 1 — graceful detach during batch rerun --wait (SIG-6)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -5738,7 +5809,7 @@
},
).catch(e => e);
expect(err).toMatchObject({ exitCode: 1 });
const artifact = JSON.parse(readFileSync(summaryFile, 'utf8')) as {

Check failure on line 5812 in src/commands/test.rerun.spec.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found readFileSync from package "node:fs" with non literal argument at index 0
total: number;
passed: number;
failed: number;
Expand Down
12 changes: 12 additions & 0 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,7 +1107,7 @@
const absolute = resolveAbsolute(path);
let stat;
try {
stat = statSync(absolute);

Check failure on line 1110 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found statSync from package "node:fs" with non literal argument at index 0
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
Expand Down Expand Up @@ -1135,7 +1135,7 @@

function readCodeFile(path: string): string {
try {
return stripBom(readFileSync(resolveAbsolute(path), 'utf8'));

Check failure on line 1138 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found readFileSync from package "node:fs" with non literal argument at index 0
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
Expand Down Expand Up @@ -1358,7 +1358,7 @@

let stat;
try {
stat = statSync(absolute);

Check failure on line 1361 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found statSync from package "node:fs" with non literal argument at index 0
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
Expand Down Expand Up @@ -1388,7 +1388,7 @@

let raw;
try {
raw = stripBom(readFileSync(absolute, 'utf8'));

Check failure on line 1391 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found readFileSync from package "node:fs" with non literal argument at index 0
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown error';
throw localValidationError('steps', `cannot read ${path}: ${reason}`);
Expand Down Expand Up @@ -1422,7 +1422,7 @@
requireArrayLength('planSteps', stepsRaw, { min: 1, max: MAX_PLAN_STEPS, itemNoun: 'step' });

for (let i = 0; i < stepsRaw.length; i += 1) {
const step = stepsRaw[i];

Check warning on line 1425 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Variable Assigned to Object Injection Sink
if (typeof step !== 'object' || step === null || Array.isArray(step)) {
throw localValidationError(`planSteps[${i}]`, 'must be an object', undefined, 'field');
}
Expand Down Expand Up @@ -1477,7 +1477,7 @@
if (!Array.isArray(stepsRaw)) return issues;

for (let i = 0; i < stepsRaw.length; i += 1) {
const step: unknown = stepsRaw[i];

Check warning on line 1480 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Variable Assigned to Object Injection Sink
if (typeof step !== 'object' || step === null || Array.isArray(step)) {
issues.push({ field: `planSteps[${i}]`, reason: 'must be an object' });
continue;
Expand Down Expand Up @@ -2658,7 +2658,7 @@

let stat;
try {
stat = statSync(absolute);

Check failure on line 2661 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found statSync from package "node:fs" with non literal argument at index 0
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
Expand Down Expand Up @@ -2688,7 +2688,7 @@

let raw;
try {
raw = stripBom(readFileSync(absolute, 'utf8'));

Check failure on line 2691 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found readFileSync from package "node:fs" with non literal argument at index 0
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown error';
throw localValidationError('plan-from', `cannot read ${path}: ${reason}`);
Expand Down Expand Up @@ -2834,7 +2834,7 @@
itemNoun: 'step',
});
for (let i = 0; i < (obj.planSteps as unknown[]).length; i += 1) {
const step = (obj.planSteps as unknown[])[i];

Check warning on line 2837 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Variable Assigned to Object Injection Sink
if (typeof step !== 'object' || step === null || Array.isArray(step)) {
throw localValidationError(
`${prefix}planSteps[${i}]`,
Expand Down Expand Up @@ -2903,7 +2903,7 @@
);
if (Array.isArray(obj.planSteps)) {
for (let i = 0; i < obj.planSteps.length; i += 1) {
const step: unknown = obj.planSteps[i];

Check warning on line 2906 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Variable Assigned to Object Injection Sink
if (typeof step !== 'object' || step === null || Array.isArray(step)) {
issues.push({ field: `${prefix}planSteps[${i}]`, reason: 'must be an object' });
continue;
Expand Down Expand Up @@ -3832,7 +3832,7 @@

let stat;
try {
stat = statSync(absolute);

Check failure on line 3835 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Found statSync from package "node:fs" with non literal argument at index 0
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
Expand Down Expand Up @@ -3882,7 +3882,7 @@
for (let i = 0; i < lines.length; i += 1) {
let parsed: unknown;
try {
parsed = JSON.parse(lines[i]!);

Check warning on line 3885 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Generic Object Injection Sink
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown error';
throw localValidationError(`plans[${i}]`, `not valid JSON: ${reason}`);
Expand Down Expand Up @@ -3940,7 +3940,7 @@
const specs: CliPlanInput[] = [];
let skippedCount = 0;
for (let i = 0; i < entries.length; i += 1) {
const filename = entries[i]!;

Check warning on line 3943 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Generic Object Injection Sink
const filePath = join(absolute, filename);

let raw: string;
Expand Down Expand Up @@ -8826,6 +8826,18 @@
} catch (err) {
if (err instanceof TimeoutError) {
ticker.finalize(`Run ${rerunResp.runId} — timed out after ${opts.timeoutSeconds}s`);
// Mirror the RequestTimeoutError path: emit a partial run to stdout so
// JSON consumers and AI agents can grab the runId and chain into
// `testsprite test wait <runId>` without parsing the stderr error envelope.
const timeoutPartial = { runId: rerunResp.runId, status: 'running' as const };
out.print(timeoutPartial, data => {
const p = data as typeof timeoutPartial;
return [
`runId ${p.runId}`,
`status ${p.status} (timed out after ${opts.timeoutSeconds}s)`,
`hint Re-attach with: testsprite test wait ${p.runId}`,
].join('\n');
});
throw ApiError.fromEnvelope({
error: {
code: 'UNSUPPORTED',
Expand Down Expand Up @@ -9032,7 +9044,7 @@
// landed server-side for it to dedup against.
chunkResponses = [];
for (let idx = 0; idx < chunks.length; idx++) {
const chunk = chunks[idx]!;

Check warning on line 9047 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Generic Object Injection Sink
// Bound the per-chunk idempotency key to <=256 chars (mirrors the retry
// path). A long base key plus the `:chunkN` suffix could otherwise exceed
// the server cap and be rejected or truncated inconsistently.
Expand Down Expand Up @@ -9205,7 +9217,7 @@
// double-trigger a shared BE producer/teardown.
retryChunkResponses = [];
for (let idx = 0; idx < retryChunks.length; idx++) {
const chunk = retryChunks[idx]!;

Check warning on line 9220 in src/commands/test.ts

View workflow job for this annotation

GitHub Actions / ESLint Security (changed files)

Generic Object Injection Sink
// [P2] Bound the derived key to ≤256 chars. Caller-supplied keys may
// be up to 256 chars; appending the suffix could exceed the server
// limit and cause every retry to be rejected. Truncate the base key
Expand Down
Loading