trxa-tool 0.0.0-stage → 0.2.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/.claude/skills/replay-trxa-testplan/SKILL.md +627 -0
- package/LICENSE +120 -0
- package/MANUAL.md +556 -0
- package/README.md +91 -3
- package/SETUP.md +401 -0
- package/STUDIO.md +427 -0
- package/dist/analyze.d.ts +58 -0
- package/dist/analyze.js +122 -0
- package/dist/bpm.d.ts +40 -0
- package/dist/bpm.js +60 -0
- package/dist/build-test-script.d.ts +24 -0
- package/dist/build-test-script.js +40 -0
- package/dist/csv.d.ts +1 -0
- package/dist/csv.js +11 -0
- package/dist/extract.d.ts +14 -0
- package/dist/extract.js +34 -0
- package/dist/fixture-schema.d.ts +52 -0
- package/dist/fixture-schema.js +112 -0
- package/dist/fixture-store.d.ts +37 -0
- package/dist/fixture-store.js +75 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +850 -0
- package/dist/library-archive.d.ts +37 -0
- package/dist/library-archive.js +106 -0
- package/dist/mapping.d.ts +25 -0
- package/dist/mapping.js +393 -0
- package/dist/mcp-bridge.d.ts +52 -0
- package/dist/mcp-bridge.js +290 -0
- package/dist/normalize.d.ts +21 -0
- package/dist/normalize.js +43 -0
- package/dist/parse.d.ts +3 -0
- package/dist/parse.js +233 -0
- package/dist/plan-runner.d.ts +27 -0
- package/dist/plan-runner.js +36 -0
- package/dist/plan-schema.d.ts +27 -0
- package/dist/plan-schema.js +45 -0
- package/dist/planrun-schema.d.ts +53 -0
- package/dist/planrun-schema.js +111 -0
- package/dist/planrun.d.ts +20 -0
- package/dist/planrun.js +46 -0
- package/dist/report-schema.d.ts +92 -0
- package/dist/report-schema.js +115 -0
- package/dist/report.d.ts +45 -0
- package/dist/report.js +77 -0
- package/dist/schema.d.ts +96 -0
- package/dist/schema.js +10 -0
- package/dist/scrub.d.ts +44 -0
- package/dist/scrub.js +166 -0
- package/dist/suggest-inputs.d.ts +19 -0
- package/dist/suggest-inputs.js +92 -0
- package/dist/suite-runner.d.ts +59 -0
- package/dist/suite-runner.js +204 -0
- package/dist/suite-schema.d.ts +59 -0
- package/dist/suite-schema.js +95 -0
- package/dist/testcase-schema.d.ts +102 -0
- package/dist/testcase-schema.js +132 -0
- package/dist/token-usage.d.ts +38 -0
- package/dist/token-usage.js +95 -0
- package/media/studio/fixtures-test-cases.png +0 -0
- package/media/studio/plans-editor.png +0 -0
- package/media/studio/recordings-create-test-case.png +0 -0
- package/media/studio/run-dialog.png +0 -0
- package/media/studio/settings-agent-runs.png +0 -0
- package/media/studio/settings-working-folder.png +0 -0
- package/media/studio/studio-recordings.png +0 -0
- package/media/studio/suites-canvas.png +0 -0
- package/media/studio/suites-inspector.png +0 -0
- package/media/studio/testcases-expectations.png +0 -0
- package/media/studio/testcases-outputs.png +0 -0
- package/media/studio/testrun-details.png +0 -0
- package/media/studio/testruns-list.png +0 -0
- package/package.json +62 -6
- package/studio/client-dist/assets/CreatePanel-C5bb5OIs.js +6 -0
- package/studio/client-dist/assets/FactTile-G1pURLys.js +1 -0
- package/studio/client-dist/assets/FieldLabel-DPmIEQ08.js +1 -0
- package/studio/client-dist/assets/FixtureNamePicker-137AAgwZ.js +1 -0
- package/studio/client-dist/assets/Fixtures-D0t-soph.js +1 -0
- package/studio/client-dist/assets/GeneralSection-CWkTeSX2.js +1 -0
- package/studio/client-dist/assets/ListPaneHeader-DXKqX36N.js +1 -0
- package/studio/client-dist/assets/Plans-C70evkGc.js +1 -0
- package/studio/client-dist/assets/Recordings-BT0dyVLs.js +1 -0
- package/studio/client-dist/assets/Settings-k-J3u5lH.js +1 -0
- package/studio/client-dist/assets/Suites-DLioOiRN.css +1 -0
- package/studio/client-dist/assets/Suites-DQhhMxjT.js +7 -0
- package/studio/client-dist/assets/TestCases-BdiopVGI.js +1 -0
- package/studio/client-dist/assets/TestRuns-CylAe-3t.js +9 -0
- package/studio/client-dist/assets/api-DJZ703_F.js +24 -0
- package/studio/client-dist/assets/bucket-18-viuwGcaj.js +1 -0
- package/studio/client-dist/assets/fixtureName-CTZT_jKu.js +1 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-LoaTnByX.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-RhT0Ayi3.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-Bf1n82Tb.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-D9J6q4_O.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-Bbd5v1xK.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-DqmgDkIN.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-BELOEI8R.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-CfSXsIbO.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-J-CBqZoJ.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-STc8XiDr.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-BEZwmy7D.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-CTYLy0lX.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-C9Of02-5.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-b4i7YzLG.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CZTNEAuW.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CsGl1sm0.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-B8Zgwjh3.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-BLFuR0P1.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-Du9zzoL2.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-V_9i1P_m.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-CkYRZD2a.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-SDfX6ktj.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-78jqtfZH.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-BbRnwouv.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- package/studio/client-dist/assets/index-cxBc2iRv.js +10 -0
- package/studio/client-dist/assets/index-wjWuVHXN.css +1 -0
- package/studio/client-dist/assets/status-DUGxC7G7.js +1 -0
- package/studio/client-dist/assets/testCaseTree-Cl1-bc5O.js +1 -0
- package/studio/client-dist/axtr-icon.svg +2 -0
- package/studio/client-dist/favicon.svg +2 -0
- package/studio/client-dist/index.html +15 -0
- package/studio/server/dist/index.js +3176 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
3
|
+
import { runMcpBridge } from "./mcp-bridge.js";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { loadTestScript, buildTestScript } from "./build-test-script.js";
|
|
10
|
+
import { analyzeLibrary } from "./analyze.js";
|
|
11
|
+
import { toCsv } from "./csv.js";
|
|
12
|
+
import { suggestInputs } from "./suggest-inputs.js";
|
|
13
|
+
import { validateTestCase } from "./testcase-schema.js";
|
|
14
|
+
import { isLegacyFixture, validateFixtureFile } from "./fixture-schema.js";
|
|
15
|
+
import { migrateFixtureFolder, readFixtureFolder } from "./fixture-store.js";
|
|
16
|
+
import { validateTestSuite } from "./suite-schema.js";
|
|
17
|
+
import { validateTestPlan } from "./plan-schema.js";
|
|
18
|
+
import { topologicalOrder, resolveInputs, nextStep } from "./suite-runner.js";
|
|
19
|
+
import { initTestRun, readTestRun, recordResult } from "./report.js";
|
|
20
|
+
import { initPlanRun, readPlanRun } from "./planrun.js";
|
|
21
|
+
import { aggregatePlanStatus } from "./plan-runner.js";
|
|
22
|
+
import { exportLibrary, importLibrary } from "./library-archive.js";
|
|
23
|
+
import { TERMINAL_STATUSES } from "./report-schema.js";
|
|
24
|
+
import { defaultClaudeProjectsRoot, findLatestTranscript, measureTokens } from "./token-usage.js";
|
|
25
|
+
export { loadTestScript, buildTestScript };
|
|
26
|
+
/**
|
|
27
|
+
* Assumes the compiled entry file (this file, once built) sits exactly one directory below the
|
|
28
|
+
* repo root -- true for `dist/index.js` per `tsconfig.json`'s `outDir`, but silent if that layout
|
|
29
|
+
* ever changes (e.g. a nested `dist/src/index.js`): `studio start`/`stop` and the version lookup
|
|
30
|
+
* would then resolve the wrong directory with no error, since `path.resolve` doesn't validate
|
|
31
|
+
* what it points at. Computed once here (was previously duplicated in `cmdStudio` and `main`).
|
|
32
|
+
*/
|
|
33
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
function flagValue(args, name) {
|
|
35
|
+
const i = args.indexOf(name);
|
|
36
|
+
return i >= 0 ? args[i + 1] ?? null : null;
|
|
37
|
+
}
|
|
38
|
+
/** Reads a JSON-valued flag that accepts either an inline string (e.g. `--outputs '{"a":"b"}'`) or
|
|
39
|
+
* a file path (`--outputs-file path/to.json`). The file form exists to sidestep shell-specific
|
|
40
|
+
* argv-quoting bugs, not just as a convenience for long payloads: Windows PowerShell 5.1's
|
|
41
|
+
* argument-passing layer to a native executable (node.exe isn't a cmdlet) can silently corrupt
|
|
42
|
+
* inline JSON containing quote characters and/or spaces before Node ever sees `process.argv` --
|
|
43
|
+
* see POWERSHELL-JSON-ARGS.md for two distinct reproduced failure modes and why a `--%`-plus-
|
|
44
|
+
* escaping workaround was needed before this flag existed. Throws if both forms are passed rather
|
|
45
|
+
* than silently preferring one, since that's more likely a caller mistake than an intentional
|
|
46
|
+
* choice. */
|
|
47
|
+
async function jsonFlagValue(rest, inlineFlag, fileFlag) {
|
|
48
|
+
const inline = flagValue(rest, inlineFlag);
|
|
49
|
+
const filePath = flagValue(rest, fileFlag);
|
|
50
|
+
if (inline !== null && filePath !== null) {
|
|
51
|
+
throw new Error(`Pass either ${inlineFlag} or ${fileFlag}, not both`);
|
|
52
|
+
}
|
|
53
|
+
if (filePath !== null)
|
|
54
|
+
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
55
|
+
if (inline !== null)
|
|
56
|
+
return JSON.parse(inline);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
async function outputJson(value, outPath, successMessage) {
|
|
60
|
+
const json = JSON.stringify(value, null, 2);
|
|
61
|
+
if (outPath) {
|
|
62
|
+
await writeFile(outPath, json, "utf-8");
|
|
63
|
+
console.error(successMessage);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
console.log(json);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function loadTestCaseFile(testcasesDir, testCaseId) {
|
|
70
|
+
const filePath = path.join(testcasesDir, `${testCaseId}.json`);
|
|
71
|
+
const raw = JSON.parse(await readFile(filePath, "utf-8"));
|
|
72
|
+
return validateTestCase(raw);
|
|
73
|
+
}
|
|
74
|
+
/** `--fixtures-dir` is optional at the CLI level (see `resolve-next`'s usage) -- if omitted, this
|
|
75
|
+
* function is never called at all and `fixtures` is just `[]`. A folder that doesn't exist reads
|
|
76
|
+
* as empty rather than a hard error (see fixture-store.ts): a suite that binds nothing via
|
|
77
|
+
* `fixture` kind doesn't need one to exist. The tradeoff is a mistyped `--fixtures-dir` path
|
|
78
|
+
* silently produces zero fixtures instead of an error naming the bad path -- the first symptom a
|
|
79
|
+
* reviewer sees is `suite-runner.ts`'s later, less specific "no fixture named X found". Files in
|
|
80
|
+
* the pre-2026-11 one-test-case format are merged into the fixture they name, so an unmigrated
|
|
81
|
+
* library still runs. */
|
|
82
|
+
async function loadFixtures(fixturesDir) {
|
|
83
|
+
const folder = await readFixtureFolder(fixturesDir);
|
|
84
|
+
if (folder.invalid.length > 0) {
|
|
85
|
+
throw new Error(`Invalid fixture file ${folder.invalid[0].file}: ${folder.invalid[0].error}`);
|
|
86
|
+
}
|
|
87
|
+
return folder.fixtures.map((f) => f.fixture);
|
|
88
|
+
}
|
|
89
|
+
async function loadSuite(suitePath) {
|
|
90
|
+
const raw = JSON.parse(await readFile(suitePath, "utf-8"));
|
|
91
|
+
return validateTestSuite(raw);
|
|
92
|
+
}
|
|
93
|
+
async function loadPlan(planPath) {
|
|
94
|
+
const raw = JSON.parse(await readFile(planPath, "utf-8"));
|
|
95
|
+
return validateTestPlan(raw);
|
|
96
|
+
}
|
|
97
|
+
// --- Commands ---
|
|
98
|
+
async function cmdParse(args) {
|
|
99
|
+
const [inputPath, ...rest] = args;
|
|
100
|
+
if (!inputPath) {
|
|
101
|
+
throw new Error("Usage: trxa parse <file.axtr> [-o out.testscript.json]");
|
|
102
|
+
}
|
|
103
|
+
const outPath = flagValue(rest, "-o");
|
|
104
|
+
const testScript = await buildTestScript(inputPath);
|
|
105
|
+
await outputJson(testScript, outPath, `Wrote ${testScript.steps.length} steps to ${outPath}`);
|
|
106
|
+
}
|
|
107
|
+
async function cmdSuggestInputs(args) {
|
|
108
|
+
const [inputPath, ...rest] = args;
|
|
109
|
+
if (!inputPath) {
|
|
110
|
+
throw new Error("Usage: trxa suggest-inputs <file.axtr> [-o draft.json]");
|
|
111
|
+
}
|
|
112
|
+
const outPath = flagValue(rest, "-o");
|
|
113
|
+
const script = await loadTestScript(inputPath);
|
|
114
|
+
const draft = suggestInputs(script);
|
|
115
|
+
await outputJson(draft, outPath, `Wrote ${draft.candidates.length} candidate inputs to ${outPath}`);
|
|
116
|
+
}
|
|
117
|
+
async function cmdValidateTestCase(args) {
|
|
118
|
+
const [filePath] = args;
|
|
119
|
+
if (!filePath)
|
|
120
|
+
throw new Error("Usage: trxa validate-testcase <testcase.json>");
|
|
121
|
+
const raw = JSON.parse(await readFile(filePath, "utf-8"));
|
|
122
|
+
const testCase = validateTestCase(raw);
|
|
123
|
+
console.error(`OK: "${testCase.id}" is a valid test case (${testCase.inputs.length} inputs, ${testCase.expectations.length} expectations)`);
|
|
124
|
+
}
|
|
125
|
+
async function cmdValidateFixture(args) {
|
|
126
|
+
const [filePath] = args;
|
|
127
|
+
if (!filePath)
|
|
128
|
+
throw new Error("Usage: trxa validate-fixture <fixture.json>");
|
|
129
|
+
const raw = JSON.parse(await readFile(filePath, "utf-8"));
|
|
130
|
+
const fixture = validateFixtureFile(raw);
|
|
131
|
+
const ids = Object.keys(fixture.testCases);
|
|
132
|
+
const legacy = isLegacyFixture(raw) ? " (old one test case per file format; run `trxa migrate-fixtures` to update it)" : "";
|
|
133
|
+
console.error(`OK: "${fixture.name}" is a valid fixture with values for ${ids.length} test case(s): ${ids.join(", ")}${legacy}`);
|
|
134
|
+
}
|
|
135
|
+
async function cmdMigrateFixtures(args) {
|
|
136
|
+
const [dir, ...rest] = args;
|
|
137
|
+
if (!dir)
|
|
138
|
+
throw new Error("Usage: trxa migrate-fixtures <testdata-dir> [--dry-run]");
|
|
139
|
+
const dryRun = rest.includes("--dry-run");
|
|
140
|
+
const result = await migrateFixtureFolder(dir, { dryRun });
|
|
141
|
+
if (result.written.length === 0) {
|
|
142
|
+
console.error("Nothing to migrate: every fixture is already one file named after it.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const verb = dryRun ? "Would write" : "Wrote";
|
|
146
|
+
for (const w of result.written)
|
|
147
|
+
console.error(`${verb} ${w.file} (fixture "${w.fixture}") from ${w.from.join(", ")}`);
|
|
148
|
+
if (result.removed.length > 0)
|
|
149
|
+
console.error(`${dryRun ? "Would remove" : "Removed"}: ${result.removed.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
async function cmdValidateSuite(args) {
|
|
152
|
+
const [filePath] = args;
|
|
153
|
+
if (!filePath)
|
|
154
|
+
throw new Error("Usage: trxa validate-suite <suite.json>");
|
|
155
|
+
const suite = await loadSuite(filePath);
|
|
156
|
+
const order = topologicalOrder(suite); // also catches cycles/unknown dependsOn ids
|
|
157
|
+
console.error(`OK: "${suite.id}" is a valid suite (${order.length} entries, execution order: ${order.join(" -> ")})`);
|
|
158
|
+
}
|
|
159
|
+
async function cmdPlanSuite(args) {
|
|
160
|
+
const [suitePath] = args;
|
|
161
|
+
if (!suitePath)
|
|
162
|
+
throw new Error("Usage: trxa plan-suite <suite.json>");
|
|
163
|
+
const suite = await loadSuite(suitePath);
|
|
164
|
+
const order = topologicalOrder(suite);
|
|
165
|
+
console.log(JSON.stringify({ suiteId: suite.id, executionOrder: order }, null, 2));
|
|
166
|
+
}
|
|
167
|
+
/** Relative to `REPO_ROOT` (where the bundled copy ships from -- see package.json's `files`). The
|
|
168
|
+
* canonical source lives under the Claude Code path since that's this repo's own dev-time
|
|
169
|
+
* convention too -- both agent tools' SKILL.md format (frontmatter `name`/`description`) is
|
|
170
|
+
* identical, only the containing directory each tool scans differs, so one source file gets
|
|
171
|
+
* copied verbatim to every target below rather than maintained twice. */
|
|
172
|
+
const SKILL_SOURCE_RELATIVE_PATH = path.join(".claude", "skills", "replay-trxa-testplan", "SKILL.md");
|
|
173
|
+
/** Every location an agent tool discovers a project-scoped skill from, not from anywhere inside an
|
|
174
|
+
* installed npm package's own `node_modules` -- so shipping the file in the package alone doesn't
|
|
175
|
+
* make any of these tools find it. This command bridges that gap explicitly, same "customer says
|
|
176
|
+
* where, nothing writes outside their project on its own" philosophy as Studio's working-folder
|
|
177
|
+
* setting. Add an entry here, not a new command, if another tool's convention needs supporting. */
|
|
178
|
+
const SKILL_INSTALL_TARGETS = [
|
|
179
|
+
{ tool: "Claude Code", relativePath: path.join(".claude", "skills", "replay-trxa-testplan", "SKILL.md") },
|
|
180
|
+
{ tool: "Codex", relativePath: path.join(".agents", "skills", "replay-trxa-testplan", "SKILL.md") },
|
|
181
|
+
];
|
|
182
|
+
async function cmdInitSkill(args) {
|
|
183
|
+
const force = args.includes("--force");
|
|
184
|
+
const sourcePath = path.join(REPO_ROOT, SKILL_SOURCE_RELATIVE_PATH);
|
|
185
|
+
if (!existsSync(sourcePath)) {
|
|
186
|
+
throw new Error(`Bundled skill file not found at ${sourcePath} -- this install may be missing it.`);
|
|
187
|
+
}
|
|
188
|
+
const alreadyExisting = SKILL_INSTALL_TARGETS.filter((t) => existsSync(path.join(process.cwd(), t.relativePath)));
|
|
189
|
+
if (alreadyExisting.length > 0 && !force) {
|
|
190
|
+
throw new Error(`Refusing to overwrite existing file(s): ${alreadyExisting.map((t) => t.relativePath).join(", ")} ` +
|
|
191
|
+
`(pass --force to overwrite)`);
|
|
192
|
+
}
|
|
193
|
+
const content = await readFile(sourcePath, "utf-8");
|
|
194
|
+
for (const target of SKILL_INSTALL_TARGETS) {
|
|
195
|
+
const destPath = path.join(process.cwd(), target.relativePath);
|
|
196
|
+
await mkdir(path.dirname(destPath), { recursive: true });
|
|
197
|
+
await writeFile(destPath, content, "utf-8");
|
|
198
|
+
}
|
|
199
|
+
console.error(`Installed the replay-trxa-testplan skill for ${SKILL_INSTALL_TARGETS.map((t) => t.tool).join(" and ")} ` +
|
|
200
|
+
`(${SKILL_INSTALL_TARGETS.map((t) => t.relativePath).join(", ")}) -- each tool will pick it up ` +
|
|
201
|
+
`automatically the next time it runs in this project.`);
|
|
202
|
+
}
|
|
203
|
+
/** Started by an agent CLI as its `d365-erp-mcp` server; settings come from environment variables. */
|
|
204
|
+
async function cmdMcpBridge() {
|
|
205
|
+
try {
|
|
206
|
+
await runMcpBridge(process.env);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
// The agent shows this in its MCP server log; a stack trace would bury the setting to fix.
|
|
210
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function cmdInitRun(args) {
|
|
215
|
+
const [suitePath, ...rest] = args;
|
|
216
|
+
const outPath = flagValue(rest, "-o");
|
|
217
|
+
if (!suitePath || !outPath) {
|
|
218
|
+
throw new Error('Usage: trxa init-run <suite.json> -o testruns/<suiteId>-<runId>.json [--name "<run name>"]');
|
|
219
|
+
}
|
|
220
|
+
const suite = await loadSuite(suitePath);
|
|
221
|
+
const name = flagValue(rest, "--name") ?? undefined;
|
|
222
|
+
const testRun = await initTestRun(suite, outPath, { name });
|
|
223
|
+
console.error(`Initialized test run for suite "${suite.id}" (${Object.keys(testRun.results).length} test cases, all pending) at ${outPath}`);
|
|
224
|
+
}
|
|
225
|
+
async function cmdInitPlanRun(args) {
|
|
226
|
+
const [planPath, ...rest] = args;
|
|
227
|
+
const outDir = flagValue(rest, "-o");
|
|
228
|
+
if (!planPath || !outDir) {
|
|
229
|
+
throw new Error('Usage: trxa init-plan-run <plan.json> -o runs/ [--name "<run name>"]');
|
|
230
|
+
}
|
|
231
|
+
const plan = await loadPlan(planPath);
|
|
232
|
+
const planDir = path.dirname(planPath);
|
|
233
|
+
const suites = await Promise.all(plan.suites.map(async (entry) => {
|
|
234
|
+
const suite = await loadSuite(path.resolve(planDir, entry.suitePath));
|
|
235
|
+
return { id: suite.id, name: suite.name };
|
|
236
|
+
}));
|
|
237
|
+
const name = flagValue(rest, "--name") ?? undefined;
|
|
238
|
+
const { planRun, filePath } = await initPlanRun(plan.id, suites, outDir, name);
|
|
239
|
+
console.error(`Initialized plan run "${planRun.id}" for plan "${plan.id}" at ${filePath}`);
|
|
240
|
+
console.log(JSON.stringify(planRun, null, 2));
|
|
241
|
+
}
|
|
242
|
+
async function cmdResolveNext(args) {
|
|
243
|
+
const [suitePath, testRunPath, ...rest] = args;
|
|
244
|
+
const testcasesDir = flagValue(rest, "--testcases-dir");
|
|
245
|
+
const fixturesDir = flagValue(rest, "--fixtures-dir");
|
|
246
|
+
if (!suitePath || !testRunPath || !testcasesDir) {
|
|
247
|
+
throw new Error("Usage: trxa resolve-next <suite.json> <testrun.json> --testcases-dir <dir> [--fixtures-dir <dir>]");
|
|
248
|
+
}
|
|
249
|
+
const suite = await loadSuite(suitePath);
|
|
250
|
+
const testRun = await readTestRun(testRunPath);
|
|
251
|
+
const progress = nextStep(suite, testRun);
|
|
252
|
+
if (progress.status === "newly-blocked") {
|
|
253
|
+
for (const id of progress.blockedIds) {
|
|
254
|
+
await recordResult(testRunPath, id, { status: "blocked" });
|
|
255
|
+
}
|
|
256
|
+
console.log(JSON.stringify({ status: "blocked", blockedIds: progress.blockedIds }, null, 2));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (progress.status === "newly-skipped") {
|
|
260
|
+
for (const id of progress.skippedIds) {
|
|
261
|
+
await recordResult(testRunPath, id, { status: "skipped" });
|
|
262
|
+
}
|
|
263
|
+
console.log(JSON.stringify({ status: "skipped", skippedIds: progress.skippedIds }, null, 2));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (progress.status === "waiting" || progress.status === "complete") {
|
|
267
|
+
console.log(JSON.stringify({ status: progress.status }, null, 2));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Two passes, deliberately not one: resolving a batch of two or more entries can partially fail
|
|
271
|
+
// (e.g. one entry's inputBindings are incomplete, confirmed live with a 3-way branch during
|
|
272
|
+
// development) -- if resolution and the "running" transition were interleaved in a single loop,
|
|
273
|
+
// an entry resolved earlier in the batch would already be persisted as "running" by the time a
|
|
274
|
+
// later one throws, leaving the TestRun file half-committed with no way to recover that entry
|
|
275
|
+
// (it's no longer "pending", so a retried resolve-next won't offer it again, but it never reached
|
|
276
|
+
// a terminal status either). Resolving every entry first, with no side effects, and only starting
|
|
277
|
+
// to write "running" transitions once the whole batch has resolved cleanly, keeps the single-entry
|
|
278
|
+
// case's original atomicity guarantee (a throw here means nothing gets persisted) intact even when
|
|
279
|
+
// `progress.testCaseIds` has more than one id in it.
|
|
280
|
+
const resolved = progress.testCaseIds.map((testCaseId) => {
|
|
281
|
+
const entry = suite.entries.find((e) => e.testCaseId === testCaseId);
|
|
282
|
+
return { testCaseId, entry };
|
|
283
|
+
});
|
|
284
|
+
const fixtures = fixturesDir ? await loadFixtures(fixturesDir) : [];
|
|
285
|
+
const toRun = await Promise.all(resolved.map(async ({ testCaseId, entry }) => {
|
|
286
|
+
const testCase = await loadTestCaseFile(testcasesDir, testCaseId);
|
|
287
|
+
const resolvedInputs = resolveInputs(entry, testCase, testRun, fixtures);
|
|
288
|
+
// `resolveInputs` deliberately leaves "autogenerated" params unresolved -- surface them here
|
|
289
|
+
// (with the param's own description/example as generation guidance) rather than in the pure
|
|
290
|
+
// resolver, since inventing a value is the orchestrating LLM's job, not this CLI's.
|
|
291
|
+
const autogeneratedParams = testCase.inputs
|
|
292
|
+
.filter((p) => entry.inputBindings[p.name]?.kind === "autogenerated")
|
|
293
|
+
.map((p) => ({ name: p.name, description: p.description, example: p.example }));
|
|
294
|
+
return { testCaseId, entry, testCase, resolvedInputs, autogeneratedParams };
|
|
295
|
+
}));
|
|
296
|
+
// Sequential, not Promise.all -- each recordResult call does its own read-modify-write of the
|
|
297
|
+
// same testRunPath file, so writing them concurrently would risk one overwriting another's
|
|
298
|
+
// "running" transition. By this point every entry has already resolved successfully, so this
|
|
299
|
+
// loop itself can't fail partway through for a data reason -- only a filesystem-level failure
|
|
300
|
+
// could leave it partially applied, the same exposure `record-result` already has today.
|
|
301
|
+
const runnable = [];
|
|
302
|
+
for (const { testCaseId, entry, testCase, resolvedInputs, autogeneratedParams } of toRun) {
|
|
303
|
+
await recordResult(testRunPath, testCaseId, { status: "running", inputs: resolvedInputs });
|
|
304
|
+
runnable.push({
|
|
305
|
+
testCaseId,
|
|
306
|
+
testScriptPath: testCase.testScriptPath,
|
|
307
|
+
legalEntity: entry.legalEntity,
|
|
308
|
+
resolvedInputs,
|
|
309
|
+
autogeneratedParams,
|
|
310
|
+
outputs: testCase.outputs,
|
|
311
|
+
expectations: testCase.expectations,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
console.log(JSON.stringify({ status: "runnable", runnable }, null, 2));
|
|
315
|
+
}
|
|
316
|
+
async function cmdRecordResult(args) {
|
|
317
|
+
const [testRunPath, testCaseId, ...rest] = args;
|
|
318
|
+
const status = flagValue(rest, "--status");
|
|
319
|
+
const tokensRaw = flagValue(rest, "--tokens");
|
|
320
|
+
const costRaw = flagValue(rest, "--cost");
|
|
321
|
+
if (!testRunPath || !testCaseId || !status) {
|
|
322
|
+
throw new Error('Usage: trxa record-result <testrun.json> <testCaseId> --status passed|failed|blocked ' +
|
|
323
|
+
'(--outputs \'{"name":"value"}\' | --outputs-file <path>) ' +
|
|
324
|
+
"[--assertions '[{\"description\":\"...\",\"passed\":true,\"actual\":\"...\"}]' | --assertions-file <path>] " +
|
|
325
|
+
'[--inputs \'{"name":"value"}\' | --inputs-file <path>] ' +
|
|
326
|
+
"[--tokens <number>] (auto-measured from Claude Code's session log if omitted) [--cost <usd>] " +
|
|
327
|
+
"[--messages '[\"...\"]' | --messages-file <path>]. " +
|
|
328
|
+
"The *-file flags read JSON from a file instead of argv -- prefer them on Windows PowerShell, whose " +
|
|
329
|
+
"argument-passing layer can corrupt inline JSON containing quotes/spaces (see MANUAL.md, Windows PowerShell section).");
|
|
330
|
+
}
|
|
331
|
+
const outputs = await jsonFlagValue(rest, "--outputs", "--outputs-file");
|
|
332
|
+
const assertionResults = await jsonFlagValue(rest, "--assertions", "--assertions-file");
|
|
333
|
+
const inputs = await jsonFlagValue(rest, "--inputs", "--inputs-file");
|
|
334
|
+
const costUsd = costRaw ? Number(costRaw) : undefined;
|
|
335
|
+
const messages = await jsonFlagValue(rest, "--messages", "--messages-file");
|
|
336
|
+
// Auto-measure real token usage from Claude Code's own session log when the caller didn't pass
|
|
337
|
+
// an explicit number and this result is reaching a terminal status with a known start time --
|
|
338
|
+
// no separate step for the orchestrating LLM to remember. An explicit --tokens still wins (e.g.
|
|
339
|
+
// a future execution mode with a better source), and this is skipped silently -- not an error --
|
|
340
|
+
// if no transcript can be found (see findLatestTranscript's own caveat about concurrent sessions).
|
|
341
|
+
let tokensUsed = tokensRaw ? Number(tokensRaw) : undefined;
|
|
342
|
+
if (tokensUsed === undefined && TERMINAL_STATUSES.has(status)) {
|
|
343
|
+
const existingRun = await readTestRun(testRunPath);
|
|
344
|
+
const startedAt = existingRun.results[testCaseId]?.startedAt;
|
|
345
|
+
if (startedAt) {
|
|
346
|
+
const transcriptPath = findLatestTranscript(defaultClaudeProjectsRoot());
|
|
347
|
+
if (transcriptPath) {
|
|
348
|
+
tokensUsed = measureTokens(transcriptPath, startedAt, new Date().toISOString());
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const testRun = await recordResult(testRunPath, testCaseId, {
|
|
353
|
+
status,
|
|
354
|
+
outputs,
|
|
355
|
+
assertionResults,
|
|
356
|
+
inputs,
|
|
357
|
+
tokensUsed,
|
|
358
|
+
costUsd,
|
|
359
|
+
messages,
|
|
360
|
+
});
|
|
361
|
+
console.error(`Recorded "${testCaseId}" as ${status} in ${testRunPath}`);
|
|
362
|
+
console.log(JSON.stringify(testRun.results[testCaseId], null, 2));
|
|
363
|
+
}
|
|
364
|
+
async function cmdMeasureTokens(args) {
|
|
365
|
+
const since = flagValue(args, "--since");
|
|
366
|
+
const until = flagValue(args, "--until");
|
|
367
|
+
const transcriptOverride = flagValue(args, "--transcript");
|
|
368
|
+
if (!since || !until) {
|
|
369
|
+
throw new Error("Usage: trxa measure-tokens --since <isoTimestamp> --until <isoTimestamp> [--transcript <path>]");
|
|
370
|
+
}
|
|
371
|
+
const transcriptPath = transcriptOverride ?? findLatestTranscript(defaultClaudeProjectsRoot());
|
|
372
|
+
if (!transcriptPath) {
|
|
373
|
+
console.log(JSON.stringify({ tokensUsed: null, transcriptPath: null, reason: "no Claude Code session transcript found" }, null, 2));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const tokensUsed = measureTokens(transcriptPath, since, until);
|
|
377
|
+
console.log(JSON.stringify({ tokensUsed, transcriptPath }, null, 2));
|
|
378
|
+
}
|
|
379
|
+
async function cmdValidatePlan(args) {
|
|
380
|
+
const [planPath] = args;
|
|
381
|
+
if (!planPath)
|
|
382
|
+
throw new Error("Usage: trxa validate-plan <plan.json>");
|
|
383
|
+
const plan = await loadPlan(planPath);
|
|
384
|
+
const planDir = path.dirname(planPath);
|
|
385
|
+
for (const entry of plan.suites) {
|
|
386
|
+
const resolvedSuitePath = path.resolve(planDir, entry.suitePath);
|
|
387
|
+
const suite = await loadSuite(resolvedSuitePath);
|
|
388
|
+
if (suite.id !== entry.suiteId) {
|
|
389
|
+
throw new Error(`Plan "${plan.id}": entry declares suiteId "${entry.suiteId}" but ${entry.suitePath} has id "${suite.id}"`);
|
|
390
|
+
}
|
|
391
|
+
topologicalOrder(suite); // catches cycles/unknown dependsOn ids within that suite
|
|
392
|
+
}
|
|
393
|
+
console.error(`OK: "${plan.id}" is a valid plan (${plan.suites.length} suites, all resolve and validate)`);
|
|
394
|
+
}
|
|
395
|
+
async function cmdPlanStatus(args) {
|
|
396
|
+
const [planPath, runPath, ...rest] = args;
|
|
397
|
+
const testrunsDir = flagValue(rest, "--testruns-dir");
|
|
398
|
+
if (!planPath || !runPath || !testrunsDir) {
|
|
399
|
+
throw new Error("Usage: trxa plan-status <plan.json> <run.json> --testruns-dir <dir>");
|
|
400
|
+
}
|
|
401
|
+
const plan = await loadPlan(planPath);
|
|
402
|
+
const run = await readPlanRun(runPath);
|
|
403
|
+
if (run.planId !== plan.id) {
|
|
404
|
+
throw new Error(`Plan run at ${runPath} belongs to plan "${run.planId}", not "${plan.id}"`);
|
|
405
|
+
}
|
|
406
|
+
const testRuns = new Map();
|
|
407
|
+
for (const suiteId of run.suiteIds) {
|
|
408
|
+
const testRunPath = path.join(testrunsDir, `${suiteId}-${run.id}.json`);
|
|
409
|
+
try {
|
|
410
|
+
testRuns.set(suiteId, await readTestRun(testRunPath));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
testRuns.set(suiteId, null);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const status = aggregatePlanStatus(run, testRuns);
|
|
417
|
+
console.log(JSON.stringify({ ...status, startedAt: run.startedAt }, null, 2));
|
|
418
|
+
}
|
|
419
|
+
async function cmdAnalyzeLibrary(args) {
|
|
420
|
+
const [recordingsDir, ...rest] = args;
|
|
421
|
+
if (!recordingsDir) {
|
|
422
|
+
throw new Error("Usage: trxa analyze-library <recordingsDir> [--report analysis/library-report.json] [--csv analysis/scrubbed-values.csv]");
|
|
423
|
+
}
|
|
424
|
+
const reportPath = flagValue(rest, "--report") ?? "analysis/library-report.json";
|
|
425
|
+
const csvPath = flagValue(rest, "--csv") ?? "analysis/scrubbed-values.csv";
|
|
426
|
+
const { report, scrubbedValues } = await analyzeLibrary(recordingsDir);
|
|
427
|
+
await mkdir(path.dirname(reportPath), { recursive: true });
|
|
428
|
+
await mkdir(path.dirname(csvPath), { recursive: true });
|
|
429
|
+
await writeFile(reportPath, JSON.stringify(report, null, 2), "utf-8");
|
|
430
|
+
await writeFile(csvPath, toCsv(["recordingFile", "recordingName", "nodeKind", "nodeId", "globalId", "sequence", "fieldPath", "token", "originalValue"], scrubbedValues.map((v) => [
|
|
431
|
+
v.recordingFile,
|
|
432
|
+
v.recordingName,
|
|
433
|
+
v.nodeKind,
|
|
434
|
+
v.nodeId,
|
|
435
|
+
v.globalId ?? "",
|
|
436
|
+
String(v.sequence),
|
|
437
|
+
v.fieldPath,
|
|
438
|
+
v.token,
|
|
439
|
+
v.originalValue,
|
|
440
|
+
])), "utf-8");
|
|
441
|
+
console.error(`Analyzed ${report.recordingsAnalyzed} recording(s) (${report.parseErrors.length} parse error(s)).\n` +
|
|
442
|
+
` Structural report (scrubbed, safe to share/review): ${reportPath}\n` +
|
|
443
|
+
` Scrubbed-value mapping (CONTAINS REAL BUSINESS DATA -- local only, never share/commit): ${csvPath} (${scrubbedValues.length} value(s))`);
|
|
444
|
+
}
|
|
445
|
+
async function cmdExport(args) {
|
|
446
|
+
const outPath = flagValue(args, "-o");
|
|
447
|
+
if (!outPath) {
|
|
448
|
+
throw new Error("Usage: trxa export -o <backup.zip>");
|
|
449
|
+
}
|
|
450
|
+
const result = await exportLibrary(process.cwd(), outPath);
|
|
451
|
+
console.error(`Exported ${result.fileCount} file(s) from ${result.dirs.join(", ")} to ${result.outPath}`);
|
|
452
|
+
}
|
|
453
|
+
async function cmdImport(args) {
|
|
454
|
+
const [zipPath, ...rest] = args;
|
|
455
|
+
const force = rest.includes("--force");
|
|
456
|
+
if (!zipPath) {
|
|
457
|
+
throw new Error("Usage: trxa import <backup.zip> [--force]");
|
|
458
|
+
}
|
|
459
|
+
const result = await importLibrary(zipPath, process.cwd(), force);
|
|
460
|
+
if (result.collisions.length > 0) {
|
|
461
|
+
throw new Error(`Refusing to import: ${result.collisions.length} file(s) already exist and would be overwritten (pass --force to overwrite):\n` +
|
|
462
|
+
result.collisions.map((c) => ` ${c}`).join("\n"));
|
|
463
|
+
}
|
|
464
|
+
console.error(`Imported ${result.fileCount} file(s) from ${zipPath}`);
|
|
465
|
+
}
|
|
466
|
+
const STUDIO_URL = "http://localhost:5173/";
|
|
467
|
+
/** Home-directory-based, not `repoRoot`-relative -- same reasoning as `studio/server/paths.ts`'s
|
|
468
|
+
* WORKING_FOLDER (see that file): a real installed package's own directory isn't a stable place
|
|
469
|
+
* to keep runtime state like a PID/log file, since it can be wiped on upgrade or need elevated
|
|
470
|
+
* permissions to write to. This is state about the *running Studio process*, not project data, so
|
|
471
|
+
* it lives next to (not inside) the per-user config `studio/server/settings.ts` also writes to
|
|
472
|
+
* `~/.trxa-tool/`. */
|
|
473
|
+
const STUDIO_RUN_DIR = path.join(os.homedir(), ".trxa-tool", "studio-run");
|
|
474
|
+
/** Checks whether `pid` names a currently-running process, without sending a real signal. */
|
|
475
|
+
function isProcessAlive(pid) {
|
|
476
|
+
try {
|
|
477
|
+
process.kill(pid, 0);
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async function readStudioPid(pidFile) {
|
|
485
|
+
try {
|
|
486
|
+
const raw = (await readFile(pidFile, "utf-8")).trim();
|
|
487
|
+
const pid = Number(raw);
|
|
488
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** Polls `url` until it responds (any status) or `timeoutMs` elapses. Returns whether it answered. */
|
|
495
|
+
async function waitForServer(url, timeoutMs) {
|
|
496
|
+
const deadline = Date.now() + timeoutMs;
|
|
497
|
+
while (Date.now() < deadline) {
|
|
498
|
+
try {
|
|
499
|
+
await fetch(url);
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// not up yet -- connection refused, still starting
|
|
504
|
+
}
|
|
505
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
506
|
+
}
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Runs `scriptPath` fully detached from this short-lived CLI process (survives it exiting),
|
|
511
|
+
* returning the PID of the actual long-running process so `studio stop` can target it later.
|
|
512
|
+
*
|
|
513
|
+
* Windows needs a different mechanism than POSIX here. `spawn("cmd", ["/c", scriptPath], {
|
|
514
|
+
* detached: true, stdio: "inherit" })` looked right -- no error, correct exit code, the script
|
|
515
|
+
* genuinely ran (confirmed with plain cmd.exe builtins like `echo`) -- but silently dropped
|
|
516
|
+
* stdout specifically from any nested node.exe process: even bare `npm --version` (no
|
|
517
|
+
* concurrently/vite/tsx involved) lost its output while still reporting exit code 0. Something
|
|
518
|
+
* about the CREATE_NEW_PROCESS_GROUP flag Node sets for `detached: true` on Windows breaks stdio
|
|
519
|
+
* inheritance for node.exe grandchildren specifically. Routing through PowerShell's
|
|
520
|
+
* `Start-Process -PassThru` instead sidesteps it entirely (confirmed live: full
|
|
521
|
+
* concurrently/vite/tsx startup output lands in the log correctly, and the returned PID's whole
|
|
522
|
+
* process tree -- 16 processes deep in testing -- dies cleanly via `taskkill /T /F`).
|
|
523
|
+
*/
|
|
524
|
+
async function launchDetachedScript(scriptPath, cwd) {
|
|
525
|
+
if (process.platform !== "win32") {
|
|
526
|
+
const child = spawn("/bin/sh", [scriptPath], { cwd, detached: true, stdio: "ignore" });
|
|
527
|
+
child.unref();
|
|
528
|
+
if (!child.pid)
|
|
529
|
+
throw new Error("Failed to start TRXA Studio (no PID returned)");
|
|
530
|
+
return child.pid;
|
|
531
|
+
}
|
|
532
|
+
const psCommand = `$p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','${scriptPath}' ` +
|
|
533
|
+
`-WorkingDirectory '${cwd}' -WindowStyle Hidden -PassThru; Write-Output $p.Id`;
|
|
534
|
+
const ps = spawn("powershell", ["-NoProfile", "-Command", psCommand], {
|
|
535
|
+
cwd,
|
|
536
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
537
|
+
});
|
|
538
|
+
let out = "";
|
|
539
|
+
ps.stdout.on("data", (chunk) => (out += chunk));
|
|
540
|
+
return new Promise((resolve, reject) => {
|
|
541
|
+
ps.on("error", reject);
|
|
542
|
+
ps.on("exit", (code) => {
|
|
543
|
+
const pid = Number(out.trim());
|
|
544
|
+
if (code !== 0 || !Number.isInteger(pid)) {
|
|
545
|
+
reject(new Error(`Failed to launch TRXA Studio via PowerShell (exit ${code}): ${out.trim()}`));
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
resolve(pid);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/** Opens `url` in the OS default browser, best-effort -- failures just fall back to printing the URL. */
|
|
554
|
+
function openBrowser(url) {
|
|
555
|
+
try {
|
|
556
|
+
const child = process.platform === "win32"
|
|
557
|
+
? spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" })
|
|
558
|
+
: process.platform === "darwin"
|
|
559
|
+
? spawn("open", [url], { detached: true, stdio: "ignore" })
|
|
560
|
+
: spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
561
|
+
child.on("error", () => console.error(`Couldn't auto-open a browser -- visit ${url} manually.`));
|
|
562
|
+
child.unref();
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
console.error(`Couldn't auto-open a browser -- visit ${url} manually.`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** Detects whether this is a real packaged install (`npm run package` has produced a bundled
|
|
569
|
+
* production server) versus a dev checkout being driven via `npm run studio`. Checking for the
|
|
570
|
+
* bundle's existence, rather than some other signal (e.g. "does `studio/` source exist"), means
|
|
571
|
+
* a dev checkout that happens to have run `npm run package` locally also gets the real packaged
|
|
572
|
+
* behavior -- useful for testing this exact code path without a real npm install. */
|
|
573
|
+
function packagedStudioServerPath(repoRoot) {
|
|
574
|
+
const candidate = path.join(repoRoot, "studio", "server", "dist", "index.js");
|
|
575
|
+
return existsSync(candidate) ? candidate : null;
|
|
576
|
+
}
|
|
577
|
+
async function cmdStudioStart(repoRoot) {
|
|
578
|
+
const runDir = STUDIO_RUN_DIR;
|
|
579
|
+
const pidFile = path.join(runDir, "pid");
|
|
580
|
+
const logFile = path.join(runDir, "log");
|
|
581
|
+
const packagedServer = packagedStudioServerPath(repoRoot);
|
|
582
|
+
const studioUrl = packagedServer ? `http://127.0.0.1:${process.env.STUDIO_SERVER_PORT ?? 4321}/` : STUDIO_URL;
|
|
583
|
+
const existingPid = await readStudioPid(pidFile);
|
|
584
|
+
if (existingPid !== null && isProcessAlive(existingPid)) {
|
|
585
|
+
console.error(`TRXA Studio is already running (PID ${existingPid}). Opening ${studioUrl} ...`);
|
|
586
|
+
openBrowser(studioUrl);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
await mkdir(runDir, { recursive: true });
|
|
590
|
+
// A tiny launcher script, not the redirection inlined into a `shell:true` command string --
|
|
591
|
+
// the latter produced no error but also no log output: Node wraps the whole command in its own
|
|
592
|
+
// outer quotes for cmd.exe, and cmd's `/S`-flag quote-stripping (triggered by shell:true)
|
|
593
|
+
// mangles the second, inner pair of quotes around the log path, silently breaking the
|
|
594
|
+
// redirection while "npm run studio" itself still launched fine. Quotes on a line inside a
|
|
595
|
+
// script file are parsed normally -- no /S involved.
|
|
596
|
+
const scriptExt = process.platform === "win32" ? "cmd" : "sh";
|
|
597
|
+
const scriptPath = path.join(runDir, `launch.${scriptExt}`);
|
|
598
|
+
// Packaged: run the bundled server directly with `node` -- no `npm run studio`, no Vite dev
|
|
599
|
+
// server, no devDependencies (tsx/vite/concurrently) needed, none of which a real npm install
|
|
600
|
+
// has. Dev: unchanged, still delegates to the root `npm run studio` script.
|
|
601
|
+
const launchCommand = packagedServer ? `node "${packagedServer}"` : "npm run studio";
|
|
602
|
+
// Packaged only: the *user's* cwd (wherever they actually ran `trxa studio` from), not
|
|
603
|
+
// `repoRoot` (the installed package's own directory) -- this becomes the working folder's
|
|
604
|
+
// first-run default (see studio/server/settings.ts), and the package's own install directory is
|
|
605
|
+
// never a sensible guess for that. Dev keeps `repoRoot` since `npm run studio` must run from
|
|
606
|
+
// wherever package.json's scripts actually live.
|
|
607
|
+
const launchCwd = packagedServer ? process.cwd() : repoRoot;
|
|
608
|
+
const scriptBody = process.platform === "win32"
|
|
609
|
+
? `@echo off\r\n${launchCommand} > "${logFile}" 2>&1\r\n`
|
|
610
|
+
: `#!/bin/sh\n${launchCommand} > "${logFile}" 2>&1\n`;
|
|
611
|
+
await writeFile(scriptPath, scriptBody, "utf-8");
|
|
612
|
+
const pid = await launchDetachedScript(scriptPath, launchCwd);
|
|
613
|
+
await writeFile(pidFile, String(pid), "utf-8");
|
|
614
|
+
const ready = await waitForServer(studioUrl, 20_000);
|
|
615
|
+
if (ready) {
|
|
616
|
+
console.error(`TRXA Studio is running in the background (PID ${pid}).\n` +
|
|
617
|
+
` UI: ${studioUrl}\n` +
|
|
618
|
+
` Logs: ${logFile}\n` +
|
|
619
|
+
` Stop: trxa studio stop`);
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
console.error(`TRXA Studio (PID ${pid}) is taking longer than expected to come up -- check ${logFile}.\n` +
|
|
623
|
+
` Opening ${studioUrl} anyway; refresh if it's not ready yet.\n` +
|
|
624
|
+
` Stop: trxa studio stop`);
|
|
625
|
+
}
|
|
626
|
+
openBrowser(studioUrl);
|
|
627
|
+
}
|
|
628
|
+
async function cmdStudioStop() {
|
|
629
|
+
const pidFile = path.join(STUDIO_RUN_DIR, "pid");
|
|
630
|
+
const pid = await readStudioPid(pidFile);
|
|
631
|
+
if (pid === null) {
|
|
632
|
+
console.error("TRXA Studio doesn't appear to be running (no PID file found).");
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (!isProcessAlive(pid)) {
|
|
636
|
+
console.error(`TRXA Studio isn't running (stale PID ${pid}) -- cleaning up.`);
|
|
637
|
+
await unlink(pidFile).catch(() => { });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (process.platform === "win32") {
|
|
641
|
+
await new Promise((resolve) => {
|
|
642
|
+
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
643
|
+
killer.on("exit", () => resolve());
|
|
644
|
+
killer.on("error", () => resolve());
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
// detached:true made `pid` the leader of its own process group; the negative PID targets the
|
|
649
|
+
// whole group (concurrently + tsx watch + vite), not just the top npm/shell process.
|
|
650
|
+
try {
|
|
651
|
+
process.kill(-pid, "SIGTERM");
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
process.kill(pid, "SIGTERM");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
await unlink(pidFile).catch(() => { });
|
|
658
|
+
console.error(`Stopped TRXA Studio (was PID ${pid}).`);
|
|
659
|
+
}
|
|
660
|
+
/** Launches (or stops) the Studio server+client, detached and backgrounded so `trxa studio`
|
|
661
|
+
* returns control immediately and auto-opens the browser. Delegates to the root `npm run studio`
|
|
662
|
+
* script in a dev checkout (that command itself stays foreground/verbose -- still the right mode
|
|
663
|
+
* for actual Studio development, e.g. via the Claude_Preview MCP tools) or launches the bundled
|
|
664
|
+
* production server directly once `npm run package` has produced one -- see
|
|
665
|
+
* `packagedStudioServerPath`/`cmdStudioStart` for how that's detected. */
|
|
666
|
+
async function cmdStudio(args) {
|
|
667
|
+
if (args[0] === "stop") {
|
|
668
|
+
await cmdStudioStop();
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
await cmdStudioStart(REPO_ROOT);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const COMMAND_GROUPS = [
|
|
675
|
+
{
|
|
676
|
+
title: "turn a recording into a test script",
|
|
677
|
+
commands: [
|
|
678
|
+
{ name: "parse", usage: "<file.axtr> [-o out.testscript.json]", summary: "Parse a .axtr recording into a Test Script", handler: cmdParse },
|
|
679
|
+
{
|
|
680
|
+
name: "suggest-inputs",
|
|
681
|
+
usage: "<file.axtr> [-o draft.json]",
|
|
682
|
+
summary: "Draft candidate Test Case inputs from a recording",
|
|
683
|
+
handler: cmdSuggestInputs,
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
title: "author & validate test cases, fixtures, suites, plans",
|
|
689
|
+
commands: [
|
|
690
|
+
{ name: "validate-testcase", usage: "<testcase.json>", summary: "Validate a Test Case file", handler: cmdValidateTestCase },
|
|
691
|
+
{ name: "validate-fixture", usage: "<fixture.json>", summary: "Validate a Fixture file", handler: cmdValidateFixture },
|
|
692
|
+
{
|
|
693
|
+
name: "migrate-fixtures",
|
|
694
|
+
usage: "<testdata-dir> [--dry-run]",
|
|
695
|
+
summary: "Merge one-test-case fixture files into one file per fixture",
|
|
696
|
+
handler: cmdMigrateFixtures,
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
name: "validate-suite",
|
|
700
|
+
usage: "<suite.json>",
|
|
701
|
+
summary: "Validate a Test Suite file (also checks DAG order/cycles)",
|
|
702
|
+
handler: cmdValidateSuite,
|
|
703
|
+
},
|
|
704
|
+
{ name: "plan-suite", usage: "<suite.json>", summary: "Print a suite's resolved execution order", handler: cmdPlanSuite },
|
|
705
|
+
{
|
|
706
|
+
name: "validate-plan",
|
|
707
|
+
usage: "<plan.json>",
|
|
708
|
+
summary: "Validate a Test Plan file and every suite it references",
|
|
709
|
+
handler: cmdValidatePlan,
|
|
710
|
+
},
|
|
711
|
+
],
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
title: "set up a project for LLM-driven replay",
|
|
715
|
+
commands: [
|
|
716
|
+
{
|
|
717
|
+
name: "init-skill",
|
|
718
|
+
usage: "[--force]",
|
|
719
|
+
summary: "Copy the replay-trxa-testplan skill into this project for Claude Code and Codex to pick up",
|
|
720
|
+
handler: cmdInitSkill,
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
name: "mcp-bridge",
|
|
724
|
+
usage: "",
|
|
725
|
+
summary: "Connect an agent to a D365 environment's MCP server (started by the agent; see SETUP.md)",
|
|
726
|
+
handler: cmdMcpBridge,
|
|
727
|
+
},
|
|
728
|
+
],
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
title: "run a suite or plan",
|
|
732
|
+
commands: [
|
|
733
|
+
{
|
|
734
|
+
name: "init-plan-run",
|
|
735
|
+
usage: '<plan.json> -o runs/ [--name "<run name>"]',
|
|
736
|
+
summary: "Create a new PlanRun record for a plan, auto-generating its id; --name labels the run",
|
|
737
|
+
handler: cmdInitPlanRun,
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
name: "init-run",
|
|
741
|
+
usage: '<suite.json> -o testruns/<suiteId>-<runId>.json [--name "<run name>"]',
|
|
742
|
+
summary: "Create a new Test Run file for a suite, all test cases pending; --name labels the run",
|
|
743
|
+
handler: cmdInitRun,
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
name: "resolve-next",
|
|
747
|
+
usage: "<suite.json> <testrun.json> --testcases-dir <dir> [--fixtures-dir <dir>]",
|
|
748
|
+
summary: "Resolve the next runnable test case's inputs",
|
|
749
|
+
handler: cmdResolveNext,
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
name: "measure-tokens",
|
|
753
|
+
usage: "--since <isoTimestamp> --until <isoTimestamp> [--transcript <path>]",
|
|
754
|
+
summary: "Sum real token usage from Claude Code's own session log for a time window",
|
|
755
|
+
handler: cmdMeasureTokens,
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
name: "record-result",
|
|
759
|
+
usage: "<testrun.json> <testCaseId> --status passed|failed|blocked (--outputs '{...}' | --outputs-file <path>) " +
|
|
760
|
+
"[--assertions '[...]' | --assertions-file <path>]",
|
|
761
|
+
summary: "Record a test case's outcome into a Test Run",
|
|
762
|
+
handler: cmdRecordResult,
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
name: "plan-status",
|
|
766
|
+
usage: "<plan.json> <run.json> --testruns-dir <dir>",
|
|
767
|
+
summary: "Roll up a Test Plan's status across its suites' Test Runs for one PlanRun",
|
|
768
|
+
handler: cmdPlanStatus,
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
title: "analyze a whole recordings library",
|
|
774
|
+
commands: [
|
|
775
|
+
{
|
|
776
|
+
name: "analyze-library",
|
|
777
|
+
usage: "<recordingsDir> [--report analysis/library-report.json] [--csv analysis/scrubbed-values.csv]",
|
|
778
|
+
summary: "Scan a directory of recordings; report structural coverage and gaps",
|
|
779
|
+
handler: cmdAnalyzeLibrary,
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
title: "visual editor",
|
|
785
|
+
commands: [
|
|
786
|
+
{ name: "studio", usage: "[stop]", summary: "Start (or stop) the Studio web UI", handler: cmdStudio },
|
|
787
|
+
],
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
title: "back up & migrate a whole library",
|
|
791
|
+
commands: [
|
|
792
|
+
{
|
|
793
|
+
name: "export",
|
|
794
|
+
usage: "-o <backup.zip>",
|
|
795
|
+
summary: "Bundle recordings/testcases/testdata/testscripts/suites/plans/testruns into a zip",
|
|
796
|
+
handler: cmdExport,
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
name: "import",
|
|
800
|
+
usage: "<backup.zip> [--force]",
|
|
801
|
+
summary: "Unpack a library backup zip (refuses on name collisions unless --force)",
|
|
802
|
+
handler: cmdImport,
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
},
|
|
806
|
+
];
|
|
807
|
+
const COMMANDS = Object.fromEntries(COMMAND_GROUPS.flatMap((group) => group.commands).map((c) => [c.name, c]));
|
|
808
|
+
async function readPackageVersion(repoRoot) {
|
|
809
|
+
try {
|
|
810
|
+
const raw = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf-8"));
|
|
811
|
+
return raw.version ?? "0.0.0";
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
return "0.0.0";
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function renderHelp(version) {
|
|
818
|
+
const nameCol = Math.max(...COMMAND_GROUPS.flatMap((g) => g.commands.map((c) => c.name.length)));
|
|
819
|
+
const groups = COMMAND_GROUPS.map((group) => {
|
|
820
|
+
const lines = group.commands.map((c) => ` ${c.name.padEnd(nameCol)} ${c.summary}`).join("\n");
|
|
821
|
+
return `${group.title}\n${lines}`;
|
|
822
|
+
}).join("\n\n");
|
|
823
|
+
return (`trxa-tool ${version} -- D365 Finance & Operations Task Recorder test automation\n\n` +
|
|
824
|
+
`Usage: trxa <command> [<args>]\n\n` +
|
|
825
|
+
`These are the trxa commands, grouped by what they're for:\n\n` +
|
|
826
|
+
`${groups}\n\n` +
|
|
827
|
+
`Run 'trxa <command>' with no further arguments to see that command's specific usage.`);
|
|
828
|
+
}
|
|
829
|
+
async function main() {
|
|
830
|
+
const [command, ...args] = process.argv.slice(2);
|
|
831
|
+
if (!command || command === "help" || command === "-h" || command === "--help") {
|
|
832
|
+
console.log(renderHelp(await readPackageVersion(REPO_ROOT)));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (command === "-v" || command === "--version") {
|
|
836
|
+
console.log(`trxa-tool ${await readPackageVersion(REPO_ROOT)}`);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const spec = COMMANDS[command];
|
|
840
|
+
if (!spec) {
|
|
841
|
+
console.error(`trxa: '${command}' is not a trxa command. See 'trxa help' for the full list.`);
|
|
842
|
+
process.exitCode = 1;
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
await spec.handler(args);
|
|
846
|
+
}
|
|
847
|
+
main().catch((err) => {
|
|
848
|
+
console.error(err);
|
|
849
|
+
process.exitCode = 1;
|
|
850
|
+
});
|