browser-broker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,585 @@
1
+ import { solidPng } from "../capture/image.js";
2
+ import { DEFAULT_BROWSER_IDS } from "./driver.js";
3
+ /**
4
+ * The one expression shape {@link FakeBrowserDriver} answers from storage.
5
+ *
6
+ * Anchored at both ends and exact about the punctuation, so it matches the
7
+ * form a test writes and nothing that merely resembles it. Deliberately
8
+ * narrow: widening this is the first step toward the interpreter the fake
9
+ * must not become.
10
+ */
11
+ const STORAGE_READ_EXPRESSION = /^__seeded\((?<area>local|session),\s*(?<origin>[^,)]+),\s*(?<key>[^)]+)\)$/u;
12
+ /**
13
+ * How one storage entry is addressed: the tab, the origin, the area, the key.
14
+ *
15
+ * All four, because all four partition storage in a real browser. A key built
16
+ * from fewer would let a seed written for one origin read back under another
17
+ * — which would make a test pass for a service that seeded the wrong place.
18
+ */
19
+ function storageKey(driverTabId, origin, area, key) {
20
+ return `${driverTabId}|${origin}|${area}|${key}`;
21
+ }
22
+ const DEFAULT_MODE = {
23
+ // The signed-in browser is headed, and that is the whole reason the keeper
24
+ // tab is a correctness mechanism rather than tidiness (`SCHEMA.md` §3.15).
25
+ regular: 'headed',
26
+ private: 'headless',
27
+ };
28
+ const DEFAULT_PID = {
29
+ regular: 4001,
30
+ private: 4002,
31
+ };
32
+ /**
33
+ * A stable per-name offset, so a browser keeps one endpoint and one process
34
+ * identifier across the calls of a single test.
35
+ *
36
+ * The two browsers the default configuration names keep the numbers they
37
+ * always had, which is what stops an assertion written against them moving.
38
+ * Any other name is hashed into the same small range — collisions are
39
+ * possible and harmless: nothing here dials the number, it only has to differ
40
+ * between browsers often enough that a test asserting two browsers are
41
+ * distinct is asserting something.
42
+ */
43
+ function endpointOffset(browser) {
44
+ const known = DEFAULT_BROWSER_IDS.indexOf(browser);
45
+ if (known !== -1) {
46
+ return known;
47
+ }
48
+ let hash = 0;
49
+ for (const character of browser) {
50
+ hash = (hash * 31 + character.charCodeAt(0)) % 900;
51
+ }
52
+ return DEFAULT_BROWSER_IDS.length + hash;
53
+ }
54
+ /**
55
+ * What a tab's cookies look like when a test has not said otherwise.
56
+ *
57
+ * Two entries rather than none, and it matters: a redaction test that asserts
58
+ * a secret appears nowhere in the output is trivially satisfied by output
59
+ * with nothing in it, and would stay green with the redaction deleted. Two
60
+ * cookies with flags that differ also mean a test asserting the flags survive
61
+ * cannot pass by returning one shape for everything.
62
+ *
63
+ * **No value field appears here because {@link CookieSummary} has none.** The
64
+ * seeding lever is what carries a secret value — see
65
+ * {@link FakeBrowserDriver.seedCookies}.
66
+ */
67
+ const DEFAULT_COOKIES = [
68
+ {
69
+ name: 'session',
70
+ domain: 'example.com',
71
+ path: '/',
72
+ expires: null,
73
+ httpOnly: true,
74
+ secure: true,
75
+ sameSite: 'Lax',
76
+ },
77
+ {
78
+ name: 'preference',
79
+ domain: 'example.com',
80
+ path: '/settings',
81
+ expires: '2027-01-01T00:00:00.000Z',
82
+ httpOnly: false,
83
+ secure: false,
84
+ sameSite: 'Strict',
85
+ },
86
+ ];
87
+ /**
88
+ * The fake, its log, and the levers a test needs.
89
+ *
90
+ * One instance is one installation's worth of browsers: it can hand out a
91
+ * session for each of the two and no more, because {@link BrowserId} has two
92
+ * members and there is nothing here that invents a third.
93
+ */
94
+ export class FakeBrowserDriver {
95
+ #calls = [];
96
+ #options;
97
+ #openTabs = new Map();
98
+ #keeperTabs = new Map();
99
+ #cookies = new Map();
100
+ /**
101
+ * Per-tab storage, keyed `<driverTabId>|<origin>|<area>` — the partitioning
102
+ * a real browser enforces, modelled just far enough to be readable back.
103
+ *
104
+ * ── Why the fake holds state here at all ────────────────────────────────
105
+ *
106
+ * This file's header is firm that the fake does not simulate a browser, and
107
+ * this does not walk that back: nothing here renders, lays out or executes.
108
+ * What it does is make **the one property `storage_seed` exists for**
109
+ * observable — that a value written before a tab's first navigation is
110
+ * there when the page looks. A fake whose `seedStorage` only logged could
111
+ * not tell a wired seed from an unwired one, because a page reading storage
112
+ * would answer nothing in both cases. That is the coinciding fixture this
113
+ * repository has been caught by six times, and it is exactly the shape it
114
+ * takes here: **the seed test would pass against a service that never
115
+ * called `seedStorage` at all.**
116
+ *
117
+ * So the seed writes and {@link FakeBrowserDriver.storedValue} reads, and
118
+ * the evaluation lever below reads through the same map — which is what
119
+ * makes "the page can see what was seeded for it" a real assertion rather
120
+ * than a restatement of the call log.
121
+ *
122
+ * Keyed by origin **and** area because both partition real storage: the
123
+ * same key in `local` and in `session`, or under two origins, are different
124
+ * entries, and a fake that collapsed them would let a seed land in the
125
+ * wrong place and still read back.
126
+ */
127
+ #storage = new Map();
128
+ #failures = [];
129
+ #nextTabNumber = 1;
130
+ constructor(options = {}) {
131
+ this.#options = options;
132
+ for (const browser of DEFAULT_BROWSER_IDS) {
133
+ this.#openTabs.set(browser, new Set());
134
+ }
135
+ }
136
+ /**
137
+ * Every call, in the order it was made.
138
+ *
139
+ * **The assertion a rejection test is built on is that this is empty**, or
140
+ * that {@link FakeBrowserDriver.callsOf} for the forbidden operation is.
141
+ * Returned as a copy so a test holding it cannot be surprised by a later
142
+ * call mutating what it already read, and cannot quietly clear the log by
143
+ * mutating the array it was handed.
144
+ */
145
+ get calls() {
146
+ return [...this.#calls];
147
+ }
148
+ /** Every recorded call to one operation. The narrow form of the assertion above. */
149
+ callsOf(name) {
150
+ return this.#calls.filter((call) => call.name === name);
151
+ }
152
+ /**
153
+ * How many tabs are open in a browser, keeper tab included.
154
+ *
155
+ * **The second half of a capacity refusal's assertion.** Refusing an
156
+ * over-budget claim means the tab count did not move, and a test that only
157
+ * checked the response would pass against a service that opened the tab and
158
+ * then said no. Use {@link FakeBrowserDriver.leasableTabCount} for the
159
+ * number the budget is actually about.
160
+ */
161
+ openTabCount(browser) {
162
+ return this.#openTabs.get(browser)?.size ?? 0;
163
+ }
164
+ /**
165
+ * Open tabs excluding the keeper tab — the number a budget is counted in.
166
+ *
167
+ * `SCHEMA.md` §3.15: the keeper tab **is not counted against the budget**,
168
+ * because it is not capacity anybody can use. Counting it would mean the
169
+ * budget was one lower than it says. Both counts are exposed rather than
170
+ * only this one, because *"one more tab than the budget accounts for"* is
171
+ * itself the thing §3.15 asks to be reconcilable.
172
+ */
173
+ leasableTabCount(browser) {
174
+ const keeper = this.#keeperTabs.get(browser);
175
+ const tabs = this.#openTabs.get(browser);
176
+ if (!tabs)
177
+ return 0;
178
+ return keeper !== undefined && tabs.has(keeper) ? tabs.size - 1 : tabs.size;
179
+ }
180
+ /** Empty the log. For a test with a setup phase whose calls are not the subject. */
181
+ clearCalls() {
182
+ this.#calls.length = 0;
183
+ }
184
+ /**
185
+ * Make the next call to `name` throw.
186
+ *
187
+ * For the paths whose whole behaviour is what happens when a browser does
188
+ * not co-operate: a close that fails is a **leaked tab and not a leaked
189
+ * lease** (`SCHEMA.md` §2.4b), and that distinction is untestable without a
190
+ * close that can fail on demand.
191
+ *
192
+ * **The failed call is still recorded**, with `failed: true`. That is the
193
+ * property that keeps the log honest — see this file's header.
194
+ */
195
+ failNext(name, error = new Error(`the fake was told to fail ${name}`)) {
196
+ this.#failures.push({ name, error });
197
+ }
198
+ /**
199
+ * Give a tab a particular set of cookies.
200
+ *
201
+ * **The lever a redaction test needs, and the shape of it is the point.**
202
+ * A test proving `read.cookies_no_values` (§7.1) seeds a cookie whose
203
+ * *value* is a known secret and then asserts that string appears nowhere in
204
+ * the response or in the file. This method takes {@link CookieSummary}
205
+ * entries, which have no value field — so the secret is supplied to the
206
+ * test's own driver-level fixture rather than through here, and this method
207
+ * exists to control the **names and flags** that do come back.
208
+ *
209
+ * That asymmetry is deliberate and it is the honest position: this fake
210
+ * cannot demonstrate that a value was dropped, because at this seam there
211
+ * was never a value to drop. What it can demonstrate is that everything
212
+ * else survives, which is the half of §3.9 a redaction is most likely to
213
+ * break by over-reaching.
214
+ */
215
+ seedCookies(tab, cookies) {
216
+ this.#cookies.set(tab.driverTabId, [...cookies]);
217
+ }
218
+ /**
219
+ * What a tab's storage holds for one origin and area, or nothing.
220
+ *
221
+ * **Read by a test the way a page would read it**, so an assertion built on
222
+ * this fails when the seed did not happen. Absent rather than empty-string
223
+ * for a key never written, because "seeded with the empty string" and
224
+ * "never seeded" are different facts and a test distinguishing them is the
225
+ * one that catches a seed that silently did nothing.
226
+ */
227
+ storedValue(tab, origin, area, key) {
228
+ return this.#storage.get(storageKey(tab.driverTabId, origin, area, key));
229
+ }
230
+ attach(browser, record) {
231
+ const failure = this.#enter({ name: 'attach', browser, detail: { endpoint: record.endpoint } });
232
+ if (failure)
233
+ return Promise.reject(failure);
234
+ return Promise.resolve(this.#session(browser));
235
+ }
236
+ coldStart(request) {
237
+ const failure = this.#enter({
238
+ name: 'coldStart',
239
+ browser: request.browser,
240
+ detail: { profileDirectory: request.profileDirectory, mode: request.mode },
241
+ });
242
+ if (failure)
243
+ return Promise.reject(failure);
244
+ return Promise.resolve(this.#session(request.browser, request.mode));
245
+ }
246
+ /**
247
+ * Append the entry, marking it failed when a failure is seeded for it.
248
+ *
249
+ * Every operation goes through this and {@link FakeBrowserDriver.#enter}
250
+ * rather than repeating the sequence at each call site, so that
251
+ * *record-before-failing* is stated once and cannot drift between
252
+ * operations. An operation that recorded after its failure check would be
253
+ * the single mutation that makes the whole log untrustworthy, and it would
254
+ * be invisible in a diff that only touched that one method.
255
+ */
256
+ #record(call) {
257
+ const seeded = this.#failures.find((failure) => failure.name === call.name);
258
+ this.#calls.push(seeded ? { ...call, failed: true } : call);
259
+ }
260
+ /**
261
+ * Record the call, then take the seeded failure if there is one.
262
+ *
263
+ * Returns the error rather than throwing it, so that every operation can
264
+ * surface it as a **rejected promise**. That distinction is not cosmetic: a
265
+ * real driver is asynchronous and reports a failure by rejecting, so a fake
266
+ * that threw synchronously would let the service's error handling be written
267
+ * against a shape production never produces — and the divergence would show
268
+ * up only once the real driver landed, which is the worst moment to find it.
269
+ */
270
+ #enter(call) {
271
+ this.#record(call);
272
+ const index = this.#failures.findIndex((failure) => failure.name === call.name);
273
+ if (index === -1)
274
+ return undefined;
275
+ const [seeded] = this.#failures.splice(index, 1);
276
+ return seeded?.error ?? new Error(`the fake was told to fail ${call.name}`);
277
+ }
278
+ #describe(browser, mode) {
279
+ const configured = browser === 'regular' ? this.#options.regular : this.#options.private;
280
+ return {
281
+ browser,
282
+ // A configured browser the fake has no entry for is headless with a
283
+ // derived process identifier. Named rather than defaulted silently:
284
+ // the fake stands up whatever browser a test asks for, and the two
285
+ // entries below are the two the default configuration has.
286
+ mode: mode ?? configured?.mode ?? DEFAULT_MODE[browser] ?? 'headless',
287
+ pid: configured?.pid ?? DEFAULT_PID[browser] ?? 4000 + endpointOffset(browser),
288
+ discovery: {
289
+ endpoint: `http://127.0.0.1:${String(9000 + endpointOffset(browser))}`,
290
+ browserUuid: `fake-${browser}-uuid`,
291
+ },
292
+ };
293
+ }
294
+ #tabsFor(browser) {
295
+ let tabs = this.#openTabs.get(browser);
296
+ if (!tabs) {
297
+ tabs = new Set();
298
+ this.#openTabs.set(browser, tabs);
299
+ }
300
+ return tabs;
301
+ }
302
+ #session(browser, mode) {
303
+ const description = this.#describe(browser, mode);
304
+ const openTab = (name) => {
305
+ const driverTabId = `fake-tab-${String(this.#nextTabNumber++)}`;
306
+ const failure = this.#enter({ name, browser, tab: { browser, driverTabId } });
307
+ // The tab is added only after the failure check. A capacity refusal's
308
+ // assertion is that the tab count did not move, so a fake that opened
309
+ // the tab and then rejected would report a count for work that failed.
310
+ if (failure)
311
+ return Promise.reject(failure);
312
+ this.#tabsFor(browser).add(driverTabId);
313
+ return Promise.resolve({ browser, driverTabId });
314
+ };
315
+ return {
316
+ describe: () => description,
317
+ openTab: () => openTab('openTab'),
318
+ /**
319
+ * Every page open in this browser **except the keeper tab**.
320
+ *
321
+ * ── Why the exclusion is here rather than left to the caller ────────
322
+ *
323
+ * `real.ts` excludes it, and says why: the keeper is *"never counted
324
+ * against the budget"* (§3.15) and never addressable, so it does not
325
+ * appear in the list capacity is derived from. This fake did not, and
326
+ * the divergence was invisible for as long as `listTabs` had no
327
+ * consumer in `src/`.
328
+ *
329
+ * **Reconciliation is that consumer** (`MILESTONES.md` #21a), and it is
330
+ * the one whose correctness the divergence destroys. Reconciliation
331
+ * closes pages no live lease owns; the keeper is owned by no lease, by
332
+ * construction. So a fake that listed it would make the fixture agree
333
+ * with a service that closes the keeper — and closing the keeper kills
334
+ * the shared signed-in session, because a headed browser dies within
335
+ * about half a second of its final tab closing.
336
+ *
337
+ * That is exactly the coinciding-fixture shape this repository keeps
338
+ * being caught by, in its most expensive form: the suite would be
339
+ * **evidence for** the destructive behaviour rather than against it,
340
+ * and nothing headed runs in continuous integration to contradict it.
341
+ */
342
+ listTabs: () => {
343
+ const failure = this.#enter({ name: 'listTabs', browser });
344
+ if (failure)
345
+ return Promise.reject(failure);
346
+ const keeper = this.#keeperTabs.get(browser);
347
+ return Promise.resolve([...this.#tabsFor(browser)]
348
+ .filter((driverTabId) => driverTabId !== keeper)
349
+ .map((driverTabId) => ({ browser, driverTabId })));
350
+ },
351
+ ensureKeeperTab: async () => {
352
+ const existing = this.#keeperTabs.get(browser);
353
+ if (existing !== undefined && this.#tabsFor(browser).has(existing)) {
354
+ // Idempotent: it is a precondition checked on every spawn
355
+ // (`SCHEMA.md` §7.2), so establishing it twice must not produce two
356
+ // tabs. The call is still recorded — a test proving the check ran
357
+ // needs to see it.
358
+ const failure = this.#enter({
359
+ name: 'ensureKeeperTab',
360
+ browser,
361
+ tab: { browser, driverTabId: existing },
362
+ });
363
+ if (failure)
364
+ throw failure;
365
+ return { browser, driverTabId: existing };
366
+ }
367
+ const tab = await openTab('ensureKeeperTab');
368
+ this.#keeperTabs.set(browser, tab.driverTabId);
369
+ return tab;
370
+ },
371
+ closeTab: (tab) => {
372
+ const failure = this.#enter({ name: 'closeTab', browser, tab });
373
+ // The tab stays open when the close fails. `SCHEMA.md` §2.4b: that is
374
+ // a leaked tab and not a leaked lease, and the distinction is only
375
+ // observable if the fake keeps the page it could not close.
376
+ if (failure)
377
+ return Promise.reject(failure);
378
+ // ── The keeper is not closable, and this is the mechanical half ────
379
+ //
380
+ // `keeper.never_leased` (§3.15, §7.3): the keeper is never
381
+ // addressable, and **a caller cannot close what it cannot name.**
382
+ // `real.ts` gets this structurally — the keeper's page is never put
383
+ // in its `#pages` map, so `closeTab` cannot resolve the handle and
384
+ // returns having done nothing.
385
+ //
386
+ // This fake mints its keeper through its own `openTab`, so without
387
+ // this branch the keeper's identifier **is** an ordinary tab name and
388
+ // closing it works. That is the same divergence the keeper had in
389
+ // `listTabs`, in its most consequential form: a fixture on which the
390
+ // destructive act succeeds is a fixture that would validate a service
391
+ // that performed it, and closing the keeper ends the shared signed-in
392
+ // browser — a headed browser dies within about half a second of its
393
+ // last tab closing.
394
+ //
395
+ // Returning without closing rather than rejecting, because that is
396
+ // what `real.ts` does and closing is best effort by design (§2.4b): a
397
+ // rejection here would be a driver reporting a failure the service is
398
+ // specified to ignore.
399
+ if (this.#keeperTabs.get(tab.browser) === tab.driverTabId) {
400
+ return Promise.resolve();
401
+ }
402
+ this.#tabsFor(tab.browser).delete(tab.driverTabId);
403
+ return Promise.resolve();
404
+ },
405
+ navigate: (tab, url) => {
406
+ const failure = this.#enter({ name: 'navigate', browser, tab, detail: { url } });
407
+ if (failure)
408
+ return Promise.reject(failure);
409
+ return Promise.resolve({ url, title: `fake page at ${url}`, status: 200 });
410
+ },
411
+ seedStorage: (tab, entries) => {
412
+ // **The whole entries list, values included**, and that is deliberate
413
+ // in a way the redaction rule does not contradict. The rule §3.2
414
+ // states is about the *ledger* — what the service persists — and the
415
+ // test that matters most for it is "a seeded value never reaches the
416
+ // events table". A fake that redacted here could not tell that test
417
+ // from a fake that was never given the value in the first place, so
418
+ // the log carries what the driver was actually handed and the
419
+ // assertion about redaction is made against the store.
420
+ //
421
+ // The log is in-memory, per-test, and never written anywhere.
422
+ const failure = this.#enter({
423
+ name: 'seedStorage',
424
+ browser,
425
+ tab,
426
+ detail: { entries: entries.map((entry) => ({ ...entry })) },
427
+ });
428
+ if (failure)
429
+ return Promise.reject(failure);
430
+ // Written **after** the failure check, so a seeded failure leaves the
431
+ // storage untouched — the same discipline `closeTab` keeps above, and
432
+ // for the same reason: a driver that half-performed a rejected call
433
+ // would let a test assert an effect the real driver never produced.
434
+ for (const entry of entries) {
435
+ this.#storage.set(storageKey(tab.driverTabId, entry.origin, entry.area, entry.key), entry.value);
436
+ }
437
+ return Promise.resolve();
438
+ },
439
+ act: (tab, request) => {
440
+ const failure = this.#enter({
441
+ name: 'act',
442
+ browser,
443
+ tab,
444
+ // The whole request, not a hand-picked few of its fields.
445
+ // `ActionRequest` is a union over the verb, so each member carries
446
+ // different arguments — a `resize` has a viewport and no reference,
447
+ // a `drag` has two references. Copying named fields would silently
448
+ // drop every argument belonging to a verb added after the copy was
449
+ // written, and a test asserting "it was asked to resize to 375
450
+ // wide" would then pass against a driver asked to resize to
451
+ // anything at all.
452
+ detail: { ...request },
453
+ });
454
+ if (failure)
455
+ return Promise.reject(failure);
456
+ // A fresh snapshot after every change (`SCHEMA.md` §3.8). No file is
457
+ // written — see this file's header on what the fake does not do.
458
+ return Promise.resolve({
459
+ artifact: 'snapshot',
460
+ path: `${tab.driverTabId}-after-${request.action}.snapshot`,
461
+ bytes: 0,
462
+ truncated: false,
463
+ });
464
+ },
465
+ read: (tab, artifacts) => {
466
+ const failure = this.#enter({
467
+ name: 'read',
468
+ browser,
469
+ tab,
470
+ detail: { artifacts: [...artifacts] },
471
+ });
472
+ if (failure)
473
+ return Promise.reject(failure);
474
+ return Promise.resolve(artifacts.map((artifact) => ({
475
+ artifact,
476
+ path: `${tab.driverTabId}-${artifact}`,
477
+ bytes: 0,
478
+ truncated: false,
479
+ })));
480
+ },
481
+ cookies: (tab) => {
482
+ const failure = this.#enter({ name: 'cookies', browser, tab });
483
+ if (failure)
484
+ return Promise.reject(failure);
485
+ // A canned pair rather than an empty list, because a redaction test
486
+ // asserting "no value appeared" against nothing at all is the
487
+ // assertion-over-an-empty-set this repository has already been caught
488
+ // by: it stays green when the redaction is deleted. `CookieSummary`
489
+ // has no value field, so there is nothing here to redact — which is
490
+ // the property #23's test exists to pin, not something this fake
491
+ // performs.
492
+ return Promise.resolve([...(this.#cookies.get(tab.driverTabId) ?? DEFAULT_COOKIES)]);
493
+ },
494
+ evaluate: (tab, expression) => {
495
+ const failure = this.#enter({ name: 'evaluate', browser, tab, detail: { expression } });
496
+ if (failure)
497
+ return Promise.reject(failure);
498
+ // ── The one expression this fake understands ────────────────────────
499
+ //
500
+ // **It is not an interpreter and must never become one.** It matches
501
+ // one fixed, exact form — a storage read, spelled out below — and
502
+ // answers it from the same map `seedStorage` writes. Everything else
503
+ // gets the canned `null` it always got.
504
+ //
505
+ // The reason it understands even this much: the property row #65 owes
506
+ // is *the page can see what was seeded before it loaded*, and "the
507
+ // page" reaches storage by evaluating. Without this, a test could
508
+ // only assert that `seedStorage` was called — which is the call log
509
+ // restated, and stays green against a driver whose seed writes
510
+ // nothing.
511
+ //
512
+ // A general evaluator here would be a worse fake, not a better one:
513
+ // it would make every evaluation test a test of this file's
514
+ // interpreter rather than of the service, and this file's header is
515
+ // explicit that the fake does not simulate a browser.
516
+ const read = STORAGE_READ_EXPRESSION.exec(expression);
517
+ if (read !== null) {
518
+ const [, area, origin, key] = read;
519
+ const value = this.#storage.get(storageKey(tab.driverTabId, origin, area, key));
520
+ // `null` and not `undefined` for a key that is not there, because
521
+ // that is what a real `getItem` answers for a missing key — and a
522
+ // test distinguishing "seeded" from "not seeded" reads the same
523
+ // shape either way.
524
+ return Promise.resolve({ value: value ?? null, bytes: 0 });
525
+ }
526
+ const canned = this.#options.evaluate;
527
+ if (canned !== undefined && Object.hasOwn(canned, 'value')) {
528
+ return Promise.resolve({ value: canned.value, bytes: 0 });
529
+ }
530
+ return Promise.resolve({ value: null, bytes: 0 });
531
+ },
532
+ settlePage: (tab) => {
533
+ const failure = this.#enter({ name: 'settlePage', browser, tab });
534
+ if (failure)
535
+ return Promise.reject(failure);
536
+ // Nothing to settle — the fake has no timing at all (see this file's
537
+ // header). What the entry in the log proves is that the pipeline
538
+ // asked, and *when* it asked relative to the shutter, which is the
539
+ // whole of what `SCHEMA.md` §3.11's "every capture settles the page
540
+ // first" is checkable as from outside a real browser.
541
+ return Promise.resolve();
542
+ },
543
+ capture: (tab, request) => {
544
+ const canned = this.#options.capture;
545
+ const failure = this.#enter({
546
+ name: 'capture',
547
+ browser,
548
+ tab,
549
+ // The mask is recorded as a count and as the rectangles themselves:
550
+ // "a mask was passed to the driver" and "it was *this* mask" are
551
+ // different assertions, and §3.11's masking-before-the-shutter
552
+ // property needs the second.
553
+ detail: {
554
+ fullPage: request.fullPage,
555
+ selector: request.selector,
556
+ mask: request.mask ? [...request.mask] : undefined,
557
+ },
558
+ });
559
+ if (failure)
560
+ return Promise.reject(failure);
561
+ const width = canned?.width ?? 1280;
562
+ const height = canned?.height ?? 720;
563
+ return Promise.resolve({
564
+ // A real, decodable picture by default — see `FakeCaptureOptions`.
565
+ image: canned?.image ?? solidPng(width, height),
566
+ width,
567
+ height,
568
+ viewportWidth: canned?.viewportWidth ?? width,
569
+ url: canned?.url ?? 'https://example.com/',
570
+ });
571
+ },
572
+ detach: () => {
573
+ const failure = this.#enter({ name: 'detach', browser });
574
+ if (failure)
575
+ return Promise.reject(failure);
576
+ // Non-destructive, and the tabs are deliberately untouched: attaching
577
+ // and detaching were measured to leave a browser exactly as they found
578
+ // it (`SCHEMA.md` §1.2a), and that is the property the shared-session
579
+ // design rests on. A fake that dropped its tabs here would let a test
580
+ // pass against a driver that killed the browser on detach.
581
+ return Promise.resolve();
582
+ },
583
+ };
584
+ }
585
+ }