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,76 @@
1
+ const STOP_WORDS = new Set(['a', 'an', 'the', 'to', 'of', 'and', 'or', 'for', 'in', 'on', 'with', 'your', 'my', 'data', 'testid', 'test', 'id', 'qa', 'js', 'el', 'div', 'span']);
2
+ const SYNONYMS = {
3
+ btn: 'button',
4
+ input: 'textbox',
5
+ field: 'textbox',
6
+ image: 'img',
7
+ icon: 'img',
8
+ anchor: 'link',
9
+ lnk: 'link',
10
+ dropdown: 'combobox',
11
+ select: 'combobox',
12
+ };
13
+ export const ROLE_WORDS = new Set(['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'heading', 'img', 'listitem', 'tab', 'menuitem', 'option', 'dialog', 'searchbox', 'switch', 'slider']);
14
+ export function tokenize(text) {
15
+ return text
16
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
17
+ .toLowerCase()
18
+ .split(/[^a-z0-9]+/)
19
+ .filter((t) => t.length > 1 && !/^\d+$/.test(t) && !STOP_WORDS.has(t))
20
+ .map((t) => SYNONYMS[t] ?? t);
21
+ }
22
+ /** The human words inside a locator string, without Playwright API names. */
23
+ export function locatorWords(locator) {
24
+ const role = locator.match(/getByRole\(\s*['"]([a-z]+)['"]/)?.[1];
25
+ const stripped = locator
26
+ .replace(/\b(?:locator|getBy[A-Z]\w*|frameLocator|internal:[\w-]+)\b/g, ' ')
27
+ .replace(/\b(?:name|exact|hasText|hasNotText|has|nth|first|last|filter)\b\s*[:(]?/g, ' ');
28
+ return { tokens: tokenize(stripped), role };
29
+ }
30
+ function bigrams(word) {
31
+ const out = new Set();
32
+ for (let i = 0; i < word.length - 1; i++)
33
+ out.add(word.slice(i, i + 2));
34
+ return out;
35
+ }
36
+ /** 1 for equal words, high for prefixes and small typos, 0 for unrelated words. */
37
+ function wordSimilarity(a, b) {
38
+ if (a === b)
39
+ return 1;
40
+ if (Math.min(a.length, b.length) >= 4 && (a.startsWith(b) || b.startsWith(a)))
41
+ return 0.85;
42
+ const [x, y] = [bigrams(a), bigrams(b)];
43
+ const shared = [...x].filter((g) => y.has(g)).length;
44
+ const dice = x.size + y.size === 0 ? 0 : (2 * shared) / (x.size + y.size);
45
+ return dice >= 0.7 ? dice : 0;
46
+ }
47
+ /**
48
+ * Soft Dice overlap of word sets. Role words (button, link, ...) count toward the score
49
+ * but can't carry it alone: at least one content word must match.
50
+ */
51
+ export function similarity(target, element) {
52
+ const content = target.tokens.filter((t) => !ROLE_WORDS.has(t));
53
+ const wantsRole = target.tokens.some((t) => ROLE_WORDS.has(t));
54
+ const words = tokenize(`${element.name} ${element.text}`);
55
+ const candidate = wantsRole ? [...new Set([...words, ...tokenize(element.role)])] : words;
56
+ if (content.length === 0 || words.length === 0)
57
+ return 0;
58
+ const best = (t) => Math.max(0, ...candidate.map((c) => wordSimilarity(t, c)));
59
+ if (!content.some((t) => best(t) > 0))
60
+ return 0;
61
+ const matched = target.tokens.reduce((sum, t) => sum + best(t), 0);
62
+ const score = Math.min(1, (2 * matched) / (target.tokens.length + candidate.length));
63
+ return target.role && target.role !== element.role ? score * 0.7 : score;
64
+ }
65
+ /** Snapshot elements most similar to the locator, best first; zero scores dropped. */
66
+ export function rankCandidates(locator, elements) {
67
+ const target = locatorWords(locator);
68
+ return elements
69
+ .map((element) => ({ element, score: similarity(target, element) }))
70
+ .filter((r) => r.score > 0)
71
+ .sort((a, b) => b.score - a.score);
72
+ }
73
+ export function describeElement(element) {
74
+ const label = element.name || element.text;
75
+ return label ? `${element.role} "${label}"` : element.role;
76
+ }
@@ -0,0 +1,56 @@
1
+ // Reads the page snapshot Playwright stores in error-context.md when a test fails.
2
+ // It is an ARIA snapshot (roles + accessible names), captured at the moment of failure.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join, posix } from 'node:path';
5
+ import { PROJECT_ROOT } from '../reporters/store.js';
6
+ /** Absolute path of the record's error-context.md, or null if it no longer exists. */
7
+ export function errorContextPath(record) {
8
+ const direct = record.attachments.find((a) => a.name === 'error-context')?.path;
9
+ // Records collected before the reporter kept this attachment: it sits next to the trace.
10
+ const first = record.attachments[0]?.path;
11
+ const sibling = first ? posix.join(posix.dirname(first), 'error-context.md') : undefined;
12
+ for (const rel of [direct, sibling]) {
13
+ if (rel && existsSync(join(PROJECT_ROOT, rel)))
14
+ return join(PROJECT_ROOT, rel);
15
+ }
16
+ return null;
17
+ }
18
+ export function readPageSnapshot(record) {
19
+ const file = errorContextPath(record);
20
+ return file ? extractPageSnapshot(readFileSync(file, 'utf8')) : null;
21
+ }
22
+ /**
23
+ * The page snapshot yaml of an error-context.md document. Playwright puts it under
24
+ * "# Page snapshot" for action failures, but for expect() failures it follows the error
25
+ * details with no heading, so fall back to the first yaml block.
26
+ */
27
+ export function pageSnapshotYaml(markdown) {
28
+ return markdown.match(/# Page snapshot\s*```yaml\r?\n([\s\S]*?)```/)?.[1] ?? markdown.match(/```yaml\r?\n([\s\S]*?)```/)?.[1] ?? null;
29
+ }
30
+ export function extractPageSnapshot(markdown) {
31
+ const yaml = pageSnapshotYaml(markdown);
32
+ return yaml === null ? null : parseAriaSnapshot(yaml);
33
+ }
34
+ // - role "name" [attr] [ref=e1]: text
35
+ const LINE = /^\s*-\s+([a-z]+)(?:\s+"((?:[^"\\]|\\.)*)")?((?:\s+\[[^\]]*\])*)\s*(?::\s*(.*))?$/;
36
+ export function parseAriaSnapshot(yaml) {
37
+ const elements = [];
38
+ for (const line of yaml.split(/\r?\n/)) {
39
+ const m = LINE.exec(line);
40
+ if (!m)
41
+ continue;
42
+ const [, role, name = '', attrs = '', text = ''] = m;
43
+ elements.push({ role, name: unquote(`"${name}"`), text: unquote(text.trim()), ref: attrs.match(/\[ref=([^\]]+)\]/)?.[1] });
44
+ }
45
+ return elements;
46
+ }
47
+ function unquote(value) {
48
+ if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"'))
49
+ return value;
50
+ try {
51
+ return JSON.parse(value);
52
+ }
53
+ catch {
54
+ return value.slice(1, -1);
55
+ }
56
+ }
@@ -0,0 +1,188 @@
1
+ import { driverClickIntercepted, driverStaleElement } from './driver-errors.js';
2
+ import { errorShape, truncate } from './signature.js';
3
+ export const FLAKY_MIN_RATE = 0.2;
4
+ export const FLAKY_MAX_RATE = 0.8;
5
+ export const TIMING_MIN_RUNS = 3;
6
+ const pct = (rate) => `${Math.round(rate * 100)}%`;
7
+ /**
8
+ * Any request that failed during the test. Narrowing this to server errors and no-response was
9
+ * measured and changed nothing on the labelled data, while it would stop a 401 (expired login)
10
+ * from counting as the environment problem it is, so every failed request still counts.
11
+ */
12
+ function dependencyFailure(load) {
13
+ return (load?.() ?? [])[0];
14
+ }
15
+ function declined(source, message, data) {
16
+ return { category: null, confidence: 0, evidence: [{ source, message, data }] };
17
+ }
18
+ /**
19
+ * The test failed but another attempt of the same code in the same run passed: Playwright's
20
+ * own "flaky" signal. A deterministic cause (real bug, drifted locator) would fail every
21
+ * attempt. Confidence stays below the auto-fix threshold: the rule knows a race happened,
22
+ * not where. Network signals are handled first by env-network.
23
+ */
24
+ export const timingRetryPass = {
25
+ id: 'timing-retry-pass',
26
+ evaluate({ history }) {
27
+ const { currentRunAttempts: attempts, currentRunFailures: failures } = history;
28
+ if (attempts < 2 || failures >= attempts)
29
+ return null;
30
+ return {
31
+ category: 'TIMING',
32
+ confidence: 0.75,
33
+ evidence: [{ source: this.id, message: `${failures} of ${attempts} attempts in this run failed and another attempt of the same code passed`, data: { attempts, failures } }],
34
+ };
35
+ },
36
+ };
37
+ /** Intermittent failures whose error keeps changing: a race, not a deterministic break. */
38
+ export const timingFlaky = {
39
+ id: 'timing-flaky',
40
+ evaluate({ history }) {
41
+ const { runs, failures, failureRate, distinctErrors } = history;
42
+ if (failureRate < FLAKY_MIN_RATE || failureRate > FLAKY_MAX_RATE)
43
+ return null;
44
+ const rate = `fails ${pct(failureRate)} of the time (${failures}/${runs} runs)`;
45
+ if (runs < TIMING_MIN_RUNS)
46
+ return declined(this.id, `${rate}, too few runs to call it flaky (need ${TIMING_MIN_RUNS})`);
47
+ if (distinctErrors < 2) {
48
+ return declined(this.id, `${rate} but always with the same error; rules can't tell a race from an intermittent real bug`);
49
+ }
50
+ return {
51
+ category: 'TIMING',
52
+ confidence: Math.min(0.9, 0.5 + 0.05 * runs),
53
+ evidence: [{ source: this.id, message: `${rate} with ${distinctErrors} different errors`, data: { runs, failures, failureRate, distinctErrors } }],
54
+ };
55
+ },
56
+ };
57
+ /**
58
+ * Playwright found the element, waited until it was visible, enabled and stable, and then the
59
+ * click still could not land because another element was on top of it: an overlay, a toast or a
60
+ * menu that had not gone away yet. That is a race in the page, not a broken feature and not a
61
+ * renamed element. On every labelled dataset (32 cases across five projects) each failure with
62
+ * this text is a TIMING failure. Confidence stays below the auto-fix line: the agent knows a
63
+ * race happened, not which wait to add (rules-7).
64
+ */
65
+ export const timingIntercepted = {
66
+ id: 'timing-intercepted',
67
+ evaluate({ record }) {
68
+ const match = record.errorMessage.match(/<[^>]+>[^\n]*intercepts pointer events/);
69
+ if (match) {
70
+ const what = truncate(match[0].replace(/\s+/g, ' ').trim(), 100);
71
+ return {
72
+ category: 'TIMING',
73
+ confidence: 0.8,
74
+ evidence: [{ source: this.id, message: `the element was ready but the click could not land: ${what}, so something was still on top of it`, data: { intercepted: what } }],
75
+ };
76
+ }
77
+ // Selenium and Cypress report the same race in their own words. No labelled data from those
78
+ // tools has measured this yet, so the confidence stays below what the merge gate trusts.
79
+ const other = driverClickIntercepted(record.errorMessage);
80
+ if (!other)
81
+ return null;
82
+ const what = truncate(other.replace(/\s+/g, ' '), 100);
83
+ return {
84
+ category: 'TIMING',
85
+ confidence: 0.65,
86
+ evidence: [{ source: this.id, message: `the click could not land because ${what} was on top of the element, so something had not gone away yet`, data: { intercepted: what } }],
87
+ };
88
+ },
89
+ };
90
+ /**
91
+ * Selenium's stale element (and Cypress's "the page updated while this command was executing"):
92
+ * the test found the element, then the page re-rendered and replaced it before the test used it.
93
+ * That is a race between the test and the page, fixed by finding the element again, not a broken
94
+ * feature. Unmeasured on labelled data, so below the merge gate's trust line.
95
+ */
96
+ export const timingStaleElement = {
97
+ id: 'timing-stale-element',
98
+ evaluate({ record }) {
99
+ const stale = driverStaleElement(record.errorMessage);
100
+ if (!stale)
101
+ return null;
102
+ return {
103
+ category: 'TIMING',
104
+ confidence: 0.65,
105
+ evidence: [{ source: this.id, message: `"${truncate(stale, 80)}": the element was found, then the page replaced it before the test used it`, data: { stale } }],
106
+ };
107
+ },
108
+ };
109
+ /**
110
+ * A known flaky test failing the way it flaked before: in an earlier run it failed with this same
111
+ * error and then passed on a retry of the same code. This is the memory flaky-test products keep
112
+ * (a test flagged once stays flagged). On the nine labelled datasets, every later failure of such
113
+ * a test is TIMING or ENV, never a real bug or a renamed element, across five projects. Declines
114
+ * when a request failed during the test, since a flaky dependency is ENV (rules-8).
115
+ */
116
+ export const timingKnownFlaky = {
117
+ id: 'timing-known-flaky',
118
+ evaluate({ record, history, loadFailedRequests }) {
119
+ const shape = errorShape(record.errorMessage);
120
+ if (!history.earlierFlakeShapes.includes(shape))
121
+ return null;
122
+ // A dependency failing during the test is checked first: that is ENV, whatever else holds.
123
+ const failed = dependencyFailure(loadFailedRequests);
124
+ if (failed) {
125
+ return declined(this.id, `this test flaked before with this same error, and ${failed.url} failed during it (${failed.status ?? 'no response'}): a dependency failing now and then is an environment problem, not a race inside the test`);
126
+ }
127
+ // A regression can wear an old flake's error. Its signature is different, though: it fails every
128
+ // retry on this commit while the test passes on other commits, where a flake usually passes one
129
+ // of its retries. Found by code review: without this, the merge gate could let such a
130
+ // regression through as "known flaky". Measured, most such failures are still the old flake, so
131
+ // the category stays TIMING, but at a confidence below what the merge gate trusts: a person
132
+ // looks before it can merge.
133
+ const retried = history.sameCommitAttempts >= 2;
134
+ if (retried && history.sameCommitFailures === history.sameCommitAttempts && history.passesOnOtherCommits > 0) {
135
+ return {
136
+ category: 'TIMING',
137
+ confidence: 0.6,
138
+ evidence: [
139
+ {
140
+ source: this.id,
141
+ message: `a known flaky test failing its old way, but this time it failed all ${history.sameCommitAttempts} attempts on this commit while passing on other commits, which is also how a regression looks: a person should confirm`,
142
+ data: { shape, regressionShaped: true },
143
+ },
144
+ ],
145
+ };
146
+ }
147
+ return {
148
+ category: 'TIMING',
149
+ confidence: 0.75,
150
+ evidence: [{ source: this.id, message: 'a known flaky test: in an earlier run it failed with this same error and then passed on a retry of the same code', data: { shape } }],
151
+ };
152
+ },
153
+ };
154
+ /** Flips between passing and failing needed before a test counts as intermittent, not broken. */
155
+ export const INTERMITTENT_MIN_ALTERNATIONS = 3;
156
+ /**
157
+ * A test that keeps switching between passing and failing across commits. A change that breaks a
158
+ * test flips it once and it stays broken; a race flips it again and again. The deterministic
159
+ * rules above (drift on the page, data collisions, server errors) are checked first, so this only
160
+ * catches what none of them explains. Confidence stays well below the auto-fix threshold: the
161
+ * rule knows the failure is not deterministic, not where the race is (rules-6, from misreading
162
+ * cross-branch flakes as real bugs on real CI data).
163
+ */
164
+ export const timingIntermittent = {
165
+ id: 'timing-intermittent',
166
+ evaluate({ history, loadFailedRequests }) {
167
+ const { alternations, runs, failures } = history;
168
+ if (alternations < INTERMITTENT_MIN_ALTERNATIONS)
169
+ return null;
170
+ // A dependency that fails now and then also makes a test come and go, and that is ENV, not a
171
+ // race in the test. Seen on held-out data, where an external media API broke this test.
172
+ const failed = dependencyFailure(loadFailedRequests);
173
+ if (failed) {
174
+ return declined(this.id, `this test comes and goes on this branch (${alternations} switches in ${runs} runs) and ${failed.url} failed during it (${failed.status ?? 'no response'}): a dependency failing now and then is an environment problem, not a race inside the test`);
175
+ }
176
+ return {
177
+ category: 'TIMING',
178
+ confidence: 0.7,
179
+ evidence: [
180
+ {
181
+ source: 'timing-intermittent',
182
+ message: `this test switched between passing and failing ${alternations} times across the last ${runs} runs (${failures} failed): it comes and goes rather than staying broken`,
183
+ data: { alternations, runs, failures },
184
+ },
185
+ ],
186
+ };
187
+ },
188
+ };
@@ -0,0 +1,92 @@
1
+ // Rebuilds DOM snapshots from a trace and condenses them into a short element list.
2
+ // Trace snapshots are incremental: a node can be [[n, i]], meaning "node i (post-order)
3
+ // of the snapshot n steps back for the same frame". Mirrors Playwright's snapshotRenderer.
4
+ const isElement = (n) => Array.isArray(n) && typeof n[0] === 'string';
5
+ const isReference = (n) => Array.isArray(n) && Array.isArray(n[0]);
6
+ /** Post-order list of the raw (unresolved) nodes of one snapshot, as the references index them. */
7
+ function rawNodes(snapshot, cache) {
8
+ let nodes = cache.get(snapshot);
9
+ if (!nodes) {
10
+ const out = [];
11
+ const visit = (n) => {
12
+ if (typeof n === 'string')
13
+ out.push(n);
14
+ else if (isElement(n)) {
15
+ for (const child of n.slice(2))
16
+ visit(child);
17
+ out.push(n);
18
+ }
19
+ };
20
+ visit(snapshot.html);
21
+ cache.set(snapshot, (nodes = out));
22
+ }
23
+ return nodes;
24
+ }
25
+ /** Resolves the last snapshot of the main frame into a plain tree, or null if there is none. */
26
+ export function lastMainFrameDom(snapshots) {
27
+ const main = snapshots.filter((s) => s.isMainFrame !== false);
28
+ if (main.length === 0)
29
+ return null;
30
+ const frameId = main[main.length - 1].frameId;
31
+ const frame = snapshots.filter((s) => s.frameId === frameId);
32
+ const cache = new Map();
33
+ const resolve = (n, index, depth) => {
34
+ if (depth > 200)
35
+ return null;
36
+ if (typeof n === 'string')
37
+ return n;
38
+ if (isReference(n)) {
39
+ const refIndex = index - n[0][0];
40
+ const target = frame[refIndex] ? rawNodes(frame[refIndex], cache)[n[0][1]] : undefined;
41
+ return target === undefined ? null : resolve(target, refIndex, depth + 1);
42
+ }
43
+ if (!isElement(n))
44
+ return null;
45
+ const [tag, attrs, ...children] = n;
46
+ const resolved = children.map((c) => resolve(c, index, depth + 1)).filter((c) => c !== null);
47
+ return { tag: tag.toUpperCase(), attrs: attrs ?? {}, children: resolved };
48
+ };
49
+ const root = resolve(frame[frame.length - 1].html, frame.length - 1, 0);
50
+ return root && typeof root !== 'string' ? { url: frame[frame.length - 1].frameUrl, root } : null;
51
+ }
52
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'svg', 'SVG', 'HEAD']);
53
+ const NOTABLE_TAGS = new Set(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL', 'FORM', 'H1', 'H2', 'H3', 'H4', 'DIALOG', 'IMG', 'TABLE']);
54
+ const KEPT_ATTRS = ['id', 'data-testid', 'data-test', 'data-qa', 'name', 'type', 'role', 'aria-label', 'placeholder', 'href', 'disabled', 'hidden', 'aria-hidden', 'aria-busy'];
55
+ function textOf(node) {
56
+ if (typeof node === 'string')
57
+ return node;
58
+ if (SKIP_TAGS.has(node.tag))
59
+ return '';
60
+ return node.children.map(textOf).join(' ');
61
+ }
62
+ const clip = (s, max) => (s.length <= max ? s : `${s.slice(0, max - 1)}…`);
63
+ function describe(node) {
64
+ const attrs = KEPT_ATTRS.filter((a) => node.attrs[a] !== undefined).map((a) => `${a}="${clip(node.attrs[a], 40)}"`);
65
+ const classes = (node.attrs.class ?? '').split(/\s+/).filter(Boolean).slice(0, 2);
66
+ if (classes.length > 0)
67
+ attrs.push(`class="${classes.join(' ')}"`);
68
+ const text = clip(textOf(node).replace(/\s+/g, ' ').trim(), 60);
69
+ const tag = node.tag.toLowerCase();
70
+ return `<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>${text}</${tag}>`;
71
+ }
72
+ /**
73
+ * Elements a selector is likely to target: interactive tags, headings, and anything with an
74
+ * id, test id, role or aria-label. Capped so the model gets a summary, not the page.
75
+ */
76
+ export function notableElements(root, max = 60) {
77
+ const found = [];
78
+ const visit = (node) => {
79
+ if (SKIP_TAGS.has(node.tag))
80
+ return;
81
+ const a = node.attrs;
82
+ if (NOTABLE_TAGS.has(node.tag) || a.id || a['data-testid'] || a['data-test'] || a['data-qa'] || a.role || a['aria-label']) {
83
+ if (node.tag !== 'HTML' && node.tag !== 'BODY')
84
+ found.push(describe(node));
85
+ }
86
+ for (const child of node.children)
87
+ if (typeof child !== 'string')
88
+ visit(child);
89
+ };
90
+ visit(root);
91
+ return { elements: found.slice(0, max), omitted: Math.max(0, found.length - max) };
92
+ }
@@ -0,0 +1,87 @@
1
+ // Condenses a Playwright trace.zip into the facts a triage model needs: the final DOM,
2
+ // failed network requests, console errors and the last test steps. Never raw logs.
3
+ import { stripVTControlCharacters } from 'node:util';
4
+ import { lastMainFrameDom, notableElements } from './dom.js';
5
+ import { readZipEntries } from './zip.js';
6
+ const MAX_STEPS = 12;
7
+ const MAX_ITEMS = 10;
8
+ const clip = (s, max) => (s.length <= max ? s : `${s.slice(0, max - 1)}…`);
9
+ function parseJsonl(buffers) {
10
+ const events = [];
11
+ for (const buf of buffers) {
12
+ for (const line of buf.toString('utf8').split('\n')) {
13
+ if (!line.trim())
14
+ continue;
15
+ try {
16
+ events.push(JSON.parse(line));
17
+ }
18
+ catch {
19
+ // A trace cut short by a crash can end with a partial line; skip it.
20
+ }
21
+ }
22
+ }
23
+ return events;
24
+ }
25
+ /** Keeps origin + path, drops query strings (they can carry tokens) and fragments. */
26
+ function shortUrl(url) {
27
+ try {
28
+ const u = new URL(url);
29
+ return `${u.origin}${u.pathname}`;
30
+ }
31
+ catch {
32
+ return clip(url, 120);
33
+ }
34
+ }
35
+ function summarizeSteps(testEvents, contextEvents) {
36
+ const after = new Map(testEvents.filter((e) => e.type === 'after').map((e) => [e.callId, e]));
37
+ // Protocol-level calls carry the selector / url; they link to test steps through stepId.
38
+ const params = new Map(contextEvents.filter((e) => e.type === 'before' && e.stepId).map((e) => [e.stepId, e.params ?? {}]));
39
+ return testEvents
40
+ .filter((e) => e.type === 'before' && !e.parentId && /^(pw:api|expect)@/.test(e.callId ?? ''))
41
+ .map((e) => {
42
+ const end = after.get(e.callId);
43
+ const p = params.get(e.callId) ?? {};
44
+ const target = p.selector ?? p.url ?? p.event ?? p.expression ?? '';
45
+ const ms = end?.endTime !== undefined ? Math.round(end.endTime - e.startTime) : null;
46
+ const outcome = end?.error ? ` FAILED: ${clip(stripVTControlCharacters(String(end.error.message ?? '')).split('\n')[0], 100)}` : '';
47
+ return `${e.title}${target ? ` ${clip(String(target), 80)}` : ''}${ms !== null ? ` (${ms}ms)` : ''}${outcome}`;
48
+ })
49
+ .slice(-MAX_STEPS);
50
+ }
51
+ export function summarizeTrace(tracePath) {
52
+ const entries = readZipEntries(tracePath, (name) => /^(\d+-trace\.(trace|network)|test\.trace)$/.test(name));
53
+ const byKind = (re) => [...entries].filter(([name]) => re.test(name)).map(([, buf]) => buf);
54
+ const contextEvents = parseJsonl(byKind(/^\d+-trace\.trace$/));
55
+ const networkEvents = parseJsonl(byKind(/^\d+-trace\.network$/));
56
+ const testEvents = parseJsonl(byKind(/^test\.trace$/));
57
+ const snapshots = contextEvents.filter((e) => e.type === 'frame-snapshot').map((e) => e.snapshot);
58
+ const dom = lastMainFrameDom(snapshots);
59
+ const notable = dom ? notableElements(dom.root) : { elements: [], omitted: 0 };
60
+ const resources = networkEvents.filter((e) => e.type === 'resource-snapshot').map((e) => e.snapshot);
61
+ const failed = resources
62
+ .filter((r) => !r.response || r.response.status >= 400 || r.response.status <= 0 || r.response._failureText)
63
+ .slice(0, MAX_ITEMS)
64
+ .map((r) => ({
65
+ method: String(r.request?.method ?? 'GET'),
66
+ url: shortUrl(String(r.request?.url ?? '')),
67
+ status: r.response && r.response.status > 0 ? Number(r.response.status) : null,
68
+ ...(r.response?._failureText ? { error: String(r.response._failureText) } : {}),
69
+ }));
70
+ const consoleEvents = contextEvents.filter((e) => e.type === 'console');
71
+ const pageErrors = contextEvents
72
+ .filter((e) => e.type === 'event' && e.method === 'pageError')
73
+ .map((e) => clip(String(e.params?.error?.error?.message ?? e.params?.error?.message ?? JSON.stringify(e.params?.error ?? '')), 200))
74
+ .slice(0, MAX_ITEMS);
75
+ return {
76
+ pageUrl: dom ? shortUrl(dom.url) : null,
77
+ domElements: notable.elements,
78
+ domElementsOmitted: notable.omitted,
79
+ network: { requests: resources.length, failed },
80
+ console: {
81
+ errors: consoleEvents.filter((e) => e.messageType === 'error').map((e) => clip(String(e.text), 200)).slice(0, MAX_ITEMS),
82
+ warnings: consoleEvents.filter((e) => e.messageType === 'warning').length,
83
+ },
84
+ pageErrors,
85
+ steps: summarizeSteps(testEvents, contextEvents),
86
+ };
87
+ }
@@ -0,0 +1,53 @@
1
+ // Minimal zip reader for Playwright trace.zip files (stored or deflated entries, no zip64,
2
+ // no encryption). Only the entries the caller asks for are decompressed.
3
+ import { readFileSync } from 'node:fs';
4
+ import { inflateRawSync } from 'node:zlib';
5
+ const EOCD_SIGNATURE = 0x06054b50;
6
+ const CENTRAL_SIGNATURE = 0x02014b50;
7
+ const LOCAL_SIGNATURE = 0x04034b50;
8
+ export function readZipEntries(file, wanted) {
9
+ return readZipBuffer(readFileSync(file), wanted, file);
10
+ }
11
+ /** Same as readZipEntries for a zip already in memory (e.g. a zip nested in another zip). */
12
+ export function readZipBuffer(buf, wanted, file = 'zip') {
13
+ let eocd = -1;
14
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
15
+ if (buf.readUInt32LE(i) === EOCD_SIGNATURE) {
16
+ eocd = i;
17
+ break;
18
+ }
19
+ }
20
+ if (eocd < 0)
21
+ throw new Error(`${file} is not a zip file`);
22
+ const count = buf.readUInt16LE(eocd + 10);
23
+ let offset = buf.readUInt32LE(eocd + 16);
24
+ const entries = new Map();
25
+ for (let i = 0; i < count; i++) {
26
+ if (buf.readUInt32LE(offset) !== CENTRAL_SIGNATURE)
27
+ throw new Error(`${file}: corrupt central directory`);
28
+ const method = buf.readUInt16LE(offset + 10);
29
+ const compressedSize = buf.readUInt32LE(offset + 20);
30
+ const nameLength = buf.readUInt16LE(offset + 28);
31
+ const extraLength = buf.readUInt16LE(offset + 30);
32
+ const commentLength = buf.readUInt16LE(offset + 32);
33
+ const localOffset = buf.readUInt32LE(offset + 42);
34
+ const name = buf.toString('utf8', offset + 46, offset + 46 + nameLength);
35
+ offset += 46 + nameLength + extraLength + commentLength;
36
+ if (!wanted(name))
37
+ continue;
38
+ if (compressedSize === 0xffffffff || localOffset === 0xffffffff)
39
+ throw new Error(`${file}: zip64 entries are not supported`);
40
+ if (buf.readUInt32LE(localOffset) !== LOCAL_SIGNATURE)
41
+ throw new Error(`${file}: corrupt local header for ${name}`);
42
+ // The local header's extra field can differ in length from the central one.
43
+ const dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
44
+ const data = buf.subarray(dataStart, dataStart + compressedSize);
45
+ if (method === 0)
46
+ entries.set(name, data);
47
+ else if (method === 8)
48
+ entries.set(name, inflateRawSync(data));
49
+ else
50
+ throw new Error(`${file}: unsupported compression method ${method} for ${name}`);
51
+ }
52
+ return entries;
53
+ }
@@ -0,0 +1,21 @@
1
+ // Thin wrapper around the GitHub CLI for read-only calls against public repositories.
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ const WINDOWS_GH = 'C:\\Program Files\\GitHub CLI\\gh.exe';
5
+ const GH = process.env.GH_PATH || (existsSync(WINDOWS_GH) ? WINDOWS_GH : 'gh');
6
+ /** Runs `gh <args>` and resolves with stdout; async so several calls can run at once. */
7
+ export function ghRaw(args) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn(GH, args);
10
+ const out = [];
11
+ const err = [];
12
+ child.stdout.on('data', (d) => out.push(d));
13
+ child.stderr.on('data', (d) => err.push(d));
14
+ child.on('error', reject);
15
+ child.on('close', (code) => code === 0 ? resolve(Buffer.concat(out)) : reject(new Error(`gh ${args.slice(0, 2).join(' ')} failed: ${Buffer.concat(err).toString('utf8').trim().slice(0, 300)}`)));
16
+ });
17
+ }
18
+ export async function ghApi(path) {
19
+ return JSON.parse((await ghRaw(['api', path])).toString('utf8'));
20
+ }
21
+ export const downloadArtifact = (repo, artifactId) => ghRaw(['api', `repos/${repo}/actions/artifacts/${artifactId}/zip`]);