@descryy/mcp 0.9.1 → 0.11.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 (206) hide show
  1. package/dist/browser/auth.d.ts +76 -0
  2. package/dist/browser/auth.d.ts.map +1 -0
  3. package/dist/browser/auth.js +223 -0
  4. package/dist/browser/auth.js.map +1 -0
  5. package/dist/browser/capability-probe.d.ts +109 -0
  6. package/dist/browser/capability-probe.d.ts.map +1 -0
  7. package/dist/browser/capability-probe.js +201 -0
  8. package/dist/browser/capability-probe.js.map +1 -0
  9. package/dist/browser/driver.d.ts +342 -4
  10. package/dist/browser/driver.d.ts.map +1 -1
  11. package/dist/browser/driver.js +79 -1
  12. package/dist/browser/driver.js.map +1 -1
  13. package/dist/browser/evidence.d.ts +3 -0
  14. package/dist/browser/evidence.d.ts.map +1 -1
  15. package/dist/browser/evidence.js +6 -1
  16. package/dist/browser/evidence.js.map +1 -1
  17. package/dist/browser/fake-driver.d.ts +65 -8
  18. package/dist/browser/fake-driver.d.ts.map +1 -1
  19. package/dist/browser/fake-driver.js +235 -8
  20. package/dist/browser/fake-driver.js.map +1 -1
  21. package/dist/browser/fault-attribution.d.ts +178 -0
  22. package/dist/browser/fault-attribution.d.ts.map +1 -0
  23. package/dist/browser/fault-attribution.js +266 -0
  24. package/dist/browser/fault-attribution.js.map +1 -0
  25. package/dist/browser/identity-graph-write.d.ts +55 -0
  26. package/dist/browser/identity-graph-write.d.ts.map +1 -0
  27. package/dist/browser/identity-graph-write.js +45 -0
  28. package/dist/browser/identity-graph-write.js.map +1 -0
  29. package/dist/browser/page-probe.d.ts +81 -0
  30. package/dist/browser/page-probe.d.ts.map +1 -0
  31. package/dist/browser/page-probe.js +202 -0
  32. package/dist/browser/page-probe.js.map +1 -0
  33. package/dist/browser/playwright-driver.d.ts +79 -0
  34. package/dist/browser/playwright-driver.d.ts.map +1 -1
  35. package/dist/browser/playwright-driver.js +1138 -180
  36. package/dist/browser/playwright-driver.js.map +1 -1
  37. package/dist/browser/reach-recording-session.d.ts +44 -0
  38. package/dist/browser/reach-recording-session.d.ts.map +1 -0
  39. package/dist/browser/reach-recording-session.js +143 -0
  40. package/dist/browser/reach-recording-session.js.map +1 -0
  41. package/dist/browser/reachability.d.ts +168 -0
  42. package/dist/browser/reachability.d.ts.map +1 -0
  43. package/dist/browser/reachability.js +294 -0
  44. package/dist/browser/reachability.js.map +1 -0
  45. package/dist/browser/registry.d.ts +24 -0
  46. package/dist/browser/registry.d.ts.map +1 -1
  47. package/dist/browser/registry.js +21 -2
  48. package/dist/browser/registry.js.map +1 -1
  49. package/dist/browser/scenario-provenance.d.ts +1 -1
  50. package/dist/browser/scenario-provenance.d.ts.map +1 -1
  51. package/dist/browser/scenario-provenance.js +15 -1
  52. package/dist/browser/scenario-provenance.js.map +1 -1
  53. package/dist/browser/scenario-runner.d.ts +10 -2
  54. package/dist/browser/scenario-runner.d.ts.map +1 -1
  55. package/dist/browser/scenario-runner.js +77 -9
  56. package/dist/browser/scenario-runner.js.map +1 -1
  57. package/dist/browser/tool-support.d.ts +5 -1
  58. package/dist/browser/tool-support.d.ts.map +1 -1
  59. package/dist/browser/tool-support.js +80 -4
  60. package/dist/browser/tool-support.js.map +1 -1
  61. package/dist/browser/wait-target.d.ts +37 -0
  62. package/dist/browser/wait-target.d.ts.map +1 -0
  63. package/dist/browser/wait-target.js +54 -0
  64. package/dist/browser/wait-target.js.map +1 -0
  65. package/dist/heap.d.ts +61 -0
  66. package/dist/heap.d.ts.map +1 -0
  67. package/dist/heap.js +82 -0
  68. package/dist/heap.js.map +1 -0
  69. package/dist/heartbeat.d.ts +46 -0
  70. package/dist/heartbeat.d.ts.map +1 -0
  71. package/dist/heartbeat.js +66 -0
  72. package/dist/heartbeat.js.map +1 -0
  73. package/dist/index.d.ts +2 -2
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/planner/predict-then-propose.d.ts +101 -0
  78. package/dist/planner/predict-then-propose.d.ts.map +1 -0
  79. package/dist/planner/predict-then-propose.js +88 -0
  80. package/dist/planner/predict-then-propose.js.map +1 -0
  81. package/dist/planner/propose-journey.d.ts +60 -0
  82. package/dist/planner/propose-journey.d.ts.map +1 -0
  83. package/dist/planner/propose-journey.js +96 -0
  84. package/dist/planner/propose-journey.js.map +1 -0
  85. package/dist/registry.d.ts +10 -1
  86. package/dist/registry.d.ts.map +1 -1
  87. package/dist/registry.js +5 -1
  88. package/dist/registry.js.map +1 -1
  89. package/dist/render.d.ts +4 -4
  90. package/dist/render.js +3 -3
  91. package/dist/scenarios/browser-run-plan-projection.d.ts +143 -0
  92. package/dist/scenarios/browser-run-plan-projection.d.ts.map +1 -0
  93. package/dist/scenarios/browser-run-plan-projection.js +228 -0
  94. package/dist/scenarios/browser-run-plan-projection.js.map +1 -0
  95. package/dist/scenarios/credential-ref.d.ts +102 -0
  96. package/dist/scenarios/credential-ref.d.ts.map +1 -0
  97. package/dist/scenarios/credential-ref.js +148 -0
  98. package/dist/scenarios/credential-ref.js.map +1 -0
  99. package/dist/scenarios/index.d.ts +4 -1
  100. package/dist/scenarios/index.d.ts.map +1 -1
  101. package/dist/scenarios/index.js +4 -1
  102. package/dist/scenarios/index.js.map +1 -1
  103. package/dist/scenarios/parse.d.ts.map +1 -1
  104. package/dist/scenarios/parse.js +42 -9
  105. package/dist/scenarios/parse.js.map +1 -1
  106. package/dist/scenarios/scenario.d.ts +39 -5
  107. package/dist/scenarios/scenario.d.ts.map +1 -1
  108. package/dist/scenarios/scenario.js +15 -2
  109. package/dist/scenarios/scenario.js.map +1 -1
  110. package/dist/scenarios/secret-ref.d.ts +86 -0
  111. package/dist/scenarios/secret-ref.d.ts.map +1 -0
  112. package/dist/scenarios/secret-ref.js +125 -0
  113. package/dist/scenarios/secret-ref.js.map +1 -0
  114. package/dist/server.d.ts +1 -1
  115. package/dist/server.d.ts.map +1 -1
  116. package/dist/server.js +8 -4
  117. package/dist/server.js.map +1 -1
  118. package/dist/session.d.ts +19 -0
  119. package/dist/session.d.ts.map +1 -1
  120. package/dist/session.js +36 -0
  121. package/dist/session.js.map +1 -1
  122. package/dist/tools/analyze-workspace.d.ts +52 -0
  123. package/dist/tools/analyze-workspace.d.ts.map +1 -0
  124. package/dist/tools/analyze-workspace.js +207 -0
  125. package/dist/tools/analyze-workspace.js.map +1 -0
  126. package/dist/tools/analyze.d.ts +50 -4
  127. package/dist/tools/analyze.d.ts.map +1 -1
  128. package/dist/tools/analyze.js +225 -5
  129. package/dist/tools/analyze.js.map +1 -1
  130. package/dist/tools/browser-close-session.d.ts +21 -1
  131. package/dist/tools/browser-close-session.d.ts.map +1 -1
  132. package/dist/tools/browser-close-session.js +71 -7
  133. package/dist/tools/browser-close-session.js.map +1 -1
  134. package/dist/tools/browser-navigate.d.ts +1 -1
  135. package/dist/tools/browser-navigate.d.ts.map +1 -1
  136. package/dist/tools/browser-navigate.js +5 -3
  137. package/dist/tools/browser-navigate.js.map +1 -1
  138. package/dist/tools/browser-run-scenario.d.ts.map +1 -1
  139. package/dist/tools/browser-run-scenario.js +6 -1
  140. package/dist/tools/browser-run-scenario.js.map +1 -1
  141. package/dist/tools/browser-save-scenario.d.ts.map +1 -1
  142. package/dist/tools/browser-save-scenario.js +84 -4
  143. package/dist/tools/browser-save-scenario.js.map +1 -1
  144. package/dist/tools/browser-select.d.ts +20 -0
  145. package/dist/tools/browser-select.d.ts.map +1 -0
  146. package/dist/tools/browser-select.js +120 -0
  147. package/dist/tools/browser-select.js.map +1 -0
  148. package/dist/tools/browser-snapshot.d.ts +17 -1
  149. package/dist/tools/browser-snapshot.d.ts.map +1 -1
  150. package/dist/tools/browser-snapshot.js +18 -5
  151. package/dist/tools/browser-snapshot.js.map +1 -1
  152. package/dist/tools/browser-start-session.d.ts.map +1 -1
  153. package/dist/tools/browser-start-session.js +161 -9
  154. package/dist/tools/browser-start-session.js.map +1 -1
  155. package/dist/tools/browser-submit.d.ts +18 -0
  156. package/dist/tools/browser-submit.d.ts.map +1 -0
  157. package/dist/tools/browser-submit.js +106 -0
  158. package/dist/tools/browser-submit.js.map +1 -0
  159. package/dist/tools/browser-wait-for.d.ts +41 -0
  160. package/dist/tools/browser-wait-for.d.ts.map +1 -0
  161. package/dist/tools/browser-wait-for.js +148 -0
  162. package/dist/tools/browser-wait-for.js.map +1 -0
  163. package/dist/tools/chain.d.ts +41 -0
  164. package/dist/tools/chain.d.ts.map +1 -0
  165. package/dist/tools/chain.js +105 -0
  166. package/dist/tools/chain.js.map +1 -0
  167. package/dist/tools/draw-conclusion.d.ts +44 -0
  168. package/dist/tools/draw-conclusion.d.ts.map +1 -0
  169. package/dist/tools/draw-conclusion.js +250 -0
  170. package/dist/tools/draw-conclusion.js.map +1 -0
  171. package/dist/tools/index.d.ts +7 -0
  172. package/dist/tools/index.d.ts.map +1 -1
  173. package/dist/tools/index.js +16 -0
  174. package/dist/tools/index.js.map +1 -1
  175. package/dist/tools/kit.d.ts +17 -0
  176. package/dist/tools/kit.d.ts.map +1 -1
  177. package/dist/tools/kit.js.map +1 -1
  178. package/dist/tools/observe-runtime.d.ts +17 -0
  179. package/dist/tools/observe-runtime.d.ts.map +1 -1
  180. package/dist/tools/observe-runtime.js +174 -10
  181. package/dist/tools/observe-runtime.js.map +1 -1
  182. package/dist/tools/predict-reach.d.ts +57 -0
  183. package/dist/tools/predict-reach.d.ts.map +1 -0
  184. package/dist/tools/predict-reach.js +159 -0
  185. package/dist/tools/predict-reach.js.map +1 -0
  186. package/dist/tools/propagation.d.ts +8 -3
  187. package/dist/tools/propagation.d.ts.map +1 -1
  188. package/dist/tools/propagation.js +16 -9
  189. package/dist/tools/propagation.js.map +1 -1
  190. package/dist/tools/propose-journey.d.ts +16 -0
  191. package/dist/tools/propose-journey.d.ts.map +1 -0
  192. package/dist/tools/propose-journey.js +121 -0
  193. package/dist/tools/propose-journey.js.map +1 -0
  194. package/dist/tools/runtime-journey-drive.d.ts +102 -0
  195. package/dist/tools/runtime-journey-drive.d.ts.map +1 -0
  196. package/dist/tools/runtime-journey-drive.js +247 -0
  197. package/dist/tools/runtime-journey-drive.js.map +1 -0
  198. package/dist/tools/scope.d.ts +3 -0
  199. package/dist/tools/scope.d.ts.map +1 -1
  200. package/dist/tools/scope.js +33 -6
  201. package/dist/tools/scope.js.map +1 -1
  202. package/dist/workspace-index.d.ts +164 -0
  203. package/dist/workspace-index.d.ts.map +1 -0
  204. package/dist/workspace-index.js +381 -0
  205. package/dist/workspace-index.js.map +1 -0
  206. package/package.json +32 -31
@@ -11,10 +11,16 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
11
11
  }
12
12
  return path;
13
13
  };
14
- import { BrowserSessionCrashedError, UnknownElementRefError } from "./driver.js";
14
+ import { BrowserSessionCrashedError, NoSuchOptionError, NotOperableError, NotSelectableError, NotSubmittableError, UnknownElementRefError, } from "./driver.js";
15
+ import { clampWaitTimeout, describeWaitTarget, isEmptyWaitTarget, matchesWaitTarget, WAIT_POLL_INTERVAL_MS, } from "./wait-target.js";
16
+ import { PROBE_CONSTANTS, probeInPage } from "./page-probe.js";
15
17
  /** The module the driver composes. Named once, imported at call time. */
16
18
  const RUNTIME_BROWSER_MODULE = "@descryy/runtime-browser";
17
19
  const defaultRuntimeModuleLoader = () => import(__rewriteRelativeImportExtension(RUNTIME_BROWSER_MODULE));
20
+ /** Same seam, for the optional identity-grounding module — imported dynamically and only
21
+ * when `groundIdentity` is set, so a build or a session that never asks for it pays nothing. */
22
+ const IDENTITY_GROUNDING_MODULE = "@descryy/runtime-identity-grounding";
23
+ const defaultIdentityGroundingModuleLoader = () => import(__rewriteRelativeImportExtension(IDENTITY_GROUNDING_MODULE));
18
24
  /** Ceiling on waiting for network activity to *start* — no signal proves "never will". Kept at the old fixed 300ms so a no-op action observes the same as before. */
19
25
  const ACTIVITY_GRACE_MS = 300;
20
26
  /** How long after the last request event counts as settled; fires only once activity is seen, so it measures a gap, not a guess about more coming. */
@@ -25,6 +31,419 @@ const SETTLE_TIMEOUT_MS = 5_000;
25
31
  const POLL_INTERVAL_MS = 10;
26
32
  /** Elements an agent can act on — deliberately narrow; a full-page snapshot would blow the reply budget. `[role]` included for custom controls. */
27
33
  const INTERACTIVE = "a, button, input, select, textarea, [role], [onclick], summary";
34
+ /** How many scroll-and-re-check rounds a reveal pass runs before it stops and says it stopped. */
35
+ const REVEAL_MAX_PASSES = 12;
36
+ /** How long after a scroll before re-checking. A lazily-rendered row needs a frame or two to exist. */
37
+ const REVEAL_SETTLE_MS = 150;
38
+ /** `RawElementIdentity` -> the port's own `ElementComponentIdentity` — the one place the two
39
+ * vocabularies are joined, so a change to either shows up as one diff here. */
40
+ function mapIdentity(raw) {
41
+ switch (raw.outcome) {
42
+ case "grounded":
43
+ return {
44
+ outcome: "grounded",
45
+ component: raw.component.name,
46
+ file: raw.sourceLocation.file,
47
+ line: raw.sourceLocation.line,
48
+ authoredSource: raw.authoredSource,
49
+ reason: null,
50
+ };
51
+ case "component-only":
52
+ return {
53
+ outcome: "component-only",
54
+ component: raw.component.name,
55
+ file: null,
56
+ line: null,
57
+ authoredSource: false,
58
+ reason: raw.reason,
59
+ };
60
+ case "refused":
61
+ return {
62
+ outcome: "refused",
63
+ component: null,
64
+ file: null,
65
+ line: null,
66
+ authoredSource: false,
67
+ reason: raw.reason,
68
+ };
69
+ }
70
+ }
71
+ /** Folds the session's install-time identity warnings into a drained observation, once —
72
+ * same drain-not-peek discipline `drainEvidence` and `close` already hold every other field
73
+ * to, so a warning surfaces on the first read after it happened and never again. */
74
+ function withIdentityWarnings(observation, warnings) {
75
+ if (warnings.length === 0)
76
+ return observation;
77
+ const drained = [...warnings];
78
+ warnings.length = 0;
79
+ return { ...observation, evidenceWarnings: [...observation.evidenceWarnings, ...drained] };
80
+ }
81
+ /** Grounds one selector, never throwing — a probe failure is reported as a named refusal
82
+ * (rule 2: no guess), the same as every other outcome the grounder itself can name. */
83
+ async function groundOne(grounder, page, selector) {
84
+ try {
85
+ const raw = await grounder.ground(page, selector);
86
+ return mapIdentity(raw);
87
+ }
88
+ catch (error) {
89
+ return {
90
+ outcome: "refused",
91
+ component: null,
92
+ file: null,
93
+ line: null,
94
+ authoredSource: false,
95
+ reason: `the identity probe failed: ${error instanceof Error ? error.message : String(error)}`,
96
+ };
97
+ }
98
+ }
99
+ /** What one element looks like from inside the page. Serialized and run there, so it closes over
100
+ * nothing in this module — every helper it needs is declared inside it.
101
+ *
102
+ * Exported for `browser-element-describe.test.ts`: role, name and renderedness are pure
103
+ * functions of one element, and pinning their decision table should not need a browser. */
104
+ export function describeElement(el) {
105
+ const tag = el.tagName.toLowerCase();
106
+ const explicit = el.getAttribute("role");
107
+ const type = el.getAttribute("type")?.toLowerCase() ?? null;
108
+ /** The HTML→ARIA role of this element. The old chain ended `: tag`, which reported
109
+ * `input[type="number"]` as role `"input"` — a role no agent filters on and no verb
110
+ * validates against, so a real control went silently missing from every role-filtered
111
+ * read. The mapping below is HTML's own, and it names no framework (rule 1).
112
+ *
113
+ * It still ends at the tag for a tag this does not know, deliberately: `[role]` and
114
+ * `[onclick]` put arbitrary elements into the query, and inventing a role for one of
115
+ * them would be this same defect pointed the other way. */
116
+ function derivedRole() {
117
+ if (tag === "a")
118
+ return "link";
119
+ if (tag === "button")
120
+ return "button";
121
+ if (tag === "textarea")
122
+ return "textbox";
123
+ // `summary` is the control that opens a `<details>`; a click is what it takes.
124
+ if (tag === "summary")
125
+ return "button";
126
+ if (tag === "select") {
127
+ const multiple = el.getAttribute("multiple") !== null;
128
+ const size = Number(el.getAttribute("size") ?? "1");
129
+ return multiple || size > 1 ? "listbox" : "combobox";
130
+ }
131
+ if (tag !== "input")
132
+ return tag;
133
+ switch (type) {
134
+ case "button":
135
+ case "submit":
136
+ case "reset":
137
+ case "image":
138
+ return "button";
139
+ case "checkbox":
140
+ return "checkbox";
141
+ case "radio":
142
+ return "radio";
143
+ case "range":
144
+ return "slider";
145
+ case "number":
146
+ return "spinbutton";
147
+ case "search":
148
+ return "searchbox";
149
+ // `password` has no ARIA role of its own; `textbox` is both the useful answer and the
150
+ // true one about how it is operated. The rest are HTML's textbox-mapped types.
151
+ case null:
152
+ case "text":
153
+ case "email":
154
+ case "tel":
155
+ case "url":
156
+ case "password":
157
+ return "textbox";
158
+ default:
159
+ // `file`, `color`, `date`, `hidden`, and anything a later HTML adds. Reporting the
160
+ // tag here is a disclosed gap; reporting `textbox` would be a wrong fact.
161
+ return tag;
162
+ }
163
+ }
164
+ const role = explicit ?? derivedRole();
165
+ /** A pragmatic accessible-name approximation, not a full AccName implementation.
166
+ *
167
+ * One case is carved out rather than approximated: a **chooser's own contents are its
168
+ * options**, so `innerText` on a `<select>` returns the list of things it can be set to,
169
+ * and a period picker came back named `"This month\nLast month"`. That is not a rough
170
+ * name, it is a false one — no person calls the control that, and the `select` verb
171
+ * matches on name. A chooser therefore skips the text tiers entirely and is left unnamed
172
+ * when nothing else names it. Unnamed is a gap; named after its options is a lie. */
173
+ function derivedName() {
174
+ const aria = el.getAttribute("aria-label");
175
+ if (aria !== null && aria.trim() !== "")
176
+ return aria;
177
+ if (tag !== "select") {
178
+ const inner = el.innerText?.trim();
179
+ if (inner !== undefined && inner !== "")
180
+ return inner;
181
+ const text = el.textContent?.trim();
182
+ if (text !== undefined && text !== null && text !== "")
183
+ return text;
184
+ }
185
+ // A push-button input carries its label in `value`, where no text tier would find it.
186
+ if (tag === "input" && (type === "button" || type === "submit" || type === "reset")) {
187
+ const value = el.getAttribute("value");
188
+ if (value !== null && value.trim() !== "")
189
+ return value.trim();
190
+ }
191
+ return el.getAttribute("placeholder") ?? el.getAttribute("title") ?? el.getAttribute("name") ?? "";
192
+ }
193
+ const name = derivedName();
194
+ /** Whether the page is laying this element out and painting it — true, false, or `null`
195
+ * when nothing available could answer.
196
+ *
197
+ * **Deliberately not a viewport test.** An element below the fold is on the page and the
198
+ * reveal pass depends on finding it; treating off-screen as hidden would trade this
199
+ * precision defect for a recall one. What it does reject is an element the page renders
200
+ * nowhere at all: `display:none`, the `hidden` attribute, a closed `<dialog>`,
201
+ * `content-visibility: hidden`, `visibility: hidden`, and `<canvas>` fallback content.
202
+ *
203
+ * Opacity is **not** rejected: a zero-opacity element still occupies space and still
204
+ * takes a click, and a fade-in caught mid-animation would vanish from the snapshot. */
205
+ function renderedState() {
206
+ if (typeof el.checkVisibility === "function") {
207
+ try {
208
+ return el.checkVisibility({ visibilityProperty: true });
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ if (typeof el.getClientRects !== "function")
215
+ return null;
216
+ try {
217
+ if (el.getClientRects().length === 0)
218
+ return false;
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ const view = globalThis.getComputedStyle;
224
+ if (typeof view !== "function")
225
+ return null;
226
+ try {
227
+ const visibility = view.call(globalThis, el)?.visibility;
228
+ if (visibility === undefined)
229
+ return null;
230
+ return visibility !== "hidden" && visibility !== "collapse";
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
236
+ // The node's own root: the document for a light-DOM element, the shadow root for one inside
237
+ // an open shadow root. Only a shadow root has a `host`, which is what tells them apart, and
238
+ // it is also the scope a uniqueness check has to run in — `ownerDocument.querySelectorAll`
239
+ // matches nothing at all for a shadow child, so every such element used to come back with a
240
+ // null selector for a reason that read like "no stable hook".
241
+ const root = typeof el.getRootNode === "function" ? el.getRootNode() : el.ownerDocument;
242
+ const inShadowRoot = root !== el.ownerDocument && root.host !== undefined && root.host !== null;
243
+ // Computed in this same round trip so role/name/selector read off one live DOM state.
244
+ // Rejects ids that look generated (hex run, React's `:r0:` useId
245
+ // style, or blank) — "stable across reload" is what a generated id lacks.
246
+ const GENERATED_ID = /[0-9a-f]{8}|:r[0-9a-z]+:|^\s*$/i;
247
+ const FORM_CONTROLS = ["input", "select", "textarea", "button"];
248
+ function escapeAttrValue(value) {
249
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
250
+ }
251
+ function isUnique(candidate) {
252
+ try {
253
+ return root.querySelectorAll(candidate).length === 1;
254
+ }
255
+ catch {
256
+ // Invalid selector (rare) is treated as "no candidate", not thrown.
257
+ return false;
258
+ }
259
+ }
260
+ /** The element's own stable-hook selector, or null. Used both on the target itself and while climbing for a structural path's anchor. */
261
+ function stableHookSelector(node) {
262
+ const testId = node.getAttribute("data-testid");
263
+ if (testId !== null && testId.trim() !== "")
264
+ return `[data-testid="${escapeAttrValue(testId)}"]`;
265
+ const testAttr = node.getAttribute("data-test");
266
+ if (testAttr !== null && testAttr.trim() !== "")
267
+ return `[data-test="${escapeAttrValue(testAttr)}"]`;
268
+ const id = node.getAttribute("id");
269
+ if (id !== null && !GENERATED_ID.test(id))
270
+ return `[id="${escapeAttrValue(id)}"]`;
271
+ return null;
272
+ }
273
+ function nthOfTypeSegment(node, parent) {
274
+ const nodeTag = node.tagName.toLowerCase();
275
+ const siblings = parent.querySelectorAll(`:scope > ${nodeTag}`);
276
+ if (siblings.length <= 1)
277
+ return nodeTag;
278
+ for (let i = 0; i < siblings.length; i += 1) {
279
+ if (siblings.item(i) === node)
280
+ return `${nodeTag}:nth-of-type(${i + 1})`;
281
+ }
282
+ return nodeTag;
283
+ }
284
+ /** A bounded path from the nearest ancestor carrying a stable hook, capped at 6 segments. Null
285
+ * when none is found in range — including when the climb reaches a shadow boundary, where
286
+ * `parentElement` is null and there is genuinely no further ancestor inside this root. */
287
+ function structuralPath(target) {
288
+ const segments = [];
289
+ let node = target;
290
+ for (let depth = 0; depth < 6; depth += 1) {
291
+ const parent = node.parentElement;
292
+ if (parent === null)
293
+ return null;
294
+ segments.unshift(nthOfTypeSegment(node, parent));
295
+ const hook = stableHookSelector(parent);
296
+ if (hook !== null) {
297
+ const candidate = `${hook} > ${segments.join(" > ")}`;
298
+ return isUnique(candidate) ? candidate : null;
299
+ }
300
+ node = parent;
301
+ }
302
+ return null;
303
+ }
304
+ function computeSelector(target) {
305
+ // Priority order: first unique candidate wins. Each tier is checked
306
+ // independently — a shared data-testid isn't addressable either,
307
+ // so the next tier gets a turn instead of stopping there.
308
+ const testId = target.getAttribute("data-testid");
309
+ if (testId !== null && testId.trim() !== "") {
310
+ const candidate = `[data-testid="${escapeAttrValue(testId)}"]`;
311
+ if (isUnique(candidate))
312
+ return candidate;
313
+ }
314
+ const testAttr = target.getAttribute("data-test");
315
+ if (testAttr !== null && testAttr.trim() !== "") {
316
+ const candidate = `[data-test="${escapeAttrValue(testAttr)}"]`;
317
+ if (isUnique(candidate))
318
+ return candidate;
319
+ }
320
+ const id = target.getAttribute("id");
321
+ if (id !== null && !GENERATED_ID.test(id)) {
322
+ const candidate = `[id="${escapeAttrValue(id)}"]`;
323
+ if (isUnique(candidate))
324
+ return candidate;
325
+ }
326
+ const controlTag = target.tagName.toLowerCase();
327
+ const controlName = target.getAttribute("name");
328
+ if (controlName !== null && controlName.trim() !== "" && FORM_CONTROLS.includes(controlTag)) {
329
+ const candidate = `${controlTag}[name="${escapeAttrValue(controlName)}"]`;
330
+ if (isUnique(candidate))
331
+ return candidate;
332
+ }
333
+ const explicitRole = target.getAttribute("role");
334
+ const ariaLabel = target.getAttribute("aria-label");
335
+ if (explicitRole !== null && ariaLabel !== null && ariaLabel.trim() !== "") {
336
+ const candidate = `[role="${escapeAttrValue(explicitRole)}"][aria-label="${escapeAttrValue(ariaLabel)}"]`;
337
+ if (isUnique(candidate))
338
+ return candidate;
339
+ }
340
+ return structuralPath(target);
341
+ }
342
+ return { role, name: name.slice(0, 200), selector: computeSelector(el), inShadowRoot, rendered: renderedState() };
343
+ }
344
+ /** Said once, so the cap and the sentence describing it cannot drift apart. */
345
+ const CONTAINER_SCAN_NOTE = "this page holds more elements than the reveal pass scans for scrollable containers, so a list " +
346
+ "inside one past that point was not scrolled and anything only it holds is not above";
347
+ /**
348
+ * One reveal step, run inside the page: scrolls the document **and every scrollable container
349
+ * in it** by just under their own height.
350
+ *
351
+ * The container half is the point. `window.scrollBy` does nothing to a list inside
352
+ * `overflow: auto` — a shape common enough that a window-only reveal reports "nothing below
353
+ * the fold" about a page whose entire content is below one. The step stays under a full
354
+ * viewport so a row sitting on the seam is never scrolled past between two enumerations.
355
+ *
356
+ * Serialized and run in the page, so it closes over nothing in this module — the scan cap is
357
+ * declared inside it for that reason and mirrored in `CONTAINER_SCAN_NOTE` above.
358
+ */
359
+ function revealStep() {
360
+ const win = globalThis;
361
+ const doc = win.document;
362
+ const SCAN_CAP = 5000;
363
+ let scrolled = false;
364
+ if (win.scrollY + win.innerHeight < doc.documentElement.scrollHeight - 1) {
365
+ win.scrollBy(0, Math.round(win.innerHeight * 0.9));
366
+ scrolled = true;
367
+ }
368
+ const all = doc.querySelectorAll("*");
369
+ const limit = all.length > SCAN_CAP ? SCAN_CAP : all.length;
370
+ let containers = 0;
371
+ for (let i = 0; i < limit; i += 1) {
372
+ const node = all.item(i);
373
+ if (node === null)
374
+ continue;
375
+ // Geometry first, style second: the overflow read is the expensive one, and an element
376
+ // whose content fits inside it can never be a scroller whatever its overflow says.
377
+ if (typeof node.scrollHeight !== "number" || typeof node.clientHeight !== "number")
378
+ continue;
379
+ if (node.clientHeight === 0 || node.scrollHeight <= node.clientHeight + 1)
380
+ continue;
381
+ const overflow = typeof win.getComputedStyle === "function" ? win.getComputedStyle(node)?.overflowY : undefined;
382
+ if (overflow !== "auto" && overflow !== "scroll" && overflow !== "overlay")
383
+ continue;
384
+ containers += 1;
385
+ if (node.scrollTop + node.clientHeight < node.scrollHeight - 1) {
386
+ node.scrollBy(0, Math.round(node.clientHeight * 0.9));
387
+ scrolled = true;
388
+ }
389
+ }
390
+ return { y: win.scrollY, scrolled, containers, scanCapped: all.length > SCAN_CAP };
391
+ }
392
+ /** A frame's URL, or "" — a frame detaching mid-snapshot throws from `url()`. */
393
+ function safeUrl(frame) {
394
+ try {
395
+ return typeof frame.url === "function" ? frame.url() : "";
396
+ }
397
+ catch {
398
+ return "";
399
+ }
400
+ }
401
+ /** Something a person can read back: the frame's name, else the iframe element's id or title.
402
+ * Null when it carries none — the reply then falls back to the frame's URL. */
403
+ async function frameLabel(frame) {
404
+ try {
405
+ const declared = typeof frame.name === "function" ? frame.name() : "";
406
+ if (declared !== "")
407
+ return declared;
408
+ if (typeof frame.frameElement !== "function")
409
+ return null;
410
+ const element = await frame.frameElement();
411
+ try {
412
+ if (typeof element.getAttribute !== "function")
413
+ return null;
414
+ const id = await element.getAttribute("id");
415
+ if (id !== null && id.trim() !== "")
416
+ return id;
417
+ const title = await element.getAttribute("title");
418
+ if (title !== null && title.trim() !== "")
419
+ return title;
420
+ return null;
421
+ }
422
+ finally {
423
+ await element.dispose().catch(() => { });
424
+ }
425
+ }
426
+ catch {
427
+ return null;
428
+ }
429
+ }
430
+ /** Identity for reveal accounting. Elements are compared as a multiset, not by ref: a ref is
431
+ * issued per snapshot and two enumerations of the same row never share one. */
432
+ function countKeys(elements) {
433
+ const counts = new Map();
434
+ for (const element of elements) {
435
+ const key = `${element.frame?.id ?? ""}\u0000${element.role}\u0000${element.name}\u0000${element.selector ?? ""}`;
436
+ counts.set(key, (counts.get(key) ?? 0) + 1);
437
+ }
438
+ return counts;
439
+ }
440
+ /** How many of `a` have no counterpart in `b`. */
441
+ function surplus(a, b) {
442
+ let total = 0;
443
+ for (const [key, count] of a)
444
+ total += Math.max(0, count - (b.get(key) ?? 0));
445
+ return total;
446
+ }
28
447
  export class BrowserBackendMissingError extends Error {
29
448
  constructor(cause) {
30
449
  super(`Could not load ${RUNTIME_BROWSER_MODULE}: ${cause instanceof Error ? cause.message : String(cause)}. ` +
@@ -42,7 +461,18 @@ export async function createPlaywrightBrowserDriver(options = {}) {
42
461
  }
43
462
  return {
44
463
  async launch(launchOptions) {
45
- const launch = await runtime.launchBrowserSessionOrRefuse({ headless: launchOptions.headless ?? true });
464
+ const auth = runtimeAuthFor(launchOptions);
465
+ const launch = await runtime.launchBrowserSessionOrRefuse({
466
+ headless: launchOptions.headless ?? true,
467
+ // B1. This used to be `{ headless }` and nothing else, while the port
468
+ // above already carried `storageState` and a reply note already said
469
+ // it had been seeded. The option reached the fake driver and stopped;
470
+ // against a real browser nothing was ever applied and the note was
471
+ // false. Building the seed here, from the target URL, is also what
472
+ // makes the origin a fact the runtime is *told* rather than one it
473
+ // has to work out.
474
+ ...(auth === null ? {} : { auth }),
475
+ });
46
476
  if (!launch.launched) {
47
477
  const { launched: _launched, ...refusal } = launch;
48
478
  return { launched: false, ...refusal };
@@ -77,6 +507,13 @@ async function openSession(runtime, session, launchOptions, options) {
77
507
  let generation = 0;
78
508
  let closed = false;
79
509
  let finalObservation = null;
510
+ /** Set when a `saveStorageStateTo` write failed. Read by `storageStateSaveFailure()`. */
511
+ let storageStateSaveError = null;
512
+ /** Set when `groundIdentity` was asked for and installed successfully. Every snapshot
513
+ * element with a selector is grounded through it; one with none is never attempted
514
+ * (§B1's `identity: null` distinction — see `driver.ts`). */
515
+ let grounder = null;
516
+ const identityWarnings = [];
80
517
  const context = {
81
518
  executionId: `mcp-browser-${session.sessionId}`,
82
519
  configuration: { environmentTier: "tier-2-container", fidelityLevel: 2, timeoutMs: 30_000, services: {} },
@@ -105,6 +542,26 @@ async function openSession(runtime, session, launchOptions, options) {
105
542
  await actionCollector.start(context);
106
543
  await consoleCollector.start(context);
107
544
  await networkCollector.start(context);
545
+ // Same contract as the collectors above, and for the same reason: the identity probe is
546
+ // an init script too, so it must install before the first navigation or it reports
547
+ // `probe-not-installed` for the document's whole lifetime. Off by default (B1) — a session
548
+ // that never asks for it pays no page.evaluate and loads no module.
549
+ if (options.groundIdentity === true) {
550
+ try {
551
+ const identityModule = (await (options.loadIdentityGroundingModule ?? defaultIdentityGroundingModuleLoader)());
552
+ grounder = identityModule.createFrameworkAwareGrounder(options.resolveSourceRoot === undefined ? {} : { resolveSourceRoot: options.resolveSourceRoot });
553
+ await grounder.install(session.page);
554
+ }
555
+ catch (error) {
556
+ // Honest degradation (rule 7): identity grounding could not be installed, so every
557
+ // element on this session reports `identity: null` — never a guess — and the reason
558
+ // travels on the observation rather than being swallowed.
559
+ grounder = null;
560
+ identityWarnings.push(`identity grounding was requested (groundIdentity) but could not be installed: ` +
561
+ `${error instanceof Error ? error.message : String(error)}. Every element on this session ` +
562
+ "reports identity: null rather than a guess.");
563
+ }
564
+ }
108
565
  await actionCollector.navigate(launchOptions.targetUrl);
109
566
  function assertUsable() {
110
567
  if (closed || !session.isUsable())
@@ -151,7 +608,256 @@ async function openSession(runtime, session, launchOptions, options) {
151
608
  refs = new Map();
152
609
  await Promise.all(handles.map((h) => h.dispose().catch(() => { })));
153
610
  }
154
- return {
611
+ /** Every document to read, main first. When the backend exposes no frame list the page
612
+ * itself is the only one, and that limitation is reported as an unread frame rather than
613
+ * passed off as "this page has no iframes". */
614
+ function documentsToRead() {
615
+ if (typeof session.page.frames !== "function")
616
+ return { frames: [{ frame: session.page, ref: null }], frameListUnavailable: true };
617
+ const all = session.page.frames();
618
+ const main = typeof session.page.mainFrame === "function" ? session.page.mainFrame() : all[0];
619
+ const out = [];
620
+ let index = 0;
621
+ for (const frame of all) {
622
+ if (frame === main) {
623
+ out.unshift({ frame, ref: null });
624
+ continue;
625
+ }
626
+ index += 1;
627
+ out.push({ frame, ref: { id: `frame-${index}`, url: safeUrl(frame), name: null } });
628
+ }
629
+ if (out.length === 0 || out[0]?.ref !== null)
630
+ out.unshift({ frame: session.page, ref: null });
631
+ return { frames: out, frameListUnavailable: false };
632
+ }
633
+ /** Reads the elements of one document, describing each in a single round trip. */
634
+ async function enumerateFrame(frame, frameRef) {
635
+ const handles = await frame.$$(INTERACTIVE);
636
+ const elements = [];
637
+ let ambiguousInDocument = 0;
638
+ let hidden = 0;
639
+ let untested = 0;
640
+ for (const handle of handles) {
641
+ const described = await handle.evaluate(describeElement).catch(() => null);
642
+ if (described === null) {
643
+ await handle.dispose().catch(() => { });
644
+ continue;
645
+ }
646
+ // The CSS query matches what is in the document; this is what is on the page. A
647
+ // `display:none` menu, a closed modal and an inactive tab panel all match the query,
648
+ // and reporting them as usable controls is Descry stating something false about the
649
+ // user's screen. Withheld and counted, never dropped in silence (rule 7).
650
+ if (described.rendered === false) {
651
+ hidden += 1;
652
+ await handle.dispose().catch(() => { });
653
+ continue;
654
+ }
655
+ // `null` is "nothing could answer". The element is kept — omitting on an unanswered
656
+ // question would hide real controls behind a test that did not run — and the fact that
657
+ // the question went unanswered travels with the snapshot.
658
+ if (described.rendered === null)
659
+ untested += 1;
660
+ let selector = described.selector;
661
+ // A hook unique inside its own shadow root is not necessarily unique in the document:
662
+ // two instances of the same component each carry `[data-testid="submit"]`. Playwright's
663
+ // own CSS resolution pierces, so asking it is the same question a replay would ask.
664
+ if (selector !== null && described.inShadowRoot === true) {
665
+ const matches = await frame.$$(selector).catch(() => null);
666
+ if (matches !== null)
667
+ await Promise.all(matches.map((m) => m.dispose().catch(() => { })));
668
+ if (matches === null || matches.length !== 1) {
669
+ selector = null;
670
+ ambiguousInDocument += 1;
671
+ }
672
+ }
673
+ elements.push({
674
+ handle,
675
+ role: described.role,
676
+ name: described.name,
677
+ // An element inside a frame has no saveable selector: the scenario format carries no
678
+ // field to name the frame, so a stored selector would replay against the main document.
679
+ selector: frameRef === null ? selector : null,
680
+ inShadowRoot: described.inShadowRoot === true,
681
+ frame: frameRef,
682
+ });
683
+ }
684
+ return { elements, ambiguousInDocument, hidden, untested };
685
+ }
686
+ /** Counts custom elements that look like closed shadow hosts. Returns null when the probe could not run. */
687
+ async function probeClosedHosts(frame) {
688
+ if (typeof frame.evaluate !== "function")
689
+ return null;
690
+ return frame
691
+ .evaluate(() => {
692
+ const page = globalThis;
693
+ const all = page.document.querySelectorAll("*");
694
+ let suspected = 0;
695
+ for (let i = 0; i < all.length; i += 1) {
696
+ const element = all.item(i);
697
+ if (element === null)
698
+ continue;
699
+ // A dash in the tag name is what the custom-element spec requires of every one of
700
+ // them; it names no language or framework (rule 1), only the HTML naming rule.
701
+ if (!element.tagName.toLowerCase().includes("-"))
702
+ continue;
703
+ if (element.shadowRoot !== null && element.shadowRoot !== undefined)
704
+ continue;
705
+ if ((element.childElementCount ?? 0) > 0)
706
+ continue;
707
+ suspected += 1;
708
+ }
709
+ return suspected;
710
+ })
711
+ .catch(() => null);
712
+ }
713
+ /** Reads every document, and reports each one it could not read with the reason. */
714
+ async function enumerateAll() {
715
+ const { frames: documents, frameListUnavailable } = documentsToRead();
716
+ const elements = [];
717
+ const reach = [];
718
+ let ambiguousInDocument = 0;
719
+ let suspectedClosedHosts = 0;
720
+ let probed = false;
721
+ let hidden = 0;
722
+ let untested = 0;
723
+ for (const { frame, ref } of documents) {
724
+ let frameRef = ref;
725
+ if (frameRef !== null)
726
+ frameRef = { ...frameRef, name: await frameLabel(frame) };
727
+ try {
728
+ const read = await enumerateFrame(frame, frameRef);
729
+ elements.push(...read.elements);
730
+ ambiguousInDocument += read.ambiguousInDocument;
731
+ hidden += read.hidden;
732
+ untested += read.untested;
733
+ reach.push({ frame: frameRef, enumerated: true, elements: read.elements.length, reason: null });
734
+ }
735
+ catch (error) {
736
+ reach.push({
737
+ frame: frameRef,
738
+ enumerated: false,
739
+ elements: 0,
740
+ reason: `this frame could not be read: ${error instanceof Error ? error.message : String(error)}`,
741
+ });
742
+ continue;
743
+ }
744
+ const closed = await probeClosedHosts(frame);
745
+ if (closed !== null) {
746
+ probed = true;
747
+ suspectedClosedHosts += closed;
748
+ }
749
+ }
750
+ if (frameListUnavailable) {
751
+ reach.push({
752
+ frame: { id: "frames-unavailable", url: "", name: null },
753
+ enumerated: false,
754
+ elements: 0,
755
+ reason: "this browser backend exposes no frame list, so any iframe on this page was not read. " +
756
+ "Nothing here claims the page has none.",
757
+ });
758
+ }
759
+ return {
760
+ elements,
761
+ frames: reach,
762
+ shadow: {
763
+ piercedElements: elements.filter((e) => e.inShadowRoot).length,
764
+ ambiguousInDocument,
765
+ suspectedClosedHosts,
766
+ probed,
767
+ },
768
+ hidden: { withheld: hidden, untested },
769
+ };
770
+ }
771
+ /** Scrolls the document **and every scrollable container in it** a viewport at a time until
772
+ * nothing can move further, or the pass cap stops it. Containers are the half a window-only
773
+ * scroll never reached: a list inside `overflow: auto` does not move when the page does. */
774
+ async function scrollToReveal() {
775
+ const page = session.page;
776
+ if (typeof page.evaluate !== "function") {
777
+ return {
778
+ passes: 0,
779
+ stoppedAtCap: false,
780
+ finalScrollY: 0,
781
+ containersScrolled: 0,
782
+ reason: "this browser backend exposes no page evaluation, so nothing below the fold could be revealed",
783
+ };
784
+ }
785
+ const evaluate = page.evaluate.bind(page);
786
+ let passes = 0;
787
+ let containers = 0;
788
+ let lastY = 0;
789
+ let scanCapped = false;
790
+ for (;;) {
791
+ const step = await evaluate(revealStep).catch(() => null);
792
+ if (step === null) {
793
+ return {
794
+ passes,
795
+ stoppedAtCap: false,
796
+ finalScrollY: lastY,
797
+ containersScrolled: containers,
798
+ reason: "the page stopped answering mid-reveal",
799
+ };
800
+ }
801
+ lastY = step.y;
802
+ containers = Math.max(containers, step.containers);
803
+ scanCapped = scanCapped || step.scanCapped;
804
+ // Nothing left to move: the page and every container it holds are at their end, or
805
+ // none of them can scroll at all. Either way there is nothing further to reveal.
806
+ if (!step.scrolled) {
807
+ return {
808
+ passes,
809
+ stoppedAtCap: false,
810
+ finalScrollY: step.y,
811
+ containersScrolled: containers,
812
+ reason: scanCapped ? CONTAINER_SCAN_NOTE : null,
813
+ };
814
+ }
815
+ passes += 1;
816
+ if (passes >= REVEAL_MAX_PASSES) {
817
+ return {
818
+ passes,
819
+ stoppedAtCap: true,
820
+ finalScrollY: step.y,
821
+ containersScrolled: containers,
822
+ reason: scanCapped ? CONTAINER_SCAN_NOTE : null,
823
+ };
824
+ }
825
+ await new Promise((done) => setTimeout(done, REVEAL_SETTLE_MS));
826
+ }
827
+ }
828
+ /**
829
+ * Refuses a control the page has put out of reach.
830
+ *
831
+ * `click` never calls this: Playwright's own actionability checks already
832
+ * cover it, and duplicating them here would mean two rules that can
833
+ * disagree. `select` and `submit` do, because neither of their underlying
834
+ * operations hit-tests — see `NotOperableError`.
835
+ *
836
+ * A browser that cannot answer is treated as operable rather than refused.
837
+ * Refusing on an unanswered question would turn a missing capability into a
838
+ * false negative on every element, which is the same trade rule 2 rejects
839
+ * pointed the other way.
840
+ */
841
+ async function assertOperable(ref, handle) {
842
+ const state = await handle
843
+ .evaluate((el) => ({
844
+ // `inert` applies to a whole subtree and `HTMLElement.inert` reflects only the
845
+ // attribute on the node it is read from, so this has to walk ancestors.
846
+ inert: el.closest === undefined ? null : el.closest("[inert]") !== null,
847
+ disabled: el.disabled === true,
848
+ }))
849
+ .catch(() => null);
850
+ if (state === null)
851
+ return;
852
+ if (state.inert === true)
853
+ throw new NotOperableError(ref, "inert");
854
+ if (state.disabled)
855
+ throw new NotOperableError(ref, "disabled");
856
+ }
857
+ // Named rather than returned inline: `waitFor` re-enumerates the page each poll and does it
858
+ // by calling this same `snapshot()`, so the refs it hands back come from the one enumeration
859
+ // path instead of a second copy that could drift from it.
860
+ const api = {
155
861
  sessionId: session.sessionId,
156
862
  startedAt: session.startedAt,
157
863
  isUsable: () => !closed && session.isUsable(),
@@ -163,154 +869,66 @@ async function openSession(runtime, session, launchOptions, options) {
163
869
  recordAction("navigate", null, null);
164
870
  recordPerformed("navigate", null, url, null);
165
871
  },
166
- async snapshot() {
872
+ async snapshot(options) {
167
873
  assertUsable();
168
874
  await dropRefs();
169
875
  generation += 1;
170
- const handles = await session.page.$$(INTERACTIVE);
171
- const elements = [];
172
- for (const [index, handle] of handles.entries()) {
173
- const described = await handle
174
- .evaluate((el) => {
175
- const tag = el.tagName.toLowerCase();
176
- const explicit = el.getAttribute("role");
177
- const type = el.getAttribute("type");
178
- const role = explicit ??
179
- (tag === "a"
180
- ? "link"
181
- : tag === "button" || (tag === "input" && (type === "button" || type === "submit"))
182
- ? "button"
183
- : tag === "select"
184
- ? "combobox"
185
- : tag === "textarea" || (tag === "input" && (type === null || type === "text" || type === "search"))
186
- ? "textbox"
187
- : tag === "input" && type === "checkbox"
188
- ? "checkbox"
189
- : tag);
190
- // A pragmatic accessible-name approximation, not a full AccName implementation.
191
- const name = el.getAttribute("aria-label") ??
192
- el.innerText?.trim() ??
193
- el.textContent?.trim() ??
194
- el.getAttribute("placeholder") ??
195
- el.getAttribute("name") ??
196
- "";
197
- // Computed in this same round trip so role/name/selector read off
198
- // one live DOM state; runs inside the page (serialized by
199
- // Playwright), so it must not close over the outer module.
200
- // Rejects ids that look generated (hex run, React's `:r0:` useId
201
- // style, or blank) — "stable across reload" is what a generated id lacks.
202
- const GENERATED_ID = /[0-9a-f]{8}|:r[0-9a-z]+:|^\s*$/i;
203
- const FORM_CONTROLS = ["input", "select", "textarea", "button"];
204
- function escapeAttrValue(value) {
205
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
206
- }
207
- function isUnique(candidate) {
208
- try {
209
- return el.ownerDocument.querySelectorAll(candidate).length === 1;
210
- }
211
- catch {
212
- // Invalid selector (rare) is treated as "no candidate", not thrown.
213
- return false;
214
- }
215
- }
216
- /** The element's own stable-hook selector, or null. Used both on the target itself and while climbing for a structural path's anchor. */
217
- function stableHookSelector(node) {
218
- const testId = node.getAttribute("data-testid");
219
- if (testId !== null && testId.trim() !== "")
220
- return `[data-testid="${escapeAttrValue(testId)}"]`;
221
- const testAttr = node.getAttribute("data-test");
222
- if (testAttr !== null && testAttr.trim() !== "")
223
- return `[data-test="${escapeAttrValue(testAttr)}"]`;
224
- const id = node.getAttribute("id");
225
- if (id !== null && !GENERATED_ID.test(id))
226
- return `[id="${escapeAttrValue(id)}"]`;
227
- return null;
228
- }
229
- function nthOfTypeSegment(node, parent) {
230
- const tag = node.tagName.toLowerCase();
231
- const siblings = parent.querySelectorAll(`:scope > ${tag}`);
232
- if (siblings.length <= 1)
233
- return tag;
234
- for (let i = 0; i < siblings.length; i += 1) {
235
- if (siblings.item(i) === node)
236
- return `${tag}:nth-of-type(${i + 1})`;
237
- }
238
- return tag;
239
- }
240
- /** A bounded path from the nearest ancestor carrying a stable hook, capped at 6 segments. Null when none is found in range. */
241
- function structuralPath(target) {
242
- const segments = [];
243
- let node = target;
244
- for (let depth = 0; depth < 6; depth += 1) {
245
- const parent = node.parentElement;
246
- if (parent === null)
247
- return null;
248
- segments.unshift(nthOfTypeSegment(node, parent));
249
- const hook = stableHookSelector(parent);
250
- if (hook !== null) {
251
- const candidate = `${hook} > ${segments.join(" > ")}`;
252
- return isUnique(candidate) ? candidate : null;
253
- }
254
- node = parent;
255
- }
256
- return null;
257
- }
258
- function computeSelector(target) {
259
- // Priority order: first unique candidate wins. Each tier is checked
260
- // independently — a shared data-testid isn't addressable either,
261
- // so the next tier gets a turn instead of stopping there.
262
- const testId = target.getAttribute("data-testid");
263
- if (testId !== null && testId.trim() !== "") {
264
- const candidate = `[data-testid="${escapeAttrValue(testId)}"]`;
265
- if (isUnique(candidate))
266
- return candidate;
267
- }
268
- const testAttr = target.getAttribute("data-test");
269
- if (testAttr !== null && testAttr.trim() !== "") {
270
- const candidate = `[data-test="${escapeAttrValue(testAttr)}"]`;
271
- if (isUnique(candidate))
272
- return candidate;
273
- }
274
- const id = target.getAttribute("id");
275
- if (id !== null && !GENERATED_ID.test(id)) {
276
- const candidate = `[id="${escapeAttrValue(id)}"]`;
277
- if (isUnique(candidate))
278
- return candidate;
279
- }
280
- const controlTag = target.tagName.toLowerCase();
281
- const name = target.getAttribute("name");
282
- if (name !== null && name.trim() !== "" && FORM_CONTROLS.includes(controlTag)) {
283
- const candidate = `${controlTag}[name="${escapeAttrValue(name)}"]`;
284
- if (isUnique(candidate))
285
- return candidate;
286
- }
287
- const explicitRole = target.getAttribute("role");
288
- const ariaLabel = target.getAttribute("aria-label");
289
- if (explicitRole !== null && ariaLabel !== null && ariaLabel.trim() !== "") {
290
- const candidate = `[role="${escapeAttrValue(explicitRole)}"][aria-label="${escapeAttrValue(ariaLabel)}"]`;
291
- if (isUnique(candidate))
292
- return candidate;
293
- }
294
- return structuralPath(target);
295
- }
296
- return { role, name: name.slice(0, 200), selector: computeSelector(el) };
297
- })
298
- .catch(() => null);
299
- if (described === null) {
300
- await handle.dispose().catch(() => { });
301
- continue;
876
+ let read = await enumerateAll();
877
+ let reveal = null;
878
+ if (options?.reveal === true) {
879
+ const before = countKeys(read.elements);
880
+ const scrolled = await scrollToReveal();
881
+ if (scrolled.passes > 0) {
882
+ // Handles taken before the scroll may point at rows the list has since destroyed;
883
+ // the page is re-read rather than merged, so every ref handed back is one that
884
+ // exists now. What the scroll cost is reported, not hidden.
885
+ await Promise.all(read.elements.map((e) => e.handle.dispose().catch(() => { })));
886
+ read = await enumerateAll();
302
887
  }
888
+ const after = countKeys(read.elements);
889
+ reveal = {
890
+ passes: scrolled.passes,
891
+ containersScrolled: scrolled.containersScrolled,
892
+ revealedElements: surplus(after, before),
893
+ recycledElements: surplus(before, after),
894
+ stoppedAtCap: scrolled.stoppedAtCap,
895
+ finalScrollY: scrolled.finalScrollY,
896
+ framesNotScrolled: read.frames
897
+ .filter((f) => f.frame !== null)
898
+ .map((f) => f.frame?.name ?? f.frame?.url ?? f.frame?.id ?? ""),
899
+ reason: scrolled.reason,
900
+ };
901
+ }
902
+ const elements = [];
903
+ for (const [index, found] of read.elements.entries()) {
303
904
  const ref = `ref-${generation}-${index}`;
304
905
  const element = {
305
906
  ref,
306
- role: described.role,
307
- name: described.name,
308
- selector: described.selector,
907
+ role: found.role,
908
+ name: found.name,
909
+ selector: found.selector,
910
+ frame: found.frame,
911
+ inShadowRoot: found.inShadowRoot,
912
+ // Grounding resolves a *document* selector, so a framed or hookless element
913
+ // (selector === null) is never attempted — `identity` stays null rather than
914
+ // guessed at, same reasoning `driver.ts` documents on the field itself.
915
+ identity: grounder === null || found.selector === null
916
+ ? null
917
+ : await groundOne(grounder, session.page, found.selector),
309
918
  };
310
- refs.set(ref, { element, handle });
919
+ refs.set(ref, { element, handle: found.handle });
311
920
  elements.push(element);
312
921
  }
313
- return { url: session.page.url(), title: await session.page.title().catch(() => ""), elements, totalElements: elements.length };
922
+ return {
923
+ url: session.page.url(),
924
+ title: await session.page.title().catch(() => ""),
925
+ elements,
926
+ totalElements: elements.length,
927
+ frames: read.frames,
928
+ shadow: read.shadow,
929
+ hidden: read.hidden,
930
+ reveal,
931
+ };
314
932
  },
315
933
  async click(ref) {
316
934
  assertUsable();
@@ -342,16 +960,144 @@ async function openSession(runtime, session, launchOptions, options) {
342
960
  recordPerformed("fill", { role: element.role, name: element.name, selector: element.selector }, null, text);
343
961
  await settleAfterAction("fill", ref);
344
962
  },
963
+ async select(ref, value) {
964
+ assertUsable();
965
+ const { element, handle } = resolve(ref);
966
+ await assertOperable(ref, handle);
967
+ // Read what the element actually offers before touching it. Playwright's own
968
+ // selectOption would time out on a non-match and say nothing useful about why; the
969
+ // refusal a caller can act on has to carry the real option list, and that means one
970
+ // round trip to read it.
971
+ const offered = await handle.evaluate((el) => {
972
+ if (el.options === undefined)
973
+ return null;
974
+ const out = [];
975
+ for (let i = 0; i < el.options.length; i += 1) {
976
+ const option = el.options[i];
977
+ out.push({ value: option.value, label: option.label === "" ? option.text : option.label });
978
+ }
979
+ return out;
980
+ });
981
+ if (offered === null)
982
+ throw new NotSelectableError(ref, element.role);
983
+ // By stored value first, then by the label a person reads — both exact. A caller
984
+ // reading a snapshot sees labels and a caller reading the page's source sees values;
985
+ // neither should have to know which one this element uses.
986
+ const matched = offered.find((o) => o.value === value) ?? offered.find((o) => o.label.trim() === value.trim());
987
+ if (matched === undefined) {
988
+ throw new NoSuchOptionError(ref, value, offered.map((o) => (o.label.trim() === "" ? o.value : o.label)));
989
+ }
990
+ if (handle.selectOption === undefined)
991
+ throw new Error("this Playwright build exposes no option-selecting method");
992
+ await handle.selectOption(matched.value);
993
+ recordAction("select", ref, "replace");
994
+ recordPerformed("select", { role: element.role, name: element.name, selector: element.selector }, null, matched.value);
995
+ await settleAfterAction("select", ref);
996
+ },
997
+ async submit(ref) {
998
+ assertUsable();
999
+ const { element, handle } = resolve(ref);
1000
+ await assertOperable(ref, handle);
1001
+ // Submitted through the form's own submit control rather than by synthesising a bare
1002
+ // submit event: that is what a person's click does, it names the submitter the page's
1003
+ // handler reads, and it runs the form's validation. A form with no such control is
1004
+ // submitted by script on some other control — refused, not guessed at.
1005
+ const outcome = await handle.evaluate((el) => {
1006
+ const form = el.closest === undefined ? null : el.closest("form");
1007
+ if (form === null)
1008
+ return "noForm";
1009
+ const control = form.querySelector('button[type="submit"], input[type="submit"], button:not([type])');
1010
+ if (control === null)
1011
+ return "noSubmitControl";
1012
+ form.requestSubmit(control);
1013
+ return "submitted";
1014
+ });
1015
+ if (outcome !== "submitted")
1016
+ throw new NotSubmittableError(ref, outcome);
1017
+ recordAction("submit", ref, null);
1018
+ recordPerformed("submit", { role: element.role, name: element.name, selector: element.selector }, null, null);
1019
+ await settleAfterAction("submit", ref);
1020
+ },
1021
+ async waitFor(target, waitOptions = {}) {
1022
+ assertUsable();
1023
+ if (isEmptyWaitTarget(target)) {
1024
+ throw new Error("browser_wait_for needs a target: give a role, a name, or both. Waiting for nothing in particular " +
1025
+ `would return the first element on the page, which is not what any caller means. Got: ${describeWaitTarget(target)}.`);
1026
+ }
1027
+ const timeoutMs = clampWaitTimeout(waitOptions.timeoutMs);
1028
+ const startedAt = Date.now();
1029
+ for (;;) {
1030
+ assertUsable();
1031
+ // Re-enumerates each poll rather than watching one selector: the port has no
1032
+ // selector to watch, and a fresh snapshot is what makes the matched element
1033
+ // actionable at all. That cost is real, and is why the interval is not tighter.
1034
+ const snapshot = await api.snapshot();
1035
+ const matches = snapshot.elements.filter((e) => matchesWaitTarget(e, target));
1036
+ const waitedMs = Date.now() - startedAt;
1037
+ if (matches.length > 0) {
1038
+ return { appeared: true, waitedMs, timeoutMs, matched: matches[0], matchCount: matches.length, snapshot };
1039
+ }
1040
+ if (waitedMs >= timeoutMs) {
1041
+ return { appeared: false, waitedMs, timeoutMs, matched: null, matchCount: 0, snapshot };
1042
+ }
1043
+ await new Promise((done) => setTimeout(done, WAIT_POLL_INTERVAL_MS));
1044
+ }
1045
+ },
1046
+ async injectStorageState(origin, state) {
1047
+ assertUsable();
1048
+ if (typeof session.injectStorageState !== "function") {
1049
+ // Honest degradation (rule 7), same shape the groundIdentity install
1050
+ // failure above uses: an older runtime-browser build predates this
1051
+ // primitive, so nothing was injected and the reason travels on the
1052
+ // result rather than a silent no-op or a thrown TypeError.
1053
+ return {
1054
+ cookiesInjected: 0,
1055
+ localStorageInjected: 0,
1056
+ localStorageSkipped: "this build of @descryy/runtime-browser does not export injectStorageState -- nothing was injected. " +
1057
+ "Republish runtime-browser at a version that includes BrowserSession.injectStorageState.",
1058
+ };
1059
+ }
1060
+ return session.injectStorageState({
1061
+ origin,
1062
+ ...(state.cookies === undefined ? {} : { cookies: state.cookies }),
1063
+ ...(state.localStorage === undefined ? {} : { localStorage: state.localStorage }),
1064
+ });
1065
+ },
345
1066
  async drainEvidence() {
346
1067
  const observation = collect(emitted, actions, unsettled);
347
1068
  emitted.length = 0;
348
1069
  actions.length = 0;
349
1070
  unsettled.length = 0;
350
- return observation;
1071
+ return withIdentityWarnings(observation, identityWarnings);
351
1072
  },
352
1073
  performedSteps() {
353
1074
  return [...performed];
354
1075
  },
1076
+ storageStateSaveFailure: () => storageStateSaveError,
1077
+ /**
1078
+ * §4.2's probe. Reads the page and counts; decides nothing — `estimateReach`
1079
+ * is the only thing that turns these counts into a claim, so a change of
1080
+ * policy never touches a driver.
1081
+ *
1082
+ * The body lives in `page-probe.ts` rather than here: this file belongs to
1083
+ * another lane, and a six-line delegation is mergeable where two hundred
1084
+ * lines of DOM walking would not be.
1085
+ */
1086
+ async fingerprint() {
1087
+ assertUsable();
1088
+ const [root] = await session.page.$$("html");
1089
+ if (root === undefined) {
1090
+ // No document element at all. Refusing is right: a fingerprint of
1091
+ // zeroes would read as a page with nothing on it.
1092
+ throw new Error("the page exposed no document element to probe");
1093
+ }
1094
+ try {
1095
+ return await root.evaluate(probeInPage, PROBE_CONSTANTS);
1096
+ }
1097
+ finally {
1098
+ await root.dispose();
1099
+ }
1100
+ },
355
1101
  async close() {
356
1102
  if (finalObservation !== null)
357
1103
  return finalObservation;
@@ -361,16 +1107,32 @@ async function openSession(runtime, session, launchOptions, options) {
361
1107
  await actionCollector.stop().catch(() => { });
362
1108
  await consoleCollector.stop().catch(() => { });
363
1109
  await networkCollector.stop().catch(() => { });
364
- const observation = collect(emitted, actions, unsettled);
1110
+ const observation = withIdentityWarnings(collect(emitted, actions, unsettled), identityWarnings);
365
1111
  emitted.length = 0;
366
1112
  actions.length = 0;
367
1113
  unsettled.length = 0;
368
1114
  await dropRefs();
1115
+ // Before `session.close()`, not after: a closed context holds no
1116
+ // cookies to read, and a save that ran afterwards would write a file
1117
+ // that looks like a session with nothing in it — which is worse than no
1118
+ // file, because the next run would seed from it and silently drive the
1119
+ // logged-out surface. A failure here is reported as a note by the
1120
+ // caller rather than swallowed, but it never fails the close: evidence
1121
+ // already collected must not be lost to a write that did not work.
1122
+ if (launchOptions.saveStorageStateTo !== undefined && session.saveStorageState !== undefined) {
1123
+ try {
1124
+ await session.saveStorageState(launchOptions.saveStorageStateTo);
1125
+ }
1126
+ catch (error) {
1127
+ storageStateSaveError = error instanceof Error ? error.message : String(error);
1128
+ }
1129
+ }
369
1130
  await session.close().catch(() => { });
370
1131
  finalObservation = observation;
371
1132
  return observation;
372
1133
  },
373
1134
  };
1135
+ return api;
374
1136
  }
375
1137
  /** Turns collector evidence into the port's vocabulary. Joins request/response on `requestId`, keeping `status: null` (not guessed) when unanswered. Exported for `browser-request-outcome-mapping.test.ts`. */
376
1138
  export function collect(emitted, actions, unsettled) {
@@ -385,51 +1147,164 @@ export function collect(emitted, actions, unsettled) {
385
1147
  // request sharing that URL could read back `status: 200` from one sibling
386
1148
  // and `outcome: "unanswered"` from another — a self-contradictory pair
387
1149
  // that is a real, reported defect (P6-D5), not a hypothetical one.
1150
+ //
1151
+ // **B4: that pairing was necessary and was not sufficient.** Three routes to
1152
+ // a false fact survived it, each verified here rather than reasoned about,
1153
+ // and each fixed at the line it occurs on below: the pair itself was never
1154
+ // checked for self-consistency across a boundary that versions separately;
1155
+ // "paired" was a set-membership test on the URL rather than a record of what
1156
+ // this request consumed, so an in-flight sibling read as failed; and the
1157
+ // HTTP_ERROR `descry-runtime` emits *alongside* a >= 400 NETWORK_RESPONSE
1158
+ // took a second queue slot, handing one exchange's 500 to two requests.
388
1159
  const byRequestId = new Map();
389
- // FIFO per URL, fed only by id-less terminal events. A URL is not unique
390
- // the way a requestId is — a page can issue the same id-less URL more than
391
- // once — so a shared cache must hand each terminal event's (status,
392
- // outcome) pair to at most one request, in arrival order, rather than
393
- // broadcasting the most recent one to every request sharing that URL. An
394
- // id-bearing terminal event is deliberately excluded from this pool: it is
395
- // already claimed evidence for one specific request, and folding it into
396
- // the shared id-less pool would just relocate the same contamination.
1160
+ // C3. A **transport-level** identity, when the collector carries one: an id
1161
+ // the browser's own request object owns, not a header an application chose
1162
+ // to propagate. It outranks `requestId` because it cannot be absent for
1163
+ // application reasons, and where it is present the whole out-of-order
1164
+ // question below simply does not arise. `descry-runtime`'s network
1165
+ // collector already holds this identity — its pending map is keyed by the
1166
+ // Playwright `Request` object itself, "not a URL or an id, which could
1167
+ // collide across concurrent requests to the same endpoint" — and does not
1168
+ // yet emit it. Read here the same way `outcome` was read before its
1169
+ // producer shipped: validated, optional, and absent degrades to the join
1170
+ // below rather than to a guess. See
1171
+ // `DEC-NEXT-transport-level-exchange-id-for-browser-requests`.
1172
+ const byExchangeId = new Map();
1173
+ // FIFO per URL, fed only by unidentified terminal events. A URL is not
1174
+ // unique the way an id is — a page can issue the same URL more than once —
1175
+ // so a shared cache must hand each terminal event's (status, outcome) pair
1176
+ // to at most one request, in arrival order, rather than broadcasting the
1177
+ // most recent one to every request sharing that URL. An id-bearing terminal
1178
+ // event is deliberately excluded from this pool: it is already claimed
1179
+ // evidence for one specific request, and folding it into the shared pool
1180
+ // would just relocate the same contamination.
1181
+ //
1182
+ // **C3: arrival order is not an answer, so it is no longer used as one.**
1183
+ // Two unidentified requests to one URL whose responses arrive out of order
1184
+ // swap records, and nothing at this layer can tell an in-order pair from a
1185
+ // swapped one. A FIFO is therefore right about half the time and silent
1186
+ // when it is wrong — the exact shape rule 2 forbids, because the failure
1187
+ // leaves no trace in the reply. Where the assignment is genuinely
1188
+ // undetermined the queue is not consulted at all and every request in that
1189
+ // bucket is reported `unattributed` with the terminal events disclosed by
1190
+ // URL instead. Where it is determined — one unidentified request, or
1191
+ // several whose records are indistinguishable from one another, so that
1192
+ // every possible assignment yields the same answer — it still pairs, since
1193
+ // refusing there would trade a silent wrong answer for a loud missing one
1194
+ // that nothing required.
397
1195
  const queueByUrl = new Map();
398
- const pairedRequestIds = new Set();
399
- // Populated only from id-less terminal events, to match `queueByUrl`'s own
400
- // restriction: an id-bearing event answers `pairedRequestIds`, not this —
401
- // otherwise an id-less request sharing a URL with an id-claimed response
402
- // would read `wasPaired: true` from an event it was never eligible to
403
- // consume, and report "undefined" (read by `isFailedRequest` as a failure)
404
- // instead of the "pending" it actually is.
405
- const pairedUrls = new Set();
1196
+ const warnings = new Set();
1197
+ // URLs that produced an id-less NETWORK_RESPONSE, so the duplicate-HTTP_ERROR
1198
+ // exclusion below can require its sibling rather than assume it. A build that
1199
+ // ever emitted a numeric-status HTTP_ERROR *alone* would otherwise have it
1200
+ // silently dropped, turning a real 4xx into "pending".
1201
+ const respondedUrls = new Set();
1202
+ for (const evidence of emitted) {
1203
+ if (evidence.eventType !== "NETWORK_RESPONSE" || evidence.requestId !== null)
1204
+ continue;
1205
+ if (exchangeIdOf(evidence.payload) !== null)
1206
+ continue;
1207
+ const url = evidence.payload["url"];
1208
+ if (typeof url === "string")
1209
+ respondedUrls.add(url);
1210
+ }
406
1211
  for (const evidence of emitted) {
407
1212
  if (evidence.eventType !== "NETWORK_RESPONSE" && evidence.eventType !== "HTTP_ERROR")
408
1213
  continue;
409
1214
  const url = evidence.payload["url"];
410
- if (evidence.requestId !== null)
411
- pairedRequestIds.add(evidence.requestId);
412
- else if (typeof url === "string")
413
- pairedUrls.add(url);
414
1215
  const rawStatus = evidence.payload["status"];
415
- const record = {
416
- status: typeof rawStatus === "number" ? rawStatus : null,
417
- outcome: requestOutcomeOf(evidence.payload),
418
- };
419
- if (record.status === null && record.outcome === undefined)
1216
+ const status = typeof rawStatus === "number" ? rawStatus : null;
1217
+ // B4. The pair is validated, not just each field: this payload crosses a
1218
+ // package boundary that versions separately, which is the same reason
1219
+ // `requestOutcomeOf` validates instead of casting. A numeric status means
1220
+ // a response arrived; "unanswered" means none ever did. Both cannot be
1221
+ // true of one request, and forwarding the pair verbatim is what produced
1222
+ // the `status: 200` + `outcome: "unanswered"` record the cal.com pass
1223
+ // reported. Neither field can be trusted to correct the other, so the
1224
+ // directly-observed status is kept and the outcome degrades to the same
1225
+ // "not reported" state an older collector build produces — and it is
1226
+ // named in the reply rather than dropped quietly (rule 7).
1227
+ const outcome = reconcile(status, requestOutcomeOf(evidence.payload), url, warnings);
1228
+ const record = { status, outcome };
1229
+ const exchangeId = exchangeIdOf(evidence.payload);
1230
+ if (exchangeId !== null) {
1231
+ byExchangeId.set(exchangeId, record);
420
1232
  continue;
1233
+ }
421
1234
  if (evidence.requestId !== null) {
422
1235
  byRequestId.set(evidence.requestId, record);
423
1236
  continue;
424
1237
  }
425
1238
  if (typeof url !== "string")
426
1239
  continue;
1240
+ // B4. One exchange, one slot. `descry-runtime`'s collector emits
1241
+ // NETWORK_RESPONSE *and* a payload-sharing HTTP_ERROR for a single
1242
+ // response with status >= 400, and a numeric status is the only shape in
1243
+ // which it ever emits HTTP_ERROR — every other HTTP_ERROR it produces
1244
+ // (requestfailed, its own timeout, a websocket socketerror, a session
1245
+ // closing mid-flight) carries `status: null` and has no NETWORK_RESPONSE
1246
+ // beside it. Keyed by requestId that duplicate is harmless (same content,
1247
+ // one map entry); in this queue it took a second slot, so one 500
1248
+ // exchange handed a 500 to a second request that never got a response at
1249
+ // all. Excluded here only — an id-keyed one still records, in case a
1250
+ // build ever emits it without its sibling.
1251
+ if (evidence.eventType === "HTTP_ERROR" && status !== null && respondedUrls.has(url))
1252
+ continue;
427
1253
  const queue = queueByUrl.get(url);
428
1254
  if (queue === undefined)
429
1255
  queueByUrl.set(url, [record]);
430
1256
  else
431
1257
  queue.push(record);
432
1258
  }
1259
+ /** The record this evidence owns outright, or undefined when it must draw from the shared
1260
+ * URL queue. Exchange id first: it identifies the transport exchange, where `requestId`
1261
+ * identifies whatever the application decided to label. */
1262
+ const keyedRecordFor = (evidence) => {
1263
+ const exchangeId = exchangeIdOf(evidence.payload);
1264
+ if (exchangeId !== null) {
1265
+ const byExchange = byExchangeId.get(exchangeId);
1266
+ if (byExchange !== undefined)
1267
+ return byExchange;
1268
+ }
1269
+ return evidence.requestId === null ? undefined : byRequestId.get(evidence.requestId);
1270
+ };
1271
+ // C3. How many requests to each URL have no record of their own — exactly the set that
1272
+ // draws from the shared queue, and therefore exactly the set the queue can be wrong about.
1273
+ // Counted after the maps are built, not from the presence of an id: a request carrying an
1274
+ // id that never came back on a terminal event falls through to the queue like any other,
1275
+ // and counting it as identified would leave the queue one short of the requests using it.
1276
+ const unkeyedByUrl = new Map();
1277
+ for (const evidence of emitted) {
1278
+ if (evidence.eventType !== "NETWORK_REQUEST")
1279
+ continue;
1280
+ const url = evidence.payload["url"];
1281
+ if (typeof url !== "string")
1282
+ continue;
1283
+ if (keyedRecordFor(evidence) !== undefined)
1284
+ continue;
1285
+ unkeyedByUrl.set(url, (unkeyedByUrl.get(url) ?? 0) + 1);
1286
+ }
1287
+ // C3. The URLs whose assignment is undetermined. Not "has duplicates" — having two requests
1288
+ // to one URL is ordinary and usually harmless. It is undetermined only when two different
1289
+ // assignments would produce two different replies.
1290
+ const undeterminedUrls = new Set();
1291
+ for (const [url, records] of queueByUrl) {
1292
+ const unkeyed = unkeyedByUrl.get(url) ?? 0;
1293
+ if (unkeyed <= 1)
1294
+ continue;
1295
+ const first = records[0];
1296
+ if (first === undefined)
1297
+ continue;
1298
+ const alike = records.every((record) => record.status === first.status && record.outcome === first.outcome);
1299
+ // Enough alike records to go round means every request gets the same one, so which is
1300
+ // which never comes up. One short means at least one request is still in flight and
1301
+ // nothing can say which — and "pending" on the wrong one is a false fact about a healthy
1302
+ // request, which is the defect B4 fixed in its first costume.
1303
+ if (alike && records.length >= unkeyed)
1304
+ continue;
1305
+ undeterminedUrls.add(url);
1306
+ warnings.add(undeterminedJoinWarning(url, unkeyed, records));
1307
+ }
433
1308
  const requests = [];
434
1309
  const consoleErrors = [];
435
1310
  for (const evidence of emitted) {
@@ -438,19 +1313,34 @@ export function collect(emitted, actions, unsettled) {
438
1313
  const url = evidence.payload["url"];
439
1314
  if (typeof method !== "string" || typeof url !== "string")
440
1315
  continue;
441
- const requestId = evidence.requestId;
442
- const wasPaired = (requestId !== null && pairedRequestIds.has(requestId)) || pairedUrls.has(url);
443
- // The id match wins when it exists; only a request with no id record of
444
- // its own draws from the shared URL queue, and it draws once.
445
- const matched = (requestId === null ? undefined : byRequestId.get(requestId)) ?? queueByUrl.get(url)?.shift();
446
- // Paired outcome wins; no terminal event means still in-flight
447
- // ("pending"); an unrecognised outcome (older runtime build) leaves the
448
- // field undefined — `isFailedRequest`'s fallback handles that, not this.
449
- const outcome = matched?.outcome ?? (wasPaired ? undefined : "pending");
1316
+ // The keyed match wins when it exists; only a request with no record of its own draws
1317
+ // from the shared URL queue, and it draws once. C3: on an undetermined URL the queue is
1318
+ // not consulted at all — consuming from it is what produced the swap.
1319
+ const keyed = keyedRecordFor(evidence);
1320
+ const undetermined = keyed === undefined && undeterminedUrls.has(url);
1321
+ const matched = keyed ?? (undetermined ? undefined : queueByUrl.get(url)?.shift());
1322
+ // B4. Paired is what this request *consumed*, not what its URL was seen
1323
+ // in. It used to be a set-membership test on the URL, so a second,
1324
+ // genuinely in-flight request to an already-answered URL came out with
1325
+ // no outcome at all — which `isFailedRequest`'s older-build fallback
1326
+ // reads as a failure, because its status is null. A healthy in-flight
1327
+ // request reported as a failed one. No record consumed means no terminal
1328
+ // event was ever attributable to this request, and "pending" is exactly
1329
+ // that fact. An unrecognised outcome on a record that *was* consumed
1330
+ // still leaves the field undefined, which is the older-build case the
1331
+ // fallback exists for.
1332
+ // C3. `unattributed` before `pending`: a terminal event for this URL exists, so claiming
1333
+ // the request is still in flight would be as wrong as handing it a sibling's status —
1334
+ // and wrong in the direction that reads as healthy.
1335
+ const outcome = undetermined
1336
+ ? "unattributed"
1337
+ : matched === undefined
1338
+ ? "pending"
1339
+ : matched.outcome;
450
1340
  requests.push({
451
1341
  method,
452
1342
  url,
453
- status: matched?.status ?? null,
1343
+ status: undetermined ? null : (matched?.status ?? null),
454
1344
  ...(outcome === undefined ? {} : { outcome }),
455
1345
  stackText: renderStack(evidence.stackTrace),
456
1346
  stackTrace: evidence.stackTrace,
@@ -473,15 +1363,53 @@ export function collect(emitted, actions, unsettled) {
473
1363
  });
474
1364
  }
475
1365
  }
476
- return { actions: [...actions], consoleErrors, requests, unsettled: [...unsettled] };
1366
+ return { actions: [...actions], consoleErrors, requests, unsettled: [...unsettled], evidenceWarnings: [...warnings] };
1367
+ }
1368
+ /** B4's pair check. Returns the outcome that may be published alongside `status`, dropping it —
1369
+ * and naming the drop in `warnings` — when the two cannot both be true. Deliberately one-sided:
1370
+ * a status is a number the collector read off a real response object, an outcome is a label it
1371
+ * derived, so when they disagree the derived half is the one that goes. `url` only names the
1372
+ * request in the warning; a missing one degrades the text, never the check. */
1373
+ function reconcile(status, outcome, url, warnings) {
1374
+ const impossible = (outcome === "unanswered" && status !== null) || (outcome === "answered" && status === null);
1375
+ if (!impossible)
1376
+ return outcome;
1377
+ const where = typeof url === "string" ? ` for ${url}` : "";
1378
+ warnings.add(`Descry's browser collector reported a request${where} as both "${outcome}" and ` +
1379
+ `status ${status === null ? "null" : String(status)}, which cannot both be true. The status is ` +
1380
+ "what was read off the response, so it is kept and the outcome is dropped — this request is " +
1381
+ "reported without one rather than with a contradiction. Usually a version mismatch between " +
1382
+ "@descryy/mcp and @descryy/runtime-browser.");
1383
+ return undefined;
477
1384
  }
478
- /** Reads `outcome` off an untyped cross-package payload; validated not cast, since this build doesn't depend on `@descryy/runtime-browser`'s types — unrecognised values degrade to undefined, never a guessed literal. */
1385
+ /** Reads `outcome` off an untyped cross-package payload; validated not cast, since this build doesn't depend on `@descryy/runtime-browser`'s types — unrecognised values degrade to undefined, never a guessed literal. `"unattributed"` is deliberately not accepted here: it is this package's verdict about a join it could not make, and a collector is in no position to assert it. */
479
1386
  function requestOutcomeOf(payload) {
480
1387
  const raw = payload["outcome"];
481
1388
  if (raw === "answered" || raw === "unanswered" || raw === "pending")
482
1389
  return raw;
483
1390
  return undefined;
484
1391
  }
1392
+ /** Reads the collector's transport-level exchange id, when it carries one. Validated not cast,
1393
+ * and an empty string is treated as absent — an id that identifies nothing would put every
1394
+ * request into one bucket, which is worse than having none. */
1395
+ function exchangeIdOf(payload) {
1396
+ const raw = payload["exchangeId"];
1397
+ return typeof raw === "string" && raw !== "" ? raw : null;
1398
+ }
1399
+ /** C3's disclosure. Says what was observed and refuses to say whose it was — the terminal
1400
+ * events are named in full so a real 500 is never lost, only left unassigned. */
1401
+ function undeterminedJoinWarning(url, unkeyed, records) {
1402
+ const describe = (record) => `${record.status === null ? "no status" : `status ${record.status}`}${record.outcome === undefined ? "" : ` (${record.outcome})`}`;
1403
+ const shortfall = records.length < unkeyed
1404
+ ? ` Fewer terminal events arrived than requests were issued, so at least one of them was still in flight, and which one is the same unanswerable question.`
1405
+ : "";
1406
+ return (`Descry saw ${unkeyed} requests to ${url} carrying no id of their own, and ${records.length} terminal ` +
1407
+ `event(s) for that URL that do not all say the same thing: ${records.map(describe).join("; ")}.${shortfall} ` +
1408
+ "Pairing them by arrival order would be right roughly half the time and silent when it was wrong, so none " +
1409
+ "of these requests is reported with a status or an outcome. The events just listed did happen — Descry " +
1410
+ "cannot say which request each one belongs to. A transport-level exchange id from the browser collector " +
1411
+ "would remove this; the collector in this build does not emit one.");
1412
+ }
485
1413
  /** Reads `scriptUrlMapping` off an untyped cross-package payload; validated not cast, so a malformed/absent field degrades to "no mapping reported" rather than a half-populated result. */
486
1414
  function scriptUrlOutcomesOf(payload) {
487
1415
  const raw = payload["scriptUrlMapping"];
@@ -518,4 +1446,34 @@ function renderStack(stack) {
518
1446
  })
519
1447
  .join("\n");
520
1448
  }
1449
+ /**
1450
+ * The runtime's auth seed for this launch, or null when the caller named none.
1451
+ *
1452
+ * The origin comes from `targetUrl` and from nowhere else — it is the same
1453
+ * value the registry records as the session's consented origin, so "the seed
1454
+ * is scoped to what was consented" is a property of one derivation rather
1455
+ * than of two that have to agree.
1456
+ */
1457
+ function runtimeAuthFor(launchOptions) {
1458
+ const state = launchOptions.storageState;
1459
+ const headers = launchOptions.extraHttpHeaders;
1460
+ if (state === undefined && headers === undefined)
1461
+ return null;
1462
+ let origin;
1463
+ try {
1464
+ origin = new URL(launchOptions.targetUrl).origin;
1465
+ }
1466
+ catch {
1467
+ // Unparseable targets are refused far above this — the tool cannot mint
1468
+ // consent for a URL it could not read an origin from. Carrying the raw
1469
+ // string keeps this total rather than throwing from inside a launch.
1470
+ origin = launchOptions.targetUrl;
1471
+ }
1472
+ return {
1473
+ origin,
1474
+ ...(state?.cookies === undefined ? {} : { cookies: state.cookies }),
1475
+ ...(state?.localStorage === undefined ? {} : { localStorage: state.localStorage }),
1476
+ ...(headers === undefined ? {} : { extraHttpHeaders: headers }),
1477
+ };
1478
+ }
521
1479
  //# sourceMappingURL=playwright-driver.js.map