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,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 {};