@f5-sales-demo/xcsh 19.53.1 → 19.54.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 +8 -8
- package/scripts/demo-presentation.ts +108 -0
- package/scripts/gen-specs.ts +96 -0
- package/scripts/inspect-form.ts +126 -0
- package/scripts/json-create-spike.ts +114 -0
- package/scripts/json-spike.ts +102 -0
- package/scripts/spec-prober.ts +35 -0
- package/scripts/sweep-harness.ts +474 -0
- package/scripts/sweep-headless.ts +267 -0
- package/src/browser/capabilities.generated.ts +75 -4
- package/src/browser/capabilities.json +69 -2
- package/src/browser/cdp-page-actions.ts +4 -1
- package/src/browser/chat-conformance.json +1 -1
- package/src/browser/chat-handler.ts +94 -0
- package/src/browser/console-routes.generated.ts +446 -0
- package/src/browser/extension-bridge.ts +120 -18
- package/src/browser/extension-page-actions.ts +138 -42
- package/src/browser/extension-provider.ts +3 -3
- package/src/browser/page-actions.ts +4 -1
- package/src/browser/page-state-interpreter.ts +211 -0
- package/src/browser/provider.ts +12 -1
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-catalog.generated.ts +38 -38
- package/src/main.ts +94 -6
- package/src/services/xcsh-env.ts +44 -0
- package/src/sweep/dependency-graph.ts +136 -0
- package/src/sweep/json-create.ts +125 -0
- package/src/sweep/openapi-spec.ts +218 -0
- package/src/sweep/spec-probe.ts +164 -0
- package/src/sweep/sweep-params.ts +112 -0
- package/src/sweep/sweep-scoring.ts +133 -0
- package/src/tools/catalog-workflow-runner.ts +102 -6
- package/src/tools/idempotency.ts +83 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.54.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -55,13 +55,13 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
57
57
|
"@mozilla/readability": "^0.6",
|
|
58
|
-
"@f5-sales-demo/xcsh-stats": "19.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
58
|
+
"@f5-sales-demo/xcsh-stats": "19.54.0",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.54.0",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.54.0",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.54.0",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.54.0",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.54.0",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.54.0",
|
|
65
65
|
"@sinclair/typebox": "^0.34",
|
|
66
66
|
"@xterm/headless": "^6.0",
|
|
67
67
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Live demo: drive console form-creates with the INSTRUCTOR presentation profile
|
|
4
|
+
* so a human watcher sees the agent's "human-like browsing" — fingerprint-before-
|
|
5
|
+
* click + highlight overlays, slow watchable pacing (2.2s/step), and full per-step
|
|
6
|
+
* narration. Built for showing the automation to an audience.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* XCSH_BROWSER_PROVIDER=extension XCSH_API_URL=… XCSH_API_TOKEN=… XCSH_NAMESPACE=demo \
|
|
10
|
+
* bun scripts/demo-presentation.ts [resource1 resource2 …]
|
|
11
|
+
* (defaults to: health-check origin-pool)
|
|
12
|
+
*/
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { startBridgeServer } from "../src/browser/extension-bridge";
|
|
15
|
+
import { ExtensionBrowserProvider } from "../src/browser/extension-provider";
|
|
16
|
+
import { paramsFor } from "../src/sweep/sweep-params";
|
|
17
|
+
import { apiItemPath } from "../src/sweep/sweep-scoring";
|
|
18
|
+
import { CatalogWorkflowRunnerTool } from "../src/tools/catalog-workflow-runner";
|
|
19
|
+
|
|
20
|
+
const NAMESPACE = process.env.XCSH_NAMESPACE ?? "demo";
|
|
21
|
+
const BASE_URL = (process.env.XCSH_API_URL ?? "").replace(/\/+$/, "");
|
|
22
|
+
const TOKEN = process.env.XCSH_API_TOKEN ?? "";
|
|
23
|
+
const CONSOLE_ROOT = path.resolve(import.meta.dir, "../../../../console");
|
|
24
|
+
const PROFILE = (process.env.XCSH_PRESENTATION ?? "instructor") as "guided" | "instructor";
|
|
25
|
+
|
|
26
|
+
process.env.XCSH_BROWSER_PROVIDER ??= "extension";
|
|
27
|
+
|
|
28
|
+
const RESOURCES = process.argv.slice(2).length ? process.argv.slice(2) : ["health-check", "origin-pool"];
|
|
29
|
+
|
|
30
|
+
function sweepName(resource: string): string {
|
|
31
|
+
return `demo-${resource}`
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
34
|
+
.slice(0, 63);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function apiDelete(resource: string, name: string): Promise<void> {
|
|
38
|
+
if (!BASE_URL || !TOKEN) return;
|
|
39
|
+
for (const ns of [NAMESPACE, "system"]) {
|
|
40
|
+
await fetch(`${BASE_URL}${apiItemPath(resource, ns, name)}`, {
|
|
41
|
+
method: "DELETE",
|
|
42
|
+
headers: { Authorization: `APIToken ${TOKEN}` },
|
|
43
|
+
signal: AbortSignal.timeout(8000),
|
|
44
|
+
}).catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function apiExists(resource: string, name: string): Promise<boolean> {
|
|
49
|
+
if (!BASE_URL || !TOKEN) return false;
|
|
50
|
+
for (const ns of [NAMESPACE, "system"]) {
|
|
51
|
+
try {
|
|
52
|
+
const r = await fetch(`${BASE_URL}${apiItemPath(resource, ns, name)}`, {
|
|
53
|
+
headers: { Authorization: `APIToken ${TOKEN}` },
|
|
54
|
+
signal: AbortSignal.timeout(8000),
|
|
55
|
+
});
|
|
56
|
+
if (r.ok) return true;
|
|
57
|
+
} catch {
|
|
58
|
+
/* try next */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
console.log(`\n🎬 xcsh automation demo — profile: ${PROFILE} (fingerprint-before-click, highlights, narration)\n`);
|
|
66
|
+
const server = await startBridgeServer();
|
|
67
|
+
const tool = new CatalogWorkflowRunnerTool({ settings: { get: () => undefined } } as never);
|
|
68
|
+
|
|
69
|
+
console.log(" Waiting for the extension to connect (open Chrome with xcsh loaded)…");
|
|
70
|
+
const deadline = Date.now() + 60_000;
|
|
71
|
+
while (Date.now() < deadline && !server.connected) await new Promise(r => setTimeout(r, 300));
|
|
72
|
+
if (!server.connected) {
|
|
73
|
+
console.error(" ✗ Extension did not connect.");
|
|
74
|
+
await server.close();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
console.log(" ✓ Extension connected.\n");
|
|
78
|
+
tool.setProvider(new ExtensionBrowserProvider({ server }));
|
|
79
|
+
|
|
80
|
+
for (const resource of RESOURCES) {
|
|
81
|
+
const name = sweepName(resource);
|
|
82
|
+
console.log(`\n▶ Creating ${resource} as "${name}" …`);
|
|
83
|
+
await apiDelete(resource, name); // clean so the create is real
|
|
84
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
85
|
+
try {
|
|
86
|
+
await tool.execute(`${resource}-create`, {
|
|
87
|
+
resource,
|
|
88
|
+
operation: "create",
|
|
89
|
+
params: paramsFor(resource, { name, namespace: NAMESPACE }),
|
|
90
|
+
base_url: BASE_URL,
|
|
91
|
+
catalog_path: CONSOLE_ROOT,
|
|
92
|
+
presentation: PROFILE,
|
|
93
|
+
} as never);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(` ✗ ${resource}: ${e instanceof Error ? e.message : String(e)}`);
|
|
96
|
+
}
|
|
97
|
+
const ok = await apiExists(resource, name);
|
|
98
|
+
console.log(` ${ok ? "✅" : "❌"} ${resource} ${ok ? "created (API-confirmed)" : "not created"}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log("\n🎬 Demo complete.\n");
|
|
102
|
+
await server.close();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
main().catch(e => {
|
|
106
|
+
console.error(e);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Generate minimal valid create specs for resources by walking the enriched
|
|
4
|
+
* OpenAPI schemas (openapi-spec.ts), then VALIDATE each against the live API by
|
|
5
|
+
* POSTing to the correct API-group path and deleting on success. Emits a
|
|
6
|
+
* resource→spec map + a pass/fail report. This is the hybrid spec source for the
|
|
7
|
+
* JSON-tab create path: OpenAPI structure, API as source of truth.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* XCSH_API_URL=… XCSH_API_TOKEN=… XCSH_NAMESPACE=demo \
|
|
11
|
+
* bun scripts/gen-specs.ts [resource1 …] # omit args = all (non-cloud)
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { buildMinimalSpec, type SchemaIndex } from "../src/sweep/openapi-spec";
|
|
16
|
+
import { probeSpec } from "../src/sweep/spec-probe";
|
|
17
|
+
import { isScopedOut } from "../src/sweep/sweep-params";
|
|
18
|
+
import { apiKindFor } from "../src/sweep/sweep-scoring";
|
|
19
|
+
|
|
20
|
+
const SPECS_DIR =
|
|
21
|
+
process.env.XCSH_OPENAPI_DIR ??
|
|
22
|
+
path.resolve(import.meta.dir, "../../../../api-specs-enriched/docs/specifications/api");
|
|
23
|
+
const WORKFLOWS_DIR = path.resolve(import.meta.dir, "../../../../console/catalog/workflows");
|
|
24
|
+
const NAMESPACE = process.env.XCSH_NAMESPACE ?? "demo";
|
|
25
|
+
const BASE_URL = (process.env.XCSH_API_URL ?? "").replace(/\/+$/, "");
|
|
26
|
+
const TOKEN = process.env.XCSH_API_TOKEN ?? "";
|
|
27
|
+
const OUT = process.env.SPECS_OUT ?? path.join(WORKFLOWS_DIR, "../generated-specs.json");
|
|
28
|
+
|
|
29
|
+
function loadIndex(): SchemaIndex {
|
|
30
|
+
const index: SchemaIndex = {};
|
|
31
|
+
for (const f of fs.readdirSync(SPECS_DIR)) {
|
|
32
|
+
if (!f.endsWith(".json") || f === "index.json") continue;
|
|
33
|
+
try {
|
|
34
|
+
const d = JSON.parse(fs.readFileSync(path.join(SPECS_DIR, f), "utf8"));
|
|
35
|
+
for (const [name, sch] of Object.entries(d?.components?.schemas ?? {})) {
|
|
36
|
+
if (!(name in index)) index[name] = sch as SchemaIndex[string];
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
/* skip unparseable spec file */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return index;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Singular API kind for the OpenAPI schema name, e.g. "healthchecks" → "healthcheck". */
|
|
46
|
+
function singularKind(resource: string): string {
|
|
47
|
+
return apiKindFor(resource).replace(/s$/, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function discover(filter: string[]): string[] {
|
|
51
|
+
return fs
|
|
52
|
+
.readdirSync(WORKFLOWS_DIR)
|
|
53
|
+
.filter(d => fs.existsSync(path.join(WORKFLOWS_DIR, d, "create.yaml")))
|
|
54
|
+
.filter(d => !isScopedOut(d))
|
|
55
|
+
.filter(d => filter.length === 0 || filter.includes(d))
|
|
56
|
+
.sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function main() {
|
|
60
|
+
const index = loadIndex();
|
|
61
|
+
console.log(`Loaded ${Object.keys(index).length} schemas from ${SPECS_DIR}`);
|
|
62
|
+
const resources = discover(process.argv.slice(2));
|
|
63
|
+
const specs: Record<string, unknown> = {};
|
|
64
|
+
let pass = 0;
|
|
65
|
+
for (const resource of resources) {
|
|
66
|
+
const name = /domain|dns-zone/.test(resource) ? "xcsh-gen.example.com" : `xcsh-gen-${resource}`.slice(0, 63);
|
|
67
|
+
const built = buildMinimalSpec(index, singularKind(resource), name, NAMESPACE);
|
|
68
|
+
if (!built.ok) {
|
|
69
|
+
console.log(` ❌ ${resource.padEnd(26)} ${built.reason}`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Walker-seed → prober-patch: start the error-driven prober from the walker's spec.
|
|
73
|
+
const v = await probeSpec({
|
|
74
|
+
baseUrl: BASE_URL,
|
|
75
|
+
token: TOKEN,
|
|
76
|
+
namespace: NAMESPACE,
|
|
77
|
+
resource,
|
|
78
|
+
name,
|
|
79
|
+
seedSpec: built.body.spec as Record<string, unknown>,
|
|
80
|
+
});
|
|
81
|
+
if (v.ok) {
|
|
82
|
+
pass++;
|
|
83
|
+
specs[resource] = { namespace: v.namespace, metadata: { name, namespace: v.namespace }, spec: v.spec };
|
|
84
|
+
console.log(` ✅ ${resource.padEnd(26)} [${v.namespace}/${v.iters}i] ${JSON.stringify(v.spec).slice(0, 55)}`);
|
|
85
|
+
} else {
|
|
86
|
+
console.log(` ❌ ${resource.padEnd(26)} ${v.lastError}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
fs.writeFileSync(OUT, JSON.stringify(specs, null, 2));
|
|
90
|
+
console.log(`\nWalker+API validated: ${pass}/${resources.length} → wrote ${OUT}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
main().catch(e => {
|
|
94
|
+
console.error(e);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Live form inspector. Opens a resource's create form via the Chrome extension
|
|
4
|
+
* and dumps the REAL attributes of every visible input/select (role, aria-label,
|
|
5
|
+
* name, placeholder, id, class, nearest label) — so we author selectors from
|
|
6
|
+
* ground truth instead of guessing `role[name='VisibleLabel']` (which fails when
|
|
7
|
+
* the input has no accessible name matching its column header / field label).
|
|
8
|
+
*
|
|
9
|
+
* Uses javascript_tool (read_ax freezes on heavy console forms).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* XCSH_BROWSER_PROVIDER=extension XCSH_API_URL=… XCSH_USERNAME=… \
|
|
13
|
+
* XCSH_CONSOLE_PASSWORD=… XCSH_NAMESPACE=demo \
|
|
14
|
+
* bun scripts/inspect-form.ts <resource>
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import * as yaml from "yaml";
|
|
19
|
+
import { startBridgeServer } from "../src/browser/extension-bridge";
|
|
20
|
+
import { ExtensionBrowserProvider } from "../src/browser/extension-provider";
|
|
21
|
+
|
|
22
|
+
const CONSOLE_ROOT = process.env.CONSOLE_CATALOG_DIR ?? path.resolve(import.meta.dir, "../../../../console");
|
|
23
|
+
const NAMESPACE = process.env.XCSH_NAMESPACE ?? "demo";
|
|
24
|
+
const BASE_URL = (process.env.XCSH_API_URL ?? "").replace(/\/+$/, "");
|
|
25
|
+
process.env.XCSH_BROWSER_PROVIDER ??= "extension";
|
|
26
|
+
|
|
27
|
+
const resource = process.argv[2];
|
|
28
|
+
if (!resource) {
|
|
29
|
+
console.error("usage: bun scripts/inspect-form.ts <resource>");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const wf = yaml.parse(fs.readFileSync(path.join(CONSOLE_ROOT, "catalog/workflows", resource, "create.yaml"), "utf8"));
|
|
34
|
+
const navStep = (wf.steps ?? []).find((s: { action?: string }) => s.action === "navigate");
|
|
35
|
+
const addStep = (wf.steps ?? []).find((s: { selector?: string }) => /text\('Add /.test(s.selector ?? ""));
|
|
36
|
+
const listUrl: string = (navStep?.url ?? "").replace(/\{namespace\}/g, NAMESPACE);
|
|
37
|
+
const addText: string = (addStep?.selector?.match(/text\('([^']+)'\)/)?.[1] ?? "Add").trim();
|
|
38
|
+
|
|
39
|
+
const DUMP = `(() => {
|
|
40
|
+
const sel = 'input,select,textarea,[role=spinbutton],[role=combobox],[role=textbox],[role=listbox]';
|
|
41
|
+
const labelFor = (e) => {
|
|
42
|
+
if (e.getAttribute('aria-labelledby')) { const l=document.getElementById(e.getAttribute('aria-labelledby')); if(l) return l.textContent.trim().slice(0,40); }
|
|
43
|
+
const lab = e.closest('label') || (e.id && document.querySelector('label[for=\\''+e.id+'\\']'));
|
|
44
|
+
if (lab) return lab.textContent.trim().slice(0,40);
|
|
45
|
+
let p=e.parentElement, hops=0; while(p&&hops<4){const h=p.querySelector('label,.field-label,[class*=label]');if(h&&h.textContent.trim())return h.textContent.trim().slice(0,40);p=p.parentElement;hops++;}
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
const section=(e)=>{let p=e;while(p&&p.tagName!=='BODY'){const h=p.querySelector&&p.querySelector('.section-header,[class*=section] h3,[class*=section] h4,h3,h4');if(h&&h.textContent.trim())return h.textContent.trim().slice(0,30);p=p.parentElement;}return null;};
|
|
49
|
+
const out=[...document.querySelectorAll(sel)].map(e=>{
|
|
50
|
+
const r=e.getBoundingClientRect();
|
|
51
|
+
return {
|
|
52
|
+
vis:e.offsetParent!==null,
|
|
53
|
+
tag:e.tagName.toLowerCase(), type:e.getAttribute('type'), role:e.getAttribute('role'),
|
|
54
|
+
ariaLabel:e.getAttribute('aria-label'), name:e.getAttribute('name'),
|
|
55
|
+
placeholder:e.getAttribute('placeholder'), id:e.id||null,
|
|
56
|
+
e2e:e.getAttribute('ves-e2e-test'), testid:e.getAttribute('data-testid'),
|
|
57
|
+
cls:(e.className||'').slice(0,50), label:labelFor(e), section:section(e),
|
|
58
|
+
dt:!!e.closest('ngx-datatable'), cell:!!e.closest('datatable-body-cell'),
|
|
59
|
+
xy:[Math.round(r.left),Math.round(r.top)],
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
return JSON.stringify(out);
|
|
63
|
+
})()`;
|
|
64
|
+
|
|
65
|
+
const CLICK_ADD = `(() => {
|
|
66
|
+
const t=${JSON.stringify(addText)};
|
|
67
|
+
const el=[...document.querySelectorAll('button,a,[role=button],span')].find(e=>e.textContent && e.textContent.trim()===t)
|
|
68
|
+
|| [...document.querySelectorAll('button,a,[role=button],span')].find(e=>e.textContent && e.textContent.trim().includes(t));
|
|
69
|
+
if(el){el.click();return 'clicked: '+el.tagName;} return 'NOTFOUND: '+t;
|
|
70
|
+
})()`;
|
|
71
|
+
|
|
72
|
+
function unwrapJs(content: unknown): unknown {
|
|
73
|
+
const payload =
|
|
74
|
+
content && typeof content === "object" && "result" in (content as object)
|
|
75
|
+
? (content as { result: unknown }).result
|
|
76
|
+
: content;
|
|
77
|
+
if (typeof payload !== "string") return payload;
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(payload);
|
|
80
|
+
} catch {
|
|
81
|
+
return payload; // plain string result (e.g. the click ack)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function main() {
|
|
86
|
+
const server = await startBridgeServer();
|
|
87
|
+
const provider = new ExtensionBrowserProvider({ server });
|
|
88
|
+
console.log(`Acquiring (login) ${BASE_URL} …`);
|
|
89
|
+
const acquired = await provider.acquire(BASE_URL);
|
|
90
|
+
try {
|
|
91
|
+
console.log(`Navigate: ${BASE_URL}${listUrl}`);
|
|
92
|
+
await server.request("navigate", { url: `${BASE_URL}${listUrl}` }, 30000);
|
|
93
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
94
|
+
const clicked = await server.request("javascript_tool", { code: CLICK_ADD }, 15000);
|
|
95
|
+
console.log(`Open form (${addText}):`, unwrapJs(clicked.content));
|
|
96
|
+
await new Promise(r => setTimeout(r, 3500));
|
|
97
|
+
// If the form has an "Add Item" button (datatable), click it to instantiate the row input.
|
|
98
|
+
const ai = await server.request(
|
|
99
|
+
"javascript_tool",
|
|
100
|
+
{
|
|
101
|
+
code: `(()=>{const b=[...document.querySelectorAll('button,a,span')].find(e=>e.textContent.trim()==='Add Item');if(b){b.click();return 'clicked-add-item';}return 'no-add-item';})()`,
|
|
102
|
+
},
|
|
103
|
+
15000,
|
|
104
|
+
);
|
|
105
|
+
console.log("Add Item:", unwrapJs(ai.content));
|
|
106
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
107
|
+
const dump = await server.request("javascript_tool", { code: DUMP }, 15000);
|
|
108
|
+
const inputs = unwrapJs(dump.content) as Array<Record<string, unknown>>;
|
|
109
|
+
console.log(`\n=== ${resource}: ${inputs.length} visible inputs ===`);
|
|
110
|
+
for (const i of inputs) {
|
|
111
|
+
console.log(
|
|
112
|
+
` ${i.vis ? "VIS" : "hid"} <${i.tag}${i.type ? ` type=${i.type}` : ""}> @${JSON.stringify(i.xy)} ` +
|
|
113
|
+
`aria=${JSON.stringify(i.ariaLabel)} ph=${JSON.stringify(i.placeholder)} ` +
|
|
114
|
+
`e2e=${JSON.stringify(i.e2e)} dt=${i.dt} cell=${i.cell}\n cls=${JSON.stringify(i.cls)}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
await acquired.release().catch(() => {});
|
|
119
|
+
await server.close().catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
main().catch(e => {
|
|
124
|
+
console.error(e);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Spike: create ip-prefix-set via the JSON tab (ACE editor) instead of the form.
|
|
4
|
+
* Sets the full spec with one ace setValue(), clicks save, reports the error
|
|
5
|
+
* banner. Decisive test of whether JSON-create bypasses the no-accessible-name
|
|
6
|
+
* form-selector problem. Verify creation separately via API-GET.
|
|
7
|
+
*/
|
|
8
|
+
import { startBridgeServer } from "../src/browser/extension-bridge";
|
|
9
|
+
import { ExtensionBrowserProvider } from "../src/browser/extension-provider";
|
|
10
|
+
|
|
11
|
+
const NAMESPACE = process.env.XCSH_NAMESPACE ?? "demo";
|
|
12
|
+
const BASE_URL = (process.env.XCSH_API_URL ?? "").replace(/\/+$/, "");
|
|
13
|
+
process.env.XCSH_BROWSER_PROVIDER ??= "extension";
|
|
14
|
+
|
|
15
|
+
const LIST_URL = `/web/workspaces/web-app-and-api-protection/namespaces/${NAMESPACE}/manage/shared_objects/ip_prefix_sets`;
|
|
16
|
+
const SPEC = {
|
|
17
|
+
metadata: { name: "xcsh-sweep-ip-prefix-set", namespace: NAMESPACE },
|
|
18
|
+
spec: { ipv4_prefixes: [{ ipv4_prefix: "10.10.0.0/24" }] },
|
|
19
|
+
};
|
|
20
|
+
const SPEC_JSON = JSON.stringify(SPEC, null, 2);
|
|
21
|
+
|
|
22
|
+
const clickByText = (t: string) => `(() => {
|
|
23
|
+
const t=${JSON.stringify(t)};
|
|
24
|
+
const el=[...document.querySelectorAll('button,a,[role=button],span')].find(e=>e.textContent.trim()===t)
|
|
25
|
+
|| [...document.querySelectorAll('button,a,[role=button],span')].find(e=>e.textContent.trim().includes(t));
|
|
26
|
+
if(el){el.click();return 'clicked '+el.tagName;} return 'NOTFOUND '+t;
|
|
27
|
+
})()`;
|
|
28
|
+
|
|
29
|
+
const SET_ACE = `(() => {
|
|
30
|
+
const el=document.querySelector('.ace_editor');
|
|
31
|
+
if(!el||!el.env||!el.env.editor) return 'NO_ACE';
|
|
32
|
+
const ed=el.env.editor;
|
|
33
|
+
ed.setValue(${JSON.stringify(SPEC_JSON)}, -1);
|
|
34
|
+
// Simulate a real edit so the console's change handler commits JSON->model:
|
|
35
|
+
ed.focus(); ed.navigateFileEnd(); ed.insert(' '); ed.remove('left');
|
|
36
|
+
return 'set:'+ed.getValue().length+'chars';
|
|
37
|
+
})()`;
|
|
38
|
+
|
|
39
|
+
const READ_FORM = `(() => {
|
|
40
|
+
const name=document.querySelector("input[aria-label='Name']");
|
|
41
|
+
const all=[...document.querySelectorAll('input')].filter(e=>e.offsetParent!==null).map(e=>({a:e.getAttribute('aria-label'),v:(e.value||'').slice(0,40)}));
|
|
42
|
+
return JSON.stringify({ nameVal: name? name.value: 'NO_NAME_INPUT', inputs: all });
|
|
43
|
+
})()`;
|
|
44
|
+
|
|
45
|
+
function unwrapJs(content: unknown): unknown {
|
|
46
|
+
const p =
|
|
47
|
+
content && typeof content === "object" && "result" in (content as object)
|
|
48
|
+
? (content as { result: unknown }).result
|
|
49
|
+
: content;
|
|
50
|
+
if (typeof p !== "string") return p;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(p);
|
|
53
|
+
} catch {
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
const server = await startBridgeServer();
|
|
60
|
+
const provider = new ExtensionBrowserProvider({ server });
|
|
61
|
+
const acquired = await provider.acquire(BASE_URL);
|
|
62
|
+
const js = (code: string) => server.request("javascript_tool", { code }, 15000).then(r => unwrapJs(r.content));
|
|
63
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
64
|
+
const present = (t: string) =>
|
|
65
|
+
js(
|
|
66
|
+
`(()=>{const b=[...document.querySelectorAll('button,a,span')].find(e=>(e.textContent||'').trim().includes(${JSON.stringify(t)}));return b?'YES':'no';})()`,
|
|
67
|
+
);
|
|
68
|
+
const waitFor = async (t: string, secs = 25) => {
|
|
69
|
+
for (let i = 0; i < secs; i++) {
|
|
70
|
+
if ((await present(t)) === "YES") return true;
|
|
71
|
+
await sleep(1000);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
// Robust open: navigate (retry up to 3×) and POLL for the list to render
|
|
77
|
+
// before clicking — the SPA intermittently shows the workspace picker.
|
|
78
|
+
let opened = false;
|
|
79
|
+
for (let attempt = 0; attempt < 3 && !opened; attempt++) {
|
|
80
|
+
await server.request("navigate", { url: `${BASE_URL}${LIST_URL}` }, 30000);
|
|
81
|
+
opened = await waitFor("Add IP Prefix Set", 20);
|
|
82
|
+
console.log(`navigate attempt ${attempt + 1}: list rendered=${opened}`);
|
|
83
|
+
}
|
|
84
|
+
if (!opened) {
|
|
85
|
+
console.log("LIST NEVER RENDERED — aborting");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log("open:", await js(clickByText("Add IP Prefix Set")));
|
|
89
|
+
await waitFor("JSON", 15);
|
|
90
|
+
console.log("json tab:", await js(clickByText("JSON")));
|
|
91
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
92
|
+
console.log("set ace:", await js(SET_ACE));
|
|
93
|
+
await sleep(2000);
|
|
94
|
+
// Switching to Form COMMITS JSON->model (proven). With a valid schema there's
|
|
95
|
+
// no error modal, so the model populates and we can save from the Form tab.
|
|
96
|
+
console.log("form tab:", await js(clickByText("Form")));
|
|
97
|
+
await sleep(3000);
|
|
98
|
+
console.log("FORM AFTER COMMIT:", await js(READ_FORM));
|
|
99
|
+
const SAVE = `(()=>{const btns=[...document.querySelectorAll('button')].filter(x=>x.offsetParent!==null);
|
|
100
|
+
const b=btns.find(x=>/save-btn/.test(x.className||'')) || btns.filter(x=>/Add IP Prefix Set/i.test(x.textContent||'')).pop();
|
|
101
|
+
if(b){b.click();return 'saved:'+(b.className||b.tagName);}return 'NO_SAVE';})()`;
|
|
102
|
+
console.log("save:", await js(SAVE));
|
|
103
|
+
await sleep(6000);
|
|
104
|
+
console.log("final url:", await js("(()=>location.href)()"));
|
|
105
|
+
} finally {
|
|
106
|
+
await acquired.release().catch(() => {});
|
|
107
|
+
await server.close().catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().catch(e => {
|
|
112
|
+
console.error(e);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Spike: can we create a resource via the console's JSON tab instead of the
|
|
4
|
+
* per-field form? Most vsui form inputs have no accessible name, so the JSON
|
|
5
|
+
* editor (one field) could be a far more robust create path.
|
|
6
|
+
*
|
|
7
|
+
* This probe opens a resource's create form, clicks the JSON tab, and reports
|
|
8
|
+
* what editor it is (Monaco / CodeMirror / ACE / textarea) + its current value,
|
|
9
|
+
* so we know how to set its content. Read-only — does not save.
|
|
10
|
+
*
|
|
11
|
+
* Usage: bun scripts/json-spike.ts <resource>
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import * as yaml from "yaml";
|
|
16
|
+
import { startBridgeServer } from "../src/browser/extension-bridge";
|
|
17
|
+
import { ExtensionBrowserProvider } from "../src/browser/extension-provider";
|
|
18
|
+
|
|
19
|
+
const CONSOLE_ROOT = process.env.CONSOLE_CATALOG_DIR ?? path.resolve(import.meta.dir, "../../../../console");
|
|
20
|
+
const NAMESPACE = process.env.XCSH_NAMESPACE ?? "demo";
|
|
21
|
+
const BASE_URL = (process.env.XCSH_API_URL ?? "").replace(/\/+$/, "");
|
|
22
|
+
process.env.XCSH_BROWSER_PROVIDER ??= "extension";
|
|
23
|
+
|
|
24
|
+
const resource = process.argv[2] ?? "ip-prefix-set";
|
|
25
|
+
const wf = yaml.parse(fs.readFileSync(path.join(CONSOLE_ROOT, "catalog/workflows", resource, "create.yaml"), "utf8"));
|
|
26
|
+
const navStep = (wf.steps ?? []).find((s: { action?: string }) => s.action === "navigate");
|
|
27
|
+
const addStep = (wf.steps ?? []).find((s: { selector?: string }) => /text\('Add /.test(s.selector ?? ""));
|
|
28
|
+
const listUrl: string = (navStep?.url ?? "").replace(/\{namespace\}/g, NAMESPACE);
|
|
29
|
+
const addText: string = (addStep?.selector?.match(/text\('([^']+)'\)/)?.[1] ?? "Add").trim();
|
|
30
|
+
|
|
31
|
+
const clickByText = (t: string) => `(() => {
|
|
32
|
+
const t=${JSON.stringify(t)};
|
|
33
|
+
const el=[...document.querySelectorAll('button,a,[role=button],span,div,li')].find(e=>{
|
|
34
|
+
const own=[...e.childNodes].filter(n=>n.nodeType===3).map(n=>n.textContent).join('').trim();
|
|
35
|
+
return own===t;
|
|
36
|
+
}) || [...document.querySelectorAll('button,a,[role=button],span')].find(e=>e.textContent.trim()===t);
|
|
37
|
+
if(el){el.click();return 'clicked '+el.tagName;} return 'NOTFOUND '+t;
|
|
38
|
+
})()`;
|
|
39
|
+
|
|
40
|
+
const PROBE = `(() => {
|
|
41
|
+
const r = { monaco:false, codemirror:false, ace:false, textareas:0, value:null, editorKind:null };
|
|
42
|
+
if (document.querySelector('.monaco-editor')) r.monaco=true;
|
|
43
|
+
if (document.querySelector('.CodeMirror')) r.codemirror=true;
|
|
44
|
+
if (document.querySelector('.ace_editor')) r.ace=true;
|
|
45
|
+
const tas=[...document.querySelectorAll('textarea')].filter(e=>e.offsetParent!==null);
|
|
46
|
+
r.textareas=tas.length;
|
|
47
|
+
try {
|
|
48
|
+
const aceEl=document.querySelector('.ace_editor');
|
|
49
|
+
if (aceEl && aceEl.env && aceEl.env.editor){ r.editorKind='ace'; const ed=aceEl.env.editor; r.value=ed.getValue(); r.readOnly=ed.getReadOnly(); }
|
|
50
|
+
else if (window.monaco && monaco.editor && monaco.editor.getModels().length){ r.editorKind='monaco'; r.value=monaco.editor.getModels()[0].getValue(); }
|
|
51
|
+
else if (tas.length){ r.editorKind='textarea'; r.value=(tas[0].value||''); }
|
|
52
|
+
if (r.value) r.value=r.value.slice(0,400);
|
|
53
|
+
// Controls near the editor that might commit/import JSON
|
|
54
|
+
r.buttons=[...document.querySelectorAll('button,a,[role=button]')].filter(b=>b.offsetParent!==null).map(b=>b.textContent.trim()).filter(t=>t&&t.length<26).slice(0,30);
|
|
55
|
+
} catch(e){ r.err=String(e); }
|
|
56
|
+
return JSON.stringify(r);
|
|
57
|
+
})()`;
|
|
58
|
+
|
|
59
|
+
function unwrapJs(content: unknown): unknown {
|
|
60
|
+
const p =
|
|
61
|
+
content && typeof content === "object" && "result" in (content as object)
|
|
62
|
+
? (content as { result: unknown }).result
|
|
63
|
+
: content;
|
|
64
|
+
if (typeof p !== "string") return p;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(p);
|
|
67
|
+
} catch {
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function main() {
|
|
73
|
+
const server = await startBridgeServer();
|
|
74
|
+
const provider = new ExtensionBrowserProvider({ server });
|
|
75
|
+
console.log(`Acquiring ${BASE_URL} …`);
|
|
76
|
+
const acquired = await provider.acquire(BASE_URL);
|
|
77
|
+
try {
|
|
78
|
+
await server.request("navigate", { url: `${BASE_URL}${listUrl}` }, 30000);
|
|
79
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
80
|
+
console.log(
|
|
81
|
+
"open form:",
|
|
82
|
+
unwrapJs((await server.request("javascript_tool", { code: clickByText(addText) }, 15000)).content),
|
|
83
|
+
);
|
|
84
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
85
|
+
console.log(
|
|
86
|
+
"click JSON tab:",
|
|
87
|
+
unwrapJs((await server.request("javascript_tool", { code: clickByText("JSON") }, 15000)).content),
|
|
88
|
+
);
|
|
89
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
90
|
+
const probe = unwrapJs((await server.request("javascript_tool", { code: PROBE }, 15000)).content);
|
|
91
|
+
console.log("\n=== JSON editor probe ===");
|
|
92
|
+
console.log(JSON.stringify(probe, null, 2));
|
|
93
|
+
} finally {
|
|
94
|
+
await acquired.release().catch(() => {});
|
|
95
|
+
await server.close().catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
main().catch(e => {
|
|
100
|
+
console.error(e);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* CLI: derive minimal valid specs purely by error-driven API probing (seed {}).
|
|
4
|
+
* Thin wrapper over `probeSpec` (src/sweep/spec-probe.ts) — the same engine the
|
|
5
|
+
* hybrid (gen-specs.ts) uses with an OpenAPI-walker seed.
|
|
6
|
+
*
|
|
7
|
+
* Usage: XCSH_API_URL=… XCSH_API_TOKEN=… XCSH_NAMESPACE=demo \
|
|
8
|
+
* bun scripts/spec-prober.ts <resource1> [resource2 …]
|
|
9
|
+
*/
|
|
10
|
+
import { probeSpec } from "../src/sweep/spec-probe";
|
|
11
|
+
|
|
12
|
+
const NAMESPACE = process.env.XCSH_NAMESPACE ?? "demo";
|
|
13
|
+
const BASE_URL = (process.env.XCSH_API_URL ?? "").replace(/\/+$/, "");
|
|
14
|
+
const TOKEN = process.env.XCSH_API_TOKEN ?? "";
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
if (!BASE_URL || !TOKEN) {
|
|
18
|
+
console.error("XCSH_API_URL / XCSH_API_TOKEN required");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
for (const resource of process.argv.slice(2)) {
|
|
22
|
+
const name = /domain|dns-zone/.test(resource) ? "xcsh-probe.example.com" : `xcsh-probe-${resource}`.slice(0, 63);
|
|
23
|
+
const r = await probeSpec({ baseUrl: BASE_URL, token: TOKEN, namespace: NAMESPACE, resource, name });
|
|
24
|
+
console.log(
|
|
25
|
+
`\n${r.ok ? "✅" : "❌"} ${resource} (${r.iters} iters, ns=${r.namespace})` +
|
|
26
|
+
`\n spec: ${JSON.stringify(r.spec)}` +
|
|
27
|
+
(r.ok ? "" : `\n stuck: ${r.lastError}`),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
main().catch(e => {
|
|
33
|
+
console.error(e);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|