browser-broker 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +0 -17
- package/README.md +3 -11
- package/dist/package.json +1 -2
- package/dist/src/adapter/conformance/cases.js +138 -1
- package/dist/src/adapter/conformance/run.js +135 -0
- package/dist/src/browser/fake.js +53 -1
- package/dist/src/browser/real.js +13 -31
- package/dist/src/capture/tiers.js +53 -0
- package/dist/src/cli/commands.js +5 -0
- package/dist/src/cli/reconcile-command.js +68 -17
- package/dist/src/config/environment.js +0 -44
- package/dist/src/service/browser-session.js +81 -8
- package/dist/src/service/comparison.js +23 -5
- package/dist/src/service/operations/pages.js +85 -2
- package/dist/src/tool/tools.js +9 -4
- package/package.json +1 -2
- package/RELEASES.md +0 -255
package/.env.example
CHANGED
|
@@ -155,20 +155,3 @@
|
|
|
155
155
|
# than quietly running the first three. `broker doctor` reports on every
|
|
156
156
|
# browser named in both lists above, so a browser added here gains its own
|
|
157
157
|
# discovery and keeper-tab lines in the health report.
|
|
158
|
-
|
|
159
|
-
# Which engine the signed-in browsers name. Does not decide which binary
|
|
160
|
-
# launches: per-engine executable resolution is not built, so every browser
|
|
161
|
-
# launches the automation library's own Chromium. See RELEASES.md.
|
|
162
|
-
# Options: chrome, brave, msedge — all Chromium over the same remote-debugging
|
|
163
|
-
# protocol, which is why the choice is a path rather than a driver.
|
|
164
|
-
# One engine per kind, never per browser: an engine per entry would put an
|
|
165
|
-
# attribute back on the entry, which is what the two lists exist to avoid.
|
|
166
|
-
# Default: msedge
|
|
167
|
-
# BROKER_REGULAR_BROWSER_ENGINE=msedge
|
|
168
|
-
|
|
169
|
-
# Which engine the clean-room browsers name. Accepted and validated, and read
|
|
170
|
-
# by nothing: one driver serves every browser in the process and only the
|
|
171
|
-
# regular engine is handed to it, so a differing value reaches no launch.
|
|
172
|
-
# Options: chrome, brave, msedge
|
|
173
|
-
# Default: msedge
|
|
174
|
-
# BROKER_PRIVATE_BROWSER_ENGINE=msedge
|
package/README.md
CHANGED
|
@@ -170,16 +170,10 @@ called, and a caller that names no browser gets the first signed-in one:
|
|
|
170
170
|
```bash
|
|
171
171
|
BROKER_REGULAR_BROWSERS=regular,checkout # persistent, signed in, at most 3
|
|
172
172
|
BROKER_PRIVATE_BROWSERS=private # ephemeral, at most 3
|
|
173
|
-
BROKER_REGULAR_BROWSER_ENGINE=msedge # chrome | brave | msedge
|
|
174
|
-
BROKER_PRIVATE_BROWSER_ENGINE=msedge # accepted and validated, and read by nothing
|
|
175
173
|
```
|
|
176
174
|
|
|
177
|
-
**
|
|
178
|
-
|
|
179
|
-
engine is named, and a machine still has to fetch that Chromium once. The private variable goes one
|
|
180
|
-
step less far than the regular one: a single driver serves every browser in the process and only the
|
|
181
|
-
regular engine is handed to it, so the private value is validated at startup and then read by nothing
|
|
182
|
-
at all. `RELEASES.md` has the fuller account.
|
|
175
|
+
**Every browser launches the automation library's own Chromium**, which a machine fetches once. The
|
|
176
|
+
browser a caller names selects an identity and its profile directory, not a different binary.
|
|
183
177
|
|
|
184
178
|
Two signed-in browsers is how two identities are exercised at once: tabs within one browser share
|
|
185
179
|
its cookie jar, so they are isolated from other browsers and not from each other. **Note that each
|
|
@@ -381,9 +375,7 @@ surface is being built, and the one manual step is wired: `broker login` hands a
|
|
|
381
375
|
browser and `broker doctor` reports whether the sign-in took. Read [`docs/ROLLOUT.md`](docs/ROLLOUT.md) for taking it from installed to sole route in an order that
|
|
382
376
|
never leaves traffic unarbitrated, [`docs/plans/PLAN.md`](docs/plans/PLAN.md) for how it works,
|
|
383
377
|
[`docs/plans/DECISIONS.md`](docs/plans/DECISIONS.md) for why it is shaped this way, and
|
|
384
|
-
[`docs/plans/MILESTONES.md`](docs/plans/MILESTONES.md) for the work queue
|
|
385
|
-
[`RELEASES.md`](RELEASES.md) for what changes between versions — in particular for defaults that
|
|
386
|
-
move, which change an installation that has taken no action.
|
|
378
|
+
[`docs/plans/MILESTONES.md`](docs/plans/MILESTONES.md) for the work queue.
|
|
387
379
|
|
|
388
380
|
## Releasing
|
|
389
381
|
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-broker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Leases over tabs in a fixed set of browsers: bounded capacity, a queue, reclamation, and an enforced capture policy.",
|
|
6
6
|
"type": "module",
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"dist/",
|
|
54
54
|
".env.example",
|
|
55
55
|
"README.md",
|
|
56
|
-
"RELEASES.md",
|
|
57
56
|
"LICENSE"
|
|
58
57
|
]
|
|
59
58
|
}
|
|
@@ -320,7 +320,144 @@ export const CONFORMANCE_CASES = [
|
|
|
320
320
|
operation: 'capture',
|
|
321
321
|
seed: withALiveLease,
|
|
322
322
|
input: {},
|
|
323
|
-
|
|
323
|
+
// §3.11's promised response, asserted as a response rather than as a row
|
|
324
|
+
// in a database. All three of `sourceWidth`, `sourceHeight` and `tier`
|
|
325
|
+
// were promised by §3.11 — *"the dimensions written, the dimensions
|
|
326
|
+
// before shrinking, the file size, the tier"* — computed by the pipeline,
|
|
327
|
+
// written to the `captures` table, and then **dropped by the service layer
|
|
328
|
+
// that builds the caller's reply**, for the whole life of the feature.
|
|
329
|
+
//
|
|
330
|
+
// Nothing caught it because nothing anywhere compared a response against
|
|
331
|
+
// the specification that promised it: `check:operations` proves the
|
|
332
|
+
// binaries reach the service, `check:argument-refusals` polices refusals,
|
|
333
|
+
// and neither walks from a documented response field to the code meant to
|
|
334
|
+
// populate it. Naming the fields here is the narrow, non-brittle version
|
|
335
|
+
// of that check — it binds the spec to the code at the one point both
|
|
336
|
+
// agree on, without a parser trying to read §3.x's English.
|
|
337
|
+
expect: {
|
|
338
|
+
outcome: 'accepted',
|
|
339
|
+
//
|
|
340
|
+
// ── Why these ten are not the list, and what bounds it ──────────────
|
|
341
|
+
//
|
|
342
|
+
// §3.11 promises more than this case names: an estimated token cost and
|
|
343
|
+
// how many captures this lease has taken are both in its sentence. They
|
|
344
|
+
// are deliberately absent here because they are **not in the reply the
|
|
345
|
+
// caller receives**: `estimatedTokens`, `capturesThisLease` and
|
|
346
|
+
// `escalation` are fields of the *pipeline's* `CaptureResult`, and
|
|
347
|
+
// `pages.ts` reshapes that into the `written` object without them. An
|
|
348
|
+
// assertion naming them would fail against correct code, which is the
|
|
349
|
+
// one failure a conformance case must never manufacture.
|
|
350
|
+
//
|
|
351
|
+
// That gap is real and it is item 557fdcd6's remaining half — a
|
|
352
|
+
// response still owes §3.11 two fields it does not carry. It is not
|
|
353
|
+
// closed here, because closing it means changing the response rather
|
|
354
|
+
// than the test, and this row is about the checks. Named so the next
|
|
355
|
+
// reader finds a known gap rather than an oversight.
|
|
356
|
+
//
|
|
357
|
+
// Spelled `capture.*` because the reply is an envelope: `capture` renews
|
|
358
|
+
// the lease it was called on, so the value carries `claimId`, `tabId`,
|
|
359
|
+
// `expiresAt` and `pageDriven` with the picture nested under `capture`.
|
|
360
|
+
// Naming the path asserts that nesting too.
|
|
361
|
+
valueFields: [
|
|
362
|
+
'capture.captureId',
|
|
363
|
+
'capture.path',
|
|
364
|
+
'capture.width',
|
|
365
|
+
'capture.height',
|
|
366
|
+
'capture.bytes',
|
|
367
|
+
// The three §3.11 promised and the shipped response omitted for the
|
|
368
|
+
// whole life of the feature. They are the reason this list exists.
|
|
369
|
+
'capture.sourceWidth',
|
|
370
|
+
'capture.sourceHeight',
|
|
371
|
+
'capture.tier',
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'capture: an escalated tier changes the picture that comes back',
|
|
377
|
+
operation: 'capture',
|
|
378
|
+
// ── The assertion `check:argument-reachability` cannot make ───────────
|
|
379
|
+
//
|
|
380
|
+
// `tier` was read at the bridge, validated, packed into a request object,
|
|
381
|
+
// and then dropped at the single `takeCapture` call site, which spread
|
|
382
|
+
// only `fullPage` and `selector`. Every capture was taken at the default
|
|
383
|
+
// rung whatever was asked for, and `tier: "max"` charged the caller a
|
|
384
|
+
// written 8-200 character justification for it. The static check passed
|
|
385
|
+
// throughout, exactly as its own table says it must: *"a branch that reads
|
|
386
|
+
// an argument and drops it on the floor passes this."*
|
|
387
|
+
//
|
|
388
|
+
// So this case asserts the thing that actually failed: that passing the
|
|
389
|
+
// argument **changes what comes back**. The runner drives the operation a
|
|
390
|
+
// second time with `tier` and `reason` removed and requires the two
|
|
391
|
+
// readings to differ, so a `tier` hard-wired to `"max"` fails here rather
|
|
392
|
+
// than passing as a constant.
|
|
393
|
+
//
|
|
394
|
+
// `max` rather than `detail` because it is the rung that costs a reason,
|
|
395
|
+
// which puts both arguments on the same case: `reason` is recorded only on
|
|
396
|
+
// this tier (`pipeline.ts` — *"a reason attached to a capture nobody had to
|
|
397
|
+
// justify would put noise into the one column the resolution study
|
|
398
|
+
// reads"*), so the two travel together or not at all.
|
|
399
|
+
//
|
|
400
|
+
// ── What this case asserts about `reason`, stated honestly ───────────
|
|
401
|
+
//
|
|
402
|
+
// **It asserts that passing `reason` does not prevent the escalation, and
|
|
403
|
+
// nothing more.** `reason` is genuinely **not observable** from any route:
|
|
404
|
+
// it is written to the `captures` row and read back by no operation the
|
|
405
|
+
// conformance suite can reach (`capture-store.ts` exposes `recordCapture`
|
|
406
|
+
// and `capturesTakenBy`, and neither returns it). So there is no response
|
|
407
|
+
// field and no driver call in which a dropped `reason` would show.
|
|
408
|
+
//
|
|
409
|
+
// It is named in `arguments` regardless, because the baseline must remove
|
|
410
|
+
// it: `max` without a reason is **refused**, so a baseline that dropped
|
|
411
|
+
// only `tier` would be measuring a refusal rather than the default rung.
|
|
412
|
+
// Removing both is what makes the comparison a comparison.
|
|
413
|
+
//
|
|
414
|
+
// The one assertion that would close `reason` is a read path to the
|
|
415
|
+
// capture's own record, which does not exist and is not invented here.
|
|
416
|
+
seed: withALiveLease,
|
|
417
|
+
input: {
|
|
418
|
+
tier: 'max',
|
|
419
|
+
reason: 'conformance: proving an escalated tier reaches the pipeline that acts on it',
|
|
420
|
+
},
|
|
421
|
+
expect: {
|
|
422
|
+
outcome: 'accepted',
|
|
423
|
+
effects: [
|
|
424
|
+
{
|
|
425
|
+
arguments: ['tier', 'reason'],
|
|
426
|
+
expect: [
|
|
427
|
+
{
|
|
428
|
+
field: 'capture.tier',
|
|
429
|
+
value: 'max',
|
|
430
|
+
// The rung a caller lands on with no tier (`DEFAULT_TIER`).
|
|
431
|
+
withoutArgument: 'default',
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
// The **physical** consequence, not merely the label. `tier`
|
|
435
|
+
// could be echoed back by a service that did nothing with it;
|
|
436
|
+
// the width cannot. The fake produces 1280x720, so the default
|
|
437
|
+
// rung's 1024 long edge shrinks it to 1024x576 while `max`'s
|
|
438
|
+
// 2576 leaves it alone — never upscaled (`image.ts`: *"a picture
|
|
439
|
+
// smaller than the rung is written as it is"*).
|
|
440
|
+
//
|
|
441
|
+
// This is the field that would have failed on the shipped
|
|
442
|
+
// defect: every capture came back 1024 wide however it was
|
|
443
|
+
// asked for.
|
|
444
|
+
field: 'capture.width',
|
|
445
|
+
value: 1280,
|
|
446
|
+
withoutArgument: 1024,
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
// The height, for the same reason as the width and as a
|
|
450
|
+
// separate reading: the shrink is taken on the **long edge**,
|
|
451
|
+
// so a change to `TIER_LONGEST_EDGE` that moved only one
|
|
452
|
+
// dimension would leave the other's assertion standing.
|
|
453
|
+
field: 'capture.height',
|
|
454
|
+
value: 720,
|
|
455
|
+
withoutArgument: 576,
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
},
|
|
324
461
|
},
|
|
325
462
|
{
|
|
326
463
|
name: 'capture: a selector and a full page together are refused',
|
|
@@ -1,5 +1,47 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
1
2
|
import { ADAPTER_IDS } from "../contract.js";
|
|
2
3
|
import { isWriteOperation, OPERATION_NAMES } from "../operations.js";
|
|
4
|
+
/**
|
|
5
|
+
* Structural comparison for effect assertions.
|
|
6
|
+
*
|
|
7
|
+
* `isDeepStrictEqual` rather than `===`, because an effect may name a
|
|
8
|
+
* structured field, and rather than `assert.deepStrictEqual`, because this
|
|
9
|
+
* runner reports findings instead of throwing (see the header).
|
|
10
|
+
*/
|
|
11
|
+
const deepEqual = (a, b) => isDeepStrictEqual(a, b);
|
|
12
|
+
/**
|
|
13
|
+
* Read a field out of an accepted value, following a dotted path.
|
|
14
|
+
*
|
|
15
|
+
* ── Why a path and not a plain key ──────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* An accepted value is not flat. `capture` returns the lease's own fields —
|
|
18
|
+
* `claimId`, `tabId`, `expiresAt`, `pageDriven` — with **the capture itself
|
|
19
|
+
* nested under `capture`**, because the operation renews the lease it was
|
|
20
|
+
* called on and the reply says both things. `SCHEMA.md` §3.11's promises are
|
|
21
|
+
* about the capture object, so a case naming `width` flatly would read
|
|
22
|
+
* `undefined` from the envelope and report a missing field on a response that
|
|
23
|
+
* carries it — a finding that is wrong in the most expensive direction, since
|
|
24
|
+
* it accuses correct code.
|
|
25
|
+
*
|
|
26
|
+
* The path is resolved rather than the case being handed the sub-object,
|
|
27
|
+
* because the envelope is part of what the route returns: a case naming
|
|
28
|
+
* `capture.tier` asserts the nesting too, and an adapter that quietly
|
|
29
|
+
* flattened or re-keyed the reply would fail here rather than passing.
|
|
30
|
+
*
|
|
31
|
+
* Returns `undefined` for any path that does not resolve, which is the same
|
|
32
|
+
* answer as a field explicitly set to `undefined` — deliberately, because
|
|
33
|
+
* both mean "the caller cannot read this" and the assertions are about what
|
|
34
|
+
* a caller can read.
|
|
35
|
+
*/
|
|
36
|
+
function fieldAt(value, path) {
|
|
37
|
+
let current = value;
|
|
38
|
+
for (const step of path.split('.')) {
|
|
39
|
+
if (typeof current !== 'object' || current === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
current = current[step];
|
|
42
|
+
}
|
|
43
|
+
return current;
|
|
44
|
+
}
|
|
3
45
|
/** Roughly four words, the same bar the hygiene gate holds a waiver to. */
|
|
4
46
|
const WAIVER_MINIMUM_WORDS = 4;
|
|
5
47
|
function wordCount(text) {
|
|
@@ -151,6 +193,99 @@ export async function runConformance(options) {
|
|
|
151
193
|
});
|
|
152
194
|
}
|
|
153
195
|
}
|
|
196
|
+
// ── Response conformance and argument effect ──────────────────────
|
|
197
|
+
//
|
|
198
|
+
// Both are assertions about the **accepted value**, which is why they
|
|
199
|
+
// sit together and why they are checked here rather than in a unit
|
|
200
|
+
// test: the value has crossed the route boundary by this point, so a
|
|
201
|
+
// field the service produced and the adapter dropped fails here and
|
|
202
|
+
// passes everywhere else.
|
|
203
|
+
if (outcome.outcome === 'accepted' && testCase.expect.outcome === 'accepted') {
|
|
204
|
+
for (const field of testCase.expect.valueFields ?? []) {
|
|
205
|
+
if (fieldAt(outcome.value, field) === undefined) {
|
|
206
|
+
findings.push({
|
|
207
|
+
kind: 'accepted-value-missing-a-field',
|
|
208
|
+
adapter: adapterId,
|
|
209
|
+
operation: testCase.operation,
|
|
210
|
+
caseName: testCase.name,
|
|
211
|
+
detail: `the accepted value has no "${field}" (it carries ${Object.keys(outcome.value).join(', ') || 'nothing'})`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const effect of testCase.expect.effects ?? []) {
|
|
216
|
+
// The same operation, driven again with the effect's arguments
|
|
217
|
+
// removed. A fresh subject, because the first run consumed a lease
|
|
218
|
+
// and moved the capture count — reusing it would measure the
|
|
219
|
+
// second call's accounting rather than the argument.
|
|
220
|
+
const baselineInput = Object.fromEntries(Object.entries(seeded.input).filter(([key]) => !effect.arguments.includes(key)));
|
|
221
|
+
const baselineSubject = await options.makeService();
|
|
222
|
+
let baseline;
|
|
223
|
+
try {
|
|
224
|
+
const baselineSubstitutions = (await testCase.seed?.apply(baselineSubject.service)) ?? {};
|
|
225
|
+
const baselineCase = {
|
|
226
|
+
...testCase,
|
|
227
|
+
name: `${testCase.name} (without ${effect.arguments.join(', ')})`,
|
|
228
|
+
input: { ...baselineInput, ...baselineSubstitutions },
|
|
229
|
+
};
|
|
230
|
+
baseline = (await driver.run(baselineSubject.service, baselineCase, {
|
|
231
|
+
driverCalls: baselineSubject.driverCalls,
|
|
232
|
+
liveClaimCount: baselineSubject.liveClaimCount,
|
|
233
|
+
})).outcome;
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await baselineSubject.dispose?.();
|
|
237
|
+
}
|
|
238
|
+
if (baseline.outcome !== 'accepted') {
|
|
239
|
+
// Never silently skipped. An effect whose baseline refused would
|
|
240
|
+
// otherwise report the same green as one that held.
|
|
241
|
+
findings.push({
|
|
242
|
+
kind: 'argument-effect-baseline-failed',
|
|
243
|
+
adapter: adapterId,
|
|
244
|
+
operation: testCase.operation,
|
|
245
|
+
caseName: testCase.name,
|
|
246
|
+
detail: `dropping ${effect.arguments.join(', ')} was refused (${baseline.code} / ${baseline.rule}), so the effect of those arguments could not be measured`,
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
for (const expectation of effect.expect) {
|
|
251
|
+
const actual = fieldAt(outcome.value, expectation.field);
|
|
252
|
+
const without = fieldAt(baseline.value, expectation.field);
|
|
253
|
+
if (!deepEqual(actual, expectation.value)) {
|
|
254
|
+
findings.push({
|
|
255
|
+
kind: 'argument-had-no-effect',
|
|
256
|
+
adapter: adapterId,
|
|
257
|
+
operation: testCase.operation,
|
|
258
|
+
caseName: testCase.name,
|
|
259
|
+
detail: `with ${effect.arguments.join(', ')}, "${expectation.field}" should be ${JSON.stringify(expectation.value)} but was ${JSON.stringify(actual)}`,
|
|
260
|
+
});
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!deepEqual(without, expectation.withoutArgument)) {
|
|
264
|
+
findings.push({
|
|
265
|
+
kind: 'argument-had-no-effect',
|
|
266
|
+
adapter: adapterId,
|
|
267
|
+
operation: testCase.operation,
|
|
268
|
+
caseName: testCase.name,
|
|
269
|
+
detail: `without ${effect.arguments.join(', ')}, "${expectation.field}" should be ${JSON.stringify(expectation.withoutArgument)} but was ${JSON.stringify(without)}`,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// The guard against a constant. Both readings can be individually
|
|
274
|
+
// right and the argument still inert — if the two agree, passing
|
|
275
|
+
// the argument changed nothing, whatever the field happens to
|
|
276
|
+
// hold. This is the assertion the whole shape exists for.
|
|
277
|
+
if (deepEqual(actual, without)) {
|
|
278
|
+
findings.push({
|
|
279
|
+
kind: 'argument-had-no-effect',
|
|
280
|
+
adapter: adapterId,
|
|
281
|
+
operation: testCase.operation,
|
|
282
|
+
caseName: testCase.name,
|
|
283
|
+
detail: `"${expectation.field}" is ${JSON.stringify(actual)} whether or not ${effect.arguments.join(', ')} is passed, so the argument is inert`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
154
289
|
if (outcome.outcome === 'refused') {
|
|
155
290
|
// Both readings, because they catch different bugs (§8.2): a guard
|
|
156
291
|
// that opens a tab and closes it on the way to refusing leaves the
|
package/dist/src/browser/fake.js
CHANGED
|
@@ -92,6 +92,35 @@ const DEFAULT_COOKIES = [
|
|
|
92
92
|
* members and there is nothing here that invents a third.
|
|
93
93
|
*/
|
|
94
94
|
export class FakeBrowserDriver {
|
|
95
|
+
/**
|
|
96
|
+
* A way to end each handed-out session's connection, without ending the
|
|
97
|
+
* browser.
|
|
98
|
+
*
|
|
99
|
+
* ── Why the fake needs to be able to do this at all ─────────────────────
|
|
100
|
+
*
|
|
101
|
+
* A connection and the browser it points at have **independent lifetimes**,
|
|
102
|
+
* and the failure this models is the asymmetry: the browser is running and
|
|
103
|
+
* answers every liveness question truthfully, while the connection one
|
|
104
|
+
* process holds has ended. Nothing else in this fake can produce that state
|
|
105
|
+
* — seeding a failure makes an operation reject, which is a different thing
|
|
106
|
+
* and one the existing eviction path already handles.
|
|
107
|
+
*
|
|
108
|
+
* A test drives it with {@link FakeBrowserDriver.disconnect}; the fake's
|
|
109
|
+
* own tabs and recorded calls are deliberately left untouched, because the
|
|
110
|
+
* browser is unaffected.
|
|
111
|
+
*/
|
|
112
|
+
#disconnectors = [];
|
|
113
|
+
/**
|
|
114
|
+
* End the connection on every session handed out for one browser, leaving
|
|
115
|
+
* the browser itself — its tabs, its call log — exactly as it was.
|
|
116
|
+
*/
|
|
117
|
+
disconnect(browser) {
|
|
118
|
+
for (const entry of this.#disconnectors) {
|
|
119
|
+
if (entry.browser === browser) {
|
|
120
|
+
entry.end();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
95
124
|
#calls = [];
|
|
96
125
|
#options;
|
|
97
126
|
#openTabs = new Map();
|
|
@@ -301,6 +330,14 @@ export class FakeBrowserDriver {
|
|
|
301
330
|
}
|
|
302
331
|
#session(browser, mode) {
|
|
303
332
|
const description = this.#describe(browser, mode);
|
|
333
|
+
// Every session this fake hands out starts connected, and only
|
|
334
|
+
// {@link FakeBrowserDriver.disconnect} changes that. It is per-session
|
|
335
|
+
// rather than per-browser deliberately: the state under test is *this
|
|
336
|
+
// connection ended while the browser carried on*, so a later acquisition
|
|
337
|
+
// for the same browser must come back connected, exactly as a real
|
|
338
|
+
// re-attach would.
|
|
339
|
+
let connected = true;
|
|
340
|
+
this.#disconnectors.push({ browser, end: () => (connected = false) });
|
|
304
341
|
const openTab = (name) => {
|
|
305
342
|
const driverTabId = `fake-tab-${String(this.#nextTabNumber++)}`;
|
|
306
343
|
const failure = this.#enter({ name, browser, tab: { browser, driverTabId } });
|
|
@@ -314,6 +351,10 @@ export class FakeBrowserDriver {
|
|
|
314
351
|
};
|
|
315
352
|
return {
|
|
316
353
|
describe: () => description,
|
|
354
|
+
// Reads the flag above and asks nothing, which is the contract
|
|
355
|
+
// {@link BrowserSession.isConnected} states: it is consulted before
|
|
356
|
+
// every page verb, so it must not perform input/output or throw.
|
|
357
|
+
isConnected: () => connected,
|
|
317
358
|
openTab: () => openTab('openTab'),
|
|
318
359
|
/**
|
|
319
360
|
* Every page open in this browser **except the keeper tab**.
|
|
@@ -420,7 +461,18 @@ export class FakeBrowserDriver {
|
|
|
420
461
|
});
|
|
421
462
|
if (failure)
|
|
422
463
|
return Promise.reject(failure);
|
|
423
|
-
|
|
464
|
+
// **Where the page ARRIVES, which is not always where it was sent.**
|
|
465
|
+
// Defaults to the requested address, so every existing caller sees
|
|
466
|
+
// what it always saw; a test that configures `arriveAt` gets a fake
|
|
467
|
+
// that redirects, which is the only way a fake can exercise the
|
|
468
|
+
// difference between reporting the request and reporting the result.
|
|
469
|
+
const options = this.#options.navigate;
|
|
470
|
+
const arrived = options?.arriveAt?.(url) ?? url;
|
|
471
|
+
return Promise.resolve({
|
|
472
|
+
url: arrived,
|
|
473
|
+
title: options?.title?.(arrived) ?? `fake page at ${arrived}`,
|
|
474
|
+
status: options?.status === undefined ? 200 : options.status,
|
|
475
|
+
});
|
|
424
476
|
},
|
|
425
477
|
seedStorage: (tab, entries) => {
|
|
426
478
|
// **The whole entries list, values included**, and that is deliberate
|
package/dist/src/browser/real.js
CHANGED
|
@@ -1254,6 +1254,18 @@ class RealBrowserSession {
|
|
|
1254
1254
|
}
|
|
1255
1255
|
}
|
|
1256
1256
|
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Whether this process's connection is still usable.
|
|
1259
|
+
*
|
|
1260
|
+
* Delegates to the flag the driving package already maintains — its
|
|
1261
|
+
* `isConnected()` is declared `(): boolean` and its implementation is a
|
|
1262
|
+
* bare field read (`return this._isConnected`), so this performs no
|
|
1263
|
+
* input/output and cannot throw. That is what {@link BrowserSession.isConnected}
|
|
1264
|
+
* requires of it, because it is consulted before every page verb.
|
|
1265
|
+
*/
|
|
1266
|
+
isConnected() {
|
|
1267
|
+
return this.#connection.isConnected();
|
|
1268
|
+
}
|
|
1257
1269
|
/**
|
|
1258
1270
|
* End this process's connection. **The browser is unaffected.**
|
|
1259
1271
|
*
|
|
@@ -1303,34 +1315,6 @@ async function connect(options) {
|
|
|
1303
1315
|
await session.ensureKeeperTab();
|
|
1304
1316
|
return session;
|
|
1305
1317
|
}
|
|
1306
|
-
/**
|
|
1307
|
-
* Where a configured engine's binary lives, if this process was told.
|
|
1308
|
-
*
|
|
1309
|
-
* ── What is built here, and what is deliberately not ────────────────
|
|
1310
|
-
*
|
|
1311
|
-
* All three engines are Chromium over the same remote-debugging protocol, so
|
|
1312
|
-
* choosing between them is choosing a binary — which is what makes the hook
|
|
1313
|
-
* cheap, and it is the hook `DECISIONS.md` §13i asks for. **The expensive
|
|
1314
|
-
* half is explicitly out of scope there**: per-engine executable discovery,
|
|
1315
|
-
* per-engine discovery-record locations, per-engine health checks.
|
|
1316
|
-
*
|
|
1317
|
-
* **So resolution is a lookup of what a caller supplied, never a search.**
|
|
1318
|
-
* The engine selects among paths this process was given; it does not go
|
|
1319
|
-
* looking for an installation, and it never carries a path of its own.
|
|
1320
|
-
* Writing a per-engine install location into this file would name one machine
|
|
1321
|
-
* — §1.0's rule forbids that outright, and `check-external-refs` fails on the
|
|
1322
|
-
* shape.
|
|
1323
|
-
*
|
|
1324
|
-
* **What happens when nothing supplied a path for the configured engine** is
|
|
1325
|
-
* the case worth being exact about: the launch falls back to the automation
|
|
1326
|
-
* library's own Chromium, which is what an unconfigured build launches. That
|
|
1327
|
-
* is a real limit and it is named in §13i rather than hidden here — the
|
|
1328
|
-
* variables are validated and carried, and the row that resolves an engine to
|
|
1329
|
-
* an installed binary is separable work.
|
|
1330
|
-
*/
|
|
1331
|
-
export function executablePathForEngine(engine, supplied) {
|
|
1332
|
-
return supplied?.[engine];
|
|
1333
|
-
}
|
|
1334
1318
|
/**
|
|
1335
1319
|
* The real driver.
|
|
1336
1320
|
*
|
|
@@ -1350,9 +1334,7 @@ export class RealBrowserDriver {
|
|
|
1350
1334
|
if (this.#options.executablePath !== undefined) {
|
|
1351
1335
|
return this.#options.executablePath;
|
|
1352
1336
|
}
|
|
1353
|
-
|
|
1354
|
-
const resolved = engine === undefined ? undefined : executablePathForEngine(engine, this.#options.enginePaths);
|
|
1355
|
-
return resolved ?? chromium.executablePath();
|
|
1337
|
+
return chromium.executablePath();
|
|
1356
1338
|
}
|
|
1357
1339
|
/**
|
|
1358
1340
|
* Attach to a browser that is already running, **having checked the record
|
|
@@ -164,3 +164,56 @@ const TOKENS_PER_PIXEL_DIVISOR = 750;
|
|
|
164
164
|
export function estimateTokens(width, height) {
|
|
165
165
|
return Math.ceil((width * height) / TOKENS_PER_PIXEL_DIVISOR);
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Describe the shrink, or return nothing when there was not one.
|
|
169
|
+
*
|
|
170
|
+
* **Absent rather than `scale: 1` when nothing was reduced**, and the
|
|
171
|
+
* difference is the one this whole helper is for: a field that is always
|
|
172
|
+
* present is a field a caller stops reading. Its presence is the signal.
|
|
173
|
+
*/
|
|
174
|
+
export function describeReduction(source, written, tier) {
|
|
175
|
+
if (written.width >= source.width && written.height >= source.height) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
// Taken on the long edge, which is the edge the cap is applied to — so this
|
|
179
|
+
// is the factor that was actually used rather than one recovered from
|
|
180
|
+
// whichever dimension happens to round more kindly.
|
|
181
|
+
const scale = Math.max(source.width, source.height) === 0
|
|
182
|
+
? 1
|
|
183
|
+
: Math.max(written.width, written.height) / Math.max(source.width, source.height);
|
|
184
|
+
const percent = Math.round(scale * 100);
|
|
185
|
+
// Named only when there is one, so the sentence never tells a caller already
|
|
186
|
+
// on the top rung to escalate to it.
|
|
187
|
+
const higher = HIGHER_TIERS[tier];
|
|
188
|
+
const remedy = higher === undefined
|
|
189
|
+
? `This is the highest rung, so a larger image of the whole page is not available; capture a selector, or read the page as text instead.`
|
|
190
|
+
: `For more detail pass tier="${higher}"${higher === TIER_REQUIRING_REASON ? ' together with reason' : ''}.`;
|
|
191
|
+
// The width is called out separately because it is the number that decides
|
|
192
|
+
// legibility on a tall page, and it is the one a caller reading "scale" on
|
|
193
|
+
// its own would not think to compare against the viewport.
|
|
194
|
+
return {
|
|
195
|
+
sourceWidth: source.width,
|
|
196
|
+
sourceHeight: source.height,
|
|
197
|
+
scale: Math.round(scale * 1000) / 1000,
|
|
198
|
+
note: `This image was REDUCED to about ${String(percent)}% of the page: ` +
|
|
199
|
+
`${String(source.width)}x${String(source.height)} was written as ` +
|
|
200
|
+
`${String(written.width)}x${String(written.height)}. ` +
|
|
201
|
+
`A capture is shrunk so its LONGEST edge fits ${String(TIER_LONGEST_EDGE[tier])}px, so on a page ` +
|
|
202
|
+
`taller than it is wide the height sets the factor and the width shrinks with it — ` +
|
|
203
|
+
`${String(written.width)}px of width here. Text may not be legible. ` +
|
|
204
|
+
remedy,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The next rung up from each, and `undefined` at the top.
|
|
209
|
+
*
|
|
210
|
+
* A table rather than an ordering computed from {@link TIER_LONGEST_EDGE},
|
|
211
|
+
* because "which rung does a caller ask for next" is a fact about the
|
|
212
|
+
* surface's vocabulary — `default` is not requestable by name — and not about
|
|
213
|
+
* which number is larger.
|
|
214
|
+
*/
|
|
215
|
+
const HIGHER_TIERS = {
|
|
216
|
+
default: 'detail',
|
|
217
|
+
detail: 'max',
|
|
218
|
+
max: undefined,
|
|
219
|
+
};
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -172,6 +172,11 @@ export const STANDALONE_COMMANDS = [
|
|
|
172
172
|
words: ['snapshot'],
|
|
173
173
|
summary: 'Write the operations document to a path and exit.',
|
|
174
174
|
owedBy: 'the row that builds the operations document',
|
|
175
|
+
options: [
|
|
176
|
+
{ flag: '--out <path>', summary: 'The file to write the document to. Required.' },
|
|
177
|
+
{ flag: '--events <n>', summary: 'How many ledger entries to include.' },
|
|
178
|
+
{ flag: '--feedback <n>', summary: 'How many feedback rows to include.' },
|
|
179
|
+
],
|
|
175
180
|
},
|
|
176
181
|
{
|
|
177
182
|
words: ['doctor'],
|