@f5xc-salesdemos/xcsh 19.40.1 → 19.41.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/uat-matrix-modalities.ts +451 -0
- package/scripts/uat-matrix-report.ts +175 -0
- package/scripts/uat-matrix.ts +349 -0
- package/src/browser/extension-page-actions.ts +146 -25
- package/src/browser/extension-provider.ts +12 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-resolve.ts +6 -1
- package/src/utils/git.ts +73 -11
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.41.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",
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
53
53
|
"@mozilla/readability": "^0.6",
|
|
54
|
-
"@f5xc-salesdemos/xcsh-stats": "19.
|
|
55
|
-
"@f5xc-salesdemos/pi-agent-core": "19.
|
|
56
|
-
"@f5xc-salesdemos/pi-ai": "19.
|
|
57
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
58
|
-
"@f5xc-salesdemos/pi-resource-management": "19.
|
|
59
|
-
"@f5xc-salesdemos/pi-tui": "19.
|
|
60
|
-
"@f5xc-salesdemos/pi-utils": "19.
|
|
54
|
+
"@f5xc-salesdemos/xcsh-stats": "19.41.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-agent-core": "19.41.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-ai": "19.41.0",
|
|
57
|
+
"@f5xc-salesdemos/pi-natives": "19.41.0",
|
|
58
|
+
"@f5xc-salesdemos/pi-resource-management": "19.41.0",
|
|
59
|
+
"@f5xc-salesdemos/pi-tui": "19.41.0",
|
|
60
|
+
"@f5xc-salesdemos/pi-utils": "19.41.0",
|
|
61
61
|
"@sinclair/typebox": "^0.34",
|
|
62
62
|
"@xterm/headless": "^6.0",
|
|
63
63
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT matrix — per-modality run + verify functions.
|
|
3
|
+
*
|
|
4
|
+
* Each returns a Cell. The agent is driven via `xcsh --print` (NL in), then
|
|
5
|
+
* verified out-of-band:
|
|
6
|
+
* console → catalog_workflow_runner (observable) + UI step-table + API GET
|
|
7
|
+
* json → API GET (autoresearch-crud pattern)
|
|
8
|
+
* hcl → terraform validate (or keyword presence fallback)
|
|
9
|
+
* i18n → HCL keyword presence per locale
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import type { Cell } from "./uat-matrix-report";
|
|
16
|
+
|
|
17
|
+
export interface MatrixConfig {
|
|
18
|
+
xcshBin: string;
|
|
19
|
+
apiUrl: string;
|
|
20
|
+
apiToken: string;
|
|
21
|
+
consoleNamespace: string;
|
|
22
|
+
tenant: string;
|
|
23
|
+
observable: boolean;
|
|
24
|
+
delayMs: number;
|
|
25
|
+
reportDir: string;
|
|
26
|
+
strictNl: boolean;
|
|
27
|
+
cellTimeoutMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// xcsh subprocess + API helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface XcshRun {
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
code: number | null;
|
|
40
|
+
timedOut: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function runXcsh(
|
|
44
|
+
args: string[],
|
|
45
|
+
opts: { timeoutMs: number; env?: NodeJS.ProcessEnv; cwd?: string; bin?: string },
|
|
46
|
+
): Promise<XcshRun> {
|
|
47
|
+
return new Promise(resolve => {
|
|
48
|
+
const child = spawn(opts.bin ?? "xcsh", args, {
|
|
49
|
+
env: opts.env ?? process.env,
|
|
50
|
+
cwd: opts.cwd,
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
let stdout = "";
|
|
54
|
+
let stderr = "";
|
|
55
|
+
let timedOut = false;
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
timedOut = true;
|
|
58
|
+
child.kill("SIGKILL");
|
|
59
|
+
}, opts.timeoutMs);
|
|
60
|
+
child.stdout.on("data", d => {
|
|
61
|
+
stdout += d.toString();
|
|
62
|
+
});
|
|
63
|
+
child.stderr.on("data", d => {
|
|
64
|
+
stderr += d.toString();
|
|
65
|
+
});
|
|
66
|
+
child.on("close", code => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
resolve({ stdout, stderr, code, timedOut });
|
|
69
|
+
});
|
|
70
|
+
child.on("error", () => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
resolve({ stdout, stderr: `${stderr}\n(spawn error)`, code: -1, timedOut });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** GET an API path; returns the HTTP code, retrying 3× on transient 000. */
|
|
78
|
+
export async function apiGet(apiPath: string, cfg: MatrixConfig): Promise<string> {
|
|
79
|
+
const url = `${cfg.apiUrl}${apiPath}`;
|
|
80
|
+
for (let i = 0; i < 3; i++) {
|
|
81
|
+
try {
|
|
82
|
+
const r = await fetch(url, { headers: { Authorization: `APIToken ${cfg.apiToken}` } });
|
|
83
|
+
return String(r.status);
|
|
84
|
+
} catch {
|
|
85
|
+
await sleep(2000);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return "000";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function apiDelete(apiPath: string, cfg: MatrixConfig): Promise<string> {
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetch(`${cfg.apiUrl}${apiPath}`, {
|
|
94
|
+
method: "DELETE",
|
|
95
|
+
headers: { Authorization: `APIToken ${cfg.apiToken}`, "Content-Type": "application/json" },
|
|
96
|
+
});
|
|
97
|
+
return String(r.status);
|
|
98
|
+
} catch {
|
|
99
|
+
return "000";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Best-effort detection of which path the agent used, from the --print trace. */
|
|
104
|
+
export function detectRouted(stdout: string): Cell["routedTo"] {
|
|
105
|
+
const s = stdout.toLowerCase();
|
|
106
|
+
const usedConsole = /catalog_workflow_runner|workflow:\s|\[pass\s*\]|\[fail\s*\]|\bobservable\b/.test(s);
|
|
107
|
+
const usedApi = /xcsh_api|\bpost \/api|\bput \/api|api call/.test(s);
|
|
108
|
+
if (usedConsole && !usedApi) return "console";
|
|
109
|
+
if (usedApi && !usedConsole) return "api";
|
|
110
|
+
if (usedConsole && usedApi) return "console"; // console drove it even if it also read via API
|
|
111
|
+
if (/```(hcl|terraform)|resource\s+"f5xc_/.test(stdout)) return "file";
|
|
112
|
+
return "unknown";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Parse the catalog_workflow_runner step table from xcsh stdout. */
|
|
116
|
+
function workflowReportedPass(stdout: string): boolean | null {
|
|
117
|
+
const m = stdout.match(/Status:\s*(PASS|FAIL)/i);
|
|
118
|
+
if (m) return m[1]!.toUpperCase() === "PASS";
|
|
119
|
+
if (/\[FAIL\s*\]/.test(stdout)) return false;
|
|
120
|
+
if (/\[PASS\s*\]/.test(stdout)) return true;
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Console phrase shape (from console-phrases.yaml)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
export interface ConsolePhrase {
|
|
129
|
+
id: string;
|
|
130
|
+
phrase: string;
|
|
131
|
+
operation: "create" | "delete";
|
|
132
|
+
console_resource: string;
|
|
133
|
+
verify: {
|
|
134
|
+
api_path: string;
|
|
135
|
+
resource_name: string;
|
|
136
|
+
namespace: string;
|
|
137
|
+
expect_http_create?: number;
|
|
138
|
+
expect_http_delete?: number;
|
|
139
|
+
ui_readback?: string;
|
|
140
|
+
};
|
|
141
|
+
nested?: Array<{ console_resource: string; api_path: string; resource_name: string }>;
|
|
142
|
+
cleanup?: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Modality runners
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export async function runConsole(p: ConsolePhrase, cfg: MatrixConfig): Promise<Cell> {
|
|
150
|
+
const start = performance.now();
|
|
151
|
+
const shotDir = path.join(cfg.reportDir, "screenshots", p.id);
|
|
152
|
+
fs.mkdirSync(shotDir, { recursive: true });
|
|
153
|
+
|
|
154
|
+
const guardrail = cfg.strictNl
|
|
155
|
+
? "" // strict-NL: no guardrail, measure raw routing
|
|
156
|
+
: [
|
|
157
|
+
"You are executing a console UAT step against the F5 XC web console.",
|
|
158
|
+
"You MUST perform this operation in the console using the catalog_workflow_runner tool —",
|
|
159
|
+
"do NOT substitute the xcsh_api tool. When you call catalog_workflow_runner, set",
|
|
160
|
+
`observable: ${cfg.observable}, observable_delay_ms: ${cfg.delayMs}, screenshot_dir: "${shotDir}",`,
|
|
161
|
+
`and pass namespace: "${cfg.consoleNamespace}" in params. Use the exact resource names in the request.`,
|
|
162
|
+
].join(" ");
|
|
163
|
+
|
|
164
|
+
const args = ["--print", "--no-session"];
|
|
165
|
+
if (guardrail) args.push("--append-system-prompt", guardrail);
|
|
166
|
+
args.push(p.phrase);
|
|
167
|
+
|
|
168
|
+
const run = await runXcsh(args, {
|
|
169
|
+
timeoutMs: cfg.cellTimeoutMs,
|
|
170
|
+
bin: cfg.xcshBin,
|
|
171
|
+
env: { ...process.env, F5XC_API_URL: cfg.apiUrl },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const routedTo = detectRouted(run.stdout);
|
|
175
|
+
const uiPass = workflowReportedPass(run.stdout);
|
|
176
|
+
|
|
177
|
+
// API verification (authoritative): parent + every nested child.
|
|
178
|
+
const ns = p.verify.namespace;
|
|
179
|
+
const targets = [
|
|
180
|
+
{ api_path: p.verify.api_path, name: p.verify.resource_name },
|
|
181
|
+
...(p.nested ?? []).map(n => ({ api_path: n.api_path, name: n.resource_name })),
|
|
182
|
+
];
|
|
183
|
+
const codes: Record<string, string> = {};
|
|
184
|
+
let apiOk = true;
|
|
185
|
+
for (const t of targets) {
|
|
186
|
+
const code = await apiGet(`/api/config/namespaces/${ns}/${t.api_path}/${t.name}`, cfg);
|
|
187
|
+
codes[t.name] = code;
|
|
188
|
+
if (p.operation === "create" && code !== String(p.verify.expect_http_create ?? 200)) apiOk = false;
|
|
189
|
+
if (p.operation === "delete" && !(code === String(p.verify.expect_http_delete ?? 404) || code === "000"))
|
|
190
|
+
apiOk = false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const screenshots = fs.existsSync(shotDir)
|
|
194
|
+
? fs
|
|
195
|
+
.readdirSync(shotDir)
|
|
196
|
+
.filter(f => f.endsWith(".png"))
|
|
197
|
+
.map(f => path.join(shotDir, f))
|
|
198
|
+
: [];
|
|
199
|
+
|
|
200
|
+
let status: Cell["status"] = "PASS";
|
|
201
|
+
const notes: string[] = [];
|
|
202
|
+
if (run.timedOut) {
|
|
203
|
+
status = "FAIL";
|
|
204
|
+
notes.push(`timed out after ${cfg.cellTimeoutMs}ms`);
|
|
205
|
+
}
|
|
206
|
+
if (!apiOk) {
|
|
207
|
+
status = "FAIL";
|
|
208
|
+
notes.push(`API codes ${JSON.stringify(codes)} (expected ${p.operation})`);
|
|
209
|
+
}
|
|
210
|
+
if (uiPass === false) {
|
|
211
|
+
// UI said fail but API ok → divergence
|
|
212
|
+
if (apiOk) notes.push("UI/API divergence: workflow reported FAIL but API matches");
|
|
213
|
+
else {
|
|
214
|
+
status = "FAIL";
|
|
215
|
+
notes.push("workflow reported FAIL");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (routedTo === "api") notes.push("router leak: agent used the API, not the console");
|
|
219
|
+
|
|
220
|
+
const parentCode = codes[p.verify.resource_name];
|
|
221
|
+
return {
|
|
222
|
+
modality: "console",
|
|
223
|
+
id: p.id,
|
|
224
|
+
phrase: p.phrase,
|
|
225
|
+
operation: p.operation,
|
|
226
|
+
resource: p.console_resource,
|
|
227
|
+
status,
|
|
228
|
+
durationMs: performance.now() - start,
|
|
229
|
+
detail: notes.join("; ") || `console ${p.operation} ok (${routedTo})`,
|
|
230
|
+
screenshots,
|
|
231
|
+
httpCreate: p.operation === "create" ? parentCode : undefined,
|
|
232
|
+
httpDelete: p.operation === "delete" ? parentCode : undefined,
|
|
233
|
+
routedTo,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface CrudPhrase {
|
|
238
|
+
phrase: string;
|
|
239
|
+
operation: string;
|
|
240
|
+
resource: string;
|
|
241
|
+
resource_name: string;
|
|
242
|
+
api_path: string;
|
|
243
|
+
namespace_scoped?: boolean;
|
|
244
|
+
api_namespace?: string;
|
|
245
|
+
skip_crud_test?: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function runJson(p: CrudPhrase, cfg: MatrixConfig): Promise<Cell> {
|
|
249
|
+
const start = performance.now();
|
|
250
|
+
if (p.skip_crud_test) {
|
|
251
|
+
return {
|
|
252
|
+
modality: "json",
|
|
253
|
+
id: `J-${p.resource}-${p.operation}`,
|
|
254
|
+
phrase: p.phrase,
|
|
255
|
+
operation: p.operation,
|
|
256
|
+
resource: p.resource,
|
|
257
|
+
status: "SKIP",
|
|
258
|
+
durationMs: 0,
|
|
259
|
+
detail: "skip_crud_test=true",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const ns = p.api_namespace || "r-mordasiewicz";
|
|
263
|
+
const verifyPath =
|
|
264
|
+
p.namespace_scoped === false
|
|
265
|
+
? `/api/web/namespaces/${p.resource_name}`
|
|
266
|
+
: `/api/config/namespaces/${ns}/${p.api_path}/${p.resource_name}`;
|
|
267
|
+
|
|
268
|
+
const run = await runXcsh(["--print", "--no-session", p.phrase], {
|
|
269
|
+
timeoutMs: 120_000,
|
|
270
|
+
bin: cfg.xcshBin,
|
|
271
|
+
env: { ...process.env, F5XC_API_URL: cfg.apiUrl, F5XC_API_TOKEN: cfg.apiToken },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
let code = await apiGet(verifyPath, cfg);
|
|
275
|
+
let ok = false;
|
|
276
|
+
if (p.operation === "create" || p.operation === "update" || p.operation === "read") {
|
|
277
|
+
ok = code === "200";
|
|
278
|
+
} else if (p.operation === "delete") {
|
|
279
|
+
await sleep(2000);
|
|
280
|
+
code = await apiGet(verifyPath, cfg);
|
|
281
|
+
ok = code === "404" || code === "000";
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
modality: "json",
|
|
285
|
+
id: `J-${p.resource}-${p.operation}`,
|
|
286
|
+
phrase: p.phrase,
|
|
287
|
+
operation: p.operation,
|
|
288
|
+
resource: p.resource,
|
|
289
|
+
status: run.timedOut ? "FAIL" : ok ? "PASS" : "FAIL",
|
|
290
|
+
durationMs: performance.now() - start,
|
|
291
|
+
detail: run.timedOut ? "timed out" : `http=${code}`,
|
|
292
|
+
httpCreate: p.operation !== "delete" ? code : undefined,
|
|
293
|
+
httpDelete: p.operation === "delete" ? code : undefined,
|
|
294
|
+
routedTo: detectRouted(run.stdout),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface TfPhrase {
|
|
299
|
+
phrase: string;
|
|
300
|
+
operation: string;
|
|
301
|
+
expect_resource?: string;
|
|
302
|
+
expect_fields?: string[];
|
|
303
|
+
expect_command?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function extractHcl(stdout: string, workdir: string): string {
|
|
307
|
+
// 1) any *.tf files xcsh wrote
|
|
308
|
+
let hcl = "";
|
|
309
|
+
try {
|
|
310
|
+
for (const f of fs.readdirSync(workdir)) {
|
|
311
|
+
if (f.endsWith(".tf")) hcl += `\n${fs.readFileSync(path.join(workdir, f), "utf8")}`;
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
/* no workdir files */
|
|
315
|
+
}
|
|
316
|
+
// 2) fenced ```hcl / ```terraform blocks in stdout
|
|
317
|
+
const fence = /```(?:hcl|terraform|tf)?\s*([\s\S]*?)```/gi;
|
|
318
|
+
let m: RegExpExecArray | null;
|
|
319
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
|
|
320
|
+
while ((m = fence.exec(stdout)) !== null) {
|
|
321
|
+
if (/resource\s+"f5xc_|provider\s+"f5xc"|terraform\s*\{/.test(m[1]!)) hcl += `\n${m[1]}`;
|
|
322
|
+
}
|
|
323
|
+
return hcl.trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function terraformAvailable(): Promise<boolean> {
|
|
327
|
+
const r = await runXcsh(["version"], { timeoutMs: 10_000, bin: "terraform" });
|
|
328
|
+
return r.code === 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function runHcl(p: TfPhrase, cfg: MatrixConfig, tfOk: boolean): Promise<Cell> {
|
|
332
|
+
const start = performance.now();
|
|
333
|
+
const id = `H-${(p.expect_resource ?? p.operation).replace(/^f5xc_/, "")}-${p.operation}`;
|
|
334
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "uat-hcl-"));
|
|
335
|
+
try {
|
|
336
|
+
const run = await runXcsh(["--print", "--no-session", p.phrase], {
|
|
337
|
+
timeoutMs: 120_000,
|
|
338
|
+
bin: cfg.xcshBin,
|
|
339
|
+
cwd: workdir,
|
|
340
|
+
env: { ...process.env, F5XC_API_URL: cfg.apiUrl },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// expect_command phrases (plan/destroy explanations): keyword presence only.
|
|
344
|
+
if (p.expect_command && !p.expect_resource) {
|
|
345
|
+
const ok = run.stdout.toLowerCase().includes(p.expect_command.toLowerCase());
|
|
346
|
+
return cell(ok, run, `expect_command "${p.expect_command}"`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const hcl = extractHcl(run.stdout, workdir);
|
|
350
|
+
const fieldsPresent =
|
|
351
|
+
!!p.expect_resource && hcl.includes(p.expect_resource) && (p.expect_fields ?? []).every(f => hcl.includes(f));
|
|
352
|
+
|
|
353
|
+
let detail = `keyword presence: ${fieldsPresent ? "ok" : "missing fields/resource"}`;
|
|
354
|
+
let ok = fieldsPresent;
|
|
355
|
+
|
|
356
|
+
// Authoritative terraform validate when available + we have HCL.
|
|
357
|
+
if (tfOk && hcl) {
|
|
358
|
+
fs.writeFileSync(path.join(workdir, "main.tf"), hcl);
|
|
359
|
+
const fmt = await runXcsh(["fmt", "-check", "-list=false"], {
|
|
360
|
+
timeoutMs: 30_000,
|
|
361
|
+
bin: "terraform",
|
|
362
|
+
cwd: workdir,
|
|
363
|
+
});
|
|
364
|
+
const init = await runXcsh(["init", "-backend=false", "-input=false", "-no-color"], {
|
|
365
|
+
timeoutMs: 120_000,
|
|
366
|
+
bin: "terraform",
|
|
367
|
+
cwd: workdir,
|
|
368
|
+
});
|
|
369
|
+
if (init.code === 0) {
|
|
370
|
+
const val = await runXcsh(["validate", "-no-color"], { timeoutMs: 60_000, bin: "terraform", cwd: workdir });
|
|
371
|
+
ok = val.code === 0 && fieldsPresent;
|
|
372
|
+
detail = `terraform validate=${val.code === 0 ? "ok" : "fail"}, fmt=${fmt.code === 0 ? "ok" : "warn"}, fields=${fieldsPresent}`;
|
|
373
|
+
if (val.code !== 0) detail += `: ${val.stderr.slice(0, 120)}`;
|
|
374
|
+
} else {
|
|
375
|
+
detail = `terraform init failed (provider unavailable?); fell back to keyword presence=${fieldsPresent}`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return cell(ok, run, detail);
|
|
379
|
+
} finally {
|
|
380
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function cell(ok: boolean, run: XcshRun, detail: string): Cell {
|
|
384
|
+
return {
|
|
385
|
+
modality: "hcl",
|
|
386
|
+
id,
|
|
387
|
+
phrase: p.phrase,
|
|
388
|
+
operation: p.operation,
|
|
389
|
+
resource: p.expect_resource ?? "(command)",
|
|
390
|
+
status: run.timedOut ? "FAIL" : ok ? "PASS" : "FAIL",
|
|
391
|
+
durationMs: performance.now() - start,
|
|
392
|
+
detail: run.timedOut ? "timed out" : detail,
|
|
393
|
+
routedTo: "file",
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export interface I18nPhrase {
|
|
399
|
+
id: string;
|
|
400
|
+
resource_name: string;
|
|
401
|
+
expected_resource_type: string;
|
|
402
|
+
expected_fields: string[];
|
|
403
|
+
phrase_en: string;
|
|
404
|
+
translations: Record<string, string>;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function runI18n(p: I18nPhrase, locale: string, cfg: MatrixConfig): Promise<Cell> {
|
|
408
|
+
const start = performance.now();
|
|
409
|
+
const phrase = locale === "en" ? p.phrase_en : p.translations[locale];
|
|
410
|
+
const id = `${p.id}-${locale}`;
|
|
411
|
+
if (!phrase) {
|
|
412
|
+
return {
|
|
413
|
+
modality: "i18n",
|
|
414
|
+
id,
|
|
415
|
+
phrase: `(missing ${locale})`,
|
|
416
|
+
operation: "create",
|
|
417
|
+
resource: p.expected_resource_type,
|
|
418
|
+
locale,
|
|
419
|
+
status: "SKIP",
|
|
420
|
+
durationMs: 0,
|
|
421
|
+
detail: `no ${locale} translation`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "uat-i18n-"));
|
|
425
|
+
try {
|
|
426
|
+
const run = await runXcsh(["--print", "--no-session", phrase], {
|
|
427
|
+
timeoutMs: 120_000,
|
|
428
|
+
bin: cfg.xcshBin,
|
|
429
|
+
cwd: workdir,
|
|
430
|
+
env: { ...process.env, F5XC_API_URL: cfg.apiUrl },
|
|
431
|
+
});
|
|
432
|
+
const hcl = extractHcl(run.stdout, workdir);
|
|
433
|
+
const ok = hcl.includes(p.expected_resource_type) && p.expected_fields.every(f => hcl.includes(f));
|
|
434
|
+
return {
|
|
435
|
+
modality: "i18n",
|
|
436
|
+
id,
|
|
437
|
+
phrase,
|
|
438
|
+
operation: "create",
|
|
439
|
+
resource: p.expected_resource_type,
|
|
440
|
+
locale,
|
|
441
|
+
status: run.timedOut ? "FAIL" : ok ? "PASS" : "FAIL",
|
|
442
|
+
durationMs: performance.now() - start,
|
|
443
|
+
detail: run.timedOut ? "timed out" : ok ? "resource+fields present" : "missing resource/fields in HCL",
|
|
444
|
+
routedTo: "file",
|
|
445
|
+
};
|
|
446
|
+
} finally {
|
|
447
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export { terraformAvailable };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT matrix — shared cell type + report writer (markdown + JSON).
|
|
3
|
+
*
|
|
4
|
+
* The matrix is the deliverable: rows = (modality × resource × operation ×
|
|
5
|
+
* phrase-id), grouped by modality then resource, with per-modality pass rates
|
|
6
|
+
* (reusing the autoresearch METRIC vocabulary) and an ASI failures block.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
export type Modality = "console" | "json" | "hcl" | "i18n";
|
|
12
|
+
export type CellStatus = "PASS" | "FAIL" | "SKIP";
|
|
13
|
+
|
|
14
|
+
export interface Cell {
|
|
15
|
+
modality: Modality;
|
|
16
|
+
id: string;
|
|
17
|
+
phrase: string;
|
|
18
|
+
operation: string;
|
|
19
|
+
resource: string;
|
|
20
|
+
locale?: string;
|
|
21
|
+
status: CellStatus;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
detail: string;
|
|
24
|
+
screenshots?: string[];
|
|
25
|
+
httpCreate?: string;
|
|
26
|
+
httpDelete?: string;
|
|
27
|
+
/** For console cells: which path the agent actually used (router-determinism). */
|
|
28
|
+
routedTo?: "console" | "api" | "file" | "unknown";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pct(n: number, d: number): number {
|
|
32
|
+
return d === 0 ? 0 : Math.round((n / d) * 1000) / 10;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fmtDur(ms: number): string {
|
|
36
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function summarize(cells: Cell[]) {
|
|
40
|
+
const byModality: Record<string, { total: number; pass: number; fail: number; skip: number }> = {};
|
|
41
|
+
for (const c of cells) {
|
|
42
|
+
if (!byModality[c.modality]) byModality[c.modality] = { total: 0, pass: 0, fail: 0, skip: 0 };
|
|
43
|
+
const m = byModality[c.modality]!;
|
|
44
|
+
m.total++;
|
|
45
|
+
if (c.status === "PASS") m.pass++;
|
|
46
|
+
else if (c.status === "FAIL") m.fail++;
|
|
47
|
+
else m.skip++;
|
|
48
|
+
}
|
|
49
|
+
const total = cells.length;
|
|
50
|
+
const pass = cells.filter(c => c.status === "PASS").length;
|
|
51
|
+
const fail = cells.filter(c => c.status === "FAIL").length;
|
|
52
|
+
const skip = cells.filter(c => c.status === "SKIP").length;
|
|
53
|
+
return { total, pass, fail, skip, byModality };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Router-determinism for console cells: fraction that actually used the console. */
|
|
57
|
+
function routerStats(cells: Cell[]) {
|
|
58
|
+
const console_ = cells.filter(c => c.modality === "console" && c.routedTo);
|
|
59
|
+
const routed = console_.filter(c => c.routedTo === "console").length;
|
|
60
|
+
return { measured: console_.length, routedToConsole: routed, rate: pct(routed, console_.length) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ReportContext {
|
|
64
|
+
startedAt: string;
|
|
65
|
+
finishedAt: string;
|
|
66
|
+
tenant: string;
|
|
67
|
+
consoleNamespace: string;
|
|
68
|
+
apiUrl: string;
|
|
69
|
+
modalities: Modality[];
|
|
70
|
+
observable: boolean;
|
|
71
|
+
delayMs: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function writeReport(
|
|
75
|
+
cells: Cell[],
|
|
76
|
+
reportDir: string,
|
|
77
|
+
ctx: ReportContext,
|
|
78
|
+
): { mdPath: string; jsonPath: string } {
|
|
79
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
80
|
+
const summary = summarize(cells);
|
|
81
|
+
const router = routerStats(cells);
|
|
82
|
+
|
|
83
|
+
// --- JSON ---
|
|
84
|
+
const jsonPath = path.join(reportDir, "report.json");
|
|
85
|
+
fs.writeFileSync(jsonPath, JSON.stringify({ ...ctx, summary, router, cells }, null, 2));
|
|
86
|
+
|
|
87
|
+
// --- Markdown ---
|
|
88
|
+
const L: string[] = [];
|
|
89
|
+
L.push(`# F5 XC Console Agent — UAT Matrix Report`, "");
|
|
90
|
+
L.push(`- Started: ${ctx.startedAt}`);
|
|
91
|
+
L.push(`- Finished: ${ctx.finishedAt}`);
|
|
92
|
+
L.push(`- Tenant: \`${ctx.tenant}\` | Console namespace: \`${ctx.consoleNamespace}\` | API: \`${ctx.apiUrl}\``);
|
|
93
|
+
L.push(
|
|
94
|
+
`- Modalities: ${ctx.modalities.join(", ")} | Observable: ${ctx.observable} | Step delay: ${ctx.delayMs}ms`,
|
|
95
|
+
);
|
|
96
|
+
L.push("");
|
|
97
|
+
L.push(`## Summary`, "");
|
|
98
|
+
L.push(`✅ PASS ${summary.pass} · ❌ FAIL ${summary.fail} · ⏭ SKIP ${summary.skip} · total ${summary.total}`, "");
|
|
99
|
+
L.push(`| Modality | Total | Pass | Fail | Skip | Pass rate |`);
|
|
100
|
+
L.push(`|---|---:|---:|---:|---:|---:|`);
|
|
101
|
+
for (const [m, s] of Object.entries(summary.byModality)) {
|
|
102
|
+
L.push(`| ${m} | ${s.total} | ${s.pass} | ${s.fail} | ${s.skip} | ${pct(s.pass, s.total - s.skip)}% |`);
|
|
103
|
+
}
|
|
104
|
+
L.push("");
|
|
105
|
+
|
|
106
|
+
// METRIC lines (autoresearch vocabulary, for comparability)
|
|
107
|
+
L.push("```");
|
|
108
|
+
for (const [m, s] of Object.entries(summary.byModality)) {
|
|
109
|
+
L.push(`METRIC ${m}_pass_rate=${pct(s.pass, s.total - s.skip)}`);
|
|
110
|
+
}
|
|
111
|
+
if (router.measured > 0) L.push(`METRIC console_router_accuracy=${router.rate}`);
|
|
112
|
+
L.push("```", "");
|
|
113
|
+
|
|
114
|
+
// --- Matrix, grouped by modality then resource ---
|
|
115
|
+
L.push(`## Matrix`, "");
|
|
116
|
+
const mods = [...new Set(cells.map(c => c.modality))];
|
|
117
|
+
for (const mod of mods) {
|
|
118
|
+
L.push(`### ${mod}`, "");
|
|
119
|
+
L.push(`| Phrase id | Resource | Op | Status | Duration | HTTP (C/D) | Routed | Detail |`);
|
|
120
|
+
L.push(`|---|---|---|---|---:|---|---|---|`);
|
|
121
|
+
const modCells = cells.filter(c => c.modality === mod);
|
|
122
|
+
for (const c of modCells.sort((a, b) => (a.resource + a.id).localeCompare(b.resource + b.id))) {
|
|
123
|
+
const icon = c.status === "PASS" ? "✅" : c.status === "FAIL" ? "❌" : "⏭";
|
|
124
|
+
const http = `${c.httpCreate ?? "-"}/${c.httpDelete ?? "-"}`;
|
|
125
|
+
const detail = (c.detail || "").replace(/\|/g, "\\|").slice(0, 80);
|
|
126
|
+
const loc = c.locale ? ` (${c.locale})` : "";
|
|
127
|
+
L.push(
|
|
128
|
+
`| ${c.id}${loc} | ${c.resource} | ${c.operation} | ${icon} ${c.status} | ${fmtDur(c.durationMs)} | ${http} | ${c.routedTo ?? "-"} | ${detail} |`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
L.push("");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Router determinism ---
|
|
135
|
+
if (router.measured > 0) {
|
|
136
|
+
L.push(`## Router determinism (console phrases)`, "");
|
|
137
|
+
L.push(`${router.routedToConsole}/${router.measured} console phrases drove the console UI (${router.rate}%).`);
|
|
138
|
+
const leaks = cells.filter(c => c.modality === "console" && c.routedTo === "api");
|
|
139
|
+
if (leaks.length) {
|
|
140
|
+
L.push("", "Phrases that leaked to the API instead of the console:");
|
|
141
|
+
for (const c of leaks) L.push(`- \`${c.id}\`: ${c.phrase}`);
|
|
142
|
+
}
|
|
143
|
+
L.push("");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- ASI failures (autoresearch-style) ---
|
|
147
|
+
const failures = cells.filter(c => c.status === "FAIL");
|
|
148
|
+
L.push(`## Failures`, "");
|
|
149
|
+
if (failures.length === 0) {
|
|
150
|
+
L.push("None. 🎉", "");
|
|
151
|
+
} else {
|
|
152
|
+
for (const c of failures) {
|
|
153
|
+
L.push(`- **${c.id}** (${c.modality}/${c.operation}/${c.resource}): ${c.detail}`);
|
|
154
|
+
if (c.screenshots?.length) L.push(` - screenshots: ${c.screenshots.map(s => `\`${s}\``).join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
L.push("");
|
|
157
|
+
const asi = failures.map(c => ({
|
|
158
|
+
id: c.id,
|
|
159
|
+
modality: c.modality,
|
|
160
|
+
operation: c.operation,
|
|
161
|
+
resource: c.resource,
|
|
162
|
+
http_create: c.httpCreate,
|
|
163
|
+
http_delete: c.httpDelete,
|
|
164
|
+
routed_to: c.routedTo,
|
|
165
|
+
detail: c.detail.slice(0, 160),
|
|
166
|
+
}));
|
|
167
|
+
L.push("```");
|
|
168
|
+
L.push(`ASI failures=${JSON.stringify(asi)}`);
|
|
169
|
+
L.push("```", "");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const mdPath = path.join(reportDir, "report.md");
|
|
173
|
+
fs.writeFileSync(mdPath, L.join("\n"));
|
|
174
|
+
return { mdPath, jsonPath };
|
|
175
|
+
}
|