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,482 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>webmcp-gauge fixture — spending table</title>
|
|
7
|
+
<!--
|
|
8
|
+
The degraded twin of the reference page.
|
|
9
|
+
|
|
10
|
+
One implementation, one dataset, two manifests. ?variant=degraded (the
|
|
11
|
+
default) registers deliberately bad tool metadata; ?variant=clean registers
|
|
12
|
+
the reference page's own descriptions and schemas verbatim. Everything else -
|
|
13
|
+
the handlers, the 965-row CSV, the DOM markers the harness observes - is
|
|
14
|
+
identical between the two, so a rate difference between them is attributable
|
|
15
|
+
to what the manifest says and to nothing else.
|
|
16
|
+
|
|
17
|
+
Every defect is registered in tools.json with the prediction it was written
|
|
18
|
+
to test. Nothing here is tuned against a measured number.
|
|
19
|
+
|
|
20
|
+
?flood=N additionally registers N filler tools, for the linter's budget rule
|
|
21
|
+
only; the measured sweeps never use it, because a tool-count effect would
|
|
22
|
+
confound the description effect.
|
|
23
|
+
-->
|
|
24
|
+
<style>
|
|
25
|
+
:root {
|
|
26
|
+
color-scheme: light;
|
|
27
|
+
--text: #16181d;
|
|
28
|
+
--dim: #9aa1ad;
|
|
29
|
+
--line: #e3e6ea;
|
|
30
|
+
--accent: #2f6bd8;
|
|
31
|
+
}
|
|
32
|
+
body {
|
|
33
|
+
margin: 0 auto;
|
|
34
|
+
padding: 2rem 1.25rem 4rem;
|
|
35
|
+
max-width: 60rem;
|
|
36
|
+
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
37
|
+
color: var(--text);
|
|
38
|
+
}
|
|
39
|
+
h1 {
|
|
40
|
+
font-size: 1.35rem;
|
|
41
|
+
margin: 0 0 0.25rem;
|
|
42
|
+
}
|
|
43
|
+
.note,
|
|
44
|
+
#fixture-banner,
|
|
45
|
+
#tool-badge,
|
|
46
|
+
#status {
|
|
47
|
+
font-size: 0.85rem;
|
|
48
|
+
color: var(--dim);
|
|
49
|
+
margin: 0.35rem 0;
|
|
50
|
+
}
|
|
51
|
+
#fixture-banner {
|
|
52
|
+
padding: 0.5rem 0.7rem;
|
|
53
|
+
border: 1px solid var(--line);
|
|
54
|
+
border-left: 3px solid var(--accent);
|
|
55
|
+
color: var(--text);
|
|
56
|
+
}
|
|
57
|
+
.table-wrap {
|
|
58
|
+
max-height: 26rem;
|
|
59
|
+
overflow: auto;
|
|
60
|
+
border: 1px solid var(--line);
|
|
61
|
+
margin-top: 0.75rem;
|
|
62
|
+
}
|
|
63
|
+
table {
|
|
64
|
+
width: 100%;
|
|
65
|
+
border-collapse: collapse;
|
|
66
|
+
font-variant-numeric: tabular-nums;
|
|
67
|
+
}
|
|
68
|
+
th,
|
|
69
|
+
td {
|
|
70
|
+
text-align: left;
|
|
71
|
+
padding: 0.35rem 0.6rem;
|
|
72
|
+
border-bottom: 1px solid var(--line);
|
|
73
|
+
}
|
|
74
|
+
th {
|
|
75
|
+
position: sticky;
|
|
76
|
+
top: 0;
|
|
77
|
+
background: #fff;
|
|
78
|
+
}
|
|
79
|
+
td.num {
|
|
80
|
+
text-align: right;
|
|
81
|
+
}
|
|
82
|
+
tbody.has-highlight tr {
|
|
83
|
+
color: var(--dim);
|
|
84
|
+
}
|
|
85
|
+
tbody.has-highlight tr.hit {
|
|
86
|
+
color: var(--text);
|
|
87
|
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
<h1>Spending table</h1>
|
|
94
|
+
<p id="fixture-banner">
|
|
95
|
+
webmcp-gauge test fixture. Not a product, not deployed, and not the reference page — this one is
|
|
96
|
+
deliberately mis-described. Variant: <strong id="variant-name">…</strong>.
|
|
97
|
+
</p>
|
|
98
|
+
<p id="tool-badge" data-state="registering">registering WebMCP tools…</p>
|
|
99
|
+
<p id="status">loading rows…</p>
|
|
100
|
+
<p class="note" id="highlight-note" aria-live="polite"></p>
|
|
101
|
+
<div class="table-wrap">
|
|
102
|
+
<table>
|
|
103
|
+
<thead>
|
|
104
|
+
<tr>
|
|
105
|
+
<th>Date</th>
|
|
106
|
+
<th>Description</th>
|
|
107
|
+
<th>Category</th>
|
|
108
|
+
<th class="num">Amount</th>
|
|
109
|
+
</tr>
|
|
110
|
+
</thead>
|
|
111
|
+
<tbody id="rows">
|
|
112
|
+
<tr class="empty">
|
|
113
|
+
<td colspan="4">No data loaded yet.</td>
|
|
114
|
+
</tr>
|
|
115
|
+
</tbody>
|
|
116
|
+
</table>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<script type="module">
|
|
120
|
+
import { composeVariant, variantNames } from './compose.mjs';
|
|
121
|
+
|
|
122
|
+
const params = new URLSearchParams(location.search);
|
|
123
|
+
const requestedVariant = params.get('variant') ?? 'degraded';
|
|
124
|
+
const flood = Math.max(0, Number.parseInt(params.get('flood') ?? '0', 10) || 0);
|
|
125
|
+
// ?iframe=1 embeds widget.html, which registers one tool from a subframe — the
|
|
126
|
+
// material for asking whether a subframe's tools reach the top frame's
|
|
127
|
+
// getTools() at all, or only the browser's own view of the page.
|
|
128
|
+
const withIframe = params.get('iframe') === '1';
|
|
129
|
+
|
|
130
|
+
const rowsBody = document.getElementById('rows');
|
|
131
|
+
const noteEl = document.getElementById('highlight-note');
|
|
132
|
+
const badgeEl = document.getElementById('tool-badge');
|
|
133
|
+
const statusEl = document.getElementById('status');
|
|
134
|
+
|
|
135
|
+
let rows = [];
|
|
136
|
+
let highlighted = null;
|
|
137
|
+
|
|
138
|
+
const money = new Intl.NumberFormat('en-LK', {
|
|
139
|
+
style: 'currency',
|
|
140
|
+
currency: 'LKR',
|
|
141
|
+
maximumFractionDigits: 2,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The reference CSV has no quoted fields and exactly four columns per line
|
|
146
|
+
* (verified over all 965 rows), so a split parser is honest here. Anything
|
|
147
|
+
* cleverer would be untested code in a fixture.
|
|
148
|
+
*/
|
|
149
|
+
const parseCsv = (text) => {
|
|
150
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
151
|
+
const header = lines[0].split(',').map((name) => name.trim().toLowerCase());
|
|
152
|
+
const index = {
|
|
153
|
+
date: header.indexOf('date'),
|
|
154
|
+
description: header.indexOf('description'),
|
|
155
|
+
category: header.indexOf('category'),
|
|
156
|
+
amount: header.indexOf('amount'),
|
|
157
|
+
};
|
|
158
|
+
return lines.slice(1).map((line, id) => {
|
|
159
|
+
const cells = line.split(',');
|
|
160
|
+
return {
|
|
161
|
+
id,
|
|
162
|
+
date: cells[index.date],
|
|
163
|
+
description: cells[index.description],
|
|
164
|
+
category: cells[index.category],
|
|
165
|
+
amount: Number.parseFloat(cells[index.amount]),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const render = () => {
|
|
171
|
+
const hits = highlighted ? new Set(highlighted.ids) : null;
|
|
172
|
+
const fragment = document.createDocumentFragment();
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
const tr = document.createElement('tr');
|
|
175
|
+
if (hits?.has(row.id)) tr.className = 'hit';
|
|
176
|
+
for (const value of [row.date, row.description, row.category]) {
|
|
177
|
+
const td = document.createElement('td');
|
|
178
|
+
td.textContent = value;
|
|
179
|
+
tr.append(td);
|
|
180
|
+
}
|
|
181
|
+
const amount = document.createElement('td');
|
|
182
|
+
amount.className = 'num';
|
|
183
|
+
amount.textContent = money.format(row.amount);
|
|
184
|
+
tr.append(amount);
|
|
185
|
+
fragment.append(tr);
|
|
186
|
+
}
|
|
187
|
+
rowsBody.className = hits && hits.size > 0 ? 'has-highlight' : '';
|
|
188
|
+
rowsBody.replaceChildren(fragment);
|
|
189
|
+
noteEl.textContent = highlighted ? `Agent highlighted: ${highlighted.note}` : '';
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const setHighlight = (ids, note) => {
|
|
193
|
+
highlighted = ids && ids.length > 0 ? { ids, note } : null;
|
|
194
|
+
render();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const categories = () => [...new Set(rows.map((row) => row.category))].sort();
|
|
198
|
+
const total = (subset) => subset.reduce((sum, row) => sum + row.amount, 0);
|
|
199
|
+
const summarise = (row) => ({
|
|
200
|
+
date: row.date,
|
|
201
|
+
description: row.description,
|
|
202
|
+
category: row.category,
|
|
203
|
+
amount: row.amount,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const handlers = {
|
|
207
|
+
describe() {
|
|
208
|
+
const dates = rows.map((row) => row.date).sort();
|
|
209
|
+
return {
|
|
210
|
+
loaded: rows.length > 0,
|
|
211
|
+
rowCount: rows.length,
|
|
212
|
+
columns: ['date', 'description', 'category', 'amount'],
|
|
213
|
+
categories: categories(),
|
|
214
|
+
dateRange: { earliest: dates[0] ?? null, latest: dates.at(-1) ?? null },
|
|
215
|
+
totalAmount: Number(total(rows).toFixed(2)),
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
sumByCategory(args) {
|
|
220
|
+
const buckets = new Map();
|
|
221
|
+
for (const row of rows) {
|
|
222
|
+
const bucket = buckets.get(row.category) ?? { category: row.category, total: 0, count: 0 };
|
|
223
|
+
bucket.total += row.amount;
|
|
224
|
+
bucket.count += 1;
|
|
225
|
+
buckets.set(row.category, bucket);
|
|
226
|
+
}
|
|
227
|
+
const totals = [...buckets.values()]
|
|
228
|
+
.map((bucket) => ({ ...bucket, total: Number(bucket.total.toFixed(2)) }))
|
|
229
|
+
.sort((a, b) => b.total - a.total);
|
|
230
|
+
|
|
231
|
+
const wanted = args?.highlight ?? args?.cat;
|
|
232
|
+
if (wanted === undefined || wanted === null || wanted === '') {
|
|
233
|
+
setHighlight(null);
|
|
234
|
+
return { totals };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const match = totals.find(
|
|
238
|
+
(bucket) => bucket.category.toLowerCase() === String(wanted).toLowerCase()
|
|
239
|
+
);
|
|
240
|
+
if (!match) {
|
|
241
|
+
return {
|
|
242
|
+
error: 'unknown_category',
|
|
243
|
+
message: `No category named "${wanted}".`,
|
|
244
|
+
validCategories: categories(),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const ids = rows.filter((row) => row.category === match.category).map((row) => row.id);
|
|
249
|
+
setHighlight(ids, `${match.category}: ${ids.length} rows`);
|
|
250
|
+
return {
|
|
251
|
+
totals,
|
|
252
|
+
effect: `The ${ids.length} rows in "${match.category}" are now highlighted in the table; all other rows are dimmed.`,
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
filterRows(args = {}) {
|
|
257
|
+
const limit = Math.min(Math.max(Number(args.limit ?? 10) || 10, 1), 25);
|
|
258
|
+
const matches = rows.filter((row) => {
|
|
259
|
+
if (args.from && row.date < String(args.from)) return false;
|
|
260
|
+
if (args.to && row.date > String(args.to)) return false;
|
|
261
|
+
if (args.category && row.category.toLowerCase() !== String(args.category).toLowerCase()) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
if (args.min_amount !== undefined && row.amount < Number(args.min_amount)) return false;
|
|
265
|
+
if (args.max_amount !== undefined && row.amount > Number(args.max_amount)) return false;
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (matches.length === 0) {
|
|
270
|
+
setHighlight(null);
|
|
271
|
+
return {
|
|
272
|
+
matchCount: 0,
|
|
273
|
+
total: 0,
|
|
274
|
+
preview: [],
|
|
275
|
+
effect: 'No rows matched; the table is left unhighlighted.',
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setHighlight(matches.map((row) => row.id), `${matches.length} matching rows`);
|
|
280
|
+
return {
|
|
281
|
+
matchCount: matches.length,
|
|
282
|
+
total: Number(total(matches).toFixed(2)),
|
|
283
|
+
preview: matches.slice(0, limit).map(summarise),
|
|
284
|
+
previewTruncated: matches.length > limit,
|
|
285
|
+
effect: `${matches.length} matching rows are now highlighted in the table; all other rows are dimmed.`,
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
monthlyTrend() {
|
|
290
|
+
const buckets = new Map();
|
|
291
|
+
for (const row of rows) {
|
|
292
|
+
const month = row.date.slice(0, 7);
|
|
293
|
+
const bucket = buckets.get(month) ?? { month, total: 0, count: 0 };
|
|
294
|
+
bucket.total += row.amount;
|
|
295
|
+
bucket.count += 1;
|
|
296
|
+
buckets.set(month, bucket);
|
|
297
|
+
}
|
|
298
|
+
const months = [...buckets.values()]
|
|
299
|
+
.map((bucket) => ({ ...bucket, total: Number(bucket.total.toFixed(2)) }))
|
|
300
|
+
.sort((a, b) => a.month.localeCompare(b.month));
|
|
301
|
+
const first = months[0]?.total ?? 0;
|
|
302
|
+
const last = months.at(-1)?.total ?? 0;
|
|
303
|
+
return {
|
|
304
|
+
months,
|
|
305
|
+
changeFirstToLast: first === 0 ? null : Number((((last - first) / first) * 100).toFixed(1)),
|
|
306
|
+
};
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
findAnomalies(args = {}) {
|
|
310
|
+
const threshold = Number(args.threshold ?? 2.5) || 2.5;
|
|
311
|
+
const byCategory = new Map();
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
byCategory.set(row.category, [...(byCategory.get(row.category) ?? []), row]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const anomalies = [];
|
|
317
|
+
for (const group of byCategory.values()) {
|
|
318
|
+
if (group.length < 3) continue;
|
|
319
|
+
const mean = total(group) / group.length;
|
|
320
|
+
const variance =
|
|
321
|
+
group.reduce((sum, row) => sum + (row.amount - mean) ** 2, 0) / group.length;
|
|
322
|
+
const sigma = Math.sqrt(variance);
|
|
323
|
+
if (sigma === 0) continue;
|
|
324
|
+
for (const row of group) {
|
|
325
|
+
const zScore = (row.amount - mean) / sigma;
|
|
326
|
+
if (zScore >= threshold) {
|
|
327
|
+
anomalies.push({
|
|
328
|
+
...summarise(row),
|
|
329
|
+
id: row.id,
|
|
330
|
+
categoryMean: Number(mean.toFixed(2)),
|
|
331
|
+
zScore: Number(zScore.toFixed(2)),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
anomalies.sort((a, b) => b.zScore - a.zScore);
|
|
338
|
+
if (anomalies.length === 0) {
|
|
339
|
+
setHighlight(null);
|
|
340
|
+
return {
|
|
341
|
+
anomalyCount: 0,
|
|
342
|
+
anomalies: [],
|
|
343
|
+
effect: 'Nothing stood out at this threshold; the table is left unhighlighted.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
setHighlight(anomalies.map((row) => row.id), `${anomalies.length} unusual rows`);
|
|
348
|
+
return {
|
|
349
|
+
anomalyCount: anomalies.length,
|
|
350
|
+
anomalies: anomalies.slice(0, 25).map(({ id, ...rest }) => rest),
|
|
351
|
+
anomaliesTruncated: anomalies.length > 25,
|
|
352
|
+
effect: `${anomalies.length} unusual rows are now highlighted in the table; all other rows are dimmed.`,
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
topExpenses(args = {}) {
|
|
357
|
+
const limit = Math.min(Math.max(Number(args.limit ?? 5) || 5, 1), 25);
|
|
358
|
+
const ranked = [...rows].sort((a, b) => b.amount - a.amount).slice(0, limit);
|
|
359
|
+
setHighlight(ranked.map((row) => row.id), `top ${ranked.length} by amount`);
|
|
360
|
+
return {
|
|
361
|
+
rows: ranked.map(summarise),
|
|
362
|
+
total: Number(total(ranked).toFixed(2)),
|
|
363
|
+
effect: `The ${ranked.length} largest rows are now highlighted in the table; all other rows are dimmed.`,
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
clearHighlights() {
|
|
368
|
+
setHighlight(null);
|
|
369
|
+
return { effect: 'Highlighting cleared; all rows are shown normally.' };
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
noop() {
|
|
373
|
+
return { ok: true, note: 'This tool does nothing; it exists to occupy a manifest slot.' };
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const registerAll = async (definitions) => {
|
|
378
|
+
const registered = [];
|
|
379
|
+
const rejected = [];
|
|
380
|
+
for (const definition of definitions) {
|
|
381
|
+
const handler = handlers[definition.impl] ?? handlers.noop;
|
|
382
|
+
const missing = definition.throwWhenMissing ?? [];
|
|
383
|
+
try {
|
|
384
|
+
await document.modelContext.registerTool({
|
|
385
|
+
name: definition.name,
|
|
386
|
+
description: definition.description,
|
|
387
|
+
inputSchema: definition.inputSchema,
|
|
388
|
+
annotations: definition.annotations,
|
|
389
|
+
async execute(args) {
|
|
390
|
+
// An over-parameterised schema is only a real defect if the page
|
|
391
|
+
// behaves like pages that have one: the handler insists on the
|
|
392
|
+
// parameters the schema declares required, and throws when they are
|
|
393
|
+
// absent. Returning an error object instead would score as ok,
|
|
394
|
+
// because a non-empty payload is indistinguishable from success.
|
|
395
|
+
const absent = missing.filter(
|
|
396
|
+
(key) => args === null || args === undefined || args[key] === undefined
|
|
397
|
+
);
|
|
398
|
+
if (absent.length > 0) {
|
|
399
|
+
throw new Error(`missing required parameter(s): ${absent.join(', ')}`);
|
|
400
|
+
}
|
|
401
|
+
return handler(args ?? {});
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
registered.push(definition.name);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
rejected.push({ name: definition.name, error: String(error?.message ?? error) });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return { registered, rejected };
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const floodDefinitions = (count) =>
|
|
413
|
+
Array.from({ length: count }, (_, index) => ({
|
|
414
|
+
name: `filler_tool_${String(index + 1).padStart(3, '0')}`,
|
|
415
|
+
impl: 'noop',
|
|
416
|
+
description: `Filler tool ${index + 1}. Registered only to push the manifest towards the undocumented per-page tool budget.`,
|
|
417
|
+
inputSchema: { type: 'object', properties: {} },
|
|
418
|
+
annotations: { readOnlyHint: true },
|
|
419
|
+
}));
|
|
420
|
+
|
|
421
|
+
const boot = async () => {
|
|
422
|
+
const [toolsFile, csv] = await Promise.all([
|
|
423
|
+
fetch('tools.json').then((response) => response.json()),
|
|
424
|
+
fetch('sample-expenses.csv').then((response) => response.text()),
|
|
425
|
+
]);
|
|
426
|
+
|
|
427
|
+
rows = parseCsv(csv);
|
|
428
|
+
render();
|
|
429
|
+
statusEl.textContent = `${rows.length} rows parsed in-browser from ${toolsFile.dataset.file}. Nothing was uploaded.`;
|
|
430
|
+
|
|
431
|
+
// An unknown variant must fail loudly. Silently falling back to another one
|
|
432
|
+
// would register a different manifest than the URL asked for, and the sweep
|
|
433
|
+
// would attribute the result to the wrong arm.
|
|
434
|
+
const variant = requestedVariant;
|
|
435
|
+
const variantTools = composeVariant(toolsFile, variant);
|
|
436
|
+
if (!Array.isArray(variantTools)) {
|
|
437
|
+
document.getElementById('variant-name').textContent = `UNKNOWN (${variant})`;
|
|
438
|
+
badgeEl.dataset.state = 'unsupported';
|
|
439
|
+
badgeEl.textContent = `no such variant '${variant}'; tools.json declares ${variantNames(toolsFile).join(', ')}`;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
document.getElementById('variant-name').textContent =
|
|
443
|
+
variant + (flood > 0 ? ` + ${flood} filler tools` : '');
|
|
444
|
+
|
|
445
|
+
if (!('modelContext' in document) || !document.modelContext) {
|
|
446
|
+
badgeEl.dataset.state = 'unsupported';
|
|
447
|
+
badgeEl.textContent = 'WebMCP unavailable — enable chrome://flags/#enable-webmcp-testing';
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const definitions = [...variantTools, ...floodDefinitions(flood)];
|
|
452
|
+
const { registered, rejected } = await registerAll(definitions);
|
|
453
|
+
|
|
454
|
+
badgeEl.dataset.state = 'ready';
|
|
455
|
+
badgeEl.textContent = `${registered.length} WebMCP tools registered${rejected.length > 0 ? `, ${rejected.length} rejected` : ''} (variant: ${variant})`;
|
|
456
|
+
badgeEl.title = [
|
|
457
|
+
...registered,
|
|
458
|
+
...rejected.map((entry) => `REJECTED ${entry.name}: ${entry.error}`),
|
|
459
|
+
].join('\n');
|
|
460
|
+
|
|
461
|
+
if (withIframe) {
|
|
462
|
+
const frame = document.createElement('iframe');
|
|
463
|
+
frame.id = 'widget-frame';
|
|
464
|
+
frame.title = 'embedded widget';
|
|
465
|
+
frame.src = 'widget.html';
|
|
466
|
+
frame.style.cssText = 'width:100%;height:5.5rem;border:1px solid var(--line);margin-top:0.75rem';
|
|
467
|
+
document.body.append(frame);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Registration outcomes are a measurement in their own right: whether this
|
|
471
|
+
// build accepts a tool whose name contains a space is spec issue #145, and
|
|
472
|
+
// the harness reads this from the page rather than from a screenshot.
|
|
473
|
+
window.__fixtureRegistration = { variant, flood, iframe: withIframe, registered, rejected };
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
boot().catch((error) => {
|
|
477
|
+
badgeEl.dataset.state = 'unsupported';
|
|
478
|
+
badgeEl.textContent = `fixture failed to boot: ${error?.message ?? error}`;
|
|
479
|
+
});
|
|
480
|
+
</script>
|
|
481
|
+
</body>
|
|
482
|
+
</html>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>webmcp-gauge fixture — embedded widget</title>
|
|
6
|
+
<!--
|
|
7
|
+
A one-tool page meant to be loaded inside an iframe of twin.html?iframe=1.
|
|
8
|
+
|
|
9
|
+
It exists to ask one compatibility question: when a subframe registers a WebMCP
|
|
10
|
+
tool, who can see it? The top frame's own document.modelContext.getTools() is
|
|
11
|
+
what this harness has always read, and the browser's CDP WebMCP domain reports
|
|
12
|
+
a frameId per tool - so if the two disagree, a page whose tools live in an
|
|
13
|
+
embed is measurable only from the browser side, and the harness would otherwise
|
|
14
|
+
report a tool that exists as not_registered.
|
|
15
|
+
-->
|
|
16
|
+
<style>
|
|
17
|
+
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
padding: 0.6rem 0.8rem;
|
|
20
|
+
font: 13px/1.4 system-ui, sans-serif;
|
|
21
|
+
color: #16181d;
|
|
22
|
+
background: #f6f7f9;
|
|
23
|
+
}
|
|
24
|
+
p {
|
|
25
|
+
margin: 0.2rem 0;
|
|
26
|
+
}
|
|
27
|
+
.state {
|
|
28
|
+
color: #6b7280;
|
|
29
|
+
}
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<p><strong>Embedded widget</strong> — registers one tool from inside an iframe.</p>
|
|
34
|
+
<p class="state" id="widget-state">registering…</p>
|
|
35
|
+
|
|
36
|
+
<script type="module">
|
|
37
|
+
const state = document.getElementById('widget-state');
|
|
38
|
+
|
|
39
|
+
if (!('modelContext' in document) || !document.modelContext) {
|
|
40
|
+
state.textContent = 'no modelContext in this frame';
|
|
41
|
+
} else {
|
|
42
|
+
try {
|
|
43
|
+
await document.modelContext.registerTool({
|
|
44
|
+
name: 'widget_ping',
|
|
45
|
+
description:
|
|
46
|
+
'Report the embedded widget status line back to the caller, so a tool registered inside an iframe can be told apart from one registered by the top frame.',
|
|
47
|
+
inputSchema: { type: 'object', properties: {} },
|
|
48
|
+
annotations: { readOnlyHint: true },
|
|
49
|
+
async execute() {
|
|
50
|
+
return { widget: 'ok', registeredIn: 'iframe', href: location.href };
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
state.textContent = 'widget_ping registered from the iframe';
|
|
54
|
+
window.__widgetRegistration = { registered: ['widget_ping'] };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
state.textContent = `registration failed: ${error?.message ?? error}`;
|
|
57
|
+
window.__widgetRegistration = { registered: [], error: String(error?.message ?? error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>Rehearsal gallery — webmcp-gauge</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<!--
|
|
9
|
+
A two-page stand-in for a Devpost project gallery, used to rehearse the page
|
|
10
|
+
walk in probes/gallery-harvest.mjs without paging through a stranger's real
|
|
11
|
+
gallery to prove our own loop.
|
|
12
|
+
|
|
13
|
+
It imitates only what the harvester depends on, and those dependencies were
|
|
14
|
+
measured against a published gallery on 2026-09-01 rather than guessed:
|
|
15
|
+
- project cards are `a.link-to-software` with the title in an `h5`
|
|
16
|
+
- pagination is `?page=N`, 1-indexed, with the pager linking every page
|
|
17
|
+
(so the last page number is readable from page 1)
|
|
18
|
+
- 24 cards per page on the real thing; 3 here, because the loop does not
|
|
19
|
+
care how many
|
|
20
|
+
-->
|
|
21
|
+
<h1>Rehearsal gallery</h1>
|
|
22
|
+
<div id="gallery"></div>
|
|
23
|
+
<nav id="pager"></nav>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
const PAGES = {
|
|
27
|
+
1: [
|
|
28
|
+
{ slug: 'rehearsal-one', title: 'Rehearsal One' },
|
|
29
|
+
{ slug: 'rehearsal-two', title: 'Rehearsal Two' },
|
|
30
|
+
{ slug: 'rehearsal-three', title: 'Rehearsal Three' },
|
|
31
|
+
],
|
|
32
|
+
2: [
|
|
33
|
+
{ slug: 'rehearsal-four', title: 'Rehearsal Four' },
|
|
34
|
+
// Repeated on purpose: the harvester must dedupe by project path, and a
|
|
35
|
+
// real gallery re-renders the same project when sorting shifts mid-walk.
|
|
36
|
+
{ slug: 'rehearsal-one', title: 'Rehearsal One' },
|
|
37
|
+
{ slug: 'rehearsal-five', title: 'Rehearsal Five' },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
const lastPage = Object.keys(PAGES).length;
|
|
41
|
+
const page = Number(new URLSearchParams(location.search).get('page') ?? '1');
|
|
42
|
+
const cards = PAGES[page] ?? [];
|
|
43
|
+
|
|
44
|
+
document.getElementById('gallery').innerHTML = cards
|
|
45
|
+
.map(
|
|
46
|
+
(card) =>
|
|
47
|
+
`<div class="gallery-item"><a class="link-to-software" href="https://devpost.com/software/${card.slug}"><h5 class="software-entry-name">${card.title}</h5></a></div>`
|
|
48
|
+
)
|
|
49
|
+
.join('');
|
|
50
|
+
|
|
51
|
+
document.getElementById('pager').innerHTML = Array.from({ length: lastPage }, (_, index) => index + 1)
|
|
52
|
+
.map((n) => `<a href="?page=${n}">${n}</a>`)
|
|
53
|
+
.join(' ');
|
|
54
|
+
</script>
|
|
55
|
+
</body>
|
|
56
|
+
</html>
|