Skip to content

[Hackathon] fix(test): interrupted delete-batch drops already-acknowledged deletion results #661

Closed as not planned

Description

@JerryNee

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:

  1. The first DELETE returns a successful { testId, deletedAt } response.
  2. The second DELETE reaches the mock, but its response remains pending.
  3. The parent process sends SIGINT or SIGTERM once that second request arrives.
  4. 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 --all selection 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:

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.js

Exit 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

  1. ruili-testsprite commented on Oct 31, 2026

    @ruili-testsprite
    Contributor

    Thanks @JerryNee, the output is lost as you describe, but nothing is lost on the server: Ctrl-C stops the batch, and test list shows which tests are still there. We're not adding partial-result reporting for an interrupted delete. Closing as not planned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    hackathonCLI hackathon submissions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions