mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 06:54:50 +01:00
* 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> * fix(flaky): stream output, keep unreported runner failures red, fail-closed duplicates and nonzero reruns Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * fix: latch cancellation and check whole-command rerun reports A cancel forwarded to a runner that traps the signal now counts as killed for every later attempt, so a cancelled job is never rerun or greened. A whole-command rerun clears nothing when its report has a failed test, misses a first-run test, or its runner counted failures outside the report. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: whole-command rerun when coverage or runner-flaky tests can fail the run A coverage threshold or a fail-on-flaky policy fails a run for a reason a narrowed rerun cannot reproduce, so either forces a whole-command rerun. --junit refuses an existing file that is not a JUnit report instead of deleting it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(flaky): rerun the whole command by default; targeted rerun is opt-in A rerun counts only on the runner's own full green verdict: exit 0, no failed or missing test, nothing counted outside the report. That covers every way a run can fail, which a narrowed rerun cannot (a coverage threshold with an lcov reporter, a fail-on-flaky policy, a test never reached). --rerun-scope test reruns the failed test alone, with its preconditions documented and the complete-run checks kept as a safety net. An unreadable existing --junit file is a usage error, not a crash. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: gstack-flaky CHANGELOG entry and standalone-binaries row (GSTA-158) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
369 lines
21 KiB
TypeScript
369 lines
21 KiB
TypeScript
/**
|
|
* gstack-flaky run (GSTA-158): run a test command, read its JUnit, rerun once
|
|
* when tests failed, 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. When the runner retried (Playwright `retries` with retry-aware
|
|
* JUnit), its attempts stand and nothing is rerun again.
|
|
* - The rerun repeats the whole command by default, and counts only on the
|
|
* runner's own full green verdict: exit 0, no failed test, every first-run
|
|
* test present, nothing counted outside the report. Only that verdict covers
|
|
* every reason a run can fail (a test it never reached, a coverage
|
|
* threshold, a fail-on-flaky policy).
|
|
* - rerunScope 'test' (opt-in) reruns the failed test alone, and only with
|
|
* proof that the first run was complete: the runner's own summary, its
|
|
* test-failure exit status 1, no early stop, no coverage and no runner-flaky
|
|
* test; otherwise, and always for generic runners, the whole command reruns.
|
|
* - 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; 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';
|
|
import { spawn as spawnChild, 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';
|
|
/** 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;
|
|
branch?: string;
|
|
run_url?: string;
|
|
excerpt: string[];
|
|
}
|
|
|
|
export interface RunOptions {
|
|
command: string[];
|
|
junit: string;
|
|
runner?: JunitRunner;
|
|
maxReruns: number;
|
|
/** 'command' (default): rerun the whole command. 'test': rerun the failed test alone when the first run is proven complete. */
|
|
rerunScope?: 'command' | 'test';
|
|
cwd?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
/** Runs one attempt; resolves with its exit status and the tail of its combined output. */
|
|
spawn?: (argv: string[], env: NodeJS.ProcessEnv, cwd: string) => Promise<Attempted>;
|
|
now?: () => Date;
|
|
}
|
|
|
|
export interface TestOutcome {
|
|
test: JunitTest;
|
|
status: 'flaky-pass' | 'fail' | 'rerun-fail';
|
|
attempts: string[];
|
|
reason?: string;
|
|
}
|
|
|
|
export interface Attempted { exit: number; output: string; signal?: string }
|
|
|
|
export interface RunResult {
|
|
exitCode: 0 | 1 | 3;
|
|
code?: string;
|
|
message?: string;
|
|
tests: number;
|
|
commandExit: number;
|
|
outcomes: TestOutcome[];
|
|
reruns: number;
|
|
/** Why the rerun was the whole command (the default, or why a requested targeted rerun was refused); absent for targeted reruns. */
|
|
wholeRerun?: string;
|
|
ledger: LedgerLine[];
|
|
}
|
|
|
|
export const DEFAULT_MAX_RERUNS = 3;
|
|
|
|
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
/** 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(' '))}$`];
|
|
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;
|
|
|
|
/** Set once gstack-flaky itself is asked to stop; every attempt after that counts as killed, whatever exit the runner chose. */
|
|
let cancelled: string | undefined;
|
|
|
|
/** Stream the child's output live (a quiet runner trips CI idle watchdogs) and keep its tail for reconciliation. */
|
|
function defaultSpawn(argv: string[], env: NodeJS.ProcessEnv, cwd: string): Promise<Attempted> {
|
|
return new Promise((resolve, reject) => {
|
|
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) => { cancelled ??= s; 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 => { release(); reject(new JunitError('FLAKY_COMMAND_NOT_STARTED', `could not start ${argv[0]}: ${e.message}`)); });
|
|
child.on('close', (code, signal) => {
|
|
release();
|
|
const killed = signal ?? cancelled;
|
|
resolve({ exit: code ?? 128, output: tail, ...(killed ? { signal: killed } : {}) });
|
|
});
|
|
});
|
|
}
|
|
|
|
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 = 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);
|
|
const fails = last(/^\s*(\d+) fail\s*$/gm);
|
|
if (errors > 0) return `bun reported ${errors} error(s) outside the JUnit report (a file failed to load or threw between tests)`;
|
|
if (fails > reportedFailed) return `bun counted ${fails} failing tests, the JUnit report ${reportedFailed}`;
|
|
}
|
|
if (runner === 'playwright') {
|
|
const outside = last(/(\d+) errors? (?:was|were) not a part of any test/g);
|
|
const fails = last(/^\s*(\d+) failed\s*$/gm);
|
|
if (outside > 0) return `Playwright reported ${outside} error(s) outside any test`;
|
|
if (fails > reportedFailed) return `Playwright counted ${fails} failed tests, the JUnit report ${reportedFailed}`;
|
|
}
|
|
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 and its exit status explained by the failed tests alone. A run that
|
|
* stopped early reports only the tests it reached, and a targeted rerun would
|
|
* never run the rest; a coverage threshold or a fail-on-flaky policy fails the
|
|
* run for a reason a narrowed rerun cannot reproduce.
|
|
*/
|
|
export function incompleteRun(runner: JunitRunner, output: string, exit: number, command: string[] = [], runnerFlaky = 0): 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 (command.some(a => a === '--coverage' || a.startsWith('--coverage=')) || /%\s*Funcs\s*\|\s*%\s*Lines|coverage threshold/i.test(text)) return 'coverage is measured, and a narrowed rerun cannot reproduce a coverage failure';
|
|
if (runnerFlaky) return `the runner retried ${runnerFlaky} flaky test(s), which a fail-on-flaky policy can fail the run on`;
|
|
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;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** 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 [];
|
|
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. */
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What keeps a rerun from clearing anything even when it exited 0: failures
|
|
* its runner counted outside the report and, for a whole-command rerun, any
|
|
* failed test or any first-run test missing from its report.
|
|
*/
|
|
function rerunProblem(again: Attempted & { report?: JunitReport }, first: JunitReport, whole: boolean): string | undefined {
|
|
if (!again.report) return undefined;
|
|
const failedNow = again.report.tests.filter(x => finalAttempt(x) === 'fail');
|
|
const unreported = unreportedFailures(first.runner, again.output, failedNow.length);
|
|
if (unreported) return unreported;
|
|
if (!whole) return undefined;
|
|
if (failedNow.length) return `${failedNow.length} test(s) failed in the whole-command rerun`;
|
|
const seen = new Set(again.report.tests.map(x => x.id));
|
|
const missing = first.tests.filter(x => !seen.has(x.id)).length;
|
|
return missing ? `${missing} test(s) of the first run are missing from the whole-command rerun` : undefined;
|
|
}
|
|
|
|
/** 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, problem?: string): { 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 (problem) return { status: 'rerun-fail', attempts, reason: `rerun: ${problem}` };
|
|
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) {
|
|
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) !== '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` };
|
|
}
|
|
const unreported = unreportedFailures(report.runner, first.output, failed.length);
|
|
if (unreported) {
|
|
return { exitCode: 1, code: 'FLAKY_UNREPORTED_FAILURES', commandExit: first.exit, ...empty, tests: report.tests.length,
|
|
outcomes: failed.map(t => ({ test: t, status: 'fail' as const, attempts: t.attempts, reason: 'not rerun: the runner reported failures outside the JUnit report' })),
|
|
message: `${unreported}; nothing was rerun` };
|
|
}
|
|
const prov = provenance(cwd, env);
|
|
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 }));
|
|
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` });
|
|
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 ? undefined
|
|
: opts.rerunScope !== 'test' ? 'the default; --rerun-scope test reruns the failed test alone'
|
|
: incompleteRun(report.runner, first.output, first.exit, opts.command, runnerFlaky.length) ?? undefined;
|
|
const junit = path.resolve(cwd, opts.junit);
|
|
const firstXml = fs.readFileSync(junit);
|
|
let reruns = 0;
|
|
const rerun = async (argv: string[]) => {
|
|
reruns++;
|
|
const again = await attempt(opts, argv, cwd, env, report.runner);
|
|
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);
|
|
const problem = rerunProblem(again, report, true);
|
|
for (const t of eligible) record(t, rerunVerdict(again, t, problem), '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, rerunProblem(again, report, false)), '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, ...(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_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): 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');
|
|
} catch (e) {
|
|
warn(`FLAKY_LEDGER_WRITE_FAILED: could not append ${file} (${(e as Error).message}); the verdict is unaffected`);
|
|
}
|
|
}
|
|
|
|
/** 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} ${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;
|
|
}
|
|
|
|
/** 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(/`/g, "'").replace(/[\r\n]+/g, ' ');
|
|
return ['### Flaky passes (gstack-flaky)', '', '| Test | Attempts | First error |', '|---|---|---|',
|
|
...rows.map(o => `| \`${cell(safeText(o.test.id))}\` | ${o.attempts.join(', ')} | ${cell(safeExcerpt(o.test.excerpt)[0] ?? '')} |`), ''].join('\n');
|
|
}
|