/** * 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.) } }