tewip-ai 0.46.0 → 0.48.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 +30 -0
- package/dist/cli/help.js +12 -3
- package/dist/cli/tewip.js +9 -0
- package/dist/cli/verbs-prove.js +5 -2
- package/dist/engine/import/junit.js +8 -1
- package/dist/engine/import/other-formats.js +55 -0
- package/dist/engine/perf/jtl-report.js +128 -0
- package/dist/engine/perf/jtl.js +159 -0
- package/dist/engine/perf/perf-command.js +41 -0
- package/dist/engine/perf/samples.js +145 -0
- package/dist/engine/start/start-command.js +216 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,36 @@ never goes to the model again.
|
|
|
38
38
|
Tewip prints what every command costs, caps its own spend, and works with no key at all - the rules
|
|
39
39
|
run on their own and everything they cannot settle is left as UNKNOWN rather than guessed.
|
|
40
40
|
|
|
41
|
+
## Start here
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm i -g tewip-ai
|
|
45
|
+
cd your-project
|
|
46
|
+
tewip
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`tewip` on its own looks at **your** project and names the two or three commands that will tell you
|
|
50
|
+
something, with your own paths in them — and says plainly what it cannot answer here. It reads the
|
|
51
|
+
directory and stops: nothing is run, nothing is written, about a second.
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Here is what Tewip can tell you about **this** project, in the order worth doing.
|
|
55
|
+
|
|
56
|
+
**1. `tewip vouch`**
|
|
57
|
+
282 test files here (282 JavaScript or TypeScript). This reads them and names the ones
|
|
58
|
+
that cannot fail - no model, a second or two.
|
|
59
|
+
|
|
60
|
+
**2. `tewip schema --diff main...HEAD`**
|
|
61
|
+
165 migrations here (165 SQL). This says what a change would do to a live database
|
|
62
|
+
before it runs.
|
|
63
|
+
|
|
64
|
+
## What it will not do here
|
|
65
|
+
- No k6, JMeter or Lighthouse report was found. Tewip judges one you produce; it does
|
|
66
|
+
not run load tests itself.
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Everything below is the full list, for when you want it.
|
|
70
|
+
|
|
41
71
|
## Use it in your project
|
|
42
72
|
|
|
43
73
|
**Giving it to your team (or another company): [TEAM-GUIDE.md](TEAM-GUIDE.md)** — what to install, what each role uses it for, and what it never does.
|
package/dist/cli/help.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
export const HELP = `Tewip - finds why each test failed, for any stack.
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
|
+
tewip Look at this project and name the two or three commands that
|
|
8
|
+
tewip start will tell you something about it, with your own paths in them,
|
|
9
|
+
and say plainly what Tewip cannot answer here. Reads the
|
|
10
|
+
directory and stops: nothing is run and nothing is written.
|
|
11
|
+
Start here. Everything below is the full list.
|
|
7
12
|
tewip explain <report> Explain a test report: a JUnit XML or .trx file, a folder of
|
|
8
13
|
them, or a Playwright blob-report folder. Keeps history per
|
|
9
14
|
project, so later runs are judged against earlier ones.
|
|
@@ -118,10 +123,11 @@ Usage:
|
|
|
118
123
|
is what was asked for covered. Answers ready, not ready, or cannot
|
|
119
124
|
say - and cannot say is never rounded up to ready. --json writes a
|
|
120
125
|
sealed verdict that can be checked later. No model, no cost.
|
|
121
|
-
tewip perf <k6
|
|
126
|
+
tewip perf <k6.json | lighthouse.json | run.jtl> [--against before.jtl] [--record] [--strict]
|
|
122
127
|
Is this change slower, and does it matter? Reads a k6 run (what
|
|
123
128
|
the server does under load) or a Lighthouse report (what one
|
|
124
|
-
page load costs in a browser), and answers two
|
|
129
|
+
page load costs in a browser) or a JMeter .jtl, and answers two
|
|
130
|
+
questions apart:
|
|
125
131
|
is anything in a bad state now - from the thresholds you wrote,
|
|
126
132
|
or from Lighthouse's own scoring, never a budget Tewip invented
|
|
127
133
|
- and is this slower than it was. The second only from repeated
|
|
@@ -129,7 +135,10 @@ Usage:
|
|
|
129
135
|
run against one cannot tell a regression from a busy machine.
|
|
130
136
|
--record adds a run to the baseline, and is deliberately not
|
|
131
137
|
automatic: a regression recorded by accident becomes next week's
|
|
132
|
-
normal.
|
|
138
|
+
normal. A .jtl is different and better: it carries every request,
|
|
139
|
+
so --against before.jtl compares the two distributions with a
|
|
140
|
+
real test rather than a range check - and refuses a difference
|
|
141
|
+
that is certain but too small to act on. No cost.
|
|
133
142
|
tewip access [<file or directory>] [--report axe.json|lighthouse.json] [--strict]
|
|
134
143
|
Can everybody use this? Reads your markup and names the barriers
|
|
135
144
|
that reading alone can prove - a picture with no alt text, a field
|
package/dist/cli/tewip.js
CHANGED
|
@@ -237,7 +237,16 @@ async function main() {
|
|
|
237
237
|
case '-v':
|
|
238
238
|
console.log(version());
|
|
239
239
|
return;
|
|
240
|
+
// `tewip` on its own used to print 233 lines of help, which is a reference for somebody who
|
|
241
|
+
// already knows what they want and a wall for everybody else. It now looks at the project and
|
|
242
|
+
// names the two or three commands that will tell *this* team something. --help is unchanged
|
|
243
|
+
// and still has all of it.
|
|
240
244
|
case undefined:
|
|
245
|
+
case 'start': {
|
|
246
|
+
const { runStart } = await import('../engine/start/start-command.js');
|
|
247
|
+
runStart(process.cwd());
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
241
250
|
case '--help':
|
|
242
251
|
case '-h':
|
|
243
252
|
case 'help':
|
package/dist/cli/verbs-prove.js
CHANGED
|
@@ -35,9 +35,11 @@ export async function proveVerb(verb, args) {
|
|
|
35
35
|
}
|
|
36
36
|
case 'perf': {
|
|
37
37
|
const { runPerf } = await import('../engine/perf/perf-command.js');
|
|
38
|
-
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--label'].includes(args[i - 1] ?? ''));
|
|
38
|
+
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--label', '--against'].includes(args[i - 1] ?? ''));
|
|
39
39
|
if (paths.length === 0) {
|
|
40
|
-
console.error('tewip perf needs
|
|
40
|
+
console.error('tewip perf needs a k6 summary, a Lighthouse report or a JMeter .jtl.');
|
|
41
|
+
console.error(' tewip perf summary.json [--record] [--label checkout]');
|
|
42
|
+
console.error(' tewip perf now.jtl --against before.jtl');
|
|
41
43
|
process.exitCode = 1;
|
|
42
44
|
return true;
|
|
43
45
|
}
|
|
@@ -45,6 +47,7 @@ export async function proveVerb(verb, args) {
|
|
|
45
47
|
root: process.cwd(),
|
|
46
48
|
summary: paths[0],
|
|
47
49
|
label: flag(args, 'label'),
|
|
50
|
+
against: flag(args, 'against'),
|
|
48
51
|
record: args.includes('--record'),
|
|
49
52
|
out: flag(args, 'out'),
|
|
50
53
|
gate: args.includes('--strict'),
|
|
@@ -13,7 +13,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { SCHEMA_VERSION } from '../../reporters/types.js';
|
|
15
15
|
import { descendants, parseXml } from './xml.js';
|
|
16
|
-
import { jmeterCases, k6Cases, looksLikeK6, robotCases, robotTime } from './other-formats.js';
|
|
16
|
+
import { jmeterCases, jmeterCsvCases, k6Cases, looksLikeCsvJtl, looksLikeK6, robotCases, robotTime } from './other-formats.js';
|
|
17
17
|
import { nunitCases, trxCases } from './dotnet.js';
|
|
18
18
|
import { PASSED, failure } from './case.js';
|
|
19
19
|
const FAILURE = new Set(['failure', 'error']);
|
|
@@ -182,6 +182,13 @@ export function importJunit(path, meta) {
|
|
|
182
182
|
addCases(k6Cases(parsed));
|
|
183
183
|
continue;
|
|
184
184
|
}
|
|
185
|
+
// JMeter's default output is CSV, not XML, so a real .jtl would otherwise be rejected as "not
|
|
186
|
+
// an XML document" - which it is not, and never was.
|
|
187
|
+
if (looksLikeCsvJtl(raw)) {
|
|
188
|
+
parsedFiles++;
|
|
189
|
+
addCases(jmeterCsvCases(raw));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
185
192
|
const root = parseXml(raw);
|
|
186
193
|
let cases;
|
|
187
194
|
if (root.name === 'TestRun') {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { descendants } from './xml.js';
|
|
2
|
+
import { looksLikeCsvJtl, readCsv } from '../perf/jtl.js';
|
|
3
|
+
export { looksLikeCsvJtl };
|
|
2
4
|
const text = (element) => (element ? element.text.trim() : '');
|
|
3
5
|
/**
|
|
4
6
|
* Robot Framework writes output.xml by default, and only writes JUnit if asked. A suite holds
|
|
@@ -125,3 +127,56 @@ export function looksLikeK6(json) {
|
|
|
125
127
|
return false;
|
|
126
128
|
return ['http_req_duration', 'iterations', 'vus', 'checks', 'http_reqs'].some((m) => m in report.metrics);
|
|
127
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* JMeter's CSV results file, which is the one it writes by default.
|
|
132
|
+
*
|
|
133
|
+
* The XML reader above has been the only one since this was written, and `.jtl` means CSV in every
|
|
134
|
+
* real repository you look in - so `importJunit` answered "not an XML document: no root element" on
|
|
135
|
+
* genuine JMeter output while the help text advertised JMeter support. A format nobody tested
|
|
136
|
+
* against a real file.
|
|
137
|
+
*
|
|
138
|
+
* The parsing is shared with `tewip perf`, which needs the same rows for its statistics, and it is a
|
|
139
|
+
* real CSV reader rather than a line split because JMeter quotes a failureMessage containing commas
|
|
140
|
+
* and newlines - one real record found here spans seven physical lines.
|
|
141
|
+
*/
|
|
142
|
+
export function jmeterCsvCases(text) {
|
|
143
|
+
const rows = readCsv(text);
|
|
144
|
+
const header = (rows[0] ?? []).map((h) => h.trim());
|
|
145
|
+
const at = (name) => header.indexOf(name);
|
|
146
|
+
const elapsed = at('elapsed');
|
|
147
|
+
const label = at('label');
|
|
148
|
+
if (elapsed === -1 || label === -1)
|
|
149
|
+
return [];
|
|
150
|
+
const success = at('success');
|
|
151
|
+
const code = at('responseCode');
|
|
152
|
+
const message = at('responseMessage');
|
|
153
|
+
const failure = at('failureMessage');
|
|
154
|
+
const byLabel = new Map();
|
|
155
|
+
for (const row of rows.slice(1)) {
|
|
156
|
+
if (row.length <= Math.max(elapsed, label))
|
|
157
|
+
continue;
|
|
158
|
+
const name = (row[label] ?? '').trim() || 'sample';
|
|
159
|
+
const found = byLabel.get(name) ?? { failed: 0, total: 0, time: 0, codes: new Map(), messages: new Set() };
|
|
160
|
+
found.total += 1;
|
|
161
|
+
const ms = Number(row[elapsed]);
|
|
162
|
+
if (Number.isFinite(ms))
|
|
163
|
+
found.time += ms;
|
|
164
|
+
if (success !== -1 && (row[success] ?? '').trim().toLowerCase() === 'false') {
|
|
165
|
+
found.failed += 1;
|
|
166
|
+
const seen = `${code === -1 ? '' : (row[code] ?? '')} ${message === -1 ? '' : (row[message] ?? '')}`.trim();
|
|
167
|
+
if (seen)
|
|
168
|
+
found.codes.set(seen, (found.codes.get(seen) ?? 0) + 1);
|
|
169
|
+
const why = failure === -1 ? '' : (row[failure] ?? '').trim();
|
|
170
|
+
if (why)
|
|
171
|
+
found.messages.add(why.replace(/\s+/g, ' ').slice(0, 200));
|
|
172
|
+
}
|
|
173
|
+
byLabel.set(name, found);
|
|
174
|
+
}
|
|
175
|
+
return [...byLabel].map(([name, s]) => {
|
|
176
|
+
const worst = [...s.codes].sort((a, b) => b[1] - a[1])[0];
|
|
177
|
+
// A 2xx that still counts as failed is an assertion failing, not the server.
|
|
178
|
+
const why = worst ? (/^2\d\d/.test(worst[0]) ? `, an assertion failed (${worst[1]} times, response ${worst[0]})` : `, mostly ${worst[0]} (${worst[1]} times)`) : '';
|
|
179
|
+
const text = s.failed === 0 ? '' : `${s.failed} of ${s.total} requests failed${why}${s.messages.size > 0 ? `: ${[...s.messages][0]}` : ''}`;
|
|
180
|
+
return { testId: `JMeter > ${name}`, file: '', duration: s.time, attempts: [{ failed: s.failed > 0, message: text, stack: [...s.messages].join('\n').slice(0, 2000) }] };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Phase 39: what Tewip says about a JMeter run.
|
|
2
|
+
//
|
|
3
|
+
// Kept apart from perf-command.ts because a .jtl is answered differently from a k6 summary or a
|
|
4
|
+
// Lighthouse report, and for a good reason rather than a tidy one: those two hand over aggregates,
|
|
5
|
+
// so the only question available is "is this number outside the baseline range". A .jtl hands over
|
|
6
|
+
// every request, so the question becomes "are these two distributions different", which is a
|
|
7
|
+
// better question with a real answer - per label, not just overall.
|
|
8
|
+
import { byLabel, overall, readJtlSamples } from './jtl.js';
|
|
9
|
+
import { compareSamples, ENOUGH_SAMPLES, median } from './samples.js';
|
|
10
|
+
const ms = (n) => `${Math.round(n * 10) / 10}ms`;
|
|
11
|
+
/** A run on its own: no baseline to compare against, so nothing is claimed about a change. */
|
|
12
|
+
export function describeRun(groups, total) {
|
|
13
|
+
const lines = ['# Tewip performance check', ''];
|
|
14
|
+
lines.push(`Read a JMeter run of **${total} requests** across ${groups.length} ${groups.length === 1 ? 'label' : 'labels'}.`, '');
|
|
15
|
+
lines.push('**No baseline given, so nothing is said about whether this is slower.** Pass the previous run ' +
|
|
16
|
+
'with `--against baseline.jtl` and Tewip will compare the two request by request.', '');
|
|
17
|
+
lines.push('## What this run did', '');
|
|
18
|
+
lines.push('| Label | Requests | Failed | Median | p95 |', '| --- | --- | --- | --- | --- |');
|
|
19
|
+
for (const g of groups) {
|
|
20
|
+
lines.push(`| ${g.label || '(unnamed)'} | ${g.total} | ${g.failed} | ${ms(median(g.elapsed))} | ${ms(percentile(g.elapsed, 95))} |`);
|
|
21
|
+
}
|
|
22
|
+
lines.push('');
|
|
23
|
+
const failing = groups.filter((g) => g.failed > 0);
|
|
24
|
+
if (failing.length > 0) {
|
|
25
|
+
lines.push('## Requests that failed', '');
|
|
26
|
+
for (const g of failing) {
|
|
27
|
+
const codes = g.codes.map((c) => `${c.code} x${c.times}`).join(', ');
|
|
28
|
+
// A 2xx that still counts as failed is an assertion failing, not the server.
|
|
29
|
+
const assertion = g.codes.some((c) => /^2\d\d/.test(c.code)) ? ' - a 2xx counted as failed is an assertion, not the server' : '';
|
|
30
|
+
lines.push(`- **${g.label || '(unnamed)'}**: ${g.failed} of ${g.total}${codes ? ` (${codes})` : ''}${assertion}`);
|
|
31
|
+
}
|
|
32
|
+
lines.push('');
|
|
33
|
+
}
|
|
34
|
+
lines.push(...notKnown());
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
}
|
|
37
|
+
export function percentile(values, p) {
|
|
38
|
+
if (values.length === 0)
|
|
39
|
+
return 0;
|
|
40
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41
|
+
const at = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
|
|
42
|
+
return sorted[Math.max(0, at)];
|
|
43
|
+
}
|
|
44
|
+
function notKnown() {
|
|
45
|
+
return [
|
|
46
|
+
'## What this does not tell you',
|
|
47
|
+
'',
|
|
48
|
+
'- **Nothing about production.** This is the hardware the run happened on, with that data volume and ' +
|
|
49
|
+
'that concurrency. Tewip cannot see your real machines, your real data or your real traffic.',
|
|
50
|
+
'- **No duration is predicted**, and no capacity. It says whether the two runs differ, not how many users you could hold.',
|
|
51
|
+
'- **Only what the plan exercised.** An endpoint the test never called is not covered, however green this looks.',
|
|
52
|
+
'- **A statistical difference is not a cause.** Tewip says the distributions moved; why they moved is in your change.',
|
|
53
|
+
'',
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
/** Two runs, compared request by request. */
|
|
57
|
+
export function compareRuns(nowPath, beforePath) {
|
|
58
|
+
const nowSamples = readJtlSamples(nowPath);
|
|
59
|
+
const beforeSamples = readJtlSamples(beforePath);
|
|
60
|
+
const nowGroups = byLabel(nowSamples);
|
|
61
|
+
const beforeByLabel = new Map(byLabel(beforeSamples).map((g) => [g.label, g]));
|
|
62
|
+
const comparisons = [];
|
|
63
|
+
// The whole run first: it is the headline, and a change that moves everything is the common case.
|
|
64
|
+
const everything = compareSamples(overall(nowSamples).elapsed, overall(beforeSamples).elapsed);
|
|
65
|
+
comparisons.push({
|
|
66
|
+
label: 'all requests',
|
|
67
|
+
verdict: everything,
|
|
68
|
+
failedNow: overall(nowSamples).failed,
|
|
69
|
+
totalNow: nowSamples.length,
|
|
70
|
+
codes: [],
|
|
71
|
+
});
|
|
72
|
+
for (const group of nowGroups) {
|
|
73
|
+
const before = beforeByLabel.get(group.label);
|
|
74
|
+
const verdict = before
|
|
75
|
+
? compareSamples(group.elapsed, before.elapsed)
|
|
76
|
+
: {
|
|
77
|
+
direction: 'cannot-say',
|
|
78
|
+
now: median(group.elapsed),
|
|
79
|
+
before: 0,
|
|
80
|
+
slowerThanBefore: 0.5,
|
|
81
|
+
p: null,
|
|
82
|
+
sampleSize: { now: group.total, before: 0 },
|
|
83
|
+
why: 'this label is not in the baseline run, so there is nothing to compare it with',
|
|
84
|
+
};
|
|
85
|
+
comparisons.push({ label: group.label, verdict, failedNow: group.failed, totalNow: group.total, codes: group.codes });
|
|
86
|
+
}
|
|
87
|
+
const slower = comparisons.filter((c) => c.verdict.direction === 'slower');
|
|
88
|
+
const failing = comparisons.filter((c) => c.failedNow > 0);
|
|
89
|
+
return { comparisons, slower, failing, requests: nowSamples.length, report: render(comparisons, slower, failing, nowSamples.length, beforeSamples.length) };
|
|
90
|
+
}
|
|
91
|
+
function render(all, slower, failing, now, before) {
|
|
92
|
+
const lines = ['# Tewip performance check', ''];
|
|
93
|
+
lines.push(`Compared a JMeter run of **${now} requests** against a baseline of **${before}**, request by request.`, '');
|
|
94
|
+
if (slower.length > 0)
|
|
95
|
+
lines.push(`**${slower.length} ${slower.length === 1 ? 'label is' : 'labels are'} slower than the baseline, by more than the runs explain.**`, '');
|
|
96
|
+
const faster = all.filter((c) => c.verdict.direction === 'faster');
|
|
97
|
+
if (faster.length > 0)
|
|
98
|
+
lines.push(`${faster.length} ${faster.length === 1 ? 'is' : 'are'} faster.`, '');
|
|
99
|
+
if (slower.length === 0 && faster.length === 0)
|
|
100
|
+
lines.push('**No difference these two runs can establish.**', '');
|
|
101
|
+
if (failing.length > 0)
|
|
102
|
+
lines.push(`**${failing.reduce((n, c) => n + c.failedNow, 0)} requests failed**, which is a separate matter from speed and is below.`, '');
|
|
103
|
+
lines.push('## Is it slower?', '');
|
|
104
|
+
for (const c of all) {
|
|
105
|
+
const mark = c.verdict.direction === 'slower' ? '**slower**' : c.verdict.direction === 'faster' ? 'faster' : 'cannot say';
|
|
106
|
+
lines.push(`- \`${c.label || '(unnamed)'}\` — ${mark}`);
|
|
107
|
+
lines.push(` - median ${ms(c.verdict.before)} → ${ms(c.verdict.now)} over ${c.verdict.sampleSize.before} and ${c.verdict.sampleSize.now} requests`);
|
|
108
|
+
lines.push(` - ${c.verdict.why}`);
|
|
109
|
+
}
|
|
110
|
+
lines.push('');
|
|
111
|
+
if (failing.length > 0) {
|
|
112
|
+
lines.push('## Requests that failed', '');
|
|
113
|
+
for (const c of failing) {
|
|
114
|
+
const codes = c.codes.map((x) => `${x.code} x${x.times}`).join(', ');
|
|
115
|
+
const assertion = c.codes.some((x) => /^2\d\d/.test(x.code)) ? ' - a 2xx counted as failed is an assertion failing, not the server' : '';
|
|
116
|
+
lines.push(`- **${c.label || '(unnamed)'}**: ${c.failedNow} of ${c.totalNow}${codes ? ` (${codes})` : ''}${assertion}`);
|
|
117
|
+
}
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push('A run with failures in it is a run whose timings describe something that did not work.', '');
|
|
120
|
+
}
|
|
121
|
+
const thin = all.filter((c) => c.verdict.p === null && c.verdict.sampleSize.before > 0);
|
|
122
|
+
if (thin.length > 0) {
|
|
123
|
+
lines.push(`${thin.length} ${thin.length === 1 ? 'label has' : 'labels have'} fewer than ${ENOUGH_SAMPLES} requests on one ` +
|
|
124
|
+
'side, so no test was run for them. That is said above rather than guessed at.', '');
|
|
125
|
+
}
|
|
126
|
+
lines.push(...notKnown());
|
|
127
|
+
return lines.join('\n');
|
|
128
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Phase 39: reading a JMeter results file.
|
|
2
|
+
//
|
|
3
|
+
// Two things made this worth doing properly.
|
|
4
|
+
//
|
|
5
|
+
// **Tewip could not read one at all.** The importer handled `.jtl` only as XML, and CSV is JMeter's
|
|
6
|
+
// default and what every real repository has - so `importJunit` answered "not an XML document: no
|
|
7
|
+
// root element" on genuine JMeter output while the help text advertised JMeter support.
|
|
8
|
+
//
|
|
9
|
+
// **And a .jtl is the best performance data Tewip has ever been handed.** A k6 summary gives six
|
|
10
|
+
// aggregates per metric and no samples, which is why Phase 30's comparison had to be a crude range
|
|
11
|
+
// check. A .jtl has one row per request: real samples, per label, which support an actual
|
|
12
|
+
// statistical test. The better answer was always sitting in a format that would not open.
|
|
13
|
+
//
|
|
14
|
+
// The parser is a real CSV reader rather than a line split, and that is not fastidiousness. In the
|
|
15
|
+
// first real file read here - NikeshMan16/JmeterPerformance_testing_Project - the quoted
|
|
16
|
+
// failureMessage of a 400 response contains a comma *and a newline*, so one logical record spans
|
|
17
|
+
// seven physical lines. Splitting on newlines would have produced six corrupt rows out of one.
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
/**
|
|
20
|
+
* RFC 4180 with JMeter's habits: quoted fields may contain commas, newlines and doubled quotes.
|
|
21
|
+
*
|
|
22
|
+
* Returned as rows of raw strings; nothing is interpreted here, because deciding what a column
|
|
23
|
+
* means is the caller's job and JMeter's column set is configurable.
|
|
24
|
+
*/
|
|
25
|
+
export function readCsv(text) {
|
|
26
|
+
const rows = [];
|
|
27
|
+
let row = [];
|
|
28
|
+
let field = '';
|
|
29
|
+
let quoted = false;
|
|
30
|
+
let i = 0;
|
|
31
|
+
// A byte order mark would otherwise become part of the first column's name.
|
|
32
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
33
|
+
i = 1;
|
|
34
|
+
for (; i < text.length; i += 1) {
|
|
35
|
+
const ch = text[i];
|
|
36
|
+
if (quoted) {
|
|
37
|
+
if (ch === '"') {
|
|
38
|
+
// A doubled quote inside a quoted field is one literal quote.
|
|
39
|
+
if (text[i + 1] === '"') {
|
|
40
|
+
field += '"';
|
|
41
|
+
i += 1;
|
|
42
|
+
}
|
|
43
|
+
else
|
|
44
|
+
quoted = false;
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
field += ch;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ch === '"') {
|
|
51
|
+
quoted = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === ',') {
|
|
55
|
+
row.push(field);
|
|
56
|
+
field = '';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '\r')
|
|
60
|
+
continue;
|
|
61
|
+
if (ch === '\n') {
|
|
62
|
+
row.push(field);
|
|
63
|
+
field = '';
|
|
64
|
+
// A blank line is not a record.
|
|
65
|
+
if (row.length > 1 || row[0] !== '')
|
|
66
|
+
rows.push(row);
|
|
67
|
+
row = [];
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
field += ch;
|
|
71
|
+
}
|
|
72
|
+
if (field !== '' || row.length > 0) {
|
|
73
|
+
row.push(field);
|
|
74
|
+
rows.push(row);
|
|
75
|
+
}
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
/** JMeter's column names, which are configurable, so each is looked up rather than counted. */
|
|
79
|
+
function columns(header) {
|
|
80
|
+
const at = {};
|
|
81
|
+
header.forEach((name, i) => {
|
|
82
|
+
at[name.trim()] = i;
|
|
83
|
+
});
|
|
84
|
+
return at;
|
|
85
|
+
}
|
|
86
|
+
const CSV_HEADER = /(^|,)\s*timeStamp\s*(,|$)/;
|
|
87
|
+
/** True of a CSV results file, as opposed to the XML form or something else entirely. */
|
|
88
|
+
export function looksLikeCsvJtl(text) {
|
|
89
|
+
const firstLine = text.slice(0, 2000).split(/\r?\n/)[0] ?? '';
|
|
90
|
+
return CSV_HEADER.test(firstLine) && firstLine.includes('elapsed');
|
|
91
|
+
}
|
|
92
|
+
export function readJtlSamples(path) {
|
|
93
|
+
const text = readFileSync(path, 'utf8');
|
|
94
|
+
if (!looksLikeCsvJtl(text)) {
|
|
95
|
+
throw new Error(`${path} is not a CSV JMeter results file: Tewip looked for a header row naming timeStamp and elapsed. ` +
|
|
96
|
+
'JMeter writes one by default; if yours is XML, it is read by `tewip explain` instead.');
|
|
97
|
+
}
|
|
98
|
+
const rows = readCsv(text);
|
|
99
|
+
const at = columns(rows[0] ?? []);
|
|
100
|
+
const elapsedAt = at.elapsed;
|
|
101
|
+
const labelAt = at.label;
|
|
102
|
+
if (elapsedAt === undefined || labelAt === undefined) {
|
|
103
|
+
throw new Error(`${path} has no elapsed or label column, so there is nothing to measure.`);
|
|
104
|
+
}
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const row of rows.slice(1)) {
|
|
107
|
+
// A short row is a truncated write - a run killed mid-flush - and inventing values for it would
|
|
108
|
+
// put a zero into a latency distribution, which is the one place a zero does real damage.
|
|
109
|
+
if (row.length <= Math.max(elapsedAt, labelAt))
|
|
110
|
+
continue;
|
|
111
|
+
const elapsed = Number(row[elapsedAt]);
|
|
112
|
+
if (!Number.isFinite(elapsed) || elapsed < 0)
|
|
113
|
+
continue;
|
|
114
|
+
const success = at.success === undefined ? true : row[at.success]?.trim().toLowerCase() === 'true';
|
|
115
|
+
out.push({
|
|
116
|
+
label: (row[labelAt] ?? '').trim(),
|
|
117
|
+
elapsed,
|
|
118
|
+
success,
|
|
119
|
+
responseCode: (at.responseCode === undefined ? '' : (row[at.responseCode] ?? '')).trim(),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** The samples grouped by what was being requested, busiest first. */
|
|
125
|
+
export function byLabel(samples) {
|
|
126
|
+
const groups = new Map();
|
|
127
|
+
const codes = new Map();
|
|
128
|
+
for (const s of samples) {
|
|
129
|
+
let group = groups.get(s.label);
|
|
130
|
+
if (!group) {
|
|
131
|
+
group = { label: s.label, elapsed: [], failed: 0, total: 0, codes: [] };
|
|
132
|
+
groups.set(s.label, group);
|
|
133
|
+
codes.set(s.label, new Map());
|
|
134
|
+
}
|
|
135
|
+
group.elapsed.push(s.elapsed);
|
|
136
|
+
group.total += 1;
|
|
137
|
+
if (!s.success) {
|
|
138
|
+
group.failed += 1;
|
|
139
|
+
const seen = codes.get(s.label);
|
|
140
|
+
const code = s.responseCode === '' ? 'no response code' : s.responseCode;
|
|
141
|
+
seen.set(code, (seen.get(code) ?? 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const [label, seen] of codes) {
|
|
145
|
+
groups.get(label).codes = [...seen].map(([code, times]) => ({ code, times })).sort((a, b) => b.times - a.times);
|
|
146
|
+
}
|
|
147
|
+
return [...groups.values()].sort((a, b) => b.total - a.total);
|
|
148
|
+
}
|
|
149
|
+
/** The whole run as one label, for the headline number. */
|
|
150
|
+
export const ALL_REQUESTS = 'all requests';
|
|
151
|
+
export function overall(samples) {
|
|
152
|
+
return {
|
|
153
|
+
label: ALL_REQUESTS,
|
|
154
|
+
elapsed: samples.map((s) => s.elapsed),
|
|
155
|
+
failed: samples.filter((s) => !s.success).length,
|
|
156
|
+
total: samples.length,
|
|
157
|
+
codes: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -21,6 +21,8 @@ import { isLighthouseFile, readLighthouse } from './lighthouse.js';
|
|
|
21
21
|
import { fromK6, metricsInOrder, show } from './run.js';
|
|
22
22
|
import { compareMetric, ENOUGH_BASELINES } from './compare.js';
|
|
23
23
|
import { baselineFor, fromSameTool, knownLabels, recordRun, storedRuns } from './history.js';
|
|
24
|
+
import { byLabel, readJtlSamples } from './jtl.js';
|
|
25
|
+
import { compareRuns, describeRun } from './jtl-report.js';
|
|
24
26
|
/** Enough requests for a percentile to mean anything. Below this, p95 is three data points. */
|
|
25
27
|
export const ENOUGH_REQUESTS = 100;
|
|
26
28
|
export function readPerfFile(path) {
|
|
@@ -120,8 +122,47 @@ export function renderPerf(run, comparisons, label, baselineCount, recorded, man
|
|
|
120
122
|
lines.push('This run was **not** recorded. Add `--record` on your main branch to make it part of the baseline; a regression recorded by accident becomes next week\'s normal.', '');
|
|
121
123
|
return lines.join('\n');
|
|
122
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* A JMeter run. With `--against` the two are compared request by request; without it the run is
|
|
127
|
+
* described and nothing is claimed about a change, because one run is not a comparison.
|
|
128
|
+
*/
|
|
129
|
+
function runJtl(root, cmd) {
|
|
130
|
+
const now = resolve(root, cmd.summary);
|
|
131
|
+
if (!cmd.against) {
|
|
132
|
+
const samples = readJtlSamples(now);
|
|
133
|
+
const report = describeRun(byLabel(samples), samples.length);
|
|
134
|
+
console.log(report);
|
|
135
|
+
if (cmd.out)
|
|
136
|
+
writeOut(root, cmd.out, report);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const result = compareRuns(now, resolve(root, cmd.against));
|
|
140
|
+
console.log(result.report);
|
|
141
|
+
if (cmd.out)
|
|
142
|
+
writeOut(root, cmd.out, result.report);
|
|
143
|
+
// Failures are a separate matter from speed and gate on their own: a run whose requests failed
|
|
144
|
+
// is a run whose timings describe something that did not work.
|
|
145
|
+
const failed = result.failing.reduce((n, c) => n + c.failedNow, 0);
|
|
146
|
+
if (cmd.gate && (result.slower.length > 0 || failed > 0)) {
|
|
147
|
+
const why = [result.slower.length > 0 ? `${result.slower.length} label(s) slower than the runs explain` : '', failed > 0 ? `${failed} request(s) failed` : '']
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.join(', and ');
|
|
150
|
+
console.error(`${String.fromCharCode(10)}Performance gate failed: ${why}.`);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
function writeOut(root, out, report) {
|
|
156
|
+
const path = resolve(root, out);
|
|
157
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
158
|
+
writeFileSync(path, report, 'utf8');
|
|
159
|
+
}
|
|
123
160
|
export function runPerf(cmd) {
|
|
124
161
|
const root = resolve(cmd.root);
|
|
162
|
+
// A JMeter results file is answered differently, and better: it carries every request rather
|
|
163
|
+
// than six aggregates, so two runs are compared with a real test instead of a range check.
|
|
164
|
+
if (/\.jtl$/i.test(cmd.summary))
|
|
165
|
+
return runJtl(root, cmd);
|
|
125
166
|
const label = cmd.label ?? 'default';
|
|
126
167
|
const { run, manual } = readPerfFile(resolve(root, cmd.summary));
|
|
127
168
|
const all = storedRuns(root, label);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Phase 39: comparing two sets of response times properly, because a .jtl gives real samples.
|
|
2
|
+
//
|
|
3
|
+
// Phase 30 had to be crude. A k6 summary carries six aggregates per metric and no samples, so the
|
|
4
|
+
// only honest test available was "is this value outside everything the baselines produced, by more
|
|
5
|
+
// than the width of their own spread". That refuses a lot of real regressions, which was the right
|
|
6
|
+
// trade when nothing better was possible.
|
|
7
|
+
//
|
|
8
|
+
// A JMeter .jtl carries one row per request, so something better *is* possible: a Mann-Whitney U
|
|
9
|
+
// test, which asks whether one distribution is shifted from another without assuming either is
|
|
10
|
+
// normal - and response times are never normal. They are a lump near the median with a long tail,
|
|
11
|
+
// which is exactly the shape a t-test handles badly.
|
|
12
|
+
//
|
|
13
|
+
// The decision that matters most here is **not** the p-value. With ten thousand samples a one
|
|
14
|
+
// millisecond shift is overwhelmingly significant and completely irrelevant, and a tool that
|
|
15
|
+
// reports it as a regression has taught its team that regressions are noise. So a change has to be
|
|
16
|
+
// both statistically real and large enough to care about, and the report says which of the two it
|
|
17
|
+
// failed when it fails.
|
|
18
|
+
/** Below this per side the normal approximation is unreliable, so no test is claimed. */
|
|
19
|
+
export const ENOUGH_SAMPLES = 20;
|
|
20
|
+
/** A shift smaller than this is not worth a person's attention, however certain it is. */
|
|
21
|
+
const SMALLEST_SHIFT = 0.05;
|
|
22
|
+
const SMALLEST_SHIFT_MS = 2;
|
|
23
|
+
export function median(values) {
|
|
24
|
+
if (values.length === 0)
|
|
25
|
+
return 0;
|
|
26
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
27
|
+
const mid = Math.floor(sorted.length / 2);
|
|
28
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The Mann-Whitney U statistic, with the tie correction.
|
|
32
|
+
*
|
|
33
|
+
* Ties matter here rather than being a formality: response times are milliseconds rounded to
|
|
34
|
+
* integers, so a fast endpoint produces hundreds of identical values and the uncorrected variance
|
|
35
|
+
* would be wrong in the direction that invents significance.
|
|
36
|
+
*/
|
|
37
|
+
export function mannWhitney(a, b) {
|
|
38
|
+
const n1 = a.length;
|
|
39
|
+
const n2 = b.length;
|
|
40
|
+
if (n1 === 0 || n2 === 0)
|
|
41
|
+
return null;
|
|
42
|
+
const all = [...a.map((v) => ({ v, from: 0 })), ...b.map((v) => ({ v, from: 1 }))].sort((x, y) => x.v - y.v);
|
|
43
|
+
const ranks = new Array(all.length);
|
|
44
|
+
const tieGroups = [];
|
|
45
|
+
for (let i = 0; i < all.length;) {
|
|
46
|
+
let j = i;
|
|
47
|
+
while (j + 1 < all.length && all[j + 1].v === all[i].v)
|
|
48
|
+
j += 1;
|
|
49
|
+
// Everything in a tie shares the average of the ranks they span.
|
|
50
|
+
const shared = (i + j + 2) / 2;
|
|
51
|
+
for (let k = i; k <= j; k += 1)
|
|
52
|
+
ranks[k] = shared;
|
|
53
|
+
if (j > i)
|
|
54
|
+
tieGroups.push(j - i + 1);
|
|
55
|
+
i = j + 1;
|
|
56
|
+
}
|
|
57
|
+
let rankSumA = 0;
|
|
58
|
+
for (const [i, entry] of all.entries())
|
|
59
|
+
if (entry.from === 0)
|
|
60
|
+
rankSumA += ranks[i];
|
|
61
|
+
const u1 = rankSumA - (n1 * (n1 + 1)) / 2;
|
|
62
|
+
const n = n1 + n2;
|
|
63
|
+
const mean = (n1 * n2) / 2;
|
|
64
|
+
const tieTerm = tieGroups.reduce((sum, t) => sum + (t * t * t - t), 0);
|
|
65
|
+
const variance = ((n1 * n2) / 12) * (n + 1 - tieTerm / (n * (n - 1)));
|
|
66
|
+
if (variance <= 0)
|
|
67
|
+
return { u: u1, z: 0, p: 1 };
|
|
68
|
+
// Continuity-corrected, two-sided.
|
|
69
|
+
const z = (u1 - mean - Math.sign(u1 - mean) * 0.5) / Math.sqrt(variance);
|
|
70
|
+
return { u: u1, z, p: 2 * (1 - normalCdf(Math.abs(z))) };
|
|
71
|
+
}
|
|
72
|
+
/** The standard normal CDF, by Abramowitz and Stegun 26.2.17 - accurate to about 1e-7. */
|
|
73
|
+
function normalCdf(x) {
|
|
74
|
+
const t = 1 / (1 + 0.2316419 * x);
|
|
75
|
+
const d = 0.3989422804014327 * Math.exp(-(x * x) / 2);
|
|
76
|
+
const poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
|
|
77
|
+
return 1 - d * poly;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether this run is slower than the baseline, from the samples themselves.
|
|
81
|
+
*
|
|
82
|
+
* Both conditions have to hold. A difference that is not statistically real might be the machine;
|
|
83
|
+
* a difference that is real but tiny is not worth anybody's afternoon, and reporting it is how a
|
|
84
|
+
* team learns to ignore the report. The verdict says which one was missing.
|
|
85
|
+
*/
|
|
86
|
+
export function compareSamples(now, before) {
|
|
87
|
+
const base = {
|
|
88
|
+
now: Math.round(median(now) * 10) / 10,
|
|
89
|
+
before: Math.round(median(before) * 10) / 10,
|
|
90
|
+
sampleSize: { now: now.length, before: before.length },
|
|
91
|
+
};
|
|
92
|
+
if (now.length < ENOUGH_SAMPLES || before.length < ENOUGH_SAMPLES) {
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
direction: 'cannot-say',
|
|
96
|
+
slowerThanBefore: 0.5,
|
|
97
|
+
p: null,
|
|
98
|
+
why: `${now.length} requests now and ${before.length} before. Below ${ENOUGH_SAMPLES} on either side the test ` +
|
|
99
|
+
'is not reliable, so Tewip will not run it. Lengthen the run rather than reading the numbers.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const test = mannWhitney(now, before);
|
|
103
|
+
if (test === null)
|
|
104
|
+
return { ...base, direction: 'cannot-say', slowerThanBefore: 0.5, p: null, why: 'there was nothing to compare' };
|
|
105
|
+
// U is the count of (now, before) pairs where now ranks higher, so U / (n1*n2) is directly the
|
|
106
|
+
// probability that a random request now is slower than a random one before. It is the most
|
|
107
|
+
// readable effect size there is, and it needs no units.
|
|
108
|
+
const slowerThanBefore = test.u / (now.length * before.length);
|
|
109
|
+
const shift = base.before > 0 ? (base.now - base.before) / base.before : 0;
|
|
110
|
+
const bigEnough = Math.abs(shift) >= SMALLEST_SHIFT && Math.abs(base.now - base.before) >= SMALLEST_SHIFT_MS;
|
|
111
|
+
const real = test.p < 0.01;
|
|
112
|
+
const percent = Math.round(shift * 100);
|
|
113
|
+
const chance = Math.round(slowerThanBefore * 100);
|
|
114
|
+
if (real && bigEnough) {
|
|
115
|
+
const faster = base.now < base.before;
|
|
116
|
+
return {
|
|
117
|
+
...base,
|
|
118
|
+
direction: faster ? 'faster' : 'slower',
|
|
119
|
+
slowerThanBefore,
|
|
120
|
+
p: test.p,
|
|
121
|
+
why: `the median moved from ${base.before}ms to ${base.now}ms (${percent > 0 ? '+' : ''}${percent}%), and a random ` +
|
|
122
|
+
`request now is ${chance}% likely to be ${faster ? 'faster' : 'slower'} than a random one before. ` +
|
|
123
|
+
`Over ${now.length} and ${before.length} requests that is not the machine (p < 0.01).`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (real) {
|
|
127
|
+
return {
|
|
128
|
+
...base,
|
|
129
|
+
direction: 'cannot-say',
|
|
130
|
+
slowerThanBefore,
|
|
131
|
+
p: test.p,
|
|
132
|
+
why: `the difference is real but too small to act on: ${base.before}ms to ${base.now}ms, ` +
|
|
133
|
+
`${percent > 0 ? '+' : ''}${percent}%. With this many samples even a millisecond is statistically certain, ` +
|
|
134
|
+
'which is why certainty alone is not the test.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
direction: 'cannot-say',
|
|
140
|
+
slowerThanBefore,
|
|
141
|
+
p: test.p,
|
|
142
|
+
why: `${base.before}ms to ${base.now}ms is within what these two runs could produce by themselves ` +
|
|
143
|
+
`(p = ${test.p.toFixed(2)}). A difference the runs make on their own is not evidence about this change.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// `tewip` with nothing after it: look at this project and say what Tewip can actually do for it.
|
|
2
|
+
//
|
|
3
|
+
// Until now the answer to "I installed it, now what" was 233 lines of help listing eleven commands,
|
|
4
|
+
// which is a reference for somebody who already knows what they want and a wall for everybody else.
|
|
5
|
+
// Eleven commands is already more than most teams will run; the problem was never the number of
|
|
6
|
+
// things Tewip can do, it was that none of them were connected to *this* repository.
|
|
7
|
+
//
|
|
8
|
+
// So this looks. It counts what is actually here - test files and which framework wrote them,
|
|
9
|
+
// migrations and which tool, markup and which template language, a k6 or JMeter or Lighthouse
|
|
10
|
+
// report somebody has already produced - and then names the two or three commands that will tell
|
|
11
|
+
// this team something, with their own paths in them. And it says what Tewip cannot do here, because
|
|
12
|
+
// a front door that implies it can do everything is the same lie as a report with no limits on it.
|
|
13
|
+
//
|
|
14
|
+
// Nothing is run and nothing is written. It reads the directory and stops.
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
16
|
+
import { join, relative, resolve } from 'node:path';
|
|
17
|
+
import { languageOf, looksLikeTests } from '../vouch/tests-in.js';
|
|
18
|
+
import { MARKUP } from '../access/markup.js';
|
|
19
|
+
import { dialectOf } from '../schema/frameworks.js';
|
|
20
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'target', 'vendor', '__pycache__', '.venv', 'venv', '.next', '.cache', '.tewip', '.triage-agent']);
|
|
21
|
+
/**
|
|
22
|
+
* A bounded look: enough to recognise a project, not enough to be slow on a large one.
|
|
23
|
+
*
|
|
24
|
+
* Breadth-first, and that is the whole correctness of it. Depth-first with a budget spends the
|
|
25
|
+
* budget wherever the alphabet happens to lead: pointed at Tewip itself it descended into `data/`,
|
|
26
|
+
* exhausted 4000 files on stored datasets, and reported **7 test files in a repository with 69**.
|
|
27
|
+
* A number that wrong is worse than no number, because the front door is the one place a person
|
|
28
|
+
* has no way to tell. Breadth-first sees every top-level directory before descending into any of
|
|
29
|
+
* them, so the budget is spent on breadth, which is what recognising a project needs.
|
|
30
|
+
*/
|
|
31
|
+
export function lookAround(root, limit = 4000) {
|
|
32
|
+
const found = {
|
|
33
|
+
tests: new Map(),
|
|
34
|
+
migrations: new Map(),
|
|
35
|
+
markup: 0,
|
|
36
|
+
perfReports: [],
|
|
37
|
+
hasWorkflows: existsSync(join(root, '.github', 'workflows')),
|
|
38
|
+
hasStandards: existsSync(join(root, '.tewip', 'standards')),
|
|
39
|
+
filesSeen: false,
|
|
40
|
+
};
|
|
41
|
+
let seen = 0;
|
|
42
|
+
// A queue rather than recursion: every directory at one depth is read before any at the next.
|
|
43
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
44
|
+
while (queue.length > 0 && seen < limit) {
|
|
45
|
+
const { dir, depth } = queue.shift();
|
|
46
|
+
if (depth > 8)
|
|
47
|
+
continue;
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(dir);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (seen >= limit)
|
|
57
|
+
break;
|
|
58
|
+
if (SKIP.has(entry) || (entry.startsWith('.') && entry !== '.github'))
|
|
59
|
+
continue;
|
|
60
|
+
const full = join(dir, entry);
|
|
61
|
+
let info;
|
|
62
|
+
try {
|
|
63
|
+
info = statSync(full);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (info.isDirectory()) {
|
|
69
|
+
queue.push({ dir: full, depth: depth + 1 });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
seen += 1;
|
|
73
|
+
found.filesSeen = true;
|
|
74
|
+
const rel = relative(root, full).replaceAll('\\', '/');
|
|
75
|
+
if (MARKUP.test(entry))
|
|
76
|
+
found.markup += 1;
|
|
77
|
+
if (/\.jtl$/i.test(entry))
|
|
78
|
+
found.perfReports.push(rel);
|
|
79
|
+
else if (/(k6|lighthouse|summary|lhr)[\w.-]*\.json$/i.test(entry) && info.size < 8_000_000)
|
|
80
|
+
found.perfReports.push(rel);
|
|
81
|
+
const inMigrations = /(^|\/)(migrations?|db\/migrate|changelog)\//i.test(rel);
|
|
82
|
+
// Reading a file is only worth it for the two questions a name cannot answer: is this a
|
|
83
|
+
// test, and is this a migration. Both are cheap, and both are often wrong from the path.
|
|
84
|
+
const language = languageOf(entry);
|
|
85
|
+
const worthReading = (language !== null || /\.(sql|xml)$/i.test(entry)) && info.size < 1_000_000;
|
|
86
|
+
if (!worthReading)
|
|
87
|
+
continue;
|
|
88
|
+
let text = '';
|
|
89
|
+
try {
|
|
90
|
+
text = readFileSync(full, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (language && looksLikeTests(rel, text))
|
|
96
|
+
found.tests.set(language, (found.tests.get(language) ?? 0) + 1);
|
|
97
|
+
if (inMigrations) {
|
|
98
|
+
const dialect = dialectOf(rel, text);
|
|
99
|
+
if (dialect !== 'unknown')
|
|
100
|
+
found.migrations.set(dialect, (found.migrations.get(dialect) ?? 0) + 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
const total = (counts) => [...counts.values()].reduce((a, b) => a + b, 0);
|
|
107
|
+
/** What a person calls these, rather than what the code calls them. */
|
|
108
|
+
const PLAIN = {
|
|
109
|
+
js: 'JavaScript or TypeScript',
|
|
110
|
+
python: 'Python',
|
|
111
|
+
jvm: 'Java, Kotlin or Scala',
|
|
112
|
+
go: 'Go',
|
|
113
|
+
csharp: 'C#',
|
|
114
|
+
ruby: 'Ruby',
|
|
115
|
+
php: 'PHP',
|
|
116
|
+
rust: 'Rust',
|
|
117
|
+
sql: 'SQL',
|
|
118
|
+
django: 'Django',
|
|
119
|
+
rails: 'Rails',
|
|
120
|
+
alembic: 'Alembic',
|
|
121
|
+
typeorm: 'TypeORM',
|
|
122
|
+
sequelize: 'Sequelize',
|
|
123
|
+
knex: 'Knex',
|
|
124
|
+
efcore: 'EF Core',
|
|
125
|
+
liquibase: 'Liquibase',
|
|
126
|
+
};
|
|
127
|
+
const named = (counts) => [...counts.entries()]
|
|
128
|
+
.sort((a, b) => b[1] - a[1])
|
|
129
|
+
// Only the ones worth naming: a repository with one stray file in a ninth language does not
|
|
130
|
+
// need all nine listed at the front door.
|
|
131
|
+
.slice(0, 3)
|
|
132
|
+
.map(([name, n]) => `${n} ${PLAIN[name] ?? name}`)
|
|
133
|
+
.join(', ');
|
|
134
|
+
/** The two or three things worth running here, most useful first. */
|
|
135
|
+
export function suggest(found) {
|
|
136
|
+
const out = [];
|
|
137
|
+
const tests = total(found.tests);
|
|
138
|
+
const migrations = total(found.migrations);
|
|
139
|
+
if (tests > 0) {
|
|
140
|
+
out.push({
|
|
141
|
+
command: 'tewip vouch',
|
|
142
|
+
because: `${tests} test ${tests === 1 ? 'file' : 'files'} here (${named(found.tests)}). This reads them and names the ones that cannot fail - no model, a second or two.`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (migrations > 0) {
|
|
146
|
+
out.push({
|
|
147
|
+
command: 'tewip schema --diff main...HEAD',
|
|
148
|
+
because: `${migrations} ${migrations === 1 ? 'migration' : 'migrations'} here (${named(found.migrations)}). This says what a change would do to a live database before it runs.`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (found.markup > 0) {
|
|
152
|
+
out.push({
|
|
153
|
+
command: 'tewip access',
|
|
154
|
+
because: `${found.markup} markup ${found.markup === 1 ? 'file' : 'files'} here. This names who cannot use the interface, against the WCAG criterion a regulator would cite.`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
for (const report of found.perfReports.slice(0, 1)) {
|
|
158
|
+
out.push({
|
|
159
|
+
command: `tewip perf ${report}`,
|
|
160
|
+
because: 'there is already a performance report here, and Tewip will judge it rather than restate it.',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (tests > 0) {
|
|
164
|
+
out.push({
|
|
165
|
+
command: 'tewip done',
|
|
166
|
+
because: 'the one verdict over your current change, once you have made one. It answers ready, not ready, or cannot say.',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
export function renderStart(found, root) {
|
|
172
|
+
const lines = ['# Tewip', ''];
|
|
173
|
+
if (!found.filesSeen) {
|
|
174
|
+
lines.push(`There is nothing to read in \`${root}\`. Run Tewip from the root of a project.`, '');
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
177
|
+
const suggestions = suggest(found);
|
|
178
|
+
if (suggestions.length === 0) {
|
|
179
|
+
lines.push(`Tewip looked around \`${root}\` and found nothing it can judge: no test files, no migrations, no markup, ` +
|
|
180
|
+
'and no performance report. That is an honest answer rather than a failure - point it at a project with ' +
|
|
181
|
+
'tests in it, or run `tewip --help` for the full list.', '');
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
lines.push('Here is what Tewip can tell you about **this** project, in the order worth doing.', '');
|
|
185
|
+
for (const [i, s] of suggestions.entries()) {
|
|
186
|
+
lines.push(`**${i + 1}. \`${s.command}\`**`, '', s.because, '');
|
|
187
|
+
}
|
|
188
|
+
lines.push('## What it will not do here', '');
|
|
189
|
+
const cannot = [];
|
|
190
|
+
if (total(found.tests) === 0)
|
|
191
|
+
cannot.push('No test files were found, so nothing can be said about whether your tests could fail or would catch a bug.');
|
|
192
|
+
if (total(found.migrations) === 0)
|
|
193
|
+
cannot.push('No migrations were found, so nothing is said about your database.');
|
|
194
|
+
if (found.markup === 0)
|
|
195
|
+
cannot.push('No markup was found, so nothing is said about accessibility.');
|
|
196
|
+
if (found.perfReports.length === 0)
|
|
197
|
+
cannot.push('No k6, JMeter or Lighthouse report was found. Tewip judges one you produce; it does not run load tests itself.');
|
|
198
|
+
if (!found.hasStandards)
|
|
199
|
+
cannot.push('This project has not taught Tewip any house rules yet. Put a markdown file in `.tewip/standards/` and `tewip standards` will enforce it.');
|
|
200
|
+
if (!found.hasWorkflows)
|
|
201
|
+
cannot.push('There is no GitHub Actions workflow here. `uses: hmamut39/tewip@main` with `mode: check` runs these same checks on every pull request.');
|
|
202
|
+
else
|
|
203
|
+
cannot.push('You have GitHub Actions. `mode: check` runs these same checks on every pull request.');
|
|
204
|
+
for (const line of cannot)
|
|
205
|
+
lines.push(`- ${line}`);
|
|
206
|
+
lines.push('');
|
|
207
|
+
lines.push('Everything Tewip answers carries its evidence and says what it did **not** check. It answers ' +
|
|
208
|
+
'"cannot say" rather than guessing. `tewip --help` has all eleven commands when you want them.', '');
|
|
209
|
+
return lines.join('\n');
|
|
210
|
+
}
|
|
211
|
+
export function runStart(root) {
|
|
212
|
+
const where = resolve(root);
|
|
213
|
+
const report = renderStart(lookAround(where), where);
|
|
214
|
+
console.log(report);
|
|
215
|
+
return report;
|
|
216
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|