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,143 @@
1
+ /**
2
+ * Decide, under arbitration, what this caller should do about a browser.
3
+ *
4
+ * `runningRecord` is what the caller observed **before** calling — the
5
+ * verified discovery record if a browser is genuinely reachable, or
6
+ * `undefined` if not. It is a parameter rather than something read here for
7
+ * one reason: reading it means talking to a browser, and this runs inside the
8
+ * transaction.
9
+ *
10
+ * That ordering has a consequence worth stating rather than leaving to be
11
+ * discovered: the observation is made outside the lock, so it can be stale by
12
+ * the time the transaction opens. The `starting` state is what covers the gap
13
+ * — a caller whose observation said *nothing is running* still loses the race
14
+ * if another caller has already recorded that it is launching, and the store
15
+ * is what settles that rather than the observation.
16
+ */
17
+ export async function decideAdoption(store, browser, runningRecord) {
18
+ return store.immediate(({ db }) => {
19
+ const row = db
20
+ .prepare('SELECT state, endpoint, browser_uuid FROM browsers WHERE id = ?')
21
+ .get(browser);
22
+ if (row === undefined) {
23
+ // The two rows are created by the first schema step and there is no
24
+ // delete operation on any surface, so this is a store that was not
25
+ // stepped rather than a browser that went missing.
26
+ throw new Error(`The store has no row for the ${browser} browser. The two rows are created by the first schema step, so this store has not been stepped.`);
27
+ }
28
+ // The caller verified a live browser. Record what it found and attach.
29
+ // This also repairs a row that says `stopped` while a browser is in fact
30
+ // running — which is the ordinary state after a process died between
31
+ // launching a browser and recording it.
32
+ if (runningRecord !== undefined) {
33
+ // `pid` is coalesced rather than overwritten, and the constraint is why
34
+ // it cannot simply be left alone: the row's check requires an
35
+ // identifier for every state that is not stopped, and this path moves a
36
+ // row to `running` that may be stopped, holding a null one.
37
+ //
38
+ // **Never overwritten**, because the identifier already there is the
39
+ // browser's and this process does not know it — the browser was adopted,
40
+ // not started here, so there is no handle to read one from. Writing this
41
+ // process's identifier over the browser's would corrupt the one fact
42
+ // §1.2 calls the isolation fact: the service acts on the process
43
+ // recorded here and on nothing else. Falling back to this process's
44
+ // identifier only when there is none at all keeps the row legal while
45
+ // never claiming to know something it does not; the browser's own
46
+ // identifier is restored by whichever caller launches it next.
47
+ db.prepare(`UPDATE browsers
48
+ SET state = 'running',
49
+ pid = COALESCE(pid, ?),
50
+ endpoint = ?,
51
+ browser_uuid = ?,
52
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
53
+ WHERE id = ?`).run(process.pid, runningRecord.endpoint, runningRecord.browserUuid, browser);
54
+ return {
55
+ value: {
56
+ action: 'attach',
57
+ browser,
58
+ endpoint: runningRecord.endpoint,
59
+ browserUuid: runningRecord.browserUuid,
60
+ },
61
+ };
62
+ }
63
+ // Nothing is running, and somebody else has already said they are
64
+ // starting it. One row, one winner — this caller is the loser and waits.
65
+ if (row.state === 'starting') {
66
+ return { value: { action: 'wait', browser } };
67
+ }
68
+ // Nothing is running and nobody has claimed the launch. Claim it here,
69
+ // inside the transaction, so a second caller arriving now sees
70
+ // `starting` and waits instead of launching a second browser.
71
+ //
72
+ // ── Why `pid` is this process and not the browser's ─────────────────
73
+ //
74
+ // The row's check constraint ties a null identifier to the stopped state
75
+ // and requires one for every other state, so `starting` cannot be
76
+ // recorded with a null identifier — and it must be recorded *before* a
77
+ // browser process exists, because claiming the race is the whole point of
78
+ // doing it inside the transaction. The browser's identifier is not a fact
79
+ // yet and writing a placeholder would be a lie the reclamation path
80
+ // branches on.
81
+ //
82
+ // So what is written is the identifier of **the process that is
83
+ // launching**, which is a true statement about the row for the duration
84
+ // it holds: during `starting`, the thing to look at if this browser never
85
+ // comes up is the caller that took the race. {@link recordLaunched}
86
+ // writes the browser's own identifier over it at the moment that becomes
87
+ // a fact, and {@link recordLaunchFailed} clears it back to null along
88
+ // with the state.
89
+ db.prepare(`UPDATE browsers
90
+ SET state = 'starting',
91
+ pid = ?,
92
+ endpoint = NULL,
93
+ browser_uuid = NULL,
94
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
95
+ WHERE id = ?`).run(process.pid, browser);
96
+ return { value: { action: 'launch', browser } };
97
+ });
98
+ }
99
+ /**
100
+ * Record a browser this caller successfully started.
101
+ *
102
+ * Separate from {@link decideAdoption} and called **after** the launch,
103
+ * because the launch is browser work and browser work does not happen inside
104
+ * the arbitration transaction. The two calls are two transactions by design,
105
+ * and the window between them is exactly what the `starting` state exists to
106
+ * describe. This writes the browser's own identifier over the placeholder the
107
+ * race left, at the moment it becomes a fact.
108
+ */
109
+ export async function recordLaunched(store, browser, launched) {
110
+ await store.immediate(({ db }) => {
111
+ db.prepare(`UPDATE browsers
112
+ SET state = 'running',
113
+ pid = ?,
114
+ endpoint = ?,
115
+ browser_uuid = ?,
116
+ launched_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
117
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
118
+ WHERE id = ?`).run(launched.pid, launched.endpoint, launched.browserUuid, browser);
119
+ return { value: undefined };
120
+ });
121
+ }
122
+ /**
123
+ * Record that a launch this caller won did not produce a browser.
124
+ *
125
+ * Without this, a caller that wins the race and then fails leaves the row at
126
+ * `starting` forever, and **every later caller waits for a launch that is
127
+ * never coming**. Releasing the race is therefore part of failing it, not
128
+ * cleanup that can be skipped.
129
+ */
130
+ export async function recordLaunchFailed(store, browser) {
131
+ await store.immediate(({ db }) => {
132
+ db.prepare(`UPDATE browsers
133
+ SET state = 'stopped',
134
+ pid = NULL,
135
+ endpoint = NULL,
136
+ browser_uuid = NULL,
137
+ launched_at = NULL,
138
+ restart_count = restart_count + 1,
139
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
140
+ WHERE id = ?`).run(browser);
141
+ return { value: undefined };
142
+ });
143
+ }
@@ -0,0 +1,113 @@
1
+ // This is the browser module reaching the automation library for a doctor
2
+ // probe — the one exception `driver.import_isolated` (`SCHEMA.md` §7.3)
3
+ // already carves out is "only the browser module reaches the automation
4
+ // library", and this file is that module doing so for a read rather than a
5
+ // launch. It never opens a browser: it only asks the library where the
6
+ // binary it would launch is supposed to be, and checks that path exists.
7
+ import fs from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { chromium } from 'playwright-core';
10
+ const require = createRequire(import.meta.url);
11
+ /**
12
+ * `playwright-core`'s own version, read from its installed `package.json`
13
+ * rather than duplicated as a string constant here — a pin in `package.json`
14
+ * and a constant beside this code are two places that could disagree, and
15
+ * `node_modules` is the one that is actually running. Resolved once at
16
+ * module load: it cannot change without a fresh install, which this process
17
+ * would not observe anyway.
18
+ *
19
+ * Exported so a test can assert this specifically, independent of whether a
20
+ * browser binary has been fetched: resolving the *library's own* version
21
+ * from its `package.json` cannot depend on that, but reaching this value
22
+ * through {@link resolveAutomationProbe}'s `present: true` branch would —
23
+ * that branch requires `pathExists` to hold, which is exactly the thing a
24
+ * CI runner with no Chromium fetched cannot provide.
25
+ */
26
+ export function resolvePlaywrightCoreVersion() {
27
+ try {
28
+ const manifestPath = require.resolve('playwright-core/package.json');
29
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
30
+ const version = manifest.version;
31
+ return typeof version === 'string' ? version : undefined;
32
+ }
33
+ catch {
34
+ // The version is informational only — a failure to resolve it should not
35
+ // turn "is the automation tool present" into a thrown error.
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Exported so a test can assert `libraryVersion` is actually wired into the
41
+ * dependency object the default resolution uses — not just that
42
+ * {@link resolvePlaywrightCoreVersion} works in isolation, which would not
43
+ * catch the two being disconnected again. Asserting through this constant
44
+ * needs no browser binary fetched, unlike asserting through
45
+ * {@link resolveAutomationProbe}'s `present: true` branch, which does.
46
+ */
47
+ export const REAL_DEPENDENCIES = {
48
+ resolveExecutablePath: () => chromium.executablePath(),
49
+ pathExists: (candidate) => fs.existsSync(candidate),
50
+ libraryVersion: resolvePlaywrightCoreVersion(),
51
+ };
52
+ /**
53
+ * Resolve the doctor's automation probe for real.
54
+ *
55
+ * **This does not launch anything.** `chromium.executablePath()` only
56
+ * computes where the automation library expects its browser binary to sit —
57
+ * it neither downloads one nor verifies one is there, which is why the
58
+ * existence check is a second, separate step here rather than trusted as
59
+ * part of the first. A computed path that resolves to nothing is exactly the
60
+ * state a fresh machine following `docs/ROLLOUT.md` without ever having
61
+ * installed a browser binary would be in, and that state must report
62
+ * `present: false` — not throw, and not silently read as `unknown`.
63
+ *
64
+ * `resolveExecutablePath()` itself is not expected to throw on the versions
65
+ * this build pins, but a caller asking "is the automation tool present" is
66
+ * the last place that question should go unanswered because of an
67
+ * unexpected library error, so a thrown error is caught and reported as
68
+ * `present: false` with the error's message as the detail — rather than
69
+ * propagating and turning `broker doctor` itself into the failure.
70
+ */
71
+ export function resolveAutomationProbe(dependencies = REAL_DEPENDENCIES) {
72
+ // `BROKER_DOCTOR_AUTOMATION_OVERRIDE` is **not** a documented configuration
73
+ // variable — it is not in `Environment` (`src/config/environment.ts`) or
74
+ // `.env.example`, and no operator should ever set it. It exists only so
75
+ // that a test spawning the real `broker` binary as a child process — with
76
+ // no seam to inject `AutomationProbeDependencies` through — can still pin
77
+ // this probe's answer deterministically, on a machine with or without a
78
+ // browser binary fetched. `present` and (optionally) `detail` come from the
79
+ // override, verbatim; unset, this branch is not taken and the real
80
+ // resolution below runs exactly as it always has.
81
+ const override = process.env.BROKER_DOCTOR_AUTOMATION_OVERRIDE;
82
+ if (override === 'present') {
83
+ return {
84
+ present: true,
85
+ version: dependencies.libraryVersion,
86
+ detail: 'Forced present for a test.',
87
+ };
88
+ }
89
+ if (override === 'absent') {
90
+ return { present: false, detail: 'Forced absent for a test.' };
91
+ }
92
+ let executablePath;
93
+ try {
94
+ executablePath = dependencies.resolveExecutablePath();
95
+ }
96
+ catch (error) {
97
+ return {
98
+ present: false,
99
+ detail: `Could not resolve where the automation tool's browser binary should be: ${error instanceof Error ? error.message : String(error)}`,
100
+ };
101
+ }
102
+ if (!dependencies.pathExists(executablePath)) {
103
+ return {
104
+ present: false,
105
+ detail: `No browser binary at the path the automation tool resolved (${executablePath}). This build depends on playwright-core, which does not download a browser on install.`,
106
+ };
107
+ }
108
+ return {
109
+ present: true,
110
+ version: dependencies.libraryVersion,
111
+ detail: `Present at ${executablePath}.`,
112
+ };
113
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,192 @@
1
+ import { ARTIFACT_COLLECTION, READ_ARTIFACTS } from "../driver.js";
2
+ /**
3
+ * The properties both driver implementations must satisfy, each argued.
4
+ *
5
+ * ── The selection rule, restated because it is the deliverable ───────────
6
+ *
7
+ * `case.ts` states it: a property belongs here when **the service reasons
8
+ * about it** — when a path in `src/` derives a decision from it, so two
9
+ * implementations disagreeing means the service decides differently against
10
+ * one than against the other. Everything below names that path.
11
+ *
12
+ * What that rule **excludes** is as much the point as what it admits, so the
13
+ * rejected candidates are recorded at the bottom of this file rather than
14
+ * silently omitted. A reader who wonders "why is navigation not pinned here"
15
+ * deserves the answer without having to reconstruct it.
16
+ *
17
+ * ── The keeper tab dominates this list, and that is not an accident ──────
18
+ *
19
+ * Four of the six properties are about the keeper. The reason is structural
20
+ * rather than a failure of imagination: the keeper is **the one object on
21
+ * this seam that the service must reason about without being able to address
22
+ * it**. Every other page is reached through a handle a caller holds, so a
23
+ * disagreement about it surfaces as an operation that fails. The keeper is
24
+ * reached through no handle at all — its whole specification is a set of
25
+ * *absences* (`SCHEMA.md` §3.15: never leased, never addressable, never
26
+ * counted), and an absence is precisely what a fixture can agree with while
27
+ * being wrong.
28
+ *
29
+ * That is why the original defect was a keeper defect, and why the properties
30
+ * that keep it from recurring are keeper properties.
31
+ */
32
+ /** A discovery record shaped like one read off disk and then verified. */
33
+ const RECORD = { endpoint: 'http://127.0.0.1:0', browserUuid: 'conformance' };
34
+ export { RECORD as CONFORMANCE_RECORD };
35
+ export const SEAM_PROPERTIES = [
36
+ {
37
+ name: 'the keeper tab is absent from listTabs',
38
+ rule: 'keeper.never_leased',
39
+ why: 'Reconciliation closes every listed page no live lease owns, and the keeper is owned by no lease by construction — so an implementation that lists it makes the destructive behaviour and the correct behaviour agree, and closing the keeper kills the shared signed-in browser (SCHEMA.md §3.15).',
40
+ check: async ({ session }) => {
41
+ const keeper = await session.ensureKeeperTab();
42
+ const listed = await session.listTabs();
43
+ // Named, not counted. A count would pass against an implementation that
44
+ // listed the keeper and omitted some *other* page — which is a second
45
+ // bug wearing this one's clothes, and reconciliation would then close a
46
+ // page a live lease owns.
47
+ if (listed.some((tab) => tab.driverTabId === keeper.driverTabId)) {
48
+ return `listTabs returned the keeper tab (${keeper.driverTabId}), so reconciliation would treat it as an unowned page and close it`;
49
+ }
50
+ return undefined;
51
+ },
52
+ },
53
+ {
54
+ name: 'a leased tab IS in listTabs, so the exclusion is not simply an empty answer',
55
+ rule: 'keeper.never_leased',
56
+ why: 'The property above is satisfied by an implementation whose listTabs always answers nothing — an assertion over an empty set passes forever and silently. Reconciliation reads this list to decide what exists, so an always-empty answer would make every recorded tab look vanished and close every live lease.',
57
+ check: async ({ session }) => {
58
+ await session.ensureKeeperTab();
59
+ const leased = await session.openTab();
60
+ const listed = await session.listTabs();
61
+ if (!listed.some((tab) => tab.driverTabId === leased.driverTabId)) {
62
+ return `listTabs did not return a tab this session had just opened (${leased.driverTabId}), so the list is not a live reading of what the browser has open`;
63
+ }
64
+ return undefined;
65
+ },
66
+ },
67
+ {
68
+ name: 'the keeper tab survives being named to closeTab',
69
+ rule: 'keeper.never_leased',
70
+ why: 'SCHEMA.md §3.15 and §7.3: the keeper is never addressable, and a caller cannot close what it cannot name. The handle ensureKeeperTab returns exists so the service can assert the tab is present, not so anything can drive it — an implementation on which that handle closes the keeper turns a lever the service holds for a safety check into the exact destructive act it is checking against.',
71
+ check: async ({ session }) => {
72
+ const keeper = await session.ensureKeeperTab();
73
+ // Closing is best effort by design (§2.4b), so this must not reject —
74
+ // and it must not do anything either. Both halves matter: a rejection
75
+ // would be a driver reporting a failure the service is specified to
76
+ // ignore, and a close would be the keeper gone.
77
+ await session.closeTab(keeper);
78
+ // ── What is asserted, and why it is identity rather than presence ────
79
+ //
80
+ // The keeper is **the one page on this seam with no observable except
81
+ // its own identity**: it is excluded from `listTabs` by the first
82
+ // property in this file, so "is it still open" cannot be asked through
83
+ // the seam at all. What *can* be asked is whether the tab the session
84
+ // now calls its keeper is the same one it called its keeper before —
85
+ // and because `ensureKeeperTab` is idempotent (the property below),
86
+ // a **changed** identifier means the original was destroyed and
87
+ // replaced. On a headed browser there would have been no second chance
88
+ // to replace it; the browser would already be gone.
89
+ //
90
+ // An earlier draft of this property asked whether the re-established
91
+ // keeper appeared in `listTabs`, and it was wrong in a way worth
92
+ // recording: an implementation that destroys the keeper and hands back
93
+ // a fresh handle keeps it out of the list too, so the check passed
94
+ // against the very mutation it was written for. It was caught by the
95
+ // control asserting its own mutation had applied.
96
+ const after = await session.ensureKeeperTab();
97
+ if (after.driverTabId !== keeper.driverTabId) {
98
+ return `closing the keeper destroyed it: the session called it ${keeper.driverTabId} before the close and ${after.driverTabId} after, so a caller holding that handle can end the shared browser`;
99
+ }
100
+ return undefined;
101
+ },
102
+ },
103
+ {
104
+ name: 'establishing the keeper twice yields one keeper, not two',
105
+ rule: 'keeper.present',
106
+ why: 'It is a spawn-time precondition checked on every spawn (SCHEMA.md §7.2), and this service is spawned per caller — so a non-idempotent implementation accumulates one uncounted page per spawn. Those pages are excluded from listTabs by the property above, which means reconciliation cannot see them either: the leak is invisible to the one mechanism built to find leaks.',
107
+ check: async ({ session }) => {
108
+ const first = await session.ensureKeeperTab();
109
+ const second = await session.ensureKeeperTab();
110
+ if (first.driverTabId !== second.driverTabId) {
111
+ return `two calls to ensureKeeperTab reported different tabs (${first.driverTabId} then ${second.driverTabId}), so each spawn establishes another keeper`;
112
+ }
113
+ return undefined;
114
+ },
115
+ },
116
+ {
117
+ name: 'a tab handle names the browser it belongs to',
118
+ rule: 'claim.browser_known',
119
+ why: 'Capacity is one total across both browsers (DECISIONS.md §6) and the store indexes a physical tab by (browser_id, driver_tab_id) — so a handle carrying the wrong browser is a row written against the wrong browser, and reconciliation then compares one browser’s pages against the other’s records.',
120
+ check: async ({ session }) => {
121
+ const described = session.describe();
122
+ const leased = await session.openTab();
123
+ const keeper = await session.ensureKeeperTab();
124
+ for (const [label, handle] of [
125
+ ['openTab', leased],
126
+ ['ensureKeeperTab', keeper],
127
+ ]) {
128
+ if (handle.browser !== described.browser) {
129
+ return `${label} returned a handle naming the ${handle.browser} browser from a session describing itself as ${described.browser}`;
130
+ }
131
+ }
132
+ for (const tab of await session.listTabs()) {
133
+ if (tab.browser !== described.browser) {
134
+ return `listTabs returned a handle naming the ${tab.browser} browser from a session describing itself as ${described.browser}`;
135
+ }
136
+ }
137
+ return undefined;
138
+ },
139
+ },
140
+ {
141
+ name: 'a cookie summary carries no value, on any implementation',
142
+ rule: 'read.cookies_no_values',
143
+ why: 'SCHEMA.md §7.1 makes this a shape rather than a refusal: the value field is absent, not masked. The service writes this structure straight to a file (real.ts read()), with no redaction step in between — so an implementation that added a value field would put cookie values on disk with no line of code anywhere saying so.',
144
+ check: async ({ session }) => {
145
+ const tab = await session.openTab();
146
+ const summaries = await session.cookies(tab);
147
+ // Asserted over the keys actually present rather than by reading a
148
+ // named field: `CookieSummary` has no `value`, so a test reading
149
+ // `summary.value` would not compile and could never fail. The runtime
150
+ // question — did this implementation put one there anyway — is only
151
+ // answerable by looking at what the object has.
152
+ for (const summary of summaries) {
153
+ const forbidden = Object.keys(summary).filter((key) => ['value', 'values', 'cookievalue'].includes(key.toLowerCase().replace(/[_-]/gu, '')));
154
+ if (forbidden.length > 0) {
155
+ return `a cookie summary carried ${forbidden.join(', ')}, and the service writes this structure to disk verbatim`;
156
+ }
157
+ }
158
+ return undefined;
159
+ },
160
+ },
161
+ ];
162
+ /**
163
+ * Properties that were considered and **deliberately left out**, with the
164
+ * reason each was rejected.
165
+ *
166
+ * ── Why the rejections are written down ─────────────────────────────────
167
+ *
168
+ * A list of what a suite covers invites the question of what it does not, and
169
+ * a reader who cannot find the answer assumes an omission is an oversight.
170
+ * Worse, the next person to extend this file re-derives the same candidates
171
+ * and adds the mechanical ones, because nothing recorded that they were
172
+ * weighed. This is the argument, kept next to the thing it is about.
173
+ *
174
+ * | Candidate | Why it is not here |
175
+ * |---|---|
176
+ * | **Navigation returns the post-redirect address** | The fake's navigation always arrives and never redirects. Pinning it would assert the fake's canned answer, not an agreement — real redirect behaviour needs a real server and belongs to the row that owns navigation. |
177
+ * | **`act` returns a fresh snapshot** | Both do, but the fake's path names no file on disk. The property the service reasons about is *that a snapshot came back*, which is type-level and already structural; *what is in it* is not comparable between the two by construction. |
178
+ * | **`settlePage` precedes `capture`** | This is an ordering the **pipeline** performs, not a property of either implementation — the seam deliberately splits the two calls so the ordering is the caller's. It is asserted against the fake's call log where sequences are observable, which is the right place for it. |
179
+ * | **Tab identifier spelling** | Nothing derives a decision from the format. Real uses the browser's own target id; the fake counts. Pinning it would only make the fake harder to change. |
180
+ * | **`detach` leaves tabs open** | Genuinely load-bearing (§1.2a is the measurement the shared-session design rests on) — but not checkable through the seam: after `detach` the real session's connection is closed, so there is no way to ask it what is open without attaching afresh, which needs a driver rather than a session. It stays a fake-log assertion in `fake.test.ts` and a measured claim in `real.ts`. |
181
+ * | **`openTab` establishes the keeper first** | **A real divergence, found by measuring: `real.ts` awaits `ensureKeeperTab()` as the first line of `openTab`; the fake does not.** It is left out because it is not observable through the seam — `listTabs` excludes the keeper by the first property above, so no sequence of seam calls distinguishes an implementation that established one from an implementation that did not. It is reported in the pull request rather than pinned here, because a property this file cannot check must not appear to be one it does. |
182
+ */
183
+ export const REJECTED_PROPERTY_COUNT = 6;
184
+ /**
185
+ * Every artefact whose collection model the service depends on.
186
+ *
187
+ * Exported for the suite's own completeness assertion rather than used here:
188
+ * `ARTIFACT_COLLECTION` decides whether asking for an artefact costs
189
+ * anything, and a member added to `ReadArtifact` without an entry there would
190
+ * make that question unanswerable for the new one.
191
+ */
192
+ export const ARTIFACTS_WITH_A_COLLECTION_MODEL = READ_ARTIFACTS.filter((artifact) => ARTIFACT_COLLECTION[artifact] !== undefined);
@@ -0,0 +1,102 @@
1
+ /** Roughly a sentence. A justification shorter than this is not an argument. */
2
+ const JUSTIFICATION_MINIMUM_WORDS = 12;
3
+ function wordCount(text) {
4
+ const trimmed = text.trim();
5
+ if (trimmed === '') {
6
+ return 0;
7
+ }
8
+ return trimmed.split(/\s+/u).length;
9
+ }
10
+ /**
11
+ * Run every property against every available implementation.
12
+ *
13
+ * Each property gets **its own session**, freshly opened and disposed. That is
14
+ * deliberate and it costs a browser page per property on the real subject:
15
+ * these properties open tabs and establish keepers, so a shared session would
16
+ * let one property's leftovers decide another's answer, and the order they
17
+ * happened to run in would become part of the specification.
18
+ */
19
+ export async function runSeamConformance(options) {
20
+ const findings = [];
21
+ const skipped = [];
22
+ const subjectsRun = [];
23
+ const browser = options.browser ?? 'private';
24
+ let pairsRun = 0;
25
+ // An assertion evaluated over an empty set passes forever and silently, so
26
+ // the set is checked directly rather than only iterated.
27
+ if (options.properties.length === 0) {
28
+ findings.push({
29
+ kind: 'property-table-empty',
30
+ detail: 'the property table is empty, so every per-property assertion would pass vacuously',
31
+ });
32
+ }
33
+ const seen = new Set();
34
+ for (const property of options.properties) {
35
+ if (seen.has(property.name)) {
36
+ findings.push({
37
+ kind: 'property-name-duplicated',
38
+ property: property.name,
39
+ detail: `two properties are named "${property.name}", so a finding cannot be attributed to one of them`,
40
+ });
41
+ }
42
+ seen.add(property.name);
43
+ // The bar `cases.ts` sets is that every property is argued. An argument
44
+ // nobody wrote down is one nobody can check, and the mechanical entries
45
+ // this suite is meant to exclude are exactly the ones with nothing to say.
46
+ if (wordCount(property.why) < JUSTIFICATION_MINIMUM_WORDS) {
47
+ findings.push({
48
+ kind: 'property-unjustified',
49
+ property: property.name,
50
+ rule: property.rule,
51
+ detail: `"${property.name}" gives no argument for why the service reasons about it`,
52
+ });
53
+ }
54
+ }
55
+ for (const subject of options.subjects) {
56
+ const reason = subject.unavailable();
57
+ if (reason !== undefined) {
58
+ skipped.push({ subject: subject.name, reason });
59
+ continue;
60
+ }
61
+ subjectsRun.push(subject.name);
62
+ for (const property of options.properties) {
63
+ const opened = await subject.open(browser);
64
+ try {
65
+ const complaint = await property.check({ session: opened.session });
66
+ pairsRun += 1;
67
+ if (complaint !== undefined) {
68
+ findings.push({
69
+ kind: 'property-violated',
70
+ subject: subject.name,
71
+ property: property.name,
72
+ rule: property.rule,
73
+ detail: complaint,
74
+ });
75
+ }
76
+ }
77
+ catch (error) {
78
+ pairsRun += 1;
79
+ findings.push({
80
+ kind: 'property-threw',
81
+ subject: subject.name,
82
+ property: property.name,
83
+ rule: property.rule,
84
+ detail: `the property threw rather than reporting: ${error instanceof Error ? error.message : String(error)}`,
85
+ });
86
+ }
87
+ finally {
88
+ await opened.dispose();
89
+ }
90
+ }
91
+ }
92
+ // Every subject skipping is not the same as every subject passing, and a
93
+ // report that did not say so would be the exact defect this suite exists
94
+ // to close — a green result standing in for an unrun one.
95
+ if (subjectsRun.length === 0) {
96
+ findings.push({
97
+ kind: 'no-subject-ran',
98
+ detail: `no implementation of the seam could be exercised here, so this run proves nothing: ${skipped.map((entry) => `${entry.subject} (${entry.reason})`).join('; ') || 'none offered'}`,
99
+ });
100
+ }
101
+ return { findings, pairsRun, skipped, subjectsRun };
102
+ }
@@ -0,0 +1,19 @@
1
+ import { FakeBrowserDriver } from "../fake.js";
2
+ import { CONFORMANCE_RECORD } from "./cases.js";
3
+ /**
4
+ * The fake, which runs everywhere.
5
+ *
6
+ * A fresh driver per session rather than one shared across properties: the
7
+ * properties open tabs and establish keepers, so a shared driver would let
8
+ * one property's leftovers decide another's answer — and the two would then
9
+ * be being asked different questions depending on the order they ran in.
10
+ */
11
+ export const fakeSubject = {
12
+ name: 'fake',
13
+ unavailable: () => undefined,
14
+ open: async (browser) => {
15
+ const driver = new FakeBrowserDriver();
16
+ const session = await driver.attach(browser, CONFORMANCE_RECORD);
17
+ return { session, dispose: () => Promise.resolve() };
18
+ },
19
+ };