my-frontend-observer 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,37 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.2.0 - 2026-08-11
4
+
5
+ Stable Semantic Targets and Region Identity.
6
+
7
+ - Canonical `{name, locators}` target model with a stable observer-owned
8
+ target identity, distinct from both the browser locator that resolves a
9
+ target and any source-code symbol. The existing `--target id=selector`
10
+ CSS shorthand remains fully supported and normalizes into this model
11
+ unchanged.
12
+ - Six frozen, real-Chromium-resolved locator kinds per target, evaluated in
13
+ configured order with fallback on no match, immediate stop (no fallback)
14
+ on ambiguous or unevaluable results: `role` (+ optional exact accessible
15
+ name), `id`, `data-attribute`, `semantic-element`, `css`, and `text`
16
+ (exact match only).
17
+ - Explicit missing/ambiguous/unavailable resolution reporting, and hidden
18
+ (present-but-not-visible) target evidence, for every locator kind.
19
+ - Bounded semantic-region evidence per resolved target: accessibility
20
+ state (`disabled`/`expanded`/`checked`/`selected`/`pressed`/`current`,
21
+ with an explicit `false` always distinguishable from "not applicable"),
22
+ derived landmark identity, and configured-target-only DOM containment.
23
+ - Proven stable request identity: the same target configuration produces
24
+ the same request identity across repeated observations; changing a
25
+ target's locator strategy changes the request identity without changing
26
+ its stable name; a target's actual runtime disappearance is
27
+ distinguishable from a configuration change.
28
+ - New `--targets-file <json-file>` CLI input for structured semantic target
29
+ configuration, mutually exclusive with `--target`.
30
+ - Observation schema `1.1.0`.
31
+ - Cross-platform packed-candidate validation: one hash-verified npm
32
+ candidate tarball proven on Windows, Linux, and macOS, covering both the
33
+ legacy `--target` CSS shorthand and the structured `--targets-file`
34
+ semantic-target path.
4
35
 
5
36
  ## 0.1.0 - 2026-08-11
6
37
 
package/README.md CHANGED
@@ -6,11 +6,12 @@ in [docs/PROJECT_DESCRIPTION.md](docs/PROJECT_DESCRIPTION.md).
6
6
 
7
7
  ## Current status
8
8
 
9
- `v0.1.0`, Runtime Observation Foundation, is the current published release:
10
- `my-frontend-observer observe` launches a real, sandboxed Chromium browser,
11
- enforces a loopback-only safety policy, captures a viewport screenshot plus
12
- bounded page/target evidence, and persists it as one portable
13
- `manifest.json` + `screenshot.png` artifact.
9
+ `v0.2.0`, Stable Semantic Targets and Region Identity, is the current
10
+ published release: `my-frontend-observer observe` launches a real,
11
+ sandboxed Chromium browser, enforces a loopback-only safety policy,
12
+ captures a viewport screenshot plus bounded page/target evidence, and
13
+ persists it as one portable `manifest.json` + `screenshot.png` artifact
14
+ (observation schema `1.1.0`).
14
15
 
15
16
  Install:
16
17
 
@@ -44,6 +45,14 @@ This prints a concise result (`Observation:`/`State:`/`Artifact:`/`Targets:`/
44
45
  `Diagnostics:`) and exits `0` on a successfully persisted observation. See
45
46
  [docs/COMMANDS.md](docs/COMMANDS.md) for the full flag reference.
46
47
 
48
+ `--target <id=css-selector>` remains the simple CSS shorthand. A second,
49
+ structured `--targets-file <json-file>` input mode ships as part of this
50
+ release - supporting a role and accessible name, a stable `id`, a `data-*`
51
+ attribute, a semantic landmark element, exact text, or an ordered fallback
52
+ between several of those. See "Structured semantic targets" in
53
+ [docs/COMMANDS.md](docs/COMMANDS.md#structured-semantic-targets-targets-file)
54
+ for the exact JSON format.
55
+
47
56
  Validation:
48
57
 
49
58
  ```powershell
@@ -61,8 +70,8 @@ Planning authorities:
61
70
  intent and responsibility boundaries.
62
71
  - [Project Milestones](docs/PROJECT_MILESTONES.md): complete ordered capability
63
72
  design and cross-milestone rules.
64
- - [ROADMAP](docs/ROADMAP.md): version-level requirements; v0.1 is released,
65
- v0.2 is next.
73
+ - [ROADMAP](docs/ROADMAP.md): version-level requirements; v0.1 and v0.2 are
74
+ released, v0.3 is next.
66
75
  - [Current State](docs/CURRENT_STATE.md): retained scaffold and release state.
67
76
 
68
77
  No sibling ecosystem repository is a runtime dependency of the retained
@@ -15,5 +15,21 @@ export interface TargetEvidenceCaptureResult {
15
15
  targetEvidence: Record<string, TargetEvidenceRecord>;
16
16
  diagnostics: Diagnostic[];
17
17
  }
18
- /** Observes every explicitly configured Batch 1 target from the same live page, honoring the 0/1/many cardinality contract. */
18
+ /**
19
+ * Observes every explicitly configured target from the same live page,
20
+ * honoring the frozen ordered-locator resolution contract: 0 matches tries
21
+ * the next locator; exactly 1 match selects and stops; more than 1 match is
22
+ * ambiguous and stops (never falls through); an unevaluable locator is
23
+ * unavailable and stops (never falls through). Ambiguity never triggers
24
+ * fallback.
25
+ *
26
+ * Batch 3 restructures this into three phases over one resolved set: (1)
27
+ * resolve every configured target exactly once, keeping a live
28
+ * `{locator, handle}` for each match; (2) compute bounded pairwise DOM
29
+ * containment among only the resolved configured targets, reusing those
30
+ * same handles (never re-resolving, never a second/independent resolution
31
+ * algorithm); (3) measure every resolved target and assemble its evidence,
32
+ * attaching the phase-2 containment result. Every handle is disposed once,
33
+ * after all three phases, regardless of outcome.
34
+ */
19
35
  export declare function captureTargetEvidence(page: Page, targets: readonly NamedTarget[]): Promise<TargetEvidenceCaptureResult>;
@@ -1,4 +1,5 @@
1
1
  import { DIAGNOSTIC_SEVERITY } from '../domain/diagnostics.js';
2
+ import { TARGET_LANDMARK_ROLES } from '../domain/schema.js';
2
3
  function diagnostic(code, message, targetName) {
3
4
  const base = { code, severity: DIAGNOSTIC_SEVERITY[code], message };
4
5
  return targetName === undefined ? base : { ...base, targetName };
@@ -63,7 +64,12 @@ function parseAriaSnapshotFirstLine(snapshot) {
63
64
  const firstLine = snapshot.split('\n')[0]?.trim();
64
65
  if (!firstLine)
65
66
  return undefined;
66
- const match = /^-\s+([A-Za-z][\w-]*)\s*(?:"([^"]*)")?:?$/.exec(firstLine);
67
+ // No trailing `$` anchor: an element with no accessible name still produces
68
+ // a role line with unquoted trailing content (e.g. `- banner: Site Header`,
69
+ // verified empirically against real Chromium), which must still yield the
70
+ // role even though there is no quoted, genuinely-computed accessible name
71
+ // to report - trailing unquoted text is never treated as a name.
72
+ const match = /^-\s+([A-Za-z][\w-]*)\s*(?:"([^"]*)")?:?/.exec(firstLine);
67
73
  if (!match)
68
74
  return undefined;
69
75
  const role = match[1];
@@ -72,130 +78,399 @@ function parseAriaSnapshotFirstLine(snapshot) {
72
78
  return undefined;
73
79
  return name !== undefined && name.length > 0 ? { role, name } : { role };
74
80
  }
75
- function missingTargetRecord() {
76
- const reason = 'target selector matched no element';
77
- return {
78
- resolution: { state: 'available', source: 'derived', value: { selectionMethod: 'css-selector', selectionStatus: 'not-found' }, derivedFrom: ['selector-query'] },
79
- tag: unavailableField(reason),
80
- geometry: unavailableField(reason),
81
- style: unavailableField(reason),
82
- layout: unavailableField(reason),
83
- visibility: unavailableField(reason),
84
- semantics: unavailableField(reason),
81
+ /** Builds the shared unresolved-target evidence record (not-found/ambiguous/unavailable) with the full ordered attempt history and no selected locator. Containment is reported separately by the caller (its own "self unresolved" reason differs from the measurement-unavailable reason used here). */
82
+ function unresolvedTargetRecord(selectionStatus, attempts, reason, containment) {
83
+ const resolution = {
84
+ selectionMethod: 'ordered-locators',
85
+ selectionStatus,
86
+ usedFallback: false,
87
+ confidence: 'none',
88
+ attempts,
85
89
  };
86
- }
87
- function ambiguousTargetRecord(matchCount) {
88
- const reason = `target selector matched ${matchCount} elements; expected exactly one`;
89
90
  return {
90
- resolution: { state: 'available', source: 'derived', value: { selectionMethod: 'css-selector', selectionStatus: 'ambiguous' }, derivedFrom: ['selector-query'] },
91
+ resolution: { state: 'available', source: 'derived', value: resolution, derivedFrom: ['locator-attempts'] },
91
92
  tag: unavailableField(reason),
92
93
  geometry: unavailableField(reason),
93
94
  style: unavailableField(reason),
94
95
  layout: unavailableField(reason),
95
96
  visibility: unavailableField(reason),
96
97
  semantics: unavailableField(reason),
98
+ semanticState: unavailableField(reason),
99
+ landmark: unavailableField(reason),
100
+ containment,
97
101
  };
98
102
  }
103
+ const TARGET_LANDMARK_ROLE_SET = new Set(TARGET_LANDMARK_ROLES);
99
104
  /**
100
- * Captures the v0.1 minimum target evidence for one resolved (single-match)
101
- * target element, from the same live page/readiness state as the page
102
- * evidence and screenshot. Role/name come from Playwright's real
103
- * accessibility tree (`page.accessibility.snapshot`), not a hand-rolled
104
- * approximation; when the browser cannot supply them reliably for this
105
- * element, they are reported `unavailable` rather than guessed.
105
+ * Reads the resolved element's own native form-control properties and
106
+ * explicit `aria-*` attributes: bounded, target-local browser/ARIA state.
107
+ * `ariaSnapshot()`'s bracket annotations were verified empirically (real
108
+ * Chromium via the installed Playwright 1.62.1) to appear only for
109
+ * true/mixed values - they cannot distinguish an explicit false from "not
110
+ * applicable to this element", and they never expose `aria-current` at all.
111
+ * This direct property/attribute read is the investigated, honest
112
+ * alternative for exactly those two gaps; it still reads only real
113
+ * browser-native/ARIA semantics (never class names, CSS, text content, or
114
+ * source ownership). A key is present in the result only when the browser
115
+ * exposes that state as applicable to this specific element.
106
116
  */
107
- async function captureResolvedTargetRecord(page, selector) {
108
- const handle = await page.$(selector);
109
- if (!handle)
110
- return missingTargetRecord();
111
- try {
112
- const raw = await handle.evaluate((el) => {
113
- const rect = el.getBoundingClientRect();
114
- const computed = getComputedStyle(el);
115
- return {
116
- tag: el.tagName.toLowerCase(),
117
- geometry: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom },
118
- style: { display: computed.display, position: computed.position, overflowX: computed.overflowX, overflowY: computed.overflowY },
119
- layout: {
120
- scrollWidth: el.scrollWidth,
121
- scrollHeight: el.scrollHeight,
122
- clientWidth: el.clientWidth,
123
- clientHeight: el.clientHeight,
124
- scrollTop: el.scrollTop,
125
- scrollLeft: el.scrollLeft,
126
- },
127
- computedVisibility: computed.visibility,
128
- };
129
- });
130
- const visible = raw.style.display !== 'none' && raw.computedVisibility !== 'hidden' && raw.geometry.width > 0 && raw.geometry.height > 0;
131
- let semantics;
132
- try {
133
- const snapshot = await page.locator(selector).ariaSnapshot();
134
- const parsed = parseAriaSnapshotFirstLine(snapshot);
135
- semantics = parsed ? computedField(parsed) : unavailableField('no distinct accessible role/name is exposed for this target');
117
+ async function captureSemanticState(handle) {
118
+ return handle.evaluate((el) => {
119
+ const result = {};
120
+ const ariaDisabled = el.getAttribute('aria-disabled');
121
+ const nativeDisabled = 'disabled' in el ? Boolean(el.disabled) : false;
122
+ if (nativeDisabled || ariaDisabled !== null)
123
+ result.disabled = nativeDisabled || ariaDisabled === 'true';
124
+ const ariaExpanded = el.getAttribute('aria-expanded');
125
+ if (ariaExpanded !== null)
126
+ result.expanded = ariaExpanded === 'true';
127
+ const ariaChecked = el.getAttribute('aria-checked');
128
+ const isNativeCheckable = el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio');
129
+ if (ariaChecked !== null) {
130
+ result.checked = ariaChecked === 'mixed' ? 'mixed' : ariaChecked === 'true';
136
131
  }
137
- catch (err) {
138
- const message = err instanceof Error ? err.message : String(err);
139
- semantics = unavailableField(`accessibility snapshot failed: ${message}`);
132
+ else if (isNativeCheckable) {
133
+ result.checked = el.indeterminate ? 'mixed' : el.checked;
140
134
  }
135
+ const ariaSelected = el.getAttribute('aria-selected');
136
+ if (ariaSelected !== null)
137
+ result.selected = ariaSelected === 'true';
138
+ const ariaPressed = el.getAttribute('aria-pressed');
139
+ if (ariaPressed !== null)
140
+ result.pressed = ariaPressed === 'mixed' ? 'mixed' : ariaPressed === 'true';
141
+ const ariaCurrent = el.getAttribute('aria-current');
142
+ if (ariaCurrent !== null && ariaCurrent !== 'false' && ariaCurrent.length > 0) {
143
+ result.current = ariaCurrent === 'true' ? true : ariaCurrent;
144
+ }
145
+ return result;
146
+ });
147
+ }
148
+ /** Wraps the raw captured state: an empty object means no supported state applies to this target ("not-applicable"), never an unexplained empty available value. */
149
+ function semanticStateEvidence(raw) {
150
+ if (Object.keys(raw).length === 0) {
151
+ return { state: 'not-applicable', reason: 'no supported semantic state is exposed for this target' };
152
+ }
153
+ return { state: 'available', source: 'computed-browser', value: raw };
154
+ }
155
+ /** Derives landmark identity from the already-captured browser-exposed role only - never from locator kind or HTML tag (Section 15). */
156
+ function landmarkEvidence(role) {
157
+ if (role === undefined)
158
+ return unavailableField('no distinct accessible role is exposed for this target');
159
+ if (TARGET_LANDMARK_ROLE_SET.has(role)) {
160
+ return { state: 'available', source: 'derived', value: role, derivedFrom: ['semantics.role'] };
161
+ }
162
+ return { state: 'not-applicable', reason: `role "${role}" is not a recognized landmark role` };
163
+ }
164
+ /**
165
+ * Wraps a computed configured-target-only containment result into the
166
+ * persisted EvidenceField. `selfUnresolvedReason` covers the case where the
167
+ * current target itself never resolved (Section 18: containment is
168
+ * `unavailable`, not partial, when there is no resolved element to check
169
+ * containment for). A computed result with any `unresolvedTargetIds` is
170
+ * `partial` (already-proven relationships are preserved, never dropped);
171
+ * with none, it is `available`.
172
+ */
173
+ function containmentEvidence(computed, selfUnresolvedReason) {
174
+ if (selfUnresolvedReason !== undefined)
175
+ return { state: 'unavailable', reason: selfUnresolvedReason };
176
+ if (!computed)
177
+ return { state: 'unavailable', reason: 'containment could not be computed' };
178
+ if (computed.unresolvedTargetIds.length > 0) {
179
+ return {
180
+ state: 'partial',
181
+ source: 'browser',
182
+ value: computed,
183
+ reason: `containment could not be evaluated against: ${computed.unresolvedTargetIds.join(', ')}`,
184
+ };
185
+ }
186
+ return { state: 'available', source: 'browser', value: computed };
187
+ }
188
+ /**
189
+ * Captures the full target evidence for one resolved (single-match) target
190
+ * element, from the same live page/readiness state as the page evidence and
191
+ * screenshot: tag/geometry/style/layout/visibility (v0.1), role/name
192
+ * semantics (v0.1, via `ariaSnapshot()`), and semantic state/landmark
193
+ * (Batch 3). Takes an already-resolved `{ locator, handle }` pair - not a
194
+ * raw CSS selector, and never re-resolves - so every locator kind converges
195
+ * on this one measurement path and containment (computed once by the caller
196
+ * across every resolved target before this runs) is simply passed through.
197
+ */
198
+ async function captureResolvedTargetRecord(target, handle, attempts, selectedLocatorIndex, containment) {
199
+ const raw = await handle.evaluate((el) => {
200
+ const rect = el.getBoundingClientRect();
201
+ const computed = getComputedStyle(el);
141
202
  return {
142
- resolution: {
143
- state: 'available',
144
- source: 'derived',
145
- value: { selectionMethod: 'css-selector', selectionStatus: 'matched' },
146
- derivedFrom: ['selector-query'],
203
+ tag: el.tagName.toLowerCase(),
204
+ geometry: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom },
205
+ style: { display: computed.display, position: computed.position, overflowX: computed.overflowX, overflowY: computed.overflowY },
206
+ layout: {
207
+ scrollWidth: el.scrollWidth,
208
+ scrollHeight: el.scrollHeight,
209
+ clientWidth: el.clientWidth,
210
+ clientHeight: el.clientHeight,
211
+ scrollTop: el.scrollTop,
212
+ scrollLeft: el.scrollLeft,
147
213
  },
214
+ computedVisibility: computed.visibility,
215
+ };
216
+ });
217
+ const visible = raw.style.display !== 'none' && raw.computedVisibility !== 'hidden' && raw.geometry.width > 0 && raw.geometry.height > 0;
218
+ let semantics;
219
+ let resolvedRole;
220
+ try {
221
+ const snapshot = await target.ariaSnapshot();
222
+ const parsed = parseAriaSnapshotFirstLine(snapshot);
223
+ semantics = parsed ? computedField(parsed) : unavailableField('no distinct accessible role/name is exposed for this target');
224
+ resolvedRole = parsed?.role;
225
+ }
226
+ catch (err) {
227
+ const message = err instanceof Error ? err.message : String(err);
228
+ semantics = unavailableField(`accessibility snapshot failed: ${message}`);
229
+ }
230
+ let semanticState;
231
+ try {
232
+ semanticState = semanticStateEvidence(await captureSemanticState(handle));
233
+ }
234
+ catch (err) {
235
+ const message = err instanceof Error ? err.message : String(err);
236
+ semanticState = unavailableField(`semantic-state evaluation failed: ${message}`);
237
+ }
238
+ const resolution = {
239
+ selectionMethod: 'ordered-locators',
240
+ selectionStatus: 'matched',
241
+ selectedLocatorKind: attempts[selectedLocatorIndex]?.locatorKind ?? 'css',
242
+ selectedLocatorIndex,
243
+ usedFallback: selectedLocatorIndex > 0,
244
+ confidence: 'exact',
245
+ attempts,
246
+ };
247
+ return {
248
+ record: {
249
+ resolution: { state: 'available', source: 'derived', value: resolution, derivedFrom: ['locator-attempts'] },
148
250
  tag: browserField(raw.tag),
149
251
  geometry: browserField(raw.geometry),
150
252
  style: computedField(raw.style),
151
253
  layout: browserField(raw.layout),
152
254
  visibility: derivedField({ visible }, ['style.display', 'computed-visibility', 'geometry.width', 'geometry.height']),
153
255
  semantics,
154
- };
256
+ semanticState,
257
+ landmark: landmarkEvidence(resolvedRole),
258
+ containment,
259
+ },
260
+ visible,
261
+ };
262
+ }
263
+ /**
264
+ * A CSS quoted-string literal for an attribute selector value (`[attr="..."]`).
265
+ * Only backslash and double-quote need escaping inside a CSS quoted string;
266
+ * this keeps the configured id/data-attribute value literal - it is never
267
+ * reinterpreted as selector syntax (e.g. a value containing `#`, `.`, `[`,
268
+ * or `"` still matches exactly, it cannot escape the attribute-value
269
+ * position it was placed in).
270
+ */
271
+ function cssAttributeValueLiteral(value) {
272
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
273
+ }
274
+ /**
275
+ * Maps one frozen v0.2 TargetLocator to the Playwright `Locator` that
276
+ * resolves it - the single point where locator kind becomes a real browser
277
+ * query. `role`/`text` use Playwright's own semantic locators (real
278
+ * accessibility-tree/text resolution, not a hand-rolled approximation);
279
+ * `id`/`data-attribute` use an exact CSS attribute-equals selector (never
280
+ * `#id`/bare-value syntax, so the configured value can never be
281
+ * reinterpreted as selector syntax); `semantic-element` uses a plain tag
282
+ * selector from the Batch 1 frozen tag set; `css` is unchanged v0.1
283
+ * behavior. Every kind converges on the same Playwright `Locator` type, so
284
+ * cardinality/measurement downstream never branches on locator kind again.
285
+ */
286
+ function buildPlaywrightLocator(page, locator) {
287
+ switch (locator.kind) {
288
+ case 'css':
289
+ return page.locator(locator.selector);
290
+ case 'id':
291
+ return page.locator(`[id=${cssAttributeValueLiteral(locator.value)}]`);
292
+ case 'data-attribute':
293
+ return page.locator(`[${locator.attribute}=${cssAttributeValueLiteral(locator.value)}]`);
294
+ case 'semantic-element':
295
+ return page.locator(locator.tag);
296
+ case 'role':
297
+ return page.getByRole(locator.role, locator.name === undefined ? undefined : { name: locator.name, exact: true });
298
+ case 'text':
299
+ return page.getByText(locator.text, { exact: true });
155
300
  }
156
- finally {
157
- await handle.dispose();
301
+ }
302
+ /**
303
+ * Evaluates one locator against the live page: builds its real Playwright
304
+ * `Locator` and measures cardinality via `.count()` (no waiting, no
305
+ * `.first()`/`.nth(0)` shortcut that would silently resolve an ambiguous
306
+ * match). A locator that cannot be constructed or counted reliably is
307
+ * `unavailable`, never silently treated as zero matches.
308
+ */
309
+ async function evaluateLocatorAttempt(page, locator) {
310
+ try {
311
+ const playwrightLocator = buildPlaywrightLocator(page, locator);
312
+ const matchCount = await playwrightLocator.count();
313
+ if (matchCount === 0)
314
+ return { playwrightLocator, outcome: { status: 'not-found', matchCount: 0 } };
315
+ if (matchCount > 1)
316
+ return { playwrightLocator, outcome: { status: 'ambiguous', matchCount } };
317
+ return { playwrightLocator, outcome: { status: 'matched', matchCount: 1 } };
318
+ }
319
+ catch {
320
+ return { playwrightLocator: undefined, outcome: { status: 'unavailable' } };
158
321
  }
159
322
  }
160
- /** Observes every explicitly configured Batch 1 target from the same live page, honoring the 0/1/many cardinality contract. */
323
+ /**
324
+ * Observes every explicitly configured target from the same live page,
325
+ * honoring the frozen ordered-locator resolution contract: 0 matches tries
326
+ * the next locator; exactly 1 match selects and stops; more than 1 match is
327
+ * ambiguous and stops (never falls through); an unevaluable locator is
328
+ * unavailable and stops (never falls through). Ambiguity never triggers
329
+ * fallback.
330
+ *
331
+ * Batch 3 restructures this into three phases over one resolved set: (1)
332
+ * resolve every configured target exactly once, keeping a live
333
+ * `{locator, handle}` for each match; (2) compute bounded pairwise DOM
334
+ * containment among only the resolved configured targets, reusing those
335
+ * same handles (never re-resolving, never a second/independent resolution
336
+ * algorithm); (3) measure every resolved target and assemble its evidence,
337
+ * attaching the phase-2 containment result. Every handle is disposed once,
338
+ * after all three phases, regardless of outcome.
339
+ */
161
340
  export async function captureTargetEvidence(page, targets) {
162
341
  const targetEvidence = {};
163
342
  const diagnostics = [];
343
+ const infos = [];
344
+ // Phase 1: resolve every configured target exactly once.
164
345
  for (const target of targets) {
165
- const matchCount = await page.evaluate((selector) => document.querySelectorAll(selector).length, target.selector);
166
- if (matchCount === 0) {
167
- targetEvidence[target.name] = missingTargetRecord();
168
- diagnostics.push(diagnostic('target-missing', `no element matched selector for target "${target.name}"`, target.name));
169
- continue;
346
+ const attempts = [];
347
+ let stopStatus;
348
+ let matchedIndex;
349
+ let matchedPlaywrightLocator;
350
+ for (let index = 0; index < target.locators.length; index += 1) {
351
+ const locator = target.locators[index];
352
+ const { playwrightLocator, outcome } = await evaluateLocatorAttempt(page, locator);
353
+ attempts.push({
354
+ locatorIndex: index,
355
+ locatorKind: locator.kind,
356
+ status: outcome.status,
357
+ ...(outcome.matchCount !== undefined ? { matchCount: outcome.matchCount } : {}),
358
+ });
359
+ if (outcome.status === 'matched' && playwrightLocator) {
360
+ stopStatus = 'matched';
361
+ matchedIndex = index;
362
+ matchedPlaywrightLocator = playwrightLocator;
363
+ break;
364
+ }
365
+ if (outcome.status === 'ambiguous') {
366
+ stopStatus = 'ambiguous';
367
+ break;
368
+ }
369
+ if (outcome.status === 'unavailable') {
370
+ stopStatus = 'unavailable';
371
+ break;
372
+ }
373
+ // 'not-found': fall through to the next configured locator.
374
+ }
375
+ if (stopStatus === 'matched' && matchedPlaywrightLocator && matchedIndex !== undefined) {
376
+ try {
377
+ const handles = await matchedPlaywrightLocator.elementHandles();
378
+ const handle = handles[0];
379
+ if (handle) {
380
+ infos.push({
381
+ name: target.name,
382
+ attempts,
383
+ status: 'matched',
384
+ selectedIndex: matchedIndex,
385
+ locator: matchedPlaywrightLocator,
386
+ handle: handle,
387
+ });
388
+ }
389
+ else {
390
+ infos.push({ name: target.name, attempts, status: 'not-found' });
391
+ }
392
+ }
393
+ catch (err) {
394
+ const message = err instanceof Error ? err.message : String(err);
395
+ infos.push({ name: target.name, attempts, status: 'unavailable', unavailableReason: `target evaluation failed: ${message}` });
396
+ }
170
397
  }
171
- if (matchCount > 1) {
172
- targetEvidence[target.name] = ambiguousTargetRecord(matchCount);
173
- diagnostics.push(diagnostic('target-ambiguous', `${matchCount} elements matched selector for target "${target.name}"`, target.name));
174
- continue;
398
+ else if (stopStatus === 'ambiguous') {
399
+ infos.push({ name: target.name, attempts, status: 'ambiguous' });
175
400
  }
176
- try {
177
- targetEvidence[target.name] = await captureResolvedTargetRecord(page, target.selector);
401
+ else if (stopStatus === 'unavailable') {
402
+ infos.push({ name: target.name, attempts, status: 'unavailable' });
178
403
  }
179
- catch (err) {
180
- const message = err instanceof Error ? err.message : String(err);
181
- const reason = `target evaluation failed: ${message}`;
182
- targetEvidence[target.name] = {
183
- resolution: {
184
- state: 'available',
185
- source: 'derived',
186
- value: { selectionMethod: 'css-selector', selectionStatus: 'matched' },
187
- derivedFrom: ['selector-query'],
188
- },
189
- tag: unavailableField(reason),
190
- geometry: unavailableField(reason),
191
- style: unavailableField(reason),
192
- layout: unavailableField(reason),
193
- visibility: unavailableField(reason),
194
- semantics: unavailableField(reason),
195
- };
196
- diagnostics.push(diagnostic('browser-evidence-unavailable', reason, target.name));
404
+ else {
405
+ infos.push({ name: target.name, attempts, status: 'not-found' });
197
406
  }
198
407
  }
408
+ try {
409
+ // Phase 2: bounded pairwise DOM containment among the configured targets
410
+ // only (never unconfigured ancestors), in configured target order.
411
+ const containmentByName = new Map();
412
+ for (const info of infos) {
413
+ if (info.status !== 'matched' || !info.handle)
414
+ continue;
415
+ const selfHandle = info.handle;
416
+ const containedByTargetIds = [];
417
+ const evaluatedTargetIds = [];
418
+ const unresolvedTargetIds = [];
419
+ for (const other of infos) {
420
+ if (other.name === info.name)
421
+ continue;
422
+ if (other.status === 'matched' && other.handle) {
423
+ evaluatedTargetIds.push(other.name);
424
+ const contains = await other.handle.evaluate((elOther, elSelf) => elOther !== elSelf && elOther.contains(elSelf), selfHandle);
425
+ if (contains)
426
+ containedByTargetIds.push(other.name);
427
+ }
428
+ else {
429
+ unresolvedTargetIds.push(other.name);
430
+ }
431
+ }
432
+ containmentByName.set(info.name, containmentEvidence({ containedByTargetIds, evaluatedTargetIds, unresolvedTargetIds }, undefined));
433
+ }
434
+ // Phase 3: measure resolved targets and assemble every target's evidence.
435
+ for (const info of infos) {
436
+ if (info.status === 'matched' && info.locator && info.handle && info.selectedIndex !== undefined) {
437
+ try {
438
+ const containment = containmentByName.get(info.name) ?? containmentEvidence(undefined, 'containment could not be computed');
439
+ const { record, visible } = await captureResolvedTargetRecord(info.locator, info.handle, info.attempts, info.selectedIndex, containment);
440
+ targetEvidence[info.name] = record;
441
+ if (!visible) {
442
+ diagnostics.push(diagnostic('target-hidden', `target "${info.name}" resolved but is not visible`, info.name));
443
+ }
444
+ }
445
+ catch (err) {
446
+ const message = err instanceof Error ? err.message : String(err);
447
+ const reason = `target evaluation failed: ${message}`;
448
+ targetEvidence[info.name] = unresolvedTargetRecord('unavailable', info.attempts, reason, containmentEvidence(undefined, `target itself could not be measured: ${reason}`));
449
+ diagnostics.push(diagnostic('browser-evidence-unavailable', reason, info.name));
450
+ }
451
+ continue;
452
+ }
453
+ const selfUnresolvedReason = `target itself did not resolve (status: ${info.status})`;
454
+ if (info.status === 'ambiguous') {
455
+ const lastAttempt = info.attempts[info.attempts.length - 1];
456
+ const matchCount = lastAttempt?.matchCount ?? 0;
457
+ targetEvidence[info.name] = unresolvedTargetRecord('ambiguous', info.attempts, `target locator matched ${matchCount} elements; expected exactly one`, containmentEvidence(undefined, selfUnresolvedReason));
458
+ diagnostics.push(diagnostic('target-ambiguous', `${matchCount} elements matched a locator for target "${info.name}"`, info.name));
459
+ }
460
+ else if (info.status === 'unavailable') {
461
+ const reason = info.unavailableReason ?? `target locator could not be evaluated reliably for "${info.name}"`;
462
+ targetEvidence[info.name] = unresolvedTargetRecord('unavailable', info.attempts, reason, containmentEvidence(undefined, selfUnresolvedReason));
463
+ diagnostics.push(diagnostic('browser-evidence-unavailable', reason, info.name));
464
+ }
465
+ else {
466
+ targetEvidence[info.name] = unresolvedTargetRecord('not-found', info.attempts, 'no configured locator matched an element', containmentEvidence(undefined, selfUnresolvedReason));
467
+ diagnostics.push(diagnostic('target-missing', `no locator matched an element for target "${info.name}"`, info.name));
468
+ }
469
+ }
470
+ }
471
+ finally {
472
+ await Promise.all(infos.filter((info) => info.handle).map((info) => info.handle.dispose()));
473
+ }
199
474
  return { targetEvidence, diagnostics };
200
475
  }
201
476
  //# sourceMappingURL=evidenceCapture.js.map