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,36 @@
|
|
|
1
|
+
import { cliConformanceDriver } from "../../cli/conformance-driver.js";
|
|
2
|
+
import { toolStdioConformanceDriver } from "../../tool/conformance-driver.js";
|
|
3
|
+
/**
|
|
4
|
+
* One conformance driver per mounted route.
|
|
5
|
+
*
|
|
6
|
+
* ── This object is the compile-time half of "an unregistered adapter fails
|
|
7
|
+
* the suite" ──────────────────────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* Its type is `Record<AdapterId, ConformanceDriver>`, and `AdapterId` is
|
|
10
|
+
* `keyof typeof ADAPTER_REGISTRY` — **the registry the application mounts
|
|
11
|
+
* through**, not a list kept beside it. So the moment a route is added to
|
|
12
|
+
* that registry, this object stops compiling with
|
|
13
|
+
* `Property '<id>' is missing`, and the only way to make it compile is to
|
|
14
|
+
* write that route's driver.
|
|
15
|
+
*
|
|
16
|
+
* `MILESTONES.md` asks for exactly this: "in a map typed from the route
|
|
17
|
+
* registry the application actually mounts through, so adding a route without
|
|
18
|
+
* adding its driver does not compile."
|
|
19
|
+
*
|
|
20
|
+
* ── What it does not catch, said plainly ────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* A module that implements {@link Adapter} and was **never added to the
|
|
23
|
+
* registry** is invisible here — no type can see a file nobody imported. That
|
|
24
|
+
* half is closed at run time by the discovery walk in `discovery.ts`, which
|
|
25
|
+
* finds adapter modules on disk and asserts each is registered. Neither half
|
|
26
|
+
* substitutes for the other, and a reader who assumed this map alone was the
|
|
27
|
+
* mechanism would be wrong in the direction that matters.
|
|
28
|
+
*
|
|
29
|
+
* **Do not widen this to `Partial`, and do not add an index signature.**
|
|
30
|
+
* Either change converts the compile error into a silently absent row in the
|
|
31
|
+
* matrix, which is the failure this file exists to prevent.
|
|
32
|
+
*/
|
|
33
|
+
export const CONFORMANCE_DRIVERS = {
|
|
34
|
+
'tool-stdio': toolStdioConformanceDriver,
|
|
35
|
+
cli: cliConformanceDriver,
|
|
36
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { ADAPTER_IDS } from "../contract.js";
|
|
2
|
+
import { isWriteOperation, OPERATION_NAMES } from "../operations.js";
|
|
3
|
+
/** Roughly four words, the same bar the hygiene gate holds a waiver to. */
|
|
4
|
+
const WAIVER_MINIMUM_WORDS = 4;
|
|
5
|
+
function wordCount(text) {
|
|
6
|
+
const trimmed = text.trim();
|
|
7
|
+
if (trimmed === '') {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
return trimmed.split(/\s+/u).length;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run the suite.
|
|
14
|
+
*
|
|
15
|
+
* Every route in {@link ADAPTER_IDS} is visited — the list comes from the
|
|
16
|
+
* registry the application mounts through, so a mounted route cannot be
|
|
17
|
+
* skipped by this file not mentioning it.
|
|
18
|
+
*/
|
|
19
|
+
export async function runConformance(options) {
|
|
20
|
+
const findings = [];
|
|
21
|
+
const rulesObserved = new Set();
|
|
22
|
+
let pairsRun = 0;
|
|
23
|
+
// An assertion evaluated over an empty set passes forever and silently
|
|
24
|
+
// (`MILESTONES.md`), so both sets are checked directly rather than only
|
|
25
|
+
// being iterated.
|
|
26
|
+
if (options.rules.names.length === 0) {
|
|
27
|
+
findings.push({
|
|
28
|
+
kind: 'rule-registry-empty',
|
|
29
|
+
detail: 'the rule registry is empty, so every per-rule assertion would pass vacuously',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (options.cases.length === 0) {
|
|
33
|
+
findings.push({
|
|
34
|
+
kind: 'case-table-empty',
|
|
35
|
+
detail: 'the case table is empty, so the conformance matrix would be empty',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const knownOperations = new Set(OPERATION_NAMES);
|
|
39
|
+
for (const adapterId of ADAPTER_IDS) {
|
|
40
|
+
const driver = options.drivers[adapterId];
|
|
41
|
+
const { adapter } = driver;
|
|
42
|
+
const offered = new Set();
|
|
43
|
+
for (const operation of adapter.operations) {
|
|
44
|
+
if (!knownOperations.has(operation)) {
|
|
45
|
+
findings.push({
|
|
46
|
+
kind: 'unknown-operation-offered',
|
|
47
|
+
adapter: adapterId,
|
|
48
|
+
detail: `offers "${String(operation)}", which is not an operation this service has`,
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
offered.add(operation);
|
|
53
|
+
}
|
|
54
|
+
const waived = new Set();
|
|
55
|
+
for (const waiver of adapter.waivers) {
|
|
56
|
+
// A waiver has to say something. An empty one silences the assertion
|
|
57
|
+
// without leaving anything in the diff for a reviewer to disagree with.
|
|
58
|
+
if (wordCount(waiver.reason) < WAIVER_MINIMUM_WORDS) {
|
|
59
|
+
findings.push({
|
|
60
|
+
kind: 'waiver-not-permitted',
|
|
61
|
+
adapter: adapterId,
|
|
62
|
+
operation: waiver.operation,
|
|
63
|
+
detail: 'the waiver does not give a reason',
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// `MILESTONES.md`: no operation any registered rule can refuse may be
|
|
68
|
+
// waived by a route that exposes any write operation. A route is
|
|
69
|
+
// read-only by declaration, or fully covered, with nothing in between —
|
|
70
|
+
// otherwise a driver that declines to expose anything passes the first
|
|
71
|
+
// assertion vacuously.
|
|
72
|
+
if (!adapter.readOnly && isWriteOperation(waiver.operation)) {
|
|
73
|
+
findings.push({
|
|
74
|
+
kind: 'waiver-not-permitted',
|
|
75
|
+
adapter: adapterId,
|
|
76
|
+
operation: waiver.operation,
|
|
77
|
+
detail: 'a route exposing a write operation may not waive one; declare the route read-only, or cover it',
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
waived.add(waiver.operation);
|
|
82
|
+
}
|
|
83
|
+
for (const operation of OPERATION_NAMES) {
|
|
84
|
+
if (!offered.has(operation) && !waived.has(operation)) {
|
|
85
|
+
findings.push({
|
|
86
|
+
kind: 'operation-neither-offered-nor-waived',
|
|
87
|
+
adapter: adapterId,
|
|
88
|
+
operation,
|
|
89
|
+
detail: 'neither offered nor carrying a written waiver',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const testCase of options.cases) {
|
|
94
|
+
if (!offered.has(testCase.operation)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const subject = await options.makeService();
|
|
98
|
+
try {
|
|
99
|
+
// What the seed established, merged over the case's own input. See
|
|
100
|
+
// `CaseSeed.apply` for why some of it can only be known here.
|
|
101
|
+
const substitutions = (await testCase.seed?.apply(subject.service)) ?? {};
|
|
102
|
+
const seeded = {
|
|
103
|
+
...testCase,
|
|
104
|
+
input: { ...testCase.input, ...substitutions },
|
|
105
|
+
};
|
|
106
|
+
// **Read after the seed, not before it.** A seed that mints a lease
|
|
107
|
+
// moves the claim count, and a baseline taken before it would make
|
|
108
|
+
// every keyed case look as though the operation itself had moved the
|
|
109
|
+
// count — turning assertion 2 into a test of the fixture.
|
|
110
|
+
const claimsBefore = subject.liveClaimCount();
|
|
111
|
+
const callsBefore = subject.driverCalls().length;
|
|
112
|
+
const observation = await driver.run(subject.service, seeded, {
|
|
113
|
+
driverCalls: subject.driverCalls,
|
|
114
|
+
liveClaimCount: subject.liveClaimCount,
|
|
115
|
+
});
|
|
116
|
+
pairsRun += 1;
|
|
117
|
+
const { outcome } = observation;
|
|
118
|
+
if (outcome.outcome === 'refused') {
|
|
119
|
+
rulesObserved.add(outcome.rule);
|
|
120
|
+
}
|
|
121
|
+
if (testCase.expect.outcome !== outcome.outcome) {
|
|
122
|
+
findings.push({
|
|
123
|
+
kind: 'outcome-mismatch',
|
|
124
|
+
adapter: adapterId,
|
|
125
|
+
operation: testCase.operation,
|
|
126
|
+
caseName: testCase.name,
|
|
127
|
+
// **The refusal's own code and rule are carried**, not just the
|
|
128
|
+
// fact that the outcomes differed. `MILESTONES.md` #72 is the
|
|
129
|
+
// reason: an intermittent failure here read only "expected
|
|
130
|
+
// accepted, got refused", which names no rule and so gives a
|
|
131
|
+
// reader nothing to attribute it to — two separate
|
|
132
|
+
// investigations reproduced it and still could not say which
|
|
133
|
+
// guard had fired. The rule name was the whole diagnosis, and it
|
|
134
|
+
// was being discarded one line before it could be reported.
|
|
135
|
+
detail: outcome.outcome === 'refused'
|
|
136
|
+
? `expected ${testCase.expect.outcome}, got ${outcome.outcome} (${outcome.code} / ${outcome.rule})`
|
|
137
|
+
: `expected ${testCase.expect.outcome}, got ${outcome.outcome}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else if (testCase.expect.outcome === 'refused' && outcome.outcome === 'refused') {
|
|
141
|
+
// The code and the rule are compared; the sentence never is
|
|
142
|
+
// (`SCHEMA.md` §3.14) — asserting text is brittle and a weaker
|
|
143
|
+
// claim than asserting the code.
|
|
144
|
+
if (outcome.code !== testCase.expect.code || outcome.rule !== testCase.expect.rule) {
|
|
145
|
+
findings.push({
|
|
146
|
+
kind: 'outcome-mismatch',
|
|
147
|
+
adapter: adapterId,
|
|
148
|
+
operation: testCase.operation,
|
|
149
|
+
caseName: testCase.name,
|
|
150
|
+
detail: `expected ${testCase.expect.code} / ${testCase.expect.rule}, got ${outcome.code} / ${outcome.rule}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (outcome.outcome === 'refused') {
|
|
155
|
+
// Both readings, because they catch different bugs (§8.2): a guard
|
|
156
|
+
// that opens a tab and closes it on the way to refusing leaves the
|
|
157
|
+
// count unchanged and the log full; a guard that decrements a
|
|
158
|
+
// counter without telling the browser leaves the log empty and the
|
|
159
|
+
// count wrong.
|
|
160
|
+
const callsDuring = observation.driverCalls.slice(callsBefore);
|
|
161
|
+
if (callsDuring.length > 0) {
|
|
162
|
+
findings.push({
|
|
163
|
+
kind: 'refusal-touched-the-browser',
|
|
164
|
+
adapter: adapterId,
|
|
165
|
+
operation: testCase.operation,
|
|
166
|
+
caseName: testCase.name,
|
|
167
|
+
detail: `a refusal asked the browser to ${callsDuring.map((call) => call.name).join(', ')}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (observation.liveClaimCount !== claimsBefore) {
|
|
171
|
+
findings.push({
|
|
172
|
+
kind: 'refusal-moved-the-claim-count',
|
|
173
|
+
adapter: adapterId,
|
|
174
|
+
operation: testCase.operation,
|
|
175
|
+
caseName: testCase.name,
|
|
176
|
+
detail: `a refusal moved the live claim count from ${String(claimsBefore)} to ${String(observation.liveClaimCount)}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await subject.dispose?.();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// §8.3 — every operation some route offers has both a case that succeeds
|
|
187
|
+
// and a case that is refused. Computed over the operations actually
|
|
188
|
+
// offered, so an operation no route has reached yet is not a failing
|
|
189
|
+
// assertion about work nobody has done.
|
|
190
|
+
const offeredAnywhere = new Set();
|
|
191
|
+
for (const adapterId of ADAPTER_IDS) {
|
|
192
|
+
for (const operation of options.drivers[adapterId].adapter.operations) {
|
|
193
|
+
offeredAnywhere.add(operation);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (const operation of OPERATION_NAMES) {
|
|
197
|
+
if (!offeredAnywhere.has(operation)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const forOperation = options.cases.filter((testCase) => testCase.operation === operation);
|
|
201
|
+
const accepts = forOperation.some((testCase) => testCase.expect.outcome === 'accepted');
|
|
202
|
+
const refuses = forOperation.some((testCase) => testCase.expect.outcome === 'refused');
|
|
203
|
+
if (!accepts || !refuses) {
|
|
204
|
+
findings.push({
|
|
205
|
+
kind: 'operation-without-both-cases',
|
|
206
|
+
operation,
|
|
207
|
+
detail: accepts ? 'has no case that is refused' : 'has no case that succeeds',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// §8.4 — every rule appears in at least one refusal **the service actually
|
|
212
|
+
// produced**, computed from what came back rather than from what a case
|
|
213
|
+
// declared. A case naming a rule the service never returned does not
|
|
214
|
+
// satisfy this, which is what keeps the suite honest a year from now.
|
|
215
|
+
for (const rule of options.rules.names) {
|
|
216
|
+
if (!rulesObserved.has(rule)) {
|
|
217
|
+
findings.push({
|
|
218
|
+
kind: 'rule-without-a-case',
|
|
219
|
+
detail: `rule "${rule}" was never produced by a refusal in this run`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { findings, pairsRun, rulesObserved: [...rulesObserved].sort() };
|
|
224
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { FakeBrowserDriver } from "../../browser/fake.js";
|
|
5
|
+
import { countActiveClaims } from "../../service/capacity.js";
|
|
6
|
+
import { createRuntime } from "../../service/runtime.js";
|
|
7
|
+
/**
|
|
8
|
+
* The **real service**, as the conformance suite's subject.
|
|
9
|
+
*
|
|
10
|
+
* ── Why the suite's subject has to be this and not a stand-in ───────────
|
|
11
|
+
*
|
|
12
|
+
* §8 asks whether **the rules are the same through every door**. A subject
|
|
13
|
+
* that implements the case table's rules itself cannot answer that: the rules
|
|
14
|
+
* being compared are the stand-in's, so a green matrix says only that each
|
|
15
|
+
* route carries an outcome faithfully — a much narrower claim, and one that
|
|
16
|
+
* holds just as well against a service enforcing something else entirely.
|
|
17
|
+
*
|
|
18
|
+
* `createRuntime` builds the service both shipped binaries build: the real
|
|
19
|
+
* store, the real schema, the real artifact store, the real broker, the real
|
|
20
|
+
* bridge. With it behind the seam, every assertion in `run.ts` is an
|
|
21
|
+
* assertion about enforcement, and no case or driver has to be shaped
|
|
22
|
+
* differently to get there — which is what the seam is for.
|
|
23
|
+
*
|
|
24
|
+
* ── The one thing that is faked, and the argument for faking it ─────────
|
|
25
|
+
*
|
|
26
|
+
* **The browser driver.** Continuous integration runs headless with no
|
|
27
|
+
* browser binary, and the two routes are spawned there on every change; a
|
|
28
|
+
* subject that needed a real browser would make the parity suite unrunnable
|
|
29
|
+
* in the one place it most needs to run.
|
|
30
|
+
*
|
|
31
|
+
* **A real service over a fake driver is still the real service**, and that
|
|
32
|
+
* is not a concession — it is the right shape for this particular assertion.
|
|
33
|
+
* §8's four assertions are about *rules*: the same acceptance or the same
|
|
34
|
+
* refusal code and rule name on every route, a refusal that touches no
|
|
35
|
+
* browser and moves no claim count, every operation covered both ways, every
|
|
36
|
+
* rule produced by a real refusal. Not one of them is a claim about what a
|
|
37
|
+
* page does. Every rule those assertions are about is enforced inside the
|
|
38
|
+
* arbitration transaction, before any browser is reached — §2.4b guarantees
|
|
39
|
+
* exactly that — so the driver is downstream of everything under test.
|
|
40
|
+
*
|
|
41
|
+
* What the fake buys, beyond running at all, is the **second half of
|
|
42
|
+
* assertion 2**: `SCHEMA.md` §8 requires that a refusal never touched the
|
|
43
|
+
* browser, and "never touched" is only checkable against something that
|
|
44
|
+
* records being touched. A real browser would have to be interrogated about
|
|
45
|
+
* what it did; the fake keeps an ordered log, which is a stronger reading of
|
|
46
|
+
* the same property.
|
|
47
|
+
*
|
|
48
|
+
* **What it therefore does not prove, stated plainly:** that a page navigates,
|
|
49
|
+
* that a capture contains pixels, or that any verb does its physical work.
|
|
50
|
+
* Those belong to the browser suites, which are local-only for the same
|
|
51
|
+
* reason this is not. A green run here is evidence about rules and routes, and
|
|
52
|
+
* reading it as evidence about browsers would be over-reading it.
|
|
53
|
+
*
|
|
54
|
+
* ── Isolated per case, because the runner asks for that ─────────────────
|
|
55
|
+
*
|
|
56
|
+
* `ConformanceRunOptions.makeService` is called **per case-and-route pair**,
|
|
57
|
+
* so each subject below gets its own temporary directory and its own store. A
|
|
58
|
+
* shared store would let one case's claim be visible to the next, and the
|
|
59
|
+
* claim-count assertion would then be reading somebody else's leftovers.
|
|
60
|
+
* `dispose` removes the tree.
|
|
61
|
+
*/
|
|
62
|
+
/** Every rule the real service produces in this suite's run. */
|
|
63
|
+
export const SERVICE_RULE_REGISTRY = {
|
|
64
|
+
names: [
|
|
65
|
+
'key.present',
|
|
66
|
+
'key.valid',
|
|
67
|
+
'claim.browser_known',
|
|
68
|
+
'claim.purpose_bounded',
|
|
69
|
+
'tab.owned',
|
|
70
|
+
'navigate.scheme_allowed',
|
|
71
|
+
'act.action_known',
|
|
72
|
+
'evaluate.expression_bounded',
|
|
73
|
+
'capture.exclusive_mode',
|
|
74
|
+
'feedback.rating_in_scale',
|
|
75
|
+
// The sign-in request's own bound. It is the only one of the three
|
|
76
|
+
// §5.5.2 rules a conformance case reaches, and that is deliberate rather
|
|
77
|
+
// than an omission: this registry is *"every rule the real service
|
|
78
|
+
// produces in this suite's run"*, and the suite asserts equality in both
|
|
79
|
+
// directions — so a name listed here that no case produces fails just as
|
|
80
|
+
// loudly as one produced and not listed. `signin.requester_holds_tab` and
|
|
81
|
+
// `signin.finish_owned` are exercised by the service tests, where a
|
|
82
|
+
// second lease and a queued lease can be arranged; a conformance case
|
|
83
|
+
// names one operation and one input and cannot set either up.
|
|
84
|
+
'signin.what_bounded',
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Build one isolated real service, with a fake browser behind it.
|
|
89
|
+
*
|
|
90
|
+
* The environment is passed as a record rather than set on the process:
|
|
91
|
+
* `readEnvironment` takes one, and a subject that mutated `process.env` would
|
|
92
|
+
* be read by every other test sharing the process.
|
|
93
|
+
*/
|
|
94
|
+
/** How many times a subject's directory is removed before the failure is reported. */
|
|
95
|
+
const REMOVE_ATTEMPTS = 10;
|
|
96
|
+
/** How long to wait between removal attempts, in milliseconds. */
|
|
97
|
+
const REMOVE_RETRY_DELAY_MS = 30;
|
|
98
|
+
/**
|
|
99
|
+
* Remove a subject's temporary directory, waiting out a handle the OS is still
|
|
100
|
+
* releasing — and **saying so** if the directory genuinely will not go.
|
|
101
|
+
*
|
|
102
|
+
* `dispose` is called from a `finally` in the conformance runner, so anything
|
|
103
|
+
* thrown here becomes the result of the case that just ran. On Windows a
|
|
104
|
+
* still-live handle makes `rmSync` fail with `EPERM` (`force` suppresses only
|
|
105
|
+
* `ENOENT`), and `node --test` attributes such a throw to the **file** rather
|
|
106
|
+
* than to any test — producing a bare file-level failure with every subtest
|
|
107
|
+
* passing. That is a fixture fault wearing the costume of a test fault, and it
|
|
108
|
+
* is worth naming because this suite has already spent a red Windows run on it.
|
|
109
|
+
*
|
|
110
|
+
* The retry is written out rather than delegated to `rmSync`'s own
|
|
111
|
+
* `maxRetries`/`retryDelay`: measured here, those options make no difference to
|
|
112
|
+
* this failure at all (`{ maxRetries: 20 }` gives up as fast as
|
|
113
|
+
* `{ maxRetries: 0 }`), so relying on them would look like a fix while changing
|
|
114
|
+
* nothing.
|
|
115
|
+
*
|
|
116
|
+
* A directory that survives every attempt still throws. The alternative —
|
|
117
|
+
* swallowing it — is how ~1,450 stores accumulated unnoticed in the first place.
|
|
118
|
+
*/
|
|
119
|
+
function removeSubjectDirectory(directory) {
|
|
120
|
+
let lastError;
|
|
121
|
+
for (let attempt = 0; attempt < REMOVE_ATTEMPTS; attempt += 1) {
|
|
122
|
+
try {
|
|
123
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
lastError = error;
|
|
128
|
+
if (attempt < REMOVE_ATTEMPTS - 1) {
|
|
129
|
+
// Blocking on purpose: the caller is synchronous, and the wait exists
|
|
130
|
+
// to let the OS finish a release this thread cannot observe otherwise.
|
|
131
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, REMOVE_RETRY_DELAY_MS);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`could not remove the conformance subject's store at ${directory}. ` +
|
|
136
|
+
'A handle was most likely still open when the subject was disposed.', { cause: lastError });
|
|
137
|
+
}
|
|
138
|
+
export async function makeServiceSubject() {
|
|
139
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'broker-conformance-'));
|
|
140
|
+
const driver = new FakeBrowserDriver();
|
|
141
|
+
const runtime = await createRuntime({
|
|
142
|
+
adapter: 'cli',
|
|
143
|
+
driver,
|
|
144
|
+
env: {
|
|
145
|
+
BROKER_DB: path.join(directory, 'broker.db'),
|
|
146
|
+
BROKER_ARTIFACTS_ROOT: path.join(directory, 'artefacts'),
|
|
147
|
+
BROKER_PROFILE_ROOT: path.join(directory, 'profiles'),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
service: runtime.service,
|
|
152
|
+
// The fake's own log, which is what makes "a refusal touched no browser"
|
|
153
|
+
// checkable rather than asserted.
|
|
154
|
+
driverCalls: () => driver.calls,
|
|
155
|
+
// **The same predicate the capacity check uses**, per `driver.ts`'s
|
|
156
|
+
// requirement for this reading — not a count of rows in `claims`, which
|
|
157
|
+
// would include the ended ones and move for reasons a refusal did not
|
|
158
|
+
// cause.
|
|
159
|
+
liveClaimCount: () => countActiveClaims(runtime.store.db),
|
|
160
|
+
dispose: () => {
|
|
161
|
+
runtime.close();
|
|
162
|
+
removeSubjectDirectory(directory);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every route this application mounts.
|
|
3
|
+
*
|
|
4
|
+
* **This is the registry, and it is the one the application uses.** Not a
|
|
5
|
+
* manifest kept beside the real wiring — the moment there are two, the
|
|
6
|
+
* conformance suite is asserting over the copy and the application is serving
|
|
7
|
+
* from the other, and nothing reports the difference. Every consumer, the
|
|
8
|
+
* suite included, reads this.
|
|
9
|
+
*
|
|
10
|
+
* `SCHEMA.md` §8: there are **two routes, not three**, because nothing is
|
|
11
|
+
* served (§4). The generated operations document is not a route: it performs
|
|
12
|
+
* no operation and refuses nothing, so it has nothing to be at parity with.
|
|
13
|
+
* Do not add it here.
|
|
14
|
+
*/
|
|
15
|
+
export const ADAPTER_REGISTRY = {
|
|
16
|
+
'tool-stdio': 'The tool surface over standard input and output. Spawned by its caller, serves that session, exits with it. The primary route.',
|
|
17
|
+
cli: 'The command line. In process, because there is nothing else for a command to talk to.',
|
|
18
|
+
};
|
|
19
|
+
/** Every mounted route's identifier, in a stable order. */
|
|
20
|
+
export const ADAPTER_IDS = Object.keys(ADAPTER_REGISTRY);
|
|
21
|
+
/** Whether a string names a mounted route. */
|
|
22
|
+
export function isAdapterId(value) {
|
|
23
|
+
return Object.prototype.hasOwnProperty.call(ADAPTER_REGISTRY, value);
|
|
24
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operations every route offers, named once.
|
|
3
|
+
*
|
|
4
|
+
* `SCHEMA.md` §8 is the claim this file exists to make checkable: *the same
|
|
5
|
+
* rules through every door*. A rule implemented inside one route is enforced
|
|
6
|
+
* for that route's callers and for nobody else, **and nothing reports it** —
|
|
7
|
+
* so the names an adapter is measured against cannot be written down once per
|
|
8
|
+
* adapter. They are written down here, and every adapter is measured against
|
|
9
|
+
* this list.
|
|
10
|
+
*
|
|
11
|
+
* ── Why a closed union rather than a string ─────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* {@link OperationName} is a union of literals, so an adapter claiming to
|
|
14
|
+
* expose an operation this service does not have is a **type error** rather
|
|
15
|
+
* than a case that quietly never runs. That matters more than it looks: the
|
|
16
|
+
* conformance runner takes the cross product of cases with the adapters
|
|
17
|
+
* exposing each operation, and an adapter that misspells a name would
|
|
18
|
+
* otherwise contribute an empty row to the matrix and pass vacuously.
|
|
19
|
+
* `MILESTONES.md` names that failure directly — an assertion evaluated over
|
|
20
|
+
* an empty set "passes forever and silently".
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The twelve operations of `SCHEMA.md` §3.1, in the order it lists them.
|
|
24
|
+
*
|
|
25
|
+
* Twelve tools, twelve commands (§5.3), one list. The diff rides on `capture`
|
|
26
|
+
* as an argument rather than being an operation of its own (§3.11), and the
|
|
27
|
+
* two removed tools are absent rather than deprecated (§3.1) —
|
|
28
|
+
* `browser_compare` folded into capture, and `browser_tab_close` deleted
|
|
29
|
+
* outright because it produced a lease owning nothing while still consuming
|
|
30
|
+
* budget.
|
|
31
|
+
*
|
|
32
|
+
* ── Why the eleventh and twelfth are two names rather than one ──────────
|
|
33
|
+
*
|
|
34
|
+
* `sign_in` and `sign_in_done` are the two halves of asking a person to sign
|
|
35
|
+
* in (§5.5.2), and they are separate operations for the reason §3.1 gives
|
|
36
|
+
* when it reconciles folding comparison into capture: **a destructive
|
|
37
|
+
* operation keeps its own name; a non-destructive one may be an argument on
|
|
38
|
+
* another.** Both halves move the browser's state under every other caller —
|
|
39
|
+
* one takes it away and one gives it back — so a rule matching on the
|
|
40
|
+
* operation name has to be able to see each. Folding the second into the
|
|
41
|
+
* first as a `done: true` argument would hide precisely the transition an
|
|
42
|
+
* operator reading the ledger is trying to find.
|
|
43
|
+
*
|
|
44
|
+
* **They are not `begin_sign_in`/`end_sign_in`.** That pair is a person's,
|
|
45
|
+
* takes no key, and is deliberately absent from this list: §3.13's ceiling is
|
|
46
|
+
* that *"the worst thing an agent can do through this surface is close its
|
|
47
|
+
* own tab"*, and an unkeyed verb that ends a sign-in by naming a browser
|
|
48
|
+
* would end a person's, mid-password. These two are keyed, and the key is
|
|
49
|
+
* what makes each answerable — which lease to exempt, and which lease is
|
|
50
|
+
* entitled to finish.
|
|
51
|
+
*/
|
|
52
|
+
export const OPERATION_NAMES = [
|
|
53
|
+
'claim',
|
|
54
|
+
'status',
|
|
55
|
+
'release',
|
|
56
|
+
'tab_replace',
|
|
57
|
+
'navigate',
|
|
58
|
+
'act',
|
|
59
|
+
'read',
|
|
60
|
+
'evaluate',
|
|
61
|
+
'capture',
|
|
62
|
+
'feedback',
|
|
63
|
+
'sign_in',
|
|
64
|
+
'sign_in_done',
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* Whether an operation changes anything a later caller can observe.
|
|
68
|
+
*
|
|
69
|
+
* Recorded here rather than inferred by an adapter, because it decides what a
|
|
70
|
+
* waiver may cover: `MILESTONES.md` requires that a route exposing **any**
|
|
71
|
+
* write operation may not waive an operation any registered rule can refuse.
|
|
72
|
+
* "A route is read-only by declaration, or fully covered, with nothing in
|
|
73
|
+
* between — otherwise a driver that declines to expose anything passes the
|
|
74
|
+
* first assertion vacuously."
|
|
75
|
+
*
|
|
76
|
+
* `feedback` is a write and is deliberately marked as one even though it
|
|
77
|
+
* takes no lease (§3.16): it appends a row to the installation's own store.
|
|
78
|
+
*/
|
|
79
|
+
const WRITE_OPERATIONS = new Set([
|
|
80
|
+
'claim',
|
|
81
|
+
'status',
|
|
82
|
+
'release',
|
|
83
|
+
'tab_replace',
|
|
84
|
+
'navigate',
|
|
85
|
+
'act',
|
|
86
|
+
'read',
|
|
87
|
+
'evaluate',
|
|
88
|
+
'capture',
|
|
89
|
+
'feedback',
|
|
90
|
+
// Both halves of a requested sign-in write: each moves the browser's state,
|
|
91
|
+
// each appends a ledger row, and each renews the lease that called it.
|
|
92
|
+
'sign_in',
|
|
93
|
+
'sign_in_done',
|
|
94
|
+
]);
|
|
95
|
+
/**
|
|
96
|
+
* Every operation here writes, and that is the design rather than an
|
|
97
|
+
* oversight worth flagging.
|
|
98
|
+
*
|
|
99
|
+
* `SCHEMA.md` §7.1 `arbitration.writes`: *"every arbitration path declares its
|
|
100
|
+
* intent to write when it opens its transaction, and every one of them does
|
|
101
|
+
* write"*, and §5.2 spells out the consequence for this route — any command
|
|
102
|
+
* that goes through arbitration performs the lazy sweep, so even a listing
|
|
103
|
+
* command closes somebody's lapsed tabs. `status` is the one that surprises
|
|
104
|
+
* people, and it is a write for exactly that reason: it extends the lease
|
|
105
|
+
* (§3.1, "there is no keyed call that does not extend").
|
|
106
|
+
*
|
|
107
|
+
* The predicate is kept as a predicate rather than collapsed to `true`
|
|
108
|
+
* because the waiver rule it feeds is about the *category*, and an operation
|
|
109
|
+
* that genuinely reads is a thing a later row may add. A function that
|
|
110
|
+
* returned `true` unconditionally would be a rule nobody could ever see fail.
|
|
111
|
+
*/
|
|
112
|
+
export function isWriteOperation(operation) {
|
|
113
|
+
return WRITE_OPERATIONS.has(operation);
|
|
114
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|