browser-broker 0.2.0 → 0.4.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 CHANGED
@@ -155,20 +155,3 @@
155
155
  # than quietly running the first three. `broker doctor` reports on every
156
156
  # browser named in both lists above, so a browser added here gains its own
157
157
  # discovery and keeper-tab lines in the health report.
158
-
159
- # Which engine the signed-in browsers name. Does not decide which binary
160
- # launches: per-engine executable resolution is not built, so every browser
161
- # launches the automation library's own Chromium. See RELEASES.md.
162
- # Options: chrome, brave, msedge — all Chromium over the same remote-debugging
163
- # protocol, which is why the choice is a path rather than a driver.
164
- # One engine per kind, never per browser: an engine per entry would put an
165
- # attribute back on the entry, which is what the two lists exist to avoid.
166
- # Default: msedge
167
- # BROKER_REGULAR_BROWSER_ENGINE=msedge
168
-
169
- # Which engine the clean-room browsers name. Accepted and validated, and read
170
- # by nothing: one driver serves every browser in the process and only the
171
- # regular engine is handed to it, so a differing value reaches no launch.
172
- # Options: chrome, brave, msedge
173
- # Default: msedge
174
- # BROKER_PRIVATE_BROWSER_ENGINE=msedge
package/README.md CHANGED
@@ -92,9 +92,17 @@ every run — so a published release arrives without anything being pulled or re
92
92
  }
93
93
  ```
94
94
 
95
- Weigh that against spawning a checkout directly: `npx` costs a registry round-trip on every spawn,
96
- which is several seconds against a fraction of one for a path on disk. A machine that develops this
97
- service is better off pointing at its own tree; a machine that only *uses* it is better off here.
95
+ `npx` costs a registry round-trip on every spawn — **measured at ~1.1s warm and ~3.7s cold**, against
96
+ ~0.4s for a path on disk. Against a typical 30s MCP connect timeout that is ample headroom, so prefer
97
+ this form even on a machine that develops the service: a config file shared between machines cannot
98
+ carry an absolute path that is correct on all of them.
99
+
100
+ ⚠️ **If handshakes start timing out, prune the npx cache before blaming npx.** An unpruned 837MB
101
+ `_npx` cache once pushed spawn cost to 9.7–33.2s and blew a 30s connect timeout outright. The cost is
102
+ the cache, not the mechanism. Two related traps: `--prefer-offline` can serve a packument that
103
+ predates a release, so npx resolves a version it then cannot fetch (`ETARGET`); and the local npm
104
+ cache lags the registry independently, so `npm cache clean --force` is the fix when `npm view` and
105
+ `npx` disagree about what exists.
98
106
 
99
107
  ### From a checkout
100
108
 
@@ -170,16 +178,10 @@ called, and a caller that names no browser gets the first signed-in one:
170
178
  ```bash
171
179
  BROKER_REGULAR_BROWSERS=regular,checkout # persistent, signed in, at most 3
172
180
  BROKER_PRIVATE_BROWSERS=private # ephemeral, at most 3
173
- BROKER_REGULAR_BROWSER_ENGINE=msedge # chrome | brave | msedge
174
- BROKER_PRIVATE_BROWSER_ENGINE=msedge # accepted and validated, and read by nothing
175
181
  ```
176
182
 
177
- **Neither engine variable changes which binary launches.** Resolving an engine name to an executable
178
- is deliberately not built, so every browser launches the automation library's own Chromium whichever
179
- engine is named, and a machine still has to fetch that Chromium once. The private variable goes one
180
- step less far than the regular one: a single driver serves every browser in the process and only the
181
- regular engine is handed to it, so the private value is validated at startup and then read by nothing
182
- at all. `RELEASES.md` has the fuller account.
183
+ **Every browser launches the automation library's own Chromium**, which a machine fetches once. The
184
+ browser a caller names selects an identity and its profile directory, not a different binary.
183
185
 
184
186
  Two signed-in browsers is how two identities are exercised at once: tabs within one browser share
185
187
  its cookie jar, so they are isolated from other browsers and not from each other. **Note that each
@@ -381,9 +383,7 @@ surface is being built, and the one manual step is wired: `broker login` hands a
381
383
  browser and `broker doctor` reports whether the sign-in took. Read [`docs/ROLLOUT.md`](docs/ROLLOUT.md) for taking it from installed to sole route in an order that
382
384
  never leaves traffic unarbitrated, [`docs/plans/PLAN.md`](docs/plans/PLAN.md) for how it works,
383
385
  [`docs/plans/DECISIONS.md`](docs/plans/DECISIONS.md) for why it is shaped this way, and
384
- [`docs/plans/MILESTONES.md`](docs/plans/MILESTONES.md) for the work queue, and
385
- [`RELEASES.md`](RELEASES.md) for what changes between versions — in particular for defaults that
386
- move, which change an installation that has taken no action.
386
+ [`docs/plans/MILESTONES.md`](docs/plans/MILESTONES.md) for the work queue.
387
387
 
388
388
  ## Releasing
389
389
 
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-broker",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Leases over tabs in a fixed set of browsers: bounded capacity, a queue, reclamation, and an enforced capture policy.",
6
6
  "type": "module",
@@ -53,7 +53,6 @@
53
53
  "dist/",
54
54
  ".env.example",
55
55
  "README.md",
56
- "RELEASES.md",
57
56
  "LICENSE"
58
57
  ]
59
58
  }
@@ -320,7 +320,144 @@ export const CONFORMANCE_CASES = [
320
320
  operation: 'capture',
321
321
  seed: withALiveLease,
322
322
  input: {},
323
- expect: { outcome: 'accepted' },
323
+ // §3.11's promised response, asserted as a response rather than as a row
324
+ // in a database. All three of `sourceWidth`, `sourceHeight` and `tier`
325
+ // were promised by §3.11 — *"the dimensions written, the dimensions
326
+ // before shrinking, the file size, the tier"* — computed by the pipeline,
327
+ // written to the `captures` table, and then **dropped by the service layer
328
+ // that builds the caller's reply**, for the whole life of the feature.
329
+ //
330
+ // Nothing caught it because nothing anywhere compared a response against
331
+ // the specification that promised it: `check:operations` proves the
332
+ // binaries reach the service, `check:argument-refusals` polices refusals,
333
+ // and neither walks from a documented response field to the code meant to
334
+ // populate it. Naming the fields here is the narrow, non-brittle version
335
+ // of that check — it binds the spec to the code at the one point both
336
+ // agree on, without a parser trying to read §3.x's English.
337
+ expect: {
338
+ outcome: 'accepted',
339
+ //
340
+ // ── Why these ten are not the list, and what bounds it ──────────────
341
+ //
342
+ // §3.11 promises more than this case names: an estimated token cost and
343
+ // how many captures this lease has taken are both in its sentence. They
344
+ // are deliberately absent here because they are **not in the reply the
345
+ // caller receives**: `estimatedTokens`, `capturesThisLease` and
346
+ // `escalation` are fields of the *pipeline's* `CaptureResult`, and
347
+ // `pages.ts` reshapes that into the `written` object without them. An
348
+ // assertion naming them would fail against correct code, which is the
349
+ // one failure a conformance case must never manufacture.
350
+ //
351
+ // That gap is real and it is item 557fdcd6's remaining half — a
352
+ // response still owes §3.11 two fields it does not carry. It is not
353
+ // closed here, because closing it means changing the response rather
354
+ // than the test, and this row is about the checks. Named so the next
355
+ // reader finds a known gap rather than an oversight.
356
+ //
357
+ // Spelled `capture.*` because the reply is an envelope: `capture` renews
358
+ // the lease it was called on, so the value carries `claimId`, `tabId`,
359
+ // `expiresAt` and `pageDriven` with the picture nested under `capture`.
360
+ // Naming the path asserts that nesting too.
361
+ valueFields: [
362
+ 'capture.captureId',
363
+ 'capture.path',
364
+ 'capture.width',
365
+ 'capture.height',
366
+ 'capture.bytes',
367
+ // The three §3.11 promised and the shipped response omitted for the
368
+ // whole life of the feature. They are the reason this list exists.
369
+ 'capture.sourceWidth',
370
+ 'capture.sourceHeight',
371
+ 'capture.tier',
372
+ ],
373
+ },
374
+ },
375
+ {
376
+ name: 'capture: an escalated tier changes the picture that comes back',
377
+ operation: 'capture',
378
+ // ── The assertion `check:argument-reachability` cannot make ───────────
379
+ //
380
+ // `tier` was read at the bridge, validated, packed into a request object,
381
+ // and then dropped at the single `takeCapture` call site, which spread
382
+ // only `fullPage` and `selector`. Every capture was taken at the default
383
+ // rung whatever was asked for, and `tier: "max"` charged the caller a
384
+ // written 8-200 character justification for it. The static check passed
385
+ // throughout, exactly as its own table says it must: *"a branch that reads
386
+ // an argument and drops it on the floor passes this."*
387
+ //
388
+ // So this case asserts the thing that actually failed: that passing the
389
+ // argument **changes what comes back**. The runner drives the operation a
390
+ // second time with `tier` and `reason` removed and requires the two
391
+ // readings to differ, so a `tier` hard-wired to `"max"` fails here rather
392
+ // than passing as a constant.
393
+ //
394
+ // `max` rather than `detail` because it is the rung that costs a reason,
395
+ // which puts both arguments on the same case: `reason` is recorded only on
396
+ // this tier (`pipeline.ts` — *"a reason attached to a capture nobody had to
397
+ // justify would put noise into the one column the resolution study
398
+ // reads"*), so the two travel together or not at all.
399
+ //
400
+ // ── What this case asserts about `reason`, stated honestly ───────────
401
+ //
402
+ // **It asserts that passing `reason` does not prevent the escalation, and
403
+ // nothing more.** `reason` is genuinely **not observable** from any route:
404
+ // it is written to the `captures` row and read back by no operation the
405
+ // conformance suite can reach (`capture-store.ts` exposes `recordCapture`
406
+ // and `capturesTakenBy`, and neither returns it). So there is no response
407
+ // field and no driver call in which a dropped `reason` would show.
408
+ //
409
+ // It is named in `arguments` regardless, because the baseline must remove
410
+ // it: `max` without a reason is **refused**, so a baseline that dropped
411
+ // only `tier` would be measuring a refusal rather than the default rung.
412
+ // Removing both is what makes the comparison a comparison.
413
+ //
414
+ // The one assertion that would close `reason` is a read path to the
415
+ // capture's own record, which does not exist and is not invented here.
416
+ seed: withALiveLease,
417
+ input: {
418
+ tier: 'max',
419
+ reason: 'conformance: proving an escalated tier reaches the pipeline that acts on it',
420
+ },
421
+ expect: {
422
+ outcome: 'accepted',
423
+ effects: [
424
+ {
425
+ arguments: ['tier', 'reason'],
426
+ expect: [
427
+ {
428
+ field: 'capture.tier',
429
+ value: 'max',
430
+ // The rung a caller lands on with no tier (`DEFAULT_TIER`).
431
+ withoutArgument: 'default',
432
+ },
433
+ {
434
+ // The **physical** consequence, not merely the label. `tier`
435
+ // could be echoed back by a service that did nothing with it;
436
+ // the width cannot. The fake produces 1280x720, so the default
437
+ // rung's 1024 long edge shrinks it to 1024x576 while `max`'s
438
+ // 2576 leaves it alone — never upscaled (`image.ts`: *"a picture
439
+ // smaller than the rung is written as it is"*).
440
+ //
441
+ // This is the field that would have failed on the shipped
442
+ // defect: every capture came back 1024 wide however it was
443
+ // asked for.
444
+ field: 'capture.width',
445
+ value: 1280,
446
+ withoutArgument: 1024,
447
+ },
448
+ {
449
+ // The height, for the same reason as the width and as a
450
+ // separate reading: the shrink is taken on the **long edge**,
451
+ // so a change to `TIER_LONGEST_EDGE` that moved only one
452
+ // dimension would leave the other's assertion standing.
453
+ field: 'capture.height',
454
+ value: 720,
455
+ withoutArgument: 576,
456
+ },
457
+ ],
458
+ },
459
+ ],
460
+ },
324
461
  },
325
462
  {
326
463
  name: 'capture: a selector and a full page together are refused',
@@ -1,5 +1,47 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  import { ADAPTER_IDS } from "../contract.js";
2
3
  import { isWriteOperation, OPERATION_NAMES } from "../operations.js";
4
+ /**
5
+ * Structural comparison for effect assertions.
6
+ *
7
+ * `isDeepStrictEqual` rather than `===`, because an effect may name a
8
+ * structured field, and rather than `assert.deepStrictEqual`, because this
9
+ * runner reports findings instead of throwing (see the header).
10
+ */
11
+ const deepEqual = (a, b) => isDeepStrictEqual(a, b);
12
+ /**
13
+ * Read a field out of an accepted value, following a dotted path.
14
+ *
15
+ * ── Why a path and not a plain key ──────────────────────────────────────
16
+ *
17
+ * An accepted value is not flat. `capture` returns the lease's own fields —
18
+ * `claimId`, `tabId`, `expiresAt`, `pageDriven` — with **the capture itself
19
+ * nested under `capture`**, because the operation renews the lease it was
20
+ * called on and the reply says both things. `SCHEMA.md` §3.11's promises are
21
+ * about the capture object, so a case naming `width` flatly would read
22
+ * `undefined` from the envelope and report a missing field on a response that
23
+ * carries it — a finding that is wrong in the most expensive direction, since
24
+ * it accuses correct code.
25
+ *
26
+ * The path is resolved rather than the case being handed the sub-object,
27
+ * because the envelope is part of what the route returns: a case naming
28
+ * `capture.tier` asserts the nesting too, and an adapter that quietly
29
+ * flattened or re-keyed the reply would fail here rather than passing.
30
+ *
31
+ * Returns `undefined` for any path that does not resolve, which is the same
32
+ * answer as a field explicitly set to `undefined` — deliberately, because
33
+ * both mean "the caller cannot read this" and the assertions are about what
34
+ * a caller can read.
35
+ */
36
+ function fieldAt(value, path) {
37
+ let current = value;
38
+ for (const step of path.split('.')) {
39
+ if (typeof current !== 'object' || current === null)
40
+ return undefined;
41
+ current = current[step];
42
+ }
43
+ return current;
44
+ }
3
45
  /** Roughly four words, the same bar the hygiene gate holds a waiver to. */
4
46
  const WAIVER_MINIMUM_WORDS = 4;
5
47
  function wordCount(text) {
@@ -151,6 +193,99 @@ export async function runConformance(options) {
151
193
  });
152
194
  }
153
195
  }
196
+ // ── Response conformance and argument effect ──────────────────────
197
+ //
198
+ // Both are assertions about the **accepted value**, which is why they
199
+ // sit together and why they are checked here rather than in a unit
200
+ // test: the value has crossed the route boundary by this point, so a
201
+ // field the service produced and the adapter dropped fails here and
202
+ // passes everywhere else.
203
+ if (outcome.outcome === 'accepted' && testCase.expect.outcome === 'accepted') {
204
+ for (const field of testCase.expect.valueFields ?? []) {
205
+ if (fieldAt(outcome.value, field) === undefined) {
206
+ findings.push({
207
+ kind: 'accepted-value-missing-a-field',
208
+ adapter: adapterId,
209
+ operation: testCase.operation,
210
+ caseName: testCase.name,
211
+ detail: `the accepted value has no "${field}" (it carries ${Object.keys(outcome.value).join(', ') || 'nothing'})`,
212
+ });
213
+ }
214
+ }
215
+ for (const effect of testCase.expect.effects ?? []) {
216
+ // The same operation, driven again with the effect's arguments
217
+ // removed. A fresh subject, because the first run consumed a lease
218
+ // and moved the capture count — reusing it would measure the
219
+ // second call's accounting rather than the argument.
220
+ const baselineInput = Object.fromEntries(Object.entries(seeded.input).filter(([key]) => !effect.arguments.includes(key)));
221
+ const baselineSubject = await options.makeService();
222
+ let baseline;
223
+ try {
224
+ const baselineSubstitutions = (await testCase.seed?.apply(baselineSubject.service)) ?? {};
225
+ const baselineCase = {
226
+ ...testCase,
227
+ name: `${testCase.name} (without ${effect.arguments.join(', ')})`,
228
+ input: { ...baselineInput, ...baselineSubstitutions },
229
+ };
230
+ baseline = (await driver.run(baselineSubject.service, baselineCase, {
231
+ driverCalls: baselineSubject.driverCalls,
232
+ liveClaimCount: baselineSubject.liveClaimCount,
233
+ })).outcome;
234
+ }
235
+ finally {
236
+ await baselineSubject.dispose?.();
237
+ }
238
+ if (baseline.outcome !== 'accepted') {
239
+ // Never silently skipped. An effect whose baseline refused would
240
+ // otherwise report the same green as one that held.
241
+ findings.push({
242
+ kind: 'argument-effect-baseline-failed',
243
+ adapter: adapterId,
244
+ operation: testCase.operation,
245
+ caseName: testCase.name,
246
+ detail: `dropping ${effect.arguments.join(', ')} was refused (${baseline.code} / ${baseline.rule}), so the effect of those arguments could not be measured`,
247
+ });
248
+ continue;
249
+ }
250
+ for (const expectation of effect.expect) {
251
+ const actual = fieldAt(outcome.value, expectation.field);
252
+ const without = fieldAt(baseline.value, expectation.field);
253
+ if (!deepEqual(actual, expectation.value)) {
254
+ findings.push({
255
+ kind: 'argument-had-no-effect',
256
+ adapter: adapterId,
257
+ operation: testCase.operation,
258
+ caseName: testCase.name,
259
+ detail: `with ${effect.arguments.join(', ')}, "${expectation.field}" should be ${JSON.stringify(expectation.value)} but was ${JSON.stringify(actual)}`,
260
+ });
261
+ continue;
262
+ }
263
+ if (!deepEqual(without, expectation.withoutArgument)) {
264
+ findings.push({
265
+ kind: 'argument-had-no-effect',
266
+ adapter: adapterId,
267
+ operation: testCase.operation,
268
+ caseName: testCase.name,
269
+ detail: `without ${effect.arguments.join(', ')}, "${expectation.field}" should be ${JSON.stringify(expectation.withoutArgument)} but was ${JSON.stringify(without)}`,
270
+ });
271
+ continue;
272
+ }
273
+ // The guard against a constant. Both readings can be individually
274
+ // right and the argument still inert — if the two agree, passing
275
+ // the argument changed nothing, whatever the field happens to
276
+ // hold. This is the assertion the whole shape exists for.
277
+ if (deepEqual(actual, without)) {
278
+ findings.push({
279
+ kind: 'argument-had-no-effect',
280
+ adapter: adapterId,
281
+ operation: testCase.operation,
282
+ caseName: testCase.name,
283
+ detail: `"${expectation.field}" is ${JSON.stringify(actual)} whether or not ${effect.arguments.join(', ')} is passed, so the argument is inert`,
284
+ });
285
+ }
286
+ }
287
+ }
288
+ }
154
289
  if (outcome.outcome === 'refused') {
155
290
  // Both readings, because they catch different bugs (§8.2): a guard
156
291
  // that opens a tab and closes it on the way to refusing leaves the
@@ -92,6 +92,35 @@ const DEFAULT_COOKIES = [
92
92
  * members and there is nothing here that invents a third.
93
93
  */
94
94
  export class FakeBrowserDriver {
95
+ /**
96
+ * A way to end each handed-out session's connection, without ending the
97
+ * browser.
98
+ *
99
+ * ── Why the fake needs to be able to do this at all ─────────────────────
100
+ *
101
+ * A connection and the browser it points at have **independent lifetimes**,
102
+ * and the failure this models is the asymmetry: the browser is running and
103
+ * answers every liveness question truthfully, while the connection one
104
+ * process holds has ended. Nothing else in this fake can produce that state
105
+ * — seeding a failure makes an operation reject, which is a different thing
106
+ * and one the existing eviction path already handles.
107
+ *
108
+ * A test drives it with {@link FakeBrowserDriver.disconnect}; the fake's
109
+ * own tabs and recorded calls are deliberately left untouched, because the
110
+ * browser is unaffected.
111
+ */
112
+ #disconnectors = [];
113
+ /**
114
+ * End the connection on every session handed out for one browser, leaving
115
+ * the browser itself — its tabs, its call log — exactly as it was.
116
+ */
117
+ disconnect(browser) {
118
+ for (const entry of this.#disconnectors) {
119
+ if (entry.browser === browser) {
120
+ entry.end();
121
+ }
122
+ }
123
+ }
95
124
  #calls = [];
96
125
  #options;
97
126
  #openTabs = new Map();
@@ -301,6 +330,14 @@ export class FakeBrowserDriver {
301
330
  }
302
331
  #session(browser, mode) {
303
332
  const description = this.#describe(browser, mode);
333
+ // Every session this fake hands out starts connected, and only
334
+ // {@link FakeBrowserDriver.disconnect} changes that. It is per-session
335
+ // rather than per-browser deliberately: the state under test is *this
336
+ // connection ended while the browser carried on*, so a later acquisition
337
+ // for the same browser must come back connected, exactly as a real
338
+ // re-attach would.
339
+ let connected = true;
340
+ this.#disconnectors.push({ browser, end: () => (connected = false) });
304
341
  const openTab = (name) => {
305
342
  const driverTabId = `fake-tab-${String(this.#nextTabNumber++)}`;
306
343
  const failure = this.#enter({ name, browser, tab: { browser, driverTabId } });
@@ -314,6 +351,10 @@ export class FakeBrowserDriver {
314
351
  };
315
352
  return {
316
353
  describe: () => description,
354
+ // Reads the flag above and asks nothing, which is the contract
355
+ // {@link BrowserSession.isConnected} states: it is consulted before
356
+ // every page verb, so it must not perform input/output or throw.
357
+ isConnected: () => connected,
317
358
  openTab: () => openTab('openTab'),
318
359
  /**
319
360
  * Every page open in this browser **except the keeper tab**.
@@ -420,7 +461,18 @@ export class FakeBrowserDriver {
420
461
  });
421
462
  if (failure)
422
463
  return Promise.reject(failure);
423
- return Promise.resolve({ url, title: `fake page at ${url}`, status: 200 });
464
+ // **Where the page ARRIVES, which is not always where it was sent.**
465
+ // Defaults to the requested address, so every existing caller sees
466
+ // what it always saw; a test that configures `arriveAt` gets a fake
467
+ // that redirects, which is the only way a fake can exercise the
468
+ // difference between reporting the request and reporting the result.
469
+ const options = this.#options.navigate;
470
+ const arrived = options?.arriveAt?.(url) ?? url;
471
+ return Promise.resolve({
472
+ url: arrived,
473
+ title: options?.title?.(arrived) ?? `fake page at ${arrived}`,
474
+ status: options?.status === undefined ? 200 : options.status,
475
+ });
424
476
  },
425
477
  seedStorage: (tab, entries) => {
426
478
  // **The whole entries list, values included**, and that is deliberate
@@ -1254,6 +1254,18 @@ class RealBrowserSession {
1254
1254
  }
1255
1255
  }
1256
1256
  }
1257
+ /**
1258
+ * Whether this process's connection is still usable.
1259
+ *
1260
+ * Delegates to the flag the driving package already maintains — its
1261
+ * `isConnected()` is declared `(): boolean` and its implementation is a
1262
+ * bare field read (`return this._isConnected`), so this performs no
1263
+ * input/output and cannot throw. That is what {@link BrowserSession.isConnected}
1264
+ * requires of it, because it is consulted before every page verb.
1265
+ */
1266
+ isConnected() {
1267
+ return this.#connection.isConnected();
1268
+ }
1257
1269
  /**
1258
1270
  * End this process's connection. **The browser is unaffected.**
1259
1271
  *
@@ -1303,34 +1315,6 @@ async function connect(options) {
1303
1315
  await session.ensureKeeperTab();
1304
1316
  return session;
1305
1317
  }
1306
- /**
1307
- * Where a configured engine's binary lives, if this process was told.
1308
- *
1309
- * ── What is built here, and what is deliberately not ────────────────
1310
- *
1311
- * All three engines are Chromium over the same remote-debugging protocol, so
1312
- * choosing between them is choosing a binary — which is what makes the hook
1313
- * cheap, and it is the hook `DECISIONS.md` §13i asks for. **The expensive
1314
- * half is explicitly out of scope there**: per-engine executable discovery,
1315
- * per-engine discovery-record locations, per-engine health checks.
1316
- *
1317
- * **So resolution is a lookup of what a caller supplied, never a search.**
1318
- * The engine selects among paths this process was given; it does not go
1319
- * looking for an installation, and it never carries a path of its own.
1320
- * Writing a per-engine install location into this file would name one machine
1321
- * — §1.0's rule forbids that outright, and `check-external-refs` fails on the
1322
- * shape.
1323
- *
1324
- * **What happens when nothing supplied a path for the configured engine** is
1325
- * the case worth being exact about: the launch falls back to the automation
1326
- * library's own Chromium, which is what an unconfigured build launches. That
1327
- * is a real limit and it is named in §13i rather than hidden here — the
1328
- * variables are validated and carried, and the row that resolves an engine to
1329
- * an installed binary is separable work.
1330
- */
1331
- export function executablePathForEngine(engine, supplied) {
1332
- return supplied?.[engine];
1333
- }
1334
1318
  /**
1335
1319
  * The real driver.
1336
1320
  *
@@ -1350,9 +1334,7 @@ export class RealBrowserDriver {
1350
1334
  if (this.#options.executablePath !== undefined) {
1351
1335
  return this.#options.executablePath;
1352
1336
  }
1353
- const engine = this.#options.engine;
1354
- const resolved = engine === undefined ? undefined : executablePathForEngine(engine, this.#options.enginePaths);
1355
- return resolved ?? chromium.executablePath();
1337
+ return chromium.executablePath();
1356
1338
  }
1357
1339
  /**
1358
1340
  * Attach to a browser that is already running, **having checked the record
@@ -164,3 +164,56 @@ const TOKENS_PER_PIXEL_DIVISOR = 750;
164
164
  export function estimateTokens(width, height) {
165
165
  return Math.ceil((width * height) / TOKENS_PER_PIXEL_DIVISOR);
166
166
  }
167
+ /**
168
+ * Describe the shrink, or return nothing when there was not one.
169
+ *
170
+ * **Absent rather than `scale: 1` when nothing was reduced**, and the
171
+ * difference is the one this whole helper is for: a field that is always
172
+ * present is a field a caller stops reading. Its presence is the signal.
173
+ */
174
+ export function describeReduction(source, written, tier) {
175
+ if (written.width >= source.width && written.height >= source.height) {
176
+ return undefined;
177
+ }
178
+ // Taken on the long edge, which is the edge the cap is applied to — so this
179
+ // is the factor that was actually used rather than one recovered from
180
+ // whichever dimension happens to round more kindly.
181
+ const scale = Math.max(source.width, source.height) === 0
182
+ ? 1
183
+ : Math.max(written.width, written.height) / Math.max(source.width, source.height);
184
+ const percent = Math.round(scale * 100);
185
+ // Named only when there is one, so the sentence never tells a caller already
186
+ // on the top rung to escalate to it.
187
+ const higher = HIGHER_TIERS[tier];
188
+ const remedy = higher === undefined
189
+ ? `This is the highest rung, so a larger image of the whole page is not available; capture a selector, or read the page as text instead.`
190
+ : `For more detail pass tier="${higher}"${higher === TIER_REQUIRING_REASON ? ' together with reason' : ''}.`;
191
+ // The width is called out separately because it is the number that decides
192
+ // legibility on a tall page, and it is the one a caller reading "scale" on
193
+ // its own would not think to compare against the viewport.
194
+ return {
195
+ sourceWidth: source.width,
196
+ sourceHeight: source.height,
197
+ scale: Math.round(scale * 1000) / 1000,
198
+ note: `This image was REDUCED to about ${String(percent)}% of the page: ` +
199
+ `${String(source.width)}x${String(source.height)} was written as ` +
200
+ `${String(written.width)}x${String(written.height)}. ` +
201
+ `A capture is shrunk so its LONGEST edge fits ${String(TIER_LONGEST_EDGE[tier])}px, so on a page ` +
202
+ `taller than it is wide the height sets the factor and the width shrinks with it — ` +
203
+ `${String(written.width)}px of width here. Text may not be legible. ` +
204
+ remedy,
205
+ };
206
+ }
207
+ /**
208
+ * The next rung up from each, and `undefined` at the top.
209
+ *
210
+ * A table rather than an ordering computed from {@link TIER_LONGEST_EDGE},
211
+ * because "which rung does a caller ask for next" is a fact about the
212
+ * surface's vocabulary — `default` is not requestable by name — and not about
213
+ * which number is larger.
214
+ */
215
+ const HIGHER_TIERS = {
216
+ default: 'detail',
217
+ detail: 'max',
218
+ max: undefined,
219
+ };
@@ -172,6 +172,11 @@ export const STANDALONE_COMMANDS = [
172
172
  words: ['snapshot'],
173
173
  summary: 'Write the operations document to a path and exit.',
174
174
  owedBy: 'the row that builds the operations document',
175
+ options: [
176
+ { flag: '--out <path>', summary: 'The file to write the document to. Required.' },
177
+ { flag: '--events <n>', summary: 'How many ledger entries to include.' },
178
+ { flag: '--feedback <n>', summary: 'How many feedback rows to include.' },
179
+ ],
175
180
  },
176
181
  {
177
182
  words: ['doctor'],