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.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. 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 {};