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,1425 @@
1
+ // The one place the automation library is reached from, which is what
2
+ // `driver.import_isolated` (§7.3) requires: only the browser module reaches
3
+ // the automation library. Keeping it to this file is what makes the choice
4
+ // reversible and what stops a surface driving a browser behind the service's
5
+ // back.
6
+ //
7
+ // `playwright-core` rather than the full distribution: the browser binary is
8
+ // spawned by this service, detached and by path (§1.2a), so the package that
9
+ // downloads and manages browsers would be adding a lifecycle this design
10
+ // deliberately does not have.
11
+ import { randomUUID } from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { chromium, } from 'playwright-core';
16
+ import { slugFromUrl, stampFromInstant } from "../artifacts/names.js";
17
+ import { BrokerError, StartupRefusal } from "../errors.js";
18
+ import { readDiscoveryRecord, verifyDiscoveryRecord } from "./discovery.js";
19
+ import { coldStartDetached } from "./launch.js";
20
+ /**
21
+ * The real driver: attach to a browser that is already running, and cold-start
22
+ * one detached when none is.
23
+ *
24
+ * ── Which half is the ordinary one ──────────────────────────────────────
25
+ *
26
+ * **Attaching is the ordinary case and launching is the rare one**, which is
27
+ * the opposite of how the two names read (`SCHEMA.md` §1.2a). Browsers are
28
+ * **adopted, not owned**: no process here lives long enough to be a browser's
29
+ * parent, so whichever caller finds none running starts one and everyone
30
+ * after attaches to it. The browser outlives every process that touched it.
31
+ *
32
+ * ── What this file does not decide ──────────────────────────────────────
33
+ *
34
+ * **It does not decide which of the two to perform.** {@link attach} and
35
+ * {@link coldStart} are separate calls because they are separate acts with
36
+ * different risks, and nothing here makes one fall back to the other — the
37
+ * fallback is precisely the thing that must not be automatic, because two
38
+ * callers arriving at an empty machine at the same instant must produce **one
39
+ * launch, not two**. That is arbitrated in the store, in the same transaction
40
+ * that arbitrates claims (`adoption.ts`, row #54), not by whoever asked first.
41
+ *
42
+ * ── The operations that are declared and not implemented ────────────────
43
+ *
44
+ * **There are none left.** The seam declares the whole tab surface and every
45
+ * member of it is implemented here against the automation library. Rows #21
46
+ * through #24 owned the page verbs; the last two to land were {@link
47
+ * RealBrowserSession.act} (#22, with #61–#64) and {@link
48
+ * RealBrowserSession.read} (#23), which until then threw a refusal naming the
49
+ * row that would bring them rather than returning a plausible empty value — a
50
+ * verb that silently did nothing and reported success being exactly the shape
51
+ * `DECISIONS.md` §5 calls worse than no guard.
52
+ *
53
+ * **What is still not decided here is where the files go.** `act` and `read`
54
+ * hand back paths, so this file writes files; the directory it writes them
55
+ * into is supplied from outside and never chosen here. See
56
+ * {@link RealDriverOptions.outputDirectory}.
57
+ */
58
+ /**
59
+ * The pixel dimensions a PNG declares about itself.
60
+ *
61
+ * ── Why this reads eight bytes instead of importing a decoder ────────────
62
+ *
63
+ * `captures.source_*` is *what the browser produced*, and the only place that
64
+ * is stated without inference is the image header. A full-page capture is
65
+ * taller than the viewport by definition, so measuring the page instead would
66
+ * be wrong in exactly the case those fields exist to describe.
67
+ *
68
+ * The repository already has a real decoder, and this deliberately does not
69
+ * call it: **the capture pipeline owns decoding, and the browser module owns
70
+ * driving a browser.** Reaching across for two integers would put a second
71
+ * consumer on that module and make an isolation rule a matter of habit rather
72
+ * than of imports. Reading a fixed-offset header is smaller than the import
73
+ * it avoids, and it decodes nothing — the pixels are never touched here.
74
+ *
75
+ * Returns `undefined` rather than throwing on anything that is not a PNG: the
76
+ * caller has a page-measured fallback, and a capture that succeeded should not
77
+ * be turned into a failure by a header this function did not recognise.
78
+ */
79
+ function readPngDimensions(image) {
80
+ // Signature, then a 4-byte length, then the type `IHDR`, then width and
81
+ // height as big-endian 32-bit integers: 24 bytes before either is complete.
82
+ const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
83
+ if (image.length < 24) {
84
+ return undefined;
85
+ }
86
+ for (let index = 0; index < SIGNATURE.length; index += 1) {
87
+ if (image[index] !== SIGNATURE[index]) {
88
+ return undefined;
89
+ }
90
+ }
91
+ const view = new DataView(image.buffer, image.byteOffset, image.byteLength);
92
+ return { width: view.getUint32(16), height: view.getUint32(20) };
93
+ }
94
+ /**
95
+ * The blank address the keeper tab sits on.
96
+ *
97
+ * A page that loads nothing from anywhere, holds nothing, and cannot navigate
98
+ * itself somewhere else.
99
+ */
100
+ export const KEEPER_TAB_URL = 'about:blank';
101
+ /**
102
+ * How long a single attempt to resolve an element reference may take, in
103
+ * milliseconds.
104
+ *
105
+ * ── Why this exists at all ──────────────────────────────────────────────
106
+ *
107
+ * Without it the wait is the automation library's **default action timeout**,
108
+ * which is thirty seconds and is the right length for the question it was
109
+ * chosen to answer — *is this element going to appear* — and the wrong length
110
+ * for the one asked here, which is *does this connection know this reference*.
111
+ * A connection that has taken no snapshot never will, and waiting thirty
112
+ * seconds to say so was measured: 30.5 seconds for a command-line action whose
113
+ * reference was minted in the previous process.
114
+ *
115
+ * ── Why this number ─────────────────────────────────────────────────────
116
+ *
117
+ * Long enough that an element still being attached by the page's own scripts
118
+ * is found rather than declared missing; short enough that the whole failure
119
+ * path — a bounded attempt, a snapshot, a second bounded attempt — is a thing
120
+ * a caller waits through rather than a thing it times out on. It is a bound on
121
+ * *resolution only*: once a reference resolves, the verb runs under the
122
+ * library's ordinary timeouts, so nothing here shortens how long a click may
123
+ * take to complete.
124
+ */
125
+ export const REFERENCE_RESOLUTION_MS = 250;
126
+ /**
127
+ * A session over one attached browser.
128
+ *
129
+ * Holds the connection, the browser's identity, and the keeper tab, and hands
130
+ * out tab handles addressed by the driver's own name for a page.
131
+ */
132
+ class RealBrowserSession {
133
+ #browser;
134
+ #mode;
135
+ #pid;
136
+ #record;
137
+ #connection;
138
+ #context;
139
+ #keeper = { page: undefined };
140
+ /**
141
+ * The driver's name for a page, and the page it names.
142
+ *
143
+ * The identifier is **never returned to a caller on any surface** — callers
144
+ * hold the opaque lease identifier and the mapping between the two is row
145
+ * #21's. Keeping the driver's name inside this class is what stops it
146
+ * leaking outward by accident.
147
+ */
148
+ #pages = new Map();
149
+ /**
150
+ * How many artefacts this session has written, used to keep their names
151
+ * apart.
152
+ *
153
+ * ── Why a counter is needed on top of the timestamp ─────────────────────
154
+ *
155
+ * **Measured, driving the real browser through every verb: without this,
156
+ * roughly twenty writes produced four files.** The stamp `names.ts` supplies
157
+ * is second-granular, and every other part of an artefact's name — the
158
+ * address slug, which artefact it is, the tab it came from — is identical
159
+ * for two actions against one tab. So a run of actions inside one second all
160
+ * assembled the *same* name and silently overwrote each other.
161
+ *
162
+ * That is not a tidiness problem. {@link RealBrowserSession.act} promises a
163
+ * **fresh snapshot after every change**, and the promise is worthless if the
164
+ * path it hands back has been overwritten by the next action before the
165
+ * caller reads it: two results would name one file, and the earlier state
166
+ * would be gone with nothing reporting that it had been.
167
+ *
168
+ * A monotonic counter rather than a random identifier, which is where this
169
+ * departs from `captureFileName`'s fifth part: captures are written by many
170
+ * processes into one lease's directory and need uniqueness *without
171
+ * coordination*, whereas these are written by one session that can simply
172
+ * count. Counting also leaves a directory listing in the order the actions
173
+ * happened, which is what makes a sequence of snapshots readable as a
174
+ * sequence.
175
+ */
176
+ #artifactsWritten = 0;
177
+ /**
178
+ * What the context has recorded for each page it handed out, keyed by the
179
+ * driver's own name for that page.
180
+ *
181
+ * Keyed by the handle rather than held on the page so that a page closing
182
+ * does not take its own history with it: a caller may read the console of a
183
+ * tab whose last action closed something, and the accumulated log is the
184
+ * only place that history exists.
185
+ */
186
+ #recordings = new Map();
187
+ /** How the next dialog on each page will be answered. See {@link DialogDisposition}. */
188
+ #dialogs = new Map();
189
+ /**
190
+ * The pages this connection has registered element references against.
191
+ *
192
+ * ── Why this is remembered rather than recomputed ───────────────────────
193
+ *
194
+ * A reference resolves only in a connection that has snapshotted the page,
195
+ * and this service is daemonless — so the first action in a spawned process
196
+ * always reaches a page with no registration. {@link #locate} establishes it
197
+ * on first contact, and this is what tells it that the first contact has
198
+ * happened. **Asking the page instead is not available**: an unregistered
199
+ * reference and a reference whose element is gone both simply fail to
200
+ * resolve, so a lookup cannot distinguish the two and the whole point of
201
+ * remembering is to distinguish them. See {@link #locate}.
202
+ *
203
+ * Keyed by the page object rather than by the tab's name, and **weakly**: a
204
+ * page that closes is collectable with nothing here keeping it alive, which
205
+ * matters because a session may outlive many tabs. Membership means only
206
+ * *this connection has snapshotted this page at least once* — the reference
207
+ * engine's own registrations are what actually resolve anything.
208
+ */
209
+ #referenced = new WeakSet();
210
+ /** Where this session writes the files `act` and `read` hand back paths to. */
211
+ #outputDirectory;
212
+ constructor(options) {
213
+ this.#browser = options.browser;
214
+ this.#mode = options.mode;
215
+ this.#pid = options.pid;
216
+ this.#record = options.record;
217
+ this.#connection = options.connection;
218
+ this.#context = options.context;
219
+ // Defaulted to a directory of this session's own rather than to the
220
+ // artifact tree, because **this module does not know where the artifact
221
+ // tree is and must not**: §1.7a puts "the service decides where the file
222
+ // may go" in `artifacts/store.ts`, and a driver that reached for that
223
+ // store would be a second thing choosing locations under the root. The
224
+ // caller supplies a directory; this file only ever fills in a leaf.
225
+ // **Named eagerly, created lazily.** `mkdtempSync` both picks a name and
226
+ // makes the directory, so calling it here made one per driver whether or
227
+ // not that driver ever wrote an artefact — and since nothing removes it,
228
+ // a session that only ever read a page still left an empty directory
229
+ // behind for good. Measured on one developer machine: 1,389 empty
230
+ // `broker-artifacts-` directories against 27 from every other source
231
+ // combined.
232
+ //
233
+ // The name is still fixed at construction, so the directory a driver
234
+ // reports is stable for its whole life; only the `mkdir` moves, to
235
+ // `#write`, which already creates the tree recursively before writing.
236
+ // The upshot is that the directory now appears exactly when there is
237
+ // something to put in it.
238
+ this.#outputDirectory =
239
+ options.outputDirectory ??
240
+ path.join(os.tmpdir(), `broker-artifacts-${randomUUID().slice(0, 8)}`);
241
+ }
242
+ describe() {
243
+ return {
244
+ browser: this.#browser,
245
+ mode: this.#mode,
246
+ pid: this.#pid,
247
+ discovery: this.#record,
248
+ };
249
+ }
250
+ /**
251
+ * Mint a handle for a page and remember the mapping.
252
+ *
253
+ * ── The identifier is the browser's, and that is the whole point ────────
254
+ *
255
+ * `driver_tab_id` is specified as a **physical** identity: the store carries
256
+ * `CREATE UNIQUE INDEX one_row_per_physical_tab ON tabs (browser_id,
257
+ * driver_tab_id)`, whose own comment says the rule exists because "across
258
+ * separate processes this is not one option among several — there is no
259
+ * shared process to hold a lock in". So the name has to mean the same thing
260
+ * in every process that connects to one browser.
261
+ *
262
+ * **A counter cannot do that.** A per-session ordinal names the first page
263
+ * this connection happened to see, which is a fact about the connection
264
+ * rather than about the page: a second process attaching to the same browser
265
+ * either gives the same page a different name, or gives a *different* page
266
+ * the same name because it enumerated in a different order. Both are worse
267
+ * than they sound in a service that is spawned per caller and exits with it
268
+ * — a fresh process is the ordinary case, not an edge one, so the ordinary
269
+ * case is a caller unable to address the tab its own lease owns.
270
+ *
271
+ * The debugging protocol already assigns every page a stable identifier that
272
+ * every connection sees identically, so that is what is used. Verified
273
+ * against a live browser: the same page reports the same identifier from a
274
+ * second, independent connection, and the keeper reports a different one.
275
+ *
276
+ * It stays inside this class exactly as the ordinal did — never returned to
277
+ * a caller on any surface — so nothing about what is disclosed changes.
278
+ */
279
+ async #track(page) {
280
+ const driverTabId = await this.#identify(page);
281
+ this.#pages.set(driverTabId, page);
282
+ this.#recordFrom(driverTabId, page);
283
+ return { browser: this.#browser, driverTabId };
284
+ }
285
+ /**
286
+ * Ask the browser what it calls this page.
287
+ *
288
+ * A short protocol session, opened and detached around the one question. It
289
+ * is not held open: the identifier does not change, so there is nothing to
290
+ * keep listening for, and a session per page held for the life of a
291
+ * connection is a resource this class would have to reason about.
292
+ */
293
+ async #identify(page) {
294
+ const cdp = await this.#context.newCDPSession(page);
295
+ try {
296
+ const info = (await cdp.send('Target.getTargetInfo'));
297
+ return info.targetInfo.targetId;
298
+ }
299
+ finally {
300
+ // Best effort: a page that closed under us cannot be detached from, and
301
+ // the session dies with it either way.
302
+ await cdp.detach().catch(() => undefined);
303
+ }
304
+ }
305
+ /**
306
+ * Find a live page by the browser's own name for it, adopting it if this
307
+ * session has not seen it before.
308
+ *
309
+ * **This is what makes a tab addressable by the process that did not open
310
+ * it.** Without it, a handle really would be "only valid in the session that
311
+ * opened it", and a command line that spawns a process per command could
312
+ * drive a page exactly once — on the call that created it.
313
+ *
314
+ * Nothing is trusted from the caller here: the identifier is matched against
315
+ * pages **the browser reports as open**, so a name that matches nothing
316
+ * resolves to nothing. The keeper is excluded, so it stays unaddressable
317
+ * (§3.13) by the same rule that keeps it out of {@link listTabs}.
318
+ */
319
+ async #adopt(driverTabId) {
320
+ for (const page of this.#context.pages()) {
321
+ if (page.isClosed() || page === this.#keeper.page) {
322
+ continue;
323
+ }
324
+ if ((await this.#identify(page)) !== driverTabId) {
325
+ continue;
326
+ }
327
+ this.#pages.set(driverTabId, page);
328
+ this.#recordFrom(driverTabId, page);
329
+ return page;
330
+ }
331
+ return undefined;
332
+ }
333
+ /**
334
+ * Begin accumulating this page's console and network activity.
335
+ *
336
+ * **Attached here — at the moment the page is first named — rather than when
337
+ * a read asks for it**, and that ordering is the whole of §3.9's "the cost of
338
+ * not asking is zero". A listener attached on demand would collect from the
339
+ * moment of asking, which is precisely the *arm, act, collect* shape §3.9
340
+ * says is not needed and deliberately does not offer. See
341
+ * {@link PageRecording}.
342
+ *
343
+ * The entries are text rather than structures because what leaves this
344
+ * module is a **file**, and a file is text. Shaping them here keeps the
345
+ * serialisation in one place rather than splitting the format between the
346
+ * collector and the writer.
347
+ */
348
+ #recordFrom(driverTabId, page) {
349
+ if (this.#recordings.has(driverTabId))
350
+ return;
351
+ const recording = { console: [], network: [] };
352
+ this.#recordings.set(driverTabId, recording);
353
+ page.on('console', (message) => {
354
+ recording.console.push(`${message.type()}: ${message.text()}`);
355
+ });
356
+ page.on('request', (request) => {
357
+ recording.network.push(`${request.method()} ${request.url()}`);
358
+ });
359
+ // A dialog blocks its tab until something answers it, so a page with no
360
+ // handler at all would hand that decision to the automation library's
361
+ // default. The handler is installed once, here, and consults the standing
362
+ // disposition — which is what makes the `dialog` verb able to be armed
363
+ // *before* the action that trips one. See {@link DialogDisposition}.
364
+ page.on('dialog', (dialog) => {
365
+ const disposition = this.#dialogs.get(driverTabId);
366
+ // Consumed rather than left in place: a disposition is an answer to the
367
+ // next dialog, not a standing policy for the tab. Leaving it set would
368
+ // make one armed accept silently answer every later dialog, including
369
+ // ones a caller never anticipated.
370
+ this.#dialogs.delete(driverTabId);
371
+ // Answering is best effort: a dialog whose page has already gone rejects
372
+ // here, and there is nobody left for that to be an error to.
373
+ if (disposition === undefined || !disposition.accept) {
374
+ void dialog.dismiss().catch(() => undefined);
375
+ return;
376
+ }
377
+ void dialog.accept(disposition.promptText).catch(() => undefined);
378
+ });
379
+ }
380
+ /**
381
+ * The page a handle names.
382
+ *
383
+ * The keeper's handle is deliberately absent from this map, so every
384
+ * operation that resolves through here is structurally unable to address it
385
+ * — **a caller cannot drive what it cannot name** (§3.13).
386
+ */
387
+ async #page(tab) {
388
+ const held = this.#pages.get(tab.driverTabId);
389
+ if (held !== undefined && !held.isClosed()) {
390
+ return held;
391
+ }
392
+ // Not in this session's map, which is the ordinary state of a process that
393
+ // did not open the tab. The name is the browser's own (see {@link #track}),
394
+ // so it can be looked for among the pages the browser reports as open.
395
+ const adopted = await this.#adopt(tab.driverTabId);
396
+ if (adopted !== undefined) {
397
+ return adopted;
398
+ }
399
+ throw new Error(`No page is open in the ${this.#browser} browser for tab ${tab.driverTabId}. The page it named has closed, or it belongs to a different browser.`);
400
+ }
401
+ async openTab() {
402
+ // The keeper tab is established before any tab is handed out, so a lease
403
+ // can never be the only tab in the browser. `keeper.present` (§7.2) is a
404
+ // precondition on serving, and this is where serving begins.
405
+ await this.ensureKeeperTab();
406
+ const page = await this.#context.newPage();
407
+ return await this.#track(page);
408
+ }
409
+ /**
410
+ * Every page open in this browser at the moment it is asked, **including
411
+ * ones no lease of this service's owns** — row #21's reconciliation needs to
412
+ * see a page nobody here opened in order to close it.
413
+ *
414
+ * `async` because naming a page means asking the browser what it calls it,
415
+ * which is a round trip. It reported a resolved promise while the name was a
416
+ * local counter; the name is now the browser's, and the await is real.
417
+ */
418
+ async listTabs() {
419
+ const handles = [];
420
+ for (const page of this.#context.pages()) {
421
+ if (page === this.#keeper.page) {
422
+ // The keeper is never counted against the budget (§3.15) and is never
423
+ // addressable, so it does not appear in the list capacity is derived
424
+ // from. Counting it would make the tab budget one lower than it says.
425
+ continue;
426
+ }
427
+ const existing = [...this.#pages.entries()].find(([, held]) => held === page);
428
+ handles.push(existing === undefined
429
+ ? await this.#track(page)
430
+ : { browser: this.#browser, driverTabId: existing[0] });
431
+ }
432
+ return handles;
433
+ }
434
+ /**
435
+ * Establish the keeper tab if it is absent, and report it.
436
+ *
437
+ * **One blank, never-leased, never-addressable tab per browser, never
438
+ * counted against the budget** (§3.15). It is a correctness mechanism and
439
+ * not tidiness: the signed-in browser is headed, and a headed browser dies
440
+ * within about half a second of its final tab closing — so without it, the
441
+ * last caller to release its lease destroys the shared authenticated
442
+ * session by doing the single most ordinary thing a caller ever does.
443
+ *
444
+ * **Idempotent**, because it runs on every spawn and before every grant. A
445
+ * second call adopts the keeper already there rather than opening another
446
+ * blank tab, which would accumulate one uncounted tab per spawn.
447
+ */
448
+ async ensureKeeperTab() {
449
+ const existing = this.#keeper.page;
450
+ if (existing !== undefined && !existing.isClosed()) {
451
+ return { browser: this.#browser, driverTabId: this.#keeperHandleId() };
452
+ }
453
+ // A browser this process has just attached to already has pages, and one
454
+ // of them may be a keeper a previous caller established. Adopting one
455
+ // rather than opening another is what keeps the count stable across the
456
+ // many processes that attach over a browser's life.
457
+ const adoptable = this.#context
458
+ .pages()
459
+ .find((page) => !page.isClosed() && page.url() === KEEPER_TAB_URL);
460
+ this.#keeper.page = adoptable ?? (await this.#context.newPage());
461
+ if (adoptable === undefined) {
462
+ await this.#keeper.page.goto(KEEPER_TAB_URL);
463
+ }
464
+ return { browser: this.#browser, driverTabId: this.#keeperHandleId() };
465
+ }
466
+ /**
467
+ * The keeper's handle identifier.
468
+ *
469
+ * Deliberately not registered in the page map, so {@link closeTab} cannot
470
+ * resolve it and a caller holding it cannot close the keeper. **A caller
471
+ * cannot close what it cannot name** (§3.13), and this is the mechanical
472
+ * half of that: the handle exists so the service can assert the tab is
473
+ * present and exclude it from the count, not so anything can drive it.
474
+ */
475
+ #keeperHandleId() {
476
+ return `${this.#browser}-keeper`;
477
+ }
478
+ async closeTab(tab) {
479
+ const page = this.#pages.get(tab.driverTabId);
480
+ if (page === undefined) {
481
+ // Includes the keeper's handle, which is never in the map. Closing is
482
+ // best effort by design (§2.4b) — it runs after the arbitration
483
+ // transaction has committed, so a tab that will not close is a leaked
484
+ // tab and not a leaked lease.
485
+ return;
486
+ }
487
+ this.#pages.delete(tab.driverTabId);
488
+ await page.close();
489
+ }
490
+ /**
491
+ * Point a tab at an address and report where it actually ended up.
492
+ *
493
+ * The address after redirects rather than the one asked for, because those
494
+ * differ constantly and the caller needs the one it got.
495
+ */
496
+ async navigate(tab, url) {
497
+ const page = await this.#page(tab);
498
+ const response = await page.goto(url);
499
+ return {
500
+ url: page.url(),
501
+ title: await page.title(),
502
+ // Null when the navigation produced no response to have a status from —
503
+ // which is the ordinary case for an address the browser satisfies
504
+ // without a request.
505
+ status: response?.status() ?? null,
506
+ };
507
+ }
508
+ /**
509
+ * Write storage entries into their origins, **before the tab's first
510
+ * navigation** (§3.2, row #65).
511
+ *
512
+ * ── How the values reach storage, and why it is not an evaluation ───────
513
+ *
514
+ * The obvious implementation is an init script — a program the browser runs
515
+ * before each load — built by interpolating the caller's key and value into
516
+ * source text. **That is exactly what is not done here**, because building
517
+ * a program out of a caller's bytes is the interpreting position this whole
518
+ * argument exists to avoid, and it would be one string-escaping bug away
519
+ * from the arbitrary-code verb §9.4 measured being abused.
520
+ *
521
+ * Instead the page is brought to the entry's origin — which is the only way
522
+ * a browser will let anything write that origin's storage, since storage is
523
+ * partitioned by origin and there is no cross-origin write — and the value
524
+ * is written by a **fixed function with its arguments passed as data**,
525
+ * never concatenated into program text.
526
+ *
527
+ * **The parameterisation is the whole property and it is worth being
528
+ * precise about it.** The expression below is a fixed string literal that
529
+ * this file contains in full; it never varies with the entry. The area, the
530
+ * key and the value travel as an argument object, which the library
531
+ * serialises and the browser deserialises as data. A caller's value is
532
+ * therefore a `string` on both sides of that boundary and is never part of
533
+ * the program text — so **there is no position in this call in which a
534
+ * caller's bytes could be read as a program**, which is the claim §3.2
535
+ * makes structurally rather than as a promise.
536
+ *
537
+ * ── The honest limits ───────────────────────────────────────────────────
538
+ *
539
+ * - **The navigation to the origin is real.** Seeding an origin means
540
+ * visiting it, so the page does load once before the caller's own first
541
+ * navigation. That is not hidden: it is a request the site sees, and a
542
+ * caller seeding an origin it does not intend to visit should know it
543
+ * will be visited.
544
+ * - **Storage is per-origin, so entries are grouped and applied per
545
+ * origin.** Two origins mean two navigations.
546
+ * - **This does not sandbox the value.** It prevents the argument being a
547
+ * code channel; it does not make a credential in a shared browser private.
548
+ * See the seam's own note.
549
+ */
550
+ async seedStorage(tab, entries) {
551
+ if (entries.length === 0)
552
+ return;
553
+ const page = await this.#page(tab);
554
+ // Grouped by origin because storage is partitioned by origin: a write has
555
+ // to happen while the page is *at* that origin, so one navigation per
556
+ // origin is the minimum and doing it per entry would be the same
557
+ // navigation repeated.
558
+ const byOrigin = new Map();
559
+ for (const entry of entries) {
560
+ const group = byOrigin.get(entry.origin);
561
+ if (group === undefined) {
562
+ byOrigin.set(entry.origin, [entry]);
563
+ }
564
+ else {
565
+ group.push(entry);
566
+ }
567
+ }
568
+ for (const [origin, group] of byOrigin) {
569
+ await page.goto(origin);
570
+ await page.evaluate(
571
+ // A fixed program. It closes over nothing and interpolates nothing —
572
+ // every value it acts on arrives in the argument below, as data.
573
+ // Changing this to a template literal that embeds an entry would
574
+ // reintroduce exactly the evaluation this argument exists to avoid.
575
+ (items) => {
576
+ // The stores are reached through a typed local rather than through
577
+ // globals, because this project compiles without the browser type
578
+ // library — the same convention `settlePage` uses and for the same
579
+ // reason: this function runs in the page, so the compiler here has
580
+ // no reason to know those names.
581
+ const scope = globalThis;
582
+ for (const item of items) {
583
+ const store = item.area === 'local' ? scope.localStorage : scope.sessionStorage;
584
+ // `setItem` takes a key and a string. This is the interface §3.2
585
+ // names, and it is the reason a seeded value cannot be a program:
586
+ // there is no argument here that anything parses.
587
+ store.setItem(item.key, item.value);
588
+ }
589
+ }, group.map((entry) => ({ area: entry.area, key: entry.key, value: entry.value })));
590
+ }
591
+ }
592
+ /**
593
+ * Perform one page verb, and hand back **a fresh snapshot of the page as it
594
+ * now is** (`SCHEMA.md` §3.8, rows #22, #61, #62, #63, #64).
595
+ *
596
+ * ── Why the snapshot comes back from an action at all ───────────────────
597
+ *
598
+ * Every element reference a caller can use comes from a snapshot, so a
599
+ * caller acting twice against one snapshot is acting against a page that no
600
+ * longer matches it — and §3.8 names a stale reference as **the most common
601
+ * cause of an action landing on the wrong element**. Returning the page as
602
+ * it is now is what makes the next action addressable, which is why it is
603
+ * not an option and not an artefact list: an action returns one snapshot.
604
+ *
605
+ * ── Why this switches on the verb rather than reading optional fields ────
606
+ *
607
+ * {@link ActionRequest} is a **discriminated union over the verb**, and the
608
+ * union exists because the verbs need genuinely different arguments: a
609
+ * resize takes two integers and addresses no element, an emulate takes three
610
+ * independent enums, a dialog a boolean and a string, a `fill_form` a list,
611
+ * a `drag` a **second** reference. A flat read of `ref?` and `value?` would
612
+ * be the shape the seam's own note rejects — it would make *"which fields
613
+ * does this verb require"* a run-time question in every implementation, and
614
+ * it would silently drop every argument belonging to a verb added later.
615
+ *
616
+ * So the switch below is exhaustive over the discriminant, and the compiler
617
+ * is what keeps it exhaustive: a verb added to the union without a case here
618
+ * fails the type check rather than falling through to a default that did
619
+ * something plausible.
620
+ *
621
+ * ── What this method does NOT refuse, stated because the absence matters ─
622
+ *
623
+ * **The conventional refusals are not here, and their absence is not an
624
+ * oversight.** A zero width, a negative height, an empty reference, an
625
+ * emulate naming no preference, prompt text accompanying a dismissal, an
626
+ * over-long field list — every one of those is refused by `validateAction`
627
+ * in the service layer, before a request is ever shaped into this union. The
628
+ * seam's own note draws that line: the compiler owns the structural set, the
629
+ * guard owns the conventional set. Re-checking them here would put the same
630
+ * rule in two places and let them disagree.
631
+ *
632
+ * What *is* owed here is the refusal for **a reference that does not
633
+ * resolve** (§3.8), because whether an element is on the page is not a fact
634
+ * any validator upstream can know.
635
+ */
636
+ async act(tab, request) {
637
+ const page = await this.#page(tab);
638
+ switch (request.action) {
639
+ case 'click':
640
+ await (await this.#locate(page, request.ref)).click();
641
+ break;
642
+ case 'hover':
643
+ await (await this.#locate(page, request.ref)).hover();
644
+ break;
645
+ case 'check':
646
+ // `check` rather than `click`: it asserts the box ends up checked and
647
+ // is a no-op on one already checked, where a click would toggle it
648
+ // off. A caller asking for `check` twice means the box is checked.
649
+ await (await this.#locate(page, request.ref)).check();
650
+ break;
651
+ case 'type':
652
+ // Keystroke by keystroke, which is what distinguishes it from `fill`:
653
+ // a field that reacts to each key — a combo box filtering as it goes,
654
+ // a validator running per character — sees the keys it would see from
655
+ // a person.
656
+ await (await this.#locate(page, request.ref)).pressSequentially(request.value);
657
+ break;
658
+ case 'fill':
659
+ // Sets the value in one step. The ordinary way to put text in a field.
660
+ await (await this.#locate(page, request.ref)).fill(request.value);
661
+ break;
662
+ case 'select':
663
+ await (await this.#locate(page, request.ref)).selectOption(request.value);
664
+ break;
665
+ case 'press':
666
+ // The reference is optional, and the two branches are different acts
667
+ // rather than one with a default: with a reference the key goes to
668
+ // that element, without one it goes to whatever the page has focused,
669
+ // which is how a caller sends a key to a page rather than to a field.
670
+ if (request.ref === undefined) {
671
+ await page.keyboard.press(request.value);
672
+ }
673
+ else {
674
+ await (await this.#locate(page, request.ref)).press(request.value);
675
+ }
676
+ break;
677
+ case 'scroll':
678
+ if (request.ref === undefined) {
679
+ // The page. A fixed expression with nothing interpolated into it —
680
+ // the same convention `seedStorage` and `settlePage` use, and for
681
+ // the same reason: it runs in the page, so the compiler here has no
682
+ // reason to know those names.
683
+ await page.evaluate(() => {
684
+ const scope = globalThis;
685
+ scope.scrollBy(0, scope.innerHeight);
686
+ });
687
+ }
688
+ else {
689
+ await (await this.#locate(page, request.ref)).scrollIntoViewIfNeeded();
690
+ }
691
+ break;
692
+ case 'resize':
693
+ // #61, and **the measured reason this verb is on the list**: 578 calls
694
+ // across 140 sessions, 58% of every session that drove a browser at
695
+ // all. A viewport is a property of the browsing context rather than of
696
+ // anything in the page, so `browser_evaluate` cannot reach it — an
697
+ // expression can read the dimensions and cannot change the window they
698
+ // describe. Without this call the measured dominant loop (resize →
699
+ // navigate → evaluate → capture, once per breakpoint) is not merely
700
+ // awkward: responsive review is **inexpressible**.
701
+ //
702
+ // The bounds are the service's (`MAX_VIEWPORT_SIDE`), already applied.
703
+ await page.setViewportSize({
704
+ width: request.viewport.width,
705
+ height: request.viewport.height,
706
+ });
707
+ break;
708
+ case 'emulate':
709
+ // #62. Each preference is set only when the caller named it, because
710
+ // they are independent: a caller switching to dark mode is not saying
711
+ // anything about motion or contrast, and passing `undefined` for the
712
+ // two it did not mention is what leaves them as they were rather than
713
+ // resetting them.
714
+ await page.emulateMedia({
715
+ ...(request.preferences.colourScheme === undefined
716
+ ? {}
717
+ : { colorScheme: request.preferences.colourScheme }),
718
+ ...(request.preferences.reducedMotion === undefined
719
+ ? {}
720
+ : { reducedMotion: request.preferences.reducedMotion }),
721
+ ...(request.preferences.forcedColours === undefined
722
+ ? {}
723
+ : { forcedColors: request.preferences.forcedColours }),
724
+ });
725
+ break;
726
+ case 'dialog':
727
+ // #63. **Arms the answer for the next dialog; it does not answer one
728
+ // already up** — which is not a shortcut but the only implementable
729
+ // reading, because an unanswered dialog blocks the very action that
730
+ // raised it. See {@link DialogDisposition} for the measurement.
731
+ this.#dialogs.set(tab.driverTabId, {
732
+ accept: request.response.accept,
733
+ ...(request.response.promptText === undefined
734
+ ? {}
735
+ : { promptText: request.response.promptText }),
736
+ });
737
+ break;
738
+ case 'fill_form': {
739
+ // #64, measured at 78 calls across 35 sessions — the ordinary half of
740
+ // this row. Sequential rather than concurrent, deliberately: fields
741
+ // routinely depend on each other (a second field that only appears
742
+ // once the first is filled, a form that revalidates on every change),
743
+ // and filling them in parallel would race against the page's own
744
+ // reaction to the previous field.
745
+ for (const field of request.fields) {
746
+ await (await this.#locate(page, field.ref)).fill(field.value);
747
+ }
748
+ break;
749
+ }
750
+ case 'drag':
751
+ // #64, and **measured at zero calls across 2,007 transcripts in a
752
+ // month** — not "few", none. Implemented so the number that justified
753
+ // its low priority can be argued with rather than defended, and
754
+ // deliberately given no more machinery than the one call it needs.
755
+ //
756
+ // **In-page, element to element.** Both references come from the same
757
+ // snapshot; there is no file-from-the-desktop shape here, because a
758
+ // lease is a tab and the desktop is not in it.
759
+ //
760
+ // **Both references are resolved before either is used**, so a drag
761
+ // naming one good reference and one stale one refuses rather than
762
+ // picking the element up and dropping it nowhere.
763
+ await (await this.#locate(page, request.ref)).dragTo(await this.#locate(page, request.targetRef));
764
+ break;
765
+ }
766
+ // A fresh snapshot **after** the change, for the reason at the top of this
767
+ // method. It is taken for every verb including the ones that address no
768
+ // element: a resize reflows the page and an emulate can change what it
769
+ // renders outright, so the references a caller holds are exactly as stale
770
+ // after those two as after a click. §3.8 says so of `emulate` explicitly.
771
+ return this.#writeSnapshot(tab, page);
772
+ }
773
+ /**
774
+ * The element a reference names, or a refusal that says where it should have
775
+ * come from.
776
+ *
777
+ * ── Why the reference is resolved rather than interpreted ───────────────
778
+ *
779
+ * A reference is a name the **snapshot** minted (`[ref=e12]`), and it is
780
+ * looked up through the automation library's own reference engine. That is
781
+ * the point: this file never turns a caller's bytes into a selector it then
782
+ * evaluates. A caller's reference is matched against references the browser
783
+ * itself handed out, so a reference that names nothing resolves to nothing
784
+ * rather than to whatever a hand-built selector would have matched.
785
+ *
786
+ * ── Why resolution is attempted here rather than left to the verb ───────
787
+ *
788
+ * **The reference engine is populated per connection, and this service is
789
+ * daemonless.** A snapshot registers its references against the connection
790
+ * that took it; a caller running one command per process reads references in
791
+ * one process and acts on them in the next, which reaches a connection that
792
+ * has taken no snapshot and therefore knows no references at all. That is
793
+ * the ordinary arrangement on the command line, not an edge case.
794
+ *
795
+ * Left to the verb, that lookup does not fail — it **waits**, for the whole
796
+ * of the automation library's default action timeout, and only then reports
797
+ * a failure whose text is about the element not appearing. Measured at 30.5
798
+ * seconds for a reference that could never have resolved. So the wait is
799
+ * bounded here instead, and the refusal names the reference rather than the
800
+ * timeout.
801
+ *
802
+ * ── The snapshot is taken ONCE PER PAGE, and only when this connection has
803
+ * never taken one ───────────────────────────────────────────────────
804
+ *
805
+ * Two measured facts about the reference engine decide the shape here, and
806
+ * they pull in opposite directions.
807
+ *
808
+ * **Within one connection, a reference is bound to an element and stays
809
+ * bound to it.** Snapshotting a second time does not renumber: on a page of
810
+ * heading `e3`, button `e4`, textbox `e5`, removing the button and
811
+ * snapshotting again leaves the textbox `e5` and leaves `e4` resolving to
812
+ * nothing. So inside a connection, a reference going stale is *reported* as
813
+ * a reference going stale, which is what a caller needs.
814
+ *
815
+ * **Across connections, the names are assigned afresh from the tree's
816
+ * current shape.** The same removal seen by a connection that had never
817
+ * snapshotted the page yields heading `e3`, **textbox `e4`** — the textbox
818
+ * inherits the removed button's name.
819
+ *
820
+ * Taking the snapshot is what makes the cross-process case work at all: a
821
+ * connection that has snapshotted nothing knows no references, and every
822
+ * `act` from a freshly spawned process was in that state.
823
+ *
824
+ * ── Why it is taken up front rather than on the failure path ────────────
825
+ *
826
+ * The obvious alternative is to try the lookup first and snapshot only when
827
+ * it fails, which saves a round trip on every action after the first.
828
+ * **Measured, that alternative behaves identically** — the renumbering above
829
+ * happens only on a connection's *first* snapshot, and once a page has been
830
+ * registered here a further snapshot never revives or re-points a reference
831
+ * whose element has gone. So it is not the hazard it looks like.
832
+ *
833
+ * It is not used because its safety rests on that last fact, which is a
834
+ * detail of the automation library established by measurement rather than by
835
+ * documentation, and which nothing would fail if a future version changed.
836
+ * Registering before the first lookup makes the guarantee structural
837
+ * instead: by the time any lookup runs, this connection's references
838
+ * describe the page, so a reference that does not resolve has genuinely gone
839
+ * — and that stays true however the engine renumbers. The cost is the same
840
+ * one snapshot per page per connection either way.
841
+ *
842
+ * So the first action against a page pays one round trip, every action after
843
+ * it pays none, and the staleness a caller must be told about is preserved
844
+ * rather than papered over. The protection against acting on a page that
845
+ * moved underneath you is unchanged and lives where it always did: every
846
+ * action hands back a fresh snapshot ({@link act}).
847
+ */
848
+ async #locate(page, ref) {
849
+ // `aria-ref` is the engine that resolves the identifiers an AI-mode aria
850
+ // snapshot mints, which is the same snapshot {@link #writeSnapshot}
851
+ // writes and hands a path to. The two halves are deliberately the same
852
+ // mechanism: a reference a caller read out of our snapshot file is a
853
+ // reference this resolves, and there is no translation step in between to
854
+ // get wrong.
855
+ //
856
+ // Once per page, before the first lookup against it — so that what follows
857
+ // is a question about the page rather than about this connection's
858
+ // history. See the note above for why it is not repeated.
859
+ if (!this.#referenced.has(page)) {
860
+ this.#referenced.add(page);
861
+ await page.locator('html').ariaSnapshot({ mode: 'ai' });
862
+ }
863
+ const locator = page.locator(`aria-ref=${ref}`);
864
+ if (await this.#resolves(locator)) {
865
+ return locator;
866
+ }
867
+ // **Nothing, so this refuses rather than proceeding.** An action on an
868
+ // unresolvable reference must not report success: a verb that accepted and
869
+ // did nothing is the defect this whole path exists to remove. The throw
870
+ // leaves `pageDriven` false — the flag is set by the last statement of the
871
+ // after-commit closure, which this never reaches — so the caller is told
872
+ // the page was not driven, and told why.
873
+ throw new BrokerError('act.ref_resolves', `No element on this page matches the reference "${ref}". References are minted by a snapshot and describe the page as it was when that snapshot was taken, so a reference goes stale when the page changes underneath it. Read the page again and use a reference from the snapshot that read returns.`);
874
+ }
875
+ /**
876
+ * Whether a reference names something on the page **now**, answered within a
877
+ * bound.
878
+ *
879
+ * ── Why a bounded wait rather than a count ──────────────────────────────
880
+ *
881
+ * Counting the matches would answer immediately and would answer the wrong
882
+ * question on a page that is still settling: an element arriving a moment
883
+ * later is present, and a check that ran before it arrived would refuse a
884
+ * reference that was about to be perfectly good. So this waits — but for a
885
+ * *short* bound of its own rather than the action timeout, because the case
886
+ * it is distinguishing is a reference that was never registered in this
887
+ * connection, and no amount of waiting fixes that one.
888
+ *
889
+ * The bound is deliberately far below the automation library's default
890
+ * action timeout of thirty seconds, which is the length a lookup takes to
891
+ * fail when nothing bounds it. What follows a `false` here is a refusal, so
892
+ * an unresolvable reference costs one of these and is answered well inside a
893
+ * second.
894
+ *
895
+ * Attached is not required, only present: `attached` is the weakest state
896
+ * that means *the reference resolves to an element*, and the stronger states
897
+ * are the verb's business. A disabled button, an element scrolled out of
898
+ * view or one covered by an overlay is a reference that resolved and an
899
+ * action that should fail on its own terms, with the automation library's
900
+ * own message about why — not one this misreports as an unknown reference.
901
+ */
902
+ async #resolves(locator) {
903
+ try {
904
+ await locator.waitFor({ state: 'attached', timeout: REFERENCE_RESOLUTION_MS });
905
+ return true;
906
+ }
907
+ catch {
908
+ // The only thing a failure here means is *not attached within the
909
+ // bound*, which is the question being asked. It is not reported: the
910
+ // caller gets the refusal below it, which names the reference.
911
+ return false;
912
+ }
913
+ }
914
+ /**
915
+ * Write the page's accessibility tree and report where it went.
916
+ *
917
+ * **The AI mode is what mints the element references** (`[ref=e12]`), and
918
+ * that is why it is used rather than the plain rendering: §3.9 calls the
919
+ * snapshot the only load-bearing artefact precisely because every reference
920
+ * `browser_act` takes comes from it. A snapshot without references would be
921
+ * readable and useless.
922
+ */
923
+ async #writeSnapshot(tab, page) {
924
+ const snapshot = await page.locator('html').ariaSnapshot({ mode: 'ai' });
925
+ return this.#write(tab, 'snapshot', page.url(), snapshot);
926
+ }
927
+ /**
928
+ * Write the requested artefacts to disk and report where each went
929
+ * (`SCHEMA.md` §3.9, row #23).
930
+ *
931
+ * ── The snapshot is the default because it is the only load-bearing one ──
932
+ *
933
+ * Console output, network activity and the cookie summary answer questions a
934
+ * caller has sometimes and most callers never have at all. The snapshot is
935
+ * what a caller needs in order to act at all. Which artefacts arrive here is
936
+ * the service's decision (`resolveReadArtifacts` always includes the
937
+ * snapshot); what this does is honour the list it is given, in the order it
938
+ * is given, so that a caller's result lines up with its request.
939
+ *
940
+ * ── Why asking for the console is free, which is the part worth knowing ──
941
+ *
942
+ * **Console and network are accumulated continuously by the browsing
943
+ * context** ({@link PageRecording}), from before the lease existed. So the
944
+ * list below is a filter on **what gets written to disk**, not on what gets
945
+ * collected — nothing is avoided by not asking, because nothing was being
946
+ * done on demand. A caller that only afterwards realises it wanted the
947
+ * console asks on its next read and gets the whole history, not a recording
948
+ * that started when it asked.
949
+ *
950
+ * **Cookies are the exception and are a live query.** They are answered
951
+ * against the context at the moment of asking, so that one *does* cost
952
+ * something.
953
+ *
954
+ * ── Cookie values are structurally absent, not redacted ─────────────────
955
+ *
956
+ * The cookie file is written from {@link RealBrowserSession.cookies}, which
957
+ * returns {@link CookieSummary} — **a type with no value field**. This
958
+ * method never sees a cookie value, so there is no redaction step here for
959
+ * anybody to forget and no branch on which one could survive. That is the
960
+ * shape §7.1 `read.cookies_no_values` asks for: serialising the jar directly
961
+ * here would have put the only checkable point inside the module that holds
962
+ * the values.
963
+ */
964
+ async read(tab, artifacts) {
965
+ const page = await this.#page(tab);
966
+ const results = [];
967
+ for (const artifact of artifacts) {
968
+ switch (artifact) {
969
+ case 'snapshot':
970
+ results.push(await this.#writeSnapshot(tab, page));
971
+ break;
972
+ case 'console':
973
+ results.push(this.#write(tab, 'console', page.url(), this.#recording(tab).console.join('\n')));
974
+ break;
975
+ case 'network':
976
+ results.push(this.#write(tab, 'network', page.url(), this.#recording(tab).network.join('\n')));
977
+ break;
978
+ case 'cookies': {
979
+ // Through the seam's own cookie member, never by serialising the
980
+ // jar here — see this method's note on why that is the whole
981
+ // mechanism rather than a preference.
982
+ const summaries = await this.cookies(tab);
983
+ results.push(this.#write(tab, 'cookies', page.url(), JSON.stringify(summaries, null, 2)));
984
+ break;
985
+ }
986
+ }
987
+ }
988
+ return results;
989
+ }
990
+ /** What has accumulated for a tab, or an empty history for one that has none. */
991
+ #recording(tab) {
992
+ return this.#recordings.get(tab.driverTabId) ?? { console: [], network: [] };
993
+ }
994
+ /**
995
+ * Write one artefact into this session's output directory.
996
+ *
997
+ * ── What this does and does not decide ──────────────────────────────────
998
+ *
999
+ * **It fills in a leaf; it does not choose a location.** The directory
1000
+ * arrives from outside (see the constructor), and every name assembled here
1001
+ * is built from parts run through `names.ts` — the same rules §1.7a applies
1002
+ * to a capture's file name, and for the same reason: a file name travels
1003
+ * further than a database column does, so the address a name is derived from
1004
+ * has its query string stripped before anything else.
1005
+ *
1006
+ * `truncated` is reported as `false` and that is honest rather than
1007
+ * placeholder: nothing here truncates. **The cap that would make it
1008
+ * sometimes true is the service's**, in the same way §3.10's inline cap is,
1009
+ * and inventing a byte count in this file would be a policy nobody agreed
1010
+ * applied before the row that owns it could argue with it.
1011
+ */
1012
+ #write(tab, artifact, url, contents) {
1013
+ fs.mkdirSync(this.#outputDirectory, { recursive: true });
1014
+ // The address's slug first so a listing groups a page's artefacts
1015
+ // together, then what kind it is, then the instant — the same ordering
1016
+ // §1.7a chooses for a capture, and readable for the same reason.
1017
+ const fileName = [
1018
+ slugFromUrl(url),
1019
+ artifact,
1020
+ stampFromInstant(new Date()),
1021
+ // The driver's own name for the tab, which is unique within this session
1022
+ // and is what keeps two tabs of one page from writing the same file.
1023
+ // It never leaves this process on any surface — a caller holds the
1024
+ // opaque lease identifier — so using it here names a file without
1025
+ // widening what is disclosed.
1026
+ tab.driverTabId,
1027
+ // Last, and load-bearing: the stamp above is only second-granular, so
1028
+ // without this every artefact written inside one second collides. See
1029
+ // {@link #artifactsWritten} for the measurement that caught it.
1030
+ String(this.#artifactsWritten),
1031
+ ].join('-');
1032
+ this.#artifactsWritten += 1;
1033
+ const destination = path.join(this.#outputDirectory, `${fileName}.txt`);
1034
+ const bytes = Buffer.byteLength(contents, 'utf8');
1035
+ fs.writeFileSync(destination, contents, 'utf8');
1036
+ return { artifact, path: destination, bytes, truncated: false };
1037
+ }
1038
+ /**
1039
+ * Evaluate an expression in the page.
1040
+ *
1041
+ * **The inline cap and the spill-to-path decision are row #24 owns**, and
1042
+ * they are deliberately not invented here: this returns the value and the
1043
+ * size it measured, which is what that row needs in order to decide. A byte
1044
+ * count chosen in this file would be a policy nobody agreed, applied before
1045
+ * the row that owns it could argue with it.
1046
+ */
1047
+ async evaluate(tab, expression) {
1048
+ const page = await this.#page(tab);
1049
+ const value = await page.evaluate(expression);
1050
+ // Measured on the serialised form, because that is what a caller would be
1051
+ // charged for if it were returned, and it is the only size that means
1052
+ // anything for a value that is not a string.
1053
+ let bytes;
1054
+ try {
1055
+ bytes = Buffer.byteLength(JSON.stringify(value) ?? 'undefined', 'utf8');
1056
+ }
1057
+ catch {
1058
+ // A value that will not serialise has no size to report; the row that
1059
+ // decides what to do with large values is the one that should decide
1060
+ // what to do with unserialisable ones too.
1061
+ bytes = 0;
1062
+ }
1063
+ return { value, bytes };
1064
+ }
1065
+ /**
1066
+ * Cookie **summaries** — names, domains, paths, expiries and flags, and
1067
+ * **structurally no values**.
1068
+ *
1069
+ * Row #23 owns the read surface. What is implemented here is the part that
1070
+ * belongs to this module rather than to that row, because
1071
+ * `read.cookies_no_values` (§7.1) is a **shape** and not a refusal: a
1072
+ * service handing over cookie values is a credential-export feature
1073
+ * whatever else it is called. The values exist in this process only inside
1074
+ * this method, and they leave it in a shape with nowhere to put one — so
1075
+ * there is no later redaction step for anybody to forget.
1076
+ *
1077
+ * **The mapping is field by field on purpose.** A spread with the value
1078
+ * deleted would carry every field the automation library adds in a future
1079
+ * version, which is precisely how a value comes back without anybody
1080
+ * writing a line that says so.
1081
+ *
1082
+ */
1083
+ async cookies(tab) {
1084
+ // Addressed to the tab, so a lease on one page is not a read of the whole
1085
+ // profile's jar — even though the tabs in one browser do share it (§1.2).
1086
+ const page = await this.#page(tab);
1087
+ const jar = await this.#context.cookies(page.url());
1088
+ return jar.map((cookie) => ({
1089
+ name: cookie.name,
1090
+ domain: cookie.domain,
1091
+ path: cookie.path,
1092
+ // A session cookie is reported with a negative expiry; the seam asks
1093
+ // for null, so the distinction is made here rather than left as a magic
1094
+ // number for a reader to decode.
1095
+ expires: cookie.expires < 0 ? null : new Date(cookie.expires * 1000).toISOString(),
1096
+ httpOnly: cookie.httpOnly,
1097
+ secure: cookie.secure,
1098
+ sameSite: cookie.sameSite,
1099
+ }));
1100
+ }
1101
+ /**
1102
+ * Stop the page moving, so the same page produces the same pixels.
1103
+ *
1104
+ * `SCHEMA.md` §3.11 calls settling the highest-value line in the comparison
1105
+ * feature, and the reason is that **no threshold fixes movement**: a colour
1106
+ * tolerance is a per-pixel comparison and has nothing to say about a banner
1107
+ * mid-fade, a transition in flight, a blinking caret or an image that
1108
+ * arrived one frame later.
1109
+ *
1110
+ * **Kept as its own call rather than folded into {@link capture}** — which
1111
+ * is the seam's decision and the right one. A driver that settled inside its
1112
+ * own capture would make *"every capture settles first"* a property of
1113
+ * whichever driver happens to be installed, provable only by reading it. As
1114
+ * two calls the ordering belongs to the pipeline, and a driver that forgot
1115
+ * to settle cannot hide the omission.
1116
+ *
1117
+ * The style sheet is added rather than toggled on elements one by one so
1118
+ * that it applies to everything the page renders, including nodes that do
1119
+ * not exist yet when this runs.
1120
+ */
1121
+ async settlePage(tab) {
1122
+ const page = await this.#page(tab);
1123
+ await page.addStyleTag({
1124
+ content: `
1125
+ *, *::before, *::after {
1126
+ animation-duration: 0s !important;
1127
+ animation-delay: 0s !important;
1128
+ animation-iteration-count: 1 !important;
1129
+ transition-duration: 0s !important;
1130
+ transition-delay: 0s !important;
1131
+ scroll-behavior: auto !important;
1132
+ }
1133
+ /* The caret blinks on its own schedule, so it is a pixel that differs
1134
+ between two runs of an identical page. */
1135
+ * { caret-color: transparent !important; }
1136
+ `,
1137
+ });
1138
+ // Web fonts land after first paint, and text rendered in a fallback face
1139
+ // is different pixels from the same text in the intended one.
1140
+ //
1141
+ // The document is reached through a typed local rather than a global,
1142
+ // because this project compiles without the browser type library: the
1143
+ // expression runs in the page, so the compiler here has no reason to know
1144
+ // those names and is right not to.
1145
+ await page.evaluate(() => {
1146
+ const scope = globalThis;
1147
+ return scope.document?.fonts?.ready ?? Promise.resolve();
1148
+ });
1149
+ }
1150
+ /**
1151
+ * Take a picture and hand back the pixels.
1152
+ *
1153
+ * **The correct-surface property is owed here**, and the seam says so:
1154
+ * `capture.surface_required` (§7.3) is not a parameter, because a parameter
1155
+ * would be a way to disable it. It is a property of this implementation.
1156
+ *
1157
+ * ── How it is actually satisfied, which is not what the name suggests ───
1158
+ *
1159
+ * Measured while building this row, in both modes, with a *different* tab in
1160
+ * front: the capture returned **the requested tab's own pixels** every time.
1161
+ * The automation library captures per target over the debugging protocol
1162
+ * rather than photographing the window surface, so it cannot return whatever
1163
+ * happens to be in front — and **nothing here brings a tab to the front**,
1164
+ * which `foreground.never_moved` (§7.3) requires and which this method's
1165
+ * complete absence of an activation call is the whole of.
1166
+ *
1167
+ * What the rule genuinely guards against is a background tab that has
1168
+ * **stopped rendering**, and that is prevented at launch: see
1169
+ * `CAPTURE_SURFACE_ARGUMENTS` in `launch.ts`, applied in both modes.
1170
+ *
1171
+ * **Masks are painted before the shutter, never after** — a mask applied
1172
+ * afterwards is a mask that was, for one moment, not applied.
1173
+ */
1174
+ async capture(tab, request) {
1175
+ const page = await this.#page(tab);
1176
+ // Painted **before** the shutter, never after: a mask applied afterwards
1177
+ // is a mask that was, for one moment, not applied. The request names
1178
+ // rectangles, so they are drawn into the page as elements rather than
1179
+ // handed to an element-masking interface that has no rectangle to point
1180
+ // at.
1181
+ const masks = request.mask ?? [];
1182
+ const maskMarker = 'data-broker-capture-mask';
1183
+ if (masks.length > 0) {
1184
+ await page.evaluate(({ areas, marker }) => {
1185
+ const scope = globalThis;
1186
+ for (const area of areas) {
1187
+ const node = scope.document.createElement('div');
1188
+ node.setAttribute(marker, '');
1189
+ node.style.cssText = [
1190
+ 'position:fixed',
1191
+ `left:${String(area.x)}px`,
1192
+ `top:${String(area.y)}px`,
1193
+ `width:${String(area.width)}px`,
1194
+ `height:${String(area.height)}px`,
1195
+ 'background:#000',
1196
+ 'z-index:2147483647',
1197
+ 'pointer-events:none',
1198
+ ].join(';');
1199
+ scope.document.body.appendChild(node);
1200
+ }
1201
+ }, { areas: masks, marker: maskMarker });
1202
+ }
1203
+ try {
1204
+ // `fullPage` describes a page and means nothing for one element, so it
1205
+ // is passed only where it applies rather than defaulted into a call that
1206
+ // would quietly ignore it.
1207
+ const image = request.selector === undefined
1208
+ ? await page.screenshot({ type: 'png', fullPage: request.fullPage })
1209
+ : await page.locator(request.selector).screenshot({ type: 'png' });
1210
+ // Read from the page rather than from whatever a caller last asked to
1211
+ // resize to: those disagree whenever a resize did not take, and the
1212
+ // breakpoint a picture was taken at is the one the page actually had.
1213
+ const measured = await page.evaluate(() => {
1214
+ const scope = globalThis;
1215
+ return {
1216
+ viewportWidth: scope.innerWidth,
1217
+ scrollWidth: scope.document.documentElement.scrollWidth,
1218
+ scrollHeight: scope.document.documentElement.scrollHeight,
1219
+ };
1220
+ });
1221
+ // The dimensions are what the browser actually produced
1222
+ // (`captures.source_*`), read out of the image rather than inferred from
1223
+ // the viewport: a full-page capture is taller than the viewport by
1224
+ // definition, so reporting the viewport would be wrong in exactly the
1225
+ // case these fields exist to describe.
1226
+ const produced = readPngDimensions(image);
1227
+ return {
1228
+ image,
1229
+ width: produced?.width ?? measured.scrollWidth,
1230
+ height: produced?.height ?? measured.scrollHeight,
1231
+ viewportWidth: measured.viewportWidth,
1232
+ url: page.url(),
1233
+ };
1234
+ }
1235
+ finally {
1236
+ // Removed whether or not the shutter succeeded, so a failed capture does
1237
+ // not leave black rectangles over a page whose lease is still live.
1238
+ if (masks.length > 0) {
1239
+ await page.evaluate((marker) => {
1240
+ const scope = globalThis;
1241
+ const nodes = scope.document.querySelectorAll(`[${marker}]`);
1242
+ for (let index = 0; index < nodes.length; index += 1) {
1243
+ nodes[index]?.remove();
1244
+ }
1245
+ }, maskMarker);
1246
+ }
1247
+ }
1248
+ }
1249
+ /**
1250
+ * End this process's connection. **The browser is unaffected.**
1251
+ *
1252
+ * Measured: attaching and detaching are non-destructive to tabs, cookies and
1253
+ * local storage — a caller connecting and disconnecting leaves the browser
1254
+ * exactly as it found it (§1.2a). That measurement is the property the whole
1255
+ * shared-session design rests on, which is why this closes the *connection*
1256
+ * and never the browser.
1257
+ */
1258
+ async detach() {
1259
+ await this.#connection.close();
1260
+ }
1261
+ }
1262
+ /** The browser modes, fixed by which browser it is (§1.2, §3.15). */
1263
+ export function modeFor(browser) {
1264
+ // The signed-in browser is headed, and that is the fact the keeper tab
1265
+ // exists for; the private one is headless and has no sign-in to lose.
1266
+ return browser === 'regular' ? 'headed' : 'headless';
1267
+ }
1268
+ /**
1269
+ * Connect to a browser whose endpoint has already been verified, and wrap it.
1270
+ *
1271
+ * Split out because both halves of the driver end here: a cold start verifies
1272
+ * the browser it just produced and then attaches to it exactly as an
1273
+ * attaching caller would, so there is one connection path rather than two.
1274
+ */
1275
+ async function connect(options) {
1276
+ const connection = await chromium.connectOverCDP(options.record.endpoint);
1277
+ const [context] = connection.contexts();
1278
+ if (context === undefined) {
1279
+ await connection.close();
1280
+ throw new StartupRefusal('keeper.present', 'The browser answered but exposes no browsing context, so there is nothing to open a tab in.');
1281
+ }
1282
+ const session = new RealBrowserSession({
1283
+ browser: options.browser,
1284
+ mode: modeFor(options.browser),
1285
+ pid: options.pid,
1286
+ record: options.record,
1287
+ connection,
1288
+ context,
1289
+ ...(options.outputDirectory === undefined ? {} : { outputDirectory: options.outputDirectory }),
1290
+ });
1291
+ // `keeper.present` (§7.2): each browser has its keeper tab open **before
1292
+ // any lease is granted against it**. Establishing it here rather than
1293
+ // leaving it to the first grant is what makes the precondition true for
1294
+ // every path that reaches a browser, including the ones that never grant.
1295
+ await session.ensureKeeperTab();
1296
+ return session;
1297
+ }
1298
+ /**
1299
+ * Where a configured engine's binary lives, if this process was told.
1300
+ *
1301
+ * ── What is built here, and what is deliberately not ────────────────
1302
+ *
1303
+ * All three engines are Chromium over the same remote-debugging protocol, so
1304
+ * choosing between them is choosing a binary — which is what makes the hook
1305
+ * cheap, and it is the hook `DECISIONS.md` §13i asks for. **The expensive
1306
+ * half is explicitly out of scope there**: per-engine executable discovery,
1307
+ * per-engine discovery-record locations, per-engine health checks.
1308
+ *
1309
+ * **So resolution is a lookup of what a caller supplied, never a search.**
1310
+ * The engine selects among paths this process was given; it does not go
1311
+ * looking for an installation, and it never carries a path of its own.
1312
+ * Writing a per-engine install location into this file would name one machine
1313
+ * — §1.0's rule forbids that outright, and `check-external-refs` fails on the
1314
+ * shape.
1315
+ *
1316
+ * **What happens when nothing supplied a path for the configured engine** is
1317
+ * the case worth being exact about: the launch falls back to the automation
1318
+ * library's own Chromium, which is what an unconfigured build launches. That
1319
+ * is a real limit and it is named in §13i rather than hidden here — the
1320
+ * variables are validated and carried, and the row that resolves an engine to
1321
+ * an installed binary is separable work.
1322
+ */
1323
+ export function executablePathForEngine(engine, supplied) {
1324
+ return supplied?.[engine];
1325
+ }
1326
+ /**
1327
+ * The real driver.
1328
+ *
1329
+ * Holds no state between calls: every fact two callers share lives in the
1330
+ * store, and this object is constructed per process like everything else here.
1331
+ */
1332
+ export class RealBrowserDriver {
1333
+ #options;
1334
+ constructor(options = {}) {
1335
+ this.#options = options;
1336
+ }
1337
+ #executablePath() {
1338
+ // Resolved lazily rather than in the constructor: a process that only
1339
+ // attaches never needs a binary path, and a driver that refused to
1340
+ // construct without one would make an attach-only caller depend on a
1341
+ // browser installation it is not going to use.
1342
+ if (this.#options.executablePath !== undefined) {
1343
+ return this.#options.executablePath;
1344
+ }
1345
+ const engine = this.#options.engine;
1346
+ const resolved = engine === undefined ? undefined : executablePathForEngine(engine, this.#options.enginePaths);
1347
+ return resolved ?? chromium.executablePath();
1348
+ }
1349
+ /**
1350
+ * Attach to a browser that is already running, **having checked the record
1351
+ * first**.
1352
+ *
1353
+ * The record is a claim and not a proof (§1.2c), so both checks are owed
1354
+ * before connecting: the endpoint answers, and the browser identifies itself
1355
+ * as the expected one. **Refuses rather than connecting to something it
1356
+ * cannot identify** — attaching to a stranger is worse than failing to
1357
+ * attach, because it succeeds.
1358
+ */
1359
+ async attach(browser, record) {
1360
+ const expectedUuid = record.browserUuid;
1361
+ if (expectedUuid === undefined) {
1362
+ throw new StartupRefusal('launch.explicit_profile_dir', 'Attaching needs the identifier the browser reported for itself, and this record carries only an address. A record read off disk has not been checked against a live browser, and a port that answers is not the same fact as the browser that recorded it.');
1363
+ }
1364
+ const outcome = await verifyDiscoveryRecord(record, expectedUuid, {
1365
+ fetchImpl: this.#options.fetchImpl,
1366
+ });
1367
+ if (!outcome.ok) {
1368
+ throw new StartupRefusal('launch.explicit_profile_dir', `The browser recorded at ${record.endpoint} was not attached to: ${outcome.detail}`);
1369
+ }
1370
+ return connect({
1371
+ browser,
1372
+ record: outcome.record,
1373
+ ...(this.#options.outputDirectory === undefined
1374
+ ? {}
1375
+ : { outputDirectory: this.#options.outputDirectory }),
1376
+ // The process is not this one's child — the browser was adopted, not
1377
+ // owned — so the identifier comes from the store's record of it rather
1378
+ // than from a handle this process holds.
1379
+ pid: 0,
1380
+ });
1381
+ }
1382
+ /**
1383
+ * Start a browser that is not running, **detached**, and attach to it.
1384
+ *
1385
+ * Success is an endpoint that answers, **asserted positively, never
1386
+ * inferred from the launch not failing** — see `launch.ts` for the measured
1387
+ * silent-collision case that makes the distinction load-bearing.
1388
+ */
1389
+ async coldStart(request) {
1390
+ const outcome = await coldStartDetached({
1391
+ profileDirectory: request.profileDirectory,
1392
+ mode: request.mode,
1393
+ executablePath: this.#executablePath(),
1394
+ }, { ...this.#options.launch, fetchImpl: this.#options.fetchImpl });
1395
+ return connect({
1396
+ browser: request.browser,
1397
+ record: outcome.record,
1398
+ pid: outcome.pid,
1399
+ ...(this.#options.outputDirectory === undefined
1400
+ ? {}
1401
+ : { outputDirectory: this.#options.outputDirectory }),
1402
+ });
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Read a profile's record and say whether a browser is actually running
1407
+ * against it.
1408
+ *
1409
+ * The question the launch race asks before it decides anything (#54), and it
1410
+ * is deliberately **not** a method on the driver: it answers *is one running*,
1411
+ * which is a question about the world, whereas the driver's two members are
1412
+ * acts performed on it.
1413
+ */
1414
+ export async function browserIsRunning(profileDir, options = {}) {
1415
+ const found = readDiscoveryRecord(profileDir);
1416
+ if (found === undefined) {
1417
+ return undefined;
1418
+ }
1419
+ const outcome = await verifyDiscoveryRecord(found.record, found.expectedUuid, {
1420
+ fetchImpl: options.fetchImpl,
1421
+ });
1422
+ // A record that fails either check is stale: the browser is treated as not
1423
+ // running, and whichever caller notices takes the launch race (§1.2c).
1424
+ return outcome.ok ? outcome.record : undefined;
1425
+ }