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,98 @@
1
+ import { extractLocator, isElementNotFound, locatorName, strictModeViolation } from './error-parse.js';
2
+ import { describeElement, rankCandidates } from './similarity.js';
3
+ export const DRIFT_MIN_SIMILARITY = 0.6;
4
+ /** Best and runner-up drift candidates closer than this are treated as ambiguous. */
5
+ const DRIFT_AMBIGUITY_GAP = 0.05;
6
+ function declined(source, message, data) {
7
+ return { category: null, confidence: 0, evidence: [{ source, message, data }] };
8
+ }
9
+ export const locatorDrift = {
10
+ id: 'locator-drift',
11
+ evaluate({ record, history, loadSnapshot }) {
12
+ if (!isElementNotFound(record.errorMessage))
13
+ return null;
14
+ const locator = extractLocator(record.errorMessage);
15
+ if (!locator)
16
+ return declined(this.id, 'element not found, but the locator could not be read from the error');
17
+ // Drift means the element used to match and stopped: that needs a run where this test passed.
18
+ // Without one, a similar name on the page is a coincidence as easily as a rename, and these
19
+ // were the rule's false drift calls on an unseen Gutenberg week (rules-5).
20
+ if (history.runs >= 2 && history.passesOnOtherCommits === 0 && history.passesBeforeStreak === 0) {
21
+ return declined(this.id, `${locator} not found, and this test has no recorded passing run (${history.failures} failures in ${history.runs} runs), so nothing shows the element ever matched`, { locator });
22
+ }
23
+ const snapshot = loadSnapshot();
24
+ if (!snapshot)
25
+ return declined(this.id, `${locator} not found; no page snapshot to look for a renamed element`, { locator });
26
+ const [best, second] = rankCandidates(locator, snapshot);
27
+ if (!best || best.score < DRIFT_MIN_SIMILARITY) {
28
+ const closest = best ? `closest: ${describeElement(best.element)} at ${best.score.toFixed(2)}` : `none of ${snapshot.length} elements share a word with it`;
29
+ return declined(this.id, `${locator} not found and nothing similar is on the page (${closest})`, { locator });
30
+ }
31
+ const candidate = best.element;
32
+ const evidence = [
33
+ {
34
+ source: this.id,
35
+ message: `${locator} not found, but the page has ${describeElement(candidate)} (similarity ${best.score.toFixed(2)})`,
36
+ data: { locator, candidate, similarity: best.score },
37
+ },
38
+ ];
39
+ let confidence = 0.5 + 0.45 * best.score;
40
+ if (second && best.score - second.score < DRIFT_AMBIGUITY_GAP) {
41
+ confidence -= 0.1;
42
+ evidence.push({ source: this.id, message: `ambiguous: ${describeElement(second.element)} is almost as similar (${second.score.toFixed(2)})` });
43
+ }
44
+ return { category: 'LOCATOR_DRIFT', confidence, evidence };
45
+ },
46
+ };
47
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+ /**
49
+ * An action (click, fill…) whose locator now matches several elements, exactly one of which
50
+ * carries the locator's name as a whole word: e.g. `name: 'Edit'` resolving to "Editor canvas"
51
+ * twice and "Edit link" once. Playwright matches names by substring, so a new element with a
52
+ * similar name broke the locator; the element the test means is the one with the whole word,
53
+ * and Playwright's error already prints a unique locator for it. Found in real Gutenberg fixes
54
+ * (label rule v4, rules-5). Assertions decline: a toBeHidden() that finds extra elements is as
55
+ * likely a behaviour change as a locator problem.
56
+ */
57
+ export const locatorDriftStrict = {
58
+ id: 'locator-drift-strict',
59
+ evaluate({ record }) {
60
+ const violation = strictModeViolation(record.errorMessage);
61
+ if (!violation)
62
+ return null;
63
+ const { locator, count, isAction, elements } = violation;
64
+ const name = locatorName(locator);
65
+ if (!name)
66
+ return declined(this.id, `${locator} matched ${count} elements, and it names no element to pick among them`, { locator });
67
+ // Playwright shortens long names in its suggestions ("64966a11-b3fa-4c41-b5bd-"), so a long
68
+ // prefix of the name counts as the same name.
69
+ const sameName = (n) => n.toLowerCase() === name.toLowerCase() || (n.length >= 20 && name.toLowerCase().startsWith(n.toLowerCase()));
70
+ const exact = elements.filter((e) => e.name && sameName(e.name));
71
+ const word = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(name)}($|[^\\p{L}\\p{N}])`, 'iu');
72
+ const matches = exact.length > 0 ? exact : elements.filter((e) => e.name && word.test(e.name));
73
+ const names = elements.map((e) => `"${e.name ?? e.locator}"`).join(', ');
74
+ if (matches.length === 0) {
75
+ // Checked fact for the AI: substring matches of other elements say nothing about whether
76
+ // the intended element still exists, which LOCATOR_DRIFT requires.
77
+ return declined(this.id, `${locator} matched ${count} elements (${names}), but none is named "${name}": only other elements whose names contain that text, so this is no evidence that the intended element still exists`, { locator });
78
+ }
79
+ if (!isAction)
80
+ return declined(this.id, `an assertion's locator ${locator} matched ${count} elements (${names}): the page may have changed behaviour, not only names`, { locator });
81
+ if (matches.length > 1) {
82
+ return declined(this.id, `${locator} matched ${count} elements (${names}) and ${matches.length} of them are named with "${name}", so the intended one is unclear`, { locator });
83
+ }
84
+ const [target] = matches;
85
+ const others = elements.filter((e) => e !== target).map((e) => `"${e.name ?? e.locator}"`);
86
+ return {
87
+ category: 'LOCATOR_DRIFT',
88
+ confidence: 0.9,
89
+ evidence: [
90
+ {
91
+ source: this.id,
92
+ message: `${locator} now matches ${count} elements: the page gained ${others.join(', ')} next to the intended ${target.locator}`,
93
+ data: { locator, candidate: { role: target.role, name: target.name }, replacement: target.locator, similarity: 1 },
94
+ },
95
+ ],
96
+ };
97
+ },
98
+ };
@@ -0,0 +1,103 @@
1
+ // Deterministic readers for the error wording of browser and component-test tools other than
2
+ // Playwright: Selenium (Java, Python, JavaScript, C#), WebdriverIO, Cypress, Testing Library (React,
3
+ // Vue, Angular, Svelte), Vue Test Utils, jest-dom, and the mobile tools (Appium, Espresso, Detox,
4
+ // Maestro, XCUITest, Flutter). Each tool says "element not found",
5
+ // "something else took the click" and "the element went stale" in its own words; these map them
6
+ // onto the same signals the rules read from Playwright. None of them guess: no match, no signal.
7
+ // The patterns were checked against real messages pasted into public GitHub issues and pull
8
+ // requests: 264 from browser tools (old and new driver versions, every Selenium binding) and 260
9
+ // from component tests (Testing Library, Vue Test Utils, jest-dom, Jasmine).
10
+ /** The element was looked for and never found, as each tool reports it, with the locator used. */
11
+ const NOT_FOUND = [
12
+ // Selenium, Chrome and Edge drivers: Unable to locate element: {"method":"css selector","selector":"#save"}
13
+ // The selector is printed without escaping its own quotes, and a paste may cut it off.
14
+ { pattern: /Unable to locate element:\s*\{"method":\s*"([^"]+)"(?:,\s*"selector":\s*"(.*?)(?:"\}|$))?/m, locator: (m) => (m[2] !== undefined ? `${m[1]}=${m[2].replaceAll(`\\"`, `"`)}` : m[1]) },
15
+ // Atata (C#): Unable to locate element:\n- By: XPath ".//td[6]"
16
+ { pattern: /Unable to locate element:\s*- By: (\w+ ".*?")$/m, locator: (m) => m[1] },
17
+ // Selenium, Firefox driver: Unable to locate element: #save (the selector may start on the next line)
18
+ { pattern: /Unable to locate element:[ \t]*\n?[ \t]*([^\s;{'"][^\n;]*)/, locator: (m) => m[1].trim() },
19
+ // Selenium explicit waits (Java), in their many shapes: waiting for visibility of element located by
20
+ // By.id: save (tried for...), waiting for element to be clickable: By.id: save, waiting for By.css: x to be displayed.
21
+ // Waits for an element to disappear or go stale are not "not found" and are left out.
22
+ { pattern: /Expected condition failed: waiting for (?![^\n]*(?:invisibility|stale|to be invisible))[\s\S]{0,80}?(By\.\w+:\s*[\s\S]+?|css=[^\n(]+?)(?=\s+to be \w|\s*\(tried for|\s*$)/m, locator: (m) => m[1].replace(/\s+/g, ' ').trim() },
23
+ // Selenium JavaScript waits: Waiting for element to be located By(css selector, #save)\nWait timed out after 10043ms
24
+ { pattern: /Waiting for element to be located (?:By\(([^,]+), (.+?)\)|(by function\(\))|(\S+))\s*\n?\s*Wait timed out/, locator: (m) => (m[1] ? `${m[1]}=${m[2]}` : m[3] ?? m[4]) },
25
+ // Selenium C# waits: Element with By By.XPath: //x not found
26
+ { pattern: /Element with By (By\.\w+: .+?) not found/, locator: (m) => m[1] },
27
+ // WebdriverIO: element ("#save") still not displayed after 5000ms; the selector may hold quotes, or none
28
+ { pattern: /element \((?:"(.*?)"|([^)"\s][^)]*))\)\s*still not (?:displayed|existing|clickable|enabled) after \d+ ?ms/, locator: (m) => m[1] ?? m[2] },
29
+ // WebdriverIO: Can't call click on element with selector "#save" because element wasn't found
30
+ { pattern: /on element with selector "(.*?)" because element wasn't found/, locator: (m) => m[1] },
31
+ // Cypress: Expected to find element: `#save`, but never found it. Old versions quote with ' or not at all;
32
+ // cy.contains adds "containing text: 'x'".
33
+ { pattern: /Expected to find element:\s*[`']?([^`'\n]+?)[`']?(?:\s+containing text:\s*'((?:[^'\\]|\\.)*)')?,?\s+but never found it/, locator: (m) => (m[2] !== undefined ? `cy.contains('${m[1]}', '${m[2]}')` : m[1]) },
34
+ // Cypress: Expected to find content: 'Save' [within the element: <h1>] but never did.
35
+ { pattern: /Expected to find content:\s*'((?:[^'\\]|\\.)*)'[\s\S]{0,300}?but never did/, locator: (m) => `cy.contains('${m[1]}')` },
36
+ // Testing Library (React, Vue, Angular, Svelte component tests), written as the same getBy... query
37
+ // Playwright uses, so the drift rules can read its name: Unable to find an accessible element with
38
+ // the role "button" and name "Save".
39
+ { pattern: /Unable to find an accessible element with the role "([^"]+)"(?: and name (?:"([^"\n]*)"|`([^`\n]*)`))?/, locator: (m) => (m[2] ?? m[3]) !== undefined ? `getByRole('${m[1]}', { name: ${arg(m[2] ?? m[3])} })` : `getByRole('${m[1]}')` },
40
+ // Older Testing Library: Unable to find role="button" and name "Save"
41
+ { pattern: /Unable to find role="([^"]+)"(?: and name (?:"([^"\n]*)"|`([^`\n]*)`))?/, locator: (m) => (m[2] ?? m[3]) !== undefined ? `getByRole('${m[1]}', { name: ${arg(m[2] ?? m[3])} })` : `getByRole('${m[1]}')` },
42
+ { pattern: /Unable to find an element by: \[data-testid="([^"]+)"\]/, locator: (m) => `getByTestId('${m[1]}')` },
43
+ { pattern: /Unable to find a label with the text of: (.+?)\.?$/m, locator: (m) => `getByLabel(${arg(m[1])})` },
44
+ { pattern: /Unable to find an element with the (placeholder text|alt text|title|display value)(?: of)?: (.+?)\.?$/m, locator: (m) => `getBy${{ 'placeholder text': 'Placeholder', 'alt text': 'AltText', title: 'Title', 'display value': 'DisplayValue' }[m[1]]}(${arg(m[2])})` },
45
+ { pattern: /Unable to find an element with the text: (.+?)\.?(?: This could be| \.\.\.|$)/m, locator: (m) => `getByText(${arg(m[1])})` },
46
+ // Espresso (Android): NoMatchingViewException: No views in hierarchy found matching: (view.getId() is <2131296257/app:id/save>...)
47
+ { pattern: /No views in hierarchy found matching:[ \t]*([^\n]+)/, locator: (m) => m[1].trim() },
48
+ // Detox: No elements found for "MATCHER(identifier == "save-button")"
49
+ // The matcher itself holds quotes, so this takes everything up to the last one on the line.
50
+ { pattern: /No elements found for[ \t]*[“"']([^\n]+)[”"']/, locator: (m) => m[1].trim() },
51
+ // Maestro: Element not found: Text matching regex: Save
52
+ { pattern: /Element not found:[ \t]*([^\n]+)/, locator: (m) => m[1].trim() },
53
+ // XCUITest: No matches found for Element at index 25
54
+ { pattern: /No matches found for[ \t]*([^\n]+)/, locator: (m) => m[1].trim() },
55
+ // Flutter: Actual: Found 0 widgets with widget matching predicate: [] // identifier('L1 O')
56
+ { pattern: /Found 0 widgets with[ \t]*([^\n]+)/, locator: (m) => m[1].trim() },
57
+ // Vue Test Utils 1: [vue-test-utils]: find did not return button.save, cannot call trigger() on empty Wrapper
58
+ { pattern: /find did not return (.+?), cannot call/, locator: (m) => m[1] },
59
+ ];
60
+ /** A Testing Library argument: a /regex/ stays as written, text is quoted. */
61
+ const arg = (s) => (/^\/.*\/[a-z]*$/.test(s.trim()) ? s.trim() : `'${s.trim().replaceAll("'", "\\'")}'`);
62
+ /**
63
+ * Not found, with no locator printed: Selenium's Python bindings, Vue Test Utils 2 ("Cannot call text
64
+ * on an empty DOMWrapper") and jest-dom given a query that found nothing ("received value must be an
65
+ * HTMLElement", "element could not be found in the document").
66
+ */
67
+ const NOT_FOUND_BARE = /\bNoSuchElement(?:Exception|Error)\b|\bno such element\b|Cannot call \w+ on an empty (?:DOM)?Wrapper|received value must be an HTMLElement or an SVGElement|element could not be found in the document|An element could not be located on the page using the given search parameters/i;
68
+ export function driverElementNotFound(message) {
69
+ return NOT_FOUND_BARE.test(message) || NOT_FOUND.some(({ pattern }) => pattern.test(message));
70
+ }
71
+ /** The locator a Selenium, WebdriverIO or Cypress lookup used, as the error printed it. */
72
+ export function driverLocator(message) {
73
+ for (const { pattern, locator } of NOT_FOUND) {
74
+ const m = message.match(pattern);
75
+ if (m)
76
+ return locator(m);
77
+ }
78
+ return null;
79
+ }
80
+ /** What received the click instead, when a tool reports the click was blocked. */
81
+ export function driverClickIntercepted(message) {
82
+ // Chrome: ... is not clickable at point (10, 20). Other element would receive the click: <div class="overlay">
83
+ const chrome = message.match(/Other element would receive the click:\s*`?(<[^>]+>)?/);
84
+ if (chrome)
85
+ return chrome[1] ?? 'another element';
86
+ // Firefox: Element <li> is not clickable at point (1168,92) because another element <a href="/x"> obscures it
87
+ const firefox = message.match(/is not clickable at point \([^)]*\) because another element (<[^>]+>)/);
88
+ if (firefox)
89
+ return firefox[1];
90
+ // Chrome, when it cannot name the element on top: element click intercepted: Element is not clickable at point (241, 600)
91
+ if (/element click intercepted|ElementClickIntercepted(?:Exception|Error)/.test(message))
92
+ return 'another element';
93
+ // Cypress: cy.click() failed because this element: <button> is being covered by another element: <div class="modal">
94
+ const cypress = message.match(/(?:is|it's|its) being covered by another element:?\s*`?(<[^>]+>)?/);
95
+ return cypress ? cypress[1] ?? 'another element' : null;
96
+ }
97
+ /** The element was found, then the page replaced it before the test used it. */
98
+ export function driverStaleElement(message) {
99
+ if (/waiting for [^\n]*to become stale|staleness of/i.test(message))
100
+ return null;
101
+ const m = message.match(/\bStaleElementReference(?:Exception|Error)\b|stale element (?:reference|not found)|element is not attached to the page document|element reference (?:of .{0,200}? )?is stale|with the reference \S+ is stale|is no longer (?:attached|connected) to the DOM|failed because the page updated while this command was executing|failed because (?:this|the) element is detached from the DOM/i);
102
+ return m ? m[0] : null;
103
+ }
@@ -0,0 +1,76 @@
1
+ import { RULES } from './rules.js';
2
+ import { readPageSnapshot } from './snapshot.js';
3
+ import { summarizeTrace } from './trace/trace-summary.js';
4
+ import { existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { PROJECT_ROOT } from '../reporters/store.js';
7
+ /** Bump whenever a rule or threshold changes, so stored diagnoses can be recomputed. */
8
+ export const ENGINE_VERSION = 'rules-9';
9
+ /** Subtracted when rules disagree on the category, so the decision is less likely to trigger an autonomous action. */
10
+ const CONFLICT_PENALTY = 0.1;
11
+ const round2 = (n) => Math.round(Math.min(1, Math.max(0, n)) * 100) / 100;
12
+ function evaluate(rule, input) {
13
+ try {
14
+ return rule.evaluate(input);
15
+ }
16
+ catch (error) {
17
+ return { category: null, confidence: 0, evidence: [{ source: rule.id, message: `rule crashed: ${String(error)}` }] };
18
+ }
19
+ }
20
+ export function diagnose(input, now = new Date()) {
21
+ const base = { source: 'rules', engineVersion: ENGINE_VERSION, diagnosedAt: now.toISOString() };
22
+ const outcomes = RULES.map((rule) => ({ rule, outcome: evaluate(rule, input) })).filter((x) => x.outcome !== null);
23
+ const matches = outcomes.filter((x) => x.outcome.category !== null);
24
+ if (matches.length === 0) {
25
+ // Narrowest rules first: their notes say the most about this particular failure.
26
+ const notes = outcomes.flatMap((x) => x.outcome.evidence).reverse();
27
+ return {
28
+ ...base,
29
+ category: 'UNKNOWN',
30
+ confidence: 0,
31
+ evidence: notes.length > 0 ? notes : [{ source: 'engine', message: 'no rule applies to this kind of failure' }],
32
+ };
33
+ }
34
+ const [winner, ...others] = matches;
35
+ const evidence = [...winner.outcome.evidence];
36
+ let confidence = winner.outcome.confidence;
37
+ const conflicts = others.filter((x) => x.outcome.category !== winner.outcome.category);
38
+ if (conflicts.length > 0) {
39
+ confidence -= CONFLICT_PENALTY;
40
+ const list = conflicts.map((x) => `${x.rule.id} → ${x.outcome.category}`).join(', ');
41
+ evidence.push({ source: 'engine', message: `lower-priority rules disagree (${list}); confidence lowered by ${CONFLICT_PENALTY}` });
42
+ }
43
+ evidence.push(...others.filter((x) => x.outcome.category === winner.outcome.category).flatMap((x) => x.outcome.evidence));
44
+ return { ...base, category: winner.outcome.category, confidence: round2(confidence), ruleId: winner.rule.id, evidence };
45
+ }
46
+ /** Requests that failed during one attempt, from its trace; [] when there is no readable trace. */
47
+ function failedRequestsOfAttempt(record) {
48
+ const rel = record.attachments.find((a) => a.name === 'trace')?.path;
49
+ if (!rel || !existsSync(join(PROJECT_ROOT, rel)))
50
+ return [];
51
+ try {
52
+ return summarizeTrace(join(PROJECT_ROOT, rel)).network.failed;
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ /**
59
+ * Requests that failed during this test in this run, across every attempt. A retry often
60
+ * succeeds in reaching a dependency that failed the first time, and only one attempt's trace
61
+ * carries the failure; a person triaging looks at all of them, so the rules do too.
62
+ */
63
+ function failedRequestsOf(record, attempts = []) {
64
+ const all = [record, ...attempts.filter((a) => a !== record)];
65
+ return all.flatMap(failedRequestsOfAttempt);
66
+ }
67
+ /** Diagnoses `targets` using the point-in-time history held by `index`. */
68
+ export function diagnoseRecords(targets, index, now = new Date()) {
69
+ return targets.map((record) => diagnose({
70
+ record,
71
+ history: index.historyAsOf(record.testId, record.runId),
72
+ run: index.runContext(record.runId),
73
+ loadSnapshot: () => readPageSnapshot(record),
74
+ loadFailedRequests: () => failedRequestsOf(record, index.runContext(record.runId).failures.filter((f) => f.testId === record.testId)),
75
+ }, now));
76
+ }
@@ -0,0 +1,150 @@
1
+ // Deterministic readers for Playwright error messages. Each returns null (or false)
2
+ // when the message does not carry the signal; none of them guess. Selenium, WebdriverIO and
3
+ // Cypress wording is read by driver-errors.ts and folded in here, so the rules see one signal.
4
+ import { driverElementNotFound, driverLocator } from './driver-errors.js';
5
+ const CALL_LOG = /\n\s*Call log:/;
6
+ const headOf = (message) => message.split(CALL_LOG)[0];
7
+ const callLogOf = (message) => message.split(CALL_LOG)[1] ?? '';
8
+ /** The element the failing call waited for never matched anything on the page. */
9
+ export function isElementNotFound(message) {
10
+ if (/element\(s\) not found/i.test(message) || driverElementNotFound(message))
11
+ return true;
12
+ const callLog = callLogOf(message);
13
+ return /Timeout \d+ms exceeded/.test(headOf(message)) && /waiting for /.test(callLog) && !/resolved to /.test(callLog);
14
+ }
15
+ const LOCATOR_START = /^(?:locator|getBy[A-Z]\w*|frameLocator|internal:)/;
16
+ /** The locator the failing call was waiting for, as Playwright printed it. */
17
+ export function extractLocator(message) {
18
+ const waited = [...message.matchAll(/waiting for (.+)/g)].map((m) => m[1].trim()).filter((s) => LOCATOR_START.test(s));
19
+ const header = message.match(/^\s*Locator:\s*(.+)$/m)?.[1]?.trim();
20
+ return waited.at(-1) ?? header ?? driverLocator(message);
21
+ }
22
+ /**
23
+ * How each language's assertion library prints "got the wrong value". Each pattern captures the
24
+ * expected and the actual value; the order of the groups differs, so each names its own.
25
+ */
26
+ const MISMATCH_PATTERNS = [
27
+ // Playwright, Jest, Vitest, node:test: "Expected: 4" / "Received: 3" on separate lines.
28
+ // A runner may print the error type first: "AssertionError: Expected: 4", or node:test's
29
+ // "Error [ERR_TEST_FAILURE]: Expected: 4".
30
+ { pattern: /^(?:[\w.]*Error(?: \[\w+\])?:\s*)?\s*Expected[^:\n]*:\s*(.+)$[\s\S]*?^\s*Received[^:\n]*:\s*(.+)$/m, expected: 1, received: 2 },
31
+ // xUnit (.NET): "Expected: 4" / "Actual: 3".
32
+ { pattern: /^\s*Expected:\s*(.+)$[\s\S]*?^\s*Actual:\s*(.+)$/m, expected: 1, received: 2 },
33
+ // NUnit (.NET): "Expected: 4" / "But was: 3".
34
+ { pattern: /^\s*Expected:\s*(.+)$[\s\S]*?^\s*But was:\s*(.+)$/m, expected: 1, received: 2 },
35
+ // RSpec (Ruby): "expected: 4" / "got: 3".
36
+ { pattern: /^\s*(?:\d+\)\s*)?expected:\s*(.+)$[\s\S]*?^\s*got:\s*(.+)$/m, expected: 1, received: 2 },
37
+ // JUnit 4/5, TestNG, Kotlin (Java/JVM): "expected: <4> but was: <3>".
38
+ { pattern: /expected:\s*<([^>\n]*)>\s*but was:\s*<([^>\n]*)>/, expected: 1, received: 2 },
39
+ // Go convention: "got 3, want 4".
40
+ { pattern: /\bgot:?\s*([^,\n]+),\s*want:?\s*(.+)$/m, expected: 2, received: 1 },
41
+ // Rust assert_eq!: "left: 3" / "right: 4" (actual on the left by convention).
42
+ { pattern: /^\s*left:\s*(.+)$[\s\S]*?^\s*right:\s*(.+)$/m, expected: 2, received: 1 },
43
+ // Python unittest: "AssertionError: 3 != 4" (actual first).
44
+ { pattern: /AssertionError:\s*([^\n]+?)\s+!=\s+([^\n]+)$/m, expected: 2, received: 1 },
45
+ // pytest assertion rewriting: "assert 3 == 4" (actual first by convention).
46
+ { pattern: /^\s*(?:E\s+)?assert\s+(.+?)\s+==\s+(.+)$/m, expected: 2, received: 1 },
47
+ // PHPUnit: "Failed asserting that 3 is identical to 4." / "... matches expected 4."
48
+ { pattern: /Failed asserting that (.+?) (?:is identical to|is equal to|matches expected) (.+?)\.?$/m, expected: 2, received: 1 },
49
+ // Chai (Mocha): "expected 3 to equal 4".
50
+ { pattern: /expected (.+?) to (?:deeply )?(?:equal|be|eql) (.+)$/m, expected: 2, received: 1 },
51
+ // MSTest (.NET): "Assert.AreEqual failed. Expected:<1>. Actual:<2>." on one line.
52
+ { pattern: /Expected:\s*<(.*?)>\.\s*Actual:\s*<(.*?)>/, expected: 1, received: 2 },
53
+ // JUnit 4 identity: "expected same:<X> was not:<Y>".
54
+ { pattern: /expected same:\s*<(.*?)>\s*was not:\s*<(.*?)>/, expected: 1, received: 2 },
55
+ // GoogleTest (C++): "Value of: add(1, 1)" / " Actual: 3" / "Expected: 2" - actual comes first.
56
+ { pattern: /^\s*Actual:\s*(.+)$[\s\S]{0,200}?^\s*Expected:\s*(.+)$/m, expected: 2, received: 1 },
57
+ // GoogleTest equality: "Expected equality of these values:\n 2\n 3" (expected first, then actual).
58
+ { pattern: /Expected equality of these values:\s*\n\s*(.+)\n\s*(.+)/, expected: 1, received: 2 },
59
+ // Catch2 (C++): "REQUIRE( Factorial(0) == 1 )" / "with expansion:" / " 0 == 1".
60
+ { pattern: /with expansion:\s*\n\s*(.+?)\s*==\s*(.+)$/m, expected: 2, received: 1 },
61
+ // XCTest (Swift/iOS): 'XCTAssertEqual failed: ("54") is not equal to ("100")'.
62
+ { pattern: /XCTAssert\w* failed:\s*\("?(.*?)"?\)\s*is not equal to\s*\("?(.*?)"?\)/, expected: 2, received: 1 },
63
+ // swift-testing: "Expectation failed: (current → .detached) == .attached".
64
+ { pattern: /Expectation failed:\s*\(.*?→\s*(.+?)\)\s*==\s*(.+)$/m, expected: 2, received: 1 },
65
+ // ScalaTest: "2 did not equal 1".
66
+ { pattern: /^\s*(.+?) did not equal (.+?)\s*$/m, expected: 2, received: 1 },
67
+ // MUnit (Scala): "=> Obtained\n1\n=> Diff (- expected, + obtained)\n-2\n+1".
68
+ { pattern: /=> Obtained\s*\n\s*(.+)\n[\s\S]{0,200}?^-\s*(.+)$/m, expected: 2, received: 1 },
69
+ // Deno and PHPUnit diffs: "[Diff] Actual / Expected" then "- actual" / "+ expected".
70
+ { pattern: /\[Diff\] Actual \/ Expected[\s\S]{0,200}?^\s*-\s*(.+)$\s*^\s*\+\s*(.+)$/m, expected: 2, received: 1 },
71
+ // PHPUnit: "--- Expected\n+++ Actual\n@@ @@\n-'bar'\n+'baz'".
72
+ { pattern: /--- Expected\s*\n\+\+\+ Actual[\s\S]{0,200}?^-(.+)$\s*^\+(.+)$/m, expected: 1, received: 2 },
73
+ // API testing, where the value is an HTTP status. REST Assured: "Expected status code <200> but
74
+ // was <500>"; Newman: "expected response to have status code 201 but got 405"; supertest:
75
+ // 'expected 200 "OK", got 403 "Forbidden"'; Karate: "status code was: 500, expected: 200".
76
+ { pattern: /Expected status code <(\d{3})> but was <(\d{3})>/, expected: 1, received: 2 },
77
+ { pattern: /expected response to have status code (\d{3}) but got (\d{3})/, expected: 1, received: 2 },
78
+ { pattern: /expected (\d{3}) "[^"\n]*", got (\d{3}) "[^"\n]*"/, expected: 1, received: 2 },
79
+ { pattern: /status code was: (\d{3}), expected: (\d{3})/, expected: 2, received: 1 },
80
+ // Jasmine (Angular's Karma default): "Expected 3 to equal 4." or, inside an object or array,
81
+ // "Expected $[0] = 'Dashboard' to equal 'Screenshots'.".
82
+ { pattern: /^\s*(?:Error: )?Expected (?:\$[^\s=]*\s*=\s*)?(.+?) to (?:equal|be) (.+?)\.$/m, expected: 2, received: 1 },
83
+ ];
84
+ /** A comparison that found the value (or element) but not the expected one, in any language. */
85
+ export function valueMismatch(message) {
86
+ // A message that says an element was never found is not a wrong value, whatever shape it takes:
87
+ // Flutter prints "Expected: exactly one matching candidate / Actual: Found 0 widgets", which
88
+ // otherwise reads as a mismatch and then as a real bug.
89
+ if (isElementNotFound(message))
90
+ return null;
91
+ const head = headOf(message);
92
+ for (const { pattern, expected: e, received: r } of MISMATCH_PATTERNS) {
93
+ const m = head.match(pattern);
94
+ if (!m)
95
+ continue;
96
+ const expected = m[e]?.trim();
97
+ const received = m[r]?.trim();
98
+ if (!expected || !received || /element\(s\) not found/i.test(received))
99
+ return null;
100
+ return { expected, received };
101
+ }
102
+ return null;
103
+ }
104
+ const NETWORK_PATTERNS = [
105
+ {
106
+ kind: 'connection',
107
+ confidence: 0.9,
108
+ pattern: /net::ERR_[A-Z_]+|NS_ERROR_[A-Z_]+|\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|EPIPE)\b|socket hang up|connection (?:refused|reset|closed)/i,
109
+ },
110
+ // Node's built-in fetch (undici) reports every network-level failure (refused, reset, DNS) as
111
+ // this one line, with the real cause nested where test reporters usually drop it.
112
+ { kind: 'connection', confidence: 0.8, pattern: /TypeError: fetch failed/ },
113
+ { kind: 'http-5xx', confidence: 0.85, pattern: /\b(?:5\d\d\s+)?(?:Internal Server Error|Bad Gateway|Service Unavailable|Gateway Time-?out)\b/i },
114
+ { kind: 'http-5xx', confidence: 0.85, pattern: /\b(?:status(?:\s*code)?|HTTP(?:\/[\d.]+)?|response code)\b\D{0,12}\b5\d\d\b/i },
115
+ // The API-testing wordings, where what came back was a server error or an auth refusal:
116
+ // REST Assured, Newman, supertest and Karate each say it differently.
117
+ { kind: 'http-5xx', confidence: 0.85, pattern: /but was <5\d\d>|but got 5\d\d\b|, got 5\d\d "|status code was: 5\d\d/ },
118
+ { kind: 'auth', confidence: 0.7, pattern: /but was <40[13]>|but got 40[13]\b|, got 40[13] "|status code was: 40[13]/ },
119
+ // expect(response.status()).toBe(200) that received a 5xx.
120
+ { kind: 'http-5xx', confidence: 0.8, pattern: /Expected[^:\n]*:\s*"?[23]\d\d"?\s*\n\s*Received[^:\n]*:\s*"?5\d\d\b"?/ },
121
+ { kind: 'auth', confidence: 0.7, pattern: /\b(?:401|403)\s+(?:Unauthorized|Forbidden)\b|\b(?:invalid|expired) (?:auth(?:entication)? )?(?:token|session|credentials)\b/i },
122
+ { kind: 'auth', confidence: 0.7, pattern: /Expected[^:\n]*:\s*"?[23]\d\d"?\s*\n\s*Received[^:\n]*:\s*"?40[13]\b"?/ },
123
+ ];
124
+ /** First network / dependent-service / auth signal in the message, strongest first. */
125
+ export function networkSignal(message) {
126
+ for (const { kind, confidence, pattern } of NETWORK_PATTERNS) {
127
+ const match = message.match(pattern)?.[0];
128
+ if (match)
129
+ return { kind, confidence, match: match.replace(/\s+/g, ' ').trim() };
130
+ }
131
+ return null;
132
+ }
133
+ /** A locator that matched several elements; Playwright lists each one with a unique locator. */
134
+ export function strictModeViolation(message) {
135
+ const head = headOf(message);
136
+ const m = head.match(/strict mode violation: (.+) resolved to (\d+) elements/);
137
+ if (!m)
138
+ return null;
139
+ const elements = [...head.matchAll(/^\s*\d+\) .* aka (.+)$/gm)].map(([, locator]) => {
140
+ const role = locator.match(/^getByRole\('([^']+)'/)?.[1];
141
+ const name = locator.match(/name: '((?:[^'\\]|\\.)*)'/)?.[1]?.replaceAll("\\'", "'");
142
+ return { locator: locator.trim(), role, name };
143
+ });
144
+ return { locator: m[1].trim(), count: Number(m[2]), isAction: !/^\s*Error: expect\(/.test(head), elements };
145
+ }
146
+ /** The accessible name or text a locator looks for, when it names one. */
147
+ export function locatorName(locator) {
148
+ const m = [...locator.matchAll(/(?:name: |getBy(?:Text|Label|Title|Placeholder|AltText)\(|cy\.contains\()'((?:[^'\\]|\\.)*)'/g)].at(-1);
149
+ return m ? m[1].replaceAll("\\'", "'") : null;
150
+ }
@@ -0,0 +1,105 @@
1
+ import { errorShape, errorSignature } from './signature.js';
2
+ /** How many of a test's most recent runs the rules look at. */
3
+ export const HISTORY_WINDOW = 50;
4
+ const distinct = (records) => new Set(records.map((r) => errorSignature(r.errorMessage))).size;
5
+ export class HistoryIndex {
6
+ runs;
7
+ position = new Map();
8
+ constructor(records, summaries) {
9
+ const entries = new Map();
10
+ const entry = (runId, at, sha) => {
11
+ const existing = entries.get(runId);
12
+ if (existing) {
13
+ if (at < existing.at)
14
+ existing.at = at;
15
+ existing.sha ||= sha;
16
+ return existing;
17
+ }
18
+ const created = { runId, at, sha, branch: '', attempts: {}, failures: [] };
19
+ entries.set(runId, created);
20
+ return created;
21
+ };
22
+ for (const s of summaries) {
23
+ const e = entry(s.runId, s.startedAt, s.gitSha);
24
+ e.branch ||= s.branch ?? '';
25
+ for (const [testId, n] of Object.entries(s.attempts))
26
+ e.attempts[testId] = (e.attempts[testId] ?? 0) + n;
27
+ }
28
+ for (const r of records)
29
+ entry(r.runId, r.timestamp, r.gitSha).failures.push(r);
30
+ // A run with no summary line: its failures are the only attempts we know about.
31
+ for (const e of entries.values()) {
32
+ for (const r of e.failures) {
33
+ const failed = e.failures.filter((f) => f.testId === r.testId).length;
34
+ e.attempts[r.testId] = Math.max(e.attempts[r.testId] ?? 0, failed);
35
+ }
36
+ }
37
+ this.runs = [...entries.values()].sort((a, b) => a.at.localeCompare(b.at) || a.runId.localeCompare(b.runId));
38
+ this.runs.forEach((e, i) => this.position.set(e.runId, i));
39
+ }
40
+ /** Run ids, oldest first. */
41
+ get runIds() {
42
+ return this.runs.map((e) => e.runId);
43
+ }
44
+ historyAsOf(testId, runId) {
45
+ const until = this.indexOf(runId);
46
+ const entries = this.runs
47
+ .slice(0, until + 1)
48
+ .filter((e) => (e.attempts[testId] ?? 0) > 0)
49
+ .slice(-HISTORY_WINDOW)
50
+ .map((e) => ({ sha: e.sha, branch: e.branch, attempts: e.attempts[testId], failures: e.failures.filter((f) => f.testId === testId) }));
51
+ const sha = this.runs[until].sha;
52
+ const sameCommit = sha ? entries.filter((e) => e.sha === sha) : [];
53
+ const otherCommits = sha ? entries.filter((e) => e.sha !== sha) : [];
54
+ let streakStart = entries.length;
55
+ while (streakStart > 0 && entries[streakStart - 1].failures.length >= entries[streakStart - 1].attempts)
56
+ streakStart--;
57
+ const before = entries.slice(0, streakStart);
58
+ const streak = entries.slice(streakStart);
59
+ const runs = entries.reduce((sum, e) => sum + e.attempts, 0);
60
+ const allFailures = entries.flatMap((e) => e.failures);
61
+ const failuresBeforeStreak = before.reduce((sum, e) => sum + e.failures.length, 0);
62
+ const current = this.runs[until].attempts[testId] ? entries[entries.length - 1] : undefined;
63
+ const sum = (xs, f) => xs.reduce((n, e) => n + f(e), 0);
64
+ return {
65
+ currentRunAttempts: current?.attempts ?? 0,
66
+ currentRunFailures: current?.failures.length ?? 0,
67
+ sameCommitAttempts: sum(sameCommit, (e) => e.attempts),
68
+ sameCommitFailures: sum(sameCommit, (e) => e.failures.length),
69
+ passesOnOtherCommits: sum(otherCommits, (e) => e.attempts - e.failures.length),
70
+ failingRunsOnOtherCommits: otherCommits.filter((e) => e.failures.length > 0).length,
71
+ testId,
72
+ runs,
73
+ failures: allFailures.length,
74
+ failureRate: runs === 0 ? 0 : allFailures.length / runs,
75
+ distinctErrors: distinct(allFailures),
76
+ failingStreak: streak.length,
77
+ streakDistinctErrors: distinct(streak.flatMap((e) => e.failures)),
78
+ failuresBeforeStreak,
79
+ passesBeforeStreak: before.reduce((sum, e) => sum + e.attempts, 0) - failuresBeforeStreak,
80
+ // Counted within this run's own branch. Across branches the same test legitimately passes on
81
+ // one line of work and fails on another, which looks like flipping but is not.
82
+ earlierFlakeShapes: [
83
+ ...new Set((current ? entries.slice(0, -1) : entries)
84
+ .filter((e) => e.failures.length > 0 && e.failures.length < e.attempts)
85
+ .flatMap((e) => e.failures.map((f) => errorShape(f.errorMessage)))),
86
+ ],
87
+ alternations: entries
88
+ .filter((e) => e.branch === this.runs[until].branch)
89
+ .reduce((acc, e) => {
90
+ const failed = e.failures.length > 0;
91
+ return { count: acc.count + (acc.last !== null && acc.last !== failed ? 1 : 0), last: failed };
92
+ }, { count: 0, last: null }).count,
93
+ };
94
+ }
95
+ runContext(runId) {
96
+ const e = this.runs[this.indexOf(runId)];
97
+ return { runId, branch: e.branch, testsAttempted: Object.keys(e.attempts).length, failures: e.failures };
98
+ }
99
+ indexOf(runId) {
100
+ const i = this.position.get(runId);
101
+ if (i === undefined)
102
+ throw new Error(`run ${runId} is not in the history index`);
103
+ return i;
104
+ }
105
+ }