trxa-tool 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/replay-trxa-testplan/SKILL.md +627 -0
- package/LICENSE +120 -0
- package/MANUAL.md +556 -0
- package/README.md +91 -3
- package/SETUP.md +401 -0
- package/STUDIO.md +427 -0
- package/dist/analyze.d.ts +58 -0
- package/dist/analyze.js +122 -0
- package/dist/bpm.d.ts +40 -0
- package/dist/bpm.js +60 -0
- package/dist/build-test-script.d.ts +24 -0
- package/dist/build-test-script.js +40 -0
- package/dist/csv.d.ts +1 -0
- package/dist/csv.js +11 -0
- package/dist/extract.d.ts +14 -0
- package/dist/extract.js +34 -0
- package/dist/fixture-schema.d.ts +52 -0
- package/dist/fixture-schema.js +112 -0
- package/dist/fixture-store.d.ts +37 -0
- package/dist/fixture-store.js +75 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +850 -0
- package/dist/library-archive.d.ts +37 -0
- package/dist/library-archive.js +106 -0
- package/dist/mapping.d.ts +25 -0
- package/dist/mapping.js +393 -0
- package/dist/mcp-bridge.d.ts +52 -0
- package/dist/mcp-bridge.js +290 -0
- package/dist/normalize.d.ts +21 -0
- package/dist/normalize.js +43 -0
- package/dist/parse.d.ts +3 -0
- package/dist/parse.js +233 -0
- package/dist/plan-runner.d.ts +27 -0
- package/dist/plan-runner.js +36 -0
- package/dist/plan-schema.d.ts +27 -0
- package/dist/plan-schema.js +45 -0
- package/dist/planrun-schema.d.ts +53 -0
- package/dist/planrun-schema.js +111 -0
- package/dist/planrun.d.ts +20 -0
- package/dist/planrun.js +46 -0
- package/dist/report-schema.d.ts +92 -0
- package/dist/report-schema.js +115 -0
- package/dist/report.d.ts +45 -0
- package/dist/report.js +77 -0
- package/dist/schema.d.ts +96 -0
- package/dist/schema.js +10 -0
- package/dist/scrub.d.ts +44 -0
- package/dist/scrub.js +166 -0
- package/dist/suggest-inputs.d.ts +19 -0
- package/dist/suggest-inputs.js +92 -0
- package/dist/suite-runner.d.ts +59 -0
- package/dist/suite-runner.js +204 -0
- package/dist/suite-schema.d.ts +59 -0
- package/dist/suite-schema.js +95 -0
- package/dist/testcase-schema.d.ts +102 -0
- package/dist/testcase-schema.js +132 -0
- package/dist/token-usage.d.ts +38 -0
- package/dist/token-usage.js +95 -0
- package/media/studio/fixtures-test-cases.png +0 -0
- package/media/studio/plans-editor.png +0 -0
- package/media/studio/recordings-create-test-case.png +0 -0
- package/media/studio/run-dialog.png +0 -0
- package/media/studio/settings-agent-runs.png +0 -0
- package/media/studio/settings-working-folder.png +0 -0
- package/media/studio/studio-recordings.png +0 -0
- package/media/studio/suites-canvas.png +0 -0
- package/media/studio/suites-inspector.png +0 -0
- package/media/studio/testcases-expectations.png +0 -0
- package/media/studio/testcases-outputs.png +0 -0
- package/media/studio/testrun-details.png +0 -0
- package/media/studio/testruns-list.png +0 -0
- package/package.json +62 -6
- package/studio/client-dist/assets/CreatePanel-C5bb5OIs.js +6 -0
- package/studio/client-dist/assets/FactTile-G1pURLys.js +1 -0
- package/studio/client-dist/assets/FieldLabel-DPmIEQ08.js +1 -0
- package/studio/client-dist/assets/FixtureNamePicker-137AAgwZ.js +1 -0
- package/studio/client-dist/assets/Fixtures-D0t-soph.js +1 -0
- package/studio/client-dist/assets/GeneralSection-CWkTeSX2.js +1 -0
- package/studio/client-dist/assets/ListPaneHeader-DXKqX36N.js +1 -0
- package/studio/client-dist/assets/Plans-C70evkGc.js +1 -0
- package/studio/client-dist/assets/Recordings-BT0dyVLs.js +1 -0
- package/studio/client-dist/assets/Settings-k-J3u5lH.js +1 -0
- package/studio/client-dist/assets/Suites-DLioOiRN.css +1 -0
- package/studio/client-dist/assets/Suites-DQhhMxjT.js +7 -0
- package/studio/client-dist/assets/TestCases-BdiopVGI.js +1 -0
- package/studio/client-dist/assets/TestRuns-CylAe-3t.js +9 -0
- package/studio/client-dist/assets/api-DJZ703_F.js +24 -0
- package/studio/client-dist/assets/bucket-18-viuwGcaj.js +1 -0
- package/studio/client-dist/assets/fixtureName-CTZT_jKu.js +1 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-LoaTnByX.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-RhT0Ayi3.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-Bf1n82Tb.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-D9J6q4_O.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-Bbd5v1xK.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-DqmgDkIN.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-BELOEI8R.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-CfSXsIbO.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-J-CBqZoJ.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-STc8XiDr.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-BEZwmy7D.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-CTYLy0lX.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-C9Of02-5.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-b4i7YzLG.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CZTNEAuW.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CsGl1sm0.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-B8Zgwjh3.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-BLFuR0P1.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-Du9zzoL2.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-V_9i1P_m.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-CkYRZD2a.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-SDfX6ktj.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-78jqtfZH.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-BbRnwouv.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- package/studio/client-dist/assets/index-cxBc2iRv.js +10 -0
- package/studio/client-dist/assets/index-wjWuVHXN.css +1 -0
- package/studio/client-dist/assets/status-DUGxC7G7.js +1 -0
- package/studio/client-dist/assets/testCaseTree-Cl1-bc5O.js +1 -0
- package/studio/client-dist/axtr-icon.svg +2 -0
- package/studio/client-dist/favicon.svg +2 -0
- package/studio/client-dist/index.html +15 -0
- package/studio/server/dist/index.js +3176 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Recording, RecordingNode } from "./schema.js";
|
|
2
|
+
/** A leaf action node from Recording.xml, flattened into execution order. */
|
|
3
|
+
export interface TestStep {
|
|
4
|
+
sequence: number;
|
|
5
|
+
description: string;
|
|
6
|
+
/** Resolved via the leaf's FormId against Recording.FormContexts. Null for MenuItemUserAction,
|
|
7
|
+
* which navigates *to* a form rather than acting within one already-known context. */
|
|
8
|
+
formName: string | null;
|
|
9
|
+
formId: string | null;
|
|
10
|
+
node: RecordingNode;
|
|
11
|
+
}
|
|
12
|
+
/** The mechanics of a replay: an ordered list of steps, nothing about pass/fail. QA vocabulary
|
|
13
|
+
* calls this a Test Script -- the executable realization of a recording's steps. */
|
|
14
|
+
export interface TestScript {
|
|
15
|
+
name: string;
|
|
16
|
+
canonicalId: string;
|
|
17
|
+
description: string;
|
|
18
|
+
steps: TestStep[];
|
|
19
|
+
}
|
|
20
|
+
/** Flattens a parsed Recording into an ordered list of executable steps. */
|
|
21
|
+
export declare function normalizeRecording(recording: Recording): TestScript;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function formIdOf(node) {
|
|
2
|
+
if (node.kind === "CommandUserAction" || node.kind === "PropertyUserAction") {
|
|
3
|
+
return node.FormId;
|
|
4
|
+
}
|
|
5
|
+
if (node.kind === "Unknown") {
|
|
6
|
+
const raw = node.raw.FormId;
|
|
7
|
+
return typeof raw === "string" ? raw : null;
|
|
8
|
+
}
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
/** Depth-first walk collecting every non-Scope (i.e. actionable) node, in document order. */
|
|
12
|
+
function collectLeaves(node, out) {
|
|
13
|
+
if (node.kind === "Scope") {
|
|
14
|
+
for (const child of node.Children)
|
|
15
|
+
collectLeaves(child, out);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
out.push(node);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Flattens a parsed Recording into an ordered list of executable steps. */
|
|
22
|
+
export function normalizeRecording(recording) {
|
|
23
|
+
const leaves = [];
|
|
24
|
+
collectLeaves(recording.RootScope, leaves);
|
|
25
|
+
const steps = leaves
|
|
26
|
+
.map((node) => {
|
|
27
|
+
const formId = formIdOf(node);
|
|
28
|
+
return {
|
|
29
|
+
sequence: node.Sequence,
|
|
30
|
+
description: node.Description,
|
|
31
|
+
formId,
|
|
32
|
+
formName: formId ? recording.FormContexts[formId]?.FormName ?? null : null,
|
|
33
|
+
node,
|
|
34
|
+
};
|
|
35
|
+
})
|
|
36
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
37
|
+
return {
|
|
38
|
+
name: recording.Name,
|
|
39
|
+
canonicalId: recording.CanonicalId,
|
|
40
|
+
description: recording.Description,
|
|
41
|
+
steps,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/parse.d.ts
ADDED
package/dist/parse.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
/**
|
|
3
|
+
* Tags that must always parse to an array, even when exactly one occurs in the XML -- fast-xml-
|
|
4
|
+
* parser otherwise collapses a single child into a bare object, and every downstream `.map()`
|
|
5
|
+
* over these (`collectLeaves`, `parseCommandArgument`, `parseFormContexts`) would throw on a
|
|
6
|
+
* recording with only one child/argument. Adding a new element type that can repeat (Scope
|
|
7
|
+
* children, command arguments, etc.) means adding its tag name here too -- there's no other
|
|
8
|
+
* signal that forces array mode.
|
|
9
|
+
* `KeyValueOfstringFormContextyaScvdpM` is the mangled tag fast-xml-parser sees for a WCF
|
|
10
|
+
* `Dictionary<string, FormContext>` entry (`DataContractSerializer`'s generic-type name-mangling
|
|
11
|
+
* convention) -- not verified against other D365FO versions, but it's the .NET serializer's own
|
|
12
|
+
* naming scheme, not something specific to one recording, so unlikely to change independently.
|
|
13
|
+
*/
|
|
14
|
+
const ARRAY_TAGS = new Set([
|
|
15
|
+
"Node",
|
|
16
|
+
"Annotation",
|
|
17
|
+
"CommandArgument",
|
|
18
|
+
"string",
|
|
19
|
+
"KeyValueOfstringFormContextyaScvdpM",
|
|
20
|
+
]);
|
|
21
|
+
function makeParser() {
|
|
22
|
+
return new XMLParser({
|
|
23
|
+
ignoreAttributes: false,
|
|
24
|
+
attributeNamePrefix: "@_",
|
|
25
|
+
removeNSPrefix: true,
|
|
26
|
+
parseTagValue: false,
|
|
27
|
+
parseAttributeValue: false,
|
|
28
|
+
trimValues: true,
|
|
29
|
+
isArray: (tagName) => ARRAY_TAGS.has(tagName),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** True when the parsed element is `{ "@_nil": "true" }` (xsi:nil marker) or otherwise empty. */
|
|
33
|
+
function isNil(v) {
|
|
34
|
+
if (v === undefined || v === null)
|
|
35
|
+
return true;
|
|
36
|
+
if (typeof v === "object" && !Array.isArray(v)) {
|
|
37
|
+
const o = v;
|
|
38
|
+
return o["@_nil"] === "true";
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/** Reads a simple text-content element, honoring xsi:nil. Preserves strings verbatim (no numeric coercion). */
|
|
43
|
+
function text(v) {
|
|
44
|
+
if (isNil(v))
|
|
45
|
+
return null;
|
|
46
|
+
if (typeof v === "string")
|
|
47
|
+
return v;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function textReq(v, field) {
|
|
51
|
+
const t = text(v);
|
|
52
|
+
if (t === null)
|
|
53
|
+
throw new Error(`Expected required text field "${field}" to be present`);
|
|
54
|
+
return t;
|
|
55
|
+
}
|
|
56
|
+
function num(v) {
|
|
57
|
+
const t = text(v);
|
|
58
|
+
if (t === null || t === "")
|
|
59
|
+
return null;
|
|
60
|
+
const n = Number(t);
|
|
61
|
+
return Number.isNaN(n) ? null : n;
|
|
62
|
+
}
|
|
63
|
+
function numReq(v, field) {
|
|
64
|
+
const n = num(v);
|
|
65
|
+
if (n === null)
|
|
66
|
+
throw new Error(`Expected required numeric field "${field}" to be present`);
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
function bool(v) {
|
|
70
|
+
return text(v) === "true";
|
|
71
|
+
}
|
|
72
|
+
/** Unwraps a collection element that is either nil, a single child, or an array of children. */
|
|
73
|
+
function collection(v, childTag) {
|
|
74
|
+
if (isNil(v))
|
|
75
|
+
return [];
|
|
76
|
+
if (typeof v === "string")
|
|
77
|
+
return []; // self-closed with no attrs -> parsed as ""
|
|
78
|
+
const obj = v;
|
|
79
|
+
const child = obj[childTag];
|
|
80
|
+
if (child === undefined)
|
|
81
|
+
return [];
|
|
82
|
+
return Array.isArray(child) ? child : [child];
|
|
83
|
+
}
|
|
84
|
+
function parseCommandArgument(raw) {
|
|
85
|
+
return {
|
|
86
|
+
IsReference: bool(raw.IsReference),
|
|
87
|
+
Value: textReq(raw.Value, "CommandArgument.Value"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function parseFormContext(raw) {
|
|
91
|
+
return {
|
|
92
|
+
FormID: textReq(raw.FormID, "FormContext.FormID"),
|
|
93
|
+
FormName: textReq(raw.FormName, "FormContext.FormName"),
|
|
94
|
+
RecordingName: textReq(raw.RecordingName, "FormContext.RecordingName"),
|
|
95
|
+
HasMetadata: bool(raw.HasMetadata),
|
|
96
|
+
Sequence: numReq(raw.Sequence, "FormContext.Sequence"),
|
|
97
|
+
Annotations: raw.Annotations,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function parseFormContexts(raw) {
|
|
101
|
+
const entries = collection(raw, "KeyValueOfstringFormContextyaScvdpM");
|
|
102
|
+
const result = {};
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const key = textReq(entry.Key, "FormContexts.Key");
|
|
105
|
+
result[key] = parseFormContext(entry.Value);
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
function nodeBaseFields(raw) {
|
|
110
|
+
return {
|
|
111
|
+
Id: textReq(raw.Id, "Node.Id"),
|
|
112
|
+
Description: text(raw.Description) ?? "",
|
|
113
|
+
CustomDescription: text(raw.CustomDescription),
|
|
114
|
+
ParentSequence: numReq(raw.ParentSequence, "Node.ParentSequence"),
|
|
115
|
+
Sequence: numReq(raw.Sequence, "Node.Sequence"),
|
|
116
|
+
ScreenshotUri: text(raw.ScreenshotUri),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function userActionBaseFields(raw) {
|
|
120
|
+
return {
|
|
121
|
+
...nodeBaseFields(raw),
|
|
122
|
+
GlobalId: textReq(raw.GlobalId, "UserAction.GlobalId"),
|
|
123
|
+
SystemGenerated: bool(raw.SystemGenerated),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function parseScope(raw) {
|
|
127
|
+
return {
|
|
128
|
+
kind: "Scope",
|
|
129
|
+
...nodeBaseFields(raw),
|
|
130
|
+
Name: text(raw.Name) ?? "",
|
|
131
|
+
IsForm: bool(raw.IsForm),
|
|
132
|
+
IsStepGroup: bool(raw.IsStepGroup),
|
|
133
|
+
ScopeType: text(raw.ScopeType) ?? "",
|
|
134
|
+
Children: collection(raw.Children, "Node").map((n) => parseNode(n)),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function parseMenuItemUserAction(raw) {
|
|
138
|
+
return {
|
|
139
|
+
kind: "MenuItemUserAction",
|
|
140
|
+
...userActionBaseFields(raw),
|
|
141
|
+
MenuItemName: textReq(raw.MenuItemName, "MenuItemUserAction.MenuItemName"),
|
|
142
|
+
MenuItemType: text(raw.MenuItemType) ?? "",
|
|
143
|
+
NavigationPath: collection(raw.NavigationPath, "string").map((s) => textReq(s, "NavigationPath.string")),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function parseCommandUserAction(raw) {
|
|
147
|
+
return {
|
|
148
|
+
kind: "CommandUserAction",
|
|
149
|
+
...userActionBaseFields(raw),
|
|
150
|
+
FormId: textReq(raw.FormId, "CommandUserAction.FormId"),
|
|
151
|
+
ControlName: text(raw.ControlName) ?? "",
|
|
152
|
+
ControlType: text(raw.ControlType) ?? "",
|
|
153
|
+
ControlLabel: text(raw.ControlLabel) ?? "",
|
|
154
|
+
ControlLabelId: text(raw.ControlLabelId) ?? "",
|
|
155
|
+
CommandName: textReq(raw.CommandName, "CommandUserAction.CommandName"),
|
|
156
|
+
Arguments: collection(raw.Arguments, "CommandArgument").map((a) => parseCommandArgument(a)),
|
|
157
|
+
ListContext: text(raw.ListContext),
|
|
158
|
+
RowIndex: num(raw.RowIndex),
|
|
159
|
+
ReturnType: text(raw.ReturnType) ?? "",
|
|
160
|
+
ReturnValue: text(raw.ReturnValue),
|
|
161
|
+
TaskId: text(raw.TaskId),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function parsePropertyUserAction(raw) {
|
|
165
|
+
return {
|
|
166
|
+
kind: "PropertyUserAction",
|
|
167
|
+
...userActionBaseFields(raw),
|
|
168
|
+
FormId: textReq(raw.FormId, "PropertyUserAction.FormId"),
|
|
169
|
+
ControlName: text(raw.ControlName) ?? "",
|
|
170
|
+
ControlType: text(raw.ControlType) ?? "",
|
|
171
|
+
ControlLabel: text(raw.ControlLabel) ?? "",
|
|
172
|
+
ControlLabelId: text(raw.ControlLabelId) ?? "",
|
|
173
|
+
BoundDataSource: text(raw.BoundDataSource) ?? "",
|
|
174
|
+
BoundProperty: text(raw.BoundProperty) ?? "",
|
|
175
|
+
PropertyName: text(raw.PropertyName) ?? "",
|
|
176
|
+
UserActionType: text(raw.UserActionType) ?? "",
|
|
177
|
+
Value: text(raw.Value) ?? "",
|
|
178
|
+
ValueLabel: text(raw.ValueLabel),
|
|
179
|
+
IsReference: bool(raw.IsReference),
|
|
180
|
+
ListContext: text(raw.ListContext),
|
|
181
|
+
RowIndex: num(raw.RowIndex),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function parseUnknownUserAction(raw, xsiType) {
|
|
185
|
+
return {
|
|
186
|
+
kind: "Unknown",
|
|
187
|
+
...userActionBaseFields(raw),
|
|
188
|
+
xsiType,
|
|
189
|
+
raw,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function parseNode(raw) {
|
|
193
|
+
const xsiType = raw["@_type"] ?? "Scope";
|
|
194
|
+
switch (xsiType) {
|
|
195
|
+
case "Scope":
|
|
196
|
+
return parseScope(raw);
|
|
197
|
+
case "MenuItemUserAction":
|
|
198
|
+
return parseMenuItemUserAction(raw);
|
|
199
|
+
case "CommandUserAction":
|
|
200
|
+
return parseCommandUserAction(raw);
|
|
201
|
+
case "PropertyUserAction":
|
|
202
|
+
return parsePropertyUserAction(raw);
|
|
203
|
+
default:
|
|
204
|
+
return parseUnknownUserAction(raw, xsiType);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Parses a `Recording.xml` document into a typed Recording tree. */
|
|
208
|
+
export function parseRecordingXml(xml) {
|
|
209
|
+
const parser = makeParser();
|
|
210
|
+
const doc = parser.parse(xml);
|
|
211
|
+
const root = doc.Recording;
|
|
212
|
+
if (!root) {
|
|
213
|
+
throw new Error("Document does not contain a root <Recording> element");
|
|
214
|
+
}
|
|
215
|
+
const rootScopeRaw = root.RootScope;
|
|
216
|
+
const rootScope = {
|
|
217
|
+
kind: "Scope",
|
|
218
|
+
...nodeBaseFields(rootScopeRaw),
|
|
219
|
+
Name: text(root.Name) ?? "",
|
|
220
|
+
IsForm: false,
|
|
221
|
+
IsStepGroup: false,
|
|
222
|
+
ScopeType: "Root",
|
|
223
|
+
Children: collection(rootScopeRaw.Children, "Node").map((n) => parseNode(n)),
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
CanonicalId: textReq(root.CanonicalId, "Recording.CanonicalId"),
|
|
227
|
+
CurrentSequence: numReq(root.CurrentSequence, "Recording.CurrentSequence"),
|
|
228
|
+
Description: text(root.Description) ?? "",
|
|
229
|
+
Name: textReq(root.Name, "Recording.Name"),
|
|
230
|
+
FormContexts: parseFormContexts(root.FormContexts),
|
|
231
|
+
RootScope: rootScope,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PlanRun } from "./planrun-schema.js";
|
|
2
|
+
import { type TestRun } from "./report-schema.js";
|
|
3
|
+
export type SuiteRunStatus = "missing" | "incomplete" | "passed" | "failed";
|
|
4
|
+
export interface SuiteRunSummary {
|
|
5
|
+
suiteId: string;
|
|
6
|
+
/** Snapshotted from `PlanRun.suiteNames`; falls back to `suiteId` for a PlanRun persisted before
|
|
7
|
+
* that field existed. */
|
|
8
|
+
suiteName: string;
|
|
9
|
+
status: SuiteRunStatus;
|
|
10
|
+
testRun: TestRun | null;
|
|
11
|
+
}
|
|
12
|
+
export type PlanOverallStatus = "all-passed" | "has-failures" | "incomplete" | "missing-runs";
|
|
13
|
+
export interface PlanStatus {
|
|
14
|
+
planId: string;
|
|
15
|
+
runId: string;
|
|
16
|
+
suites: SuiteRunSummary[];
|
|
17
|
+
overall: PlanOverallStatus;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Pure aggregation, no filesystem access: given a PlanRun and the TestRun already loaded for each
|
|
21
|
+
* of its snapshotted suites (or null if that suite's run file doesn't exist for this runId),
|
|
22
|
+
* computes a rollup. Deliberately reads `run.suiteIds` -- the suite list as it existed when the
|
|
23
|
+
* run started -- rather than the live TestPlan's `suites`, so a run's status can't retroactively
|
|
24
|
+
* change just because the plan later gained or lost a suite (confirmed live: adding a suite to
|
|
25
|
+
* Accounts Payable flipped an already-passed run to "Missing runs" before this fix).
|
|
26
|
+
*/
|
|
27
|
+
export declare function aggregatePlanStatus(run: PlanRun, testRuns: Map<string, TestRun | null>): PlanStatus;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { TERMINAL_STATUSES } from "./report-schema.js";
|
|
2
|
+
function summarizeSuiteRun(suiteId, suiteName, testRun) {
|
|
3
|
+
if (!testRun)
|
|
4
|
+
return { suiteId, suiteName, status: "missing", testRun: null };
|
|
5
|
+
const statuses = Object.values(testRun.results).map((r) => r.status);
|
|
6
|
+
const allTerminal = statuses.every((s) => TERMINAL_STATUSES.has(s));
|
|
7
|
+
if (!allTerminal)
|
|
8
|
+
return { suiteId, suiteName, status: "incomplete", testRun };
|
|
9
|
+
const hasFailure = statuses.some((s) => s === "failed" || s === "blocked");
|
|
10
|
+
return { suiteId, suiteName, status: hasFailure ? "failed" : "passed", testRun };
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pure aggregation, no filesystem access: given a PlanRun and the TestRun already loaded for each
|
|
14
|
+
* of its snapshotted suites (or null if that suite's run file doesn't exist for this runId),
|
|
15
|
+
* computes a rollup. Deliberately reads `run.suiteIds` -- the suite list as it existed when the
|
|
16
|
+
* run started -- rather than the live TestPlan's `suites`, so a run's status can't retroactively
|
|
17
|
+
* change just because the plan later gained or lost a suite (confirmed live: adding a suite to
|
|
18
|
+
* Accounts Payable flipped an already-passed run to "Missing runs" before this fix).
|
|
19
|
+
*/
|
|
20
|
+
export function aggregatePlanStatus(run, testRuns) {
|
|
21
|
+
const suites = run.suiteIds.map((suiteId) => summarizeSuiteRun(suiteId, run.suiteNames[suiteId] ?? suiteId, testRuns.get(suiteId) ?? null));
|
|
22
|
+
let overall;
|
|
23
|
+
if (suites.some((s) => s.status === "failed")) {
|
|
24
|
+
overall = "has-failures";
|
|
25
|
+
}
|
|
26
|
+
else if (suites.some((s) => s.status === "incomplete")) {
|
|
27
|
+
overall = "incomplete";
|
|
28
|
+
}
|
|
29
|
+
else if (suites.some((s) => s.status === "missing")) {
|
|
30
|
+
overall = "missing-runs";
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
overall = "all-passed";
|
|
34
|
+
}
|
|
35
|
+
return { planId: run.planId, runId: run.id, suites, overall };
|
|
36
|
+
}
|