Repository navigation
[Hackathon] fix(test): interrupted delete-batch drops already-acknowledged deletion results #661
Description
Verified against (commit SHA or released version)
a18d37c8183c1cc39190cfdf2a7a82897e4e113c (current main, CLI 0.15.0), built locally on macOS arm64 / Node v22.16.0. Rechecked main on October 24.
Discord Username / User ID
jerry_nee (display name: JerryNee)
What does this improvement do?
Preserve the partial result of test delete-batch when SIGINT/SIGTERM interrupts a later deletion.
I reproduced this with the built CLI and a loopback mock API:
- The first DELETE returns a successful
{ testId, deletedAt }response. - The second DELETE reaches the mock, but its response remains pending.
- The parent process sends SIGINT or SIGTERM once that second request arrives.
- The CLI exits 130/143, with zero bytes on stdout. Neither stdout nor stderr identifies the already-acknowledged deletion, the interrupted request, or the never-started third item.
The signal itself is handled correctly; the problem is losing the receipt the CLI already held. This also happens with --all --project proj_fixture --status passed: stderr says it resolved three tests, but their identities are lost.
For an agent or cleanup script, the distinction matters: one operation was confirmed, one has an unknown outcome, and one was never sent. Re-running --all is not an audit of that original selection, since it enumerates the project again. This does not claim that Ctrl-C causes an extra deletion, that the interrupted request definitely failed, or that server-side records were lost.
Details / implementation notes
Reproduction results (eight real CLI subprocesses):
| Case | Exit | stdout |
|---|---|---|
| Explicit IDs, all succeed | 0 | Three results, deleted=3 |
| Explicit IDs, last request returns a validation error | 1 | Three results, deleted=2 / failed=1 |
| Explicit IDs, SIGINT during second DELETE (JSON) | 130 | Empty |
| Explicit IDs, SIGTERM during second DELETE (JSON) | 143 | Empty |
| Explicit IDs, SIGINT during second DELETE (text) | 130 | Empty |
--all --status passed, all succeed |
0 | Three results, deleted=3 |
--all --status passed, SIGINT during second DELETE |
130 | Empty |
--all --status passed, SIGTERM during second DELETE |
143 | Empty |
In every interrupted case the first reply was consumed (the second request could not start otherwise), exactly two DELETE requests arrived, and the third was never sent. JSON stderr contains the generic INTERRUPTED envelope; the preceding hint refers to running tests rather than the deletion results.
Source: runDeleteBatch at this SHA. Successful responses accumulate in results, but the per-item InterruptError catch rethrows before the summary is constructed or printed.
Proposed scope, subject to triage:
- Only
test delete-batch, including its--allselection path. - On the first graceful interrupt, print one partial receipt in JSON/text retaining acknowledged results and identifying the interrupted/unknown item separately from never-attempted IDs.
- Preserve 130/143. Do not count an unanswered request as deleted, failed, or not attempted; do not issue further deletes while reporting.
- Keep completed-command output and normal error precedence unchanged. No persistent journal, automatic replay, new endpoint, or change to single-item deletion / cancellation.
- Cover interruption after prior success and prior error, interruption before any acknowledged result, and both output modes. The exact additive partial-output fields should be agreed before implementation. Also account for the existing signal handler's armed/unarmed lifecycle when testing between requests.
Duplicate/overlap check:
- [Hackathon] fix(test): multi-id
test cancelandtest delete-batchexit 1 on a rejected API key (or too-old CLI) instead of 3 / 14 #373 / PR fix(test): exit 3/14 from multi-id cancel and delete-batch on a rejected key or too-old CLI #595 handle auth/client-too-old exit-code precedence in this same function. I read the current diff (772e5fd): it leaves the immediateInterruptErrorrethrow unchanged. This proposal does not change that PR's normal 1/3/14 behavior and can be rebased around it. - [Hackathon]
test delete-batch --all --status ""permanently deletes every test in the project (andtest rerun --all --status ""reruns every test) #498 / PR fix(test): refuse an empty --status instead of deleting or rerunning every test #579 reject empty status filters before deletion; the reproduction uses explicit IDs or the validpassedfilter. - [Hackathon] fix(test): retain new run receipts when auto-resume metadata is interrupted #533 / PR fix(test): retain receipts during conflict metadata interrupts #565 preserve newly started run receipts during auto-resume metadata reads. Its accepted scope is explicitly
test run --all, not deletion. - [Hackathon] Graceful Ctrl+C (SIGINT) handling #75 established graceful interruption for waiting on runs. This report uses that existing signal handling rather than replacing it.
- Searched open and closed issues/PRs for delete-batch, deletion interruption, and partial receipts.
npm run build passed; the existing test.quickwins.spec.ts baseline passed 31/31. The standalone diagnostic below passed all eight fixture/regression checks. Full-suite, Windows, and live-service behavior were not tested. Everything uses a temporary home, a fake key, disabled telemetry/update checks, and a loopback API. No hosted tests or credits are involved.
Standalone reproducer (Node builtins only)
Save as repro.mjs and run against a built checkout:
node repro.mjs ./dist/index.jsExit 0 means the expected current regression and the controls were reproduced; assertions fail if behavior differs. No production fix is included.
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
// Usage: node repro.mjs ./dist/index.js
// Only a loopback mock is used. No real account or TestSprite API is contacted.
const cli = resolve(process.argv[2] ?? './dist/index.js');
const home = await mkdtemp(join(tmpdir(), 'testsprite-delete-interrupt-'));
const ids = ['test_acknowledged', 'test_interrupted', 'test_not_started'];
async function runCase({ mode = 'json', signal, failLast = false, scope = 'ids' }) {
const requests = [];
const acknowledged = [];
let listings = 0;
let child;
const server = createServer((req, res) => {
const path = new URL(req.url, 'http://127.0.0.1').pathname;
const id = path.split('/').at(-1);
if (scope === 'all' && req.method === 'GET' && path === '/api/cli/v1/tests') {
listings += 1;
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ items: ids.map(testId => ({
id: testId, projectId: 'proj_fixture', name: testId, type: 'frontend',
status: 'passed', createdFrom: 'cli', updatedAt: '2026-10-24T00:00:00.000Z',
})), nextToken: null }));
return;
}
if (req.method !== 'DELETE' || !ids.includes(id)) {
res.writeHead(400, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: { code: 'VALIDATION_ERROR', message: path } }));
return;
}
requests.push(id);
req.resume();
req.on('end', () => {
// A second request proves the CLI has consumed the first success reply.
// Leave this response pending: its eventual server outcome is unknown.
if (signal && id === ids[1]) {
child.kill(signal);
return;
}
res.writeHead(failLast && id === ids[2] ? 400 : 200, {
'content-type': 'application/json',
});
if (failLast && id === ids[2]) {
res.end(JSON.stringify({ error: {
code: 'VALIDATION_ERROR', message: 'Mock refusal', nextAction: '',
requestId: 'mock-refusal', details: {},
} }));
} else {
acknowledged.push(id);
res.end(JSON.stringify({ testId: id, deletedAt: '2026-10-24T00:00:00.000Z' }));
}
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const endpoint = `http://127.0.0.1:${server.address().port}`;
let stdout = '';
let stderr = '';
let timedOut = false;
try {
child = spawn(process.execPath, [
cli, '--endpoint-url', endpoint, 'test', 'delete-batch',
...(scope === 'all' ? ['--all', '--project', 'proj_fixture', '--status', 'passed'] : ids),
'--confirm', '--output', mode,
], {
cwd: home,
env: {
PATH: process.env.PATH,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(home, 'config'),
XDG_CACHE_HOME: join(home, 'cache'),
TESTSPRITE_API_KEY: 'sk-user-local-reproduction-not-a-real-key',
TESTSPRITE_NO_TELEMETRY: '1',
DO_NOT_TRACK: '1',
TESTSPRITE_NO_UPDATE_NOTIFIER: '1',
CI: 'true',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
const watchdog = setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, 10_000);
let exitCode;
let exitSignal;
try {
[exitCode, exitSignal] = await once(child, 'close');
} finally {
clearTimeout(watchdog);
}
assert.equal(timedOut, false, 'CLI did not exit within 10 seconds');
assert.equal(exitSignal, null, 'expected a handled signal, not an OS kill');
assert.equal(listings, scope === 'all' ? 1 : 0);
if (signal) {
assert.equal(exitCode, signal === 'SIGINT' ? 130 : 143);
assert.deepEqual(requests, ids.slice(0, 2));
assert.deepEqual(acknowledged, ids.slice(0, 1));
assert.equal(stdout, '', 'reproduction no longer holds: partial output was emitted');
assert.equal(stderr.includes(ids[0]), false);
assert.equal(stderr.includes(ids[1]), false);
assert.equal(stderr.includes(ids[2]), false);
} else {
assert.equal(exitCode, failLast ? 1 : 0);
assert.deepEqual(requests, ids);
const payload = JSON.parse(stdout);
assert.equal(payload.results.length, 3);
assert.equal(payload.summary.deleted, failLast ? 2 : 3);
assert.equal(payload.summary.failed, failLast ? 1 : 0);
}
console.log(JSON.stringify({
mode, scope, signal: signal ?? 'none', failLast, exitCode, requests, acknowledged,
stdoutBytes: Buffer.byteLength(stdout),
...(signal ? { stderr: stderr.trim() } : {}),
}));
} finally {
server.closeAllConnections();
await new Promise((done, reject) => server.close(error => error ? reject(error) : done()));
}
}
try {
await runCase({});
await runCase({ failLast: true });
await runCase({ signal: 'SIGINT' });
await runCase({ signal: 'SIGTERM' });
await runCase({ mode: 'text', signal: 'SIGINT' });
await runCase({ scope: 'all' });
await runCase({ scope: 'all', signal: 'SIGINT' });
await runCase({ scope: 'all', signal: 'SIGTERM' });
} finally {
await rm(home, { recursive: true, force: true });
}I would like to implement this after acceptance and assignment. Submitted using the Hackathon Improvement fields; if the API does not retain the template labels, please add hackathon / needs-triage.
AI assistance: Codex helped inspect source, write and execute the local reproduction, check related reports, and draft this issue.
Confirmations
- I have searched existing issues and this is not a duplicate.
- I have provided my Discord identity above for reward coordination.
Activity
ruili-testsprite commentedon Oct 31, 2026
Thanks @JerryNee, the output is lost as you describe, but nothing is lost on the server: Ctrl-C stops the batch, and
test listshows which tests are still there. We're not adding partial-result reporting for an interrupted delete. Closing as not planned.