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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judge adapter for any OpenAI-compatible chat-completions endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Invocation rate is a property of (page, client, judge model), so the model id
|
|
5
|
+
* and endpoint are returned with every answer and belong in every report. The
|
|
6
|
+
* adapter deliberately does no retrying and no repair of malformed output: a
|
|
7
|
+
* judge that cannot follow the response contract is a measurement result, not
|
|
8
|
+
* an error to paper over.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 60000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reasoning models spend completion tokens on thinking before they emit anything,
|
|
15
|
+
* and the budget covers both. At 1024, glm-5.3 spent 1021 tokens reasoning about
|
|
16
|
+
* one utterance and returned an empty string with finish_reason "length" - which
|
|
17
|
+
* looked exactly like a judge declining to pick a tool. A truncated judge is a
|
|
18
|
+
* harness misconfiguration, so the ceiling is set where truncation is unlikely and
|
|
19
|
+
* the caller is told when it happens anyway. Unused headroom costs nothing.
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* agentrouter.org rejects requests that do not look like a CLI client, with
|
|
25
|
+
* `401 unauthorized client detected`, even when the key is valid.
|
|
26
|
+
*/
|
|
27
|
+
const HOST_HEADERS = {
|
|
28
|
+
'agentrouter.org': { 'User-Agent': 'claude-cli/1.0.80 (external, cli)', 'x-app': 'cli' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const hostHeadersFor = (baseUrl) => {
|
|
32
|
+
const { hostname } = new URL(baseUrl);
|
|
33
|
+
return HOST_HEADERS[hostname] ?? {};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Key selection has to be deterministic and reportable. Qwen Code exports one
|
|
38
|
+
* `QWEN_CUSTOM_API_KEY_*` variable per configured provider, and picking whichever
|
|
39
|
+
* one enumerates first silently grabbed an Anthropic-scoped key for an OpenAI
|
|
40
|
+
* endpoint on the first run - it happened to work, which is worse than failing.
|
|
41
|
+
* Candidates are therefore ranked: explicit variable, then the project's own
|
|
42
|
+
* variable, then session variables whose name matches this endpoint's host,
|
|
43
|
+
* preferring the OpenAI-scoped one, in sorted order.
|
|
44
|
+
*/
|
|
45
|
+
const hostTokens = (baseUrl) => {
|
|
46
|
+
const { hostname } = new URL(baseUrl);
|
|
47
|
+
return hostname.toUpperCase().split('.').filter((part) => part.length > 2 && part !== 'WWW');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const resolveApiKey = (env = process.env, explicitVar, baseUrl) => {
|
|
51
|
+
if (explicitVar) {
|
|
52
|
+
const value = env[explicitVar];
|
|
53
|
+
if (!value) throw new Error(`Judge API key variable ${explicitVar} is set to nothing`);
|
|
54
|
+
return { key: value, source: explicitVar };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (env.WEBMCP_GAUGE_JUDGE_API_KEY) {
|
|
58
|
+
return { key: env.WEBMCP_GAUGE_JUDGE_API_KEY, source: 'WEBMCP_GAUGE_JUDGE_API_KEY' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sessionVars = Object.keys(env)
|
|
62
|
+
.filter((name) => name.startsWith('QWEN_CUSTOM_API_KEY_') && env[name])
|
|
63
|
+
.sort();
|
|
64
|
+
const tokens = baseUrl ? hostTokens(baseUrl) : [];
|
|
65
|
+
const hostMatches = sessionVars.filter((name) => tokens.every((token) => name.includes(token)));
|
|
66
|
+
const ranked = [
|
|
67
|
+
...hostMatches.filter((name) => name.includes('OPENAI')),
|
|
68
|
+
...hostMatches.filter((name) => !name.includes('OPENAI')),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
if (ranked.length > 0) return { key: env[ranked[0]], source: ranked[0] };
|
|
72
|
+
|
|
73
|
+
throw new Error(
|
|
74
|
+
`No judge API key found for ${baseUrl ?? 'the configured endpoint'}. Set WEBMCP_GAUGE_JUDGE_API_KEY (see .env.example).` +
|
|
75
|
+
(sessionVars.length > 0 ? ` Session variables present but not host-matched: ${sessionVars.join(', ')}` : '')
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const createJudge = ({
|
|
80
|
+
baseUrl,
|
|
81
|
+
model,
|
|
82
|
+
apiKeyVar,
|
|
83
|
+
env = process.env,
|
|
84
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
85
|
+
temperature = 0,
|
|
86
|
+
}) => {
|
|
87
|
+
if (!baseUrl || !model) throw new Error('createJudge requires baseUrl and model');
|
|
88
|
+
const { key, source } = resolveApiKey(env, apiKeyVar, baseUrl);
|
|
89
|
+
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
id: model,
|
|
93
|
+
baseUrl,
|
|
94
|
+
keySource: source,
|
|
95
|
+
|
|
96
|
+
async complete({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
97
|
+
const body = {
|
|
98
|
+
model,
|
|
99
|
+
temperature,
|
|
100
|
+
max_tokens: maxTokens,
|
|
101
|
+
messages: [
|
|
102
|
+
...(system ? [{ role: 'system', content: system }] : []),
|
|
103
|
+
{ role: 'user', content: user },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const startedAt = Date.now();
|
|
108
|
+
const response = await fetch(endpoint, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
Authorization: `Bearer ${key}`,
|
|
113
|
+
...hostHeadersFor(baseUrl),
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify(body),
|
|
116
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const text = await response.text();
|
|
120
|
+
const elapsedMs = Date.now() - startedAt;
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
// The body carries the useful part - a whitelist rejection reads very
|
|
124
|
+
// differently from an exhausted quota - so it is surfaced, not swallowed.
|
|
125
|
+
throw new Error(`judge HTTP ${response.status}: ${text.slice(0, 500)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const payload = JSON.parse(text);
|
|
129
|
+
const message = payload.choices?.[0]?.message ?? {};
|
|
130
|
+
const finishReason = payload.choices?.[0]?.finish_reason ?? null;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
content: (message.content ?? '').trim(),
|
|
134
|
+
raw: text,
|
|
135
|
+
model: payload.model ?? model,
|
|
136
|
+
finishReason,
|
|
137
|
+
// Truncation is the caller's problem to classify, not something to hide:
|
|
138
|
+
// an empty answer cut off mid-thought is not a judge declining to answer.
|
|
139
|
+
truncated: finishReason === 'length',
|
|
140
|
+
usage: payload.usage ?? null,
|
|
141
|
+
elapsedMs,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webmcp-gauge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Measures whether an AI agent actually calls the tools a page exposes through WebMCP.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"webmcp-gauge": "bin/webmcp-gauge.mjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24.0.0"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"core",
|
|
16
|
+
"browser",
|
|
17
|
+
"judges",
|
|
18
|
+
"report",
|
|
19
|
+
"fixtures/airlock.utterances.json",
|
|
20
|
+
"fixtures/gallery",
|
|
21
|
+
"fixtures/broken",
|
|
22
|
+
"fixtures/README.md",
|
|
23
|
+
"docs",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"action.yml"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Svishwa2004/webmcp-gauge.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/Svishwa2004/webmcp-gauge/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/Svishwa2004/webmcp-gauge#readme",
|
|
36
|
+
"keywords": [
|
|
37
|
+
"webmcp",
|
|
38
|
+
"mcp",
|
|
39
|
+
"model-context-protocol",
|
|
40
|
+
"ai-agents",
|
|
41
|
+
"linter",
|
|
42
|
+
"measurement",
|
|
43
|
+
"browser-automation",
|
|
44
|
+
"invocation-rate"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"gauge": "node bin/webmcp-gauge.mjs",
|
|
48
|
+
"test": "node --test"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"chrome-remote-interface": "0.33.3"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/report/badge.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The badge a CI run leaves behind.
|
|
3
|
+
*
|
|
4
|
+
* A badge is a bare number in a coloured pill, which is exactly the thing this
|
|
5
|
+
* project refuses to publish. That tension is not resolved by making the badge
|
|
6
|
+
* prettier; it is resolved by making the badge unable to overstate:
|
|
7
|
+
*
|
|
8
|
+
* - a run with unmeasured trials shows **incomplete**, never a rate, because a
|
|
9
|
+
* rate over a denominator the run did not choose is the wrong number however
|
|
10
|
+
* it is coloured (this mirrors the exit-code contract, where incomplete
|
|
11
|
+
* outranks a breach);
|
|
12
|
+
* - the message carries `n`, so a 100% from twenty trials cannot be mistaken
|
|
13
|
+
* for a 100% from a thousand;
|
|
14
|
+
* - colour is a threshold, not a grade, and the thresholds are stated here
|
|
15
|
+
* rather than tuned to flatter a particular subject.
|
|
16
|
+
*
|
|
17
|
+
* The output is the Shields "endpoint" schema, which any README can point at
|
|
18
|
+
* without this project hosting anything, plus a self-contained SVG for repos
|
|
19
|
+
* that would rather not call out to a third party at page load.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Thresholds are deliberately coarse: a badge is a smoke alarm, not a gauge. */
|
|
23
|
+
export const BADGE_THRESHOLDS = [
|
|
24
|
+
{ atLeast: 0.95, color: 'brightgreen' },
|
|
25
|
+
{ atLeast: 0.85, color: 'green' },
|
|
26
|
+
{ atLeast: 0.7, color: 'yellowgreen' },
|
|
27
|
+
{ atLeast: 0.5, color: 'yellow' },
|
|
28
|
+
{ atLeast: 0.0, color: 'red' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const colorFor = (rate) => BADGE_THRESHOLDS.find((band) => rate >= band.atLeast)?.color ?? 'lightgrey';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reads only what a finished report already carries, so a badge can never
|
|
35
|
+
* disagree with the report beside it.
|
|
36
|
+
*/
|
|
37
|
+
export const buildBadge = (report, { label = 'webmcp invocation' } = {}) => {
|
|
38
|
+
const overall = report?.invocation?.overall ?? null;
|
|
39
|
+
|
|
40
|
+
if (!overall || overall.trials === 0) {
|
|
41
|
+
return { schemaVersion: 1, label, message: 'no trials', color: 'lightgrey', isError: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A schema-2 report has no `coverage` block, and `report/emit.mjs` is explicit
|
|
45
|
+
// that its absence means **unknown**, not complete — that is why the schema
|
|
46
|
+
// version moved. Reading a missing block as zero missing trials would publish a
|
|
47
|
+
// rate for a run whose completeness nobody established, which is the exact
|
|
48
|
+
// overstatement this file exists to prevent.
|
|
49
|
+
if (!report.coverage) {
|
|
50
|
+
return { schemaVersion: 1, label, message: 'coverage unknown', color: 'lightgrey', isError: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const missing = report.coverage.missingTrials ?? 0;
|
|
54
|
+
if (missing > 0) {
|
|
55
|
+
const expected = report.coverage.expectedTrials ?? overall.trials + missing;
|
|
56
|
+
return {
|
|
57
|
+
schemaVersion: 1,
|
|
58
|
+
label,
|
|
59
|
+
// Named for what it is. "incomplete" is not a bad score, it is the absence
|
|
60
|
+
// of a score, and a reader must not be able to read it as one.
|
|
61
|
+
message: `incomplete (${expected - missing}/${expected})`,
|
|
62
|
+
color: 'lightgrey',
|
|
63
|
+
isError: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rate = overall.ok / overall.trials;
|
|
68
|
+
return {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
label,
|
|
71
|
+
message: `${(rate * 100).toFixed(0)}% (n=${overall.trials})`,
|
|
72
|
+
color: colorFor(rate),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const PALETTE = {
|
|
77
|
+
brightgreen: '#4c1',
|
|
78
|
+
green: '#97ca00',
|
|
79
|
+
yellowgreen: '#a4a61d',
|
|
80
|
+
yellow: '#dfb317',
|
|
81
|
+
red: '#e05d44',
|
|
82
|
+
lightgrey: '#9f9f9f',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Rough advance width for the 11px DejaVu-ish face Shields uses. */
|
|
86
|
+
const textWidth = (text) => Math.round([...text].reduce((sum, ch) => sum + (/[iljI.,:'|]/.test(ch) ? 3 : /[mwMW%]/.test(ch) ? 9 : 6.4), 0)) + 10;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A self-contained SVG, so a README can commit the badge instead of fetching it.
|
|
90
|
+
* Deliberately plain: no gradients, no external font reference, nothing that
|
|
91
|
+
* needs a network request to render.
|
|
92
|
+
*/
|
|
93
|
+
export const renderBadgeSvg = (badge) => {
|
|
94
|
+
const labelWidth = textWidth(badge.label);
|
|
95
|
+
const messageWidth = textWidth(badge.message);
|
|
96
|
+
const total = labelWidth + messageWidth;
|
|
97
|
+
const fill = PALETTE[badge.color] ?? PALETTE.lightgrey;
|
|
98
|
+
const escape = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
99
|
+
|
|
100
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${escape(badge.label)}: ${escape(badge.message)}">
|
|
101
|
+
<title>${escape(badge.label)}: ${escape(badge.message)}</title>
|
|
102
|
+
<rect width="${labelWidth}" height="20" fill="#555"/>
|
|
103
|
+
<rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${fill}"/>
|
|
104
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
105
|
+
<text x="${labelWidth / 2}" y="14">${escape(badge.label)}</text>
|
|
106
|
+
<text x="${labelWidth + messageWidth / 2}" y="14">${escape(badge.message)}</text>
|
|
107
|
+
</g>
|
|
108
|
+
</svg>
|
|
109
|
+
`;
|
|
110
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { buildBadge, renderBadgeSvg, BADGE_THRESHOLDS } from './badge.mjs';
|
|
5
|
+
|
|
6
|
+
const report = ({ ok, trials, missing = 0, expected = trials }) => ({
|
|
7
|
+
invocation: { overall: { ok, trials } },
|
|
8
|
+
coverage: missing > 0 ? { missingTrials: missing, expectedTrials: expected } : { missingTrials: 0, expectedTrials: expected },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('a complete run shows the rate and the trial count, so 20 cannot pass for 1000', () => {
|
|
12
|
+
const badge = buildBadge(report({ ok: 399, trials: 480 }));
|
|
13
|
+
assert.equal(badge.message, '83% (n=480)');
|
|
14
|
+
assert.equal(badge.schemaVersion, 1);
|
|
15
|
+
assert.ok(!badge.isError);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The rule this file exists for. A rate over a denominator the run did not choose
|
|
20
|
+
* is the wrong number however it is coloured, and the exit-code contract already
|
|
21
|
+
* says incomplete outranks everything else.
|
|
22
|
+
*/
|
|
23
|
+
test('a run with unmeasured trials shows incomplete, never a rate', () => {
|
|
24
|
+
const badge = buildBadge(report({ ok: 100, trials: 100, missing: 60, expected: 160 }));
|
|
25
|
+
assert.equal(badge.message, 'incomplete (100/160)');
|
|
26
|
+
assert.equal(badge.color, 'lightgrey');
|
|
27
|
+
assert.equal(badge.isError, true);
|
|
28
|
+
assert.ok(!/%/.test(badge.message), 'a percentage must not appear on an incomplete run');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a perfect but incomplete run still says incomplete, not 100%', () => {
|
|
32
|
+
const badge = buildBadge(report({ ok: 20, trials: 20, missing: 140, expected: 160 }));
|
|
33
|
+
assert.match(badge.message, /^incomplete/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('no trials at all is an error state rather than a zero', () => {
|
|
37
|
+
const badge = buildBadge(report({ ok: 0, trials: 0 }));
|
|
38
|
+
assert.equal(badge.message, 'no trials');
|
|
39
|
+
assert.equal(badge.isError, true);
|
|
40
|
+
const missingReport = buildBadge({});
|
|
41
|
+
assert.equal(missingReport.message, 'no trials');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Caught by generating a badge from a real published report: the 960-trial
|
|
46
|
+
* reference run predates schema 3 and carries no `coverage` block at all, and
|
|
47
|
+
* `report/emit.mjs` says plainly that its absence means unknown rather than
|
|
48
|
+
* complete. The first version of this file read it as zero missing trials and
|
|
49
|
+
* happily published "99% (n=840)" for a run whose completeness nobody had
|
|
50
|
+
* established.
|
|
51
|
+
*/
|
|
52
|
+
test('a schema-2 report with no coverage block says unknown, not a rate', () => {
|
|
53
|
+
const badge = buildBadge({ invocation: { overall: { ok: 831, trials: 840 } } });
|
|
54
|
+
assert.equal(badge.message, 'coverage unknown');
|
|
55
|
+
assert.equal(badge.color, 'lightgrey');
|
|
56
|
+
assert.equal(badge.isError, true);
|
|
57
|
+
assert.ok(!/%/.test(badge.message));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('zero of many is red and says so, which is different from having no trials', () => {
|
|
61
|
+
const badge = buildBadge(report({ ok: 0, trials: 60 }));
|
|
62
|
+
assert.equal(badge.message, '0% (n=60)');
|
|
63
|
+
assert.equal(badge.color, 'red');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('colour bands are ordered and cover the whole range', () => {
|
|
67
|
+
const rates = BADGE_THRESHOLDS.map((band) => band.atLeast);
|
|
68
|
+
assert.deepEqual(rates, [...rates].sort((a, b) => b - a), 'bands must be descending or the first match is wrong');
|
|
69
|
+
assert.equal(rates.at(-1), 0, 'the last band must catch every remaining rate');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the band boundaries are inclusive, so exactly 95% is the better colour', () => {
|
|
73
|
+
assert.equal(buildBadge(report({ ok: 95, trials: 100 })).color, 'brightgreen');
|
|
74
|
+
assert.equal(buildBadge(report({ ok: 94, trials: 100 })).color, 'green');
|
|
75
|
+
assert.equal(buildBadge(report({ ok: 85, trials: 100 })).color, 'green');
|
|
76
|
+
assert.equal(buildBadge(report({ ok: 60, trials: 100 })).color, 'yellow');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the label can be overridden, because a repo may measure more than one page', () => {
|
|
80
|
+
const badge = buildBadge(report({ ok: 1, trials: 1 }), { label: 'airlock' });
|
|
81
|
+
assert.equal(badge.label, 'airlock');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the SVG is self-contained: no external font, no network reference', () => {
|
|
85
|
+
const svg = renderBadgeSvg(buildBadge(report({ ok: 399, trials: 480 })));
|
|
86
|
+
assert.match(svg, /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
|
|
87
|
+
assert.ok(!/<image|xlink:href|@import|https?:\/\/(?!www\.w3\.org)/.test(svg), 'nothing may be fetched to render this');
|
|
88
|
+
assert.match(svg, /83% \(n=480\)/);
|
|
89
|
+
assert.match(svg, /role="img"/);
|
|
90
|
+
assert.match(svg, /<title>/, 'a badge without a title is unreadable to a screen reader');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('angle brackets and ampersands in a label cannot break the SVG', () => {
|
|
94
|
+
const svg = renderBadgeSvg(buildBadge(report({ ok: 1, trials: 1 }), { label: 'a & b <c>' }));
|
|
95
|
+
assert.match(svg, /a & b <c>/);
|
|
96
|
+
assert.ok(!/<c>/.test(svg));
|
|
97
|
+
});
|
package/report/emit.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report emitters.
|
|
3
|
+
*
|
|
4
|
+
* A report that does not name its judge, its browser build and its utterance-set
|
|
5
|
+
* version is not a measurement, because invocation rate is a property of
|
|
6
|
+
* (page, client, judge, utterances) rather than of the page alone. Every stamp
|
|
7
|
+
* therefore travels with the numbers, and the Markdown is generated from the same
|
|
8
|
+
* object as the JSON so the two cannot drift.
|
|
9
|
+
*
|
|
10
|
+
* Two spreads are reported and never merged: between-session sigma, which compares
|
|
11
|
+
* whole sessions that shared nothing but the machine, and within-session sigma,
|
|
12
|
+
* which compares repeats that shared a browser and a warm cache. The first two
|
|
13
|
+
* sweeps of this project reported the second and described it as the first.
|
|
14
|
+
*
|
|
15
|
+
* Schema 3 adds `coverage` and `gate`: whether the run measured what it planned to,
|
|
16
|
+
* and the verdict a CI job exits on. In schema 2 reports the absence of `coverage`
|
|
17
|
+
* means unknown rather than complete, which is why the version moved.
|
|
18
|
+
*/
|
|
19
|
+
import { rollUpControls, rollUpTool } from '../core/stats.mjs';
|
|
20
|
+
|
|
21
|
+
const percent = (value) =>
|
|
22
|
+
value === null || value === undefined ? '—' : `${(value * 100).toFixed(1)}%`;
|
|
23
|
+
|
|
24
|
+
const sigma = (value) => (typeof value === 'number' ? value.toFixed(3) : '—');
|
|
25
|
+
|
|
26
|
+
const interval = (wilsonResult) =>
|
|
27
|
+
!wilsonResult || wilsonResult.rate === null
|
|
28
|
+
? '—'
|
|
29
|
+
: `${percent(wilsonResult.rate)} [${percent(wilsonResult.low)}, ${percent(wilsonResult.high)}]`;
|
|
30
|
+
|
|
31
|
+
export const buildReport = ({
|
|
32
|
+
fixture,
|
|
33
|
+
records,
|
|
34
|
+
harnessFailures = [],
|
|
35
|
+
recoveredFailures = 0,
|
|
36
|
+
coverage = null,
|
|
37
|
+
judge,
|
|
38
|
+
settings,
|
|
39
|
+
sessionResults = [],
|
|
40
|
+
timing,
|
|
41
|
+
}) => {
|
|
42
|
+
const toolRecords = records.filter((record) => record.kind === 'tool');
|
|
43
|
+
const controlRecords = records.filter((record) => record.kind === 'control');
|
|
44
|
+
|
|
45
|
+
const toolNames = [...new Set(toolRecords.map((record) => record.expectedTool))];
|
|
46
|
+
const tools = toolNames.map((tool) =>
|
|
47
|
+
rollUpTool({ tool, records: toolRecords.filter((record) => record.expectedTool === tool) })
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const byTag = {};
|
|
51
|
+
for (const record of toolRecords) {
|
|
52
|
+
const tag = record.tag ?? 'untagged';
|
|
53
|
+
byTag[tag] ??= { trials: 0, ok: 0 };
|
|
54
|
+
byTag[tag].trials += 1;
|
|
55
|
+
if (record.outcome === 'ok') byTag[tag].ok += 1;
|
|
56
|
+
}
|
|
57
|
+
for (const tag of Object.keys(byTag)) {
|
|
58
|
+
byTag[tag].rate = byTag[tag].ok / byTag[tag].trials;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sessionMetas = [];
|
|
62
|
+
for (const record of records) {
|
|
63
|
+
const session = record.session ?? 1;
|
|
64
|
+
if (sessionMetas.some((entry) => entry.session === session)) continue;
|
|
65
|
+
sessionMetas.push({ session, ...(record.sessionMeta ?? {}) });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
schema: 'webmcp-gauge/report/3',
|
|
70
|
+
generatedAt: new Date().toISOString(),
|
|
71
|
+
/**
|
|
72
|
+
* The subject label is explicit because the utterance set and the page under
|
|
73
|
+
* test are separable: the frozen Airlock set also drives a deliberately
|
|
74
|
+
* mis-described local twin, and a report that inherited the fixture's name
|
|
75
|
+
* would claim to have measured Airlock.
|
|
76
|
+
*/
|
|
77
|
+
subject: {
|
|
78
|
+
url: settings.url,
|
|
79
|
+
name: settings.subjectName ?? fixture.subject?.name ?? null,
|
|
80
|
+
servedFrom: settings.servedFrom ?? null,
|
|
81
|
+
},
|
|
82
|
+
stamps: {
|
|
83
|
+
utteranceSet: {
|
|
84
|
+
version: fixture.version,
|
|
85
|
+
frozen: fixture.frozen === true,
|
|
86
|
+
authoringModel: fixture.authoring?.modelId ?? null,
|
|
87
|
+
},
|
|
88
|
+
judge,
|
|
89
|
+
harness: {
|
|
90
|
+
sessions: settings.sessions,
|
|
91
|
+
repeatsPerSession: settings.repeatsPerSession,
|
|
92
|
+
concurrency: settings.concurrency,
|
|
93
|
+
gapSeconds: settings.gapSeconds ?? 0,
|
|
94
|
+
isolatedSessions: settings.isolatedSessions !== false,
|
|
95
|
+
},
|
|
96
|
+
browsers: sessionMetas,
|
|
97
|
+
},
|
|
98
|
+
/**
|
|
99
|
+
* What a session does not isolate. Stated in the artifact rather than in a
|
|
100
|
+
* commit message, because someone reading the number a month from now is the
|
|
101
|
+
* person who needs it.
|
|
102
|
+
*/
|
|
103
|
+
isolationCaveats: [
|
|
104
|
+
'Sessions share the machine, the OS network stack and the route to the provider.',
|
|
105
|
+
'Provider-side state (routing, caches, rate-limit counters, model version behind a slug) is not controlled.',
|
|
106
|
+
settings.gapSeconds > 0
|
|
107
|
+
? `Sessions were spaced ${settings.gapSeconds}s apart, which is not the same as spanning days.`
|
|
108
|
+
: 'Sessions ran back to back, so this measures process and browser independence, not day-to-day drift.',
|
|
109
|
+
settings.isolatedSessions === false
|
|
110
|
+
? 'This run attached to a pre-existing browser (--port), so sessions shared a browser process and page cache: between-session sigma here is not isolated.'
|
|
111
|
+
: 'Each session ran in its own OS process with its own browser and a cold profile, so no HTTP connection pool, renderer or page cache was shared.',
|
|
112
|
+
],
|
|
113
|
+
invocation: {
|
|
114
|
+
overall: {
|
|
115
|
+
trials: toolRecords.length,
|
|
116
|
+
ok: toolRecords.filter((record) => record.outcome === 'ok').length,
|
|
117
|
+
},
|
|
118
|
+
perTool: tools,
|
|
119
|
+
perTag: byTag,
|
|
120
|
+
},
|
|
121
|
+
controls: controlRecords.length > 0 ? rollUpControls({ records: controlRecords }) : null,
|
|
122
|
+
/**
|
|
123
|
+
* Whether the run measured what it planned to measure, derived from the plan
|
|
124
|
+
* against the checkpoint rather than from the failure log. A log only knows
|
|
125
|
+
* about trials that failed loudly: a session killed mid-plan leaves no entry
|
|
126
|
+
* and would otherwise report as complete.
|
|
127
|
+
*/
|
|
128
|
+
coverage,
|
|
129
|
+
harnessFailures,
|
|
130
|
+
recoveredFailures,
|
|
131
|
+
sessionResults,
|
|
132
|
+
timing,
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const toMarkdown = (report) => {
|
|
137
|
+
const lines = [];
|
|
138
|
+
const { harness } = report.stamps;
|
|
139
|
+
|
|
140
|
+
lines.push(`# webmcp-gauge — ${report.subject.name ?? report.subject.url}`);
|
|
141
|
+
lines.push('');
|
|
142
|
+
if (report.subject.servedFrom) {
|
|
143
|
+
lines.push(
|
|
144
|
+
`Subject: \`${report.subject.url}\`, served locally from \`${report.subject.servedFrom}\` — a fixture page in this repository, not a deployed site.`
|
|
145
|
+
);
|
|
146
|
+
lines.push('');
|
|
147
|
+
}
|
|
148
|
+
lines.push(
|
|
149
|
+
`Utterance set \`${report.stamps.utteranceSet.version}\`${report.stamps.utteranceSet.frozen ? ' (frozen)' : ' (DRAFT — numbers are not comparable)'} · judge \`${report.stamps.judge.model}\` · ${harness.sessions} session${harness.sessions === 1 ? '' : 's'} × ${harness.repeatsPerSession} repeat${harness.repeatsPerSession === 1 ? '' : 's'} · concurrency ${harness.concurrency}${harness.isolatedSessions ? '' : ' · **shared browser, sessions not isolated**'}`
|
|
150
|
+
);
|
|
151
|
+
lines.push('');
|
|
152
|
+
lines.push(
|
|
153
|
+
`Authored by \`${report.stamps.utteranceSet.authoringModel ?? 'unrecorded'}\`, which is disqualified as a judge for these numbers.`
|
|
154
|
+
);
|
|
155
|
+
lines.push('');
|
|
156
|
+
|
|
157
|
+
if (report.gate) {
|
|
158
|
+
lines.push(`**Gate:** ${report.gate.summary} _(exit ${report.gate.code})_`);
|
|
159
|
+
lines.push('');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
lines.push('## Invocation rate');
|
|
163
|
+
lines.push('');
|
|
164
|
+
lines.push('| Tool | Rate (95% Wilson) | σ between sessions | σ within session | Trials | Outcomes |');
|
|
165
|
+
lines.push('|---|---|---|---|---|---|');
|
|
166
|
+
for (const tool of [...report.invocation.perTool].sort(
|
|
167
|
+
(a, b) => (b.invocation.rate ?? 0) - (a.invocation.rate ?? 0)
|
|
168
|
+
)) {
|
|
169
|
+
const outcomes = Object.entries(tool.outcomes)
|
|
170
|
+
.sort((a, b) => b[1] - a[1])
|
|
171
|
+
.map(([outcome, count]) => `${outcome} ${count}`)
|
|
172
|
+
.join(', ');
|
|
173
|
+
lines.push(
|
|
174
|
+
`| \`${tool.tool}\` | ${interval(tool.invocation)} | ${sigma(tool.betweenSession.sigma)} | ${sigma(tool.withinSession.sigma)} | ${tool.trials} | ${outcomes} |`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
lines.push('');
|
|
178
|
+
lines.push(
|
|
179
|
+
'**σ between sessions** compares whole sessions, each with its own process, browser and cold profile — the only figure that speaks to reproducibility. **σ within session** compares repeats that shared a warm page and one provider connection, so it is a floor. Where the within-session column reads `—`, only one repeat per session was run.'
|
|
180
|
+
);
|
|
181
|
+
lines.push('');
|
|
182
|
+
|
|
183
|
+
lines.push('## By phrasing difficulty');
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push('| Tag | Rate | Trials |');
|
|
186
|
+
lines.push('|---|---|---|');
|
|
187
|
+
for (const tag of ['plain', 'paraphrase', 'oblique']) {
|
|
188
|
+
const entry = report.invocation.perTag[tag];
|
|
189
|
+
if (!entry) continue;
|
|
190
|
+
lines.push(`| ${tag} | ${percent(entry.rate)} | ${entry.trials} |`);
|
|
191
|
+
}
|
|
192
|
+
lines.push('');
|
|
193
|
+
|
|
194
|
+
if (report.controls) {
|
|
195
|
+
lines.push('## Control false positives');
|
|
196
|
+
lines.push('');
|
|
197
|
+
lines.push(
|
|
198
|
+
'A control utterance is one no registered tool can serve, so **not selecting anything is the pass**. This rate is never pooled with invocation rate.'
|
|
199
|
+
);
|
|
200
|
+
lines.push('');
|
|
201
|
+
lines.push(
|
|
202
|
+
`False positive rate: **${interval(report.controls.falsePositiveRate)}** over ${report.controls.trials} trials · σ between sessions ${sigma(report.controls.betweenSession.sigma)}.`
|
|
203
|
+
);
|
|
204
|
+
lines.push('');
|
|
205
|
+
lines.push('| Control class | False positives | Rate |');
|
|
206
|
+
lines.push('|---|---|---|');
|
|
207
|
+
for (const [tag, entry] of Object.entries(report.controls.byClass)) {
|
|
208
|
+
lines.push(`| ${tag} | ${entry.falsePositives}/${entry.trials} | ${interval(entry.rate)} |`);
|
|
209
|
+
}
|
|
210
|
+
lines.push('');
|
|
211
|
+
if (report.controls.injectionFailures.length > 0) {
|
|
212
|
+
lines.push(
|
|
213
|
+
`⚠️ **Injection-class false positives — a safety finding, not a scoring miss:** ${report.controls.injectionFailures
|
|
214
|
+
.map((failure) => `${failure.id} (session ${failure.session}) → \`${failure.selected}\``)
|
|
215
|
+
.join(', ')}`
|
|
216
|
+
);
|
|
217
|
+
lines.push('');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
lines.push('## What a session does not isolate');
|
|
222
|
+
lines.push('');
|
|
223
|
+
for (const caveat of report.isolationCaveats) lines.push(`- ${caveat}`);
|
|
224
|
+
lines.push('');
|
|
225
|
+
|
|
226
|
+
if (report.stamps.browsers.length > 0) {
|
|
227
|
+
lines.push('| Session | Browser | Headless | Profile |');
|
|
228
|
+
lines.push('|---|---|---|---|');
|
|
229
|
+
for (const entry of report.stamps.browsers) {
|
|
230
|
+
lines.push(
|
|
231
|
+
`| ${entry.session} | ${entry.build ?? 'attached'} | ${entry.headless === undefined ? '—' : String(entry.headless)} | ${entry.profileDir ?? entry.note ?? '—'} |`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
lines.push('');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (
|
|
238
|
+
report.harnessFailures.length > 0 ||
|
|
239
|
+
report.recoveredFailures > 0 ||
|
|
240
|
+
(report.coverage?.missingTrials ?? 0) > 0
|
|
241
|
+
) {
|
|
242
|
+
lines.push('## Harness failures');
|
|
243
|
+
lines.push('');
|
|
244
|
+
lines.push(
|
|
245
|
+
'Trials that produced no measurement at all — an unreachable or truncated judge, or a page that never loaded, says nothing about the page under test. These are excluded from every rate above rather than counted as outcomes, and `--resume` retries them.'
|
|
246
|
+
);
|
|
247
|
+
lines.push('');
|
|
248
|
+
if (report.recoveredFailures > 0) {
|
|
249
|
+
lines.push(
|
|
250
|
+
`**${report.recoveredFailures} earlier failure${report.recoveredFailures === 1 ? '' : 's'} recovered by \`--resume\`** and are counted in the rates above.`
|
|
251
|
+
);
|
|
252
|
+
lines.push('');
|
|
253
|
+
}
|
|
254
|
+
if ((report.coverage?.missingTrials ?? 0) > 0) {
|
|
255
|
+
lines.push(
|
|
256
|
+
`**${report.coverage.missingTrials} of ${report.coverage.expectedTrials} planned trials have no measurement**, so every rate above is over a denominator this run did not choose. Re-run with \`--resume\`.`
|
|
257
|
+
);
|
|
258
|
+
if (report.coverage.missing?.length > 0) {
|
|
259
|
+
lines.push('');
|
|
260
|
+
lines.push(
|
|
261
|
+
`Missing (session:repeat:utterance, first ${report.coverage.missing.length}): ${report.coverage.missing.map((key) => `\`${key}\``).join(', ')}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
lines.push('');
|
|
265
|
+
} else if (report.harnessFailures.length === 0) {
|
|
266
|
+
lines.push('No outstanding gaps: every planned trial has a measurement.');
|
|
267
|
+
lines.push('');
|
|
268
|
+
}
|
|
269
|
+
for (const failure of report.harnessFailures) {
|
|
270
|
+
lines.push(
|
|
271
|
+
`- \`${failure.utteranceId}\` (session ${failure.session ?? '?'}, repeat ${failure.repeat}, ${failure.kind ?? 'unknown'}): ${failure.error}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
lines.push('');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
lines.push(
|
|
278
|
+
`_${report.invocation.overall.ok}/${report.invocation.overall.trials} tool trials returned \`ok\`${report.coverage ? ` · ${report.coverage.expectedTrials - report.coverage.missingTrials}/${report.coverage.expectedTrials} planned trials measured` : ''}. Generated ${report.generatedAt} in ${(report.timing.elapsedMs / 1000).toFixed(0)}s._`
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return `${lines.join('\n')}\n`;
|
|
282
|
+
};
|