trxa-tool 0.1.1 → 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/MANUAL.md +376 -368
- package/README.md +75 -51
- package/SETUP.md +401 -0
- package/STUDIO.md +417 -309
- package/dist/fixture-schema.d.ts +45 -5
- package/dist/fixture-schema.js +97 -13
- package/dist/fixture-store.d.ts +37 -0
- package/dist/fixture-store.js +75 -0
- package/dist/index.js +76 -40
- package/dist/mapping.js +4 -1
- package/dist/mcp-bridge.d.ts +52 -0
- package/dist/mcp-bridge.js +290 -0
- package/dist/planrun-schema.d.ts +14 -6
- package/dist/planrun-schema.js +31 -7
- package/dist/planrun.d.ts +1 -1
- package/dist/planrun.js +2 -2
- package/dist/report-schema.d.ts +10 -1
- package/dist/report-schema.js +31 -5
- package/dist/report.d.ts +4 -1
- package/dist/report.js +9 -2
- package/dist/suite-runner.js +8 -2
- 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 +6 -2
- 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 -5
- package/studio/client-dist/favicon.svg +2 -5
- package/studio/client-dist/index.html +5 -4
- package/studio/server/dist/index.js +1530 -292
- package/studio/client-dist/assets/index-CgWhGkEn.js +0 -24
- package/studio/client-dist/assets/index-CwAUMVCL.css +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// studio/server/index.ts
|
|
2
2
|
import express2 from "express";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import
|
|
4
|
+
import path15 from "node:path";
|
|
5
5
|
|
|
6
6
|
// studio/server/routes/recordings.ts
|
|
7
7
|
import { Router } from "express";
|
|
@@ -569,10 +569,11 @@ function propertyHint(step) {
|
|
|
569
569
|
note: `Value was entered on a grid-bound control (UserActionType="${UserActionType}"); may be a filter rather than a cell edit.`
|
|
570
570
|
};
|
|
571
571
|
}
|
|
572
|
+
const liveControlName = ControlName === "PurchParmTable_gridParmTableNum" ? "PurchParmTable_Num" : ControlName;
|
|
572
573
|
return {
|
|
573
574
|
tool: "form_set_control_values",
|
|
574
575
|
confidence: "high",
|
|
575
|
-
params: { setControlValues: [{ controlName:
|
|
576
|
+
params: { setControlValues: [{ controlName: liveControlName, value: formattedValue }] },
|
|
576
577
|
followUp: [
|
|
577
578
|
{
|
|
578
579
|
tool: "form_save_form",
|
|
@@ -737,6 +738,9 @@ var TESTRUNS_DIR = path.join(WORKING_FOLDER, "testruns");
|
|
|
737
738
|
var SUITES_DIR = path.join(WORKING_FOLDER, "suites");
|
|
738
739
|
var PLANS_DIR = path.join(WORKING_FOLDER, "plans");
|
|
739
740
|
var RUNS_DIR = path.join(WORKING_FOLDER, "runs");
|
|
741
|
+
function getWorkingFolder() {
|
|
742
|
+
return WORKING_FOLDER;
|
|
743
|
+
}
|
|
740
744
|
function allDataDirs() {
|
|
741
745
|
return [RECORDINGS_DIR, TESTCASES_DIR, TESTDATA_DIR, TESTSCRIPTS_DIR, TESTRUNS_DIR, SUITES_DIR, PLANS_DIR, RUNS_DIR];
|
|
742
746
|
}
|
|
@@ -942,6 +946,7 @@ testCasesRouter.get("/", async (_req, res) => {
|
|
|
942
946
|
description: testCase.description,
|
|
943
947
|
inputCount: testCase.inputs.length,
|
|
944
948
|
outputCount: testCase.outputs.length,
|
|
949
|
+
inputs: testCase.inputs,
|
|
945
950
|
sourceRecordingFolder: testCase.sourceRecordingFolder
|
|
946
951
|
});
|
|
947
952
|
} catch (err) {
|
|
@@ -951,6 +956,7 @@ testCasesRouter.get("/", async (_req, res) => {
|
|
|
951
956
|
description: `Invalid test case file: ${err instanceof Error ? err.message : String(err)}`,
|
|
952
957
|
inputCount: 0,
|
|
953
958
|
outputCount: 0,
|
|
959
|
+
inputs: [],
|
|
954
960
|
invalid: true
|
|
955
961
|
});
|
|
956
962
|
}
|
|
@@ -1008,7 +1014,7 @@ testCasesRouter.post("/", async (req, res) => {
|
|
|
1008
1014
|
const testCase = validateTestCase({
|
|
1009
1015
|
id,
|
|
1010
1016
|
name: body.name,
|
|
1011
|
-
description: trimmedDescription.length > 0 ? trimmedDescription : `Created via
|
|
1017
|
+
description: trimmedDescription.length > 0 ? trimmedDescription : `Created via TRXA Studio from ${body.sourceRecording}.`,
|
|
1012
1018
|
testScriptPath: `../testscripts/${testScriptFileName}`,
|
|
1013
1019
|
...sourceRecordingFolder ? { sourceRecordingFolder } : {},
|
|
1014
1020
|
inputs: body.inputs ?? [],
|
|
@@ -1038,157 +1044,511 @@ testCasesRouter.delete("/:id", async (req, res) => {
|
|
|
1038
1044
|
|
|
1039
1045
|
// studio/server/routes/fixtures.ts
|
|
1040
1046
|
import { Router as Router3 } from "express";
|
|
1041
|
-
import { readdir as
|
|
1042
|
-
import
|
|
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";
|
|
1043
1049
|
|
|
1044
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
|
+
}
|
|
1045
1061
|
function fail2(field, reason) {
|
|
1046
1062
|
throw new Error(`Invalid Fixture: ${field} ${reason}`);
|
|
1047
1063
|
}
|
|
1048
1064
|
function assertString2(value, field) {
|
|
1049
1065
|
if (typeof value !== "string" || value.length === 0) fail2(field, "must be a non-empty string");
|
|
1050
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
|
+
}
|
|
1051
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) {
|
|
1052
1097
|
if (typeof value !== "object" || value === null) fail2("fixture", "must be an object");
|
|
1053
1098
|
const v = value;
|
|
1054
1099
|
assertString2(v.testCaseId, "testCaseId");
|
|
1055
1100
|
assertString2(v.name, "name");
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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");
|
|
1059
1242
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1243
|
+
const inputBindings = {};
|
|
1244
|
+
for (const [param, binding] of Object.entries(v.inputBindings)) {
|
|
1245
|
+
inputBindings[param] = validateInputBinding(binding, `entries[${index}].inputBindings.${param}`);
|
|
1062
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;
|
|
1063
1249
|
return {
|
|
1064
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,
|
|
1065
1266
|
name: v.name,
|
|
1066
1267
|
description: v.description,
|
|
1067
|
-
|
|
1268
|
+
entries: v.entries.map(validateTestSuiteEntry)
|
|
1068
1269
|
};
|
|
1069
1270
|
}
|
|
1070
1271
|
|
|
1071
1272
|
// studio/server/routes/fixtures.ts
|
|
1072
1273
|
var fixturesRouter = Router3();
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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` });
|
|
1076
1294
|
return true;
|
|
1077
|
-
} catch {
|
|
1078
|
-
return false;
|
|
1079
1295
|
}
|
|
1296
|
+
return false;
|
|
1080
1297
|
}
|
|
1081
|
-
|
|
1082
|
-
const files = (await
|
|
1083
|
-
const
|
|
1298
|
+
async function readSuites() {
|
|
1299
|
+
const files = (await readdir4(SUITES_DIR)).filter((f) => f.endsWith(".json"));
|
|
1300
|
+
const suites = [];
|
|
1084
1301
|
for (const file of files) {
|
|
1085
|
-
const slug = file.replace(/\.json$/, "");
|
|
1086
1302
|
try {
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
summaries.push({ file: slug, name: fixture.name, testCaseId: fixture.testCaseId, description: fixture.description });
|
|
1090
|
-
} catch (err) {
|
|
1091
|
-
summaries.push({
|
|
1092
|
-
file: slug,
|
|
1093
|
-
name: file,
|
|
1094
|
-
testCaseId: "",
|
|
1095
|
-
description: "",
|
|
1096
|
-
invalid: true,
|
|
1097
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1098
|
-
});
|
|
1303
|
+
suites.push({ file: file.replace(/\.json$/, ""), suite: validateTestSuite(JSON.parse(await readFile4(path5.join(SUITES_DIR, file), "utf-8"))) });
|
|
1304
|
+
} catch {
|
|
1099
1305
|
}
|
|
1100
1306
|
}
|
|
1101
|
-
|
|
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 });
|
|
1102
1324
|
});
|
|
1103
|
-
fixturesRouter.
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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 });
|
|
1107
1343
|
});
|
|
1108
1344
|
fixturesRouter.post("/", async (req, res) => {
|
|
1109
1345
|
const body = req.body;
|
|
1110
|
-
if (
|
|
1111
|
-
|
|
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}"` });
|
|
1112
1355
|
return;
|
|
1113
1356
|
}
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
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.` });
|
|
1119
1373
|
return;
|
|
1120
1374
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
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);
|
|
1123
1384
|
});
|
|
1124
|
-
fixturesRouter.put("/:
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
res.status(404).json({ error: `Fixture file "${slug}.json" does not exist` });
|
|
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}"` });
|
|
1129
1389
|
return;
|
|
1130
1390
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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 });
|
|
1135
1415
|
});
|
|
1136
|
-
fixturesRouter.delete("/:
|
|
1137
|
-
const
|
|
1138
|
-
|
|
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));
|
|
1139
1423
|
res.status(204).end();
|
|
1140
1424
|
});
|
|
1141
1425
|
|
|
1142
1426
|
// studio/server/routes/testruns.ts
|
|
1143
1427
|
import { Router as Router4 } from "express";
|
|
1144
|
-
import { readdir as
|
|
1145
|
-
import
|
|
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
|
+
}
|
|
1146
1491
|
|
|
1147
1492
|
// src/report-schema.ts
|
|
1148
1493
|
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(
|
|
1149
1494
|
["passed", "failed", "blocked", "skipped"]
|
|
1150
1495
|
);
|
|
1151
|
-
function
|
|
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) {
|
|
1152
1512
|
throw new Error(`Invalid TestRun: ${field} ${reason}`);
|
|
1153
1513
|
}
|
|
1154
|
-
function
|
|
1155
|
-
if (typeof value !== "string" || value.length === 0)
|
|
1514
|
+
function assertString5(value, field) {
|
|
1515
|
+
if (typeof value !== "string" || value.length === 0) fail5(field, "must be a non-empty string");
|
|
1156
1516
|
}
|
|
1157
1517
|
var VALID_STATUSES = ["pending", "running", "passed", "failed", "blocked", "skipped"];
|
|
1158
1518
|
function validateTestCaseResult(value, testCaseId) {
|
|
1159
1519
|
if (typeof value !== "object" || value === null) {
|
|
1160
|
-
|
|
1520
|
+
fail5(`results.${testCaseId}`, "must be an object");
|
|
1161
1521
|
}
|
|
1162
1522
|
const v = value;
|
|
1163
1523
|
if (!VALID_STATUSES.includes(v.status)) {
|
|
1164
|
-
|
|
1524
|
+
fail5(`results.${testCaseId}.status`, `must be one of ${VALID_STATUSES.join(", ")}`);
|
|
1165
1525
|
}
|
|
1166
1526
|
if (typeof v.outputs !== "object" || v.outputs === null || Array.isArray(v.outputs)) {
|
|
1167
|
-
|
|
1527
|
+
fail5(`results.${testCaseId}.outputs`, "must be an object");
|
|
1168
1528
|
}
|
|
1169
1529
|
if (!Array.isArray(v.assertionResults)) {
|
|
1170
|
-
|
|
1530
|
+
fail5(`results.${testCaseId}.assertionResults`, "must be an array");
|
|
1171
1531
|
}
|
|
1172
1532
|
if (v.sequence !== void 0 && typeof v.sequence !== "number") {
|
|
1173
|
-
|
|
1533
|
+
fail5(`results.${testCaseId}.sequence`, "must be a number");
|
|
1174
1534
|
}
|
|
1175
1535
|
if (v.inputs !== void 0 && (typeof v.inputs !== "object" || v.inputs === null || Array.isArray(v.inputs))) {
|
|
1176
|
-
|
|
1536
|
+
fail5(`results.${testCaseId}.inputs`, "must be an object");
|
|
1177
1537
|
}
|
|
1178
1538
|
if (v.startedAt !== void 0 && typeof v.startedAt !== "string") {
|
|
1179
|
-
|
|
1539
|
+
fail5(`results.${testCaseId}.startedAt`, "must be a string");
|
|
1180
1540
|
}
|
|
1181
1541
|
if (v.durationMs !== void 0 && typeof v.durationMs !== "number") {
|
|
1182
|
-
|
|
1542
|
+
fail5(`results.${testCaseId}.durationMs`, "must be a number");
|
|
1183
1543
|
}
|
|
1184
1544
|
if (v.tokensUsed !== void 0 && typeof v.tokensUsed !== "number") {
|
|
1185
|
-
|
|
1545
|
+
fail5(`results.${testCaseId}.tokensUsed`, "must be a number");
|
|
1186
1546
|
}
|
|
1187
1547
|
if (v.costUsd !== void 0 && typeof v.costUsd !== "number") {
|
|
1188
|
-
|
|
1548
|
+
fail5(`results.${testCaseId}.costUsd`, "must be a number");
|
|
1189
1549
|
}
|
|
1190
1550
|
if (v.messages !== void 0 && (!Array.isArray(v.messages) || !v.messages.every((m) => typeof m === "string"))) {
|
|
1191
|
-
|
|
1551
|
+
fail5(`results.${testCaseId}.messages`, "must be an array of strings");
|
|
1192
1552
|
}
|
|
1193
1553
|
return {
|
|
1194
1554
|
status: v.status,
|
|
@@ -1204,35 +1564,59 @@ function validateTestCaseResult(value, testCaseId) {
|
|
|
1204
1564
|
};
|
|
1205
1565
|
}
|
|
1206
1566
|
function validateTestRun(value) {
|
|
1207
|
-
if (typeof value !== "object" || value === null)
|
|
1567
|
+
if (typeof value !== "object" || value === null) fail5("testRun", "must be an object");
|
|
1208
1568
|
const v = value;
|
|
1209
|
-
|
|
1210
|
-
|
|
1569
|
+
assertString5(v.suiteId, "suiteId");
|
|
1570
|
+
assertString5(v.startedAt, "startedAt");
|
|
1211
1571
|
if (typeof v.results !== "object" || v.results === null || Array.isArray(v.results)) {
|
|
1212
|
-
|
|
1572
|
+
fail5("results", "must be an object mapping testCaseId to TestCaseResult");
|
|
1213
1573
|
}
|
|
1214
1574
|
const results = {};
|
|
1215
1575
|
for (const [id, result] of Object.entries(v.results)) {
|
|
1216
1576
|
results[id] = validateTestCaseResult(result, id);
|
|
1217
1577
|
}
|
|
1218
|
-
|
|
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
|
+
};
|
|
1219
1596
|
}
|
|
1220
1597
|
|
|
1221
1598
|
// studio/server/routes/testruns.ts
|
|
1222
1599
|
var testRunsRouter = Router4();
|
|
1223
1600
|
testRunsRouter.get("/", async (_req, res) => {
|
|
1224
|
-
const files = (await
|
|
1601
|
+
const files = (await readdir5(TESTRUNS_DIR)).filter((f) => f.endsWith(".json"));
|
|
1225
1602
|
const summaries = [];
|
|
1226
1603
|
for (const file of files) {
|
|
1227
1604
|
const slug = file.replace(/\.json$/, "");
|
|
1228
1605
|
try {
|
|
1229
|
-
const raw = JSON.parse(await
|
|
1606
|
+
const raw = JSON.parse(await readFile5(path6.join(TESTRUNS_DIR, file), "utf-8"));
|
|
1230
1607
|
const testRun = validateTestRun(raw);
|
|
1231
1608
|
const counts = {};
|
|
1232
1609
|
for (const result of Object.values(testRun.results)) {
|
|
1233
1610
|
counts[result.status] = (counts[result.status] ?? 0) + 1;
|
|
1234
1611
|
}
|
|
1235
|
-
summaries.push({
|
|
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
|
+
});
|
|
1236
1620
|
} catch (err) {
|
|
1237
1621
|
summaries.push({
|
|
1238
1622
|
file: slug,
|
|
@@ -1249,94 +1633,19 @@ testRunsRouter.get("/", async (_req, res) => {
|
|
|
1249
1633
|
});
|
|
1250
1634
|
testRunsRouter.get("/:file", async (req, res) => {
|
|
1251
1635
|
const slug = safeFileName(req.params.file);
|
|
1252
|
-
const raw = JSON.parse(await
|
|
1636
|
+
const raw = JSON.parse(await readFile5(path6.join(TESTRUNS_DIR, `${slug}.json`), "utf-8"));
|
|
1253
1637
|
res.json(validateTestRun(raw));
|
|
1254
1638
|
});
|
|
1255
1639
|
testRunsRouter.delete("/:file", async (req, res) => {
|
|
1256
1640
|
const slug = safeFileName(req.params.file);
|
|
1257
|
-
await
|
|
1641
|
+
await unlink4(path6.join(TESTRUNS_DIR, `${slug}.json`));
|
|
1258
1642
|
res.status(204).end();
|
|
1259
1643
|
});
|
|
1260
1644
|
|
|
1261
1645
|
// studio/server/routes/suites.ts
|
|
1262
1646
|
import { Router as Router5 } from "express";
|
|
1263
|
-
import { readdir as
|
|
1264
|
-
import
|
|
1265
|
-
|
|
1266
|
-
// src/suite-schema.ts
|
|
1267
|
-
function fail4(field, reason) {
|
|
1268
|
-
throw new Error(`Invalid TestSuite: ${field} ${reason}`);
|
|
1269
|
-
}
|
|
1270
|
-
function assertString4(value, field) {
|
|
1271
|
-
if (typeof value !== "string" || value.length === 0) fail4(field, "must be a non-empty string");
|
|
1272
|
-
}
|
|
1273
|
-
function validateInputBinding(value, field) {
|
|
1274
|
-
if (typeof value !== "object" || value === null) fail4(field, "must be an object");
|
|
1275
|
-
const v = value;
|
|
1276
|
-
if (v.kind === "fixture") {
|
|
1277
|
-
assertString4(v.fixtureName, `${field}.fixtureName`);
|
|
1278
|
-
assertString4(v.param, `${field}.param`);
|
|
1279
|
-
} else if (v.kind === "testCaseOutput") {
|
|
1280
|
-
assertString4(v.testCaseId, `${field}.testCaseId`);
|
|
1281
|
-
assertString4(v.outputName, `${field}.outputName`);
|
|
1282
|
-
} else if (v.kind === "literal") {
|
|
1283
|
-
assertString4(v.value, `${field}.value`);
|
|
1284
|
-
} else if (v.kind === "autogenerated") {
|
|
1285
|
-
} else {
|
|
1286
|
-
fail4(`${field}.kind`, 'must be "fixture", "testCaseOutput", "literal", or "autogenerated"');
|
|
1287
|
-
}
|
|
1288
|
-
return v;
|
|
1289
|
-
}
|
|
1290
|
-
var VALID_COMPARATORS = ["equals", "notEquals", "contains", "greaterThan", "lessThan"];
|
|
1291
|
-
function validateRunCondition(value, field) {
|
|
1292
|
-
if (typeof value !== "object" || value === null) fail4(field, "must be an object");
|
|
1293
|
-
const v = value;
|
|
1294
|
-
assertString4(v.testCaseId, `${field}.testCaseId`);
|
|
1295
|
-
assertString4(v.outputName, `${field}.outputName`);
|
|
1296
|
-
if (!VALID_COMPARATORS.includes(v.comparator)) {
|
|
1297
|
-
fail4(`${field}.comparator`, `must be one of ${VALID_COMPARATORS.join(", ")}`);
|
|
1298
|
-
}
|
|
1299
|
-
assertString4(v.value, `${field}.value`);
|
|
1300
|
-
return v;
|
|
1301
|
-
}
|
|
1302
|
-
function validateTestSuiteEntry(value, index) {
|
|
1303
|
-
if (typeof value !== "object" || value === null) fail4(`entries[${index}]`, "must be an object");
|
|
1304
|
-
const v = value;
|
|
1305
|
-
assertString4(v.testCaseId, `entries[${index}].testCaseId`);
|
|
1306
|
-
if (!Array.isArray(v.dependsOn) || !v.dependsOn.every((d) => typeof d === "string")) {
|
|
1307
|
-
fail4(`entries[${index}].dependsOn`, "must be an array of testCaseId strings");
|
|
1308
|
-
}
|
|
1309
|
-
if (typeof v.inputBindings !== "object" || v.inputBindings === null || Array.isArray(v.inputBindings)) {
|
|
1310
|
-
fail4(`entries[${index}].inputBindings`, "must be an object mapping param names to bindings");
|
|
1311
|
-
}
|
|
1312
|
-
const inputBindings = {};
|
|
1313
|
-
for (const [param, binding] of Object.entries(v.inputBindings)) {
|
|
1314
|
-
inputBindings[param] = validateInputBinding(binding, `entries[${index}].inputBindings.${param}`);
|
|
1315
|
-
}
|
|
1316
|
-
if (v.legalEntity !== void 0) assertString4(v.legalEntity, `entries[${index}].legalEntity`);
|
|
1317
|
-
const runIf = v.runIf !== void 0 ? validateRunCondition(v.runIf, `entries[${index}].runIf`) : void 0;
|
|
1318
|
-
return {
|
|
1319
|
-
testCaseId: v.testCaseId,
|
|
1320
|
-
dependsOn: v.dependsOn,
|
|
1321
|
-
inputBindings,
|
|
1322
|
-
...v.legalEntity !== void 0 ? { legalEntity: v.legalEntity } : {},
|
|
1323
|
-
...runIf !== void 0 ? { runIf } : {}
|
|
1324
|
-
};
|
|
1325
|
-
}
|
|
1326
|
-
function validateTestSuite(value) {
|
|
1327
|
-
if (typeof value !== "object" || value === null) fail4("suite", "must be an object");
|
|
1328
|
-
const v = value;
|
|
1329
|
-
assertString4(v.id, "id");
|
|
1330
|
-
assertString4(v.name, "name");
|
|
1331
|
-
assertString4(v.description, "description");
|
|
1332
|
-
if (!Array.isArray(v.entries)) fail4("entries", "must be an array");
|
|
1333
|
-
return {
|
|
1334
|
-
id: v.id,
|
|
1335
|
-
name: v.name,
|
|
1336
|
-
description: v.description,
|
|
1337
|
-
entries: v.entries.map(validateTestSuiteEntry)
|
|
1338
|
-
};
|
|
1339
|
-
}
|
|
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";
|
|
1340
1649
|
|
|
1341
1650
|
// src/suite-runner.ts
|
|
1342
1651
|
function topologicalOrder(suite) {
|
|
@@ -1383,9 +1692,9 @@ function topologicalOrder(suite) {
|
|
|
1383
1692
|
|
|
1384
1693
|
// studio/server/routes/suites.ts
|
|
1385
1694
|
var suitesRouter = Router5();
|
|
1386
|
-
async function
|
|
1695
|
+
async function fileExists2(filePath) {
|
|
1387
1696
|
try {
|
|
1388
|
-
await
|
|
1697
|
+
await stat2(filePath);
|
|
1389
1698
|
return true;
|
|
1390
1699
|
} catch {
|
|
1391
1700
|
return false;
|
|
@@ -1397,12 +1706,12 @@ function validateAndCheckDag(value) {
|
|
|
1397
1706
|
return suite;
|
|
1398
1707
|
}
|
|
1399
1708
|
suitesRouter.get("/", async (_req, res) => {
|
|
1400
|
-
const files = (await
|
|
1709
|
+
const files = (await readdir6(SUITES_DIR)).filter((f) => f.endsWith(".json"));
|
|
1401
1710
|
const summaries = [];
|
|
1402
1711
|
for (const file of files) {
|
|
1403
1712
|
const slug = file.replace(/\.json$/, "");
|
|
1404
1713
|
try {
|
|
1405
|
-
const raw = JSON.parse(await
|
|
1714
|
+
const raw = JSON.parse(await readFile6(path7.join(SUITES_DIR, file), "utf-8"));
|
|
1406
1715
|
const suite = validateTestSuite(raw);
|
|
1407
1716
|
summaries.push({ file: slug, id: suite.id, name: suite.name, description: suite.description, entryCount: suite.entries.length });
|
|
1408
1717
|
} catch (err) {
|
|
@@ -1421,7 +1730,7 @@ suitesRouter.get("/", async (_req, res) => {
|
|
|
1421
1730
|
});
|
|
1422
1731
|
suitesRouter.get("/:file", async (req, res) => {
|
|
1423
1732
|
const slug = safeFileName(req.params.file);
|
|
1424
|
-
const raw = JSON.parse(await
|
|
1733
|
+
const raw = JSON.parse(await readFile6(path7.join(SUITES_DIR, `${slug}.json`), "utf-8"));
|
|
1425
1734
|
res.json(validateTestSuite(raw));
|
|
1426
1735
|
});
|
|
1427
1736
|
suitesRouter.post("/", async (req, res) => {
|
|
@@ -1431,60 +1740,60 @@ suitesRouter.post("/", async (req, res) => {
|
|
|
1431
1740
|
return;
|
|
1432
1741
|
}
|
|
1433
1742
|
const slug = safeFileName(body.file);
|
|
1434
|
-
const filePath =
|
|
1435
|
-
if (await
|
|
1743
|
+
const filePath = path7.join(SUITES_DIR, `${slug}.json`);
|
|
1744
|
+
if (await fileExists2(filePath)) {
|
|
1436
1745
|
res.status(409).json({ error: `Suite file "${slug}.json" already exists` });
|
|
1437
1746
|
return;
|
|
1438
1747
|
}
|
|
1439
1748
|
const suite = validateAndCheckDag(body);
|
|
1440
|
-
await
|
|
1749
|
+
await writeFile4(filePath, JSON.stringify(suite, null, 2) + "\n", "utf-8");
|
|
1441
1750
|
res.status(201).json({ file: slug, ...suite });
|
|
1442
1751
|
});
|
|
1443
1752
|
suitesRouter.put("/:file", async (req, res) => {
|
|
1444
1753
|
const slug = safeFileName(req.params.file);
|
|
1445
|
-
const filePath =
|
|
1446
|
-
if (!await
|
|
1754
|
+
const filePath = path7.join(SUITES_DIR, `${slug}.json`);
|
|
1755
|
+
if (!await fileExists2(filePath)) {
|
|
1447
1756
|
res.status(404).json({ error: `Suite file "${slug}.json" does not exist` });
|
|
1448
1757
|
return;
|
|
1449
1758
|
}
|
|
1450
|
-
const existing = validateTestSuite(JSON.parse(await
|
|
1759
|
+
const existing = validateTestSuite(JSON.parse(await readFile6(filePath, "utf-8")));
|
|
1451
1760
|
const body = req.body;
|
|
1452
1761
|
const suite = validateAndCheckDag({ ...body, id: existing.id });
|
|
1453
|
-
await
|
|
1762
|
+
await writeFile4(filePath, JSON.stringify(suite, null, 2) + "\n", "utf-8");
|
|
1454
1763
|
res.json({ file: slug, ...suite });
|
|
1455
1764
|
});
|
|
1456
1765
|
suitesRouter.delete("/:file", async (req, res) => {
|
|
1457
1766
|
const slug = safeFileName(req.params.file);
|
|
1458
|
-
await
|
|
1767
|
+
await unlink5(path7.join(SUITES_DIR, `${slug}.json`));
|
|
1459
1768
|
res.status(204).end();
|
|
1460
1769
|
});
|
|
1461
1770
|
|
|
1462
1771
|
// studio/server/routes/plans.ts
|
|
1463
1772
|
import { Router as Router6 } from "express";
|
|
1464
|
-
import { readdir as
|
|
1465
|
-
import
|
|
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";
|
|
1466
1775
|
|
|
1467
1776
|
// src/plan-schema.ts
|
|
1468
|
-
function
|
|
1777
|
+
function fail6(field, reason) {
|
|
1469
1778
|
throw new Error(`Invalid TestPlan: ${field} ${reason}`);
|
|
1470
1779
|
}
|
|
1471
|
-
function
|
|
1472
|
-
if (typeof value !== "string" || value.length === 0)
|
|
1780
|
+
function assertString6(value, field) {
|
|
1781
|
+
if (typeof value !== "string" || value.length === 0) fail6(field, "must be a non-empty string");
|
|
1473
1782
|
}
|
|
1474
1783
|
function validateTestPlanEntry(value, index) {
|
|
1475
|
-
if (typeof value !== "object" || value === null)
|
|
1784
|
+
if (typeof value !== "object" || value === null) fail6(`suites[${index}]`, "must be an object");
|
|
1476
1785
|
const v = value;
|
|
1477
|
-
|
|
1478
|
-
|
|
1786
|
+
assertString6(v.suiteId, `suites[${index}].suiteId`);
|
|
1787
|
+
assertString6(v.suitePath, `suites[${index}].suitePath`);
|
|
1479
1788
|
return { suiteId: v.suiteId, suitePath: v.suitePath };
|
|
1480
1789
|
}
|
|
1481
1790
|
function validateTestPlan(value) {
|
|
1482
|
-
if (typeof value !== "object" || value === null)
|
|
1791
|
+
if (typeof value !== "object" || value === null) fail6("plan", "must be an object");
|
|
1483
1792
|
const v = value;
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
if (!Array.isArray(v.suites))
|
|
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");
|
|
1488
1797
|
return {
|
|
1489
1798
|
id: v.id,
|
|
1490
1799
|
name: v.name,
|
|
@@ -1520,68 +1829,23 @@ function aggregatePlanStatus(run, testRuns) {
|
|
|
1520
1829
|
}
|
|
1521
1830
|
|
|
1522
1831
|
// src/planrun.ts
|
|
1523
|
-
import { mkdir as mkdir2, readdir as
|
|
1524
|
-
import
|
|
1525
|
-
|
|
1526
|
-
// src/planrun-schema.ts
|
|
1527
|
-
function createPlanRun(planId, suites) {
|
|
1528
|
-
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1529
|
-
return {
|
|
1530
|
-
id: startedAt.replace(/:/g, "-"),
|
|
1531
|
-
planId,
|
|
1532
|
-
startedAt,
|
|
1533
|
-
suiteIds: suites.map((s) => s.id),
|
|
1534
|
-
suiteNames: Object.fromEntries(suites.map((s) => [s.id, s.name]))
|
|
1535
|
-
};
|
|
1536
|
-
}
|
|
1537
|
-
function fail6(field, reason) {
|
|
1538
|
-
throw new Error(`Invalid PlanRun: ${field} ${reason}`);
|
|
1539
|
-
}
|
|
1540
|
-
function assertString6(value, field) {
|
|
1541
|
-
if (typeof value !== "string" || value.length === 0) fail6(field, "must be a non-empty string");
|
|
1542
|
-
}
|
|
1543
|
-
function assertSuiteIds(value) {
|
|
1544
|
-
if (value === void 0) return [];
|
|
1545
|
-
if (!Array.isArray(value)) fail6("suiteIds", "must be an array of strings");
|
|
1546
|
-
for (const [i, entry] of value.entries()) assertString6(entry, `suiteIds[${i}]`);
|
|
1547
|
-
return value;
|
|
1548
|
-
}
|
|
1549
|
-
function assertSuiteNames(value) {
|
|
1550
|
-
if (value === void 0) return {};
|
|
1551
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1552
|
-
fail6("suiteNames", "must be an object mapping suiteId to name");
|
|
1553
|
-
}
|
|
1554
|
-
const v = value;
|
|
1555
|
-
for (const [id, name] of Object.entries(v)) assertString6(name, `suiteNames.${id}`);
|
|
1556
|
-
return v;
|
|
1557
|
-
}
|
|
1558
|
-
function validatePlanRun(value) {
|
|
1559
|
-
if (typeof value !== "object" || value === null) fail6("planRun", "must be an object");
|
|
1560
|
-
const v = value;
|
|
1561
|
-
assertString6(v.id, "id");
|
|
1562
|
-
assertString6(v.planId, "planId");
|
|
1563
|
-
assertString6(v.startedAt, "startedAt");
|
|
1564
|
-
const suiteIds = assertSuiteIds(v.suiteIds);
|
|
1565
|
-
const suiteNames = assertSuiteNames(v.suiteNames);
|
|
1566
|
-
return { id: v.id, planId: v.planId, startedAt: v.startedAt, suiteIds, suiteNames };
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
// 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";
|
|
1570
1834
|
async function readPlanRun(filePath) {
|
|
1571
|
-
const raw = await
|
|
1835
|
+
const raw = await readFile7(filePath, "utf-8");
|
|
1572
1836
|
return validatePlanRun(JSON.parse(raw));
|
|
1573
1837
|
}
|
|
1574
1838
|
async function listPlanRuns(dir, planId) {
|
|
1575
1839
|
let files;
|
|
1576
1840
|
try {
|
|
1577
|
-
files = (await
|
|
1841
|
+
files = (await readdir7(dir)).filter((f) => f.endsWith(".json"));
|
|
1578
1842
|
} catch {
|
|
1579
1843
|
return [];
|
|
1580
1844
|
}
|
|
1581
1845
|
const runs = [];
|
|
1582
1846
|
for (const file of files) {
|
|
1583
1847
|
try {
|
|
1584
|
-
const run = await readPlanRun(
|
|
1848
|
+
const run = await readPlanRun(path8.join(dir, file));
|
|
1585
1849
|
if (!planId || run.planId === planId) runs.push(run);
|
|
1586
1850
|
} catch {
|
|
1587
1851
|
}
|
|
@@ -1589,19 +1853,19 @@ async function listPlanRuns(dir, planId) {
|
|
|
1589
1853
|
runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
1590
1854
|
return runs;
|
|
1591
1855
|
}
|
|
1592
|
-
async function initPlanRun(planId, suites, dir) {
|
|
1593
|
-
const planRun = createPlanRun(planId, suites);
|
|
1594
|
-
const filePath =
|
|
1856
|
+
async function initPlanRun(planId, suites, dir, name) {
|
|
1857
|
+
const planRun = createPlanRun(planId, suites, name);
|
|
1858
|
+
const filePath = path8.join(dir, `${planRun.id}.json`);
|
|
1595
1859
|
await mkdir2(dir, { recursive: true });
|
|
1596
|
-
await
|
|
1860
|
+
await writeFile5(filePath, JSON.stringify(planRun, null, 2), "utf-8");
|
|
1597
1861
|
return { planRun, filePath };
|
|
1598
1862
|
}
|
|
1599
1863
|
|
|
1600
1864
|
// studio/server/routes/plans.ts
|
|
1601
1865
|
var plansRouter = Router6();
|
|
1602
|
-
async function
|
|
1866
|
+
async function fileExists3(filePath) {
|
|
1603
1867
|
try {
|
|
1604
|
-
await
|
|
1868
|
+
await stat3(filePath);
|
|
1605
1869
|
return true;
|
|
1606
1870
|
} catch {
|
|
1607
1871
|
return false;
|
|
@@ -1609,19 +1873,19 @@ async function fileExists4(filePath) {
|
|
|
1609
1873
|
}
|
|
1610
1874
|
async function entryFromSuiteFile(suiteFile) {
|
|
1611
1875
|
const slug = safeFileName(suiteFile);
|
|
1612
|
-
const suitePath =
|
|
1613
|
-
const raw = JSON.parse(await
|
|
1876
|
+
const suitePath = path9.join(SUITES_DIR, `${slug}.json`);
|
|
1877
|
+
const raw = JSON.parse(await readFile8(suitePath, "utf-8"));
|
|
1614
1878
|
const suite = validateTestSuite(raw);
|
|
1615
|
-
const relative =
|
|
1879
|
+
const relative = path9.relative(PLANS_DIR, suitePath).replace(/\\/g, "/");
|
|
1616
1880
|
return { suiteId: suite.id, suitePath: relative };
|
|
1617
1881
|
}
|
|
1618
1882
|
plansRouter.get("/", async (_req, res) => {
|
|
1619
|
-
const files = (await
|
|
1883
|
+
const files = (await readdir8(PLANS_DIR)).filter((f) => f.endsWith(".json"));
|
|
1620
1884
|
const summaries = [];
|
|
1621
1885
|
for (const file of files) {
|
|
1622
1886
|
const slug = file.replace(/\.json$/, "");
|
|
1623
1887
|
try {
|
|
1624
|
-
const raw = JSON.parse(await
|
|
1888
|
+
const raw = JSON.parse(await readFile8(path9.join(PLANS_DIR, file), "utf-8"));
|
|
1625
1889
|
const plan = validateTestPlan(raw);
|
|
1626
1890
|
summaries.push({
|
|
1627
1891
|
file: slug,
|
|
@@ -1646,7 +1910,7 @@ plansRouter.get("/", async (_req, res) => {
|
|
|
1646
1910
|
});
|
|
1647
1911
|
plansRouter.get("/:file", async (req, res) => {
|
|
1648
1912
|
const slug = safeFileName(req.params.file);
|
|
1649
|
-
const raw = JSON.parse(await
|
|
1913
|
+
const raw = JSON.parse(await readFile8(path9.join(PLANS_DIR, `${slug}.json`), "utf-8"));
|
|
1650
1914
|
res.json(validateTestPlan(raw));
|
|
1651
1915
|
});
|
|
1652
1916
|
plansRouter.post("/", async (req, res) => {
|
|
@@ -1660,24 +1924,24 @@ plansRouter.post("/", async (req, res) => {
|
|
|
1660
1924
|
return;
|
|
1661
1925
|
}
|
|
1662
1926
|
const slug = safeFileName(body.file);
|
|
1663
|
-
const filePath =
|
|
1664
|
-
if (await
|
|
1927
|
+
const filePath = path9.join(PLANS_DIR, `${slug}.json`);
|
|
1928
|
+
if (await fileExists3(filePath)) {
|
|
1665
1929
|
res.status(409).json({ error: `Plan file "${slug}.json" already exists` });
|
|
1666
1930
|
return;
|
|
1667
1931
|
}
|
|
1668
1932
|
const suites = await Promise.all(body.suiteFiles.map((f) => entryFromSuiteFile(f)));
|
|
1669
1933
|
const plan = validateTestPlan({ id: body.id, name: body.name, description: body.description, suites });
|
|
1670
|
-
await
|
|
1934
|
+
await writeFile6(filePath, JSON.stringify(plan, null, 2) + "\n", "utf-8");
|
|
1671
1935
|
res.status(201).json({ file: slug, ...plan });
|
|
1672
1936
|
});
|
|
1673
1937
|
plansRouter.put("/:file", async (req, res) => {
|
|
1674
1938
|
const slug = safeFileName(req.params.file);
|
|
1675
|
-
const filePath =
|
|
1676
|
-
if (!await
|
|
1939
|
+
const filePath = path9.join(PLANS_DIR, `${slug}.json`);
|
|
1940
|
+
if (!await fileExists3(filePath)) {
|
|
1677
1941
|
res.status(404).json({ error: `Plan file "${slug}.json" does not exist` });
|
|
1678
1942
|
return;
|
|
1679
1943
|
}
|
|
1680
|
-
const existing = validateTestPlan(JSON.parse(await
|
|
1944
|
+
const existing = validateTestPlan(JSON.parse(await readFile8(filePath, "utf-8")));
|
|
1681
1945
|
const body = req.body;
|
|
1682
1946
|
if (!Array.isArray(body.suiteFiles)) {
|
|
1683
1947
|
res.status(400).json({ error: '"suiteFiles" must be an array of suite file slugs' });
|
|
@@ -1685,36 +1949,36 @@ plansRouter.put("/:file", async (req, res) => {
|
|
|
1685
1949
|
}
|
|
1686
1950
|
const suites = await Promise.all(body.suiteFiles.map((f) => entryFromSuiteFile(f)));
|
|
1687
1951
|
const plan = validateTestPlan({ id: existing.id, name: body.name, description: body.description, suites });
|
|
1688
|
-
await
|
|
1952
|
+
await writeFile6(filePath, JSON.stringify(plan, null, 2) + "\n", "utf-8");
|
|
1689
1953
|
res.json({ file: slug, ...plan });
|
|
1690
1954
|
});
|
|
1691
1955
|
plansRouter.delete("/:file", async (req, res) => {
|
|
1692
1956
|
const slug = safeFileName(req.params.file);
|
|
1693
|
-
await
|
|
1957
|
+
await unlink6(path9.join(PLANS_DIR, `${slug}.json`));
|
|
1694
1958
|
res.status(204).end();
|
|
1695
1959
|
});
|
|
1696
1960
|
plansRouter.get("/:file/runs", async (req, res) => {
|
|
1697
1961
|
const slug = safeFileName(req.params.file);
|
|
1698
|
-
const plan = validateTestPlan(JSON.parse(await
|
|
1962
|
+
const plan = validateTestPlan(JSON.parse(await readFile8(path9.join(PLANS_DIR, `${slug}.json`), "utf-8")));
|
|
1699
1963
|
const runs = await listPlanRuns(RUNS_DIR, plan.id);
|
|
1700
1964
|
res.json({ runs });
|
|
1701
1965
|
});
|
|
1702
1966
|
plansRouter.post("/:file/runs", async (req, res) => {
|
|
1703
1967
|
const slug = safeFileName(req.params.file);
|
|
1704
|
-
const plan = validateTestPlan(JSON.parse(await
|
|
1968
|
+
const plan = validateTestPlan(JSON.parse(await readFile8(path9.join(PLANS_DIR, `${slug}.json`), "utf-8")));
|
|
1705
1969
|
const suites = await Promise.all(
|
|
1706
1970
|
plan.suites.map(async (entry) => {
|
|
1707
|
-
const raw = JSON.parse(await
|
|
1971
|
+
const raw = JSON.parse(await readFile8(safeSuitePathFromPlan(entry.suitePath), "utf-8"));
|
|
1708
1972
|
const suite = validateTestSuite(raw);
|
|
1709
1973
|
return { id: suite.id, name: suite.name };
|
|
1710
1974
|
})
|
|
1711
1975
|
);
|
|
1712
|
-
const { planRun } = await initPlanRun(plan.id, suites, RUNS_DIR);
|
|
1976
|
+
const { planRun } = await initPlanRun(plan.id, suites, RUNS_DIR, req.body?.name);
|
|
1713
1977
|
res.status(201).json(planRun);
|
|
1714
1978
|
});
|
|
1715
1979
|
plansRouter.delete("/:file/runs/:runId", async (req, res) => {
|
|
1716
1980
|
const runId = safeFileName(req.params.runId);
|
|
1717
|
-
await
|
|
1981
|
+
await unlink6(path9.join(RUNS_DIR, `${runId}.json`));
|
|
1718
1982
|
res.status(204).end();
|
|
1719
1983
|
});
|
|
1720
1984
|
plansRouter.get("/:file/status", async (req, res) => {
|
|
@@ -1724,17 +1988,17 @@ plansRouter.get("/:file/status", async (req, res) => {
|
|
|
1724
1988
|
res.status(400).json({ error: '"runId" query parameter is required' });
|
|
1725
1989
|
return;
|
|
1726
1990
|
}
|
|
1727
|
-
const plan = validateTestPlan(JSON.parse(await
|
|
1728
|
-
const run = await readPlanRun(
|
|
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`));
|
|
1729
1993
|
if (run.planId !== plan.id) {
|
|
1730
1994
|
res.status(400).json({ error: `Plan run "${run.id}" belongs to plan "${run.planId}", not "${plan.id}"` });
|
|
1731
1995
|
return;
|
|
1732
1996
|
}
|
|
1733
1997
|
const testRuns = /* @__PURE__ */ new Map();
|
|
1734
1998
|
for (const suiteId of run.suiteIds) {
|
|
1735
|
-
const testRunPath =
|
|
1999
|
+
const testRunPath = path9.join(TESTRUNS_DIR, `${suiteId}-${run.id}.json`);
|
|
1736
2000
|
try {
|
|
1737
|
-
testRuns.set(suiteId, validateTestRun(JSON.parse(await
|
|
2001
|
+
testRuns.set(suiteId, validateTestRun(JSON.parse(await readFile8(testRunPath, "utf-8"))));
|
|
1738
2002
|
} catch {
|
|
1739
2003
|
testRuns.set(suiteId, null);
|
|
1740
2004
|
}
|
|
@@ -1747,13 +2011,13 @@ import { Router as Router7 } from "express";
|
|
|
1747
2011
|
import express from "express";
|
|
1748
2012
|
|
|
1749
2013
|
// src/library-archive.ts
|
|
1750
|
-
import { readFile as
|
|
1751
|
-
import
|
|
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";
|
|
1752
2016
|
import JSZip3 from "jszip";
|
|
1753
2017
|
var LIBRARY_DIRS = ["recordings", "testcases", "testdata", "testscripts", "suites", "plans", "testruns"];
|
|
1754
|
-
async function
|
|
2018
|
+
async function fileExists4(p) {
|
|
1755
2019
|
try {
|
|
1756
|
-
await
|
|
2020
|
+
await stat4(p);
|
|
1757
2021
|
return true;
|
|
1758
2022
|
} catch {
|
|
1759
2023
|
return false;
|
|
@@ -1762,18 +2026,18 @@ async function fileExists5(p) {
|
|
|
1762
2026
|
async function addDirToZip(zip, dirAbsPath, zipPrefix) {
|
|
1763
2027
|
let entries;
|
|
1764
2028
|
try {
|
|
1765
|
-
entries = await
|
|
2029
|
+
entries = await readdir9(dirAbsPath, { withFileTypes: true });
|
|
1766
2030
|
} catch {
|
|
1767
2031
|
return 0;
|
|
1768
2032
|
}
|
|
1769
2033
|
let count = 0;
|
|
1770
2034
|
for (const entry of entries) {
|
|
1771
|
-
const abs =
|
|
2035
|
+
const abs = path10.join(dirAbsPath, entry.name);
|
|
1772
2036
|
const zipPath = `${zipPrefix}/${entry.name}`;
|
|
1773
2037
|
if (entry.isDirectory()) {
|
|
1774
2038
|
count += await addDirToZip(zip, abs, zipPath);
|
|
1775
2039
|
} else if (entry.isFile()) {
|
|
1776
|
-
zip.file(zipPath, await
|
|
2040
|
+
zip.file(zipPath, await readFile9(abs));
|
|
1777
2041
|
count++;
|
|
1778
2042
|
}
|
|
1779
2043
|
}
|
|
@@ -1783,7 +2047,7 @@ async function buildLibraryZip(baseDir) {
|
|
|
1783
2047
|
const zip = new JSZip3();
|
|
1784
2048
|
let fileCount = 0;
|
|
1785
2049
|
for (const dir of LIBRARY_DIRS) {
|
|
1786
|
-
fileCount += await addDirToZip(zip,
|
|
2050
|
+
fileCount += await addDirToZip(zip, path10.join(baseDir, dir), dir);
|
|
1787
2051
|
}
|
|
1788
2052
|
const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
1789
2053
|
return { buffer, fileCount, dirs: LIBRARY_DIRS };
|
|
@@ -1791,15 +2055,15 @@ async function buildLibraryZip(baseDir) {
|
|
|
1791
2055
|
async function extractLibraryZip(bytes, baseDir, force) {
|
|
1792
2056
|
const zip = await JSZip3.loadAsync(bytes);
|
|
1793
2057
|
const entries = Object.values(zip.files).filter((f) => !f.dir);
|
|
1794
|
-
const baseAbs =
|
|
2058
|
+
const baseAbs = path10.resolve(baseDir);
|
|
1795
2059
|
const targets = [];
|
|
1796
2060
|
for (const entry of entries) {
|
|
1797
2061
|
const topDir = entry.name.split("/")[0];
|
|
1798
2062
|
if (!LIBRARY_DIRS.includes(topDir)) {
|
|
1799
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.`);
|
|
1800
2064
|
}
|
|
1801
|
-
const targetPath =
|
|
1802
|
-
if (!targetPath.startsWith(baseAbs +
|
|
2065
|
+
const targetPath = path10.resolve(baseAbs, entry.name);
|
|
2066
|
+
if (!targetPath.startsWith(baseAbs + path10.sep)) {
|
|
1803
2067
|
throw new Error(`Refusing to import: "${entry.name}" resolves outside the target directory.`);
|
|
1804
2068
|
}
|
|
1805
2069
|
targets.push({ entry, targetPath });
|
|
@@ -1807,15 +2071,15 @@ async function extractLibraryZip(bytes, baseDir, force) {
|
|
|
1807
2071
|
if (!force) {
|
|
1808
2072
|
const collisions = [];
|
|
1809
2073
|
for (const { entry, targetPath } of targets) {
|
|
1810
|
-
if (await
|
|
2074
|
+
if (await fileExists4(targetPath)) collisions.push(entry.name);
|
|
1811
2075
|
}
|
|
1812
2076
|
if (collisions.length > 0) {
|
|
1813
2077
|
return { fileCount: 0, collisions };
|
|
1814
2078
|
}
|
|
1815
2079
|
}
|
|
1816
2080
|
for (const { entry, targetPath } of targets) {
|
|
1817
|
-
await mkdir3(
|
|
1818
|
-
await
|
|
2081
|
+
await mkdir3(path10.dirname(targetPath), { recursive: true });
|
|
2082
|
+
await writeFile7(targetPath, await entry.async("nodebuffer"));
|
|
1819
2083
|
}
|
|
1820
2084
|
return { fileCount: targets.length, collisions: [] };
|
|
1821
2085
|
}
|
|
@@ -1845,14 +2109,14 @@ libraryRouter.post("/import", express.raw({ type: "application/zip", limit: "200
|
|
|
1845
2109
|
|
|
1846
2110
|
// studio/server/routes/settings.ts
|
|
1847
2111
|
import { Router as Router8 } from "express";
|
|
1848
|
-
import { stat as
|
|
2112
|
+
import { stat as stat5 } from "node:fs/promises";
|
|
1849
2113
|
|
|
1850
2114
|
// studio/server/settings.ts
|
|
1851
|
-
import { mkdir as mkdir4, readFile as
|
|
1852
|
-
import
|
|
2115
|
+
import { mkdir as mkdir4, readFile as readFile10, writeFile as writeFile8 } from "node:fs/promises";
|
|
2116
|
+
import path11 from "node:path";
|
|
1853
2117
|
import os from "node:os";
|
|
1854
|
-
var CONFIG_DIR =
|
|
1855
|
-
var CONFIG_FILE =
|
|
2118
|
+
var CONFIG_DIR = path11.join(os.homedir(), ".trxa-tool");
|
|
2119
|
+
var CONFIG_FILE = path11.join(CONFIG_DIR, "settings.json");
|
|
1856
2120
|
var cached = null;
|
|
1857
2121
|
async function ensureWorkingFolderDirs() {
|
|
1858
2122
|
await Promise.all(allDataDirs().map((dir) => mkdir4(dir, { recursive: true })));
|
|
@@ -1860,7 +2124,7 @@ async function ensureWorkingFolderDirs() {
|
|
|
1860
2124
|
async function loadSettings() {
|
|
1861
2125
|
if (cached) return cached;
|
|
1862
2126
|
try {
|
|
1863
|
-
const raw = JSON.parse(await
|
|
2127
|
+
const raw = JSON.parse(await readFile10(CONFIG_FILE, "utf-8"));
|
|
1864
2128
|
if (typeof raw.workingFolder === "string" && raw.workingFolder.trim()) {
|
|
1865
2129
|
cached = { workingFolder: raw.workingFolder };
|
|
1866
2130
|
setWorkingFolder(cached.workingFolder);
|
|
@@ -1870,11 +2134,11 @@ async function loadSettings() {
|
|
|
1870
2134
|
} catch (err) {
|
|
1871
2135
|
if (err.code !== "ENOENT") throw err;
|
|
1872
2136
|
}
|
|
1873
|
-
return saveSettings({ workingFolder: process.cwd() });
|
|
2137
|
+
return saveSettings({ workingFolder: process.env.STUDIO_DEFAULT_WORKING_FOLDER || process.cwd() });
|
|
1874
2138
|
}
|
|
1875
2139
|
async function saveSettings(next) {
|
|
1876
2140
|
await mkdir4(CONFIG_DIR, { recursive: true });
|
|
1877
|
-
await
|
|
2141
|
+
await writeFile8(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", "utf-8");
|
|
1878
2142
|
cached = next;
|
|
1879
2143
|
setWorkingFolder(next.workingFolder);
|
|
1880
2144
|
await ensureWorkingFolderDirs();
|
|
@@ -1896,7 +2160,7 @@ settingsRouter.put("/", async (req, res, next) => {
|
|
|
1896
2160
|
if (typeof workingFolder !== "string" || !workingFolder.trim()) {
|
|
1897
2161
|
throw new Error("workingFolder must be a non-empty string");
|
|
1898
2162
|
}
|
|
1899
|
-
const info = await
|
|
2163
|
+
const info = await stat5(workingFolder).catch(() => null);
|
|
1900
2164
|
if (!info || !info.isDirectory()) {
|
|
1901
2165
|
throw new Error(`Not a directory: "${workingFolder}"`);
|
|
1902
2166
|
}
|
|
@@ -1906,6 +2170,978 @@ settingsRouter.put("/", async (req, res, next) => {
|
|
|
1906
2170
|
}
|
|
1907
2171
|
});
|
|
1908
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
|
+
|
|
1909
3145
|
// studio/server/index.ts
|
|
1910
3146
|
var PORT = Number(process.env.STUDIO_SERVER_PORT ?? 4321);
|
|
1911
3147
|
var app = express2();
|
|
@@ -1918,9 +3154,11 @@ app.use("/api/suites", suitesRouter);
|
|
|
1918
3154
|
app.use("/api/plans", plansRouter);
|
|
1919
3155
|
app.use("/api/library", libraryRouter);
|
|
1920
3156
|
app.use("/api/settings", settingsRouter);
|
|
3157
|
+
app.use("/api/agent", agentRouter);
|
|
3158
|
+
app.use("/api/runs", runsRouter);
|
|
1921
3159
|
var CLIENT_DIST = [
|
|
1922
|
-
|
|
1923
|
-
|
|
3160
|
+
path15.join(import.meta.dirname, "..", "client-dist"),
|
|
3161
|
+
path15.join(import.meta.dirname, "..", "..", "client-dist")
|
|
1924
3162
|
].find((candidate) => existsSync(candidate));
|
|
1925
3163
|
if (CLIENT_DIST) {
|
|
1926
3164
|
app.use(express2.static(CLIENT_DIST));
|
|
@@ -1933,6 +3171,6 @@ var errorHandler = (err, _req, res, _next) => {
|
|
|
1933
3171
|
app.use(errorHandler);
|
|
1934
3172
|
var settings = await loadSettings();
|
|
1935
3173
|
app.listen(PORT, "127.0.0.1", () => {
|
|
1936
|
-
console.log(`
|
|
3174
|
+
console.log(`TRXA Studio API listening on http://127.0.0.1:${PORT} (local only, do not expose on a network)`);
|
|
1937
3175
|
console.log(`Working folder: ${settings.workingFolder}`);
|
|
1938
3176
|
});
|