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.
Files changed (120) hide show
  1. package/MANUAL.md +376 -368
  2. package/README.md +75 -51
  3. package/SETUP.md +401 -0
  4. package/STUDIO.md +417 -309
  5. package/dist/fixture-schema.d.ts +45 -5
  6. package/dist/fixture-schema.js +97 -13
  7. package/dist/fixture-store.d.ts +37 -0
  8. package/dist/fixture-store.js +75 -0
  9. package/dist/index.js +76 -40
  10. package/dist/mapping.js +4 -1
  11. package/dist/mcp-bridge.d.ts +52 -0
  12. package/dist/mcp-bridge.js +290 -0
  13. package/dist/planrun-schema.d.ts +14 -6
  14. package/dist/planrun-schema.js +31 -7
  15. package/dist/planrun.d.ts +1 -1
  16. package/dist/planrun.js +2 -2
  17. package/dist/report-schema.d.ts +10 -1
  18. package/dist/report-schema.js +31 -5
  19. package/dist/report.d.ts +4 -1
  20. package/dist/report.js +9 -2
  21. package/dist/suite-runner.js +8 -2
  22. package/media/studio/fixtures-test-cases.png +0 -0
  23. package/media/studio/plans-editor.png +0 -0
  24. package/media/studio/recordings-create-test-case.png +0 -0
  25. package/media/studio/run-dialog.png +0 -0
  26. package/media/studio/settings-agent-runs.png +0 -0
  27. package/media/studio/settings-working-folder.png +0 -0
  28. package/media/studio/studio-recordings.png +0 -0
  29. package/media/studio/suites-canvas.png +0 -0
  30. package/media/studio/suites-inspector.png +0 -0
  31. package/media/studio/testcases-expectations.png +0 -0
  32. package/media/studio/testcases-outputs.png +0 -0
  33. package/media/studio/testrun-details.png +0 -0
  34. package/media/studio/testruns-list.png +0 -0
  35. package/package.json +6 -2
  36. package/studio/client-dist/assets/CreatePanel-C5bb5OIs.js +6 -0
  37. package/studio/client-dist/assets/FactTile-G1pURLys.js +1 -0
  38. package/studio/client-dist/assets/FieldLabel-DPmIEQ08.js +1 -0
  39. package/studio/client-dist/assets/FixtureNamePicker-137AAgwZ.js +1 -0
  40. package/studio/client-dist/assets/Fixtures-D0t-soph.js +1 -0
  41. package/studio/client-dist/assets/GeneralSection-CWkTeSX2.js +1 -0
  42. package/studio/client-dist/assets/ListPaneHeader-DXKqX36N.js +1 -0
  43. package/studio/client-dist/assets/Plans-C70evkGc.js +1 -0
  44. package/studio/client-dist/assets/Recordings-BT0dyVLs.js +1 -0
  45. package/studio/client-dist/assets/Settings-k-J3u5lH.js +1 -0
  46. package/studio/client-dist/assets/Suites-DLioOiRN.css +1 -0
  47. package/studio/client-dist/assets/Suites-DQhhMxjT.js +7 -0
  48. package/studio/client-dist/assets/TestCases-BdiopVGI.js +1 -0
  49. package/studio/client-dist/assets/TestRuns-CylAe-3t.js +9 -0
  50. package/studio/client-dist/assets/api-DJZ703_F.js +24 -0
  51. package/studio/client-dist/assets/bucket-18-viuwGcaj.js +1 -0
  52. package/studio/client-dist/assets/fixtureName-CTZT_jKu.js +1 -0
  53. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  54. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  55. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  56. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  57. package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  58. package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  59. package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  60. package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  61. package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  62. package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  63. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-LoaTnByX.woff +0 -0
  64. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-RhT0Ayi3.woff2 +0 -0
  65. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-Bf1n82Tb.woff +0 -0
  66. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-D9J6q4_O.woff2 +0 -0
  67. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  68. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  69. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  70. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  71. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-Bbd5v1xK.woff +0 -0
  72. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-DqmgDkIN.woff2 +0 -0
  73. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-BELOEI8R.woff2 +0 -0
  74. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-CfSXsIbO.woff +0 -0
  75. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  76. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  77. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  78. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  79. package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-J-CBqZoJ.woff2 +0 -0
  80. package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-STc8XiDr.woff +0 -0
  81. package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-BEZwmy7D.woff2 +0 -0
  82. package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-CTYLy0lX.woff +0 -0
  83. package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  84. package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  85. package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  86. package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  87. package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-C9Of02-5.woff +0 -0
  88. package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-b4i7YzLG.woff2 +0 -0
  89. package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CZTNEAuW.woff2 +0 -0
  90. package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CsGl1sm0.woff +0 -0
  91. package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  92. package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  93. package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  94. package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  95. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-B8Zgwjh3.woff +0 -0
  96. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-BLFuR0P1.woff2 +0 -0
  97. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-Du9zzoL2.woff2 +0 -0
  98. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-V_9i1P_m.woff +0 -0
  99. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  100. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  101. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  102. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  103. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-CkYRZD2a.woff +0 -0
  104. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-SDfX6ktj.woff2 +0 -0
  105. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-78jqtfZH.woff +0 -0
  106. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-BbRnwouv.woff2 +0 -0
  107. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  108. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  109. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  110. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  111. package/studio/client-dist/assets/index-cxBc2iRv.js +10 -0
  112. package/studio/client-dist/assets/index-wjWuVHXN.css +1 -0
  113. package/studio/client-dist/assets/status-DUGxC7G7.js +1 -0
  114. package/studio/client-dist/assets/testCaseTree-Cl1-bc5O.js +1 -0
  115. package/studio/client-dist/axtr-icon.svg +2 -5
  116. package/studio/client-dist/favicon.svg +2 -5
  117. package/studio/client-dist/index.html +5 -4
  118. package/studio/server/dist/index.js +1530 -292
  119. package/studio/client-dist/assets/index-CgWhGkEn.js +0 -24
  120. 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 path11 from "node:path";
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: ControlName, value: formattedValue }] },
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 trxa-tool Studio from ${body.sourceRecording}.`,
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 readdir3, readFile as readFile3, writeFile as writeFile2, unlink as unlink2, stat as stat2 } from "node:fs/promises";
1042
- import path4 from "node:path";
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
- assertString2(v.description, "description");
1057
- if (typeof v.values !== "object" || v.values === null || Array.isArray(v.values)) {
1058
- fail2("values", "must be an object mapping param names to string values");
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
- for (const [key, val] of Object.entries(v.values)) {
1061
- if (typeof val !== "string") fail2(`values.${key}`, "must be a string");
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
- values: v.values
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
- async function fileExists2(filePath) {
1074
- try {
1075
- await stat2(filePath);
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
- fixturesRouter.get("/", async (_req, res) => {
1082
- const files = (await readdir3(TESTDATA_DIR)).filter((f) => f.endsWith(".json"));
1083
- const summaries = [];
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
- const raw = JSON.parse(await readFile3(path4.join(TESTDATA_DIR, file), "utf-8"));
1088
- const fixture = validateFixture(raw);
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
- res.json({ fixtures: summaries });
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.get("/:file", async (req, res) => {
1104
- const slug = safeFileName(req.params.file);
1105
- const raw = JSON.parse(await readFile3(path4.join(TESTDATA_DIR, `${slug}.json`), "utf-8"));
1106
- res.json(validateFixture(raw));
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 (typeof body.file !== "string" || body.file.length === 0) {
1111
- res.status(400).json({ error: '"file" (the filename slug) is required' });
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 slug = safeFileName(body.file);
1115
- const fixture = validateFixture(body);
1116
- const filePath = path4.join(TESTDATA_DIR, `${slug}.json`);
1117
- if (await fileExists2(filePath)) {
1118
- res.status(409).json({ error: `Fixture file "${slug}.json" already exists` });
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
- await writeFile2(filePath, JSON.stringify(fixture, null, 2) + "\n", "utf-8");
1122
- res.status(201).json({ file: slug, ...fixture });
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("/:file", async (req, res) => {
1125
- const slug = safeFileName(req.params.file);
1126
- const filePath = path4.join(TESTDATA_DIR, `${slug}.json`);
1127
- if (!await fileExists2(filePath)) {
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
- const existing = validateFixture(JSON.parse(await readFile3(filePath, "utf-8")));
1132
- const fixture = validateFixture({ ...req.body, testCaseId: existing.testCaseId });
1133
- await writeFile2(filePath, JSON.stringify(fixture, null, 2) + "\n", "utf-8");
1134
- res.json({ file: slug, ...fixture });
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("/:file", async (req, res) => {
1137
- const slug = safeFileName(req.params.file);
1138
- await unlink2(path4.join(TESTDATA_DIR, `${slug}.json`));
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 readdir4, readFile as readFile4, unlink as unlink3 } from "node:fs/promises";
1145
- import path5 from "node:path";
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 fail3(field, reason) {
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 assertString3(value, field) {
1155
- if (typeof value !== "string" || value.length === 0) fail3(field, "must be a non-empty string");
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
- fail3(`results.${testCaseId}`, "must be an object");
1520
+ fail5(`results.${testCaseId}`, "must be an object");
1161
1521
  }
1162
1522
  const v = value;
1163
1523
  if (!VALID_STATUSES.includes(v.status)) {
1164
- fail3(`results.${testCaseId}.status`, `must be one of ${VALID_STATUSES.join(", ")}`);
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
- fail3(`results.${testCaseId}.outputs`, "must be an object");
1527
+ fail5(`results.${testCaseId}.outputs`, "must be an object");
1168
1528
  }
1169
1529
  if (!Array.isArray(v.assertionResults)) {
1170
- fail3(`results.${testCaseId}.assertionResults`, "must be an array");
1530
+ fail5(`results.${testCaseId}.assertionResults`, "must be an array");
1171
1531
  }
1172
1532
  if (v.sequence !== void 0 && typeof v.sequence !== "number") {
1173
- fail3(`results.${testCaseId}.sequence`, "must be a number");
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
- fail3(`results.${testCaseId}.inputs`, "must be an object");
1536
+ fail5(`results.${testCaseId}.inputs`, "must be an object");
1177
1537
  }
1178
1538
  if (v.startedAt !== void 0 && typeof v.startedAt !== "string") {
1179
- fail3(`results.${testCaseId}.startedAt`, "must be a string");
1539
+ fail5(`results.${testCaseId}.startedAt`, "must be a string");
1180
1540
  }
1181
1541
  if (v.durationMs !== void 0 && typeof v.durationMs !== "number") {
1182
- fail3(`results.${testCaseId}.durationMs`, "must be a number");
1542
+ fail5(`results.${testCaseId}.durationMs`, "must be a number");
1183
1543
  }
1184
1544
  if (v.tokensUsed !== void 0 && typeof v.tokensUsed !== "number") {
1185
- fail3(`results.${testCaseId}.tokensUsed`, "must be a number");
1545
+ fail5(`results.${testCaseId}.tokensUsed`, "must be a number");
1186
1546
  }
1187
1547
  if (v.costUsd !== void 0 && typeof v.costUsd !== "number") {
1188
- fail3(`results.${testCaseId}.costUsd`, "must be a number");
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
- fail3(`results.${testCaseId}.messages`, "must be an array of strings");
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) fail3("testRun", "must be an object");
1567
+ if (typeof value !== "object" || value === null) fail5("testRun", "must be an object");
1208
1568
  const v = value;
1209
- assertString3(v.suiteId, "suiteId");
1210
- assertString3(v.startedAt, "startedAt");
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
- fail3("results", "must be an object mapping testCaseId to TestCaseResult");
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
- return { suiteId: v.suiteId, startedAt: v.startedAt, results };
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 readdir4(TESTRUNS_DIR)).filter((f) => f.endsWith(".json"));
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 readFile4(path5.join(TESTRUNS_DIR, file), "utf-8"));
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({ file: slug, suiteId: testRun.suiteId, startedAt: testRun.startedAt, counts });
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 readFile4(path5.join(TESTRUNS_DIR, `${slug}.json`), "utf-8"));
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 unlink3(path5.join(TESTRUNS_DIR, `${slug}.json`));
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 readdir5, readFile as readFile5, writeFile as writeFile3, unlink as unlink4, stat as stat3 } from "node:fs/promises";
1264
- import path6 from "node:path";
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 fileExists3(filePath) {
1695
+ async function fileExists2(filePath) {
1387
1696
  try {
1388
- await stat3(filePath);
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 readdir5(SUITES_DIR)).filter((f) => f.endsWith(".json"));
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 readFile5(path6.join(SUITES_DIR, file), "utf-8"));
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 readFile5(path6.join(SUITES_DIR, `${slug}.json`), "utf-8"));
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 = path6.join(SUITES_DIR, `${slug}.json`);
1435
- if (await fileExists3(filePath)) {
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 writeFile3(filePath, JSON.stringify(suite, null, 2) + "\n", "utf-8");
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 = path6.join(SUITES_DIR, `${slug}.json`);
1446
- if (!await fileExists3(filePath)) {
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 readFile5(filePath, "utf-8")));
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 writeFile3(filePath, JSON.stringify(suite, null, 2) + "\n", "utf-8");
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 unlink4(path6.join(SUITES_DIR, `${slug}.json`));
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 readdir7, readFile as readFile7, writeFile as writeFile5, unlink as unlink5, stat as stat4 } from "node:fs/promises";
1465
- import path8 from "node:path";
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 fail5(field, reason) {
1777
+ function fail6(field, reason) {
1469
1778
  throw new Error(`Invalid TestPlan: ${field} ${reason}`);
1470
1779
  }
1471
- function assertString5(value, field) {
1472
- if (typeof value !== "string" || value.length === 0) fail5(field, "must be a non-empty string");
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) fail5(`suites[${index}]`, "must be an object");
1784
+ if (typeof value !== "object" || value === null) fail6(`suites[${index}]`, "must be an object");
1476
1785
  const v = value;
1477
- assertString5(v.suiteId, `suites[${index}].suiteId`);
1478
- assertString5(v.suitePath, `suites[${index}].suitePath`);
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) fail5("plan", "must be an object");
1791
+ if (typeof value !== "object" || value === null) fail6("plan", "must be an object");
1483
1792
  const v = value;
1484
- assertString5(v.id, "id");
1485
- assertString5(v.name, "name");
1486
- assertString5(v.description, "description");
1487
- if (!Array.isArray(v.suites)) fail5("suites", "must be an array");
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 readdir6, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
1524
- import path7 from "node:path";
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 readFile6(filePath, "utf-8");
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 readdir6(dir)).filter((f) => f.endsWith(".json"));
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(path7.join(dir, file));
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 = path7.join(dir, `${planRun.id}.json`);
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 writeFile4(filePath, JSON.stringify(planRun, null, 2), "utf-8");
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 fileExists4(filePath) {
1866
+ async function fileExists3(filePath) {
1603
1867
  try {
1604
- await stat4(filePath);
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 = path8.join(SUITES_DIR, `${slug}.json`);
1613
- const raw = JSON.parse(await readFile7(suitePath, "utf-8"));
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 = path8.relative(PLANS_DIR, suitePath).replace(/\\/g, "/");
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 readdir7(PLANS_DIR)).filter((f) => f.endsWith(".json"));
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 readFile7(path8.join(PLANS_DIR, file), "utf-8"));
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 readFile7(path8.join(PLANS_DIR, `${slug}.json`), "utf-8"));
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 = path8.join(PLANS_DIR, `${slug}.json`);
1664
- if (await fileExists4(filePath)) {
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 writeFile5(filePath, JSON.stringify(plan, null, 2) + "\n", "utf-8");
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 = path8.join(PLANS_DIR, `${slug}.json`);
1676
- if (!await fileExists4(filePath)) {
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 readFile7(filePath, "utf-8")));
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 writeFile5(filePath, JSON.stringify(plan, null, 2) + "\n", "utf-8");
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 unlink5(path8.join(PLANS_DIR, `${slug}.json`));
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 readFile7(path8.join(PLANS_DIR, `${slug}.json`), "utf-8")));
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 readFile7(path8.join(PLANS_DIR, `${slug}.json`), "utf-8")));
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 readFile7(safeSuitePathFromPlan(entry.suitePath), "utf-8"));
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 unlink5(path8.join(RUNS_DIR, `${runId}.json`));
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 readFile7(path8.join(PLANS_DIR, `${slug}.json`), "utf-8")));
1728
- const run = await readPlanRun(path8.join(RUNS_DIR, `${safeFileName(runId)}.json`));
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 = path8.join(TESTRUNS_DIR, `${suiteId}-${run.id}.json`);
1999
+ const testRunPath = path9.join(TESTRUNS_DIR, `${suiteId}-${run.id}.json`);
1736
2000
  try {
1737
- testRuns.set(suiteId, validateTestRun(JSON.parse(await readFile7(testRunPath, "utf-8"))));
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 readFile8, writeFile as writeFile6, readdir as readdir8, mkdir as mkdir3, stat as stat5 } from "node:fs/promises";
1751
- import path9 from "node:path";
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 fileExists5(p) {
2018
+ async function fileExists4(p) {
1755
2019
  try {
1756
- await stat5(p);
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 readdir8(dirAbsPath, { withFileTypes: true });
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 = path9.join(dirAbsPath, entry.name);
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 readFile8(abs));
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, path9.join(baseDir, dir), dir);
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 = path9.resolve(baseDir);
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 = path9.resolve(baseAbs, entry.name);
1802
- if (!targetPath.startsWith(baseAbs + path9.sep)) {
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 fileExists5(targetPath)) collisions.push(entry.name);
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(path9.dirname(targetPath), { recursive: true });
1818
- await writeFile6(targetPath, await entry.async("nodebuffer"));
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 stat6 } from "node:fs/promises";
2112
+ import { stat as stat5 } from "node:fs/promises";
1849
2113
 
1850
2114
  // studio/server/settings.ts
1851
- import { mkdir as mkdir4, readFile as readFile9, writeFile as writeFile7 } from "node:fs/promises";
1852
- import path10 from "node:path";
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 = path10.join(os.homedir(), ".trxa-tool");
1855
- var CONFIG_FILE = path10.join(CONFIG_DIR, "settings.json");
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 readFile9(CONFIG_FILE, "utf-8"));
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 writeFile7(CONFIG_FILE, JSON.stringify(next, null, 2) + "\n", "utf-8");
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 stat6(workingFolder).catch(() => null);
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
- path11.join(import.meta.dirname, "..", "client-dist"),
1923
- path11.join(import.meta.dirname, "..", "..", "client-dist")
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(`trxa-tool Studio API listening on http://127.0.0.1:${PORT} (local only, do not expose on a network)`);
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
  });