tewip-ai 0.15.1 → 0.16.1
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.
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ It works for every stack: Node.js (node:test, Jest, Vitest, Mocha), Python (pyte
|
|
|
14
14
|
|
|
15
15
|
Formerly *triage-agent*: the repository moved to `hmamut39/tewip`, and GitHub redirects the old address. See [ROADMAP.md](ROADMAP.md) for the plan.
|
|
16
16
|
|
|
17
|
-
**Status: phases 1 to
|
|
17
|
+
**Status: phases 1 to 14 are built; the two auto-fix phases (5 and 8) are the ones still in progress.** Tewip collects failures from any framework, diagnoses them with rules first and a model only for what rules cannot settle, and turns that into work for each role: a CI comment and merge gate, test plans and verified tests, a flake dossier, a PR-risk review, security scanning, tickets in Jira, Rally, GitHub Issues, Azure Boards or Linear, checks generated from a Figma design, specifications written for people, and - opt in, and only when proved - fixes to test or application code. It runs live in GitHub CI and answers questions from AI assistants through MCP. On fresh real Gutenberg CI failures, with the pipeline frozen before labelling, accuracy is 93.4%; across six real datasets it ranges from 86.1% to 99.7%, and the caveats are in [eval/README.md](eval/README.md).
|
|
18
18
|
|
|
19
19
|
## Use it in your project
|
|
20
20
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** The line is checking the value rather than creating anything with it. */
|
|
2
|
+
const ASSERTING = /\bexpect\s*\(|\.\s*should\s*\(|\bassert(?:Equals?|That|ion)?\b|\btoBe\w*\s*\(|\bto(?:Have|Contain)\w*\s*\(/;
|
|
3
|
+
/**
|
|
4
|
+
* The line is making or sending something: this is data, and making it unique is the fix.
|
|
5
|
+
* The object-property form matters most and was missing at first - real test data is written
|
|
6
|
+
* `name: "Secondi"` and `employee_name="Nguyen Van A"` far more often than it is assigned to its
|
|
7
|
+
* own variable, so without it four real creation sites were misread as assertions.
|
|
8
|
+
*/
|
|
9
|
+
const CREATING = /\b(?:const|let|var)\s+[\w$]+\s*=|[\w$]+\s*[:=]\s*['"`]|\b(?:create|insert|add|register|signup|signUp|post|put|save|seed|make|new)\w*\s*\(|\.\s*(?:fill|type|values|send)\s*\(/;
|
|
10
|
+
/**
|
|
11
|
+
* The value is naming an element, not creating a record. `getByRole('button', { name: 'Submit' })`
|
|
12
|
+
* reads exactly like an object property to the pattern above, and rewriting it would point the test
|
|
13
|
+
* at an element that does not exist - breaking the test rather than fixing the collision. A unit
|
|
14
|
+
* test caught this before any real data did.
|
|
15
|
+
*/
|
|
16
|
+
const A_SELECTOR = /\bgetBy\w+\s*\(|\bfindBy\w+\s*\(|\blocator\s*\(|\bcy\.get\s*\(|querySelector|\bBy\.\w+\s*\(|\bselector\b/i;
|
|
17
|
+
/**
|
|
18
|
+
* Picks the one occurrence it is safe to make unique, given the whole file.
|
|
19
|
+
*/
|
|
20
|
+
export function chooseDataSite(source, sites, value) {
|
|
21
|
+
const asserting = sites.filter((s) => ASSERTING.test(lineOf(source, s)));
|
|
22
|
+
const creating = sites.filter((s) => {
|
|
23
|
+
const line = lineOf(source, s);
|
|
24
|
+
return CREATING.test(line) && !ASSERTING.test(line) && !A_SELECTOR.test(line);
|
|
25
|
+
});
|
|
26
|
+
if (creating.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
site: null,
|
|
29
|
+
reason: 'value-only-asserted',
|
|
30
|
+
why: sites.length === 1
|
|
31
|
+
? `"${value}" appears only in ${lineOf(source, sites[0]).trim().slice(0, 60)}, where the test is checking it rather than creating anything. Making that unique would change what the test asserts instead of fixing the collision.`
|
|
32
|
+
: `"${value}" is written ${sites.length} times in the test and none of them creates anything: they are assertions or arguments Tewip cannot tell apart. A person should decide which one is the data.`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (creating.length > 1) {
|
|
36
|
+
return {
|
|
37
|
+
site: null,
|
|
38
|
+
reason: 'value-ambiguous',
|
|
39
|
+
why: `"${value}" is created in ${creating.length} places (lines ${creating.map((s) => s.line).join(', ')}). Making only one unique would break the link between them, so this needs a person.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (asserting.length > 0) {
|
|
43
|
+
// The commonest real shape of all: `name: "Secondi"` on one line and
|
|
44
|
+
// `expect(body.data.name).toBe("Secondi")` a few lines down. Making the data unique without
|
|
45
|
+
// rewriting the assertion turns a flaky test into a failing one, and rewriting both is a bigger
|
|
46
|
+
// change than this fix is allowed to make.
|
|
47
|
+
return {
|
|
48
|
+
site: null,
|
|
49
|
+
reason: 'value-also-asserted',
|
|
50
|
+
why: `"${value}" is created on line ${creating[0].line} and also checked on line ${asserting[0].line}. Making the data unique would leave the assertion looking for the old value, so the two have to change together - which is a person's call, not this fix's.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
site: creating[0],
|
|
55
|
+
why: sites.length === 1 ? `"${value}" is written once in the test, where the record is created` : `"${value}" is written ${sites.length} times; line ${creating[0].line} is the one that creates the record`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const lineOf = (source, site) => source.split('\n')[site.line - 1] ?? '';
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Same sandbox as the other fixes: test files only, never application code, and the file goes back
|
|
9
9
|
// exactly as it was unless a candidate is accepted.
|
|
10
10
|
import { relative, resolve, sep } from 'node:path';
|
|
11
|
+
import { chooseDataSite } from './data-site.js';
|
|
11
12
|
import { applyDataFix, collision, dataFixCandidates, findLiteral, languageOf } from './data.js';
|
|
12
13
|
/** Two passes in a row: the second run is what proves the value is really unique. */
|
|
13
14
|
export const REQUIRED_PASSES = 2;
|
|
@@ -34,7 +35,13 @@ export async function attemptDataFix({ record, diagnosis, action, specFile }, de
|
|
|
34
35
|
const sites = findLiteral(source, found.value);
|
|
35
36
|
if (sites.length === 0)
|
|
36
37
|
return stop('value-not-in-test', `"${found.value}" is not written in ${specFile}: it comes from a fixture or a factory, which a person should make unique`);
|
|
37
|
-
|
|
38
|
+
// Not simply the first occurrence: a colliding value is often also a value the test checks, and
|
|
39
|
+
// rewriting that would change what the test asserts while leaving the collision in place.
|
|
40
|
+
const choice = chooseDataSite(source, sites, found.value);
|
|
41
|
+
if (!choice.site)
|
|
42
|
+
return stop(choice.reason, `refused: ${choice.why}`);
|
|
43
|
+
const site = choice.site;
|
|
44
|
+
result.log.push(choice.why);
|
|
38
45
|
result.line = site.line;
|
|
39
46
|
result.original = site.text;
|
|
40
47
|
const candidates = dataFixCandidates(found.value, languageOf(specFile));
|
|
@@ -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 (
|
|
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
|
-
|
|
21
|
+
new RegExp(String.raw `^(?<indent>\s*)(?:await\s+)?[\w.]*page\.waitForTimeout\(\s*(?<ms>${MS})\s*\)${TRAILING}`),
|
|
12
22
|
// Cypress
|
|
13
|
-
|
|
23
|
+
new RegExp(String.raw `^(?<indent>\s*)cy\.wait\(\s*(?<ms>${MS})\s*\)${TRAILING}`),
|
|
14
24
|
// WebdriverIO and anything with a sleep helper
|
|
15
|
-
|
|
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
|
-
|
|
30
|
+
new RegExp(String.raw `^(?<indent>\s*)Thread\.sleep\(\s*(?<ms>${MS})\s*\)${TRAILING}`),
|
|
18
31
|
// Python
|
|
19
|
-
|
|
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
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
|
|
69
|
+
if (!next)
|
|
70
|
+
return [];
|
|
59
71
|
const candidates = [];
|
|
60
|
-
if (
|
|
72
|
+
if (nextStepRetries(next, framework)) {
|
|
61
73
|
candidates.push({
|
|
62
74
|
replacement: '',
|
|
63
|
-
why: `${framework === '
|
|
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
|
|
67
|
-
if (
|
|
68
|
-
candidates.push(
|
|
69
|
-
replacement: `${site.indent}
|
|
70
|
-
why: `Waits for ${
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
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",
|