tewip-ai 0.37.0 → 0.38.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 +31 -0
- package/dist/cli/help.js +12 -10
- package/dist/engine/perf/history.js +21 -6
- package/dist/engine/perf/lighthouse.js +149 -0
- package/dist/engine/perf/perf-command.js +102 -57
- package/dist/engine/perf/run.js +39 -0
- package/dist/mcp/tools-prove.js +9 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -624,6 +624,37 @@ compare everywhere else.
|
|
|
624
624
|
**It never predicts a duration, a capacity, or what a user will feel.** It cannot see your production
|
|
625
625
|
hardware, your real data volume or your real traffic, and every report says so.
|
|
626
626
|
|
|
627
|
+
### Lighthouse too (Phase 31, new)
|
|
628
|
+
|
|
629
|
+
```bash
|
|
630
|
+
tewip perf lighthouse-report.json --record
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
k6 measures what your server does under load. Lighthouse measures what **one page load costs in a real
|
|
634
|
+
browser** — Largest Contentful Paint, layout shift, blocking time — which is nearer to what a person
|
|
635
|
+
actually waits for. `tewip perf` takes either and works out which it was given.
|
|
636
|
+
|
|
637
|
+
Lighthouse needs the same discipline *more*, not less. **Four real Lighthouse runs of an unchanged
|
|
638
|
+
page gave LCP of 922, 912, 936 and 916ms.** Same rule: three baselines, outside the range by more than
|
|
639
|
+
its own width.
|
|
640
|
+
|
|
641
|
+
**And Tewip does not invent a verdict.** "Is 2112ms bad?" has a published answer and it is Google's:
|
|
642
|
+
Lighthouse scores every metric on its own curve. Tewip reads that score rather than hard-coding
|
|
643
|
+
thresholds it remembered — which matters, because on the real slowed run FCP and LCP had the *identical*
|
|
644
|
+
value of 2112ms and Lighthouse rates them differently:
|
|
645
|
+
|
|
646
|
+
```
|
|
647
|
+
- good — `largest-contentful-paint`: 2112ms
|
|
648
|
+
- needs improvement — `first-contentful-paint`: 2112ms
|
|
649
|
+
- **poor** — `server-response-time`: 611ms
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
One remembered threshold applied to both would have been wrong.
|
|
653
|
+
|
|
654
|
+
It also surfaces the **ten accessibility audits Lighthouse refuses to score** — logical tab order, focus
|
|
655
|
+
traps, whether visual order follows the DOM. Those are neither failures nor passes, and a page with no
|
|
656
|
+
failures and ten unanswered manual checks is not a page that passed.
|
|
657
|
+
|
|
627
658
|
One more thing it refuses: if your summary came from `--new-machine-readable-summary`, that export
|
|
628
659
|
records **no threshold results at all** — the word appears in it only inside a copy of your script.
|
|
629
660
|
Tewip says "this export cannot tell me" rather than "nothing was breached".
|
package/dist/cli/help.js
CHANGED
|
@@ -118,16 +118,18 @@ 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-summary.json> [--record] [--label checkout] [--strict] [--out perf.md]
|
|
122
|
-
Is this change slower, and does it matter? Reads a k6 run
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
tewip perf <k6-summary.json | lighthouse.json> [--record] [--label checkout] [--strict] [--out perf.md]
|
|
122
|
+
Is this change slower, and does it matter? Reads a k6 run (what
|
|
123
|
+
the server does under load) or a Lighthouse report (what one
|
|
124
|
+
page load costs in a browser), and answers two questions apart:
|
|
125
|
+
is anything in a bad state now - from the thresholds you wrote,
|
|
126
|
+
or from Lighthouse's own scoring, never a budget Tewip invented
|
|
127
|
+
- and is this slower than it was. The second only from repeated
|
|
128
|
+
baselines: with fewer than three it says cannot say, because one
|
|
129
|
+
run against one cannot tell a regression from a busy machine.
|
|
130
|
+
--record adds a run to the baseline, and is deliberately not
|
|
131
|
+
automatic: a regression recorded by accident becomes next week's
|
|
132
|
+
normal. It never predicts a duration or a capacity. No cost.
|
|
131
133
|
tewip access [<file or directory>] [--report axe.json] [--strict] [--out access.md]
|
|
132
134
|
Can everybody use this? Reads your markup and names the barriers
|
|
133
135
|
that reading alone can prove - a picture with no alt text, a field
|
|
@@ -25,7 +25,8 @@ export function storedRuns(root, label) {
|
|
|
25
25
|
const parsed = JSON.parse(trimmed);
|
|
26
26
|
// A line Tewip cannot read is skipped rather than throwing: one bad line written by a crashed
|
|
27
27
|
// run must not make the whole baseline unreadable.
|
|
28
|
-
|
|
28
|
+
// Either shape counts: `metrics` is what is written now, `timings` what older versions wrote.
|
|
29
|
+
if (parsed && typeof parsed.at === 'string' && parsed.label === label && (parsed.metrics ?? parsed.timings))
|
|
29
30
|
out.push(parsed);
|
|
30
31
|
}
|
|
31
32
|
catch {
|
|
@@ -34,22 +35,36 @@ export function storedRuns(root, label) {
|
|
|
34
35
|
}
|
|
35
36
|
return out;
|
|
36
37
|
}
|
|
37
|
-
export function recordRun(root, label, run,
|
|
38
|
+
export function recordRun(root, label, run, gitSha) {
|
|
38
39
|
const stored = {
|
|
39
40
|
at: run.at,
|
|
40
41
|
label,
|
|
41
42
|
...(gitSha ? { gitSha } : {}),
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
source: run.source,
|
|
44
|
+
metrics: Object.fromEntries(Object.entries(run.metrics).map(([m, v]) => [m, v.value])),
|
|
45
|
+
requests: run.volume.n,
|
|
44
46
|
};
|
|
45
47
|
const path = file(root);
|
|
46
48
|
mkdirSync(dirname(path), { recursive: true });
|
|
47
49
|
appendFileSync(path, `${JSON.stringify(stored)}\n`, 'utf8');
|
|
48
50
|
return stored;
|
|
49
51
|
}
|
|
50
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Every stored value for one metric, oldest first, for the comparison to read the spread from.
|
|
54
|
+
*
|
|
55
|
+
* Reads the older shape as well, so upgrading Tewip does not quietly empty a project's baseline -
|
|
56
|
+
* which would drop it below three runs and make Tewip refuse to judge a project it judged fine the
|
|
57
|
+
* day before.
|
|
58
|
+
*/
|
|
51
59
|
export function baselineFor(runs, metric) {
|
|
52
|
-
return runs
|
|
60
|
+
return runs
|
|
61
|
+
.map((r) => r.metrics?.[metric] ?? r.timings?.[metric]?.p95)
|
|
62
|
+
.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
63
|
+
}
|
|
64
|
+
/** Runs from the same tool. A k6 p95 and a Lighthouse LCP are not each other's baseline. */
|
|
65
|
+
export function fromSameTool(runs, source) {
|
|
66
|
+
// Runs recorded before the field existed were all k6, which is what the absence means.
|
|
67
|
+
return runs.filter((r) => (r.source ?? 'k6') === source);
|
|
53
68
|
}
|
|
54
69
|
/** The labels this project has recorded, so a typo in --label does not look like an empty history. */
|
|
55
70
|
export function knownLabels(root) {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Phase 31: reading a Lighthouse report.
|
|
2
|
+
//
|
|
3
|
+
// k6 measures what a server does under load. Lighthouse measures what one page load costs in a real
|
|
4
|
+
// browser - Largest Contentful Paint, layout shift, blocking time - which is nearer to what a person
|
|
5
|
+
// actually waits for, and is what most teams already run. Tewip could read neither until now.
|
|
6
|
+
//
|
|
7
|
+
// The same discipline as everywhere else applies, and Lighthouse needs it more than k6 does. Four
|
|
8
|
+
// real Lighthouse runs of an unchanged page, measured here, gave LCP of 922, 912, 936 and 916ms: a
|
|
9
|
+
// 2.6% spread from nothing happening. Lighthouse's own score moved between runs too. So the
|
|
10
|
+
// comparison is the same one k6 gets - outside the baseline range by more than its own width, with
|
|
11
|
+
// three baselines required before anything is called at all.
|
|
12
|
+
//
|
|
13
|
+
// One thing is deliberately not invented. "Is 2112ms bad?" has a published answer, and it is
|
|
14
|
+
// Google's, not Tewip's: Lighthouse scores every metric 0 to 1 on its own curve and renders the
|
|
15
|
+
// bands at 0.9 and 0.5. Tewip reads that score rather than hard-coding thresholds it remembered,
|
|
16
|
+
// so the word "poor" in a report is Lighthouse's judgement and is traceable to it.
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
/** The audits worth comparing between runs: what the page load cost, not advice about it. */
|
|
19
|
+
const MEASURED = new Set([
|
|
20
|
+
'largest-contentful-paint',
|
|
21
|
+
'first-contentful-paint',
|
|
22
|
+
'total-blocking-time',
|
|
23
|
+
'cumulative-layout-shift',
|
|
24
|
+
'speed-index',
|
|
25
|
+
'interaction-to-next-paint',
|
|
26
|
+
'server-response-time',
|
|
27
|
+
'time-to-first-byte',
|
|
28
|
+
]);
|
|
29
|
+
/** Lighthouse renders green at 0.9 and orange at 0.5. The bands are its own, and so is the word. */
|
|
30
|
+
function rating(score) {
|
|
31
|
+
if (typeof score !== 'number' || !Number.isFinite(score))
|
|
32
|
+
return null;
|
|
33
|
+
if (score >= 0.9)
|
|
34
|
+
return 'good';
|
|
35
|
+
if (score >= 0.5)
|
|
36
|
+
return 'needs-improvement';
|
|
37
|
+
return 'poor';
|
|
38
|
+
}
|
|
39
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
40
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
41
|
+
function looksLikeLighthouse(v) {
|
|
42
|
+
if (typeof v !== 'object' || v === null)
|
|
43
|
+
return false;
|
|
44
|
+
const o = v;
|
|
45
|
+
return typeof o.lighthouseVersion === 'string' && typeof o.audits === 'object' && o.audits !== null;
|
|
46
|
+
}
|
|
47
|
+
/** The nodes a failing accessibility audit points at. */
|
|
48
|
+
function failingNodes(audit) {
|
|
49
|
+
const details = (audit.details ?? {});
|
|
50
|
+
const items = Array.isArray(details.items) ? details.items : [];
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
const node = (item?.node ?? {});
|
|
54
|
+
out.push({
|
|
55
|
+
selector: text(node.selector),
|
|
56
|
+
snippet: text(node.snippet).replace(/\s+/g, ' ').slice(0, 160),
|
|
57
|
+
explanation: text(node.explanation)
|
|
58
|
+
.split(/\r?\n/)
|
|
59
|
+
.map((l) => l.trim())
|
|
60
|
+
.filter((l) => l !== '' && !/^Fix (any|all) of the following:$/.test(l))
|
|
61
|
+
.join('; '),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
export function readLighthouse(path) {
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new Error(`${path} is not JSON. Tewip reads the JSON Lighthouse writes with --output=json.`);
|
|
73
|
+
}
|
|
74
|
+
if (!looksLikeLighthouse(parsed)) {
|
|
75
|
+
throw new Error(`${path} is JSON, but not a Lighthouse report: Tewip looked for "lighthouseVersion" and an "audits" object. ` +
|
|
76
|
+
'Write one with: lighthouse <url> --output=json --output-path=report.json');
|
|
77
|
+
}
|
|
78
|
+
const o = parsed;
|
|
79
|
+
// A report can carry a runtime error and still be valid JSON with empty audits. Reporting "no
|
|
80
|
+
// regression" from a run that never loaded the page would be the worst kind of wrong.
|
|
81
|
+
const runtimeError = (o.runtimeError ?? null);
|
|
82
|
+
if (runtimeError && text(runtimeError.code) !== 'NO_ERROR' && text(runtimeError.message) !== '') {
|
|
83
|
+
throw new Error(`that Lighthouse run did not complete: ${text(runtimeError.message)}`);
|
|
84
|
+
}
|
|
85
|
+
const audits = (o.audits ?? {});
|
|
86
|
+
const categories = (o.categories ?? {});
|
|
87
|
+
const metrics = {};
|
|
88
|
+
for (const [id, audit] of Object.entries(audits)) {
|
|
89
|
+
if (!MEASURED.has(id) || typeof audit.numericValue !== 'number')
|
|
90
|
+
continue;
|
|
91
|
+
metrics[id] = {
|
|
92
|
+
value: num(audit.numericValue),
|
|
93
|
+
unit: audit.numericUnit === 'millisecond' ? 'ms' : 'unitless',
|
|
94
|
+
rating: rating(audit.score),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// The category score itself is worth tracking: it is the number teams quote at each other.
|
|
98
|
+
for (const [name, category] of Object.entries(categories)) {
|
|
99
|
+
if (typeof category?.score === 'number') {
|
|
100
|
+
metrics[`${name}-score`] = { value: category.score, unit: 'score', rating: rating(category.score) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const accessibilityFailures = [];
|
|
104
|
+
const manual = [];
|
|
105
|
+
const a11yRefs = (categories.accessibility?.auditRefs ?? []);
|
|
106
|
+
for (const ref of a11yRefs) {
|
|
107
|
+
const audit = audits[text(ref.id)];
|
|
108
|
+
if (!audit)
|
|
109
|
+
continue;
|
|
110
|
+
const id = text(audit.id);
|
|
111
|
+
if (audit.scoreDisplayMode === 'manual') {
|
|
112
|
+
manual.push({ id, title: text(audit.title) });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (typeof audit.score === 'number' && audit.score < 1) {
|
|
116
|
+
const nodes = failingNodes(audit);
|
|
117
|
+
if (nodes.length === 0)
|
|
118
|
+
accessibilityFailures.push({ id, title: text(audit.title), selector: '', snippet: '', explanation: '' });
|
|
119
|
+
for (const node of nodes)
|
|
120
|
+
accessibilityFailures.push({ id, title: text(audit.title), ...node });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
run: {
|
|
125
|
+
source: 'lighthouse',
|
|
126
|
+
at: text(o.fetchTime) || new Date().toISOString(),
|
|
127
|
+
metrics,
|
|
128
|
+
// Lighthouse has no user-written thresholds the way a k6 script does. That is an absence of
|
|
129
|
+
// the feature, not an export that forgot to record them, so an empty list is the honest value.
|
|
130
|
+
thresholds: [],
|
|
131
|
+
volume: { kind: 'page load', n: 1 },
|
|
132
|
+
},
|
|
133
|
+
categories: Object.fromEntries(Object.entries(categories).map(([k, v]) => [k, num(v?.score)])),
|
|
134
|
+
accessibilityFailures,
|
|
135
|
+
manual,
|
|
136
|
+
url: text(o.finalDisplayedUrl) || text(o.finalUrl) || text(o.requestedUrl),
|
|
137
|
+
lighthouseVersion: text(o.lighthouseVersion),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** True of a Lighthouse report, for a command that accepts either tool's output. */
|
|
141
|
+
export function isLighthouseFile(path) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
144
|
+
return looksLikeLighthouse(parsed);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,111 +1,156 @@
|
|
|
1
|
-
// Phase 30: `tewip perf` - is this change slower, and does it matter?
|
|
1
|
+
// Phase 30, extended in Phase 31: `tewip perf` - is this change slower, and does it matter?
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Reads a k6 run (what a server does under load) or a Lighthouse report (what one page load costs in
|
|
4
|
+
// a browser), whichever it is handed, and answers two questions separately because they fail
|
|
5
|
+
// separately:
|
|
4
6
|
//
|
|
5
|
-
// 1. **
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// 1. **Is anything in a bad state right now?** From the team's own k6 thresholds, or from
|
|
8
|
+
// Lighthouse's published scoring bands. Never from a budget Tewip invented.
|
|
9
|
+
// 2. **Is it slower than it was?** Only from repeated baselines, and refused below three, because
|
|
10
|
+
// one run against one run is not evidence. Four real k6 runs of unchanged code gave p95 of
|
|
11
|
+
// 60.8-62.8ms; four real Lighthouse runs of an unchanged page gave LCP of 912-936ms. Both move
|
|
12
|
+
// on their own, and a tool that calls that a regression is muted within a week.
|
|
11
13
|
//
|
|
12
|
-
// It never
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// It never predicts a duration, a capacity or a user impact: it cannot see the production hardware,
|
|
15
|
+
// the real data volume or the real traffic, and an invented number for any of those is worse than
|
|
16
|
+
// silence.
|
|
15
17
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
16
18
|
import { dirname, resolve } from 'node:path';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
import { readK6Summary, requestCount } from './k6.js';
|
|
20
|
+
import { isLighthouseFile, readLighthouse } from './lighthouse.js';
|
|
21
|
+
import { fromK6, metricsInOrder, show } from './run.js';
|
|
22
|
+
import { compareMetric, ENOUGH_BASELINES } from './compare.js';
|
|
23
|
+
import { baselineFor, fromSameTool, knownLabels, recordRun, storedRuns } from './history.js';
|
|
24
|
+
/** Enough requests for a percentile to mean anything. Below this, p95 is three data points. */
|
|
25
|
+
export const ENOUGH_REQUESTS = 100;
|
|
26
|
+
export function readPerfFile(path) {
|
|
27
|
+
if (isLighthouseFile(path)) {
|
|
28
|
+
const report = readLighthouse(path);
|
|
29
|
+
return { run: report.run, manual: report.manual, url: report.url };
|
|
30
|
+
}
|
|
31
|
+
const k6 = readK6Summary(path);
|
|
32
|
+
return { run: fromK6(k6, requestCount(k6)), manual: [], url: '' };
|
|
33
|
+
}
|
|
34
|
+
export function renderPerf(run, comparisons, label, baselineCount, recorded, manual = []) {
|
|
22
35
|
const lines = ['# Tewip performance check', ''];
|
|
23
36
|
const breaches = (run.thresholds ?? []).filter((t) => t.breached);
|
|
37
|
+
const poor = Object.entries(run.metrics).filter(([, m]) => m.rating === 'poor');
|
|
24
38
|
const slower = comparisons.filter((c) => c.direction === 'slower');
|
|
25
39
|
const faster = comparisons.filter((c) => c.direction === 'faster');
|
|
26
40
|
const unsure = comparisons.filter((c) => c.direction === 'cannot-say');
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
const tool = run.source === 'k6' ? 'k6' : 'Lighthouse';
|
|
42
|
+
lines.push(`Read a ${tool} run of **${run.volume.n} ${run.volume.kind}${run.volume.n === 1 ? '' : 's'}**, compared against **${baselineCount}** recorded ` +
|
|
43
|
+
`${tool} baseline ${baselineCount === 1 ? 'run' : 'runs'} for \`${label}\`.`, '');
|
|
44
|
+
if (breaches.length > 0)
|
|
30
45
|
lines.push(`**${breaches.length} of your own thresholds ${breaches.length === 1 ? 'was' : 'were'} breached.**`, '');
|
|
31
|
-
|
|
46
|
+
if (poor.length > 0)
|
|
47
|
+
lines.push(`**${poor.length} ${poor.length === 1 ? 'metric is' : 'metrics are'} in ${tool}'s worst band.**`, '');
|
|
32
48
|
if (slower.length > 0)
|
|
33
49
|
lines.push(`**${slower.length} ${slower.length === 1 ? 'metric is' : 'metrics are'} slower than the baseline runs can explain.**`, '');
|
|
34
|
-
if (breaches.length === 0 && slower.length === 0) {
|
|
50
|
+
if (breaches.length === 0 && poor.length === 0 && slower.length === 0) {
|
|
35
51
|
lines.push(run.thresholds === null
|
|
36
52
|
? '**No regression this baseline can detect.** Your thresholds are not in this export, so nothing is said about them.'
|
|
37
|
-
: '**
|
|
53
|
+
: '**Nothing in a bad state, and no regression this baseline can detect.**', '');
|
|
38
54
|
}
|
|
39
|
-
lines.push('##
|
|
40
|
-
if (run.
|
|
41
|
-
|
|
42
|
-
'
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
lines.push('## Is anything in a bad state right now?', '');
|
|
56
|
+
if (run.source === 'k6') {
|
|
57
|
+
if (run.thresholds === null) {
|
|
58
|
+
lines.push('**Cannot say.** This summary came from `--new-machine-readable-summary`, and that export records no threshold ' +
|
|
59
|
+
'results at all - the word appears in it only inside the copy of your script. Tewip will not report "nothing was ' +
|
|
60
|
+
'breached" from a file that does not say. Re-run with plain `--summary-export` to get the answer.', '');
|
|
61
|
+
}
|
|
62
|
+
else if (run.thresholds.length === 0) {
|
|
63
|
+
lines.push('This run has no thresholds. Add them to your k6 script and the team decides what "fast enough" means, rather than Tewip.', '');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
for (const t of run.thresholds)
|
|
67
|
+
lines.push(`- ${t.breached ? '**breached**' : 'held'} — \`${t.metric}\`: \`${t.expression}\``);
|
|
68
|
+
lines.push('');
|
|
69
|
+
}
|
|
47
70
|
}
|
|
48
71
|
else {
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
// The bands are Lighthouse's own, computed by Lighthouse. Tewip is relaying a judgement with a
|
|
73
|
+
// published basis, not applying numbers it chose.
|
|
74
|
+
lines.push("Rated by Lighthouse's own scoring, not by any threshold Tewip invented:", '');
|
|
75
|
+
for (const name of metricsInOrder(run)) {
|
|
76
|
+
const m = run.metrics[name];
|
|
77
|
+
if (m.rating === null)
|
|
78
|
+
continue;
|
|
79
|
+
const mark = m.rating === 'poor' ? '**poor**' : m.rating === 'needs-improvement' ? 'needs improvement' : 'good';
|
|
80
|
+
lines.push(`- ${mark} — \`${name}\`: ${show(m)}`);
|
|
51
81
|
}
|
|
52
82
|
lines.push('');
|
|
53
83
|
}
|
|
54
84
|
lines.push('## Is it slower than it was?', '');
|
|
55
85
|
if (baselineCount < ENOUGH_BASELINES) {
|
|
56
|
-
lines.push(`**Cannot say.** There ${baselineCount === 1 ? 'is' : 'are'} ${baselineCount} recorded ${baselineCount === 1 ? 'run' : 'runs'}
|
|
57
|
-
`and Tewip needs ${ENOUGH_BASELINES} before it will call anything a regression. Performance numbers move
|
|
58
|
-
'runs, and without several of them there is no way to tell a change from the machine having a busy moment. ' +
|
|
59
|
-
|
|
86
|
+
lines.push(`**Cannot say.** There ${baselineCount === 1 ? 'is' : 'are'} ${baselineCount} recorded ${tool} ${baselineCount === 1 ? 'run' : 'runs'} ` +
|
|
87
|
+
`for \`${label}\`, and Tewip needs ${ENOUGH_BASELINES} before it will call anything a regression. Performance numbers move ` +
|
|
88
|
+
'between identical runs, and without several of them there is no way to tell a change from the machine having a busy moment. ' +
|
|
89
|
+
'Record baselines on your main branch with `tewip perf <file> --record`.', '');
|
|
60
90
|
}
|
|
61
91
|
for (const c of [...slower, ...faster, ...unsure]) {
|
|
62
92
|
const verdict = c.direction === 'slower' ? '**slower**' : c.direction === 'faster' ? 'faster' : 'cannot say';
|
|
63
|
-
|
|
64
|
-
|
|
93
|
+
const unit = run.metrics[c.metric]?.unit ?? 'ms';
|
|
94
|
+
const u = (n) => (unit === 'ms' ? `${n}ms` : `${n}`);
|
|
95
|
+
lines.push(`- \`${c.metric}\` ${u(c.now)} — ${verdict}`);
|
|
96
|
+
lines.push(` - baseline ${u(c.baselineLow)}–${u(c.baselineHigh)} over ${c.baselineRuns} runs, middle ${u(c.before)}`);
|
|
65
97
|
lines.push(` - ${c.why}`);
|
|
66
98
|
}
|
|
67
99
|
lines.push('');
|
|
68
|
-
if (
|
|
69
|
-
lines.push(`**${
|
|
100
|
+
if (run.source === 'k6' && run.volume.n < ENOUGH_REQUESTS) {
|
|
101
|
+
lines.push(`**${run.volume.n} requests is thin.** A p95 from this few is a handful of data points and will move on its own. ` +
|
|
70
102
|
'Treat everything above as indicative and lengthen the run before deciding anything.', '');
|
|
71
103
|
}
|
|
72
104
|
lines.push('## What this does not tell you', '');
|
|
73
105
|
lines.push('- **Nothing about production.** This is the hardware the run happened on, with that data volume and that ' +
|
|
74
|
-
'concurrency. Tewip cannot see your real machines, your real data or your real traffic, and will not guess.', '- **No duration is predicted.** How long a real user waits depends on network, device and cache, none of which are here.', '- **Only what the
|
|
106
|
+
'concurrency. Tewip cannot see your real machines, your real data or your real traffic, and will not guess.', '- **No duration is predicted.** How long a real user waits depends on network, device and cache, none of which are here.', '- **Only what the run exercised.** A page or endpoint it never touched is not covered, however green this looks.', '- **Not capacity.** This says whether it changed, not how many users it would hold.');
|
|
107
|
+
if (run.source === 'lighthouse') {
|
|
108
|
+
lines.push(`- **One page load.** k6 reports a percentile over many requests; this is a single load of one page, and a single ` +
|
|
109
|
+
'load is the noisiest measurement either tool offers.');
|
|
110
|
+
if (manual.length > 0) {
|
|
111
|
+
lines.push(`- **${manual.length} accessibility checks Lighthouse refuses to score** and hands to a person - ` +
|
|
112
|
+
`${manual.slice(0, 3).map((m) => `\`${m.id}\``).join(', ')} and others. They are not failures and not passes. ` +
|
|
113
|
+
'Run `tewip access` for the accessibility side.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
75
117
|
if (recorded)
|
|
76
118
|
lines.push(`This run was added to the baseline for \`${label}\`.`, '');
|
|
77
119
|
else
|
|
78
|
-
lines.push(
|
|
120
|
+
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.', '');
|
|
79
121
|
return lines.join('\n');
|
|
80
122
|
}
|
|
81
123
|
export function runPerf(cmd) {
|
|
82
124
|
const root = resolve(cmd.root);
|
|
83
125
|
const label = cmd.label ?? 'default';
|
|
84
|
-
const run =
|
|
85
|
-
const
|
|
86
|
-
const history =
|
|
87
|
-
if (
|
|
126
|
+
const { run, manual } = readPerfFile(resolve(root, cmd.summary));
|
|
127
|
+
const all = storedRuns(root, label);
|
|
128
|
+
const history = fromSameTool(all, run.source);
|
|
129
|
+
if (all.length === 0) {
|
|
88
130
|
const known = knownLabels(root);
|
|
89
|
-
// A typo in --label would otherwise look exactly like "no history yet", and the team would
|
|
90
|
-
// record a second baseline under the wrong name without noticing.
|
|
91
131
|
if (known.length > 0 && !known.includes(label)) {
|
|
92
132
|
console.error(`[tewip] no recorded runs for label "${label}". This project has: ${known.join(', ')}.`);
|
|
93
133
|
}
|
|
94
134
|
}
|
|
95
|
-
const comparisons =
|
|
96
|
-
.map((metric) => compareMetric(metric, run.
|
|
97
|
-
// A metric that is zero now and was zero every time before says nothing
|
|
98
|
-
//
|
|
135
|
+
const comparisons = metricsInOrder(run)
|
|
136
|
+
.map((metric) => compareMetric(metric, run.metrics[metric].value, baselineFor(history, metric)))
|
|
137
|
+
// A metric that is zero now and was zero every time before says nothing, and printing
|
|
138
|
+
// "0ms is within 0-0ms" three times buries the real lines.
|
|
99
139
|
.filter((c) => c.now > 0 || c.baselineHigh > 0);
|
|
100
140
|
const recorded = cmd.record === true;
|
|
101
141
|
if (recorded)
|
|
102
|
-
recordRun(root, label, run,
|
|
142
|
+
recordRun(root, label, run, cmd.gitSha);
|
|
103
143
|
const breached = (run.thresholds ?? []).some((t) => t.breached);
|
|
144
|
+
const poor = Object.values(run.metrics).some((m) => m.rating === 'poor');
|
|
104
145
|
const regressed = comparisons.some((c) => c.direction === 'slower');
|
|
105
|
-
const report = renderPerf(run, comparisons,
|
|
146
|
+
const report = renderPerf(run, comparisons, label, history.length, recorded, manual);
|
|
106
147
|
console.log(report);
|
|
107
|
-
if (cmd.gate && (breached || regressed)) {
|
|
108
|
-
const why = [
|
|
148
|
+
if (cmd.gate && (breached || regressed || poor)) {
|
|
149
|
+
const why = [
|
|
150
|
+
breached ? 'a threshold you wrote was breached' : '',
|
|
151
|
+
poor ? "something is in Lighthouse's worst band" : '',
|
|
152
|
+
regressed ? 'something is slower than the baselines explain' : '',
|
|
153
|
+
]
|
|
109
154
|
.filter(Boolean)
|
|
110
155
|
.join(', and ');
|
|
111
156
|
console.error(`${String.fromCharCode(10)}Performance gate failed: ${why}.`);
|
|
@@ -116,5 +161,5 @@ export function runPerf(cmd) {
|
|
|
116
161
|
mkdirSync(dirname(path), { recursive: true });
|
|
117
162
|
writeFileSync(path, report, 'utf8');
|
|
118
163
|
}
|
|
119
|
-
return { run, comparisons,
|
|
164
|
+
return { run, comparisons, regressed, breached: breached || poor, report };
|
|
120
165
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** k6's percentiles, reduced to the one number a comparison needs: p95. */
|
|
2
|
+
export function fromK6(run, requests) {
|
|
3
|
+
const metrics = {};
|
|
4
|
+
for (const [name, m] of Object.entries(run.timings)) {
|
|
5
|
+
// Tagged duplicates (`http_req_duration{expected_response:true}`) are the same measurement
|
|
6
|
+
// sliced, and comparing both says the same thing twice.
|
|
7
|
+
if (name.includes('{'))
|
|
8
|
+
continue;
|
|
9
|
+
// k6 publishes no bands. It reports numbers and leaves "fast enough" to the team's thresholds,
|
|
10
|
+
// which is the right division and the reason rating is null here.
|
|
11
|
+
metrics[name] = { value: m.p95, unit: 'ms', rating: null };
|
|
12
|
+
}
|
|
13
|
+
return { source: 'k6', at: run.at, metrics, thresholds: run.thresholds, volume: { kind: 'request', n: requests } };
|
|
14
|
+
}
|
|
15
|
+
/** Metrics worth putting in front of a person, most important first. */
|
|
16
|
+
const ORDER = [
|
|
17
|
+
'largest-contentful-paint',
|
|
18
|
+
'cumulative-layout-shift',
|
|
19
|
+
'total-blocking-time',
|
|
20
|
+
'interaction-to-next-paint',
|
|
21
|
+
'first-contentful-paint',
|
|
22
|
+
'speed-index',
|
|
23
|
+
'server-response-time',
|
|
24
|
+
'http_req_duration',
|
|
25
|
+
];
|
|
26
|
+
export function metricsInOrder(run) {
|
|
27
|
+
const names = Object.keys(run.metrics);
|
|
28
|
+
const known = ORDER.filter((m) => names.includes(m));
|
|
29
|
+
const rest = names.filter((m) => !ORDER.includes(m)).sort();
|
|
30
|
+
return [...known, ...rest];
|
|
31
|
+
}
|
|
32
|
+
/** How a metric is written in a report: 2112ms, 0.08, 97. */
|
|
33
|
+
export function show(metric) {
|
|
34
|
+
if (metric.unit === 'ms')
|
|
35
|
+
return `${Math.round(metric.value)}ms`;
|
|
36
|
+
if (metric.unit === 'score')
|
|
37
|
+
return `${Math.round(metric.value * 100)}`;
|
|
38
|
+
return `${Math.round(metric.value * 1000) / 1000}`;
|
|
39
|
+
}
|
package/dist/mcp/tools-prove.js
CHANGED
|
@@ -109,18 +109,18 @@ export function registerProofTools(server, { answer, readOnly, project }) {
|
|
|
109
109
|
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
110
110
|
// Never records: an assistant adding runs to a team's baseline unasked is how a regression
|
|
111
111
|
// silently becomes the new normal. Recording stays a deliberate act on the command line.
|
|
112
|
-
const {
|
|
113
|
-
const {
|
|
114
|
-
const {
|
|
115
|
-
const {
|
|
112
|
+
const { compareMetric } = await import('../engine/perf/compare.js');
|
|
113
|
+
const { baselineFor, fromSameTool, storedRuns } = await import('../engine/perf/history.js');
|
|
114
|
+
const { readPerfFile, renderPerf } = await import('../engine/perf/perf-command.js');
|
|
115
|
+
const { metricsInOrder } = await import('../engine/perf/run.js');
|
|
116
116
|
const { isAbsolute: abs, resolve } = await import('node:path');
|
|
117
|
-
const run =
|
|
117
|
+
const { run, manual } = readPerfFile(abs(summary) ? summary : resolve(root, summary));
|
|
118
118
|
const name = label ?? 'default';
|
|
119
|
-
const history = storedRuns(root, name);
|
|
120
|
-
const comparisons =
|
|
121
|
-
.map((m) => compareMetric(m, run.
|
|
119
|
+
const history = fromSameTool(storedRuns(root, name), run.source);
|
|
120
|
+
const comparisons = metricsInOrder(run)
|
|
121
|
+
.map((m) => compareMetric(m, run.metrics[m].value, baselineFor(history, m)))
|
|
122
122
|
.filter((c) => c.now > 0 || c.baselineHigh > 0);
|
|
123
|
-
return renderPerf(run, comparisons,
|
|
123
|
+
return renderPerf(run, comparisons, name, history.length, false, manual);
|
|
124
124
|
}));
|
|
125
125
|
server.registerTool('accessibility_barriers', {
|
|
126
126
|
title: 'Can everybody use this interface?',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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": [
|