mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* 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>
213 lines
9.1 KiB
TypeScript
213 lines
9.1 KiB
TypeScript
/**
|
||
* 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]!;
|