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
|
@@ -0,0 +1,3176 @@
|
|
|
1
|
+
// studio/server/index.ts
|
|
2
|
+
import express2 from "express";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path15 from "node:path";
|
|
5
|
+
|
|
6
|
+
// studio/server/routes/recordings.ts
|
|
7
|
+
import { Router } from "express";
|
|
8
|
+
import { readdir } from "node:fs/promises";
|
|
9
|
+
import path2 from "node:path";
|
|
10
|
+
|
|
11
|
+
// src/build-test-script.ts
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
|
|
14
|
+
// src/extract.ts
|
|
15
|
+
import JSZip from "jszip";
|
|
16
|
+
var RECORDING_ENTRY = "Recording.xml";
|
|
17
|
+
var RESOURCE_ENTRY = "recording_resource.xml";
|
|
18
|
+
var BPM_ENTRY = "BPMPackage.ax7bpm";
|
|
19
|
+
async function extractAxtr(fileBytes) {
|
|
20
|
+
const zip = await JSZip.loadAsync(fileBytes);
|
|
21
|
+
const recordingEntry = findEntry(zip, RECORDING_ENTRY);
|
|
22
|
+
if (!recordingEntry) {
|
|
23
|
+
throw new Error(`.axtr package does not contain a ${RECORDING_ENTRY} entry`);
|
|
24
|
+
}
|
|
25
|
+
const resourceEntry = findEntry(zip, RESOURCE_ENTRY);
|
|
26
|
+
const bpmEntry = findEntry(zip, BPM_ENTRY);
|
|
27
|
+
return {
|
|
28
|
+
recordingXml: await recordingEntry.async("string"),
|
|
29
|
+
resourceXml: resourceEntry ? await resourceEntry.async("string") : null,
|
|
30
|
+
bpmPackageBytes: bpmEntry ? await bpmEntry.async("nodebuffer") : null
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function findEntry(zip, name) {
|
|
34
|
+
const exact = zip.file(name);
|
|
35
|
+
if (exact) return exact;
|
|
36
|
+
const match = Object.values(zip.files).find(
|
|
37
|
+
(f) => !f.dir && f.name.toLowerCase() === name.toLowerCase()
|
|
38
|
+
);
|
|
39
|
+
return match ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/parse.ts
|
|
43
|
+
import { XMLParser } from "fast-xml-parser";
|
|
44
|
+
var ARRAY_TAGS = /* @__PURE__ */ new Set([
|
|
45
|
+
"Node",
|
|
46
|
+
"Annotation",
|
|
47
|
+
"CommandArgument",
|
|
48
|
+
"string",
|
|
49
|
+
"KeyValueOfstringFormContextyaScvdpM"
|
|
50
|
+
]);
|
|
51
|
+
function makeParser() {
|
|
52
|
+
return new XMLParser({
|
|
53
|
+
ignoreAttributes: false,
|
|
54
|
+
attributeNamePrefix: "@_",
|
|
55
|
+
removeNSPrefix: true,
|
|
56
|
+
parseTagValue: false,
|
|
57
|
+
parseAttributeValue: false,
|
|
58
|
+
trimValues: true,
|
|
59
|
+
isArray: (tagName) => ARRAY_TAGS.has(tagName)
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function isNil(v) {
|
|
63
|
+
if (v === void 0 || v === null) return true;
|
|
64
|
+
if (typeof v === "object" && !Array.isArray(v)) {
|
|
65
|
+
const o = v;
|
|
66
|
+
return o["@_nil"] === "true";
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
function text(v) {
|
|
71
|
+
if (isNil(v)) return null;
|
|
72
|
+
if (typeof v === "string") return v;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function textReq(v, field) {
|
|
76
|
+
const t = text(v);
|
|
77
|
+
if (t === null) throw new Error(`Expected required text field "${field}" to be present`);
|
|
78
|
+
return t;
|
|
79
|
+
}
|
|
80
|
+
function num(v) {
|
|
81
|
+
const t = text(v);
|
|
82
|
+
if (t === null || t === "") return null;
|
|
83
|
+
const n = Number(t);
|
|
84
|
+
return Number.isNaN(n) ? null : n;
|
|
85
|
+
}
|
|
86
|
+
function numReq(v, field) {
|
|
87
|
+
const n = num(v);
|
|
88
|
+
if (n === null) throw new Error(`Expected required numeric field "${field}" to be present`);
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
function bool(v) {
|
|
92
|
+
return text(v) === "true";
|
|
93
|
+
}
|
|
94
|
+
function collection(v, childTag) {
|
|
95
|
+
if (isNil(v)) return [];
|
|
96
|
+
if (typeof v === "string") return [];
|
|
97
|
+
const obj = v;
|
|
98
|
+
const child = obj[childTag];
|
|
99
|
+
if (child === void 0) return [];
|
|
100
|
+
return Array.isArray(child) ? child : [child];
|
|
101
|
+
}
|
|
102
|
+
function parseCommandArgument(raw) {
|
|
103
|
+
return {
|
|
104
|
+
IsReference: bool(raw.IsReference),
|
|
105
|
+
Value: textReq(raw.Value, "CommandArgument.Value")
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function parseFormContext(raw) {
|
|
109
|
+
return {
|
|
110
|
+
FormID: textReq(raw.FormID, "FormContext.FormID"),
|
|
111
|
+
FormName: textReq(raw.FormName, "FormContext.FormName"),
|
|
112
|
+
RecordingName: textReq(raw.RecordingName, "FormContext.RecordingName"),
|
|
113
|
+
HasMetadata: bool(raw.HasMetadata),
|
|
114
|
+
Sequence: numReq(raw.Sequence, "FormContext.Sequence"),
|
|
115
|
+
Annotations: raw.Annotations
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function parseFormContexts(raw) {
|
|
119
|
+
const entries = collection(raw, "KeyValueOfstringFormContextyaScvdpM");
|
|
120
|
+
const result = {};
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const key = textReq(entry.Key, "FormContexts.Key");
|
|
123
|
+
result[key] = parseFormContext(entry.Value);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
function nodeBaseFields(raw) {
|
|
128
|
+
return {
|
|
129
|
+
Id: textReq(raw.Id, "Node.Id"),
|
|
130
|
+
Description: text(raw.Description) ?? "",
|
|
131
|
+
CustomDescription: text(raw.CustomDescription),
|
|
132
|
+
ParentSequence: numReq(raw.ParentSequence, "Node.ParentSequence"),
|
|
133
|
+
Sequence: numReq(raw.Sequence, "Node.Sequence"),
|
|
134
|
+
ScreenshotUri: text(raw.ScreenshotUri)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function userActionBaseFields(raw) {
|
|
138
|
+
return {
|
|
139
|
+
...nodeBaseFields(raw),
|
|
140
|
+
GlobalId: textReq(raw.GlobalId, "UserAction.GlobalId"),
|
|
141
|
+
SystemGenerated: bool(raw.SystemGenerated)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function parseScope(raw) {
|
|
145
|
+
return {
|
|
146
|
+
kind: "Scope",
|
|
147
|
+
...nodeBaseFields(raw),
|
|
148
|
+
Name: text(raw.Name) ?? "",
|
|
149
|
+
IsForm: bool(raw.IsForm),
|
|
150
|
+
IsStepGroup: bool(raw.IsStepGroup),
|
|
151
|
+
ScopeType: text(raw.ScopeType) ?? "",
|
|
152
|
+
Children: collection(raw.Children, "Node").map(
|
|
153
|
+
(n) => parseNode(n)
|
|
154
|
+
)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function parseMenuItemUserAction(raw) {
|
|
158
|
+
return {
|
|
159
|
+
kind: "MenuItemUserAction",
|
|
160
|
+
...userActionBaseFields(raw),
|
|
161
|
+
MenuItemName: textReq(raw.MenuItemName, "MenuItemUserAction.MenuItemName"),
|
|
162
|
+
MenuItemType: text(raw.MenuItemType) ?? "",
|
|
163
|
+
NavigationPath: collection(raw.NavigationPath, "string").map((s) => textReq(s, "NavigationPath.string"))
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function parseCommandUserAction(raw) {
|
|
167
|
+
return {
|
|
168
|
+
kind: "CommandUserAction",
|
|
169
|
+
...userActionBaseFields(raw),
|
|
170
|
+
FormId: textReq(raw.FormId, "CommandUserAction.FormId"),
|
|
171
|
+
ControlName: text(raw.ControlName) ?? "",
|
|
172
|
+
ControlType: text(raw.ControlType) ?? "",
|
|
173
|
+
ControlLabel: text(raw.ControlLabel) ?? "",
|
|
174
|
+
ControlLabelId: text(raw.ControlLabelId) ?? "",
|
|
175
|
+
CommandName: textReq(raw.CommandName, "CommandUserAction.CommandName"),
|
|
176
|
+
Arguments: collection(raw.Arguments, "CommandArgument").map(
|
|
177
|
+
(a) => parseCommandArgument(a)
|
|
178
|
+
),
|
|
179
|
+
ListContext: text(raw.ListContext),
|
|
180
|
+
RowIndex: num(raw.RowIndex),
|
|
181
|
+
ReturnType: text(raw.ReturnType) ?? "",
|
|
182
|
+
ReturnValue: text(raw.ReturnValue),
|
|
183
|
+
TaskId: text(raw.TaskId)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function parsePropertyUserAction(raw) {
|
|
187
|
+
return {
|
|
188
|
+
kind: "PropertyUserAction",
|
|
189
|
+
...userActionBaseFields(raw),
|
|
190
|
+
FormId: textReq(raw.FormId, "PropertyUserAction.FormId"),
|
|
191
|
+
ControlName: text(raw.ControlName) ?? "",
|
|
192
|
+
ControlType: text(raw.ControlType) ?? "",
|
|
193
|
+
ControlLabel: text(raw.ControlLabel) ?? "",
|
|
194
|
+
ControlLabelId: text(raw.ControlLabelId) ?? "",
|
|
195
|
+
BoundDataSource: text(raw.BoundDataSource) ?? "",
|
|
196
|
+
BoundProperty: text(raw.BoundProperty) ?? "",
|
|
197
|
+
PropertyName: text(raw.PropertyName) ?? "",
|
|
198
|
+
UserActionType: text(raw.UserActionType) ?? "",
|
|
199
|
+
Value: text(raw.Value) ?? "",
|
|
200
|
+
ValueLabel: text(raw.ValueLabel),
|
|
201
|
+
IsReference: bool(raw.IsReference),
|
|
202
|
+
ListContext: text(raw.ListContext),
|
|
203
|
+
RowIndex: num(raw.RowIndex)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function parseUnknownUserAction(raw, xsiType) {
|
|
207
|
+
return {
|
|
208
|
+
kind: "Unknown",
|
|
209
|
+
...userActionBaseFields(raw),
|
|
210
|
+
xsiType,
|
|
211
|
+
raw
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function parseNode(raw) {
|
|
215
|
+
const xsiType = raw["@_type"] ?? "Scope";
|
|
216
|
+
switch (xsiType) {
|
|
217
|
+
case "Scope":
|
|
218
|
+
return parseScope(raw);
|
|
219
|
+
case "MenuItemUserAction":
|
|
220
|
+
return parseMenuItemUserAction(raw);
|
|
221
|
+
case "CommandUserAction":
|
|
222
|
+
return parseCommandUserAction(raw);
|
|
223
|
+
case "PropertyUserAction":
|
|
224
|
+
return parsePropertyUserAction(raw);
|
|
225
|
+
default:
|
|
226
|
+
return parseUnknownUserAction(raw, xsiType);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function parseRecordingXml(xml) {
|
|
230
|
+
const parser = makeParser();
|
|
231
|
+
const doc = parser.parse(xml);
|
|
232
|
+
const root = doc.Recording;
|
|
233
|
+
if (!root) {
|
|
234
|
+
throw new Error("Document does not contain a root <Recording> element");
|
|
235
|
+
}
|
|
236
|
+
const rootScopeRaw = root.RootScope;
|
|
237
|
+
const rootScope = {
|
|
238
|
+
kind: "Scope",
|
|
239
|
+
...nodeBaseFields(rootScopeRaw),
|
|
240
|
+
Name: text(root.Name) ?? "",
|
|
241
|
+
IsForm: false,
|
|
242
|
+
IsStepGroup: false,
|
|
243
|
+
ScopeType: "Root",
|
|
244
|
+
Children: collection(rootScopeRaw.Children, "Node").map(
|
|
245
|
+
(n) => parseNode(n)
|
|
246
|
+
)
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
CanonicalId: textReq(root.CanonicalId, "Recording.CanonicalId"),
|
|
250
|
+
CurrentSequence: numReq(root.CurrentSequence, "Recording.CurrentSequence"),
|
|
251
|
+
Description: text(root.Description) ?? "",
|
|
252
|
+
Name: textReq(root.Name, "Recording.Name"),
|
|
253
|
+
FormContexts: parseFormContexts(root.FormContexts),
|
|
254
|
+
RootScope: rootScope
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/normalize.ts
|
|
259
|
+
function formIdOf(node) {
|
|
260
|
+
if (node.kind === "CommandUserAction" || node.kind === "PropertyUserAction") {
|
|
261
|
+
return node.FormId;
|
|
262
|
+
}
|
|
263
|
+
if (node.kind === "Unknown") {
|
|
264
|
+
const raw = node.raw.FormId;
|
|
265
|
+
return typeof raw === "string" ? raw : null;
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
function collectLeaves(node, out) {
|
|
270
|
+
if (node.kind === "Scope") {
|
|
271
|
+
for (const child of node.Children) collectLeaves(child, out);
|
|
272
|
+
} else {
|
|
273
|
+
out.push(node);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function normalizeRecording(recording) {
|
|
277
|
+
const leaves = [];
|
|
278
|
+
collectLeaves(recording.RootScope, leaves);
|
|
279
|
+
const steps = leaves.map((node) => {
|
|
280
|
+
const formId = formIdOf(node);
|
|
281
|
+
return {
|
|
282
|
+
sequence: node.Sequence,
|
|
283
|
+
description: node.Description,
|
|
284
|
+
formId,
|
|
285
|
+
formName: formId ? recording.FormContexts[formId]?.FormName ?? null : null,
|
|
286
|
+
node
|
|
287
|
+
};
|
|
288
|
+
}).sort((a, b) => a.sequence - b.sequence);
|
|
289
|
+
return {
|
|
290
|
+
name: recording.Name,
|
|
291
|
+
canonicalId: recording.CanonicalId,
|
|
292
|
+
description: recording.Description,
|
|
293
|
+
steps
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/mapping.ts
|
|
298
|
+
function skipHint(step) {
|
|
299
|
+
if (step.node.kind === "Scope" || !step.node.SystemGenerated) return null;
|
|
300
|
+
return {
|
|
301
|
+
tool: "skip",
|
|
302
|
+
confidence: "high",
|
|
303
|
+
params: {},
|
|
304
|
+
note: "SystemGenerated=true in the recording; this was an automatic side effect, not a user action. Nothing to replay for this step."
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function menuItemHint(step) {
|
|
308
|
+
if (step.node.kind !== "MenuItemUserAction") return null;
|
|
309
|
+
return {
|
|
310
|
+
tool: "form_open_menu_item",
|
|
311
|
+
confidence: "high",
|
|
312
|
+
params: { name: step.node.MenuItemName, type: step.node.MenuItemType }
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function commandHint(step) {
|
|
316
|
+
if (step.node.kind !== "CommandUserAction") return null;
|
|
317
|
+
const { CommandName, ControlName, ControlType } = step.node;
|
|
318
|
+
if (ControlType === "Grid" && CommandName === "ChangeSelectedIndexInCache") {
|
|
319
|
+
return {
|
|
320
|
+
tool: "form_filter_grid",
|
|
321
|
+
confidence: "low",
|
|
322
|
+
params: { gridName: ControlName },
|
|
323
|
+
note: "Recording selects a grid row by recorded position (Arguments), which is not portable across environments/data. Confirmed on a live replay where the recorded index no longer pointed at the intended record. Identify the target record from surrounding step context (e.g. a business key mentioned in the description) and supply gridColumnName/gridColumnValue to filter the grid to it before selecting a row."
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (ControlType === "Grid" && (CommandName === "NavigationAction" || CommandName === "MarkActiveRow")) {
|
|
327
|
+
return {
|
|
328
|
+
tool: "form_select_grid_row",
|
|
329
|
+
confidence: "low",
|
|
330
|
+
params: { gridName: ControlName },
|
|
331
|
+
note: `Requires rowNumber (read it off the current grid state after filtering) and, per d365-erp-mcp's own per-grid guidance, usually a marking parameter ("Marked" to check the row's selection box, "Unmarked" to just navigate/highlight it).`
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (ControlType === "AppBarTab") {
|
|
335
|
+
return {
|
|
336
|
+
tool: "form_click_control",
|
|
337
|
+
confidence: "high",
|
|
338
|
+
params: { controlName: ControlName },
|
|
339
|
+
note: "Action Pane / ribbon tab selection."
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (ControlType === "Tab" || ControlType === "SectionPage" && CommandName === "ExpandedChanged" || ControlType === "PivotItem" && CommandName === "ActivateTab") {
|
|
343
|
+
return {
|
|
344
|
+
tool: "form_open_or_close_tab",
|
|
345
|
+
confidence: "high",
|
|
346
|
+
params: { tabName: ControlName, tabAction: "Open" },
|
|
347
|
+
note: ControlType === "SectionPage" ? `Confirmed live (form_open_or_close_tab works identically on ControlType=SectionPage, not just Tab). Recording can't tell you whether the user expanded or collapsed the section. Check the current FormState's Tab[name].IsOpen and only call this if it doesn't already match; use tabAction="Close" instead if collapsing.` : ControlType === "PivotItem" ? `Confirmed live on a purchase order line's detail Pivot (e.g. TabLineProject): opening one PivotItem auto-closes whichever sibling was open, so tabAction="Open" is always the right call here (unlike Tab/SectionPage, there's no independent collapse state to worry about).` : void 0
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (CommandName === "RequestPopup" && ControlType === "Input") {
|
|
351
|
+
return {
|
|
352
|
+
tool: "form_open_lookup",
|
|
353
|
+
confidence: "high",
|
|
354
|
+
params: { lookupName: ControlName },
|
|
355
|
+
note: "Confirmed live against a multi-select Purpose/Roles lookup: opens a SysLookupMultiSelectGrid-style popup form. Follow its own returned Guidance for how to select rows and confirm/cancel."
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (CommandName === "RequestPopup" && ControlType === "DropDialogButton") {
|
|
359
|
+
return {
|
|
360
|
+
tool: "form_click_control",
|
|
361
|
+
confidence: "high",
|
|
362
|
+
params: { controlName: ControlName },
|
|
363
|
+
note: "Confirmed live (e.g. the 'New from template' split button on Free text invoice): a DropDialogButton opens its popup via a plain click, same as CLICKABLE_CONTROL_TYPES below. Unlike RequestPopup on an Input control, this is not a lookup."
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (CommandName === "ExecuteHyperlink" && ControlType === "Input") {
|
|
367
|
+
return {
|
|
368
|
+
tool: "form_click_control",
|
|
369
|
+
confidence: "high",
|
|
370
|
+
params: { controlName: ControlName },
|
|
371
|
+
note: "Confirmed live (e.g. clicking a grid row's Name/description link to open its edit dialog): hyperlink-style Input fields are clicked the same way as any other control, despite the different CommandName."
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (CommandName === "SelectionPathChanged" && ControlType === "Tree") {
|
|
375
|
+
return {
|
|
376
|
+
tool: "form_click_control",
|
|
377
|
+
confidence: "medium",
|
|
378
|
+
params: { controlName: ControlName },
|
|
379
|
+
note: `Confirmed live on a category tree lookup (EcoResCategorySingleLookup/ctrlFormTree): tree nodes are clicked via actionId, formatted "Select:<n>" or "Expand:<n>" per the current FormState's Tree[name].Nodes[].Actions. Not a plain click. The node index is a live-session identifier, not portable from the recording (same non-portability caveat as grid row selection): find the target node by matching its Text to the business value you want, expanding parent nodes first if needed, then click with that node's own Actions.Select id.`
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (CommandName === "RequestClose") {
|
|
383
|
+
return {
|
|
384
|
+
tool: "form_close_form",
|
|
385
|
+
confidence: "high",
|
|
386
|
+
params: {},
|
|
387
|
+
note: "Confirmed live: closes the current popup/dialog with no ControlName needed (matches how these steps are recorded; there's no associated button). Some forms with explicit OK/Cancel buttons ask you to use those buttons instead of a direct close; in that case the recording captures a separate Click step on the button, not RequestClose."
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (ControlName === "buttonUpdateConfirm" && CommandName === "Click") {
|
|
391
|
+
return {
|
|
392
|
+
tool: "form_open_menu_item",
|
|
393
|
+
confidence: "medium",
|
|
394
|
+
params: { name: "SalesFormLetter_Confirmation", type: "Action" },
|
|
395
|
+
note: "Confirmed live (headless intercompany replay, 2026-08-20): buttonUpdateConfirm wasn't reachable on the SalesTable order form in this environment. Invoke the SalesFormLetter_Confirmation action menu item directly instead of clicking the recorded button."
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
if (ControlName === "buttonUpdateInvoice" && CommandName === "Click") {
|
|
399
|
+
return {
|
|
400
|
+
tool: "form_click_control",
|
|
401
|
+
confidence: "high",
|
|
402
|
+
params: { controlName: ControlName },
|
|
403
|
+
note: "Confirmed live (headless intercompany replay, 2026-08-20) on an intercompany purchase order (DEMF): clicking buttonUpdateInvoice from the PO form itself opened a USMF-scoped, empty vendor-invoice list; company context didn't carry through. Clicking it from the DEMF list page instead, with the target row marked and companyId set explicitly on that navigation, opened a correctly pre-populated invoice."
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (ControlName === "ButtonInterCompanyCustPackingSlipJournal" && CommandName === "Click") {
|
|
407
|
+
return {
|
|
408
|
+
tool: "form_click_control",
|
|
409
|
+
confidence: "high",
|
|
410
|
+
params: { controlName: ControlName },
|
|
411
|
+
followUp: [
|
|
412
|
+
{
|
|
413
|
+
tool: "form_click_control",
|
|
414
|
+
note: "Confirmed live (headless intercompany replay, 2026-08-20): generating an intercompany product receipt needs an explicit follow-up click on ButtonInterCompanyUpdate; posting the sales-side packing slip alone doesn't produce it."
|
|
415
|
+
}
|
|
416
|
+
]
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const CLICKABLE_CONTROL_TYPES = /* @__PURE__ */ new Set([
|
|
420
|
+
"CommandButton",
|
|
421
|
+
"MenuItemButton",
|
|
422
|
+
"MenuButton",
|
|
423
|
+
"MenuItem",
|
|
424
|
+
"Button"
|
|
425
|
+
]);
|
|
426
|
+
if (CommandName === "Click" && CLICKABLE_CONTROL_TYPES.has(ControlType)) {
|
|
427
|
+
const isNestedMenuChild = ControlType === "MenuButton" || ControlType === "MenuItem";
|
|
428
|
+
return {
|
|
429
|
+
tool: "form_click_control",
|
|
430
|
+
confidence: "high",
|
|
431
|
+
params: { controlName: ControlName },
|
|
432
|
+
...isNestedMenuChild && {
|
|
433
|
+
note: "Nested menu button children (ControlType MenuButton/MenuItem) could be clicked directly by name on a live replay; no separate 'open the dropdown' step was needed even though the recording captured two clicks."
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const filterOrSortHint = filterManagerHint(step.node);
|
|
438
|
+
if (filterOrSortHint) return filterOrSortHint;
|
|
439
|
+
const investigated = INVESTIGATED_BUT_UNVERIFIED[`${CommandName}:${ControlType}`];
|
|
440
|
+
return {
|
|
441
|
+
tool: "form_find_controls",
|
|
442
|
+
confidence: "low",
|
|
443
|
+
params: { controlSearchTerm: ControlName },
|
|
444
|
+
note: investigated ? `Unmapped CommandUserAction (CommandName=${CommandName}, ControlType=${ControlType}). ${investigated}` : `Unmapped CommandUserAction (CommandName=${CommandName}, ControlType=${ControlType}). Resolve via form_find_controls before acting.`
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
function parseJson(raw) {
|
|
448
|
+
if (!raw) return null;
|
|
449
|
+
try {
|
|
450
|
+
return JSON.parse(raw);
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function filterGridColumnName(meta) {
|
|
456
|
+
return `${meta.DataSourceName}_${meta.FieldName}`;
|
|
457
|
+
}
|
|
458
|
+
function filterOperatorToValue(operator, value) {
|
|
459
|
+
if (!value) return "";
|
|
460
|
+
switch (operator) {
|
|
461
|
+
case "BeginsWith":
|
|
462
|
+
return `${value}*`;
|
|
463
|
+
case "Contains":
|
|
464
|
+
return `*${value}*`;
|
|
465
|
+
case "EndsWith":
|
|
466
|
+
return `*${value}`;
|
|
467
|
+
default:
|
|
468
|
+
return value;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
var FILTER_MANAGER_GRID_NAME_NOTE = `gridName isn't recorded on this step and isn't always "Grid". Standard lookup popups (e.g. CustTableLookup) commonly expose exactly one grid literally named "Grid" (confirmed live), but list-page forms (e.g. PurchTable) don't; confirm the real gridName from the current FormState before calling.`;
|
|
472
|
+
function filterManagerHint(node) {
|
|
473
|
+
if (node.kind !== "CommandUserAction") return null;
|
|
474
|
+
const { CommandName, ControlType, Arguments } = node;
|
|
475
|
+
if (CommandName === "GetFilters" && ControlType === "FilterManager") {
|
|
476
|
+
return {
|
|
477
|
+
tool: "skip",
|
|
478
|
+
confidence: "medium",
|
|
479
|
+
params: {},
|
|
480
|
+
note: "Confirmed across every GetFilters:FilterManager occurrence in the recordings library: the embedded filter-field JSON always has an empty Values array. This step just opens/reads the column filter dropdown for display; nothing to replay. If a future recording ever shows a non-empty Values here, treat it like ApplyFiltersForTaskRecorder instead."
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (CommandName === "SetSortsForTaskRecorder" && ControlType === "FilterManager") {
|
|
484
|
+
const meta = parseJson(Arguments[0]?.Value)?.[0];
|
|
485
|
+
const direction = Arguments[Arguments.length - 1]?.Value;
|
|
486
|
+
if (meta && direction) {
|
|
487
|
+
return {
|
|
488
|
+
tool: "form_sort_grid_column",
|
|
489
|
+
confidence: "medium",
|
|
490
|
+
params: {
|
|
491
|
+
gridColumnName: filterGridColumnName(meta),
|
|
492
|
+
sortDirection: /z to a/i.test(direction) ? "Descending" : "Ascending"
|
|
493
|
+
},
|
|
494
|
+
note: `sortDirection parsed from the recording's human-readable direction ("${direction}"). ${FILTER_MANAGER_GRID_NAME_NOTE}`
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (CommandName === "ApplyFiltersForTaskRecorder" && ControlType === "FilterManager") {
|
|
499
|
+
const meta = parseJson(Arguments[0]?.Value)?.[0];
|
|
500
|
+
if (meta) {
|
|
501
|
+
return {
|
|
502
|
+
tool: "form_filter_grid",
|
|
503
|
+
confidence: "medium",
|
|
504
|
+
params: {
|
|
505
|
+
gridColumnName: filterGridColumnName(meta),
|
|
506
|
+
gridColumnValue: filterOperatorToValue(meta.Operator, meta.Values?.[0] ?? "")
|
|
507
|
+
},
|
|
508
|
+
note: FILTER_MANAGER_GRID_NAME_NOTE
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (CommandName === "ApplyFilterForTaskRecorder" && ControlType === "QuickFilter") {
|
|
513
|
+
const meta = parseJson(Arguments[2]?.Value);
|
|
514
|
+
if (meta) {
|
|
515
|
+
return {
|
|
516
|
+
tool: "form_filter_grid",
|
|
517
|
+
confidence: "medium",
|
|
518
|
+
params: { gridColumnName: meta.FieldName, gridColumnValue: meta.Value },
|
|
519
|
+
note: `gridColumnName/gridColumnValue parsed directly from the step's embedded JSON (already DataSource_Field form here, e.g. "ProjTable_Name", unlike the FilterManager family). ${FILTER_MANAGER_GRID_NAME_NOTE}`
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (CommandName === "ApplyFilters" && ControlType === "FilterPane") {
|
|
524
|
+
const metas = (parseJson(Arguments[2]?.Value) ?? []).filter(
|
|
525
|
+
(m) => (m.Values?.[0] ?? "") !== ""
|
|
526
|
+
);
|
|
527
|
+
if (metas.length > 0) {
|
|
528
|
+
const [first, ...rest] = metas;
|
|
529
|
+
return {
|
|
530
|
+
tool: "form_filter_grid",
|
|
531
|
+
confidence: "medium",
|
|
532
|
+
params: {
|
|
533
|
+
gridColumnName: filterGridColumnName(first),
|
|
534
|
+
gridColumnValue: filterOperatorToValue(first.Operator, first.Values[0])
|
|
535
|
+
},
|
|
536
|
+
followUp: rest.map((m) => ({
|
|
537
|
+
tool: "form_filter_grid",
|
|
538
|
+
note: `Also apply gridColumnName=${filterGridColumnName(m)}, gridColumnValue=${filterOperatorToValue(m.Operator, m.Values[0])} on the same grid.`
|
|
539
|
+
})),
|
|
540
|
+
note: `A FilterPane step can carry filters for multiple fields at once; fields with an empty recorded value are omitted (blank means "no filter" for that field, not "clear it"). ${FILTER_MANAGER_GRID_NAME_NOTE}`
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
var INVESTIGATED_BUT_UNVERIFIED = {
|
|
547
|
+
"GetFilters:FilterManager": "Expected empty Arguments; see filterManagerHint in this file.",
|
|
548
|
+
"ApplyFilterForTaskRecorder:QuickFilter": "Expected Arguments[2] to be JSON with FieldName/Value; see filterManagerHint in this file.",
|
|
549
|
+
"ApplyFiltersForTaskRecorder:FilterManager": "Expected Arguments[0] to be a JSON array with DataSourceName/FieldName/Operator/Values; see filterManagerHint in this file.",
|
|
550
|
+
"SetSortsForTaskRecorder:FilterManager": "Expected Arguments[0] to be a JSON array with DataSourceName/FieldName, and a trailing human-readable direction; see filterManagerHint in this file.",
|
|
551
|
+
"ApplyFilters:FilterPane": "Expected Arguments[2] to be a JSON array of per-field filter objects; see filterManagerHint in this file."
|
|
552
|
+
};
|
|
553
|
+
var ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
554
|
+
function formatDateForControl(value) {
|
|
555
|
+
const match = ISO_DATE.exec(value);
|
|
556
|
+
if (!match) return value;
|
|
557
|
+
const [, year, month, day] = match;
|
|
558
|
+
return `${Number(month)}/${Number(day)}/${Number(year)}`;
|
|
559
|
+
}
|
|
560
|
+
function propertyHint(step) {
|
|
561
|
+
if (step.node.kind !== "PropertyUserAction") return null;
|
|
562
|
+
const { ControlName, ControlType, Value, ListContext, UserActionType } = step.node;
|
|
563
|
+
const formattedValue = ControlType === "Date" && Value ? formatDateForControl(Value) : Value;
|
|
564
|
+
if (ListContext && UserActionType !== "Input") {
|
|
565
|
+
return {
|
|
566
|
+
tool: "form_filter_grid",
|
|
567
|
+
confidence: "medium",
|
|
568
|
+
params: { gridName: ListContext, gridColumnName: ControlName, gridColumnValue: formattedValue },
|
|
569
|
+
note: `Value was entered on a grid-bound control (UserActionType="${UserActionType}"); may be a filter rather than a cell edit.`
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const liveControlName = ControlName === "PurchParmTable_gridParmTableNum" ? "PurchParmTable_Num" : ControlName;
|
|
573
|
+
return {
|
|
574
|
+
tool: "form_set_control_values",
|
|
575
|
+
confidence: "high",
|
|
576
|
+
params: { setControlValues: [{ controlName: liveControlName, value: formattedValue }] },
|
|
577
|
+
followUp: [
|
|
578
|
+
{
|
|
579
|
+
tool: "form_save_form",
|
|
580
|
+
note: "Confirmed live: the form reports 'has been modified and needs to be saved' after form_set_control_values. Edits are not persisted until form_save_form is called."
|
|
581
|
+
}
|
|
582
|
+
]
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function mapStep(step) {
|
|
586
|
+
return skipHint(step) ?? menuItemHint(step) ?? commandHint(step) ?? propertyHint(step);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/bpm.ts
|
|
590
|
+
import JSZip2 from "jszip";
|
|
591
|
+
import { XMLParser as XMLParser2 } from "fast-xml-parser";
|
|
592
|
+
function asArray(v) {
|
|
593
|
+
if (v === void 0) return [];
|
|
594
|
+
return Array.isArray(v) ? v : [v];
|
|
595
|
+
}
|
|
596
|
+
function parseProcessStepXml(xml) {
|
|
597
|
+
const parser = new XMLParser2({ ignoreAttributes: false, attributeNamePrefix: "" });
|
|
598
|
+
const doc = parser.parse(xml);
|
|
599
|
+
const tasks = asArray(doc.Activities?.Task);
|
|
600
|
+
return {
|
|
601
|
+
tasks: tasks.map((t) => ({
|
|
602
|
+
id: String(t.Id ?? ""),
|
|
603
|
+
aotName: String(t.AOTName ?? ""),
|
|
604
|
+
objectType: String(t.ObjectType ?? ""),
|
|
605
|
+
configurationKey: String(t.ConfigurationKey ?? ""),
|
|
606
|
+
layer: String(t.Layer ?? ""),
|
|
607
|
+
model: String(t.Model ?? ""),
|
|
608
|
+
sequence: Number(t.Sequence ?? 0),
|
|
609
|
+
security: asArray(t.Permissions?.Security).map((s) => ({
|
|
610
|
+
roleName: String(s.RoleName ?? ""),
|
|
611
|
+
roleDisplayName: String(s.RoleDisplayName ?? ""),
|
|
612
|
+
dutyName: String(s.DutyName ?? ""),
|
|
613
|
+
dutyDisplayName: String(s.DutyDisplayName ?? ""),
|
|
614
|
+
privilegeName: String(s.PrivilegeName ?? ""),
|
|
615
|
+
privilegeDisplayName: String(s.PrivilegeDisplayName ?? ""),
|
|
616
|
+
accessLevel: String(s.AccessLevel ?? ""),
|
|
617
|
+
isValidRole: String(s.IsValidRole ?? "").toLowerCase() === "true"
|
|
618
|
+
})),
|
|
619
|
+
connectsTo: asArray(t.Connections?.Connection).map((c) => String(c.Target ?? ""))
|
|
620
|
+
}))
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
async function parseBpmPackage(bytes) {
|
|
624
|
+
if (!bytes) return null;
|
|
625
|
+
const zip = await JSZip2.loadAsync(bytes);
|
|
626
|
+
const entry = zip.file("Recording/ProcessStep.xml") ?? zip.file("Recording\\ProcessStep.xml") ?? null;
|
|
627
|
+
if (!entry) return null;
|
|
628
|
+
const xml = await entry.async("string");
|
|
629
|
+
return parseProcessStepXml(xml);
|
|
630
|
+
}
|
|
631
|
+
function distinctPrivileges(pkg) {
|
|
632
|
+
const set = /* @__PURE__ */ new Set();
|
|
633
|
+
for (const task of pkg.tasks) {
|
|
634
|
+
for (const sec of task.security) {
|
|
635
|
+
if (sec.privilegeName) set.add(sec.privilegeName);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return [...set];
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/build-test-script.ts
|
|
642
|
+
async function loadTestScript(axtrPath) {
|
|
643
|
+
const bytes = await readFile(axtrPath);
|
|
644
|
+
const extracted = await extractAxtr(bytes);
|
|
645
|
+
const recording = parseRecordingXml(extracted.recordingXml);
|
|
646
|
+
return normalizeRecording(recording);
|
|
647
|
+
}
|
|
648
|
+
async function buildTestScript(axtrPath) {
|
|
649
|
+
const bytes = await readFile(axtrPath);
|
|
650
|
+
const extracted = await extractAxtr(bytes);
|
|
651
|
+
const recording = parseRecordingXml(extracted.recordingXml);
|
|
652
|
+
const script = normalizeRecording(recording);
|
|
653
|
+
const bpm = await parseBpmPackage(extracted.bpmPackageBytes);
|
|
654
|
+
return {
|
|
655
|
+
name: script.name,
|
|
656
|
+
canonicalId: script.canonicalId,
|
|
657
|
+
description: script.description,
|
|
658
|
+
steps: script.steps.map((step) => ({
|
|
659
|
+
sequence: step.sequence,
|
|
660
|
+
description: step.description,
|
|
661
|
+
formName: step.formName,
|
|
662
|
+
nodeType: step.node.kind,
|
|
663
|
+
node: step.node,
|
|
664
|
+
hint: mapStep(step)
|
|
665
|
+
})),
|
|
666
|
+
security: bpm ? {
|
|
667
|
+
requiredPrivileges: distinctPrivileges(bpm),
|
|
668
|
+
formsVisited: bpm.tasks.map((t) => ({ aotName: t.aotName, objectType: t.objectType }))
|
|
669
|
+
} : null
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/suggest-inputs.ts
|
|
674
|
+
function uniqueName(base, seen) {
|
|
675
|
+
if (!seen.has(base)) {
|
|
676
|
+
seen.add(base);
|
|
677
|
+
return base;
|
|
678
|
+
}
|
|
679
|
+
let n = 2;
|
|
680
|
+
while (seen.has(`${base}_${n}`)) n++;
|
|
681
|
+
const name = `${base}_${n}`;
|
|
682
|
+
seen.add(name);
|
|
683
|
+
return name;
|
|
684
|
+
}
|
|
685
|
+
function draftFromPropertyStep(step, seen) {
|
|
686
|
+
if (step.node.kind !== "PropertyUserAction") return null;
|
|
687
|
+
const controlName = step.node.ControlName || `step${step.sequence}`;
|
|
688
|
+
const name = uniqueName(controlName, seen);
|
|
689
|
+
return {
|
|
690
|
+
name,
|
|
691
|
+
// ControlLabel is the human-readable label D365FO itself shows next to the field (e.g.
|
|
692
|
+
// "Quantity"), as opposed to `name`/controlName which is the internal control identifier
|
|
693
|
+
// (e.g. "PurchLine_PurchQtyGrid") -- useful for UIs that want to show a form-goer-friendly
|
|
694
|
+
// label instead of the raw control name.
|
|
695
|
+
controlLabel: step.node.ControlLabel || void 0,
|
|
696
|
+
description: step.description || `Value for ${controlName}`,
|
|
697
|
+
example: step.node.Value,
|
|
698
|
+
required: true,
|
|
699
|
+
bindTo: { kind: "control", stepSequence: step.sequence, controlName }
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
function draftFromGridFilterStep(step, seen) {
|
|
703
|
+
const hint = mapStep(step);
|
|
704
|
+
if (!hint || hint.tool !== "form_filter_grid") return null;
|
|
705
|
+
const recordedValue = typeof hint.params.gridColumnValue === "string" ? hint.params.gridColumnValue : void 0;
|
|
706
|
+
const name = uniqueName(`record_${step.sequence}`, seen);
|
|
707
|
+
return {
|
|
708
|
+
name,
|
|
709
|
+
description: `Needs a business key to identify the record for: "${step.description}". ` + (recordedValue ? `The recording filtered by "${recordedValue}", one specific record from that session -- ` : "The source recording only captured a row position, which is not portable across environments/data -- ") + "supply the actual value (e.g. an account number, order id) for this environment/data here.",
|
|
710
|
+
required: true,
|
|
711
|
+
example: recordedValue,
|
|
712
|
+
bindTo: { kind: "grid-row-selector", stepSequence: step.sequence, note: step.description }
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
function suggestInputs(script) {
|
|
716
|
+
const seen = /* @__PURE__ */ new Set();
|
|
717
|
+
const candidates = [];
|
|
718
|
+
for (const step of script.steps) {
|
|
719
|
+
const fromProperty = draftFromPropertyStep(step, seen);
|
|
720
|
+
if (fromProperty) {
|
|
721
|
+
candidates.push(fromProperty);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const fromGridFilter = draftFromGridFilterStep(step, seen);
|
|
725
|
+
if (fromGridFilter) candidates.push(fromGridFilter);
|
|
726
|
+
}
|
|
727
|
+
return { testScriptName: script.name, candidates };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// studio/server/paths.ts
|
|
731
|
+
import path from "node:path";
|
|
732
|
+
var WORKING_FOLDER = process.cwd();
|
|
733
|
+
var RECORDINGS_DIR = path.join(WORKING_FOLDER, "recordings");
|
|
734
|
+
var TESTCASES_DIR = path.join(WORKING_FOLDER, "testcases");
|
|
735
|
+
var TESTDATA_DIR = path.join(WORKING_FOLDER, "testdata");
|
|
736
|
+
var TESTSCRIPTS_DIR = path.join(WORKING_FOLDER, "testscripts");
|
|
737
|
+
var TESTRUNS_DIR = path.join(WORKING_FOLDER, "testruns");
|
|
738
|
+
var SUITES_DIR = path.join(WORKING_FOLDER, "suites");
|
|
739
|
+
var PLANS_DIR = path.join(WORKING_FOLDER, "plans");
|
|
740
|
+
var RUNS_DIR = path.join(WORKING_FOLDER, "runs");
|
|
741
|
+
function getWorkingFolder() {
|
|
742
|
+
return WORKING_FOLDER;
|
|
743
|
+
}
|
|
744
|
+
function allDataDirs() {
|
|
745
|
+
return [RECORDINGS_DIR, TESTCASES_DIR, TESTDATA_DIR, TESTSCRIPTS_DIR, TESTRUNS_DIR, SUITES_DIR, PLANS_DIR, RUNS_DIR];
|
|
746
|
+
}
|
|
747
|
+
function setWorkingFolder(folder) {
|
|
748
|
+
WORKING_FOLDER = folder;
|
|
749
|
+
RECORDINGS_DIR = path.join(WORKING_FOLDER, "recordings");
|
|
750
|
+
TESTCASES_DIR = path.join(WORKING_FOLDER, "testcases");
|
|
751
|
+
TESTDATA_DIR = path.join(WORKING_FOLDER, "testdata");
|
|
752
|
+
TESTSCRIPTS_DIR = path.join(WORKING_FOLDER, "testscripts");
|
|
753
|
+
TESTRUNS_DIR = path.join(WORKING_FOLDER, "testruns");
|
|
754
|
+
SUITES_DIR = path.join(WORKING_FOLDER, "suites");
|
|
755
|
+
PLANS_DIR = path.join(WORKING_FOLDER, "plans");
|
|
756
|
+
RUNS_DIR = path.join(WORKING_FOLDER, "runs");
|
|
757
|
+
}
|
|
758
|
+
function safeFileName(name) {
|
|
759
|
+
const base = path.basename(name);
|
|
760
|
+
if (!base || base === "." || base === "..") throw new Error(`Invalid file name: "${name}"`);
|
|
761
|
+
return base;
|
|
762
|
+
}
|
|
763
|
+
function safeRecordingPath(relativePath) {
|
|
764
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
765
|
+
const resolved = path.resolve(RECORDINGS_DIR, normalized);
|
|
766
|
+
if (resolved !== RECORDINGS_DIR && !resolved.startsWith(RECORDINGS_DIR + path.sep)) {
|
|
767
|
+
throw new Error(`Invalid recording path: "${relativePath}"`);
|
|
768
|
+
}
|
|
769
|
+
return resolved;
|
|
770
|
+
}
|
|
771
|
+
function safeTestScriptPath(testScriptPath) {
|
|
772
|
+
const normalized = testScriptPath.replace(/\\/g, "/");
|
|
773
|
+
const resolved = path.resolve(TESTCASES_DIR, normalized);
|
|
774
|
+
if (resolved !== TESTSCRIPTS_DIR && !resolved.startsWith(TESTSCRIPTS_DIR + path.sep)) {
|
|
775
|
+
throw new Error(`Invalid testScriptPath: "${testScriptPath}"`);
|
|
776
|
+
}
|
|
777
|
+
return resolved;
|
|
778
|
+
}
|
|
779
|
+
function safeSuitePathFromPlan(suitePath) {
|
|
780
|
+
const normalized = suitePath.replace(/\\/g, "/");
|
|
781
|
+
const resolved = path.resolve(PLANS_DIR, normalized);
|
|
782
|
+
if (resolved !== SUITES_DIR && !resolved.startsWith(SUITES_DIR + path.sep)) {
|
|
783
|
+
throw new Error(`Invalid suitePath: "${suitePath}"`);
|
|
784
|
+
}
|
|
785
|
+
return resolved;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// studio/server/routes/recordings.ts
|
|
789
|
+
var recordingsRouter = Router();
|
|
790
|
+
async function listAxtrFilesRecursive(dir) {
|
|
791
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
792
|
+
const results = [];
|
|
793
|
+
for (const entry of entries) {
|
|
794
|
+
const fullPath = path2.join(dir, entry.name);
|
|
795
|
+
if (entry.isDirectory()) {
|
|
796
|
+
results.push(...await listAxtrFilesRecursive(fullPath));
|
|
797
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".axtr")) {
|
|
798
|
+
results.push(path2.relative(RECORDINGS_DIR, fullPath).replace(/\\/g, "/"));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return results;
|
|
802
|
+
}
|
|
803
|
+
recordingsRouter.get("/", async (_req, res) => {
|
|
804
|
+
const files = await listAxtrFilesRecursive(RECORDINGS_DIR);
|
|
805
|
+
res.json({ files: files.sort() });
|
|
806
|
+
});
|
|
807
|
+
function recordingFileFromBody(req) {
|
|
808
|
+
const body = req.body;
|
|
809
|
+
if (typeof body.file !== "string" || body.file.length === 0) {
|
|
810
|
+
throw new Error('"file" is required');
|
|
811
|
+
}
|
|
812
|
+
return body.file;
|
|
813
|
+
}
|
|
814
|
+
recordingsRouter.post("/parse", async (req, res) => {
|
|
815
|
+
const axtrPath = safeRecordingPath(recordingFileFromBody(req));
|
|
816
|
+
res.json(await buildTestScript(axtrPath));
|
|
817
|
+
});
|
|
818
|
+
recordingsRouter.post("/suggest-inputs", async (req, res) => {
|
|
819
|
+
const axtrPath = safeRecordingPath(recordingFileFromBody(req));
|
|
820
|
+
const script = await loadTestScript(axtrPath);
|
|
821
|
+
res.json(suggestInputs(script));
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// studio/server/routes/testcases.ts
|
|
825
|
+
import { Router as Router2 } from "express";
|
|
826
|
+
import { readdir as readdir2, readFile as readFile2, writeFile, unlink, mkdir, stat } from "node:fs/promises";
|
|
827
|
+
import path3 from "node:path";
|
|
828
|
+
|
|
829
|
+
// src/testcase-schema.ts
|
|
830
|
+
function fail(field, reason) {
|
|
831
|
+
throw new Error(`Invalid TestCase: ${field} ${reason}`);
|
|
832
|
+
}
|
|
833
|
+
function assertString(value, field) {
|
|
834
|
+
if (typeof value !== "string" || value.length === 0) fail(field, "must be a non-empty string");
|
|
835
|
+
}
|
|
836
|
+
function assertBindTarget(value, field) {
|
|
837
|
+
if (typeof value !== "object" || value === null) fail(field, "must be an object");
|
|
838
|
+
const v = value;
|
|
839
|
+
if (typeof v.stepSequence !== "number") fail(`${field}.stepSequence`, "must be a number");
|
|
840
|
+
if (v.kind === "control") {
|
|
841
|
+
assertString(v.controlName, `${field}.controlName`);
|
|
842
|
+
} else if (v.kind === "grid-row-selector") {
|
|
843
|
+
assertString(v.note, `${field}.note`);
|
|
844
|
+
} else {
|
|
845
|
+
fail(`${field}.kind`, 'must be "control" or "grid-row-selector"');
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
function validateInputParamSpec(value, index) {
|
|
849
|
+
if (typeof value !== "object" || value === null) fail(`inputs[${index}]`, "must be an object");
|
|
850
|
+
const v = value;
|
|
851
|
+
assertString(v.name, `inputs[${index}].name`);
|
|
852
|
+
assertString(v.description, `inputs[${index}].description`);
|
|
853
|
+
if (typeof v.required !== "boolean") fail(`inputs[${index}].required`, "must be a boolean");
|
|
854
|
+
assertBindTarget(v.bindTo, `inputs[${index}].bindTo`);
|
|
855
|
+
if (v.example !== void 0) assertString(v.example, `inputs[${index}].example`);
|
|
856
|
+
if (v.controlLabel !== void 0) assertString(v.controlLabel, `inputs[${index}].controlLabel`);
|
|
857
|
+
return v;
|
|
858
|
+
}
|
|
859
|
+
function validateOutputCaptureSpec(value, index) {
|
|
860
|
+
if (typeof value !== "object" || value === null) fail(`outputs[${index}]`, "must be an object");
|
|
861
|
+
const v = value;
|
|
862
|
+
assertString(v.name, `outputs[${index}].name`);
|
|
863
|
+
if (v.kind === "odata") {
|
|
864
|
+
assertString(v.entitySet, `outputs[${index}].entitySet`);
|
|
865
|
+
assertString(v.filter, `outputs[${index}].filter`);
|
|
866
|
+
assertString(v.field, `outputs[${index}].field`);
|
|
867
|
+
} else if (v.kind === "uiField") {
|
|
868
|
+
if (typeof v.afterStepSequence !== "number") {
|
|
869
|
+
fail(`outputs[${index}].afterStepSequence`, "must be a number");
|
|
870
|
+
}
|
|
871
|
+
if (v.controlName !== void 0 && typeof v.controlName !== "string") {
|
|
872
|
+
fail(`outputs[${index}].controlName`, "must be a string");
|
|
873
|
+
}
|
|
874
|
+
if (v.hint !== void 0 && typeof v.hint !== "string") {
|
|
875
|
+
fail(`outputs[${index}].hint`, "must be a string");
|
|
876
|
+
}
|
|
877
|
+
if (!v.controlName && !v.hint) {
|
|
878
|
+
fail(`outputs[${index}]`, "must have a non-empty controlName or hint");
|
|
879
|
+
}
|
|
880
|
+
} else {
|
|
881
|
+
fail(`outputs[${index}].kind`, 'must be "odata" or "uiField"');
|
|
882
|
+
}
|
|
883
|
+
return v;
|
|
884
|
+
}
|
|
885
|
+
function validateExpectation(value, index) {
|
|
886
|
+
if (typeof value !== "object" || value === null) {
|
|
887
|
+
fail(`expectations[${index}]`, "must be an object");
|
|
888
|
+
}
|
|
889
|
+
const v = value;
|
|
890
|
+
assertString(v.description, `expectations[${index}].description`);
|
|
891
|
+
if (typeof v.checkAfterStepSequence !== "number") {
|
|
892
|
+
fail(`expectations[${index}].checkAfterStepSequence`, "must be a number");
|
|
893
|
+
}
|
|
894
|
+
if (v.kind === "odata") {
|
|
895
|
+
assertString(v.entitySet, `expectations[${index}].entitySet`);
|
|
896
|
+
assertString(v.filter, `expectations[${index}].filter`);
|
|
897
|
+
assertString(v.expectedField, `expectations[${index}].expectedField`);
|
|
898
|
+
assertString(v.expectedValue, `expectations[${index}].expectedValue`);
|
|
899
|
+
assertString(v.comparator, `expectations[${index}].comparator`);
|
|
900
|
+
} else if (v.kind === "uiMessage") {
|
|
901
|
+
assertString(v.severity, `expectations[${index}].severity`);
|
|
902
|
+
if (typeof v.pattern !== "string") fail(`expectations[${index}].pattern`, "must be a string");
|
|
903
|
+
if (typeof v.shouldExist !== "boolean") {
|
|
904
|
+
fail(`expectations[${index}].shouldExist`, "must be a boolean");
|
|
905
|
+
}
|
|
906
|
+
} else {
|
|
907
|
+
fail(`expectations[${index}].kind`, 'must be "odata" or "uiMessage"');
|
|
908
|
+
}
|
|
909
|
+
return v;
|
|
910
|
+
}
|
|
911
|
+
function validateTestCase(value) {
|
|
912
|
+
if (typeof value !== "object" || value === null) fail("testcase", "must be an object");
|
|
913
|
+
const v = value;
|
|
914
|
+
assertString(v.id, "id");
|
|
915
|
+
assertString(v.name, "name");
|
|
916
|
+
assertString(v.description, "description");
|
|
917
|
+
assertString(v.testScriptPath, "testScriptPath");
|
|
918
|
+
if (v.sourceRecordingFolder !== void 0) assertString(v.sourceRecordingFolder, "sourceRecordingFolder");
|
|
919
|
+
if (!Array.isArray(v.inputs)) fail("inputs", "must be an array");
|
|
920
|
+
if (!Array.isArray(v.outputs)) fail("outputs", "must be an array");
|
|
921
|
+
if (!Array.isArray(v.expectations)) fail("expectations", "must be an array");
|
|
922
|
+
return {
|
|
923
|
+
id: v.id,
|
|
924
|
+
name: v.name,
|
|
925
|
+
description: v.description,
|
|
926
|
+
testScriptPath: v.testScriptPath,
|
|
927
|
+
...v.sourceRecordingFolder !== void 0 ? { sourceRecordingFolder: v.sourceRecordingFolder } : {},
|
|
928
|
+
inputs: v.inputs.map(validateInputParamSpec),
|
|
929
|
+
outputs: v.outputs.map(validateOutputCaptureSpec),
|
|
930
|
+
expectations: v.expectations.map(validateExpectation)
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// studio/server/routes/testcases.ts
|
|
935
|
+
var testCasesRouter = Router2();
|
|
936
|
+
testCasesRouter.get("/", async (_req, res) => {
|
|
937
|
+
const files = (await readdir2(TESTCASES_DIR)).filter((f) => f.endsWith(".json"));
|
|
938
|
+
const summaries = [];
|
|
939
|
+
for (const file of files) {
|
|
940
|
+
try {
|
|
941
|
+
const raw = JSON.parse(await readFile2(path3.join(TESTCASES_DIR, file), "utf-8"));
|
|
942
|
+
const testCase = validateTestCase(raw);
|
|
943
|
+
summaries.push({
|
|
944
|
+
id: testCase.id,
|
|
945
|
+
name: testCase.name,
|
|
946
|
+
description: testCase.description,
|
|
947
|
+
inputCount: testCase.inputs.length,
|
|
948
|
+
outputCount: testCase.outputs.length,
|
|
949
|
+
inputs: testCase.inputs,
|
|
950
|
+
sourceRecordingFolder: testCase.sourceRecordingFolder
|
|
951
|
+
});
|
|
952
|
+
} catch (err) {
|
|
953
|
+
summaries.push({
|
|
954
|
+
id: file.replace(/\.json$/, ""),
|
|
955
|
+
name: file,
|
|
956
|
+
description: `Invalid test case file: ${err instanceof Error ? err.message : String(err)}`,
|
|
957
|
+
inputCount: 0,
|
|
958
|
+
outputCount: 0,
|
|
959
|
+
inputs: [],
|
|
960
|
+
invalid: true
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
res.json({ testCases: summaries });
|
|
965
|
+
});
|
|
966
|
+
testCasesRouter.get("/:id", async (req, res) => {
|
|
967
|
+
const id = safeFileName(req.params.id);
|
|
968
|
+
const raw = JSON.parse(await readFile2(path3.join(TESTCASES_DIR, `${id}.json`), "utf-8"));
|
|
969
|
+
res.json(validateTestCase(raw));
|
|
970
|
+
});
|
|
971
|
+
testCasesRouter.get("/:id/script", async (req, res) => {
|
|
972
|
+
const id = safeFileName(req.params.id);
|
|
973
|
+
const raw = JSON.parse(await readFile2(path3.join(TESTCASES_DIR, `${id}.json`), "utf-8"));
|
|
974
|
+
const testCase = validateTestCase(raw);
|
|
975
|
+
const scriptPath = safeTestScriptPath(testCase.testScriptPath);
|
|
976
|
+
res.json(JSON.parse(await readFile2(scriptPath, "utf-8")));
|
|
977
|
+
});
|
|
978
|
+
async function fileExists(filePath) {
|
|
979
|
+
try {
|
|
980
|
+
await stat(filePath);
|
|
981
|
+
return true;
|
|
982
|
+
} catch {
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
testCasesRouter.post("/", async (req, res) => {
|
|
987
|
+
const body = req.body;
|
|
988
|
+
if (typeof body.id !== "string" || body.id.length === 0) {
|
|
989
|
+
res.status(400).json({ error: '"id" is required' });
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
if (typeof body.sourceRecording !== "string" || body.sourceRecording.length === 0) {
|
|
993
|
+
res.status(400).json({ error: '"sourceRecording" is required' });
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const id = safeFileName(body.id);
|
|
997
|
+
const testCasePath = path3.join(TESTCASES_DIR, `${id}.json`);
|
|
998
|
+
if (await fileExists(testCasePath)) {
|
|
999
|
+
res.status(409).json({ error: `Test case file "${id}.json" already exists` });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const axtrPath = safeRecordingPath(body.sourceRecording);
|
|
1003
|
+
const testScript = await buildTestScript(axtrPath);
|
|
1004
|
+
await mkdir(TESTSCRIPTS_DIR, { recursive: true });
|
|
1005
|
+
const testScriptFileName = `${id}.testscript.json`;
|
|
1006
|
+
await writeFile(
|
|
1007
|
+
path3.join(TESTSCRIPTS_DIR, testScriptFileName),
|
|
1008
|
+
JSON.stringify(testScript, null, 2) + "\n",
|
|
1009
|
+
"utf-8"
|
|
1010
|
+
);
|
|
1011
|
+
const recordingParts = body.sourceRecording.split("/");
|
|
1012
|
+
const sourceRecordingFolder = recordingParts.length > 1 ? recordingParts.slice(0, -1).join("/") : void 0;
|
|
1013
|
+
const trimmedDescription = typeof body.description === "string" ? body.description.trim() : "";
|
|
1014
|
+
const testCase = validateTestCase({
|
|
1015
|
+
id,
|
|
1016
|
+
name: body.name,
|
|
1017
|
+
description: trimmedDescription.length > 0 ? trimmedDescription : `Created via TRXA Studio from ${body.sourceRecording}.`,
|
|
1018
|
+
testScriptPath: `../testscripts/${testScriptFileName}`,
|
|
1019
|
+
...sourceRecordingFolder ? { sourceRecordingFolder } : {},
|
|
1020
|
+
inputs: body.inputs ?? [],
|
|
1021
|
+
outputs: [],
|
|
1022
|
+
expectations: []
|
|
1023
|
+
});
|
|
1024
|
+
await writeFile(testCasePath, JSON.stringify(testCase, null, 2) + "\n", "utf-8");
|
|
1025
|
+
res.status(201).json(testCase);
|
|
1026
|
+
});
|
|
1027
|
+
testCasesRouter.put("/:id", async (req, res) => {
|
|
1028
|
+
const id = safeFileName(req.params.id);
|
|
1029
|
+
const testCasePath = path3.join(TESTCASES_DIR, `${id}.json`);
|
|
1030
|
+
if (!await fileExists(testCasePath)) {
|
|
1031
|
+
res.status(404).json({ error: `Test case file "${id}.json" does not exist` });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const existing = validateTestCase(JSON.parse(await readFile2(testCasePath, "utf-8")));
|
|
1035
|
+
const testCase = validateTestCase({ ...req.body, id: existing.id, testScriptPath: existing.testScriptPath });
|
|
1036
|
+
await writeFile(testCasePath, JSON.stringify(testCase, null, 2) + "\n", "utf-8");
|
|
1037
|
+
res.json(testCase);
|
|
1038
|
+
});
|
|
1039
|
+
testCasesRouter.delete("/:id", async (req, res) => {
|
|
1040
|
+
const id = safeFileName(req.params.id);
|
|
1041
|
+
await unlink(path3.join(TESTCASES_DIR, `${id}.json`));
|
|
1042
|
+
res.status(204).end();
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// studio/server/routes/fixtures.ts
|
|
1046
|
+
import { Router as Router3 } from "express";
|
|
1047
|
+
import { readdir as readdir4, readFile as readFile4, writeFile as writeFile3, unlink as unlink3, rename } from "node:fs/promises";
|
|
1048
|
+
import path5 from "node:path";
|
|
1049
|
+
|
|
1050
|
+
// src/fixture-schema.ts
|
|
1051
|
+
var FIXTURE_NAME_MAX_LENGTH = 64;
|
|
1052
|
+
var FIXTURE_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9 ._-]*[A-Za-z0-9_-])?$/;
|
|
1053
|
+
function fixtureNameProblem(name) {
|
|
1054
|
+
if (name.length === 0) return "must not be empty";
|
|
1055
|
+
if (name.length > FIXTURE_NAME_MAX_LENGTH) return `must be at most ${FIXTURE_NAME_MAX_LENGTH} characters`;
|
|
1056
|
+
if (!FIXTURE_NAME_PATTERN.test(name)) {
|
|
1057
|
+
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";
|
|
1058
|
+
}
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
function fail2(field, reason) {
|
|
1062
|
+
throw new Error(`Invalid Fixture: ${field} ${reason}`);
|
|
1063
|
+
}
|
|
1064
|
+
function assertString2(value, field) {
|
|
1065
|
+
if (typeof value !== "string" || value.length === 0) fail2(field, "must be a non-empty string");
|
|
1066
|
+
}
|
|
1067
|
+
function assertStringMap(value, field) {
|
|
1068
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1069
|
+
fail2(field, "must be an object mapping param names to string values");
|
|
1070
|
+
}
|
|
1071
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1072
|
+
if (typeof val !== "string") fail2(`${field}.${key}`, "must be a string");
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function isLegacyFixture(value) {
|
|
1076
|
+
return typeof value === "object" && value !== null && "testCaseId" in value && !("testCases" in value);
|
|
1077
|
+
}
|
|
1078
|
+
function validateFixture(value) {
|
|
1079
|
+
if (typeof value !== "object" || value === null) fail2("fixture", "must be an object");
|
|
1080
|
+
const v = value;
|
|
1081
|
+
assertString2(v.name, "name");
|
|
1082
|
+
const problem = fixtureNameProblem(v.name);
|
|
1083
|
+
if (problem) fail2("name", problem);
|
|
1084
|
+
if (typeof v.description !== "string") fail2("description", "must be a string");
|
|
1085
|
+
if (typeof v.testCases !== "object" || v.testCases === null || Array.isArray(v.testCases)) {
|
|
1086
|
+
fail2("testCases", "must be an object mapping test case ids to their values");
|
|
1087
|
+
}
|
|
1088
|
+
const testCases = {};
|
|
1089
|
+
for (const [testCaseId, values] of Object.entries(v.testCases)) {
|
|
1090
|
+
if (testCaseId.length === 0) fail2("testCases", "must not contain an empty test case id");
|
|
1091
|
+
assertStringMap(values, `testCases.${testCaseId}`);
|
|
1092
|
+
testCases[testCaseId] = { ...values };
|
|
1093
|
+
}
|
|
1094
|
+
return { name: v.name, description: v.description, testCases };
|
|
1095
|
+
}
|
|
1096
|
+
function validateLegacyFixture(value) {
|
|
1097
|
+
if (typeof value !== "object" || value === null) fail2("fixture", "must be an object");
|
|
1098
|
+
const v = value;
|
|
1099
|
+
assertString2(v.testCaseId, "testCaseId");
|
|
1100
|
+
assertString2(v.name, "name");
|
|
1101
|
+
if (typeof v.description !== "string") fail2("description", "must be a string");
|
|
1102
|
+
assertStringMap(v.values, "values");
|
|
1103
|
+
return { testCaseId: v.testCaseId, name: v.name, description: v.description, values: { ...v.values } };
|
|
1104
|
+
}
|
|
1105
|
+
function validateFixtureFile(value) {
|
|
1106
|
+
if (!isLegacyFixture(value)) return validateFixture(value);
|
|
1107
|
+
const legacy = validateLegacyFixture(value);
|
|
1108
|
+
return { name: legacy.name, description: legacy.description, testCases: { [legacy.testCaseId]: legacy.values } };
|
|
1109
|
+
}
|
|
1110
|
+
function mergeFixtures(sources) {
|
|
1111
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1112
|
+
for (const { source, fixture } of sources) {
|
|
1113
|
+
const key = fixture.name.toLowerCase();
|
|
1114
|
+
let entry = merged.get(key);
|
|
1115
|
+
if (!entry) {
|
|
1116
|
+
entry = { fixture: { name: fixture.name, description: fixture.description, testCases: {} }, sources: [], slices: /* @__PURE__ */ new Map() };
|
|
1117
|
+
merged.set(key, entry);
|
|
1118
|
+
}
|
|
1119
|
+
if (!entry.fixture.description && fixture.description) entry.fixture.description = fixture.description;
|
|
1120
|
+
entry.sources.push(source);
|
|
1121
|
+
for (const [testCaseId, values] of Object.entries(fixture.testCases)) {
|
|
1122
|
+
const previous = entry.slices.get(testCaseId);
|
|
1123
|
+
if (previous) {
|
|
1124
|
+
throw new Error(
|
|
1125
|
+
`Fixture "${fixture.name}" has values for test case "${testCaseId}" in both ${previous} and ${source}. Keep one of them.`
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
entry.slices.set(testCaseId, source);
|
|
1129
|
+
entry.fixture.testCases[testCaseId] = { ...values };
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return [...merged.values()].map(({ fixture, sources: sources2 }) => ({ fixture, sources: sources2 }));
|
|
1133
|
+
}
|
|
1134
|
+
function serializeFixture(fixture) {
|
|
1135
|
+
const testCases = {};
|
|
1136
|
+
for (const id of Object.keys(fixture.testCases).sort((a, b) => a.localeCompare(b))) testCases[id] = fixture.testCases[id];
|
|
1137
|
+
return JSON.stringify({ name: fixture.name, description: fixture.description, testCases }, null, 2) + "\n";
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/fixture-store.ts
|
|
1141
|
+
import { readdir as readdir3, readFile as readFile3, writeFile as writeFile2, unlink as unlink2 } from "node:fs/promises";
|
|
1142
|
+
import path4 from "node:path";
|
|
1143
|
+
async function readFixtureFolder(dir) {
|
|
1144
|
+
let entries;
|
|
1145
|
+
try {
|
|
1146
|
+
entries = (await readdir3(dir)).filter((f) => f.endsWith(".json")).sort((a, b) => a.localeCompare(b));
|
|
1147
|
+
} catch {
|
|
1148
|
+
return { fixtures: [], legacyFiles: [], invalid: [] };
|
|
1149
|
+
}
|
|
1150
|
+
const sources = [];
|
|
1151
|
+
const legacyFiles = [];
|
|
1152
|
+
const invalid = [];
|
|
1153
|
+
for (const file of entries) {
|
|
1154
|
+
try {
|
|
1155
|
+
const raw = JSON.parse(await readFile3(path4.join(dir, file), "utf-8"));
|
|
1156
|
+
if (isLegacyFixture(raw)) legacyFiles.push(file);
|
|
1157
|
+
sources.push({ source: file, fixture: validateFixtureFile(raw) });
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
invalid.push({ file, error: err instanceof Error ? err.message : String(err) });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
sources.sort((a, b) => Number(legacyFiles.includes(a.source)) - Number(legacyFiles.includes(b.source)));
|
|
1163
|
+
const fixtures = mergeFixtures(sources).map(({ fixture, sources: sources2 }) => ({ fixture, files: sources2 }));
|
|
1164
|
+
return { fixtures, legacyFiles, invalid };
|
|
1165
|
+
}
|
|
1166
|
+
function needsMigration(fixture, files, legacyFiles) {
|
|
1167
|
+
if (files.some((f) => legacyFiles.includes(f))) return true;
|
|
1168
|
+
return files.length !== 1 || files[0].toLowerCase() !== `${fixture.name}.json`.toLowerCase();
|
|
1169
|
+
}
|
|
1170
|
+
async function migrateFixtureFolder(dir, options = {}) {
|
|
1171
|
+
const folder = await readFixtureFolder(dir);
|
|
1172
|
+
if (folder.invalid.length > 0) {
|
|
1173
|
+
throw new Error(
|
|
1174
|
+
`Fix or remove these files first: ${folder.invalid.map((i) => `${i.file} (${i.error})`).join("; ")}`
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
const plan = [];
|
|
1178
|
+
for (const { fixture, files } of folder.fixtures) {
|
|
1179
|
+
if (!needsMigration(fixture, files, folder.legacyFiles)) continue;
|
|
1180
|
+
const problem = fixtureNameProblem(fixture.name);
|
|
1181
|
+
if (problem) {
|
|
1182
|
+
throw new Error(`Fixture name "${fixture.name}" (in ${files.join(", ")}) can't be used as a file name: it ${problem}. Rename it first.`);
|
|
1183
|
+
}
|
|
1184
|
+
const file = `${fixture.name}.json`;
|
|
1185
|
+
plan.push({ file, fixture, from: files });
|
|
1186
|
+
}
|
|
1187
|
+
const result = {
|
|
1188
|
+
written: plan.map(({ file, fixture, from }) => ({ file, fixture: fixture.name, from })),
|
|
1189
|
+
removed: plan.flatMap(({ file, from }) => from.filter((f) => f.toLowerCase() !== file.toLowerCase()))
|
|
1190
|
+
};
|
|
1191
|
+
if (options.dryRun) return result;
|
|
1192
|
+
for (const { file, fixture } of plan) await writeFile2(path4.join(dir, file), serializeFixture(fixture), "utf-8");
|
|
1193
|
+
for (const file of result.removed) await unlink2(path4.join(dir, file));
|
|
1194
|
+
return result;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// src/suite-schema.ts
|
|
1198
|
+
function fail3(field, reason) {
|
|
1199
|
+
throw new Error(`Invalid TestSuite: ${field} ${reason}`);
|
|
1200
|
+
}
|
|
1201
|
+
function assertString3(value, field) {
|
|
1202
|
+
if (typeof value !== "string" || value.length === 0) fail3(field, "must be a non-empty string");
|
|
1203
|
+
}
|
|
1204
|
+
function validateInputBinding(value, field) {
|
|
1205
|
+
if (typeof value !== "object" || value === null) fail3(field, "must be an object");
|
|
1206
|
+
const v = value;
|
|
1207
|
+
if (v.kind === "fixture") {
|
|
1208
|
+
assertString3(v.fixtureName, `${field}.fixtureName`);
|
|
1209
|
+
assertString3(v.param, `${field}.param`);
|
|
1210
|
+
} else if (v.kind === "testCaseOutput") {
|
|
1211
|
+
assertString3(v.testCaseId, `${field}.testCaseId`);
|
|
1212
|
+
assertString3(v.outputName, `${field}.outputName`);
|
|
1213
|
+
} else if (v.kind === "literal") {
|
|
1214
|
+
assertString3(v.value, `${field}.value`);
|
|
1215
|
+
} else if (v.kind === "autogenerated") {
|
|
1216
|
+
} else {
|
|
1217
|
+
fail3(`${field}.kind`, 'must be "fixture", "testCaseOutput", "literal", or "autogenerated"');
|
|
1218
|
+
}
|
|
1219
|
+
return v;
|
|
1220
|
+
}
|
|
1221
|
+
var VALID_COMPARATORS = ["equals", "notEquals", "contains", "greaterThan", "lessThan"];
|
|
1222
|
+
function validateRunCondition(value, field) {
|
|
1223
|
+
if (typeof value !== "object" || value === null) fail3(field, "must be an object");
|
|
1224
|
+
const v = value;
|
|
1225
|
+
assertString3(v.testCaseId, `${field}.testCaseId`);
|
|
1226
|
+
assertString3(v.outputName, `${field}.outputName`);
|
|
1227
|
+
if (!VALID_COMPARATORS.includes(v.comparator)) {
|
|
1228
|
+
fail3(`${field}.comparator`, `must be one of ${VALID_COMPARATORS.join(", ")}`);
|
|
1229
|
+
}
|
|
1230
|
+
assertString3(v.value, `${field}.value`);
|
|
1231
|
+
return v;
|
|
1232
|
+
}
|
|
1233
|
+
function validateTestSuiteEntry(value, index) {
|
|
1234
|
+
if (typeof value !== "object" || value === null) fail3(`entries[${index}]`, "must be an object");
|
|
1235
|
+
const v = value;
|
|
1236
|
+
assertString3(v.testCaseId, `entries[${index}].testCaseId`);
|
|
1237
|
+
if (!Array.isArray(v.dependsOn) || !v.dependsOn.every((d) => typeof d === "string")) {
|
|
1238
|
+
fail3(`entries[${index}].dependsOn`, "must be an array of testCaseId strings");
|
|
1239
|
+
}
|
|
1240
|
+
if (typeof v.inputBindings !== "object" || v.inputBindings === null || Array.isArray(v.inputBindings)) {
|
|
1241
|
+
fail3(`entries[${index}].inputBindings`, "must be an object mapping param names to bindings");
|
|
1242
|
+
}
|
|
1243
|
+
const inputBindings = {};
|
|
1244
|
+
for (const [param, binding] of Object.entries(v.inputBindings)) {
|
|
1245
|
+
inputBindings[param] = validateInputBinding(binding, `entries[${index}].inputBindings.${param}`);
|
|
1246
|
+
}
|
|
1247
|
+
if (v.legalEntity !== void 0) assertString3(v.legalEntity, `entries[${index}].legalEntity`);
|
|
1248
|
+
const runIf = v.runIf !== void 0 ? validateRunCondition(v.runIf, `entries[${index}].runIf`) : void 0;
|
|
1249
|
+
return {
|
|
1250
|
+
testCaseId: v.testCaseId,
|
|
1251
|
+
dependsOn: v.dependsOn,
|
|
1252
|
+
inputBindings,
|
|
1253
|
+
...v.legalEntity !== void 0 ? { legalEntity: v.legalEntity } : {},
|
|
1254
|
+
...runIf !== void 0 ? { runIf } : {}
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function validateTestSuite(value) {
|
|
1258
|
+
if (typeof value !== "object" || value === null) fail3("suite", "must be an object");
|
|
1259
|
+
const v = value;
|
|
1260
|
+
assertString3(v.id, "id");
|
|
1261
|
+
assertString3(v.name, "name");
|
|
1262
|
+
assertString3(v.description, "description");
|
|
1263
|
+
if (!Array.isArray(v.entries)) fail3("entries", "must be an array");
|
|
1264
|
+
return {
|
|
1265
|
+
id: v.id,
|
|
1266
|
+
name: v.name,
|
|
1267
|
+
description: v.description,
|
|
1268
|
+
entries: v.entries.map(validateTestSuiteEntry)
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// studio/server/routes/fixtures.ts
|
|
1273
|
+
var fixturesRouter = Router3();
|
|
1274
|
+
var sameName = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
1275
|
+
var fixturePath = (name) => path5.join(TESTDATA_DIR, `${name}.json`);
|
|
1276
|
+
async function findFixture(name) {
|
|
1277
|
+
const folder = await readFixtureFolder(TESTDATA_DIR);
|
|
1278
|
+
const found = folder.fixtures.find((f) => sameName(f.fixture.name, name));
|
|
1279
|
+
return found ? { ...found, needsMigration: needsMigration(found.fixture, found.files, folder.legacyFiles) } : null;
|
|
1280
|
+
}
|
|
1281
|
+
async function rejectNewName(res, name, except) {
|
|
1282
|
+
if (typeof name !== "string") {
|
|
1283
|
+
res.status(400).json({ error: "A fixture name is required" });
|
|
1284
|
+
return true;
|
|
1285
|
+
}
|
|
1286
|
+
const problem = fixtureNameProblem(name);
|
|
1287
|
+
if (problem) {
|
|
1288
|
+
res.status(400).json({ error: `A fixture name ${problem}` });
|
|
1289
|
+
return true;
|
|
1290
|
+
}
|
|
1291
|
+
if (except && sameName(name, except)) return false;
|
|
1292
|
+
if (await findFixture(name)) {
|
|
1293
|
+
res.status(409).json({ error: `A fixture named "${name}" already exists` });
|
|
1294
|
+
return true;
|
|
1295
|
+
}
|
|
1296
|
+
return false;
|
|
1297
|
+
}
|
|
1298
|
+
async function readSuites() {
|
|
1299
|
+
const files = (await readdir4(SUITES_DIR)).filter((f) => f.endsWith(".json"));
|
|
1300
|
+
const suites = [];
|
|
1301
|
+
for (const file of files) {
|
|
1302
|
+
try {
|
|
1303
|
+
suites.push({ file: file.replace(/\.json$/, ""), suite: validateTestSuite(JSON.parse(await readFile4(path5.join(SUITES_DIR, file), "utf-8"))) });
|
|
1304
|
+
} catch {
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return suites;
|
|
1308
|
+
}
|
|
1309
|
+
function usageOf(suite, fixtureName) {
|
|
1310
|
+
return suite.entries.filter((e) => Object.values(e.inputBindings).some((b) => b.kind === "fixture" && sameName(b.fixtureName, fixtureName))).map((e) => e.testCaseId);
|
|
1311
|
+
}
|
|
1312
|
+
fixturesRouter.get("/", async (_req, res) => {
|
|
1313
|
+
const folder = await readFixtureFolder(TESTDATA_DIR);
|
|
1314
|
+
const fixtures = folder.fixtures.map(({ fixture, files }) => ({
|
|
1315
|
+
name: fixture.name,
|
|
1316
|
+
description: fixture.description,
|
|
1317
|
+
testCaseIds: Object.keys(fixture.testCases),
|
|
1318
|
+
...needsMigration(fixture, files, folder.legacyFiles) ? { needsMigration: true } : {}
|
|
1319
|
+
}));
|
|
1320
|
+
for (const bad of folder.invalid) {
|
|
1321
|
+
fixtures.push({ name: bad.file, description: "", testCaseIds: [], invalid: true, error: bad.error });
|
|
1322
|
+
}
|
|
1323
|
+
res.json({ fixtures, legacyFiles: folder.legacyFiles });
|
|
1324
|
+
});
|
|
1325
|
+
fixturesRouter.post("/migrate", async (_req, res) => {
|
|
1326
|
+
res.json(await migrateFixtureFolder(TESTDATA_DIR));
|
|
1327
|
+
});
|
|
1328
|
+
fixturesRouter.get("/:name", async (req, res) => {
|
|
1329
|
+
const found = await findFixture(req.params.name);
|
|
1330
|
+
if (!found) {
|
|
1331
|
+
res.status(404).json({ error: `No fixture named "${req.params.name}"` });
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
res.json({ ...found.fixture, needsMigration: found.needsMigration });
|
|
1335
|
+
});
|
|
1336
|
+
fixturesRouter.get("/:name/usage", async (req, res) => {
|
|
1337
|
+
const usage = [];
|
|
1338
|
+
for (const { file, suite } of await readSuites()) {
|
|
1339
|
+
const testCaseIds = usageOf(suite, req.params.name);
|
|
1340
|
+
if (testCaseIds.length > 0) usage.push({ file, id: suite.id, name: suite.name, testCaseIds });
|
|
1341
|
+
}
|
|
1342
|
+
res.json({ suites: usage });
|
|
1343
|
+
});
|
|
1344
|
+
fixturesRouter.post("/", async (req, res) => {
|
|
1345
|
+
const body = req.body;
|
|
1346
|
+
if (await rejectNewName(res, body.name)) return;
|
|
1347
|
+
const fixture = validateFixture({ testCases: {}, ...body });
|
|
1348
|
+
await writeFile3(fixturePath(fixture.name), serializeFixture(fixture), "utf-8");
|
|
1349
|
+
res.status(201).json(fixture);
|
|
1350
|
+
});
|
|
1351
|
+
fixturesRouter.post("/:name/duplicate", async (req, res) => {
|
|
1352
|
+
const found = await findFixture(req.params.name);
|
|
1353
|
+
if (!found) {
|
|
1354
|
+
res.status(404).json({ error: `No fixture named "${req.params.name}"` });
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
const body = req.body;
|
|
1358
|
+
if (await rejectNewName(res, body.name)) return;
|
|
1359
|
+
const copy = validateFixture({
|
|
1360
|
+
name: body.name,
|
|
1361
|
+
description: typeof body.description === "string" ? body.description : found.fixture.description,
|
|
1362
|
+
testCases: found.fixture.testCases
|
|
1363
|
+
});
|
|
1364
|
+
await writeFile3(fixturePath(copy.name), serializeFixture(copy), "utf-8");
|
|
1365
|
+
res.status(201).json(copy);
|
|
1366
|
+
});
|
|
1367
|
+
fixturesRouter.put("/:name/testcases/:testCaseId", async (req, res) => {
|
|
1368
|
+
const { name, testCaseId } = req.params;
|
|
1369
|
+
const body = req.body;
|
|
1370
|
+
const found = await findFixture(name);
|
|
1371
|
+
if (found?.needsMigration) {
|
|
1372
|
+
res.status(409).json({ error: `Fixture "${found.fixture.name}" uses the old file format. Merge the fixture files on the Fixtures screen first.` });
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (!found && await rejectNewName(res, name)) return;
|
|
1376
|
+
const fixture = found ? found.fixture : { name, description: typeof body.description === "string" ? body.description : "", testCases: {} };
|
|
1377
|
+
if (fixture.testCases[testCaseId]) {
|
|
1378
|
+
res.status(409).json({ error: `Fixture "${fixture.name}" already has values for test case "${testCaseId}"` });
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const next = validateFixture({ ...fixture, testCases: { ...fixture.testCases, [testCaseId]: body.values ?? {} } });
|
|
1382
|
+
await writeFile3(fixturePath(next.name), serializeFixture(next), "utf-8");
|
|
1383
|
+
res.json(next);
|
|
1384
|
+
});
|
|
1385
|
+
fixturesRouter.put("/:name", async (req, res) => {
|
|
1386
|
+
const found = await findFixture(req.params.name);
|
|
1387
|
+
if (!found) {
|
|
1388
|
+
res.status(404).json({ error: `No fixture named "${req.params.name}"` });
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (found.needsMigration) {
|
|
1392
|
+
res.status(409).json({ error: `Fixture "${found.fixture.name}" uses the old file format. Merge the fixture files first.` });
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const oldName = found.fixture.name;
|
|
1396
|
+
const body = req.body;
|
|
1397
|
+
if (await rejectNewName(res, body.name, oldName)) return;
|
|
1398
|
+
const fixture = validateFixture(body);
|
|
1399
|
+
const suitesUpdated = [];
|
|
1400
|
+
if (fixture.name !== oldName) {
|
|
1401
|
+
await rename(path5.join(TESTDATA_DIR, found.files[0]), fixturePath(fixture.name));
|
|
1402
|
+
for (const { file, suite } of await readSuites()) {
|
|
1403
|
+
if (usageOf(suite, oldName).length === 0) continue;
|
|
1404
|
+
for (const entry of suite.entries) {
|
|
1405
|
+
for (const binding of Object.values(entry.inputBindings)) {
|
|
1406
|
+
if (binding.kind === "fixture" && sameName(binding.fixtureName, oldName)) binding.fixtureName = fixture.name;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
await writeFile3(path5.join(SUITES_DIR, `${file}.json`), JSON.stringify(suite, null, 2) + "\n", "utf-8");
|
|
1410
|
+
suitesUpdated.push(file);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
await writeFile3(fixturePath(fixture.name), serializeFixture(fixture), "utf-8");
|
|
1414
|
+
res.json({ fixture, suitesUpdated });
|
|
1415
|
+
});
|
|
1416
|
+
fixturesRouter.delete("/:name", async (req, res) => {
|
|
1417
|
+
const found = await findFixture(req.params.name);
|
|
1418
|
+
if (!found) {
|
|
1419
|
+
res.status(404).json({ error: `No fixture named "${req.params.name}"` });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
for (const file of found.files) await unlink3(path5.join(TESTDATA_DIR, file));
|
|
1423
|
+
res.status(204).end();
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
// studio/server/routes/testruns.ts
|
|
1427
|
+
import { Router as Router4 } from "express";
|
|
1428
|
+
import { readdir as readdir5, readFile as readFile5, unlink as unlink4 } from "node:fs/promises";
|
|
1429
|
+
import path6 from "node:path";
|
|
1430
|
+
|
|
1431
|
+
// src/planrun-schema.ts
|
|
1432
|
+
var RUN_NAME_MAX_LENGTH = 120;
|
|
1433
|
+
function normalizeRunName(value, field = "name") {
|
|
1434
|
+
if (value === void 0 || value === null) return void 0;
|
|
1435
|
+
if (typeof value !== "string") throw new Error(`Invalid run name: ${field} must be a string`);
|
|
1436
|
+
const name = value.trim().replace(/\s+/g, " ");
|
|
1437
|
+
if (name.length > RUN_NAME_MAX_LENGTH) {
|
|
1438
|
+
throw new Error(`Invalid run name: ${field} must be at most ${RUN_NAME_MAX_LENGTH} characters`);
|
|
1439
|
+
}
|
|
1440
|
+
return name || void 0;
|
|
1441
|
+
}
|
|
1442
|
+
function createPlanRun(planId, suites, name) {
|
|
1443
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1444
|
+
const runName = normalizeRunName(name);
|
|
1445
|
+
return {
|
|
1446
|
+
id: startedAt.replace(/:/g, "-"),
|
|
1447
|
+
planId,
|
|
1448
|
+
startedAt,
|
|
1449
|
+
suiteIds: suites.map((s) => s.id),
|
|
1450
|
+
suiteNames: Object.fromEntries(suites.map((s) => [s.id, s.name])),
|
|
1451
|
+
...runName ? { name: runName } : {}
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
function fail4(field, reason) {
|
|
1455
|
+
throw new Error(`Invalid PlanRun: ${field} ${reason}`);
|
|
1456
|
+
}
|
|
1457
|
+
function assertString4(value, field) {
|
|
1458
|
+
if (typeof value !== "string" || value.length === 0) fail4(field, "must be a non-empty string");
|
|
1459
|
+
}
|
|
1460
|
+
function assertSuiteIds(value) {
|
|
1461
|
+
if (value === void 0) return [];
|
|
1462
|
+
if (!Array.isArray(value)) fail4("suiteIds", "must be an array of strings");
|
|
1463
|
+
for (const [i, entry] of value.entries()) assertString4(entry, `suiteIds[${i}]`);
|
|
1464
|
+
return value;
|
|
1465
|
+
}
|
|
1466
|
+
function assertSuiteNames(value) {
|
|
1467
|
+
if (value === void 0) return {};
|
|
1468
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1469
|
+
fail4("suiteNames", "must be an object mapping suiteId to name");
|
|
1470
|
+
}
|
|
1471
|
+
const v = value;
|
|
1472
|
+
for (const [id, name] of Object.entries(v)) assertString4(name, `suiteNames.${id}`);
|
|
1473
|
+
return v;
|
|
1474
|
+
}
|
|
1475
|
+
function validatePlanRun(value) {
|
|
1476
|
+
if (typeof value !== "object" || value === null) fail4("planRun", "must be an object");
|
|
1477
|
+
const v = value;
|
|
1478
|
+
assertString4(v.id, "id");
|
|
1479
|
+
assertString4(v.planId, "planId");
|
|
1480
|
+
assertString4(v.startedAt, "startedAt");
|
|
1481
|
+
const suiteIds = assertSuiteIds(v.suiteIds);
|
|
1482
|
+
const suiteNames = assertSuiteNames(v.suiteNames);
|
|
1483
|
+
let name;
|
|
1484
|
+
try {
|
|
1485
|
+
name = normalizeRunName(v.name);
|
|
1486
|
+
} catch (err) {
|
|
1487
|
+
fail4("name", err.message.replace(/^Invalid run name: name /, ""));
|
|
1488
|
+
}
|
|
1489
|
+
return { id: v.id, planId: v.planId, startedAt: v.startedAt, suiteIds, suiteNames, ...name ? { name } : {} };
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// src/report-schema.ts
|
|
1493
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(
|
|
1494
|
+
["passed", "failed", "blocked", "skipped"]
|
|
1495
|
+
);
|
|
1496
|
+
function createTestRun(suiteId, testCaseIds, extra = {}) {
|
|
1497
|
+
const name = normalizeRunName(extra.name);
|
|
1498
|
+
const results = {};
|
|
1499
|
+
for (const id of testCaseIds) {
|
|
1500
|
+
results[id] = { status: "pending", outputs: {}, assertionResults: [] };
|
|
1501
|
+
}
|
|
1502
|
+
return {
|
|
1503
|
+
suiteId,
|
|
1504
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1505
|
+
results,
|
|
1506
|
+
...extra.scope !== void 0 ? { scope: extra.scope } : {},
|
|
1507
|
+
...extra.agentRunId !== void 0 ? { agentRunId: extra.agentRunId } : {},
|
|
1508
|
+
...name ? { name } : {}
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function fail5(field, reason) {
|
|
1512
|
+
throw new Error(`Invalid TestRun: ${field} ${reason}`);
|
|
1513
|
+
}
|
|
1514
|
+
function assertString5(value, field) {
|
|
1515
|
+
if (typeof value !== "string" || value.length === 0) fail5(field, "must be a non-empty string");
|
|
1516
|
+
}
|
|
1517
|
+
var VALID_STATUSES = ["pending", "running", "passed", "failed", "blocked", "skipped"];
|
|
1518
|
+
function validateTestCaseResult(value, testCaseId) {
|
|
1519
|
+
if (typeof value !== "object" || value === null) {
|
|
1520
|
+
fail5(`results.${testCaseId}`, "must be an object");
|
|
1521
|
+
}
|
|
1522
|
+
const v = value;
|
|
1523
|
+
if (!VALID_STATUSES.includes(v.status)) {
|
|
1524
|
+
fail5(`results.${testCaseId}.status`, `must be one of ${VALID_STATUSES.join(", ")}`);
|
|
1525
|
+
}
|
|
1526
|
+
if (typeof v.outputs !== "object" || v.outputs === null || Array.isArray(v.outputs)) {
|
|
1527
|
+
fail5(`results.${testCaseId}.outputs`, "must be an object");
|
|
1528
|
+
}
|
|
1529
|
+
if (!Array.isArray(v.assertionResults)) {
|
|
1530
|
+
fail5(`results.${testCaseId}.assertionResults`, "must be an array");
|
|
1531
|
+
}
|
|
1532
|
+
if (v.sequence !== void 0 && typeof v.sequence !== "number") {
|
|
1533
|
+
fail5(`results.${testCaseId}.sequence`, "must be a number");
|
|
1534
|
+
}
|
|
1535
|
+
if (v.inputs !== void 0 && (typeof v.inputs !== "object" || v.inputs === null || Array.isArray(v.inputs))) {
|
|
1536
|
+
fail5(`results.${testCaseId}.inputs`, "must be an object");
|
|
1537
|
+
}
|
|
1538
|
+
if (v.startedAt !== void 0 && typeof v.startedAt !== "string") {
|
|
1539
|
+
fail5(`results.${testCaseId}.startedAt`, "must be a string");
|
|
1540
|
+
}
|
|
1541
|
+
if (v.durationMs !== void 0 && typeof v.durationMs !== "number") {
|
|
1542
|
+
fail5(`results.${testCaseId}.durationMs`, "must be a number");
|
|
1543
|
+
}
|
|
1544
|
+
if (v.tokensUsed !== void 0 && typeof v.tokensUsed !== "number") {
|
|
1545
|
+
fail5(`results.${testCaseId}.tokensUsed`, "must be a number");
|
|
1546
|
+
}
|
|
1547
|
+
if (v.costUsd !== void 0 && typeof v.costUsd !== "number") {
|
|
1548
|
+
fail5(`results.${testCaseId}.costUsd`, "must be a number");
|
|
1549
|
+
}
|
|
1550
|
+
if (v.messages !== void 0 && (!Array.isArray(v.messages) || !v.messages.every((m) => typeof m === "string"))) {
|
|
1551
|
+
fail5(`results.${testCaseId}.messages`, "must be an array of strings");
|
|
1552
|
+
}
|
|
1553
|
+
return {
|
|
1554
|
+
status: v.status,
|
|
1555
|
+
outputs: v.outputs,
|
|
1556
|
+
assertionResults: v.assertionResults,
|
|
1557
|
+
...v.sequence !== void 0 ? { sequence: v.sequence } : {},
|
|
1558
|
+
...v.inputs !== void 0 ? { inputs: v.inputs } : {},
|
|
1559
|
+
...v.startedAt !== void 0 ? { startedAt: v.startedAt } : {},
|
|
1560
|
+
...v.durationMs !== void 0 ? { durationMs: v.durationMs } : {},
|
|
1561
|
+
...v.tokensUsed !== void 0 ? { tokensUsed: v.tokensUsed } : {},
|
|
1562
|
+
...v.costUsd !== void 0 ? { costUsd: v.costUsd } : {},
|
|
1563
|
+
...v.messages !== void 0 ? { messages: v.messages } : {}
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
function validateTestRun(value) {
|
|
1567
|
+
if (typeof value !== "object" || value === null) fail5("testRun", "must be an object");
|
|
1568
|
+
const v = value;
|
|
1569
|
+
assertString5(v.suiteId, "suiteId");
|
|
1570
|
+
assertString5(v.startedAt, "startedAt");
|
|
1571
|
+
if (typeof v.results !== "object" || v.results === null || Array.isArray(v.results)) {
|
|
1572
|
+
fail5("results", "must be an object mapping testCaseId to TestCaseResult");
|
|
1573
|
+
}
|
|
1574
|
+
const results = {};
|
|
1575
|
+
for (const [id, result] of Object.entries(v.results)) {
|
|
1576
|
+
results[id] = validateTestCaseResult(result, id);
|
|
1577
|
+
}
|
|
1578
|
+
if (v.scope !== void 0 && v.scope !== "suite" && v.scope !== "testcase") {
|
|
1579
|
+
fail5("scope", "must be suite or testcase");
|
|
1580
|
+
}
|
|
1581
|
+
if (v.agentRunId !== void 0 && typeof v.agentRunId !== "string") fail5("agentRunId", "must be a string");
|
|
1582
|
+
let name;
|
|
1583
|
+
try {
|
|
1584
|
+
name = normalizeRunName(v.name);
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
fail5("name", err.message.replace(/^Invalid run name: name /, ""));
|
|
1587
|
+
}
|
|
1588
|
+
return {
|
|
1589
|
+
suiteId: v.suiteId,
|
|
1590
|
+
startedAt: v.startedAt,
|
|
1591
|
+
results,
|
|
1592
|
+
...v.scope !== void 0 ? { scope: v.scope } : {},
|
|
1593
|
+
...v.agentRunId !== void 0 ? { agentRunId: v.agentRunId } : {},
|
|
1594
|
+
...name ? { name } : {}
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// studio/server/routes/testruns.ts
|
|
1599
|
+
var testRunsRouter = Router4();
|
|
1600
|
+
testRunsRouter.get("/", async (_req, res) => {
|
|
1601
|
+
const files = (await readdir5(TESTRUNS_DIR)).filter((f) => f.endsWith(".json"));
|
|
1602
|
+
const summaries = [];
|
|
1603
|
+
for (const file of files) {
|
|
1604
|
+
const slug = file.replace(/\.json$/, "");
|
|
1605
|
+
try {
|
|
1606
|
+
const raw = JSON.parse(await readFile5(path6.join(TESTRUNS_DIR, file), "utf-8"));
|
|
1607
|
+
const testRun = validateTestRun(raw);
|
|
1608
|
+
const counts = {};
|
|
1609
|
+
for (const result of Object.values(testRun.results)) {
|
|
1610
|
+
counts[result.status] = (counts[result.status] ?? 0) + 1;
|
|
1611
|
+
}
|
|
1612
|
+
summaries.push({
|
|
1613
|
+
file: slug,
|
|
1614
|
+
suiteId: testRun.suiteId,
|
|
1615
|
+
startedAt: testRun.startedAt,
|
|
1616
|
+
counts,
|
|
1617
|
+
...testRun.scope ? { scope: testRun.scope } : {},
|
|
1618
|
+
...testRun.agentRunId ? { agentRunId: testRun.agentRunId } : {}
|
|
1619
|
+
});
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
summaries.push({
|
|
1622
|
+
file: slug,
|
|
1623
|
+
suiteId: "",
|
|
1624
|
+
startedAt: "",
|
|
1625
|
+
counts: {},
|
|
1626
|
+
invalid: true,
|
|
1627
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
summaries.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1632
|
+
res.json({ testRuns: summaries });
|
|
1633
|
+
});
|
|
1634
|
+
testRunsRouter.get("/:file", async (req, res) => {
|
|
1635
|
+
const slug = safeFileName(req.params.file);
|
|
1636
|
+
const raw = JSON.parse(await readFile5(path6.join(TESTRUNS_DIR, `${slug}.json`), "utf-8"));
|
|
1637
|
+
res.json(validateTestRun(raw));
|
|
1638
|
+
});
|
|
1639
|
+
testRunsRouter.delete("/:file", async (req, res) => {
|
|
1640
|
+
const slug = safeFileName(req.params.file);
|
|
1641
|
+
await unlink4(path6.join(TESTRUNS_DIR, `${slug}.json`));
|
|
1642
|
+
res.status(204).end();
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
// studio/server/routes/suites.ts
|
|
1646
|
+
import { Router as Router5 } from "express";
|
|
1647
|
+
import { readdir as readdir6, readFile as readFile6, writeFile as writeFile4, unlink as unlink5, stat as stat2 } from "node:fs/promises";
|
|
1648
|
+
import path7 from "node:path";
|
|
1649
|
+
|
|
1650
|
+
// src/suite-runner.ts
|
|
1651
|
+
function topologicalOrder(suite) {
|
|
1652
|
+
const ids = new Set(suite.entries.map((e) => e.testCaseId));
|
|
1653
|
+
for (const entry of suite.entries) {
|
|
1654
|
+
for (const dep of entry.dependsOn) {
|
|
1655
|
+
if (!ids.has(dep)) {
|
|
1656
|
+
throw new Error(
|
|
1657
|
+
`Suite "${suite.id}": entry "${entry.testCaseId}" depends on unknown testCaseId "${dep}"`
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
if (entry.runIf && !entry.dependsOn.includes(entry.runIf.testCaseId)) {
|
|
1662
|
+
throw new Error(
|
|
1663
|
+
`Suite "${suite.id}": entry "${entry.testCaseId}"'s runIf references "${entry.runIf.testCaseId}", which must be one of its own dependsOn`
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
1668
|
+
for (const entry of suite.entries) inDegree.set(entry.testCaseId, entry.dependsOn.length);
|
|
1669
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
1670
|
+
for (const entry of suite.entries) {
|
|
1671
|
+
for (const dep of entry.dependsOn) {
|
|
1672
|
+
dependents.set(dep, [...dependents.get(dep) ?? [], entry.testCaseId]);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
const queue = suite.entries.filter((e) => e.dependsOn.length === 0).map((e) => e.testCaseId);
|
|
1676
|
+
const order = [];
|
|
1677
|
+
while (queue.length > 0) {
|
|
1678
|
+
const id = queue.shift();
|
|
1679
|
+
order.push(id);
|
|
1680
|
+
for (const dependent of dependents.get(id) ?? []) {
|
|
1681
|
+
const remaining = inDegree.get(dependent) - 1;
|
|
1682
|
+
inDegree.set(dependent, remaining);
|
|
1683
|
+
if (remaining === 0) queue.push(dependent);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (order.length !== suite.entries.length) {
|
|
1687
|
+
const stuck = suite.entries.map((e) => e.testCaseId).filter((id) => !order.includes(id));
|
|
1688
|
+
throw new Error(`Suite "${suite.id}" has a dependency cycle involving: ${stuck.join(", ")}`);
|
|
1689
|
+
}
|
|
1690
|
+
return order;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// studio/server/routes/suites.ts
|
|
1694
|
+
var suitesRouter = Router5();
|
|
1695
|
+
async function fileExists2(filePath) {
|
|
1696
|
+
try {
|
|
1697
|
+
await stat2(filePath);
|
|
1698
|
+
return true;
|
|
1699
|
+
} catch {
|
|
1700
|
+
return false;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
function validateAndCheckDag(value) {
|
|
1704
|
+
const suite = validateTestSuite(value);
|
|
1705
|
+
topologicalOrder(suite);
|
|
1706
|
+
return suite;
|
|
1707
|
+
}
|
|
1708
|
+
suitesRouter.get("/", async (_req, res) => {
|
|
1709
|
+
const files = (await readdir6(SUITES_DIR)).filter((f) => f.endsWith(".json"));
|
|
1710
|
+
const summaries = [];
|
|
1711
|
+
for (const file of files) {
|
|
1712
|
+
const slug = file.replace(/\.json$/, "");
|
|
1713
|
+
try {
|
|
1714
|
+
const raw = JSON.parse(await readFile6(path7.join(SUITES_DIR, file), "utf-8"));
|
|
1715
|
+
const suite = validateTestSuite(raw);
|
|
1716
|
+
summaries.push({ file: slug, id: suite.id, name: suite.name, description: suite.description, entryCount: suite.entries.length });
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
summaries.push({
|
|
1719
|
+
file: slug,
|
|
1720
|
+
id: "",
|
|
1721
|
+
name: file,
|
|
1722
|
+
description: "",
|
|
1723
|
+
entryCount: 0,
|
|
1724
|
+
invalid: true,
|
|
1725
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
res.json({ suites: summaries });
|
|
1730
|
+
});
|
|
1731
|
+
suitesRouter.get("/:file", async (req, res) => {
|
|
1732
|
+
const slug = safeFileName(req.params.file);
|
|
1733
|
+
const raw = JSON.parse(await readFile6(path7.join(SUITES_DIR, `${slug}.json`), "utf-8"));
|
|
1734
|
+
res.json(validateTestSuite(raw));
|
|
1735
|
+
});
|
|
1736
|
+
suitesRouter.post("/", async (req, res) => {
|
|
1737
|
+
const body = req.body;
|
|
1738
|
+
if (typeof body.file !== "string" || body.file.length === 0) {
|
|
1739
|
+
res.status(400).json({ error: '"file" (the filename slug) is required' });
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
const slug = safeFileName(body.file);
|
|
1743
|
+
const filePath = path7.join(SUITES_DIR, `${slug}.json`);
|
|
1744
|
+
if (await fileExists2(filePath)) {
|
|
1745
|
+
res.status(409).json({ error: `Suite file "${slug}.json" already exists` });
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
const suite = validateAndCheckDag(body);
|
|
1749
|
+
await writeFile4(filePath, JSON.stringify(suite, null, 2) + "\n", "utf-8");
|
|
1750
|
+
res.status(201).json({ file: slug, ...suite });
|
|
1751
|
+
});
|
|
1752
|
+
suitesRouter.put("/:file", async (req, res) => {
|
|
1753
|
+
const slug = safeFileName(req.params.file);
|
|
1754
|
+
const filePath = path7.join(SUITES_DIR, `${slug}.json`);
|
|
1755
|
+
if (!await fileExists2(filePath)) {
|
|
1756
|
+
res.status(404).json({ error: `Suite file "${slug}.json" does not exist` });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const existing = validateTestSuite(JSON.parse(await readFile6(filePath, "utf-8")));
|
|
1760
|
+
const body = req.body;
|
|
1761
|
+
const suite = validateAndCheckDag({ ...body, id: existing.id });
|
|
1762
|
+
await writeFile4(filePath, JSON.stringify(suite, null, 2) + "\n", "utf-8");
|
|
1763
|
+
res.json({ file: slug, ...suite });
|
|
1764
|
+
});
|
|
1765
|
+
suitesRouter.delete("/:file", async (req, res) => {
|
|
1766
|
+
const slug = safeFileName(req.params.file);
|
|
1767
|
+
await unlink5(path7.join(SUITES_DIR, `${slug}.json`));
|
|
1768
|
+
res.status(204).end();
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
// studio/server/routes/plans.ts
|
|
1772
|
+
import { Router as Router6 } from "express";
|
|
1773
|
+
import { readdir as readdir8, readFile as readFile8, writeFile as writeFile6, unlink as unlink6, stat as stat3 } from "node:fs/promises";
|
|
1774
|
+
import path9 from "node:path";
|
|
1775
|
+
|
|
1776
|
+
// src/plan-schema.ts
|
|
1777
|
+
function fail6(field, reason) {
|
|
1778
|
+
throw new Error(`Invalid TestPlan: ${field} ${reason}`);
|
|
1779
|
+
}
|
|
1780
|
+
function assertString6(value, field) {
|
|
1781
|
+
if (typeof value !== "string" || value.length === 0) fail6(field, "must be a non-empty string");
|
|
1782
|
+
}
|
|
1783
|
+
function validateTestPlanEntry(value, index) {
|
|
1784
|
+
if (typeof value !== "object" || value === null) fail6(`suites[${index}]`, "must be an object");
|
|
1785
|
+
const v = value;
|
|
1786
|
+
assertString6(v.suiteId, `suites[${index}].suiteId`);
|
|
1787
|
+
assertString6(v.suitePath, `suites[${index}].suitePath`);
|
|
1788
|
+
return { suiteId: v.suiteId, suitePath: v.suitePath };
|
|
1789
|
+
}
|
|
1790
|
+
function validateTestPlan(value) {
|
|
1791
|
+
if (typeof value !== "object" || value === null) fail6("plan", "must be an object");
|
|
1792
|
+
const v = value;
|
|
1793
|
+
assertString6(v.id, "id");
|
|
1794
|
+
assertString6(v.name, "name");
|
|
1795
|
+
assertString6(v.description, "description");
|
|
1796
|
+
if (!Array.isArray(v.suites)) fail6("suites", "must be an array");
|
|
1797
|
+
return {
|
|
1798
|
+
id: v.id,
|
|
1799
|
+
name: v.name,
|
|
1800
|
+
description: v.description,
|
|
1801
|
+
suites: v.suites.map(validateTestPlanEntry)
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// src/plan-runner.ts
|
|
1806
|
+
function summarizeSuiteRun(suiteId, suiteName, testRun) {
|
|
1807
|
+
if (!testRun) return { suiteId, suiteName, status: "missing", testRun: null };
|
|
1808
|
+
const statuses = Object.values(testRun.results).map((r) => r.status);
|
|
1809
|
+
const allTerminal = statuses.every((s) => TERMINAL_STATUSES.has(s));
|
|
1810
|
+
if (!allTerminal) return { suiteId, suiteName, status: "incomplete", testRun };
|
|
1811
|
+
const hasFailure = statuses.some((s) => s === "failed" || s === "blocked");
|
|
1812
|
+
return { suiteId, suiteName, status: hasFailure ? "failed" : "passed", testRun };
|
|
1813
|
+
}
|
|
1814
|
+
function aggregatePlanStatus(run, testRuns) {
|
|
1815
|
+
const suites = run.suiteIds.map(
|
|
1816
|
+
(suiteId) => summarizeSuiteRun(suiteId, run.suiteNames[suiteId] ?? suiteId, testRuns.get(suiteId) ?? null)
|
|
1817
|
+
);
|
|
1818
|
+
let overall;
|
|
1819
|
+
if (suites.some((s) => s.status === "failed")) {
|
|
1820
|
+
overall = "has-failures";
|
|
1821
|
+
} else if (suites.some((s) => s.status === "incomplete")) {
|
|
1822
|
+
overall = "incomplete";
|
|
1823
|
+
} else if (suites.some((s) => s.status === "missing")) {
|
|
1824
|
+
overall = "missing-runs";
|
|
1825
|
+
} else {
|
|
1826
|
+
overall = "all-passed";
|
|
1827
|
+
}
|
|
1828
|
+
return { planId: run.planId, runId: run.id, suites, overall };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// src/planrun.ts
|
|
1832
|
+
import { mkdir as mkdir2, readdir as readdir7, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
|
|
1833
|
+
import path8 from "node:path";
|
|
1834
|
+
async function readPlanRun(filePath) {
|
|
1835
|
+
const raw = await readFile7(filePath, "utf-8");
|
|
1836
|
+
return validatePlanRun(JSON.parse(raw));
|
|
1837
|
+
}
|
|
1838
|
+
async function listPlanRuns(dir, planId) {
|
|
1839
|
+
let files;
|
|
1840
|
+
try {
|
|
1841
|
+
files = (await readdir7(dir)).filter((f) => f.endsWith(".json"));
|
|
1842
|
+
} catch {
|
|
1843
|
+
return [];
|
|
1844
|
+
}
|
|
1845
|
+
const runs = [];
|
|
1846
|
+
for (const file of files) {
|
|
1847
|
+
try {
|
|
1848
|
+
const run = await readPlanRun(path8.join(dir, file));
|
|
1849
|
+
if (!planId || run.planId === planId) runs.push(run);
|
|
1850
|
+
} catch {
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1854
|
+
return runs;
|
|
1855
|
+
}
|
|
1856
|
+
async function initPlanRun(planId, suites, dir, name) {
|
|
1857
|
+
const planRun = createPlanRun(planId, suites, name);
|
|
1858
|
+
const filePath = path8.join(dir, `${planRun.id}.json`);
|
|
1859
|
+
await mkdir2(dir, { recursive: true });
|
|
1860
|
+
await writeFile5(filePath, JSON.stringify(planRun, null, 2), "utf-8");
|
|
1861
|
+
return { planRun, filePath };
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// studio/server/routes/plans.ts
|
|
1865
|
+
var plansRouter = Router6();
|
|
1866
|
+
async function fileExists3(filePath) {
|
|
1867
|
+
try {
|
|
1868
|
+
await stat3(filePath);
|
|
1869
|
+
return true;
|
|
1870
|
+
} catch {
|
|
1871
|
+
return false;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
async function entryFromSuiteFile(suiteFile) {
|
|
1875
|
+
const slug = safeFileName(suiteFile);
|
|
1876
|
+
const suitePath = path9.join(SUITES_DIR, `${slug}.json`);
|
|
1877
|
+
const raw = JSON.parse(await readFile8(suitePath, "utf-8"));
|
|
1878
|
+
const suite = validateTestSuite(raw);
|
|
1879
|
+
const relative = path9.relative(PLANS_DIR, suitePath).replace(/\\/g, "/");
|
|
1880
|
+
return { suiteId: suite.id, suitePath: relative };
|
|
1881
|
+
}
|
|
1882
|
+
plansRouter.get("/", async (_req, res) => {
|
|
1883
|
+
const files = (await readdir8(PLANS_DIR)).filter((f) => f.endsWith(".json"));
|
|
1884
|
+
const summaries = [];
|
|
1885
|
+
for (const file of files) {
|
|
1886
|
+
const slug = file.replace(/\.json$/, "");
|
|
1887
|
+
try {
|
|
1888
|
+
const raw = JSON.parse(await readFile8(path9.join(PLANS_DIR, file), "utf-8"));
|
|
1889
|
+
const plan = validateTestPlan(raw);
|
|
1890
|
+
summaries.push({
|
|
1891
|
+
file: slug,
|
|
1892
|
+
id: plan.id,
|
|
1893
|
+
name: plan.name,
|
|
1894
|
+
description: plan.description,
|
|
1895
|
+
suiteCount: plan.suites.length
|
|
1896
|
+
});
|
|
1897
|
+
} catch (err) {
|
|
1898
|
+
summaries.push({
|
|
1899
|
+
file: slug,
|
|
1900
|
+
id: "",
|
|
1901
|
+
name: file,
|
|
1902
|
+
description: "",
|
|
1903
|
+
suiteCount: 0,
|
|
1904
|
+
invalid: true,
|
|
1905
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
res.json({ plans: summaries });
|
|
1910
|
+
});
|
|
1911
|
+
plansRouter.get("/:file", async (req, res) => {
|
|
1912
|
+
const slug = safeFileName(req.params.file);
|
|
1913
|
+
const raw = JSON.parse(await readFile8(path9.join(PLANS_DIR, `${slug}.json`), "utf-8"));
|
|
1914
|
+
res.json(validateTestPlan(raw));
|
|
1915
|
+
});
|
|
1916
|
+
plansRouter.post("/", async (req, res) => {
|
|
1917
|
+
const body = req.body;
|
|
1918
|
+
if (typeof body.file !== "string" || body.file.length === 0) {
|
|
1919
|
+
res.status(400).json({ error: '"file" (the filename slug) is required' });
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (!Array.isArray(body.suiteFiles)) {
|
|
1923
|
+
res.status(400).json({ error: '"suiteFiles" must be an array of suite file slugs' });
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const slug = safeFileName(body.file);
|
|
1927
|
+
const filePath = path9.join(PLANS_DIR, `${slug}.json`);
|
|
1928
|
+
if (await fileExists3(filePath)) {
|
|
1929
|
+
res.status(409).json({ error: `Plan file "${slug}.json" already exists` });
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const suites = await Promise.all(body.suiteFiles.map((f) => entryFromSuiteFile(f)));
|
|
1933
|
+
const plan = validateTestPlan({ id: body.id, name: body.name, description: body.description, suites });
|
|
1934
|
+
await writeFile6(filePath, JSON.stringify(plan, null, 2) + "\n", "utf-8");
|
|
1935
|
+
res.status(201).json({ file: slug, ...plan });
|
|
1936
|
+
});
|
|
1937
|
+
plansRouter.put("/:file", async (req, res) => {
|
|
1938
|
+
const slug = safeFileName(req.params.file);
|
|
1939
|
+
const filePath = path9.join(PLANS_DIR, `${slug}.json`);
|
|
1940
|
+
if (!await fileExists3(filePath)) {
|
|
1941
|
+
res.status(404).json({ error: `Plan file "${slug}.json" does not exist` });
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
const existing = validateTestPlan(JSON.parse(await readFile8(filePath, "utf-8")));
|
|
1945
|
+
const body = req.body;
|
|
1946
|
+
if (!Array.isArray(body.suiteFiles)) {
|
|
1947
|
+
res.status(400).json({ error: '"suiteFiles" must be an array of suite file slugs' });
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
const suites = await Promise.all(body.suiteFiles.map((f) => entryFromSuiteFile(f)));
|
|
1951
|
+
const plan = validateTestPlan({ id: existing.id, name: body.name, description: body.description, suites });
|
|
1952
|
+
await writeFile6(filePath, JSON.stringify(plan, null, 2) + "\n", "utf-8");
|
|
1953
|
+
res.json({ file: slug, ...plan });
|
|
1954
|
+
});
|
|
1955
|
+
plansRouter.delete("/:file", async (req, res) => {
|
|
1956
|
+
const slug = safeFileName(req.params.file);
|
|
1957
|
+
await unlink6(path9.join(PLANS_DIR, `${slug}.json`));
|
|
1958
|
+
res.status(204).end();
|
|
1959
|
+
});
|
|
1960
|
+
plansRouter.get("/:file/runs", async (req, res) => {
|
|
1961
|
+
const slug = safeFileName(req.params.file);
|
|
1962
|
+
const plan = validateTestPlan(JSON.parse(await readFile8(path9.join(PLANS_DIR, `${slug}.json`), "utf-8")));
|
|
1963
|
+
const runs = await listPlanRuns(RUNS_DIR, plan.id);
|
|
1964
|
+
res.json({ runs });
|
|
1965
|
+
});
|
|
1966
|
+
plansRouter.post("/:file/runs", async (req, res) => {
|
|
1967
|
+
const slug = safeFileName(req.params.file);
|
|
1968
|
+
const plan = validateTestPlan(JSON.parse(await readFile8(path9.join(PLANS_DIR, `${slug}.json`), "utf-8")));
|
|
1969
|
+
const suites = await Promise.all(
|
|
1970
|
+
plan.suites.map(async (entry) => {
|
|
1971
|
+
const raw = JSON.parse(await readFile8(safeSuitePathFromPlan(entry.suitePath), "utf-8"));
|
|
1972
|
+
const suite = validateTestSuite(raw);
|
|
1973
|
+
return { id: suite.id, name: suite.name };
|
|
1974
|
+
})
|
|
1975
|
+
);
|
|
1976
|
+
const { planRun } = await initPlanRun(plan.id, suites, RUNS_DIR, req.body?.name);
|
|
1977
|
+
res.status(201).json(planRun);
|
|
1978
|
+
});
|
|
1979
|
+
plansRouter.delete("/:file/runs/:runId", async (req, res) => {
|
|
1980
|
+
const runId = safeFileName(req.params.runId);
|
|
1981
|
+
await unlink6(path9.join(RUNS_DIR, `${runId}.json`));
|
|
1982
|
+
res.status(204).end();
|
|
1983
|
+
});
|
|
1984
|
+
plansRouter.get("/:file/status", async (req, res) => {
|
|
1985
|
+
const slug = safeFileName(req.params.file);
|
|
1986
|
+
const runId = req.query.runId;
|
|
1987
|
+
if (typeof runId !== "string" || runId.length === 0) {
|
|
1988
|
+
res.status(400).json({ error: '"runId" query parameter is required' });
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
const plan = validateTestPlan(JSON.parse(await readFile8(path9.join(PLANS_DIR, `${slug}.json`), "utf-8")));
|
|
1992
|
+
const run = await readPlanRun(path9.join(RUNS_DIR, `${safeFileName(runId)}.json`));
|
|
1993
|
+
if (run.planId !== plan.id) {
|
|
1994
|
+
res.status(400).json({ error: `Plan run "${run.id}" belongs to plan "${run.planId}", not "${plan.id}"` });
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
const testRuns = /* @__PURE__ */ new Map();
|
|
1998
|
+
for (const suiteId of run.suiteIds) {
|
|
1999
|
+
const testRunPath = path9.join(TESTRUNS_DIR, `${suiteId}-${run.id}.json`);
|
|
2000
|
+
try {
|
|
2001
|
+
testRuns.set(suiteId, validateTestRun(JSON.parse(await readFile8(testRunPath, "utf-8"))));
|
|
2002
|
+
} catch {
|
|
2003
|
+
testRuns.set(suiteId, null);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
res.json({ ...aggregatePlanStatus(run, testRuns), startedAt: run.startedAt });
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
// studio/server/routes/library.ts
|
|
2010
|
+
import { Router as Router7 } from "express";
|
|
2011
|
+
import express from "express";
|
|
2012
|
+
|
|
2013
|
+
// src/library-archive.ts
|
|
2014
|
+
import { readFile as readFile9, writeFile as writeFile7, readdir as readdir9, mkdir as mkdir3, stat as stat4 } from "node:fs/promises";
|
|
2015
|
+
import path10 from "node:path";
|
|
2016
|
+
import JSZip3 from "jszip";
|
|
2017
|
+
var LIBRARY_DIRS = ["recordings", "testcases", "testdata", "testscripts", "suites", "plans", "testruns"];
|
|
2018
|
+
async function fileExists4(p) {
|
|
2019
|
+
try {
|
|
2020
|
+
await stat4(p);
|
|
2021
|
+
return true;
|
|
2022
|
+
} catch {
|
|
2023
|
+
return false;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
async function addDirToZip(zip, dirAbsPath, zipPrefix) {
|
|
2027
|
+
let entries;
|
|
2028
|
+
try {
|
|
2029
|
+
entries = await readdir9(dirAbsPath, { withFileTypes: true });
|
|
2030
|
+
} catch {
|
|
2031
|
+
return 0;
|
|
2032
|
+
}
|
|
2033
|
+
let count = 0;
|
|
2034
|
+
for (const entry of entries) {
|
|
2035
|
+
const abs = path10.join(dirAbsPath, entry.name);
|
|
2036
|
+
const zipPath = `${zipPrefix}/${entry.name}`;
|
|
2037
|
+
if (entry.isDirectory()) {
|
|
2038
|
+
count += await addDirToZip(zip, abs, zipPath);
|
|
2039
|
+
} else if (entry.isFile()) {
|
|
2040
|
+
zip.file(zipPath, await readFile9(abs));
|
|
2041
|
+
count++;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
return count;
|
|
2045
|
+
}
|
|
2046
|
+
async function buildLibraryZip(baseDir) {
|
|
2047
|
+
const zip = new JSZip3();
|
|
2048
|
+
let fileCount = 0;
|
|
2049
|
+
for (const dir of LIBRARY_DIRS) {
|
|
2050
|
+
fileCount += await addDirToZip(zip, path10.join(baseDir, dir), dir);
|
|
2051
|
+
}
|
|
2052
|
+
const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
2053
|
+
return { buffer, fileCount, dirs: LIBRARY_DIRS };
|
|
2054
|
+
}
|
|
2055
|
+
async function extractLibraryZip(bytes, baseDir, force) {
|
|
2056
|
+
const zip = await JSZip3.loadAsync(bytes);
|
|
2057
|
+
const entries = Object.values(zip.files).filter((f) => !f.dir);
|
|
2058
|
+
const baseAbs = path10.resolve(baseDir);
|
|
2059
|
+
const targets = [];
|
|
2060
|
+
for (const entry of entries) {
|
|
2061
|
+
const topDir = entry.name.split("/")[0];
|
|
2062
|
+
if (!LIBRARY_DIRS.includes(topDir)) {
|
|
2063
|
+
throw new Error(`Refusing to import: "${entry.name}" is outside the recognized library directories (${LIBRARY_DIRS.join(", ")}) -- this doesn't look like an trxa-tool library backup.`);
|
|
2064
|
+
}
|
|
2065
|
+
const targetPath = path10.resolve(baseAbs, entry.name);
|
|
2066
|
+
if (!targetPath.startsWith(baseAbs + path10.sep)) {
|
|
2067
|
+
throw new Error(`Refusing to import: "${entry.name}" resolves outside the target directory.`);
|
|
2068
|
+
}
|
|
2069
|
+
targets.push({ entry, targetPath });
|
|
2070
|
+
}
|
|
2071
|
+
if (!force) {
|
|
2072
|
+
const collisions = [];
|
|
2073
|
+
for (const { entry, targetPath } of targets) {
|
|
2074
|
+
if (await fileExists4(targetPath)) collisions.push(entry.name);
|
|
2075
|
+
}
|
|
2076
|
+
if (collisions.length > 0) {
|
|
2077
|
+
return { fileCount: 0, collisions };
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
for (const { entry, targetPath } of targets) {
|
|
2081
|
+
await mkdir3(path10.dirname(targetPath), { recursive: true });
|
|
2082
|
+
await writeFile7(targetPath, await entry.async("nodebuffer"));
|
|
2083
|
+
}
|
|
2084
|
+
return { fileCount: targets.length, collisions: [] };
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// studio/server/routes/library.ts
|
|
2088
|
+
var libraryRouter = Router7();
|
|
2089
|
+
libraryRouter.get("/export", async (_req, res) => {
|
|
2090
|
+
const { buffer } = await buildLibraryZip(WORKING_FOLDER);
|
|
2091
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
2092
|
+
res.setHeader("Content-Type", "application/zip");
|
|
2093
|
+
res.setHeader("Content-Disposition", `attachment; filename="trxa-tool-library-backup-${stamp}.zip"`);
|
|
2094
|
+
res.send(buffer);
|
|
2095
|
+
});
|
|
2096
|
+
libraryRouter.post("/import", express.raw({ type: "application/zip", limit: "200mb" }), async (req, res) => {
|
|
2097
|
+
const force = req.query.force === "true";
|
|
2098
|
+
const bytes = req.body;
|
|
2099
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
2100
|
+
throw new Error("Request body must be a non-empty .zip file");
|
|
2101
|
+
}
|
|
2102
|
+
const result = await extractLibraryZip(bytes, WORKING_FOLDER, force);
|
|
2103
|
+
if (!force && result.collisions.length > 0) {
|
|
2104
|
+
res.status(409).json(result);
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
res.json(result);
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
// studio/server/routes/settings.ts
|
|
2111
|
+
import { Router as Router8 } from "express";
|
|
2112
|
+
import { stat as stat5 } from "node:fs/promises";
|
|
2113
|
+
|
|
2114
|
+
// studio/server/settings.ts
|
|
2115
|
+
import { mkdir as mkdir4, readFile as readFile10, writeFile as writeFile8 } from "node:fs/promises";
|
|
2116
|
+
import path11 from "node:path";
|
|
2117
|
+
import os from "node:os";
|
|
2118
|
+
var CONFIG_DIR = path11.join(os.homedir(), ".trxa-tool");
|
|
2119
|
+
var CONFIG_FILE = path11.join(CONFIG_DIR, "settings.json");
|
|
2120
|
+
var cached = null;
|
|
2121
|
+
async function ensureWorkingFolderDirs() {
|
|
2122
|
+
await Promise.all(allDataDirs().map((dir) => mkdir4(dir, { recursive: true })));
|
|
2123
|
+
}
|
|
2124
|
+
async function loadSettings() {
|
|
2125
|
+
if (cached) return cached;
|
|
2126
|
+
try {
|
|
2127
|
+
const raw = JSON.parse(await readFile10(CONFIG_FILE, "utf-8"));
|
|
2128
|
+
if (typeof raw.workingFolder === "string" && raw.workingFolder.trim()) {
|
|
2129
|
+
cached = { workingFolder: raw.workingFolder };
|
|
2130
|
+
setWorkingFolder(cached.workingFolder);
|
|
2131
|
+
await ensureWorkingFolderDirs();
|
|
2132
|
+
return cached;
|
|
2133
|
+
}
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
if (err.code !== "ENOENT") throw err;
|
|
2136
|
+
}
|
|
2137
|
+
return saveSettings({ workingFolder: process.env.STUDIO_DEFAULT_WORKING_FOLDER || process.cwd() });
|
|
2138
|
+
}
|
|
2139
|
+
async function saveSettings(next) {
|
|
2140
|
+
await mkdir4(CONFIG_DIR, { recursive: true });
|
|
2141
|
+
await writeFile8(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
2142
|
+
cached = next;
|
|
2143
|
+
setWorkingFolder(next.workingFolder);
|
|
2144
|
+
await ensureWorkingFolderDirs();
|
|
2145
|
+
return cached;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// studio/server/routes/settings.ts
|
|
2149
|
+
var settingsRouter = Router8();
|
|
2150
|
+
settingsRouter.get("/", async (_req, res, next) => {
|
|
2151
|
+
try {
|
|
2152
|
+
res.json(await loadSettings());
|
|
2153
|
+
} catch (err) {
|
|
2154
|
+
next(err);
|
|
2155
|
+
}
|
|
2156
|
+
});
|
|
2157
|
+
settingsRouter.put("/", async (req, res, next) => {
|
|
2158
|
+
try {
|
|
2159
|
+
const workingFolder = req.body?.workingFolder;
|
|
2160
|
+
if (typeof workingFolder !== "string" || !workingFolder.trim()) {
|
|
2161
|
+
throw new Error("workingFolder must be a non-empty string");
|
|
2162
|
+
}
|
|
2163
|
+
const info = await stat5(workingFolder).catch(() => null);
|
|
2164
|
+
if (!info || !info.isDirectory()) {
|
|
2165
|
+
throw new Error(`Not a directory: "${workingFolder}"`);
|
|
2166
|
+
}
|
|
2167
|
+
res.json(await saveSettings({ workingFolder }));
|
|
2168
|
+
} catch (err) {
|
|
2169
|
+
next(err);
|
|
2170
|
+
}
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
// studio/server/routes/agent.ts
|
|
2174
|
+
import { Router as Router9 } from "express";
|
|
2175
|
+
import { execFile } from "node:child_process";
|
|
2176
|
+
import { access, readFile as readFile13 } from "node:fs/promises";
|
|
2177
|
+
import os2 from "node:os";
|
|
2178
|
+
import path13 from "node:path";
|
|
2179
|
+
|
|
2180
|
+
// studio/server/agent-run.ts
|
|
2181
|
+
import { mkdir as mkdir5, readFile as readFile12, readdir as readdir10, stat as stat6, writeFile as writeFile10 } from "node:fs/promises";
|
|
2182
|
+
import { randomUUID } from "node:crypto";
|
|
2183
|
+
import path12 from "node:path";
|
|
2184
|
+
import { spawn } from "node:child_process";
|
|
2185
|
+
import { CopilotClient, approveAll } from "@github/copilot-sdk";
|
|
2186
|
+
|
|
2187
|
+
// src/report.ts
|
|
2188
|
+
import { readFile as readFile11, writeFile as writeFile9 } from "node:fs/promises";
|
|
2189
|
+
async function writeTestRun(testRun, path16) {
|
|
2190
|
+
await writeFile9(path16, JSON.stringify(testRun, null, 2), "utf-8");
|
|
2191
|
+
}
|
|
2192
|
+
async function initTestRun(suite, path16, extra = {}) {
|
|
2193
|
+
const testRun = createTestRun(
|
|
2194
|
+
suite.id,
|
|
2195
|
+
suite.entries.map((e) => e.testCaseId),
|
|
2196
|
+
{ ...extra, scope: "suite" }
|
|
2197
|
+
);
|
|
2198
|
+
await writeTestRun(testRun, path16);
|
|
2199
|
+
return testRun;
|
|
2200
|
+
}
|
|
2201
|
+
async function initTestCaseRun(testCaseId, path16, extra = {}) {
|
|
2202
|
+
const testRun = createTestRun(testCaseId, [testCaseId], { ...extra, scope: "testcase" });
|
|
2203
|
+
await writeTestRun(testRun, path16);
|
|
2204
|
+
return testRun;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// studio/server/agent-run.ts
|
|
2208
|
+
var TARGET_DIRS = {
|
|
2209
|
+
testcase: { dir: "testcases", label: "Test Case" },
|
|
2210
|
+
suite: { dir: "suites", label: "Test Suite" },
|
|
2211
|
+
plan: { dir: "plans", label: "Test Plan" }
|
|
2212
|
+
};
|
|
2213
|
+
var NAVIGATION_TOOLS = /* @__PURE__ */ new Set([
|
|
2214
|
+
"form_open_menu_item",
|
|
2215
|
+
"form_find_menu_item",
|
|
2216
|
+
"form_find_controls",
|
|
2217
|
+
"form_select_grid_row",
|
|
2218
|
+
"form_filter_form",
|
|
2219
|
+
"form_filter_grid",
|
|
2220
|
+
"form_sort_grid_column",
|
|
2221
|
+
"form_open_or_close_tab",
|
|
2222
|
+
"form_open_lookup",
|
|
2223
|
+
"form_close_form",
|
|
2224
|
+
"get_current_user",
|
|
2225
|
+
"api_find_actions",
|
|
2226
|
+
"data_find_entity_type",
|
|
2227
|
+
"data_find_entities_sql",
|
|
2228
|
+
"data_get_entity_metadata"
|
|
2229
|
+
]);
|
|
2230
|
+
var ALLOWED_SHELL = /^\s*(trxa|node\s+\S*dist[\\/]index\.js|get-content|get-childitem|select-string|test-path|cat|type|dir|ls|where|git\s+(status|log|diff|--no-pager))\b/i;
|
|
2231
|
+
var SAFE_CMDLETS = /* @__PURE__ */ new Set(
|
|
2232
|
+
[
|
|
2233
|
+
"get-content",
|
|
2234
|
+
"get-childitem",
|
|
2235
|
+
"get-item",
|
|
2236
|
+
"select-string",
|
|
2237
|
+
"test-path",
|
|
2238
|
+
"resolve-path",
|
|
2239
|
+
"split-path",
|
|
2240
|
+
"join-path",
|
|
2241
|
+
"convertfrom-json",
|
|
2242
|
+
"convertto-json",
|
|
2243
|
+
"foreach-object",
|
|
2244
|
+
"where-object",
|
|
2245
|
+
"select-object",
|
|
2246
|
+
"sort-object",
|
|
2247
|
+
"group-object",
|
|
2248
|
+
"measure-object",
|
|
2249
|
+
"out-string",
|
|
2250
|
+
"format-table",
|
|
2251
|
+
"format-list",
|
|
2252
|
+
"write-output",
|
|
2253
|
+
"write-host"
|
|
2254
|
+
]
|
|
2255
|
+
);
|
|
2256
|
+
var SAFE_ALIASES = /* @__PURE__ */ new Set(["cat", "type", "dir", "ls", "gc", "select", "sort", "group", "measure", "ft", "fl", "where", "%", "?", "foreach"]);
|
|
2257
|
+
var SCRATCH_FILE = /^(\.[\\/])?(scratch|tmp)-[\w.*-]+\.json$/i;
|
|
2258
|
+
var UNSAFE_WORD = /(?<![\w-])(rm|del|erase|ri|rmdir|rd|mv|move|mi|cp|copy|cpi|ni|sc|set|sv|ac|iex|iwr|irm|curl|wget|saps|start|kill|spps|node|npm|npx|python|powershell|pwsh|cmd|invoke|add|clear|new|out-file|tee)(?![\w-])/i;
|
|
2259
|
+
function splitStatements(cmd) {
|
|
2260
|
+
const parts = [];
|
|
2261
|
+
let depth = 0;
|
|
2262
|
+
let quote = "";
|
|
2263
|
+
let cur = "";
|
|
2264
|
+
for (const ch of cmd) {
|
|
2265
|
+
if (quote) {
|
|
2266
|
+
if (ch === quote) quote = "";
|
|
2267
|
+
} else if (ch === "'" || ch === '"') quote = ch;
|
|
2268
|
+
else if ("({[".includes(ch)) depth++;
|
|
2269
|
+
else if (")}]".includes(ch)) depth--;
|
|
2270
|
+
if (!quote && depth === 0 && (ch === ";" || ch === "|" || ch === "\n")) {
|
|
2271
|
+
parts.push(cur);
|
|
2272
|
+
cur = "";
|
|
2273
|
+
} else cur += ch;
|
|
2274
|
+
}
|
|
2275
|
+
parts.push(cur);
|
|
2276
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
2277
|
+
}
|
|
2278
|
+
function stripQuotedText(cmd) {
|
|
2279
|
+
return cmd.replace(/'[^']*'|"[^"]*"/g, "''");
|
|
2280
|
+
}
|
|
2281
|
+
function shellAllowed(rawCmd) {
|
|
2282
|
+
const cmd = rawCmd.replace(/\s2>&1/g, "");
|
|
2283
|
+
if (ALLOWED_SHELL.test(cmd) && !/[;|&<>`$(]/.test(cmd.replace(/'[^']*'|"[^"]*"/g, ""))) return true;
|
|
2284
|
+
const code = stripQuotedText(cmd).replace(/\btrxa\s+[a-z][a-z-]*/gi, "trxa");
|
|
2285
|
+
if (/[>&`]/.test(code) || /\bforeach-object\b[^{]*\bnew\b/i.test(code)) return false;
|
|
2286
|
+
for (const m of code.matchAll(/(?<![\w\\./-])((?:get|set|add|new|remove|clear|invoke|start|stop|out|copy|move|rename|write|export|import|tee|select|sort|group|measure|format|convertfrom|convertto|foreach|where|test|resolve|split|join)-[A-Za-z]+)(?![\w-])/gi)) {
|
|
2287
|
+
const name = m[1].toLowerCase();
|
|
2288
|
+
if (!SAFE_CMDLETS.has(name) && name !== "remove-item") return false;
|
|
2289
|
+
}
|
|
2290
|
+
if (UNSAFE_WORD.test(code.replace(/-[A-Za-z]+/g, " ").replace(/\bremove-item\b/gi, ""))) return false;
|
|
2291
|
+
for (const stmt of splitStatements(cmd)) {
|
|
2292
|
+
const body = stmt.replace(/^\$\w+\s*=\s*/, "");
|
|
2293
|
+
const first = (/^['"]?[\w%?-]+/.exec(body)?.[0] ?? "").replace(/^['"]/, "").toLowerCase();
|
|
2294
|
+
if (first === "remove-item") {
|
|
2295
|
+
const args = body.split(/\s+/).slice(1).filter((a) => !a.startsWith("-")).map((a) => a.replace(/^['"]|['"]$|,$/g, ""));
|
|
2296
|
+
if (args.length === 0 || !args.every((a) => SCRATCH_FILE.test(a))) return false;
|
|
2297
|
+
} else if (first === "trxa" || /^node\s+\S*dist[\\/]index\.js/i.test(body)) {
|
|
2298
|
+
continue;
|
|
2299
|
+
} else if (first === "git") {
|
|
2300
|
+
if (!/^git\s+(--no-pager\s+)?(status|log|diff)\b/i.test(body)) return false;
|
|
2301
|
+
} else if (/^[$[(]/.test(body)) {
|
|
2302
|
+
continue;
|
|
2303
|
+
} else if (!SAFE_CMDLETS.has(first) && !SAFE_ALIASES.has(first)) {
|
|
2304
|
+
return false;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
return true;
|
|
2308
|
+
}
|
|
2309
|
+
var MCP_TOOL_PREFIX = /^d365-erp-mcp-/;
|
|
2310
|
+
var active = /* @__PURE__ */ new Map();
|
|
2311
|
+
var MAX_EVENT_TEXT = 2e4;
|
|
2312
|
+
var MAX_EVENTS = 2e4;
|
|
2313
|
+
var PERSIST_EVERY_MS = 3e3;
|
|
2314
|
+
function runsDir() {
|
|
2315
|
+
return path12.join(getWorkingFolder(), "agent-runs");
|
|
2316
|
+
}
|
|
2317
|
+
function samePathOrInside(parent, child) {
|
|
2318
|
+
const rel = path12.relative(parent.toLowerCase(), child.toLowerCase());
|
|
2319
|
+
return rel === "" || !rel.startsWith("..") && !path12.isAbsolute(rel);
|
|
2320
|
+
}
|
|
2321
|
+
function decide(req, depth, workingFolder) {
|
|
2322
|
+
switch (req.kind) {
|
|
2323
|
+
case "write": {
|
|
2324
|
+
const file = String(req.fileName ?? "");
|
|
2325
|
+
if (!file || !samePathOrInside(workingFolder, path12.resolve(workingFolder, file))) {
|
|
2326
|
+
return { allow: false, reason: `Writing outside the working folder is not allowed (${file || "unknown path"}).` };
|
|
2327
|
+
}
|
|
2328
|
+
return { allow: true };
|
|
2329
|
+
}
|
|
2330
|
+
case "shell": {
|
|
2331
|
+
const cmd = String(req.fullCommandText ?? "");
|
|
2332
|
+
if (!shellAllowed(cmd)) return { allow: false, reason: `Shell command not allowed: ${cmd.slice(0, 120)}` };
|
|
2333
|
+
return { allow: true };
|
|
2334
|
+
}
|
|
2335
|
+
case "mcp": {
|
|
2336
|
+
const mcp = req;
|
|
2337
|
+
const server = String(mcp.serverName ?? "");
|
|
2338
|
+
const tool = String(mcp.toolName ?? "");
|
|
2339
|
+
const bare = tool.startsWith(server + "-") ? tool.slice(server.length + 1) : tool;
|
|
2340
|
+
if (depth === "navigation") {
|
|
2341
|
+
if (server === "d365-erp-mcp" || MCP_TOOL_PREFIX.test(tool)) {
|
|
2342
|
+
if (!NAVIGATION_TOOLS.has(bare)) {
|
|
2343
|
+
return { allow: false, reason: `"${bare}" could change data, which a navigation-only run does not allow.` };
|
|
2344
|
+
}
|
|
2345
|
+
} else if (!mcp.readOnly) {
|
|
2346
|
+
return { allow: false, reason: `"${bare}" is not read-only, which a navigation-only run requires.` };
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
return { allow: true };
|
|
2350
|
+
}
|
|
2351
|
+
default:
|
|
2352
|
+
return { allow: true };
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
function buildPrompt(req, relPath, target) {
|
|
2356
|
+
const label = TARGET_DIRS[req.kind].label;
|
|
2357
|
+
const depthText = req.depth === "navigation" ? "NAVIGATION ONLY: open forms and read values, but do not save, post, create, update or delete anything. Do not click buttons that change data. Mark steps you could not run for that reason as skipped, and say so in the report." : "FULL RUN: execute every step, including saving and posting, as the test defines.";
|
|
2358
|
+
const files = (target?.testRunFiles ?? []).map((f) => `testruns/${f}.json`);
|
|
2359
|
+
let records = "";
|
|
2360
|
+
if (req.kind === "plan" && target?.planRunId) {
|
|
2361
|
+
records = `The Plan run \`${target.planRunId}\` and every suite's Test Run file already exist, all entries pending. Do NOT run \`trxa init-plan-run\` or \`trxa init-run\`; use these files:
|
|
2362
|
+
${files.map((f) => `- ${f}`).join("\n")}`;
|
|
2363
|
+
} else if (req.kind === "suite" && files.length > 0) {
|
|
2364
|
+
records = `The Test Run file \`${files[0]}\` already exists with every entry pending. Do NOT run \`trxa init-run\`; use that file with \`resolve-next\` and \`record-result\`.`;
|
|
2365
|
+
} else if (req.kind === "testcase" && files.length > 0) {
|
|
2366
|
+
records = `The Test Run file \`${files[0]}\` already exists with one pending entry keyed by the test case id. There is no suite, so do not use \`resolve-next\`; replay the test case and record its result in that file with \`trxa record-result\`.`;
|
|
2367
|
+
}
|
|
2368
|
+
return `Use the replay-trxa-testplan skill to run the ${label} \`${relPath}\` in the current working folder.
|
|
2369
|
+
|
|
2370
|
+
This run is UNATTENDED. Nobody is available to answer questions, so never ask one. If you cannot continue, or need input you do not have, stop and finish your final message with a single line of the form \`BLOCKED: <reason>\`.
|
|
2371
|
+
|
|
2372
|
+
Before you execute any step:
|
|
2373
|
+
- Check the live D365 session with the d365-erp-mcp tools and note which environment and company it points at.
|
|
2374
|
+
- I expect environment "${req.environment}" and company "${req.company}". The D365 session reports the company but NOT the environment host, so do not search config files for it: verify the company, treat the environment as the one the MCP server is connected to, and say in your report that the host was not independently verified. If the company does not match, execute nothing and finish with \`BLOCKED: <what you saw versus what was expected>\`.
|
|
2375
|
+
|
|
2376
|
+
Run depth: ${depthText}
|
|
2377
|
+
|
|
2378
|
+
${records}
|
|
2379
|
+
|
|
2380
|
+
Record results with \`trxa record-result\`, passing JSON through the --outputs-file / --assertions-file / --inputs-file / --messages-file flags. Write those JSON files only as \`scratch-<name>.json\` in the working folder and delete them with \`Remove-Item scratch-*.json\` when you are done. Do not edit the trxa-tool source, dist or any file outside this working folder. When finished, summarize what passed, failed or was skipped.
|
|
2381
|
+
|
|
2382
|
+
Working rules:
|
|
2383
|
+
- Call only tools that exist in this session; never invent a tool name.
|
|
2384
|
+
- D365 form tools are stateful: call one at a time, never in parallel.
|
|
2385
|
+
- To read a test script, use PowerShell (\`Get-Content <file> -Raw | ConvertFrom-Json\`, with \`Select-Object\`/\`ForEach-Object\`) or grep. \`node -e\` and other interpreters are not allowed.
|
|
2386
|
+
- Control names in a test script's hints can differ from the live form. If a control is not found, use \`form_find_controls\` rather than guessing.
|
|
2387
|
+
- While an async operation is processing (\`BACKGROUND_PROCESS_IN_PROGRESS\`), keep polling with \`__TimerForAsyncTaskPolling\` without commentary between polls.`;
|
|
2388
|
+
}
|
|
2389
|
+
async function prepareRecords(req, agentRunId) {
|
|
2390
|
+
const runId = (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-");
|
|
2391
|
+
await mkdir5(TESTRUNS_DIR, { recursive: true });
|
|
2392
|
+
const slug = (base) => `${base}-${runId}`;
|
|
2393
|
+
const file = (s) => path12.join(TESTRUNS_DIR, `${s}.json`);
|
|
2394
|
+
if (req.kind === "testcase") {
|
|
2395
|
+
const s = slug(req.id);
|
|
2396
|
+
await initTestCaseRun(req.id, file(s), { agentRunId, name: req.name });
|
|
2397
|
+
return { testRunFiles: [s] };
|
|
2398
|
+
}
|
|
2399
|
+
if (req.kind === "suite") {
|
|
2400
|
+
const suite = validateTestSuite(JSON.parse(await readFile12(path12.join(getWorkingFolder(), "suites", `${req.id}.json`), "utf-8")));
|
|
2401
|
+
const s = slug(suite.id);
|
|
2402
|
+
await initTestRun(suite, file(s), { agentRunId, name: req.name });
|
|
2403
|
+
return { testRunFiles: [s] };
|
|
2404
|
+
}
|
|
2405
|
+
const plan = validateTestPlan(JSON.parse(await readFile12(path12.join(PLANS_DIR, `${req.id}.json`), "utf-8")));
|
|
2406
|
+
const suites = await Promise.all(
|
|
2407
|
+
plan.suites.map(async (entry) => validateTestSuite(JSON.parse(await readFile12(safeSuitePathFromPlan(entry.suitePath), "utf-8"))))
|
|
2408
|
+
);
|
|
2409
|
+
const { planRun } = await initPlanRun(plan.id, suites.map((s) => ({ id: s.id, name: s.name })), RUNS_DIR, req.name);
|
|
2410
|
+
const files = [];
|
|
2411
|
+
for (const suite of suites) {
|
|
2412
|
+
const s = `${suite.id}-${planRun.id}`;
|
|
2413
|
+
await initTestRun(suite, file(s), { agentRunId });
|
|
2414
|
+
files.push(s);
|
|
2415
|
+
}
|
|
2416
|
+
return { planRunId: planRun.id, testRunFiles: files };
|
|
2417
|
+
}
|
|
2418
|
+
async function resolveTarget(req) {
|
|
2419
|
+
if (!/^[\w .()-]+$/.test(req.id)) throw new Error("Invalid test id");
|
|
2420
|
+
const { dir } = TARGET_DIRS[req.kind];
|
|
2421
|
+
const rel = path12.join(dir, `${req.id}.json`);
|
|
2422
|
+
const info = await stat6(path12.join(getWorkingFolder(), rel)).catch(() => null);
|
|
2423
|
+
if (!info?.isFile()) throw new Error(`${rel} does not exist in the working folder`);
|
|
2424
|
+
return rel.replace(/\\/g, "/");
|
|
2425
|
+
}
|
|
2426
|
+
function validateRequest(body) {
|
|
2427
|
+
const kind = body?.kind;
|
|
2428
|
+
const depth = body?.depth;
|
|
2429
|
+
if (!(kind in TARGET_DIRS)) throw new Error("kind must be testcase, suite or plan");
|
|
2430
|
+
if (depth !== "navigation" && depth !== "full") throw new Error("depth must be navigation or full");
|
|
2431
|
+
for (const f of ["id", "environment", "company"]) {
|
|
2432
|
+
if (typeof body?.[f] !== "string" || !body[f].trim()) throw new Error(`${f} is required`);
|
|
2433
|
+
}
|
|
2434
|
+
if (depth === "full" && body.confirmFull !== true) throw new Error("A full run needs explicit confirmation");
|
|
2435
|
+
const agent = body.agent === "claude" ? "claude" : "copilot";
|
|
2436
|
+
const name = normalizeRunName(body.name);
|
|
2437
|
+
return {
|
|
2438
|
+
kind,
|
|
2439
|
+
depth,
|
|
2440
|
+
agent,
|
|
2441
|
+
id: body.id.trim(),
|
|
2442
|
+
environment: body.environment.trim(),
|
|
2443
|
+
company: body.company.trim(),
|
|
2444
|
+
...name ? { name } : {}
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
async function persist(run) {
|
|
2448
|
+
await mkdir5(runsDir(), { recursive: true });
|
|
2449
|
+
await writeFile10(path12.join(runsDir(), `${run.record.id}.json`), JSON.stringify(run.record, null, 2), "utf-8");
|
|
2450
|
+
}
|
|
2451
|
+
function emit(run, e) {
|
|
2452
|
+
if (e.text && e.text.length > MAX_EVENT_TEXT) e = { ...e, text: e.text.slice(0, MAX_EVENT_TEXT) + " \u2026(truncated)" };
|
|
2453
|
+
const event = { t: Date.now() - run.t0, ...e };
|
|
2454
|
+
if (run.record.events.length >= MAX_EVENTS && event.type !== "finished" && event.type !== "error") return;
|
|
2455
|
+
run.record.events.push(event);
|
|
2456
|
+
for (const l of run.listeners) l(event);
|
|
2457
|
+
if (Date.now() - run.lastPersist > PERSIST_EVERY_MS) {
|
|
2458
|
+
run.lastPersist = Date.now();
|
|
2459
|
+
void persist(run).catch(() => {
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
function clip(value) {
|
|
2464
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2465
|
+
const text2 = typeof value === "string" ? value : JSON.stringify(value);
|
|
2466
|
+
return text2.length > MAX_EVENT_TEXT ? text2.slice(0, MAX_EVENT_TEXT) + " \u2026(truncated)" : text2;
|
|
2467
|
+
}
|
|
2468
|
+
async function settleRecords(target, reason) {
|
|
2469
|
+
for (const slug of target?.testRunFiles ?? []) {
|
|
2470
|
+
const file = path12.join(TESTRUNS_DIR, `${slug}.json`);
|
|
2471
|
+
try {
|
|
2472
|
+
const testRun = JSON.parse(await readFile12(file, "utf-8"));
|
|
2473
|
+
let changed = false;
|
|
2474
|
+
for (const result of Object.values(testRun.results ?? {})) {
|
|
2475
|
+
if (result.status !== "pending" && result.status !== "running") continue;
|
|
2476
|
+
result.status = "blocked";
|
|
2477
|
+
result.messages = [...result.messages ?? [], reason];
|
|
2478
|
+
changed = true;
|
|
2479
|
+
}
|
|
2480
|
+
if (changed) await writeFile10(file, JSON.stringify(testRun, null, 2), "utf-8");
|
|
2481
|
+
} catch {
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
function settleOrphan(r) {
|
|
2486
|
+
if (r.status === "running" && !active.has(r.id)) {
|
|
2487
|
+
r.status = "interrupted";
|
|
2488
|
+
r.finishedAt = r.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2489
|
+
r.events.push({ t: 0, type: "error", text: "Run was interrupted (Studio closed or restarted) before it finished." });
|
|
2490
|
+
void settleRecords(r.target, "Agent run was interrupted before this test case ran.");
|
|
2491
|
+
}
|
|
2492
|
+
return r;
|
|
2493
|
+
}
|
|
2494
|
+
function friendlyError(message) {
|
|
2495
|
+
if (/401|unauthori[sz]ed|not (logged|signed) in|authentication|bad credentials/i.test(message)) {
|
|
2496
|
+
return `GitHub Copilot sign-in failed or expired. Run \`copilot login\` in a terminal, then try again. (${message})`;
|
|
2497
|
+
}
|
|
2498
|
+
if (/ENOENT|spawn|not found|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND/i.test(message)) {
|
|
2499
|
+
return `Could not reach the Copilot agent or its tools. Check Settings > Agent runs, your network, and that the d365-erp-mcp server is connected. (${message})`;
|
|
2500
|
+
}
|
|
2501
|
+
return message;
|
|
2502
|
+
}
|
|
2503
|
+
function finish(run, status, text2) {
|
|
2504
|
+
if (run.record.status !== "running") return;
|
|
2505
|
+
if (status === "finished") {
|
|
2506
|
+
const lastMsg = [...run.record.events].reverse().find((e) => e.type === "message");
|
|
2507
|
+
const blocked = lastMsg?.text?.match(/^\s*BLOCKED:\s*(.+)$/im);
|
|
2508
|
+
if (blocked) {
|
|
2509
|
+
status = "failed";
|
|
2510
|
+
text2 = `Blocked: ${blocked[1].trim()}`;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
run.record.status = status;
|
|
2514
|
+
run.record.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2515
|
+
if (status !== "finished") void settleRecords(run.record.target, text2 ?? `Agent run ${status} before this test case ran.`);
|
|
2516
|
+
emit(run, { type: status === "failed" ? "error" : "finished", text: text2 && status === "failed" ? friendlyError(text2) : text2, ok: status === "finished" });
|
|
2517
|
+
void persist(run).catch(() => {
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
async function startRun(req) {
|
|
2521
|
+
if ([...active.values()].some((r) => r.record.status === "running")) {
|
|
2522
|
+
throw new Error("Another run is already in progress");
|
|
2523
|
+
}
|
|
2524
|
+
const relPath = await resolveTarget(req);
|
|
2525
|
+
const workingFolder = getWorkingFolder();
|
|
2526
|
+
const agentRunId = randomUUID();
|
|
2527
|
+
const target = await prepareRecords(req, agentRunId);
|
|
2528
|
+
const run = {
|
|
2529
|
+
record: { id: agentRunId, request: req, status: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString(), events: [], target },
|
|
2530
|
+
t0: Date.now(),
|
|
2531
|
+
lastPersist: Date.now(),
|
|
2532
|
+
listeners: /* @__PURE__ */ new Set()
|
|
2533
|
+
};
|
|
2534
|
+
active.set(run.record.id, run);
|
|
2535
|
+
emit(run, { type: "started", text: `${TARGET_DIRS[req.kind].label}: ${relPath}` });
|
|
2536
|
+
void persist(run).catch(() => {
|
|
2537
|
+
});
|
|
2538
|
+
if (req.agent === "claude") {
|
|
2539
|
+
runClaude(run, req, relPath, workingFolder);
|
|
2540
|
+
return run.record;
|
|
2541
|
+
}
|
|
2542
|
+
void (async () => {
|
|
2543
|
+
const client = new CopilotClient({ workingDirectory: workingFolder });
|
|
2544
|
+
try {
|
|
2545
|
+
const session = await client.createSession({
|
|
2546
|
+
workingDirectory: workingFolder,
|
|
2547
|
+
enableConfigDiscovery: true,
|
|
2548
|
+
streaming: true,
|
|
2549
|
+
onPermissionRequest: (request) => {
|
|
2550
|
+
const verdict = decide(request, req.depth, workingFolder);
|
|
2551
|
+
if (verdict.allow) return approveAll(request, { sessionId: "" });
|
|
2552
|
+
emit(run, { type: "denied", text: verdict.reason });
|
|
2553
|
+
return { kind: "reject", feedback: verdict.reason };
|
|
2554
|
+
},
|
|
2555
|
+
onUserInputRequest: async (q) => {
|
|
2556
|
+
emit(run, { type: "question", text: q.question });
|
|
2557
|
+
const answer = "This run is unattended and nobody can answer. Do not ask again. Stop and finish your final message with a line `BLOCKED: <reason>`.";
|
|
2558
|
+
emit(run, { type: "answer", text: answer });
|
|
2559
|
+
return { answer, wasFreeform: true };
|
|
2560
|
+
}
|
|
2561
|
+
});
|
|
2562
|
+
run.session = session;
|
|
2563
|
+
const idle = new Promise((resolve) => session.on("session.idle", () => resolve()));
|
|
2564
|
+
session.on((raw) => {
|
|
2565
|
+
const d = raw.data ?? {};
|
|
2566
|
+
switch (raw.type) {
|
|
2567
|
+
case "assistant.message":
|
|
2568
|
+
if (d.content) emit(run, { type: "message", text: String(d.content) });
|
|
2569
|
+
break;
|
|
2570
|
+
case "tool.execution_start":
|
|
2571
|
+
emit(run, { type: "tool", id: d.toolCallId, tool: d.toolName, text: d.toolTitle, args: clip(d.arguments) });
|
|
2572
|
+
break;
|
|
2573
|
+
case "tool.execution_complete":
|
|
2574
|
+
emit(run, {
|
|
2575
|
+
type: "tool-result",
|
|
2576
|
+
id: d.toolCallId,
|
|
2577
|
+
ok: d.success !== false,
|
|
2578
|
+
result: clip(d.result?.detailedContent ?? d.result?.content ?? d.error?.message)
|
|
2579
|
+
});
|
|
2580
|
+
break;
|
|
2581
|
+
case "session.error":
|
|
2582
|
+
emit(run, { type: "error", text: friendlyError(String(d.message ?? "Unknown error")) });
|
|
2583
|
+
break;
|
|
2584
|
+
}
|
|
2585
|
+
});
|
|
2586
|
+
await session.send({ prompt: buildPrompt(req, relPath, run.record.target) });
|
|
2587
|
+
await idle;
|
|
2588
|
+
finish(run, "finished");
|
|
2589
|
+
} catch (err) {
|
|
2590
|
+
finish(run, run.record.status === "cancelled" ? "cancelled" : "failed", err instanceof Error ? err.message : String(err));
|
|
2591
|
+
} finally {
|
|
2592
|
+
await client.stop().catch(() => {
|
|
2593
|
+
});
|
|
2594
|
+
run.session = void 0;
|
|
2595
|
+
}
|
|
2596
|
+
})();
|
|
2597
|
+
return run.record;
|
|
2598
|
+
}
|
|
2599
|
+
function subscribe(id, listener) {
|
|
2600
|
+
const run = active.get(id);
|
|
2601
|
+
if (!run) return null;
|
|
2602
|
+
run.listeners.add(listener);
|
|
2603
|
+
return () => run.listeners.delete(listener);
|
|
2604
|
+
}
|
|
2605
|
+
async function getRun(id) {
|
|
2606
|
+
const live = active.get(id);
|
|
2607
|
+
if (live) return live.record;
|
|
2608
|
+
if (!/^[0-9a-f-]{36}$/.test(id)) return null;
|
|
2609
|
+
try {
|
|
2610
|
+
return settleOrphan(JSON.parse(await readFile12(path12.join(runsDir(), `${id}.json`), "utf-8")));
|
|
2611
|
+
} catch {
|
|
2612
|
+
return null;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
async function listRuns() {
|
|
2616
|
+
const records = /* @__PURE__ */ new Map();
|
|
2617
|
+
const files = await readdir10(runsDir()).catch(() => []);
|
|
2618
|
+
for (const f of files.filter((n) => n.endsWith(".json"))) {
|
|
2619
|
+
try {
|
|
2620
|
+
const r = JSON.parse(await readFile12(path12.join(runsDir(), f), "utf-8"));
|
|
2621
|
+
records.set(r.id, settleOrphan(r));
|
|
2622
|
+
} catch {
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
for (const [id, a] of active) records.set(id, a.record);
|
|
2626
|
+
return [...records.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt)).map(({ id, request, status, startedAt, finishedAt, target }) => ({ id, request, status, startedAt, finishedAt, target }));
|
|
2627
|
+
}
|
|
2628
|
+
function busyTestRunFiles() {
|
|
2629
|
+
const busy = /* @__PURE__ */ new Set();
|
|
2630
|
+
for (const run of active.values()) {
|
|
2631
|
+
if (run.record.status === "running") for (const f of run.record.target?.testRunFiles ?? []) busy.add(f);
|
|
2632
|
+
}
|
|
2633
|
+
return busy;
|
|
2634
|
+
}
|
|
2635
|
+
async function cancelRun(id) {
|
|
2636
|
+
const run = active.get(id);
|
|
2637
|
+
if (!run || run.record.status !== "running") return false;
|
|
2638
|
+
run.record.status = "cancelled";
|
|
2639
|
+
run.record.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2640
|
+
emit(run, { type: "finished", text: "Cancelled", ok: false });
|
|
2641
|
+
void settleRecords(run.record.target, "Agent run was stopped before this test case ran.");
|
|
2642
|
+
await run.session?.abort().catch(() => {
|
|
2643
|
+
});
|
|
2644
|
+
run.stop?.();
|
|
2645
|
+
await persist(run).catch(() => {
|
|
2646
|
+
});
|
|
2647
|
+
return true;
|
|
2648
|
+
}
|
|
2649
|
+
function claudeArgs(req, prompt) {
|
|
2650
|
+
const nav = req.depth === "navigation";
|
|
2651
|
+
const allowed = ["Bash(trxa:*)", "Read", "Glob", "Grep", "Edit", "Write", ...nav ? [...NAVIGATION_TOOLS].map((t) => `mcp__d365-erp-mcp__${t}`) : ["mcp__d365-erp-mcp"]];
|
|
2652
|
+
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", allowed.join(",")];
|
|
2653
|
+
if (nav) args.push("--disallowedTools", "mcp__d365-erp-mcp");
|
|
2654
|
+
return args;
|
|
2655
|
+
}
|
|
2656
|
+
function runClaude(run, req, relPath, cwd) {
|
|
2657
|
+
const prompt = buildPrompt(req, relPath, run.record.target);
|
|
2658
|
+
const args = claudeArgs(req, prompt);
|
|
2659
|
+
const nav = req.depth === "navigation";
|
|
2660
|
+
const child = spawn("claude", args.map((a) => /[\s"()*&|<>^]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a), { cwd, shell: true, windowsHide: true });
|
|
2661
|
+
run.stop = () => {
|
|
2662
|
+
if (child.pid) spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { windowsHide: true }).on("error", () => child.kill());
|
|
2663
|
+
};
|
|
2664
|
+
let buf = "";
|
|
2665
|
+
let stderr = "";
|
|
2666
|
+
const handle = (line) => {
|
|
2667
|
+
let m;
|
|
2668
|
+
try {
|
|
2669
|
+
m = JSON.parse(line);
|
|
2670
|
+
} catch {
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
if (m.type === "assistant") {
|
|
2674
|
+
for (const c of m.message?.content ?? []) {
|
|
2675
|
+
if (c.type === "text" && c.text) emit(run, { type: "message", text: c.text });
|
|
2676
|
+
if (c.type === "tool_use") emit(run, { type: "tool", id: c.id, tool: String(c.name).replace(/^mcp__d365-erp-mcp__/, ""), text: void 0, args: clip(c.input) });
|
|
2677
|
+
}
|
|
2678
|
+
} else if (m.type === "user") {
|
|
2679
|
+
for (const c of m.message?.content ?? []) {
|
|
2680
|
+
if (c.type === "tool_result") {
|
|
2681
|
+
const body = Array.isArray(c.content) ? c.content.map((x) => x?.text ?? "").join("\n") : c.content;
|
|
2682
|
+
emit(run, { type: "tool-result", id: c.tool_use_id, ok: !c.is_error, result: clip(body) });
|
|
2683
|
+
if (c.is_error && nav && /permission|not allowed|denied/i.test(JSON.stringify(c.content))) emit(run, { type: "denied", text: "A tool was blocked by the navigation-only rules." });
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
};
|
|
2688
|
+
child.stdout.on("data", (d) => {
|
|
2689
|
+
buf += d.toString();
|
|
2690
|
+
const lines = buf.split(/\r?\n/);
|
|
2691
|
+
buf = lines.pop() ?? "";
|
|
2692
|
+
lines.forEach(handle);
|
|
2693
|
+
});
|
|
2694
|
+
child.stderr.on("data", (d) => {
|
|
2695
|
+
stderr += d.toString();
|
|
2696
|
+
});
|
|
2697
|
+
child.on("error", (err) => finish(run, "failed", err.message));
|
|
2698
|
+
child.on("close", (code) => {
|
|
2699
|
+
if (buf.trim()) handle(buf);
|
|
2700
|
+
if (run.record.status !== "running") return;
|
|
2701
|
+
if (code === 0) finish(run, "finished");
|
|
2702
|
+
else finish(run, "failed", stderr.trim().slice(-500) || `Claude Code exited with code ${code}`);
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// studio/server/routes/agent.ts
|
|
2707
|
+
var agentRouter = Router9();
|
|
2708
|
+
var REPLAY_SKILL = "replay-trxa-testplan";
|
|
2709
|
+
var REQUIRED_MCP = "d365-erp-mcp";
|
|
2710
|
+
function cliVersion(cmd) {
|
|
2711
|
+
return new Promise((resolve) => {
|
|
2712
|
+
execFile(cmd, ["--version"], { shell: true, timeout: 15e3, windowsHide: true }, (err, stdout) => {
|
|
2713
|
+
if (err) return resolve(null);
|
|
2714
|
+
const first = stdout.split(/\r?\n/).find((l) => l.trim());
|
|
2715
|
+
resolve(first ? first.trim() : null);
|
|
2716
|
+
});
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
async function exists(p) {
|
|
2720
|
+
try {
|
|
2721
|
+
await access(p);
|
|
2722
|
+
return true;
|
|
2723
|
+
} catch {
|
|
2724
|
+
return false;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
async function readCommentedJson(file) {
|
|
2728
|
+
try {
|
|
2729
|
+
const raw = (await readFile13(file, "utf-8")).replace(/^\uFEFF/, "");
|
|
2730
|
+
return JSON.parse(raw.split(/\r?\n/).filter((l) => !l.trim().startsWith("//")).join("\n"));
|
|
2731
|
+
} catch {
|
|
2732
|
+
return null;
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
async function copilotSignedInUser() {
|
|
2736
|
+
const cfg = await readCommentedJson(path13.join(os2.homedir(), ".copilot", "config.json"));
|
|
2737
|
+
const login = cfg?.lastLoggedInUser?.login ?? cfg?.loggedInUsers?.[0]?.login;
|
|
2738
|
+
if (typeof login === "string" && login) return login;
|
|
2739
|
+
if (process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN) return "token from environment";
|
|
2740
|
+
return null;
|
|
2741
|
+
}
|
|
2742
|
+
async function copilotMcpServers() {
|
|
2743
|
+
const cfg = await readCommentedJson(path13.join(os2.homedir(), ".copilot", "mcp-config.json"));
|
|
2744
|
+
return Object.keys(cfg?.mcpServers ?? cfg?.servers ?? {});
|
|
2745
|
+
}
|
|
2746
|
+
var normalizedPath = (p) => path13.resolve(p).split(path13.sep).join("/").toLowerCase();
|
|
2747
|
+
var samePath = (a, b) => normalizedPath(a) === normalizedPath(b);
|
|
2748
|
+
async function claudeMcpServers() {
|
|
2749
|
+
const workingFolder = getWorkingFolder();
|
|
2750
|
+
const [user, project] = await Promise.all([
|
|
2751
|
+
readCommentedJson(path13.join(os2.homedir(), ".claude.json")),
|
|
2752
|
+
readCommentedJson(path13.join(workingFolder, ".mcp.json"))
|
|
2753
|
+
]);
|
|
2754
|
+
const projects = Object.entries(user?.projects ?? {}).filter(([dir]) => samePath(dir, workingFolder)).flatMap(([, p]) => Object.keys(p?.mcpServers ?? {}));
|
|
2755
|
+
return [...Object.keys(user?.mcpServers ?? {}), ...projects, ...Object.keys(project?.mcpServers ?? {})];
|
|
2756
|
+
}
|
|
2757
|
+
async function replaySkillFound() {
|
|
2758
|
+
const roots = [
|
|
2759
|
+
path13.join(getWorkingFolder(), ".claude", "skills"),
|
|
2760
|
+
path13.join(getWorkingFolder(), ".github", "skills"),
|
|
2761
|
+
path13.join(os2.homedir(), ".claude", "skills"),
|
|
2762
|
+
path13.join(os2.homedir(), ".copilot", "skills")
|
|
2763
|
+
];
|
|
2764
|
+
const hits = await Promise.all(roots.map((r) => exists(path13.join(r, REPLAY_SKILL, "SKILL.md"))));
|
|
2765
|
+
return hits.some(Boolean);
|
|
2766
|
+
}
|
|
2767
|
+
var SETUP_GUIDE = "https://github.com/elcanelo/trxa-tool/blob/master/SETUP.md";
|
|
2768
|
+
var guide = (anchor) => `${SETUP_GUIDE}#${anchor}`;
|
|
2769
|
+
async function checkReadiness() {
|
|
2770
|
+
const [trxaVersion, copilotVersion, claudeVersion, user, copilotMcp, claudeMcp, skill, claudeCreds] = await Promise.all([
|
|
2771
|
+
cliVersion("trxa"),
|
|
2772
|
+
cliVersion("copilot"),
|
|
2773
|
+
cliVersion("claude"),
|
|
2774
|
+
copilotSignedInUser(),
|
|
2775
|
+
copilotMcpServers(),
|
|
2776
|
+
claudeMcpServers(),
|
|
2777
|
+
replaySkillFound(),
|
|
2778
|
+
exists(path13.join(os2.homedir(), ".claude", ".credentials.json"))
|
|
2779
|
+
]);
|
|
2780
|
+
const mcpFor = { copilot: copilotMcp.includes(REQUIRED_MCP), claude: claudeMcp.includes(REQUIRED_MCP) };
|
|
2781
|
+
const claudeSignedIn = claudeCreds || Boolean(process.env.ANTHROPIC_API_KEY);
|
|
2782
|
+
const checks = [
|
|
2783
|
+
// Every replay drives the run through `trxa resolve-next` / `record-result`. The desktop app
|
|
2784
|
+
// ships its own copy and puts it on the server's PATH (desktop/main.cjs), so this only fails
|
|
2785
|
+
// for a browser Studio started some other way than from an installed `trxa`.
|
|
2786
|
+
trxaVersion ? { id: "trxa-cli", label: "TRXA CLI", status: "ok", detail: trxaVersion } : {
|
|
2787
|
+
id: "trxa-cli",
|
|
2788
|
+
label: "TRXA CLI",
|
|
2789
|
+
status: "missing",
|
|
2790
|
+
detail: "The trxa command was not found. The agent uses it to track and record the run.",
|
|
2791
|
+
fix: "Install it with: npm install -g trxa-tool",
|
|
2792
|
+
help: guide("step-1-install-trxa-tool")
|
|
2793
|
+
},
|
|
2794
|
+
copilotVersion ? { id: "copilot-cli", label: "GitHub Copilot CLI", status: "ok", detail: copilotVersion } : {
|
|
2795
|
+
id: "copilot-cli",
|
|
2796
|
+
label: "GitHub Copilot CLI",
|
|
2797
|
+
status: "missing",
|
|
2798
|
+
detail: "Not installed, or not found on PATH.",
|
|
2799
|
+
fix: "Install it in PowerShell 7 with: winget install GitHub.Copilot",
|
|
2800
|
+
help: guide("step-2-install-an-agent")
|
|
2801
|
+
},
|
|
2802
|
+
user ? { id: "copilot-login", label: "Copilot sign-in", status: "ok", detail: `Signed in as ${user}` } : {
|
|
2803
|
+
id: "copilot-login",
|
|
2804
|
+
label: "Copilot sign-in",
|
|
2805
|
+
status: "missing",
|
|
2806
|
+
detail: "No signed-in GitHub account found.",
|
|
2807
|
+
fix: "Run copilot login in a terminal and follow the prompts.",
|
|
2808
|
+
help: guide("step-3-sign-in-to-the-agent")
|
|
2809
|
+
},
|
|
2810
|
+
mcpFor.copilot || mcpFor.claude ? {
|
|
2811
|
+
id: "mcp-d365",
|
|
2812
|
+
label: "D365 ERP MCP server",
|
|
2813
|
+
status: "ok",
|
|
2814
|
+
detail: `${REQUIRED_MCP} is configured for ${[mcpFor.copilot && "GitHub Copilot", mcpFor.claude && "Claude Code"].filter(Boolean).join(" and ")}.`
|
|
2815
|
+
} : {
|
|
2816
|
+
id: "mcp-d365",
|
|
2817
|
+
label: "D365 ERP MCP server",
|
|
2818
|
+
status: "missing",
|
|
2819
|
+
detail: `No MCP server named ${REQUIRED_MCP} is configured for GitHub Copilot or Claude Code.`,
|
|
2820
|
+
fix: `Add ${REQUIRED_MCP} to your agent's MCP settings so it can work in D365.`,
|
|
2821
|
+
help: guide("step-5-connect-the-agent-to-dynamics-365")
|
|
2822
|
+
},
|
|
2823
|
+
skill ? { id: "replay-skill", label: "Replay skill", status: "ok", detail: `${REPLAY_SKILL} found` } : {
|
|
2824
|
+
id: "replay-skill",
|
|
2825
|
+
label: "Replay skill",
|
|
2826
|
+
status: "warn",
|
|
2827
|
+
detail: `${REPLAY_SKILL} was not found in the working folder or your home skills folders.`,
|
|
2828
|
+
fix: "Install it into the working folder, or run trxa init-skill there.",
|
|
2829
|
+
help: guide("step-6-install-the-replay-skill"),
|
|
2830
|
+
...trxaVersion ? { action: "install-skill" } : {}
|
|
2831
|
+
},
|
|
2832
|
+
claudeVersion ? {
|
|
2833
|
+
id: "claude-cli",
|
|
2834
|
+
label: "Claude Code CLI",
|
|
2835
|
+
status: claudeSignedIn ? "ok" : "warn",
|
|
2836
|
+
detail: claudeSignedIn ? claudeVersion : `${claudeVersion} (installed, but no sign-in found)`,
|
|
2837
|
+
...claudeSignedIn ? {} : { fix: "Run claude once in a terminal and sign in.", help: guide("step-3-sign-in-to-the-agent") }
|
|
2838
|
+
} : {
|
|
2839
|
+
id: "claude-cli",
|
|
2840
|
+
label: "Claude Code CLI",
|
|
2841
|
+
status: "warn",
|
|
2842
|
+
detail: "Not installed, or not found on PATH (optional).",
|
|
2843
|
+
fix: "Install it in PowerShell with: irm https://claude.ai/install.ps1 | iex",
|
|
2844
|
+
help: guide("step-2-install-an-agent")
|
|
2845
|
+
}
|
|
2846
|
+
];
|
|
2847
|
+
const ok = (id) => checks.find((c) => c.id === id)?.status === "ok";
|
|
2848
|
+
const common = ok("trxa-cli") && ok("replay-skill");
|
|
2849
|
+
return {
|
|
2850
|
+
checks,
|
|
2851
|
+
ready: {
|
|
2852
|
+
copilot: ok("copilot-cli") && ok("copilot-login") && mcpFor.copilot && common,
|
|
2853
|
+
claude: ok("claude-cli") && mcpFor.claude && common
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
agentRouter.get("/readiness", async (_req, res, next) => {
|
|
2858
|
+
try {
|
|
2859
|
+
res.json(await checkReadiness());
|
|
2860
|
+
} catch (err) {
|
|
2861
|
+
next(err);
|
|
2862
|
+
}
|
|
2863
|
+
});
|
|
2864
|
+
agentRouter.post("/install-skill", (_req, res) => {
|
|
2865
|
+
execFile("trxa", ["init-skill"], { shell: true, cwd: getWorkingFolder(), timeout: 3e4, windowsHide: true }, (err, _stdout, stderr) => {
|
|
2866
|
+
const message = stderr.trim().split(/\r?\n/).filter(Boolean).pop() ?? "";
|
|
2867
|
+
if (err) res.status(400).json({ error: message || err.message });
|
|
2868
|
+
else res.json({ message });
|
|
2869
|
+
});
|
|
2870
|
+
});
|
|
2871
|
+
agentRouter.post("/runs", async (req, res) => {
|
|
2872
|
+
try {
|
|
2873
|
+
const readiness = await checkReadiness();
|
|
2874
|
+
const request = validateRequest(req.body);
|
|
2875
|
+
const agent = request.agent ?? "copilot";
|
|
2876
|
+
if (!readiness.ready[agent]) {
|
|
2877
|
+
res.status(409).json({ error: `${agent === "claude" ? "Claude Code" : "GitHub Copilot"} is not ready. See Settings > Agent readiness.` });
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
res.status(201).json(await startRun(request));
|
|
2881
|
+
} catch (err) {
|
|
2882
|
+
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
2883
|
+
}
|
|
2884
|
+
});
|
|
2885
|
+
agentRouter.get("/runs", async (_req, res, next) => {
|
|
2886
|
+
try {
|
|
2887
|
+
res.json(await listRuns());
|
|
2888
|
+
} catch (err) {
|
|
2889
|
+
next(err);
|
|
2890
|
+
}
|
|
2891
|
+
});
|
|
2892
|
+
agentRouter.get("/runs/:id", async (req, res, next) => {
|
|
2893
|
+
try {
|
|
2894
|
+
const run = await getRun(req.params.id);
|
|
2895
|
+
if (!run) res.status(404).json({ error: "Run not found" });
|
|
2896
|
+
else res.json(run);
|
|
2897
|
+
} catch (err) {
|
|
2898
|
+
next(err);
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
agentRouter.get("/runs/:id/events", async (req, res) => {
|
|
2902
|
+
const run = await getRun(req.params.id);
|
|
2903
|
+
if (!run) {
|
|
2904
|
+
res.status(404).json({ error: "Run not found" });
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
2908
|
+
res.flushHeaders();
|
|
2909
|
+
const send = (e) => res.write(`data: ${JSON.stringify(e)}
|
|
2910
|
+
|
|
2911
|
+
`);
|
|
2912
|
+
run.events.forEach(send);
|
|
2913
|
+
const unsubscribe = run.status === "running" ? subscribe(run.id, send) : null;
|
|
2914
|
+
if (!unsubscribe) {
|
|
2915
|
+
res.end();
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
req.on("close", unsubscribe);
|
|
2919
|
+
});
|
|
2920
|
+
agentRouter.get("/runs/:id/log", async (req, res) => {
|
|
2921
|
+
const run = await getRun(req.params.id);
|
|
2922
|
+
if (!run) return res.status(404).json({ error: "Run not found" });
|
|
2923
|
+
res.setHeader("Content-Disposition", `attachment; filename="agent-run-${run.id}.json"`);
|
|
2924
|
+
res.json(run);
|
|
2925
|
+
});
|
|
2926
|
+
agentRouter.delete("/runs/:id", async (req, res) => {
|
|
2927
|
+
res.json({ cancelled: await cancelRun(req.params.id) });
|
|
2928
|
+
});
|
|
2929
|
+
|
|
2930
|
+
// studio/server/routes/runs.ts
|
|
2931
|
+
import { Router as Router10 } from "express";
|
|
2932
|
+
import { readdir as readdir11, readFile as readFile14, writeFile as writeFile11 } from "node:fs/promises";
|
|
2933
|
+
import path14 from "node:path";
|
|
2934
|
+
var EMPTY_CASES = () => ({ total: 0, pending: 0, running: 0, passed: 0, failed: 0, blocked: 0, skipped: 0 });
|
|
2935
|
+
async function readJsonDir(dir, parse) {
|
|
2936
|
+
const out = /* @__PURE__ */ new Map();
|
|
2937
|
+
const files = await readdir11(dir).catch(() => []);
|
|
2938
|
+
for (const f of files.filter((n) => n.endsWith(".json"))) {
|
|
2939
|
+
try {
|
|
2940
|
+
out.set(f.replace(/\.json$/, ""), parse(JSON.parse(await readFile14(path14.join(dir, f), "utf-8"))));
|
|
2941
|
+
} catch {
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
return out;
|
|
2945
|
+
}
|
|
2946
|
+
async function namesById(dir) {
|
|
2947
|
+
const files = await readJsonDir(dir, (raw) => raw);
|
|
2948
|
+
const names = /* @__PURE__ */ new Map();
|
|
2949
|
+
for (const [slug, v] of files) {
|
|
2950
|
+
const id = typeof v.id === "string" ? v.id : slug;
|
|
2951
|
+
if (typeof v.name === "string" && v.name.trim()) names.set(id, v.name);
|
|
2952
|
+
}
|
|
2953
|
+
return names;
|
|
2954
|
+
}
|
|
2955
|
+
function addCases(cases, extras, testRun) {
|
|
2956
|
+
for (const r of Object.values(testRun.results)) {
|
|
2957
|
+
cases[r.status]++;
|
|
2958
|
+
cases.total++;
|
|
2959
|
+
if (r.tokensUsed !== void 0) {
|
|
2960
|
+
extras.tokens += r.tokensUsed;
|
|
2961
|
+
extras.hasTokens = true;
|
|
2962
|
+
}
|
|
2963
|
+
if (r.costUsd !== void 0) {
|
|
2964
|
+
extras.cost += r.costUsd;
|
|
2965
|
+
extras.hasCost = true;
|
|
2966
|
+
}
|
|
2967
|
+
if (r.startedAt && r.durationMs !== void 0) extras.lastEnd = Math.max(extras.lastEnd, Date.parse(r.startedAt) + r.durationMs);
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
function outcomeOf(cases, agentStatus, missingSuites) {
|
|
2971
|
+
if (agentStatus === "running") return "running";
|
|
2972
|
+
if (cases.total === 0 && agentStatus === "finished") return "no-results";
|
|
2973
|
+
if (cases.failed > 0) return "failed";
|
|
2974
|
+
if (agentStatus === "cancelled" || agentStatus === "interrupted") return agentStatus;
|
|
2975
|
+
if (agentStatus === "failed" || cases.blocked > 0) return "failed";
|
|
2976
|
+
if (cases.running > 0) return "running";
|
|
2977
|
+
if (cases.total === 0 || cases.pending === cases.total) return "not-started";
|
|
2978
|
+
if (cases.pending > 0 || missingSuites) return "incomplete";
|
|
2979
|
+
return "passed";
|
|
2980
|
+
}
|
|
2981
|
+
async function buildRunRows() {
|
|
2982
|
+
const [plans, planRuns, testRuns, agentRuns, suiteNames, caseNames] = await Promise.all([
|
|
2983
|
+
readJsonDir(PLANS_DIR, validateTestPlan),
|
|
2984
|
+
listPlanRuns(RUNS_DIR),
|
|
2985
|
+
readJsonDir(TESTRUNS_DIR, validateTestRun),
|
|
2986
|
+
listRuns(),
|
|
2987
|
+
namesById(SUITES_DIR),
|
|
2988
|
+
namesById(TESTCASES_DIR)
|
|
2989
|
+
]);
|
|
2990
|
+
const planFileById = new Map([...plans].map(([file, plan]) => [plan.id, { file, name: plan.name }]));
|
|
2991
|
+
const agentFor = (pred) => agentRuns.find(pred);
|
|
2992
|
+
const claimedAgents = /* @__PURE__ */ new Set();
|
|
2993
|
+
const claimedTestRuns = /* @__PURE__ */ new Set();
|
|
2994
|
+
const rows = [];
|
|
2995
|
+
const agentInfo = (a) => a && {
|
|
2996
|
+
id: a.id,
|
|
2997
|
+
status: a.status,
|
|
2998
|
+
agent: a.request.agent ?? "copilot",
|
|
2999
|
+
environment: a.request.environment,
|
|
3000
|
+
company: a.request.company,
|
|
3001
|
+
depth: a.request.depth
|
|
3002
|
+
};
|
|
3003
|
+
function finishRow(row, extras, missingSuites, agent) {
|
|
3004
|
+
const end = agent?.finishedAt ? Date.parse(agent.finishedAt) : agent?.status === "running" ? Date.now() : extras.lastEnd;
|
|
3005
|
+
return {
|
|
3006
|
+
...row,
|
|
3007
|
+
...agent?.finishedAt ? { finishedAt: agent.finishedAt } : {},
|
|
3008
|
+
...end > 0 ? { durationMs: Math.max(0, end - Date.parse(row.startedAt)) } : {},
|
|
3009
|
+
outcome: outcomeOf(row.cases, agent?.status, missingSuites),
|
|
3010
|
+
...extras.hasTokens ? { tokens: extras.tokens } : {},
|
|
3011
|
+
...extras.hasCost ? { costUsd: extras.cost } : {}
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
for (const run of planRuns) {
|
|
3015
|
+
const plan = planFileById.get(run.planId);
|
|
3016
|
+
const suiteRuns = /* @__PURE__ */ new Map();
|
|
3017
|
+
const cases = EMPTY_CASES();
|
|
3018
|
+
const extras = { tokens: 0, cost: 0, hasTokens: false, hasCost: false, lastEnd: 0 };
|
|
3019
|
+
for (const suiteId of run.suiteIds) {
|
|
3020
|
+
const slug = `${suiteId}-${run.id}`;
|
|
3021
|
+
const testRun = testRuns.get(slug) ?? null;
|
|
3022
|
+
suiteRuns.set(suiteId, testRun);
|
|
3023
|
+
if (testRun) {
|
|
3024
|
+
claimedTestRuns.add(slug);
|
|
3025
|
+
addCases(cases, extras, testRun);
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
const status = aggregatePlanStatus(run, suiteRuns);
|
|
3029
|
+
const agent = agentFor((a) => a.target?.planRunId === run.id);
|
|
3030
|
+
if (agent) claimedAgents.add(agent.id);
|
|
3031
|
+
rows.push(
|
|
3032
|
+
finishRow(
|
|
3033
|
+
{
|
|
3034
|
+
key: `run:${plan?.file ?? run.planId}:${run.id}`,
|
|
3035
|
+
kind: "plan",
|
|
3036
|
+
...run.name ? { name: run.name } : {},
|
|
3037
|
+
targetName: plan?.name ?? run.planId,
|
|
3038
|
+
startedAt: run.startedAt,
|
|
3039
|
+
cases,
|
|
3040
|
+
suites: { total: status.suites.length, done: status.suites.filter((s) => s.status === "passed" || s.status === "failed").length },
|
|
3041
|
+
agent: agentInfo(agent)
|
|
3042
|
+
},
|
|
3043
|
+
extras,
|
|
3044
|
+
status.suites.some((s) => s.status === "missing"),
|
|
3045
|
+
agent
|
|
3046
|
+
)
|
|
3047
|
+
);
|
|
3048
|
+
}
|
|
3049
|
+
for (const [slug, testRun] of testRuns) {
|
|
3050
|
+
if (claimedTestRuns.has(slug)) continue;
|
|
3051
|
+
const kind = testRun.scope === "testcase" ? "testcase" : "suite";
|
|
3052
|
+
const agent = agentFor((a) => (testRun.agentRunId ? a.id === testRun.agentRunId : false) || (a.target?.testRunFiles.includes(slug) ?? false));
|
|
3053
|
+
if (agent) claimedAgents.add(agent.id);
|
|
3054
|
+
const cases = EMPTY_CASES();
|
|
3055
|
+
const extras = { tokens: 0, cost: 0, hasTokens: false, hasCost: false, lastEnd: 0 };
|
|
3056
|
+
addCases(cases, extras, testRun);
|
|
3057
|
+
rows.push(
|
|
3058
|
+
finishRow(
|
|
3059
|
+
{
|
|
3060
|
+
key: `testrun:${slug}`,
|
|
3061
|
+
kind,
|
|
3062
|
+
...testRun.name ? { name: testRun.name } : {},
|
|
3063
|
+
targetName: (kind === "testcase" ? caseNames : suiteNames).get(testRun.suiteId) ?? testRun.suiteId,
|
|
3064
|
+
startedAt: testRun.startedAt,
|
|
3065
|
+
cases,
|
|
3066
|
+
agent: agentInfo(agent)
|
|
3067
|
+
},
|
|
3068
|
+
extras,
|
|
3069
|
+
false,
|
|
3070
|
+
agent
|
|
3071
|
+
)
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
for (const a of agentRuns) {
|
|
3075
|
+
if (claimedAgents.has(a.id)) continue;
|
|
3076
|
+
const kind = a.request.kind;
|
|
3077
|
+
const targetName = kind === "plan" ? plans.get(a.request.id)?.name ?? a.request.id : (kind === "suite" ? suiteNames : caseNames).get(a.request.id) ?? a.request.id;
|
|
3078
|
+
rows.push(
|
|
3079
|
+
finishRow(
|
|
3080
|
+
{
|
|
3081
|
+
key: `agent:${a.id}`,
|
|
3082
|
+
kind,
|
|
3083
|
+
...a.request.name ? { name: a.request.name } : {},
|
|
3084
|
+
targetName,
|
|
3085
|
+
startedAt: a.startedAt,
|
|
3086
|
+
cases: EMPTY_CASES(),
|
|
3087
|
+
agent: agentInfo(a)
|
|
3088
|
+
},
|
|
3089
|
+
{ tokens: 0, cost: 0, hasTokens: false, hasCost: false, lastEnd: 0 },
|
|
3090
|
+
false,
|
|
3091
|
+
a
|
|
3092
|
+
)
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
rows.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3096
|
+
return rows;
|
|
3097
|
+
}
|
|
3098
|
+
var runsRouter = Router10();
|
|
3099
|
+
runsRouter.get("/", async (_req, res, next) => {
|
|
3100
|
+
try {
|
|
3101
|
+
res.json({ runs: await buildRunRows() });
|
|
3102
|
+
} catch (err) {
|
|
3103
|
+
next(err);
|
|
3104
|
+
}
|
|
3105
|
+
});
|
|
3106
|
+
runsRouter.put("/name", async (req, res, next) => {
|
|
3107
|
+
try {
|
|
3108
|
+
const key = String(req.body?.key ?? "");
|
|
3109
|
+
let name;
|
|
3110
|
+
try {
|
|
3111
|
+
name = normalizeRunName(req.body?.name);
|
|
3112
|
+
} catch (err) {
|
|
3113
|
+
res.status(400).json({ error: err.message });
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
let file;
|
|
3117
|
+
let check;
|
|
3118
|
+
if (key.startsWith("run:")) {
|
|
3119
|
+
const runId = key.slice(key.indexOf(":", 4) + 1);
|
|
3120
|
+
file = path14.join(RUNS_DIR, `${safeFileName(runId)}.json`);
|
|
3121
|
+
check = validatePlanRun;
|
|
3122
|
+
} else if (key.startsWith("testrun:")) {
|
|
3123
|
+
const slug = safeFileName(key.slice("testrun:".length));
|
|
3124
|
+
if (busyTestRunFiles().has(slug)) {
|
|
3125
|
+
res.status(409).json({ error: "This run is still in progress. Rename it once it finishes." });
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
file = path14.join(TESTRUNS_DIR, `${slug}.json`);
|
|
3129
|
+
check = validateTestRun;
|
|
3130
|
+
} else {
|
|
3131
|
+
res.status(400).json({ error: "Only plan runs and test runs can be renamed." });
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
const raw = JSON.parse(await readFile14(file, "utf-8"));
|
|
3135
|
+
check(raw);
|
|
3136
|
+
if (name) raw.name = name;
|
|
3137
|
+
else delete raw.name;
|
|
3138
|
+
await writeFile11(file, JSON.stringify(raw, null, 2), "utf-8");
|
|
3139
|
+
res.json({ key, ...name ? { name } : {} });
|
|
3140
|
+
} catch (err) {
|
|
3141
|
+
next(err);
|
|
3142
|
+
}
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
// studio/server/index.ts
|
|
3146
|
+
var PORT = Number(process.env.STUDIO_SERVER_PORT ?? 4321);
|
|
3147
|
+
var app = express2();
|
|
3148
|
+
app.use(express2.json());
|
|
3149
|
+
app.use("/api/recordings", recordingsRouter);
|
|
3150
|
+
app.use("/api/testcases", testCasesRouter);
|
|
3151
|
+
app.use("/api/fixtures", fixturesRouter);
|
|
3152
|
+
app.use("/api/testruns", testRunsRouter);
|
|
3153
|
+
app.use("/api/suites", suitesRouter);
|
|
3154
|
+
app.use("/api/plans", plansRouter);
|
|
3155
|
+
app.use("/api/library", libraryRouter);
|
|
3156
|
+
app.use("/api/settings", settingsRouter);
|
|
3157
|
+
app.use("/api/agent", agentRouter);
|
|
3158
|
+
app.use("/api/runs", runsRouter);
|
|
3159
|
+
var CLIENT_DIST = [
|
|
3160
|
+
path15.join(import.meta.dirname, "..", "client-dist"),
|
|
3161
|
+
path15.join(import.meta.dirname, "..", "..", "client-dist")
|
|
3162
|
+
].find((candidate) => existsSync(candidate));
|
|
3163
|
+
if (CLIENT_DIST) {
|
|
3164
|
+
app.use(express2.static(CLIENT_DIST));
|
|
3165
|
+
}
|
|
3166
|
+
var errorHandler = (err, _req, res, _next) => {
|
|
3167
|
+
console.error(err);
|
|
3168
|
+
const status = err?.code === "ENOENT" ? 404 : 400;
|
|
3169
|
+
res.status(status).json({ error: err instanceof Error ? err.message : String(err) });
|
|
3170
|
+
};
|
|
3171
|
+
app.use(errorHandler);
|
|
3172
|
+
var settings = await loadSettings();
|
|
3173
|
+
app.listen(PORT, "127.0.0.1", () => {
|
|
3174
|
+
console.log(`TRXA Studio API listening on http://127.0.0.1:${PORT} (local only, do not expose on a network)`);
|
|
3175
|
+
console.log(`Working folder: ${settings.workingFolder}`);
|
|
3176
|
+
});
|