tewip-ai 0.15.0 → 0.16.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.
@@ -69,10 +69,17 @@ export function hasAssertionAfter(source, line) {
69
69
  for (let i = line - 1; i < lines.length; i++) {
70
70
  if (i > line - 1 && /^\s*test(\.\w+)?\(/.test(lines[i]))
71
71
  return false;
72
- if (/\bexpect\s*\(/.test(lines[i]))
72
+ if (ASSERTION.test(lines[i]))
73
73
  return true;
74
74
  if (/^\}\);?\s*$/.test(lines[i]))
75
75
  return false;
76
76
  }
77
77
  return false;
78
78
  }
79
+ /**
80
+ * What counts as checking something. `expect(` alone missed a whole framework: Cypress asserts with
81
+ * `cy.get(...).should('be.visible')` and writes `expect(` only inside `.then()`. Six real Cypress
82
+ * fixed-wait removals were refused as "nothing is asserted after the wait" when the very next line
83
+ * was an assertion, which is how this was found. Python's bare `assert` counts for the same reason.
84
+ */
85
+ const ASSERTION = /\bexpect\s*\(|\.should\s*\(|\bassert\s+|\bassertThat\s*\(/;
@@ -0,0 +1,61 @@
1
+ // What to put in place of a fixed wait, and - more important - when there is nothing safe to put.
2
+ //
3
+ // Split out of waits.ts after the first measurement of this fix against real commits: 60 commits
4
+ // from 47 public repositories in which a developer removed one fixed wait from a test. Two things
5
+ // that measurement showed:
6
+ //
7
+ // 1. Tewip offered a second option in 0 of 43 proposals. The explicit-wait candidate existed but
8
+ // almost never fired, because it only recognised a Playwright action written in one shape. So in
9
+ // practice Tewip could only ever say "delete the wait", and disagreed with the developer in 27
10
+ // cases where they waited for something instead.
11
+ // 2. More serious: deleting a fixed wait is only safe when the *next* step waits by itself.
12
+ // `await expect(locator).toBeVisible()` retries until it passes, so the fixed wait before it is
13
+ // pure delay. `const url = page.url()`, `.count()`, `.textContent()` and `if (await x.isEnabled())`
14
+ // read a value once and never retry - and those were common in the real commits. Deleting the
15
+ // wait in front of one of those removes the only thing making the read reliable, and because a
16
+ // race passes by luck, even three passing runs can fail to catch it.
17
+ //
18
+ // So a bare deletion is now offered only in front of a step that retries on its own. Everywhere
19
+ // else Tewip must name something to wait for, or refuse.
20
+ /** A step that waits by itself, so a fixed wait in front of it only adds time. */
21
+ export function nextStepRetries(line, framework) {
22
+ if (framework === 'cypress') {
23
+ // Every cy.* query retries until it resolves, except cy.wait itself.
24
+ return /\bcy\.(?!wait\b)\w+\s*\(/.test(line);
25
+ }
26
+ if (framework !== 'playwright')
27
+ return false;
28
+ // `await expect(page.getByRole(...)).toBeVisible()` retries; `expect(page.url())` and
29
+ // `expect(await x.count())` read a value once and do not.
30
+ const assertion = /\bexpect\s*\(\s*([^)]*)/.exec(line);
31
+ if (assertion && !/\bawait\b/.test(assertion[1]) && LOCATOR_EXPRESSION.test(assertion[1]))
32
+ return true;
33
+ // A step that is itself a wait, or a navigation, already blocks until the page is ready: a fixed
34
+ // wait in front of `await this.cancelButton().waitFor({ state: 'visible' })` is pure delay, and
35
+ // that exact line appeared in the real commits.
36
+ if (SELF_WAITING.test(line))
37
+ return true;
38
+ // An action on a locator waits for that element to be actionable first.
39
+ return ACTION_ON_LOCATOR.test(line);
40
+ }
41
+ const SELF_WAITING = /\.\s*waitFor(?:Selector|URL|LoadState|Function|Event|Response|Request|Navigation)?\s*\(|\.\s*goto\s*\(/;
42
+ const LOCATOR_EXPRESSION = /(?:page|frame|component|[\w$]+)\s*\.\s*(?:getBy\w+|locator|frameLocator)\s*\(|^\s*[\w$]+\s*$/;
43
+ const ACTION_ON_LOCATOR = /(?:getBy\w+\([^;]*?\)|locator\([^;]*?\))(?:\s*\.\s*\w+\([^;]*?\))*?\s*\.\s*(?:click|fill|press|check|uncheck|selectOption|hover|type|tap|dragTo|setInputFiles|focus)\s*\(/;
44
+ /**
45
+ * The thing worth waiting for instead of the guess: whatever the next step acts on or reads.
46
+ * Recognising only `locator.click()` was why the explicit-wait option never appeared in practice;
47
+ * real code just as often assigns the locator first, asserts on it, or reads from it.
48
+ */
49
+ export function thingToWaitFor(line, framework) {
50
+ if (framework === 'cypress') {
51
+ const m = /\bcy\.get\s*\(\s*([^)]+?)\s*\)/.exec(line);
52
+ return m ? `cy.get(${m[1]})` : null;
53
+ }
54
+ const chain = /((?:page|frame|component|[\w$]+)\s*\.\s*(?:getBy\w+|locator|frameLocator)\s*\([^;]*?\)(?:\s*\.\s*(?:first|last|nth|filter|getBy\w+|locator)\s*\([^;]*?\))*)/.exec(line);
55
+ if (!chain)
56
+ return null;
57
+ const expression = chain[1].trim();
58
+ // `await page.locator('x').count()` and `const n = page.locator('x')` both point at the same
59
+ // element; the expression itself is what to wait for either way.
60
+ return expression.startsWith('.') ? `page${expression}` : expression;
61
+ }
@@ -6,17 +6,30 @@
6
6
  // time, so one passing run proves the fix. A race passes by luck, so one passing run proves nothing.
7
7
  // A wait fix is therefore only accepted after several passes in a row, and the whole change is
8
8
  // deleting or replacing a line whose only job was to wait.
9
+ // What may follow the wait on its own line and still leave it a pure wait: a semicolon, and a
10
+ // comment. Real code nearly always carries one - `cy.wait(500); // eslint-disable-line` is written
11
+ // precisely *because* the line is a fixed wait, and `waitForTimeout(300); // allow debounce` says
12
+ // what the guess was for. Requiring the line to end after the call made Tewip refuse 15 of 60 real
13
+ // fixed-wait removals harvested from public repositories, which is how this was found.
14
+ const TRAILING = String.raw `\s*;?\s*(?://.*)?$`;
15
+ const TRAILING_PY = String.raw `\s*(?:#.*)?$`;
16
+ /** Digits, allowing JavaScript's numeric separators: `3_000` is as common as `3000`. */
17
+ const MS = String.raw `[\d_]+`;
18
+ import { nextStepRetries, thingToWaitFor } from './wait-candidates.js';
9
19
  const FIXED_WAIT = [
10
20
  // Playwright and Puppeteer
11
- /^(?<indent>\s*)(?:await\s+)?[\w.]*page\.waitForTimeout\(\s*(?<ms>\d+)\s*\)\s*;?\s*$/,
21
+ new RegExp(String.raw `^(?<indent>\s*)(?:await\s+)?[\w.]*page\.waitForTimeout\(\s*(?<ms>${MS})\s*\)${TRAILING}`),
12
22
  // Cypress
13
- /^(?<indent>\s*)cy\.wait\(\s*(?<ms>\d+)\s*\)\s*;?\s*$/,
23
+ new RegExp(String.raw `^(?<indent>\s*)cy\.wait\(\s*(?<ms>${MS})\s*\)${TRAILING}`),
14
24
  // WebdriverIO and anything with a sleep helper
15
- /^(?<indent>\s*)(?:await\s+)?(?:browser\.pause|sleep|delay)\(\s*(?<ms>\d+)\s*\)\s*;?\s*$/,
25
+ new RegExp(String.raw `^(?<indent>\s*)(?:await\s+)?(?:browser\.pause|sleep|delay)\(\s*(?<ms>${MS})\s*\)${TRAILING}`),
26
+ // The hand-rolled sleep, which is what people write when no helper exists:
27
+ // `await new Promise(resolve => setTimeout(resolve, 500))`.
28
+ new RegExp(String.raw `^(?<indent>\s*)(?:await\s+)?new\s+Promise\(\s*\(?\s*(?<arg>\w+)\s*\)?\s*=>\s*setTimeout\(\s*\k<arg>\s*,\s*(?<ms>${MS})\s*\)\s*\)${TRAILING}`),
16
29
  // Java and Kotlin
17
- /^(?<indent>\s*)Thread\.sleep\(\s*(?<ms>\d+)\s*\)\s*;?\s*$/,
30
+ new RegExp(String.raw `^(?<indent>\s*)Thread\.sleep\(\s*(?<ms>${MS})\s*\)${TRAILING}`),
18
31
  // Python
19
- /^(?<indent>\s*)time\.sleep\(\s*(?<seconds>[\d.]+)\s*\)\s*$/,
32
+ new RegExp(String.raw `^(?<indent>\s*)time\.sleep\(\s*(?<seconds>[\d._]+)\s*\)${TRAILING_PY}`),
20
33
  ];
21
34
  /** Every fixed wait in the file, in order. */
22
35
  export function findFixedWaits(source) {
@@ -26,7 +39,8 @@ export function findFixedWaits(source) {
26
39
  const m = line.match(pattern);
27
40
  if (!m?.groups)
28
41
  continue;
29
- const ms = m.groups.seconds ? Math.round(Number(m.groups.seconds) * 1000) : Number(m.groups.ms);
42
+ const digits = (text) => Number(text.replace(/_/g, ''));
43
+ const ms = m.groups.seconds ? Math.round(digits(m.groups.seconds) * 1000) : digits(m.groups.ms);
30
44
  out.push({ line: i + 1, text: line.trim(), ms, indent: m.groups.indent ?? '' });
31
45
  break;
32
46
  }
@@ -39,36 +53,33 @@ export function waitNearest(sites, failing) {
39
53
  return sites[0];
40
54
  return [...sites].sort((a, b) => Math.abs(a.line - failing) - Math.abs(b.line - failing))[0];
41
55
  }
42
- /** The Playwright locator a line acts on, when it has one: the thing worth waiting for instead. */
43
- function locatorIn(line) {
44
- const m = line.match(/((?:page|frame|component)?\.?(?:getBy\w+\([^;]*?\)|locator\([^;]*?\))(?:\.\w+\([^;]*?\))*?)\.(?:click|fill|press|check|selectOption|hover|type|textContent|innerText|isVisible)\(/);
45
- return m ? m[1].replace(/^\./, 'page.') : null;
46
- }
47
56
  /**
48
- * What to put in place of the fixed wait, best first.
57
+ * What to put in place of the fixed wait, best first - and nothing at all when nothing is safe.
49
58
  *
50
- * Removing it is first on purpose: Playwright and Cypress already wait for an element to be
51
- * actionable before clicking or asserting, so a fixed wait in front of such a step adds nothing but
52
- * time, and deleting it is the smallest possible change. Where the next line does act on an element,
53
- * waiting for that element is offered too, for suites whose style is an explicit wait.
59
+ * Deleting it comes first only where the next step waits by itself: Playwright and Cypress wait for
60
+ * an element before acting on it, and a web-first assertion retries until it passes, so a fixed wait
61
+ * in front of one of those is pure delay. In front of a step that reads a value once -
62
+ * `page.url()`, `.count()`, `if (await x.isEnabled())` - the fixed wait may be the only thing making
63
+ * the read reliable, so deletion is not offered there at all and Tewip must name something to wait
64
+ * for or refuse. Measured against 60 real fixed-wait removals in 47 public repositories.
54
65
  */
55
66
  export function waitFixCandidates(source, site, framework) {
56
67
  const lines = source.split('\n');
57
68
  const next = lines.slice(site.line, site.line + 3).find((l) => l.trim() && !l.trim().startsWith('//'));
58
- const autoWaits = framework === 'playwright' || framework === 'cypress';
69
+ if (!next)
70
+ return [];
59
71
  const candidates = [];
60
- if (autoWaits && next) {
72
+ if (nextStepRetries(next, framework)) {
61
73
  candidates.push({
62
74
  replacement: '',
63
- why: `${framework === 'playwright' ? 'Playwright' : 'Cypress'} already waits for the element in the next step to be ready, so the fixed ${site.ms} ms wait only added time.`,
75
+ why: `${framework === 'cypress' ? 'Cypress' : 'Playwright'} already waits for the next step to be ready, so the fixed ${site.ms} ms wait only added time.`,
64
76
  });
65
77
  }
66
- const locator = next ? locatorIn(next) : null;
67
- if (locator) {
68
- candidates.push({
69
- replacement: `${site.indent}await expect(${locator}).toBeVisible();`,
70
- why: `Waits for ${locator} to be there instead of guessing ${site.ms} ms.`,
71
- });
78
+ const thing = thingToWaitFor(next, framework);
79
+ if (thing) {
80
+ candidates.push(framework === 'cypress'
81
+ ? { replacement: `${site.indent}${thing}.should('be.visible');`, why: `Waits for ${thing} to be there instead of guessing ${site.ms} ms.` }
82
+ : { replacement: `${site.indent}await expect(${thing}).toBeVisible();`, why: `Waits for ${thing} to be there instead of guessing ${site.ms} ms.` });
72
83
  }
73
84
  return candidates;
74
85
  }
@@ -25,12 +25,11 @@ function sharedWords(errors, min = 0.6) {
25
25
  */
26
26
  function ruleOf(record) {
27
27
  const diagnosis = record?.diagnosis;
28
- if (diagnosis?.ruleId)
29
- return diagnosis.ruleId;
30
- for (const e of diagnosis?.evidence ?? [])
31
- if (e.source && e.source !== 'engine' && e.source !== 'llm')
32
- return e.source;
33
- return '';
28
+ // Only a diagnosis a rule actually decided has a rule to blame. Reading a name out of the
29
+ // evidence instead was wrong twice over: a rule that *declined* still leaves evidence under its
30
+ // own name, so failures the model answered were being blamed on a rule that had explicitly said
31
+ // "not mine". A report that misnames the rule sends someone to read the wrong code.
32
+ return diagnosis?.source === 'rules' ? (diagnosis.ruleId ?? '') : '';
34
33
  }
35
34
  const layerOf = (record) => String(record?.diagnosis?.source ?? 'unknown');
36
35
  /** "rules 5, llm 2" - said in full, because "2 different layers" tells a person nothing. */
@@ -161,10 +160,11 @@ export function renderLearning(learned) {
161
160
  lines.push(` - **${example.testId}** — ${example.error}${example.why ? ` (you said: ${example.why})` : ''}`);
162
161
  lines.push('');
163
162
  const topRule = mistake.rules[0];
163
+ const ruleDecided = mistake.rules.reduce((sum, r) => sum + r.count, 0);
164
164
  lines.push(mistake.rule
165
165
  ? `What to do: read \`${mistake.rule}\` against those failures - it decided ${topRule.count} of ${mistake.count}. If it is matching something it should not, narrow it; if a rule for ${mistake.corrected} should have matched first, order that one first.`
166
166
  : topRule
167
- ? `What to do: no single rule is behind these - ${mistake.rules.length} different ones are, the commonest being \`${topRule.rule}\` (${topRule.count} of ${mistake.count}). That points at a missing rule for ${mistake.corrected} rather than a wrong one: the words above are what it would match on.`
167
+ ? `What to do: no rule is behind most of these. ${mistake.rules.length === 1 ? `\`${topRule.rule}\` decided ${topRule.count} of ${mistake.count}` : `${mistake.rules.length} different rules decided ${ruleDecided} of ${mistake.count}, the commonest being \`${topRule.rule}\``}; the model was left to answer the other ${mistake.count - ruleDecided}. That points at a missing rule for ${mistake.corrected} rather than a wrong one - one that settles these before the model is asked: the words above are what it would match on.`
168
168
  : `What to do: no rule decided these, so the model was left to answer them. A rule for ${mistake.corrected} matching the words above would settle them without asking it.`);
169
169
  lines.push('');
170
170
  }
@@ -3,28 +3,28 @@
3
3
  import { ROOT_CAUSES } from '../../reporters/types.js';
4
4
  /** Bump whenever the prompt, schema or context format changes; it is part of the cache key. */
5
5
  export const PROMPT_VERSION = 'llm-6';
6
- export const SYSTEM_PROMPT = `You are the root-cause classifier of a Playwright test-failure triage agent.
7
- You receive a structured summary of ONE failed test: the error, the test's run history, what the deterministic rules already checked, the test steps, the page at failure time (accessibility snapshot + notable DOM elements), failed network requests and console errors.
8
-
9
- Classify the root cause into exactly one of these categories:
10
- - REAL_BUG: the application under test behaves incorrectly. Examples: a wrong value is displayed; a feature or element the test needs is absent from the page and nothing equivalent exists; the app never reaches the expected state even though nothing external failed; uncaught page errors from application code.
11
- - LOCATOR_DRIFT: the element the test wants is on the page, but the selector no longer matches it (renamed id, class, test id or text). The page summary must show an element with the same purpose.
12
- - TIMING: the expected element or state does arrive, but not in time, or a race makes the result vary between attempts or runs. Signs: another attempt of the same code passed, intermittent history with varying errors, hard-coded waits, the element resolved but was not yet visible, stable or enabled.
13
- - ENV: something outside the app code and the test broke: failed or 5xx/502/503 responses from a backend or third-party service, connection or DNS errors, expired auth or credentials, or many tests in the same run failing the same way.
14
- - TEST_DATA: the test's preconditions or fixture data are wrong: missing or stale seeded data, a user or account in the wrong state, an expected value that depends on data the test did not set up, a 404 for a record the test assumed exists.
15
-
16
- How to decide:
17
- - Use only facts present in the summary. Do not invent page content, requests or history.
18
- - Prefer the category the evidence directly shows over one that is merely possible.
19
- - An element that is missing with no similar element on the page is not LOCATOR_DRIFT.
20
- - Playwright matches accessible names by substring, so a locator can match several elements at once ("strict mode violation"). That is LOCATOR_DRIFT only when one of the matched elements is the one the test means, which usually carries the locator's name as a whole word ("Edit" -> "Edit link"). When every match only contains the text inside a longer word or phrase ("Edit" -> "Editor canvas", "Gift" -> "Gifting options"), the element the test means is absent: judge it as REAL_BUG, TIMING or TEST_DATA on the other evidence, not LOCATOR_DRIFT.
21
- - A failed request or server error is only ENV evidence if the test did not provoke it. When "test.exercisesErrorHandling" is set, the test deliberately triggers failures (rejections, server errors, retries), so a failed request is the scenario under test: judge whether the app handled it as the test expects. If it did not, that is REAL_BUG, not ENV. A real environment outage also tends to hit unrelated tests and to pass on retry, rather than failing every attempt on one commit while other commits pass.
22
- - Check "thisRun" first. If anotherAttemptPassed is true, the same code both failed and passed, so the cause is not deterministic: a real bug or a drifted locator would fail every attempt. Choose among TIMING, ENV and TEST_DATA.
23
- - Then read "history.pattern", which compares this commit's attempts with other commits. If every attempt on this commit failed while other commits passed, something in this commit changed behaviour: choose among REAL_BUG, LOCATOR_DRIFT and TEST_DATA using the page summary, not TIMING. An overall failure rate that mixes different commits is not evidence of flakiness; only failing and passing on the same code is.
24
- - The "rules" section lists what the deterministic rules already examined and why they declined; treat those as checked facts.
25
-
26
- Confidence (0 to 1): 0.9 or higher only when the summary directly shows the cause; 0.6-0.8 when it is the most likely reading but another category remains plausible; below 0.5 when you are mostly guessing. Low confidence routes the failure to a human, so do not inflate it.
27
-
6
+ export const SYSTEM_PROMPT = `You are the root-cause classifier of a Playwright test-failure triage agent.
7
+ You receive a structured summary of ONE failed test: the error, the test's run history, what the deterministic rules already checked, the test steps, the page at failure time (accessibility snapshot + notable DOM elements), failed network requests and console errors.
8
+
9
+ Classify the root cause into exactly one of these categories:
10
+ - REAL_BUG: the application under test behaves incorrectly. Examples: a wrong value is displayed; a feature or element the test needs is absent from the page and nothing equivalent exists; the app never reaches the expected state even though nothing external failed; uncaught page errors from application code.
11
+ - LOCATOR_DRIFT: the element the test wants is on the page, but the selector no longer matches it (renamed id, class, test id or text). The page summary must show an element with the same purpose.
12
+ - TIMING: the expected element or state does arrive, but not in time, or a race makes the result vary between attempts or runs. Signs: another attempt of the same code passed, intermittent history with varying errors, hard-coded waits, the element resolved but was not yet visible, stable or enabled.
13
+ - ENV: something outside the app code and the test broke: failed or 5xx/502/503 responses from a backend or third-party service, connection or DNS errors, expired auth or credentials, or many tests in the same run failing the same way.
14
+ - TEST_DATA: the test's preconditions or fixture data are wrong: missing or stale seeded data, a user or account in the wrong state, an expected value that depends on data the test did not set up, a 404 for a record the test assumed exists.
15
+
16
+ How to decide:
17
+ - Use only facts present in the summary. Do not invent page content, requests or history.
18
+ - Prefer the category the evidence directly shows over one that is merely possible.
19
+ - An element that is missing with no similar element on the page is not LOCATOR_DRIFT.
20
+ - Playwright matches accessible names by substring, so a locator can match several elements at once ("strict mode violation"). That is LOCATOR_DRIFT only when one of the matched elements is the one the test means, which usually carries the locator's name as a whole word ("Edit" -> "Edit link"). When every match only contains the text inside a longer word or phrase ("Edit" -> "Editor canvas", "Gift" -> "Gifting options"), the element the test means is absent: judge it as REAL_BUG, TIMING or TEST_DATA on the other evidence, not LOCATOR_DRIFT.
21
+ - A failed request or server error is only ENV evidence if the test did not provoke it. When "test.exercisesErrorHandling" is set, the test deliberately triggers failures (rejections, server errors, retries), so a failed request is the scenario under test: judge whether the app handled it as the test expects. If it did not, that is REAL_BUG, not ENV. A real environment outage also tends to hit unrelated tests and to pass on retry, rather than failing every attempt on one commit while other commits pass.
22
+ - Check "thisRun" first. If anotherAttemptPassed is true, the same code both failed and passed, so the cause is not deterministic: a real bug or a drifted locator would fail every attempt. Choose among TIMING, ENV and TEST_DATA.
23
+ - Then read "history.pattern", which compares this commit's attempts with other commits. If every attempt on this commit failed while other commits passed, something in this commit changed behaviour: choose among REAL_BUG, LOCATOR_DRIFT and TEST_DATA using the page summary, not TIMING. An overall failure rate that mixes different commits is not evidence of flakiness; only failing and passing on the same code is.
24
+ - The "rules" section lists what the deterministic rules already examined and why they declined; treat those as checked facts.
25
+
26
+ Confidence (0 to 1): 0.9 or higher only when the summary directly shows the cause; 0.6-0.8 when it is the most likely reading but another category remains plausible; below 0.5 when you are mostly guessing. Low confidence routes the failure to a human, so do not inflate it.
27
+
28
28
  Return JSON only: the category, the confidence, a short reasoning (at most 3 sentences), and 1-4 evidence items quoting or pointing at specific facts from the summary.`;
29
29
  export const OUTPUT_SCHEMA_NAME = 'triage_diagnosis';
30
30
  export const OUTPUT_SCHEMA = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
5
5
  "keywords": [
6
6
  "test-failures",