webmcp-gauge 0.1.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/LICENSE +21 -0
- package/README.md +121 -0
- package/action.yml +162 -0
- package/bin/webmcp-gauge.mjs +544 -0
- package/bin/webmcp-gauge.test.mjs +354 -0
- package/browser/launch.mjs +188 -0
- package/browser/serve.mjs +78 -0
- package/browser/session.mjs +210 -0
- package/browser/webmcp.mjs +432 -0
- package/browser/webmcp.test.mjs +299 -0
- package/core/args.mjs +93 -0
- package/core/args.test.mjs +85 -0
- package/core/capture-seam.test.mjs +86 -0
- package/core/cohort.mjs +432 -0
- package/core/cohort.test.mjs +370 -0
- package/core/gallery.mjs +145 -0
- package/core/gallery.test.mjs +128 -0
- package/core/gate.mjs +164 -0
- package/core/gate.test.mjs +213 -0
- package/core/lint.mjs +381 -0
- package/core/lint.test.mjs +346 -0
- package/core/orchestrate.mjs +128 -0
- package/core/orchestrate.test.mjs +191 -0
- package/core/stats.mjs +172 -0
- package/core/stats.test.mjs +156 -0
- package/core/sweep.mjs +274 -0
- package/core/sweep.test.mjs +162 -0
- package/core/taxonomy.mjs +175 -0
- package/core/taxonomy.test.mjs +198 -0
- package/core/trial.mjs +248 -0
- package/core/visibility.mjs +163 -0
- package/core/visibility.test.mjs +164 -0
- package/docs/concept.md +468 -0
- package/docs/explainer.md +161 -0
- package/docs/getting-started.md +331 -0
- package/fixtures/README.md +42 -0
- package/fixtures/airlock.utterances.json +284 -0
- package/fixtures/broken/compose.mjs +52 -0
- package/fixtures/broken/compose.test.mjs +270 -0
- package/fixtures/broken/sample-expenses.csv +966 -0
- package/fixtures/broken/tools.json +1311 -0
- package/fixtures/broken/twin.html +482 -0
- package/fixtures/broken/widget.html +62 -0
- package/fixtures/gallery/gallery.html +56 -0
- package/judges/openai-compatible.mjs +145 -0
- package/package.json +53 -0
- package/report/badge.mjs +110 -0
- package/report/badge.test.mjs +97 -0
- package/report/emit.mjs +282 -0
- package/report/published-runs.test.mjs +77 -0
- package/report/scorecard.mjs +157 -0
- package/report/scorecard.test.mjs +130 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { DEFAULT_OPTIONS, lintManifest, lintToText, RULES, similarity } from './lint.mjs';
|
|
5
|
+
|
|
6
|
+
const toolsFile = JSON.parse(
|
|
7
|
+
await readFile(new URL('../fixtures/broken/tools.json', import.meta.url), 'utf8')
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/** The linter reads a manifest, so a variant from the fixture file is one directly. */
|
|
11
|
+
const manifestOf = (variant) => ({
|
|
12
|
+
present: true,
|
|
13
|
+
settled: true,
|
|
14
|
+
settledAtMs: 1000,
|
|
15
|
+
tools: toolsFile.variants[variant].map(({ name, description, inputSchema, annotations }) => ({
|
|
16
|
+
name,
|
|
17
|
+
description,
|
|
18
|
+
inputSchema,
|
|
19
|
+
annotations,
|
|
20
|
+
})),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const rulesFired = (result) => new Set(result.findings.map((finding) => finding.rule));
|
|
24
|
+
|
|
25
|
+
test('the reference-quality manifest lints clean, which is what calibrates the defaults', () => {
|
|
26
|
+
const result = lintManifest({ manifest: manifestOf('clean') });
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
result.findings,
|
|
30
|
+
[],
|
|
31
|
+
`a default that flags the page measured at 100% is a broken default. Fired: ${JSON.stringify(result.findings.map((f) => `${f.rule}:${f.tool}`))}`
|
|
32
|
+
);
|
|
33
|
+
assert.equal(result.counts.error, 0);
|
|
34
|
+
assert.equal(result.counts.warning, 0);
|
|
35
|
+
assert.equal(result.manifest.toolCount, 7);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('the degraded manifest trips every documented failure family except budget', () => {
|
|
39
|
+
const result = lintManifest({ manifest: manifestOf('degraded') });
|
|
40
|
+
|
|
41
|
+
// Budget needs a tool count the sweep page deliberately does not have; it is
|
|
42
|
+
// reached with ?flood=N and tested separately below.
|
|
43
|
+
assert.deepEqual(result.families.sort(), ['description', 'name', 'schema']);
|
|
44
|
+
assert.ok(result.counts.error > 0, 'a page this bad must produce errors, not only advice');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('each defect registered in the fixture fires the rule it claims to', () => {
|
|
48
|
+
const fired = rulesFired(lintManifest({ manifest: manifestOf('degraded') }));
|
|
49
|
+
|
|
50
|
+
// fixtures/broken/tools.json records, per injected defect, which rules it should
|
|
51
|
+
// trip. That register is the fixture's contract with the linter: if a defect
|
|
52
|
+
// stops firing its rule, one of the two changed silently.
|
|
53
|
+
const claimed = new Set(
|
|
54
|
+
toolsFile.defects.flatMap((defect) => defect.rules).filter((rule) => !rule.startsWith('budget/'))
|
|
55
|
+
);
|
|
56
|
+
assert.ok(claimed.size >= 6, 'the fixture should claim a spread of rules, not one');
|
|
57
|
+
|
|
58
|
+
for (const rule of claimed) {
|
|
59
|
+
assert.ok(fired.has(rule), `fixture claims ${rule} but the linter did not report it`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('an invalid name is reported with the character that makes it invalid', () => {
|
|
64
|
+
const result = lintManifest({
|
|
65
|
+
manifest: { present: true, settled: true, tools: [{ name: 'Clear Highlights', description: 'x'.repeat(80), inputSchema: { type: 'object', properties: {} } }] },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const finding = result.findings.find((entry) => entry.rule === 'name/invalid-characters');
|
|
69
|
+
assert.ok(finding, 'a name with a space must be flagged');
|
|
70
|
+
assert.match(finding.detail, /a space/);
|
|
71
|
+
assert.equal(finding.severity, 'error');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('two tools with one name are reported, because only one of them is reachable', () => {
|
|
75
|
+
const tool = {
|
|
76
|
+
name: 'filter_rows',
|
|
77
|
+
description: 'A'.repeat(80),
|
|
78
|
+
inputSchema: { type: 'object', properties: {} },
|
|
79
|
+
};
|
|
80
|
+
const result = lintManifest({ manifest: { present: true, settled: true, tools: [tool, { ...tool, description: 'B'.repeat(80) }] } });
|
|
81
|
+
|
|
82
|
+
assert.equal(result.findings.filter((entry) => entry.rule === 'name/duplicate').length, 1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('identical descriptions are an error and near-identical ones a warning', () => {
|
|
86
|
+
const identical = lintManifest({
|
|
87
|
+
manifest: {
|
|
88
|
+
present: true,
|
|
89
|
+
settled: true,
|
|
90
|
+
tools: [
|
|
91
|
+
{ name: 'a_tool', description: 'Works with the rows in the table and returns totals for what it finds.', inputSchema: { type: 'object', properties: {} } },
|
|
92
|
+
{ name: 'b_tool', description: 'Works with the rows in the table and returns totals for what it finds.', inputSchema: { type: 'object', properties: {} } },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const duplicate = identical.findings.find((entry) => entry.rule === 'description/duplicate');
|
|
97
|
+
assert.ok(duplicate);
|
|
98
|
+
assert.equal(duplicate.severity, 'error');
|
|
99
|
+
assert.equal(duplicate.tool, 'a_tool + b_tool');
|
|
100
|
+
|
|
101
|
+
const near = lintManifest({
|
|
102
|
+
manifest: {
|
|
103
|
+
present: true,
|
|
104
|
+
settled: true,
|
|
105
|
+
tools: [
|
|
106
|
+
{ name: 'a_tool', description: 'Works with the rows in the table and returns totals for what it finds.', inputSchema: { type: 'object', properties: {} } },
|
|
107
|
+
{ name: 'b_tool', description: 'Works with the rows in the table and returns counts for what it finds.', inputSchema: { type: 'object', properties: {} } },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
const nearFinding = near.findings.find((entry) => entry.rule === 'description/near-duplicate');
|
|
112
|
+
assert.ok(nearFinding, 'one changed word must not make two descriptions distinguishable');
|
|
113
|
+
assert.equal(nearFinding.severity, 'warning');
|
|
114
|
+
assert.equal(near.findings.some((entry) => entry.rule === 'description/duplicate'), false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('two genuinely different descriptions of the same length are left alone', () => {
|
|
118
|
+
const result = lintManifest({
|
|
119
|
+
manifest: {
|
|
120
|
+
present: true,
|
|
121
|
+
settled: true,
|
|
122
|
+
tools: [
|
|
123
|
+
{ name: 'a_tool', description: 'Total the loaded spending per calendar month, oldest first, so month-on-month changes are visible.', inputSchema: { type: 'object', properties: {} } },
|
|
124
|
+
{ name: 'b_tool', description: 'Find rows that are unusually large compared with the rest of their own category, using a z-score.', inputSchema: { type: 'object', properties: {} } },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(result.findings, []);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `description/indistinguishable-pair`, adopted 2026-09-05 after five measured arms
|
|
134
|
+
* showed a 45-point loss that no description-similarity threshold catches. These
|
|
135
|
+
* tests pin the two halves of the rule and, more importantly, the cases it must
|
|
136
|
+
* stay silent on — each of which is a manifest with a measured rate at or near the
|
|
137
|
+
* ceiling, so a false positive here would be a rule that flags working pages.
|
|
138
|
+
*/
|
|
139
|
+
const pairPresent = (result) =>
|
|
140
|
+
result.findings.some((entry) => entry.rule === 'description/indistinguishable-pair');
|
|
141
|
+
|
|
142
|
+
const twoTools = (aName, aDescription, bName, bDescription, options) =>
|
|
143
|
+
lintManifest({
|
|
144
|
+
manifest: {
|
|
145
|
+
present: true,
|
|
146
|
+
settled: true,
|
|
147
|
+
tools: [
|
|
148
|
+
{ name: aName, description: aDescription, inputSchema: { type: 'object', properties: {} } },
|
|
149
|
+
{ name: bName, description: bDescription, inputSchema: { type: 'object', properties: {} } },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
options,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const VAGUE = 'Works with the rows in the table and returns totals for what it finds.';
|
|
156
|
+
const ALSO_VAGUE = 'Handles the listed entries and hands back combined figures for whatever turns up.';
|
|
157
|
+
const INFORMATIVE = 'Total the loaded spending by category, largest first, and return aggregates only.';
|
|
158
|
+
|
|
159
|
+
test('close names plus two descriptions that never say which tool is which is a warning', () => {
|
|
160
|
+
const result = twoTools('sum_by_category', VAGUE, 'summarise_by_category', ALSO_VAGUE);
|
|
161
|
+
const finding = result.findings.find((entry) => entry.rule === 'description/indistinguishable-pair');
|
|
162
|
+
|
|
163
|
+
assert.ok(finding, 'this is the configuration measured at 48–58% against a 95% baseline');
|
|
164
|
+
assert.equal(finding.severity, 'warning');
|
|
165
|
+
assert.equal(finding.tool, 'sum_by_category + summarise_by_category');
|
|
166
|
+
assert.equal(finding.evidence.nameSimilarity, 0.5);
|
|
167
|
+
// It must not need the two descriptions to resemble each other: these two share
|
|
168
|
+
// almost nothing, which is exactly the case the similarity rules cannot see.
|
|
169
|
+
assert.ok(similarity(VAGUE, ALSO_VAGUE) < DEFAULT_OPTIONS.nearDuplicateThreshold);
|
|
170
|
+
assert.equal(result.findings.some((entry) => entry.rule === 'description/near-duplicate'), false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('one description that says what its own tool is for is enough to stay silent', () => {
|
|
174
|
+
// Measured: `ablate-competitor-vague` — a vague competitor beside a well-described
|
|
175
|
+
// tool — cost nothing at all (95.0%, identical to clean failure for failure).
|
|
176
|
+
assert.equal(pairPresent(twoTools('sum_by_category', INFORMATIVE, 'summarise_by_category', VAGUE)), false);
|
|
177
|
+
assert.equal(pairPresent(twoTools('sum_by_category', VAGUE, 'summarise_by_category', INFORMATIVE)), false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('two vague descriptions on unrelated names are not this rule\'s business', () => {
|
|
181
|
+
// Measured: `ablate-desc-degraded` — vague descriptions with no close-named
|
|
182
|
+
// sibling — cost 1.7 points, inside the harness's own noise.
|
|
183
|
+
assert.equal(pairPresent(twoTools('sum_by_category', VAGUE, 'clear_highlights', ALSO_VAGUE)), false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('the name-similarity threshold is an option, and the rule follows it', () => {
|
|
187
|
+
const names = ['fetch_orders', VAGUE, 'orders_report', ALSO_VAGUE];
|
|
188
|
+
assert.equal(pairPresent(twoTools(...names)), false, 'these names share one token of three');
|
|
189
|
+
assert.equal(pairPresent(twoTools(...names, { nameSimilarityThreshold: 0.3 })), true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('the rule reports presence, not cost — it cannot know what a pair will cost', () => {
|
|
193
|
+
// The five measured rungs span 48.3% to 83.3% and the rule fires identically on
|
|
194
|
+
// all of them. That is deliberate: severity is a property of the rule, and only
|
|
195
|
+
// `run` measures what a defect costs.
|
|
196
|
+
const identical = twoTools('sum_by_category', VAGUE, 'summarise_by_category', VAGUE);
|
|
197
|
+
const distant = twoTools('sum_by_category', VAGUE, 'summarise_by_category', ALSO_VAGUE);
|
|
198
|
+
const pairOf = (result) => result.findings.find((entry) => entry.rule === 'description/indistinguishable-pair');
|
|
199
|
+
|
|
200
|
+
assert.equal(pairOf(identical).severity, pairOf(distant).severity);
|
|
201
|
+
// The byte-identical case additionally trips the error-graded duplicate rule, so
|
|
202
|
+
// the two rules stack rather than replace one another.
|
|
203
|
+
assert.equal(identical.findings.some((entry) => entry.rule === 'description/duplicate'), true);
|
|
204
|
+
assert.equal(distant.findings.some((entry) => entry.rule === 'description/duplicate'), false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('a required property with no description is an error, an optional one a warning', () => {
|
|
208
|
+
const result = lintManifest({
|
|
209
|
+
manifest: {
|
|
210
|
+
present: true,
|
|
211
|
+
settled: true,
|
|
212
|
+
tools: [
|
|
213
|
+
{
|
|
214
|
+
name: 'top_expenses',
|
|
215
|
+
description: 'D'.repeat(80),
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
required: ['mode'],
|
|
219
|
+
properties: { mode: { type: 'string' }, locale: { type: 'string' } },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const required = result.findings.find((entry) => entry.rule === 'schema/required-without-description');
|
|
227
|
+
const optional = result.findings.find((entry) => entry.rule === 'schema/undocumented-property');
|
|
228
|
+
assert.equal(required.severity, 'error');
|
|
229
|
+
assert.match(required.detail, /must guess/);
|
|
230
|
+
assert.match(optional.detail, /'locale'/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a required property that is not declared at all is reported', () => {
|
|
234
|
+
const result = lintManifest({
|
|
235
|
+
manifest: {
|
|
236
|
+
present: true,
|
|
237
|
+
settled: true,
|
|
238
|
+
tools: [{ name: 'a_tool', description: 'D'.repeat(80), inputSchema: { type: 'object', required: ['ghost'], properties: {} } }],
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
assert.match(
|
|
243
|
+
result.findings.find((entry) => entry.rule === 'schema/required-without-description').detail,
|
|
244
|
+
/not declared in properties/
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('a non-object schema stops schema analysis for that tool rather than throwing', () => {
|
|
249
|
+
const result = lintManifest({
|
|
250
|
+
manifest: {
|
|
251
|
+
present: true,
|
|
252
|
+
settled: true,
|
|
253
|
+
tools: [{ name: 'top.expenses.v2', description: 'D'.repeat(80), inputSchema: { type: 'string' } }],
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
assert.equal(result.findings.filter((entry) => entry.rule.startsWith('schema/')).length, 1);
|
|
258
|
+
assert.equal(result.findings.find((entry) => entry.rule === 'schema/not-object').severity, 'error');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('the property-count threshold sits exactly where the reference page does', () => {
|
|
262
|
+
const propertiesOf = (count) =>
|
|
263
|
+
Object.fromEntries(
|
|
264
|
+
Array.from({ length: count }, (_, index) => [`p${index}`, { type: 'string', description: 'documented' }])
|
|
265
|
+
);
|
|
266
|
+
const at = lintManifest({
|
|
267
|
+
manifest: { present: true, settled: true, tools: [{ name: 'a_tool', description: 'D'.repeat(80), inputSchema: { type: 'object', properties: propertiesOf(DEFAULT_OPTIONS.maxProperties) } }] },
|
|
268
|
+
});
|
|
269
|
+
const over = lintManifest({
|
|
270
|
+
manifest: { present: true, settled: true, tools: [{ name: 'a_tool', description: 'D'.repeat(80), inputSchema: { type: 'object', properties: propertiesOf(DEFAULT_OPTIONS.maxProperties + 1) } }] },
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
assert.deepEqual(at.findings, [], 'six properties is what the reference page ships and it invokes at 99.2%');
|
|
274
|
+
assert.equal(over.findings.filter((entry) => entry.rule === 'schema/over-parameterised').length, 1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('budget headroom warns well below the count that has been reported to break a page, and stays a warning at it', () => {
|
|
278
|
+
const manifestWith = (count) => ({
|
|
279
|
+
present: true,
|
|
280
|
+
settled: true,
|
|
281
|
+
tools: Array.from({ length: count }, (_, index) => ({
|
|
282
|
+
name: `filler_tool_${index}`,
|
|
283
|
+
description: 'A filler tool registered only to occupy a slot in this manifest.',
|
|
284
|
+
inputSchema: { type: 'object', properties: {} },
|
|
285
|
+
})),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
assert.equal(
|
|
289
|
+
lintManifest({ manifest: manifestWith(DEFAULT_OPTIONS.budgetWarnAt - 1) }).findings.some((entry) => entry.rule === 'budget/headroom'),
|
|
290
|
+
false
|
|
291
|
+
);
|
|
292
|
+
const warned = lintManifest({ manifest: manifestWith(DEFAULT_OPTIONS.budgetWarnAt) }).findings.find((entry) => entry.rule === 'budget/headroom');
|
|
293
|
+
assert.equal(warned.severity, 'warning');
|
|
294
|
+
assert.match(warned.detail, /headroom here is unknown rather than fine/);
|
|
295
|
+
|
|
296
|
+
// Chrome 152 registered and surfaced all 507 tools of a flooded fixture (measured
|
|
297
|
+
// 2026-08-31), so the 296 field report is an unknown rather than a ceiling. An
|
|
298
|
+
// error here would be the false positive the calibrated defaults exist to prevent.
|
|
299
|
+
const atReported = lintManifest({ manifest: manifestWith(DEFAULT_OPTIONS.budgetBreakAt) }).findings.find((entry) => entry.rule === 'budget/headroom');
|
|
300
|
+
assert.equal(atReported.severity, 'warning');
|
|
301
|
+
assert.match(atReported.detail, /does not reproduce on Chrome 152/);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('thresholds are options, so a project can set its own and still be reported', () => {
|
|
305
|
+
const manifest = {
|
|
306
|
+
present: true,
|
|
307
|
+
settled: true,
|
|
308
|
+
tools: [{ name: 'a_tool', description: 'Short one.', inputSchema: { type: 'object', properties: {} } }],
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
assert.equal(lintManifest({ manifest }).findings.some((entry) => entry.rule === 'description/thin'), true);
|
|
312
|
+
assert.equal(
|
|
313
|
+
lintManifest({ manifest, options: { minDescriptionChars: 5 } }).findings.some((entry) => entry.rule === 'description/thin'),
|
|
314
|
+
false
|
|
315
|
+
);
|
|
316
|
+
assert.equal(lintManifest({ manifest, options: { minDescriptionChars: 5 } }).thresholds.minDescriptionChars, 5);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('similarity is symmetric, 1 for identical text and 0 against nothing', () => {
|
|
320
|
+
assert.equal(similarity('a b c', 'c b a'), 1);
|
|
321
|
+
assert.equal(similarity('a b c', ''), 0);
|
|
322
|
+
assert.equal(similarity('one two', 'two three'), similarity('two three', 'one two'));
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('every rule in the registry has a severity the emitter can render', () => {
|
|
326
|
+
for (const rule of RULES) {
|
|
327
|
+
assert.ok(['error', 'warning'].includes(rule.severity), `${rule.id} has severity ${rule.severity}`);
|
|
328
|
+
assert.ok(rule.id.includes('/'), `${rule.id} is not family-prefixed`);
|
|
329
|
+
}
|
|
330
|
+
assert.equal(new Set(RULES.map((rule) => rule.id)).size, RULES.length);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('the text report never lets a clean lint read as a measured invocation rate', () => {
|
|
334
|
+
const text = lintToText(lintManifest({ manifest: manifestOf('clean') }), { subject: 'clean' });
|
|
335
|
+
|
|
336
|
+
assert.match(text, /No findings/);
|
|
337
|
+
assert.match(text, /A clean lint is not a measured invocation rate/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('the text report leads with errors and names the thresholds it used', () => {
|
|
341
|
+
const text = lintToText(lintManifest({ manifest: manifestOf('degraded') }), { subject: 'degraded' });
|
|
342
|
+
const firstFindingLine = text.split('\n').find((line) => line.startsWith('ERROR') || line.startsWith('WARN'));
|
|
343
|
+
|
|
344
|
+
assert.ok(firstFindingLine.startsWith('ERROR'), `errors must sort first, got ${firstFindingLine}`);
|
|
345
|
+
assert.match(text, /Calibrated on the reference page, not taken from the spec/);
|
|
346
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs S measurement sessions, each in its own OS process.
|
|
3
|
+
*
|
|
4
|
+
* The child process is not ceremony. Node pools HTTP connections per process, so
|
|
5
|
+
* sessions sharing a process share the judge's keep-alive connections, and a
|
|
6
|
+
* "session" that reuses the same socket to the same provider is not independent in
|
|
7
|
+
* the way a reproducibility claim needs. One process per session also means one
|
|
8
|
+
* browser, one cold profile, and crash isolation: a session that dies takes only
|
|
9
|
+
* its own trials with it, and the checkpoint keeps the rest.
|
|
10
|
+
*
|
|
11
|
+
* What this still does not isolate, and the report says so: the machine, the
|
|
12
|
+
* network path, the provider's own server-side state, and time - three sessions
|
|
13
|
+
* minutes apart are not three sessions on three days.
|
|
14
|
+
*
|
|
15
|
+
* A session is watched rather than merely awaited. A child that stops making
|
|
16
|
+
* progress and never exits stalled a whole run silently once, and the layer that
|
|
17
|
+
* notices has to be the one that can kill it. The watchdog is measured in *progress*
|
|
18
|
+
* rather than elapsed time: an honest session duration depends on how many trials it
|
|
19
|
+
* was given, while "wrote nothing for ten minutes" means the same thing for a
|
|
20
|
+
* 20-trial session and a 480-trial one.
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { stat } from 'node:fs/promises';
|
|
24
|
+
import { killTree } from '../browser/launch.mjs';
|
|
25
|
+
|
|
26
|
+
/** Newest mtime across the files a session appends to as it works. */
|
|
27
|
+
const lastProgressAt = async (paths) => {
|
|
28
|
+
let newest = 0;
|
|
29
|
+
for (const path of paths) {
|
|
30
|
+
try {
|
|
31
|
+
const info = await stat(path);
|
|
32
|
+
newest = Math.max(newest, info.mtimeMs);
|
|
33
|
+
} catch {
|
|
34
|
+
// A file that does not exist yet is not progress, and not an error either.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return newest;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const runSessions = async ({
|
|
41
|
+
sessions = 3,
|
|
42
|
+
binPath,
|
|
43
|
+
args,
|
|
44
|
+
gapSeconds = 0,
|
|
45
|
+
/** Files a working session appends to: the checkpoint and the failure log. */
|
|
46
|
+
progressPaths = [],
|
|
47
|
+
/**
|
|
48
|
+
* How long a session may write nothing before it counts as stalled. The per-trial
|
|
49
|
+
* deadline inside a session is 180s, so ten minutes of silence means the stall is
|
|
50
|
+
* somewhere a trial deadline cannot see - launching a browser, or the process.
|
|
51
|
+
*/
|
|
52
|
+
stallTimeoutMs = 600000,
|
|
53
|
+
pollIntervalMs = 15000,
|
|
54
|
+
onSessionStart = () => {},
|
|
55
|
+
onSessionEnd = () => {},
|
|
56
|
+
}) => {
|
|
57
|
+
const results = [];
|
|
58
|
+
|
|
59
|
+
for (let session = 1; session <= sessions; session += 1) {
|
|
60
|
+
if (session > 1 && gapSeconds > 0) {
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, gapSeconds * 1000));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onSessionStart({ session, sessions });
|
|
65
|
+
const startedMs = Date.now();
|
|
66
|
+
|
|
67
|
+
const result = await new Promise((resolve) => {
|
|
68
|
+
const child = spawn(
|
|
69
|
+
process.execPath,
|
|
70
|
+
[binPath, 'session', '--session', String(session), ...args],
|
|
71
|
+
{ stdio: ['ignore', 'inherit', 'inherit'] }
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
let settled = false;
|
|
75
|
+
const finish = (value) => {
|
|
76
|
+
if (settled) return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearInterval(watchdog);
|
|
79
|
+
resolve(value);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let lastSeenAt = Date.now();
|
|
83
|
+
let lastProgress = 0;
|
|
84
|
+
// Set before the kill, because killing the child makes 'close' fire while the
|
|
85
|
+
// watchdog is still awaiting taskkill, and the first finish() wins. Without
|
|
86
|
+
// this flag a session we killed reports as an ordinary non-zero exit, which is
|
|
87
|
+
// the one distinction the watchdog exists to make.
|
|
88
|
+
let stallReason = null;
|
|
89
|
+
|
|
90
|
+
const watchdog = setInterval(async () => {
|
|
91
|
+
if (settled || stallReason) return;
|
|
92
|
+
|
|
93
|
+
const progressAt = await lastProgressAt(progressPaths);
|
|
94
|
+
if (progressAt > lastProgress) {
|
|
95
|
+
lastProgress = progressAt;
|
|
96
|
+
lastSeenAt = Date.now();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (Date.now() - lastSeenAt < stallTimeoutMs) return;
|
|
100
|
+
|
|
101
|
+
// Killed rather than waited on, and reported as a stall rather than as a
|
|
102
|
+
// crash: the parent recomputes coverage from the plan, so whatever this
|
|
103
|
+
// session did not reach comes back as missing and --resume retries it.
|
|
104
|
+
stallReason = `wrote nothing for ${Math.round((Date.now() - lastSeenAt) / 1000)}s and was killed`;
|
|
105
|
+
if (child.pid) await killTree(child.pid);
|
|
106
|
+
finish({ session, code: null, stalled: true, error: stallReason });
|
|
107
|
+
}, pollIntervalMs);
|
|
108
|
+
watchdog.unref?.();
|
|
109
|
+
|
|
110
|
+
child.on('error', (error) =>
|
|
111
|
+
finish({ session, code: null, stalled: false, error: String(error.message ?? error) })
|
|
112
|
+
);
|
|
113
|
+
child.on('close', (code) =>
|
|
114
|
+
finish(
|
|
115
|
+
stallReason
|
|
116
|
+
? // The exit code of a process we killed describes the kill, not the run.
|
|
117
|
+
{ session, code: null, stalled: true, error: stallReason }
|
|
118
|
+
: { session, code, stalled: false, error: null }
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
results.push({ ...result, elapsedMs: Date.now() - startedMs });
|
|
124
|
+
onSessionEnd({ session, sessions, ...result });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return results;
|
|
128
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The orchestrator's job in a failure is to notice and to kill. These tests use
|
|
3
|
+
* throwaway "session" scripts instead of the real one, because what is being tested
|
|
4
|
+
* is the watchdog contract - progress resets it, silence trips it, and a stall is
|
|
5
|
+
* reported as a stall rather than as a crash - not anything about browsers or judges.
|
|
6
|
+
*/
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { runSessions } from './orchestrate.mjs';
|
|
13
|
+
|
|
14
|
+
const withTempDir = async (body) => {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), 'webmcp-gauge-orch-'));
|
|
16
|
+
try {
|
|
17
|
+
return await body(dir);
|
|
18
|
+
} finally {
|
|
19
|
+
await rm(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const fakeSession = async (dir, name, source) => {
|
|
24
|
+
const path = join(dir, name);
|
|
25
|
+
await writeFile(path, source, 'utf8');
|
|
26
|
+
return path;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test('a session that exits is reported by its exit code, with no stall', async () => {
|
|
30
|
+
await withTempDir(async (dir) => {
|
|
31
|
+
const binPath = await fakeSession(dir, 'ok-session.mjs', 'process.exitCode = 0;\n');
|
|
32
|
+
|
|
33
|
+
const results = await runSessions({
|
|
34
|
+
sessions: 2,
|
|
35
|
+
binPath,
|
|
36
|
+
args: [],
|
|
37
|
+
progressPaths: [join(dir, 'sweep.jsonl')],
|
|
38
|
+
stallTimeoutMs: 60000,
|
|
39
|
+
pollIntervalMs: 50,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
results.map(({ session, code, stalled, error }) => ({ session, code, stalled, error })),
|
|
44
|
+
[
|
|
45
|
+
{ session: 1, code: 0, stalled: false, error: null },
|
|
46
|
+
{ session: 2, code: 0, stalled: false, error: null },
|
|
47
|
+
]
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a non-zero exit is an exit, not a stall', async () => {
|
|
53
|
+
await withTempDir(async (dir) => {
|
|
54
|
+
// This is what an incomplete session looks like under the exit-code contract:
|
|
55
|
+
// code 2, which the parent must not confuse with a process it had to kill.
|
|
56
|
+
const binPath = await fakeSession(dir, 'incomplete-session.mjs', 'process.exitCode = 2;\n');
|
|
57
|
+
|
|
58
|
+
const [result] = await runSessions({
|
|
59
|
+
sessions: 1,
|
|
60
|
+
binPath,
|
|
61
|
+
args: [],
|
|
62
|
+
progressPaths: [join(dir, 'sweep.jsonl')],
|
|
63
|
+
stallTimeoutMs: 60000,
|
|
64
|
+
pollIntervalMs: 50,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.equal(result.code, 2);
|
|
68
|
+
assert.equal(result.stalled, false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a session that writes nothing is killed and reported as stalled', async () => {
|
|
73
|
+
await withTempDir(async (dir) => {
|
|
74
|
+
const binPath = await fakeSession(
|
|
75
|
+
dir,
|
|
76
|
+
'hanging-session.mjs',
|
|
77
|
+
'setInterval(() => {}, 1000);\n'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const startedMs = Date.now();
|
|
81
|
+
const [result] = await runSessions({
|
|
82
|
+
sessions: 1,
|
|
83
|
+
binPath,
|
|
84
|
+
args: [],
|
|
85
|
+
progressPaths: [join(dir, 'sweep.jsonl')],
|
|
86
|
+
stallTimeoutMs: 400,
|
|
87
|
+
pollIntervalMs: 100,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.equal(result.stalled, true, 'a process that never exits must be killed, not awaited');
|
|
91
|
+
assert.equal(result.code, null);
|
|
92
|
+
assert.match(result.error, /wrote nothing for \d+s and was killed/);
|
|
93
|
+
assert.ok(
|
|
94
|
+
Date.now() - startedMs < 30000,
|
|
95
|
+
'the whole point is that this returns in seconds rather than never'
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('progress resets the watchdog, so a slow session is not mistaken for a stalled one', async () => {
|
|
101
|
+
await withTempDir(async (dir) => {
|
|
102
|
+
const checkpoint = join(dir, 'sweep.jsonl');
|
|
103
|
+
const binPath = await fakeSession(
|
|
104
|
+
dir,
|
|
105
|
+
'slow-session.mjs',
|
|
106
|
+
`import { appendFile } from 'node:fs/promises';
|
|
107
|
+
let written = 0;
|
|
108
|
+
const tick = setInterval(async () => {
|
|
109
|
+
written += 1;
|
|
110
|
+
await appendFile(${JSON.stringify(checkpoint)}, JSON.stringify({ trial: written }) + '\\n', 'utf8');
|
|
111
|
+
if (written >= 8) {
|
|
112
|
+
clearInterval(tick);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
}, 100);
|
|
116
|
+
`
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const [result] = await runSessions({
|
|
120
|
+
sessions: 1,
|
|
121
|
+
binPath,
|
|
122
|
+
args: [],
|
|
123
|
+
progressPaths: [checkpoint],
|
|
124
|
+
// Shorter than the session's total runtime, longer than the gap between writes:
|
|
125
|
+
// the difference between "taking a while" and "not working" is progress.
|
|
126
|
+
stallTimeoutMs: 350,
|
|
127
|
+
pollIntervalMs: 50,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.equal(result.stalled, false, `killed a session that was making progress: ${result.error}`);
|
|
131
|
+
assert.equal(result.code, 0);
|
|
132
|
+
assert.equal((await readFile(checkpoint, 'utf8')).trim().split('\n').length, 8);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('--gap actually spaces the sessions out, which is the only way drift can be measured', async () => {
|
|
137
|
+
await withTempDir(async (dir) => {
|
|
138
|
+
const binPath = await fakeSession(dir, 'quick-session.mjs', 'process.exitCode = 0;\n');
|
|
139
|
+
|
|
140
|
+
const shape = { binPath, args: [], progressPaths: [join(dir, 'sweep.jsonl')], pollIntervalMs: 50 };
|
|
141
|
+
const startedBackToBack = Date.now();
|
|
142
|
+
await runSessions({ sessions: 3, ...shape });
|
|
143
|
+
const backToBackMs = Date.now() - startedBackToBack;
|
|
144
|
+
|
|
145
|
+
const startedSpaced = Date.now();
|
|
146
|
+
await runSessions({ sessions: 3, gapSeconds: 0.4, ...shape });
|
|
147
|
+
const spacedMs = Date.now() - startedSpaced;
|
|
148
|
+
|
|
149
|
+
// Two gaps for three sessions - the wait goes before every session except the
|
|
150
|
+
// first, or the run would end with a pointless sleep.
|
|
151
|
+
assert.ok(
|
|
152
|
+
spacedMs - backToBackMs >= 700,
|
|
153
|
+
`expected roughly two 400ms gaps, got ${spacedMs}ms against ${backToBackMs}ms`
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('the failure log counts as progress, so an all-failing session is not killed', async () => {
|
|
159
|
+
await withTempDir(async (dir) => {
|
|
160
|
+
// A session whose every trial fails writes only to the failure log. Watching the
|
|
161
|
+
// checkpoint alone would kill exactly the run that has the most to report.
|
|
162
|
+
const failures = join(dir, 'harness-failures.jsonl');
|
|
163
|
+
const binPath = await fakeSession(
|
|
164
|
+
dir,
|
|
165
|
+
'failing-session.mjs',
|
|
166
|
+
`import { appendFile } from 'node:fs/promises';
|
|
167
|
+
let n = 0;
|
|
168
|
+
const tick = setInterval(async () => {
|
|
169
|
+
n += 1;
|
|
170
|
+
await appendFile(${JSON.stringify(failures)}, JSON.stringify({ kind: 'judge_unavailable', n }) + '\\n', 'utf8');
|
|
171
|
+
if (n >= 6) {
|
|
172
|
+
clearInterval(tick);
|
|
173
|
+
process.exit(2);
|
|
174
|
+
}
|
|
175
|
+
}, 100);
|
|
176
|
+
`
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const [result] = await runSessions({
|
|
180
|
+
sessions: 1,
|
|
181
|
+
binPath,
|
|
182
|
+
args: [],
|
|
183
|
+
progressPaths: [join(dir, 'sweep.jsonl'), failures],
|
|
184
|
+
stallTimeoutMs: 350,
|
|
185
|
+
pollIntervalMs: 50,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
assert.equal(result.stalled, false, `killed an all-failing session: ${result.error}`);
|
|
189
|
+
assert.equal(result.code, 2);
|
|
190
|
+
});
|
|
191
|
+
});
|