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,162 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { appendFailures, buildPlan, readCheckpoint, readFailures, trialKey, __withDeadline as withDeadline } from './sweep.mjs';
7
+
8
+ const withTempDir = async (body) => {
9
+ const dir = await mkdtemp(join(tmpdir(), 'webmcp-gauge-sweep-'));
10
+ try {
11
+ return await body(dir);
12
+ } finally {
13
+ await rm(dir, { recursive: true, force: true });
14
+ }
15
+ };
16
+
17
+ const failure = (utteranceId, kind, session = 1) => ({
18
+ session,
19
+ repeat: 1,
20
+ utteranceId,
21
+ kind,
22
+ error: `${kind} on ${utteranceId}`,
23
+ });
24
+
25
+ test('a failure is durable as soon as it is appended, one line at a time', async () => {
26
+ await withTempDir(async (dir) => {
27
+ const path = join(dir, 'nested', 'harness-failures.jsonl');
28
+
29
+ // Written one at a time, which is how the sweep calls it: the point of the fix
30
+ // is that a process killed after the first failure still leaves the first
31
+ // failure on disk.
32
+ await appendFailures(path, [failure('filter_rows-05', 'judge_truncated')]);
33
+ const afterOne = await readFailures(path);
34
+ assert.equal(afterOne.length, 1);
35
+
36
+ await appendFailures(path, [failure('top_expenses-11', 'trial_threw')]);
37
+ const afterTwo = await readFailures(path);
38
+ assert.deepEqual(
39
+ afterTwo.map((entry) => entry.utteranceId),
40
+ ['filter_rows-05', 'top_expenses-11']
41
+ );
42
+ assert.equal((await readFile(path, 'utf8')).trim().split('\n').length, 2);
43
+ });
44
+ });
45
+
46
+ test('appending nothing writes nothing, and a missing log reads as no failures', async () => {
47
+ await withTempDir(async (dir) => {
48
+ const path = join(dir, 'harness-failures.jsonl');
49
+ await appendFailures(path, []);
50
+ await appendFailures(null, [failure('a-01', 'trial_threw')]);
51
+ assert.deepEqual(await readFailures(path), []);
52
+ });
53
+ });
54
+
55
+ test('a trial that failed twice across attempts counts once, with its newest kind', async () => {
56
+ await withTempDir(async (dir) => {
57
+ const path = join(dir, 'harness-failures.jsonl');
58
+ await appendFailures(path, [failure('top_expenses-11', 'judge_truncated')]);
59
+ await appendFailures(path, [failure('top_expenses-11', 'trial_threw')]);
60
+ await appendFailures(path, [failure('top_expenses-11', 'judge_unavailable', 2)]);
61
+
62
+ const entries = await readFailures(path);
63
+ // Same trial in session 1 twice - one entry, newest kind. Session 2 is a
64
+ // different trial key and keeps its own entry.
65
+ assert.equal(entries.length, 2);
66
+ assert.equal(entries.find((entry) => entry.session === 1).kind, 'trial_threw');
67
+ assert.equal(entries.find((entry) => entry.session === 2).kind, 'judge_unavailable');
68
+ });
69
+ });
70
+
71
+ test('a malformed log line fails loudly rather than silently dropping a failure', async () => {
72
+ await withTempDir(async (dir) => {
73
+ const path = join(dir, 'harness-failures.jsonl');
74
+ await writeFile(path, '{"session":1,"repeat":1,"utteranceId":"a-01"}\nnot json\n', 'utf8');
75
+ await assert.rejects(() => readFailures(path), SyntaxError);
76
+ });
77
+ });
78
+
79
+ test('a trial that never settles is stopped waiting for, and says so with a code', async () => {
80
+ // The stall this exists to prevent: 90 minutes on one trial with no output, because
81
+ // a CDP command never answered. A deadline turns that into a non-measurement the
82
+ // sweep records and --resume retries.
83
+ const neverSettles = new Promise(() => {});
84
+ await assert.rejects(
85
+ () => withDeadline(neverSettles, 20, 'trial x-01'),
86
+ (error) => {
87
+ assert.equal(error.code, 'DEADLINE');
88
+ assert.match(error.message, /trial x-01 did not finish within 20ms/);
89
+ return true;
90
+ }
91
+ );
92
+ });
93
+
94
+ test('the deadline does not interfere with work that finishes, or with its errors', async () => {
95
+ assert.equal(await withDeadline(Promise.resolve('measured'), 1000, 'fast'), 'measured');
96
+
97
+ // A real failure must arrive as itself, not as a timeout: the sweep classifies
98
+ // trial_threw and trial_timeout differently and the distinction is diagnostic.
99
+ await assert.rejects(
100
+ () => withDeadline(Promise.reject(new Error('page threw')), 1000, 'failing'),
101
+ (error) => {
102
+ assert.equal(error.message, 'page threw');
103
+ assert.equal(error.code, undefined);
104
+ return true;
105
+ }
106
+ );
107
+ });
108
+
109
+ test('an abandoned trial that rejects later cannot crash the process', async () => {
110
+ // The worker attaches a bare catch to the attempt for exactly this reason: after
111
+ // the deadline wins, nothing is awaiting the original promise any more.
112
+ let failLater;
113
+ const attempt = new Promise((_, reject) => {
114
+ failLater = reject;
115
+ });
116
+ attempt.catch(() => {});
117
+
118
+ await assert.rejects(() => withDeadline(attempt, 10, 'trial y-02'));
119
+ failLater(new Error('CDP Runtime.evaluate did not answer within 30000ms'));
120
+ await new Promise((resolve) => setTimeout(resolve, 20));
121
+ });
122
+
123
+ test('the plan covers every utterance once per repeat, controls included', () => {
124
+ const fixture = {
125
+ version: 'test',
126
+ tools: [
127
+ { name: 'a_tool', utterances: [{ id: 'a_tool-01' }, { id: 'a_tool-02' }] },
128
+ { name: 'b_tool', utterances: [{ id: 'b_tool-01' }] },
129
+ ],
130
+ controls: { utterances: [{ id: 'control-01' }] },
131
+ };
132
+
133
+ const plan = buildPlan({ fixture, repeatsPerSession: 2 });
134
+ assert.equal(plan.length, 8);
135
+ assert.equal(plan.filter((item) => item.kind === 'control').length, 2);
136
+
137
+ const withoutControls = buildPlan({ fixture, includeControls: false });
138
+ assert.equal(withoutControls.length, 3);
139
+
140
+ const oneTool = buildPlan({ fixture, tools: ['b_tool'], includeControls: false });
141
+ assert.deepEqual(oneTool.map((item) => item.utterance.id), ['b_tool-01']);
142
+ });
143
+
144
+ test('the checkpoint reads back as records plus the trial keys already measured', async () => {
145
+ await withTempDir(async (dir) => {
146
+ const path = join(dir, 'sweep.jsonl');
147
+ await writeFile(
148
+ path,
149
+ '{"session":2,"repeat":1,"utteranceId":"a_tool-01","outcome":"ok"}\n{"repeat":1,"utteranceId":"a_tool-02","outcome":"wrong_tool"}\n',
150
+ 'utf8'
151
+ );
152
+
153
+ const { records, keys } = await readCheckpoint(path);
154
+ assert.equal(records.length, 2);
155
+ assert.ok(keys.has(trialKey(2, 1, 'a_tool-01')));
156
+ // A record written before sessions existed defaults to session 1 rather than
157
+ // being dropped, or a resume would re-run every trial of an older run.
158
+ assert.ok(keys.has(trialKey(1, 1, 'a_tool-02')));
159
+
160
+ assert.deepEqual(await readCheckpoint(join(dir, 'missing.jsonl')), { records: [], keys: new Set() });
161
+ });
162
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The outcome taxonomy, and the argument checks that decide between its buckets.
3
+ *
4
+ * The taxonomy is the product: a pass/fail number tells a developer nothing,
5
+ * while `not_selected` vs `bad_args` vs `not_registered` tells them which line to
6
+ * edit. Every trial lands in exactly one bucket, so these functions return one
7
+ * outcome and never a set.
8
+ */
9
+
10
+ export const OUTCOMES = Object.freeze([
11
+ 'not_supported', // the client has no WebMCP surface at all
12
+ 'not_registered', // the page registered nothing, or not this tool
13
+ 'not_discovered', // the page has it, the browser never surfaced it
14
+ 'not_selected', // the judge chose no tool
15
+ 'wrong_tool', // the judge chose a different tool
16
+ 'bad_args', // right tool, arguments that do not satisfy the request
17
+ 'exec_error', // the call threw
18
+ 'silent_fail', // the call returned and nothing observably happened
19
+ 'ok',
20
+ ]);
21
+
22
+ const isPlainObject = (value) =>
23
+ typeof value === 'object' && value !== null && !Array.isArray(value);
24
+
25
+ const sameScalar = (a, b) => {
26
+ if (typeof a === 'number' && typeof b === 'number') return a === b;
27
+ if (typeof a === 'string' && typeof b === 'string') return a.trim() === b.trim();
28
+ return a === b;
29
+ };
30
+
31
+ const constraintHolds = (value, constraint) =>
32
+ Object.entries(constraint).every(([operator, bound]) => {
33
+ if (typeof value !== 'number') return false;
34
+ if (operator === 'gt') return value > bound;
35
+ if (operator === 'gte') return value >= bound;
36
+ if (operator === 'lt') return value < bound;
37
+ if (operator === 'lte') return value <= bound;
38
+ return false;
39
+ });
40
+
41
+ /**
42
+ * Checks the judge's arguments against one utterance's expectations and the
43
+ * tool's own schema. Returns every violation rather than the first, because the
44
+ * reason is what a developer acts on; the caller collapses them to `bad_args`.
45
+ */
46
+ export const checkArguments = ({ args, expectation = {}, inputSchema }) => {
47
+ const violations = [];
48
+ const supplied = isPlainObject(args) ? args : {};
49
+
50
+ if (args !== undefined && args !== null && !isPlainObject(args)) {
51
+ violations.push({ kind: 'not_an_object', detail: typeof args });
52
+ }
53
+
54
+ const schemaKeys = isPlainObject(inputSchema?.properties)
55
+ ? Object.keys(inputSchema.properties)
56
+ : null;
57
+ if (schemaKeys) {
58
+ for (const key of Object.keys(supplied)) {
59
+ if (!schemaKeys.includes(key)) violations.push({ kind: 'unknown_key', key });
60
+ }
61
+ }
62
+
63
+ for (const [key, expected] of Object.entries(expectation.expectedArgs ?? {})) {
64
+ if (!(key in supplied)) {
65
+ violations.push({ kind: 'missing_expected_key', key, expected });
66
+ continue;
67
+ }
68
+ if (!sameScalar(supplied[key], expected)) {
69
+ violations.push({ kind: 'wrong_value', key, expected, actual: supplied[key] });
70
+ }
71
+ }
72
+
73
+ for (const key of expectation.requiredArgKeys ?? []) {
74
+ if (!(key in supplied)) violations.push({ kind: 'missing_required_key', key });
75
+ }
76
+
77
+ for (const [key, constraint] of Object.entries(expectation.argConstraints ?? {})) {
78
+ if (!(key in supplied)) continue; // already reported by requiredArgKeys
79
+ if (!constraintHolds(supplied[key], constraint)) {
80
+ violations.push({ kind: 'constraint_violated', key, constraint, actual: supplied[key] });
81
+ }
82
+ }
83
+
84
+ for (const key of expectation.forbiddenArgKeys ?? []) {
85
+ if (key in supplied) violations.push({ kind: 'forbidden_key', key, actual: supplied[key] });
86
+ }
87
+
88
+ return { valid: violations.length === 0, violations };
89
+ };
90
+
91
+ /**
92
+ * Everything decidable before the tool is called. Returns null when the trial
93
+ * should proceed to execution.
94
+ *
95
+ * `browserToolNames` is optional: without the browser's own view there is no way
96
+ * to separate "the page never registered it" from "the browser never surfaced
97
+ * it", and guessing between them would invent a diagnosis.
98
+ */
99
+ export const classifyBeforeExecution = ({
100
+ manifest,
101
+ expectedTool,
102
+ selection,
103
+ expectation,
104
+ browserToolNames = null,
105
+ }) => {
106
+ if (!manifest?.present) {
107
+ return { outcome: 'not_supported', reason: 'no modelContext on this client' };
108
+ }
109
+
110
+ const pageToolNames = (manifest.tools ?? []).map((tool) => tool.name);
111
+
112
+ if (pageToolNames.length === 0) {
113
+ return { outcome: 'not_registered', reason: 'page registered no tools' };
114
+ }
115
+
116
+ if (!pageToolNames.includes(expectedTool)) {
117
+ return {
118
+ outcome: 'not_registered',
119
+ reason: `page registered ${pageToolNames.length} tools, none named ${expectedTool}`,
120
+ };
121
+ }
122
+
123
+ if (browserToolNames && !browserToolNames.includes(expectedTool)) {
124
+ return {
125
+ outcome: 'not_discovered',
126
+ reason: `page registered ${expectedTool} but the browser never surfaced it`,
127
+ };
128
+ }
129
+
130
+ if (!selection?.tool) {
131
+ return { outcome: 'not_selected', reason: 'judge chose no tool' };
132
+ }
133
+
134
+ if (selection.tool !== expectedTool) {
135
+ return { outcome: 'wrong_tool', reason: `judge chose ${selection.tool}` };
136
+ }
137
+
138
+ const schema = manifest.tools.find((tool) => tool.name === expectedTool)?.inputSchema;
139
+ const argCheck = checkArguments({
140
+ args: selection.arguments,
141
+ expectation,
142
+ inputSchema: schema,
143
+ });
144
+
145
+ if (!argCheck.valid) {
146
+ return { outcome: 'bad_args', reason: 'arguments rejected', violations: argCheck.violations };
147
+ }
148
+
149
+ return null;
150
+ };
151
+
152
+ /**
153
+ * Everything decidable after the call. `silent_fail` needs care: a read-only tool
154
+ * legitimately changes no DOM, so an empty payload AND an unchanged page is the
155
+ * only honest signal that nothing happened.
156
+ */
157
+ export const classifyAfterExecution = ({ execution, before, after }) => {
158
+ if (!execution?.ok) {
159
+ return { outcome: 'exec_error', reason: execution?.error ?? 'call failed' };
160
+ }
161
+
162
+ const result = execution.result;
163
+ const emptyResult =
164
+ result === null ||
165
+ result === undefined ||
166
+ (isPlainObject(result) && Object.keys(result).length === 0) ||
167
+ (typeof result === 'string' && result.trim() === '');
168
+ const pageChanged = JSON.stringify(before ?? null) !== JSON.stringify(after ?? null);
169
+
170
+ if (emptyResult && !pageChanged) {
171
+ return { outcome: 'silent_fail', reason: 'empty result and no observable page change' };
172
+ }
173
+
174
+ return { outcome: 'ok', reason: pageChanged ? 'result returned, page changed' : 'result returned' };
175
+ };
@@ -0,0 +1,198 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ OUTCOMES,
5
+ checkArguments,
6
+ classifyAfterExecution,
7
+ classifyBeforeExecution,
8
+ } from './taxonomy.mjs';
9
+
10
+ const manifest = {
11
+ present: true,
12
+ tools: [
13
+ {
14
+ name: 'sum_by_category',
15
+ inputSchema: { type: 'object', properties: { highlight: { type: 'string' } } },
16
+ },
17
+ {
18
+ name: 'find_anomalies',
19
+ inputSchema: { type: 'object', properties: { threshold: { type: 'number' } } },
20
+ },
21
+ ],
22
+ };
23
+
24
+ test('the taxonomy is exactly the nine documented outcomes', () => {
25
+ assert.deepEqual(OUTCOMES, [
26
+ 'not_supported',
27
+ 'not_registered',
28
+ 'not_discovered',
29
+ 'not_selected',
30
+ 'wrong_tool',
31
+ 'bad_args',
32
+ 'exec_error',
33
+ 'silent_fail',
34
+ 'ok',
35
+ ]);
36
+ });
37
+
38
+ test('a client with no modelContext is not_supported, not not_registered', () => {
39
+ const verdict = classifyBeforeExecution({
40
+ manifest: { present: false },
41
+ expectedTool: 'sum_by_category',
42
+ selection: { tool: 'sum_by_category' },
43
+ });
44
+ assert.equal(verdict.outcome, 'not_supported');
45
+ });
46
+
47
+ test('an empty or mismatched page manifest is not_registered', () => {
48
+ assert.equal(
49
+ classifyBeforeExecution({
50
+ manifest: { present: true, tools: [] },
51
+ expectedTool: 'sum_by_category',
52
+ selection: null,
53
+ }).outcome,
54
+ 'not_registered'
55
+ );
56
+
57
+ assert.equal(
58
+ classifyBeforeExecution({
59
+ manifest,
60
+ expectedTool: 'top_expenses',
61
+ selection: { tool: 'top_expenses' },
62
+ }).outcome,
63
+ 'not_registered'
64
+ );
65
+ });
66
+
67
+ test('not_discovered needs the browser view, and is never guessed without it', () => {
68
+ const withoutBrowserView = classifyBeforeExecution({
69
+ manifest,
70
+ expectedTool: 'sum_by_category',
71
+ selection: { tool: 'sum_by_category', arguments: {} },
72
+ });
73
+ assert.equal(withoutBrowserView, null, 'proceeds to execution rather than inventing a diagnosis');
74
+
75
+ const withBrowserView = classifyBeforeExecution({
76
+ manifest,
77
+ expectedTool: 'sum_by_category',
78
+ selection: { tool: 'sum_by_category', arguments: {} },
79
+ browserToolNames: ['find_anomalies'],
80
+ });
81
+ assert.equal(withBrowserView.outcome, 'not_discovered');
82
+ });
83
+
84
+ test('no selection is not_selected, another tool is wrong_tool', () => {
85
+ assert.equal(
86
+ classifyBeforeExecution({ manifest, expectedTool: 'sum_by_category', selection: null }).outcome,
87
+ 'not_selected'
88
+ );
89
+ assert.equal(
90
+ classifyBeforeExecution({
91
+ manifest,
92
+ expectedTool: 'sum_by_category',
93
+ selection: { tool: 'find_anomalies', arguments: {} },
94
+ }).outcome,
95
+ 'wrong_tool'
96
+ );
97
+ });
98
+
99
+ test('expected argument values must match, and misses are bad_args', () => {
100
+ const verdict = classifyBeforeExecution({
101
+ manifest,
102
+ expectedTool: 'sum_by_category',
103
+ selection: { tool: 'sum_by_category', arguments: { highlight: 'Dining' } },
104
+ expectation: { expectedArgs: { highlight: 'Groceries' } },
105
+ });
106
+ assert.equal(verdict.outcome, 'bad_args');
107
+ assert.equal(verdict.violations[0].kind, 'wrong_value');
108
+ });
109
+
110
+ test('a forbidden argument is over-reach, not a near miss', () => {
111
+ const verdict = classifyBeforeExecution({
112
+ manifest,
113
+ expectedTool: 'sum_by_category',
114
+ selection: { tool: 'sum_by_category', arguments: { highlight: 'Dining' } },
115
+ expectation: { forbiddenArgKeys: ['highlight'] },
116
+ });
117
+ assert.equal(verdict.outcome, 'bad_args');
118
+ assert.equal(verdict.violations[0].kind, 'forbidden_key');
119
+ });
120
+
121
+ test('constraint direction is enforced, so "be stricter" answered lower fails', () => {
122
+ const stricter = { requiredArgKeys: ['threshold'], argConstraints: { threshold: { gt: 2.5 } } };
123
+
124
+ assert.equal(
125
+ classifyBeforeExecution({
126
+ manifest,
127
+ expectedTool: 'find_anomalies',
128
+ selection: { tool: 'find_anomalies', arguments: { threshold: 1 } },
129
+ expectation: stricter,
130
+ }).violations[0].kind,
131
+ 'constraint_violated'
132
+ );
133
+
134
+ assert.equal(
135
+ classifyBeforeExecution({
136
+ manifest,
137
+ expectedTool: 'find_anomalies',
138
+ selection: { tool: 'find_anomalies', arguments: { threshold: 4 } },
139
+ expectation: stricter,
140
+ }),
141
+ null
142
+ );
143
+ });
144
+
145
+ test('arguments outside the tool schema are rejected', () => {
146
+ const { valid, violations } = checkArguments({
147
+ args: { nonsense: 1 },
148
+ inputSchema: manifest.tools[0].inputSchema,
149
+ });
150
+ assert.equal(valid, false);
151
+ assert.equal(violations[0].kind, 'unknown_key');
152
+ });
153
+
154
+ test('a missing required key is reported even when nothing else is expected', () => {
155
+ const { violations } = checkArguments({
156
+ args: {},
157
+ expectation: { requiredArgKeys: ['threshold'] },
158
+ inputSchema: manifest.tools[1].inputSchema,
159
+ });
160
+ assert.deepEqual(violations, [{ kind: 'missing_required_key', key: 'threshold' }]);
161
+ });
162
+
163
+ test('a throwing call is exec_error', () => {
164
+ assert.equal(
165
+ classifyAfterExecution({ execution: { ok: false, error: 'boom' } }).outcome,
166
+ 'exec_error'
167
+ );
168
+ });
169
+
170
+ test('silent_fail needs both an empty result and an unchanged page', () => {
171
+ const unchanged = { highlightClass: '' };
172
+
173
+ assert.equal(
174
+ classifyAfterExecution({ execution: { ok: true, result: null }, before: unchanged, after: unchanged })
175
+ .outcome,
176
+ 'silent_fail'
177
+ );
178
+
179
+ // A read-only tool legitimately changes no DOM, so a payload alone is enough.
180
+ assert.equal(
181
+ classifyAfterExecution({
182
+ execution: { ok: true, result: { totals: [] } },
183
+ before: unchanged,
184
+ after: unchanged,
185
+ }).outcome,
186
+ 'ok'
187
+ );
188
+
189
+ // And an empty payload with a visible change is a real effect.
190
+ assert.equal(
191
+ classifyAfterExecution({
192
+ execution: { ok: true, result: null },
193
+ before: unchanged,
194
+ after: { highlightClass: 'has-highlight' },
195
+ }).outcome,
196
+ 'ok'
197
+ );
198
+ });