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,446 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { StartupRefusal } from "../errors.js";
4
+ /**
5
+ * The per-user application-data location the platform defines, assembled from
6
+ * the home directory and ordinary segment strings.
7
+ */
8
+ function applicationDataDirectory(home, platform) {
9
+ if (platform === 'win32') {
10
+ return path.join(home, 'AppData', 'Local');
11
+ }
12
+ if (platform === 'darwin') {
13
+ return path.join(home, 'Library', 'Application Support');
14
+ }
15
+ return path.join(home, '.local', 'share');
16
+ }
17
+ /** A directory of the service's own under that location. */
18
+ function ownDirectory(home, platform) {
19
+ return path.join(applicationDataDirectory(home, platform), 'browser-broker');
20
+ }
21
+ const DECLARATIONS = [
22
+ {
23
+ key: 'BROKER_DB',
24
+ kind: 'path',
25
+ fallback: (home, platform) => path.join(ownDirectory(home, platform), 'broker.db'),
26
+ },
27
+ {
28
+ key: 'BROKER_ARTIFACTS_ROOT',
29
+ kind: 'path',
30
+ fallback: (home, platform) => path.join(ownDirectory(home, platform), 'artefacts'),
31
+ },
32
+ {
33
+ key: 'BROKER_PROFILE_ROOT',
34
+ kind: 'path',
35
+ fallback: (home, platform) => path.join(ownDirectory(home, platform), 'profiles'),
36
+ },
37
+ {
38
+ /**
39
+ * The total tab budget across **both** browsers (§6.2), and — since a
40
+ * lease is a tab (§2.3) — the same number as the maximum count of live
41
+ * leases. No per-browser cap: the scarce thing is page processes and one
42
+ * costs the same in either browser.
43
+ *
44
+ * **This is the one value also written to the store** (§1.10), because
45
+ * several processes arbitrate against it at the same moment and two of
46
+ * them believing different numbers means the ceiling silently stops being
47
+ * one. `src/store/budget.ts` is the agreement check; this is only where
48
+ * the value is read.
49
+ */
50
+ key: 'BROKER_TAB_BUDGET',
51
+ kind: 'positive-integer',
52
+ fallback: 15,
53
+ unit: 'a count of tabs',
54
+ },
55
+ {
56
+ /**
57
+ * How long an active lease lives without a call (§6.2).
58
+ *
59
+ * **Deliberately not given the agreement check the budget gets** (§1.10).
60
+ * Two processes disagreeing here expires something early or late, which
61
+ * is degraded behaviour rather than a broken invariant — no bound is
62
+ * violated and no capacity is over-allocated. That distinction is the
63
+ * rule: a value several processes must *agree* on gets the row; a value
64
+ * they merely each *use* does not.
65
+ */
66
+ key: 'BROKER_LEASE_SECONDS',
67
+ kind: 'positive-integer',
68
+ fallback: 600,
69
+ unit: 'a duration in seconds',
70
+ },
71
+ {
72
+ /**
73
+ * How long a place in the queue lives without a call (§6.2).
74
+ *
75
+ * **Equal to the lease lifetime, deliberately** (§2.5). Both arguments
76
+ * for making them differ pointed the other way: polling *is* renewing, so
77
+ * a queued caller holds exactly the instrument an active holder does; and
78
+ * under strict ordering a queue place held longer blocks everyone behind
79
+ * it, so a generous queued lifetime is the harsher setting rather than
80
+ * the kinder one.
81
+ *
82
+ * It is a separate variable rather than a reuse of the one above because
83
+ * they are two decisions that presently agree, and collapsing them would
84
+ * make changing one impossible without changing both.
85
+ */
86
+ key: 'BROKER_QUEUE_SECONDS',
87
+ kind: 'positive-integer',
88
+ fallback: 600,
89
+ unit: 'a duration in seconds',
90
+ },
91
+ {
92
+ /**
93
+ * How long a launch-race loser waits for the winner's browser to accept
94
+ * a connection before declaring the launch failed (§1.2b, §9.3, row #55).
95
+ *
96
+ * **Settled by row #55: the signal is `verifyDiscoveryRecord` — liveness
97
+ * plus identity, §1.2c — polled, not a fixed pause.** This is only the
98
+ * bound on how long that poll runs, and it is the same 30 seconds the
99
+ * loser already waited before this row made the number configurable.
100
+ *
101
+ * **Deliberately not given the agreement check the tab budget gets**
102
+ * (§1.10). Two processes disagreeing here means one loser gives up
103
+ * sooner or later than another watching the same launch — degraded
104
+ * behaviour, not a broken invariant, so it does not need the row.
105
+ */
106
+ key: 'BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS',
107
+ kind: 'positive-integer',
108
+ fallback: 30,
109
+ unit: 'a duration in seconds',
110
+ },
111
+ {
112
+ /**
113
+ * The names of the persistent, signed-in browsers (§1.2, `DECISIONS.md`
114
+ * §13i).
115
+ *
116
+ * **A bounded list defaulting to one entry**, which is what keeps the
117
+ * property §6 defends: process count is bounded by configuration, not by
118
+ * how many callers connect. The bound is a constant in this file and
119
+ * nothing reads it per call, so there is no request that can widen it.
120
+ *
121
+ * The first entry is what an unstated `browser` resolves to on a claim
122
+ * (§3.2) — the reason it is *first* rather than a separate setting is
123
+ * that a separate setting could name a browser that is not in the list.
124
+ */
125
+ key: 'BROKER_REGULAR_BROWSERS',
126
+ kind: 'name-list',
127
+ fallback: ['regular'],
128
+ maximum: 3,
129
+ browserKind: 'signed-in',
130
+ },
131
+ {
132
+ /**
133
+ * The names of the ephemeral, signed-in-to-nothing browsers (§1.2).
134
+ *
135
+ * **Capped separately from the list above rather than sharing a total**,
136
+ * deliberately: the two kinds are not interchangeable. One total would let
137
+ * a configuration spend every place on signed-in browsers and leave **no
138
+ * clean-room browser at all**, and clean-room is the one that cannot be
139
+ * substituted for — a signed-in browser cannot show what a page does for
140
+ * somebody who has never been there. A cap per list guarantees both kinds
141
+ * remain reachable.
142
+ */
143
+ key: 'BROKER_PRIVATE_BROWSERS',
144
+ kind: 'name-list',
145
+ fallback: ['private'],
146
+ maximum: 3,
147
+ browserKind: 'clean-room',
148
+ },
149
+ {
150
+ /**
151
+ * Which browser binary the signed-in browsers launch (§6.2).
152
+ *
153
+ * **One engine per kind, never per browser**, for the same reason there is
154
+ * no per-entry private flag: an engine per entry reintroduces the
155
+ * per-entry attribute this configuration exists without.
156
+ *
157
+ * The three accepted words are all Chromium over the same remote-debugging
158
+ * protocol, which is what makes the choice a binary path rather than a
159
+ * second driver.
160
+ */
161
+ key: 'BROKER_REGULAR_BROWSER_ENGINE',
162
+ kind: 'enum',
163
+ fallback: 'msedge',
164
+ allowed: ['chrome', 'brave', 'msedge'],
165
+ unit: 'a browser engine',
166
+ },
167
+ {
168
+ /**
169
+ * Which browser binary the clean-room browsers launch (§6.2).
170
+ *
171
+ * **May differ from the signed-in engine**, and separate variables are
172
+ * what make that expressible: a person signs into the signed-in browser by
173
+ * hand, so which binary that is can be a matter of what they already use,
174
+ * while nobody signs into a clean-room browser at all.
175
+ */
176
+ key: 'BROKER_PRIVATE_BROWSER_ENGINE',
177
+ kind: 'enum',
178
+ fallback: 'msedge',
179
+ allowed: ['chrome', 'brave', 'msedge'],
180
+ unit: 'a browser engine',
181
+ },
182
+ ];
183
+ /** Every variable this build declares. Row #9's walk test reads this. */
184
+ export const DECLARED_VARIABLES = DECLARATIONS.map((d) => d.key);
185
+ /**
186
+ * Read a value as its declared type, applying §6.3's table.
187
+ *
188
+ * Unset uses the default; set and valid is used; **set and unreadable
189
+ * refuses, naming the variable**. Falling back to the default silently would
190
+ * run a configuration nobody chose with nothing to notice it by.
191
+ */
192
+ function readPath(declaration, raw, fallback) {
193
+ if (raw === undefined) {
194
+ return fallback;
195
+ }
196
+ // A path that is empty or blank is set-and-unreadable rather than unset:
197
+ // somebody wrote the variable and meant something by it, and no path is
198
+ // the one thing it cannot mean.
199
+ if (raw.trim() === '') {
200
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set but empty. Expected a filesystem path; unset it to use the default.`);
201
+ }
202
+ // A null byte cannot appear in a path and every filesystem call would
203
+ // throw on it far from here, naming neither the variable nor the value.
204
+ if (raw.includes('\0')) {
205
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set to a value that is not a filesystem path. Expected a path, found a string containing a null byte.`);
206
+ }
207
+ return path.resolve(raw);
208
+ }
209
+ /**
210
+ * Read a value as a whole number greater than zero, applying §6.3's table.
211
+ *
212
+ * **The rejections are the specification here.** Every one of these is a
213
+ * value somebody wrote deliberately, and the alternative to refusing is
214
+ * running a configuration nobody chose:
215
+ *
216
+ * - **A blank value.** Somebody set the variable and meant something by it,
217
+ * and no number is the one thing it cannot mean.
218
+ * - **Anything that is not entirely digits**, including a decimal point, a
219
+ * sign, a trailing unit and leading text. Reading `10s` as ten would be a
220
+ * guess, and reading `1e3` as a thousand would let a typo of a thousand
221
+ * pass as a small number somewhere else.
222
+ * - **Zero.** A budget of zero admits nobody and a lifetime of zero expires
223
+ * every lease before its first call, so both are configurations in which
224
+ * the service cannot work at all. Refusing at the loudest moment is
225
+ * better than every call refusing for a reason nothing names.
226
+ * - **Anything above the safe-integer boundary**, because past it arithmetic
227
+ * stops being exact and a comparison against a budget stops being one.
228
+ */
229
+ function readPositiveInteger(declaration, raw) {
230
+ if (raw === undefined) {
231
+ return declaration.fallback;
232
+ }
233
+ const trimmed = raw.trim();
234
+ if (trimmed === '') {
235
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set but empty. Expected ${declaration.unit} as a whole number above zero; unset it to use the default of ${String(declaration.fallback)}.`);
236
+ }
237
+ // Digits only. A permissive parse would read a decimal, a sign or a
238
+ // trailing unit as a number the caller did not write, and every one of
239
+ // those is a value somebody typed on purpose and got wrong.
240
+ if (!/^\d+$/.test(trimmed)) {
241
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set to ${JSON.stringify(raw)}, which is not a whole number. Expected ${declaration.unit} written in digits alone, above zero.`);
242
+ }
243
+ const value = Number(trimmed);
244
+ if (value === 0) {
245
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set to zero. Expected ${declaration.unit} above zero; a value of zero is a configuration in which the service cannot serve anybody.`);
246
+ }
247
+ if (!Number.isSafeInteger(value)) {
248
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set to ${JSON.stringify(raw)}, which is larger than this runtime counts exactly. Expected ${declaration.unit} below ${String(Number.MAX_SAFE_INTEGER)}.`);
249
+ }
250
+ return value;
251
+ }
252
+ /**
253
+ * A browser name is a word this service will make a directory of.
254
+ *
255
+ * **Narrow on purpose.** A name reaches three places that each have their own
256
+ * idea of a legal string: it is a primary key in the store, it is the profile
257
+ * directory's own name under the configured profile root (§1.2), and it is
258
+ * what a caller types on `browser_claim`. Lower-case letters, digits and a
259
+ * hyphen is the intersection, and refusing outside it here — once, at startup,
260
+ * naming the entry — is the only place the refusal can name what was wrong.
261
+ * Every later refusal would be a directory error or a constraint failure
262
+ * naming neither the variable nor the value.
263
+ *
264
+ * A leading digit or hyphen is refused because a name is read as a word by a
265
+ * person choosing between browsers, and the two names in the default
266
+ * configuration are words.
267
+ */
268
+ const BROWSER_NAME = /^[a-z][a-z0-9-]*$/;
269
+ /** The longest a name may be, so a directory name stays a directory name. */
270
+ const BROWSER_NAME_MAXIMUM = 32;
271
+ /**
272
+ * Read a comma-separated list of browser names, applying §6.3's table.
273
+ *
274
+ * **Every rejection names the offending entry**, not merely the variable.
275
+ * §6.3's rule is *"refuse to start, naming the variable and what was
276
+ * expected"*, and for a list the useful half of "what was expected" is which
277
+ * token failed — a caller told only that `BROKER_REGULAR_BROWSERS` is wrong
278
+ * has to work out which of three names it meant.
279
+ *
280
+ * The rejections:
281
+ *
282
+ * - **Blank, or a blank entry.** Somebody wrote the variable and meant
283
+ * something by it, and no browser is the one thing it cannot mean. `a,,b` is
284
+ * a typo rather than a two-entry list, and reading it as one would silently
285
+ * run a configuration nobody wrote.
286
+ * - **A name outside the shape above**, including whitespace inside a name and
287
+ * an upper-case letter. The store's key, the directory's name and the
288
+ * caller's word are the same string, so it has to be legal in all three.
289
+ * - **A duplicate within one list.** Two entries naming one browser is not a
290
+ * pair of browsers, so a list that reads as three and launches two is a
291
+ * configuration whose count nobody can trust.
292
+ * - **More entries than the cap.** The cap is what keeps the bound §6 defends
293
+ * — *"as many as are asked for, with a politer name"* is what an
294
+ * unbounded list is — so exceeding it is refused rather than truncated.
295
+ * Truncating would drop a browser somebody configured and say nothing.
296
+ */
297
+ function readNameList(declaration, raw) {
298
+ if (raw === undefined) {
299
+ return declaration.fallback;
300
+ }
301
+ const trimmed = raw.trim();
302
+ if (trimmed === '') {
303
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set but empty. Expected a comma-separated list of ${declaration.browserKind} browser names; unset it to use the default of ${declaration.fallback.join(', ')}.`);
304
+ }
305
+ const entries = trimmed.split(',').map((entry) => entry.trim());
306
+ const names = [];
307
+ for (const entry of entries) {
308
+ if (entry === '') {
309
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set to ${JSON.stringify(raw)}, which has an empty entry. Expected a browser name between every comma.`);
310
+ }
311
+ if (entry.length > BROWSER_NAME_MAXIMUM) {
312
+ throw new StartupRefusal('config.value_readable', `${declaration.key} names a browser ${JSON.stringify(entry)}, which is longer than ${String(BROWSER_NAME_MAXIMUM)} characters. A browser name is also the name of its profile directory.`);
313
+ }
314
+ if (!BROWSER_NAME.test(entry)) {
315
+ throw new StartupRefusal('config.value_readable', `${declaration.key} names a browser ${JSON.stringify(entry)}, which is not a usable name. Expected lower-case letters, digits and hyphens, starting with a letter: the name is the browser's key, the name of its profile directory, and the word a caller types to claim it.`);
316
+ }
317
+ if (names.includes(entry)) {
318
+ throw new StartupRefusal('config.value_readable', `${declaration.key} names ${JSON.stringify(entry)} more than once. Two entries naming one browser is one browser, so the list would say a number it does not have.`);
319
+ }
320
+ names.push(entry);
321
+ }
322
+ if (names.length > declaration.maximum) {
323
+ throw new StartupRefusal('config.value_readable', `${declaration.key} names ${String(names.length)} browsers and the most it may name is ${String(declaration.maximum)}: ${names.join(', ')}. Each browser is a process before it holds a single tab, and the bound is what keeps the process count a property of configuration rather than of how many callers ask.`);
324
+ }
325
+ return names;
326
+ }
327
+ /**
328
+ * Read one of a fixed set of words, applying §6.3's table.
329
+ *
330
+ * The refusal lists every accepted word, because a caller that wrote one word
331
+ * is a caller who will write another one, and the set is short enough that
332
+ * naming it costs a clause.
333
+ */
334
+ function readEnum(declaration, raw) {
335
+ if (raw === undefined) {
336
+ return declaration.fallback;
337
+ }
338
+ const trimmed = raw.trim();
339
+ if (trimmed === '') {
340
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set but empty. Expected ${declaration.unit}, one of ${declaration.allowed.join(', ')}; unset it to use the default of ${declaration.fallback}.`);
341
+ }
342
+ if (!declaration.allowed.includes(trimmed)) {
343
+ throw new StartupRefusal('config.value_readable', `${declaration.key} is set to ${JSON.stringify(raw)}, which is not ${declaration.unit} this service launches. Expected one of ${declaration.allowed.join(', ')}.`);
344
+ }
345
+ return trimmed;
346
+ }
347
+ /**
348
+ * Resolve one declaration by its kind.
349
+ *
350
+ * A `switch` over the kind rather than a chain of ternaries, so that adding a
351
+ * fifth kind is a compile error here rather than a value silently read as
352
+ * whichever branch the chain ended in.
353
+ */
354
+ function readDeclaration(declaration, raw, home, platform) {
355
+ switch (declaration.kind) {
356
+ case 'path':
357
+ return readPath(declaration, raw, declaration.fallback(home, platform));
358
+ case 'positive-integer':
359
+ return readPositiveInteger(declaration, raw);
360
+ case 'name-list':
361
+ return readNameList(declaration, raw);
362
+ case 'enum':
363
+ return readEnum(declaration, raw);
364
+ }
365
+ }
366
+ /**
367
+ * Take the snapshot. Called once, at the start of a spawn.
368
+ *
369
+ * Unrecognised variables are ignored, per §6.3: a process cannot tell an
370
+ * unrecognised variable of its own from any other variable in an environment
371
+ * it shares with everything on the machine.
372
+ */
373
+ export function readEnvironment(options = {}) {
374
+ const env = options.env ?? process.env;
375
+ const platform = options.platform ?? process.platform;
376
+ const homedir = options.homedir ?? os.homedir;
377
+ const home = homedir();
378
+ // One loop over the declarations rather than a call per variable, so a
379
+ // declared variable that nothing reads is impossible: every key in the
380
+ // table is resolved here, and the accessors below fail loudly on a key
381
+ // that is not.
382
+ const resolved = new Map();
383
+ for (const declaration of DECLARATIONS) {
384
+ const raw = env[declaration.key];
385
+ resolved.set(declaration.key, readDeclaration(declaration, raw, home, platform));
386
+ }
387
+ // Every declared key is resolved above, so these cannot be absent. The
388
+ // non-null assertions would be the wrong tool; a throw names the bug.
389
+ const get = (key) => {
390
+ const value = resolved.get(key);
391
+ if (typeof value !== 'string') {
392
+ throw new Error(`${key} was declared as a path but not resolved as one`);
393
+ }
394
+ return value;
395
+ };
396
+ const getNumber = (key) => {
397
+ const value = resolved.get(key);
398
+ if (typeof value !== 'number') {
399
+ throw new Error(`${key} was declared as a number but not resolved as one`);
400
+ }
401
+ return value;
402
+ };
403
+ const getList = (key) => {
404
+ const value = resolved.get(key);
405
+ if (!Array.isArray(value)) {
406
+ throw new Error(`${key} was declared as a name list but not resolved as one`);
407
+ }
408
+ return value;
409
+ };
410
+ // The reader already refused anything outside the declared set, so this
411
+ // narrows a checked value rather than trusting one.
412
+ const getEngine = (key) => {
413
+ const value = resolved.get(key);
414
+ if (value !== 'chrome' && value !== 'brave' && value !== 'msedge') {
415
+ throw new Error(`${key} was declared as an engine but not resolved as one`);
416
+ }
417
+ return value;
418
+ };
419
+ const regularBrowsers = getList('BROKER_REGULAR_BROWSERS');
420
+ const privateBrowsers = getList('BROKER_PRIVATE_BROWSERS');
421
+ // **A name in both lists is refused rather than resolved**, because the
422
+ // profile name is the lease-time key (§3.2): a name written in both kinds
423
+ // has no single answer to *which browser is this*, and picking one of the
424
+ // two would hand a caller the other kind roughly half the time it mattered.
425
+ // Checked here rather than in either list's own reader, because neither
426
+ // reader can see the other list.
427
+ for (const name of regularBrowsers) {
428
+ if (privateBrowsers.includes(name)) {
429
+ throw new StartupRefusal('config.value_readable', `${JSON.stringify(name)} is named in both BROKER_REGULAR_BROWSERS and BROKER_PRIVATE_BROWSERS. A browser name is what a caller claims by, so a name in both kinds has no single answer: rename one of them.`);
430
+ }
431
+ }
432
+ return {
433
+ databasePath: get('BROKER_DB'),
434
+ configuredDatabasePath: env['BROKER_DB'],
435
+ artifactsRoot: get('BROKER_ARTIFACTS_ROOT'),
436
+ profileRoot: get('BROKER_PROFILE_ROOT'),
437
+ tabBudget: getNumber('BROKER_TAB_BUDGET'),
438
+ leaseSeconds: getNumber('BROKER_LEASE_SECONDS'),
439
+ queueSeconds: getNumber('BROKER_QUEUE_SECONDS'),
440
+ launchReadinessTimeoutSeconds: getNumber('BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS'),
441
+ regularBrowsers,
442
+ privateBrowsers,
443
+ regularBrowserEngine: getEngine('BROKER_REGULAR_BROWSER_ENGINE'),
444
+ privateBrowserEngine: getEngine('BROKER_PRIVATE_BROWSER_ENGINE'),
445
+ };
446
+ }
@@ -0,0 +1,77 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Naming the files a comparison writes (`SCHEMA.md` §1.7a, §1.9).
4
+ *
5
+ * ── This module names files. It does not resolve them ───────────────────
6
+ *
7
+ * **Resolving a stored path under the artifact root belongs to
8
+ * `src/artifacts/store.ts`, and this module deliberately does not do it.**
9
+ * `ArtifactStore.write` and `ArtifactStore.resolve` both refuse a path that
10
+ * escapes the root, and both ask the question in a way this module could not
11
+ * have got right on its own: they test the **supplied name as well as the
12
+ * computed result**, in **both path namespaces**, because a name that is
13
+ * absolute in the other namespace is a legal relative filename here and
14
+ * resolves quietly under the root — so the computed answer looks clean while
15
+ * the input was an escape.
16
+ *
17
+ * That is a subtle enough trap that having two implementations of it would be
18
+ * a liability rather than defence in depth: the second one is the one that
19
+ * would be missing a case. So this module produces **relative path fragments
20
+ * and file names**, and every join to a real location goes through the store.
21
+ *
22
+ * ── What §1.7a asks of a crop's name ────────────────────────────────────
23
+ *
24
+ * Crops "take the capture's name plus a region suffix, so they sort
25
+ * immediately beside the picture they came from". That is the whole
26
+ * requirement, and it is why the suffix goes **inside** the stem rather than
27
+ * after the extension: a name ending in something other than the image
28
+ * extension sorts nowhere useful and opens in nothing.
29
+ */
30
+ /** The subfolder a comparison's images live in, per §1.7a. */
31
+ export const IMAGES_KIND = 'images';
32
+ /**
33
+ * Split a file name into its stem and extension, so a suffix lands before the
34
+ * extension.
35
+ *
36
+ * Uses the forward-slash parser explicitly rather than the platform's own.
37
+ * A stored path uses forward slashes whatever wrote it (`ArtifactStore.write`
38
+ * normalises on the way out), so reading one with the host platform's parser
39
+ * would give a different answer on the two platforms for the same stored row.
40
+ */
41
+ function stem(fileName) {
42
+ const extension = path.posix.extname(fileName);
43
+ return { base: fileName.slice(0, fileName.length - extension.length), extension };
44
+ }
45
+ /**
46
+ * The file name for one region crop.
47
+ *
48
+ * The index is the region's position in the ordered list, zero-based and
49
+ * zero-padded to two digits, so a directory listing sorts the regions in the
50
+ * order they were reported rather than putting the tenth between the first and
51
+ * the second.
52
+ *
53
+ * **A name, not a path.** It is handed to `ArtifactStore.write`, which is what
54
+ * decides where it lands and what refuses it if it would land outside.
55
+ */
56
+ export function regionCropFileName(captureFileName, index, side) {
57
+ const { base, extension } = stem(captureFileName);
58
+ const numbered = String(index).padStart(2, '0');
59
+ return `${base}-region-${numbered}-${side}${extension}`;
60
+ }
61
+ /** The file name for a diff's overlay, beside the capture it was drawn from. */
62
+ export function overlayFileName(captureFileName) {
63
+ const { base, extension } = stem(captureFileName);
64
+ return `${base}-overlay${extension}`;
65
+ }
66
+ /**
67
+ * The file name out of a stored relative path.
68
+ *
69
+ * A stored path uses forward slashes, so it is read with the forward-slash
70
+ * parser — but a row written by an older build, or read on a platform whose
71
+ * separator is the backslash, can carry the other spelling. Both are reduced
72
+ * to the last segment, because taking the wrong one would name a directory as
73
+ * though it were a file and produce a crop name built from part of a path.
74
+ */
75
+ export function fileNameFrom(storedPath) {
76
+ return path.posix.basename(storedPath.replaceAll(String.fromCharCode(92), '/'));
77
+ }
@@ -0,0 +1,102 @@
1
+ import { clampToImage, copyImage, crop, outlineRectangle, } from "./image.js";
2
+ /**
3
+ * Cutting the crops and drawing the overlay (`MILESTONES.md` #42).
4
+ *
5
+ * §1.9 is unusually specific about what a diff returns, and it is worth
6
+ * quoting because it decides three things in this file at once:
7
+ *
8
+ * > It is **not** a set of coordinates you then have to go and cut out of a
9
+ * > picture yourself. The service does the cutting. For each region that
10
+ * > changed it writes **two** small images — that region as it was in the
11
+ * > capture you named, and as it is now, cut from the same rectangle with a
12
+ * > little padding so the crop is identifiable — and returns their paths
13
+ * > alongside the numbers. It also writes one full-frame image with the
14
+ * > changed regions outlined.
15
+ *
16
+ * ── Both crops come from one rectangle, and that is load-bearing ────────
17
+ *
18
+ * The rectangle is computed once — padded once, clamped once — and both crops
19
+ * are cut from it. Padding each crop against its own image would produce two
20
+ * pictures of subtly different areas whenever the two images differ in height,
21
+ * which is precisely the full-page case §3.11 allows. Two crops of different
22
+ * areas, presented side by side as before-and-after, invite the reader to
23
+ * attribute the framing difference to the change.
24
+ *
25
+ * ── Why padding at all ──────────────────────────────────────────────────
26
+ *
27
+ * §6.2: "a tight box with nothing around it can be genuinely unidentifiable".
28
+ * A tight crop of a changed word is a picture of a word on a blank field; the
29
+ * same crop with sixteen pixels of context around it usually contains the
30
+ * label beside it, which is what tells a reader which word it is.
31
+ */
32
+ /** The colour a region is outlined in on the overlay. */
33
+ export const OUTLINE_COLOUR = { red: 255, green: 0, blue: 0, alpha: 255 };
34
+ /**
35
+ * How thick that outline is.
36
+ *
37
+ * Two pixels rather than one: a single-pixel outline around a single-pixel
38
+ * change is a three-pixel mark on a full page, and at the scale a full page is
39
+ * usually looked at, it disappears. The overlay's whole job is answering
40
+ * "where", so it has to be visible without zooming.
41
+ */
42
+ export const OUTLINE_THICKNESS = 2;
43
+ /**
44
+ * Pad a region and clamp it to the area both images share.
45
+ *
46
+ * The width and height passed in must be the geometry the comparison ran over,
47
+ * **not** either image's own. On a full page that grew, the new image is taller
48
+ * than the earlier one, and a rectangle clamped against the new image's height
49
+ * could fall outside the earlier one entirely — producing a crop that throws,
50
+ * or worse, a crop of whatever the buffer held.
51
+ *
52
+ * Returns `null` only when nothing survives clamping, which cannot happen for a
53
+ * region that came from a mask of this size and is kept as a guard rather than
54
+ * as a case anybody expects.
55
+ */
56
+ export function paddedRectangle(region, padding, comparableWidth, comparableHeight) {
57
+ const grown = {
58
+ x: region.x - padding,
59
+ y: region.y - padding,
60
+ width: region.width + padding * 2,
61
+ height: region.height + padding * 2,
62
+ };
63
+ const padded = clampToImage(grown, comparableWidth, comparableHeight);
64
+ if (padded === null) {
65
+ return null;
66
+ }
67
+ return { region, padded };
68
+ }
69
+ /**
70
+ * Cut both crops for one region.
71
+ *
72
+ * Order of arguments mirrors the order §1.9 returns them in — the earlier
73
+ * capture first, "as it was", then "as it is now".
74
+ */
75
+ export function cutRegionCrops(earlier, current, rectangle) {
76
+ return {
77
+ rectangle,
78
+ before: crop(earlier, rectangle.padded),
79
+ after: crop(current, rectangle.padded),
80
+ };
81
+ }
82
+ /**
83
+ * Draw the overlay: the new capture with every changed region outlined.
84
+ *
85
+ * **The new capture rather than the earlier one**, because the caller is
86
+ * looking at what it just produced and wants to know where on *that* to look.
87
+ * And **the full new capture, not the compared sub-rectangle** — a full page
88
+ * that grew is still the page the caller took a picture of, and cropping the
89
+ * overlay to the shared rows would silently hide the part that grew.
90
+ *
91
+ * **The unpadded region is outlined, not the padded rectangle**, so the mark
92
+ * on the page is the size of the thing that changed. The padding exists to
93
+ * make a crop readable; drawing it would overstate the change by sixteen
94
+ * pixels in every direction.
95
+ */
96
+ export function drawOverlay(current, regions) {
97
+ const overlay = copyImage(current);
98
+ for (const region of regions) {
99
+ outlineRectangle(overlay, region, OUTLINE_COLOUR, OUTLINE_THICKNESS);
100
+ }
101
+ return overlay;
102
+ }