@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 +14 -12
- package/scripts/capture-ax-fixture.ts +3 -3
- package/scripts/check-workflow-field-coverage.ts +101 -0
- package/scripts/extension-uat-harness.ts +10 -6
- package/scripts/generate-console-field-metadata.ts +74 -0
- package/scripts/uat-matrix.ts +11 -7
- package/src/browser/extension-page-actions.ts +143 -50
- package/src/browser/extension-provider.ts +44 -3
- package/src/browser/provider.ts +27 -2
- package/src/cli.ts +9 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-catalog.generated.ts +10 -10
- package/src/internal-urls/console-field-metadata-types.ts +52 -0
- package/src/internal-urls/console-field-metadata.generated.ts +4271 -0
- package/src/internal-urls/console-resolve.ts +56 -3
- package/src/internal-urls/xcsh-protocol.ts +16 -1
- package/src/modes/theme/mermaid-cache.ts +43 -14
- package/src/modes/theme/mermaid-palette.ts +22 -26
- package/src/modes/theme/theme.ts +1 -1
- package/src/prompts/system/system-prompt.md +2 -2
- package/src/tools/catalog-workflow-runner.ts +70 -16
- package/src/tools/mermaid-renderer.ts +4 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "19.
|
|
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
|
-
"
|
|
50
|
-
"
|
|
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.
|
|
56
|
-
"@f5xc-salesdemos/pi-agent-core": "19.
|
|
57
|
-
"@f5xc-salesdemos/pi-ai": "19.
|
|
58
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
59
|
-
"@f5xc-salesdemos/pi-resource-management": "19.
|
|
60
|
-
"@f5xc-salesdemos/pi-tui": "19.
|
|
61
|
-
"@f5xc-salesdemos/pi-utils": "19.
|
|
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 (
|
|
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.
|
|
22
|
-
await page.type("#password", process.env.
|
|
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
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
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(
|
|
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
|
|
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 (
|
|
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})`);
|
package/scripts/uat-matrix.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
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
|
-
|
|
179
|
-
|
|
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,
|
|
192
|
-
|
|
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,
|
|
196
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
}
|