Files
gstack/lib/junit.ts
T
Garry Tanandcapy-ai[bot] daf661374f v1.93.10.0 wave(docs): #3590 #3591 #3819 #3618 #3824 #3828 (#3847)
* Attribute every paid trial's cost: bind label-named records by testName, price aborted SDK sessions from streamed usage, model-free cases cost $0

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

* fix: case cost adds streamed sessions to record sums and keeps record cost_known

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

* fix: the paid report headline counts ledger-only session cost once

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

* fix: a cost-only record never sets the trial model

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

* test: recordE2E writes cost_case_id only for a registered label-named case

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

* docs: CHANGELOG for paid-report cost attribution (Unreleased)

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

* fix: the headline adds only streamed session cost; cache writes without a 5m key

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

* docs: how the paid report prices a trial and what stays cost-unknown

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

* Price PTY sessions from their transcripts: pin a session id, sum the session's and its subagents' usage into the ledger's streamed_cost_usd

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

* fix: transcript pricing never throws from a PTY ledger writer

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

* chore: pin transcript-cost.ts as non-behavioral at HARNESS_VERSION 6

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

* fix: recognise --session-id= and --resume= forms

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

* docs: CHANGELOG for PTY transcript pricing (Unreleased)

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

* docs: PTY sessions priced from their transcripts

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

* fix: price PTY transcripts without adding a launch argument; native AUQ capture opts out

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

* docs: harness notes and CHANGELOG match the transcript claim

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

* 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>

* feat(flaky): quarantine registry for repeat flakes (GSTA-158 P2)

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

* fix(flaky): bound renewal dates; match non-ASCII paths in check --base

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

* fix(flaky): reword the FLAKY_QUARANTINE_BASE hint so the egress scanner does not read it as a network op

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

* test(agent-sdk): every SDK caller passes the claude_code preset or names why not

The SDK's default system prompt is a bare stub that drops the line telling the
model to run /skill through the Skill tool. runAgentSdkTest now refuses a
missing, empty or bare-string prompt; non-preset callers name a reason; a free
tripwire scans test/ and scripts/ for the same mistake.

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

* test: record the SDK prompt guard as a non-behavioral harness change and baseline its tripwire

harness-version: runAgentSdkTest validates systemPrompt before starting; every
live caller already passed the claude_code preset, so no verdict can change.
test-of-test ratchet: the tripwire's subject is the eval harness by design.

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

* docs: name the preset literal callers pass and the unnamed-prompt refusal

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

* feat(eval-plan): frontier models under test (R54)

Haiku and Sonnet are not models under test. dry-run refuses a plan whose every
arm is Haiku or Sonnet unless --tier-question names what it asks about that
tier, warns on a mix, and re-costs the plan on Opus 5.5 and GPT-6.1 Sol.

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

* fix(eval-plan): trim R54 skill prose and raise the eval-plan context ceiling

The R54 paragraph and CHECK: frontier bullet were cut to the minimum; the
exemptions and verdict rule stay in docs/TESTING_INTERNALS.md. The remaining
growth is the feature, so only the eval-plan eager ceiling moves (4887 -> 5458,
the capture helper's value); other skills' ceilings are left as they were.

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

* fix(eval-plan): pilot start keeps the R54 refusal; --tier-question rejects a flag

Review pass 1 (full depth) found the frontier refusal could be skipped by
starting a pilot on Haiku or Sonnet arms behind a dry run made on other arms,
or behind a dry-run.json written before the check existed. startPilot now
refuses a pilot whose every arm is Haiku or Sonnet unless its dry run recorded
a tier question. dry-run --tier-question no longer takes the next flag as its
answer. The testing doc now says the frontier re-cost is printed for
comparison with the brief cap, not enforced.

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

* docs(support-tiers): Windows CI in the present tense now that windows-smoke is required (#3806)

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

* chore: empty commit for a clean check set at the same tree (cancelled orphan free-suite check at 97487df)

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

* docs(skills): /eval-plan entry names the R54 frontier check

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-02 17:44:00 +00:00

213 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(' > ')}`;
/** The parts of a test id, or null when it is not `<file>::<project>::<title>`. */
export function parseTestId(id: string): { file: string; project: string; title: string } | null {
const i = id.indexOf('::');
const j = i < 0 ? -1 : id.indexOf('::', i + 2);
if (i <= 0 || j < 0 || j + 2 >= id.length) return null;
return { file: id.slice(0, i), project: id.slice(i + 2, j), title: id.slice(j + 2) };
}
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]!;