browser-broker 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 (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,270 @@
1
+ import { PAGE_ACTIONS } from "../browser/driver.js";
2
+ /**
3
+ * The action verbs, for the help text.
4
+ *
5
+ * Read from the same list the service validates against, so a verb added
6
+ * there appears in `broker act --help` without anybody remembering to add it
7
+ * — the drift this table's own header warns about.
8
+ */
9
+ const PAGE_ACTION_NAMES = PAGE_ACTIONS;
10
+ /**
11
+ * Twelve commands for twelve tools (§5.3).
12
+ *
13
+ * ── Two of them are `broker sign in` and `broker sign in done` ──────────
14
+ *
15
+ * **Not `broker login`, which stays exactly where it is** (§5.5). That
16
+ * command is a person driving, and these two are a *caller* asking a person
17
+ * to drive — they take a lease key, and `broker login` has no lease in the
18
+ * picture at all. Both routes in exist on purpose and §5.5.1 says which is
19
+ * which: sign in ahead of time with `broker login` when you know a profile
20
+ * needs it, and let a caller ask on demand when it turns out one does.
21
+ *
22
+ * The words join to the operation name (`sign in` to `sign_in`, `sign in
23
+ * done` to `sign_in_done`), which is not a coincidence: `check-operations.mjs`
24
+ * derives a command's operation by joining its words with an underscore, so a
25
+ * command spelled any other way would be invisible to the check that proves
26
+ * the shipped executable reaches the service.
27
+ *
28
+ * The diff rides on `capture` as an argument exactly as it does on the tool
29
+ * surface (§3.11) rather than being an eleventh command, and the two removed
30
+ * tools are absent rather than deprecated (§3.1).
31
+ */
32
+ export const OPERATION_COMMANDS = [
33
+ {
34
+ words: ['claim'],
35
+ operation: 'claim',
36
+ summary: 'Ask for a lease. Get one tab, or a place in the queue.',
37
+ options: [
38
+ {
39
+ flag: '--wait',
40
+ summary: 'Poll a queued place until it is granted, lost or refused, rather than returning the place.',
41
+ },
42
+ ],
43
+ },
44
+ { words: ['status'], operation: 'status', summary: 'Where your lease stands. Extends it.' },
45
+ {
46
+ words: ['release'],
47
+ operation: 'release',
48
+ summary: 'Give back your tab, or your place in the queue.',
49
+ },
50
+ {
51
+ words: ['tab', 'replace'],
52
+ operation: 'tab_replace',
53
+ summary: 'Discard this lease’s tab and open a fresh one in its place.',
54
+ },
55
+ { words: ['navigate'], operation: 'navigate', summary: 'Point your tab at an address.' },
56
+ {
57
+ words: ['act'],
58
+ operation: 'act',
59
+ summary: 'Click, type, fill, press, select, hover, check, scroll, resize, emulate, dialog.',
60
+ // **Undocumented options are unusable options**, and this command had
61
+ // none listed at all — so `broker act --help` printed `--json` and
62
+ // `--help` and nothing else, for the verb with the most arguments on the
63
+ // surface. A caller refused by `resize` had no second place to look, which
64
+ // is what turned one bad message into a dead end.
65
+ options: [
66
+ { flag: '--action <verb>', summary: `One of: ${PAGE_ACTION_NAMES.join(', ')}.` },
67
+ {
68
+ flag: '--target <ref>',
69
+ summary: 'The element, as a reference from the most recent snapshot. Needed by click, type, ' +
70
+ 'fill, press, select, hover, check and drag.',
71
+ },
72
+ {
73
+ flag: '--value <text>',
74
+ summary: 'What to apply: the text to type or fill, the option to select, the key to press.',
75
+ },
76
+ {
77
+ flag: '--target-ref <ref>',
78
+ summary: 'The second element, for drag: where the dragged element is dropped.',
79
+ },
80
+ {
81
+ flag: '--width <n> --height <n>',
82
+ summary: 'For resize, the viewport in pixels. `--value 390x844` says the same thing.',
83
+ },
84
+ {
85
+ flag: '--colour-scheme <light|dark|no-preference>',
86
+ summary: 'For emulate. At least one preference is needed, and each is set independently.',
87
+ },
88
+ {
89
+ flag: '--reduced-motion <reduce|no-preference>',
90
+ summary: 'For emulate.',
91
+ },
92
+ {
93
+ flag: '--forced-colours <active|none>',
94
+ summary: 'For emulate.',
95
+ },
96
+ ],
97
+ },
98
+ {
99
+ words: ['read'],
100
+ operation: 'read',
101
+ summary: 'The page snapshot; console, network or cookies on request.',
102
+ },
103
+ { words: ['evaluate'], operation: 'evaluate', summary: 'Evaluate an expression in the page.' },
104
+ {
105
+ words: ['capture'],
106
+ operation: 'capture',
107
+ summary: 'Take a picture, and optionally the difference from an earlier one.',
108
+ },
109
+ {
110
+ words: ['sign', 'in'],
111
+ operation: 'sign_in',
112
+ summary: 'Ask a person to sign in on the tab this lease already holds.',
113
+ options: [
114
+ { flag: '--what <text>', summary: 'What they are signing into, relayed to them verbatim.' },
115
+ {
116
+ flag: '--request-seconds <n>',
117
+ summary: 'A shorter wait than the default. Capped, never extended.',
118
+ },
119
+ ],
120
+ },
121
+ {
122
+ words: ['sign', 'in', 'done'],
123
+ operation: 'sign_in_done',
124
+ summary: 'The person has signed in: give the browser back, keeping this lease and its tab.',
125
+ },
126
+ {
127
+ words: ['feedback'],
128
+ operation: 'feedback',
129
+ summary: 'Record that something helped or got in the way; with no arguments, read the rows back.',
130
+ },
131
+ ];
132
+ /**
133
+ * The commands with no service operation behind them.
134
+ *
135
+ * §5.5 names four; the fifth is `diffs`, which reads the comparison history
136
+ * back. It sits here rather than beside the operations because it decides
137
+ * nothing and takes no lease — it is a read of what has already been recorded,
138
+ * which is the same reason `events` is not an operation either.
139
+ */
140
+ export const STANDALONE_COMMANDS = [
141
+ {
142
+ words: ['snapshot'],
143
+ summary: 'Write the operations document to a path and exit.',
144
+ owedBy: 'the row that builds the operations document',
145
+ },
146
+ {
147
+ words: ['doctor'],
148
+ summary: 'Report every precondition separately, and exit non-zero on any failure.',
149
+ owedBy: 'the row that builds the preconditions report',
150
+ },
151
+ {
152
+ words: ['login'],
153
+ summary: 'Claim a browser for a person to sign in to.',
154
+ owedBy: 'the row that builds the sign-in handshake',
155
+ },
156
+ {
157
+ words: ['init'],
158
+ summary: 'Run the setup handshake explicitly and show its report.',
159
+ owedBy: 'the row that builds the setup report',
160
+ },
161
+ {
162
+ words: ['diffs'],
163
+ summary: 'List the recorded comparisons, filtered by lease, tab or outcome.',
164
+ owedBy: 'the row that builds changed-region review',
165
+ options: [
166
+ { flag: '--capture <id>', summary: 'Only comparisons made from this capture.' },
167
+ { flag: '--target <id>', summary: 'Only comparisons made against this earlier capture.' },
168
+ { flag: '--lease <id>', summary: 'Only comparisons recorded under this lease.' },
169
+ { flag: '--limit <n>', summary: 'At most this many comparisons.' },
170
+ ],
171
+ },
172
+ {
173
+ // The bytes of one recorded image, named by the identifier of a row.
174
+ // Standalone for the same reason `diffs` is: it takes no tab budget,
175
+ // drives no browser and decides nothing — it reads what is already
176
+ // recorded. §3.1 fixes the agent surface at twelve tools and none of them
177
+ // serves bytes, deliberately: the tools return paths so a caller pays for
178
+ // the part it opens.
179
+ words: ['image'],
180
+ summary: 'Write the bytes of one recorded image — a capture, an overlay or a region crop.',
181
+ owedBy: 'the row that builds image delivery',
182
+ options: [
183
+ { flag: '--capture <id>', summary: 'The capture whose bytes to write.' },
184
+ { flag: '--overlay <id>', summary: 'The comparison whose overlay to write.' },
185
+ { flag: '--region <id>', summary: 'The comparison whose changed region to crop.' },
186
+ { flag: '--index <n>', summary: 'Which region, largest first; defaults to the largest.' },
187
+ { flag: '--side <before|after>', summary: 'Which capture a region crop is cut from.' },
188
+ { flag: '--out <file>', summary: 'The file to write the bytes to. Required.' },
189
+ { flag: '--lease-key <key>', summary: 'Your lease key; an artifact belongs to its lease.' },
190
+ ],
191
+ },
192
+ {
193
+ // The capture telemetry rollups (#37). Standalone for the same reason
194
+ // `diffs` is: it takes no lease and no tab budget, drives no browser and
195
+ // decides nothing — it adds up what has already been recorded. §5.4 puts
196
+ // "list captures" on the operations command surface, which is this one.
197
+ words: ['captures'],
198
+ summary: 'What pictures cost, and what diffs did, over a window.',
199
+ owedBy: 'the row that builds the capture telemetry rollups',
200
+ options: [
201
+ { flag: '--since <t>', summary: 'Captures taken at or after this timestamp.' },
202
+ { flag: '--until <t>', summary: 'Captures taken strictly before this timestamp.' },
203
+ { flag: '--lease <id>', summary: 'Only captures taken under this lease.' },
204
+ { flag: '--capture <id>', summary: 'Instead: what diffs ran from and against one capture.' },
205
+ { flag: '--targets', summary: 'Instead: which captures are most diffed against.' },
206
+ { flag: '--limit <n>', summary: 'At most this many rows, where a listing is returned.' },
207
+ ],
208
+ },
209
+ {
210
+ // The administrative operation that asks a live browser what it actually
211
+ // has open (§2.6 step 2, §4.3). Standalone because it is not one of the
212
+ // ten and must never be: closing a page is browser-scoped, so §3.13 keeps
213
+ // it off the agent surface and `browser_scoped.never` (§7.3) makes that a
214
+ // build rule. It is a command for the reason §4.3 gives about the rest of
215
+ // that surface — a person runs it, and the ledger records that a person
216
+ // did.
217
+ words: ['reconcile'],
218
+ summary: 'Ask a browser what it has open; close pages no live lease owns, and end leases whose page is gone.',
219
+ owedBy: 'the row that builds reconciliation against a live browser',
220
+ options: [
221
+ {
222
+ flag: '--browser <regular|private>',
223
+ summary: 'Which browser to reconcile. May also be given as the first word.',
224
+ },
225
+ ],
226
+ },
227
+ {
228
+ // Reading the ledger back. Standalone for the same reason `diffs` is: it
229
+ // takes no lease and no tab budget, drives no browser and decides nothing —
230
+ // it is a read of history. Listed here because this table is what
231
+ // `broker --help` prints, and a command absent from it is a command a
232
+ // caller has no way to discover.
233
+ words: ['events'],
234
+ summary: 'Read the decision ledger, filtered by kind, outcome, guard, session or claim.',
235
+ owedBy: 'the row that builds the ledger read',
236
+ options: [
237
+ { flag: '--kind <a,b>', summary: 'Only these event kinds, comma-separated.' },
238
+ { flag: '--outcome <name>', summary: 'Only entries with this outcome.' },
239
+ { flag: '--guard <rule>', summary: 'Only entries naming this rule.' },
240
+ { flag: '--session-id <id>', summary: 'Only entries for this session.' },
241
+ { flag: '--claim-id <id>', summary: 'Only entries for this claim.' },
242
+ { flag: '--since <n>', summary: 'Entries after this cursor, oldest first.' },
243
+ { flag: '--before <n>', summary: 'Entries before this cursor.' },
244
+ { flag: '--limit <n>', summary: 'At most this many entries.' },
245
+ ],
246
+ },
247
+ ];
248
+ /**
249
+ * Match an argument vector against the table, longest name first.
250
+ *
251
+ * Longest first is not a preference: `tab replace` is two words and `tab`
252
+ * alone is not a command, so a shortest-first match would resolve `tab
253
+ * replace` to an unknown noun and never reach the verb.
254
+ */
255
+ export function parseCommand(argv) {
256
+ const candidates = [
257
+ ...OPERATION_COMMANDS.map((command) => ({ kind: 'operation', command })),
258
+ ...STANDALONE_COMMANDS.map((command) => ({ kind: 'standalone', command })),
259
+ ].sort((a, b) => b.command.words.length - a.command.words.length);
260
+ for (const candidate of candidates) {
261
+ const { words } = candidate.command;
262
+ if (words.every((word, index) => argv[index] === word)) {
263
+ const rest = argv.slice(words.length);
264
+ return candidate.kind === 'operation'
265
+ ? { kind: 'operation', command: candidate.command, rest }
266
+ : { kind: 'standalone', command: candidate.command, rest };
267
+ }
268
+ }
269
+ return { kind: 'unknown', attempted: argv.filter((word) => !word.startsWith('-')).join(' ') };
270
+ }
@@ -0,0 +1,119 @@
1
+ import { cliAdapter, EXIT } from "./adapter.js";
2
+ import { OPERATION_COMMANDS } from "./commands.js";
3
+ import { run } from "./index.js";
4
+ /**
5
+ * How the conformance suite drives the command line.
6
+ *
7
+ * ── It goes through the real entry point, and that is the whole point ────
8
+ *
9
+ * `MILESTONES.md`: "drive the command line through its entry point with an
10
+ * argument vector". So this calls {@link run} — the same function
11
+ * `src/bin/broker.ts` calls — with an argv it built, and reads the outcome
12
+ * back out of the exit code and the streams.
13
+ *
14
+ * **The alternative is the hollow version, and it is worth naming so nobody
15
+ * reinvents it.** A driver that called `cliAdapter.invoke` directly, or worse
16
+ * called the service itself, would produce a green matrix while testing none
17
+ * of the argument parsing, none of the exit-code mapping and none of the
18
+ * never-printed rule — every part of this route that could hold a rule of its
19
+ * own. That is the "tested a local copy of the logic so the shipped code was
20
+ * never exercised" failure, and it passes.
21
+ *
22
+ * ── Why it reads the machine-readable mode ──────────────────────────────
23
+ *
24
+ * The suite compares codes and rule names, never sentences (`SCHEMA.md`
25
+ * §3.14). The machine-readable mode is where this route puts a code and a
26
+ * rule name as data rather than as prose, so parsing that document is reading
27
+ * the route's own contract instead of matching English out of a message —
28
+ * which would be brittle *and* would be asserting the one thing §3.14 says
29
+ * never to compare across surfaces.
30
+ */
31
+ /** Turn a neutral case input into this route's own vocabulary: an argv. */
32
+ export function argvFor(testCase) {
33
+ const command = OPERATION_COMMANDS.find((entry) => entry.operation === testCase.operation);
34
+ if (command === undefined) {
35
+ throw new Error(`the command line has no command for "${testCase.operation}"`);
36
+ }
37
+ const argv = [...command.words];
38
+ for (const [key, value] of Object.entries(testCase.input)) {
39
+ // The terminal spells keys with hyphens; the service names them with
40
+ // underscores (§3). The adapter normalises on the way in, so the
41
+ // translation is inverted here rather than being a second convention.
42
+ const flag = `--${key.replaceAll('_', '-')}`;
43
+ if (value === true) {
44
+ argv.push(flag);
45
+ continue;
46
+ }
47
+ if (value === false || value === undefined || value === null) {
48
+ continue;
49
+ }
50
+ // A shell carries text. A case whose input is a structure has written
51
+ // something this transport cannot express, and coercing it would put
52
+ // `[object Object]` on the command line and then assert against whatever
53
+ // came back — a comparison that runs, means nothing, and passes. Failing
54
+ // here names the case instead.
55
+ if (typeof value !== 'string' && typeof value !== 'number') {
56
+ throw new TypeError(`case "${testCase.name}" gives ${flag} a value the command line cannot carry`);
57
+ }
58
+ argv.push(flag, String(value));
59
+ }
60
+ argv.push('--json');
61
+ return argv;
62
+ }
63
+ /**
64
+ * Read the outcome back out of what a command wrote and the code it exited
65
+ * with.
66
+ *
67
+ * Both are checked against each other rather than one being trusted: a
68
+ * document saying `refused` alongside an exit code of zero is a real bug on
69
+ * this route — a caller reading only the code would proceed as though the
70
+ * operation had happened (§5.6) — so the mismatch is raised here instead of
71
+ * being silently resolved in favour of whichever is read first.
72
+ */
73
+ export function outcomeFrom(code, out) {
74
+ const document = JSON.parse(out.join('\n'));
75
+ if (document === null || typeof document !== 'object') {
76
+ throw new Error('the machine-readable mode did not produce a document');
77
+ }
78
+ const record = document;
79
+ if (record['outcome'] === 'accepted') {
80
+ if (code !== EXIT.accepted) {
81
+ throw new Error(`accepted document alongside exit code ${String(code)}`);
82
+ }
83
+ return { outcome: 'accepted', value: (record['value'] ?? {}) };
84
+ }
85
+ if (record['outcome'] === 'refused') {
86
+ if (code !== EXIT.refused) {
87
+ throw new Error(`refused document alongside exit code ${String(code)}`);
88
+ }
89
+ return {
90
+ outcome: 'refused',
91
+ code: String(record['code']),
92
+ rule: String(record['rule']),
93
+ // The sentence went to the error stream in this mode and is never
94
+ // compared (§3.14), so it is not read back here.
95
+ message: '',
96
+ ...(record['details'] === undefined
97
+ ? {}
98
+ : { details: record['details'] }),
99
+ };
100
+ }
101
+ throw new Error(`the document names no outcome: ${out.join('\n')}`);
102
+ }
103
+ /** The command line's conformance driver. */
104
+ export const cliConformanceDriver = {
105
+ adapter: cliAdapter,
106
+ run: async (service, testCase, observe) => {
107
+ const out = [];
108
+ const err = [];
109
+ const code = await run(argvFor(testCase), {
110
+ service,
111
+ streams: { out: (line) => out.push(line), err: (line) => err.push(line) },
112
+ });
113
+ return {
114
+ outcome: outcomeFrom(code, out),
115
+ driverCalls: observe.driverCalls(),
116
+ liveClaimCount: observe.liveClaimCount(),
117
+ };
118
+ },
119
+ };
@@ -0,0 +1,122 @@
1
+ import { listComparisons } from "../service/comparison-store.js";
2
+ export const DIFFS_USAGE = [
3
+ 'broker diffs — list the comparisons this store has recorded.',
4
+ '',
5
+ 'Usage:',
6
+ ' broker diffs [--capture <id>] [--target <id>] [--lease <id>] [--limit <n>] [--json]',
7
+ '',
8
+ 'Filters combine, so the diffs one lease ran against one capture is two of them:',
9
+ ' --capture <id> diffs run FROM this capture',
10
+ ' --target <id> diffs run AGAINST this capture',
11
+ ' --lease <id> diffs run by this lease',
12
+ ' --limit <n> at most this many, most recent first',
13
+ ' --json one JSON document, for something reading rather than someone',
14
+ ].join('\n');
15
+ const FILTERS = {
16
+ '--capture': 'sourceCaptureId',
17
+ '--target': 'targetCaptureId',
18
+ '--lease': 'claimId',
19
+ };
20
+ /**
21
+ * Parse the arguments after `diffs`.
22
+ *
23
+ * **An unrecognised flag refuses rather than being ignored**, and a value-taking
24
+ * flag with nothing after it refuses too. Ignoring either would run a query
25
+ * nobody asked for and print a result that looks like an answer — the same
26
+ * reasoning §6.3 applies to a variable that is set and unreadable.
27
+ */
28
+ export function parseDiffsArguments(argv) {
29
+ const query = {};
30
+ let json = false;
31
+ for (let at = 0; at < argv.length; at += 1) {
32
+ const argument = argv[at] ?? '';
33
+ if (argument === '--json') {
34
+ json = true;
35
+ continue;
36
+ }
37
+ if (argument === '--limit') {
38
+ const raw = argv[at + 1];
39
+ if (raw === undefined) {
40
+ return { ok: false, message: '--limit needs a number after it.' };
41
+ }
42
+ const value = Number(raw);
43
+ if (!Number.isInteger(value) || value < 1) {
44
+ return {
45
+ ok: false,
46
+ message: `--limit takes a whole number of at least one; got ${JSON.stringify(raw)}.`,
47
+ };
48
+ }
49
+ query.limit = value;
50
+ at += 1;
51
+ continue;
52
+ }
53
+ const field = FILTERS[argument];
54
+ if (field !== undefined) {
55
+ const value = argv[at + 1];
56
+ if (value === undefined || value.startsWith('--')) {
57
+ return { ok: false, message: `${argument} needs an identifier after it.` };
58
+ }
59
+ query[field] = value;
60
+ at += 1;
61
+ continue;
62
+ }
63
+ return { ok: false, message: `Unrecognised option: ${argument}` };
64
+ }
65
+ return { ok: true, query, json };
66
+ }
67
+ /** A ratio as a percentage with two decimals, which is the readable form. */
68
+ function asPercentage(ratio) {
69
+ return `${(ratio * 100).toFixed(2)}%`;
70
+ }
71
+ /**
72
+ * Run the command.
73
+ *
74
+ * Returns an exit code rather than exiting, so the whole of it is reachable
75
+ * from a test — the arrangement `src/cli/index.ts` establishes and the reason
76
+ * its dispatcher is importable.
77
+ */
78
+ export function runDiffs(argv, options) {
79
+ if (argv.includes('--help') || argv.includes('-h')) {
80
+ options.streams.out(DIFFS_USAGE);
81
+ return 0;
82
+ }
83
+ const parsed = parseDiffsArguments(argv);
84
+ if (!parsed.ok) {
85
+ options.streams.err(parsed.message);
86
+ options.streams.err(DIFFS_USAGE);
87
+ return 2;
88
+ }
89
+ const comparisons = listComparisons(options.db, parsed.query);
90
+ if (parsed.json) {
91
+ options.streams.out(JSON.stringify(comparisons, null, 2));
92
+ return 0;
93
+ }
94
+ if (comparisons.length === 0) {
95
+ // **Says what was asked, not merely that there was nothing.** An empty
96
+ // listing after a mistyped identifier looks identical to one after a
97
+ // correct identifier with no diffs behind it, and the first is far more
98
+ // likely — the same reasoning §1.9 applies to a missing diff target.
99
+ const asked = Object.entries(parsed.query)
100
+ .filter(([, value]) => value !== undefined)
101
+ .map(([key, value]) => `${key}=${String(value)}`);
102
+ options.streams.out(asked.length === 0
103
+ ? 'No comparisons recorded. A diff is an optional argument on a capture, so none is recorded until a capture names an earlier one to compare against.'
104
+ : `No comparisons match ${asked.join(', ')}.`);
105
+ return 0;
106
+ }
107
+ for (const comparison of comparisons) {
108
+ options.streams.out([
109
+ comparison.at,
110
+ comparison.id,
111
+ comparison.changed ? 'CHANGED' : 'unchanged',
112
+ `${String(comparison.regions.length)} region(s)${comparison.truncated ? ' (truncated)' : ''}`,
113
+ `${String(comparison.changedPixels)} px (${asPercentage(comparison.changedRatio)})`,
114
+ ].join(' '));
115
+ options.streams.out(` from ${comparison.sourceCaptureId} against ${comparison.targetCaptureId}, lease ${comparison.claimId}`);
116
+ // The three settings, on every row. This is the line tuning reads.
117
+ options.streams.out(` settings: tolerance ${String(comparison.colourTolerance)}, minimum area ${String(comparison.minimumRegionArea)} px squared, at most ${String(comparison.maximumRegions)} regions`);
118
+ options.streams.out(` overlay: ${comparison.overlayPath}`);
119
+ }
120
+ options.streams.out(`${String(comparisons.length)} comparison${comparisons.length === 1 ? '' : 's'}.`);
121
+ return 0;
122
+ }