Files
gstack/test/review-dispatch-plan-timeout.test.ts
T
Garry Tanandcapy-ai[bot] 1bdb1644d8 Stop /ship stalling on branches that merged in thousands of data files (GST-1855) (#4149)
* Stop /ship stalling on branches that merged in thousands of data files (GST-1855)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Still spot scripts in data files when git grep is set to fixed strings

Review-Finding: p1/red-team#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Document that a review plan run by itself also stops at its time limit

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-08 01:46:06 +00:00

63 lines
5.2 KiB
TypeScript

/**
* gstack-review-dispatch pass stops with a REVIEW_PLAN_STOPPED line when gstack-review-plan runs past its limit (GST-1442):
* on harness-evals #279 the plan took about 214 s against the pass's 180 s, and the pass was killed with no output at all.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const DISPATCH = path.join(import.meta.dir, '..', 'bin', 'gstack-review-dispatch');
const dirs: string[] = [];
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
describe('pass names the plan step and its limit when the plan does not finish (GST-1442)', () => {
// Value: protects=a plan past the pass's limit ends in a line naming the step, the reason and the limit, with no manifest;
// fails_when=the pass treats the killed plan like any failed plan and exits 1 with empty output (the #279 silent kill);
// why_new=GST-1442; seam=gstack-review-dispatch pass with GSTACK_REVIEW_PLAN_TIMEOUT_MS=1; no_claim=the 180 s default
test('a plan that runs past the limit prints REVIEW_PLAN_STOPPED with reason=timeout and writes no manifest', () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rd-plan-timeout-')));
dirs.push(root);
const work = path.join(root, 'work');
const env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
const sh = (cwd: string, c: string) => { const r = spawnSync('bash', ['-c', c], { cwd, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(`${c}: ${r.stderr}`); };
sh(root, 'git init -q --bare -b main origin.git && git clone -q origin.git work 2>/dev/null');
sh(work, "mkdir src && echo 'export const a = 1;' > src/a.ts && git add -A && git commit -qm base && git push -q origin main && git checkout -qb feat && echo 'export const b = 2;' >> src/a.ts && git commit -qam feat");
const out = path.join(root, 'run');
const r = spawnSync(DISPATCH, ['pass', '--base', 'main', '--out', out, '--pass', 'timeout-p1'], { cwd: work, env: { ...env, GSTACK_REVIEW_PLAN_TIMEOUT_MS: '1' }, encoding: 'utf8', timeout: 120_000 });
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stderr).toContain('gstack-review-dispatch pass: gstack-review-plan did not finish inside the pass');
expect(r.stderr).toContain('step="gstack-review-plan --base main" reason=timeout timeout_ms=1 signal=SIGTERM');
expect(r.stderr.trimEnd().endsWith('(REVIEW_PLAN_STOPPED)')).toBe(true);
expect(r.stdout).not.toContain('MANIFEST:');
expect(fs.existsSync(path.join(out, 'pass-manifest.json'))).toBe(false);
}, 180_000);
});
describe('gstack-review-plan run by itself stops at its own limit (GST-1855)', () => {
// Value: protects=a plan /ship runs directly ends at the limit in a named line with no plan, instead of holding the run for
// 25 to 58 minutes as harness-evals #320's did; fails_when=a standalone plan ignores GSTACK_REVIEW_PLAN_TIMEOUT_MS again;
// seam=gstack-review-plan with GSTACK_REVIEW_PLAN_TIMEOUT_MS=1; no_claim=the 180 s default
test('a plan past the limit prints REVIEW_PLAN_STOPPED with reason=timeout and no plan lines', () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rp-plan-timeout-')));
dirs.push(root);
const work = path.join(root, 'work');
const env = { ...process.env, GSTACK_STATE_ROOT: path.join(root, 'state'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig') };
fs.writeFileSync(path.join(root, 'gitconfig'), '[commit]\n\tgpgsign = false\n');
const sh = (cwd: string, c: string) => { const r = spawnSync('bash', ['-c', c], { cwd, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(`${c}: ${r.stderr}`); };
sh(root, 'git init -q --bare -b main origin.git && git clone -q origin.git work 2>/dev/null');
sh(work, "mkdir src && echo 'export const a = 1;' > src/a.ts && git add -A && git commit -qm base && git push -q origin main && git checkout -qb feat && echo 'export const b = 2;' >> src/a.ts && git commit -qam feat");
const PLAN = path.join(import.meta.dir, '..', 'bin', 'gstack-review-plan');
const r = spawnSync(PLAN, ['--base', 'main'], { cwd: work, env: { ...env, GSTACK_REVIEW_PLAN_TIMEOUT_MS: '1' }, encoding: 'utf8', timeout: 120_000 });
expect(r.status, r.stdout + r.stderr).toBe(1);
expect(r.stderr).toContain('step="gstack-review-plan --base main" reason=timeout timeout_ms=1 signal=SIGTERM');
expect(r.stderr.trimEnd().endsWith('(REVIEW_PLAN_STOPPED)')).toBe(true);
expect(r.stdout).not.toContain('REVIEW_PLAN:');
const ok = spawnSync(PLAN, ['--base', 'main'], { cwd: work, env, encoding: 'utf8', timeout: 120_000 });
expect(ok.status, ok.stderr).toBe(0);
expect(ok.stdout).toContain('REVIEW_PLAN:');
}, 180_000);
});