tewip-ai 0.16.2 → 0.17.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.
package/README.md
CHANGED
|
@@ -159,6 +159,15 @@ tewip write --only CO-002,CO-004 --framework pytest
|
|
|
159
159
|
```
|
|
160
160
|
|
|
161
161
|
- **`tewip plan`** reads what is really there: the tests you already have (in any language), the change (`--diff main...HEAD`), the live pages (what a user sees on each page, via Playwright), and the failures Tewip has diagnosed in this project before. The plan lists risks and scenarios by priority. A claim that a scenario is "already covered" is checked against your real test names, and dropped if no such test exists. Saved to `.tewip/plans/` as Markdown to review and JSON for `write`.
|
|
162
|
+
- **Does a plan catch real bugs?** Measured on 18 real pull requests that were later found to have
|
|
163
|
+
broken something: the repository is fetched at the pull request's own head, the plan written from
|
|
164
|
+
the diff a reviewer saw, and nothing about the bug is known to it. **In 8 of the 9 pairs that
|
|
165
|
+
could be judged, a scenario named the bug** - in `matrix-org/complement`, `NLnetLabs/domain`,
|
|
166
|
+
`Bioconductor/rhdf5`, `Try/OpenGothic` and others. One pull request was titled "Resolve nightly
|
|
167
|
+
warnings" and the plan still found the behaviour change hidden inside it. The caveats are real
|
|
168
|
+
and written out in [ROADMAP.md](ROADMAP.md): nine judged pairs is a small sample, Claude did the
|
|
169
|
+
judging rather than each project's engineers, and the one miss is the interesting case - when a
|
|
170
|
+
pull request's *intention* is wrong, a plan grounded in that intention agrees with it.
|
|
162
171
|
- **`tewip write`** writes each scenario as a test in **your** framework and style: Playwright, Cypress, WebdriverIO, Angular, Jest, Vitest, pytest (with Selenium or Playwright), JUnit on Maven or Gradle (Selenium, Spring Boot), .NET or Go. It copies the style of an existing test and uses only elements seen on the real page. Front-end apps get component tests the way their own tests are written: React, Vue or Svelte with Testing Library under Jest or Vitest, and Angular through `ng test` (Karma and Jasmine, or Vitest). A project with both e2e and unit tests gets browser tests when the plan read pages, and unit or component tests when it did not. Then it runs the test. A draft is **kept only if** it passes every run (3 by default) **and** fails once its main check is broken on purpose, because a test that cannot fail is worse than no test. A failing draft is repaired from the real error, twice at most. If the test looks right and the app does something else, Tewip reports a **possible bug** instead of weakening the test until it passes. Kept tests are new files beside yours, for you to review and commit; nothing is committed, and no application code is touched.
|
|
163
172
|
|
|
164
173
|
## Check the page against the design (Phase 11, new)
|
|
@@ -273,6 +282,18 @@ Measured on **16 real commits** from two public projects, where the developers c
|
|
|
273
282
|
its test: given only the source files, Tewip **named the right test in 16 of 16, ranked it first in
|
|
274
283
|
12**.
|
|
275
284
|
|
|
285
|
+
It also shows **the checks a change renegotiates** — every test assertion the diff rewrites,
|
|
286
|
+
withdraws or renames, with the old text beside the new one. A test written earlier is behaviour
|
|
287
|
+
someone agreed on, and this is the one thing in a review that the change's own description cannot
|
|
288
|
+
account for: everything else reads the change's account of itself. It never passes judgement — tests
|
|
289
|
+
change for good reasons — it asks which of them the author intended.
|
|
290
|
+
|
|
291
|
+
This came out of a measurement rather than an idea. Of 18 real pull requests later found to have
|
|
292
|
+
broken something, the one Tewip's test plan missed was one whose *intention* was wrong: it changed
|
|
293
|
+
a freshness rule, changed the tests to match, and was reverted the next day. Run over those same 18
|
|
294
|
+
pull requests, this check fires on 2 — **and both were reverted**, including the one the plan
|
|
295
|
+
missed. No model is called, so it costs nothing.
|
|
296
|
+
|
|
276
297
|
## Which flaky tests to quarantine, and what they cost (new)
|
|
277
298
|
|
|
278
299
|
```bash
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// What a change does to the checks that were already agreed.
|
|
2
|
+
//
|
|
3
|
+
// Measuring test plans against 18 real pull requests that later turned out to have broken something
|
|
4
|
+
// found one blind spot, and it was the same one everywhere in Tewip: the pull request's own
|
|
5
|
+
// description is treated as the specification. In `budget-mvp#16` the author changed how an
|
|
6
|
+
// indicator's freshness was judged, changed the existing test to match, and it was reverted a day
|
|
7
|
+
// later because the new rule was wrong for a value that legitimately stops changing. Every layer
|
|
8
|
+
// agreed with the change, because every layer was reading the change's own account of itself.
|
|
9
|
+
//
|
|
10
|
+
// A rewritten expectation is the one place where that assumption is checkable. A test someone wrote
|
|
11
|
+
// earlier is behaviour the team agreed on; editing its assertion renegotiates that agreement, and
|
|
12
|
+
// deleting it withdraws it. Neither is wrong - tests change for good reasons all the time - but
|
|
13
|
+
// both deserve to be said out loud in a review, with the old text and the new one side by side, so
|
|
14
|
+
// a person decides rather than a description deciding for them.
|
|
15
|
+
//
|
|
16
|
+
// This reads the diff only. No model, no network, nothing to configure.
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
const IS_TEST_FILE = /(\.spec\.|\.test\.|_test\.|test_|\/tests?\/|\/e2e\/|\/cypress\/|Test\.(java|kt|cs)$|_spec\.rb$)/i;
|
|
19
|
+
/** A line that checks something: the sentence of the agreement. */
|
|
20
|
+
const ASSERTION = /\b(?:expect|assert|should|verify|assertEquals|assertThat|assertTrue|assertFalse|toBe|toEqual|toMatch|toHaveBeenCalled|require\.\w+|is\.\w+)\b/;
|
|
21
|
+
/** A line that names a test: the title people read in a report. */
|
|
22
|
+
const TEST_TITLE = /\b(?:it|test|describe|context|scenario|def test_\w+|func Test\w+|@Test)\b\s*[( ]/;
|
|
23
|
+
const title = (line) => /['"`]([^'"`]{4,120})['"`]/.exec(line)?.[1] ?? /\b(?:def|func)\s+(\w+)/.exec(line)?.[1] ?? '';
|
|
24
|
+
/** The diff of test files only, which is all this looks at. */
|
|
25
|
+
function testDiff(root, range) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Expectations this change rewrites, withdraws or renames. A brand-new test is not reported: adding
|
|
35
|
+
* checks is not renegotiating anything, and a review that cried out about every added test would be
|
|
36
|
+
* ignored within a week.
|
|
37
|
+
*/
|
|
38
|
+
export function changedExpectations(root, range, diff = testDiff(root, range)) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let file = '';
|
|
41
|
+
let isNewFile = false;
|
|
42
|
+
let removed = [];
|
|
43
|
+
let added = [];
|
|
44
|
+
const flush = () => {
|
|
45
|
+
if (!isNewFile && file) {
|
|
46
|
+
const gone = removed.filter((l) => ASSERTION.test(l));
|
|
47
|
+
const fresh = added.filter((l) => ASSERTION.test(l));
|
|
48
|
+
for (const [i, before] of gone.entries()) {
|
|
49
|
+
const after = fresh[i] ?? '';
|
|
50
|
+
out.push({
|
|
51
|
+
file,
|
|
52
|
+
kind: after ? 'rewritten' : 'withdrawn',
|
|
53
|
+
test: title(removed.find((l) => TEST_TITLE.test(l)) ?? '') || title(added.find((l) => TEST_TITLE.test(l)) ?? ''),
|
|
54
|
+
before: before.trim().slice(0, 160),
|
|
55
|
+
after: after.trim().slice(0, 160),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const renamedFrom = removed.find((l) => TEST_TITLE.test(l) && title(l));
|
|
59
|
+
const renamedTo = added.find((l) => TEST_TITLE.test(l) && title(l));
|
|
60
|
+
if (renamedFrom && renamedTo && title(renamedFrom) !== title(renamedTo) && gone.length === 0) {
|
|
61
|
+
out.push({ file, kind: 'renamed', test: title(renamedFrom), before: title(renamedFrom), after: title(renamedTo) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
removed = [];
|
|
65
|
+
added = [];
|
|
66
|
+
};
|
|
67
|
+
for (const line of diff.split('\n')) {
|
|
68
|
+
if (line.startsWith('diff --git')) {
|
|
69
|
+
flush();
|
|
70
|
+
file = /b\/(\S+)/.exec(line)?.[1] ?? '';
|
|
71
|
+
isNewFile = false;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// A file added by this change had no agreed behaviour to renegotiate.
|
|
75
|
+
if (line.startsWith('new file mode'))
|
|
76
|
+
isNewFile = true;
|
|
77
|
+
if (line.startsWith('@@'))
|
|
78
|
+
flush();
|
|
79
|
+
if (!file || !IS_TEST_FILE.test(file))
|
|
80
|
+
continue;
|
|
81
|
+
if (line.startsWith('-') && !line.startsWith('---'))
|
|
82
|
+
removed.push(line.slice(1));
|
|
83
|
+
else if (line.startsWith('+') && !line.startsWith('+++'))
|
|
84
|
+
added.push(line.slice(1));
|
|
85
|
+
}
|
|
86
|
+
flush();
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** The sentences a reviewer reads. Never a verdict: this asks, it does not decide. */
|
|
90
|
+
export function renderExpectations(changes) {
|
|
91
|
+
if (changes.length === 0)
|
|
92
|
+
return [];
|
|
93
|
+
const rewritten = changes.filter((c) => c.kind === 'rewritten');
|
|
94
|
+
const withdrawn = changes.filter((c) => c.kind === 'withdrawn');
|
|
95
|
+
const renamed = changes.filter((c) => c.kind === 'renamed');
|
|
96
|
+
const lines = ['## Checks this change renegotiates', ''];
|
|
97
|
+
lines.push(`This change edits ${changes.length} check(s) that were already agreed: ${[
|
|
98
|
+
rewritten.length ? `${rewritten.length} rewritten` : '',
|
|
99
|
+
withdrawn.length ? `${withdrawn.length} withdrawn` : '',
|
|
100
|
+
renamed.length ? `${renamed.length} renamed` : '',
|
|
101
|
+
]
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join(', ')}. Tests change for good reasons, so this is not a fault - but a test written earlier is behaviour someone agreed on, and the change's own description cannot tell you whether replacing it was intended.`, '');
|
|
104
|
+
for (const change of [...withdrawn, ...rewritten, ...renamed].slice(0, 10)) {
|
|
105
|
+
const where = `${change.file.split('/').at(-1)}${change.test ? ` — ${change.test}` : ''}`;
|
|
106
|
+
if (change.kind === 'withdrawn') {
|
|
107
|
+
lines.push(`- **${where}** no longer checks: \`${change.before}\``);
|
|
108
|
+
}
|
|
109
|
+
else if (change.kind === 'renamed') {
|
|
110
|
+
lines.push(`- **${where}** was renamed to "${change.after}", so a report of it will no longer match the old name`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
lines.push(`- **${where}**`, ` - was: \`${change.before}\``, ` - now: \`${change.after}\``);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
lines.push('', 'Ask the author which of these the change intended, and which followed from it.');
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
@@ -6,6 +6,7 @@ import { chooseFramework, detectFrameworks } from '../quality/frameworks.js';
|
|
|
6
6
|
import { readTriageRecords, datasetFiles } from '../../reporters/store.js';
|
|
7
7
|
import { coverageFor, uncovered } from './coverage.js';
|
|
8
8
|
import { renderReview, reviewChange } from './review.js';
|
|
9
|
+
import { changedExpectations } from './expectations.js';
|
|
9
10
|
/** Every failure Tewip has diagnosed in this project, from the history it keeps. */
|
|
10
11
|
function history(root, only) {
|
|
11
12
|
const dir = join(root, '.tewip', 'history');
|
|
@@ -25,7 +26,7 @@ export async function runReview(cmd) {
|
|
|
25
26
|
const files = change.files.map((f) => f.split('\\').join('/'));
|
|
26
27
|
const coverage = coverageFor(cmd.root, files);
|
|
27
28
|
const past = history(cmd.root, cmd.name);
|
|
28
|
-
const review = reviewChange(cmd.diff, files, coverage, past, uncovered(files, coverage));
|
|
29
|
+
const review = reviewChange(cmd.diff, files, coverage, past, uncovered(files, coverage), changedExpectations(cmd.root, cmd.diff));
|
|
29
30
|
// The command that runs just those tests - but only when the project's runner really owns them.
|
|
30
31
|
// Printing a Playwright command for a file Playwright never runs is worse than printing nothing.
|
|
31
32
|
const framework = chooseFramework(detectFrameworks(cmd.root), undefined, false) ?? detectFrameworks(cmd.root)[0] ?? null;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// August, and that nothing at all covers the file you edited. That comes from Tewip's own history,
|
|
6
6
|
// which is why this costs nothing and why nothing else can produce it.
|
|
7
7
|
import { componentOf } from '../../outlets/items.js';
|
|
8
|
+
import { renderExpectations } from './expectations.js';
|
|
8
9
|
const fileOf = (record) => record.file ?? '';
|
|
9
10
|
const countBy = (xs, key) => {
|
|
10
11
|
const out = new Map();
|
|
@@ -13,7 +14,7 @@ const countBy = (xs, key) => {
|
|
|
13
14
|
return out;
|
|
14
15
|
};
|
|
15
16
|
/** The review, assembled from the change, what covers it, and what has gone wrong here before. */
|
|
16
|
-
export function reviewChange(range, changed, coverage, history, notCovered) {
|
|
17
|
+
export function reviewChange(range, changed, coverage, history, notCovered, expectations = []) {
|
|
17
18
|
const byFile = new Map();
|
|
18
19
|
for (const record of history) {
|
|
19
20
|
const file = fileOf(record).split('\\').join('/');
|
|
@@ -75,7 +76,14 @@ export function reviewChange(range, changed, coverage, history, notCovered) {
|
|
|
75
76
|
if (tests.length === 0 && changed.length > 0) {
|
|
76
77
|
notes.push('No test in this project reaches any of the changed files. Tewip can write one: `tewip plan "<what the change should do>"` then `tewip write`.');
|
|
77
78
|
}
|
|
78
|
-
|
|
79
|
+
// A change that rewrites checks people already agreed on is worth saying before anything else:
|
|
80
|
+
// every other layer reads the change's own account of itself, and this is the one place where
|
|
81
|
+
// that account can be held against what the project had already decided.
|
|
82
|
+
if (expectations.length > 0) {
|
|
83
|
+
const withdrawn = expectations.filter((e) => e.kind === 'withdrawn').length;
|
|
84
|
+
notes.unshift(`This change edits ${expectations.length} check(s) that were already agreed${withdrawn > 0 ? `, ${withdrawn} of them withdrawn outright` : ''}. Read those first: the change's description cannot tell you whether replacing them was intended.`);
|
|
85
|
+
}
|
|
86
|
+
return { range, files: changed, tests, uncovered: notCovered, expectations, areas: [...areas.values()].sort((a, b) => b.realBugs - a.realBugs || b.failures - a.failures), runFirst, notes };
|
|
79
87
|
}
|
|
80
88
|
const when = (at) => {
|
|
81
89
|
if (!at)
|
|
@@ -94,6 +102,9 @@ export function renderReview(review, runCommand) {
|
|
|
94
102
|
lines.push(`- ${note}`);
|
|
95
103
|
lines.push('');
|
|
96
104
|
}
|
|
105
|
+
const renegotiated = renderExpectations(review.expectations);
|
|
106
|
+
if (renegotiated.length > 0)
|
|
107
|
+
lines.push(...renegotiated, '');
|
|
97
108
|
lines.push('## Tests that cover this change', '');
|
|
98
109
|
if (review.tests.length === 0)
|
|
99
110
|
lines.push('None found.', '');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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",
|