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,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "browser-broker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Leases over tabs in a fixed set of browsers: bounded capacity, a queue, reclamation, and an enforced capture policy.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"broker": "dist/src/bin/broker.js",
|
|
9
|
+
"broker-tool": "dist/src/bin/broker-tool.js"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.18"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"check:external-refs": "node scripts/check-external-refs.mjs",
|
|
16
|
+
"check:doc-links": "node scripts/check-doc-links.mjs",
|
|
17
|
+
"check:capture-isolation": "node scripts/check-capture-isolation.mjs",
|
|
18
|
+
"check:install": "node scripts/check-install.mjs",
|
|
19
|
+
"check:operations": "node scripts/check-operations.mjs",
|
|
20
|
+
"check:argument-refusals": "node scripts/check-argument-refusals.mjs",
|
|
21
|
+
"check:injected-tests": "node scripts/check-injected-tests.mjs",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"lint": "eslint .",
|
|
24
|
+
"format": "prettier --write .",
|
|
25
|
+
"format:check": "prettier --check .",
|
|
26
|
+
"test": "node --test \"tests/**/*.test.mjs\" \"tests/**/*.test.ts\"",
|
|
27
|
+
"check": "npm run check:external-refs && npm run check:doc-links && npm run check:arbitration && npm run check:capture-isolation && npm run check:artifact-path && npm run typecheck && npm run lint && npm run format:check && npm test && npm run check:install && npm run check:operations && npm run check:argument-refusals && npm run check:injected-tests",
|
|
28
|
+
"check:arbitration": "node scripts/check-arbitration.mjs",
|
|
29
|
+
"check:artifact-path": "node scripts/check-artifact-path.mjs",
|
|
30
|
+
"build": "tsc --project tsconfig.build.json",
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"check:package": "node scripts/check-package.mjs"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"better-sqlite3": "^13.0.3",
|
|
36
|
+
"pixelmatch": "^7.2.0",
|
|
37
|
+
"playwright-core": "1.62.1",
|
|
38
|
+
"pngjs": "^7.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@eslint/js": "^10.0.1",
|
|
42
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
43
|
+
"@types/node": "^22.18.12",
|
|
44
|
+
"@types/pngjs": "^6.0.5",
|
|
45
|
+
"eslint": "^10.9.1",
|
|
46
|
+
"globals": "^17.11.0",
|
|
47
|
+
"prettier": "^3.6.2",
|
|
48
|
+
"typescript": "~5.9.3",
|
|
49
|
+
"typescript-eslint": "^8.68.0"
|
|
50
|
+
},
|
|
51
|
+
"files": [
|
|
52
|
+
"dist/",
|
|
53
|
+
".env.example",
|
|
54
|
+
"README.md",
|
|
55
|
+
"RELEASES.md",
|
|
56
|
+
"LICENSE"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { OPERATION_NAMES } from "../operations.js";
|
|
2
|
+
/**
|
|
3
|
+
* The case table: authored once per operation, crossed with every route.
|
|
4
|
+
*
|
|
5
|
+
* `SCHEMA.md` §8 assertion 3 requires **every operation to have a case that
|
|
6
|
+
* succeeds and a case that is refused**, so each operation below has both.
|
|
7
|
+
* The runner checks that requirement over the operations routes actually
|
|
8
|
+
* offer, rather than trusting this file to be complete — a table that
|
|
9
|
+
* silently lost a case would otherwise weaken the suite without failing it.
|
|
10
|
+
*
|
|
11
|
+
* ── What these cases assert while the service layer is unbuilt ──────────
|
|
12
|
+
*
|
|
13
|
+
* The rules these cases name are `SCHEMA.md` §7.1's, and the service that
|
|
14
|
+
* enforces them is row #10 onward — **not on `main` yet**. So the cases are
|
|
15
|
+
* run against a service double that implements exactly those rules, and what
|
|
16
|
+
* they prove is the property this row owes: **that the route carries an
|
|
17
|
+
* outcome faithfully.** Every code and rule name that comes out of the
|
|
18
|
+
* service arrives at the caller unchanged, with the physical side-effects
|
|
19
|
+
* unmoved on a refusal.
|
|
20
|
+
*
|
|
21
|
+
* That is a narrower claim than #30's and it is stated narrowly on purpose.
|
|
22
|
+
* When the real service lands, this table is pointed at it and the same cases
|
|
23
|
+
* become the parity assertion over real enforcement, with no case rewritten —
|
|
24
|
+
* which is the property the once-per-operation shape exists to give.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Mint a live lease and hand its key to the case.
|
|
28
|
+
*
|
|
29
|
+
* ── Why every keyed case needs this, rather than a constant ─────────────
|
|
30
|
+
*
|
|
31
|
+
* A lease key is **returned once by the claim that granted it and is not
|
|
32
|
+
* recoverable** (§2.2), so a case cannot write one down. A constant would be
|
|
33
|
+
* a key the service never issued, and every keyed case would then measure
|
|
34
|
+
* `key.valid` refusing it — the whole matrix would go green on the wrong
|
|
35
|
+
* assertion, with the operations themselves never reached.
|
|
36
|
+
*
|
|
37
|
+
* The seed runs afresh per case-and-route pair, so each pair gets its own
|
|
38
|
+
* lease and no pair inherits another's.
|
|
39
|
+
*/
|
|
40
|
+
const withALiveLease = {
|
|
41
|
+
apply: async (service) => {
|
|
42
|
+
const granted = await service.perform({
|
|
43
|
+
operation: 'claim',
|
|
44
|
+
adapter: 'conformance',
|
|
45
|
+
arguments: {
|
|
46
|
+
session_id: 'conformance-seed',
|
|
47
|
+
browser: 'regular',
|
|
48
|
+
purpose: 'conformance: a lease for the keyed cases',
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (granted.outcome !== 'accepted') {
|
|
52
|
+
throw new Error(`the seed could not obtain a lease: ${granted.rule}`);
|
|
53
|
+
}
|
|
54
|
+
// ── Two different outcomes, and only one of them is a live lease ─────
|
|
55
|
+
//
|
|
56
|
+
// `granted.outcome` is the **transport's** answer: the call was accepted
|
|
57
|
+
// rather than refused. `granted.value['outcome']` is the **service's**:
|
|
58
|
+
// whether the claim was granted a tab or put in the queue. A full budget
|
|
59
|
+
// answers `accepted` at the transport and `queued` underneath, and a
|
|
60
|
+
// queued claim holds a real key with no tab behind it.
|
|
61
|
+
//
|
|
62
|
+
// Checking only the first is the shape this suite exists to catch. The
|
|
63
|
+
// docblock above argues that a key the service never issued would send
|
|
64
|
+
// the whole matrix green on `key.valid`; a queued key is the same defect
|
|
65
|
+
// one layer in, and harder to see, because the key is genuine — the
|
|
66
|
+
// operations would refuse for having no tab while the seed reported
|
|
67
|
+
// success, and the failure would name a rule that looks like a real
|
|
68
|
+
// finding about the operation under test.
|
|
69
|
+
//
|
|
70
|
+
// The sibling seed below already reads `value['outcome']`, because
|
|
71
|
+
// reaching a queue placement is the thing it is trying to do. This one
|
|
72
|
+
// wants the opposite and had not said so.
|
|
73
|
+
if (granted.value['outcome'] !== 'granted') {
|
|
74
|
+
throw new Error(`the seed obtained a lease that is not live: the service answered ` +
|
|
75
|
+
`'${String(granted.value['outcome'])}' rather than 'granted', so the key it ` +
|
|
76
|
+
`returned has no tab behind it and every keyed case would measure that ` +
|
|
77
|
+
`instead of the operation it names.`);
|
|
78
|
+
}
|
|
79
|
+
// **The key is substituted into the case's input**, so the operation
|
|
80
|
+
// under test is reached rather than being refused for an unknown key.
|
|
81
|
+
return { lease_key: granted.value['key'] };
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A live lease whose tab has been given back, so the key is real and there is
|
|
86
|
+
* no tab behind it.
|
|
87
|
+
*
|
|
88
|
+
* The reachable way to exercise `tab.owned`: no surface takes a tab argument
|
|
89
|
+
* (§3.4), so a caller cannot name another lease's tab and there is no input
|
|
90
|
+
* that would. Releasing the tab leaves a key that resolves and a tab that
|
|
91
|
+
* does not, which is the state the rule refuses.
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* A lease that is **live and holds no tab**: a queue placement.
|
|
95
|
+
*
|
|
96
|
+
* ── Why the queue rather than a released lease ──────────────────────────
|
|
97
|
+
*
|
|
98
|
+
* `tab.owned` is only reachable while the key still resolves. Releasing the
|
|
99
|
+
* lease ends it, so `claim.live` refuses first and the case measures that
|
|
100
|
+
* rule instead — which is correct behaviour and the wrong thing to assert
|
|
101
|
+
* here. §3.14's refusal ordering is a property callers branch on, so a seed
|
|
102
|
+
* that trips an earlier rule is a seed testing the earlier rule.
|
|
103
|
+
*
|
|
104
|
+
* A queued lease is the state that satisfies both halves: §2.5 gives it a key
|
|
105
|
+
* and no tab, because *"a queued lease has no tab"*. The bridge resolves no
|
|
106
|
+
* tab for it and the ownership guard refuses — with `tab_not_found`, the same
|
|
107
|
+
* code an unknown tab gets, because §7.1 requires the two be
|
|
108
|
+
* indistinguishable so probing cannot enumerate another lease's tabs.
|
|
109
|
+
*
|
|
110
|
+
* **Nothing here takes a tab argument**, and could not: no surface offers one
|
|
111
|
+
* (§3.4). That is why this rule is reached by exhausting the budget rather
|
|
112
|
+
* than by naming somebody else's tab — the naming route does not exist.
|
|
113
|
+
*/
|
|
114
|
+
const withAQueuedLease = {
|
|
115
|
+
apply: async (service) => {
|
|
116
|
+
// Claim until one is queued rather than counting to the budget: the
|
|
117
|
+
// budget is configurable (§1.10), so a fixture that assumed a number
|
|
118
|
+
// would break on a build configured differently and would break silently
|
|
119
|
+
// — every claim granted, nothing queued, and the case measuring an
|
|
120
|
+
// active lease.
|
|
121
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
122
|
+
const outcome = await service.perform({
|
|
123
|
+
operation: 'claim',
|
|
124
|
+
adapter: 'conformance',
|
|
125
|
+
arguments: {
|
|
126
|
+
session_id: `conformance-queue-${String(attempt)}`,
|
|
127
|
+
browser: 'regular',
|
|
128
|
+
purpose: 'conformance: filling the budget to reach a queue placement',
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
if (outcome.outcome !== 'accepted') {
|
|
132
|
+
throw new Error(`the seed could not obtain a lease: ${outcome.rule}`);
|
|
133
|
+
}
|
|
134
|
+
if (outcome.value['outcome'] === 'queued') {
|
|
135
|
+
return { lease_key: outcome.value['key'] };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
throw new Error('the seed never reached a queue placement');
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* A live lease that has **already asked for a sign-in**, so the browser is in
|
|
143
|
+
* `signing-in` and this lease is the one that put it there.
|
|
144
|
+
*
|
|
145
|
+
* ── Why the seed asks rather than the case doing it ─────────────────────
|
|
146
|
+
*
|
|
147
|
+
* `sign_in_done` is only reachable against a sign-in that is open, and only
|
|
148
|
+
* by the lease that opened it (§7.1 `signin.finish_owned`). A case cannot
|
|
149
|
+
* express two calls — it names one operation and one input — so the first of
|
|
150
|
+
* the two happens here, which is what a seed is for.
|
|
151
|
+
*
|
|
152
|
+
* **It goes through the service, exactly like every other seed here.** The
|
|
153
|
+
* browser is moved to `signing-in` by a real `sign_in` call rather than by
|
|
154
|
+
* writing the row, so the state this case finishes is a state the product
|
|
155
|
+
* produced. Seeding it by hand would prove the finish path can move a row
|
|
156
|
+
* somebody inserted, which is not the claim.
|
|
157
|
+
*/
|
|
158
|
+
const withARequestedSignIn = {
|
|
159
|
+
apply: async (service) => {
|
|
160
|
+
const granted = await service.perform({
|
|
161
|
+
operation: 'claim',
|
|
162
|
+
adapter: 'conformance',
|
|
163
|
+
arguments: {
|
|
164
|
+
session_id: 'conformance-signin-seed',
|
|
165
|
+
browser: 'regular',
|
|
166
|
+
purpose: 'conformance: a lease that will ask for a sign-in',
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
if (granted.outcome !== 'accepted') {
|
|
170
|
+
throw new Error(`the seed could not obtain a lease: ${granted.rule}`);
|
|
171
|
+
}
|
|
172
|
+
if (granted.value['outcome'] !== 'granted') {
|
|
173
|
+
throw new Error(`the seed obtained a lease that is not live: the service answered ` +
|
|
174
|
+
`'${String(granted.value['outcome'])}' rather than 'granted', so it holds no tab ` +
|
|
175
|
+
`and cannot ask for a sign-in on one.`);
|
|
176
|
+
}
|
|
177
|
+
const key = granted.value['key'];
|
|
178
|
+
const asked = await service.perform({
|
|
179
|
+
operation: 'sign_in',
|
|
180
|
+
adapter: 'conformance',
|
|
181
|
+
arguments: { lease_key: key, what: 'conformance: the account dashboard' },
|
|
182
|
+
});
|
|
183
|
+
if (asked.outcome !== 'accepted') {
|
|
184
|
+
throw new Error(`the seed could not open a sign-in: ${asked.rule}`);
|
|
185
|
+
}
|
|
186
|
+
return { lease_key: key };
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
/** A key that was never issued — for the cases whose subject is `key.valid`. */
|
|
190
|
+
const NOT_A_KEY = 'not-a-key';
|
|
191
|
+
export const CONFORMANCE_CASES = [
|
|
192
|
+
{
|
|
193
|
+
name: 'claim: a well-formed request is granted',
|
|
194
|
+
operation: 'claim',
|
|
195
|
+
input: { session_id: 'session-a', browser: 'regular', purpose: 'conformance: a granted claim' },
|
|
196
|
+
expect: { outcome: 'accepted' },
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'claim: a browser that is not one of the two is refused',
|
|
200
|
+
operation: 'claim',
|
|
201
|
+
// §7.1 `claim.browser_known`. There are exactly two browsers and there is
|
|
202
|
+
// no third (§1.2), so naming one is a refusal on every route or on none.
|
|
203
|
+
input: { session_id: 'session-a', browser: 'third', purpose: 'conformance: unknown browser' },
|
|
204
|
+
expect: { outcome: 'refused', code: 'unknown_browser', rule: 'claim.browser_known' },
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'status: a live lease reports where it stands',
|
|
208
|
+
operation: 'status',
|
|
209
|
+
seed: withALiveLease,
|
|
210
|
+
input: {},
|
|
211
|
+
expect: { outcome: 'accepted' },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'status: a call with no key is refused',
|
|
215
|
+
operation: 'status',
|
|
216
|
+
// §7.1 `key.present`: every operation except requesting a lease carries a
|
|
217
|
+
// key, written out explicitly and never derived from a session (§3.1).
|
|
218
|
+
input: {},
|
|
219
|
+
expect: { outcome: 'refused', code: 'key_missing', rule: 'key.present' },
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: 'release: a lease gives back what it holds',
|
|
223
|
+
operation: 'release',
|
|
224
|
+
seed: withALiveLease,
|
|
225
|
+
input: {},
|
|
226
|
+
expect: { outcome: 'accepted' },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'release: an unrecognised key is refused',
|
|
230
|
+
operation: 'release',
|
|
231
|
+
input: { lease_key: NOT_A_KEY },
|
|
232
|
+
expect: { outcome: 'refused', code: 'unrecognised_key', rule: 'key.valid' },
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'tab replace: a live lease gets a fresh tab',
|
|
236
|
+
operation: 'tab_replace',
|
|
237
|
+
seed: withALiveLease,
|
|
238
|
+
input: {},
|
|
239
|
+
expect: { outcome: 'accepted' },
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'tab replace: an unrecognised key is refused',
|
|
243
|
+
operation: 'tab_replace',
|
|
244
|
+
input: { lease_key: NOT_A_KEY },
|
|
245
|
+
expect: { outcome: 'refused', code: 'unrecognised_key', rule: 'key.valid' },
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'navigate: an ordinary web address is accepted',
|
|
249
|
+
operation: 'navigate',
|
|
250
|
+
seed: withALiveLease,
|
|
251
|
+
input: { url: 'https://example.com/' },
|
|
252
|
+
expect: { outcome: 'accepted' },
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'navigate: a local-file address is refused',
|
|
256
|
+
operation: 'navigate',
|
|
257
|
+
// §7.1 `navigate.scheme_allowed`, and the reason it is refused explicitly
|
|
258
|
+
// rather than merely unsupported: it turns a browser lease into an
|
|
259
|
+
// arbitrary read of the machine's filesystem.
|
|
260
|
+
seed: withALiveLease,
|
|
261
|
+
input: { url: 'file:///etc/passwd' },
|
|
262
|
+
expect: {
|
|
263
|
+
outcome: 'refused',
|
|
264
|
+
code: 'navigate.scheme_allowed',
|
|
265
|
+
rule: 'navigate.scheme_allowed',
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'act: a named verb is performed',
|
|
270
|
+
operation: 'act',
|
|
271
|
+
seed: withALiveLease,
|
|
272
|
+
input: { action: 'click', target: 'the-button' },
|
|
273
|
+
expect: { outcome: 'accepted' },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'act: a verb that is not on the list is refused',
|
|
277
|
+
operation: 'act',
|
|
278
|
+
seed: withALiveLease,
|
|
279
|
+
input: { action: 'teleport', target: 'the-button' },
|
|
280
|
+
// The rule is spelled `act.action_known`, which is what the service
|
|
281
|
+
// actually produces. §8.4 computes coverage from what came back rather
|
|
282
|
+
// than from what a case declared, so a case naming a rule nothing raises
|
|
283
|
+
// fails the run instead of quietly passing.
|
|
284
|
+
expect: { outcome: 'refused', code: 'act.action_known', rule: 'act.action_known' },
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'read: the page snapshot is returned by default',
|
|
288
|
+
operation: 'read',
|
|
289
|
+
seed: withALiveLease,
|
|
290
|
+
input: {},
|
|
291
|
+
expect: { outcome: 'accepted' },
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: 'read: an unrecognised key is refused',
|
|
295
|
+
operation: 'read',
|
|
296
|
+
input: { lease_key: NOT_A_KEY },
|
|
297
|
+
expect: { outcome: 'refused', code: 'unrecognised_key', rule: 'key.valid' },
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'evaluate: an expression within the cap is evaluated',
|
|
301
|
+
operation: 'evaluate',
|
|
302
|
+
seed: withALiveLease,
|
|
303
|
+
input: { expression: '1 + 1' },
|
|
304
|
+
expect: { outcome: 'accepted' },
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'evaluate: an expression over the cap is refused',
|
|
308
|
+
operation: 'evaluate',
|
|
309
|
+
// §7.1 `evaluate.expression_bounded`.
|
|
310
|
+
seed: withALiveLease,
|
|
311
|
+
input: { expression: 'x'.repeat(100_000) },
|
|
312
|
+
expect: {
|
|
313
|
+
outcome: 'refused',
|
|
314
|
+
code: 'evaluate.expression_bounded',
|
|
315
|
+
rule: 'evaluate.expression_bounded',
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'capture: a default-tier picture is taken',
|
|
320
|
+
operation: 'capture',
|
|
321
|
+
seed: withALiveLease,
|
|
322
|
+
input: {},
|
|
323
|
+
expect: { outcome: 'accepted' },
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
name: 'capture: a selector and a full page together are refused',
|
|
327
|
+
operation: 'capture',
|
|
328
|
+
// §7.1 `capture.exclusive_mode`. Note this is a refusal about a malformed
|
|
329
|
+
// argument, never about cost — `capture.never_refused_for_cost` (§7.3) is
|
|
330
|
+
// what keeps the "never a refusal for cost" promise checkable.
|
|
331
|
+
seed: withALiveLease,
|
|
332
|
+
input: { selector: '.thing', full_page: true },
|
|
333
|
+
expect: {
|
|
334
|
+
outcome: 'refused',
|
|
335
|
+
code: 'capture.exclusive_mode',
|
|
336
|
+
rule: 'capture.exclusive_mode',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'claim: a purpose outside its bounds is refused',
|
|
341
|
+
operation: 'claim',
|
|
342
|
+
// §7.1 `claim.purpose_bounded`. The purpose is what an operator reads
|
|
343
|
+
// when deciding whether to revoke a lease (§1.3), so it is bounded at
|
|
344
|
+
// both ends and the refusal is the same on every route.
|
|
345
|
+
input: { session_id: 'session-a', browser: 'regular', purpose: 'x' },
|
|
346
|
+
expect: {
|
|
347
|
+
outcome: 'refused',
|
|
348
|
+
code: 'purpose_out_of_bounds',
|
|
349
|
+
rule: 'claim.purpose_bounded',
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: 'navigate: a lease holding no tab is refused',
|
|
354
|
+
operation: 'navigate',
|
|
355
|
+
// §7.1 `tab.owned`. **The tab is not an argument on any surface** (§3.4),
|
|
356
|
+
// so a route cannot name somebody else's — the bridge resolves the tab
|
|
357
|
+
// from the key. A queued lease is live and holds no tab, which reaches
|
|
358
|
+
// this rule from the direction a caller can actually get to.
|
|
359
|
+
seed: withAQueuedLease,
|
|
360
|
+
input: { url: 'https://example.com/' },
|
|
361
|
+
// `tab_not_found` and not a code of its own: §7.1 requires an unowned
|
|
362
|
+
// tab and an unknown one to be indistinguishable to the caller.
|
|
363
|
+
expect: { outcome: 'refused', code: 'tab_not_found', rule: 'tab.owned' },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'sign in: a live lease holding a tab can ask a person to sign in',
|
|
367
|
+
operation: 'sign_in',
|
|
368
|
+
seed: withALiveLease,
|
|
369
|
+
input: { what: 'conformance: the account dashboard' },
|
|
370
|
+
expect: { outcome: 'accepted' },
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
name: 'sign in: a request that does not say what it is signing into is refused',
|
|
374
|
+
operation: 'sign_in',
|
|
375
|
+
// The one free-text field relayed to a person verbatim by a third party,
|
|
376
|
+
// so an empty one produces a request nobody can act on. Reached with a
|
|
377
|
+
// real lease rather than a bad key, because a bad key would refuse on
|
|
378
|
+
// `key.valid` first and the case would measure that instead (§3.14's
|
|
379
|
+
// ordering is a property callers branch on).
|
|
380
|
+
seed: withALiveLease,
|
|
381
|
+
input: { what: '' },
|
|
382
|
+
expect: {
|
|
383
|
+
outcome: 'refused',
|
|
384
|
+
code: 'sign_in_what_out_of_bounds',
|
|
385
|
+
rule: 'signin.what_bounded',
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: 'sign in done: the lease that asked gives the browser back',
|
|
390
|
+
operation: 'sign_in_done',
|
|
391
|
+
seed: withARequestedSignIn,
|
|
392
|
+
input: {},
|
|
393
|
+
expect: { outcome: 'accepted' },
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'sign in done: an unrecognised key is refused',
|
|
397
|
+
operation: 'sign_in_done',
|
|
398
|
+
input: { lease_key: NOT_A_KEY },
|
|
399
|
+
expect: { outcome: 'refused', code: 'unrecognised_key', rule: 'key.valid' },
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'feedback: a rated report is recorded without a lease',
|
|
403
|
+
operation: 'feedback',
|
|
404
|
+
// §3.16: no lease required, and that is the point rather than a
|
|
405
|
+
// convenience — requiring one would silence exactly the population the
|
|
406
|
+
// tool exists to hear from.
|
|
407
|
+
input: { rating: '4', category: 'worked-well', note: 'conformance: a recorded report' },
|
|
408
|
+
expect: { outcome: 'accepted' },
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
name: 'feedback: a rating outside the scale is refused',
|
|
412
|
+
operation: 'feedback',
|
|
413
|
+
input: { rating: '9', category: 'worked-well', note: 'conformance: a rating off the scale' },
|
|
414
|
+
expect: {
|
|
415
|
+
outcome: 'refused',
|
|
416
|
+
code: 'feedback.rating_in_scale',
|
|
417
|
+
rule: 'feedback.rating_in_scale',
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
];
|
|
421
|
+
/**
|
|
422
|
+
* Every operation named by at least one case.
|
|
423
|
+
*
|
|
424
|
+
* Exported so a test can assert the table covers the operation list by name
|
|
425
|
+
* rather than by counting — `MILESTONES.md` records a hollow test that
|
|
426
|
+
* "iterated a list rather than naming its entries, so deleting an entry
|
|
427
|
+
* stayed green", and a count would have exactly that shape.
|
|
428
|
+
*/
|
|
429
|
+
export const OPERATIONS_WITH_CASES = OPERATION_NAMES.filter((operation) => CONFORMANCE_CASES.some((testCase) => testCase.operation === operation));
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isAdapterId } from "../contract.js";
|
|
4
|
+
/**
|
|
5
|
+
* Find every adapter this tree contains, and report the ones nothing mounts.
|
|
6
|
+
*
|
|
7
|
+
* ── This is the run-time half of "an unregistered adapter fails the suite" ─
|
|
8
|
+
*
|
|
9
|
+
* The typed driver map catches a route added to the registry with no driver.
|
|
10
|
+
* It cannot catch the other direction: a module that implements an adapter
|
|
11
|
+
* and was **never added to the registry at all**. No type can see a file
|
|
12
|
+
* nobody imported, so that half is a walk of the source tree.
|
|
13
|
+
*
|
|
14
|
+
* An adapter module is one that exports an {@link Adapter} — which is
|
|
15
|
+
* recognised here by the declaration that names it, `Adapter =` with the type
|
|
16
|
+
* annotation, rather than by executing anything. **Reading rather than
|
|
17
|
+
* importing is deliberate:** importing every candidate to see what it exports
|
|
18
|
+
* would execute code found on disk, and a walk that runs what it finds is a
|
|
19
|
+
* worse thing to have than the gap it closes.
|
|
20
|
+
*
|
|
21
|
+
* ── What this can and cannot see, so nobody over-trusts it ──────────────
|
|
22
|
+
*
|
|
23
|
+
* It sees a file under {@link ADAPTER_SOURCE_ROOTS} whose **code** declares an
|
|
24
|
+
* `Adapter`. It does **not** see: an adapter written outside those roots, one
|
|
25
|
+
* assembled at run time from something that is not a literal declaration, or
|
|
26
|
+
* one whose type annotation is spelled differently. The roots are asserted by
|
|
27
|
+
* this module's own test, so moving or narrowing them is a visible change to
|
|
28
|
+
* a test rather than a silent loss of coverage — which is the property that
|
|
29
|
+
* makes a walk-based check worth having at all.
|
|
30
|
+
*
|
|
31
|
+
* Comments are blanked before matching, because they are prose about
|
|
32
|
+
* adapters rather than adapters. That is a fix for a real false positive
|
|
33
|
+
* rather than a precaution: this file's own header quotes the shape it
|
|
34
|
+
* matches, and the walk reported that quotation as an unregistered adapter
|
|
35
|
+
* until {@link withoutComments} was added.
|
|
36
|
+
*
|
|
37
|
+
* That is the same shape, and the same honesty, as the hygiene gate's own
|
|
38
|
+
* header: a green run means no *unregistered adapter of the recognised shape*
|
|
39
|
+
* exists, not that no adapter anywhere is unregistered.
|
|
40
|
+
*/
|
|
41
|
+
/** Where an adapter may live. Asserted by this module's test. */
|
|
42
|
+
export const ADAPTER_SOURCE_ROOTS = ['src'];
|
|
43
|
+
/**
|
|
44
|
+
* How an adapter declares itself.
|
|
45
|
+
*
|
|
46
|
+
* Matches a declaration annotated with the contract's own type — for example
|
|
47
|
+
* `export const cliAdapter: Adapter = {`. The annotation is what is matched
|
|
48
|
+
* rather than the file's name or its directory, because a naming convention
|
|
49
|
+
* is a thing somebody can be unaware of and a type annotation is a thing the
|
|
50
|
+
* compiler already made them write.
|
|
51
|
+
*/
|
|
52
|
+
const ADAPTER_DECLARATION = /(?:const|let|var)\s+(\w+)\s*:\s*Adapter\b/gu;
|
|
53
|
+
/**
|
|
54
|
+
* The identifier an adapter module claims, read from its `id` field.
|
|
55
|
+
*
|
|
56
|
+
* Matched on the literal because that is what the registry's keys are
|
|
57
|
+
* compared against. A computed id would not match, and that is a limitation
|
|
58
|
+
* this file states rather than papers over.
|
|
59
|
+
*/
|
|
60
|
+
const ADAPTER_ID_FIELD = /\bid\s*:\s*['"]([\w-]+)['"]/u;
|
|
61
|
+
/** Walk a directory for TypeScript sources, skipping what cannot hold one. */
|
|
62
|
+
function* sourceFiles(directory) {
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const full = path.join(directory, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
yield* sourceFiles(full);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
80
|
+
yield full;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Blank out comments, so a declaration *described* in prose is not mistaken
|
|
86
|
+
* for one.
|
|
87
|
+
*
|
|
88
|
+
* This is not a nicety — it was a real false positive. `discovery.ts`'s own
|
|
89
|
+
* header documents the shape it matches by quoting it, and the walk duly
|
|
90
|
+
* reported its own documentation as an unregistered adapter. Blanking rather
|
|
91
|
+
* than deleting keeps every offset intact, so the id lookup that follows a
|
|
92
|
+
* match still reads the right region of the file.
|
|
93
|
+
*
|
|
94
|
+
* **It is a lexer's job done with a regular expression, and it is approximate
|
|
95
|
+
* in one direction that is worth naming**: a `//` or a comment opener inside a
|
|
96
|
+
* string literal is treated as the start of a comment. The consequence is a
|
|
97
|
+
* declaration *after* such a string on the same line being missed, which
|
|
98
|
+
* would be a false negative. It is accepted because the alternative is
|
|
99
|
+
* parsing TypeScript to run a check, and because the tests below pin both the
|
|
100
|
+
* positive and the negative behaviour rather than only the happy one.
|
|
101
|
+
*/
|
|
102
|
+
function withoutComments(text) {
|
|
103
|
+
const blanked = (match) => match.replaceAll(/[^\n]/gu, ' ');
|
|
104
|
+
return text.replaceAll(/\/\*[\s\S]*?\*\//gu, blanked).replaceAll(/\/\/[^\n]*/gu, blanked);
|
|
105
|
+
}
|
|
106
|
+
/** Every adapter declared anywhere under the roots. */
|
|
107
|
+
export function discoverAdapters(treeRoot) {
|
|
108
|
+
const found = [];
|
|
109
|
+
for (const root of ADAPTER_SOURCE_ROOTS) {
|
|
110
|
+
for (const file of sourceFiles(path.join(treeRoot, root))) {
|
|
111
|
+
const text = withoutComments(fs.readFileSync(file, 'utf8'));
|
|
112
|
+
// The contract itself declares the type; it is not an adapter.
|
|
113
|
+
const relative = path.relative(treeRoot, file).split(path.sep).join('/');
|
|
114
|
+
if (relative === 'src/adapter/contract.ts') {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
ADAPTER_DECLARATION.lastIndex = 0;
|
|
118
|
+
let match = ADAPTER_DECLARATION.exec(text);
|
|
119
|
+
while (match !== null) {
|
|
120
|
+
const declaration = match[1] ?? '<anonymous>';
|
|
121
|
+
// Read the id from the object that follows the declaration.
|
|
122
|
+
const after = text.slice(match.index);
|
|
123
|
+
const idMatch = ADAPTER_ID_FIELD.exec(after);
|
|
124
|
+
found.push({ file: relative, declaration, id: idMatch?.[1] });
|
|
125
|
+
match = ADAPTER_DECLARATION.exec(text);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Every adapter the tree contains that the registry does not mount.
|
|
133
|
+
*
|
|
134
|
+
* **A non-empty result fails the suite.** That is the assertion row #25 is
|
|
135
|
+
* defined by, and it is proved by a test that actually adds an unregistered
|
|
136
|
+
* adapter and watches this return it — not by a comment claiming it would.
|
|
137
|
+
*/
|
|
138
|
+
export function unregisteredAdapters(treeRoot) {
|
|
139
|
+
const unregistered = [];
|
|
140
|
+
for (const adapter of discoverAdapters(treeRoot)) {
|
|
141
|
+
if (adapter.id === undefined) {
|
|
142
|
+
unregistered.push({
|
|
143
|
+
...adapter,
|
|
144
|
+
why: 'its id is not a literal, so nothing can check it against the registry',
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!isAdapterId(adapter.id)) {
|
|
149
|
+
unregistered.push({
|
|
150
|
+
...adapter,
|
|
151
|
+
why: `"${adapter.id}" is not in the registry the application mounts through`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return unregistered;
|
|
156
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|