tewip-ai 0.8.0 → 0.9.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 +18 -0
- package/TEAM-GUIDE.md +1 -0
- package/dist/cli/tewip.js +16 -0
- package/dist/engine/flaky/flaky-command.js +142 -0
- package/dist/engine/flaky/flaky.js +104 -0
- package/dist/engine/flaky/quarantine.js +77 -0
- package/dist/engine/llm/client.js +31 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,6 +166,24 @@ that lost its markup) and **5 of 5 harmless changes ignored** (capitals, extra s
|
|
|
166
166
|
implemented as a link, reordered sections). No designer has judged the output yet, so the roadmap's
|
|
167
167
|
own metric is still open.
|
|
168
168
|
|
|
169
|
+
## Which flaky tests to quarantine, and what they cost (new)
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
tewip flaky --days 30 # the dossier: quarantine, fix, or watch - with the evidence
|
|
173
|
+
tewip flaky --apply # writes the quarantine line into the test file
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Other tools count retries. Tewip already decided *why* each failure happened, so it can say: this
|
|
177
|
+
test failed in 161 of 647 runs **across 109 different branches** - that is the test, not your
|
|
178
|
+
change - and quarantining it gives the team back 148 CI minutes a week. It refuses to hide a test
|
|
179
|
+
that mostly fails (that one is broken, not flaky) and **never** quarantines a test whose failures
|
|
180
|
+
looked like a real bug.
|
|
181
|
+
|
|
182
|
+
Measured on three real projects' CI history (Gutenberg twice, Supabase): 76 tests proposed for
|
|
183
|
+
quarantine, **zero of them containing a single real-bug failure**, giving back roughly 34, 148 and
|
|
184
|
+
1,178 CI minutes a week. It also tells you when a quarantined test has stopped failing and should
|
|
185
|
+
come back - the half nobody ever does.
|
|
186
|
+
|
|
169
187
|
## Bugs filed back, and test cases where your team keeps them (Phase 10, completed)
|
|
170
188
|
|
|
171
189
|
```bash
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -127,6 +127,7 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
|
|
|
127
127
|
| `tewip spec "<feature>"` | An epic, its user stories and acceptance criteria, grounded in your code; files them in Jira with `--project PROJ --create` | Before the work is broken down |
|
|
128
128
|
| `tewip report report.xml --gate` | The CI command for GitLab, Jenkins, CircleCI, Azure, Bitbucket and the rest: diagnoses the run, comments on the GitLab merge request, and fails the build only on failures that need a person | Your CI is not GitHub Actions |
|
|
129
129
|
| `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
|
|
130
|
+
| `tewip flaky --days 30` | Which tests to quarantine, which to fix, what they cost in CI minutes - and when a quarantined one can come back | Monday morning |
|
|
130
131
|
| `tewip note --days 7` | The weekly quality note for a lead, from runs already diagnosed (no model calls, free) | Monday morning |
|
|
131
132
|
| `tewip fix --code report.xml` | Opt-in: proposes the smallest change to application code for a real bug, and keeps it only if the failing test passes and the whole suite still passes | "It is a real bug and I want a starting patch" |
|
|
132
133
|
| `tewip design <figma link> --url <page>` | Compares a Figma frame against the running page: the words the design specifies and the controls its layer names imply | "Did we build what was designed?" |
|
package/dist/cli/tewip.js
CHANGED
|
@@ -71,6 +71,11 @@ Usage:
|
|
|
71
71
|
Beside your editor: runs your tests when a file changes (or
|
|
72
72
|
watches for new reports) and explains each failure as it
|
|
73
73
|
happens, without CI.
|
|
74
|
+
tewip flaky [--days 30] [--apply] [--name <project>] [--out flaky.md]
|
|
75
|
+
Which tests are wasting the team's time, how much CI time they
|
|
76
|
+
cost, and what to do with each: quarantine, fix, or watch. Read
|
|
77
|
+
from runs already diagnosed, so it costs nothing. --apply puts
|
|
78
|
+
the quarantine line in the test file with the evidence above it.
|
|
74
79
|
tewip note [--days 7] [--name <project>] [--out note.md] [--post]
|
|
75
80
|
The weekly quality note for a lead, written from the runs Tewip
|
|
76
81
|
already diagnosed. No model calls, so it costs nothing.
|
|
@@ -254,6 +259,17 @@ async function main() {
|
|
|
254
259
|
});
|
|
255
260
|
return;
|
|
256
261
|
}
|
|
262
|
+
case 'flaky': {
|
|
263
|
+
const { runFlaky } = await import('../engine/flaky/flaky-command.js');
|
|
264
|
+
await runFlaky({
|
|
265
|
+
root: process.cwd(),
|
|
266
|
+
name: flag(args, 'name'),
|
|
267
|
+
days: Math.max(1, Number(flag(args, 'days')) || 30),
|
|
268
|
+
apply: args.includes('--apply'),
|
|
269
|
+
out: flag(args, 'out'),
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
257
273
|
case 'note': {
|
|
258
274
|
const { runNote } = await import('../engine/notify/note-command.js');
|
|
259
275
|
await runNote({
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// `tewip flaky`: the Monday morning answer. Which tests are wasting the team's time, how much, and
|
|
2
|
+
// what to do about each one - read from the runs Tewip already diagnosed, so it costs nothing and
|
|
3
|
+
// cannot re-judge an old failure with today's rules.
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { datasetFiles, readRunSummaries, readTriageRecords } from '../../reporters/store.js';
|
|
7
|
+
import { flakyTests, readyToRelease, weeklyCost } from './flaky.js';
|
|
8
|
+
import { applyQuarantine, frameworkOf, quarantineFor } from './quarantine.js';
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const minutes = (ms) => `${(ms / 60_000).toFixed(1)} CI min`;
|
|
11
|
+
function projects(root, only) {
|
|
12
|
+
const dir = join(root, '.tewip', 'history');
|
|
13
|
+
if (only)
|
|
14
|
+
return [only];
|
|
15
|
+
return existsSync(dir) ? readdirSync(dir).filter((name) => existsSync(join(dir, name, 'triage.jsonl'))) : [];
|
|
16
|
+
}
|
|
17
|
+
/** The test file on disk, when the report's path still exists in this project. */
|
|
18
|
+
function testFile(root, test) {
|
|
19
|
+
const direct = resolve(root, test.file);
|
|
20
|
+
return test.file && existsSync(direct) ? direct : null;
|
|
21
|
+
}
|
|
22
|
+
export async function runFlaky(cmd) {
|
|
23
|
+
const names = projects(cmd.root, cmd.name);
|
|
24
|
+
if (names.length === 0) {
|
|
25
|
+
throw new Error(`no history in ${join(cmd.root, '.tewip', 'history')}. Run \`tewip explain\` or \`tewip report\` on a few runs first: this is read from what those kept.`);
|
|
26
|
+
}
|
|
27
|
+
const since = new Date(Date.now() - cmd.days * DAY_MS).toISOString();
|
|
28
|
+
const all = [];
|
|
29
|
+
const lines = ['# Flaky tests', '', `The last ${cmd.days} days, from runs Tewip already diagnosed. No tests were run and no model was called.`, ''];
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const history = datasetFiles(join(cmd.root, '.tewip', 'history', name));
|
|
32
|
+
const records = readTriageRecords(history.triage);
|
|
33
|
+
const runs = readRunSummaries(history.runs);
|
|
34
|
+
const tests = flakyTests(records, runs, { since });
|
|
35
|
+
if (tests.length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
all.push(...tests);
|
|
38
|
+
const cost = weeklyCost(tests, cmd.days);
|
|
39
|
+
lines.push(`## ${name}`, '', `${tests.length} test(s) failed at least once. About ${cost.minutes} CI minute(s) and ${cost.attempts} failed attempt(s) a week.`, '');
|
|
40
|
+
const group = (verdict, title, explain) => {
|
|
41
|
+
const chosen = tests.filter((t) => t.verdict === verdict);
|
|
42
|
+
if (chosen.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
lines.push(`### ${title} (${chosen.length})`, '', explain, '');
|
|
45
|
+
for (const test of chosen.slice(0, 15)) {
|
|
46
|
+
lines.push(`- **${test.title}**`);
|
|
47
|
+
lines.push(` - ${test.failedRuns} of ${test.totalRuns} run(s), ${test.failedAttempts} failed attempt(s), ${minutes(test.wastedMs)} lost${test.recoveredOnRetry > 0 ? `, passed on a retry ${test.recoveredOnRetry} time(s)` : ''}`);
|
|
48
|
+
lines.push(` - Tewip read them as: ${test.categories.map((c) => `${c.category} ×${c.count}`).join(', ')}`);
|
|
49
|
+
lines.push(` - Why: ${test.why}`);
|
|
50
|
+
if (test.file)
|
|
51
|
+
lines.push(` - Where: ${test.file}`);
|
|
52
|
+
}
|
|
53
|
+
lines.push('');
|
|
54
|
+
};
|
|
55
|
+
group('quarantine', 'Quarantine these', 'Unreliable rather than wrong. Stop them blocking everyone, then fix them on purpose.');
|
|
56
|
+
group('fix-now', 'Fix these, do not hide them', 'Hiding one of these would hide something real.');
|
|
57
|
+
group('watch', 'Watch', 'Not enough evidence yet to act.');
|
|
58
|
+
// Coming back out is the half nobody ever does.
|
|
59
|
+
const quarantined = readQuarantined(cmd.root);
|
|
60
|
+
const back = readyToRelease(quarantined, records, runs, since);
|
|
61
|
+
if (back.length > 0) {
|
|
62
|
+
lines.push('### Ready to come out of quarantine', '', 'These ran in this period and did not fail once.', '');
|
|
63
|
+
for (const testId of back)
|
|
64
|
+
lines.push(`- ${testId.split(' > ').at(-1)} — take its quarantine line out and let it block again.`);
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (all.length === 0) {
|
|
69
|
+
console.log(`Nothing failed in the last ${cmd.days} days. Try --days 30.`);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const toQuarantine = all.filter((t) => t.verdict === 'quarantine');
|
|
73
|
+
if (cmd.apply && toQuarantine.length > 0)
|
|
74
|
+
lines.push(...applyAll(cmd.root, toQuarantine));
|
|
75
|
+
else if (toQuarantine.length > 0)
|
|
76
|
+
lines.push(...suggest(cmd.root, toQuarantine));
|
|
77
|
+
const report = lines.join('\n');
|
|
78
|
+
console.log(report);
|
|
79
|
+
if (cmd.out) {
|
|
80
|
+
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
|
81
|
+
console.log(`\nWritten to ${cmd.out}`);
|
|
82
|
+
}
|
|
83
|
+
return all;
|
|
84
|
+
}
|
|
85
|
+
/** Tests already carrying Tewip's quarantine line, found by reading the test files it knows about. */
|
|
86
|
+
function readQuarantined(root) {
|
|
87
|
+
const file = join(root, '.tewip', 'quarantined.txt');
|
|
88
|
+
return existsSync(file) ? readFileSync(file, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : [];
|
|
89
|
+
}
|
|
90
|
+
function suggest(root, tests) {
|
|
91
|
+
const lines = ['### The line to add', ''];
|
|
92
|
+
for (const test of tests.slice(0, 5)) {
|
|
93
|
+
const path = testFile(root, test);
|
|
94
|
+
const framework = path ? frameworkOf(path, readFileSync(path, 'utf8')) : null;
|
|
95
|
+
if (!path || !framework) {
|
|
96
|
+
lines.push(`- ${test.title}: ${test.file || 'file not found in this project'} - quarantine it the way this project does.`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
lines.push(`- ${test.title} (${test.file}):`, '```', quarantineFor(test, framework).line, '```');
|
|
100
|
+
}
|
|
101
|
+
lines.push('', 'Run `tewip flaky --apply` to put these lines in, each with the evidence above it. Only the named tests are touched.', '');
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
/** Writes the quarantine into the test files, and remembers which tests were quarantined. */
|
|
105
|
+
function applyAll(root, tests) {
|
|
106
|
+
const lines = ['### Applied', ''];
|
|
107
|
+
const done = [];
|
|
108
|
+
for (const test of tests) {
|
|
109
|
+
const path = testFile(root, test);
|
|
110
|
+
if (!path) {
|
|
111
|
+
lines.push(`- ${test.title}: not applied, ${test.file || 'no file'} is not in this project`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const source = readFileSync(path, 'utf8');
|
|
115
|
+
const framework = frameworkOf(path, source);
|
|
116
|
+
if (!framework) {
|
|
117
|
+
lines.push(`- ${test.title}: not applied, the framework of ${test.file} was not recognised`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const changed = applyQuarantine(source, test, framework);
|
|
121
|
+
if (!changed) {
|
|
122
|
+
lines.push(`- ${test.title}: not applied, the test was not found in ${test.file} (was it renamed?)`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (changed === source) {
|
|
126
|
+
lines.push(`- ${test.title}: already quarantined`);
|
|
127
|
+
done.push(test.testId);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
writeFileSync(path, changed, 'utf8');
|
|
131
|
+
done.push(test.testId);
|
|
132
|
+
lines.push(`- ${test.title}: quarantined in ${test.file}`);
|
|
133
|
+
}
|
|
134
|
+
if (done.length > 0) {
|
|
135
|
+
const file = join(root, '.tewip', 'quarantined.txt');
|
|
136
|
+
const before = readQuarantined(root);
|
|
137
|
+
writeFileSync(file, [...new Set([...before, ...done])].join('\n') + '\n', 'utf8');
|
|
138
|
+
lines.push('', `Remembered in ${join('.tewip', 'quarantined.txt')}, so Tewip can tell you when each one is safe to bring back.`);
|
|
139
|
+
}
|
|
140
|
+
lines.push('', 'Nothing else in those files was changed. Review the diff before committing: a quarantined test is a decision, not a fix.', '');
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const titleOf = (testId) => testId.split(' > ').at(-1) ?? testId;
|
|
2
|
+
const fileOf = (testId, record) => record?.file ?? testId.split(' > ').find((p) => /\.(spec|test)\./.test(p)) ?? '';
|
|
3
|
+
/**
|
|
4
|
+
* A test's story across the kept runs. `records` are the failures Tewip diagnosed; `runs` say how
|
|
5
|
+
* often each test ran at all, which is what turns a count into a rate.
|
|
6
|
+
*/
|
|
7
|
+
export function flakyTests(records, runs, options = {}) {
|
|
8
|
+
const { since = '', minRuns = 2, alwaysFailsAbove = 0.8 } = options;
|
|
9
|
+
const inWindow = runs.filter((r) => (r.startedAt ?? '') >= since);
|
|
10
|
+
const runIds = new Set(inWindow.map((r) => r.runId));
|
|
11
|
+
const branchOf = new Map(inWindow.map((r) => [r.runId, r.branch ?? '']));
|
|
12
|
+
const failures = records.filter((r) => runIds.has(r.runId) || (!r.runId && !since));
|
|
13
|
+
const ranIn = new Map();
|
|
14
|
+
for (const run of inWindow)
|
|
15
|
+
for (const testId of Object.keys(run.attempts ?? {}))
|
|
16
|
+
ranIn.set(testId, (ranIn.get(testId) ?? 0) + 1);
|
|
17
|
+
const byTest = new Map();
|
|
18
|
+
for (const record of failures)
|
|
19
|
+
byTest.set(record.testId, [...(byTest.get(record.testId) ?? []), record]);
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const [testId, all] of byTest) {
|
|
22
|
+
const runsFailed = new Set(all.map((r) => r.runId));
|
|
23
|
+
// A retry that passed: the same test, same run, a later attempt that is not in the failures.
|
|
24
|
+
const attemptsByRun = new Map();
|
|
25
|
+
for (const r of all)
|
|
26
|
+
attemptsByRun.set(r.runId, [...(attemptsByRun.get(r.runId) ?? []), r]);
|
|
27
|
+
let recovered = 0;
|
|
28
|
+
for (const [runId, attempts] of attemptsByRun) {
|
|
29
|
+
const ran = inWindow.find((run) => run.runId === runId)?.attempts?.[testId] ?? attempts.length;
|
|
30
|
+
if (ran > attempts.length)
|
|
31
|
+
recovered++;
|
|
32
|
+
}
|
|
33
|
+
const counts = new Map();
|
|
34
|
+
for (const r of all)
|
|
35
|
+
counts.set(r.diagnosis?.category ?? 'UNKNOWN', (counts.get(r.diagnosis?.category ?? 'UNKNOWN') ?? 0) + 1);
|
|
36
|
+
const categories = [...counts].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count);
|
|
37
|
+
const times = all.map((r) => r.timestamp).sort();
|
|
38
|
+
const branches = [...new Set([...runsFailed].map((id) => branchOf.get(id) ?? '').filter(Boolean))];
|
|
39
|
+
const totalRuns = ranIn.get(testId) ?? runsFailed.size;
|
|
40
|
+
const test = {
|
|
41
|
+
testId,
|
|
42
|
+
title: titleOf(testId),
|
|
43
|
+
file: fileOf(testId, all[0]),
|
|
44
|
+
failedRuns: runsFailed.size,
|
|
45
|
+
totalRuns,
|
|
46
|
+
failedAttempts: all.length,
|
|
47
|
+
wastedMs: all.reduce((sum, r) => sum + (r.duration ?? 0), 0),
|
|
48
|
+
recoveredOnRetry: recovered,
|
|
49
|
+
branches,
|
|
50
|
+
categories,
|
|
51
|
+
firstSeen: times[0] ?? '',
|
|
52
|
+
lastSeen: times.at(-1) ?? '',
|
|
53
|
+
verdict: 'watch',
|
|
54
|
+
why: '',
|
|
55
|
+
};
|
|
56
|
+
Object.assign(test, decide(test, { minRuns, alwaysFailsAbove }));
|
|
57
|
+
out.push(test);
|
|
58
|
+
}
|
|
59
|
+
return out.sort((a, b) => b.wastedMs - a.wastedMs || b.failedRuns - a.failedRuns);
|
|
60
|
+
}
|
|
61
|
+
const share = (test) => (test.totalRuns > 0 ? test.failedRuns / test.totalRuns : 1);
|
|
62
|
+
const flakyShare = (test) => {
|
|
63
|
+
const flaky = test.categories.find((c) => c.category === 'TIMING')?.count ?? 0;
|
|
64
|
+
return test.failedAttempts > 0 ? flaky / test.failedAttempts : 0;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* The decision, and why. Three rules, all conservative:
|
|
68
|
+
* a test that mostly fails is broken, not flaky - hiding it hides a real problem;
|
|
69
|
+
* a test whose failures Tewip read as the application being wrong is never quarantined;
|
|
70
|
+
* quarantine needs the test to have failed in several runs, and to be unreliable rather than
|
|
71
|
+
* consistently wrong.
|
|
72
|
+
*/
|
|
73
|
+
function decide(test, { minRuns, alwaysFailsAbove }) {
|
|
74
|
+
const rate = share(test);
|
|
75
|
+
const percent = `${Math.round(rate * 100)}% of the ${test.totalRuns} run(s) it was in`;
|
|
76
|
+
const realBugs = test.categories.find((c) => c.category === 'REAL_BUG')?.count ?? 0;
|
|
77
|
+
if (realBugs > 0 && realBugs >= test.failedAttempts / 2) {
|
|
78
|
+
return { verdict: 'fix-now', why: `${realBugs} of its ${test.failedAttempts} failures looked like the application being wrong, not the test. Quarantining it would hide a bug.` };
|
|
79
|
+
}
|
|
80
|
+
if (rate > alwaysFailsAbove && test.totalRuns >= minRuns) {
|
|
81
|
+
return { verdict: 'fix-now', why: `it fails in ${percent}. That is not flakiness, it is a test that does not work - hiding it would hide whatever it is telling you.` };
|
|
82
|
+
}
|
|
83
|
+
if (test.failedRuns >= minRuns && flakyShare(test) >= 0.5) {
|
|
84
|
+
const spread = test.branches.length > 1 ? ` It failed on ${test.branches.length} different branches, so it is the test rather than one person's change.` : '';
|
|
85
|
+
return { verdict: 'quarantine', why: `it failed in ${percent}, and Tewip read most of those as the test being unreliable.${spread}` };
|
|
86
|
+
}
|
|
87
|
+
return { verdict: 'watch', why: `it failed in ${percent}: not often enough, or not consistently enough one thing, to act on yet.` };
|
|
88
|
+
}
|
|
89
|
+
/** What a week of this costs, so the number means something to whoever pays for CI. */
|
|
90
|
+
export function weeklyCost(tests, days) {
|
|
91
|
+
const wasted = tests.reduce((sum, t) => sum + t.wastedMs, 0);
|
|
92
|
+
const attempts = tests.reduce((sum, t) => sum + t.failedAttempts, 0);
|
|
93
|
+
const weeks = Math.max(days / 7, 0.1);
|
|
94
|
+
return { minutes: Math.round(wasted / 60_000 / weeks), attempts: Math.round(attempts / weeks) };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Tests that were quarantined and have stopped failing. Nobody ever takes a test out of quarantine,
|
|
98
|
+
* so this looks for the ones that have earned it: no failure in the window, and they ran.
|
|
99
|
+
*/
|
|
100
|
+
export function readyToRelease(quarantined, records, runs, since) {
|
|
101
|
+
const failed = new Set(records.filter((r) => r.timestamp >= since).map((r) => r.testId));
|
|
102
|
+
const ran = new Set(runs.filter((r) => (r.startedAt ?? '') >= since).flatMap((r) => Object.keys(r.attempts ?? {})));
|
|
103
|
+
return quarantined.filter((testId) => ran.has(testId) && !failed.has(testId));
|
|
104
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Which framework a test file belongs to, from its name and its contents. */
|
|
2
|
+
export function frameworkOf(file, source) {
|
|
3
|
+
if (/\.py$/.test(file))
|
|
4
|
+
return 'pytest';
|
|
5
|
+
if (/\.java$|\.kt$/.test(file))
|
|
6
|
+
return 'junit';
|
|
7
|
+
if (/\.cs$/.test(file))
|
|
8
|
+
return 'dotnet';
|
|
9
|
+
if (/_test\.go$/.test(file))
|
|
10
|
+
return 'go';
|
|
11
|
+
if (/@playwright\/test|from ['"]playwright/.test(source))
|
|
12
|
+
return 'playwright';
|
|
13
|
+
if (/from ['"]vitest['"]|\bvi\./.test(source))
|
|
14
|
+
return 'vitest';
|
|
15
|
+
if (/@jest\/globals|jest\./.test(source))
|
|
16
|
+
return 'jest';
|
|
17
|
+
if (/\brequire\(['"]mocha|describe\(/.test(source))
|
|
18
|
+
return 'mocha';
|
|
19
|
+
return /\.[cm]?[jt]sx?$/.test(file) ? 'jest' : null;
|
|
20
|
+
}
|
|
21
|
+
const MARK = {
|
|
22
|
+
playwright: (title) => `test.fixme(true, ${JSON.stringify(`quarantined by Tewip: ${title}`)});`,
|
|
23
|
+
jest: () => 'test.skip',
|
|
24
|
+
vitest: () => 'test.skip',
|
|
25
|
+
mocha: () => 'it.skip',
|
|
26
|
+
pytest: (title) => `@pytest.mark.skip(reason=${JSON.stringify(`quarantined by Tewip: ${title}`)})`,
|
|
27
|
+
junit: (title) => `@Disabled(${JSON.stringify(`quarantined by Tewip: ${title}`)})`,
|
|
28
|
+
dotnet: (title) => `[Fact(Skip = ${JSON.stringify(`quarantined by Tewip: ${title}`)})]`,
|
|
29
|
+
go: () => 't.Skip("quarantined by Tewip")',
|
|
30
|
+
};
|
|
31
|
+
/** What to write, and the note that goes above it. */
|
|
32
|
+
export function quarantineFor(test, framework) {
|
|
33
|
+
const rate = test.totalRuns > 0 ? `${test.failedRuns} of ${test.totalRuns} runs` : `${test.failedRuns} run(s)`;
|
|
34
|
+
return {
|
|
35
|
+
line: MARK[framework](test.title),
|
|
36
|
+
comment: [
|
|
37
|
+
`Quarantined by Tewip on ${new Date().toISOString().slice(0, 10)}: failed in ${rate}, ${Math.round(test.wastedMs / 60_000)} CI minute(s) lost.`,
|
|
38
|
+
test.branches.length > 1 ? `It failed on ${test.branches.length} branches, so it is the test and not one change.` : '',
|
|
39
|
+
'Take this line out when the cause is fixed. `tewip flaky` will say when it has stopped failing.',
|
|
40
|
+
].filter(Boolean),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The file with the test quarantined, or null when the test could not be found in it. Only the one
|
|
45
|
+
* test named is touched; everything else in the file stays byte for byte as it was.
|
|
46
|
+
*/
|
|
47
|
+
export function applyQuarantine(source, test, framework) {
|
|
48
|
+
const nl = source.includes('\r\n') ? '\r\n' : '\n';
|
|
49
|
+
const lines = source.split(nl);
|
|
50
|
+
const quoted = test.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
const declaration = new RegExp(`^(\\s*)((?:async\\s+)?(?:export\\s+)?(?:test|it|def test_|public\\s+void|func\\s+Test)\\b[^\\n]*${quoted})`, 'i');
|
|
52
|
+
const index = lines.findIndex((line) => declaration.test(line) || line.includes(`"${test.title}"`) || line.includes(`'${test.title}'`));
|
|
53
|
+
if (index < 0)
|
|
54
|
+
return null;
|
|
55
|
+
if (lines.slice(Math.max(0, index - 4), index + 1).some((l) => /quarantined by tewip/i.test(l)))
|
|
56
|
+
return source; // already done
|
|
57
|
+
const indent = (lines[index].match(/^\s*/) ?? [''])[0];
|
|
58
|
+
const { line, comment } = quarantineFor(test, framework);
|
|
59
|
+
const commentMark = framework === 'pytest' ? '#' : '//';
|
|
60
|
+
const note = comment.map((c) => `${indent}${commentMark} ${c}`);
|
|
61
|
+
// Where the mark goes differs: an annotation sits above the test, a skip replaces the call.
|
|
62
|
+
if (framework === 'jest' || framework === 'vitest' || framework === 'mocha') {
|
|
63
|
+
const replaced = lines[index].replace(/\b(test|it)(\.\w+)?\(/, `${line}(`);
|
|
64
|
+
if (replaced === lines[index])
|
|
65
|
+
return null;
|
|
66
|
+
return [...lines.slice(0, index), ...note, replaced, ...lines.slice(index + 1)].join(nl);
|
|
67
|
+
}
|
|
68
|
+
if (framework === 'playwright') {
|
|
69
|
+
// Playwright's fixme goes inside the test body, so the test still reports itself as skipped.
|
|
70
|
+
const body = lines[index].includes('=>') ? index : -1;
|
|
71
|
+
if (body < 0)
|
|
72
|
+
return null;
|
|
73
|
+
return [...lines.slice(0, index), ...note, lines[index], `${indent} ${line}`, ...lines.slice(index + 1)].join(nl);
|
|
74
|
+
}
|
|
75
|
+
// pytest, JUnit, .NET: the annotation goes on its own line above the test.
|
|
76
|
+
return [...lines.slice(0, index), ...note, `${indent}${line}`, ...lines.slice(index)].join(nl);
|
|
77
|
+
}
|
|
@@ -14,14 +14,41 @@ const MAX_ATTEMPTS = 3;
|
|
|
14
14
|
/** TEWIP_LLM_CACHE moves the cache, e.g. into the project the command line runs in. */
|
|
15
15
|
const CACHE_FILE = process.env.TEWIP_LLM_CACHE || join(DATA_DIR, 'llm-cache.jsonl');
|
|
16
16
|
let envLoaded = false;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Loads a `.env` once, from the project being worked on.
|
|
19
|
+
*
|
|
20
|
+
* This used to read only Tewip's own folder, which is right when running from a checkout and wrong
|
|
21
|
+
* for everybody who installs it: their key sits in their project's .env, and Tewip silently found
|
|
22
|
+
* no model and answered with rules alone. The working directory is now looked at first, then its
|
|
23
|
+
* parent (a monorepo package), then Tewip's own folder for development.
|
|
24
|
+
*
|
|
25
|
+
* Variables already set in the environment always win: CI sets real secrets, a file must not
|
|
26
|
+
* quietly replace them.
|
|
27
|
+
*/
|
|
18
28
|
export function loadDotEnv() {
|
|
19
29
|
if (envLoaded)
|
|
20
30
|
return;
|
|
21
31
|
envLoaded = true;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
const places = [process.cwd(), join(process.cwd(), '..'), PROJECT_ROOT];
|
|
33
|
+
for (const dir of places) {
|
|
34
|
+
const file = join(dir, '.env');
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
continue;
|
|
37
|
+
const before = new Set(Object.keys(process.env));
|
|
38
|
+
const kept = {};
|
|
39
|
+
for (const name of before)
|
|
40
|
+
kept[name] = process.env[name];
|
|
41
|
+
try {
|
|
42
|
+
process.loadEnvFile(file);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue; // an unreadable or malformed file is not a reason to stop
|
|
46
|
+
}
|
|
47
|
+
for (const [name, value] of Object.entries(kept))
|
|
48
|
+
if (value !== undefined)
|
|
49
|
+
process.env[name] = value;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
25
52
|
}
|
|
26
53
|
export function openAiClient({ apiKey, model, reasoningEffort, baseUrl = 'https://api.openai.com/v1' }) {
|
|
27
54
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|