feat(flaky): gstack-flaky run reruns a failed JUnit test once and ledgers the flake (GSTA-158)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-29 16:24:41 +00:00
1 parent 319ef500ca
commit 5d3ccb69c3
10 files changed
+1458

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-flaky (GSTA-158): rerun a failed test once and record the flake, for
* any runner that writes JUnit (Playwright, bun, jest, pytest, vitest). CREW
* rule 16 for every repository: "you can just run the failed test again and
* note that it is flaky". The rules live in lib/flaky-run.ts; docs/flaky.md is
* the how-to and the error-code table.
*/
import * as fs from 'fs';
import { DEFAULT_MAX_RERUNS, appendLedger, ledgerPath, renderResult, runFlaky, stepSummary } from '../lib/flaky-run';
import { JunitError, readJunit, type JunitRunner } from '../lib/junit';
const USAGE = `Usage:
gstack-flaky run --junit <file> [--runner playwright|bun|generic] [--max-reruns <n>]
[--rerun-arg <template>]... [--ledger <file>] -- <test command...>
gstack-flaky ids --junit <file> [--runner playwright|bun|generic]
gstack-flaky --help
run: runs the command (an argv, no shell), reads the JUnit report it writes at
--junit, and reruns each failed test once when the runner made no retries and
at most --max-reruns (${DEFAULT_MAX_RERUNS}) tests failed. A rerun appends the runner's
selection to the same command: playwright adds <file> --grep <title> --project
<p>; bun adds <file> --test-name-pattern <title>; generic appends each
--rerun-arg with {file}, {title} and {pattern} substituted. A test that passes
on rerun is a flaky pass: green, and one line on the ledger (--ledger, else
GSTACK_FLAKE_LEDGER, else <state root>/flake-ledger-junit.jsonl). The command
runs with PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1 so Playwright's own retries are
reported.
ids: prints the id of every test in a report (<file>::<project>::<title>).
Output: one FLAKY: line per test that failed at least once, then
FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>
Exit: 0 pass (flaky passes included), 1 a test failed, 2 usage,
3 no usable report or a crash (FLAKY_<CODE> and a fix: line on stderr).
`;
const FIX: Record<string, string> = {
FLAKY_NO_RESULTS: 'make the command write JUnit to the --junit path (playwright: PLAYWRIGHT_JUNIT_OUTPUT_NAME=<file> with --reporter=junit; bun: --reporter=junit --reporter-outfile=<file>)',
FLAKY_JUNIT_INVALID: 'the report is not well-formed XML; check that one process writes it and nothing else appends to it',
FLAKY_RETRIES_HIDDEN: 'set PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1 where the report is written',
FLAKY_COMMAND_FAILED: 'read the command output above: the suite crashed or a non-test step failed; that is a real red',
FLAKY_COMMAND_NOT_STARTED: 'check the command after -- (it runs without a shell; wrap it as bash -c "<script>" when it needs one)',
FLAKY_RERUN_UNSUPPORTED: 'pass --runner playwright|bun, or --rerun-arg templates that select one test (for example --rerun-arg {file} --rerun-arg -k --rerun-arg {title})',
};
function die(msg: string, code = 2): never {
process.stderr.write(`gstack-flaky: ${msg}\n`);
process.exit(code);
}
function coded(e: JunitError): never {
process.stderr.write(`gstack-flaky: ${e.message} (${e.code})\nfix: ${FIX[e.code] ?? 'see docs/flaky.md'}\n`);
process.exit(3);
}
function parse(argv: string[]) {
const o: { junit?: string; runner?: JunitRunner; maxReruns: number; rerunArgs: string[]; ledger?: string; command: string[] } = { maxReruns: DEFAULT_MAX_RERUNS, rerunArgs: [], command: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
const val = () => { const v = argv[++i]; if (v === undefined) die(`${a} needs a value`); return v; };
if (a === '--') { o.command = argv.slice(i + 1); break; }
if (a === '--junit') o.junit = val();
else if (a === '--runner') {
const r = val();
if (r !== 'playwright' && r !== 'bun' && r !== 'generic') die(`--runner must be playwright, bun or generic, got ${JSON.stringify(r)}`);
o.runner = r;
} else if (a === '--max-reruns') {
const n = Number(val());
if (!Number.isInteger(n) || n < 0) die('--max-reruns must be a whole number');
o.maxReruns = n;
} else if (a === '--rerun-arg') o.rerunArgs.push(val());
else if (a === '--ledger') o.ledger = val();
else die(`unknown option ${a}\n${USAGE}`);
}
return o;
}
const [sub, ...rest] = process.argv.slice(2);
if (!sub || sub === '--help' || sub === '-h') { process.stdout.write(USAGE); process.exit(sub ? 0 : 2); }
if (sub === 'ids') {
const o = parse(rest);
if (!o.junit) die('ids needs --junit <file>');
try {
for (const t of readJunit(o.junit, o.runner).tests) process.stdout.write(`${t.id}\n`);
} catch (e) { if (e instanceof JunitError) coded(e); throw e; }
process.exit(0);
}
if (sub !== 'run') die(`unknown command ${sub}\n${USAGE}`);
const o = parse(rest);
if (!o.junit) die('run needs --junit <file>');
if (!o.command.length) die('run needs the test command after --');
let result;
try {
result = runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns, rerunArgs: o.rerunArgs });
} catch (e) { if (e instanceof JunitError) coded(e); throw e; }
const ledger = ledgerPath(o.ledger);
appendLedger(result.ledger, ledger, s => process.stderr.write(`gstack-flaky: ${s}\n`));
for (const l of renderResult(result, ledger)) process.stdout.write(`${l}\n`);
const summary = process.env.GITHUB_STEP_SUMMARY && stepSummary(result);
if (summary) { try { fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY!, summary); } catch { /* a summary is a convenience, never the verdict */ } }
if (process.env.GITHUB_ACTIONS === 'true') {
for (const f of result.outcomes.filter(x => x.status === 'flaky-pass')) process.stdout.write(`::warning title=flaky test::${f.test.id} passed on attempt ${f.attempts.length}\n`);
}
if (result.code) {
process.stderr.write(`gstack-flaky: ${result.message} (${result.code})\nfix: ${FIX[result.code] ?? 'see docs/flaky.md'}\n`);
}
process.exit(result.exitCode);
+5
View File
@@ -1409,6 +1409,11 @@ prints a run's ledgers as a table. A flaky file is fixed at source; the retry
never masks it. `bun run eval:pass-rates` lists only the local ledger
(`flakeLedgerPath()`, overridden by `GSTACK_FLAKE_LEDGER`).
Other repositories get the same rule per test with `bin/gstack-flaky run`
([docs/flaky.md](flaky.md)): it reads a JUnit report from any runner, reruns
each failed test once, and writes `flaky-pass` and `rerun-fail` lines to its
own ledger (`flake-ledger-junit.jsonl`).
The aggregate job of `free-tests.yml` downloads every `flake-ledger-*` artifact
of the run and appends `bun run scripts/test-health-report.ts flake-summary <dir>`
to its job summary. The Windows lane uploads `flake-ledger-windows-<shard>`.
+91
View File
@@ -0,0 +1,91 @@
# Flaky tests: rerun once and record it (`gstack-flaky`)
`gstack-flaky run` runs your test command, reads the JUnit report it writes,
and reruns each failed test once. A test that passes on its rerun is a
**flaky pass**: the job stays green and the flake is recorded on a ledger, so
the daily flaky pass can fix it at the source. A test that fails twice is red.
This is CREW rule 16 ("run the failed test again and note that it is flaky")
for any runner that writes JUnit: Playwright, `bun test`, jest, vitest and
pytest. gstack's own free suite keeps its file-level retry
(`GSTACK_FREE_RETRY_FLAKY`, [Flake ledger](TESTING_INTERNALS.md#flake-ledger)).
## Get started in GitHub Actions
```yaml
- uses: oven-sh/setup-bun@v2
with: { bun-version: 1.4.4 }
- name: Install gstack-flaky (pinned)
env: { GSTACK_REF: <a gstack commit sha you have tested> }
run: |
git clone --quiet --filter=blob:none https://github.com/garrytan/gstack.git "$RUNNER_TEMP/gstack"
git -C "$RUNNER_TEMP/gstack" checkout --quiet "$GSTACK_REF"
- name: E2E
env:
PLAYWRIGHT_JUNIT_OUTPUT_NAME: results/junit.xml
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
run: "$RUNNER_TEMP/gstack/bin/gstack-flaky" run --junit results/junit.xml -- npx playwright test --reporter=junit
- if: always()
uses: actions/upload-artifact@v4
with: { name: flake-ledger-e2e, path: "${{ runner.temp }}/flake-ledger.jsonl", if-no-files-found: ignore, retention-days: 90 }
```
Pin a gstack commit you have tested; the bin needs only Bun and git. In Actions it also writes a step-summary table of the flaky passes and
one `::warning` annotation per flaky test, so they are visible on the run
before anyone reads the ledger.
## Runners
| Runner | Command after `--` | Notes |
|---|---|---|
| Playwright | `npx playwright test --reporter=junit` with `PLAYWRIGHT_JUNIT_OUTPUT_NAME=<file>` | `gstack-flaky` sets `PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1` so Playwright's own `retries` are reported. With retries configured, those attempts stand and nothing is rerun again. A rerun adds `<file> --grep <title> --project <project>`. |
| bun | `bun test --reporter=junit --reporter-outfile=<file>` | A rerun adds `<file> --test-name-pattern <title>`. |
| jest, vitest, pytest (`--runner generic`) | your command, writing JUnit | Give the rerun selection with `--rerun-arg` templates, for example pytest: `--rerun-arg {file} --rerun-arg -k --rerun-arg {title}`. `{file}`, `{title}` and `{pattern}` (the anchored, regex-escaped title) each fill one argument. |
The command after `--` runs without a shell. Wrap it as
`bash -c '<script>'` when it needs one. A rerun runs the same command with the
selection arguments appended, each one argument, so a title with shell or
regex characters selects only itself.
## Rules
- **One rerun, small failures only.** A failed test gets one rerun when the
runner made no retries and at most `--max-reruns` (3) tests failed. More
failures than that is a real red, and nothing is rerun.
- **Never green by accident.** The report is deleted before every attempt, and
a report that attempt did not write counts as missing. A missing, empty,
zero-test or malformed report fails, as does a non-zero exit with no failed
test (a crash or a non-test step). Only the targeted test is read back from
a rerun.
- **Ids.** A test is `<file>::<project>::<describe > title>`, with the project
empty outside Playwright, so a test in two browser projects is two tests.
`gstack-flaky ids --junit <file>` prints them.
- **Ledger.** One JSON line per flaky pass (`kind: flaky-pass`) and per test
that failed its rerun (`rerun-fail`), with the tree, commit, branch, run URL,
attempts and the first lines of the error. Failure text goes through gstack's
redaction engine first; when the scan cannot run, the excerpt is withheld.
The path is `--ledger`, else `GSTACK_FLAKE_LEDGER`, else
`<state root>/flake-ledger-junit.jsonl`, and `FLAKY_RESULT` names it. A
ledger write error is a warning and never changes the verdict.
## Output and exit codes
One `FLAKY: <flaky-pass|fail|rerun-fail> <id> attempts=<…> (<reason>)` line per
test that failed at least once, then
`FLAKY_RESULT: tests=<n> failed=<n> flaky=<n> reruns=<n> command_exit=<n> verdict=pass|fail ledger=<file|none>`.
| Exit | Meaning |
|---|---|
| 0 | Every test passed, flaky passes included |
| 1 | A test failed (after its rerun, or with no rerun) |
| 2 | Usage error |
| 3 | No usable report, or the command crashed (a code below on stderr) |
| Code | Cause | Fix |
|---|---|---|
| `FLAKY_NO_RESULTS` | No report at `--junit`, an empty one, or one with no testcase | Make the command write JUnit to that path |
| `FLAKY_JUNIT_INVALID` | The report is not well-formed XML | Make sure one process writes it and nothing appends to it |
| `FLAKY_RETRIES_HIDDEN` | Playwright retried but its report omits the retries | Set `PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1` where the report is written |
| `FLAKY_COMMAND_FAILED` | Non-zero exit with no failed test | Read the command output: a real red |
| `FLAKY_COMMAND_NOT_STARTED` | The command could not be started | Check the argv after `--` |
| `FLAKY_RERUN_UNSUPPORTED` | A generic runner without `--rerun-arg` | Add `--runner` or `--rerun-arg` templates |
| `FLAKY_LEDGER_WRITE_FAILED` | The ledger could not be appended (warning) | Check the path; the verdict stands |
+226
View File
@@ -0,0 +1,226 @@
/**
* gstack-flaky run (GSTA-158): run a test command, read its JUnit, rerun each
* failed test once, and record flaky passes on a ledger. This is CREW rule 16
* ("run the failed test again and note that it is flaky") for any runner that
* writes JUnit, the per-test form of the free suite's GSTACK_FREE_RETRY_FLAKY.
*
* Rules (docs/flaky.md):
* - A failed test gets one rerun when the runner made no retries of its own and
* at most maxReruns tests failed; more failures are a real red and nothing
* is rerun. A rerun that passes makes the test a flaky pass (green, ledgered).
* - When the runner retried (Playwright `retries` with retry-aware JUnit), its
* attempts stand and nothing is rerun again.
* - The test command is an argv run without a shell. A rerun appends the
* adapter's selection arguments, each one argv element, and only the
* targeted test is read back from the rerun's report, and a rerun counts as
* a pass only when its command also exited 0.
* - The JUnit file is removed before every attempt; a report that attempt did
* not write is missing. Missing, unreadable or empty JUnit is never green, and
* a non-zero exit with no failed test is a crash, never green.
*/
import * as fs from 'fs';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { finalAttempt, JunitError, readJunit, type JunitReport, type JunitRunner, type JunitTest } from './junit';
import { redactFindingSpans } from './redact-engine';
import { resolveStateRoot } from './state-root';
export type LedgerKind = 'flaky-pass' | 'rerun-fail';
export interface LedgerLine {
ts: string;
runner: 'junit';
kind: LedgerKind;
id: string;
file: string;
/** 'runner' when the runner's own retries decided, 'gstack-flaky' for our rerun. */
rerun_by: 'runner' | 'gstack-flaky';
attempts: string[];
tree?: string;
git_sha?: string;
branch?: string;
run_url?: string;
excerpt: string[];
}
export interface RunOptions {
command: string[];
junit: string;
runner?: JunitRunner;
maxReruns: number;
/** Generic adapter: argv templates appended on rerun, {file} {title} {pattern} substituted. */
rerunArgs?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
spawn?: (argv: string[], env: NodeJS.ProcessEnv, cwd: string) => number;
now?: () => Date;
}
export interface TestOutcome {
test: JunitTest;
status: 'flaky-pass' | 'fail' | 'rerun-fail';
attempts: string[];
reason?: string;
}
export interface RunResult {
exitCode: 0 | 1 | 3;
code?: string;
message?: string;
tests: number;
commandExit: number;
outcomes: TestOutcome[];
reruns: number;
ledger: LedgerLine[];
}
export const DEFAULT_MAX_RERUNS = 3;
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Selection arguments a rerun appends for one test, each one argv element. */
export function rerunSelection(runner: JunitRunner, t: JunitTest, generic: string[] = []): string[] {
if (runner === 'playwright') {
const args = [escapeRegex(t.file), '--grep', `(?:^|\\s)${escapeRegex(t.path.join(' '))}(?:\\s|$)`];
return t.project ? [...args, '--project', t.project] : args;
}
if (runner === 'bun') return [t.file, '--test-name-pattern', `^${escapeRegex(t.path.join(' '))}$`];
if (!generic.length) throw new JunitError('FLAKY_RERUN_UNSUPPORTED', 'a generic JUnit runner needs --rerun-arg templates to select one test');
const title = t.path.join(' ');
const pattern = `^${escapeRegex(title)}$`;
return generic.map(a => a.replaceAll('{file}', t.file).replaceAll('{title}', title).replaceAll('{pattern}', pattern));
}
function defaultSpawn(argv: string[], env: NodeJS.ProcessEnv, cwd: string): number {
const r = spawnSync(argv[0]!, argv.slice(1), { stdio: 'inherit', env, cwd });
if (r.error) throw new JunitError('FLAKY_COMMAND_NOT_STARTED', `could not start ${argv[0]}: ${r.error.message}`);
return r.status ?? (r.signal ? 128 : 1);
}
function git(args: string[], cwd: string): string | undefined {
const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 10_000 });
return r.status === 0 ? r.stdout.trim() || undefined : undefined;
}
export function provenance(cwd: string, env: NodeJS.ProcessEnv): Pick<LedgerLine, 'tree' | 'git_sha' | 'branch' | 'run_url'> {
const runUrl = env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}${env.GITHUB_RUN_ATTEMPT ? `/attempts/${env.GITHUB_RUN_ATTEMPT}` : ''}`
: undefined;
return {
tree: git(['rev-parse', 'HEAD^{tree}'], cwd),
git_sha: git(['rev-parse', 'HEAD'], cwd),
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
run_url: runUrl,
};
}
/** Failure text leaves this process only through the redaction engine; when it cannot scan, nothing leaves. */
export function safeExcerpt(lines: string[]): string[] {
if (!lines.length) return [];
const redacted = redactFindingSpans(lines.join('\n'));
return redacted === null ? ['[excerpt withheld: the redaction scan could not run]'] : redacted.split('\n');
}
/** One attempt: remove the report, run, and read a report this attempt wrote. */
function attempt(opts: RunOptions, argv: string[], cwd: string, env: NodeJS.ProcessEnv): { exit: number; report?: JunitReport; error?: JunitError } {
const junit = path.resolve(cwd, opts.junit);
fs.rmSync(junit, { force: true });
const exit = (opts.spawn ?? defaultSpawn)(argv, env, cwd);
if (!fs.existsSync(junit)) return { exit, error: new JunitError('FLAKY_NO_RESULTS', `the command wrote no JUnit report at ${opts.junit} (exit ${exit})`) };
try { return { exit, report: readJunit(junit, opts.runner) }; } catch (e) {
if (e instanceof JunitError) return { exit, error: e };
throw e;
}
}
export function runFlaky(opts: RunOptions): RunResult {
const cwd = opts.cwd ?? process.cwd();
const env = { ...(opts.env ?? process.env), PLAYWRIGHT_JUNIT_INCLUDE_RETRIES: '1' };
const now = opts.now ?? (() => new Date());
const empty = { tests: 0, outcomes: [], reruns: 0, ledger: [] };
const first = attempt(opts, opts.command, cwd, env);
if (!first.report) return { exitCode: 3, code: first.error!.code, message: first.error!.message, commandExit: first.exit, ...empty };
const report = first.report;
if (report.retriesHidden) {
return { exitCode: 3, code: 'FLAKY_RETRIES_HIDDEN', commandExit: first.exit, ...empty, tests: report.tests.length,
message: 'Playwright retried but its JUnit omits the retries; set PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1 (gstack-flaky run sets it for the command it starts)' };
}
const failed = report.tests.filter(t => finalAttempt(t) === 'fail');
const runnerFlaky = report.tests.filter(t => finalAttempt(t) === 'pass' && t.attempts.includes('fail'));
if (first.exit !== 0 && failed.length === 0) {
return { exitCode: 3, code: 'FLAKY_COMMAND_FAILED', commandExit: first.exit, ...empty, tests: report.tests.length,
message: `the command exited ${first.exit} but its JUnit reports no failed test; a crash or a non-test step failed` };
}
const prov = provenance(cwd, env);
const line = (t: JunitTest, kind: LedgerKind, by: LedgerLine['rerun_by'], attempts: string[]): LedgerLine =>
({ ts: now().toISOString(), runner: 'junit', kind, id: t.id, file: t.file, rerun_by: by, attempts, ...prov, excerpt: safeExcerpt(t.excerpt) });
const ledger: LedgerLine[] = runnerFlaky.map(t => line(t, 'flaky-pass', 'runner', t.attempts));
const outcomes: TestOutcome[] = runnerFlaky.map(t => ({ test: t, status: 'flaky-pass' as const, attempts: t.attempts }));
let reruns = 0;
const rerunnable = failed.length <= opts.maxReruns;
for (const t of failed) {
if (t.executions > 1) { outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `it ran ${t.executions} times in this report and one failed; not rerun` }); continue; }
if (t.attempts.length > 1) { outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `the runner already retried (${t.attempts.length} attempts)` }); continue; }
if (!rerunnable) { outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `${failed.length} tests failed, more than --max-reruns ${opts.maxReruns}; not rerun` }); continue; }
const argv = [...opts.command, ...rerunSelection(report.runner, t, opts.rerunArgs)];
reruns++;
const again = attempt(opts, argv, cwd, env);
const match = again.report?.tests.find(x => x.id === t.id);
if (!match) {
outcomes.push({ test: t, status: 'rerun-fail', attempts: [...t.attempts, 'fail'], reason: again.error ? `rerun: ${again.error.message}` : 'rerun: the report does not contain this test' });
ledger.push(line(t, 'rerun-fail', 'gstack-flaky', [...t.attempts, 'fail']));
continue;
}
const attempts = [...t.attempts, ...match.attempts];
if (again.exit !== 0) {
outcomes.push({ test: t, status: 'rerun-fail', attempts, reason: `rerun: the command exited ${again.exit}` });
ledger.push(line(t, 'rerun-fail', 'gstack-flaky', attempts));
} else if (finalAttempt(match) === 'pass') {
outcomes.push({ test: t, status: 'flaky-pass', attempts });
ledger.push(line(t, 'flaky-pass', 'gstack-flaky', attempts));
} else {
outcomes.push({ test: t, status: 'rerun-fail', attempts });
ledger.push(line(t, 'rerun-fail', 'gstack-flaky', attempts));
}
}
const red = outcomes.some(o => o.status !== 'flaky-pass');
return { exitCode: red ? 1 : 0, tests: report.tests.length, commandExit: first.exit, outcomes, reruns, ledger };
}
export function ledgerPath(flag: string | undefined, env: NodeJS.ProcessEnv = process.env): string {
if (flag) return flag;
if (env.GSTACK_FLAKE_LEDGER) return env.GSTACK_FLAKE_LEDGER;
return path.join(resolveStateRoot(env), 'flake-ledger-junit.jsonl');
}
/** Append ledger lines; an I/O error is a warning and never changes the verdict. */
export function appendLedger(lines: LedgerLine[], file: string, warn: (s: string) => void): boolean {
if (!lines.length) return true;
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.appendFileSync(file, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
return true;
} catch (e) {
warn(`FLAKY_LEDGER_WRITE_FAILED: could not append ${file} (${(e as Error).message}); the verdict is unaffected`);
return false;
}
}
/** Lines the bin prints: one per non-passing test, then the result line. */
export function renderResult(r: RunResult, ledger: string): string[] {
const out: string[] = [];
for (const o of r.outcomes) out.push(`FLAKY: ${o.status} ${o.test.id} attempts=${o.attempts.join(',')}${o.reason ? ` (${o.reason})` : ''}`);
const flaky = r.outcomes.filter(o => o.status === 'flaky-pass').length;
const failed = r.outcomes.length - flaky;
out.push(`FLAKY_RESULT: tests=${r.tests} failed=${failed} flaky=${flaky} reruns=${r.reruns} command_exit=${r.commandExit} verdict=${r.exitCode === 0 ? 'pass' : 'fail'} ledger=${r.ledger.length ? ledger : 'none'}`);
return out;
}
/** A GitHub step summary for the flaky passes, so they are seen before any report reads the ledger. */
export function stepSummary(r: RunResult): string {
const rows = r.outcomes.filter(o => o.status === 'flaky-pass');
if (!rows.length) return '';
const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
return ['### Flaky passes (gstack-flaky)', '', '| Test | Attempts | First error |', '|---|---|---|',
...rows.map(o => `| \`${cell(o.test.id)}\` | ${o.attempts.join(', ')} | ${cell(safeExcerpt(o.test.excerpt)[0] ?? '')} |`), ''].join('\n');
}
+204
View File
@@ -0,0 +1,204 @@
/**
* JUnit XML reader for gstack-flaky (GSTA-158). One reader for every runner
* that writes JUnit: Playwright (`--reporter=junit`, retry-aware with
* PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1), `bun test --reporter=junit`, jest,
* pytest and vitest. No XML dependency: the subset JUnit uses (elements,
* attributes, text, CDATA, comments, processing instructions) is tokenized
* here.
*
* A test's id is `<file>::<project>::<title>`. The title is the describe path
* plus the test name joined by " > ", so Playwright's " › " and bun's nested
* testsuites give the same shape. The project is Playwright's project name
* (its testsuite `hostname`) and empty for every other runner, so a test
* that runs in two browser projects is two ids.
*
* Attempts: Playwright's retry-aware report lists each failed retry as
* `rerunFailure` (final result failed) or `flakyFailure` (final result
* passed). A Playwright failure whose text shows "Retry #" without those
* elements means retries ran but were not reported (`retriesHidden`). A test
* that appears more than once (repeat-each, a duplicate name) fails when any
* of its executions failed.
*/
import * as fs from 'fs';
export type JunitRunner = 'playwright' | 'bun' | 'generic';
export type Attempt = 'pass' | 'fail' | 'skip';
export interface JunitTest {
id: string;
file: string;
project: string;
/** Describe path plus test name. */
path: string[];
attempts: Attempt[];
/** Testcase elements with this id in the report (more than one: repeat-each or a duplicate name). */
executions: number;
/** First lines of the final failure, or of the first failed attempt. */
excerpt: string[];
}
export interface JunitReport {
runner: JunitRunner;
tests: JunitTest[];
retriesHidden: boolean;
}
export class JunitError extends Error {
constructor(readonly code: string, message: string) { super(message); }
}
interface XmlNode { name: string; attrs: Record<string, string>; children: XmlNode[]; text: string; parent: XmlNode | null }
const ENTITIES: Record<string, string> = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
function decode(s: string): string {
return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-z]+);/g, (m, e: string) => {
if (e[0] === '#') {
const code = e[1] === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : m;
}
return ENTITIES[e] ?? m;
});
}
const TAG = /^<(\/?)([A-Za-z_][\w:.-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/;
const ATTR = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
export function parseXml(xml: string): XmlNode {
const root: XmlNode = { name: '#root', attrs: {}, children: [], text: '', parent: null };
let cur = root;
let i = 0;
while (i < xml.length) {
const lt = xml.indexOf('<', i);
if (lt < 0) { cur.text += decode(xml.slice(i)); break; }
if (lt > i) cur.text += decode(xml.slice(i, lt));
const rest = xml.slice(lt, lt + 9);
if (rest.startsWith('<![CDATA[')) {
const end = xml.indexOf(']]>', lt);
if (end < 0) throw new JunitError('FLAKY_JUNIT_INVALID', 'unterminated CDATA section');
cur.text += xml.slice(lt + 9, end);
i = end + 3;
continue;
}
if (rest.startsWith('<!--')) {
const end = xml.indexOf('-->', lt);
if (end < 0) throw new JunitError('FLAKY_JUNIT_INVALID', 'unterminated comment');
i = end + 3;
continue;
}
if (rest.startsWith('<?') || rest.startsWith('<!')) {
const end = xml.indexOf('>', lt);
if (end < 0) throw new JunitError('FLAKY_JUNIT_INVALID', 'unterminated declaration');
i = end + 1;
continue;
}
const m = TAG.exec(xml.slice(lt));
if (!m) throw new JunitError('FLAKY_JUNIT_INVALID', `malformed tag at offset ${lt}`);
const [whole, close, name, attrText, selfClose] = m;
if (close) {
if (cur.name !== name) throw new JunitError('FLAKY_JUNIT_INVALID', `</${name}> closes <${cur.name}> at offset ${lt}`);
cur = cur.parent!;
} else {
const attrs: Record<string, string> = {};
for (const a of attrText!.matchAll(ATTR)) attrs[a[1]!] = decode(a[2] ?? a[3] ?? '');
const node: XmlNode = { name: name!, attrs, children: [], text: '', parent: cur };
cur.children.push(node);
if (!selfClose) cur = node;
}
i = lt + whole!.length;
}
if (cur !== root) throw new JunitError('FLAKY_JUNIT_INVALID', `<${cur.name}> is never closed`);
return root;
}
function allNodes(node: XmlNode, name: string, out: XmlNode[] = []): XmlNode[] {
for (const c of node.children) {
if (c.name === name) out.push(c);
allNodes(c, name, out);
}
return out;
}
export function detectRunner(root: XmlNode): JunitRunner {
const top = root.children.find(c => c.name === 'testsuites');
if (top?.attrs.name === 'bun test') return 'bun';
const cases = allNodes(root, 'testcase');
if (cases.some(c => (c.attrs.name ?? '').includes(' › '))) return 'playwright';
const suites = allNodes(root, 'testsuite');
if (suites.length && suites.every(s => 'timestamp' in s.attrs && 'hostname' in s.attrs && !('file' in s.attrs))
&& cases.every(c => c.parent?.name === 'testsuite' && c.attrs.classname === c.parent.attrs.name)) return 'playwright';
return 'generic';
}
function excerptOf(node: XmlNode | undefined): string[] {
if (!node) return [];
const body = (node.attrs.message ? node.attrs.message + '\n' : '') + node.text;
return body.replace(/\x1b\[[0-9;]*m/g, '').split('\n').map(l => l.trimEnd()).filter(l => l.trim()).slice(0, 8);
}
export const testId = (file: string, project: string, path: string[]) => `${file}::${project}::${path.join(' > ')}`;
function readCase(tc: XmlNode, runner: JunitRunner): Omit<JunitTest, 'id' | 'executions'> & { hidden: boolean } {
const suite = tc.parent!;
let file: string, project = '', path: string[];
if (runner === 'playwright') {
file = tc.attrs.classname || suite.attrs.name || '';
project = suite.attrs.hostname ?? '';
path = (tc.attrs.name ?? '').split(' › ');
} else if (runner === 'bun') {
file = tc.attrs.file ?? '';
path = [];
for (let s: XmlNode | null = suite; s && s.name === 'testsuite'; s = s.parent) {
if ('file' in s.attrs && s.attrs.name === s.attrs.file && s.parent?.name !== 'testsuite') break;
path.unshift(s.attrs.name ?? '');
}
path.push(tc.attrs.name ?? '');
} else {
file = tc.attrs.file || tc.attrs.classname || suite.attrs.file || suite.attrs.name || '';
path = [tc.attrs.name ?? ''];
}
const kids = (n: string) => tc.children.filter(c => c.name === n);
const final = kids('failure')[0] ?? kids('error')[0];
const skipped = kids('skipped').length > 0;
const flaky = [...kids('flakyFailure'), ...kids('flakyError')];
const rerun = [...kids('rerunFailure'), ...kids('rerunError')];
let attempts: Attempt[];
if (final) attempts = [...rerun.map(() => 'fail' as const), 'fail'];
else if (skipped) attempts = ['skip'];
else attempts = [...flaky.map(() => 'fail' as const), 'pass'];
const hidden = runner === 'playwright' && !!final && rerun.length === 0 && /\bRetry #\d+/.test(final.text);
return { file, project, path, attempts, excerpt: excerptOf(final ?? flaky[0]), hidden };
}
export function parseJunit(xml: string, runner?: JunitRunner): JunitReport {
const root = parseXml(xml);
const kind = runner ?? detectRunner(root);
const byId = new Map<string, JunitTest>();
let retriesHidden = false;
for (const tc of allNodes(root, 'testcase')) {
const { hidden, ...t } = readCase(tc, kind);
retriesHidden ||= hidden;
const id = testId(t.file, t.project, t.path);
const seen = byId.get(id);
if (seen) {
const failed = finalAttempt(seen) === 'fail' || t.attempts.at(-1) === 'fail';
seen.attempts = [...seen.attempts, ...t.attempts].sort((a, b) => failed ? Number(a === 'fail') - Number(b === 'fail') : 0);
seen.executions++;
if (!seen.excerpt.length) seen.excerpt = t.excerpt;
} else byId.set(id, { id, ...t, executions: 1 });
}
if (byId.size === 0) throw new JunitError('FLAKY_NO_RESULTS', 'the JUnit report holds no testcase');
return { runner: kind, tests: [...byId.values()], retriesHidden };
}
/** Read and parse one JUnit file; missing, unreadable and empty files are errors, never green. */
export function readJunit(file: string, runner?: JunitRunner): JunitReport {
let xml: string;
try { xml = fs.readFileSync(file, 'utf-8'); } catch (e) {
throw new JunitError('FLAKY_NO_RESULTS', `cannot read ${file}: ${(e as Error).message}`);
}
if (!xml.trim()) throw new JunitError('FLAKY_NO_RESULTS', `${file} is empty`);
return parseJunit(xml, runner);
}
/** The final outcome of a test: its last attempt. */
export const finalAttempt = (t: JunitTest): Attempt => t.attempts[t.attempts.length - 1]!;
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="bun test" tests="4" assertions="2" failures="1" skipped="1" time="0.02640424">
<testsuite name="a.test.ts" file="a.test.ts" tests="4" assertions="2" failures="1" skipped="1" time="0.014448064" hostname="ci-host">
<testsuite name="outer" file="a.test.ts" line="2" tests="3" assertions="2" failures="1" skipped="1" time="0" hostname="ci-host">
<testsuite name="inner" file="a.test.ts" line="2" tests="2" assertions="2" failures="1" skipped="0" time="0.006" hostname="ci-host">
<testcase name="passes" classname="inner &gt; outer" time="0.000069" file="a.test.ts" line="2" assertions="1" />
<testcase name="fails" classname="inner &gt; outer" time="0.006322" file="a.test.ts" line="2" assertions="1">
<failure type="AssertionError" message="expect(received).toBe(expected)&#10;&#10;Expected: 2&#10;Received: 1&#10;">AssertionError: expect(received).toBe(expected)&#10;&#10;Expected: 2&#10;Received: 1&#10;&#10; at a.test.ts:2:131&#10;</failure>
</testcase>
</testsuite>
<testcase name="skipped" classname="outer" time="0" file="a.test.ts" line="2" assertions="0">
<skipped />
</testcase>
</testsuite>
<testcase name="top level" classname="" time="0.000039" file="a.test.ts" line="3" assertions="0" />
</testsuite>
</testsuites>
+138
View File
@@ -0,0 +1,138 @@
<testsuites id="" name="" tests="10" failures="4" skipped="2" errors="0" time="3.23467">
<testsuite name="a.spec.ts" timestamp="2026-10-29T16:02:06.214Z" hostname="alpha" tests="5" failures="2" skipped="1" time="0.03" errors="0">
<testcase name="group one › passes" classname="a.spec.ts" time="0.011">
</testcase>
<testcase name="group one › fails always" classname="a.spec.ts" time="0.004">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-alpha/error-context.md]]
]]>
</system-out>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [alpha] › a.spec.ts:5:7 › group one › fails always ───────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-alpha/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › flaky once" classname="a.spec.ts" time="0.009">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-flaky-once-alpha/error-context.md]]
]]>
</system-out>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [alpha] › a.spec.ts:6:7 › group one › flaky once ─────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: "second"
Received: "first"
4 | test('passes', () => { expect(1).toBe(1); });
5 | test('fails always', () => { expect(1).toBe(2); });
> 6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
| ^
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
9 | });
at /repo/tests/a.spec.ts:6:152
Error Context: ../test-results/a-group-one-flaky-once-alpha/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › skipped" classname="a.spec.ts">
<properties>
<property name="skip" value="">
</property>
</properties>
<skipped>
</skipped>
</testcase>
<testcase name="group one › title with $(rm -rf) [x] (y)? *" classname="a.spec.ts" time="0.006">
</testcase>
</testsuite>
<testsuite name="a.spec.ts" timestamp="2026-10-29T16:02:06.214Z" hostname="beta" tests="5" failures="2" skipped="1" time="0.027" errors="0">
<testcase name="group one › passes" classname="a.spec.ts" time="0.005">
</testcase>
<testcase name="group one › fails always" classname="a.spec.ts" time="0.002">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-beta/error-context.md]]
]]>
</system-out>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [beta] › a.spec.ts:5:7 › group one › fails always ────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-beta/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › flaky once" classname="a.spec.ts" time="0.013">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-flaky-once-beta/error-context.md]]
]]>
</system-out>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [beta] › a.spec.ts:6:7 › group one › flaky once ──────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: "second"
Received: "first"
4 | test('passes', () => { expect(1).toBe(1); });
5 | test('fails always', () => { expect(1).toBe(2); });
> 6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
| ^
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
9 | });
at /repo/tests/a.spec.ts:6:152
Error Context: ../test-results/a-group-one-flaky-once-beta/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › skipped" classname="a.spec.ts">
<properties>
<property name="skip" value="">
</property>
</properties>
<skipped>
</skipped>
</testcase>
<testcase name="group one › title with $(rm -rf) [x] (y)? *" classname="a.spec.ts" time="0.007">
</testcase>
</testsuite>
</testsuites>
+244
View File
@@ -0,0 +1,244 @@
<testsuites id="" name="" tests="10" failures="2" skipped="2" errors="0" time="4.805343">
<testsuite name="a.spec.ts" timestamp="2026-10-29T16:02:16.450Z" hostname="alpha" tests="5" failures="1" skipped="1" time="0.043" errors="0">
<testcase name="group one › passes" classname="a.spec.ts" time="0.007">
</testcase>
<testcase name="group one › fails always" classname="a.spec.ts" time="0.005">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-alpha/error-context.md]]
]]>
</system-out>
<rerunFailure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe" time="0.008">
<stackTrace>
<![CDATA[Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
at /repo/tests/a.spec.ts:5:42]]>
</stackTrace>
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-alpha-retry1/error-context.md]]
]]>
</system-out>
</rerunFailure>
<rerunFailure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe" time="0.009">
<stackTrace>
<![CDATA[Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
at /repo/tests/a.spec.ts:5:42]]>
</stackTrace>
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-alpha-retry2/error-context.md]]
]]>
</system-out>
</rerunFailure>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [alpha] › a.spec.ts:5:7 › group one › fails always ───────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-alpha/error-context.md
Retry #1 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-alpha-retry1/error-context.md
Retry #2 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-alpha-retry2/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › flaky once" classname="a.spec.ts" time="0.002">
<flakyFailure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe" time="0.009">
<stackTrace>
<![CDATA[Error: expect(received).toBe(expected) // Object.is equality
Expected: "second"
Received: "first"
at /repo/tests/a.spec.ts:6:152]]>
</stackTrace>
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-flaky-once-alpha/error-context.md]]
]]>
</system-out>
</flakyFailure>
</testcase>
<testcase name="group one › skipped" classname="a.spec.ts">
<properties>
<property name="skip" value="">
</property>
</properties>
<skipped>
</skipped>
</testcase>
<testcase name="group one › title with $(rm -rf) [x] (y)? *" classname="a.spec.ts" time="0.003">
</testcase>
</testsuite>
<testsuite name="a.spec.ts" timestamp="2026-10-29T16:02:16.450Z" hostname="beta" tests="5" failures="1" skipped="1" time="0.033" errors="0">
<testcase name="group one › passes" classname="a.spec.ts" time="0.004">
</testcase>
<testcase name="group one › fails always" classname="a.spec.ts" time="0.002">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-beta/error-context.md]]
]]>
</system-out>
<rerunFailure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe" time="0.006">
<stackTrace>
<![CDATA[Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
at /repo/tests/a.spec.ts:5:42]]>
</stackTrace>
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-beta-retry1/error-context.md]]
]]>
</system-out>
</rerunFailure>
<rerunFailure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe" time="0.006">
<stackTrace>
<![CDATA[Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
at /repo/tests/a.spec.ts:5:42]]>
</stackTrace>
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-beta-retry2/error-context.md]]
]]>
</system-out>
</rerunFailure>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [beta] › a.spec.ts:5:7 › group one › fails always ────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-beta/error-context.md
Retry #1 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-beta-retry1/error-context.md
Retry #2 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-beta-retry2/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › flaky once" classname="a.spec.ts" time="0.002">
<flakyFailure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe" time="0.01">
<stackTrace>
<![CDATA[Error: expect(received).toBe(expected) // Object.is equality
Expected: "second"
Received: "first"
at /repo/tests/a.spec.ts:6:152]]>
</stackTrace>
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-flaky-once-beta/error-context.md]]
]]>
</system-out>
</flakyFailure>
</testcase>
<testcase name="group one › skipped" classname="a.spec.ts">
<properties>
<property name="skip" value="">
</property>
</properties>
<skipped>
</skipped>
</testcase>
<testcase name="group one › title with $(rm -rf) [x] (y)? *" classname="a.spec.ts" time="0.003">
</testcase>
</testsuite>
</testsuites>
+178
View File
@@ -0,0 +1,178 @@
<testsuites id="" name="" tests="10" failures="2" skipped="2" errors="0" time="4.985760999999999">
<testsuite name="a.spec.ts" timestamp="2026-10-29T16:02:10.182Z" hostname="alpha" tests="5" failures="1" skipped="1" time="0.044" errors="0">
<testcase name="group one › passes" classname="a.spec.ts" time="0.005">
</testcase>
<testcase name="group one › fails always" classname="a.spec.ts" time="0.021">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-alpha/error-context.md]]
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-alpha-retry1/error-context.md]]
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-alpha-retry2/error-context.md]]
]]>
</system-out>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [alpha] › a.spec.ts:5:7 › group one › fails always ───────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-alpha/error-context.md
Retry #1 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-alpha-retry1/error-context.md
Retry #2 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-alpha-retry2/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › flaky once" classname="a.spec.ts" time="0.014">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-flaky-once-alpha/error-context.md]]
]]>
</system-out>
</testcase>
<testcase name="group one › skipped" classname="a.spec.ts">
<properties>
<property name="skip" value="">
</property>
</properties>
<skipped>
</skipped>
</testcase>
<testcase name="group one › title with $(rm -rf) [x] (y)? *" classname="a.spec.ts" time="0.004">
</testcase>
</testsuite>
<testsuite name="a.spec.ts" timestamp="2026-10-29T16:02:10.182Z" hostname="beta" tests="5" failures="1" skipped="1" time="0.04" errors="0">
<testcase name="group one › passes" classname="a.spec.ts" time="0.005">
</testcase>
<testcase name="group one › fails always" classname="a.spec.ts" time="0.019">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-beta/error-context.md]]
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-beta-retry1/error-context.md]]
[[ATTACHMENT|pw/test-results/a-group-one-fails-always-beta-retry2/error-context.md]]
]]>
</system-out>
<failure message="expect(received).toBe(expected) // Object.is equality" type="expect.toBe">
<![CDATA[ [beta] › a.spec.ts:5:7 › group one › fails always ────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-beta/error-context.md
Retry #1 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-beta-retry1/error-context.md
Retry #2 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
3 | test.describe('group one', () => {
4 | test('passes', () => { expect(1).toBe(1); });
> 5 | test('fails always', () => { expect(1).toBe(2); });
| ^
6 | test('flaky once', ({}, info) => { const f = '/tmp/pwflake-' + info.project.name; if (!fs.existsSync(f)) { fs.writeFileSync(f, '1'); expect('first').toBe('second'); } });
7 | test.skip('skipped', () => {});
8 | test('title with $(rm -rf) [x] (y)? *', () => { expect(1).toBe(1); });
at /repo/tests/a.spec.ts:5:42
Error Context: ../test-results/a-group-one-fails-always-beta-retry2/error-context.md
]]>
</failure>
</testcase>
<testcase name="group one › flaky once" classname="a.spec.ts" time="0.012">
<system-out>
<![CDATA[
[[ATTACHMENT|pw/test-results/a-group-one-flaky-once-beta/error-context.md]]
]]>
</system-out>
</testcase>
<testcase name="group one › skipped" classname="a.spec.ts">
<properties>
<property name="skip" value="">
</property>
</properties>
<skipped>
</skipped>
</testcase>
<testcase name="group one › title with $(rm -rf) [x] (y)? *" classname="a.spec.ts" time="0.004">
</testcase>
</testsuite>
</testsuites>
+245
View File
@@ -0,0 +1,245 @@
/**
* gstack-flaky run (GSTA-158): the JUnit reader against reports captured from
* Playwright 1.64 and bun 1.4 (test/fixtures/junit, paths rewritten to /repo),
* the rerun-once rules, and the bin end to end with a fake runner.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { JunitError, parseJunit, readJunit, testId } from '../lib/junit';
import { renderResult, rerunSelection, runFlaky, safeExcerpt, stepSummary } from '../lib/flaky-run';
const ROOT = path.resolve(import.meta.dir, '..');
const FIX = path.join(ROOT, 'test', 'fixtures', 'junit');
const fixture = (name: string) => fs.readFileSync(path.join(FIX, name), 'utf-8');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-flaky-'));
const tmp = () => fs.mkdtempSync(path.join(TMP, 't-'));
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe('JUnit reader', () => {
test('bun: nested describe path, skip, failure excerpt', () => {
const r = parseJunit(fixture('bun.xml'));
expect(r.runner).toBe('bun');
expect(r.tests.map(t => [t.id, t.attempts.join(',')])).toEqual([
['a.test.ts::::outer > inner > passes', 'pass'],
['a.test.ts::::outer > inner > fails', 'fail'],
['a.test.ts::::outer > skipped', 'skip'],
['a.test.ts::::top level', 'pass'],
]);
expect(r.tests[1]!.excerpt[0]).toContain('expect(received).toBe(expected)');
});
test('playwright: one id per project, retry-aware attempts', () => {
const r = parseJunit(fixture('playwright-r2-inc.xml'));
expect(r.runner).toBe('playwright');
expect(r.retriesHidden).toBe(false);
const byId = Object.fromEntries(r.tests.map(t => [t.id, t.attempts.join(',')]));
expect(byId['a.spec.ts::alpha::group one > fails always']).toBe('fail,fail,fail');
expect(byId['a.spec.ts::beta::group one > fails always']).toBe('fail,fail,fail');
expect(byId['a.spec.ts::alpha::group one > flaky once']).toBe('fail,pass');
expect(byId['a.spec.ts::alpha::group one > skipped']).toBe('skip');
expect(byId['a.spec.ts::alpha::group one > title with $(rm -rf) [x] (y)? *']).toBe('pass');
});
test('playwright retries without retry data are detected, not read as one attempt', () => {
expect(parseJunit(fixture('playwright-r2.xml')).retriesHidden).toBe(true);
expect(parseJunit(fixture('playwright-r0.xml')).retriesHidden).toBe(false);
});
test('missing, empty, zero-testcase and malformed reports are errors, never green', () => {
const dir = tmp();
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return (e as JunitError).code; } };
expect(code(() => readJunit(path.join(dir, 'absent.xml')))).toBe('FLAKY_NO_RESULTS');
fs.writeFileSync(path.join(dir, 'empty.xml'), ' \n');
expect(code(() => readJunit(path.join(dir, 'empty.xml')))).toBe('FLAKY_NO_RESULTS');
expect(code(() => parseJunit('<testsuites name="x"><testsuite name="s" tests="0"/></testsuites>'))).toBe('FLAKY_NO_RESULTS');
expect(code(() => parseJunit('<testsuites><testsuite name="s"><testcase name="a"></testsuite></testsuites>'))).toBe('FLAKY_JUNIT_INVALID');
});
test('generic runners (jest, pytest): file from the testcase, entities decoded', () => {
const r = parseJunit('<?xml version="1.0"?><testsuites><testsuite name="pytest"><testcase classname="tests.test_a" name="test_x[a&amp;b]" file="tests/test_a.py"><error message="boom"/></testcase></testsuite></testsuites>');
expect(r.runner).toBe('generic');
expect(r.tests[0]!.id).toBe('tests/test_a.py::::test_x[a&b]');
expect(r.tests[0]!.attempts).toEqual(['fail']);
});
});
describe('rerun selection', () => {
const pw = parseJunit(fixture('playwright-r0.xml')).tests.find(t => t.path.at(-1)!.startsWith('title with') && t.project === 'beta')!;
test('playwright: file and title regex-escaped, project kept, each one argv element', () => {
expect(rerunSelection('playwright', pw)).toEqual([
'a\\.spec\\.ts', '--grep', '(?:^|\\s)group one title with \\$\\(rm -rf\\) \\[x\\] \\(y\\)\\? \\*(?:\\s|$)', '--project', 'beta',
]);
});
test('bun: anchored space-joined path', () => {
const t = parseJunit(fixture('bun.xml')).tests[1]!;
expect(rerunSelection('bun', t)).toEqual(['a.test.ts', '--test-name-pattern', '^outer inner fails$']);
});
test('generic: templates substitute into single argv elements; none given is a coded refusal', () => {
const t = parseJunit('<testsuites><testsuite name="s"><testcase name="a b; rm x" file="t.py"><failure/></testcase></testsuite></testsuites>').tests[0]!;
expect(rerunSelection('generic', t, ['{file}', '-k', '{title}'])).toEqual(['t.py', '-k', 'a b; rm x']);
expect(() => rerunSelection('generic', t)).toThrow(JunitError);
});
});
/** A fake runner: argv selection decides which canned report the attempt writes. */
function fakeRun(reports: (argv: string[], n: number) => string | null, exits: (argv: string[], n: number) => number) {
const calls: string[][] = [];
return {
calls,
spawn(junit: string) {
return (argv: string[], _env: NodeJS.ProcessEnv, cwd: string) => {
calls.push(argv);
const xml = reports(argv, calls.length);
if (xml !== null) fs.writeFileSync(path.resolve(cwd, junit), xml);
return exits(argv, calls.length);
};
},
};
}
const suite = (cases: string) => `<testsuites name="bun test"><testsuite name="a.test.ts" file="a.test.ts">${cases}</testsuite></testsuites>`;
const pass = (name: string) => `<testcase name="${name}" classname="" file="a.test.ts"/>`;
const fail = (name: string, msg = 'boom') => `<testcase name="${name}" classname="" file="a.test.ts"><failure message="${msg}"/></testcase>`;
describe('runFlaky', () => {
const base = (spawn: ReturnType<typeof fakeRun>, dir: string, extra: Partial<Parameters<typeof runFlaky>[0]> = {}) =>
runFlaky({ command: ['bun', 'test'], junit: 'r.xml', maxReruns: 3, cwd: dir, spawn: spawn.spawn('r.xml'), env: {}, now: () => new Date('2026-10-29T00:00:00Z'), ...extra });
test('a failed test that passes on rerun is a flaky pass and goes on the ledger', () => {
const dir = tmp();
const f = fakeRun((argv, n) => n === 1 ? suite(pass('a') + fail('b')) : suite(pass('b')), (_a, n) => n === 1 ? 1 : 0);
const r = base(f, dir);
expect(r.exitCode).toBe(0);
expect(f.calls[1]).toEqual(['bun', 'test', 'a.test.ts', '--test-name-pattern', '^b$']);
expect(r.outcomes.map(o => [o.status, o.attempts.join(',')])).toEqual([['flaky-pass', 'fail,pass']]);
expect(r.ledger).toHaveLength(1);
expect(r.ledger[0]).toMatchObject({ kind: 'flaky-pass', id: 'a.test.ts::::b', rerun_by: 'gstack-flaky', attempts: ['fail', 'pass'], excerpt: ['boom'] });
expect(renderResult(r, '/l.jsonl').at(-1)).toBe('FLAKY_RESULT: tests=2 failed=0 flaky=1 reruns=1 command_exit=1 verdict=pass ledger=/l.jsonl');
expect(stepSummary(r)).toContain('a.test.ts::::b');
});
test('a test that fails its rerun is red and is ledgered as rerun-fail', () => {
const dir = tmp();
const f = fakeRun(() => suite(fail('b')), () => 1);
const r = base(f, dir);
expect(r.exitCode).toBe(1);
expect(r.outcomes[0]).toMatchObject({ status: 'rerun-fail', attempts: ['fail', 'fail'] });
expect(r.ledger[0]!.kind).toBe('rerun-fail');
});
test('more failures than --max-reruns: a real red, nothing rerun', () => {
const dir = tmp();
const f = fakeRun(() => suite(fail('a') + fail('b') + fail('c') + fail('d')), () => 1);
const r = base(f, dir);
expect(r.exitCode).toBe(1);
expect(f.calls).toHaveLength(1);
expect(r.reruns).toBe(0);
expect(r.outcomes.every(o => o.status === 'fail' && /more than --max-reruns 3/.test(o.reason!))).toBe(true);
expect(r.ledger).toEqual([]);
});
test('runner retries stand: no second rerun, and a runner flaky pass is ledgered', () => {
const dir = tmp();
const f = fakeRun(() => fixture('playwright-r2-inc.xml'), () => 1);
const r = base(f, dir);
expect(f.calls).toHaveLength(1);
expect(r.exitCode).toBe(1);
expect(r.ledger.filter(l => l.kind === 'flaky-pass').map(l => [l.id, l.rerun_by])).toEqual([
['a.spec.ts::alpha::group one > flaky once', 'runner'], ['a.spec.ts::beta::group one > flaky once', 'runner'],
]);
expect(r.outcomes.filter(o => o.status === 'fail').every(o => /already retried \(3 attempts\)/.test(o.reason!))).toBe(true);
});
test('hidden Playwright retries refuse with a code instead of a guess', () => {
const dir = tmp();
const r = base(fakeRun(() => fixture('playwright-r2.xml'), () => 1), dir);
expect([r.exitCode, r.code]).toEqual([3, 'FLAKY_RETRIES_HIDDEN']);
});
test('no report, a stale report, and a crash with no failed test are never green', () => {
const dir = tmp();
expect(base(fakeRun(() => null, () => 0), dir)).toMatchObject({ exitCode: 3, code: 'FLAKY_NO_RESULTS' });
fs.writeFileSync(path.join(dir, 'r.xml'), suite(pass('stale')));
expect(base(fakeRun(() => null, () => 0), dir)).toMatchObject({ exitCode: 3, code: 'FLAKY_NO_RESULTS' });
expect(base(fakeRun(() => suite(pass('a')), () => 2), dir)).toMatchObject({ exitCode: 3, code: 'FLAKY_COMMAND_FAILED', commandExit: 2 });
});
test('a rerun that does not report the targeted test fails it; other tests in the rerun are ignored', () => {
const dir = tmp();
const f = fakeRun((_a, n) => n === 1 ? suite(fail('b')) : suite(pass('other')), (_a, n) => n === 1 ? 1 : 0);
const r = base(f, dir);
expect(r.exitCode).toBe(1);
expect(r.outcomes[0]).toMatchObject({ status: 'rerun-fail', reason: 'rerun: the report does not contain this test' });
});
test('a rerun whose command exits non-zero is not a flaky pass, even with a passing report', () => {
const dir = tmp();
const f = fakeRun((_a, n) => n === 1 ? suite(fail('b')) : suite(pass('b')), () => 1);
expect(base(f, dir).outcomes[0]).toMatchObject({ status: 'rerun-fail', reason: 'rerun: the command exited 1' });
});
test('a test reported twice with one failure is red and not rerun', () => {
const dir = tmp();
const r = parseJunit(suite(pass('b') + fail('b')));
expect(r.tests[0]).toMatchObject({ executions: 2, attempts: ['pass', 'fail'] });
const f = fakeRun(() => suite(fail('b') + pass('b') + fail('c')), () => 1);
const out = base(f, dir);
expect(out.outcomes.find(o => o.test.id.endsWith('::b'))).toMatchObject({ status: 'fail', reason: 'it ran 2 times in this report and one failed; not rerun' });
expect(f.calls).toHaveLength(2);
});
test('the Playwright environment flag reaches every attempt', () => {
const dir = tmp();
const seen: string[] = [];
runFlaky({ command: ['x'], junit: 'r.xml', maxReruns: 3, cwd: dir, env: {}, spawn: (_a, env, cwd) => {
seen.push(env.PLAYWRIGHT_JUNIT_INCLUDE_RETRIES ?? '');
fs.writeFileSync(path.join(cwd, 'r.xml'), suite(seen.length === 1 ? fail('b') : pass('b')));
return seen.length === 1 ? 1 : 0;
} });
expect(seen).toEqual(['1', '1']);
});
test('credentials in failure text never reach the ledger', () => {
const key = ['sk', 'ant', 'api03', 'A'.repeat(93) + 'AA'].join('-');
const out = safeExcerpt([`Error: request failed with key ${key}`]);
expect(out.join('\n')).not.toContain(key);
});
});
describe('bin/gstack-flaky', () => {
test('run: a fake bun runner flakes once, the bin reruns it and exits 0 with a ledger line', () => {
const dir = tmp();
const runner = path.join(dir, 'runner.sh');
fs.writeFileSync(runner, [
'#!/usr/bin/env bash',
'n=$(cat count 2>/dev/null || echo 0); echo $((n+1)) > count',
`if [ "$n" = 0 ]; then printf '%s' '${suite(pass('a') + fail('b'))}' > r.xml; exit 1; fi`,
'[ "$1" = a.test.ts ] && [ "$2" = --test-name-pattern ] && [ "$3" = "^b$" ] || exit 9',
`printf '%s' '${suite(pass('b'))}' > r.xml`,
].join('\n'), { mode: 0o755 });
const ledger = path.join(dir, 'ledger.jsonl');
const r = spawnSync(path.join(ROOT, 'bin', 'gstack-flaky'), ['run', '--junit', 'r.xml', '--runner', 'bun', '--ledger', ledger, '--', runner], { cwd: dir, encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GITHUB_ACTIONS: '', GITHUB_STEP_SUMMARY: '' } });
expect(r.stdout).toContain('FLAKY: flaky-pass a.test.ts::::b attempts=fail,pass');
expect(r.stdout).toContain(`verdict=pass ledger=${ledger}`);
expect(r.status).toBe(0);
expect(JSON.parse(fs.readFileSync(ledger, 'utf-8').trim()).kind).toBe('flaky-pass');
});
test('usage and missing reports exit with their codes', () => {
const dir = tmp();
const bin = path.join(ROOT, 'bin', 'gstack-flaky');
expect(spawnSync(bin, ['run', '--', 'true'], { cwd: dir, encoding: 'utf-8', timeout: 30_000 }).status).toBe(2);
const r = spawnSync(bin, ['run', '--junit', 'r.xml', '--', 'true'], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(3);
expect(r.stderr).toContain('(FLAKY_NO_RESULTS)');
expect(r.stderr).toContain('fix: ');
});
test('ids prints one id per test', () => {
const r = spawnSync(path.join(ROOT, 'bin', 'gstack-flaky'), ['ids', '--junit', path.join(FIX, 'bun.xml')], { encoding: 'utf-8', timeout: 30_000 });
expect(r.stdout.trim().split('\n')).toEqual([testId('a.test.ts', '', ['outer', 'inner', 'passes']), 'a.test.ts::::outer > inner > fails', 'a.test.ts::::outer > skipped', 'a.test.ts::::top level']);
});
});