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
@@ -0,0 +1,290 @@
1
+ /**
2
+ * `trxa mcp-bridge`: a stdio MCP server that forwards every message to a Dynamics 365 finance and
3
+ * operations environment's MCP endpoint (`<environment URL>/mcp`, streamable HTTP), signing in as a
4
+ * Microsoft Entra app registration (client credentials).
5
+ *
6
+ * Agent CLIs (GitHub Copilot CLI, Claude Code) start it as a local server named `d365-erp-mcp`.
7
+ * Neither CLI is on the environment's default "Allowed MCP clients" list, and a replay runs
8
+ * unattended, so a registered app with its own D365 user is the setup SETUP.md documents.
9
+ *
10
+ * Besides forwarding, it keeps a long replay alive across environment restarts:
11
+ * - every request has a timeout, and connections are never reused, so a recycled server can't
12
+ * leave a request hanging on a dead socket;
13
+ * - when the server loses the session, it opens a new one by replaying the client's `initialize`
14
+ * handshake, then retries;
15
+ * - it replays a request only when that is safe: the server rejected it without running it
16
+ * (session not found, 401, 408/429/502/503/504), or the tool only reads. A tool call that timed
17
+ * out may have run, so it is answered with an error instead of being replayed;
18
+ * - every request with an id gets an answer, a JSON-RPC error when all else fails.
19
+ *
20
+ * stdout carries only JSON-RPC messages; diagnostics go to stderr.
21
+ */
22
+ import https from "node:https";
23
+ import readline from "node:readline";
24
+ const TRANSIENT_STATUS = new Set([408, 429, 502, 503, 504]);
25
+ /** Methods that only read, so replaying them after a timeout is harmless. */
26
+ const SAFE_METHODS = new Set(["initialize", "ping", "tools/list", "resources/list", "resources/templates/list", "prompts/list"]);
27
+ /** Tools that only read. */
28
+ const READ_ONLY_TOOL = /^(get_current_user|data_find_|data_get_|form_find_|api_find_)/;
29
+ const SESSION_LOST = /session.*(could not be found|not found|expired)/i;
30
+ /** The JSON-RPC messages in a JSON or server-sent-events body; anything else (HTML) is skipped. */
31
+ export function parseMessages(body) {
32
+ const messages = [];
33
+ for (const raw of (body || "").split("\n")) {
34
+ const line = raw.startsWith("data: ") ? raw.slice(6) : raw;
35
+ if (!line.trim() || line.startsWith(":") || line.startsWith("event:") || line.startsWith("id:"))
36
+ continue;
37
+ try {
38
+ messages.push(JSON.parse(line));
39
+ }
40
+ catch {
41
+ // not JSON
42
+ }
43
+ }
44
+ return messages;
45
+ }
46
+ export function isReplaySafe(message) {
47
+ if (!message?.method)
48
+ return false;
49
+ if (SAFE_METHODS.has(message.method))
50
+ return true;
51
+ return message.method === "tools/call" && READ_ONLY_TOOL.test(message.params?.name ?? "");
52
+ }
53
+ function isSessionLost(response) {
54
+ if (response.status === 404)
55
+ return true;
56
+ if (response.status >= 400)
57
+ return SESSION_LOST.test(response.body || "");
58
+ // A 2xx counts only when it carries a JSON-RPC *error* about the session: a tool result that
59
+ // merely mentions those words must not trigger a replay.
60
+ return parseMessages(response.body).some((m) => m.error && SESSION_LOST.test(m.error.message || ""));
61
+ }
62
+ /** The message-handling core, independent of stdio, HTTPS and MSAL. */
63
+ export function createBridge(deps, options = {}) {
64
+ const requestTimeoutMs = options.requestTimeoutMs ?? 90000;
65
+ const initializeTimeoutMs = options.initializeTimeoutMs ?? 25000;
66
+ const retryDelaysMs = options.retryDelaysMs ?? [5000, 15000, 30000];
67
+ let sessionId = null;
68
+ let initializeLine = null; // replayed to open a new session
69
+ let initializedLine = null; // notifications/initialized, replayed after it
70
+ let reopening = null; // shared, so concurrent requests reopen only once
71
+ async function post(body, session, timeoutMs) {
72
+ return deps.post(body, session, await deps.getToken(false), timeoutMs);
73
+ }
74
+ function reopenSession() {
75
+ if (!initializeLine)
76
+ return Promise.reject(new Error("no initialize request to replay"));
77
+ if (!reopening) {
78
+ const init = initializeLine;
79
+ reopening = (async () => {
80
+ deps.log(`opening a new session (previous: ${sessionId || "none"})`);
81
+ sessionId = null;
82
+ const response = await post(init, null, initializeTimeoutMs);
83
+ if (response.status < 200 || response.status >= 300 || !response.sessionId) {
84
+ throw new Error(`initialize returned HTTP ${response.status}`);
85
+ }
86
+ sessionId = response.sessionId;
87
+ if (initializedLine)
88
+ await post(initializedLine, sessionId, requestTimeoutMs);
89
+ deps.log(`new session ${sessionId}`);
90
+ })().finally(() => {
91
+ reopening = null;
92
+ });
93
+ }
94
+ return reopening;
95
+ }
96
+ async function send(line, message) {
97
+ const isInitialize = message?.method === "initialize";
98
+ const replaySafe = isReplaySafe(message);
99
+ let tokenRefreshed = false;
100
+ let sessionReopened = false;
101
+ for (let attempt = 0;; attempt++) {
102
+ const canRetry = attempt < retryDelaysMs.length && !isInitialize;
103
+ if (reopening)
104
+ await reopening.catch(() => { });
105
+ let response;
106
+ try {
107
+ response = await post(line, isInitialize ? null : sessionId, isInitialize ? initializeTimeoutMs : requestTimeoutMs);
108
+ }
109
+ catch (err) {
110
+ // Network error or timeout: the environment may be restarting, and the session is suspect.
111
+ const text = err instanceof Error ? err.message : String(err);
112
+ deps.log(`${message?.method ?? "request"} failed: ${text}`);
113
+ if (!canRetry)
114
+ throw err;
115
+ await deps.sleep(retryDelaysMs[attempt]);
116
+ try {
117
+ await reopenSession();
118
+ sessionReopened = true;
119
+ }
120
+ catch (e) {
121
+ deps.log(`reopen failed: ${e instanceof Error ? e.message : String(e)}`);
122
+ }
123
+ if (!replaySafe) {
124
+ throw new Error(`${text}. The connection to D365 was lost (restart or deploy?); ` +
125
+ `${sessionReopened ? "a new session is open" : "reconnecting"}. This call may or may not have run: check before retrying it.`);
126
+ }
127
+ continue;
128
+ }
129
+ if (response.status === 401 && !tokenRefreshed) {
130
+ deps.log("401: refreshing the token and retrying");
131
+ tokenRefreshed = true;
132
+ await deps.getToken(true);
133
+ continue;
134
+ }
135
+ if (response.status === 401)
136
+ throw new Error("authentication failed (401) after refreshing the token");
137
+ if (!isInitialize && sessionId && isSessionLost(response) && !sessionReopened) {
138
+ // The server rejected the call without running it: safe to replay on a new session.
139
+ deps.log(`session ${sessionId} lost (HTTP ${response.status})`);
140
+ try {
141
+ await reopenSession();
142
+ sessionReopened = true;
143
+ continue;
144
+ }
145
+ catch (e) {
146
+ deps.log(`reopen failed: ${e instanceof Error ? e.message : String(e)}`);
147
+ if (!canRetry)
148
+ throw e;
149
+ await deps.sleep(retryDelaysMs[attempt]);
150
+ continue;
151
+ }
152
+ }
153
+ if (TRANSIENT_STATUS.has(response.status)) {
154
+ deps.log(`HTTP ${response.status}${canRetry ? `, retrying in ${retryDelaysMs[attempt] / 1000} s` : ""}`);
155
+ if (!canRetry)
156
+ throw new Error(`HTTP ${response.status} from D365 after ${attempt + 1} attempts`);
157
+ await deps.sleep(retryDelaysMs[attempt]);
158
+ continue;
159
+ }
160
+ if (isInitialize || response.sessionId)
161
+ sessionId = response.sessionId || sessionId;
162
+ return response;
163
+ }
164
+ }
165
+ function writeError(id, text) {
166
+ deps.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `d365-erp-mcp: ${text}` } }));
167
+ }
168
+ /** Handles one line from the client; resolves once its answer (if any) is written. */
169
+ async function handle(line) {
170
+ let message = null;
171
+ try {
172
+ message = JSON.parse(line);
173
+ }
174
+ catch {
175
+ // forwarded as-is
176
+ }
177
+ const id = message && message.id !== undefined && message.id !== null ? message.id : null;
178
+ if (message?.method === "initialize") {
179
+ initializeLine = line;
180
+ sessionId = null;
181
+ }
182
+ if (message?.method === "notifications/initialized")
183
+ initializedLine = line;
184
+ try {
185
+ const response = await send(line, message);
186
+ const messages = parseMessages(response.body);
187
+ for (const m of messages)
188
+ deps.write(JSON.stringify(m));
189
+ if (id !== null && messages.length === 0) {
190
+ writeError(id, `HTTP ${response.status} without a JSON-RPC response: ${(response.body || "").slice(0, 200)}`);
191
+ }
192
+ }
193
+ catch (err) {
194
+ const text = err instanceof Error ? err.message : String(err);
195
+ deps.log(`error: ${text}`);
196
+ if (id !== null)
197
+ writeError(id, text);
198
+ }
199
+ }
200
+ return { handle };
201
+ }
202
+ /** Reads the bridge settings from the environment variables SETUP.md documents. */
203
+ export function configFromEnv(env) {
204
+ const missing = ["D365_URL", "D365_TENANT_ID", "D365_CLIENT_ID", "D365_CLIENT_SECRET"].filter((k) => !env[k]?.trim());
205
+ if (missing.length > 0) {
206
+ throw new Error(`trxa mcp-bridge: set ${missing.join(", ")} in the MCP server's "env" settings (see SETUP.md).`);
207
+ }
208
+ let url;
209
+ try {
210
+ url = new URL(env.D365_URL.trim());
211
+ }
212
+ catch {
213
+ throw new Error(`trxa mcp-bridge: D365_URL is not a URL: ${env.D365_URL}`);
214
+ }
215
+ if (url.protocol !== "https:")
216
+ throw new Error("trxa mcp-bridge: D365_URL must start with https://");
217
+ return {
218
+ // Accept the address as copied from the browser: drop any path, query or trailing /mcp.
219
+ environmentUrl: `${url.protocol}//${url.host}`,
220
+ tenantId: env.D365_TENANT_ID.trim(),
221
+ clientId: env.D365_CLIENT_ID.trim(),
222
+ clientSecret: env.D365_CLIENT_SECRET.trim(),
223
+ requestTimeoutMs: Number(env.D365_MCP_TIMEOUT_MS) || 90000,
224
+ };
225
+ }
226
+ function httpsPost(mcpUrl, body, sessionId, token, timeoutMs) {
227
+ return new Promise((resolve, reject) => {
228
+ const headers = {
229
+ "Content-Type": "application/json",
230
+ Authorization: `Bearer ${token}`,
231
+ Accept: "application/json, text/event-stream",
232
+ "Content-Length": Buffer.byteLength(body),
233
+ };
234
+ if (sessionId)
235
+ headers["Mcp-Session-Id"] = sessionId;
236
+ const req = https.request(
237
+ // A new connection per request: no stale pooled sockets after a restart.
238
+ { hostname: mcpUrl.hostname, path: mcpUrl.pathname, method: "POST", headers, agent: false }, (res) => {
239
+ const next = res.headers["mcp-session-id"];
240
+ let data = "";
241
+ res.setEncoding("utf8");
242
+ res.on("data", (chunk) => (data += chunk));
243
+ res.on("end", () => resolve({ body: data, sessionId: (Array.isArray(next) ? next[0] : next) || sessionId, status: res.statusCode ?? 0 }));
244
+ res.on("error", reject);
245
+ });
246
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`no response within ${timeoutMs / 1000} s`)));
247
+ req.on("error", reject);
248
+ req.write(body);
249
+ req.end();
250
+ });
251
+ }
252
+ /** `trxa mcp-bridge`: runs until stdin closes. */
253
+ export async function runMcpBridge(env = process.env) {
254
+ const config = configFromEnv(env);
255
+ const { ConfidentialClientApplication } = await import("@azure/msal-node");
256
+ const msal = new ConfidentialClientApplication({
257
+ auth: { clientId: config.clientId, authority: `https://login.microsoftonline.com/${config.tenantId}`, clientSecret: config.clientSecret },
258
+ });
259
+ const scopes = [`${config.environmentUrl}/.default`];
260
+ const mcpUrl = new URL(`${config.environmentUrl}/mcp`);
261
+ let token = null;
262
+ let expires = 0;
263
+ const log = (text) => process.stderr.write(`[trxa mcp-bridge ${new Date().toISOString()}] ${text}\n`);
264
+ const deps = {
265
+ async getToken(forceRefresh) {
266
+ if (!forceRefresh && token && Date.now() < expires - 60000)
267
+ return token;
268
+ const result = await msal.acquireTokenByClientCredential({ scopes, skipCache: forceRefresh });
269
+ if (!result?.accessToken)
270
+ throw new Error("Microsoft Entra ID returned no access token");
271
+ token = result.accessToken;
272
+ expires = result.expiresOn ? result.expiresOn.getTime() : Date.now() + 300000;
273
+ return token;
274
+ },
275
+ post: (body, sessionId, accessToken, timeoutMs) => httpsPost(mcpUrl, body, sessionId, accessToken, timeoutMs),
276
+ write: (line) => process.stdout.write(line + "\n"),
277
+ log,
278
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
279
+ };
280
+ const bridge = createBridge(deps, { requestTimeoutMs: config.requestTimeoutMs });
281
+ log(`forwarding to ${mcpUrl.href}`);
282
+ // Warm the token without blocking startup: the client's connect timeout is short.
283
+ deps.getToken(false).then(() => log("signed in to Microsoft Entra ID"), (err) => log(`not signed in yet: ${err instanceof Error ? err.message : String(err)}`));
284
+ const rl = readline.createInterface({ input: process.stdin });
285
+ rl.on("line", (line) => {
286
+ if (line.trim())
287
+ void bridge.handle(line);
288
+ });
289
+ await new Promise((resolve) => rl.on("close", () => resolve()));
290
+ }
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * A PlanRun is the thin, persisted record that gives a Test Plan's execution a real identity --
3
- * `{id, planId, startedAt, suiteIds}`. It deliberately carries no name or purpose of its own: that
4
- * would duplicate what the TestPlan it belongs to already owns. Every reference tool checked
5
- * before settling this shape (TestRail, Zephyr, Azure DevOps Test Plans) converges on the same
6
- * pattern -- Plan-as-anchor, Run-as-dated-instance, distinguished from sibling runs only by when
7
- * it started.
3
+ * `{id, planId, startedAt, suiteIds}`. The plan stays the anchor and the run a dated instance of
4
+ * it, but a run can also carry an optional `name` of its own. People refer to a run by what it
5
+ * tested ("Accounts Payable regression, PQU 10.0.48"), not by its start time, and TestRail and
6
+ * Azure DevOps Test Plans both let a run be named for that reason. The name never replaces the
7
+ * plan's identity: `planId` still says which plan it ran.
8
8
  *
9
9
  * `suiteIds` is a snapshot of the plan's suite composition at the moment the run started --
10
10
  * confirmed live that a PlanRun without one breaks: adding "create-new-vendor" as a second suite
@@ -30,7 +30,15 @@ export interface PlanRun {
30
30
  startedAt: string;
31
31
  suiteIds: string[];
32
32
  suiteNames: Record<string, string>;
33
+ /** What people call this run, e.g. "Accounts Payable regression, PQU 10.0.48". Optional: a run
34
+ * without one is shown by its plan's name and start time. */
35
+ name?: string;
33
36
  }
37
+ /** Longest run name accepted, so a pasted paragraph can't end up as a list label. */
38
+ export declare const RUN_NAME_MAX_LENGTH = 120;
39
+ /** Trims a run name and checks its length; an empty name means "no name". Shared by the plan run
40
+ * and test run schemas, the CLI's `--name` flag and Studio's rename endpoints. */
41
+ export declare function normalizeRunName(value: unknown, field?: string): string | undefined;
34
42
  /** Colons aren't legal in Windows filenames, so `id` is `startedAt` with them swapped for dashes
35
43
  * -- still lexically sortable, still human-readable, and trivially derivable from `startedAt`
36
44
  * rather than needing a separate id-generation scheme. A caller never invents this string
@@ -41,5 +49,5 @@ export interface PlanRun {
41
49
  export declare function createPlanRun(planId: string, suites: {
42
50
  id: string;
43
51
  name: string;
44
- }[]): PlanRun;
52
+ }[], name?: string): PlanRun;
45
53
  export declare function validatePlanRun(value: unknown): PlanRun;
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * A PlanRun is the thin, persisted record that gives a Test Plan's execution a real identity --
3
- * `{id, planId, startedAt, suiteIds}`. It deliberately carries no name or purpose of its own: that
4
- * would duplicate what the TestPlan it belongs to already owns. Every reference tool checked
5
- * before settling this shape (TestRail, Zephyr, Azure DevOps Test Plans) converges on the same
6
- * pattern -- Plan-as-anchor, Run-as-dated-instance, distinguished from sibling runs only by when
7
- * it started.
3
+ * `{id, planId, startedAt, suiteIds}`. The plan stays the anchor and the run a dated instance of
4
+ * it, but a run can also carry an optional `name` of its own. People refer to a run by what it
5
+ * tested ("Accounts Payable regression, PQU 10.0.48"), not by its start time, and TestRail and
6
+ * Azure DevOps Test Plans both let a run be named for that reason. The name never replaces the
7
+ * plan's identity: `planId` still says which plan it ran.
8
8
  *
9
9
  * `suiteIds` is a snapshot of the plan's suite composition at the moment the run started --
10
10
  * confirmed live that a PlanRun without one breaks: adding "create-new-vendor" as a second suite
@@ -24,6 +24,21 @@
24
24
  * file's only job is to make that id a real, validated, directly-queryable record instead of an
25
25
  * unvalidated convention nobody checks.
26
26
  */
27
+ /** Longest run name accepted, so a pasted paragraph can't end up as a list label. */
28
+ export const RUN_NAME_MAX_LENGTH = 120;
29
+ /** Trims a run name and checks its length; an empty name means "no name". Shared by the plan run
30
+ * and test run schemas, the CLI's `--name` flag and Studio's rename endpoints. */
31
+ export function normalizeRunName(value, field = "name") {
32
+ if (value === undefined || value === null)
33
+ return undefined;
34
+ if (typeof value !== "string")
35
+ throw new Error(`Invalid run name: ${field} must be a string`);
36
+ const name = value.trim().replace(/\s+/g, " ");
37
+ if (name.length > RUN_NAME_MAX_LENGTH) {
38
+ throw new Error(`Invalid run name: ${field} must be at most ${RUN_NAME_MAX_LENGTH} characters`);
39
+ }
40
+ return name || undefined;
41
+ }
27
42
  /** Colons aren't legal in Windows filenames, so `id` is `startedAt` with them swapped for dashes
28
43
  * -- still lexically sortable, still human-readable, and trivially derivable from `startedAt`
29
44
  * rather than needing a separate id-generation scheme. A caller never invents this string
@@ -31,14 +46,16 @@
31
46
  * arbitrary, unvalidated string a human or LLM picked by hand. `suites` is captured from the plan
32
47
  * as it exists right now, at creation time -- never re-derived later -- and split into the two
33
48
  * snapshot fields above. */
34
- export function createPlanRun(planId, suites) {
49
+ export function createPlanRun(planId, suites, name) {
35
50
  const startedAt = new Date().toISOString();
51
+ const runName = normalizeRunName(name);
36
52
  return {
37
53
  id: startedAt.replace(/:/g, "-"),
38
54
  planId,
39
55
  startedAt,
40
56
  suiteIds: suites.map((s) => s.id),
41
57
  suiteNames: Object.fromEntries(suites.map((s) => [s.id, s.name])),
58
+ ...(runName ? { name: runName } : {}),
42
59
  };
43
60
  }
44
61
  function fail(field, reason) {
@@ -83,5 +100,12 @@ export function validatePlanRun(value) {
83
100
  assertString(v.startedAt, "startedAt");
84
101
  const suiteIds = assertSuiteIds(v.suiteIds);
85
102
  const suiteNames = assertSuiteNames(v.suiteNames);
86
- return { id: v.id, planId: v.planId, startedAt: v.startedAt, suiteIds, suiteNames };
103
+ let name;
104
+ try {
105
+ name = normalizeRunName(v.name);
106
+ }
107
+ catch (err) {
108
+ fail("name", err.message.replace(/^Invalid run name: name /, ""));
109
+ }
110
+ return { id: v.id, planId: v.planId, startedAt: v.startedAt, suiteIds, suiteNames, ...(name ? { name } : {}) };
87
111
  }
package/dist/planrun.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare function listPlanRuns(dir: string, planId?: string): Promise<Plan
14
14
  export declare function initPlanRun(planId: string, suites: {
15
15
  id: string;
16
16
  name: string;
17
- }[], dir: string): Promise<{
17
+ }[], dir: string, name?: string): Promise<{
18
18
  planRun: PlanRun;
19
19
  filePath: string;
20
20
  }>;
package/dist/planrun.js CHANGED
@@ -37,8 +37,8 @@ export async function listPlanRuns(dir, planId) {
37
37
  * plan's current suites (id + name) -- captured once, here, so this run's status/display never
38
38
  * drifts if the plan's suites are later added, removed, or renamed (see planrun-schema.ts's
39
39
  * `PlanRun.suiteIds`/`suiteNames`). */
40
- export async function initPlanRun(planId, suites, dir) {
41
- const planRun = createPlanRun(planId, suites);
40
+ export async function initPlanRun(planId, suites, dir, name) {
41
+ const planRun = createPlanRun(planId, suites, name);
42
42
  const filePath = path.join(dir, `${planRun.id}.json`);
43
43
  await mkdir(dir, { recursive: true });
44
44
  await writeFile(filePath, JSON.stringify(planRun, null, 2), "utf-8");
@@ -73,11 +73,20 @@ export interface TestCaseResult {
73
73
  messages?: string[];
74
74
  }
75
75
  export interface TestRun {
76
+ /** The suite's id -- or, for a `scope: "testcase"` run, the test case's id. */
76
77
  suiteId: string;
77
78
  startedAt: string;
78
79
  results: Record<string, TestCaseResult>;
80
+ /** What this run was started for. Absent on runs the CLI created for a suite (the original
81
+ * behavior). `testcase` marks a run of one test case on its own, with no suite around it. */
82
+ scope?: "suite" | "testcase";
83
+ /** The Studio agent run executing this record, when one created it. */
84
+ agentRunId?: string;
85
+ /** What people call this run, e.g. "Vendor onboarding, PQU 10.0.48". Optional; a suite run that
86
+ * belongs to a plan run is named by its PlanRun instead. See planrun-schema.ts's `name`. */
87
+ name?: string;
79
88
  }
80
89
  /** Creates a fresh TestRun with every suite entry pre-populated as "pending". */
81
- export declare function createTestRun(suiteId: string, testCaseIds: string[]): TestRun;
90
+ export declare function createTestRun(suiteId: string, testCaseIds: string[], extra?: Pick<TestRun, "scope" | "agentRunId" | "name">): TestRun;
82
91
  /** Validates a parsed JSON object against the TestRun shape. */
83
92
  export declare function validateTestRun(value: unknown): TestRun;
@@ -1,5 +1,4 @@
1
- /** Persisted, durable record of a suite run -- this is the mechanism that answers "keep track
2
- * of each test case, expected input, and actual output" instead of relying on chat memory. */
1
+ import { normalizeRunName } from "./planrun-schema.js";
3
2
  /** Statuses a test case never leaves once reached -- used by both `suite-runner.ts` (to decide
4
3
  * whether a whole suite run is done) and `plan-runner.ts` (to roll that up across suites).
5
4
  * Exported from here, not duplicated in each, so the two can't drift out of sync with each other
@@ -8,12 +7,20 @@
8
7
  * error. */
9
8
  export const TERMINAL_STATUSES = new Set(["passed", "failed", "blocked", "skipped"]);
10
9
  /** Creates a fresh TestRun with every suite entry pre-populated as "pending". */
11
- export function createTestRun(suiteId, testCaseIds) {
10
+ export function createTestRun(suiteId, testCaseIds, extra = {}) {
11
+ const name = normalizeRunName(extra.name);
12
12
  const results = {};
13
13
  for (const id of testCaseIds) {
14
14
  results[id] = { status: "pending", outputs: {}, assertionResults: [] };
15
15
  }
16
- return { suiteId, startedAt: new Date().toISOString(), results };
16
+ return {
17
+ suiteId,
18
+ startedAt: new Date().toISOString(),
19
+ results,
20
+ ...(extra.scope !== undefined ? { scope: extra.scope } : {}),
21
+ ...(extra.agentRunId !== undefined ? { agentRunId: extra.agentRunId } : {}),
22
+ ...(name ? { name } : {}),
23
+ };
17
24
  }
18
25
  function fail(field, reason) {
19
26
  throw new Error(`Invalid TestRun: ${field} ${reason}`);
@@ -85,5 +92,24 @@ export function validateTestRun(value) {
85
92
  for (const [id, result] of Object.entries(v.results)) {
86
93
  results[id] = validateTestCaseResult(result, id);
87
94
  }
88
- return { suiteId: v.suiteId, startedAt: v.startedAt, results };
95
+ if (v.scope !== undefined && v.scope !== "suite" && v.scope !== "testcase") {
96
+ fail("scope", "must be suite or testcase");
97
+ }
98
+ if (v.agentRunId !== undefined && typeof v.agentRunId !== "string")
99
+ fail("agentRunId", "must be a string");
100
+ let name;
101
+ try {
102
+ name = normalizeRunName(v.name);
103
+ }
104
+ catch (err) {
105
+ fail("name", err.message.replace(/^Invalid run name: name /, ""));
106
+ }
107
+ return {
108
+ suiteId: v.suiteId,
109
+ startedAt: v.startedAt,
110
+ results,
111
+ ...(v.scope !== undefined ? { scope: v.scope } : {}),
112
+ ...(v.agentRunId !== undefined ? { agentRunId: v.agentRunId } : {}),
113
+ ...(name ? { name } : {}),
114
+ };
89
115
  }
package/dist/report.d.ts CHANGED
@@ -9,7 +9,10 @@ import type { TestSuite } from "./suite-schema.js";
9
9
  export declare function readTestRun(path: string): Promise<TestRun>;
10
10
  export declare function writeTestRun(testRun: TestRun, path: string): Promise<void>;
11
11
  /** Creates and persists a fresh TestRun for a suite, with every entry pre-populated as "pending". */
12
- export declare function initTestRun(suite: TestSuite, path: string): Promise<TestRun>;
12
+ export declare function initTestRun(suite: TestSuite, path: string, extra?: Pick<TestRun, "agentRunId" | "name">): Promise<TestRun>;
13
+ /** Creates and persists a TestRun for one test case run on its own, with no suite: a single
14
+ * pending entry keyed by the test case id. `recordResult` works on it unchanged. */
15
+ export declare function initTestCaseRun(testCaseId: string, path: string, extra?: Pick<TestRun, "agentRunId" | "name">): Promise<TestRun>;
13
16
  export interface RecordResultInput {
14
17
  status: TestCaseStatus;
15
18
  outputs?: Record<string, string>;
package/dist/report.js CHANGED
@@ -14,8 +14,15 @@ export async function writeTestRun(testRun, path) {
14
14
  await writeFile(path, JSON.stringify(testRun, null, 2), "utf-8");
15
15
  }
16
16
  /** Creates and persists a fresh TestRun for a suite, with every entry pre-populated as "pending". */
17
- export async function initTestRun(suite, path) {
18
- const testRun = createTestRun(suite.id, suite.entries.map((e) => e.testCaseId));
17
+ export async function initTestRun(suite, path, extra = {}) {
18
+ const testRun = createTestRun(suite.id, suite.entries.map((e) => e.testCaseId), { ...extra, scope: "suite" });
19
+ await writeTestRun(testRun, path);
20
+ return testRun;
21
+ }
22
+ /** Creates and persists a TestRun for one test case run on its own, with no suite: a single
23
+ * pending entry keyed by the test case id. `recordResult` works on it unchanged. */
24
+ export async function initTestCaseRun(testCaseId, path, extra = {}) {
25
+ const testRun = createTestRun(testCaseId, [testCaseId], { ...extra, scope: "testcase" });
19
26
  await writeTestRun(testRun, path);
20
27
  return testRun;
21
28
  }
@@ -68,11 +68,17 @@ export function resolveInputs(entry, testCase, testRun, fixtures) {
68
68
  continue;
69
69
  }
70
70
  if (binding.kind === "fixture") {
71
- const fixture = fixtures.find((f) => f.name === binding.fixtureName && f.testCaseId === testCase.id);
71
+ // Ignoring case: a fixture's name is also its file name, so two names differing only in
72
+ // case can't coexist on Windows and are the same fixture.
73
+ const fixture = fixtures.find((f) => f.name.toLowerCase() === binding.fixtureName.toLowerCase());
72
74
  if (!fixture) {
73
75
  throw new Error(`Test case "${testCase.id}", param "${param.name}": no fixture named "${binding.fixtureName}" found`);
74
76
  }
75
- const value = fixture.values[binding.param];
77
+ const values = fixture.testCases[testCase.id];
78
+ if (!values) {
79
+ throw new Error(`Test case "${testCase.id}", param "${param.name}": fixture "${binding.fixtureName}" has no values for this test case`);
80
+ }
81
+ const value = values[binding.param];
76
82
  if (value === undefined) {
77
83
  throw new Error(`Test case "${testCase.id}", param "${param.name}": fixture "${binding.fixtureName}" has no value for "${binding.param}"`);
78
84
  }
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trxa-tool",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "TRXA-TOOL -- extract D365 Finance & Operations Task Recorder (.axtr) files into structured test plans for LLM-driven automated testing via the d365-erp-mcp server.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,6 +29,8 @@
29
29
  "README.md",
30
30
  "MANUAL.md",
31
31
  "STUDIO.md",
32
+ "SETUP.md",
33
+ "media",
32
34
  "LICENSE"
33
35
  ],
34
36
  "scripts": {
@@ -39,10 +41,12 @@
39
41
  "studio:server": "tsx watch studio/server/index.ts",
40
42
  "studio:client": "npm run dev --prefix studio/client",
41
43
  "build:studio-client": "npm run build --prefix studio/client",
42
- "build:studio-server": "esbuild studio/server/index.ts --bundle --platform=node --format=esm --outfile=studio/server/dist/index.js --external:express --external:fast-xml-parser --external:jszip",
44
+ "build:studio-server": "esbuild studio/server/index.ts --bundle --platform=node --format=esm --outfile=studio/server/dist/index.js --external:express --external:fast-xml-parser --external:jszip --external:@github/copilot-sdk",
43
45
  "package": "npm run build && npm run build:studio-client && npm run build:studio-server"
44
46
  },
45
47
  "dependencies": {
48
+ "@azure/msal-node": "^5.6.0",
49
+ "@github/copilot-sdk": "^1.0.26",
46
50
  "express": "^5.2.1",
47
51
  "fast-xml-parser": "^4.5.0",
48
52
  "jszip": "^3.10.1"