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,299 @@
1
+ /**
2
+ * The browser-side tool view, which is the only way `not_discovered` can ever fire.
3
+ *
4
+ * Tested against a fake session because what matters is the accumulation contract,
5
+ * not Chrome: the WebMCP domain has no command that lists tools, so the view is
6
+ * assembled from a stream of events and every rule about that assembly - one event
7
+ * per registration, removals, an unavailable domain, unsubscribing - is logic this
8
+ * repo owns.
9
+ */
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { watchBrowserTools, watchBrowserToolsAtBrowser } from './webmcp.mjs';
13
+
14
+ const fakeSession = ({ available = true, reason = null } = {}) => {
15
+ const subscribers = new Map();
16
+ return {
17
+ subscribe(method, handler) {
18
+ const handlers = subscribers.get(method) ?? new Set();
19
+ handlers.add(handler);
20
+ subscribers.set(method, handlers);
21
+ return () => handlers.delete(handler);
22
+ },
23
+ async enableWebMcpDomain() {
24
+ return available ? { available: true } : { available: false, reason };
25
+ },
26
+ emit(method, params) {
27
+ for (const handler of [...(subscribers.get(method) ?? [])]) handler(params);
28
+ },
29
+ subscriberCount() {
30
+ let total = 0;
31
+ for (const handlers of subscribers.values()) total += handlers.size;
32
+ return total;
33
+ },
34
+ };
35
+ };
36
+
37
+ const tool = (name) => ({ name, description: `${name} does something`, frameId: 'F1' });
38
+
39
+ test('the view accumulates across one event per registration, which is how Chrome sends them', async () => {
40
+ const session = fakeSession();
41
+ const watch = await watchBrowserTools(session);
42
+
43
+ // Measured on Chrome 152: each registerTool produces its own toolsAdded event, so
44
+ // a reader that waits for one event sees one tool and calls the rest missing.
45
+ session.emit('WebMCP.toolsAdded', { tools: [tool('describe_dataset')] });
46
+ session.emit('WebMCP.toolsAdded', { tools: [tool('filter_rows')] });
47
+ session.emit('WebMCP.toolsAdded', { tools: [tool('top_expenses'), tool('monthly_trend')] });
48
+
49
+ assert.deepEqual(watch.names(), [
50
+ 'describe_dataset',
51
+ 'filter_rows',
52
+ 'top_expenses',
53
+ 'monthly_trend',
54
+ ]);
55
+ assert.equal(watch.tools().length, 4);
56
+ assert.equal(watch.tools()[0].frameId, 'F1', 'the browser knows which frame registered it');
57
+ });
58
+
59
+ test('a removed tool leaves the view and is remembered as removed', async () => {
60
+ const session = fakeSession();
61
+ const watch = await watchBrowserTools(session);
62
+
63
+ session.emit('WebMCP.toolsAdded', { tools: [tool('filter_rows'), tool('clear_highlights')] });
64
+ session.emit('WebMCP.toolsRemoved', { tools: [{ name: 'clear_highlights', frameId: 'F1' }] });
65
+
66
+ assert.deepEqual(watch.names(), ['filter_rows']);
67
+ assert.deepEqual(watch.removedNames(), ['clear_highlights']);
68
+ });
69
+
70
+ test('re-registering a name after removal puts it back exactly once', async () => {
71
+ const session = fakeSession();
72
+ const watch = await watchBrowserTools(session);
73
+
74
+ session.emit('WebMCP.toolsAdded', { tools: [tool('filter_rows')] });
75
+ session.emit('WebMCP.toolsRemoved', { tools: [{ name: 'filter_rows' }] });
76
+ session.emit('WebMCP.toolsAdded', { tools: [tool('filter_rows')] });
77
+ session.emit('WebMCP.toolsAdded', { tools: [tool('filter_rows')] });
78
+
79
+ // Names are the unit of selection, so the view is keyed by name: two tools with
80
+ // one name are one thing an agent can call, not two.
81
+ assert.deepEqual(watch.names(), ['filter_rows']);
82
+ });
83
+
84
+ test('an unavailable domain reports null rather than an empty list', async () => {
85
+ const session = fakeSession({ available: false, reason: "'WebMCP.enable' wasn't found" });
86
+ const watch = await watchBrowserTools(session);
87
+
88
+ // The distinction the classifier depends on: no view at all must never read as
89
+ // "the browser surfaced nothing", or every trial on an older build would become
90
+ // not_discovered.
91
+ assert.equal(watch.available, false);
92
+ assert.equal(watch.names(), null);
93
+ assert.equal(watch.tools(), null);
94
+ assert.equal(watch.removedNames(), null);
95
+ assert.match(watch.reason, /WebMCP\.enable/);
96
+ assert.equal(session.subscriberCount(), 0, 'an unavailable watch must not leave subscribers behind');
97
+ });
98
+
99
+ test('events without a usable name are ignored rather than crashing the reader', async () => {
100
+ const session = fakeSession();
101
+ const watch = await watchBrowserTools(session);
102
+
103
+ session.emit('WebMCP.toolsAdded', {});
104
+ session.emit('WebMCP.toolsAdded', { tools: [{ description: 'no name' }, null] });
105
+ session.emit('WebMCP.toolsRemoved', { tools: [{ frameId: 'F2' }] });
106
+
107
+ assert.deepEqual(watch.names(), []);
108
+ assert.deepEqual(watch.removedNames(), []);
109
+ });
110
+
111
+ test('stop() unsubscribes, so a closed trial cannot keep collecting', async () => {
112
+ const session = fakeSession();
113
+ const watch = await watchBrowserTools(session);
114
+
115
+ session.emit('WebMCP.toolsAdded', { tools: [tool('filter_rows')] });
116
+ assert.equal(session.subscriberCount(), 2);
117
+
118
+ watch.stop();
119
+ session.emit('WebMCP.toolsAdded', { tools: [tool('late_tool')] });
120
+
121
+ assert.equal(session.subscriberCount(), 0);
122
+ assert.deepEqual(watch.names(), ['filter_rows']);
123
+ });
124
+
125
+ /**
126
+ * The browser-endpoint view is tested against a fake socket for the same reason
127
+ * the host-attached one is tested against a fake session: the accumulation
128
+ * contract across sessions is this repo's logic, and Chrome is not. The fake
129
+ * answers CDP commands the way a build does — every command succeeds except
130
+ * `WebMCP.enable` when the scenario wants a build without the domain — and the
131
+ * tests drive `attachedToTarget` and the WebMCP events in the shapes Chrome
132
+ * delivers in flatten mode, tagged with the `sessionId` of the target each
133
+ * event came from.
134
+ */
135
+ class FakeSocket {
136
+ constructor({ enableResult = 'ok' } = {}) {
137
+ this.enableResult = enableResult;
138
+ this.sent = [];
139
+ this.closed = false;
140
+ this.handlers = new Map();
141
+ queueMicrotask(() => this.emit('open'));
142
+ }
143
+
144
+ addEventListener(type, handler) {
145
+ const list = this.handlers.get(type) ?? [];
146
+ list.push(handler);
147
+ this.handlers.set(type, list);
148
+ }
149
+
150
+ emit(type, event = {}) {
151
+ for (const handler of [...(this.handlers.get(type) ?? [])]) handler(event);
152
+ }
153
+
154
+ send(text) {
155
+ const message = JSON.parse(text);
156
+ this.sent.push(message);
157
+ const reply =
158
+ message.method === 'WebMCP.enable' && this.enableResult !== 'ok'
159
+ ? { error: { code: -32601, message: this.enableResult } }
160
+ : { result: {} };
161
+ queueMicrotask(() => this.emit('message', { data: JSON.stringify({ id: message.id, ...reply }) }));
162
+ }
163
+
164
+ close() {
165
+ this.closed = true;
166
+ }
167
+ }
168
+
169
+ const openWatch = async ({ enableResult = 'ok' } = {}) => {
170
+ const socket = new FakeSocket({ enableResult });
171
+ const watch = await watchBrowserToolsAtBrowser('ws://browser', {
172
+ WebSocket: class {
173
+ constructor() {
174
+ return socket;
175
+ }
176
+ },
177
+ });
178
+ return { watch, socket };
179
+ };
180
+
181
+ /** Attaches a target the way flattened auto-attach reports one. */
182
+ const attach = (socket, sessionId, type = 'page') =>
183
+ socket.emit('message', {
184
+ data: JSON.stringify({
185
+ method: 'Target.attachedToTarget',
186
+ params: {
187
+ sessionId,
188
+ targetInfo: { targetId: `T-${sessionId}`, type, url: 'http://site.example/' },
189
+ },
190
+ }),
191
+ });
192
+
193
+ const added = (socket, sessionId, names) =>
194
+ socket.emit('message', {
195
+ data: JSON.stringify({
196
+ method: 'WebMCP.toolsAdded',
197
+ sessionId,
198
+ params: { tools: names.map((name) => ({ name, description: `${name} does something`, frameId: 'F1' })) },
199
+ }),
200
+ });
201
+
202
+ const removedOne = (socket, sessionId, name) =>
203
+ socket.emit('message', {
204
+ data: JSON.stringify({
205
+ method: 'WebMCP.toolsRemoved',
206
+ sessionId,
207
+ params: { tools: [{ name, frameId: 'F1' }] },
208
+ }),
209
+ });
210
+
211
+ const settle = () => new Promise((done) => setTimeout(done, 0));
212
+
213
+ test('the browser-endpoint view reaches a cross-site embed the host session cannot hear', async () => {
214
+ const { watch, socket } = await openWatch();
215
+
216
+ attach(socket, 'S1', 'page');
217
+ attach(socket, 'S2', 'iframe');
218
+ await settle();
219
+
220
+ added(socket, 'S1', ['host_alpha', 'host_beta', 'host_gamma']);
221
+ added(socket, 'S2', ['widget_ping']);
222
+
223
+ // The 2026-09-05 measurement, replayed: 4 tools across 2 sessions where the
224
+ // host-attached view stops at 3. The counters are what make the number
225
+ // interpretable — an iframe session did attach, and the union came from both.
226
+ assert.deepEqual(watch.names(), ['host_alpha', 'host_beta', 'host_gamma', 'widget_ping']);
227
+ assert.equal(watch.tools().length, 4);
228
+ assert.equal(watch.available, true);
229
+ assert.equal(watch.oopiFrames, 1);
230
+ assert.equal(watch.toolSessionCount, 2);
231
+
232
+ // Recursion is the fix, so it is pinned: one arm on the browser session, then
233
+ // one per attached session. A single arm measured 6 targets with no iframe
234
+ // among them and saw 3 tools.
235
+ const arms = socket.sent.filter((message) => message.method === 'Target.setAutoAttach');
236
+ assert.equal(arms.length, 3, 'auto-attach must be armed on the browser session and on every attached session');
237
+ assert.equal(arms[1].sessionId, 'S1');
238
+ assert.equal(arms[2].sessionId, 'S2');
239
+ });
240
+
241
+ test('a removal from one session leaves the union and is remembered', async () => {
242
+ const { watch, socket } = await openWatch();
243
+
244
+ attach(socket, 'S1', 'page');
245
+ attach(socket, 'S2', 'iframe');
246
+ await settle();
247
+
248
+ added(socket, 'S1', ['host_alpha', 'host_beta']);
249
+ added(socket, 'S2', ['widget_ping']);
250
+ removedOne(socket, 'S2', 'widget_ping');
251
+
252
+ assert.deepEqual(watch.names(), ['host_alpha', 'host_beta']);
253
+ assert.deepEqual(watch.removedNames(), ['widget_ping']);
254
+ });
255
+
256
+ test('a build whose every enable refuses reports unavailable, not empty', async () => {
257
+ const { watch, socket } = await openWatch({ enableResult: "'WebMCP.enable' wasn't found" });
258
+
259
+ attach(socket, 'S1', 'page');
260
+ attach(socket, 'S2', 'iframe');
261
+ await settle();
262
+
263
+ added(socket, 'S1', ['host_alpha']);
264
+
265
+ // The distinction the classifier depends on travels across sessions too: no
266
+ // view at all must never read as "the browser surfaced nothing".
267
+ assert.equal(watch.available, false);
268
+ assert.equal(watch.names(), null);
269
+ assert.equal(watch.tools(), null);
270
+ assert.equal(watch.removedNames(), null);
271
+ assert.match(watch.reason, /WebMCP\.enable/);
272
+ });
273
+
274
+ test('a run where no target ever attached reports that, not an empty union', async () => {
275
+ const { watch } = await openWatch();
276
+
277
+ // The same exit-2 distinction browser-scope.mjs had to learn the hard way:
278
+ // "auto-attach reached nothing" and "the browser cannot see it" are different
279
+ // findings, and only one of them is about WebMCP.
280
+ assert.equal(watch.available, false);
281
+ assert.equal(watch.names(), null);
282
+ assert.equal(watch.oopiFrames, 0);
283
+ assert.equal(watch.toolSessionCount, 0);
284
+ assert.match(watch.reason, /no target session attached/);
285
+ });
286
+
287
+ test('stop() closes the socket, so a closed capture cannot keep collecting', async () => {
288
+ const { watch, socket } = await openWatch();
289
+
290
+ attach(socket, 'S1', 'page');
291
+ await settle();
292
+ added(socket, 'S1', ['host_alpha']);
293
+
294
+ watch.stop();
295
+ assert.equal(socket.closed, true);
296
+
297
+ added(socket, 'S1', ['late_tool']);
298
+ assert.deepEqual(watch.names(), ['host_alpha']);
299
+ });
package/core/args.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * One option parser for the whole repository, because two were one too many.
3
+ *
4
+ * Until 2026-09-03 `bin/` and `probes/` accepted **opposite** syntaxes and neither
5
+ * complained about the other's:
6
+ *
7
+ * - `bin/webmcp-gauge.mjs` read `--name value` and turned `--fail-under=0.9`
8
+ * into a *switch* named `fail-under=0.9`, so `flags['fail-under']` was
9
+ * undefined and the build was **not gated at all**. Latent rather than live —
10
+ * every documented example and `action.yml` use the space form — but a CI job
11
+ * written the other way would have passed silently forever.
12
+ * - the probes read `--name=value` and ignored `--serve fixtures/gallery`
13
+ * entirely, which walked the *live* gallery while claiming to walk a fixture.
14
+ *
15
+ * Same defect twice, in opposite directions: an option that does not arrive leaves
16
+ * a default in place and says nothing. So both forms are accepted everywhere now,
17
+ * and the three ways of getting it wrong are refused instead of absorbed:
18
+ *
19
+ * 1. an unknown option — a typo silently kept the default before
20
+ * 2. a value option with no value
21
+ * 3. a value handed to a switch
22
+ *
23
+ * Returns `{ options, positional, error }` and never throws: each caller owns its
24
+ * own exit code, and in this repo those codes carry meaning (`core/gate.mjs`).
25
+ *
26
+ * One older lesson is kept from the CLI's own parser, which this replaces: argv
27
+ * must be walked once, in order. Collecting positionals by filtering on "does not
28
+ * start with `--`" looks equivalent and is not — it swallowed the judge model and
29
+ * the port as positionals, and the first of them became the subject url.
30
+ */
31
+
32
+ /**
33
+ * @param {string[]} argv
34
+ * @param {{ values?: string[], switches?: string[], maxPositional?: number }} spec
35
+ */
36
+ export const parseOptions = (argv, { values = [], switches = [], maxPositional = 0 } = {}) => {
37
+ const list = Array.isArray(argv) ? argv.map((entry) => String(entry)) : [];
38
+ const valueNames = new Set(values);
39
+ const switchNames = new Set(switches);
40
+
41
+ const options = {};
42
+ const positional = [];
43
+ const fail = (error) => ({ options, positional, error });
44
+
45
+ for (let index = 0; index < list.length; index += 1) {
46
+ const token = list[index];
47
+
48
+ if (!token.startsWith('--')) {
49
+ positional.push(token);
50
+ if (positional.length > maxPositional) {
51
+ const previous = index > 0 ? list[index - 1] : null;
52
+ // The most common way to land here is the syntax that used to be ignored,
53
+ // so name the fix rather than only the symptom.
54
+ const hint =
55
+ previous && previous.startsWith('--') && !previous.includes('=')
56
+ ? ` — if it is the value for ${previous}, both ${previous}=${token} and ${previous} ${token} are accepted, but ${previous} takes no value`
57
+ : '';
58
+ return fail(`unexpected argument '${token}'${hint}`);
59
+ }
60
+ continue;
61
+ }
62
+
63
+ const equals = token.indexOf('=');
64
+ const name = equals === -1 ? token.slice(2) : token.slice(2, equals);
65
+ const inlineValue = equals === -1 ? null : token.slice(equals + 1);
66
+
67
+ if (!valueNames.has(name) && !switchNames.has(name)) {
68
+ return fail(`unknown option '--${name}'. Known options: ${[...valueNames, ...switchNames].sort().map((known) => `--${known}`).join(', ')}`);
69
+ }
70
+
71
+ if (switchNames.has(name)) {
72
+ if (inlineValue !== null) return fail(`--${name} is a switch and takes no value, got '--${name}=${inlineValue}'`);
73
+ options[name] = true;
74
+ continue;
75
+ }
76
+
77
+ if (inlineValue !== null) {
78
+ options[name] = inlineValue;
79
+ continue;
80
+ }
81
+
82
+ const next = list[index + 1];
83
+ // A following `--something` is the next option, not this one's value: the
84
+ // alternative swallows options and produces a plausible wrong run.
85
+ if (next === undefined || next.startsWith('--')) {
86
+ return fail(`--${name} needs a value, e.g. --${name}=<value>`);
87
+ }
88
+ options[name] = next;
89
+ index += 1;
90
+ }
91
+
92
+ return { options, positional, error: null };
93
+ };
@@ -0,0 +1,85 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { parseOptions } from './args.mjs';
5
+
6
+ const spec = { values: ['url', 'fail-under', 'serve'], switches: ['json', 'resume'], maxPositional: 1 };
7
+
8
+ test('both option syntaxes mean the same thing', () => {
9
+ const equals = parseOptions(['--url=https://example.com', '--fail-under=0.9'], spec);
10
+ const spaced = parseOptions(['--url', 'https://example.com', '--fail-under', '0.9'], spec);
11
+ assert.equal(equals.error, null);
12
+ assert.equal(spaced.error, null);
13
+ assert.deepEqual(equals.options, spaced.options);
14
+ assert.equal(equals.options['fail-under'], '0.9');
15
+ });
16
+
17
+ /**
18
+ * The two bugs this parser exists for, one per surface, both of which left a
19
+ * default in place and said nothing: `--fail-under=0.9` was a switch named
20
+ * `fail-under=0.9` in the CLI, so nothing was gated; `--serve fixtures/gallery`
21
+ * never reached the probes, so a rehearsal walked the live gallery.
22
+ */
23
+ test('the equals form reaches the gate, which it did not in the old CLI parser', () => {
24
+ const { options } = parseOptions(['--fail-under=0.9'], spec);
25
+ assert.equal(options['fail-under'], '0.9');
26
+ assert.equal(options['fail-under=0.9'], undefined);
27
+ });
28
+
29
+ test('the space form reaches the probes, which it did not before', () => {
30
+ const { options, error } = parseOptions(['--serve', 'fixtures/gallery', '--json'], spec);
31
+ assert.equal(error, null);
32
+ assert.equal(options.serve, 'fixtures/gallery');
33
+ assert.equal(options.json, true);
34
+ });
35
+
36
+ test('an unknown option is refused and the known ones are listed', () => {
37
+ const { error } = parseOptions(['--fail-undr=0.9'], spec);
38
+ assert.match(error, /unknown option '--fail-undr'/);
39
+ assert.match(error, /--fail-under/);
40
+ });
41
+
42
+ test('a value option with no value is refused rather than read as true', () => {
43
+ assert.match(parseOptions(['--url'], spec).error, /--url needs a value/);
44
+ assert.match(parseOptions(['--url', '--json'], spec).error, /--url needs a value/);
45
+ });
46
+
47
+ test('a switch handed a value is refused rather than silently truthy', () => {
48
+ assert.match(parseOptions(['--json=yes'], spec).error, /--json is a switch and takes no value/);
49
+ });
50
+
51
+ test('a switch does not swallow the token after it', () => {
52
+ const { options, positional, error } = parseOptions(['--json', 'subject.html'], spec);
53
+ assert.equal(error, null);
54
+ assert.equal(options.json, true);
55
+ assert.deepEqual(positional, ['subject.html']);
56
+ });
57
+
58
+ test('a positional beyond the declared budget is refused, and names the option it may belong to', () => {
59
+ const { error } = parseOptions(['--json', 'one', 'two'], spec);
60
+ assert.match(error, /unexpected argument 'two'/);
61
+ });
62
+
63
+ test('`--resume yes` is refused, and the message says the switch takes no value', () => {
64
+ const { error } = parseOptions(['--resume', 'yes'], { ...spec, maxPositional: 0 });
65
+ assert.match(error, /unexpected argument 'yes'/);
66
+ assert.match(error, /--resume takes no value/);
67
+ });
68
+
69
+ test('an equals value keeps everything after the first equals', () => {
70
+ const { options } = parseOptions(['--url=https://example.com/?a=1&b=2'], spec);
71
+ assert.equal(options.url, 'https://example.com/?a=1&b=2');
72
+ });
73
+
74
+ test('a value that looks like a flag is refused rather than consumed', () => {
75
+ const { error } = parseOptions(['--serve', '--json'], spec);
76
+ assert.match(error, /--serve needs a value/);
77
+ });
78
+
79
+ test('empty argv parses to nothing', () => {
80
+ const { options, positional, error } = parseOptions([], spec);
81
+ assert.equal(error, null);
82
+ assert.deepEqual(options, {});
83
+ assert.deepEqual(positional, []);
84
+ assert.equal(parseOptions(undefined, spec).error, null);
85
+ });
@@ -0,0 +1,86 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { pickTarget, toHarvest } from './gallery.mjs';
5
+ import { normalizeTargets } from './cohort.mjs';
6
+
7
+ /**
8
+ * The seam between the two halves of the one-day capture.
9
+ *
10
+ * `gallery-harvest.mjs` writes a targets file; `cohort-snapshot.mjs` reads it.
11
+ * Both halves were tested in isolation before gallery-publish day and the join
12
+ * between them never was — so if `toHarvest`'s row shape and
13
+ * `normalizeTargets`'s accepted shape ever disagree, the discovery would happen
14
+ * on the one day the capture cannot be repeated. These tests are that join.
15
+ */
16
+
17
+ const harvestOf = (projects) =>
18
+ toHarvest(
19
+ projects.map((project) => pickTarget(project)),
20
+ { source: 'https://example.invalid/project-gallery', harvestedAt: '2026-09-04T00:00:00.000Z' }
21
+ );
22
+
23
+ test('a harvest\u2019s targets are accepted by the snapshot without translation', () => {
24
+ const harvest = harvestOf([
25
+ {
26
+ title: 'Project One',
27
+ devpostUrl: 'https://devpost.com/software/one',
28
+ links: [{ url: 'https://one.example.com/', label: 'Try it' }, { url: 'https://github.com/o/one', label: 'Code' }],
29
+ },
30
+ {
31
+ title: 'Project Two',
32
+ devpostUrl: 'https://devpost.com/software/two',
33
+ links: [{ url: 'https://two.example.com/app', label: 'Demo' }],
34
+ },
35
+ ]);
36
+
37
+ assert.equal(harvest.counts.usable, 2);
38
+
39
+ const normalized = normalizeTargets(harvest.targets);
40
+ assert.equal(normalized.length, 2);
41
+ assert.deepEqual(
42
+ normalized.map((row) => row.url),
43
+ ['https://one.example.com/', 'https://two.example.com/app']
44
+ );
45
+ });
46
+
47
+ test('a submission with no demo link never reaches the snapshot, and keeps its reason', () => {
48
+ const harvest = harvestOf([
49
+ { title: 'No Demo', devpostUrl: 'https://devpost.com/software/nodemo', links: [{ url: 'https://github.com/o/nd' }] },
50
+ { title: 'Has Demo', devpostUrl: 'https://devpost.com/software/hasdemo', links: [{ url: 'https://demo.example.com/' }] },
51
+ ]);
52
+
53
+ assert.equal(harvest.counts.usable, 1);
54
+ assert.equal(harvest.skipped.length, 1);
55
+ assert.equal(harvest.skipped[0].reason, 'no demo-class link on the submission');
56
+ assert.equal(normalizeTargets(harvest.targets).length, 1);
57
+ });
58
+
59
+ /**
60
+ * The two halves dedupe on different keys — the harvest by project, the snapshot
61
+ * by canonical URL — so two submissions pointing at one deployment are two rows
62
+ * and one capture. That is correct, and it means the census cannot report
63
+ * "projects" and "captures" as the same number.
64
+ */
65
+ test('two submissions sharing one deployment are two harvest rows and one capture', () => {
66
+ const harvest = harvestOf([
67
+ { title: 'Team A', devpostUrl: 'https://devpost.com/software/a', links: [{ url: 'https://shared.example.com/app' }] },
68
+ { title: 'Team B', devpostUrl: 'https://devpost.com/software/b', links: [{ url: 'https://shared.example.com/app#try' }] },
69
+ ]);
70
+
71
+ assert.equal(harvest.counts.usable, 2);
72
+ assert.equal(normalizeTargets(harvest.targets).length, 1);
73
+ });
74
+
75
+ test('an empty harvest normalizes to nothing rather than throwing', () => {
76
+ const harvest = harvestOf([]);
77
+ assert.equal(harvest.counts.usable, 0);
78
+ assert.deepEqual(normalizeTargets(harvest.targets), []);
79
+ });
80
+
81
+ /**
82
+ * The option-syntax half of this story lives in `core/args.test.mjs` now. It began
83
+ * here on 2026-09-03, when `--serve fixtures/gallery` walked the **live** gallery
84
+ * because the probes read `--name=value` only — then `bin/` turned out to have the
85
+ * mirror-image bug, so both went onto one parser and the tests followed it.
86
+ */