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.
- package/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- 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
|
+
}
|