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,370 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
normalizeTargets,
|
|
6
|
+
capturedUrlsFrom,
|
|
7
|
+
remainingTargets,
|
|
8
|
+
robotsAllows,
|
|
9
|
+
toRecord,
|
|
10
|
+
toPublishable,
|
|
11
|
+
summarize,
|
|
12
|
+
attributeTools,
|
|
13
|
+
localDateStamp,
|
|
14
|
+
HARNESS_UA_SUFFIX,
|
|
15
|
+
} from './cohort.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The capture's whole claim is the date it was taken on, and this machine runs at
|
|
19
|
+
* UTC+5:30 — a run started at 00:30 local would be filed under yesterday if the
|
|
20
|
+
* stamp came from toISOString().
|
|
21
|
+
*/
|
|
22
|
+
test('the date stamp is local, not UTC', () => {
|
|
23
|
+
// 2026-09-02T00:30 in a +05:30 zone is 2026-09-01T19:00Z. The stamp must follow
|
|
24
|
+
// the operator's calendar, whatever the machine's offset happens to be.
|
|
25
|
+
const local = new Date(2026, 8, 2, 0, 30, 0);
|
|
26
|
+
assert.equal(localDateStamp(local), '2026-09-02');
|
|
27
|
+
assert.equal(localDateStamp(new Date(2026, 0, 5, 23, 59)), '2026-01-05', 'months and days are zero-padded');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a bare hostname becomes an https URL, and the project name defaults to the host', () => {
|
|
31
|
+
const [row] = normalizeTargets(['example.netlify.app']);
|
|
32
|
+
assert.equal(row.url, 'https://example.netlify.app/');
|
|
33
|
+
assert.equal(row.project, 'example.netlify.app');
|
|
34
|
+
assert.equal(row.repo, null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the same page under two spellings is one capture, not two visits', () => {
|
|
38
|
+
const rows = normalizeTargets([
|
|
39
|
+
'https://a.example/demo/',
|
|
40
|
+
'https://a.example/demo',
|
|
41
|
+
'https://a.example/demo#tools',
|
|
42
|
+
]);
|
|
43
|
+
assert.equal(rows.length, 1);
|
|
44
|
+
assert.deepEqual(rows[0].aliases, ['https://a.example/demo', 'https://a.example/demo']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a query string is part of identity, because a variant is a different page', () => {
|
|
48
|
+
const rows = normalizeTargets(['https://a.example/t?variant=clean', 'https://a.example/t?variant=degraded']);
|
|
49
|
+
assert.equal(rows.length, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('an entry with no url, or a non-http scheme, is refused rather than skipped', () => {
|
|
53
|
+
assert.throws(() => normalizeTargets([{ project: 'x' }]), /has no url/);
|
|
54
|
+
assert.throws(() => normalizeTargets(['ftp://a.example/x']), /not http/);
|
|
55
|
+
assert.throws(() => normalizeTargets('not an array'), TypeError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('missing or empty robots.txt is permission, which is what the standard says', () => {
|
|
59
|
+
assert.equal(robotsAllows('', '/demo'), true);
|
|
60
|
+
assert.equal(robotsAllows(null, '/demo'), true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a wildcard Disallow blocks, and an empty Disallow allows everything', () => {
|
|
64
|
+
assert.equal(robotsAllows('User-agent: *\nDisallow: /', '/demo'), false);
|
|
65
|
+
assert.equal(robotsAllows('User-agent: *\nDisallow:', '/demo'), true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the longest matching rule wins, so a nested Allow beats a broad Disallow', () => {
|
|
69
|
+
const robots = 'User-agent: *\nDisallow: /private\nAllow: /private/public';
|
|
70
|
+
assert.equal(robotsAllows(robots, '/private/secret'), false);
|
|
71
|
+
assert.equal(robotsAllows(robots, '/private/public/page'), true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a rule naming us specifically overrides the wildcard group entirely', () => {
|
|
75
|
+
const robots = 'User-agent: *\nDisallow: /\n\nUser-agent: webmcp-gauge\nDisallow: /admin';
|
|
76
|
+
assert.equal(robotsAllows(robots, '/demo'), true, 'our own group replaces the wildcard one');
|
|
77
|
+
assert.equal(robotsAllows(robots, '/admin/x'), false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('wildcards and end-anchors in patterns are honoured', () => {
|
|
81
|
+
assert.equal(robotsAllows('User-agent: *\nDisallow: /*.pdf$', '/files/a.pdf'), false);
|
|
82
|
+
assert.equal(robotsAllows('User-agent: *\nDisallow: /*.pdf$', '/files/a.pdf.html'), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('comments and blank lines do not become rules', () => {
|
|
86
|
+
const robots = '# nothing to see\n\nUser-agent: *\n# Disallow: /\nAllow: /\n';
|
|
87
|
+
assert.equal(robotsAllows(robots, '/demo'), true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const manifest = {
|
|
91
|
+
present: true,
|
|
92
|
+
settled: true,
|
|
93
|
+
inNavigator: false,
|
|
94
|
+
tools: [
|
|
95
|
+
{
|
|
96
|
+
name: 'sum_by_category',
|
|
97
|
+
description: 'Totals spending per category and optionally highlights one.',
|
|
98
|
+
inputSchema: { type: 'object', properties: { highlight: { type: 'string' } }, required: ['highlight'] },
|
|
99
|
+
inputSchemaWire: 'string',
|
|
100
|
+
annotations: { readOnlyHint: true },
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
test('the local record keeps the manifest verbatim, because the text is the measured object', () => {
|
|
106
|
+
const record = toRecord({
|
|
107
|
+
target: { project: 'airlock', url: 'https://airlock.example/', repo: null, aliases: [] },
|
|
108
|
+
capturedAt: '2026-09-04T00:00:00.000Z',
|
|
109
|
+
status: 200,
|
|
110
|
+
finalUrl: 'https://airlock.example/',
|
|
111
|
+
title: 'Airlock',
|
|
112
|
+
manifest,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.equal(record.webmcp.tools[0].description, manifest.tools[0].description);
|
|
116
|
+
assert.equal(record.liveness.reachable, true);
|
|
117
|
+
assert.equal(record.liveness.redirected, false);
|
|
118
|
+
assert.equal(record.webmcp.toolCount, 1);
|
|
119
|
+
assert.equal(record.webmcp.registered, true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The defect this guards against was found by the 2026-09-01 dry run, not by
|
|
124
|
+
* reasoning: document.modelContext exists on every page in a WebMCP-enabled
|
|
125
|
+
* browser, so "present" was true for example.com and for a 404 page.
|
|
126
|
+
*/
|
|
127
|
+
test('a page where only the browser API exists has not adopted WebMCP', () => {
|
|
128
|
+
const record = toRecord({
|
|
129
|
+
target: { project: 'example.com', url: 'https://example.com/', repo: null, aliases: [] },
|
|
130
|
+
capturedAt: 'now',
|
|
131
|
+
status: 200,
|
|
132
|
+
finalUrl: 'https://example.com/',
|
|
133
|
+
manifest: { present: true, settled: false, tools: [] },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
assert.equal(record.webmcp.apiPresent, true, 'the browser fact is still recorded');
|
|
137
|
+
assert.equal(record.webmcp.registered, false, 'but it is not adoption');
|
|
138
|
+
assert.equal(record.webmcp.toolCount, 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a redirect is recorded as one, and a 4xx page registers nothing whatever its error document does', () => {
|
|
142
|
+
const target = { project: 'x', url: 'https://x.example/', repo: null, aliases: [] };
|
|
143
|
+
const moved = toRecord({ target, capturedAt: 'now', status: 200, finalUrl: 'https://y.example/' });
|
|
144
|
+
assert.equal(moved.liveness.redirected, true);
|
|
145
|
+
|
|
146
|
+
const gone = toRecord({ target, capturedAt: 'now', status: 404, finalUrl: 'https://x.example/', manifest });
|
|
147
|
+
assert.equal(gone.liveness.reachable, false);
|
|
148
|
+
assert.equal(gone.webmcp.registered, false);
|
|
149
|
+
assert.equal(gone.webmcp.toolCount, 0, 'a 404 page\u2019s tools are not the project\u2019s tools');
|
|
150
|
+
assert.deepEqual(gone.webmcp.tools, []);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('the published row carries tool names and shapes but never a description', () => {
|
|
154
|
+
const record = toRecord({
|
|
155
|
+
target: { project: 'airlock', url: 'https://airlock.example/', repo: 'https://github.com/x/y', aliases: [] },
|
|
156
|
+
capturedAt: '2026-09-04T00:00:00.000Z',
|
|
157
|
+
status: 200,
|
|
158
|
+
finalUrl: 'https://airlock.example/',
|
|
159
|
+
title: 'Airlock',
|
|
160
|
+
manifest,
|
|
161
|
+
});
|
|
162
|
+
const published = toPublishable(record);
|
|
163
|
+
|
|
164
|
+
const serialized = JSON.stringify(published);
|
|
165
|
+
assert.ok(!serialized.includes('Totals spending'), 'a description must not leave this machine');
|
|
166
|
+
assert.equal(published.tools[0].name, 'sum_by_category');
|
|
167
|
+
assert.equal(published.tools[0].descriptionLength, manifest.tools[0].description.length);
|
|
168
|
+
assert.equal(published.tools[0].propertyCount, 1);
|
|
169
|
+
assert.equal(published.tools[0].requiredCount, 1);
|
|
170
|
+
assert.equal(published.tools[0].hasAnnotations, true);
|
|
171
|
+
assert.equal(published.usesWebmcp, true);
|
|
172
|
+
assert.equal(published.title, undefined, 'a page title is prose too, and is not published');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('the census counts adoption, not browser support', () => {
|
|
176
|
+
const at = '2026-09-04T00:00:00.000Z';
|
|
177
|
+
const mk = (project, status, tools) =>
|
|
178
|
+
toRecord({
|
|
179
|
+
target: { project, url: `https://${project}.example/`, repo: null, aliases: [] },
|
|
180
|
+
capturedAt: at,
|
|
181
|
+
status,
|
|
182
|
+
finalUrl: `https://${project}.example/`,
|
|
183
|
+
// present: true everywhere, because that is what a WebMCP-enabled browser
|
|
184
|
+
// actually reports — the census must not be fooled by it.
|
|
185
|
+
manifest: { present: true, tools },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const summary = summarize([
|
|
189
|
+
mk('live-with-tools', 200, [{ name: 'a' }, { name: 'b' }]),
|
|
190
|
+
mk('live-registered-nothing', 200, []),
|
|
191
|
+
mk('dead-with-an-error-page', 404, [{ name: 'ghost' }]),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
assert.equal(summary.projects, 3);
|
|
195
|
+
assert.equal(summary.reachable, 2);
|
|
196
|
+
assert.equal(summary.dead, 1);
|
|
197
|
+
assert.equal(summary.usingWebmcp, 1, 'one project registered a tool');
|
|
198
|
+
assert.equal(summary.reachableWithoutTools, 1);
|
|
199
|
+
assert.equal(summary.totalTools, 2);
|
|
200
|
+
assert.equal(summary.maxToolCount, 2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The union decision, taken 2026-09-02 before the capture. A cross-origin embed
|
|
205
|
+
* with allow="tools" puts a tool in the browser's view and in nobody's
|
|
206
|
+
* getTools(), so "what this builder shipped" and "what an agent can call here"
|
|
207
|
+
* stop being the same number. Both are captured; neither is allowed to stand in
|
|
208
|
+
* for the other.
|
|
209
|
+
*/
|
|
210
|
+
const embedInputs = () => ({
|
|
211
|
+
target: { project: 'host', url: 'https://host.example/', repo: null, aliases: [] },
|
|
212
|
+
capturedAt: 'now',
|
|
213
|
+
status: 200,
|
|
214
|
+
finalUrl: 'https://host.example/',
|
|
215
|
+
manifest: { present: true, tools: [{ name: 'own_one', description: 'x' }] },
|
|
216
|
+
browserTools: [
|
|
217
|
+
{ name: 'own_one', frameId: 'F1' },
|
|
218
|
+
{
|
|
219
|
+
name: 'embedded_pay',
|
|
220
|
+
frameId: 'F2',
|
|
221
|
+
stackTrace: { callFrames: [{ url: 'https://widget.example/w.js' }] },
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
frames: [
|
|
225
|
+
{ id: 'F1', origin: 'https://host.example' },
|
|
226
|
+
{ id: 'F2', origin: 'https://widget.example' },
|
|
227
|
+
],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const embedRecord = () => toRecord(embedInputs());
|
|
231
|
+
|
|
232
|
+
test('a third party\u2019s tool is agent-visible but never credited to the page', () => {
|
|
233
|
+
const record = embedRecord();
|
|
234
|
+
|
|
235
|
+
assert.equal(record.webmcp.toolCount, 1, 'the page shipped one tool');
|
|
236
|
+
assert.equal(record.webmcp.agentToolCount, 2, 'an agent can call two');
|
|
237
|
+
assert.equal(record.webmcp.thirdPartyToolCount, 1);
|
|
238
|
+
assert.deepEqual(record.webmcp.divergence.onlyInBrowser, ['embedded_pay']);
|
|
239
|
+
assert.deepEqual(record.webmcp.divergence.onlyInPage, []);
|
|
240
|
+
|
|
241
|
+
const attributed = record.webmcp.attribution.find((t) => t.name === 'embedded_pay');
|
|
242
|
+
assert.equal(attributed.origin, 'https://widget.example');
|
|
243
|
+
assert.equal(attributed.sameOrigin, false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('the published row carries the counts but not a third party\u2019s identity', () => {
|
|
247
|
+
const published = toPublishable(embedRecord());
|
|
248
|
+
|
|
249
|
+
assert.equal(published.toolCount, 1);
|
|
250
|
+
assert.equal(published.agentVisibleToolCount, 2);
|
|
251
|
+
assert.equal(published.thirdPartyToolCount, 1);
|
|
252
|
+
assert.equal(published.viewsDiverge, true);
|
|
253
|
+
assert.ok(
|
|
254
|
+
!JSON.stringify(published).includes('widget.example'),
|
|
255
|
+
"a fourth party's origin must not appear in somebody else's published row"
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('a tool whose origin cannot be established is unattributed, not assumed to be the page\u2019s', () => {
|
|
260
|
+
const attributed = attributeTools([{ name: 'mystery', frameId: 'gone' }], [], 'https://host.example');
|
|
261
|
+
assert.equal(attributed[0].origin, null);
|
|
262
|
+
assert.equal(attributed[0].sameOrigin, null, 'null, not true');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('no browser view means null throughout, never zero or an empty list', () => {
|
|
266
|
+
const record = toRecord({
|
|
267
|
+
target: { project: 'x', url: 'https://x.example/', repo: null, aliases: [] },
|
|
268
|
+
capturedAt: 'now',
|
|
269
|
+
status: 200,
|
|
270
|
+
finalUrl: 'https://x.example/',
|
|
271
|
+
manifest: { present: true, tools: [{ name: 'a' }] },
|
|
272
|
+
browserTools: null,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
assert.equal(record.webmcp.agentTools, null);
|
|
276
|
+
assert.equal(record.webmcp.agentToolCount, null);
|
|
277
|
+
assert.equal(record.webmcp.divergence, null);
|
|
278
|
+
assert.equal(record.webmcp.thirdPartyToolCount, null);
|
|
279
|
+
|
|
280
|
+
const summary = summarize([record]);
|
|
281
|
+
assert.equal(summary.pagesWithAgentView, 0);
|
|
282
|
+
assert.equal(summary.totalAgentVisibleTools, null, 'a cohort with no browser view reports null, not 0');
|
|
283
|
+
assert.equal(summary.pagesWhereViewsDiverge, null);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('the census reports agent-side totals separately from adoption', () => {
|
|
287
|
+
const summary = summarize([embedRecord()]);
|
|
288
|
+
|
|
289
|
+
assert.equal(summary.usingWebmcp, 1);
|
|
290
|
+
assert.equal(summary.totalTools, 1, 'adoption counts what the builder shipped');
|
|
291
|
+
assert.equal(summary.totalAgentVisibleTools, 2, 'reality counts what an agent can call');
|
|
292
|
+
assert.equal(summary.pagesWithThirdPartyTools, 1);
|
|
293
|
+
assert.equal(summary.pagesWhereViewsDiverge, 1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Item 23 (2026-09-05): the agent view can be taken at the browser endpoint,
|
|
298
|
+
* and then the record has to say so — with the OOPIF session count attached,
|
|
299
|
+
* because a watch no out-of-process iframe ever attached to has measured
|
|
300
|
+
* auto-attach rather than the browser's view. From `agentToolCount` alone the
|
|
301
|
+
* two findings are identical; they are not the same claim.
|
|
302
|
+
*/
|
|
303
|
+
test('the record keeps how the agent view was taken, and defaults to null when unsaid', () => {
|
|
304
|
+
const watched = toRecord({
|
|
305
|
+
...embedInputs(),
|
|
306
|
+
browserView: { endpoint: 'browser', oopiFrames: 1, attachedSessions: 3, toolSessions: 2 },
|
|
307
|
+
});
|
|
308
|
+
assert.deepEqual(watched.webmcp.browserView, {
|
|
309
|
+
endpoint: 'browser',
|
|
310
|
+
oopiFrames: 1,
|
|
311
|
+
attachedSessions: 3,
|
|
312
|
+
toolSessions: 2,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const unsaid = embedRecord();
|
|
316
|
+
assert.equal(unsaid.webmcp.browserView, null, 'older callers must not gain a made-up view description');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The resume rules, from the night of 2026-09-25/26 when a browser death and a
|
|
321
|
+
* machine sleep each stopped a full-corpus run and the URL subtraction ran
|
|
322
|
+
* twice by hand. A capture that cannot be repeated must be resumable in one
|
|
323
|
+
* command, and the subtraction must be exactly the file's canonical URLs
|
|
324
|
+
* against the fixture's canonical targets — no near-miss spellings.
|
|
325
|
+
*/
|
|
326
|
+
test('resume: captured urls parse out of an append-only snapshot, torn lines and all', () => {
|
|
327
|
+
const text = [
|
|
328
|
+
JSON.stringify({ project: 'a', url: 'https://a.example/' }),
|
|
329
|
+
'{"project":"torn",',
|
|
330
|
+
JSON.stringify({ project: 'b', url: 'https://b.example/' }),
|
|
331
|
+
'',
|
|
332
|
+
].join('\n');
|
|
333
|
+
|
|
334
|
+
const captured = capturedUrlsFrom(text);
|
|
335
|
+
assert.equal(captured.size, 2, 'a torn final line must not poison the rest of the file');
|
|
336
|
+
assert.ok(captured.has('https://a.example/'));
|
|
337
|
+
assert.ok(captured.has('https://b.example/'));
|
|
338
|
+
|
|
339
|
+
assert.deepEqual([...capturedUrlsFrom('')], [], 'an empty file resumes everything');
|
|
340
|
+
assert.deepEqual([...capturedUrlsFrom(null)], [], 'a missing file resumes everything');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('resume: remaining targets subtract exactly what the file already holds', () => {
|
|
344
|
+
const targets = normalizeTargets([
|
|
345
|
+
{ url: 'https://a.example/' },
|
|
346
|
+
{ url: 'https://b.example/#frag' },
|
|
347
|
+
{ url: 'https://c.example/' },
|
|
348
|
+
]);
|
|
349
|
+
const captured = capturedUrlsFrom(
|
|
350
|
+
`${JSON.stringify({ url: 'https://a.example/' })}\n${JSON.stringify({ url: 'https://b.example/' })}`
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const remaining = remainingTargets(targets, captured);
|
|
354
|
+
assert.deepEqual(remaining.map((t) => t.url), ['https://c.example/']);
|
|
355
|
+
|
|
356
|
+
assert.throws(() => remainingTargets(targets, new Map()), TypeError, 'a Set is the contract');
|
|
357
|
+
assert.equal(remainingTargets([], new Set()).length, 0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('an empty cohort summarizes to zeroes rather than throwing', () => {
|
|
361
|
+
const summary = summarize([]);
|
|
362
|
+
assert.equal(summary.projects, 0);
|
|
363
|
+
assert.equal(summary.medianToolCount, null);
|
|
364
|
+
assert.equal(summary.maxToolCount, null);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('the user agent identifies the harness and offers a contact, per the publishing policy', () => {
|
|
368
|
+
assert.match(HARNESS_UA_SUFFIX, /webmcp-gauge/);
|
|
369
|
+
assert.match(HARNESS_UA_SUFFIX, /github\.com/);
|
|
370
|
+
});
|
package/core/gallery.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a gallery page into a targets file: the parts that can be decided
|
|
3
|
+
* without a browser, and therefore tested before the one day they have to work.
|
|
4
|
+
*
|
|
5
|
+
* The gallery was still unpublished when this was written (2026-09-01: the page
|
|
6
|
+
* says "The hackathon managers haven't published this gallery yet"), so the DOM
|
|
7
|
+
* selectors cannot be verified yet and live in the runner behind a cascade. What
|
|
8
|
+
* *can* be settled now is the reasoning applied to whatever links come back:
|
|
9
|
+
* which link is the demo, which is the repo, and which are neither.
|
|
10
|
+
*
|
|
11
|
+
* Getting that wrong on the day is expensive in a specific way — a project whose
|
|
12
|
+
* GitHub URL is mistaken for its demo produces a "no tools registered" row, which
|
|
13
|
+
* is indistinguishable in the census from a project that genuinely shipped none.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Hosts whose links are never the thing we want to measure. */
|
|
17
|
+
const REPO_HOSTS = new Set(['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org']);
|
|
18
|
+
const VIDEO_HOSTS = new Set(['youtube.com', 'youtu.be', 'vimeo.com', 'loom.com']);
|
|
19
|
+
const SOCIAL_HOSTS = new Set([
|
|
20
|
+
'twitter.com',
|
|
21
|
+
'x.com',
|
|
22
|
+
'linkedin.com',
|
|
23
|
+
'facebook.com',
|
|
24
|
+
'instagram.com',
|
|
25
|
+
'discord.com',
|
|
26
|
+
'discord.gg',
|
|
27
|
+
'reddit.com',
|
|
28
|
+
't.me',
|
|
29
|
+
]);
|
|
30
|
+
/** Package and doc hosts that appear on submissions but host no page to measure. */
|
|
31
|
+
const ARTEFACT_HOSTS = new Set([
|
|
32
|
+
'npmjs.com',
|
|
33
|
+
'pypi.org',
|
|
34
|
+
'crates.io',
|
|
35
|
+
'chromewebstore.google.com',
|
|
36
|
+
'chrome.google.com',
|
|
37
|
+
'addons.mozilla.org',
|
|
38
|
+
'docs.google.com',
|
|
39
|
+
'drive.google.com',
|
|
40
|
+
'notion.so',
|
|
41
|
+
'devpost.com',
|
|
42
|
+
// Devpost's own chrome and asset hosts. Measured on a real project page
|
|
43
|
+
// (2026-09-01): its footer and sponsor rail offer devpost.team and a
|
|
44
|
+
// cloudfront asset URL, both of which classify as "demo" unless named here,
|
|
45
|
+
// and one of them would then be captured as somebody's submission.
|
|
46
|
+
'devpost.team',
|
|
47
|
+
'cloudfront.net',
|
|
48
|
+
'amazonaws.com',
|
|
49
|
+
'devpost-file-uploads.s3.amazonaws.com',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const hostOf = (url) => {
|
|
53
|
+
try {
|
|
54
|
+
return new URL(url).host.replace(/^www\./, '').toLowerCase();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What kind of link this is. Deliberately coarse: the only decision downstream is
|
|
62
|
+
* "is this a page a browser can be pointed at to read a WebMCP manifest".
|
|
63
|
+
*/
|
|
64
|
+
export const classifyLink = (url) => {
|
|
65
|
+
const host = hostOf(url);
|
|
66
|
+
if (!host) return 'unusable';
|
|
67
|
+
|
|
68
|
+
const base = host.split('.').slice(-2).join('.');
|
|
69
|
+
if (REPO_HOSTS.has(base)) return 'repo';
|
|
70
|
+
if (VIDEO_HOSTS.has(base)) return 'video';
|
|
71
|
+
if (SOCIAL_HOSTS.has(base)) return 'social';
|
|
72
|
+
if (ARTEFACT_HOSTS.has(base) || ARTEFACT_HOSTS.has(host)) return 'artefact';
|
|
73
|
+
return 'demo';
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One project's links reduced to one row for the snapshot, or a refusal.
|
|
78
|
+
*
|
|
79
|
+
* Ordering rule, stated because it decides the dataset: the **first** demo-class
|
|
80
|
+
* link wins. Devpost renders submission links in the order the entrant chose, and
|
|
81
|
+
* an entrant who lists their live site first meant it. Ties are not broken by
|
|
82
|
+
* cleverness — guessing which of two candidate URLs is "more live" would be an
|
|
83
|
+
* invented preference, and the runner records every candidate so a human can
|
|
84
|
+
* override one row rather than distrust all of them.
|
|
85
|
+
*/
|
|
86
|
+
export const pickTarget = ({ title, devpostUrl, links = [] }) => {
|
|
87
|
+
const classified = links
|
|
88
|
+
.map((link) => (typeof link === 'string' ? { url: link } : link))
|
|
89
|
+
.filter((link) => link && typeof link.url === 'string' && link.url.trim() !== '')
|
|
90
|
+
.map((link) => ({ url: link.url.trim(), label: link.label ?? null, kind: classifyLink(link.url.trim()) }));
|
|
91
|
+
|
|
92
|
+
const demos = classified.filter((l) => l.kind === 'demo');
|
|
93
|
+
const repos = classified.filter((l) => l.kind === 'repo');
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
project: title?.trim() || hostOf(devpostUrl) || 'untitled',
|
|
97
|
+
devpostUrl: devpostUrl ?? null,
|
|
98
|
+
url: demos[0]?.url ?? null,
|
|
99
|
+
repo: repos[0]?.url ?? null,
|
|
100
|
+
otherDemoCandidates: demos.slice(1).map((l) => l.url),
|
|
101
|
+
rejected: classified.filter((l) => l.kind !== 'demo' && l.kind !== 'repo'),
|
|
102
|
+
// A submission with no page to visit is not an error and not a target: it is a
|
|
103
|
+
// row in the census that says so, and the reason has to survive to the report.
|
|
104
|
+
skipReason: demos.length === 0 ? 'no demo-class link on the submission' : null,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Devpost paginates its galleries with ?page=N, 1-indexed. Kept here so the page
|
|
110
|
+
* walk is a pure function of the base URL and can be tested without fetching.
|
|
111
|
+
*/
|
|
112
|
+
export const galleryPageUrl = (base, page) => {
|
|
113
|
+
const url = new URL(base);
|
|
114
|
+
if (page > 1) url.searchParams.set('page', String(page));
|
|
115
|
+
else url.searchParams.delete('page');
|
|
116
|
+
return url.href;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The harvest as it will be handed to `cohort-snapshot.mjs`, plus the audit trail
|
|
121
|
+
* of what was dropped. Both halves are written to disk: a targets file that is
|
|
122
|
+
* only the measurable rows, and a manifest of every decision made to get there.
|
|
123
|
+
*/
|
|
124
|
+
export const toHarvest = (picks, { source, harvestedAt }) => {
|
|
125
|
+
const usable = picks.filter((p) => !p.skipReason && p.url);
|
|
126
|
+
const skipped = picks.filter((p) => p.skipReason || !p.url);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
schema: 'webmcp-gauge/gallery-harvest/1',
|
|
130
|
+
source,
|
|
131
|
+
harvestedAt,
|
|
132
|
+
counts: {
|
|
133
|
+
projects: picks.length,
|
|
134
|
+
usable: usable.length,
|
|
135
|
+
skipped: skipped.length,
|
|
136
|
+
ambiguous: usable.filter((p) => p.otherDemoCandidates.length > 0).length,
|
|
137
|
+
withRepo: usable.filter((p) => p.repo).length,
|
|
138
|
+
},
|
|
139
|
+
targets: usable.map((p) => ({ project: p.project, url: p.url, repo: p.repo })),
|
|
140
|
+
skipped: skipped.map((p) => ({ project: p.project, devpostUrl: p.devpostUrl, reason: p.skipReason ?? 'no url' })),
|
|
141
|
+
ambiguous: usable
|
|
142
|
+
.filter((p) => p.otherDemoCandidates.length > 0)
|
|
143
|
+
.map((p) => ({ project: p.project, chosen: p.url, alsoOffered: p.otherDemoCandidates })),
|
|
144
|
+
};
|
|
145
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { classifyLink, pickTarget, galleryPageUrl, toHarvest } from './gallery.mjs';
|
|
5
|
+
|
|
6
|
+
test('a repo host is a repo, however it is spelled', () => {
|
|
7
|
+
assert.equal(classifyLink('https://github.com/user/project'), 'repo');
|
|
8
|
+
assert.equal(classifyLink('https://www.github.com/user/project'), 'repo');
|
|
9
|
+
assert.equal(classifyLink('https://gitlab.com/user/project'), 'repo');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('videos, socials and package pages are not pages to measure', () => {
|
|
13
|
+
assert.equal(classifyLink('https://youtu.be/abc123'), 'video');
|
|
14
|
+
assert.equal(classifyLink('https://www.youtube.com/watch?v=abc'), 'video');
|
|
15
|
+
assert.equal(classifyLink('https://x.com/someone/status/1'), 'social');
|
|
16
|
+
assert.equal(classifyLink('https://www.npmjs.com/package/thing'), 'artefact');
|
|
17
|
+
assert.equal(classifyLink('https://devpost.com/software/thing'), 'artefact');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Measured on a real project page (2026-09-01), where Devpost's own footer and
|
|
22
|
+
* sponsor rail offered these alongside the entrant's links. Left unnamed, each
|
|
23
|
+
* one classifies as a demo, and the first-wins rule would then record a sponsor's
|
|
24
|
+
* marketing site as somebody's submission.
|
|
25
|
+
*/
|
|
26
|
+
test("Devpost's own chrome and asset hosts are not somebody's submission", () => {
|
|
27
|
+
assert.equal(classifyLink('https://devpost.team/'), 'artefact');
|
|
28
|
+
assert.equal(classifyLink('https://d112y698adiu2z.cloudfront.net/photos/x.png'), 'artefact');
|
|
29
|
+
assert.equal(classifyLink('https://devpost-file-uploads.s3.amazonaws.com/x.pdf'), 'artefact');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('anything else is a demo candidate, including subdomains of free hosting', () => {
|
|
33
|
+
assert.equal(classifyLink('https://my-app.netlify.app'), 'demo');
|
|
34
|
+
assert.equal(classifyLink('https://project.vercel.app/demo'), 'demo');
|
|
35
|
+
assert.equal(classifyLink('https://user.github.io/project'), 'demo', 'pages hosting is a live page, not a repo');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a malformed link is unusable rather than a demo', () => {
|
|
39
|
+
assert.equal(classifyLink('not a url'), 'unusable');
|
|
40
|
+
assert.equal(classifyLink(''), 'unusable');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the first demo link wins, and the repo is captured beside it', () => {
|
|
44
|
+
const pick = pickTarget({
|
|
45
|
+
title: ' Airlock ',
|
|
46
|
+
devpostUrl: 'https://devpost.com/software/airlock',
|
|
47
|
+
links: [
|
|
48
|
+
'https://github.com/user/airlock',
|
|
49
|
+
'https://airlock-app.netlify.app',
|
|
50
|
+
'https://backup.example/airlock',
|
|
51
|
+
'https://youtu.be/demo',
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
assert.equal(pick.project, 'Airlock');
|
|
56
|
+
assert.equal(pick.url, 'https://airlock-app.netlify.app');
|
|
57
|
+
assert.equal(pick.repo, 'https://github.com/user/airlock');
|
|
58
|
+
assert.deepEqual(pick.otherDemoCandidates, ['https://backup.example/airlock']);
|
|
59
|
+
assert.equal(pick.skipReason, null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a submission with only a repo and a video is skipped with the reason recorded', () => {
|
|
63
|
+
const pick = pickTarget({
|
|
64
|
+
title: 'CLI thing',
|
|
65
|
+
devpostUrl: 'https://devpost.com/software/cli-thing',
|
|
66
|
+
links: ['https://github.com/user/cli', 'https://youtu.be/x'],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
assert.equal(pick.url, null);
|
|
70
|
+
assert.equal(pick.repo, 'https://github.com/user/cli');
|
|
71
|
+
assert.match(pick.skipReason, /no demo-class link/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a project with no links at all is skipped rather than throwing', () => {
|
|
75
|
+
const pick = pickTarget({ title: 'Empty', devpostUrl: 'https://devpost.com/software/empty' });
|
|
76
|
+
assert.equal(pick.url, null);
|
|
77
|
+
assert.ok(pick.skipReason);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('an untitled submission falls back to a name rather than an empty string', () => {
|
|
81
|
+
const pick = pickTarget({ title: ' ', devpostUrl: 'https://devpost.com/software/x', links: [] });
|
|
82
|
+
assert.equal(pick.project, 'devpost.com');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('labelled links are accepted in object form as well as bare strings', () => {
|
|
86
|
+
const pick = pickTarget({
|
|
87
|
+
title: 'Mixed',
|
|
88
|
+
devpostUrl: null,
|
|
89
|
+
links: [{ url: 'https://demo.example', label: 'Try it out' }, 'https://github.com/a/b'],
|
|
90
|
+
});
|
|
91
|
+
assert.equal(pick.url, 'https://demo.example');
|
|
92
|
+
assert.equal(pick.repo, 'https://github.com/a/b');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('page 1 carries no page parameter, and later pages do', () => {
|
|
96
|
+
const base = 'https://webmcp.devpost.com/project-gallery';
|
|
97
|
+
assert.equal(galleryPageUrl(base, 1), `${base}`);
|
|
98
|
+
assert.equal(galleryPageUrl(base, 3), `${base}?page=3`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('an existing query string on the gallery URL survives pagination', () => {
|
|
102
|
+
const base = 'https://webmcp.devpost.com/project-gallery?sort=recent';
|
|
103
|
+
assert.equal(galleryPageUrl(base, 2), 'https://webmcp.devpost.com/project-gallery?sort=recent&page=2');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the harvest separates what can be measured from what was dropped, and counts both', () => {
|
|
107
|
+
const picks = [
|
|
108
|
+
pickTarget({ title: 'A', devpostUrl: 'd/a', links: ['https://a.example', 'https://github.com/a/a'] }),
|
|
109
|
+
pickTarget({ title: 'B', devpostUrl: 'd/b', links: ['https://b.example', 'https://b2.example'] }),
|
|
110
|
+
pickTarget({ title: 'C', devpostUrl: 'd/c', links: ['https://github.com/c/c'] }),
|
|
111
|
+
];
|
|
112
|
+
const harvest = toHarvest(picks, { source: 'https://webmcp.devpost.com/project-gallery', harvestedAt: 'now' });
|
|
113
|
+
|
|
114
|
+
assert.equal(harvest.counts.projects, 3);
|
|
115
|
+
assert.equal(harvest.counts.usable, 2);
|
|
116
|
+
assert.equal(harvest.counts.skipped, 1);
|
|
117
|
+
assert.equal(harvest.counts.ambiguous, 1, 'B offered two demo links');
|
|
118
|
+
assert.equal(harvest.counts.withRepo, 1);
|
|
119
|
+
assert.deepEqual(harvest.targets[0], { project: 'A', url: 'https://a.example', repo: 'https://github.com/a/a' });
|
|
120
|
+
assert.equal(harvest.skipped[0].project, 'C');
|
|
121
|
+
assert.deepEqual(harvest.ambiguous[0].alsoOffered, ['https://b2.example']);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('an empty gallery harvests to an empty target list rather than a crash', () => {
|
|
125
|
+
const harvest = toHarvest([], { source: 'x', harvestedAt: 'now' });
|
|
126
|
+
assert.equal(harvest.counts.projects, 0);
|
|
127
|
+
assert.deepEqual(harvest.targets, []);
|
|
128
|
+
});
|