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