browser-broker 0.1.0 → 0.3.1

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,19 +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 browser binary the signed-in browsers launch.
160
- # Options: chrome, brave, msedge — all Chromium over the same remote-debugging
161
- # protocol, which is why the choice is a path rather than a driver.
162
- # One engine per kind, never per browser: an engine per entry would put an
163
- # attribute back on the entry, which is what the two lists exist to avoid.
164
- # Default: msedge
165
- # BROKER_REGULAR_BROWSER_ENGINE=msedge
166
-
167
- # Which browser binary the clean-room browsers launch. MAY DIFFER from the
168
- # engine above: a person signs into the signed-in browsers by hand, so which
169
- # binary those are can follow what they already use, while nobody signs into
170
- # a clean-room browser at all.
171
- # Options: chrome, brave, msedge
172
- # Default: msedge
173
- # BROKER_PRIVATE_BROWSER_ENGINE=msedge
package/README.md CHANGED
@@ -61,23 +61,33 @@ hanging the report.
61
61
  service to keep running: the process is started by whatever calls it and exits with it. So getting it
62
62
  working is an install and one more fetch below — there is no step after that.
63
63
 
64
- You need **Node 22.18 or newer**.
64
+ You need **Node 22.18 or newer**. Either path below also needs a browser binary, which is a separate
65
+ fetch from either install step — see [Browser binary](#browser-binary) once you've picked a path.
65
66
 
66
67
  ### From the registry
67
68
 
68
- The package ships compiled JavaScript, so nothing is built on your machine:
69
+ The package ships compiled JavaScript, so **nothing is compiled** on your machine. That is not the
70
+ same as nothing to set up: a browser binary is still owed, and this path does not fetch it — see
71
+ [Browser binary](#browser-binary) below before your first `broker doctor`.
69
72
 
70
73
  ```bash
71
- npx browser-broker doctor
74
+ npx -p browser-broker broker doctor
72
75
  ```
73
76
 
77
+ The package installs two executables — `broker`, the command line, and `broker-tool`, the surface a
78
+ client spawns — and neither is named for the package, so `npx browser-broker` cannot tell which you
79
+ meant and refuses. `-p` names the package and the word after it names the executable.
80
+
74
81
  A client that spawns the tool surface names the same package, and npm revalidates the version on
75
82
  every run — so a published release arrives without anything being pulled or rebuilt by hand:
76
83
 
77
84
  ```json
78
85
  {
79
86
  "mcpServers": {
80
- "browser-broker": { "command": "npx", "args": ["-y", "browser-broker"] }
87
+ "browser-broker": {
88
+ "command": "npx",
89
+ "args": ["-y", "-p", "browser-broker", "broker-tool"]
90
+ }
81
91
  }
82
92
  }
83
93
  ```
@@ -97,20 +107,8 @@ cd browser-broker
97
107
  npm install
98
108
  ```
99
109
 
100
- That compiles the one runtime dependency's native binding, which is *not* the only part of the install
101
- that does real work: this repository depends on `playwright-core`, not the full `playwright`
102
- distribution, precisely because the browser binary is spawned by this service, detached and by path,
103
- rather than downloaded and managed by the package. `playwright-core` does not fetch a browser on
104
- install, so a checkout that has never had one fetched by some other tooling has none, and `broker
105
- doctor`'s automation check will genuinely fail with exit code 11 until you run:
106
-
107
- ```bash
108
- npx playwright-core install chromium
109
- ```
110
-
111
- Run this once per machine, before the first `broker doctor`. It is the same install mechanism the
112
- full `playwright` package would run automatically on `npm install`; `playwright-core` just does not
113
- run it for you. Then run the broker itself:
110
+ That compiles the one runtime dependency's native binding. It does not fetch a browser binary see
111
+ [Browser binary](#browser-binary) below before your first `broker doctor`. Then run the broker itself:
114
112
 
115
113
  ```bash
116
114
  node src/bin/broker.ts
@@ -134,6 +132,23 @@ To get the command on your path as `broker`, link the package from the checkout:
134
132
  npm link # then: broker --help
135
133
  ```
136
134
 
135
+ ### Browser binary
136
+
137
+ **Neither install path above fetches a browser.** This repository depends on `playwright-core`, not
138
+ the full `playwright` distribution, precisely because the browser binary is spawned by this service,
139
+ detached and by path, rather than downloaded and managed by the package. `playwright-core` does not
140
+ fetch a browser on install, so a machine that has never had one fetched by some other tooling has
141
+ none, and `broker doctor`'s automation check will genuinely fail with exit code 11 until you run:
142
+
143
+ ```bash
144
+ npx playwright-core install chromium
145
+ ```
146
+
147
+ Run this once per machine, before the first `broker doctor` — it applies whether you installed from
148
+ the registry or from a checkout, because it is a fetch neither install step performs. It is the same
149
+ install mechanism the full `playwright` package would run automatically on `npm install`;
150
+ `playwright-core` just does not run it for you.
151
+
137
152
  ### Configuring it
138
153
 
139
154
  **Nothing needs setting.** Every value is an environment variable with a working default, so the
@@ -155,10 +170,11 @@ called, and a caller that names no browser gets the first signed-in one:
155
170
  ```bash
156
171
  BROKER_REGULAR_BROWSERS=regular,checkout # persistent, signed in, at most 3
157
172
  BROKER_PRIVATE_BROWSERS=private # ephemeral, at most 3
158
- BROKER_REGULAR_BROWSER_ENGINE=msedge # chrome | brave | msedge
159
- BROKER_PRIVATE_BROWSER_ENGINE=msedge # may differ from the line above
160
173
  ```
161
174
 
175
+ **Every browser launches the automation library's own Chromium**, which a machine fetches once. The
176
+ browser a caller names selects an identity and its profile directory, not a different binary.
177
+
162
178
  Two signed-in browsers is how two identities are exercised at once: tabs within one browser share
163
179
  its cookie jar, so they are isolated from other browsers and not from each other. **Note that each
164
180
  browser is a process before it holds a single tab**, which the tab budget does not count —
@@ -198,6 +214,24 @@ it is rarely the checkout. Nothing else is required: there is no port to configu
198
214
  issue, and no process to have started first — the client starts it, and it exits when the client
199
215
  closes the pipe.
200
216
 
217
+ **If that configuration file is itself synchronised between machines, an absolute path is the one
218
+ thing in it that cannot travel.** A home directory differs per machine and often per user, so one
219
+ entry naming a checkout is correct on the machine it was written on and names nothing on the other
220
+ — where it fails as a connection that closes immediately, which reads as a broken service rather
221
+ than as a path that does not exist. Give each machine its own entry under its own server name, or
222
+ point the shared entry at the published package, which carries no machine's path:
223
+
224
+ ```json
225
+ {
226
+ "mcpServers": {
227
+ "browser-broker": {
228
+ "command": "npx",
229
+ "args": ["-y", "-p", "browser-broker", "broker-tool"]
230
+ }
231
+ }
232
+ }
233
+ ```
234
+
201
235
  To point it at a store other than the default, add the environment to the same block:
202
236
 
203
237
  ```json
@@ -341,9 +375,7 @@ surface is being built, and the one manual step is wired: `broker login` hands a
341
375
  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
342
376
  never leaves traffic unarbitrated, [`docs/plans/PLAN.md`](docs/plans/PLAN.md) for how it works,
343
377
  [`docs/plans/DECISIONS.md`](docs/plans/DECISIONS.md) for why it is shaped this way, and
344
- [`docs/plans/MILESTONES.md`](docs/plans/MILESTONES.md) for the work queue, and
345
- [`RELEASES.md`](RELEASES.md) for what changes between versions — in particular for defaults that
346
- move, which change an installation that has taken no action.
378
+ [`docs/plans/MILESTONES.md`](docs/plans/MILESTONES.md) for the work queue.
347
379
 
348
380
  ## Releasing
349
381
 
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-broker",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
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",
@@ -19,12 +19,13 @@
19
19
  "check:operations": "node scripts/check-operations.mjs",
20
20
  "check:argument-refusals": "node scripts/check-argument-refusals.mjs",
21
21
  "check:injected-tests": "node scripts/check-injected-tests.mjs",
22
+ "check:argument-reachability": "node scripts/check-argument-reachability.mjs",
22
23
  "typecheck": "tsc --noEmit",
23
24
  "lint": "eslint .",
24
25
  "format": "prettier --write .",
25
26
  "format:check": "prettier --check .",
26
27
  "test": "node --test \"tests/**/*.test.mjs\" \"tests/**/*.test.ts\"",
27
- "check": "npm run check:external-refs && npm run check:doc-links && npm run check:arbitration && npm run check:capture-isolation && npm run check:artifact-path && npm run typecheck && npm run lint && npm run format:check && npm test && npm run check:install && npm run check:operations && npm run check:argument-refusals && npm run check:injected-tests",
28
+ "check": "npm run check:external-refs && npm run check:doc-links && npm run check:arbitration && npm run check:capture-isolation && npm run check:artifact-path && npm run typecheck && npm run lint && npm run format:check && npm test && npm run check:install && npm run check:operations && npm run check:argument-refusals && npm run check:argument-reachability && npm run check:injected-tests && npm run check:package",
28
29
  "check:arbitration": "node scripts/check-arbitration.mjs",
29
30
  "check:artifact-path": "node scripts/check-artifact-path.mjs",
30
31
  "build": "tsc --project tsconfig.build.json",
@@ -52,7 +53,6 @@
52
53
  "dist/",
53
54
  ".env.example",
54
55
  "README.md",
55
- "RELEASES.md",
56
56
  "LICENSE"
57
57
  ]
58
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
@@ -150,7 +150,11 @@ export async function makeServiceSubject() {
150
150
  return {
151
151
  service: runtime.service,
152
152
  // The fake's own log, which is what makes "a refusal touched no browser"
153
- // checkable rather than asserted.
153
+ // checkable rather than asserted — and the entries are passed whole, so a
154
+ // case can also ask what the browser was told, not merely that it was
155
+ // spoken to. Dropping the arguments here would make a route that forwards
156
+ // an argument and one that discards it indistinguishable to every case in
157
+ // the table.
154
158
  driverCalls: () => driver.calls,
155
159
  // **The same predicate the capacity check uses**, per `driver.ts`'s
156
160
  // requirement for this reading — not a count of rows in `claims`, which
@@ -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**.
@@ -402,11 +443,36 @@ export class FakeBrowserDriver {
402
443
  this.#tabsFor(tab.browser).delete(tab.driverTabId);
403
444
  return Promise.resolve();
404
445
  },
405
- navigate: (tab, url) => {
406
- const failure = this.#enter({ name: 'navigate', browser, tab, detail: { url } });
446
+ navigate: (tab, url, waitMs) => {
447
+ // The wait is recorded even though nothing here waits, because what a
448
+ // test needs to assert is that the service *asked* for it. An argument
449
+ // that is accepted and dropped between the caller and the driver looks
450
+ // identical from the outside to one that was honoured, and the only
451
+ // place that difference is observable is this log.
452
+ //
453
+ // Recorded as an absent key when the caller omitted it, rather than as
454
+ // an explicit undefined, so a test can tell "asked for no wait" from
455
+ // "asked for a wait of nothing".
456
+ const failure = this.#enter({
457
+ name: 'navigate',
458
+ browser,
459
+ tab,
460
+ detail: { url, ...(waitMs === undefined ? {} : { waitMs }) },
461
+ });
407
462
  if (failure)
408
463
  return Promise.reject(failure);
409
- 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
+ });
410
476
  },
411
477
  seedStorage: (tab, entries) => {
412
478
  // **The whole entries list, values included**, and that is deliberate