@f5xc-salesdemos/xcsh 19.34.1 → 19.35.2
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 +12 -11
- package/scripts/capture-ax-fixture.ts +84 -0
- package/scripts/generate-console-catalog.ts +88 -0
- package/scripts/generate-terraform-index.ts +25 -1
- package/src/browser/actions.ts +126 -0
- package/src/browser/ax.ts +134 -0
- package/src/browser/cdp-core.ts +48 -0
- package/src/browser/dom-context.ts +89 -0
- package/src/browser/dt-context.ts +100 -0
- package/src/browser/index.ts +7 -0
- package/src/browser/input-commit.ts +52 -0
- package/src/browser/resolver.ts +126 -0
- package/src/browser/selector.ts +40 -0
- package/src/config/settings-schema.ts +12 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-catalog-types.ts +21 -0
- package/src/internal-urls/console-catalog.generated.ts +288 -0
- package/src/internal-urls/console-resolve.ts +77 -0
- package/src/internal-urls/index.ts +2 -0
- package/src/internal-urls/terraform-index.generated.ts +40 -31
- package/src/internal-urls/terraform-resolve.ts +9 -2
- package/src/internal-urls/terraform-types.ts +2 -0
- package/src/internal-urls/xcsh-protocol.ts +28 -1
- package/src/modes/components/plugins/plugin-dashboard.ts +1 -1
- package/src/modes/components/welcome.ts +2 -2
- package/src/prompts/system/system-prompt.md +10 -1
- package/src/prompts/tools/catalog-workflow-runner.md +2 -2
- package/src/prompts/tools/xcsh-api.md +2 -2
- package/src/services/f5xc-api-client.ts +15 -1
- package/src/services/f5xc-context.ts +13 -5
- package/src/tools/browser.ts +172 -296
- package/src/tools/catalog-workflow-runner.ts +243 -117
- package/src/web/scrapers/readthedocs.ts +1 -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.35.2",
|
|
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 && 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 && 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 test --max-concurrency 4",
|
|
38
|
+
"test": "bun run generate-build-info && bun run generate-api-spec-index && bun run generate-console-catalog && 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",
|
|
@@ -44,19 +44,20 @@
|
|
|
44
44
|
"generate-branding-index": "bun scripts/generate-branding-index.ts",
|
|
45
45
|
"generate-build-info": "bun scripts/generate-build-info.ts",
|
|
46
46
|
"generate-terraform-index": "bun scripts/generate-terraform-index.ts",
|
|
47
|
-
"
|
|
47
|
+
"generate-console-catalog": "bun scripts/generate-console-catalog.ts",
|
|
48
|
+
"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",
|
|
48
49
|
"generate-template": "bun scripts/generate-template.ts"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
52
53
|
"@mozilla/readability": "^0.6",
|
|
53
|
-
"@f5xc-salesdemos/xcsh-stats": "19.
|
|
54
|
-
"@f5xc-salesdemos/pi-agent-core": "19.
|
|
55
|
-
"@f5xc-salesdemos/pi-ai": "19.
|
|
56
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
57
|
-
"@f5xc-salesdemos/pi-resource-management": "19.
|
|
58
|
-
"@f5xc-salesdemos/pi-tui": "19.
|
|
59
|
-
"@f5xc-salesdemos/pi-utils": "19.
|
|
54
|
+
"@f5xc-salesdemos/xcsh-stats": "19.35.2",
|
|
55
|
+
"@f5xc-salesdemos/pi-agent-core": "19.35.2",
|
|
56
|
+
"@f5xc-salesdemos/pi-ai": "19.35.2",
|
|
57
|
+
"@f5xc-salesdemos/pi-natives": "19.35.2",
|
|
58
|
+
"@f5xc-salesdemos/pi-resource-management": "19.35.2",
|
|
59
|
+
"@f5xc-salesdemos/pi-tui": "19.35.2",
|
|
60
|
+
"@f5xc-salesdemos/pi-utils": "19.35.2",
|
|
60
61
|
"@sinclair/typebox": "^0.34",
|
|
61
62
|
"@xterm/headless": "^6.0",
|
|
62
63
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
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.
|
|
4
|
+
import puppeteer from "puppeteer";
|
|
5
|
+
|
|
6
|
+
const CHROME = process.env.CHROME_PATH;
|
|
7
|
+
const BASE = process.env.F5XC_CONSOLE_URL ?? "https://nferreira.staging.volterra.us";
|
|
8
|
+
const NS = process.env.F5XC_NS ?? "demo";
|
|
9
|
+
const OUT = "test/browser/fixtures/xc-http-lb-create.ax.json";
|
|
10
|
+
|
|
11
|
+
const browser = await puppeteer.launch({
|
|
12
|
+
headless: true,
|
|
13
|
+
executablePath: CHROME,
|
|
14
|
+
userDataDir: "/tmp/xc-ax-capture",
|
|
15
|
+
});
|
|
16
|
+
try {
|
|
17
|
+
const page = (await browser.pages())[0] ?? (await browser.newPage());
|
|
18
|
+
await page.goto(BASE, { waitUntil: "networkidle2", timeout: 45000 }).catch(() => {});
|
|
19
|
+
if (/login-staging\.volterra\.us/.test(page.url())) {
|
|
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 ?? "");
|
|
23
|
+
await Promise.all([
|
|
24
|
+
page.waitForNavigation({ waitUntil: "networkidle2", timeout: 45000 }).catch(() => {}),
|
|
25
|
+
page.click("#kc-login").catch(() => page.keyboard.press("Enter")),
|
|
26
|
+
]);
|
|
27
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
28
|
+
}
|
|
29
|
+
const route = `/web/workspaces/web-app-and-api-protection/namespaces/${NS}/manage/load_balancers/http_loadbalancers`;
|
|
30
|
+
await page.goto(`${BASE}${route}`, { waitUntil: "networkidle2", timeout: 45000 });
|
|
31
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
32
|
+
// open the create form: click the "Add HTTP Load Balancer" tab via its resolved handle
|
|
33
|
+
{
|
|
34
|
+
const h = (await page.$("aria/Add HTTP Load Balancer")) ?? (await page.$("text/Add HTTP Load Balancer"));
|
|
35
|
+
if (!h) {
|
|
36
|
+
console.log("could not resolve the Add tab handle");
|
|
37
|
+
} else {
|
|
38
|
+
await h.click().catch(async () => {
|
|
39
|
+
await h.evaluate(el => (el as { click(): void }).click()).catch(() => {});
|
|
40
|
+
});
|
|
41
|
+
await h.dispose();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// wait until the create form's Name textbox is present in the AX tree (form actually open)
|
|
45
|
+
let opened = false;
|
|
46
|
+
for (let i = 0; i < 40; i++) {
|
|
47
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
48
|
+
const s = await page.accessibility.snapshot({ interestingOnly: false });
|
|
49
|
+
const hasName = (function find(n: { role?: string; name?: string; children?: unknown[] }): boolean {
|
|
50
|
+
if (n.role === "textbox" && (n.name ?? "").trim() === "Name") return true;
|
|
51
|
+
return (n.children ?? []).some(c => find(c as never));
|
|
52
|
+
})((s ?? {}) as never);
|
|
53
|
+
if (hasName) {
|
|
54
|
+
opened = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log("create form opened (Name textbox present):", opened, "url=", page.url().slice(0, 110));
|
|
59
|
+
const snap = await page.accessibility.snapshot({ interestingOnly: false });
|
|
60
|
+
if (!snap) throw new Error("accessibility snapshot was null");
|
|
61
|
+
await Bun.write(OUT, JSON.stringify(snap, null, 2));
|
|
62
|
+
// also capture the create-form DOM root (for DOM-structural context-scoping tests)
|
|
63
|
+
const HTML_OUT = "test/browser/fixtures/xc-http-lb-create.html";
|
|
64
|
+
const formHtml = await page.evaluate(() => {
|
|
65
|
+
const d = (
|
|
66
|
+
globalThis as unknown as {
|
|
67
|
+
document: { querySelector(s: string): { outerHTML: string } | null; body: { outerHTML: string } };
|
|
68
|
+
}
|
|
69
|
+
).document;
|
|
70
|
+
const root = d.querySelector('[role="main"], main, .ant-drawer-body, .ant-modal-body') ?? d.body;
|
|
71
|
+
return root.outerHTML;
|
|
72
|
+
});
|
|
73
|
+
await Bun.write(HTML_OUT, formHtml);
|
|
74
|
+
console.log(`wrote ${HTML_OUT} (${formHtml.length} bytes)`);
|
|
75
|
+
const json = JSON.stringify(snap);
|
|
76
|
+
const roleCount = (role: string) => (json.match(new RegExp(`"role":"${role}"`, "g")) ?? []).length;
|
|
77
|
+
console.log(`wrote ${OUT} (${json.length} bytes)`);
|
|
78
|
+
console.log(`url=${page.url().slice(0, 90)}`);
|
|
79
|
+
console.log(
|
|
80
|
+
`roles: tab=${roleCount("tab")} textbox=${roleCount("textbox")} button=${roleCount("button")} listbox=${roleCount("listbox")}`,
|
|
81
|
+
);
|
|
82
|
+
} finally {
|
|
83
|
+
await browser.close().catch(() => {});
|
|
84
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// scripts/generate-console-catalog.ts
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
const repoRoot = path.resolve(import.meta.dir, "..");
|
|
7
|
+
const outputPath = path.join(repoRoot, "src/internal-urls/console-catalog.generated.ts");
|
|
8
|
+
|
|
9
|
+
function resolveCatalogRoot(): string | null {
|
|
10
|
+
const fromEnv = process.env.CONSOLE_CATALOG_DIR;
|
|
11
|
+
if (fromEnv && fs.existsSync(path.join(fromEnv, "catalog"))) return fromEnv;
|
|
12
|
+
// Local sibling checkout: <workspace>/console
|
|
13
|
+
const sibling = path.resolve(repoRoot, "../../../console");
|
|
14
|
+
if (fs.existsSync(path.join(sibling, "catalog"))) return sibling;
|
|
15
|
+
// TODO(P2): add release-artifact download mirroring generate-api-spec-index.ts.
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readYamlDir(dir: string, keyFn: (rel: string) => string): Record<string, string> {
|
|
20
|
+
const out: Record<string, string> = {};
|
|
21
|
+
if (!fs.existsSync(dir)) return out;
|
|
22
|
+
const walk = (d: string) => {
|
|
23
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
24
|
+
const full = path.join(d, entry.name);
|
|
25
|
+
if (entry.isDirectory()) walk(full);
|
|
26
|
+
else if (entry.name.endsWith(".yaml")) {
|
|
27
|
+
const rel = path.relative(dir, full).replace(/\.yaml$/, "");
|
|
28
|
+
out[keyFn(rel)] = fs.readFileSync(full, "utf-8");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
walk(dir);
|
|
33
|
+
// Sort keys for deterministic output regardless of readdir order.
|
|
34
|
+
return Object.fromEntries(
|
|
35
|
+
Object.keys(out)
|
|
36
|
+
.sort()
|
|
37
|
+
.map(k => [k, out[k]]),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function build(): {
|
|
42
|
+
version: string;
|
|
43
|
+
workflows: Record<string, string>;
|
|
44
|
+
resources: Record<string, string>;
|
|
45
|
+
routes: Record<string, string>;
|
|
46
|
+
navigation: string | null;
|
|
47
|
+
} {
|
|
48
|
+
const root = resolveCatalogRoot();
|
|
49
|
+
if (!root) {
|
|
50
|
+
console.warn("console catalogue source not found; emitting empty catalogue");
|
|
51
|
+
return { version: "unavailable", workflows: {}, resources: {}, routes: {}, navigation: null };
|
|
52
|
+
}
|
|
53
|
+
const catalog = path.join(root, "catalog");
|
|
54
|
+
// workflows keyed "<resource>/<operation>" (directory/file)
|
|
55
|
+
const workflows = readYamlDir(path.join(catalog, "workflows"), rel => rel.split(path.sep).join("/"));
|
|
56
|
+
// resources/routes keyed by basename id
|
|
57
|
+
const resources = readYamlDir(path.join(catalog, "resources"), rel => path.basename(rel));
|
|
58
|
+
const routes = readYamlDir(path.join(catalog, "routes"), rel => path.basename(rel));
|
|
59
|
+
const navPath = path.join(catalog, "navigation/console-tree.yaml");
|
|
60
|
+
const navigation = fs.existsSync(navPath) ? fs.readFileSync(navPath, "utf-8") : null;
|
|
61
|
+
const version = process.env.CONSOLE_CATALOG_VERSION ?? "local";
|
|
62
|
+
return { version, workflows, resources, routes, navigation };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const data = build();
|
|
66
|
+
|
|
67
|
+
// Fix A: when the source was not found AND the output file already exists,
|
|
68
|
+
// keep the committed catalogue rather than overwriting it with empty data.
|
|
69
|
+
if (data.version === "unavailable" && fs.existsSync(outputPath)) {
|
|
70
|
+
console.log("console catalogue source not found; keeping existing generated module");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const output = [
|
|
75
|
+
"// Auto-generated by scripts/generate-console-catalog.ts - DO NOT EDIT",
|
|
76
|
+
"",
|
|
77
|
+
`import type { ConsoleCatalogData } from "./console-catalog-types";`,
|
|
78
|
+
"",
|
|
79
|
+
`export const CONSOLE_CATALOG_VERSION = ${JSON.stringify(data.version)};`,
|
|
80
|
+
"",
|
|
81
|
+
`export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = ${JSON.stringify(data, null, 2)};`,
|
|
82
|
+
"",
|
|
83
|
+
].join("\n");
|
|
84
|
+
|
|
85
|
+
await Bun.write(outputPath, output);
|
|
86
|
+
console.log(
|
|
87
|
+
`Wrote ${outputPath} (workflows=${Object.keys(data.workflows).length}, resources=${Object.keys(data.resources).length})`,
|
|
88
|
+
);
|
|
@@ -38,6 +38,30 @@ async function loadTerraformIndex(): Promise<unknown> {
|
|
|
38
38
|
return response.json();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Backfill provider fields that older terraform-llms-index.json revisions lack, so the
|
|
42
|
+
// generated index always satisfies TerraformProvider regardless of which provider-repo
|
|
43
|
+
// revision it was fetched from (the source repo is the authority once it ships them).
|
|
44
|
+
const DEFAULT_CONFIG_BLOCK = 'provider "f5xc" {}';
|
|
45
|
+
const DEFAULT_AUTH_METHODS = [
|
|
46
|
+
'REQUIRED: every .tf must contain a `provider "f5xc" {}` block. Without it Terraform errors: "Provider requires explicit configuration. Add a provider block".',
|
|
47
|
+
"Configure exactly ONE auth method, via environment variables (preferred) or explicit arguments in the provider block:",
|
|
48
|
+
"api_token (env F5XC_API_TOKEN) — API token authentication.",
|
|
49
|
+
"api_p12_file + p12_password (env F5XC_P12_FILE + F5XC_P12_PASSWORD) — PKCS#12 certificate authentication.",
|
|
50
|
+
"api_cert + api_key (env F5XC_CERT + F5XC_KEY) — PEM certificate authentication.",
|
|
51
|
+
"api_url (env F5XC_API_URL) — tenant base URL without /api suffix, e.g. https://your-tenant.console.ves.volterra.io.",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
function normalizeProvider(data: unknown): unknown {
|
|
55
|
+
if (data && typeof data === "object" && "provider" in data) {
|
|
56
|
+
const provider = (data as { provider: Record<string, unknown> }).provider;
|
|
57
|
+
if (provider && typeof provider === "object") {
|
|
58
|
+
if (typeof provider.config_block !== "string") provider.config_block = DEFAULT_CONFIG_BLOCK;
|
|
59
|
+
if (!Array.isArray(provider.auth_methods)) provider.auth_methods = DEFAULT_AUTH_METHODS;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
function generateTypeScript(data: unknown): string {
|
|
42
66
|
const lines = [
|
|
43
67
|
"// AUTO-GENERATED — do not edit. Run `bun generate-terraform-index` to regenerate.",
|
|
@@ -50,7 +74,7 @@ function generateTypeScript(data: unknown): string {
|
|
|
50
74
|
return lines.join("\n");
|
|
51
75
|
}
|
|
52
76
|
|
|
53
|
-
const data = await loadTerraformIndex();
|
|
77
|
+
const data = normalizeProvider(await loadTerraformIndex());
|
|
54
78
|
const output = generateTypeScript(data);
|
|
55
79
|
await fs.writeFile(OUTPUT_FILE, output, "utf-8");
|
|
56
80
|
await Bun.$`bunx biome format --write ${OUTPUT_FILE}`.quiet();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Page } from "puppeteer";
|
|
2
|
+
import { commitInputValue } from "./input-commit";
|
|
3
|
+
import { resolve } from "./resolver";
|
|
4
|
+
|
|
5
|
+
/** Delay after a fill to let blur-triggered framework revalidation complete. */
|
|
6
|
+
const SETTLE_AFTER_FILL_MS = 600;
|
|
7
|
+
|
|
8
|
+
/** Resolve after `ms` milliseconds (a small, explicit settle for SPA timing). */
|
|
9
|
+
function settle(ms: number): Promise<void> {
|
|
10
|
+
return new Promise(resolveSettle => setTimeout(resolveSettle, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** XC-SPA-aware: wait until the console's loading/spinner indicators clear. */
|
|
14
|
+
export async function waitForXcSettled(page: Page, timeoutMs = 15000): Promise<void> {
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
while (Date.now() - start < timeoutMs) {
|
|
17
|
+
const busy = await page
|
|
18
|
+
.evaluate((): boolean => {
|
|
19
|
+
const sel = '[aria-busy="true"], .ant-spin-spinning, [role="progressbar"], .loading-indicator';
|
|
20
|
+
const doc = (globalThis as unknown as { document: { querySelector(s: string): unknown } }).document;
|
|
21
|
+
return doc.querySelector(sel) != null;
|
|
22
|
+
})
|
|
23
|
+
.catch(() => false);
|
|
24
|
+
if (!busy) return;
|
|
25
|
+
await Bun.sleep(150);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function withRetry<T>(fn: () => Promise<T>, timeoutMs = 15000): Promise<T> {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
let last: unknown;
|
|
32
|
+
while (Date.now() - start < timeoutMs) {
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
} catch (e) {
|
|
36
|
+
last = e;
|
|
37
|
+
await Bun.sleep(150);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function click(page: Page, selector: string, context?: string): Promise<void> {
|
|
44
|
+
await waitForXcSettled(page);
|
|
45
|
+
await withRetry(async () => {
|
|
46
|
+
const h = await resolve(page, selector, context);
|
|
47
|
+
try {
|
|
48
|
+
await h.scrollIntoView().catch(() => {});
|
|
49
|
+
try {
|
|
50
|
+
await h.click();
|
|
51
|
+
} catch {
|
|
52
|
+
// sticky/offscreen elements: synthetic DOM click fallback
|
|
53
|
+
await h.evaluate(el => (el as unknown as { click(): void }).click());
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
await h.dispose().catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function fill(page: Page, selector: string, value: string, context?: string): Promise<void> {
|
|
62
|
+
await waitForXcSettled(page);
|
|
63
|
+
await withRetry(async () => {
|
|
64
|
+
const h = await resolve(page, selector, context);
|
|
65
|
+
try {
|
|
66
|
+
await h.focus().catch(() => {});
|
|
67
|
+
// Set the value through the native value setter + framework events
|
|
68
|
+
// (input/change/blur/focusout). This replaces any existing value and is
|
|
69
|
+
// the robust path for framework-bound controls — including the console's
|
|
70
|
+
// vsui-input over ngx-datatable, whose patched value descriptor swallows
|
|
71
|
+
// plain keystrokes so they never reach the Angular model. (Use the `type`
|
|
72
|
+
// action for fields that must observe individual keystrokes.)
|
|
73
|
+
await h.evaluate(commitInputValue, value);
|
|
74
|
+
} finally {
|
|
75
|
+
await h.dispose().catch(() => {});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
// Let the framework's blur-triggered revalidation run before the next action
|
|
79
|
+
// (Angular `updateOn: 'blur'` controls update validity asynchronously, so an
|
|
80
|
+
// immediate Save would otherwise still see the field as invalid).
|
|
81
|
+
await settle(SETTLE_AFTER_FILL_MS);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function selectOption(page: Page, selector: string, value: string, context?: string): Promise<void> {
|
|
85
|
+
await click(page, selector, context); // open the listbox/combobox
|
|
86
|
+
await click(page, `option:text('${value}')`); // pick the option by role+name
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function scrollIntoView(page: Page, selector: string, context?: string): Promise<void> {
|
|
90
|
+
await waitForXcSettled(page);
|
|
91
|
+
const h = await resolve(page, selector, context);
|
|
92
|
+
try {
|
|
93
|
+
await h.scrollIntoView();
|
|
94
|
+
} finally {
|
|
95
|
+
await h.dispose().catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function pressKey(page: Page, key: string): Promise<void> {
|
|
100
|
+
await page.keyboard.press(key as Parameters<Page["keyboard"]["press"]>[0]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function assertText(page: Page, selector: string, expected: string, context?: string): Promise<void> {
|
|
104
|
+
await waitForXcSettled(page);
|
|
105
|
+
const h = await resolve(page, selector, context);
|
|
106
|
+
try {
|
|
107
|
+
const txt = (await h.evaluate(el => (el as unknown as { innerText: string }).innerText)) as string;
|
|
108
|
+
if (!txt.includes(expected)) throw new Error(`assert failed: "${expected}" not in "${txt.slice(0, 200)}"`);
|
|
109
|
+
} finally {
|
|
110
|
+
await h.dispose().catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function waitFor(page: Page, selector: string, context?: string, timeoutMs = 30000): Promise<void> {
|
|
115
|
+
await withRetry(async () => {
|
|
116
|
+
const h = await resolve(page, selector, context);
|
|
117
|
+
await h.dispose().catch(() => {});
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function screenshot(page: Page, file: string): Promise<void> {
|
|
122
|
+
if (!file.endsWith(".png") || file.includes("..") || file.includes("\0")) {
|
|
123
|
+
throw new Error(`Invalid screenshot path: ${file}`);
|
|
124
|
+
}
|
|
125
|
+
await page.screenshot({ path: file as `${string}.png`, type: "png" });
|
|
126
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Locator } from "./selector";
|
|
2
|
+
|
|
3
|
+
export interface AxNode {
|
|
4
|
+
role: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
children?: AxNode[];
|
|
7
|
+
[k: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class NotFoundError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "NotFoundError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class AmbiguousError extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "AmbiguousError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function norm(s: string): string {
|
|
25
|
+
return s.trim().replace(/\s+/g, " ");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function collect(node: AxNode, results: AxNode[]): void {
|
|
29
|
+
results.push(node);
|
|
30
|
+
for (const child of node.children ?? []) {
|
|
31
|
+
collect(child, results);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function matchNode(tree: AxNode, loc: Locator): AxNode {
|
|
36
|
+
if (loc.kind === "css") {
|
|
37
|
+
throw new Error("css locators cannot be resolved against an AX tree — resolve live via CDP");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const all: AxNode[] = [];
|
|
41
|
+
collect(tree, all);
|
|
42
|
+
|
|
43
|
+
let matches: AxNode[];
|
|
44
|
+
|
|
45
|
+
if (loc.kind === "roleName") {
|
|
46
|
+
const wantRole = loc.role;
|
|
47
|
+
const wantName = norm(loc.name);
|
|
48
|
+
matches = all.filter(n => {
|
|
49
|
+
if (n.role !== wantRole) return false;
|
|
50
|
+
const nodeName = norm(n.name ?? "");
|
|
51
|
+
// roleName from role:text('X') pattern — text match (includes)
|
|
52
|
+
// roleName from role[name='X'] pattern — exact match
|
|
53
|
+
// We always use exact match for roleName kind (the parser normalises both patterns to roleName)
|
|
54
|
+
return nodeName === wantName;
|
|
55
|
+
});
|
|
56
|
+
} else if (loc.kind === "role") {
|
|
57
|
+
matches = all.filter(n => n.role === loc.role);
|
|
58
|
+
} else {
|
|
59
|
+
// kind === "text"
|
|
60
|
+
const want = norm(loc.text);
|
|
61
|
+
matches = all.filter(n => {
|
|
62
|
+
const nodeName = norm(n.name ?? "");
|
|
63
|
+
return nodeName === want || nodeName.includes(want);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (matches.length === 0) {
|
|
68
|
+
// Collect candidates for helpful error message
|
|
69
|
+
let hint = "";
|
|
70
|
+
if (loc.kind === "roleName" || loc.kind === "role") {
|
|
71
|
+
const sameRole = all.filter(n => n.role === loc.role && n.name).map(n => JSON.stringify(n.name));
|
|
72
|
+
if (sameRole.length > 0) {
|
|
73
|
+
hint = ` (same-role candidates: ${sameRole.slice(0, 5).join(", ")})`;
|
|
74
|
+
}
|
|
75
|
+
} else if (loc.kind === "text") {
|
|
76
|
+
// For text kind, list nearby text candidates (non-empty normalized names)
|
|
77
|
+
const textCandidates: string[] = [];
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
for (const node of all) {
|
|
80
|
+
if (node.name) {
|
|
81
|
+
const normalized = norm(node.name);
|
|
82
|
+
if (normalized.length > 0 && !seen.has(normalized)) {
|
|
83
|
+
seen.add(normalized);
|
|
84
|
+
textCandidates.push(JSON.stringify(normalized));
|
|
85
|
+
if (textCandidates.length >= 5) break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (textCandidates.length > 0) {
|
|
90
|
+
hint = ` (nearby text candidates: ${textCandidates.join(", ")})`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new NotFoundError(`No AX node found for ${JSON.stringify(loc)}${hint}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// For text locators, return the first match (presence semantics).
|
|
97
|
+
// For roleName and role, enforce strict 1:1 matching.
|
|
98
|
+
if (loc.kind === "text") {
|
|
99
|
+
return matches[0]!;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (matches.length > 1) {
|
|
103
|
+
const names = matches.map(n => JSON.stringify(n.name ?? n.role));
|
|
104
|
+
throw new AmbiguousError(
|
|
105
|
+
`${matches.length} AX nodes match ${JSON.stringify(loc)}: ${names.slice(0, 5).join(", ")}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return matches[0]!;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function matchNodes(tree: AxNode, loc: Locator): AxNode[] {
|
|
113
|
+
if (loc.kind === "css") {
|
|
114
|
+
throw new Error("css locators cannot be resolved against an AX tree — resolve live via CDP");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const all: AxNode[] = [];
|
|
118
|
+
collect(tree, all);
|
|
119
|
+
|
|
120
|
+
if (loc.kind === "roleName") {
|
|
121
|
+
const wantRole = loc.role;
|
|
122
|
+
const wantName = norm(loc.name);
|
|
123
|
+
return all.filter(n => n.role === wantRole && norm(n.name ?? "") === wantName);
|
|
124
|
+
} else if (loc.kind === "role") {
|
|
125
|
+
return all.filter(n => n.role === loc.role);
|
|
126
|
+
} else {
|
|
127
|
+
// kind === "text"
|
|
128
|
+
const want = norm(loc.text);
|
|
129
|
+
return all.filter(n => {
|
|
130
|
+
const nodeName = norm(n.name ?? "");
|
|
131
|
+
return nodeName === want || nodeName.includes(want);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Browser, CDPSession, Page } from "puppeteer";
|
|
2
|
+
import { assertLoopbackBrowserUrl, pickCoDrivePage, resolveBrowserConnectUrl } from "../tools/browser";
|
|
3
|
+
|
|
4
|
+
type Settings = { get(key: string): unknown };
|
|
5
|
+
|
|
6
|
+
export class BrowserSession {
|
|
7
|
+
#browser: Browser | null = null;
|
|
8
|
+
#page: Page | null = null;
|
|
9
|
+
#cdp: CDPSession | null = null;
|
|
10
|
+
#attached = false;
|
|
11
|
+
constructor(private readonly settings: Settings) {}
|
|
12
|
+
|
|
13
|
+
async ensurePage(): Promise<Page> {
|
|
14
|
+
if (this.#page && !this.#page.isClosed()) return this.#page;
|
|
15
|
+
const puppeteer = (await import("puppeteer")).default;
|
|
16
|
+
const connectUrl = resolveBrowserConnectUrl(this.settings);
|
|
17
|
+
if (connectUrl) {
|
|
18
|
+
assertLoopbackBrowserUrl(connectUrl);
|
|
19
|
+
this.#browser = await puppeteer.connect({ browserURL: connectUrl });
|
|
20
|
+
this.#attached = true;
|
|
21
|
+
const pages = await this.#browser.pages();
|
|
22
|
+
this.#page = pages.length ? pages[pickCoDrivePage(pages)]! : await this.#browser.newPage();
|
|
23
|
+
} else {
|
|
24
|
+
this.#browser = await puppeteer.launch({ headless: !!this.settings.get("browser.headless") });
|
|
25
|
+
this.#attached = false;
|
|
26
|
+
this.#page = await this.#browser.newPage();
|
|
27
|
+
}
|
|
28
|
+
this.#cdp = null;
|
|
29
|
+
return this.#page;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async cdp(): Promise<CDPSession> {
|
|
33
|
+
const page = await this.ensurePage();
|
|
34
|
+
if (!this.#cdp) this.#cdp = await page.createCDPSession();
|
|
35
|
+
return this.#cdp;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async close(): Promise<void> {
|
|
39
|
+
if (this.#browser) {
|
|
40
|
+
if (this.#attached) await this.#browser.disconnect();
|
|
41
|
+
else await this.#browser.close();
|
|
42
|
+
}
|
|
43
|
+
this.#browser = null;
|
|
44
|
+
this.#page = null;
|
|
45
|
+
this.#cdp = null;
|
|
46
|
+
this.#attached = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM context scoping for XC component classes.
|
|
3
|
+
* Pure module — no Puppeteer imports. Works under linkedom in tests and
|
|
4
|
+
* the live document in page.evaluate in the resolver (Task 5).
|
|
5
|
+
*
|
|
6
|
+
* We define minimal structural interfaces instead of relying on the DOM lib
|
|
7
|
+
* (the project tsconfig does not include DOM types globally).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Minimal structural interface matching both linkedom and browser Element. */
|
|
11
|
+
export interface DomElement {
|
|
12
|
+
tagName: string;
|
|
13
|
+
textContent: string | null;
|
|
14
|
+
className: string | { toString(): string };
|
|
15
|
+
parentElement: DomElement | null;
|
|
16
|
+
querySelector(sel: string): DomElement | null;
|
|
17
|
+
querySelectorAll(sel: string): ArrayLike<DomElement>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Minimal structural interface matching both linkedom and browser Document. */
|
|
21
|
+
export interface DomDocument {
|
|
22
|
+
body: DomElement | null;
|
|
23
|
+
documentElement: DomElement;
|
|
24
|
+
querySelectorAll(sel: string): ArrayLike<DomElement>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Strip a leading asterisk, trim, and collapse internal whitespace. */
|
|
28
|
+
export function normLabel(s: string): string {
|
|
29
|
+
return s.trim().replace(/^\*/, "").trim().replace(/\s+/g, " ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Suffixes we strip before matching a label phrase. */
|
|
33
|
+
export const STRIP_SUFFIX = / (?:section|table|table row|selector)$/i;
|
|
34
|
+
|
|
35
|
+
/** CSS selectors that identify XC field/section labels. */
|
|
36
|
+
export const LABEL_SELECTORS = [
|
|
37
|
+
"label.ves-label_level_3_label",
|
|
38
|
+
"label[class*='ves-label_level_3_label']",
|
|
39
|
+
".datatable-header-cell-label",
|
|
40
|
+
".tile-header__name",
|
|
41
|
+
"label.form-control-label",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** CSS that identifies "interactive" controls — presence means the ancestor is the section container. */
|
|
45
|
+
export const CONTROL_SELECTOR = "button, [role='listbox'], .listbox, input";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Find the nearest ancestor element that:
|
|
49
|
+
* 1. Is an ancestor of the matched label element, AND
|
|
50
|
+
* 2. Contains at least one interactive control (button / listbox / input).
|
|
51
|
+
*
|
|
52
|
+
* Walk up from the label, stopping at `document.body` (or `documentElement`).
|
|
53
|
+
*/
|
|
54
|
+
export function findControlBearingAncestor(label: DomElement, doc: DomDocument): DomElement | null {
|
|
55
|
+
const body = doc.body ?? doc.documentElement;
|
|
56
|
+
let current: DomElement | null = label.parentElement;
|
|
57
|
+
while (current && current !== body) {
|
|
58
|
+
if (current.querySelector(CONTROL_SELECTOR)) {
|
|
59
|
+
return current;
|
|
60
|
+
}
|
|
61
|
+
current = current.parentElement;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Given a phrase like "Domains table" or "Origin Pools section",
|
|
68
|
+
* strip the trailing qualifier, find the matching label element in the DOM,
|
|
69
|
+
* then return the nearest ancestor that contains an interactive control.
|
|
70
|
+
*
|
|
71
|
+
* Returns `null` if no label or no control-bearing ancestor is found.
|
|
72
|
+
*/
|
|
73
|
+
export function findSectionContainer(doc: DomDocument, phrase: string): DomElement | null {
|
|
74
|
+
const bare = phrase.replace(STRIP_SUFFIX, "").trim();
|
|
75
|
+
const want = normLabel(bare);
|
|
76
|
+
|
|
77
|
+
// Try each label selector in priority order
|
|
78
|
+
for (const sel of LABEL_SELECTORS) {
|
|
79
|
+
const candidates = Array.from(doc.querySelectorAll(sel));
|
|
80
|
+
for (const el of candidates) {
|
|
81
|
+
if (normLabel(el.textContent ?? "") === want) {
|
|
82
|
+
const container = findControlBearingAncestor(el, doc);
|
|
83
|
+
if (container) return container;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|