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 +32 -1
- package/README.md +16 -7
- package/dist/browser/evidenceCapture.d.ts +17 -1
- package/dist/browser/evidenceCapture.js +366 -91
- package/dist/browser/evidenceCapture.js.map +1 -1
- package/dist/cli.js +87 -7
- package/dist/cli.js.map +1 -1
- package/dist/domain/diagnostics.d.ts +1 -1
- package/dist/domain/diagnostics.js +2 -0
- package/dist/domain/diagnostics.js.map +1 -1
- package/dist/domain/identity.js +1 -1
- package/dist/domain/schema.d.ts +49 -5
- package/dist/domain/schema.js +129 -3
- package/dist/domain/schema.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/request/request.d.ts +28 -1
- package/dist/request/request.js +184 -14
- package/dist/request/request.js.map +1 -1
- package/docs/ARCHITECTURE.md +29 -1
- package/docs/CI_CD.md +22 -8
- package/docs/COMMANDS.md +75 -4
- package/docs/CONTRACTS.md +62 -6
- package/docs/CURRENT_STATE.md +83 -6
- package/docs/DEVELOPMENT.md +24 -3
- package/docs/PROJECT_OVERVIEW.md +9 -6
- package/docs/QUICKSTART.md +2 -1
- package/docs/RELEASE.md +7 -5
- package/docs/ROADMAP.md +4 -0
- package/docs/SECURITY.md +6 -2
- package/docs/WORKFLOWS.md +23 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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.
|
|
10
|
-
`my-frontend-observer observe` launches a real,
|
|
11
|
-
enforces a loopback-only safety policy,
|
|
12
|
-
bounded page/target evidence, and
|
|
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
|
|
65
|
-
v0.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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:
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* element, they
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
/**
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 (
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
401
|
+
else if (stopStatus === 'unavailable') {
|
|
402
|
+
infos.push({ name: target.name, attempts, status: 'unavailable' });
|
|
178
403
|
}
|
|
179
|
-
|
|
180
|
-
|
|
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
|