fix: pre-landing review fixes

gstack-flaky never greens an incomplete run: a targeted rerun needs the
runner's summary proving the first run complete, otherwise (and always for
generic runners) the rerun repeats the whole command. A rerun clears a test
only when it exits 0 and the test passes once on its first attempt. Killed
runners exit 3 (FLAKY_COMMAND_KILLED) and signals are forwarded. The first
report is restored at --junit with reruns kept beside it; ids are redacted at
every output; the ledger gets its own GSTACK_FLAKE_LEDGER_JUNIT; fixtures are
registered in FREE_FIXTURES.

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:54:50 +00:00
1 parent fb30855ce3
commit a44dda6cf6
6 files changed
+335 -133

No files matched your search

+25 -22
View File
@@ -7,32 +7,35 @@
* 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 { DEFAULT_MAX_RERUNS, appendLedger, ledgerPath, renderResult, runFlaky, safeText, stepSummary, workflowEscape } 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 run --junit <file.xml> [--runner playwright|bun|generic] [--max-reruns <n>]
[--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.
at most --max-reruns (${DEFAULT_MAX_RERUNS}) tests failed. When the runner's summary proves
the first run complete, a rerun appends the runner's selection to the same
command (playwright: <file> --grep <title> --project <p>; bun: <file>
--test-name-pattern <title>); otherwise, and always for generic runners, the
rerun is the whole command. A test that passes its rerun once, with the rerun
exiting 0, is a flaky pass: green, and one line on the ledger (--ledger, else
GSTACK_FLAKE_LEDGER_JUNIT, else <state root>/flake-ledger-junit.jsonl). --junit
holds the first run's report afterwards; reruns' reports sit beside it as
<name>.rerun-<n>.xml. 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
Output: one FLAKY: line per test that failed at least once, FLAKY_RERUN:
scope=command (<why>) when the rerun repeated the whole command, 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).
3 no usable report, a crash or a killed runner (FLAKY_<CODE> and a fix: line on stderr).
`;
const FIX: Record<string, string> = {
@@ -40,9 +43,9 @@ const FIX: Record<string, string> = {
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_COMMAND_KILLED: 'the runner was killed by a signal (a cancelled job, an OOM kill); nothing was rerun, rerun the job',
FLAKY_COMMAND_NOT_STARTED: 'check the command after -- (it runs without a shell; wrap it as bash -c \'<script> "$@"\' _ when it needs one)',
FLAKY_UNREPORTED_FAILURES: 'the runner counted failures its JUnit omits (a test file that failed to load, an error outside any test); fix that first, it is a real red',
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 {
@@ -56,7 +59,7 @@ function coded(e: JunitError): never {
}
function parse(argv: string[]) {
const o: { junit?: string; runner?: JunitRunner; maxReruns: number; rerunArgs: string[]; ledger?: string; command: string[] } = { maxReruns: DEFAULT_MAX_RERUNS, rerunArgs: [], command: [] };
const o: { junit?: string; runner?: JunitRunner; maxReruns: number; ledger?: string; command: string[] } = { maxReruns: DEFAULT_MAX_RERUNS, 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; };
@@ -70,8 +73,7 @@ function parse(argv: string[]) {
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 if (a === '--ledger') o.ledger = val();
else die(`unknown option ${a}\n${USAGE}`);
}
return o;
@@ -84,18 +86,19 @@ 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`);
for (const t of readJunit(o.junit, o.runner).tests) process.stdout.write(`${safeText(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.junit) die('run needs --junit <file.xml>');
if (!/\.xml$/i.test(o.junit) || fs.statSync(o.junit, { throwIfNoEntry: false })?.isDirectory()) die(`--junit must name a .xml file (it is deleted before every attempt), got ${JSON.stringify(o.junit)}`);
if (!o.command.length) die('run needs the test command after --');
let result;
try {
result = await runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns, rerunArgs: o.rerunArgs });
result = await runFlaky({ command: o.command, junit: o.junit, runner: o.runner, maxReruns: o.maxReruns });
} 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`));
@@ -103,7 +106,7 @@ 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`);
for (const f of result.outcomes.filter(x => x.status === 'flaky-pass')) process.stdout.write(`::warning title=flaky test::${workflowEscape(safeText(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`);
+2 -1
View File
@@ -1412,7 +1412,8 @@ never masks it. `bun run eval:pass-rates` lists only the local 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`).
own ledger (`flake-ledger-junit.jsonl`, or `GSTACK_FLAKE_LEDGER_JUNIT`), kept apart
from this file-level ledger.
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>`
+57 -34
View File
@@ -22,70 +22,93 @@ pytest. gstack's own free suite keeps its file-level retry
- 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
GSTACK_FLAKE_LEDGER_JUNIT: ${{ runner.temp }}/flaky-junit-ledger.jsonl
run: |
"$RUNNER_TEMP/gstack/bin/gstack-flaky" run --junit results/junit.xml -- npx playwright test --reporter=line,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 }
with: { name: flaky-junit-ledger-e2e, path: "${{ runner.temp }}/flaky-junit-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.
Replace `GSTACK_REF` with 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 |
| Runner | Command after `--` | Rerun |
|---|---|---|
| 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. |
| Playwright | `npx playwright test --reporter=line,junit` with `PLAYWRIGHT_JUNIT_OUTPUT_NAME=<file>` | The failed test alone (`<file> --grep <title> --project <project>`) when the summary proves the run complete, else the whole command. `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. |
| bun | `bun test --reporter=junit --reporter-outfile=<file>` | The failed test alone (`<file> --test-name-pattern <title>`) when the summary proves the run complete, else the whole command. |
| jest, vitest, pytest (`--runner generic`) | your command, writing JUnit | Always the whole command: a generic runner's output cannot prove that every test ran. |
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.
The command after `--` runs without a shell. When it needs one, wrap it as
`bash -c '<script> "$@"' _` so a targeted rerun's selection arguments reach
the runner. A targeted rerun appends its selection to the same command, 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.
- **A targeted rerun needs a complete first run.** The failed test is rerun
alone only when the runner exited 1 (its test-failure status) and its own
summary is present with no early stop: bun's `Ran N tests` line without
`Bailed out`, Playwright's `N failed` line without `did not run`,
`interrupted`, `Testing stopped early` or a global timeout. Otherwise the
rerun repeats the whole command (`FLAKY_RERUN: scope=command (<why>)`), so
a test the first run never reached (`--bail`, `--max-failures`, `-x`)
still has to pass. When the run is already red for another test, a
whole-command rerun is skipped.
- **A rerun clears a test only cleanly.** The rerun's command must exit 0 and
the test must run once and pass on its first attempt; a rerun the runner
retried again does not count.
- **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.
test (a crash or a non-test step) and a runner killed by a signal. When the
runner's summary counts failures its JUnit omits (a bun file that fails to
load, a Playwright error outside any test), the run is red and nothing is
rerun.
- **Reports.** After the run, `--junit` holds the first run's report, so a
later upload or test reporter sees what failed. Each rerun's report is kept
beside it as `<name>.rerun-<n>.xml`.
- **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.
attempts, the rerun's scope (`test` or `command`) and the first lines of
the error. Ids, paths and failure text go through gstack's redaction engine
first; when the scan cannot run, they are withheld. The path is `--ledger`,
else `GSTACK_FLAKE_LEDGER_JUNIT`, else
`<state root>/flake-ledger-junit.jsonl`, and `FLAKY_RESULT` names it. It is
kept apart from the free suite's file-level ledger (`GSTACK_FLAKE_LEDGER`).
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
test that failed at least once, `FLAKY_RERUN: scope=command (<why>)` when the
rerun repeated the whole command, 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) |
| 1 | A test failed (after its rerun, or with no rerun), including `FLAKY_UNREPORTED_FAILURES` |
| 2 | Usage error, including a `--junit` path that is not a `.xml` file |
| 3 | No usable report, a crash, or a killed runner (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 |
| Code | Exit | Cause | Fix |
|---|---|---|---|
| `FLAKY_NO_RESULTS` | 3 | No report at `--junit`, an empty one, or one with no testcase | Make the command write JUnit to that path |
| `FLAKY_JUNIT_INVALID` | 3 | The report is not well-formed XML | Make sure one process writes it and nothing appends to it |
| `FLAKY_RETRIES_HIDDEN` | 3 | Playwright retried but its report omits the retries | Set `PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1` where the report is written |
| `FLAKY_COMMAND_FAILED` | 3 | Non-zero exit with no failed test | Read the command output: a real red |
| `FLAKY_COMMAND_KILLED` | 3 | The runner was killed by a signal (a cancelled job, an OOM kill) | Nothing was rerun; rerun the job |
| `FLAKY_COMMAND_NOT_STARTED` | 3 | The command could not be started | Check the argv after `--` |
| `FLAKY_UNREPORTED_FAILURES` | 1 | The runner counted failures or errors its JUnit omits | Fix the load error or the error outside any test: a real red |
| `FLAKY_LEDGER_WRITE_FAILED` | — | The ledger could not be appended (warning) | Check the path; the verdict stands |
+128 -67
View File
@@ -7,18 +7,24 @@
* 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.
* is rerun. When the runner retried (Playwright `retries` with retry-aware
* JUnit), its attempts stand and nothing is rerun again.
* - A targeted rerun (the failed test alone) needs proof that the first run
* was complete: the runner's own summary, its test-failure exit status 1 and
* no early stop (bail, max-failures, interrupt, global timeout). Without that
* proof, and always for generic runners, the rerun is the whole command, so a
* test the first run never reached still has to pass.
* - A rerun makes a flaky pass only when its command exited 0 and the test ran
* exactly once and passed on its first attempt. The command is an argv run
* without a shell; a targeted rerun appends the adapter's selection, each one
* argv element.
* - 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. When the
* runner's own summary counts failures or errors the JUnit omits (bun files
* that fail to load), the run is red and nothing is rerun.
* not write is missing. Missing, unreadable or empty JUnit is never green; a
* non-zero exit with no failed test and a runner killed by a signal are never
* green. When the runner's own summary counts failures or errors the JUnit
* omits (bun files that fail to load), the run is red and nothing is rerun.
* The first report is restored at the --junit path when the run ends; each
* rerun's report is kept beside it as <name>.rerun-<n>.xml.
*/
import * as fs from 'fs';
import * as path from 'path';
@@ -37,6 +43,8 @@ export interface LedgerLine {
file: string;
/** 'runner' when the runner's own retries decided, 'gstack-flaky' for our rerun. */
rerun_by: 'runner' | 'gstack-flaky';
/** For gstack-flaky reruns: the failed test alone, or the whole command when the first run could not be proven complete. */
rerun_scope?: 'test' | 'command';
attempts: string[];
tree?: string;
git_sha?: string;
@@ -50,8 +58,6 @@ export interface RunOptions {
junit: string;
runner?: JunitRunner;
maxReruns: number;
/** Generic adapter: argv templates appended on rerun, {file} {title} {pattern} substituted. */
rerunArgs?: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
/** Runs one attempt; resolves with its exit status and the tail of its combined output. */
@@ -66,7 +72,7 @@ export interface TestOutcome {
reason?: string;
}
export interface Attempted { exit: number; output: string }
export interface Attempted { exit: number; output: string; signal?: string }
export interface RunResult {
exitCode: 0 | 1 | 3;
@@ -76,6 +82,8 @@ export interface RunResult {
commandExit: number;
outcomes: TestOutcome[];
reruns: number;
/** Why the rerun was the whole command; absent for targeted reruns. */
wholeRerun?: string;
ledger: LedgerLine[];
}
@@ -83,17 +91,11 @@ 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;
}
/** Selection arguments a targeted rerun appends for one test, each one argv element. */
export function rerunSelection(runner: 'playwright' | 'bun', t: JunitTest): string[] {
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));
const args = [escapeRegex(t.file), '--grep', `(?:^|\\s)${escapeRegex(t.path.join(' '))}(?:\\s|$)`];
return t.project ? [...args, '--project', t.project] : args;
}
const OUTPUT_TAIL = 256 * 1024;
@@ -104,20 +106,26 @@ function defaultSpawn(argv: string[], env: NodeJS.ProcessEnv, cwd: string): Prom
let tail = '';
const keep = (chunk: Buffer) => { tail = (tail + chunk.toString('utf-8')).slice(-OUTPUT_TAIL); };
const child = spawnChild(argv[0]!, argv.slice(1), { stdio: ['inherit', 'pipe', 'pipe'], env, cwd });
const forward = (s: NodeJS.Signals) => { child.kill(s); };
const release = () => { process.off('SIGINT', forward); process.off('SIGTERM', forward); };
process.on('SIGINT', forward);
process.on('SIGTERM', forward);
child.stdout!.on('data', (c: Buffer) => { process.stdout.write(c); keep(c); });
child.stderr!.on('data', (c: Buffer) => { process.stderr.write(c); keep(c); });
child.on('error', e => reject(new JunitError('FLAKY_COMMAND_NOT_STARTED', `could not start ${argv[0]}: ${e.message}`)));
child.on('close', (code, signal) => resolve({ exit: code ?? (signal ? 128 : 1), output: tail }));
child.on('error', e => { release(); reject(new JunitError('FLAKY_COMMAND_NOT_STARTED', `could not start ${argv[0]}: ${e.message}`)); });
child.on('close', (code, signal) => { release(); resolve({ exit: code ?? 128, output: tail, ...(signal ? { signal } : {}) }); });
});
}
const plain = (output: string) => output.replace(/\x1b\[[0-9;]*m/g, '');
/**
* Failures a runner counted that its JUnit omits: bun leaves files that fail to
* load out of the report ("N errors"), Playwright reports errors outside any
* test. A rerun cannot clear those, so they keep the run red.
*/
export function unreportedFailures(runner: JunitRunner, output: string, reportedFailed: number): string | null {
const text = output.replace(/\x1b\[[0-9;]*m/g, '');
const text = plain(output);
const last = (re: RegExp) => { const m = [...text.matchAll(re)].pop(); return m ? Number(m[1]) : 0; };
if (runner === 'bun') {
const errors = last(/^\s*(\d+) errors?\s*$/gm);
@@ -134,6 +142,28 @@ export function unreportedFailures(runner: JunitRunner, output: string, reported
return null;
}
/**
* Why the first run cannot be trusted to have run every test, so a rerun must
* repeat the whole command; null when the runner's own output proves it
* complete. A run that stopped early reports only the tests it reached, and a
* targeted rerun would never run the rest.
*/
export function incompleteRun(runner: JunitRunner, output: string, exit: number): string | null {
if (runner === 'generic') return 'a generic runner cannot prove every test ran';
if (exit !== 1) return `the command exited ${exit}, not the runner's test-failure status 1`;
const text = plain(output);
if (runner === 'bun') {
if (/Bailed out after/.test(text)) return 'bun stopped early (--bail)';
if (!/^\s*Ran \d+ tests? across \d+ files?/m.test(text)) return 'bun printed no run summary';
return null;
}
const notRun = text.match(/\b(\d+) (did not run|interrupted)\b/);
if (notRun) return `Playwright reports ${notRun[1]} ${notRun[2]}`;
if (/Testing stopped early|Timed out waiting [\d.]+s for the (?:entire )?test run/.test(text)) return 'Playwright stopped early';
if (!/^\s*\d+ failed\s*$/m.test(text)) return 'Playwright printed no summary (add a console reporter: --reporter=line,junit)';
return null;
}
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;
@@ -151,6 +181,14 @@ export function provenance(cwd: string, env: NodeJS.ProcessEnv): Pick<LedgerLine
};
}
/** Test ids and paths leave this process only through the redaction engine: a parameterized title can carry a credential. */
export function safeText(s: string): string {
return redactFindingSpans(s) ?? '[withheld: the redaction scan could not run]';
}
/** Escape a value for a GitHub workflow command, so a test title cannot end the command or start another. */
export const workflowEscape = (s: string) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
/** 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 [];
@@ -159,23 +197,35 @@ export function safeExcerpt(lines: string[]): string[] {
}
/** One attempt: remove the report, run, and read a report this attempt wrote. */
async function attempt(opts: RunOptions, argv: string[], cwd: string, env: NodeJS.ProcessEnv): Promise<{ exit: number; output: string; report?: JunitReport; error?: JunitError }> {
async function attempt(opts: RunOptions, argv: string[], cwd: string, env: NodeJS.ProcessEnv, runner = opts.runner): Promise<Attempted & { report?: JunitReport; error?: JunitError }> {
const junit = path.resolve(cwd, opts.junit);
fs.rmSync(junit, { force: true });
const { exit, output } = await (opts.spawn ?? defaultSpawn)(argv, env, cwd);
if (!fs.existsSync(junit)) return { exit, output, error: new JunitError('FLAKY_NO_RESULTS', `the command wrote no JUnit report at ${opts.junit} (exit ${exit})`) };
try { return { exit, output, report: readJunit(junit, opts.runner) }; } catch (e) {
if (e instanceof JunitError) return { exit, output, error: e };
const ran = await (opts.spawn ?? defaultSpawn)(argv, env, cwd);
if (!fs.existsSync(junit)) return { ...ran, error: new JunitError('FLAKY_NO_RESULTS', `the command wrote no JUnit report at ${opts.junit} (exit ${ran.exit})`) };
try { return { ...ran, report: readJunit(junit, runner) }; } catch (e) {
if (e instanceof JunitError) return { ...ran, error: e };
throw e;
}
}
/** A rerun clears a test only when its command exited 0 and the test ran once and passed on its first attempt. */
function rerunVerdict(again: Attempted & { report?: JunitReport; error?: JunitError }, t: JunitTest): { status: 'flaky-pass' | 'rerun-fail'; attempts: string[]; reason?: string } {
const match = again.report?.tests.find(x => x.id === t.id);
if (!match) return { status: 'rerun-fail', attempts: [...t.attempts, 'fail'], reason: again.error ? `rerun: ${again.error.message}` : 'rerun: the report does not contain this test' };
const attempts = [...t.attempts, ...match.attempts];
if (again.signal) return { status: 'rerun-fail', attempts, reason: `rerun: killed by ${again.signal}` };
if (again.exit !== 0) return { status: 'rerun-fail', attempts, reason: `rerun: the command exited ${again.exit}` };
if (match.executions !== 1 || match.attempts.join() !== 'pass') return { status: 'rerun-fail', attempts, reason: `rerun: the test did not pass once on its first attempt (${match.attempts.join(',')})` };
return { status: 'flaky-pass', attempts };
}
export async function runFlaky(opts: RunOptions): Promise<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 = await attempt(opts, opts.command, cwd, env);
if (first.signal) return { exitCode: 3, code: 'FLAKY_COMMAND_KILLED', message: `the command was killed by ${first.signal}`, commandExit: first.exit, ...empty };
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) {
@@ -183,7 +233,7 @@ export async function runFlaky(opts: RunOptions): Promise<RunResult> {
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'));
const runnerFlaky = report.tests.filter(t => finalAttempt(t) !== 'fail' && 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` };
@@ -195,66 +245,77 @@ export async function runFlaky(opts: RunOptions): Promise<RunResult> {
message: `${unreported}; nothing was rerun` };
}
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 line = (t: JunitTest, kind: LedgerKind, by: LedgerLine['rerun_by'], attempts: string[], scope?: LedgerLine['rerun_scope']): LedgerLine =>
({ ts: now().toISOString(), runner: 'junit', kind, id: safeText(t.id), file: safeText(t.file), rerun_by: by, ...(scope ? { rerun_scope: scope } : {}), 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;
const eligible: JunitTest[] = [];
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)];
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` });
else if (t.attempts.length > 1) outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `the runner already retried (${t.attempts.length} attempts)` });
else if (failed.length > opts.maxReruns) outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `${failed.length} tests failed, more than --max-reruns ${opts.maxReruns}; not rerun` });
else eligible.push(t);
}
const wholeRerun = eligible.length ? incompleteRun(report.runner, first.output, first.exit) ?? undefined : undefined;
const junit = path.resolve(cwd, opts.junit);
const firstXml = fs.readFileSync(junit);
let reruns = 0;
const rerun = async (argv: string[]) => {
const again = await attempt(opts, argv, cwd, env, report.runner);
reruns++;
const again = await 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));
if (fs.existsSync(junit)) fs.renameSync(junit, `${junit.replace(/\.xml$/i, '')}.rerun-${reruns}.xml`);
return again;
};
const record = (t: JunitTest, v: ReturnType<typeof rerunVerdict>, scope: 'test' | 'command') => {
outcomes.push({ test: t, status: v.status, attempts: v.attempts, ...(v.reason ? { reason: v.reason } : {}) });
ledger.push(line(t, v.status, 'gstack-flaky', v.attempts, scope));
};
try {
if (wholeRerun && outcomes.some(o => o.status === 'fail')) {
for (const t of eligible) outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: `not rerun: the run is already red, and a rerun would repeat the whole command (${wholeRerun})` });
} else if (wholeRerun) {
const again = await rerun(opts.command);
for (const t of eligible) record(t, rerunVerdict(again, t), 'command');
} else if (eligible.length) {
let interrupted = false;
for (const t of eligible) {
if (interrupted) { outcomes.push({ test: t, status: 'fail', attempts: t.attempts, reason: 'not rerun: an earlier rerun was killed' }); continue; }
const again = await rerun([...opts.command, ...rerunSelection(report.runner as 'playwright' | 'bun', t)]);
interrupted = !!again.signal;
record(t, rerunVerdict(again, t), 'test');
}
}
} finally {
if (reruns) fs.writeFileSync(junit, firstXml);
}
const red = outcomes.some(o => o.status !== 'flaky-pass');
return { exitCode: red ? 1 : 0, tests: report.tests.length, commandExit: first.exit, outcomes, reruns, ledger };
return { exitCode: red ? 1 : 0, tests: report.tests.length, commandExit: first.exit, outcomes, reruns, ...(wholeRerun && reruns ? { wholeRerun } : {}), 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;
if (env.GSTACK_FLAKE_LEDGER_JUNIT) return env.GSTACK_FLAKE_LEDGER_JUNIT;
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;
export function appendLedger(lines: LedgerLine[], file: string, warn: (s: string) => void): void {
if (!lines.length) return;
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})` : ''}`);
for (const o of r.outcomes) out.push(`FLAKY: ${o.status} ${safeText(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;
if (r.wholeRerun) out.push(`FLAKY_RERUN: scope=command (${r.wholeRerun})`);
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;
}
@@ -263,7 +324,7 @@ export function renderResult(r: RunResult, ledger: string): string[] {
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, ' ');
const cell = (s: string) => s.replace(/\|/g, '\\|').replace(/`/g, "'").replace(/[\r\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');
...rows.map(o => `| \`${cell(safeText(o.test.id))}\` | ${o.attempts.join(', ')} | ${cell(safeExcerpt(o.test.excerpt)[0] ?? '')} |`), ''].join('\n');
}
+122 -9
View File
@@ -9,7 +9,7 @@ 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, unreportedFailures } from '../lib/flaky-run';
import { appendLedger, incompleteRun, ledgerPath, renderResult, rerunSelection, runFlaky, safeExcerpt, stepSummary, unreportedFailures, workflowEscape } from '../lib/flaky-run';
const ROOT = path.resolve(import.meta.dir, '..');
const FIX = path.join(ROOT, 'test', 'fixtures', 'junit');
@@ -77,15 +77,27 @@ describe('rerun selection', () => {
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);
});
describe('complete-run proof for a targeted rerun', () => {
test('a complete run needs exit 1 and the runner summary without an early stop', () => {
expect(incompleteRun('bun', ' 4 pass\n 1 fail\nRan 5 tests across 2 files. [9ms]\n', 1)).toBeNull();
expect(incompleteRun('bun', 'Bailed out after 1 failure\nRan 1 test across 1 file.\n', 1)).toContain('--bail');
expect(incompleteRun('bun', ' 1 fail\n', 1)).toContain('no run summary');
expect(incompleteRun('bun', 'Ran 5 tests across 2 files.\n', 2)).toContain('exited 2');
expect(incompleteRun('playwright', ' 1 failed\n [alpha] x\n 4 passed (1s)\n', 1)).toBeNull();
expect(incompleteRun('playwright', 'Testing stopped early after 1 maximum allowed failures.\n 1 failed\n 2 did not run\n', 1)).toContain('2 did not run');
expect(incompleteRun('playwright', ' 1 failed\n 3 interrupted\n', 1)).toContain('3 interrupted');
expect(incompleteRun('playwright', 'Timed out waiting 600s for the test suite to run\nTimed out waiting 600s for the entire test run\n 1 failed\n', 1)).toContain('stopped early');
expect(incompleteRun('playwright', '', 1)).toContain('no summary');
expect(incompleteRun('generic', 'Ran 5 tests', 1)).toContain('generic');
});
});
/** 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, outputs: (argv: string[], n: number) => string = () => '') {
const BUN_SUMMARY = 'Ran 2 tests across 1 file. [3ms]\n';
function fakeRun(reports: (argv: string[], n: number) => string | null, exits: (argv: string[], n: number) => number, outputs: (argv: string[], n: number) => string = () => BUN_SUMMARY, signals: (n: number) => string | undefined = () => undefined) {
const calls: string[][] = [];
return {
calls,
@@ -94,7 +106,8 @@ function fakeRun(reports: (argv: string[], n: number) => string | null, exits: (
calls.push(argv);
const xml = reports(argv, calls.length);
if (xml !== null) fs.writeFileSync(path.resolve(cwd, junit), xml);
return { exit: exits(argv, calls.length), output: outputs(argv, calls.length) };
const signal = signals(calls.length);
return { exit: exits(argv, calls.length), output: outputs(argv, calls.length), ...(signal ? { signal } : {}) };
};
},
};
@@ -191,6 +204,14 @@ describe('runFlaky', () => {
expect(f.calls).toHaveLength(2);
});
test('a runner-caught flake is ledgered even when a duplicate execution of the test was skipped', async () => {
const dir = tmp();
const caught = '<testcase name="b" classname="" file="a.test.ts"><flakyFailure message="x"/></testcase>';
const r = await base(fakeRun(() => suite(caught + '<testcase name="b" classname="" file="a.test.ts"><skipped/></testcase>'), () => 0), dir);
expect(r.exitCode).toBe(0);
expect(r.ledger.map(l => [l.kind, l.rerun_by])).toEqual([['flaky-pass', 'runner']]);
});
test('the Playwright environment flag reaches every attempt', async () => {
const dir = tmp();
const seen: string[] = [];
@@ -218,6 +239,84 @@ describe('runFlaky', () => {
expect(unreportedFailures('generic', '5 failed', 0)).toBeNull();
});
test('a run that stopped early reruns the whole command, so tests it never reached must pass too', async () => {
const dir = tmp();
const bail = () => 'Bailed out after 1 failure\nRan 1 test across 1 file.\n';
const green = fakeRun((_a, n) => n === 1 ? suite(fail('b')) : suite(pass('b') + pass('never-reached')), (_a, n) => n === 1 ? 1 : 0, bail);
const r = await base(green, dir);
expect(green.calls).toEqual([['bun', 'test'], ['bun', 'test']]);
expect(r).toMatchObject({ exitCode: 0, wholeRerun: 'bun stopped early (--bail)' });
expect(r.ledger[0]).toMatchObject({ kind: 'flaky-pass', rerun_scope: 'command' });
expect(renderResult(r, '/l')).toContain('FLAKY_RERUN: scope=command (bun stopped early (--bail))');
const red = fakeRun((_a, n) => n === 1 ? suite(fail('b')) : suite(pass('b') + fail('never-reached')), () => 1, bail);
expect((await base(red, dir)).outcomes[0]).toMatchObject({ status: 'rerun-fail', reason: 'rerun: the command exited 1' });
});
test('generic runners always rerun the whole command', async () => {
const dir = tmp();
const f = fakeRun((_a, n) => n === 1 ? suite(fail('b')) : suite(pass('b')), (_a, n) => n === 1 ? 1 : 0);
const r = await base(f, dir, { runner: 'generic' });
expect(f.calls[1]).toEqual(['bun', 'test']);
expect(r).toMatchObject({ exitCode: 0, wholeRerun: 'a generic runner cannot prove every test ran' });
});
test('an incomplete run that is already red is not rerun as a whole', async () => {
const dir = tmp();
const f = fakeRun(() => suite(fail('b') + pass('b') + fail('c')), () => 1, () => '');
const r = await base(f, dir);
expect(f.calls).toHaveLength(1);
expect(r.outcomes.find(o => o.test.id.endsWith('::c'))!.reason).toContain('already red');
});
test('a rerun the runner retried again does not clear the test', async () => {
const dir = tmp();
const f = fakeRun((_a, n) => n === 1 ? suite(fail('b')) : suite('<testcase name="b" classname="" file="a.test.ts"><flakyFailure message="x"/></testcase>'), (_a, n) => n === 1 ? 1 : 0);
expect((await base(f, dir)).outcomes[0]).toMatchObject({ status: 'rerun-fail', attempts: ['fail', 'fail', 'pass'] });
});
test('a killed runner is never green and stops further reruns', async () => {
const dir = tmp();
expect(await base(fakeRun(() => suite(fail('b')), () => 128, undefined, () => 'SIGTERM'), dir)).toMatchObject({ exitCode: 3, code: 'FLAKY_COMMAND_KILLED' });
const f = fakeRun((_a, n) => n === 1 ? suite(fail('b') + fail('c')) : suite(pass('b') + pass('c')), (_a, n) => n === 1 ? 1 : 128, undefined, n => n === 2 ? 'SIGTERM' : undefined);
const r = await base(f, dir);
expect(f.calls).toHaveLength(2);
expect(r.outcomes.map(o => o.reason)).toEqual(['rerun: killed by SIGTERM', 'not rerun: an earlier rerun was killed']);
expect(r.exitCode).toBe(1);
});
test('the first report is restored at --junit and each rerun report is kept beside it', async () => {
const dir = tmp();
const firstXml = suite(pass('a') + fail('b'));
await base(fakeRun((_a, n) => n === 1 ? firstXml : suite(pass('b')), (_a, n) => n === 1 ? 1 : 0), dir);
expect(fs.readFileSync(path.join(dir, 'r.xml'), 'utf-8')).toBe(firstXml);
expect(fs.readFileSync(path.join(dir, 'r.rerun-1.xml'), 'utf-8')).toBe(suite(pass('b')));
});
test('credentials in a test title never reach the ledger, the result lines or the step summary', async () => {
const dir = tmp();
const key = ['sk', 'ant', 'api03', 'B'.repeat(93) + 'AA'].join('-');
const r = await base(fakeRun((_a, n) => n === 1 ? suite(fail(`login with ${key}`)) : suite(pass(`login with ${key}`)), (_a, n) => n === 1 ? 1 : 0), dir);
expect(r.exitCode).toBe(0);
expect(JSON.stringify(r.ledger)).not.toContain(key);
expect(renderResult(r, '/l').join('\n')).not.toContain(key);
expect(stepSummary(r)).not.toContain(key);
});
test('workflow commands escape a title that would end or start a command', () => {
expect(workflowEscape('a%b\r\n::error::x')).toBe('a%25b%0D%0A::error::x');
});
test('ledger: a write error is a warning; the path comes from --ledger, then GSTACK_FLAKE_LEDGER_JUNIT, then the state root', () => {
const dir = tmp();
fs.writeFileSync(path.join(dir, 'file'), '');
const warns: string[] = [];
appendLedger([{ ts: 't', runner: 'junit', kind: 'flaky-pass', id: 'x', file: 'x', rerun_by: 'gstack-flaky', attempts: [], excerpt: [] }], path.join(dir, 'file', 'l.jsonl'), w => warns.push(w));
expect(warns[0]).toContain('FLAKY_LEDGER_WRITE_FAILED');
expect(ledgerPath('/flag', { GSTACK_FLAKE_LEDGER_JUNIT: '/env' })).toBe('/flag');
expect(ledgerPath(undefined, { GSTACK_FLAKE_LEDGER_JUNIT: '/env', GSTACK_FLAKE_LEDGER: '/free' })).toBe('/env');
expect(ledgerPath(undefined, { GSTACK_STATE_ROOT: dir, GSTACK_FLAKE_LEDGER: '/free' })).toBe(path.join(dir, 'flake-ledger-junit.jsonl'));
});
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}`]);
@@ -232,7 +331,7 @@ describe('bin/gstack-flaky', () => {
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`,
`if [ "$n" = 0 ]; then printf '%s' '${suite(pass('a') + fail('b'))}' > r.xml; echo 'Ran 2 tests across 1 file.' >&2; 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 });
@@ -244,16 +343,30 @@ describe('bin/gstack-flaky', () => {
expect(JSON.parse(fs.readFileSync(ledger, 'utf-8').trim()).kind).toBe('flaky-pass');
});
test('usage and missing reports exit with their codes', () => {
test('usage, a command that cannot start, 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);
fs.writeFileSync(path.join(dir, 'keep.ts'), 'x');
expect(spawnSync(bin, ['run', '--junit', 'keep.ts', '--', 'true'], { cwd: dir, encoding: 'utf-8', timeout: 30_000 }).status).toBe(2);
expect(fs.existsSync(path.join(dir, 'keep.ts'))).toBe(true);
const missing = spawnSync(bin, ['run', '--junit', 'r.xml', '--', path.join(dir, 'no-such-runner')], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
expect(missing.status).toBe(3);
expect(missing.stderr).toContain('(FLAKY_COMMAND_NOT_STARTED)');
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('every FLAKY_ code the bin and the library emit is in the docs table', () => {
const doc = fs.readFileSync(path.join(ROOT, 'docs', 'flaky.md'), 'utf-8');
const src = fs.readFileSync(path.join(ROOT, 'bin', 'gstack-flaky'), 'utf-8') + fs.readFileSync(path.join(ROOT, 'lib', 'flaky-run.ts'), 'utf-8') + fs.readFileSync(path.join(ROOT, 'lib', 'junit.ts'), 'utf-8');
const codes = [...new Set(src.match(/FLAKY_[A-Z_]+(?=['":])/g))].filter(c => c !== 'FLAKY_RESULT' && c !== 'FLAKY_RERUN');
expect(codes.length).toBeGreaterThan(5);
expect(codes.filter(c => !doc.includes(`| \`${c}\` |`))).toEqual([]);
});
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']);
+1
View File
@@ -151,6 +151,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = {
'test/fixtures/investigate-owned-question-39195396438.json': ['test/workflow-boundaries-fixture.test.ts'],
'test/fixtures/judge-calibration/**': ['scripts/judge-calibration.ts', 'test/judge-calibration.test.ts'],
'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'],
'test/fixtures/junit/**': ['test/flaky-run.test.ts'],
'test/fixtures/launch-env-echo/**': ['test/launch-env-echo-ratchet.test.ts'],
'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'],
'test/fixtures/merge-train-3453/**': ['test/changelog-merge-damage.test.ts'],