@pcamarajr/scout 0.13.0 → 0.15.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.
@@ -1,6 +1,12 @@
1
1
  import path from "node:path";
2
- import { chromium } from "playwright";
2
+ import { chromium, selectors } from "playwright";
3
+ import { resolveDeviceOptions } from "../device.js";
4
+ import { assembleTarget, buildLadderCandidates, runWithFallback } from "./selector-ladder.js";
3
5
  import { findConsoleErrors, globToRegex, matchNetwork, } from "./network-match.js";
6
+ /** Default testid attribute, when `scout.config.json` sets none. */
7
+ export const DEFAULT_TESTID_ATTR = "data-testid";
8
+ /** How many ancestors a click may borrow a testid from (self is checked first). */
9
+ const TESTID_ANCESTOR_HOPS = 2;
4
10
  /**
5
11
  * Builds an init-script that denies the permissions that would otherwise pop a
6
12
  * native prompt (geolocation, notifications, camera, microphone), stubbing the
@@ -88,6 +94,84 @@ export function demoCursorStub() {
88
94
  };
89
95
  })();`;
90
96
  }
97
+ /**
98
+ * Pool of shared Chromium Browser processes, keyed by the launch-time options
99
+ * that cannot be changed per-context: `${headless}|${slowMoMs}`. A full
100
+ * `chromium.launch()` is the expensive part; within one process (the CLI `go`
101
+ * loop over scenarios/viewports, or the long-lived MCP server across tool
102
+ * calls) we launch once per key and reuse the process, opening a fresh isolated
103
+ * BrowserContext per BrowserSession — the context is the isolation boundary.
104
+ *
105
+ * slowMo is a launch-time option: the paced demo replay needs its own pooled
106
+ * browser, and both demo attempts (paced + fallback share the same slowMo=0 for
107
+ * the fallback / the paced value for the first) plus subsequent scenarios reuse
108
+ * whichever they key into.
109
+ *
110
+ * The map stores the launch PROMISE (not the resolved Browser) so two
111
+ * concurrent launch() calls with the same key share one launch instead of
112
+ * racing into two Chromium processes.
113
+ */
114
+ const browserPool = new Map();
115
+ function poolKey(headless, slowMoMs) {
116
+ return `${headless}|${slowMoMs ?? 0}`;
117
+ }
118
+ /**
119
+ * Returns a shared Browser for the given launch options, launching one on first
120
+ * use and reusing it thereafter. If a pooled browser has since disconnected
121
+ * (crash, or an external close), its entry is dropped and a fresh browser is
122
+ * launched.
123
+ */
124
+ async function acquireBrowser(headless, slowMoMs) {
125
+ const key = poolKey(headless, slowMoMs);
126
+ // Reuse a live pooled entry; drop a stale/failed one and launch a fresh
127
+ // browser. The map is only ever written synchronously (no await between the
128
+ // decision to claim `key` and the set below), so two concurrent callers that
129
+ // both find the same stale entry can't both launch: the first to resume
130
+ // claims the key, the second re-reads the map and awaits that fresh launch.
131
+ for (;;) {
132
+ const existing = browserPool.get(key);
133
+ if (!existing)
134
+ break;
135
+ let browser;
136
+ try {
137
+ browser = await existing;
138
+ }
139
+ catch {
140
+ browser = undefined; // a prior pooled launch failed — relaunch below
141
+ }
142
+ if (browser?.isConnected())
143
+ return browser;
144
+ // Stale/failed. Evict by identity: only drop it if it's still the mapped
145
+ // entry, so we never discard a fresh browser a concurrent caller just
146
+ // installed under this key. If the map already changed, loop to re-read it.
147
+ if (browserPool.get(key) === existing) {
148
+ browserPool.delete(key);
149
+ break;
150
+ }
151
+ }
152
+ const launched = chromium.launch({ headless, slowMo: slowMoMs });
153
+ browserPool.set(key, launched);
154
+ // If the launch itself rejects, don't leave a rejected promise cached — the
155
+ // next call should get a clean retry. Delete by identity so a rejecting
156
+ // orphaned launch can't evict a healthy replacement mapped under the same key.
157
+ launched.catch(() => {
158
+ if (browserPool.get(key) === launched)
159
+ browserPool.delete(key);
160
+ });
161
+ return launched;
162
+ }
163
+ /**
164
+ * Closes every pooled Browser process and clears the pool. Call this before a
165
+ * process that ran scenarios exits (the CLI `go` command, a library consumer
166
+ * looping runScenario) — otherwise the live browser keeps the Node event loop
167
+ * alive and the process never exits. Errors are tolerated: a browser that is
168
+ * already gone is not a failure to close.
169
+ */
170
+ export async function closeBrowsers() {
171
+ const pending = Array.from(browserPool.values());
172
+ browserPool.clear();
173
+ await Promise.all(pending.map((p) => p.then((b) => b.close()).catch(() => { })));
174
+ }
91
175
  const STEP_TIMEOUT = 10_000;
92
176
  /**
93
177
  * Cap on the "let the page settle" wait of one-shot presence checks. Network
@@ -103,7 +187,6 @@ const CONSOLE_LOG_CAP = 500;
103
187
  * Owns ref→element resolution, tracing and screenshots.
104
188
  */
105
189
  export class BrowserSession {
106
- browser;
107
190
  context;
108
191
  opts;
109
192
  videoEpoch;
@@ -121,33 +204,59 @@ export class BrowserSession {
121
204
  activePage;
122
205
  /** The first page — it owns the recorded video for the whole context. */
123
206
  firstPage;
124
- constructor(browser, context, page, opts,
207
+ /** Testid attribute the selector ladder reads and `getByTestId` matches. */
208
+ testIdAttr;
209
+ constructor(context, page, opts,
125
210
  /** Date.now() at video start (context creation); undefined when not recording */
126
211
  videoEpoch, refs = new Map()) {
127
- this.browser = browser;
128
212
  this.context = context;
129
213
  this.opts = opts;
130
214
  this.videoEpoch = videoEpoch;
131
215
  this.refs = refs;
132
216
  this.activePage = page;
133
217
  this.firstPage = page;
218
+ this.testIdAttr = opts.testIdAttribute ?? DEFAULT_TESTID_ATTR;
134
219
  }
135
220
  /** The tab currently under control. Switching tabs reassigns it. */
136
221
  get page() {
137
222
  return this.activePage;
138
223
  }
139
224
  static async launch(opts) {
140
- const browser = await chromium.launch({ headless: opts.headless, slowMo: opts.slowMoMs });
225
+ // Align Playwright's global testid attribute with the ladder's, so a
226
+ // recorded `testId` target resolves via getByTestId to the same attribute
227
+ // the ladder read at record time. Idempotent; safe to set every launch.
228
+ const testIdAttr = opts.testIdAttribute ?? DEFAULT_TESTID_ATTR;
229
+ if (testIdAttr !== DEFAULT_TESTID_ATTR) {
230
+ try {
231
+ selectors.setTestIdAttribute(testIdAttr);
232
+ }
233
+ catch {
234
+ // Older Playwright without the setter — getByTestId keeps the default.
235
+ }
236
+ }
237
+ // Reuse a pooled Chromium process (launched once per headless|slowMo key);
238
+ // isolation is provided by the fresh context created below, not by a new
239
+ // browser process.
240
+ const browser = await acquireBrowser(opts.headless, opts.slowMoMs);
141
241
  const perm = opts.permissions;
142
242
  const vp = opts.viewport;
143
- const size = { width: vp.width, height: vp.height };
243
+ // Device emulation composes OVER the viewport: an explicit device field wins
244
+ // over the viewport's value; whatever the device doesn't set falls back to
245
+ // the viewport. This is how a scenario emulates a UA-gated environment (e.g.
246
+ // an iOS-Safari-only sheet) without changing which viewport it fans out in.
247
+ const dev = opts.device ? resolveDeviceOptions(opts.device) : undefined;
248
+ const size = dev?.viewport ?? { width: vp.width, height: vp.height };
249
+ const deviceScaleFactor = dev?.deviceScaleFactor ?? vp.deviceScaleFactor;
250
+ const isMobile = dev?.isMobile ?? vp.isMobile;
251
+ const hasTouch = dev?.hasTouch ?? vp.hasTouch;
252
+ const userAgent = dev?.userAgent ?? vp.userAgent;
144
253
  const context = await browser.newContext({
145
254
  locale: opts.locale ?? "pt-BR",
146
255
  viewport: size,
147
- ...(vp.deviceScaleFactor != null ? { deviceScaleFactor: vp.deviceScaleFactor } : {}),
148
- ...(vp.isMobile != null ? { isMobile: vp.isMobile } : {}),
149
- ...(vp.hasTouch != null ? { hasTouch: vp.hasTouch } : {}),
150
- ...(vp.userAgent != null ? { userAgent: vp.userAgent } : {}),
256
+ ...(deviceScaleFactor != null ? { deviceScaleFactor } : {}),
257
+ ...(isMobile != null ? { isMobile } : {}),
258
+ ...(hasTouch != null ? { hasTouch } : {}),
259
+ ...(userAgent != null ? { userAgent } : {}),
151
260
  storageState: opts.storageState,
152
261
  ...(opts.extraHeaders ? { extraHTTPHeaders: opts.extraHeaders } : {}),
153
262
  ...(perm?.grant?.length ? { permissions: perm.grant } : {}),
@@ -179,7 +288,7 @@ export class BrowserSession {
179
288
  const videoEpoch = opts.recordVideo ? Date.now() : undefined;
180
289
  await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
181
290
  const page = await context.newPage();
182
- const session = new BrowserSession(browser, context, page, opts, videoEpoch);
291
+ const session = new BrowserSession(context, page, opts, videoEpoch);
183
292
  session.trackPage(page); // first page: registered before context.on("page")
184
293
  context.on("page", (p) => session.trackPage(p)); // popups/new tabs
185
294
  return session;
@@ -316,7 +425,7 @@ export class BrowserSession {
316
425
  */
317
426
  async snapshot() {
318
427
  await this.page.waitForLoadState("domcontentloaded").catch(() => { });
319
- const raw = (await this.page.evaluate(snapshotScript));
428
+ const raw = (await this.page.evaluate(buildSnapshotScript(this.testIdAttr, TESTID_ANCESTOR_HOPS)));
320
429
  this.refs.clear();
321
430
  for (const el of raw.elements)
322
431
  this.refs.set(el.ref, el);
@@ -342,37 +451,146 @@ export class BrowserSession {
342
451
  }
343
452
  /**
344
453
  * Resolves a snapshot ref to a locator AND the durable Target recorded for
345
- * replay: role+name when unique on the page, CSS path otherwise.
454
+ * replay, walking the selector preference ladder (testid id → role+name →
455
+ * text → positional CSS) against the live page and recording the most stable
456
+ * rung that uniquely matches — with the other unique rungs kept as ordered
457
+ * fallbacks. `action` gates the ancestor-testid rung: a click may borrow a
458
+ * close ancestor's testid, an input (fill/select) may not (it must act on the
459
+ * field itself, not a wrapping container).
346
460
  */
347
- async resolveRef(ref) {
461
+ async resolveRef(ref, action) {
348
462
  const info = this.refs.get(ref);
349
463
  if (!info) {
350
464
  throw new Error(`Ref [${ref}] unknown — take a new browser_snapshot, the page changed.`);
351
465
  }
352
- const description = `${info.role} "${info.name}"`;
353
- if (info.role && info.name) {
354
- const byRole = this.page.getByRole(info.role, {
355
- name: info.name,
356
- exact: true,
357
- });
358
- if ((await byRole.count()) === 1) {
359
- return { locator: byRole, target: { role: info.role, name: info.name, description } };
466
+ const ladderInfo = {
467
+ role: info.role || undefined,
468
+ name: info.name || undefined,
469
+ css: info.css,
470
+ testId: info.testId,
471
+ ancestorTestId: info.ancestorTestId,
472
+ id: info.id,
473
+ text: info.text,
474
+ };
475
+ const target = await this.resolveTarget(ladderInfo, { allowAncestorTestId: action === "click" });
476
+ return { locator: this.targetLocator(target), target };
477
+ }
478
+ /**
479
+ * Walks the selector ladder against the live page: builds the ordered
480
+ * candidates, keeps those that match EXACTLY one element (so a recorded
481
+ * selector is never ambiguous), and assembles the primary + fallbacks + the
482
+ * fragile flag. Shared by ref-based interactions and selector-based clicks.
483
+ */
484
+ async resolveTarget(info, opts = {}) {
485
+ const candidates = buildLadderCandidates(info, {
486
+ allowAncestorTestId: opts.allowAncestorTestId,
487
+ testIdAttr: this.testIdAttr,
488
+ });
489
+ const unique = [];
490
+ for (const candidate of candidates) {
491
+ // A locator for an unsupported ARIA role (a tag used as a role fallback)
492
+ // may throw; treat any failure to count as "no match".
493
+ let count = 0;
494
+ try {
495
+ count = await this.rawLocator(candidate.target).count();
496
+ }
497
+ catch {
498
+ count = 0;
360
499
  }
500
+ if (count === 1)
501
+ unique.push(candidate);
361
502
  }
362
- return { locator: this.page.locator(info.css), target: { css: info.css, description } };
503
+ return assembleTarget(candidates, unique);
363
504
  }
364
505
  async click(ref) {
365
- const { locator, target } = await this.resolveRef(ref);
506
+ const { locator, target } = await this.resolveRef(ref, "click");
366
507
  await locator.click();
367
508
  return target;
368
509
  }
510
+ /**
511
+ * Clicks an element located directly by data-testid or CSS — no snapshot ref
512
+ * involved. This is the escape hatch for elements OUTSIDE the accessibility
513
+ * tree (a gesture layer, an overlay `<div>` with only a data-testid): they
514
+ * never get a numbered [ref], so ref-based click can't reach them. Returns the
515
+ * durable Target so the caller records a deterministic `click` step (the same
516
+ * kind as a ref click — one replay path, testId/css resolved by targetLocator).
517
+ */
518
+ async clickSelector(sel) {
519
+ const base = buildSelectorTarget(sel);
520
+ // Upgrade the AI-supplied selector through the ladder: read the live
521
+ // element's signals and record the most stable rung (+ fallbacks). A
522
+ // role-less overlay with only a positional CSS still bottoms out as fragile,
523
+ // which is the signal to add a data-testid — surfaced as a warning.
524
+ const info = await this.ladderInfoFor(base);
525
+ const target = info
526
+ ? await this.resolveTarget(info, { allowAncestorTestId: true })
527
+ : base;
528
+ await this.targetLocator(target).click();
529
+ return target;
530
+ }
531
+ /**
532
+ * Reads the selector-ladder signals from the FIRST element the given
533
+ * testid/css target resolves to, in-page. Returns undefined when the element
534
+ * isn't found (the click below then fails on the base target, as before).
535
+ */
536
+ async ladderInfoFor(target) {
537
+ const selector = target.testId
538
+ ? `[${cssAttrEscape(this.testIdAttr)}="${cssValueEscape(target.testId)}"]`
539
+ : target.css;
540
+ if (!selector)
541
+ return undefined;
542
+ const raw = (await this.page.evaluate(buildExtractScript(selector, this.testIdAttr, TESTID_ANCESTOR_HOPS)));
543
+ return raw ?? undefined;
544
+ }
545
+ /**
546
+ * Asserts an element's VISUAL/structural state (class token, attribute,
547
+ * computed style) — the check text/URL assertions can't express. Polls until
548
+ * every provided condition holds or `timeout` elapses, so it is deterministic
549
+ * on replay. The canonical use is the opacity toggle: a control kept in the DOM
550
+ * but hidden with `opacity:0` (which Playwright still reports as "visible", so
551
+ * assertNotVisible would false-pass) — assert `opacity-0`/computed opacity
552
+ * instead. The element must at least be attached; a missing element fails.
553
+ */
554
+ async assertState(target, checks, timeout = STEP_TIMEOUT) {
555
+ if (checks.hasClass === undefined &&
556
+ checks.notHasClass === undefined &&
557
+ checks.attribute === undefined &&
558
+ checks.computedStyle === undefined) {
559
+ throw new Error("assertState needs at least one of hasClass/notHasClass/attribute/computedStyle.");
560
+ }
561
+ const locator = this.targetLocator(target);
562
+ await locator.waitFor({ state: "attached", timeout }).catch(() => {
563
+ throw new Error(`Element ${target.description} not found within ${timeout}ms.`);
564
+ });
565
+ const styleProp = checks.computedStyle?.property ?? null;
566
+ const deadline = Date.now() + timeout;
567
+ let lastReason = "";
568
+ for (;;) {
569
+ const observed = await locator
570
+ .evaluate((el, prop) => ({
571
+ classes: Array.from(el.classList),
572
+ attrs: Object.fromEntries(Array.from(el.attributes).map((a) => [a.name, a.value])),
573
+ styleValue: prop ? getComputedStyle(el).getPropertyValue(prop).trim() : null,
574
+ }), styleProp)
575
+ .catch(() => null);
576
+ if (observed) {
577
+ lastReason = describeStateMismatch(target, checks, observed);
578
+ if (!lastReason)
579
+ return; // every provided check holds
580
+ }
581
+ if (Date.now() >= deadline) {
582
+ throw new Error(lastReason || `Could not read the state of ${target.description} within ${timeout}ms.`);
583
+ }
584
+ await this.page.waitForTimeout(100);
585
+ }
586
+ }
369
587
  async fill(ref, value) {
370
- const { locator, target } = await this.resolveRef(ref);
588
+ const { locator, target } = await this.resolveRef(ref, "input");
371
589
  await locator.fill(value);
372
590
  return target;
373
591
  }
374
592
  async select(ref, value) {
375
- const { locator, target } = await this.resolveRef(ref);
593
+ const { locator, target } = await this.resolveRef(ref, "input");
376
594
  await locator.selectOption(value);
377
595
  return target;
378
596
  }
@@ -535,17 +753,23 @@ export class BrowserSession {
535
753
  this.screenshots.push(file);
536
754
  return file;
537
755
  }
538
- /** Replays one recorded step (used by the deterministic runner). */
539
- async executeStep(step) {
756
+ /**
757
+ * Replays one recorded step (used by the deterministic runner). For the
758
+ * interaction steps (click/fill/select) the action is tried on the primary
759
+ * selector and, if it fails, on each recorded fallback in order — a purely
760
+ * deterministic retry (no LLM, `--no-heal` intact). `onFallback` is notified
761
+ * when a fallback resolved, so the runner can log which selector rescued it.
762
+ */
763
+ async executeStep(step, onFallback) {
540
764
  switch (step.kind) {
541
765
  case "navigate":
542
766
  return this.navigate(step.url);
543
767
  case "click":
544
- return void (await this.targetLocator(step.target).click());
768
+ return this.actWithFallback(step.target, (loc) => loc.click(), onFallback);
545
769
  case "fill":
546
- return void (await this.targetLocator(step.target).fill(resolveEnvValue(step.value)));
770
+ return this.actWithFallback(step.target, (loc) => loc.fill(resolveEnvValue(step.value)), onFallback);
547
771
  case "select":
548
- return void (await this.targetLocator(step.target).selectOption(resolveEnvValue(step.value)));
772
+ return this.actWithFallback(step.target, (loc) => loc.selectOption(resolveEnvValue(step.value)), onFallback);
549
773
  case "press":
550
774
  return this.press(step.key);
551
775
  case "wheel":
@@ -560,6 +784,13 @@ export class BrowserSession {
560
784
  return this.assertVisible(step.text, { timeout: step.timeout, oneShot: step.oneShot });
561
785
  case "assertNotVisible":
562
786
  return this.assertNotVisible(step.text, step.timeout);
787
+ case "assertState":
788
+ return this.assertState(step.target, {
789
+ hasClass: step.hasClass,
790
+ notHasClass: step.notHasClass,
791
+ attribute: step.attribute,
792
+ computedStyle: step.computedStyle,
793
+ }, step.timeout);
563
794
  case "assertUrl":
564
795
  return this.assertUrl(step.pattern, step.timeout);
565
796
  case "assertNetwork":
@@ -574,15 +805,36 @@ export class BrowserSession {
574
805
  return void (await this.screenshot(step.label));
575
806
  }
576
807
  }
808
+ /**
809
+ * Attempts an action on the target's primary selector, then on each recorded
810
+ * fallback in order until one succeeds. The primary's own auto-wait runs
811
+ * first (unchanged happy path); only on failure are fallbacks tried, each with
812
+ * their own auto-wait. If every candidate fails, the PRIMARY's error is thrown
813
+ * — the most relevant one to debug. No LLM, so `--no-heal` semantics hold.
814
+ */
815
+ actWithFallback(target, act, onFallback) {
816
+ return runWithFallback(target, (t) => this.targetLocator(t), act, onFallback);
817
+ }
818
+ /** The replay locator: the primary strategy, narrowed to the first match. */
577
819
  targetLocator(target) {
820
+ return this.rawLocator(target).first();
821
+ }
822
+ /**
823
+ * The unnarrowed locator for a target's primary strategy — used for the record
824
+ * -time uniqueness check (its `count()` must reflect ALL matches, so no
825
+ * `.first()` here). testId → role+name → text → css, mirroring the ladder.
826
+ */
827
+ rawLocator(target) {
828
+ if (target.testId)
829
+ return this.page.getByTestId(target.testId);
578
830
  if (target.role && target.name) {
579
- return this.page
580
- .getByRole(target.role, {
831
+ return this.page.getByRole(target.role, {
581
832
  name: target.name,
582
833
  exact: true,
583
- })
584
- .first();
834
+ });
585
835
  }
836
+ if (target.text)
837
+ return this.page.getByText(target.text, { exact: true });
586
838
  if (target.css)
587
839
  return this.page.locator(target.css);
588
840
  throw new Error(`Target has no location strategy: ${target.description}`);
@@ -635,8 +887,9 @@ export class BrowserSession {
635
887
  // finalized once the context is closed (hence close() is awaited). The
636
888
  // first page owns the context recording, even after switching tabs.
637
889
  const video = this.firstPage.video();
890
+ // Close ONLY the context — the Browser process is shared (pooled) and reused
891
+ // by later sessions. closeBrowsers() tears the process down at process exit.
638
892
  await this.context.close().catch(() => { });
639
- await this.browser.close().catch(() => { });
640
893
  let videoPath;
641
894
  if (video) {
642
895
  try {
@@ -715,6 +968,57 @@ export function buildStorageInitScript(storage) {
715
968
  } catch (e) {}
716
969
  })();`;
717
970
  }
971
+ /**
972
+ * Builds a durable Target for an element located directly by data-testid or CSS
973
+ * (no snapshot ref). testId wins when both are given — it is the stabler handle
974
+ * for elements outside the a11y tree. Throws when neither is provided, so a
975
+ * selector click/assert can never silently target nothing.
976
+ */
977
+ export function buildSelectorTarget(sel) {
978
+ if (sel.testId)
979
+ return { testId: sel.testId, description: `[data-testid="${sel.testId}"]` };
980
+ if (sel.css)
981
+ return { css: sel.css, description: sel.css };
982
+ throw new Error("A selector click/assert needs testId or css.");
983
+ }
984
+ /** Escapes a CSS attribute NAME for an attribute selector (identifier subset). */
985
+ function cssAttrEscape(name) {
986
+ return name.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
987
+ }
988
+ /** Escapes a value for a double-quoted CSS attribute selector. */
989
+ function cssValueEscape(value) {
990
+ return value.replace(/(["\\])/g, "\\$1");
991
+ }
992
+ /**
993
+ * Compares an observed element state against a matcher, returning "" when every
994
+ * provided check holds or a human-readable reason for the FIRST failing one.
995
+ * Pure, so it is unit-testable without a browser and shared by the live poll.
996
+ */
997
+ export function describeStateMismatch(target, checks, observed) {
998
+ const where = ` on ${target.description}`;
999
+ if (checks.hasClass !== undefined && !observed.classes.includes(checks.hasClass)) {
1000
+ return `Expected class "${checks.hasClass}"${where}, but classes are [${observed.classes.join(", ")}].`;
1001
+ }
1002
+ if (checks.notHasClass !== undefined && observed.classes.includes(checks.notHasClass)) {
1003
+ return `Class "${checks.notHasClass}" must be absent${where}, but it is present [${observed.classes.join(", ")}].`;
1004
+ }
1005
+ if (checks.attribute !== undefined) {
1006
+ const { name, value } = checks.attribute;
1007
+ const has = Object.prototype.hasOwnProperty.call(observed.attrs, name);
1008
+ if (!has)
1009
+ return `Expected attribute "${name}"${where}, but it is absent.`;
1010
+ if (value !== undefined && observed.attrs[name] !== value) {
1011
+ return `Expected attribute "${name}"="${value}"${where}, but it is "${observed.attrs[name]}".`;
1012
+ }
1013
+ }
1014
+ if (checks.computedStyle !== undefined) {
1015
+ const { property, value } = checks.computedStyle;
1016
+ if (observed.styleValue !== value) {
1017
+ return `Expected computed ${property}="${value}"${where}, but it is "${observed.styleValue ?? "(unread)"}".`;
1018
+ }
1019
+ }
1020
+ return "";
1021
+ }
718
1022
  /** Replaces $ENV:VAR_NAME placeholders so secrets never live in committed scripts. */
719
1023
  export function resolveEnvValue(value) {
720
1024
  return value.replace(/\$ENV:([A-Z0-9_]+)/g, (_, name) => {
@@ -724,8 +1028,16 @@ export function resolveEnvValue(value) {
724
1028
  return v;
725
1029
  });
726
1030
  }
727
- /** Runs inside the page: collects visible interactive elements + text excerpt. */
728
- const snapshotScript = `(() => {
1031
+ /**
1032
+ * In-page helper library, shared by the snapshot script and the single-element
1033
+ * ladder extractor. Kept as one string so the accessible-name / role / css-path
1034
+ * logic lives in exactly one place. `ATTR` (the testid attribute) and `HOPS`
1035
+ * (ancestor testid reach) are interpolated by the builders below.
1036
+ */
1037
+ function inPageHelpers(attr, hops) {
1038
+ return `
1039
+ const TESTID_ATTR = ${JSON.stringify(attr)};
1040
+ const TESTID_HOPS = ${JSON.stringify(hops)};
729
1041
  const ROLE_BY_TAG = {
730
1042
  a: "link", button: "button", select: "combobox", textarea: "textbox",
731
1043
  summary: "button", option: "option",
@@ -800,6 +1112,55 @@ const snapshotScript = `(() => {
800
1112
  return parts.join(" > ");
801
1113
  }
802
1114
 
1115
+ // The element's OWN testid — the top rung of the selector ladder.
1116
+ function testIdOf(el) {
1117
+ const v = el.getAttribute(TESTID_ATTR);
1118
+ return v || undefined;
1119
+ }
1120
+
1121
+ // The nearest ANCESTOR testid within TESTID_HOPS (a click may borrow it).
1122
+ function ancestorTestIdOf(el) {
1123
+ let node = el.parentElement;
1124
+ let hops = 0;
1125
+ while (node && hops < TESTID_HOPS) {
1126
+ const v = node.getAttribute(TESTID_ATTR);
1127
+ if (v) return v;
1128
+ node = node.parentElement;
1129
+ hops += 1;
1130
+ }
1131
+ return undefined;
1132
+ }
1133
+
1134
+ // Visible text for a text anchor — only when short and stable enough, and not
1135
+ // for form controls (their "text" is the value, not a stable label).
1136
+ function textOf(el) {
1137
+ const tag = el.tagName;
1138
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return undefined;
1139
+ const t = (el.textContent || "").replace(/\\s+/g, " ").trim();
1140
+ if (!t || t.length > 50) return undefined;
1141
+ return t;
1142
+ }
1143
+
1144
+ // The full ladder signal set for one element (role/name are COMPUTED here,
1145
+ // from the live DOM, never guessed from a visible label).
1146
+ function extractLadder(el) {
1147
+ return {
1148
+ role: roleOf(el) || undefined,
1149
+ name: nameOf(el) || undefined,
1150
+ css: cssPath(el),
1151
+ testId: testIdOf(el),
1152
+ ancestorTestId: ancestorTestIdOf(el),
1153
+ id: el.id || undefined,
1154
+ text: textOf(el),
1155
+ };
1156
+ }
1157
+ `;
1158
+ }
1159
+ /** Runs inside the page: collects visible interactive elements + text excerpt. */
1160
+ function buildSnapshotScript(attr, hops) {
1161
+ return `(() => {
1162
+ ${inPageHelpers(attr, hops)}
1163
+
803
1164
  const selector = 'a[href], button, input, select, textarea, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"], [role="option"], [onclick], [tabindex]:not([tabindex="-1"])';
804
1165
  const nodes = Array.from(document.querySelectorAll(selector)).filter(isVisible);
805
1166
  const seen = new Set();
@@ -811,7 +1172,12 @@ const snapshotScript = `(() => {
811
1172
  const role = roleOf(el);
812
1173
  const name = nameOf(el);
813
1174
  if (!role && !name) continue;
814
- const info = { ref: ref++, role: role || el.tagName.toLowerCase(), name, css: cssPath(el), tag: el.tagName.toLowerCase() };
1175
+ const ladder = extractLadder(el);
1176
+ const info = { ref: ref++, role: role || el.tagName.toLowerCase(), name, css: ladder.css, tag: el.tagName.toLowerCase() };
1177
+ if (ladder.testId) info.testId = ladder.testId;
1178
+ if (ladder.ancestorTestId) info.ancestorTestId = ladder.ancestorTestId;
1179
+ if (ladder.id) info.id = ladder.id;
1180
+ if (ladder.text) info.text = ladder.text;
815
1181
  if (el.tagName === "INPUT" && el.type !== "password" && el.value) info.value = String(el.value).slice(0, 40);
816
1182
  if (el.tagName === "A") {
817
1183
  const href = el.getAttribute("href");
@@ -824,4 +1190,18 @@ const snapshotScript = `(() => {
824
1190
  const text = (document.body.innerText || "").replace(/\\n{3,}/g, "\\n\\n").slice(0, 2500);
825
1191
  return { elements, text };
826
1192
  })()`;
1193
+ }
1194
+ /**
1195
+ * Runs inside the page: extracts the ladder signals for the FIRST element that
1196
+ * matches `selector`, or returns null when nothing matches. Used to upgrade an
1197
+ * AI-supplied selector click through the ladder.
1198
+ */
1199
+ function buildExtractScript(selector, attr, hops) {
1200
+ return `(() => {
1201
+ ${inPageHelpers(attr, hops)}
1202
+ const el = document.querySelector(${JSON.stringify(selector)});
1203
+ if (!el) return null;
1204
+ return extractLadder(el);
1205
+ })()`;
1206
+ }
827
1207
  //# sourceMappingURL=browser.js.map