tewip-ai 0.46.0 → 0.47.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/dist/cli/help.js +7 -3
- 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/package.json +1 -1
package/dist/cli/help.js
CHANGED
|
@@ -118,10 +118,11 @@ Usage:
|
|
|
118
118
|
is what was asked for covered. Answers ready, not ready, or cannot
|
|
119
119
|
say - and cannot say is never rounded up to ready. --json writes a
|
|
120
120
|
sealed verdict that can be checked later. No model, no cost.
|
|
121
|
-
tewip perf <k6
|
|
121
|
+
tewip perf <k6.json | lighthouse.json | run.jtl> [--against before.jtl] [--record] [--strict]
|
|
122
122
|
Is this change slower, and does it matter? Reads a k6 run (what
|
|
123
123
|
the server does under load) or a Lighthouse report (what one
|
|
124
|
-
page load costs in a browser), and answers two
|
|
124
|
+
page load costs in a browser) or a JMeter .jtl, and answers two
|
|
125
|
+
questions apart:
|
|
125
126
|
is anything in a bad state now - from the thresholds you wrote,
|
|
126
127
|
or from Lighthouse's own scoring, never a budget Tewip invented
|
|
127
128
|
- and is this slower than it was. The second only from repeated
|
|
@@ -129,7 +130,10 @@ Usage:
|
|
|
129
130
|
run against one cannot tell a regression from a busy machine.
|
|
130
131
|
--record adds a run to the baseline, and is deliberately not
|
|
131
132
|
automatic: a regression recorded by accident becomes next week's
|
|
132
|
-
normal.
|
|
133
|
+
normal. A .jtl is different and better: it carries every request,
|
|
134
|
+
so --against before.jtl compares the two distributions with a
|
|
135
|
+
real test rather than a range check - and refuses a difference
|
|
136
|
+
that is certain but too small to act on. No cost.
|
|
133
137
|
tewip access [<file or directory>] [--report axe.json|lighthouse.json] [--strict]
|
|
134
138
|
Can everybody use this? Reads your markup and names the barriers
|
|
135
139
|
that reading alone can prove - a picture with no alt text, a field
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.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": [
|