dsh-agent-team-ui-testing 0.1.1 → 0.1.2

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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -35
  3. package/annotation-capture.js +111 -0
  4. package/browser-safety.js +45 -1
  5. package/client.js +3709 -134
  6. package/context-menu.js +134 -0
  7. package/core/clock.js +88 -0
  8. package/core/config-store.js +566 -0
  9. package/core/config.js +892 -0
  10. package/core/contracts.js +240 -0
  11. package/core/errors.js +156 -0
  12. package/core/evidence.js +141 -0
  13. package/core/geometry.js +217 -0
  14. package/core/identity.js +180 -0
  15. package/core/index.js +24 -0
  16. package/core/p0.js +1101 -0
  17. package/core/readiness.js +175 -0
  18. package/core/registry.js +239 -0
  19. package/core/tab-access.js +1350 -0
  20. package/docs/adapters.md +160 -0
  21. package/docs/architecture.md +44 -9
  22. package/docs/capability-tiers.md +20 -4
  23. package/docs/capture-p0.md +39 -2
  24. package/docs/diagnose-reason-codes.md +157 -0
  25. package/docs/element-picker.md +24 -6
  26. package/docs/getting-started.md +47 -1
  27. package/docs/index.md +10 -7
  28. package/docs/multi-tab.md +55 -20
  29. package/docs/network-p0.md +2 -2
  30. package/docs/panel-context-menu.md +41 -0
  31. package/docs/t2-structured-gui.md +2 -2
  32. package/docs/t3-vision.md +6 -1
  33. package/docs/t4-dynamic-behavior.md +1 -0
  34. package/docs/tools.md +65 -7
  35. package/docs/verification.md +26 -2
  36. package/docs/viewport-control.md +21 -8
  37. package/index.js +3263 -251
  38. package/integration/cross-environment.js +1535 -0
  39. package/integration/diagnose.js +945 -0
  40. package/integration/tab-principal.js +493 -0
  41. package/locale/en.json +1 -1
  42. package/locale/zh.json +1 -1
  43. package/network-p0.js +24 -7
  44. package/package.json +4 -3
  45. package/select-control.js +298 -0
  46. package/skills/ui-testing-common/SKILL.md +54 -0
  47. package/skills/ui-testing-fake/SKILL.md +43 -0
  48. package/skills/{ui-testing → ui-testing-web}/SKILL.md +64 -24
  49. package/skills/{ui-testing → ui-testing-web}/manifests/declared-capabilities.json +9 -2
  50. package/skills/{ui-testing → ui-testing-web}/references/contracts/capability.md +3 -3
  51. package/skills/{ui-testing → ui-testing-web}/references/contracts/evidence.md +1 -1
  52. package/skills/{ui-testing → ui-testing-web}/references/contracts/operation.md +1 -1
  53. package/skills/{ui-testing → ui-testing-web}/references/core/fallback.md +1 -1
  54. package/skills/{ui-testing → ui-testing-web}/references/core/page-identity.md +3 -2
  55. package/skills/{ui-testing → ui-testing-web}/references/core/selection.md +1 -1
  56. package/skills/{ui-testing → ui-testing-web}/references/core/tool-routing-and-limits.md +6 -3
  57. package/skills/{ui-testing → ui-testing-web}/references/platforms/index.md +1 -1
  58. package/skills/{ui-testing → ui-testing-web}/references/platforms/web.md +2 -2
  59. package/skills/ui-testing-web/references/scenarios/annotations.md +104 -0
  60. package/skills/{ui-testing → ui-testing-web}/references/scenarios/functional.md +1 -1
  61. package/skills/{ui-testing → ui-testing-web}/references/scenarios/visual-motion.md +1 -1
  62. package/skills/{ui-testing → ui-testing-web}/references/tiers/t1-programmatic.md +2 -2
  63. package/skills/{ui-testing → ui-testing-web}/references/tiers/t3-static-visual.md +2 -2
  64. package/skills/{ui-testing → ui-testing-web}/references/tiers/t4-dynamic-visual.md +2 -1
  65. package/targets/fake/adapter.js +1163 -0
  66. package/targets/fake/fixtures.js +177 -0
  67. package/targets/web/adapter.js +1231 -0
  68. package/targets/web/provider.js +415 -0
  69. package/skills/ui-testing/references/scenarios/annotations.md +0 -87
  70. /package/skills/{ui-testing → ui-testing-web}/references/core/dynamic-visual.md +0 -0
  71. /package/skills/{ui-testing → ui-testing-web}/references/core/network-and-state.md +0 -0
  72. /package/skills/{ui-testing → ui-testing-web}/references/core/report-template.md +0 -0
  73. /package/skills/{ui-testing → ui-testing-web}/references/core/reporting.md +0 -0
  74. /package/skills/{ui-testing → ui-testing-web}/references/core/safety.md +0 -0
  75. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-examples.md +0 -0
  76. /package/skills/{ui-testing → ui-testing-web}/references/core/verdict-gate.md +0 -0
  77. /package/skills/{ui-testing → ui-testing-web}/references/core/verification.md +0 -0
  78. /package/skills/{ui-testing → ui-testing-web}/references/core/workflow.md +0 -0
  79. /package/skills/{ui-testing → ui-testing-web}/references/tiers/t2-structured.md +0 -0
  80. /package/skills/{ui-testing → ui-testing-web}/templates/capability-note.md +0 -0
  81. /package/skills/{ui-testing → ui-testing-web}/templates/platform.md +0 -0
  82. /package/skills/{ui-testing → ui-testing-web}/templates/regression-case.md +0 -0
  83. /package/skills/{ui-testing → ui-testing-web}/templates/scenario.md +0 -0
@@ -0,0 +1,1231 @@
1
+ /**
2
+ * Web (Chromium) adapter boundary: TargetAdapter + CapabilityAdapter.
3
+ *
4
+ * This module is the *only* place where the web environment declares its target lifecycle, its
5
+ * tool inventory and its execution classes. It deliberately owns no browser protocol: Chromium/CDP
6
+ * live behind the injected `dispatch` (capability execution) and the shared `registry` (target
7
+ * discovery). Nothing here starts a browser, reads user configuration, or imports the host entry.
8
+ *
9
+ * Contracts follow docs/internal/cross-environment-foundation-m0.md §2.1 (v1):
10
+ * - TargetAdapter: { manifest, probe, createSession, dispose }
11
+ * - TargetSession: { sessionId, environment, generation, configRevision, target,
12
+ * listTargets, bind, close, dispose }
13
+ * - CapabilityAdapter: { manifest, operationStatus, invoke, preflight, dispose }
14
+ *
15
+ * `probe()` is a bounded, low-interference check (M0 §5.2): it may look at the filesystem and at
16
+ * registry state, and must never launch Chromium. Missing evidence is reported as `not-probed`,
17
+ * never as a fabricated version or a static "valid" boolean.
18
+ */
19
+
20
+ import { existsSync } from 'node:fs';
21
+ import { delimiter, isAbsolute, join } from 'node:path';
22
+ import process from 'node:process';
23
+
24
+ import {
25
+ AdapterError,
26
+ AVAILABILITY,
27
+ CONTRACT_VERSION,
28
+ DEFAULT_WEB_PROVIDER,
29
+ ERROR_CODES,
30
+ READINESS_STATUS,
31
+ SUPPORT,
32
+ WEB_PROVIDER_IDS,
33
+ WEB_PROVIDER_REASONS,
34
+ WEB_PROVIDER_VENDOR,
35
+ axis,
36
+ createEvidenceEnvelope,
37
+ createReadinessReport,
38
+ isImplementedWebProvider,
39
+ newTargetIdentity,
40
+ webProviderSupport,
41
+ } from '../../core/index.js';
42
+
43
+ /**
44
+ * Capability ids declared by the web adapter (mirrors core/contracts.js ENVIRONMENT_CAPABILITIES.web).
45
+ * Declaring support is separate from live availability: see `probe`/`operationStatus`.
46
+ */
47
+ export const WEB_CAPABILITIES = Object.freeze([
48
+ 'web.t1',
49
+ 'web.t2',
50
+ 'web.t3',
51
+ 'web.t4',
52
+ 'web.network',
53
+ 'web.tabs',
54
+ 'web.p0.annotations',
55
+ 'web.p0.capture',
56
+ 'web.p0.network',
57
+ 'web.p0.display',
58
+ 'web.p0.canvas',
59
+ 'web.p0.targets',
60
+ ]);
61
+
62
+ /**
63
+ * Execution classes understood by the composition root (M0 §2.1).
64
+ *
65
+ * The adapter never changes the routing branch; it only declares which class an operation belongs
66
+ * to and validates a caller-supplied class against that declaration.
67
+ */
68
+ export const WEB_EXECUTION_CLASSES = Object.freeze({
69
+ PAGE: 'page',
70
+ READ_ONLY: 'readOnly',
71
+ HOST_ONLY: 'hostOnly',
72
+ NETWORK_READ: 'networkRead',
73
+ DIAGNOSTIC: 'diagnostic',
74
+ INDEPENDENT_HTTP: 'independentHttp',
75
+ TABS: 'tabs',
76
+ });
77
+
78
+ const EXECUTION_CLASS_SET = new Set(Object.values(WEB_EXECUTION_CLASSES));
79
+
80
+ /**
81
+ * Only these execution classes need a bound page target. `readOnly`, `hostOnly`, `networkRead`,
82
+ * `diagnostic` and `independentHttp` are host/runtime-level operations that must keep working when
83
+ * no page is bound (A06: the 36-tool surface must not regress into TARGET_NOT_BOUND).
84
+ */
85
+ const TARGET_BOUND_CLASSES = new Set([WEB_EXECUTION_CLASSES.PAGE, WEB_EXECUTION_CLASSES.TABS]);
86
+
87
+ /**
88
+ * The 36 `browser_*` operations: name -> capability id.
89
+ *
90
+ * The public contract stays environment-neutral (`operation` -> capability id); the web-specific
91
+ * tool names are inventory data, not a second interface.
92
+ */
93
+ export const WEB_TOOL_OPERATIONS = Object.freeze({
94
+ // T1 — programmatic interface (+ the tab/lease surface).
95
+ browser_navigate: 'web.t1',
96
+ browser_eval: 'web.t1',
97
+ browser_screenshot: 'web.t1',
98
+ browser_input: 'web.t1',
99
+ browser_network: 'web.t1',
100
+ browser_computed_style: 'web.t1',
101
+ browser_http_request: 'web.t1',
102
+ browser_state: 'web.t1',
103
+ browser_capabilities: 'web.t1',
104
+ browser_viewport: 'web.t1',
105
+ browser_tabs: 'web.tabs',
106
+ browser_annotation: 'web.p0.annotations',
107
+
108
+ // T2 — structured GUI.
109
+ browser_snapshot: 'web.t2',
110
+ browser_click: 'web.t2',
111
+ browser_type: 'web.t2',
112
+ browser_scroll: 'web.t2',
113
+ browser_fill_form: 'web.t2',
114
+ browser_node_state: 'web.t2',
115
+ browser_wait_for: 'web.t2',
116
+
117
+ // T3 — static vision.
118
+ browser_vision_image: 'web.t3',
119
+ browser_vision_find: 'web.t3',
120
+ browser_vision_compare: 'web.t3',
121
+ browser_vision_act: 'web.t3',
122
+ browser_vision_baseline: 'web.t3',
123
+ browser_vision_check: 'web.t3',
124
+
125
+ // P0-B — network evidence and control.
126
+ browser_network_session: 'web.network',
127
+ browser_network_events: 'web.network',
128
+ browser_network_detail: 'web.network',
129
+ browser_network_trace: 'web.network',
130
+ browser_network_control: 'web.network',
131
+ browser_network_evidence: 'web.network',
132
+ browser_network_subscribe: 'web.network',
133
+
134
+ // T4 — dynamic behaviour.
135
+ browser_dynamic_session: 'web.t4',
136
+ browser_motion_observe: 'web.t4',
137
+ browser_motion_analyze: 'web.t4',
138
+ browser_motion_control: 'web.t4',
139
+ browser_dynamic_correlate: 'web.t4',
140
+ });
141
+
142
+ /** Operation names, in declaration order. Used for lookups and manifest ordering. */
143
+ export const WEB_TOOL_OPERATION_NAMES = Object.freeze(Object.keys(WEB_TOOL_OPERATIONS));
144
+
145
+ function spec(name, capability, executionClass, executionClasses) {
146
+ const entry = { name, capability, executionClass };
147
+ if (Array.isArray(executionClasses) && executionClasses.length > 1) {
148
+ entry.executionClasses = Object.freeze([...executionClasses]);
149
+ }
150
+ return Object.freeze(entry);
151
+ }
152
+
153
+ /**
154
+ * Per-operation execution class declaration.
155
+ *
156
+ * `executionClass` is the default; `executionClasses` lists every class the operation may use when
157
+ * the concrete action changes it (e.g. `browser_viewport` get vs set). `resolveExecutionClass()`
158
+ * applies the action-level refinement that `preflight` exposes to the composition root.
159
+ */
160
+ export const WEB_TOOL_OPERATION_SPECS = Object.freeze([
161
+ // T1
162
+ spec('browser_navigate', 'web.t1', 'page'),
163
+ spec('browser_eval', 'web.t1', 'page'),
164
+ spec('browser_screenshot', 'web.t1', 'page'),
165
+ spec('browser_input', 'web.t1', 'page'),
166
+ spec('browser_network', 'web.t1', 'page'),
167
+ spec('browser_computed_style', 'web.t1', 'page'),
168
+ spec('browser_http_request', 'web.t1', 'independentHttp'),
169
+ spec('browser_state', 'web.t1', 'diagnostic', ['diagnostic', 'readOnly']),
170
+ spec('browser_capabilities', 'web.t1', 'diagnostic', ['diagnostic', 'readOnly']),
171
+ spec('browser_viewport', 'web.t1', 'readOnly', ['readOnly', 'page']),
172
+ spec('browser_tabs', 'web.tabs', 'tabs'),
173
+ // P0-D annotations: `read` is a cached host read, every write is host-only (no page queue/lease).
174
+ spec('browser_annotation', 'web.p0.annotations', 'readOnly', ['readOnly', 'hostOnly']),
175
+
176
+ // T2
177
+ spec('browser_snapshot', 'web.t2', 'page'),
178
+ spec('browser_click', 'web.t2', 'page'),
179
+ spec('browser_type', 'web.t2', 'page'),
180
+ spec('browser_scroll', 'web.t2', 'page'),
181
+ spec('browser_fill_form', 'web.t2', 'page'),
182
+ spec('browser_node_state', 'web.t2', 'page'),
183
+ spec('browser_wait_for', 'web.t2', 'page'),
184
+
185
+ // T3
186
+ spec('browser_vision_image', 'web.t3', 'page'),
187
+ spec('browser_vision_find', 'web.t3', 'page'),
188
+ spec('browser_vision_compare', 'web.t3', 'page'),
189
+ spec('browser_vision_act', 'web.t3', 'page'),
190
+ spec('browser_vision_baseline', 'web.t3', 'page'),
191
+ spec('browser_vision_check', 'web.t3', 'page'),
192
+
193
+ // Network
194
+ spec('browser_network_session', 'web.network', 'hostOnly', ['hostOnly', 'readOnly']),
195
+ spec('browser_network_events', 'web.network', 'networkRead', ['networkRead', 'readOnly']),
196
+ spec('browser_network_detail', 'web.network', 'networkRead', ['networkRead', 'readOnly']),
197
+ spec('browser_network_trace', 'web.network', 'networkRead', ['networkRead', 'readOnly']),
198
+ spec('browser_network_control', 'web.network', 'hostOnly'),
199
+ spec('browser_network_evidence', 'web.network', 'networkRead', ['networkRead', 'readOnly']),
200
+ spec('browser_network_subscribe', 'web.network', 'networkRead', ['networkRead', 'readOnly']),
201
+
202
+ // T4
203
+ spec('browser_dynamic_session', 'web.t4', 'page', ['page', 'hostOnly']),
204
+ spec('browser_motion_observe', 'web.t4', 'page'),
205
+ spec('browser_motion_analyze', 'web.t4', 'page'),
206
+ spec('browser_motion_control', 'web.t4', 'page'),
207
+ spec('browser_dynamic_correlate', 'web.t4', 'readOnly'),
208
+ ]);
209
+
210
+ const SPEC_BY_NAME = new Map(WEB_TOOL_OPERATION_SPECS.map((entry) => [entry.name, entry]));
211
+
212
+ /**
213
+ * Resolve the execution class for one concrete call.
214
+ *
215
+ * Only the action-level cases that M0 calls out are refined here; everything else uses the
216
+ * declared default. Returns `null` for an unknown operation so callers can fail closed.
217
+ */
218
+ export function resolveExecutionClass(operation, args = {}) {
219
+ const entry = SPEC_BY_NAME.get(operation);
220
+ if (!entry) return null;
221
+ const action = typeof args?.action === 'string' ? args.action : null;
222
+
223
+ if (operation === 'browser_viewport' && action === 'set') return 'page';
224
+ if (operation === 'browser_annotation') return action === 'read' ? 'readOnly' : 'hostOnly';
225
+ if (operation === 'browser_network_session') {
226
+ if (action === 'status') return 'readOnly';
227
+ return 'hostOnly';
228
+ }
229
+ if (operation === 'browser_dynamic_session') {
230
+ if (action === 'status' || action === 'reset') return 'hostOnly';
231
+ return 'page';
232
+ }
233
+ return entry.executionClass;
234
+ }
235
+
236
+ function executionClassesOf(operation) {
237
+ const entry = SPEC_BY_NAME.get(operation);
238
+ if (!entry) return [];
239
+ return entry.executionClasses ?? [entry.executionClass];
240
+ }
241
+
242
+ /**
243
+ * Resolve which browser *driver* one snapshot addresses (contract §4.2).
244
+ *
245
+ * The web environment keeps exactly one facade per adapter class (`environment` / `adapterId` stay
246
+ * `"web"`); the provider dimension lives here and only here, so "which browser executes" is answered
247
+ * once instead of at every call site. `provider` missing from the snapshot is the legacy shape
248
+ * (hand-built test snapshots, pre-provider hosts) and means chrome — the documented v1 default.
249
+ *
250
+ * `blocked` is never resolved by substituting another browser (§5): it either reports `null`
251
+ * (executable) or the reason the *selected* provider cannot run, with the §4.4 error code.
252
+ *
253
+ * @param {object|null} snapshot
254
+ * @returns {{provider:string, executionAdapterId:string, implemented:boolean,
255
+ * blocked:string|null, errorCode:string|null}}
256
+ */
257
+ export function resolveWebDriver(snapshot) {
258
+ const provider = snapshot && typeof snapshot.provider === 'string' && snapshot.provider.length > 0
259
+ ? snapshot.provider
260
+ : DEFAULT_WEB_PROVIDER;
261
+ const known = WEB_PROVIDER_IDS.includes(provider);
262
+ const support = webProviderSupport(provider);
263
+ const implemented = isImplementedWebProvider(provider);
264
+ const configReason = snapshot && isPlainObject(snapshot.source)
265
+ && typeof snapshot.source.rejectedProviderReason === 'string'
266
+ ? snapshot.source.rejectedProviderReason
267
+ : null;
268
+ // Config policy first (it is the actionable thing a user can change), then engineering support.
269
+ let blocked = null;
270
+ if (!known) blocked = WEB_PROVIDER_REASONS.UNKNOWN_PROVIDER;
271
+ else if (configReason === WEB_PROVIDER_REASONS.ENVIRONMENT_DISABLED
272
+ || configReason === WEB_PROVIDER_REASONS.PROVIDER_DISABLED) {
273
+ blocked = configReason;
274
+ } else if (support === 'pending') blocked = WEB_PROVIDER_REASONS.DRIVER_PENDING;
275
+ else if (!implemented) blocked = WEB_PROVIDER_REASONS.PROVIDER_UNSUPPORTED;
276
+ return {
277
+ provider,
278
+ executionAdapterId: `web-${provider}`,
279
+ support,
280
+ implemented,
281
+ blocked,
282
+ errorCode: blocked === null
283
+ ? null
284
+ : (blocked === WEB_PROVIDER_REASONS.DRIVER_PENDING || blocked === WEB_PROVIDER_REASONS.UNKNOWN_PROVIDER
285
+ || blocked === WEB_PROVIDER_REASONS.PROVIDER_UNSUPPORTED
286
+ ? ERROR_CODES.ADAPTER_UNSUPPORTED
287
+ : ERROR_CODES.ADAPTER_DISABLED),
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Refuse a call whose provider cannot execute, using the fixed §4.4 vocabulary:
293
+ * disabled -> ADAPTER_DISABLED, unknown / not implemented -> ADAPTER_UNSUPPORTED.
294
+ */
295
+ function assertDriverAllowed(driver, operation) {
296
+ if (driver.blocked === null) return driver;
297
+ const disabled = driver.blocked === WEB_PROVIDER_REASONS.ENVIRONMENT_DISABLED
298
+ || driver.blocked === WEB_PROVIDER_REASONS.PROVIDER_DISABLED;
299
+ throw new AdapterError(
300
+ driver.errorCode,
301
+ disabled
302
+ ? `web provider '${driver.provider}' is disabled (${driver.blocked})`
303
+ : `web provider '${driver.provider}' cannot execute (${driver.blocked})`,
304
+ { details: { operation: operation ?? null, provider: driver.provider, reason: driver.blocked } },
305
+ );
306
+ }
307
+
308
+ /** Error codes that a platform dispatch is allowed to raise and have preserved. */
309
+ const PRESERVED_ERROR_CODES = new Set(Object.values(ERROR_CODES));
310
+
311
+ /** Chrome candidates per platform; an explicit `chromePath` replaces this list (no fallback). */
312
+ const LINUX_CHROME_CANDIDATES = Object.freeze([
313
+ '/opt/google/chrome/chrome',
314
+ '/usr/bin/google-chrome',
315
+ '/usr/bin/google-chrome-stable',
316
+ '/usr/bin/chromium',
317
+ '/usr/bin/chromium-browser',
318
+ 'google-chrome',
319
+ 'chromium',
320
+ 'chromium-browser',
321
+ ]);
322
+
323
+ const MAC_CHROME_CANDIDATES = Object.freeze([
324
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
325
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
326
+ ]);
327
+
328
+ function windowsChromeCandidates(env) {
329
+ const programFiles = env.ProgramFiles || env.PROGRAMFILES || 'C:\\Program Files';
330
+ const programFilesX86 = env['ProgramFiles(x86)'] || env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
331
+ const localAppData = env.LOCALAPPDATA || '';
332
+ const candidates = [
333
+ join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'),
334
+ join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
335
+ ];
336
+ if (localAppData) {
337
+ candidates.push(join(localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'));
338
+ }
339
+ return candidates;
340
+ }
341
+
342
+ /** Explicit per-provider override env var; one variable per vendor, never shared. */
343
+ export const WEB_PROVIDER_ENV = Object.freeze({
344
+ chrome: 'DSH_UI_TESTING_CHROME',
345
+ edge: 'DSH_UI_TESTING_EDGE',
346
+ });
347
+
348
+ /** Edge candidates per platform. Chrome's list is untouched above. */
349
+ const EDGE_LINUX_CANDIDATES = Object.freeze([
350
+ '/opt/microsoft/msedge/msedge',
351
+ '/usr/bin/microsoft-edge',
352
+ '/usr/bin/microsoft-edge-stable',
353
+ 'microsoft-edge',
354
+ 'microsoft-edge-stable',
355
+ ]);
356
+
357
+ const EDGE_MAC_CANDIDATES = Object.freeze([
358
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
359
+ '/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta',
360
+ ]);
361
+
362
+ function windowsEdgeCandidates(env) {
363
+ const programFiles = env.ProgramFiles || env.PROGRAMFILES || 'C:\\Program Files';
364
+ const programFilesX86 = env['ProgramFiles(x86)'] || env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)';
365
+ const localAppData = env.LOCALAPPDATA || '';
366
+ const candidates = [
367
+ join(programFilesX86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
368
+ join(programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
369
+ ];
370
+ if (localAppData) candidates.push(join(localAppData, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
371
+ return candidates;
372
+ }
373
+
374
+ /**
375
+ * Ordered executable candidates for one provider. Vendors never mix: an Edge search never contains
376
+ * a Chrome path (a "successful" Edge launch that is really Chrome is the failure mode §8 forbids).
377
+ */
378
+ export function providerExecutableCandidates({ provider, platform = process.platform, env = process.env } = {}) {
379
+ if (provider === 'edge') {
380
+ if (platform === 'darwin') return [...EDGE_MAC_CANDIDATES];
381
+ if (platform === 'win32') return windowsEdgeCandidates(env);
382
+ return [...EDGE_LINUX_CANDIDATES];
383
+ }
384
+ return chromeCandidates({ chromePath: undefined, platform, env });
385
+ }
386
+
387
+ /**
388
+ * Resolve the executable for one provider **without starting anything**.
389
+ *
390
+ * @returns {{provider:string, path:string|null, candidates:string[], source:'override'|'search',
391
+ * reason:string|null, overrideValue:string|null}}
392
+ */
393
+ export function resolveProviderExecutable({
394
+ provider, platform = process.platform, env = process.env, overridePath = undefined,
395
+ } = {}) {
396
+ const id = typeof provider === 'string' && provider.length > 0 ? provider : 'chrome';
397
+ const envName = WEB_PROVIDER_ENV[id] ?? null;
398
+ const raw = overridePath !== undefined
399
+ ? overridePath
400
+ : (envName !== null && envName in env ? env[envName] : undefined);
401
+ const overrideValue = raw === undefined || raw === null ? null : String(raw).trim();
402
+ if (overrideValue !== null) {
403
+ // An explicit override is authoritative for *this* provider: when it is unusable we report it
404
+ // and stop. Falling back to another vendor would run the wrong browser under this label.
405
+ if (overrideValue === '' || overrideValue.includes('\0')) {
406
+ return { provider: id, path: null, candidates: [overrideValue], source: 'override',
407
+ reason: WEB_PROVIDER_REASONS.OVERRIDE_INVALID, overrideValue };
408
+ }
409
+ const usable = isAbsolute(overrideValue) || !/[\\/]/.test(overrideValue)
410
+ ? candidateExists(overrideValue, { platform, env })
411
+ : false;
412
+ return {
413
+ provider: id,
414
+ path: usable ? overrideValue : null,
415
+ candidates: [overrideValue],
416
+ source: 'override',
417
+ reason: usable ? null : WEB_PROVIDER_REASONS.OVERRIDE_INVALID,
418
+ overrideValue,
419
+ };
420
+ }
421
+ const candidates = providerExecutableCandidates({ provider: id, platform, env });
422
+ const found = firstExistingCandidate(candidates, { platform, env });
423
+ return {
424
+ provider: id,
425
+ path: found,
426
+ candidates,
427
+ source: 'search',
428
+ reason: found === null ? WEB_PROVIDER_REASONS.EXECUTABLE_MISSING : null,
429
+ overrideValue: null,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Confirm the product behind a live CDP endpoint is the vendor we think we started (§8: "CDP
435
+ * connected" is not proof). `Browser.getVersion.product` is the reliable signal.
436
+ *
437
+ * @returns {{ok:boolean, product:string|null, expected:string}}
438
+ */
439
+ export function verifyProviderProduct({ provider, version } = {}) {
440
+ const rule = WEB_PROVIDER_VENDOR[provider] ?? null;
441
+ const product = typeof version?.product === 'string' && version.product.length > 0 ? version.product : null;
442
+ if (rule === null) {
443
+ // Unknown provider: no rule means no way to prove identity — never let it through.
444
+ return { ok: false, verified: false, product, expected: null, reason: WEB_PROVIDER_REASONS.PROVIDER_UNSUPPORTED };
445
+ }
446
+ const expected = rule.requires.join('+');
447
+ if (product === null) {
448
+ // "No evidence" is not "wrong evidence": non-chrome providers fail closed (their whole point is
449
+ // catching a Chrome binary under an Edge label); chrome keeps its accepted startup path.
450
+ return rule.failClosedOnMissing
451
+ ? { ok: false, verified: false, product, expected, reason: WEB_PROVIDER_REASONS.IDENTITY_UNVERIFIED }
452
+ : { ok: true, verified: false, product, expected, reason: null };
453
+ }
454
+ const missing = rule.requires.filter((token) => !product.includes(token));
455
+ const forbidden = rule.forbids.filter((token) => product.includes(token));
456
+ const ok = missing.length === 0 && forbidden.length === 0;
457
+ return {
458
+ ok,
459
+ verified: true,
460
+ product,
461
+ expected,
462
+ reason: ok ? null : WEB_PROVIDER_REASONS.IDENTITY_MISMATCH,
463
+ missing,
464
+ forbidden,
465
+ };
466
+ }
467
+
468
+ function isPlainObject(value) {
469
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
470
+ }
471
+
472
+ /**
473
+ * Build the ordered executable candidates for the platform.
474
+ *
475
+ * An explicitly configured path is authoritative: when it is missing the dependency axis reports
476
+ * `failed` instead of silently probing unrelated installations. That keeps "configured but wrong"
477
+ * distinguishable from "not configured, nothing found".
478
+ */
479
+ function chromeCandidates({ chromePath, platform, env = process.env }) {
480
+ const explicit = typeof chromePath === 'string' ? chromePath.trim() : '';
481
+ if (explicit) return [explicit];
482
+ if (platform === 'darwin') return [...MAC_CHROME_CANDIDATES];
483
+ if (platform === 'win32') return windowsChromeCandidates(env);
484
+ return [...LINUX_CHROME_CANDIDATES];
485
+ }
486
+
487
+ /** True when a candidate exists; bare names are resolved through PATH without spawning anything. */
488
+ function candidateExists(candidate, { platform, env = process.env }) {
489
+ if (typeof candidate !== 'string' || candidate.length === 0) return false;
490
+ if (candidate.includes('/') || candidate.includes('\\')) return existsSync(candidate);
491
+ const separator = platform === 'win32' ? ';' : delimiter;
492
+ const pathValue = env.PATH ?? env.Path ?? '';
493
+ return pathValue
494
+ .split(separator)
495
+ .filter((dir) => dir.length > 0)
496
+ .some((dir) => existsSync(join(dir, candidate)));
497
+ }
498
+
499
+ function firstExistingCandidate(candidates, options) {
500
+ for (const candidate of candidates) {
501
+ if (candidateExists(candidate, options)) return candidate;
502
+ }
503
+ return null;
504
+ }
505
+
506
+ /** Normalize the injected registry's entries without assuming one container type. */
507
+ function registryEntries(registry) {
508
+ const entries = registry?.entries;
509
+ if (!entries) return [];
510
+ if (Array.isArray(entries)) return entries.filter(isPlainObject);
511
+ if (typeof entries.values === 'function') {
512
+ try {
513
+ return [...entries.values()].filter(isPlainObject);
514
+ } catch {
515
+ return [];
516
+ }
517
+ }
518
+ return [];
519
+ }
520
+
521
+ function entryScopeId(entry) {
522
+ const scope = entry?.scope;
523
+ if (typeof scope === 'string' && scope.length > 0) return scope;
524
+ if (isPlainObject(scope) && typeof scope.id === 'string' && scope.id.length > 0) return scope.id;
525
+ if (typeof entry?.scopeId === 'string' && entry.scopeId.length > 0) return entry.scopeId;
526
+ return null;
527
+ }
528
+
529
+ function entryGroup(entry) {
530
+ const group = entry?.group ?? entry?.workspaceGroup ?? entry?.scope?.group;
531
+ return typeof group === 'string' && group.length > 0 ? group : null;
532
+ }
533
+
534
+ function entryUrl(entry) {
535
+ const url = entry?.runtime?.url ?? entry?.url;
536
+ return typeof url === 'string' && url.length > 0 ? url : null;
537
+ }
538
+
539
+ function entryTargetId(entry) {
540
+ const candidates = [entry?.tabId, entry?.runtimeId, entry?.targetId, entry?.id];
541
+ for (const value of candidates) {
542
+ if (typeof value === 'string' && value.length > 0) return value;
543
+ }
544
+ return null;
545
+ }
546
+
547
+ function entryCreatedAt(entry) {
548
+ const value = entry?.createdAt ?? entry?.runtime?.createdAt;
549
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
550
+ }
551
+
552
+ function entryLabel(entry) {
553
+ const label = entry?.label ?? entry?.title ?? entryUrl(entry) ?? entryTargetId(entry);
554
+ return typeof label === 'string' && label.length > 0 ? label : null;
555
+ }
556
+
557
+ /** True when an entry belongs to the session's scope or to the same web workspace group. */
558
+ function entryBelongsToScope(entry, scope) {
559
+ const scopeId = typeof scope === 'string' ? scope : scope?.id;
560
+ const entryScope = entryScopeId(entry);
561
+ if (entryScope !== null && scopeId && entryScope === scopeId) return true;
562
+ const group = entryGroup(entry);
563
+ const scopeGroup = isPlainObject(scope) ? scope.group ?? scope.workspaceGroup : null;
564
+ return Boolean(group && scopeGroup && group === scopeGroup);
565
+ }
566
+
567
+ function findScopeEntry(registry, scope) {
568
+ return registryEntries(registry).find((entry) => entryBelongsToScope(entry, scope)) ?? null;
569
+ }
570
+
571
+ /**
572
+ * Generation for a target identity.
573
+ *
574
+ * Derived from `createdAt` ordering plus the entry's ordinal, then cached per session so the same
575
+ * target keeps the same generation even if the registry reorders its entries. A generation is an
576
+ * observation of the target, never a fabricated "version".
577
+ */
578
+ function assignTargetGenerations(entries, cache) {
579
+ const ordered = [...entries].sort((left, right) => {
580
+ const byCreatedAt = entryCreatedAt(left) - entryCreatedAt(right);
581
+ if (byCreatedAt !== 0) return byCreatedAt;
582
+ return String(entryTargetId(left) ?? '').localeCompare(String(entryTargetId(right) ?? ''));
583
+ });
584
+ for (const entry of ordered) {
585
+ const targetId = entryTargetId(entry);
586
+ if (targetId === null || cache.has(targetId)) continue;
587
+ cache.set(targetId, cache.size + 1);
588
+ }
589
+ return cache;
590
+ }
591
+
592
+ function enabledEnvironmentsOf(snapshot) {
593
+ const list = snapshot?.enabledEnvironments ?? snapshot?.global?.enabledEnvironments;
594
+ return Array.isArray(list) ? list : [];
595
+ }
596
+
597
+ function configRevisionOf(snapshot) {
598
+ const revision = snapshot?.revision ?? snapshot?.configRevision;
599
+ return Number.isInteger(revision) ? revision : null;
600
+ }
601
+
602
+ function capabilityListOf(snapshot) {
603
+ const list = snapshot?.capabilities;
604
+ return Array.isArray(list) ? list.filter((id) => typeof id === 'string') : null;
605
+ }
606
+
607
+ /** Map a platform error onto the shared taxonomy, preserving a known `code`. */
608
+ function toAdapterError(error, { operation, fallback = ERROR_CODES.INTERNAL } = {}) {
609
+ if (error instanceof AdapterError) return error;
610
+ // A platform-typed error (BrowserError and its wider taxonomy: NAV_FAILED/BUSY/REF_STALE/...)
611
+ // is rethrown untouched. Wrapping it in AdapterError would normalize the platform code to
612
+ // INTERNAL, and the tool boundary must preserve the stable code it has always exposed.
613
+ if (typeof error?.code === 'string' && error.code.length > 0) return error;
614
+ const code = fallback;
615
+ const message = error instanceof Error && error.message
616
+ ? error.message
617
+ : typeof error === 'string' && error.length > 0
618
+ ? error
619
+ : 'web adapter operation failed';
620
+ const details = { operation: operation ?? null };
621
+ if (isPlainObject(error?.details)) Object.assign(details, error.details);
622
+ if (error instanceof Error) details.causeName = error.name;
623
+ return new AdapterError(code, message, {
624
+ details,
625
+ cause: error instanceof Error ? error : undefined,
626
+ });
627
+ }
628
+
629
+ /** CONFIG_STALE (M0 §2.1): a step must never execute against a different config revision. */
630
+ function configStaleError({ sessionRevision, snapshotRevision, sessionId }) {
631
+ return new AdapterError(
632
+ ERROR_CODES.CONFIG_STALE,
633
+ `session config revision ${String(sessionRevision)} does not match snapshot revision ${String(snapshotRevision)}`,
634
+ { details: { sessionId: sessionId ?? null, sessionRevision, snapshotRevision } },
635
+ );
636
+ }
637
+
638
+ function assertNotAborted(signal, operation) {
639
+ if (signal?.aborted) {
640
+ throw new AdapterError(ERROR_CODES.INTERNAL, `${operation} aborted before completion`, {
641
+ details: { operation, reason: 'aborted' },
642
+ });
643
+ }
644
+ }
645
+
646
+ function createTargetAdapterManifest() {
647
+ return {
648
+ contractVersion: CONTRACT_VERSION,
649
+ id: 'web',
650
+ class: 'target',
651
+ environment: 'web',
652
+ displayName: 'Web / Chromium',
653
+ capabilities: [...WEB_CAPABILITIES],
654
+ dependencies: [{ id: 'chromium', kind: 'executable', required: true, version: null }],
655
+ visibility: 'user',
656
+ };
657
+ }
658
+
659
+ function createCapabilityAdapterManifest() {
660
+ return {
661
+ contractVersion: CONTRACT_VERSION,
662
+ id: 'web.capability',
663
+ class: 'capability',
664
+ environment: 'web',
665
+ capabilities: [...WEB_CAPABILITIES],
666
+ operations: WEB_TOOL_OPERATION_SPECS.map((entry) => ({ ...entry })),
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Create the web TargetAdapter.
672
+ *
673
+ * @param {object} [options]
674
+ * @param {object} [options.registry] Shared runtime registry (read-only here). Entries describe
675
+ * discovered targets; `registry.host` means a runtime exists but has not been handshaked.
676
+ * @param {object} [options.log] Optional logger (`debug`/`warn`); never receives user config.
677
+ * @param {() => number} [options.now] Clock injection for `checkedAt`.
678
+ * @param {string} [options.chromePath] Explicit Chromium executable (defaults to
679
+ * `DSH_UI_TESTING_CHROME`). When set, it is authoritative and no fallback is probed.
680
+ * @param {string} [options.platform] `process.platform` override (test seam).
681
+ * @param {(input: object) => Promise<object>|object} [options.capabilityAvailability] Optional
682
+ * live capability probe; without it the capability axis stays `not-probed`.
683
+ */
684
+ export function createWebTargetAdapter({
685
+ registry,
686
+ log,
687
+ now = Date.now,
688
+ chromePath = process.env.DSH_UI_TESTING_CHROME,
689
+ platform = process.platform,
690
+ capabilityAvailability,
691
+ } = {}) {
692
+ const manifest = createTargetAdapterManifest();
693
+ const sessions = new Map();
694
+ let disposed = false;
695
+
696
+ function assertNotDisposed() {
697
+ if (disposed) {
698
+ throw new AdapterError(ERROR_CODES.ADAPTER_DISABLED, 'web target adapter is disposed', {
699
+ details: { adapterId: manifest.id },
700
+ });
701
+ }
702
+ }
703
+
704
+ async function createSession({ scope, snapshot, signal } = {}) {
705
+ assertNotDisposed();
706
+ assertNotAborted(signal, 'createSession');
707
+ if (!isPlainObject(scope) || typeof scope.id !== 'string' || scope.id.length === 0) {
708
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'createSession requires a scope with an id', {
709
+ details: { field: 'scope' },
710
+ });
711
+ }
712
+
713
+ const driver = assertDriverAllowed(resolveWebDriver(snapshot), 'createSession');
714
+ const sessionId = `web:${scope.id}`;
715
+ const generationCache = new Map();
716
+ const state = {
717
+ sessionId,
718
+ environment: 'web',
719
+ // Runtime provider this session talks to. The facade identity stays `web`.
720
+ provider: driver.provider,
721
+ executionAdapterId: driver.executionAdapterId,
722
+ generation: 1,
723
+ configRevision: configRevisionOf(snapshot),
724
+ target: null,
725
+ closed: false,
726
+ disposed: false,
727
+ scope,
728
+ };
729
+
730
+ function assertOpen() {
731
+ if (state.disposed || state.closed) {
732
+ throw new AdapterError(ERROR_CODES.TARGET_GONE, `web session ${sessionId} is closed`, {
733
+ details: { sessionId },
734
+ });
735
+ }
736
+ }
737
+
738
+ function collectIdentities() {
739
+ const entries = registryEntries(registry).filter((entry) => entryBelongsToScope(entry, scope));
740
+ assignTargetGenerations(entries, generationCache);
741
+ const identities = [];
742
+ for (const entry of entries) {
743
+ const targetId = entryTargetId(entry);
744
+ if (targetId === null) continue;
745
+ identities.push(newTargetIdentity({
746
+ targetId,
747
+ environment: 'web',
748
+ kind: 'page',
749
+ generation: generationCache.get(targetId) ?? null,
750
+ label: entryLabel(entry),
751
+ lifecycle: typeof entry?.lifecycle === 'string' ? entry.lifecycle : 'live',
752
+ }));
753
+ }
754
+ return identities;
755
+ }
756
+
757
+ const session = {
758
+ sessionId,
759
+ environment: state.environment,
760
+ provider: state.provider,
761
+ executionAdapterId: state.executionAdapterId,
762
+ scopeId: scope.id,
763
+ get generation() {
764
+ return state.generation;
765
+ },
766
+ get configRevision() {
767
+ return state.configRevision;
768
+ },
769
+ get target() {
770
+ return state.target;
771
+ },
772
+ get closed() {
773
+ return state.closed;
774
+ },
775
+ async listTargets({ signal: listSignal } = {}) {
776
+ assertNotAborted(listSignal, 'listTargets');
777
+ assertOpen();
778
+ return collectIdentities();
779
+ },
780
+ async bind(targetId, { signal: bindSignal } = {}) {
781
+ assertNotAborted(bindSignal, 'bind');
782
+ assertOpen();
783
+ if (typeof targetId !== 'string' || targetId.length === 0) {
784
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'bind requires a targetId', {
785
+ details: { field: 'targetId' },
786
+ });
787
+ }
788
+ const identity = collectIdentities().find((candidate) => candidate.targetId === targetId);
789
+ if (!identity) {
790
+ throw new AdapterError(ERROR_CODES.TARGET_NOT_BOUND, `web target '${targetId}' is not available`, {
791
+ details: { targetId, sessionId },
792
+ });
793
+ }
794
+ state.generation += 1;
795
+ state.target = identity;
796
+ return identity;
797
+ },
798
+ async close({ signal: closeSignal } = {}) {
799
+ assertNotAborted(closeSignal, 'close');
800
+ if (state.closed) return;
801
+ state.closed = true;
802
+ },
803
+ async dispose() {
804
+ if (state.disposed) return;
805
+ state.disposed = true;
806
+ state.closed = true;
807
+ state.target = null;
808
+ },
809
+ };
810
+
811
+ sessions.set(sessionId, session);
812
+ return session;
813
+ }
814
+
815
+ async function probe({ scope, snapshot, signal } = {}) {
816
+ assertNotDisposed();
817
+ if (signal?.aborted) {
818
+ throw new AdapterError(ERROR_CODES.PROBE_TIMEOUT, 'web probe aborted before completion');
819
+ }
820
+
821
+ const checkedAt = typeof now === 'function' ? now() : Date.now();
822
+ const enabled = enabledEnvironmentsOf(snapshot).includes('web');
823
+
824
+ const driver = resolveWebDriver(snapshot);
825
+ // Only the chrome vendor resolver exists in this slice. Reporting another provider from the
826
+ // Chrome candidate list would fabricate a dependency observation (§8: CDP connecting is not
827
+ // proof of the product), so a non-chrome provider answers `not-probed` with a named reason.
828
+ const resolution = resolveProviderExecutable({
829
+ provider: driver.provider,
830
+ platform,
831
+ // The adapter-level `chromePath` option stays authoritative for chrome (test seam + config).
832
+ overridePath: driver.provider === DEFAULT_WEB_PROVIDER ? chromePath : undefined,
833
+ });
834
+ const candidates = resolution.candidates;
835
+ const found = resolution.path;
836
+ // No version is ever invented: `version: null` means "not observed", not "unknown but assumed".
837
+ const dependencyEvidence = {
838
+ id: driver.provider === DEFAULT_WEB_PROVIDER ? 'chromium' : driver.provider,
839
+ kind: 'executable',
840
+ provider: driver.provider,
841
+ // Resolved path is evidence, never a secret; version stays null unless it was observed
842
+ // without starting a browser (probe must not launch anything).
843
+ path: found,
844
+ version: null,
845
+ };
846
+ // A provider without a driver is not probed at all: `pending` says "driver not wired yet",
847
+ // `unsupported` says "no driver planned". Neither borrows chrome's evidence (§8).
848
+ const providerDependency = driver.blocked === WEB_PROVIDER_REASONS.DRIVER_PENDING
849
+ ? WEB_PROVIDER_REASONS.DRIVER_PENDING
850
+ : (driver.blocked === WEB_PROVIDER_REASONS.PROVIDER_UNSUPPORTED
851
+ || driver.blocked === WEB_PROVIDER_REASONS.UNKNOWN_PROVIDER
852
+ ? WEB_PROVIDER_REASONS.PROVIDER_UNSUPPORTED
853
+ : WEB_PROVIDER_REASONS.DRIVER_PENDING);
854
+ // A provider with a driver is probed with *its own* resolver (edge has its own candidate list),
855
+ // so "implemented but executable missing" is distinguishable from "not probed".
856
+ const dependency = !driver.implemented
857
+ ? axis(READINESS_STATUS.NOT_PROBED, {
858
+ reason: providerDependency,
859
+ checkedAt,
860
+ evidence: dependencyEvidence,
861
+ })
862
+ : (found === null
863
+ ? axis(READINESS_STATUS.FAILED, {
864
+ reason: 'executable-missing',
865
+ checkedAt,
866
+ evidence: dependencyEvidence,
867
+ })
868
+ : axis(READINESS_STATUS.OK, { checkedAt, evidence: dependencyEvidence }));
869
+
870
+ const host = registry?.host ?? null;
871
+ const entry = findScopeEntry(registry, scope);
872
+ const runtime = entry?.runtime ?? null;
873
+ // A13: "connection" answers "is a transport actually attached", never "is the executable
874
+ // installed". An idle host (process running, no page/session attached) is *not* a connection.
875
+ const transport = runtime && runtime.cdp && runtime.cdp.closed !== true
876
+ ? axis(READINESS_STATUS.OK, {
877
+ reason: null,
878
+ checkedAt,
879
+ evidence: {
880
+ kind: 'transport',
881
+ transport: typeof runtime.transport === 'string' ? runtime.transport : (runtime.send ? 'ws' : null),
882
+ runtimeId: typeof runtime.runtimeId === 'string' ? runtime.runtimeId : null,
883
+ closed: false,
884
+ },
885
+ })
886
+ : (host
887
+ ? axis(READINESS_STATUS.NOT_PROBED, { reason: 'host-idle-no-transport', checkedAt })
888
+ : axis(READINESS_STATUS.NOT_PROBED, { reason: 'cold', checkedAt }));
889
+ const connection = transport;
890
+
891
+ const targetId = entry ? entryTargetId(entry) : null;
892
+ const expectedTargetId = typeof snapshot?.targetId === 'string' && snapshot.targetId.length > 0
893
+ ? snapshot.targetId
894
+ : null;
895
+ // A13 identity: a configured target that is not the entry this scope actually resolves is a
896
+ // *wrong identity*, not a bound target.
897
+ const identityMismatch = expectedTargetId !== null && targetId !== null && expectedTargetId !== targetId;
898
+ const target = entry !== null && entryUrl(entry) !== null && targetId !== null
899
+ ? (identityMismatch
900
+ ? axis(READINESS_STATUS.STALE, {
901
+ reason: 'identity-mismatch',
902
+ checkedAt,
903
+ target: { targetId, generation: assignTargetGenerations([entry], new Map()).get(targetId) ?? null },
904
+ evidence: { kind: 'identity', expectedTargetId, actualTargetId: targetId },
905
+ })
906
+ : axis(READINESS_STATUS.BOUND, {
907
+ target: { targetId, generation: assignTargetGenerations([entry], new Map()).get(targetId) ?? null },
908
+ checkedAt,
909
+ evidence: { kind: 'identity', targetId },
910
+ }))
911
+ : axis(READINESS_STATUS.UNBOUND, { reason: 'no-target', checkedAt });
912
+
913
+ const capability = await probeCapabilityAxis({
914
+ scope,
915
+ snapshot,
916
+ signal,
917
+ entry,
918
+ checkedAt,
919
+ });
920
+
921
+ const report = createReadinessReport({
922
+ environment: 'web',
923
+ snapshot,
924
+ adapter: { manifest },
925
+ now: checkedAt,
926
+ axes: {
927
+ support: axis(SUPPORT.SUPPORTED, { checkedAt }),
928
+ enabled: axis(enabled ? READINESS_STATUS.OK : READINESS_STATUS.DISABLED, {
929
+ reason: enabled ? null : 'environment-disabled',
930
+ checkedAt,
931
+ }),
932
+ dependency,
933
+ connection,
934
+ target,
935
+ capability,
936
+ },
937
+ });
938
+
939
+ log?.debug?.('web.probe', {
940
+ enabled,
941
+ dependency: dependency.status,
942
+ connection: connection.status,
943
+ target: target.status,
944
+ });
945
+ return report;
946
+ }
947
+
948
+ async function probeCapabilityAxis({ scope, snapshot, signal, entry, checkedAt }) {
949
+ if (typeof capabilityAvailability !== 'function') {
950
+ const derived = entry?.capabilityStatus ?? registry?.capabilityStatus;
951
+ if (typeof derived === 'string' && derived.length > 0) {
952
+ return axis(normalizeReadinessStatus(derived), { checkedAt });
953
+ }
954
+ if (isPlainObject(derived) && typeof derived.status === 'string') {
955
+ return axis(normalizeReadinessStatus(derived.status), {
956
+ reason: typeof derived.reason === 'string' ? derived.reason : null,
957
+ checkedAt,
958
+ });
959
+ }
960
+ return axis(READINESS_STATUS.NOT_PROBED, { reason: 'not-probed', checkedAt });
961
+ }
962
+
963
+ let raw;
964
+ try {
965
+ raw = await capabilityAvailability({ scope, snapshot, signal, entry, registry });
966
+ } catch (error) {
967
+ throw toAdapterError(error, { operation: 'probe.capability', fallback: ERROR_CODES.PROBE_TIMEOUT });
968
+ }
969
+ if (typeof raw === 'string') {
970
+ return axis(normalizeReadinessStatus(raw), { checkedAt });
971
+ }
972
+ if (isPlainObject(raw)) {
973
+ const status = typeof raw.status === 'string' ? raw.status : READINESS_STATUS.NOT_PROBED;
974
+ // A13: the capability axis is the *union* of independent checks (structure / input /
975
+ // network / frames / identity). Its detail must survive to the caller, otherwise the page
976
+ // can only show one opaque word instead of which capability is missing.
977
+ return axis(normalizeReadinessStatus(status), {
978
+ reason: typeof raw.reason === 'string' ? raw.reason : null,
979
+ checkedAt,
980
+ target: isPlainObject(raw.target) ? raw.target : null,
981
+ evidence: isPlainObject(raw.evidence) ? raw.evidence : null,
982
+ });
983
+ }
984
+ return axis(READINESS_STATUS.NOT_PROBED, { reason: 'not-probed', checkedAt });
985
+ }
986
+
987
+ async function dispose() {
988
+ if (disposed) return;
989
+ disposed = true;
990
+ for (const session of sessions.values()) {
991
+ await session.dispose();
992
+ }
993
+ sessions.clear();
994
+ // The shared registry/runtime is owned by the composition root; dispose never closes it.
995
+ }
996
+
997
+ return { manifest, probe, createSession, dispose };
998
+ }
999
+
1000
+ /**
1001
+ * The capability axis speaks `available|partial|unavailable|unsupported|not-probed` (M0 §5.2),
1002
+ * which is not the same vocabulary as the other five axes. Both are accepted here; anything else
1003
+ * degrades to `not-probed` instead of inventing readiness.
1004
+ */
1005
+ const CAPABILITY_AXIS_STATUSES = new Set([
1006
+ 'available',
1007
+ 'partial',
1008
+ 'unavailable',
1009
+ 'unsupported',
1010
+ 'not-probed',
1011
+ 'busy',
1012
+ ]);
1013
+
1014
+ function normalizeReadinessStatus(status) {
1015
+ if (status === true) return READINESS_STATUS.OK;
1016
+ if (status === false) return READINESS_STATUS.FAILED;
1017
+ if (typeof status === 'string'
1018
+ && (Object.values(READINESS_STATUS).includes(status) || CAPABILITY_AXIS_STATUSES.has(status))) {
1019
+ return status;
1020
+ }
1021
+ return READINESS_STATUS.NOT_PROBED;
1022
+ }
1023
+
1024
+ /**
1025
+ * Create the web CapabilityAdapter.
1026
+ *
1027
+ * `dispatch` is injected by the composition root and owns the actual Chromium/CDP work:
1028
+ * `dispatch({ operation, args, session, snapshot, signal, executionClass, context })`.
1029
+ * `context` carries the composition root's execution environment (runtime/meta/httpClient); the
1030
+ * dispatch is expected to call `context.execute()` after it applied the execution class. `context`
1031
+ * is passed through untouched and never enters evidence or public identity.
1032
+ *
1033
+ * @param {object} [options]
1034
+ * @param {(input: object) => Promise<unknown>|unknown} [options.dispatch]
1035
+ * @param {object} [options.log]
1036
+ * @param {() => number} [options.now]
1037
+ */
1038
+ export function createWebCapabilityAdapter({ dispatch, log, now = Date.now } = {}) {
1039
+ const manifest = createCapabilityAdapterManifest();
1040
+ let disposed = false;
1041
+
1042
+ function assertNotDisposed() {
1043
+ if (disposed) {
1044
+ throw new AdapterError(ERROR_CODES.ADAPTER_DISABLED, 'web capability adapter is disposed', {
1045
+ details: { adapterId: manifest.id },
1046
+ });
1047
+ }
1048
+ }
1049
+
1050
+ function assertSessionPresent(session) {
1051
+ if (!isPlainObject(session)) {
1052
+ throw new AdapterError(ERROR_CODES.TARGET_NOT_BOUND, 'web capability requires a session', {
1053
+ details: { reason: 'session-missing' },
1054
+ });
1055
+ }
1056
+ if (session.closed === true) {
1057
+ throw new AdapterError(ERROR_CODES.TARGET_GONE, `web session ${String(session.sessionId)} is closed`, {
1058
+ details: { sessionId: session.sessionId ?? null },
1059
+ });
1060
+ }
1061
+ }
1062
+
1063
+ /** Page-level work needs a bound target; host-level work does not (A06 regression guard). */
1064
+ function assertTargetForClass(session, executionClass) {
1065
+ if (!TARGET_BOUND_CLASSES.has(executionClass)) return;
1066
+ if (!session.target) {
1067
+ throw new AdapterError(ERROR_CODES.TARGET_NOT_BOUND, 'web capability requires a bound target', {
1068
+ details: { sessionId: session.sessionId ?? null, executionClass },
1069
+ });
1070
+ }
1071
+ }
1072
+
1073
+ function availabilityOf(operation, { session, snapshot } = {}) {
1074
+ if (isPlainObject(session) && session.closed === true) {
1075
+ return { support: SUPPORT.SUPPORTED, availability: AVAILABILITY.UNAVAILABLE, reason: 'target-gone' };
1076
+ }
1077
+ // A provider that cannot run (disabled / unknown / not implemented) makes every operation of
1078
+ // this environment unavailable — it is never answered from another browser's state.
1079
+ const driver = resolveWebDriver(snapshot);
1080
+ if (driver.blocked !== null) {
1081
+ return { support: SUPPORT.SUPPORTED, availability: AVAILABILITY.UNAVAILABLE, reason: driver.blocked };
1082
+ }
1083
+ // Only page/tabs work depends on a bound target; the rest is judged from the config snapshot so
1084
+ // a target-free tool is never reported as "not probed" just because no page is attached.
1085
+ const declaredClass = SPEC_BY_NAME.get(operation)?.executionClass;
1086
+ if (TARGET_BOUND_CLASSES.has(declaredClass)
1087
+ && (!isPlainObject(session) || !session.target)) {
1088
+ return { support: SUPPORT.SUPPORTED, availability: AVAILABILITY.NOT_PROBED, reason: 'target-unbound' };
1089
+ }
1090
+ const capabilities = capabilityListOf(snapshot);
1091
+ if (capabilities === null) {
1092
+ return { support: SUPPORT.SUPPORTED, availability: AVAILABILITY.NOT_PROBED, reason: 'capability-unknown' };
1093
+ }
1094
+ if (!capabilities.includes(WEB_TOOL_OPERATIONS[operation])) {
1095
+ return { support: SUPPORT.SUPPORTED, availability: AVAILABILITY.UNAVAILABLE, reason: 'capability-disabled' };
1096
+ }
1097
+ return { support: SUPPORT.SUPPORTED, availability: AVAILABILITY.AVAILABLE, reason: null };
1098
+ }
1099
+
1100
+ return {
1101
+ manifest,
1102
+
1103
+ operationStatus({ session, snapshot } = {}) {
1104
+ const out = {};
1105
+ for (const operation of WEB_TOOL_OPERATION_NAMES) {
1106
+ out[operation] = availabilityOf(operation, { session, snapshot });
1107
+ }
1108
+ return out;
1109
+ },
1110
+
1111
+ /**
1112
+ * Validate argument shape and expose the execution class the composition root should route.
1113
+ *
1114
+ * @param {object|undefined} args
1115
+ * @param {string|{operation?:string}} [operation] Optional operation (needed for action-level
1116
+ * classes such as `browser_viewport` get vs set).
1117
+ * @returns {{ok:true, executionClass:string|null}}
1118
+ */
1119
+ preflight(args, operation) {
1120
+ if (args !== undefined && args !== null && !isPlainObject(args)) {
1121
+ throw new AdapterError(ERROR_CODES.INVALID_ARGUMENT, 'operation args must be a plain object', {
1122
+ details: { field: 'args', type: Array.isArray(args) ? 'array' : typeof args },
1123
+ });
1124
+ }
1125
+ const operationName = typeof operation === 'string' ? operation : operation?.operation ?? null;
1126
+ const executionClass = operationName === null
1127
+ ? null
1128
+ : resolveExecutionClass(operationName, args ?? {});
1129
+ return { ok: true, executionClass };
1130
+ },
1131
+
1132
+ async invoke({ operation, args, session, snapshot, signal, executionClass, context } = {}) {
1133
+ assertNotDisposed();
1134
+ if (typeof operation !== 'string' || !SPEC_BY_NAME.has(operation)) {
1135
+ throw new AdapterError(ERROR_CODES.CAPABILITY_UNSUPPORTED, `unknown web operation '${String(operation)}'`, {
1136
+ details: { operation: operation ?? null },
1137
+ });
1138
+ }
1139
+ assertSessionPresent(session);
1140
+
1141
+ // Provider gate before any dispatch work (§4.4): disabled -> ADAPTER_DISABLED,
1142
+ // unknown / not implemented -> ADAPTER_UNSUPPORTED. No silent browser substitution.
1143
+ const driver = assertDriverAllowed(resolveWebDriver(snapshot), operation);
1144
+
1145
+ const snapshotRevision = configRevisionOf(snapshot);
1146
+ const sessionRevision = Number.isInteger(session.configRevision) ? session.configRevision : null;
1147
+ if (snapshotRevision !== null && sessionRevision !== snapshotRevision) {
1148
+ throw configStaleError({
1149
+ sessionRevision,
1150
+ snapshotRevision,
1151
+ sessionId: session.sessionId ?? null,
1152
+ });
1153
+ }
1154
+
1155
+ const allowedClasses = executionClassesOf(operation);
1156
+ if (executionClass !== undefined && executionClass !== null
1157
+ && !allowedClasses.includes(executionClass)) {
1158
+ throw new AdapterError(
1159
+ ERROR_CODES.INVALID_ARGUMENT,
1160
+ `operation '${operation}' does not allow executionClass '${String(executionClass)}'`,
1161
+ { details: { operation, executionClass, allowedClasses } },
1162
+ );
1163
+ }
1164
+ const effectiveClass = executionClass ?? resolveExecutionClass(operation, args ?? {});
1165
+ assertTargetForClass(session, effectiveClass);
1166
+
1167
+ const capability = WEB_TOOL_OPERATIONS[operation];
1168
+ const capabilities = capabilityListOf(snapshot);
1169
+ if (capabilities === null || !capabilities.includes(capability)) {
1170
+ throw new AdapterError(
1171
+ ERROR_CODES.ADAPTER_DISABLED,
1172
+ `capability '${capability}' is not enabled for operation '${operation}'`,
1173
+ { details: { operation, capability, reason: 'capability-disabled' } },
1174
+ );
1175
+ }
1176
+ if (typeof dispatch !== 'function') {
1177
+ throw new AdapterError(ERROR_CODES.PROVIDER_UNAVAILABLE, 'web capability dispatch is not injected', {
1178
+ details: { operation },
1179
+ });
1180
+ }
1181
+
1182
+ let raw;
1183
+ try {
1184
+ raw = await dispatch({
1185
+ operation,
1186
+ args: args === undefined ? {} : args,
1187
+ session,
1188
+ snapshot,
1189
+ signal,
1190
+ executionClass: effectiveClass,
1191
+ context,
1192
+ // Explicit routing facts: the composition root must execute this call on the provider's
1193
+ // own runtime, never on whichever browser happens to be running.
1194
+ provider: driver.provider,
1195
+ executionAdapterId: driver.executionAdapterId,
1196
+ });
1197
+ } catch (error) {
1198
+ throw toAdapterError(error, { operation });
1199
+ }
1200
+
1201
+ const value = isPlainObject(raw) && Object.hasOwn(raw, 'value') ? raw.value : raw;
1202
+ const evidence = createEvidenceEnvelope({
1203
+ scope: session.scopeId ?? null,
1204
+ session: session.sessionId ?? null,
1205
+ target: session.target?.targetId ?? null,
1206
+ generation: Number.isInteger(session.generation) ? session.generation : null,
1207
+ configRevision: snapshotRevision ?? sessionRevision,
1208
+ operation,
1209
+ source: 'web',
1210
+ capturedAt: typeof now === 'function' ? now() : Date.now(),
1211
+ });
1212
+
1213
+ log?.debug?.('web.invoke', { operation, capability, executionClass: effectiveClass });
1214
+ return { value, evidence };
1215
+ },
1216
+
1217
+ async dispose() {
1218
+ if (disposed) return;
1219
+ disposed = true;
1220
+ },
1221
+ };
1222
+ }
1223
+
1224
+ /** Guard against a spec table drift: every declared class must be a known execution class. */
1225
+ for (const entry of WEB_TOOL_OPERATION_SPECS) {
1226
+ for (const executionClass of entry.executionClasses ?? [entry.executionClass]) {
1227
+ if (!EXECUTION_CLASS_SET.has(executionClass)) {
1228
+ throw new Error(`web adapter spec for ${entry.name} uses unknown executionClass '${executionClass}'`);
1229
+ }
1230
+ }
1231
+ }