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.
Files changed (155) hide show
  1. package/.claude/skills/replay-trxa-testplan/SKILL.md +627 -0
  2. package/LICENSE +120 -0
  3. package/MANUAL.md +556 -0
  4. package/README.md +91 -3
  5. package/SETUP.md +401 -0
  6. package/STUDIO.md +427 -0
  7. package/dist/analyze.d.ts +58 -0
  8. package/dist/analyze.js +122 -0
  9. package/dist/bpm.d.ts +40 -0
  10. package/dist/bpm.js +60 -0
  11. package/dist/build-test-script.d.ts +24 -0
  12. package/dist/build-test-script.js +40 -0
  13. package/dist/csv.d.ts +1 -0
  14. package/dist/csv.js +11 -0
  15. package/dist/extract.d.ts +14 -0
  16. package/dist/extract.js +34 -0
  17. package/dist/fixture-schema.d.ts +52 -0
  18. package/dist/fixture-schema.js +112 -0
  19. package/dist/fixture-store.d.ts +37 -0
  20. package/dist/fixture-store.js +75 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +850 -0
  23. package/dist/library-archive.d.ts +37 -0
  24. package/dist/library-archive.js +106 -0
  25. package/dist/mapping.d.ts +25 -0
  26. package/dist/mapping.js +393 -0
  27. package/dist/mcp-bridge.d.ts +52 -0
  28. package/dist/mcp-bridge.js +290 -0
  29. package/dist/normalize.d.ts +21 -0
  30. package/dist/normalize.js +43 -0
  31. package/dist/parse.d.ts +3 -0
  32. package/dist/parse.js +233 -0
  33. package/dist/plan-runner.d.ts +27 -0
  34. package/dist/plan-runner.js +36 -0
  35. package/dist/plan-schema.d.ts +27 -0
  36. package/dist/plan-schema.js +45 -0
  37. package/dist/planrun-schema.d.ts +53 -0
  38. package/dist/planrun-schema.js +111 -0
  39. package/dist/planrun.d.ts +20 -0
  40. package/dist/planrun.js +46 -0
  41. package/dist/report-schema.d.ts +92 -0
  42. package/dist/report-schema.js +115 -0
  43. package/dist/report.d.ts +45 -0
  44. package/dist/report.js +77 -0
  45. package/dist/schema.d.ts +96 -0
  46. package/dist/schema.js +10 -0
  47. package/dist/scrub.d.ts +44 -0
  48. package/dist/scrub.js +166 -0
  49. package/dist/suggest-inputs.d.ts +19 -0
  50. package/dist/suggest-inputs.js +92 -0
  51. package/dist/suite-runner.d.ts +59 -0
  52. package/dist/suite-runner.js +204 -0
  53. package/dist/suite-schema.d.ts +59 -0
  54. package/dist/suite-schema.js +95 -0
  55. package/dist/testcase-schema.d.ts +102 -0
  56. package/dist/testcase-schema.js +132 -0
  57. package/dist/token-usage.d.ts +38 -0
  58. package/dist/token-usage.js +95 -0
  59. package/media/studio/fixtures-test-cases.png +0 -0
  60. package/media/studio/plans-editor.png +0 -0
  61. package/media/studio/recordings-create-test-case.png +0 -0
  62. package/media/studio/run-dialog.png +0 -0
  63. package/media/studio/settings-agent-runs.png +0 -0
  64. package/media/studio/settings-working-folder.png +0 -0
  65. package/media/studio/studio-recordings.png +0 -0
  66. package/media/studio/suites-canvas.png +0 -0
  67. package/media/studio/suites-inspector.png +0 -0
  68. package/media/studio/testcases-expectations.png +0 -0
  69. package/media/studio/testcases-outputs.png +0 -0
  70. package/media/studio/testrun-details.png +0 -0
  71. package/media/studio/testruns-list.png +0 -0
  72. package/package.json +62 -6
  73. package/studio/client-dist/assets/CreatePanel-C5bb5OIs.js +6 -0
  74. package/studio/client-dist/assets/FactTile-G1pURLys.js +1 -0
  75. package/studio/client-dist/assets/FieldLabel-DPmIEQ08.js +1 -0
  76. package/studio/client-dist/assets/FixtureNamePicker-137AAgwZ.js +1 -0
  77. package/studio/client-dist/assets/Fixtures-D0t-soph.js +1 -0
  78. package/studio/client-dist/assets/GeneralSection-CWkTeSX2.js +1 -0
  79. package/studio/client-dist/assets/ListPaneHeader-DXKqX36N.js +1 -0
  80. package/studio/client-dist/assets/Plans-C70evkGc.js +1 -0
  81. package/studio/client-dist/assets/Recordings-BT0dyVLs.js +1 -0
  82. package/studio/client-dist/assets/Settings-k-J3u5lH.js +1 -0
  83. package/studio/client-dist/assets/Suites-DLioOiRN.css +1 -0
  84. package/studio/client-dist/assets/Suites-DQhhMxjT.js +7 -0
  85. package/studio/client-dist/assets/TestCases-BdiopVGI.js +1 -0
  86. package/studio/client-dist/assets/TestRuns-CylAe-3t.js +9 -0
  87. package/studio/client-dist/assets/api-DJZ703_F.js +24 -0
  88. package/studio/client-dist/assets/bucket-18-viuwGcaj.js +1 -0
  89. package/studio/client-dist/assets/fixtureName-CTZT_jKu.js +1 -0
  90. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  91. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  92. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  93. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  94. package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  95. package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  96. package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  97. package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  98. package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  99. package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  100. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-LoaTnByX.woff +0 -0
  101. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-RhT0Ayi3.woff2 +0 -0
  102. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-Bf1n82Tb.woff +0 -0
  103. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-D9J6q4_O.woff2 +0 -0
  104. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  105. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  106. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  107. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  108. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-Bbd5v1xK.woff +0 -0
  109. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-DqmgDkIN.woff2 +0 -0
  110. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-BELOEI8R.woff2 +0 -0
  111. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-CfSXsIbO.woff +0 -0
  112. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  113. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  114. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  115. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  116. package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-J-CBqZoJ.woff2 +0 -0
  117. package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-STc8XiDr.woff +0 -0
  118. package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-BEZwmy7D.woff2 +0 -0
  119. package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-CTYLy0lX.woff +0 -0
  120. package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  121. package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  122. package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  123. package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  124. package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-C9Of02-5.woff +0 -0
  125. package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-b4i7YzLG.woff2 +0 -0
  126. package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CZTNEAuW.woff2 +0 -0
  127. package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CsGl1sm0.woff +0 -0
  128. package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  129. package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  130. package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  131. package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  132. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-B8Zgwjh3.woff +0 -0
  133. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-BLFuR0P1.woff2 +0 -0
  134. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-Du9zzoL2.woff2 +0 -0
  135. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-V_9i1P_m.woff +0 -0
  136. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  137. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  138. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  139. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  140. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-CkYRZD2a.woff +0 -0
  141. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-SDfX6ktj.woff2 +0 -0
  142. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-78jqtfZH.woff +0 -0
  143. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-BbRnwouv.woff2 +0 -0
  144. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  145. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  146. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  147. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  148. package/studio/client-dist/assets/index-cxBc2iRv.js +10 -0
  149. package/studio/client-dist/assets/index-wjWuVHXN.css +1 -0
  150. package/studio/client-dist/assets/status-DUGxC7G7.js +1 -0
  151. package/studio/client-dist/assets/testCaseTree-Cl1-bc5O.js +1 -0
  152. package/studio/client-dist/axtr-icon.svg +2 -0
  153. package/studio/client-dist/favicon.svg +2 -0
  154. package/studio/client-dist/index.html +15 -0
  155. package/studio/server/dist/index.js +3176 -0
package/dist/bpm.js ADDED
@@ -0,0 +1,60 @@
1
+ import JSZip from "jszip";
2
+ import { XMLParser } from "fast-xml-parser";
3
+ function asArray(v) {
4
+ if (v === undefined)
5
+ return [];
6
+ return Array.isArray(v) ? v : [v];
7
+ }
8
+ function parseProcessStepXml(xml) {
9
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "" });
10
+ const doc = parser.parse(xml);
11
+ const tasks = asArray(doc.Activities?.Task);
12
+ return {
13
+ tasks: tasks.map((t) => ({
14
+ id: String(t.Id ?? ""),
15
+ aotName: String(t.AOTName ?? ""),
16
+ objectType: String(t.ObjectType ?? ""),
17
+ configurationKey: String(t.ConfigurationKey ?? ""),
18
+ layer: String(t.Layer ?? ""),
19
+ model: String(t.Model ?? ""),
20
+ sequence: Number(t.Sequence ?? 0),
21
+ security: asArray(t.Permissions?.Security).map((s) => ({
22
+ roleName: String(s.RoleName ?? ""),
23
+ roleDisplayName: String(s.RoleDisplayName ?? ""),
24
+ dutyName: String(s.DutyName ?? ""),
25
+ dutyDisplayName: String(s.DutyDisplayName ?? ""),
26
+ privilegeName: String(s.PrivilegeName ?? ""),
27
+ privilegeDisplayName: String(s.PrivilegeDisplayName ?? ""),
28
+ accessLevel: String(s.AccessLevel ?? ""),
29
+ isValidRole: String(s.IsValidRole ?? "").toLowerCase() === "true",
30
+ })),
31
+ connectsTo: asArray(t.Connections?.Connection).map((c) => String(c.Target ?? "")),
32
+ })),
33
+ };
34
+ }
35
+ /** Parses the nested BPMPackage.ax7bpm bytes extracted from a .axtr file. Returns null if absent. */
36
+ export async function parseBpmPackage(bytes) {
37
+ if (!bytes)
38
+ return null;
39
+ const zip = await JSZip.loadAsync(bytes);
40
+ // The backslash variant is defensive, not confirmed live: every recording checked so far uses
41
+ // "/", but zip entry names are just stored strings (no path-separator normalization is
42
+ // guaranteed across the tools that can produce a BPMPackage.ax7bpm), so this is here in case a
43
+ // Windows-authored zip ever stores "Recording\ProcessStep.xml" literally instead.
44
+ const entry = zip.file("Recording/ProcessStep.xml") ?? zip.file("Recording\\ProcessStep.xml") ?? null;
45
+ if (!entry)
46
+ return null;
47
+ const xml = await entry.async("string");
48
+ return parseProcessStepXml(xml);
49
+ }
50
+ /** Every distinct privilege that appears across all tasks -- a starting point for a security preflight check. */
51
+ export function distinctPrivileges(pkg) {
52
+ const set = new Set();
53
+ for (const task of pkg.tasks) {
54
+ for (const sec of task.security) {
55
+ if (sec.privilegeName)
56
+ set.add(sec.privilegeName);
57
+ }
58
+ }
59
+ return [...set];
60
+ }
@@ -0,0 +1,24 @@
1
+ import { type TestScript } from "./normalize.js";
2
+ /** Mechanics only, no hints -- the raw shape mapStep/suggestInputs operate on. */
3
+ export declare function loadTestScript(axtrPath: string): Promise<TestScript>;
4
+ /** Full `testscript.json` shape: mechanics plus per-step hints and security info. */
5
+ export declare function buildTestScript(axtrPath: string): Promise<{
6
+ name: string;
7
+ canonicalId: string;
8
+ description: string;
9
+ steps: {
10
+ sequence: number;
11
+ description: string;
12
+ formName: string | null;
13
+ nodeType: "Scope" | "MenuItemUserAction" | "CommandUserAction" | "PropertyUserAction" | "Unknown";
14
+ node: import("./schema.js").RecordingNode;
15
+ hint: import("./mapping.js").McpToolHint | null;
16
+ }[];
17
+ security: {
18
+ requiredPrivileges: string[];
19
+ formsVisited: {
20
+ aotName: string;
21
+ objectType: string;
22
+ }[];
23
+ } | null;
24
+ }>;
@@ -0,0 +1,40 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extractAxtr } from "./extract.js";
3
+ import { parseRecordingXml } from "./parse.js";
4
+ import { normalizeRecording } from "./normalize.js";
5
+ import { mapStep } from "./mapping.js";
6
+ import { parseBpmPackage, distinctPrivileges } from "./bpm.js";
7
+ /** Mechanics only, no hints -- the raw shape mapStep/suggestInputs operate on. */
8
+ export async function loadTestScript(axtrPath) {
9
+ const bytes = await readFile(axtrPath);
10
+ const extracted = await extractAxtr(bytes);
11
+ const recording = parseRecordingXml(extracted.recordingXml);
12
+ return normalizeRecording(recording);
13
+ }
14
+ /** Full `testscript.json` shape: mechanics plus per-step hints and security info. */
15
+ export async function buildTestScript(axtrPath) {
16
+ const bytes = await readFile(axtrPath);
17
+ const extracted = await extractAxtr(bytes);
18
+ const recording = parseRecordingXml(extracted.recordingXml);
19
+ const script = normalizeRecording(recording);
20
+ const bpm = await parseBpmPackage(extracted.bpmPackageBytes);
21
+ return {
22
+ name: script.name,
23
+ canonicalId: script.canonicalId,
24
+ description: script.description,
25
+ steps: script.steps.map((step) => ({
26
+ sequence: step.sequence,
27
+ description: step.description,
28
+ formName: step.formName,
29
+ nodeType: step.node.kind,
30
+ node: step.node,
31
+ hint: mapStep(step),
32
+ })),
33
+ security: bpm
34
+ ? {
35
+ requiredPrivileges: distinctPrivileges(bpm),
36
+ formsVisited: bpm.tasks.map((t) => ({ aotName: t.aotName, objectType: t.objectType })),
37
+ }
38
+ : null,
39
+ };
40
+ }
package/dist/csv.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function toCsv(header: string[], rows: string[][]): string;
package/dist/csv.js ADDED
@@ -0,0 +1,11 @@
1
+ /** Minimal RFC4180 CSV writer -- no dependency needed for the handful of columns this repo emits. */
2
+ function escapeCell(value) {
3
+ if (/[",\r\n]/.test(value)) {
4
+ return `"${value.replace(/"/g, '""')}"`;
5
+ }
6
+ return value;
7
+ }
8
+ export function toCsv(header, rows) {
9
+ const lines = [header, ...rows].map((row) => row.map(escapeCell).join(","));
10
+ return lines.join("\r\n") + "\r\n";
11
+ }
@@ -0,0 +1,14 @@
1
+ export declare const RECORDING_ENTRY = "Recording.xml";
2
+ export declare const RESOURCE_ENTRY = "recording_resource.xml";
3
+ export declare const BPM_ENTRY = "BPMPackage.ax7bpm";
4
+ export interface ExtractedAxtr {
5
+ recordingXml: string;
6
+ resourceXml: string | null;
7
+ /** Raw bytes of the nested BPMPackage.ax7bpm zip, if present. Parsed separately by bpm.ts. */
8
+ bpmPackageBytes: Buffer | null;
9
+ }
10
+ /**
11
+ * Unzips a .axtr file (confirmed to be a plain ZIP via its PK\x03\x04 header) and pulls out
12
+ * the entries D365FO's own UserSecGovTaskRecorderUtil::GetRecordingFromPackage reads.
13
+ */
14
+ export declare function extractAxtr(fileBytes: Buffer): Promise<ExtractedAxtr>;
@@ -0,0 +1,34 @@
1
+ import JSZip from "jszip";
2
+ export const RECORDING_ENTRY = "Recording.xml";
3
+ export const RESOURCE_ENTRY = "recording_resource.xml";
4
+ export const BPM_ENTRY = "BPMPackage.ax7bpm";
5
+ /**
6
+ * Unzips a .axtr file (confirmed to be a plain ZIP via its PK\x03\x04 header) and pulls out
7
+ * the entries D365FO's own UserSecGovTaskRecorderUtil::GetRecordingFromPackage reads.
8
+ */
9
+ export async function extractAxtr(fileBytes) {
10
+ const zip = await JSZip.loadAsync(fileBytes);
11
+ const recordingEntry = findEntry(zip, RECORDING_ENTRY);
12
+ if (!recordingEntry) {
13
+ throw new Error(`.axtr package does not contain a ${RECORDING_ENTRY} entry`);
14
+ }
15
+ const resourceEntry = findEntry(zip, RESOURCE_ENTRY);
16
+ const bpmEntry = findEntry(zip, BPM_ENTRY);
17
+ return {
18
+ recordingXml: await recordingEntry.async("string"),
19
+ resourceXml: resourceEntry ? await resourceEntry.async("string") : null,
20
+ bpmPackageBytes: bpmEntry ? await bpmEntry.async("nodebuffer") : null,
21
+ };
22
+ }
23
+ /** Case-insensitive fallback is defensive, not confirmed live: every `.axtr` checked so far uses
24
+ * the exact casing in `RECORDING_ENTRY`/`RESOURCE_ENTRY`/`BPM_ENTRY` above. Zip entry names are
25
+ * just stored strings with no case-folding guarantee across whatever tool produced the archive,
26
+ * so this exists in case one ever doesn't match exactly -- same rationale as `bpm.ts`'s backslash
27
+ * path fallback. */
28
+ function findEntry(zip, name) {
29
+ const exact = zip.file(name);
30
+ if (exact)
31
+ return exact;
32
+ const match = Object.values(zip.files).find((f) => !f.dir && f.name.toLowerCase() === name.toLowerCase());
33
+ return match ?? null;
34
+ }
@@ -0,0 +1,52 @@
1
+ /** A named set of input values, e.g. "USMF" or "EU company", holding one slice of values per test
2
+ * case it covers. Kept separate from test cases so the same test case can run against different
3
+ * data without editing it, and so one data set reads as one thing even when it spans many test
4
+ * cases. One fixture is one `testdata/<name>.json` file.
5
+ *
6
+ * Suites bind to a fixture by `name` and a param (see suite-schema.ts); at run time the slice for
7
+ * the test case being run is used (suite-runner.ts's `resolveInputs`). */
8
+ export interface Fixture {
9
+ /** Identifier suites refer to, and the file name (`testdata/<name>.json`). Filename safe, see
10
+ * `validateFixtureName`. */
11
+ name: string;
12
+ /** Short free-text description shown alongside `name` in Studio. Purely descriptive. */
13
+ description: string;
14
+ /** testCaseId -> (param name -> value). */
15
+ testCases: Record<string, Record<string, string>>;
16
+ }
17
+ /** The format before 2026-11: one file per (test case, fixture name) pair, each repeating the
18
+ * fixture's name and description. Still read by the CLI (merged by name, see `mergeFixtures`) so
19
+ * an existing library keeps working; `trxa migrate-fixtures` rewrites them in the current format. */
20
+ export interface LegacyFixture {
21
+ testCaseId: string;
22
+ name: string;
23
+ description: string;
24
+ values: Record<string, string>;
25
+ }
26
+ /** Longest fixture name accepted. */
27
+ export declare const FIXTURE_NAME_MAX_LENGTH = 64;
28
+ /** Returns why `name` can't be a fixture name, or null when it can. A fixture's name is also its
29
+ * file name, so it is limited to letters, digits, spaces, dots, underscores and hyphens, starting
30
+ * with a letter or digit and not ending with a dot or space (Windows drops those silently). */
31
+ export declare function fixtureNameProblem(name: string): string | null;
32
+ /** True when `value` looks like the pre-2026-11 one-test-case-per-file format. */
33
+ export declare function isLegacyFixture(value: unknown): boolean;
34
+ export declare function validateFixture(value: unknown): Fixture;
35
+ export declare function validateLegacyFixture(value: unknown): LegacyFixture;
36
+ /** Reads either format. A legacy file becomes a Fixture covering its one test case. Its name is
37
+ * not checked against `fixtureNameProblem`, since legacy names were never limited and still have
38
+ * to load; `trxa migrate-fixtures` reports a name that can't become a file name. */
39
+ export declare function validateFixtureFile(value: unknown): Fixture;
40
+ /** Merges fixtures that share a name (case-insensitively, since file names on Windows are) into
41
+ * one each, in input order. The first non-empty description wins. Two sources holding values for
42
+ * the same test case under the same name is ambiguous, so it throws, naming both sources. */
43
+ export declare function mergeFixtures(sources: {
44
+ source: string;
45
+ fixture: Fixture;
46
+ }[]): {
47
+ fixture: Fixture;
48
+ sources: string[];
49
+ }[];
50
+ /** Writes a fixture the way every tool writes one, with test cases in a stable order so a saved
51
+ * file diffs cleanly. */
52
+ export declare function serializeFixture(fixture: Fixture): string;
@@ -0,0 +1,112 @@
1
+ /** Longest fixture name accepted. */
2
+ export const FIXTURE_NAME_MAX_LENGTH = 64;
3
+ const FIXTURE_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9 ._-]*[A-Za-z0-9_-])?$/;
4
+ /** Returns why `name` can't be a fixture name, or null when it can. A fixture's name is also its
5
+ * file name, so it is limited to letters, digits, spaces, dots, underscores and hyphens, starting
6
+ * with a letter or digit and not ending with a dot or space (Windows drops those silently). */
7
+ export function fixtureNameProblem(name) {
8
+ if (name.length === 0)
9
+ return "must not be empty";
10
+ if (name.length > FIXTURE_NAME_MAX_LENGTH)
11
+ return `must be at most ${FIXTURE_NAME_MAX_LENGTH} characters`;
12
+ if (!FIXTURE_NAME_PATTERN.test(name)) {
13
+ return "may only use letters, digits, spaces, dots, underscores and hyphens, starting with a letter or digit and not ending with a dot or space";
14
+ }
15
+ return null;
16
+ }
17
+ function fail(field, reason) {
18
+ throw new Error(`Invalid Fixture: ${field} ${reason}`);
19
+ }
20
+ function assertString(value, field) {
21
+ if (typeof value !== "string" || value.length === 0)
22
+ fail(field, "must be a non-empty string");
23
+ }
24
+ function assertStringMap(value, field) {
25
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
26
+ fail(field, "must be an object mapping param names to string values");
27
+ }
28
+ for (const [key, val] of Object.entries(value)) {
29
+ if (typeof val !== "string")
30
+ fail(`${field}.${key}`, "must be a string");
31
+ }
32
+ }
33
+ /** True when `value` looks like the pre-2026-11 one-test-case-per-file format. */
34
+ export function isLegacyFixture(value) {
35
+ return typeof value === "object" && value !== null && "testCaseId" in value && !("testCases" in value);
36
+ }
37
+ export function validateFixture(value) {
38
+ if (typeof value !== "object" || value === null)
39
+ fail("fixture", "must be an object");
40
+ const v = value;
41
+ assertString(v.name, "name");
42
+ const problem = fixtureNameProblem(v.name);
43
+ if (problem)
44
+ fail("name", problem);
45
+ if (typeof v.description !== "string")
46
+ fail("description", "must be a string");
47
+ if (typeof v.testCases !== "object" || v.testCases === null || Array.isArray(v.testCases)) {
48
+ fail("testCases", "must be an object mapping test case ids to their values");
49
+ }
50
+ const testCases = {};
51
+ for (const [testCaseId, values] of Object.entries(v.testCases)) {
52
+ if (testCaseId.length === 0)
53
+ fail("testCases", "must not contain an empty test case id");
54
+ assertStringMap(values, `testCases.${testCaseId}`);
55
+ testCases[testCaseId] = { ...values };
56
+ }
57
+ return { name: v.name, description: v.description, testCases };
58
+ }
59
+ export function validateLegacyFixture(value) {
60
+ if (typeof value !== "object" || value === null)
61
+ fail("fixture", "must be an object");
62
+ const v = value;
63
+ assertString(v.testCaseId, "testCaseId");
64
+ assertString(v.name, "name");
65
+ if (typeof v.description !== "string")
66
+ fail("description", "must be a string");
67
+ assertStringMap(v.values, "values");
68
+ return { testCaseId: v.testCaseId, name: v.name, description: v.description, values: { ...v.values } };
69
+ }
70
+ /** Reads either format. A legacy file becomes a Fixture covering its one test case. Its name is
71
+ * not checked against `fixtureNameProblem`, since legacy names were never limited and still have
72
+ * to load; `trxa migrate-fixtures` reports a name that can't become a file name. */
73
+ export function validateFixtureFile(value) {
74
+ if (!isLegacyFixture(value))
75
+ return validateFixture(value);
76
+ const legacy = validateLegacyFixture(value);
77
+ return { name: legacy.name, description: legacy.description, testCases: { [legacy.testCaseId]: legacy.values } };
78
+ }
79
+ /** Merges fixtures that share a name (case-insensitively, since file names on Windows are) into
80
+ * one each, in input order. The first non-empty description wins. Two sources holding values for
81
+ * the same test case under the same name is ambiguous, so it throws, naming both sources. */
82
+ export function mergeFixtures(sources) {
83
+ const merged = new Map();
84
+ for (const { source, fixture } of sources) {
85
+ const key = fixture.name.toLowerCase();
86
+ let entry = merged.get(key);
87
+ if (!entry) {
88
+ entry = { fixture: { name: fixture.name, description: fixture.description, testCases: {} }, sources: [], slices: new Map() };
89
+ merged.set(key, entry);
90
+ }
91
+ if (!entry.fixture.description && fixture.description)
92
+ entry.fixture.description = fixture.description;
93
+ entry.sources.push(source);
94
+ for (const [testCaseId, values] of Object.entries(fixture.testCases)) {
95
+ const previous = entry.slices.get(testCaseId);
96
+ if (previous) {
97
+ throw new Error(`Fixture "${fixture.name}" has values for test case "${testCaseId}" in both ${previous} and ${source}. Keep one of them.`);
98
+ }
99
+ entry.slices.set(testCaseId, source);
100
+ entry.fixture.testCases[testCaseId] = { ...values };
101
+ }
102
+ }
103
+ return [...merged.values()].map(({ fixture, sources }) => ({ fixture, sources }));
104
+ }
105
+ /** Writes a fixture the way every tool writes one, with test cases in a stable order so a saved
106
+ * file diffs cleanly. */
107
+ export function serializeFixture(fixture) {
108
+ const testCases = {};
109
+ for (const id of Object.keys(fixture.testCases).sort((a, b) => a.localeCompare(b)))
110
+ testCases[id] = fixture.testCases[id];
111
+ return JSON.stringify({ name: fixture.name, description: fixture.description, testCases }, null, 2) + "\n";
112
+ }
@@ -0,0 +1,37 @@
1
+ import { type Fixture } from "./fixture-schema.js";
2
+ export interface FixtureFolder {
3
+ /** Every fixture in the folder, with legacy files merged into the fixture they name. */
4
+ fixtures: {
5
+ fixture: Fixture;
6
+ files: string[];
7
+ }[];
8
+ /** File names (with extension) still in the pre-2026-11 one-test-case format. */
9
+ legacyFiles: string[];
10
+ /** Files that could not be read as a fixture at all. */
11
+ invalid: {
12
+ file: string;
13
+ error: string;
14
+ }[];
15
+ }
16
+ /** A missing folder reads as empty: a suite that binds no fixture doesn't need one to exist. */
17
+ export declare function readFixtureFolder(dir: string): Promise<FixtureFolder>;
18
+ /** A fixture is in its final shape when it is exactly one current format file named after it.
19
+ * Anything else (legacy files, or a file named differently from the fixture inside it) is what
20
+ * `migrateFixtureFolder` rewrites. */
21
+ export declare function needsMigration(fixture: Fixture, files: string[], legacyFiles: string[]): boolean;
22
+ export interface MigrationResult {
23
+ /** File written (`<name>.json`) and the files merged into it. */
24
+ written: {
25
+ file: string;
26
+ fixture: string;
27
+ from: string[];
28
+ }[];
29
+ /** Legacy files removed after their values were written. */
30
+ removed: string[];
31
+ }
32
+ /** Rewrites every legacy file in `dir` into one `<name>.json` per fixture, merging into a current
33
+ * format file of that name when one exists, then removes the legacy files. Everything is checked
34
+ * before anything is written, so a conflict or an unusable name leaves the folder untouched. */
35
+ export declare function migrateFixtureFolder(dir: string, options?: {
36
+ dryRun?: boolean;
37
+ }): Promise<MigrationResult>;
@@ -0,0 +1,75 @@
1
+ /** Reading and migrating a fixtures folder (`testdata/`), shared by the CLI and Studio's server so
2
+ * both see the same fixtures and the same migration. */
3
+ import { readdir, readFile, writeFile, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fixtureNameProblem, isLegacyFixture, mergeFixtures, serializeFixture, validateFixtureFile, } from "./fixture-schema.js";
6
+ /** A missing folder reads as empty: a suite that binds no fixture doesn't need one to exist. */
7
+ export async function readFixtureFolder(dir) {
8
+ let entries;
9
+ try {
10
+ entries = (await readdir(dir)).filter((f) => f.endsWith(".json")).sort((a, b) => a.localeCompare(b));
11
+ }
12
+ catch {
13
+ return { fixtures: [], legacyFiles: [], invalid: [] };
14
+ }
15
+ const sources = [];
16
+ const legacyFiles = [];
17
+ const invalid = [];
18
+ for (const file of entries) {
19
+ try {
20
+ const raw = JSON.parse(await readFile(path.join(dir, file), "utf-8"));
21
+ if (isLegacyFixture(raw))
22
+ legacyFiles.push(file);
23
+ sources.push({ source: file, fixture: validateFixtureFile(raw) });
24
+ }
25
+ catch (err) {
26
+ invalid.push({ file, error: err instanceof Error ? err.message : String(err) });
27
+ }
28
+ }
29
+ // Files in the current format come first, so a fixture's own description wins over a legacy
30
+ // file's when both exist.
31
+ sources.sort((a, b) => Number(legacyFiles.includes(a.source)) - Number(legacyFiles.includes(b.source)));
32
+ const fixtures = mergeFixtures(sources).map(({ fixture, sources }) => ({ fixture, files: sources }));
33
+ return { fixtures, legacyFiles, invalid };
34
+ }
35
+ /** A fixture is in its final shape when it is exactly one current format file named after it.
36
+ * Anything else (legacy files, or a file named differently from the fixture inside it) is what
37
+ * `migrateFixtureFolder` rewrites. */
38
+ export function needsMigration(fixture, files, legacyFiles) {
39
+ if (files.some((f) => legacyFiles.includes(f)))
40
+ return true;
41
+ return files.length !== 1 || files[0].toLowerCase() !== `${fixture.name}.json`.toLowerCase();
42
+ }
43
+ /** Rewrites every legacy file in `dir` into one `<name>.json` per fixture, merging into a current
44
+ * format file of that name when one exists, then removes the legacy files. Everything is checked
45
+ * before anything is written, so a conflict or an unusable name leaves the folder untouched. */
46
+ export async function migrateFixtureFolder(dir, options = {}) {
47
+ const folder = await readFixtureFolder(dir);
48
+ if (folder.invalid.length > 0) {
49
+ throw new Error(`Fix or remove these files first: ${folder.invalid.map((i) => `${i.file} (${i.error})`).join("; ")}`);
50
+ }
51
+ const plan = [];
52
+ for (const { fixture, files } of folder.fixtures) {
53
+ if (!needsMigration(fixture, files, folder.legacyFiles))
54
+ continue;
55
+ const problem = fixtureNameProblem(fixture.name);
56
+ if (problem) {
57
+ throw new Error(`Fixture name "${fixture.name}" (in ${files.join(", ")}) can't be used as a file name: it ${problem}. Rename it first.`);
58
+ }
59
+ const file = `${fixture.name}.json`;
60
+ plan.push({ file, fixture, from: files });
61
+ }
62
+ // Every source merged into <name>.json is removed afterwards, except <name>.json itself, which
63
+ // is overwritten in place (compared case-insensitively, as Windows does).
64
+ const result = {
65
+ written: plan.map(({ file, fixture, from }) => ({ file, fixture: fixture.name, from })),
66
+ removed: plan.flatMap(({ file, from }) => from.filter((f) => f.toLowerCase() !== file.toLowerCase())),
67
+ };
68
+ if (options.dryRun)
69
+ return result;
70
+ for (const { file, fixture } of plan)
71
+ await writeFile(path.join(dir, file), serializeFixture(fixture), "utf-8");
72
+ for (const file of result.removed)
73
+ await unlink(path.join(dir, file));
74
+ return result;
75
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { loadTestScript, buildTestScript } from "./build-test-script.js";
3
+ export { loadTestScript, buildTestScript };