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/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>;
|
package/dist/extract.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED