/**
* Extension-backed {@link PageActions} implementation.
*
* Resolves catalogue workflow selectors **without a full `read_ax`** — instead
* of serializing the entire AX tree (which FREEZES the MV3 service worker on
* heavy create forms), each interaction asks the in-page DOM for the ONE
* element that matches the selector, scrolls it into view, and returns its
* viewport-center coords. Then `click_xy` / `type_text` dispatch a trusted CDP
* action — trusted events fire inside Angular's NgZone, so vsui dropdowns,
* form submit, and the console's reactive forms all work.
*
* **Selector grammar** (matches the catalogue YAML + the AX resolver):
* - `text('X')` — element whose textContent includes X.
* - `role:text('X')` — element of `role` (tag-derived) with text X.
* - `role[name='X']` — input/control whose label/name/aria matches X.
* - bare `role` — first element of that role.
* - anything else — CSS querySelector.
*
* This approach was validated by the manual full-UI CRUD smoke (login → create
* health-check via form → delete via kebab → verify) which used javascript_tool
* + click_xy/type_text to bypass the read_ax freeze.
*/
import type { ExtensionPage } from "./extension-provider";
import type { PageActions } from "./page-actions";
const ALLOWED_SCHEMES = new Set(["https:"]);
const CONSOLE_DOMAIN_RE = /\.(volterra\.us|console\.ves\.volterra\.io)$/;
/**
* A self-contained in-page resolver: given a catalogue selector string, finds
* the matching element, scrolls it into view, and returns its viewport-center
* coords. Runs entirely via `javascript_tool` — one small returnByValue, no
* full AX serialization.
*/
/**
* Shared element-matching JS for the catalogue selector grammar (text('…'),
* role:text('…'), role[name='…'], bare role, and "row:has-text('…') >> sub").
* Both buildResolverScript (returns coords) and buildElementResolverScript
* (returns the live element for the deterministic click path) embed this verbatim
* so they resolve identically — only their tails differ.
*/
const RESOLVER_HELPERS = `
function roleToSelector(role){
switch(role){
case'button':return'button,input[type=button],input[type=submit],[role=button]';
case'link':return'a[href],[role=link]';
case'tab':return'[role=tab]';
case'textbox':return'input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]),textarea,[role=textbox]';
case'textarea':return'textarea';
case'spinbutton':return'input[type=number],[role=spinbutton]';
case'checkbox':return'input[type=checkbox],[role=checkbox]';
case'radio':return'input[type=radio],[role=radio]';
case'combobox':return'select,[role=combobox]';
case'listbox':return'[role=listbox],select';
case'option':return'[role=option],option';
case'heading':return'h1,h2,h3,h4,h5,h6,[role=heading]';
case'menuitem':return'[role=menuitem]';
case'dialog':return'dialog,[role=dialog],[role=alertdialog]';
case'navigation':return'nav,[role=navigation]';
case'table':return'table,[role=table]';
case'row':return'tr,[role=row],datatable-body-row';
case'cell':return'td,th,[role=cell],[role=gridcell],[role=columnheader]';
case'img':return'img,[role=img]';
case'switch':return'[role=switch]';
default:return'[role='+role+']';
}
}
function isVisible(e){
if(!e||!e.getBoundingClientRect)return false;
const r=e.getBoundingClientRect();
if(r.width===0&&r.height===0)return false;
// offsetParent is null for display:none (and position:fixed, hence the rect check above).
return e.offsetParent!==null||r.width>0||r.height>0;
}
function findByText(text,roleSel){
const all=roleSel?[...document.querySelectorAll(roleSel)]:[...document.querySelectorAll('*')];
const norm=t=>t.replace(/\\u0421/g,'C').replace(/\\s+/g,' ').trim();
const want=norm(text);
// A bare text() query matches wrapper
s too (an ancestor's textContent
// contains its children's), so the OUTERMOST wins by document order — clicking
// that wrapper is a no-op and e.g. an "Add …" control never opens its form.
// Prefer, in order: (1) an INTERACTIVE match (button/link/tab/input/…) — the
// real semantic click target, robust for the trusted CDP click — else the
// INNERMOST match (drop any element that contains another match); then a
// VISIBLE one (a hidden/template duplicate resolves to rect 0,0 and misses).
const CLICKABLE='a[href],button,input,select,textarea,[role=button],[role=tab],[role=link],[role=menuitem],[role=menuitemcheckbox],[role=option],[role=checkbox],[role=switch],[onclick],[tabindex]';
const pick=list=>{
if(!list.length)return null;
const clickable=list.filter(e=>e.matches&&e.matches(CLICKABLE));
const tier=clickable.length?clickable:list;
const inner=tier.filter(e=>!tier.some(o=>o!==e&&e.contains(o)));
const pool=inner.length?inner:tier;
return pool.find(isVisible)||pool[0];
};
const exact=all.filter(e=>norm(e.textContent||'')===want);
if(exact.length)return pick(exact);
return pick(all.filter(e=>norm(e.textContent||'').includes(want)));
}
function findByRoleName(role,name){
const roleSel=roleToSelector(role);
const candidates=[...document.querySelectorAll(roleSel)];
const norm=t=>t.replace(/\\s+/g,' ').trim();
const want=norm(name);
const byAttr=candidates.filter(e=>{
const n=norm(e.getAttribute('aria-label')||e.getAttribute('name')||e.getAttribute('placeholder')||e.textContent||'');
return n===want||n.includes(want);
});
if(byAttr.length)return byAttr.find(isVisible)||byAttr[0];
if(role==='textbox'){
const byLabel=candidates.filter(e=>norm((e.closest('[class*=form-group],[class*=field],.row')||{}).textContent||'').includes(want));
return byLabel.find(isVisible)||byLabel[0]||null;
}
return null;
}
// Row-scoping: "row:has-text('NAME') >> " finds the table row
// containing NAME, then resolves WITHIN that row. Essential for
// per-row actions (kebab/Delete) when a list has many rows.
function findInScope(s){
const textM=s.match(/^text\\('([^']*)'\\)$/);
const roleTextM=s.match(/^([a-z]+):text\\('([^']*)'\\)$/);
const roleNameM=s.match(/^([a-z]+)\\[name='([^']*)'\\]$/);
const bareRoleM=s.match(/^[a-z]+$/);
if(textM)return findByText(textM[1]);
if(roleTextM)return findByText(roleTextM[2],roleToSelector(roleTextM[1]));
if(roleNameM)return findByRoleName(roleNameM[1],roleNameM[2]);
if(bareRoleM){const c=[...document.querySelectorAll(roleToSelector(s))].filter(isVisible);return c[0]||document.querySelector(roleToSelector(s));}
{const c=[...document.querySelectorAll(s)].filter(isVisible);return c[0]||document.querySelector(s);}
}
`;
/**
* The element-finding block, shared. `__FAIL__('msg')` sentinels are substituted
* per builder: buildResolverScript → a {found:false,error} JSON return;
* buildElementResolverScript → `return null`. Sets `el` and scrolls it into view.
*/
const RESOLVER_FIND = `
let el=null;
if(sel.includes('>>')){
const parts=sel.split('>>').map(p=>p.trim());
const rowM=parts[0].match(/^row:has-text\\('([^']*)'\\)$/);
const want=rowM?rowM[1].replace(/\\u0421/g,'C'):'';
const rows=[...document.querySelectorAll('tr,[role=row],datatable-body-row')];
const row=rows.find(r=>(r.textContent||'').replace(/\\u0421/g,'C').includes(want));
if(!row)__FAIL__('no row matching '+parts[0]);
const subTextM=parts[1].match(/^([a-z]+):text\\('([^']*)'\\)$/);
if(subTextM){const want2=subTextM[2];const cs=[...row.querySelectorAll(roleToSelector(subTextM[1]))].filter(isVisible);const nm=t=>t.replace(/\\s+/g,' ').trim();el=cs.find(e=>nm(e.textContent||'')===want2)||cs.find(e=>nm(e.textContent||'').includes(want2));}
else{const cs=[...row.querySelectorAll(parts[1])].filter(isVisible);el=cs[0]||row.querySelector(parts[1]);}
if(!el)__FAIL__('sub-selector '+parts[1]+' not found in row');
}else{
el=findInScope(sel);
}
if(!el)__FAIL__('no match for '+sel);
el.scrollIntoView({block:'center',inline:'center'});
`;
/** Resolve a selector to viewport-center coords (returns JSON). */
export function buildResolverScript(selector: string): string {
const sel = JSON.stringify(selector);
const find = RESOLVER_FIND.replace(/__FAIL__\(([^;]*?)\);/g, "return JSON.stringify({found:false,error:($1)});");
return `(()=>{
const sel=${sel};
${RESOLVER_HELPERS}
${find}
const r=el.getBoundingClientRect();
const inGrid=!!el.closest&&!!el.closest('ngx-datatable,datatable-body-cell,datatable-body-row,[class*=datatable]');
return JSON.stringify({found:true,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),tag:el.tagName,txt:(el.textContent||'').trim().slice(0,30),inGrid:inGrid,val:(el.value!==undefined&&el.value!==null?String(el.value):'')});
})()`;
}
/** Resolve a selector to the live ELEMENT (returns the element or null) — the
* input to the deterministic click path (clickElement → getContentQuads + hit-test). */
export function buildElementResolverScript(selector: string): string {
const sel = JSON.stringify(selector);
const find = RESOLVER_FIND.replace(/__FAIL__\([^;]*?\);/g, "return null;");
return `(()=>{
const sel=${sel};
${RESOLVER_HELPERS}
${find}
return el;
})()`;
}
/**
* Resolve a selector and fire a native DOM `.click()` on it — the fallback for
* occlusion. The deterministic click path hit-tests `document.elementFromPoint`
* before dispatching, which fails on a footer button that is covered/pushed by an
* overlay (e.g. the xcsh chat side panel occluding the bottom-right Save button)
* or pinned in a sticky footer that `scrollIntoView` can't move out of the
* occluded zone. A native `.click()` dispatches straight to the element's own
* handler, so a Save submits regardless of viewport occlusion. Scrolls into view
* first (best-effort) and returns `{clicked:true}` on success.
*/
export function buildNativeClickScript(selector: string): string {
const sel = JSON.stringify(selector);
const find = RESOLVER_FIND.replace(/__FAIL__\(([^;]*?)\);/g, "return JSON.stringify({clicked:false,error:($1)});");
return `(()=>{
const sel=${sel};
${RESOLVER_HELPERS}
${find}
try{el.scrollIntoView({block:'center',inline:'center'});}catch(e){}
el.click();
return JSON.stringify({clicked:true,tag:el.tagName,txt:(el.textContent||'').trim().slice(0,30)});
})()`;
}
/**
* Run a `javascript_tool` snippet whose body returns a JSON string, and parse it.
* The bridge's `javascript_tool` wraps the evaluated value as `{ result: }`,
* so unwrap `.result` first; tolerate either the wrapper, a bare string, or a bare
* object so this works against the real BridgeExtensionPage AND test doubles.
*/
async function evalJson(ext: ExtensionPage, code: string): Promise {
const raw = await ext.javascriptTool(code);
const payload =
raw && typeof raw === "object" && "result" in (raw as object) ? (raw as { result: unknown }).result : raw;
return typeof payload === "string" ? JSON.parse(payload) : payload;
}
/** Resolve a selector to viewport-center coords via `javascript_tool`. */
async function resolveCoords(ext: ExtensionPage, selector: string): Promise<{ x: number; y: number }> {
const result = await evalJson(ext, buildResolverScript(selector));
if (!result?.found) {
throw new Error(`selector "${selector}" not found in the page: ${result?.error ?? "no match"}`);
}
return { x: result.x as number, y: result.y as number };
}
/**
* Fill a field located by selector: find it → focus → set value via the
* framework-safe native-setter technique → dispatch events to commit to
* Angular's form model. Runs entirely via `javascript_tool`, never serializing
* the whole AX tree. The selector-matching logic is inlined (same grammar as
* buildResolverScript) so the fill runs as a single, self-contained eval.
*/
function buildFillScript(selector: string, value: string): string {
const sel = JSON.stringify(selector);
const val = JSON.stringify(value);
return `(()=>{
const sel=${sel};
function roleToSel(r){const m={button:'button,input[type=button],input[type=submit],[role=button]',link:'a[href],[role=link]',tab:'[role=tab]',textbox:'input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]),textarea,[role=textbox]',textarea:'textarea',spinbutton:'input[type=number],[role=spinbutton]',checkbox:'input[type=checkbox],[role=checkbox]',combobox:'select,[role=combobox]',listbox:'[role=listbox],select',option:'[role=option],option',heading:'h1,h2,h3,h4,h5,h6,[role=heading]'};return m[r]||'[role='+r+']';}
function norm(t){return t.replace(/\\u0421/g,'C').replace(/\\s+/g,' ').trim();}
function findText(text,rSel){return(rSel?[...document.querySelectorAll(rSel)]:[...document.querySelectorAll('*')]).find(e=>{const n=norm(e.textContent||'');return n===norm(text)||n.includes(norm(text));});}
function findRoleName(role,name){const cs=[...document.querySelectorAll(roleToSel(role))];const w=norm(name);return cs.find(e=>norm(e.getAttribute('aria-label')||e.getAttribute('name')||e.getAttribute('placeholder')||e.textContent||'').includes(w))||(role==='textbox'?cs.find(e=>norm((e.closest('[class*=form-group],[class*=field],.row')||{}).textContent||'').includes(w)):null);}
let el=null;
const tm=sel.match(/^text\\('([^']*)'\\)$/),rtm=sel.match(/^([a-z]+):text\\('([^']*)'\\)$/),rnm=sel.match(/^([a-z]+)\\[name='([^']*)'\\]$/),br=sel.match(/^[a-z]+$/);
if(tm)el=findText(tm[1]);else if(rtm)el=findText(rtm[2],roleToSel(rtm[1]));else if(rnm)el=findRoleName(rnm[1],rnm[2]);else if(br)el=document.querySelector(roleToSel(sel));else el=document.querySelector(sel);
if(!el)return JSON.stringify({filled:false,error:'selector not found: '+sel});
el.scrollIntoView({block:'center',inline:'center'});el.focus();
let p=Object.getPrototypeOf(el),d;while(p){d=Object.getOwnPropertyDescriptor(p,'value');if(d&&d.set)break;p=Object.getPrototypeOf(p);}
const v=${val};d&&d.set?d.set.call(el,v):el.value=v;
// Commit to Angular's form model: input+change first. ngx-datatable inline-edit
// cells differ in how they persist: http-lb "Domains" (vsui-input) commits on
// BLUR, while ip-prefix-set "IPv4 Prefix" reverts on a bare blur and only persists
// on an Enter keydown. So for inputs inside a datatable we dispatch Enter BEFORE
// Commit to Angular's reactive-form model. Proven live (HTTP 200 form-create for
// ip-prefix-set "IPv4 Prefix"): native value-setter + input/change + blur/focusout
// persists the value. A synthetic Enter keydown REVERTS the cell on the current
// console (tested 2026-06-29), so it is NOT dispatched.
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
const inGrid=!!el.closest&&!!el.closest('ngx-datatable,datatable-body-cell,datatable-body-row,[class*=datatable]');
el.dispatchEvent(new Event('blur',{bubbles:true}));
el.dispatchEvent(new Event('focusout',{bubbles:true}));
return JSON.stringify({filled:true,val:el.value,inGrid:inGrid});
})()`;
}
export class ExtensionPageActions implements PageActions {
#ext: ExtensionPage;
constructor(ext: ExtensionPage) {
this.#ext = ext;
}
async goto(url: string): Promise {
const parsed = new URL(url);
if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
throw new Error(`Disallowed URL scheme: ${parsed.protocol} (only https: is allowed)`);
}
if (!CONSOLE_DOMAIN_RE.test(parsed.hostname)) {
throw new Error(`URL "${parsed.hostname}" is not an F5 XC console domain`);
}
await this.#ext.navigate(url);
}
async setExplainMode(enabled: boolean): Promise {
await this.#ext.setExplainMode(enabled);
}
async showCallout(selector: string, text: string): Promise {
const js = `(function(){const el=(${buildElementResolverScript(selector)});if(!el)return null;const r=el.getBoundingClientRect();return{x:Math.round(r.x+r.width/2),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)}})()`;
const rect = (await this.#ext.javascriptTool(js)) as { x: number; y: number } | null;
if (rect) {
await this.#ext.annotate({ kind: "callout", x: rect.x, y: rect.y, text });
}
}
async highlightElement(selector: string): Promise {
// Resolve the selector to the target's bounding rect, then draw a highlight
// overlay ('look here' box) around it. Uses the same JS resolver as click.
const js = `(function(){const el=(${buildElementResolverScript(selector)});if(!el)return null;const r=el.getBoundingClientRect();return{x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)}})()`;
const rect = (await this.#ext.javascriptTool(js)) as { x: number; y: number; w: number; h: number } | null;
if (rect) {
await this.#ext.annotate({ kind: "highlight", x: rect.x, y: rect.y, w: rect.w, h: rect.h });
}
}
async click(selector: string, _context?: string, opts?: { native?: boolean }): Promise {
// Escalation path: a synthetic DOM .click() dispatched straight to the
// element's handler. Some vsui controls (e.g. the role=tab "Add …" button)
// ignore the trusted CDP mouse dispatch entirely and only react to this —
// the mirror of the controls that NEED real events (dropdowns/submit). The
// runner uses it on click-step retries after the real click didn't take.
if (opts?.native) {
const res = await evalJson(this.#ext, buildNativeClickScript(selector));
if (res?.clicked) return;
throw new Error(`native click "${selector}" not found: ${res?.error ?? "no match"}`);
}
// Deterministic click: resolve the selector to the live element, then let the
// extension derive geometry from the renderer (DOM.getContentQuads) and
// hit-test the point (document.elementFromPoint) before dispatching. This
// replaces the JS getBoundingClientRect coords + 300ms settle heuristic —
// the hit-test is the gate now (it fails loudly on occlusion instead of
// landing mid-transition or on an overlay).
await this.#clickElementWithFallback(selector);
}
/**
* Trusted CDP click with a native-DOM fallback on "not hittable". The hit-test
* fails when the target is occluded (a footer button under the open side panel)
* OR off-viewport (elementFromPoint returns "none" for a point past the window
* width — the console's right-pane fields sit at large x). A native .click()
* scrolls the element into view (inline:center) and dispatches straight to its
* handler, clearing both cases. Shared by click() and fill()'s focus step.
*/
async #clickElementWithFallback(selector: string): Promise {
try {
await this.#ext.clickElement(buildElementResolverScript(selector));
} catch (e) {
if (/not hittable/i.test(e instanceof Error ? e.message : String(e))) {
const res = await evalJson(this.#ext, buildNativeClickScript(selector));
if (res?.clicked) return;
}
throw e;
}
}
async fill(selector: string, value: string, _context?: string): Promise {
// Probe the element: location, current value, grid/textarea status.
let resolvedSelector = selector;
let probe = await evalJson(this.#ext, buildResolverScript(resolvedSelector));
if (!probe?.found) {
// ROLE-SWAP FALLBACK: vsui renders numeric fields inconsistently — some as
// role=spinbutton (with stepper arrows), some as plain role=textbox (e.g.
// origin-pool's "Port"). A workflow generated for one role misses the other.
// Since both are an with the same aria-label, retry the alternate
// role before giving up.
const swap = selector.startsWith("spinbutton[")
? selector.replace(/^spinbutton\[/, "textbox[")
: selector.startsWith("textbox[")
? selector.replace(/^textbox\[/, "spinbutton[")
: null;
if (swap) {
const alt = await evalJson(this.#ext, buildResolverScript(swap));
if (alt?.found) {
resolvedSelector = swap;
probe = alt;
}
}
}
if (!probe?.found) {
throw new Error(`fill("${selector}"): ${probe?.error ?? "selector not found"}`);
}
selector = resolvedSelector;
const isTextarea = probe.tag === "TEXTAREA";
// PRIMARY PATH: CDP trusted keystrokes for ALL inputs (not just grid/textarea).
// Angular's reactive forms only commit values typed inside NgZone — synthetic
// value-setters (buildFillScript) set the DOM but leave the model empty,
// causing "This field is required" even when the value is visible. Trusted CDP
// Input.insertText fires keydown/input/keyup inside NgZone, so Angular sees it.
// 1. Click to focus (Angular transitions pristine → dirty/touched). Uses the
// native fallback so an off-viewport field (elementFromPoint "none") is
// scrolled into view and focused instead of throwing "not hittable".
await this.#clickElementWithFallback(selector);
await new Promise(r => setTimeout(r, 200));
// 2. Clear any pre-existing text so re-fills are clean.
const curLen = typeof probe.val === "string" ? probe.val.length : 0;
if (curLen > 0) {
await this.#ext.keyPress("a", { modifiers: ["Meta"] }).catch(() => {});
await this.#ext.keyPress("Backspace").catch(() => {});
}
// 3. Type the value via CDP (trusted, inside NgZone).
await this.#ext.typeText(value);
await new Promise(r => setTimeout(r, 200));
// 4. Commit: Tab blurs (ordinary inputs); Enter commits grid inline-edit rows.
// Textareas must NOT get Enter (it inserts a newline).
await this.#ext.keyPress(probe.inGrid && !isTextarea ? "Enter" : "Tab").catch(() => {});
await new Promise(r => setTimeout(r, 300));
// 5. Verify the value stuck in the Angular model (read back via probe).
const after = await evalJson(this.#ext, buildResolverScript(selector));
if (typeof after?.val === "string" && after.val.includes(value)) return;
// FALLBACK: if CDP keystrokes didn't commit (e.g. grid cells with late
// value-accessor wiring), try the native-setter with settle + verify.
if (probe.inGrid) {
await new Promise(r => setTimeout(r, 1200));
const res = await evalJson(this.#ext, buildFillScript(selector, value));
if (!res?.filled) throw new Error(`fill("${selector}"): ${res?.error ?? "native fill failed"}`);
await new Promise(r => setTimeout(r, 300));
const check = await evalJson(this.#ext, buildResolverScript(selector));
if (check?.val === value) return;
}
// Last resort: native-setter (sets DOM; Angular may or may not see it).
const result = await evalJson(this.#ext, buildFillScript(selector, value));
if (!result?.filled) {
throw new Error(`fill("${selector}"): ${result?.error ?? "could not set value"}`);
}
}
async selectOption(selector: string, value: string, _context?: string): Promise {
// F5 XC vsui listboxes are — clicking opens the
// panel, and typing FILTERS the options (the target option often isn't in
// the initial set). So: focus → type to filter → click the matching option.
// Bound every sub-step so a frozen/blocking dropdown can never hang the whole
// workflow — the option set may load async, and many selects have a sensible
// default, so option selection is best-effort.
const withTimeout = (p: Promise, ms: number, label: string): Promise =>
Promise.race([
p,
new Promise((_, rej) =>
setTimeout(() => rej(new Error(`selectOption ${label} timed out after ${ms}ms`)), ms),
),
]);
// Click the listbox to open it — do NOT type into it. The vsui listbox
// dropdowns are but typing filter text corrupts the
// display ("HTTPS with Automatic Ceruat-lbr...", "BlockingMonitoring").
// All options appear on click; the option is clicked directly below.
const resolved = await withTimeout(evalJson(this.#ext, buildResolverScript(selector)), 10_000, "resolve-listbox");
if (!resolved?.found) throw new Error(`selectOption: selector "${selector}" not found`);
// Native fallback so an off-viewport listbox (elementFromPoint "none") is
// scrolled into view and opened rather than throwing "not hittable".
await withTimeout(this.#clickElementWithFallback(selector), 10_000, "click-listbox");
await new Promise(r => setTimeout(r, 1200));
// Click the option whose text matches value (exact-first via the resolver).
// Best-effort: if it never renders, fall through — the default value usually
// already applies, and a hard failure here would block create flows.
try {
// Deterministic option click — polls for the option to render (async panel)
// and hit-tests it. Best-effort; on no-match it throws and we Escape below.
await withTimeout(
this.#ext.clickElement(buildElementResolverScript(`option:text('${value}')`), 6_000),
12_000,
"click-option",
);
await new Promise(r => setTimeout(r, 600));
} catch {
// Option not selectable (already default, or non-standard widget) — press
// Escape to dismiss any open overlay and continue.
await this.#ext.keyPress("Escape").catch(() => {});
}
}
async selectLabel(selector: string, value: string, _context?: string): Promise {
// Atomic CDK-portal typeahead interaction: the extension's label_select tool
// handles type → poll → click in ONE handler without losing focus (the root
// cause of all previous label-selector failures). Uses plain Runtime.evaluate
// (not evaluateWithRecovery which detaches the debugger and closes the portal).
await this.#ext.labelSelect(selector, value);
}
async scrollIntoView(selector: string, _context?: string): Promise {
// resolveCoords already does scrollIntoView.
await resolveCoords(this.#ext, selector);
}
async pressKey(key: string): Promise {
await this.#ext.keyPress(key);
}
async assertText(selector: string, expected: string, _context?: string): Promise {
// Use javascript_tool to check text presence (avoids read_ax freeze).
const result = await evalJson(this.#ext, buildResolverScript(selector));
if (!result?.found) throw new Error(`assertText: selector "${selector}" not found`);
const txt = result.txt ?? "";
if (!txt.includes(expected)) {
throw new Error(`assertText: expected "${expected}" not found in "${txt}"`);
}
}
async waitFor(selector: string, _context?: string, timeoutMs?: number): Promise {
// Poll via javascript_tool (not read_ax, which freezes on heavy forms).
const ms = timeoutMs ?? 30_000;
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
try {
await resolveCoords(this.#ext, selector);
return; // found
} catch {
await new Promise(r => setTimeout(r, 1000));
}
}
throw new Error(`waitFor "${selector}" timed out after ${ms}ms`);
}
async screenshot(_file: string): Promise {
// Intentionally a no-op for the extension provider. CDP captureScreenshot
// transiently FREEZES the MV3 service worker; in observable mode the runner
// shoots after EVERY step, so the freeze cascades into the next step's
// operations (each then blocking on the 30s bridge timeout) — that is what
// hung multi-step flows like delete. The extension provider's whole value is
// that the human watches the LIVE Chrome, so per-step PNG artifacts are
// redundant. Skipping the capture removes the freeze entirely. (CDP-provider
// screenshots are unaffected — this override is extension-only.)
}
}