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