@f5xc-salesdemos/xcsh 19.43.1 → 19.44.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "19.43.1",
4
+ "version": "19.44.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -31,11 +31,11 @@
31
31
  "xcsh": "src/cli.ts"
32
32
  },
33
33
  "scripts": {
34
- "build": "bun run generate-build-info && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && bun run generate-console-catalog && test -f src/internal-urls/api-spec-index.generated.ts && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset",
34
+ "build": "bun run generate-build-info && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && bun run generate-console-catalog && bun run generate-console-field-metadata && test -f src/internal-urls/api-spec-index.generated.ts && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset",
35
35
  "check": "biome check . && bun run format-prompts -- --check && bun run check:types",
36
36
  "check:types": "bun run generate-build-info && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && tsgo -p tsconfig.json --noEmit",
37
37
  "lint": "biome lint .",
38
- "test": "bun run generate-build-info && bun run generate-api-spec-index && bun run generate-console-catalog && bun test --max-concurrency 4",
38
+ "test": "bun run generate-build-info && bun run generate-api-spec-index && bun run generate-console-catalog && bun run generate-console-field-metadata && bun test --max-concurrency 4",
39
39
  "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
40
40
  "fmt": "biome format --write . && bun run format-prompts",
41
41
  "format-prompts": "bun scripts/format-prompts.ts",
@@ -46,19 +46,21 @@
46
46
  "generate-build-info": "bun scripts/generate-build-info.ts",
47
47
  "generate-terraform-index": "bun scripts/generate-terraform-index.ts",
48
48
  "generate-console-catalog": "bun scripts/generate-console-catalog.ts",
49
- "prepack": "bun scripts/generate-docs-index.ts && bun scripts/generate-api-spec-index.ts && bun scripts/generate-build-info.ts && bun scripts/generate-terraform-index.ts && bun scripts/generate-console-catalog.ts",
50
- "generate-template": "bun scripts/generate-template.ts"
49
+ "generate-console-field-metadata": "bun scripts/generate-console-field-metadata.ts",
50
+ "prepack": "bun scripts/generate-docs-index.ts && bun scripts/generate-api-spec-index.ts && bun scripts/generate-build-info.ts && bun scripts/generate-terraform-index.ts && bun scripts/generate-console-catalog.ts && bun scripts/generate-console-field-metadata.ts",
51
+ "generate-template": "bun scripts/generate-template.ts",
52
+ "check-workflow-field-coverage": "bun scripts/check-workflow-field-coverage.ts"
51
53
  },
52
54
  "dependencies": {
53
55
  "@agentclientprotocol/sdk": "0.16.1",
54
56
  "@mozilla/readability": "^0.6",
55
- "@f5xc-salesdemos/xcsh-stats": "19.43.1",
56
- "@f5xc-salesdemos/pi-agent-core": "19.43.1",
57
- "@f5xc-salesdemos/pi-ai": "19.43.1",
58
- "@f5xc-salesdemos/pi-natives": "19.43.1",
59
- "@f5xc-salesdemos/pi-resource-management": "19.43.1",
60
- "@f5xc-salesdemos/pi-tui": "19.43.1",
61
- "@f5xc-salesdemos/pi-utils": "19.43.1",
57
+ "@f5xc-salesdemos/xcsh-stats": "19.44.0",
58
+ "@f5xc-salesdemos/pi-agent-core": "19.44.0",
59
+ "@f5xc-salesdemos/pi-ai": "19.44.0",
60
+ "@f5xc-salesdemos/pi-natives": "19.44.0",
61
+ "@f5xc-salesdemos/pi-resource-management": "19.44.0",
62
+ "@f5xc-salesdemos/pi-tui": "19.44.0",
63
+ "@f5xc-salesdemos/pi-utils": "19.44.0",
62
64
  "@sinclair/typebox": "^0.34",
63
65
  "@xterm/headless": "^6.0",
64
66
  "ajv": "^8.20",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  // One-shot capture: login + open the HTTP-LB create form + snapshot the accessibility tree.
3
- // Creds via env (KC_USER/KC_PASS); never hard-coded. Output is the resolver's test oracle.
3
+ // Creds via env (F5XC_USERNAME/F5XC_CONSOLE_PASSWORD); never hard-coded. Output is the resolver's test oracle.
4
4
  import puppeteer from "puppeteer";
5
5
 
6
6
  const CHROME = process.env.CHROME_PATH;
@@ -18,8 +18,8 @@ try {
18
18
  await page.goto(BASE, { waitUntil: "networkidle2", timeout: 45000 }).catch(() => {});
19
19
  if (/login-staging\.volterra\.us/.test(page.url())) {
20
20
  await page.waitForSelector("#username", { timeout: 20000 });
21
- await page.type("#username", process.env.KC_USER ?? "");
22
- await page.type("#password", process.env.KC_PASS ?? "");
21
+ await page.type("#username", process.env.F5XC_USERNAME ?? "");
22
+ await page.type("#password", process.env.F5XC_CONSOLE_PASSWORD ?? "");
23
23
  await Promise.all([
24
24
  page.waitForNavigation({ waitUntil: "networkidle2", timeout: 45000 }).catch(() => {}),
25
25
  page.click("#kc-login").catch(() => page.keyboard.press("Enter")),
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/check-workflow-field-coverage.ts
3
+ //
4
+ // Cross-checks every console `create` workflow against the authoritative
5
+ // required-field registry (console_field_metadata, embedded). For each required
6
+ // field it reports whether the create workflow ADDRESSES it (a fill/select step
7
+ // whose selector or context names the field) or relies on a console DEFAULT —
8
+ // and flags GAPS (required, no default, not addressed) and AMBIGUOUS selectors
9
+ // (addressed only by a bare role like `textbox`, which grabs the wrong input on
10
+ // multi-field forms — exactly the http-lb "Domains is required" bug).
11
+ //
12
+ // Reports per-resource. Exit 1 if any GAP (so CI surfaces missing required-field
13
+ // coverage); ambiguous selectors are warnings only.
14
+
15
+ import { parse as parseYaml } from "yaml";
16
+ import { CONSOLE_CATALOG_DATA } from "../src/internal-urls/console-catalog.generated";
17
+ import { CONSOLE_FIELD_METADATA } from "../src/internal-urls/console-field-metadata.generated";
18
+ import type { ConsoleFieldMeta } from "../src/internal-urls/console-field-metadata-types";
19
+
20
+ const norm = (s: string) => s.toLowerCase().replace(/[-_\s]+/g, "");
21
+
22
+ interface Step {
23
+ action?: string;
24
+ selector?: string;
25
+ context?: string;
26
+ value?: string;
27
+ then?: Step[];
28
+ }
29
+ function flatten(steps: Step[]): Step[] {
30
+ const out: Step[] = [];
31
+ for (const s of steps) {
32
+ out.push(s);
33
+ if (Array.isArray(s.then)) out.push(...flatten(s.then));
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /** Does any fill/select step explicitly name this field's label/section? */
39
+ function addressedBy(steps: Step[], label: string, section: string | undefined): "named" | "ambiguous" | "no" {
40
+ const nlabel = norm(label);
41
+ const nsection = section ? norm(section) : "";
42
+ const inputs = steps.filter(s => s.action === "fill" || s.action === "select");
43
+ let ambiguous = false;
44
+ for (const s of inputs) {
45
+ const sel = s.selector ?? "";
46
+ const ctx = s.context ?? "";
47
+ // Extract the value of any [name='X'] / :text('X') / [name="X"] in the selector.
48
+ const targets = [...sel.matchAll(/name=['"]([^'"]*)['"]|text\(['"]([^'"]*)['"]\)/g)].map(m =>
49
+ norm(m[1] ?? m[2] ?? ""),
50
+ );
51
+ const named =
52
+ targets.some(t => t === nlabel || (t.length > 0 && (t.includes(nlabel) || nlabel.includes(t)))) ||
53
+ norm(ctx).includes(nlabel) ||
54
+ (nsection !== "" && norm(ctx).includes(nsection));
55
+ if (named) return "named";
56
+ // bare role selector (textbox / listbox / spinbutton with no [name=…]) → ambiguous
57
+ if (/^(textbox|listbox|spinbutton|combobox|checkbox)$/i.test(sel.trim())) ambiguous = true;
58
+ }
59
+ return ambiguous ? "ambiguous" : "no";
60
+ }
61
+
62
+ let gaps = 0;
63
+ let warns = 0;
64
+ const resources = Object.keys(CONSOLE_CATALOG_DATA.resources).sort();
65
+ console.log("# Console create-workflow required-field coverage\n");
66
+
67
+ for (const id of resources) {
68
+ const createRaw = CONSOLE_CATALOG_DATA.workflows[`${id}/create`];
69
+ if (!createRaw) continue;
70
+ const resourceDoc = parseYaml(CONSOLE_CATALOG_DATA.resources[id] ?? "") as { api?: { kind?: string } };
71
+ const kind = resourceDoc?.api?.kind;
72
+ const fields = (kind && CONSOLE_FIELD_METADATA.resources[kind]) || undefined;
73
+ if (!fields) continue;
74
+ const required = Object.entries(fields).filter(([, m]) => (m as ConsoleFieldMeta).required === true);
75
+ if (required.length === 0) continue;
76
+
77
+ const steps = flatten((parseYaml(createRaw) as { steps?: Step[] })?.steps ?? []);
78
+ const rows: string[] = [];
79
+ for (const [apiField, raw] of required) {
80
+ const m = raw as ConsoleFieldMeta;
81
+ const label = m.label ?? apiField;
82
+ const how = addressedBy(steps, label, m.form_section);
83
+ const hasDefault = m.default !== undefined && m.default !== "" && m.default !== 0;
84
+ let mark: string;
85
+ if (how === "named") mark = "✅ filled by a named step";
86
+ else if (hasDefault) mark = `✅ relies on default (${JSON.stringify(m.default)})`;
87
+ else if (how === "ambiguous") {
88
+ mark = "⚠️ only a BARE selector (e.g. `textbox`) targets this — ambiguous on multi-field forms";
89
+ warns++;
90
+ } else {
91
+ mark = "❌ GAP: required, no default, no step fills it";
92
+ gaps++;
93
+ }
94
+ rows.push(` - **${label}** (\`${apiField}\`): ${mark}`);
95
+ }
96
+ console.log(`## ${id} (${kind})`);
97
+ console.log(rows.join("\n"), "\n");
98
+ }
99
+
100
+ console.log(`\n---\nGaps: ${gaps} Ambiguous-selector warnings: ${warns}`);
101
+ process.exit(gaps > 0 ? 1 : 0);
@@ -69,15 +69,19 @@ async function run() {
69
69
  }
70
70
 
71
71
  console.log("[1b] login (native F5 XC auth)");
72
- const KC_USER = process.env.KC_USER;
73
- const KC_PASS = process.env.KC_PASS;
74
- if (KC_USER && KC_PASS) {
72
+ const F5XC_USERNAME = process.env.F5XC_USERNAME;
73
+ const F5XC_CONSOLE_PASSWORD = process.env.F5XC_CONSOLE_PASSWORD;
74
+ if (F5XC_USERNAME && F5XC_CONSOLE_PASSWORD) {
75
75
  try {
76
76
  // Login directly to the target deep-link — NOT /web. Going to /web triggers
77
77
  // an OIDC flow, then the subsequent navigate to the LB URL interrupts it
78
78
  // mid-flight → "Invalid CSRF token". Logging into the target URL directly
79
79
  // means ONE navigation, one OIDC flow, no interruption.
80
- const l = await tool("login", { email: KC_USER, password: KC_PASS, consoleUrl: ROUTE }, 90000);
80
+ const l = await tool(
81
+ "login",
82
+ { email: F5XC_USERNAME, password: F5XC_CONSOLE_PASSWORD, consoleUrl: ROUTE },
83
+ 90000,
84
+ );
81
85
  (l as any)?.loggedIn
82
86
  ? pass("login", `${(l as any).steps?.length ?? 0} redirect steps: ${((l as any).steps || []).join(" | ")}`)
83
87
  : fail("login", JSON.stringify(l));
@@ -85,11 +89,11 @@ async function run() {
85
89
  fail("login", (e as Error).message);
86
90
  }
87
91
  } else {
88
- console.log(" ⏭ login skipped (no KC_USER/KC_PASS env) — relying on existing session");
92
+ console.log(" ⏭ login skipped (no F5XC_USERNAME/F5XC_CONSOLE_PASSWORD env) — relying on existing session");
89
93
  }
90
94
 
91
95
  console.log("[2] navigate");
92
- if (KC_USER && KC_PASS) {
96
+ if (F5XC_USERNAME && F5XC_CONSOLE_PASSWORD) {
93
97
  // Login already navigated to ROUTE — skip the redundant second navigate
94
98
  // (two navigations to the same OIDC-protected URL in succession → CSRF).
95
99
  pass("navigate", "skipped (login already navigated to target)");
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bun
2
+ // scripts/generate-console-field-metadata.ts
3
+ //
4
+ // Embeds the console field-requirements registry from the authoritative source
5
+ // (api-specs-enriched/config/console_field_metadata.yaml) into
6
+ // src/internal-urls/console-field-metadata.generated.ts. Mirrors the source
7
+ // resolution of generate-api-spec-index.ts (env override → sibling checkout).
8
+ // The registry is what `xcsh://console/<resource>` surfaces so the agent knows
9
+ // each form's required fields + constraints before driving a create workflow.
10
+ //
11
+ // If api-specs-enriched is missing/incorrect field metadata, fix it UPSTREAM
12
+ // there and re-run this — never hand-edit the generated file or patch downstream.
13
+
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { parse as parseYaml } from "yaml";
17
+
18
+ const repoRoot = path.resolve(import.meta.dir, "..");
19
+ const outputPath = path.join(repoRoot, "src/internal-urls/console-field-metadata.generated.ts");
20
+
21
+ /** Resolve the console_field_metadata.yaml path from api-specs-enriched. */
22
+ function resolveMetadataFile(): string | null {
23
+ const candidates = [
24
+ process.env.API_SPECS_ENRICHED_DIR &&
25
+ path.join(process.env.API_SPECS_ENRICHED_DIR, "config/console_field_metadata.yaml"),
26
+ // Local sibling checkout: <workspace>/api-specs-enriched
27
+ path.resolve(repoRoot, "../../../api-specs-enriched/config/console_field_metadata.yaml"),
28
+ path.resolve(repoRoot, "../../../../api-specs-enriched/config/console_field_metadata.yaml"),
29
+ ].filter(Boolean) as string[];
30
+ for (const c of candidates) {
31
+ if (fs.existsSync(c)) return c;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function build(): { version: string; resources: Record<string, Record<string, unknown>> } {
37
+ const file = resolveMetadataFile();
38
+ if (!file) {
39
+ console.warn("api-specs-enriched console_field_metadata.yaml not found; emitting empty registry");
40
+ return { version: "unavailable", resources: {} };
41
+ }
42
+ const doc = (parseYaml(fs.readFileSync(file, "utf-8")) ?? {}) as {
43
+ version?: string;
44
+ resources?: Record<string, Record<string, unknown> | null>;
45
+ };
46
+ const resources: Record<string, Record<string, unknown>> = {};
47
+ for (const [kind, fields] of Object.entries(doc.resources ?? {})) {
48
+ if (fields && typeof fields === "object") resources[kind] = fields as Record<string, unknown>;
49
+ }
50
+ const version = process.env.API_SPECS_ENRICHED_VERSION ?? doc.version ?? "local";
51
+ return { version, resources };
52
+ }
53
+
54
+ const data = build();
55
+
56
+ // Keep the committed registry if the source wasn't found and a file already exists.
57
+ if (data.version === "unavailable" && fs.existsSync(outputPath)) {
58
+ console.log("console field metadata source not found; keeping existing generated module");
59
+ process.exit(0);
60
+ }
61
+
62
+ const output = [
63
+ "// Auto-generated by scripts/generate-console-field-metadata.ts - DO NOT EDIT",
64
+ "// Source of truth: api-specs-enriched/config/console_field_metadata.yaml",
65
+ "",
66
+ `import type { ConsoleFieldMetadataData } from "./console-field-metadata-types";`,
67
+ "",
68
+ `export const CONSOLE_FIELD_METADATA: ConsoleFieldMetadataData = ${JSON.stringify(data, null, 2)};`,
69
+ "",
70
+ ].join("\n");
71
+
72
+ await Bun.write(outputPath, output);
73
+ const total = Object.values(data.resources).reduce((n, f) => n + Object.keys(f).length, 0);
74
+ console.log(`Wrote ${outputPath} (resources=${Object.keys(data.resources).length}, fields=${total})`);
@@ -13,7 +13,7 @@
13
13
  * [--filter <regex over phrase ids>] [--no-cleanup] [--cleanup-only]
14
14
  * [--dry-run] [--self-test-api] [--strict-nl] [--report-dir <dir>]
15
15
  *
16
- * Env: F5XC_API_URL, F5XC_API_TOKEN, KC_USER, KC_PASS, CONSOLE_NAMESPACE=demo,
16
+ * Env: F5XC_API_URL, F5XC_API_TOKEN, F5XC_USERNAME, F5XC_CONSOLE_PASSWORD, F5XC_NAMESPACE=demo,
17
17
  * XCSH_BIN=xcsh
18
18
  */
19
19
  import * as fs from "node:fs";
@@ -124,7 +124,7 @@ function cfgFromEnv(args: Args): MatrixConfig {
124
124
  xcshBin: process.env.XCSH_BIN ?? "xcsh",
125
125
  apiUrl: process.env.F5XC_API_URL ?? "https://nferreira.staging.volterra.us",
126
126
  apiToken: process.env.F5XC_API_TOKEN ?? "",
127
- consoleNamespace: process.env.CONSOLE_NAMESPACE ?? "demo",
127
+ consoleNamespace: process.env.F5XC_NAMESPACE ?? "demo",
128
128
  tenant: (process.env.F5XC_API_URL ?? "https://nferreira.staging.volterra.us")
129
129
  .replace(/^https?:\/\//, "")
130
130
  .split(".")[0]!,
@@ -152,17 +152,21 @@ async function ensureExtensionConnectedAndLogin(cfg: MatrixConfig): Promise<void
152
152
  );
153
153
  }
154
154
  console.log("[console] extension connected ✅");
155
- const KC_USER = process.env.KC_USER;
156
- const KC_PASS = process.env.KC_PASS;
157
- if (KC_USER && KC_PASS) {
155
+ const F5XC_USERNAME = process.env.F5XC_USERNAME;
156
+ const F5XC_CONSOLE_PASSWORD = process.env.F5XC_CONSOLE_PASSWORD;
157
+ if (F5XC_USERNAME && F5XC_CONSOLE_PASSWORD) {
158
158
  const route = `${cfg.apiUrl}/web/workspaces/web-app-and-api-protection/namespaces/${cfg.consoleNamespace}/manage/load_balancers/http_loadbalancers`;
159
159
  console.log("[console] establishing session via login tool...");
160
- const r = await server.request("login", { email: KC_USER, password: KC_PASS, consoleUrl: route }, 160_000);
160
+ const r = await server.request(
161
+ "login",
162
+ { email: F5XC_USERNAME, password: F5XC_CONSOLE_PASSWORD, consoleUrl: route },
163
+ 160_000,
164
+ );
161
165
  if (r.is_error || !(r.content as any)?.loggedIn)
162
166
  throw new Error(`login failed: ${JSON.stringify(r.content).slice(0, 160)}`);
163
167
  console.log("[console] logged in ✅ (session persists across xcsh runs)");
164
168
  } else {
165
- console.log("[console] no KC_USER/KC_PASS — relying on an existing Chrome session");
169
+ console.log("[console] no F5XC_USERNAME/F5XC_CONSOLE_PASSWORD — relying on an existing Chrome session");
166
170
  }
167
171
  } finally {
168
172
  // Release the socket so each `xcsh --print` can own it (the extension reconnects via its alarm).
@@ -20,8 +20,6 @@
20
20
  * health-check via form → delete via kebab → verify) which used javascript_tool
21
21
  * + click_xy/type_text to bypass the read_ax freeze.
22
22
  */
23
- import * as fs from "node:fs";
24
- import * as path from "node:path";
25
23
  import type { ExtensionPage } from "./extension-provider";
26
24
  import type { PageActions } from "./page-actions";
27
25
 
@@ -63,35 +61,72 @@ function roleToSelector(role){
63
61
  default:return'[role='+role+']';
64
62
  }
65
63
  }
64
+ function isVisible(e){
65
+ if(!e||!e.getBoundingClientRect)return false;
66
+ const r=e.getBoundingClientRect();
67
+ if(r.width===0&&r.height===0)return false;
68
+ // offsetParent is null for display:none (and position:fixed, hence the rect check above).
69
+ return e.offsetParent!==null||r.width>0||r.height>0;
70
+ }
66
71
  function findByText(text,roleSel){
67
- const candidates=roleSel?[...document.querySelectorAll(roleSel)]:[...document.querySelectorAll('*')];
68
- const norm=t=>t.replace(/\\s+/g,' ').trim();
72
+ const all=roleSel?[...document.querySelectorAll(roleSel)]:[...document.querySelectorAll('*')];
73
+ const norm=t=>t.replace(/\\u0421/g,'C').replace(/\\s+/g,' ').trim();
69
74
  const want=norm(text);
70
- return candidates.find(e=>{const n=norm(e.textContent||'');return n===want||n.includes(want);});
75
+ // Match by text FIRST (cheap, no layout), then prefer a VISIBLE match — a
76
+ // hidden/template duplicate (e.g. an off-screen 'Delete' button) resolves to
77
+ // rect 0,0 and the trusted click misses. Computing visibility only on the few
78
+ // text matches avoids forcing a full-page reflow per call.
79
+ const exact=all.filter(e=>norm(e.textContent||'')===want);
80
+ if(exact.length)return exact.find(isVisible)||exact[0];
81
+ const sub=all.filter(e=>norm(e.textContent||'').includes(want));
82
+ return sub.find(isVisible)||sub[0];
71
83
  }
72
84
  function findByRoleName(role,name){
73
85
  const roleSel=roleToSelector(role);
74
86
  const candidates=[...document.querySelectorAll(roleSel)];
75
87
  const norm=t=>t.replace(/\\s+/g,' ').trim();
76
88
  const want=norm(name);
77
- return candidates.find(e=>{
89
+ const byAttr=candidates.filter(e=>{
78
90
  const n=norm(e.getAttribute('aria-label')||e.getAttribute('name')||e.getAttribute('placeholder')||e.textContent||'');
79
91
  return n===want||n.includes(want);
80
- })||(role==='textbox'?candidates.find(e=>{
81
- const lbl=(e.closest('[class*=form-group],[class*=field],.row')||{}).textContent||'';
82
- return norm(lbl).includes(want);
83
- }):null);
92
+ });
93
+ if(byAttr.length)return byAttr.find(isVisible)||byAttr[0];
94
+ if(role==='textbox'){
95
+ const byLabel=candidates.filter(e=>norm((e.closest('[class*=form-group],[class*=field],.row')||{}).textContent||'').includes(want));
96
+ return byLabel.find(isVisible)||byLabel[0]||null;
97
+ }
98
+ return null;
99
+ }
100
+ // Row-scoping: "row:has-text('NAME') >> <subSelector>" finds the table row
101
+ // containing NAME, then resolves <subSelector> WITHIN that row. Essential for
102
+ // per-row actions (kebab/Delete) when a list has many rows.
103
+ function findInScope(s){
104
+ const textM=s.match(/^text\\('([^']*)'\\)$/);
105
+ const roleTextM=s.match(/^([a-z]+):text\\('([^']*)'\\)$/);
106
+ const roleNameM=s.match(/^([a-z]+)\\[name='([^']*)'\\]$/);
107
+ const bareRoleM=s.match(/^[a-z]+$/);
108
+ if(textM)return findByText(textM[1]);
109
+ if(roleTextM)return findByText(roleTextM[2],roleToSelector(roleTextM[1]));
110
+ if(roleNameM)return findByRoleName(roleNameM[1],roleNameM[2]);
111
+ if(bareRoleM){const c=[...document.querySelectorAll(roleToSelector(s))].filter(isVisible);return c[0]||document.querySelector(roleToSelector(s));}
112
+ {const c=[...document.querySelectorAll(s)].filter(isVisible);return c[0]||document.querySelector(s);}
84
113
  }
85
114
  let el=null;
86
- const textM=sel.match(/^text\\('([^']*)'\\)$/);
87
- const roleTextM=sel.match(/^([a-z]+):text\\('([^']*)'\\)$/);
88
- const roleNameM=sel.match(/^([a-z]+)\\[name='([^']*)'\\]$/);
89
- const bareRoleM=sel.match(/^[a-z]+$/);
90
- if(textM){el=findByText(textM[1]);}
91
- else if(roleTextM){el=findByText(roleTextM[2],roleToSelector(roleTextM[1]));}
92
- else if(roleNameM){el=findByRoleName(roleNameM[1],roleNameM[2]);}
93
- else if(bareRoleM){el=document.querySelector(roleToSelector(sel));}
94
- else{el=document.querySelector(sel);}
115
+ if(sel.includes('>>')){
116
+ const parts=sel.split('>>').map(p=>p.trim());
117
+ const rowM=parts[0].match(/^row:has-text\\('([^']*)'\\)$/);
118
+ const want=rowM?rowM[1].replace(/\\u0421/g,'C'):'';
119
+ const rows=[...document.querySelectorAll('tr,[role=row],datatable-body-row')];
120
+ const row=rows.find(r=>(r.textContent||'').replace(/\\u0421/g,'C').includes(want));
121
+ if(!row)return JSON.stringify({found:false,error:'no row matching '+parts[0]});
122
+ // resolve the sub-selector within the row
123
+ const subTextM=parts[1].match(/^([a-z]+):text\\('([^']*)'\\)$/);
124
+ 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));}
125
+ else{const cs=[...row.querySelectorAll(parts[1])].filter(isVisible);el=cs[0]||row.querySelector(parts[1]);}
126
+ if(!el)return JSON.stringify({found:false,error:'sub-selector '+parts[1]+' not found in row'});
127
+ }else{
128
+ el=findInScope(sel);
129
+ }
95
130
  if(!el)return JSON.stringify({found:false,error:'no match for '+sel});
96
131
  el.scrollIntoView({block:'center',inline:'center'});
97
132
  const r=el.getBoundingClientRect();
@@ -99,10 +134,22 @@ return JSON.stringify({found:true,x:Math.round(r.x+r.width/2),y:Math.round(r.y+r
99
134
  })()`;
100
135
  }
101
136
 
137
+ /**
138
+ * Run a `javascript_tool` snippet whose body returns a JSON string, and parse it.
139
+ * The bridge's `javascript_tool` wraps the evaluated value as `{ result: <value> }`,
140
+ * so unwrap `.result` first; tolerate either the wrapper, a bare string, or a bare
141
+ * object so this works against the real BridgeExtensionPage AND test doubles.
142
+ */
143
+ async function evalJson(ext: ExtensionPage, code: string): Promise<any> {
144
+ const raw = await ext.javascriptTool(code);
145
+ const payload =
146
+ raw && typeof raw === "object" && "result" in (raw as object) ? (raw as { result: unknown }).result : raw;
147
+ return typeof payload === "string" ? JSON.parse(payload) : payload;
148
+ }
149
+
102
150
  /** Resolve a selector to viewport-center coords via `javascript_tool`. */
103
151
  async function resolveCoords(ext: ExtensionPage, selector: string): Promise<{ x: number; y: number }> {
104
- const raw = await ext.javascriptTool(buildResolverScript(selector));
105
- const result = typeof raw === "string" ? JSON.parse(raw) : raw;
152
+ const result = await evalJson(ext, buildResolverScript(selector));
106
153
  if (!result?.found) {
107
154
  throw new Error(`selector "${selector}" not found in the page: ${result?.error ?? "no match"}`);
108
155
  }
@@ -122,7 +169,7 @@ function buildFillScript(selector: string, value: string): string {
122
169
  return `(()=>{
123
170
  const sel=${sel};
124
171
  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]',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+']';}
125
- function norm(t){return t.replace(/\\s+/g,' ').trim();}
172
+ function norm(t){return t.replace(/\\u0421/g,'C').replace(/\\s+/g,' ').trim();}
126
173
  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));});}
127
174
  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);}
128
175
  let el=null;
@@ -156,27 +203,61 @@ export class ExtensionPageActions implements PageActions {
156
203
  }
157
204
 
158
205
  async click(selector: string, _context?: string): Promise<void> {
206
+ // Brief settle before the trusted click: dialogs/overlays/menus animate in,
207
+ // and a click landing mid-transition misses. resolveCoords already scrolled
208
+ // the element into view; a short pause lets the transition finish. (The
209
+ // deterministic driver slept between steps, which is why it clicked reliably
210
+ // where the back-to-back runner missed the confirm button.)
159
211
  const { x, y } = await resolveCoords(this.#ext, selector);
212
+ await new Promise(r => setTimeout(r, 300));
160
213
  await this.#ext.clickXy(x, y);
161
214
  }
162
215
 
163
216
  async fill(selector: string, value: string, _context?: string): Promise<void> {
164
- const raw = await this.#ext.javascriptTool(buildFillScript(selector, value));
165
- const result = typeof raw === "string" ? JSON.parse(raw) : raw;
217
+ const result = await evalJson(this.#ext, buildFillScript(selector, value));
166
218
  if (!result?.filled) {
167
219
  throw new Error(`fill("${selector}"): ${result?.error ?? "could not set value"}`);
168
220
  }
169
221
  }
170
222
 
171
223
  async selectOption(selector: string, value: string, _context?: string): Promise<void> {
172
- // Open the dropdown / listbox via a trusted click.
173
- const { x, y } = await resolveCoords(this.#ext, selector);
174
- await this.#ext.clickXy(x, y);
175
- // Wait briefly for the dropdown to render.
224
+ // F5 XC vsui listboxes are <input role="listbox"> clicking opens the
225
+ // panel, and typing FILTERS the options (the target option often isn't in
226
+ // the initial set). So: focus → type to filter → click the matching option.
227
+ // Bound every sub-step so a frozen/blocking dropdown can never hang the whole
228
+ // workflow — the option set may load async, and many selects have a sensible
229
+ // default, so option selection is best-effort.
230
+ const withTimeout = <R>(p: Promise<R>, ms: number, label: string): Promise<R> =>
231
+ Promise.race([
232
+ p,
233
+ new Promise<R>((_, rej) =>
234
+ setTimeout(() => rej(new Error(`selectOption ${label} timed out after ${ms}ms`)), ms),
235
+ ),
236
+ ]);
237
+ // Click the listbox to open it — do NOT type into it. The vsui listbox
238
+ // dropdowns are <input role="listbox"> but typing filter text corrupts the
239
+ // display ("HTTPS with Automatic Ceruat-lbr...", "BlockingMonitoring").
240
+ // All options appear on click; the option is clicked directly below.
241
+ const resolved = await withTimeout(evalJson(this.#ext, buildResolverScript(selector)), 10_000, "resolve-listbox");
242
+ if (!resolved?.found) throw new Error(`selectOption: selector "${selector}" not found`);
243
+ await withTimeout(this.#ext.clickXy(resolved.x, resolved.y), 10_000, "click-listbox");
176
244
  await new Promise(r => setTimeout(r, 1200));
177
- // Click the option whose text matches value.
178
- const optCoords = await resolveCoords(this.#ext, `option:text('${value}')`);
179
- await this.#ext.clickXy(optCoords.x, optCoords.y);
245
+ // Click the option whose text matches value (exact-first via the resolver).
246
+ // Best-effort: if it never renders, fall through — the default value usually
247
+ // already applies, and a hard failure here would block create flows.
248
+ try {
249
+ const optCoords = await withTimeout(
250
+ resolveCoords(this.#ext, `option:text('${value}')`),
251
+ 10_000,
252
+ "resolve-option",
253
+ );
254
+ await withTimeout(this.#ext.clickXy(optCoords.x, optCoords.y), 10_000, "click-option");
255
+ await new Promise(r => setTimeout(r, 600));
256
+ } catch {
257
+ // Option not selectable (already default, or non-standard widget) — press
258
+ // Escape to dismiss any open overlay and continue.
259
+ await this.#ext.keyPress("Escape").catch(() => {});
260
+ }
180
261
  }
181
262
 
182
263
  async scrollIntoView(selector: string, _context?: string): Promise<void> {
@@ -188,28 +269,40 @@ export class ExtensionPageActions implements PageActions {
188
269
  await this.#ext.keyPress(key);
189
270
  }
190
271
 
191
- async assertText(selector: string, expected: string, context?: string): Promise<void> {
192
- await this.#ext.assertText(selector, expected, context);
272
+ async assertText(selector: string, expected: string, _context?: string): Promise<void> {
273
+ // Use javascript_tool to check text presence (avoids read_ax freeze).
274
+ const result = await evalJson(this.#ext, buildResolverScript(selector));
275
+ if (!result?.found) throw new Error(`assertText: selector "${selector}" not found`);
276
+ const txt = result.txt ?? "";
277
+ if (!txt.includes(expected)) {
278
+ throw new Error(`assertText: expected "${expected}" not found in "${txt}"`);
279
+ }
193
280
  }
194
281
 
195
- async waitFor(selector: string, context?: string, timeoutMs?: number): Promise<void> {
196
- await this.#ext.waitFor(selector, context, timeoutMs);
282
+ async waitFor(selector: string, _context?: string, timeoutMs?: number): Promise<void> {
283
+ // Poll via javascript_tool (not read_ax, which freezes on heavy forms).
284
+ const ms = timeoutMs ?? 30_000;
285
+ const deadline = Date.now() + ms;
286
+ while (Date.now() < deadline) {
287
+ try {
288
+ await resolveCoords(this.#ext, selector);
289
+ return; // found
290
+ } catch {
291
+ await new Promise(r => setTimeout(r, 1000));
292
+ }
293
+ }
294
+ throw new Error(`waitFor "${selector}" timed out after ${ms}ms`);
197
295
  }
198
296
 
199
- async screenshot(file: string): Promise<void> {
200
- const cwdReal = fs.realpathSync(process.cwd());
201
- const lexical = path.resolve(file);
202
- let parentReal: string;
203
- try {
204
- parentReal = fs.realpathSync(path.dirname(lexical));
205
- } catch {
206
- throw new Error(`Screenshot directory does not exist: ${path.dirname(lexical)}`);
207
- }
208
- const resolved = path.join(parentReal, path.basename(lexical));
209
- if (!resolved.startsWith(cwdReal + path.sep) && resolved !== cwdReal) {
210
- throw new Error(`Screenshot path "${file}" resolves outside the working directory`);
211
- }
212
- const b64 = await this.#ext.screenshot();
213
- await Bun.write(resolved, Buffer.from(b64, "base64"));
297
+ // biome-ignore lint/suspicious/useAwait: signature is async (PageActions contract)
298
+ async screenshot(_file: string): Promise<void> {
299
+ // Intentionally a no-op for the extension provider. CDP captureScreenshot
300
+ // transiently FREEZES the MV3 service worker; in observable mode the runner
301
+ // shoots after EVERY step, so the freeze cascades into the next step's
302
+ // operations (each then blocking on the 30s bridge timeout) — that is what
303
+ // hung multi-step flows like delete. The extension provider's whole value is
304
+ // that the human watches the LIVE Chrome, so per-step PNG artifacts are
305
+ // redundant. Skipping the capture removes the freeze entirely. (CDP-provider
306
+ // screenshots are unaffected — this override is extension-only.)
214
307
  }
215
308
  }