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
|
@@ -121,16 +121,55 @@ export async function runReconcileCommand(rest, options) {
|
|
|
121
121
|
}
|
|
122
122
|
return COMMAND_EXIT.accepted;
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Did this run decline to do the thing it was called to do?
|
|
126
|
+
*
|
|
127
|
+
* **Two facts together, and neither alone is enough.** Tabs still being
|
|
128
|
+
* opened block the sweep (`decideReconciliation` leaves them alone, because
|
|
129
|
+
* closing a page a mid-open lease is about to be handed would be worse than
|
|
130
|
+
* declining) — but a run that closed pages *and* skipped one did work, and
|
|
131
|
+
* calling that "nothing was closed" would be false. A run that closed
|
|
132
|
+
* nothing because there was nothing to close is not declining either; it is
|
|
133
|
+
* simply a clean run, and telling that caller to try again would send it
|
|
134
|
+
* back for an answer it already has.
|
|
135
|
+
*
|
|
136
|
+
* So the conclusion is drawn only where both hold: something was in the way,
|
|
137
|
+
* and nothing was swept past it.
|
|
138
|
+
*/
|
|
139
|
+
function nothingClosedPendingRetry(report) {
|
|
140
|
+
return report.skippedOpening > 0 && report.closed === 0;
|
|
141
|
+
}
|
|
124
142
|
/**
|
|
125
143
|
* The report a person reads.
|
|
126
144
|
*
|
|
127
145
|
* **Every line is a count or an opaque identifier**, which is §1.4's rule
|
|
128
146
|
* made true by there being nothing else available to print: the report type
|
|
129
147
|
* carries no driver name, so this function could not print one if it tried.
|
|
148
|
+
*
|
|
149
|
+
* ── Why the outcome is the first line and not the last ──────────────────
|
|
150
|
+
*
|
|
151
|
+
* A caller reads the first line and acts on it. When this run declined —
|
|
152
|
+
* {@link nothingClosedPendingRetry} — the sentence that predicts that
|
|
153
|
+
* caller's next failure is the one that has to arrive first, because a
|
|
154
|
+
* headline of `reconciled: <browser>` above four counters reads as
|
|
155
|
+
* completion, and a reader who takes it at face value stops there and runs
|
|
156
|
+
* straight back into the state they invoked this to clear. The counters are
|
|
157
|
+
* still printed, unchanged and in the same order; what moves is the
|
|
158
|
+
* conclusion, which stops being something the reader has to derive from the
|
|
159
|
+
* bottom of a list.
|
|
160
|
+
*
|
|
161
|
+
* **The headline stops claiming completion on such a run** for the same
|
|
162
|
+
* reason. `reconciled:` is a claim about what happened, and on a run that
|
|
163
|
+
* closed nothing and needs invoking again it is not a true one — this is the
|
|
164
|
+
* defect class this repository keeps finding in itself, a call that succeeds
|
|
165
|
+
* while delivering less than it announced. The word is kept for the runs
|
|
166
|
+
* that earned it.
|
|
130
167
|
*/
|
|
131
168
|
export function formatReconciliation(browser, report) {
|
|
169
|
+
const declined = nothingClosedPendingRetry(report);
|
|
132
170
|
const lines = [
|
|
133
|
-
`reconciled: ${browser}`,
|
|
171
|
+
declined ? `did not reconcile: ${browser}` : `reconciled: ${browser}`,
|
|
172
|
+
...(declined ? [conclusionLine(report)] : []),
|
|
134
173
|
`pages open, not counting the keeper: ${String(report.pagesSeen)}`,
|
|
135
174
|
`pages closed because no live lease owned them: ${String(report.closed)}`,
|
|
136
175
|
`leases ended because their page was gone: ${String(report.settled.length)}`,
|
|
@@ -148,22 +187,34 @@ export function formatReconciliation(browser, report) {
|
|
|
148
187
|
// reader knows what it costs — memory, and not budget.
|
|
149
188
|
lines.push(`${String(report.closeFailures)} page(s) would not close. That is a leaked page and not a leaked lease: the budget is unaffected, and \`broker doctor\` reports them.`);
|
|
150
189
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// say so.
|
|
162
|
-
const owned = report.skippedOpeningOwnedByCaller;
|
|
163
|
-
lines.push(`${String(report.skippedOpening)} tab(s) are still being opened, so nothing was closed on this run — a page seen now may belong to one of them.` +
|
|
164
|
-
(owned > 0
|
|
165
|
-
? ` ${String(owned)} of them ${owned === 1 ? 'belongs' : 'belong'} to your own lease — release ${owned === 1 ? 'it' : 'them'}, or run this from another session.`
|
|
166
|
-
: ' Run again once they have settled.'));
|
|
190
|
+
// Said on the run it happened on, because the alternative is a person
|
|
191
|
+
// reading "0 closed" and concluding there was nothing to close.
|
|
192
|
+
//
|
|
193
|
+
// **Printed here only when it was not already printed at the top.** The
|
|
194
|
+
// conclusion belongs above the counters on a run that declined, and below
|
|
195
|
+
// them on a run that closed pages anyway — where it is a caveat on real
|
|
196
|
+
// work rather than the outcome. Either way it is written once, by one
|
|
197
|
+
// function, so the two positions cannot drift into two wordings.
|
|
198
|
+
if (report.skippedOpening > 0 && !declined) {
|
|
199
|
+
lines.push(conclusionLine(report));
|
|
167
200
|
}
|
|
168
201
|
return lines;
|
|
169
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* The sentence that tells a caller what to do next.
|
|
205
|
+
*
|
|
206
|
+
* **The caution itself does not change when the caller owns the blocking
|
|
207
|
+
* row.** Closing a page belonging to an in-flight claim would be worse than
|
|
208
|
+
* declining, and that is true whoever the claim belongs to. What changes is
|
|
209
|
+
* that the caller is told the remedy is in its own hands: an operator can
|
|
210
|
+
* otherwise run this repeatedly against a row that is its own lease, held
|
|
211
|
+
* open for as long as the command keeps being run, with nothing in the
|
|
212
|
+
* message able to say so.
|
|
213
|
+
*/
|
|
214
|
+
function conclusionLine(report) {
|
|
215
|
+
const owned = report.skippedOpeningOwnedByCaller;
|
|
216
|
+
return (`${String(report.skippedOpening)} tab(s) are still being opened, so nothing was closed on this run — a page seen now may belong to one of them.` +
|
|
217
|
+
(owned > 0
|
|
218
|
+
? ` ${String(owned)} of them ${owned === 1 ? 'belongs' : 'belong'} to your own lease — release ${owned === 1 ? 'it' : 'them'}, or run this from another session.`
|
|
219
|
+
: ' Run again once they have settled.'));
|
|
220
|
+
}
|
|
@@ -146,39 +146,6 @@ const DECLARATIONS = [
|
|
|
146
146
|
maximum: 3,
|
|
147
147
|
browserKind: 'clean-room',
|
|
148
148
|
},
|
|
149
|
-
{
|
|
150
|
-
/**
|
|
151
|
-
* Which browser binary the signed-in browsers launch (§6.2).
|
|
152
|
-
*
|
|
153
|
-
* **One engine per kind, never per browser**, for the same reason there is
|
|
154
|
-
* no per-entry private flag: an engine per entry reintroduces the
|
|
155
|
-
* per-entry attribute this configuration exists without.
|
|
156
|
-
*
|
|
157
|
-
* The three accepted words are all Chromium over the same remote-debugging
|
|
158
|
-
* protocol, which is what makes the choice a binary path rather than a
|
|
159
|
-
* second driver.
|
|
160
|
-
*/
|
|
161
|
-
key: 'BROKER_REGULAR_BROWSER_ENGINE',
|
|
162
|
-
kind: 'enum',
|
|
163
|
-
fallback: 'msedge',
|
|
164
|
-
allowed: ['chrome', 'brave', 'msedge'],
|
|
165
|
-
unit: 'a browser engine',
|
|
166
|
-
},
|
|
167
|
-
{
|
|
168
|
-
/**
|
|
169
|
-
* Which browser binary the clean-room browsers launch (§6.2).
|
|
170
|
-
*
|
|
171
|
-
* **May differ from the signed-in engine**, and separate variables are
|
|
172
|
-
* what make that expressible: a person signs into the signed-in browser by
|
|
173
|
-
* hand, so which binary that is can be a matter of what they already use,
|
|
174
|
-
* while nobody signs into a clean-room browser at all.
|
|
175
|
-
*/
|
|
176
|
-
key: 'BROKER_PRIVATE_BROWSER_ENGINE',
|
|
177
|
-
kind: 'enum',
|
|
178
|
-
fallback: 'msedge',
|
|
179
|
-
allowed: ['chrome', 'brave', 'msedge'],
|
|
180
|
-
unit: 'a browser engine',
|
|
181
|
-
},
|
|
182
149
|
];
|
|
183
150
|
/** Every variable this build declares. Row #9's walk test reads this. */
|
|
184
151
|
export const DECLARED_VARIABLES = DECLARATIONS.map((d) => d.key);
|
|
@@ -407,15 +374,6 @@ export function readEnvironment(options = {}) {
|
|
|
407
374
|
}
|
|
408
375
|
return value;
|
|
409
376
|
};
|
|
410
|
-
// The reader already refused anything outside the declared set, so this
|
|
411
|
-
// narrows a checked value rather than trusting one.
|
|
412
|
-
const getEngine = (key) => {
|
|
413
|
-
const value = resolved.get(key);
|
|
414
|
-
if (value !== 'chrome' && value !== 'brave' && value !== 'msedge') {
|
|
415
|
-
throw new Error(`${key} was declared as an engine but not resolved as one`);
|
|
416
|
-
}
|
|
417
|
-
return value;
|
|
418
|
-
};
|
|
419
377
|
const regularBrowsers = getList('BROKER_REGULAR_BROWSERS');
|
|
420
378
|
const privateBrowsers = getList('BROKER_PRIVATE_BROWSERS');
|
|
421
379
|
// **A name in both lists is refused rather than resolved**, because the
|
|
@@ -440,7 +398,5 @@ export function readEnvironment(options = {}) {
|
|
|
440
398
|
launchReadinessTimeoutSeconds: getNumber('BROKER_LAUNCH_READINESS_TIMEOUT_SECONDS'),
|
|
441
399
|
regularBrowsers,
|
|
442
400
|
privateBrowsers,
|
|
443
|
-
regularBrowserEngine: getEngine('BROKER_REGULAR_BROWSER_ENGINE'),
|
|
444
|
-
privateBrowserEngine: getEngine('BROKER_PRIVATE_BROWSER_ENGINE'),
|
|
445
401
|
};
|
|
446
402
|
}
|
|
@@ -47,6 +47,30 @@ import { StartupRefusal } from "../errors.js";
|
|
|
47
47
|
* The memo holds the **promise**, not the resolved session, so two verbs
|
|
48
48
|
* racing in the same process await one acquisition rather than starting two.
|
|
49
49
|
*
|
|
50
|
+
* ── …but a memoised session is checked before it is handed back ──────────
|
|
51
|
+
*
|
|
52
|
+
* One session per process is right; *trusting* it for the life of the process
|
|
53
|
+
* is not. A session is a connection, and a connection can end while the
|
|
54
|
+
* browser it points at is still running — so a memo that is never revalidated
|
|
55
|
+
* hands a dead connection to every page verb until the process exits.
|
|
56
|
+
*
|
|
57
|
+
* That is not hypothetical and it is why this check exists. It is the
|
|
58
|
+
* difference between the two surfaces: the command line is one process per
|
|
59
|
+
* command, so its memo cannot outlive the verb that created it and the state
|
|
60
|
+
* is unreachable there. The tool surface serves a whole session from one
|
|
61
|
+
* process, so it is the surface where a stale memo is not merely possible but
|
|
62
|
+
* eventually certain — and a caller doing the obvious correct thing (release,
|
|
63
|
+
* claim again, drive the page) got the same dead connection every time, with
|
|
64
|
+
* no way back from that surface at all.
|
|
65
|
+
*
|
|
66
|
+
* So {@link BrowserSessions.session} asks {@link BrowserSession.isConnected}
|
|
67
|
+
* before returning a settled entry and drops it if the answer is no. **This
|
|
68
|
+
* is a different question from {@link BrowserSessions.liveness}**, which asks
|
|
69
|
+
* the machine whether a browser is running: in the state above the browser is
|
|
70
|
+
* running, so liveness says `live` and correctly evicts nothing. Both checks
|
|
71
|
+
* are needed because a browser and a connection can each die without the
|
|
72
|
+
* other.
|
|
73
|
+
*
|
|
50
74
|
* ── A failed acquisition is not cached ───────────────────────────────────
|
|
51
75
|
*
|
|
52
76
|
* If acquiring throws, the memo is cleared, so the next call tries again. The
|
|
@@ -82,20 +106,69 @@ export function browserSessionProvider(options) {
|
|
|
82
106
|
// BrowserSessionProviderOptions.artifacts} for why this is the
|
|
83
107
|
// shared tree rather than one lease's.
|
|
84
108
|
{ outputDirectory: path.join(options.artifacts.root, 'snapshots') }),
|
|
85
|
-
// **The signed-in engine, carried from this process's one environment
|
|
86
|
-
// snapshot** (§6.3, `DECISIONS.md` §13i). One driver serves every
|
|
87
|
-
// browser in this process, so the kind-specific engine cannot be chosen
|
|
88
|
-
// per session here — see the note on `RealDriverOptions.engine` for
|
|
89
|
-
// what the value does and does not do, which is what makes one engine
|
|
90
|
-
// per process the honest shape rather than a shortcut.
|
|
91
|
-
engine: options.environment.regularBrowserEngine,
|
|
92
109
|
});
|
|
93
110
|
const inFlight = new Map();
|
|
94
111
|
const settled = new Map();
|
|
95
112
|
const session = (browser) => {
|
|
96
113
|
const existing = inFlight.get(browser);
|
|
97
114
|
if (existing !== undefined) {
|
|
98
|
-
|
|
115
|
+
// ── The memo is checked before it is trusted ──────────────────────
|
|
116
|
+
//
|
|
117
|
+
// **This is what stops a dead connection being handed out for the life
|
|
118
|
+
// of a long-running process.** A settled session is a live connection
|
|
119
|
+
// over the debugging protocol, and a connection can end while the
|
|
120
|
+
// browser it points at carries on perfectly well — a browser restart, a
|
|
121
|
+
// closed target, a dropped protocol socket. When that happens the
|
|
122
|
+
// session object is still here, still resolved, and every page verb
|
|
123
|
+
// performed over it fails with `Target page, context or browser has
|
|
124
|
+
// been closed`.
|
|
125
|
+
//
|
|
126
|
+
// Nothing else in this file can catch that state, which is why it
|
|
127
|
+
// survived a previous fix. The `.catch` below clears only a *rejected*
|
|
128
|
+
// acquisition, and a session that resolved and later died never
|
|
129
|
+
// rejects. {@link BrowserSessions.liveness} clears a dead entry, but it
|
|
130
|
+
// asks the machine whether a browser is **running** — which in this
|
|
131
|
+
// state is `true` — and it is reached only from `status`, never from a
|
|
132
|
+
// page verb.
|
|
133
|
+
//
|
|
134
|
+
// ── Why it is safe to consult on the hot path ─────────────────────
|
|
135
|
+
//
|
|
136
|
+
// `isConnected` reads a flag the connection already maintains. It
|
|
137
|
+
// performs no input/output and cannot throw, so this adds no round trip
|
|
138
|
+
// to a call that is about to make several.
|
|
139
|
+
//
|
|
140
|
+
// ── Every session answers, because the member is required ─────────
|
|
141
|
+
//
|
|
142
|
+
// It was optional first, so that a source unable to observe its
|
|
143
|
+
// connection could stay silent and be assumed usable. That let the one
|
|
144
|
+
// production session omit it entirely while `tsc` stayed quiet, and
|
|
145
|
+
// this guard then took the assume-usable branch on every real call —
|
|
146
|
+
// shipping a fix that changed nothing. A source that cannot tell now
|
|
147
|
+
// returns `true` explicitly instead, so the permissive answer is a
|
|
148
|
+
// decision in the source rather than a hole in it.
|
|
149
|
+
//
|
|
150
|
+
// ── Only a settled session can be judged ──────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// An entry still in flight has no session to ask yet, and it is
|
|
153
|
+
// returned untouched: two verbs racing must await one acquisition
|
|
154
|
+
// rather than starting two, which is the property the promise-valued
|
|
155
|
+
// memo exists for. An acquisition in progress cannot be stale.
|
|
156
|
+
const open = settled.get(browser);
|
|
157
|
+
if (open === undefined || open.isConnected()) {
|
|
158
|
+
return existing;
|
|
159
|
+
}
|
|
160
|
+
// ── Dropped, not detached, and nothing is launched here ───────────
|
|
161
|
+
//
|
|
162
|
+
// There is nothing to detach from: the connection is the thing that
|
|
163
|
+
// ended. Dropping both entries sends this very call through `acquire`,
|
|
164
|
+
// which makes its own observation and wins or loses the launch race in
|
|
165
|
+
// the store like any other caller — the same recovery `liveness` takes,
|
|
166
|
+
// for the same reason it takes it that way. **No browser is ended**
|
|
167
|
+
// (`browser_scoped.never`, §7.3) and none is started from here: a
|
|
168
|
+
// second process launching against one profile directory is the
|
|
169
|
+
// measured silent-collision failure the race exists to prevent.
|
|
170
|
+
settled.delete(browser);
|
|
171
|
+
inFlight.delete(browser);
|
|
99
172
|
}
|
|
100
173
|
const acquiring = acquire(driver, browser, options)
|
|
101
174
|
.then((acquired) => {
|
|
@@ -4,13 +4,31 @@ import { reconcileGeometry } from "../diff/geometry.js";
|
|
|
4
4
|
import { decodePng, encodePng } from "../diff/image.js";
|
|
5
5
|
import { computeMask } from "../diff/mask.js";
|
|
6
6
|
import { extractRegions } from "../diff/regions.js";
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* A result carrying no diff, with the sentence saying why.
|
|
9
|
+
*
|
|
10
|
+
* **`changed`, `changedPixels` and `changedRatio` are not set here at all** —
|
|
11
|
+
* not to `false`, not to `0`. Every path into this function is a path on which
|
|
12
|
+
* no comparison ran, so there is no finding to report, and the shape says so by
|
|
13
|
+
* having no field rather than by having a field whose value happens to be the
|
|
14
|
+
* one a real all-clear also produces. `false` and `0` are what a comparison
|
|
15
|
+
* that ran and found nothing returns; emitting them here would make the two
|
|
16
|
+
* cases identical to anything reading the fields directly.
|
|
17
|
+
*
|
|
18
|
+
* `regions` stays an empty array and `truncated` stays `false` because those
|
|
19
|
+
* describe the *output listing* rather than a finding about the page: an empty
|
|
20
|
+
* list of regions is honest about a call that produced no regions, and neither
|
|
21
|
+
* can be misread as an assertion that the page is unchanged.
|
|
22
|
+
*/
|
|
23
|
+
function noDiff(settings, explanation,
|
|
24
|
+
// **Typed to exclude the three findings**, rather than a bare
|
|
25
|
+
// `Partial<ComparisonResult>`. The spread below is the one way a caller could
|
|
26
|
+
// put `changed` back into a no-diff result, so the parameter that feeds it
|
|
27
|
+
// does not accept those keys and a future call site trying to pass one is a
|
|
28
|
+
// build failure instead of a silently restored defect.
|
|
29
|
+
extra = {}) {
|
|
9
30
|
return {
|
|
10
31
|
diffed: false,
|
|
11
|
-
changed: false,
|
|
12
|
-
changedPixels: 0,
|
|
13
|
-
changedRatio: 0,
|
|
14
32
|
regions: [],
|
|
15
33
|
overlayPath: null,
|
|
16
34
|
truncated: false,
|
|
@@ -8,6 +8,7 @@ import { seedRecord } from "../storage-seed.js";
|
|
|
8
8
|
import { BrokerError } from "../../errors.js";
|
|
9
9
|
import { sanitiseLabel, stampFromInstant } from "../../artifacts/names.js";
|
|
10
10
|
import { takeCapture } from "../../capture/pipeline.js";
|
|
11
|
+
import { describeReduction } from "../../capture/tiers.js";
|
|
11
12
|
import { capturesTakenBy, recordCapture } from "../capture-store.js";
|
|
12
13
|
import { captureSource } from "../capture-seam.js";
|
|
13
14
|
import { insertComparison } from "../comparison-store.js";
|
|
@@ -307,9 +308,37 @@ export function decideNavigate(scope, input) {
|
|
|
307
308
|
// check was honoured.
|
|
308
309
|
detail: { url, ...(waitMs === undefined ? {} : { waitMs }) },
|
|
309
310
|
});
|
|
310
|
-
|
|
311
|
+
// Where the page actually ended up, filled by the after-commit closure
|
|
312
|
+
// below. Undefined until the browser has answered — and permanently so on a
|
|
313
|
+
// build with no browser, which is what the getters' fallbacks are for.
|
|
314
|
+
let arrived;
|
|
315
|
+
const work = afterCommitWork(scope, input, tab, async (session, page) => {
|
|
316
|
+
// **The driver's answer is kept, not discarded.** It reports
|
|
317
|
+
// `page.url()` read after the load settles, plus the title and status,
|
|
318
|
+
// and this assignment is the whole of the redirect fix: the value was
|
|
319
|
+
// always available here and was being thrown away.
|
|
320
|
+
arrived = await session.navigate(page, url, waitMs);
|
|
321
|
+
}, lease.claimId);
|
|
311
322
|
return {
|
|
312
|
-
value: withPageDriven({
|
|
323
|
+
value: withPageDriven({
|
|
324
|
+
claimId: lease.claimId,
|
|
325
|
+
tabId: tab.tabId,
|
|
326
|
+
expiresAt,
|
|
327
|
+
// Getters, for the reason `pageDriven` is one — see
|
|
328
|
+
// {@link withPageDriven}. Read eagerly they would always report the
|
|
329
|
+
// request and no title, because nothing has run yet. That eager read
|
|
330
|
+
// is precisely the defect this fix removes, so spelling these as
|
|
331
|
+
// plain properties would restore it while looking correct.
|
|
332
|
+
get url() {
|
|
333
|
+
return arrived?.url ?? url;
|
|
334
|
+
},
|
|
335
|
+
get title() {
|
|
336
|
+
return arrived?.title;
|
|
337
|
+
},
|
|
338
|
+
get status() {
|
|
339
|
+
return arrived?.status;
|
|
340
|
+
},
|
|
341
|
+
}, work),
|
|
313
342
|
afterCommit: work.afterCommit,
|
|
314
343
|
};
|
|
315
344
|
}
|
|
@@ -527,6 +556,20 @@ export function decideCapture(scope, input) {
|
|
|
527
556
|
// written reason — that rule is not duplicated here, only the one the type
|
|
528
557
|
// system cannot make on text arriving from a surface.
|
|
529
558
|
const tier = validateCaptureTier(input.tier);
|
|
559
|
+
// **Typed as the pipeline's options, not the driver's `CaptureRequest`.**
|
|
560
|
+
//
|
|
561
|
+
// This annotation is the defect's whole mechanism, so it is worth naming.
|
|
562
|
+
// `CaptureRequest` is the *driver* seam — what the browser is told — and it
|
|
563
|
+
// has no `tier` and no `reason`, correctly: a rung is a decision about the
|
|
564
|
+
// picture after the shutter, not something a browser is asked for. The
|
|
565
|
+
// literal below nevertheless packed both in, and the excess-property check
|
|
566
|
+
// that would ordinarily catch that **does not apply to conditionally spread
|
|
567
|
+
// properties**, so it compiled silently. The value then had nowhere to go,
|
|
568
|
+
// and the one call site downstream quietly took only the two fields the
|
|
569
|
+
// driver type admits.
|
|
570
|
+
//
|
|
571
|
+
// Annotating with the type that actually consumes these fields is what makes
|
|
572
|
+
// the same mistake a compile error next time rather than an inert argument.
|
|
530
573
|
const request = {
|
|
531
574
|
fullPage,
|
|
532
575
|
...(input.selector === undefined ? {} : { selector: input.selector }),
|
|
@@ -590,16 +633,56 @@ export function decideCapture(scope, input) {
|
|
|
590
633
|
const taken = await takeCapture({ tabs: session, artifacts }, lease.claimId, page, {
|
|
591
634
|
fullPage,
|
|
592
635
|
...(request.selector === undefined ? {} : { selector: request.selector }),
|
|
636
|
+
// **The rung and its justification, which used to stop here.**
|
|
637
|
+
//
|
|
638
|
+
// `request` was built with both a dozen lines above — `tier`
|
|
639
|
+
// validated by `validateCaptureTier`, `reason` carried whenever it
|
|
640
|
+
// was given — and then this call site spread only `fullPage` and
|
|
641
|
+
// `selector`, so both died one line before the pipeline that
|
|
642
|
+
// honours them. Every capture was consequently taken at the default
|
|
643
|
+
// rung no matter what the caller asked for.
|
|
644
|
+
//
|
|
645
|
+
// That is the inert-argument defect `check:argument-reachability`
|
|
646
|
+
// exists to prevent, one layer below where that check looks: its
|
|
647
|
+
// rule is that a declared argument is read *at the bridge*, and
|
|
648
|
+
// `tier` is read there, so the check passed while the value went
|
|
649
|
+
// nowhere. It is worse than the `wait_ms` case that motivated the
|
|
650
|
+
// check, because `tier="max"` charges the caller a written
|
|
651
|
+
// justification first — the caller pays for the escalation, is told
|
|
652
|
+
// it was accepted, and receives the unescalated picture.
|
|
653
|
+
...(request.tier === undefined ? {} : { tier: request.tier }),
|
|
654
|
+
...(request.reason === undefined ? {} : { reason: request.reason }),
|
|
593
655
|
}, takenBefore);
|
|
594
656
|
// The row last, describing a file that is already on disk. See
|
|
595
657
|
// `capture-store.ts` for why that order is the rule and not a preference.
|
|
596
658
|
recordCapture(scope.db, lease.claimId, tab.tabId, taken.telemetry);
|
|
659
|
+
// **What the browser produced, and by how much it was shrunk to fit the
|
|
660
|
+
// rung** — present exactly when the two differ.
|
|
661
|
+
//
|
|
662
|
+
// The pipeline has computed `sourceWidth`/`sourceHeight` all along and
|
|
663
|
+
// `captures` has stored them all along; this object simply never passed
|
|
664
|
+
// them on, so the one layer that talks to the caller was the one layer
|
|
665
|
+
// that could not tell a reduced picture from an unreduced one. A
|
|
666
|
+
// `full_page` capture of a long article consequently came back at about
|
|
667
|
+
// sixteen per cent, complete and undistorted and entirely illegible,
|
|
668
|
+
// with nothing in the response saying so.
|
|
669
|
+
const reduction = describeReduction({ width: taken.sourceWidth, height: taken.sourceHeight }, { width: taken.width, height: taken.height }, taken.tier);
|
|
597
670
|
written = {
|
|
598
671
|
captureId: taken.captureId,
|
|
599
672
|
path: taken.path,
|
|
600
673
|
width: taken.width,
|
|
601
674
|
height: taken.height,
|
|
602
675
|
bytes: taken.bytes,
|
|
676
|
+
// Echoed on every capture, reduced or not, because "what did the page
|
|
677
|
+
// actually measure" is a fact a caller may want either way — and
|
|
678
|
+
// because a field that appears only on the bad case is a field nobody
|
|
679
|
+
// learns to read.
|
|
680
|
+
sourceWidth: taken.sourceWidth,
|
|
681
|
+
sourceHeight: taken.sourceHeight,
|
|
682
|
+
tier: taken.tier,
|
|
683
|
+
// Absent when nothing was shrunk. Its **presence** is the signal, which
|
|
684
|
+
// is why it is not a `scale: 1` that a caller would learn to skip.
|
|
685
|
+
...(reduction === undefined ? {} : { reduced: reduction }),
|
|
603
686
|
compareHint: `to diff a later capture against this one, pass compare_to: ${taken.captureId}`,
|
|
604
687
|
};
|
|
605
688
|
// ── The diff, when one was asked for (§3.11, §1.9) ──────────────────
|
package/dist/src/tool/tools.js
CHANGED
|
@@ -222,9 +222,10 @@ export const TOOL_DEFINITIONS = [
|
|
|
222
222
|
{
|
|
223
223
|
name: 'browser_navigate',
|
|
224
224
|
operation: 'navigate',
|
|
225
|
-
description: 'Point your tab at an address. Returns the final address after redirects
|
|
226
|
-
'
|
|
227
|
-
'
|
|
225
|
+
description: 'Point your tab at an address. Returns the final address after redirects — which is not ' +
|
|
226
|
+
'always the address you asked for, and is the field to check when you need to know ' +
|
|
227
|
+
'whether something sent you elsewhere — plus the title and the status. ' +
|
|
228
|
+
'It does NOT take a snapshot: use browser_read for one.',
|
|
228
229
|
arguments: [
|
|
229
230
|
LEASE_KEY,
|
|
230
231
|
{
|
|
@@ -344,7 +345,11 @@ export const TOOL_DEFINITIONS = [
|
|
|
344
345
|
type: 'string',
|
|
345
346
|
required: false,
|
|
346
347
|
description: '"detail" or "max" for a higher resolution. Omit for the default — there is no way to ' +
|
|
347
|
-
'ask for the default by name. "max" also requires reason.'
|
|
348
|
+
'ask for the default by name. "max" also requires reason. **A tier raises the LONGEST ' +
|
|
349
|
+
'edge, not the width**, so on a full_page capture of a page taller than it is wide the ' +
|
|
350
|
+
'height takes the whole budget and the width stays small at every rung — a 1030px-wide ' +
|
|
351
|
+
'page 6400px tall is about 165px wide by default and about 414px wide at "max". For ' +
|
|
352
|
+
'legible text on a tall page, capture a selector instead, or read the page as text.',
|
|
348
353
|
},
|
|
349
354
|
{
|
|
350
355
|
name: 'reason',
|
package/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
|
}
|