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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/action.yml +162 -0
  4. package/bin/webmcp-gauge.mjs +544 -0
  5. package/bin/webmcp-gauge.test.mjs +354 -0
  6. package/browser/launch.mjs +188 -0
  7. package/browser/serve.mjs +78 -0
  8. package/browser/session.mjs +210 -0
  9. package/browser/webmcp.mjs +432 -0
  10. package/browser/webmcp.test.mjs +299 -0
  11. package/core/args.mjs +93 -0
  12. package/core/args.test.mjs +85 -0
  13. package/core/capture-seam.test.mjs +86 -0
  14. package/core/cohort.mjs +432 -0
  15. package/core/cohort.test.mjs +370 -0
  16. package/core/gallery.mjs +145 -0
  17. package/core/gallery.test.mjs +128 -0
  18. package/core/gate.mjs +164 -0
  19. package/core/gate.test.mjs +213 -0
  20. package/core/lint.mjs +381 -0
  21. package/core/lint.test.mjs +346 -0
  22. package/core/orchestrate.mjs +128 -0
  23. package/core/orchestrate.test.mjs +191 -0
  24. package/core/stats.mjs +172 -0
  25. package/core/stats.test.mjs +156 -0
  26. package/core/sweep.mjs +274 -0
  27. package/core/sweep.test.mjs +162 -0
  28. package/core/taxonomy.mjs +175 -0
  29. package/core/taxonomy.test.mjs +198 -0
  30. package/core/trial.mjs +248 -0
  31. package/core/visibility.mjs +163 -0
  32. package/core/visibility.test.mjs +164 -0
  33. package/docs/concept.md +468 -0
  34. package/docs/explainer.md +161 -0
  35. package/docs/getting-started.md +331 -0
  36. package/fixtures/README.md +42 -0
  37. package/fixtures/airlock.utterances.json +284 -0
  38. package/fixtures/broken/compose.mjs +52 -0
  39. package/fixtures/broken/compose.test.mjs +270 -0
  40. package/fixtures/broken/sample-expenses.csv +966 -0
  41. package/fixtures/broken/tools.json +1311 -0
  42. package/fixtures/broken/twin.html +482 -0
  43. package/fixtures/broken/widget.html +62 -0
  44. package/fixtures/gallery/gallery.html +56 -0
  45. package/judges/openai-compatible.mjs +145 -0
  46. package/package.json +53 -0
  47. package/report/badge.mjs +110 -0
  48. package/report/badge.test.mjs +97 -0
  49. package/report/emit.mjs +282 -0
  50. package/report/published-runs.test.mjs +77 -0
  51. package/report/scorecard.mjs +157 -0
  52. 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
+ });
@@ -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
+ });