tewip-ai 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/QUICKSTART.md +178 -0
  3. package/README.md +308 -0
  4. package/TEAM-GUIDE.md +234 -0
  5. package/dist/cli/tewip.js +117 -0
  6. package/dist/engine/autofix/autofix.js +63 -0
  7. package/dist/engine/autofix/candidates.js +33 -0
  8. package/dist/engine/autofix/locate.js +78 -0
  9. package/dist/engine/corrections.js +66 -0
  10. package/dist/engine/dataset.js +41 -0
  11. package/dist/engine/decide.js +130 -0
  12. package/dist/engine/device-errors.js +45 -0
  13. package/dist/engine/drift-rules.js +98 -0
  14. package/dist/engine/driver-errors.js +103 -0
  15. package/dist/engine/engine.js +76 -0
  16. package/dist/engine/error-parse.js +150 -0
  17. package/dist/engine/history.js +105 -0
  18. package/dist/engine/import/blob.js +165 -0
  19. package/dist/engine/import/junit.js +235 -0
  20. package/dist/engine/import/xml.js +104 -0
  21. package/dist/engine/llm/anthropic.js +59 -0
  22. package/dist/engine/llm/client.js +139 -0
  23. package/dist/engine/llm/context.js +141 -0
  24. package/dist/engine/llm/llm-diagnose.js +48 -0
  25. package/dist/engine/llm/prompt.js +40 -0
  26. package/dist/engine/llm/providers.js +54 -0
  27. package/dist/engine/llm/usage.js +69 -0
  28. package/dist/engine/pipeline.js +33 -0
  29. package/dist/engine/quality/frameworks.js +181 -0
  30. package/dist/engine/quality/grounding.js +203 -0
  31. package/dist/engine/quality/model.js +17 -0
  32. package/dist/engine/quality/plan-command.js +34 -0
  33. package/dist/engine/quality/plan-render.js +38 -0
  34. package/dist/engine/quality/plan.js +119 -0
  35. package/dist/engine/quality/write-command.js +146 -0
  36. package/dist/engine/quality/write-verify.js +140 -0
  37. package/dist/engine/quality/write.js +144 -0
  38. package/dist/engine/rules.js +174 -0
  39. package/dist/engine/signature.js +23 -0
  40. package/dist/engine/similarity.js +76 -0
  41. package/dist/engine/snapshot.js +56 -0
  42. package/dist/engine/timing-rules.js +188 -0
  43. package/dist/engine/trace/dom.js +92 -0
  44. package/dist/engine/trace/trace-summary.js +87 -0
  45. package/dist/engine/trace/zip.js +53 -0
  46. package/dist/eval/harvest/gh.js +21 -0
  47. package/dist/eval/harvest/harvest.js +117 -0
  48. package/dist/mcp/github-report.js +50 -0
  49. package/dist/mcp/server.js +80 -0
  50. package/dist/mcp/tools.js +124 -0
  51. package/dist/outlets/design-debt.js +22 -0
  52. package/dist/outlets/items.js +94 -0
  53. package/dist/outlets/view.js +63 -0
  54. package/dist/outlets/views/architecture.js +77 -0
  55. package/dist/outlets/views/developer.js +59 -0
  56. package/dist/outlets/views/digest.js +66 -0
  57. package/dist/outlets/views/infra.js +49 -0
  58. package/dist/outlets/views/quality.js +108 -0
  59. package/dist/outlets/views/release.js +33 -0
  60. package/dist/outlets/views/sdet.js +29 -0
  61. package/dist/reporters/run-context.js +20 -0
  62. package/dist/reporters/store.js +46 -0
  63. package/dist/reporters/triage-reporter.js +101 -0
  64. package/dist/reporters/types.js +4 -0
  65. package/dist/scripts/explain.js +58 -0
  66. package/package.json +81 -0
@@ -0,0 +1,59 @@
1
+ // PR comment for developers: which failures in this run are likely caused by their change.
2
+ import { errorShape, truncate } from '../../engine/signature.js';
3
+ import { itemBlock, sourceNote } from '../view.js';
4
+ /** Failures sharing a cause, before this many are read one by one, get a single line up front. */
5
+ const SHARED_CAUSE_MIN = 3;
6
+ /**
7
+ * Groups of failures with the same category and the same error: one outage or one changed
8
+ * component often fails many tests, and the reader should see that before twenty entries.
9
+ */
10
+ export function sharedCauses(items) {
11
+ const groups = new Map();
12
+ // Undecided failures are left out: the agent does not know their cause, so it cannot say two share one.
13
+ for (const i of items.filter((x) => x.diagnosis.category !== 'UNKNOWN')) {
14
+ // The locator is part of the cause: two tests timing out on different elements are two problems.
15
+ const key = [i.diagnosis.category, errorShape(i.errorHead), i.locator ?? ''].join('|');
16
+ groups.set(key, [...(groups.get(key) ?? []), i]);
17
+ }
18
+ return [...groups.entries()]
19
+ .filter(([, xs]) => xs.length >= SHARED_CAUSE_MIN)
20
+ .map(([key, xs]) => ({ category: key.split('|')[0], error: xs[0].errorHead, items: xs }))
21
+ .sort((a, b) => b.items.length - a.items.length);
22
+ }
23
+ /**
24
+ * The order the PR comment shows failures in: likely caused by this change, then probably not,
25
+ * then unclear. The visible "#n" numbers follow it, and the hidden map that turns a person's
26
+ * "/triage ... #n" back into a test is written in the same order, so the two always agree.
27
+ */
28
+ export function commentOrder(items) {
29
+ const by = (verdict) => items.filter((i) => i.relation.verdict === verdict);
30
+ return [...by('likely-this-change'), ...by('not-this-change'), ...by('unclear')];
31
+ }
32
+ export function prCommentView(items, runId, dataset) {
33
+ const commit = items[0]?.commit.slice(0, 8) || 'unknown commit';
34
+ const by = (verdict) => items.filter((i) => i.relation.verdict === verdict);
35
+ const likely = by('likely-this-change');
36
+ const not = by('not-this-change');
37
+ const unclear = by('unclear');
38
+ const numbers = new Map(commentOrder(items).map((i, n) => [i, n + 1]));
39
+ const section = (title, xs) => xs.length === 0 ? [] : [`### ${title} (${xs.length})`, '', ...xs.map((i) => itemBlock(i, [`Related to your change? ${i.relation.why}`], numbers.get(i))), ''];
40
+ // Nothing failed: say so plainly instead of an empty list under "0 failing test(s)".
41
+ if (items.length === 0) {
42
+ const markdown = ['## Tewip: all tests passed', '', 'Nothing to triage in this run.', '', sourceNote(dataset), ''].join('\n');
43
+ return { id: 'pr-comment', role: 'developer', title: `PR comment for run ${runId}`, scope: 'run', items, markdown };
44
+ }
45
+ const markdown = [
46
+ `## Tewip: ${items.length} failing test(s) on \`${commit}\``,
47
+ '',
48
+ `**${likely.length} likely caused by this change**, ${not.length} probably not, ${unclear.length} unclear.`,
49
+ '',
50
+ ...sharedCauses(items).slice(0, 3).map((g) => `> **${g.items.length} failures look like one cause** (${g.category}, same error${g.items[0].locator ? ' on the same element' : ''}): \`${truncate(g.error, 110)}\`. Fixing it once may clear them all.`),
51
+ ...(sharedCauses(items).length > 0 ? [''] : []),
52
+ sourceNote(dataset),
53
+ '',
54
+ ...section('Likely caused by this change', likely),
55
+ ...section('Probably not caused by this change', not),
56
+ ...section('Unclear', unclear),
57
+ ].join('\n');
58
+ return { id: 'pr-comment', role: 'developer', title: `PR comment for run ${runId}`, scope: 'run', items, markdown };
59
+ }
@@ -0,0 +1,66 @@
1
+ import { componentOf, isDefaultBranch } from '../items.js';
2
+ import { clean, itemBlock, PLAIN_CATEGORY, sourceNote } from '../view.js';
3
+ /** Degrading: the failure rate grew by at least this factor, with at least MIN_FAILURES in the later half. */
4
+ const GROWTH = 1.5;
5
+ const MIN_FAILURES = 3;
6
+ /** Spec file inside a test id ("chromium > editor/blocks/image.spec.js > ..."), or its first segment. */
7
+ const fileOfTestId = (testId) => testId.split(' > ').find((s) => /\.(spec|test)\.[jt]sx?$/.test(s)) ?? testId.split(' > ')[0];
8
+ export function digestView(allItems, allSummaries, dataset) {
9
+ // Leads care about the main branch: pull-request runs fail on unfinished work by design.
10
+ const mainRuns = allSummaries.filter((s) => isDefaultBranch(s.branch ?? ''));
11
+ const mainOnly = mainRuns.length >= 2 && mainRuns.length < allSummaries.length;
12
+ const summaries = mainOnly ? mainRuns : allSummaries;
13
+ const items = mainOnly ? allItems.filter((i) => i.onDefaultBranch) : allItems;
14
+ const scope = mainOnly ? `${summaries.length} main-branch runs (pull-request runs left out)` : `all ${summaries.length} runs`;
15
+ const times = summaries.map((s) => s.startedAt).sort();
16
+ const middle = times[Math.floor(times.length / 2)] ?? '';
17
+ const half = (at) => (at < middle ? 0 : 1);
18
+ const period = (h) => (h === 0 ? `${times[0]?.slice(0, 10)} to ${middle.slice(0, 10)}` : `${middle.slice(0, 10)} to ${times.at(-1)?.slice(0, 10)}`);
19
+ const attempts = new Map();
20
+ for (const s of summaries) {
21
+ for (const [testId, n] of Object.entries(s.attempts)) {
22
+ const c = componentOf(fileOfTestId(testId));
23
+ const a = attempts.get(c) ?? [0, 0];
24
+ a[half(s.startedAt)] += n;
25
+ attempts.set(c, a);
26
+ }
27
+ }
28
+ const rows = [...attempts.entries()]
29
+ .map(([component, [a0, a1]]) => {
30
+ const xs = items.filter((i) => i.component === component);
31
+ const f0 = xs.filter((i) => half(i.timestamp) === 0).length;
32
+ const later = xs.filter((i) => half(i.timestamp) === 1);
33
+ return { component, r0: a0 ? f0 / a0 : 0, r1: a1 ? later.length / a1 : 0, f0, later };
34
+ })
35
+ .filter((r) => r.later.length >= MIN_FAILURES && r.r1 >= r.r0 * GROWTH)
36
+ .sort((a, b) => b.r1 - a.r1)
37
+ .slice(0, 6);
38
+ const shown = [];
39
+ const pct = (r) => `${(r * 100).toFixed(2)}%`;
40
+ const main = (xs) => {
41
+ const counts = new Map();
42
+ for (const i of xs)
43
+ counts.set(i.diagnosis.category, (counts.get(i.diagnosis.category) ?? 0) + 1);
44
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
45
+ };
46
+ const markdown = [
47
+ '# Risk digest',
48
+ '',
49
+ `Comparing ${period(0)} with ${period(1)}, over ${scope}. ${rows.length === 0 ? 'No area got noticeably worse.' : `${rows.length} area(s) got worse.`}`,
50
+ '',
51
+ sourceNote(dataset),
52
+ '',
53
+ ...rows.flatMap((r) => {
54
+ const example = [...r.later].sort((a, b) => b.diagnosis.confidence - a.diagnosis.confidence)[0];
55
+ shown.push(example);
56
+ return [
57
+ `## ${clean(r.component)}: failure rate ${pct(r.r0)} → ${pct(r.r1)} (${r.later.length} failures, mostly ${PLAIN_CATEGORY[main(r.later)]})`,
58
+ `Next action: ask the "${clean(r.component)}" owners to review the new failures this week.`,
59
+ '',
60
+ itemBlock(example, ['(example)']),
61
+ '',
62
+ ];
63
+ }),
64
+ ].join('\n');
65
+ return { id: 'digest', role: 'lead', title: 'Risk digest', scope: 'window', items: shown, markdown };
66
+ }
@@ -0,0 +1,49 @@
1
+ // Infrastructure view for senior DevOps / SRE: environment failures of the run grouped by the
2
+ // failing dependency, with retry recovery and one grouped alert instead of N pages.
3
+ import { errorShape, truncate } from '../../engine/signature.js';
4
+ import { clean, itemBlock, sourceNote } from '../view.js';
5
+ /** What broke, as specific as the evidence allows: the network signal, else the error shape. */
6
+ export function signatureOf(i) {
7
+ const net = i.diagnosis.evidence.find((e) => e.source === 'env-network')?.data;
8
+ if (net?.match)
9
+ return `${net.kind ?? 'network'}: ${truncate(net.match, 60)}`;
10
+ return truncate(errorShape(i.errorHead), 80) || 'environment failure (no error text)';
11
+ }
12
+ export function groupEnv(items) {
13
+ const groups = new Map();
14
+ for (const i of items.filter((x) => x.diagnosis.category === 'ENV'))
15
+ groups.set(signatureOf(i), [...(groups.get(signatureOf(i)) ?? []), i]);
16
+ return [...groups.entries()]
17
+ .map(([signature, xs]) => ({
18
+ signature,
19
+ items: xs,
20
+ recovered: xs.filter((x) => x.recoveredOnRetry).length,
21
+ hosts: [...new Set(xs.flatMap((x) => [x.errorHead, ...x.evidence].join(' ').match(/https?:\/\/[^\s/'"]+/g) ?? []))].slice(0, 3),
22
+ }))
23
+ .sort((a, b) => b.items.length - a.items.length);
24
+ }
25
+ export function infraView(items, runId, dataset) {
26
+ const groups = groupEnv(items);
27
+ const env = groups.flatMap((g) => g.items);
28
+ const alert = [
29
+ `[tewip] run ${runId}: ${env.length} test failure(s) caused by the test environment, not by code.`,
30
+ ...groups.map((g) => `- ${clean(g.signature)}: ${g.items.length} test(s), ${g.recovered} recovered on retry${g.hosts.length ? ` (hosts: ${g.hosts.join(', ')})` : ''}`),
31
+ 'Test code was not changed. Next: re-run the affected tests once; if they fail again, investigate the dependency above.',
32
+ ];
33
+ const markdown = [
34
+ `# Infrastructure view: run ${runId}`,
35
+ '',
36
+ env.length === 0 ? 'No environment failures in this run.' : `${env.length} of ${items.length} failing test(s) are environment problems, in ${groups.length} group(s).`,
37
+ '',
38
+ sourceNote(dataset),
39
+ '',
40
+ ...(env.length === 0 ? [] : ['## One grouped alert (send this instead of one page per test)', '', '```text', ...alert, '```', '']),
41
+ ...groups.flatMap((g) => [
42
+ `## ${clean(g.signature)}: ${g.items.length} test(s), ${g.recovered} recovered on retry`,
43
+ '',
44
+ ...g.items.map((i) => itemBlock(i, [`Recovered on retry: ${i.recoveredOnRetry ? 'yes' : 'no'}`])),
45
+ '',
46
+ ]),
47
+ ].join('\n');
48
+ return { id: 'infra', role: 'devops', title: `Infrastructure view for run ${runId}`, scope: 'run', items: env, markdown };
49
+ }
@@ -0,0 +1,108 @@
1
+ // Engineering-quality view for senior / principal engineers, over the whole dataset window:
2
+ // flakiness hotspots, recurring root-cause patterns and test-design debt ranked by CI cost.
3
+ import { errorShape, truncate } from '../../engine/signature.js';
4
+ import { clean, itemBlock, PLAIN_CATEGORY, sourceNote } from '../view.js';
5
+ const minutes = (ms) => `${(ms / 60_000).toFixed(1)} CI min`;
6
+ const groupBy = (xs, key) => {
7
+ const m = new Map();
8
+ for (const x of xs)
9
+ m.set(key(x), [...(m.get(key(x)) ?? []), x]);
10
+ return [...m.entries()];
11
+ };
12
+ const wasted = (xs) => xs.reduce((sum, i) => sum + i.wastedMs, 0);
13
+ /** Separate runs a test must have flaked in before it is worth naming for quarantine. */
14
+ const QUARANTINE_MIN_RUNS = 2;
15
+ /** The shortest tail of a test's name that no other listed test shares, so rows stay telling apart. */
16
+ function name(testId, others) {
17
+ const parts = testId.split(' > ');
18
+ for (let take = 1; take < parts.length; take++) {
19
+ const tail = parts.slice(-take).join(' > ');
20
+ if (others.filter((o) => o.testId.endsWith(tail)).length === 1)
21
+ return tail;
22
+ }
23
+ return testId;
24
+ }
25
+ export function qualityView(items, dataset) {
26
+ const shown = [];
27
+ const example = (xs) => {
28
+ const pick = [...xs].sort((a, b) => b.diagnosis.confidence - a.diagnosis.confidence)[0];
29
+ shown.push(pick);
30
+ return itemBlock(pick, ['(example)']);
31
+ };
32
+ const hotspots = groupBy(items.filter((i) => i.diagnosis.category === 'TIMING'), (i) => i.component)
33
+ .map(([component, xs]) => ({ component, xs, tests: new Set(xs.map((i) => i.testId)).size }))
34
+ .sort((a, b) => wasted(b.xs) - wasted(a.xs))
35
+ .slice(0, 8);
36
+ // The flaky tests that cost the most, named one by one: a hotspot per component tells a lead
37
+ // where to look, but an SDET needs the list of tests to quarantine this morning.
38
+ const quarantine = groupBy(items.filter((i) => i.diagnosis.category === 'TIMING'), (i) => i.testId)
39
+ .map(([testId, xs]) => ({ testId, xs, runs: new Set(xs.map((i) => i.runId)).size }))
40
+ .filter((q) => q.runs >= QUARANTINE_MIN_RUNS)
41
+ .sort((a, b) => wasted(b.xs) - wasted(a.xs))
42
+ .slice(0, 5);
43
+ const patterns = groupBy(items, (i) => `${i.diagnosis.category} ${truncate(errorShape(i.errorHead), 90)}`)
44
+ .filter(([, xs]) => xs.length >= 3)
45
+ .sort((a, b) => b[1].length - a[1].length)
46
+ .slice(0, 6);
47
+ const debt = groupBy(items.flatMap((i) => i.debt.map((d) => ({ d, i }))), (x) => `${x.d.kind} ${x.d.detail}`)
48
+ .map(([, xs]) => ({ debt: xs[0].d, xs: xs.map((x) => x.i) }))
49
+ .sort((a, b) => wasted(b.xs) - wasted(a.xs))
50
+ .slice(0, 10);
51
+ const markdown = [
52
+ '# Engineering quality: whole window',
53
+ '',
54
+ `${items.length} failing test case(s), ${minutes(wasted(items))} spent on failed attempts.`,
55
+ '',
56
+ sourceNote(dataset),
57
+ '',
58
+ '## Flakiness hotspots (TIMING), ranked by CI time lost',
59
+ '',
60
+ ...(hotspots.length === 0
61
+ ? ['None.', '']
62
+ : hotspots.flatMap((h) => [
63
+ `### ${h.component}: ${h.xs.length} flaky failure(s) in ${h.tests} test(s), ${minutes(wasted(h.xs))}`,
64
+ `Next action: give "${h.component}" an owner to stabilize its flakiest test first.`,
65
+ '',
66
+ example(h.xs),
67
+ '',
68
+ ])),
69
+ '## Quarantine candidates: flaky tests ranked by CI time lost',
70
+ '',
71
+ ...(quarantine.length === 0
72
+ ? [`None: no test was diagnosed TIMING in ${QUARANTINE_MIN_RUNS} or more separate runs.`, '']
73
+ : [
74
+ `| Test | File | Failed runs | CI time lost | Next action |`,
75
+ `|---|---|---:|---:|---|`,
76
+ ...quarantine.map((q) => {
77
+ const first = q.xs[0];
78
+ return `| ${clean(name(q.testId, quarantine))} | \`${first.file}\` | ${q.runs} | ${minutes(wasted(q.xs))} | Quarantine it (\`test.fixme\`) and open one issue to fix the race; it is costing this much every window |`;
79
+ }),
80
+ '',
81
+ 'Quarantining stops a flaky test from blocking other people while the race is fixed. It is a decision for a person: the agent never edits a test to skip it.',
82
+ '',
83
+ ]),
84
+ '## Recurring root-cause patterns (3+ occurrences)',
85
+ '',
86
+ ...(patterns.length === 0
87
+ ? ['None.', '']
88
+ : patterns.flatMap(([key, xs]) => [
89
+ `### ${xs.length}x ${PLAIN_CATEGORY[xs[0].diagnosis.category]}: ${clean(key.slice(key.indexOf(' ') + 1)) || '(no error text)'}`,
90
+ `Next action: fix the shared cause once instead of triaging ${xs.length} failures one by one.`,
91
+ '',
92
+ example(xs),
93
+ '',
94
+ ])),
95
+ '## Test-design debt, ranked by CI time lost',
96
+ '',
97
+ ...(debt.length === 0
98
+ ? ['No fixed waits or brittle selectors found near the failing lines.', '']
99
+ : debt.flatMap((d) => [
100
+ `### ${d.debt.kind === 'hard-wait' ? 'Fixed wait' : 'Brittle selector'}: \`${clean(d.debt.detail)}\` (${d.xs.length} failure(s), ${minutes(wasted(d.xs))})`,
101
+ `Next action: ${d.debt.kind === 'hard-wait' ? 'replace it with a web-first assertion on the awaited element' : 'switch to a role, label or test-id locator'}.`,
102
+ '',
103
+ example(d.xs),
104
+ '',
105
+ ])),
106
+ ].join('\n');
107
+ return { id: 'quality', role: 'senior-engineer', title: 'Engineering quality view', scope: 'window', items: [...new Set(shown)], markdown };
108
+ }
@@ -0,0 +1,33 @@
1
+ // Release-readiness report for UAT / business users: is the product broken, in plain words.
2
+ // Only confident product problems block; everything else is explained as "not the product".
3
+ import { ESCALATE_BELOW } from '../../engine/decide.js';
4
+ import { clean, itemBlock, PLAIN_CATEGORY, sourceNote } from '../view.js';
5
+ export function releaseView(items, runId, dataset) {
6
+ const blockers = items.filter((i) => i.diagnosis.category === 'REAL_BUG' && i.diagnosis.confidence >= ESCALATE_BELOW);
7
+ const review = items.filter((i) => (i.diagnosis.category === 'REAL_BUG' && i.diagnosis.confidence < ESCALATE_BELOW) || i.diagnosis.category === 'UNKNOWN');
8
+ const fine = items.filter((i) => !blockers.includes(i) && !review.includes(i));
9
+ const areas = [...new Set(blockers.map((i) => i.component))];
10
+ const verdict = blockers.length > 0
11
+ ? `**NOT READY**: ${blockers.length} product problem(s) found, in ${areas.map((a) => `"${a}"`).join(', ')}.`
12
+ : review.length > 0
13
+ ? `**WAIT**: no confirmed product problem, but ${review.length} failure(s) are still being looked at.`
14
+ : `**READY from a product point of view**: none of the ${items.length} failing test(s) is a product problem.`;
15
+ const plain = (i) => review.includes(i)
16
+ ? `In plain words: not understood yet; someone is checking whether "${clean(i.title)}" is a product problem.`
17
+ : i.diagnosis.category === 'REAL_BUG'
18
+ ? `In plain words: in the "${i.component}" area, "${clean(i.title)}" did not work as expected.`
19
+ : `In plain words: ${PLAIN_CATEGORY[i.diagnosis.category]}, not a problem in the product itself.`;
20
+ const section = (title, xs) => (xs.length === 0 ? [] : [`## ${title} (${xs.length})`, '', ...xs.map((i) => itemBlock(i, [plain(i)])), '']);
21
+ const markdown = [
22
+ `# Release readiness: run ${runId}`,
23
+ '',
24
+ verdict,
25
+ '',
26
+ sourceNote(dataset),
27
+ '',
28
+ ...section('Product problems (block the release)', blockers),
29
+ ...section('Still being looked at', review),
30
+ ...section('Not product problems (do not block)', fine),
31
+ ].join('\n');
32
+ return { id: 'release', role: 'business', title: `Release readiness for run ${runId}`, scope: 'run', items, markdown };
33
+ }
@@ -0,0 +1,29 @@
1
+ import { itemBlock, sourceNote } from '../view.js';
2
+ const ORDER = [
3
+ { kind: 'escalate', title: 'Needs a human decision' },
4
+ { kind: 'fix-locator', title: 'Fix a locator (UI changed)' },
5
+ { kind: 'fix-wait', title: 'Replace a fixed wait' },
6
+ { kind: 'stabilize-test', title: 'Stabilize a flaky test' },
7
+ { kind: 'fix-precondition', title: 'Fix test data / setup' },
8
+ { kind: 'file-bug', title: 'Product bugs: file and move on' },
9
+ { kind: 'rerun-and-notify', title: 'Environment: re-run, do not touch the test' },
10
+ ];
11
+ export function sdetBoardView(items, runId, dataset) {
12
+ const counts = ORDER.map(({ kind, title }) => ({ title, n: items.filter((i) => i.action.kind === kind).length })).filter((c) => c.n > 0);
13
+ const agentCan = items.filter((i) => i.action.autonomous).length;
14
+ const markdown = [
15
+ `# Triage board: run ${runId}`,
16
+ '',
17
+ `${items.length} failing test(s). ${agentCan} can be handled by the agent without a human (once each action is automated); ${items.length - agentCan} need a person.`,
18
+ '',
19
+ sourceNote(dataset),
20
+ '',
21
+ ...counts.map((c) => `- ${c.title}: **${c.n}**`),
22
+ '',
23
+ ...ORDER.flatMap(({ kind, title }) => {
24
+ const xs = items.filter((i) => i.action.kind === kind);
25
+ return xs.length === 0 ? [] : [`## ${title} (${xs.length})`, '', ...xs.map((i) => itemBlock(i, [`Why this action: ${i.action.reason}`])), ''];
26
+ }),
27
+ ].join('\n');
28
+ return { id: 'sdet-board', role: 'sdet', title: `SDET triage board for run ${runId}`, scope: 'run', items, markdown };
29
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolves the run id once per `playwright test` invocation. The value is stored in
3
+ * process.env so the config (for the per-run outputDir), the reporter and worker
4
+ * processes (which inherit env) all agree on it.
5
+ */
6
+ export function resolveRunId() {
7
+ const existing = process.env.TRIAGE_RUN_ID;
8
+ if (existing)
9
+ return existing;
10
+ const runId = process.env.CI_RUN_ID || `run-${new Date().toISOString().replace(/[-:.]/g, '')}`;
11
+ process.env.TRIAGE_RUN_ID = runId;
12
+ return runId;
13
+ }
14
+ export function resolveGitSha() {
15
+ return process.env.GIT_SHA || process.env.GITHUB_SHA || '';
16
+ }
17
+ /** On pull requests GitHub sets GITHUB_HEAD_REF (the PR branch); on pushes GITHUB_REF_NAME. */
18
+ export function resolveBranch() {
19
+ return process.env.GIT_BRANCH || process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || '';
20
+ }
@@ -0,0 +1,46 @@
1
+ // JSONL persistence shared by the reporter, the history script and future layers.
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ export const PROJECT_ROOT = join(import.meta.dirname, '..');
5
+ export const DATA_DIR = join(PROJECT_ROOT, 'data');
6
+ /** Where the reporter collects failures. TRIAGE_DATA_DIR redirects it (e.g. the drift lab uses data/lab). */
7
+ const COLLECT_DIR = process.env.TRIAGE_DATA_DIR ? resolve(PROJECT_ROOT, process.env.TRIAGE_DATA_DIR) : DATA_DIR;
8
+ export const TRIAGE_FILE = join(COLLECT_DIR, 'triage.jsonl');
9
+ export const RUNS_FILE = join(COLLECT_DIR, 'runs.jsonl');
10
+ export function appendJsonl(file, rows) {
11
+ if (rows.length === 0)
12
+ return;
13
+ mkdirSync(dirname(file), { recursive: true });
14
+ appendFileSync(file, rows.map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
15
+ }
16
+ /** Reads a JSONL file; missing file yields []. Malformed lines are skipped with a warning. */
17
+ export function readJsonl(file) {
18
+ if (!existsSync(file))
19
+ return [];
20
+ const rows = [];
21
+ readFileSync(file, 'utf8')
22
+ .split(/\r?\n/)
23
+ .forEach((line, i) => {
24
+ if (!line.trim())
25
+ return;
26
+ try {
27
+ rows.push(JSON.parse(line));
28
+ }
29
+ catch {
30
+ console.warn(`[triage] skipping malformed line ${i + 1} in ${file}`);
31
+ }
32
+ });
33
+ return rows;
34
+ }
35
+ export const readTriageRecords = (file = TRIAGE_FILE) => readJsonl(file);
36
+ export const readRunSummaries = (file = RUNS_FILE) => readJsonl(file);
37
+ /**
38
+ * The triage/runs files of a dataset directory. The default dataset is data/ (what the
39
+ * reporter collects); imported datasets live elsewhere, e.g. data/real/<source>/.
40
+ */
41
+ export function datasetFiles(dir) {
42
+ if (!dir)
43
+ return { dir: DATA_DIR, triage: TRIAGE_FILE, runs: RUNS_FILE };
44
+ const abs = isAbsolute(dir) ? dir : join(PROJECT_ROOT, dir);
45
+ return { dir: abs, triage: join(abs, 'triage.jsonl'), runs: join(abs, 'runs.jsonl') };
46
+ }
@@ -0,0 +1,101 @@
1
+ import { relative, sep } from 'node:path';
2
+ import { stripVTControlCharacters } from 'node:util';
3
+ import { HistoryIndex } from '../engine/history.js';
4
+ import { llmFromEnv } from '../engine/llm/client.js';
5
+ import { diagnosePipeline } from '../engine/pipeline.js';
6
+ import { resolveBranch, resolveGitSha, resolveRunId } from './run-context.js';
7
+ import { appendJsonl, PROJECT_ROOT, readRunSummaries, readTriageRecords, RUNS_FILE, TRIAGE_FILE } from './store.js';
8
+ import { SCHEMA_VERSION } from './types.js';
9
+ const COLLECTED_ATTACHMENTS = new Set(['trace', 'screenshot', 'error-context']);
10
+ const projectPath = (p) => relative(PROJECT_ROOT, p).split(sep).join('/');
11
+ export function testIdOf(test) {
12
+ return test.titlePath().filter(Boolean).join(' > ');
13
+ }
14
+ /**
15
+ * Collects failed / timed-out attempts and appends them to data/triage.jsonl when the
16
+ * run ends. Also appends one run summary line to data/runs.jsonl so failure rates can
17
+ * be computed later.
18
+ */
19
+ export default class TriageReporter {
20
+ runId = resolveRunId();
21
+ gitSha = resolveGitSha();
22
+ startedAt = new Date().toISOString();
23
+ records = [];
24
+ attempts = {};
25
+ onBegin() {
26
+ this.startedAt = new Date().toISOString();
27
+ }
28
+ onTestEnd(test, result) {
29
+ const testId = testIdOf(test);
30
+ if (result.status !== 'skipped' && result.status !== 'interrupted') {
31
+ this.attempts[testId] = (this.attempts[testId] ?? 0) + 1;
32
+ }
33
+ if (result.status !== 'failed' && result.status !== 'timedOut')
34
+ return;
35
+ this.records.push(this.toRecord(test, result, testId));
36
+ }
37
+ async onEnd(result) {
38
+ const summary = {
39
+ schemaVersion: SCHEMA_VERSION,
40
+ runId: this.runId,
41
+ gitSha: this.gitSha,
42
+ branch: resolveBranch(),
43
+ startedAt: this.startedAt,
44
+ finishedAt: new Date().toISOString(),
45
+ status: result.status,
46
+ attempts: this.attempts,
47
+ };
48
+ const records = await this.enrich(this.records, summary);
49
+ appendJsonl(TRIAGE_FILE, records);
50
+ appendJsonl(RUNS_FILE, [summary]);
51
+ const by = (source) => records.filter((r) => r.diagnosis?.source === source && r.diagnosis.category !== 'UNKNOWN').length;
52
+ const unknown = records.filter((r) => !r.diagnosis || r.diagnosis.category === 'UNKNOWN').length;
53
+ console.log(`[triage] ${records.length} failure record(s) appended to ${projectPath(TRIAGE_FILE)} (runId=${this.runId}); ` +
54
+ `rules classified ${by('rules')}, LLM ${by('llm')}, ${unknown} UNKNOWN`);
55
+ }
56
+ /**
57
+ * Diagnosis step: runs after the run finishes and before records are persisted. Rules
58
+ * decide first; failures they leave UNKNOWN go to the LLM layer when OPENAI_API_KEY is set
59
+ * (TRIAGE_LLM=off disables it). A failing engine must never cost us the collected data.
60
+ */
61
+ async enrich(records, summary) {
62
+ if (records.length === 0)
63
+ return records;
64
+ try {
65
+ const index = new HistoryIndex([...readTriageRecords(), ...records], [...readRunSummaries(), summary]);
66
+ const diagnoses = await diagnosePipeline(records, index, llmFromEnv());
67
+ return records.map((record, i) => ({ ...record, diagnosis: diagnoses[i] }));
68
+ }
69
+ catch (error) {
70
+ console.warn(`[triage] rule engine failed, saving records without a diagnosis: ${String(error)}`);
71
+ return records;
72
+ }
73
+ }
74
+ printsToStdio() {
75
+ return false;
76
+ }
77
+ toRecord(test, result, testId) {
78
+ const error = result.error ?? result.errors[0];
79
+ const attachments = result.attachments
80
+ .filter((a) => COLLECTED_ATTACHMENTS.has(a.name) && a.path)
81
+ .map((a) => ({
82
+ name: a.name,
83
+ contentType: a.contentType,
84
+ path: projectPath(a.path),
85
+ }));
86
+ return {
87
+ schemaVersion: SCHEMA_VERSION,
88
+ testId,
89
+ file: projectPath(test.location.file),
90
+ status: result.status,
91
+ errorMessage: stripVTControlCharacters(error?.message ?? ''),
92
+ errorStack: stripVTControlCharacters(error?.stack ?? ''),
93
+ duration: result.duration,
94
+ retry: result.retry,
95
+ attachments,
96
+ runId: this.runId,
97
+ gitSha: this.gitSha,
98
+ timestamp: new Date(result.startTime.getTime() + result.duration).toISOString(),
99
+ };
100
+ }
101
+ }
@@ -0,0 +1,4 @@
1
+ /** Bump when the on-disk record shape changes incompatibly. */
2
+ export const SCHEMA_VERSION = 1;
3
+ /** The fixed root-cause taxonomy from ROADMAP.md. No layer may add to it. */
4
+ export const ROOT_CAUSES = ['REAL_BUG', 'LOCATOR_DRIFT', 'TIMING', 'ENV', 'TEST_DATA'];
@@ -0,0 +1,58 @@
1
+ // Explains a test report on your own machine, no CI needed: run your tests, then
2
+ // node scripts/explain.ts <report>
3
+ // where <report> is a JUnit XML or TRX file, a directory of them, or a Playwright blob report
4
+ // directory. Works for any stack the importer reads (see QUICKSTART.md). Each run is kept in a
5
+ // .tewip folder in the directory you run it from (your project, not the installed tool), so later
6
+ // runs judge failures against their history, as CI does.
7
+ // Options: --name <project> keeps a separate history per project (default: the report's folder).
8
+ import { existsSync, readdirSync, statSync } from 'node:fs';
9
+ import { basename, dirname, join, resolve } from 'node:path';
10
+ import { loadCorrections } from '../engine/corrections.js';
11
+ import { HistoryIndex } from '../engine/history.js';
12
+ import { importBlobReportDir } from '../engine/import/blob.js';
13
+ import { importJunit } from '../engine/import/junit.js';
14
+ import { llmFromEnv } from '../engine/llm/client.js';
15
+ import { usageLine } from '../engine/llm/usage.js';
16
+ import { buildItems } from '../outlets/items.js';
17
+ import { sdetBoardView } from '../outlets/views/sdet.js';
18
+ import { appendJsonl, datasetFiles, readRunSummaries, readTriageRecords } from '../reporters/store.js';
19
+ const arg = (name) => {
20
+ const i = process.argv.indexOf(`--${name}`);
21
+ return i > 0 ? process.argv[i + 1] : undefined;
22
+ };
23
+ /** A Playwright blob report is a directory of report-*.zip files; anything else is JUnit or TRX. */
24
+ function isBlobDir(path) {
25
+ return statSync(path).isDirectory() && readdirSync(path).some((f) => /^report.*\.zip$/.test(f));
26
+ }
27
+ async function main() {
28
+ const target = process.argv[2];
29
+ if (!target || target.startsWith('--') || !existsSync(target)) {
30
+ console.error('usage: node scripts/explain.ts <report.xml | report.trx | reports-dir | blob-report-dir> [--name project]');
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ const path = resolve(target);
35
+ const name = (arg('name') ?? basename(statSync(path).isDirectory() ? path : dirname(path))).replace(/[^\w.-]+/g, '-');
36
+ const historyDir = join(process.cwd(), '.tewip', 'history', name);
37
+ const history = datasetFiles(historyDir);
38
+ const runId = `local-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`;
39
+ const meta = { runId, gitSha: '', workspace: process.cwd() };
40
+ const imported = isBlobDir(path) ? importBlobReportDir(path, meta, join(historyDir, 'files')) : importJunit(path, meta);
41
+ const pastRecords = readTriageRecords(history.triage);
42
+ const pastRuns = readRunSummaries(history.runs);
43
+ const index = new HistoryIndex([...pastRecords, ...imported.records], [...pastRuns, imported.summary]);
44
+ const items = await buildItems(imported.records, index, { dataset: `local: ${name}`, llm: llmFromEnv(), corrections: loadCorrections(historyDir) });
45
+ const tests = Object.keys(imported.summary.attempts).length;
46
+ if (items.length === 0)
47
+ console.log(`All ${tests} test(s) passed. Nothing to explain.`);
48
+ else
49
+ console.log(sdetBoardView(items, runId, `local: ${name}`).markdown);
50
+ // Keep this run as history for the next one, with its diagnoses.
51
+ const diagnosed = imported.records.map((r) => ({ ...r, diagnosis: items.find((i) => i.testId === r.testId)?.diagnosis }));
52
+ appendJsonl(history.triage, diagnosed);
53
+ appendJsonl(history.runs, [imported.summary]);
54
+ const runs = pastRuns.length + 1;
55
+ console.log(`\n${usageLine()}`);
56
+ console.log(`History: ${runs} run(s) of "${name}" kept in ${historyDir}.${runs === 1 ? ' A test\'s first failure often stays undecided; later runs are judged against this one. Add .tewip/ to your .gitignore.' : ''}`);
57
+ }
58
+ await main();