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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every top-level directory a library backup bundles. `testscripts/` is included even though it's
|
|
3
|
+
* derived (via `parse`) from `recordings/` -- a personal backup isn't a shared-repo commit, and
|
|
4
|
+
* including it saves re-running `parse` on every recording after a restore, at the cost of a
|
|
5
|
+
* bigger archive.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LIBRARY_DIRS: string[];
|
|
8
|
+
export interface LibraryZipBuffer {
|
|
9
|
+
buffer: Buffer;
|
|
10
|
+
fileCount: number;
|
|
11
|
+
dirs: string[];
|
|
12
|
+
}
|
|
13
|
+
/** Bundles every {@link LIBRARY_DIRS} directory under `baseDir` into an in-memory zip buffer --
|
|
14
|
+
* the shared core both the CLI's file-writing `exportLibrary` and the Studio server's
|
|
15
|
+
* streaming-response route build on. */
|
|
16
|
+
export declare function buildLibraryZip(baseDir: string): Promise<LibraryZipBuffer>;
|
|
17
|
+
export interface ExportResult {
|
|
18
|
+
outPath: string;
|
|
19
|
+
fileCount: number;
|
|
20
|
+
dirs: string[];
|
|
21
|
+
}
|
|
22
|
+
/** Bundles every {@link LIBRARY_DIRS} directory under `baseDir` into a zip at `outPath`. */
|
|
23
|
+
export declare function exportLibrary(baseDir: string, outPath: string): Promise<ExportResult>;
|
|
24
|
+
export interface ImportResult {
|
|
25
|
+
fileCount: number;
|
|
26
|
+
collisions: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Unpacks a library backup zip (already read into memory) into `baseDir`. Refuses (writing
|
|
30
|
+
* nothing) if any target file already exists, unless `force` is set -- a silent overwrite of test
|
|
31
|
+
* cases, fixtures, or recorded runs is exactly the kind of mistake this check exists to prevent.
|
|
32
|
+
* Shared core for both the CLI's file-reading `importLibrary` and the Studio server's upload
|
|
33
|
+
* route, which already has the bytes in memory from the request body.
|
|
34
|
+
*/
|
|
35
|
+
export declare function extractLibraryZip(bytes: Buffer, baseDir: string, force: boolean): Promise<ImportResult>;
|
|
36
|
+
/** Reads `zipPath` off disk and unpacks it into `baseDir` -- see {@link extractLibraryZip}. */
|
|
37
|
+
export declare function importLibrary(zipPath: string, baseDir: string, force: boolean): Promise<ImportResult>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, mkdir, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import JSZip from "jszip";
|
|
4
|
+
/**
|
|
5
|
+
* Every top-level directory a library backup bundles. `testscripts/` is included even though it's
|
|
6
|
+
* derived (via `parse`) from `recordings/` -- a personal backup isn't a shared-repo commit, and
|
|
7
|
+
* including it saves re-running `parse` on every recording after a restore, at the cost of a
|
|
8
|
+
* bigger archive.
|
|
9
|
+
*/
|
|
10
|
+
export const LIBRARY_DIRS = ["recordings", "testcases", "testdata", "testscripts", "suites", "plans", "testruns"];
|
|
11
|
+
async function fileExists(p) {
|
|
12
|
+
try {
|
|
13
|
+
await stat(p);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function addDirToZip(zip, dirAbsPath, zipPrefix) {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = await readdir(dirAbsPath, { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Directory doesn't exist -- nothing to bundle from it, same "empty, not an error" tradeoff
|
|
27
|
+
// used elsewhere for optional library directories (see loadFixtures in index.ts).
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const abs = path.join(dirAbsPath, entry.name);
|
|
33
|
+
const zipPath = `${zipPrefix}/${entry.name}`;
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
count += await addDirToZip(zip, abs, zipPath);
|
|
36
|
+
}
|
|
37
|
+
else if (entry.isFile()) {
|
|
38
|
+
zip.file(zipPath, await readFile(abs));
|
|
39
|
+
count++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return count;
|
|
43
|
+
}
|
|
44
|
+
/** Bundles every {@link LIBRARY_DIRS} directory under `baseDir` into an in-memory zip buffer --
|
|
45
|
+
* the shared core both the CLI's file-writing `exportLibrary` and the Studio server's
|
|
46
|
+
* streaming-response route build on. */
|
|
47
|
+
export async function buildLibraryZip(baseDir) {
|
|
48
|
+
const zip = new JSZip();
|
|
49
|
+
let fileCount = 0;
|
|
50
|
+
for (const dir of LIBRARY_DIRS) {
|
|
51
|
+
fileCount += await addDirToZip(zip, path.join(baseDir, dir), dir);
|
|
52
|
+
}
|
|
53
|
+
const buffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
54
|
+
return { buffer, fileCount, dirs: LIBRARY_DIRS };
|
|
55
|
+
}
|
|
56
|
+
/** Bundles every {@link LIBRARY_DIRS} directory under `baseDir` into a zip at `outPath`. */
|
|
57
|
+
export async function exportLibrary(baseDir, outPath) {
|
|
58
|
+
const { buffer, fileCount, dirs } = await buildLibraryZip(baseDir);
|
|
59
|
+
await mkdir(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
60
|
+
await writeFile(outPath, buffer);
|
|
61
|
+
return { outPath, fileCount, dirs };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Unpacks a library backup zip (already read into memory) into `baseDir`. Refuses (writing
|
|
65
|
+
* nothing) if any target file already exists, unless `force` is set -- a silent overwrite of test
|
|
66
|
+
* cases, fixtures, or recorded runs is exactly the kind of mistake this check exists to prevent.
|
|
67
|
+
* Shared core for both the CLI's file-reading `importLibrary` and the Studio server's upload
|
|
68
|
+
* route, which already has the bytes in memory from the request body.
|
|
69
|
+
*/
|
|
70
|
+
export async function extractLibraryZip(bytes, baseDir, force) {
|
|
71
|
+
const zip = await JSZip.loadAsync(bytes);
|
|
72
|
+
const entries = Object.values(zip.files).filter((f) => !f.dir);
|
|
73
|
+
const baseAbs = path.resolve(baseDir);
|
|
74
|
+
const targets = [];
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const topDir = entry.name.split("/")[0];
|
|
77
|
+
if (!LIBRARY_DIRS.includes(topDir)) {
|
|
78
|
+
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.`);
|
|
79
|
+
}
|
|
80
|
+
const targetPath = path.resolve(baseAbs, entry.name);
|
|
81
|
+
if (!targetPath.startsWith(baseAbs + path.sep)) {
|
|
82
|
+
throw new Error(`Refusing to import: "${entry.name}" resolves outside the target directory.`);
|
|
83
|
+
}
|
|
84
|
+
targets.push({ entry, targetPath });
|
|
85
|
+
}
|
|
86
|
+
if (!force) {
|
|
87
|
+
const collisions = [];
|
|
88
|
+
for (const { entry, targetPath } of targets) {
|
|
89
|
+
if (await fileExists(targetPath))
|
|
90
|
+
collisions.push(entry.name);
|
|
91
|
+
}
|
|
92
|
+
if (collisions.length > 0) {
|
|
93
|
+
return { fileCount: 0, collisions };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const { entry, targetPath } of targets) {
|
|
97
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
98
|
+
await writeFile(targetPath, await entry.async("nodebuffer"));
|
|
99
|
+
}
|
|
100
|
+
return { fileCount: targets.length, collisions: [] };
|
|
101
|
+
}
|
|
102
|
+
/** Reads `zipPath` off disk and unpacks it into `baseDir` -- see {@link extractLibraryZip}. */
|
|
103
|
+
export async function importLibrary(zipPath, baseDir, force) {
|
|
104
|
+
const bytes = await readFile(zipPath);
|
|
105
|
+
return extractLibraryZip(bytes, baseDir, force);
|
|
106
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TestStep } from "./normalize.js";
|
|
2
|
+
export type Confidence = "high" | "medium" | "low";
|
|
3
|
+
export interface FollowUp {
|
|
4
|
+
tool: string;
|
|
5
|
+
note: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A *hint*, not a compiled instruction: the orchestrating LLM should treat `params` as a
|
|
9
|
+
* starting guess and fall back to form_find_controls / form_find_menu_item to resolve the
|
|
10
|
+
* real target when confidence is "low" or the hinted call fails.
|
|
11
|
+
*
|
|
12
|
+
* Param shapes below were validated against the real d365-erp-mcp tool schemas (and, for
|
|
13
|
+
* form_select_grid_row / form_filter_grid / form_save_form, against a live replay) rather than
|
|
14
|
+
* guessed from the recording alone -- see ARCHITECTURE.md "Learnings from a live dry run".
|
|
15
|
+
*/
|
|
16
|
+
export interface McpToolHint {
|
|
17
|
+
tool: string;
|
|
18
|
+
confidence: Confidence;
|
|
19
|
+
params: Record<string, unknown>;
|
|
20
|
+
note?: string;
|
|
21
|
+
/** Additional tool calls the orchestrator should make after this one, e.g. form_save_form. */
|
|
22
|
+
followUp?: FollowUp[];
|
|
23
|
+
}
|
|
24
|
+
/** Maps a normalized TestStep to a best-effort d365-erp-mcp tool call hint. Null for Unknown nodes. */
|
|
25
|
+
export declare function mapStep(step: TestStep): McpToolHint | null;
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D365FO's Task Recorder marks some steps SystemGenerated=true -- automatic side effects (e.g. a
|
|
3
|
+
* form part lazy-loading once its parent tab becomes visible) rather than anything the user did.
|
|
4
|
+
* Confirmed live: recordings/BPM-AXTR-Add a new Delivery address.axtr's OpenFormPart step has
|
|
5
|
+
* SystemGenerated=true and no corresponding user action exists to replay.
|
|
6
|
+
*/
|
|
7
|
+
function skipHint(step) {
|
|
8
|
+
if (step.node.kind === "Scope" || !step.node.SystemGenerated)
|
|
9
|
+
return null;
|
|
10
|
+
return {
|
|
11
|
+
tool: "skip",
|
|
12
|
+
confidence: "high",
|
|
13
|
+
params: {},
|
|
14
|
+
note: "SystemGenerated=true in the recording; this was an automatic side effect, not a user action. Nothing to replay for this step.",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function menuItemHint(step) {
|
|
18
|
+
if (step.node.kind !== "MenuItemUserAction")
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
tool: "form_open_menu_item",
|
|
22
|
+
confidence: "high",
|
|
23
|
+
params: { name: step.node.MenuItemName, type: step.node.MenuItemType },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function commandHint(step) {
|
|
27
|
+
if (step.node.kind !== "CommandUserAction")
|
|
28
|
+
return null;
|
|
29
|
+
const { CommandName, ControlName, ControlType } = step.node;
|
|
30
|
+
if (ControlType === "Grid" && CommandName === "ChangeSelectedIndexInCache") {
|
|
31
|
+
return {
|
|
32
|
+
tool: "form_filter_grid",
|
|
33
|
+
confidence: "low",
|
|
34
|
+
params: { gridName: ControlName },
|
|
35
|
+
note: "Recording selects a grid row by recorded position (Arguments), which is not portable " +
|
|
36
|
+
"across environments/data. Confirmed on a live replay where the recorded index no " +
|
|
37
|
+
"longer pointed at the intended record. Identify the target record from surrounding " +
|
|
38
|
+
"step context (e.g. a business key mentioned in the description) and supply " +
|
|
39
|
+
"gridColumnName/gridColumnValue to filter the grid to it before selecting a row.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (ControlType === "Grid" && (CommandName === "NavigationAction" || CommandName === "MarkActiveRow")) {
|
|
43
|
+
return {
|
|
44
|
+
tool: "form_select_grid_row",
|
|
45
|
+
confidence: "low",
|
|
46
|
+
params: { gridName: ControlName },
|
|
47
|
+
note: "Requires rowNumber (read it off the current grid state after filtering) and, per " +
|
|
48
|
+
"d365-erp-mcp's own per-grid guidance, usually a marking parameter " +
|
|
49
|
+
'("Marked" to check the row\'s selection box, "Unmarked" to just navigate/highlight it).',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (ControlType === "AppBarTab") {
|
|
53
|
+
return {
|
|
54
|
+
tool: "form_click_control",
|
|
55
|
+
confidence: "high",
|
|
56
|
+
params: { controlName: ControlName },
|
|
57
|
+
note: "Action Pane / ribbon tab selection.",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (ControlType === "Tab" ||
|
|
61
|
+
(ControlType === "SectionPage" && CommandName === "ExpandedChanged") ||
|
|
62
|
+
(ControlType === "PivotItem" && CommandName === "ActivateTab")) {
|
|
63
|
+
return {
|
|
64
|
+
tool: "form_open_or_close_tab",
|
|
65
|
+
confidence: "high",
|
|
66
|
+
params: { tabName: ControlName, tabAction: "Open" },
|
|
67
|
+
note: ControlType === "SectionPage"
|
|
68
|
+
? "Confirmed live (form_open_or_close_tab works identically on ControlType=SectionPage, not just Tab). " +
|
|
69
|
+
"Recording can't tell you whether the user expanded or collapsed the section. Check the current " +
|
|
70
|
+
"FormState's Tab[name].IsOpen and only call this if it doesn't already match; use tabAction=\"Close\" instead if collapsing."
|
|
71
|
+
: ControlType === "PivotItem"
|
|
72
|
+
? "Confirmed live on a purchase order line's detail Pivot (e.g. TabLineProject): opening one PivotItem " +
|
|
73
|
+
"auto-closes whichever sibling was open, so tabAction=\"Open\" is always the right call here (unlike " +
|
|
74
|
+
"Tab/SectionPage, there's no independent collapse state to worry about)."
|
|
75
|
+
: undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (CommandName === "RequestPopup" && ControlType === "Input") {
|
|
79
|
+
return {
|
|
80
|
+
tool: "form_open_lookup",
|
|
81
|
+
confidence: "high",
|
|
82
|
+
params: { lookupName: ControlName },
|
|
83
|
+
note: "Confirmed live against a multi-select Purpose/Roles lookup: opens a SysLookupMultiSelectGrid-style " +
|
|
84
|
+
"popup form. Follow its own returned Guidance for how to select rows and confirm/cancel.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (CommandName === "RequestPopup" && ControlType === "DropDialogButton") {
|
|
88
|
+
return {
|
|
89
|
+
tool: "form_click_control",
|
|
90
|
+
confidence: "high",
|
|
91
|
+
params: { controlName: ControlName },
|
|
92
|
+
note: "Confirmed live (e.g. the 'New from template' split button on Free text invoice): a DropDialogButton " +
|
|
93
|
+
"opens its popup via a plain click, same as CLICKABLE_CONTROL_TYPES below. Unlike RequestPopup on an " +
|
|
94
|
+
"Input control, this is not a lookup.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (CommandName === "ExecuteHyperlink" && ControlType === "Input") {
|
|
98
|
+
return {
|
|
99
|
+
tool: "form_click_control",
|
|
100
|
+
confidence: "high",
|
|
101
|
+
params: { controlName: ControlName },
|
|
102
|
+
note: "Confirmed live (e.g. clicking a grid row's Name/description link to open its edit dialog): hyperlink-style " +
|
|
103
|
+
"Input fields are clicked the same way as any other control, despite the different CommandName.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (CommandName === "SelectionPathChanged" && ControlType === "Tree") {
|
|
107
|
+
return {
|
|
108
|
+
tool: "form_click_control",
|
|
109
|
+
confidence: "medium",
|
|
110
|
+
params: { controlName: ControlName },
|
|
111
|
+
note: "Confirmed live on a category tree lookup (EcoResCategorySingleLookup/ctrlFormTree): tree nodes are " +
|
|
112
|
+
'clicked via actionId, formatted "Select:<n>" or "Expand:<n>" per the current FormState\'s Tree[name]' +
|
|
113
|
+
".Nodes[].Actions. Not a plain click. The node index is a live-session identifier, not portable from " +
|
|
114
|
+
"the recording (same non-portability caveat as grid row selection): find the target node by matching " +
|
|
115
|
+
"its Text to the business value you want, expanding parent nodes first if needed, then click with that " +
|
|
116
|
+
"node's own Actions.Select id.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (CommandName === "RequestClose") {
|
|
120
|
+
return {
|
|
121
|
+
tool: "form_close_form",
|
|
122
|
+
confidence: "high",
|
|
123
|
+
params: {},
|
|
124
|
+
note: "Confirmed live: closes the current popup/dialog with no ControlName needed (matches how these steps " +
|
|
125
|
+
"are recorded; there's no associated button). Some forms with explicit OK/Cancel buttons ask you to use " +
|
|
126
|
+
"those buttons instead of a direct close; in that case the recording captures a separate Click step " +
|
|
127
|
+
"on the button, not RequestClose.",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (ControlName === "buttonUpdateConfirm" && CommandName === "Click") {
|
|
131
|
+
return {
|
|
132
|
+
tool: "form_open_menu_item",
|
|
133
|
+
confidence: "medium",
|
|
134
|
+
params: { name: "SalesFormLetter_Confirmation", type: "Action" },
|
|
135
|
+
note: "Confirmed live (headless intercompany replay, 2026-08-20): buttonUpdateConfirm wasn't " +
|
|
136
|
+
"reachable on the SalesTable order form in this environment. Invoke the " +
|
|
137
|
+
"SalesFormLetter_Confirmation action menu item directly instead of clicking the recorded " +
|
|
138
|
+
"button.",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (ControlName === "buttonUpdateInvoice" && CommandName === "Click") {
|
|
142
|
+
return {
|
|
143
|
+
tool: "form_click_control",
|
|
144
|
+
confidence: "high",
|
|
145
|
+
params: { controlName: ControlName },
|
|
146
|
+
note: "Confirmed live (headless intercompany replay, 2026-08-20) on an intercompany purchase " +
|
|
147
|
+
"order (DEMF): clicking buttonUpdateInvoice from the PO form itself opened a USMF-scoped, " +
|
|
148
|
+
"empty vendor-invoice list; company context didn't carry through. Clicking it from the " +
|
|
149
|
+
"DEMF list page instead, with the target row marked and companyId set explicitly on that " +
|
|
150
|
+
"navigation, opened a correctly pre-populated invoice.",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (ControlName === "ButtonInterCompanyCustPackingSlipJournal" && CommandName === "Click") {
|
|
154
|
+
return {
|
|
155
|
+
tool: "form_click_control",
|
|
156
|
+
confidence: "high",
|
|
157
|
+
params: { controlName: ControlName },
|
|
158
|
+
followUp: [
|
|
159
|
+
{
|
|
160
|
+
tool: "form_click_control",
|
|
161
|
+
note: "Confirmed live (headless intercompany replay, 2026-08-20): generating an intercompany " +
|
|
162
|
+
"product receipt needs an explicit follow-up click on ButtonInterCompanyUpdate; " +
|
|
163
|
+
"posting the sales-side packing slip alone doesn't produce it.",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const CLICKABLE_CONTROL_TYPES = new Set([
|
|
169
|
+
"CommandButton",
|
|
170
|
+
"MenuItemButton",
|
|
171
|
+
"MenuButton",
|
|
172
|
+
"MenuItem",
|
|
173
|
+
"Button",
|
|
174
|
+
]);
|
|
175
|
+
if (CommandName === "Click" && CLICKABLE_CONTROL_TYPES.has(ControlType)) {
|
|
176
|
+
const isNestedMenuChild = ControlType === "MenuButton" || ControlType === "MenuItem";
|
|
177
|
+
return {
|
|
178
|
+
tool: "form_click_control",
|
|
179
|
+
confidence: "high",
|
|
180
|
+
params: { controlName: ControlName },
|
|
181
|
+
...(isNestedMenuChild && {
|
|
182
|
+
note: "Nested menu button children (ControlType MenuButton/MenuItem) could be clicked " +
|
|
183
|
+
"directly by name on a live replay; no separate 'open the dropdown' step was " +
|
|
184
|
+
"needed even though the recording captured two clicks.",
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const filterOrSortHint = filterManagerHint(step.node);
|
|
189
|
+
if (filterOrSortHint)
|
|
190
|
+
return filterOrSortHint;
|
|
191
|
+
const investigated = INVESTIGATED_BUT_UNVERIFIED[`${CommandName}:${ControlType}`];
|
|
192
|
+
return {
|
|
193
|
+
tool: "form_find_controls",
|
|
194
|
+
confidence: "low",
|
|
195
|
+
params: { controlSearchTerm: ControlName },
|
|
196
|
+
note: investigated
|
|
197
|
+
? `Unmapped CommandUserAction (CommandName=${CommandName}, ControlType=${ControlType}). ${investigated}`
|
|
198
|
+
: `Unmapped CommandUserAction (CommandName=${CommandName}, ControlType=${ControlType}). Resolve via form_find_controls before acting.`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function parseJson(raw) {
|
|
202
|
+
if (!raw)
|
|
203
|
+
return null;
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(raw);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function filterGridColumnName(meta) {
|
|
212
|
+
return `${meta.DataSourceName}_${meta.FieldName}`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* D365FO's recorded filter Operator translated to the wildcard syntax form_filter_grid's own
|
|
216
|
+
* schema documents (SysQueryRangeUtil-style range strings) -- confirmed live for the no-wildcard
|
|
217
|
+
* (exact/Matches) case; Contains/EndsWith follow the same documented convention but haven't been
|
|
218
|
+
* live-tested with those specific operators yet.
|
|
219
|
+
*/
|
|
220
|
+
function filterOperatorToValue(operator, value) {
|
|
221
|
+
if (!value)
|
|
222
|
+
return "";
|
|
223
|
+
switch (operator) {
|
|
224
|
+
case "BeginsWith":
|
|
225
|
+
return `${value}*`;
|
|
226
|
+
case "Contains":
|
|
227
|
+
return `*${value}*`;
|
|
228
|
+
case "EndsWith":
|
|
229
|
+
return `*${value}`;
|
|
230
|
+
default:
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const FILTER_MANAGER_GRID_NAME_NOTE = "gridName isn't recorded on this step and isn't always \"Grid\". Standard lookup popups " +
|
|
235
|
+
"(e.g. CustTableLookup) commonly expose exactly one grid literally named \"Grid\" (confirmed " +
|
|
236
|
+
"live), but list-page forms (e.g. PurchTable) don't; confirm the real gridName from the " +
|
|
237
|
+
"current FormState before calling.";
|
|
238
|
+
/**
|
|
239
|
+
* The FilterManager/QuickFilter/FilterPane command family: the recorded ControlName is always the
|
|
240
|
+
* filter/sort UI itself (e.g. SystemDefinedFilterManager), never the grid it acts on, so it can't
|
|
241
|
+
* be passed through as gridName directly. Confirmed live against CustTableLookup's "Grid" (see
|
|
242
|
+
* recordings/BPM-AXTR-Create a free text invoice.axtr) and cross-checked against the JSON shape
|
|
243
|
+
* on every occurrence of these CommandNames across the full recordings library (not just one
|
|
244
|
+
* sample): each step's Arguments carries a JSON string with the real DataSourceName/FieldName
|
|
245
|
+
* (and, for filters, an Operator + Values; for sorts, a human-readable direction). gridColumnName
|
|
246
|
+
* is consistently DataSourceName_FieldName -- confirmed by comparing the parsed metadata against
|
|
247
|
+
* the live FormState's actual column names (CustTable_AccountNum, not just AccountNum).
|
|
248
|
+
*/
|
|
249
|
+
function filterManagerHint(node) {
|
|
250
|
+
if (node.kind !== "CommandUserAction")
|
|
251
|
+
return null;
|
|
252
|
+
const { CommandName, ControlType, Arguments } = node;
|
|
253
|
+
if (CommandName === "GetFilters" && ControlType === "FilterManager") {
|
|
254
|
+
return {
|
|
255
|
+
tool: "skip",
|
|
256
|
+
confidence: "medium",
|
|
257
|
+
params: {},
|
|
258
|
+
note: "Confirmed across every GetFilters:FilterManager occurrence in the recordings library: " +
|
|
259
|
+
"the embedded filter-field JSON always has an empty Values array. This step just opens/" +
|
|
260
|
+
"reads the column filter dropdown for display; nothing to replay. If a future recording " +
|
|
261
|
+
"ever shows a non-empty Values here, treat it like ApplyFiltersForTaskRecorder instead.",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (CommandName === "SetSortsForTaskRecorder" && ControlType === "FilterManager") {
|
|
265
|
+
const meta = parseJson(Arguments[0]?.Value)?.[0];
|
|
266
|
+
const direction = Arguments[Arguments.length - 1]?.Value;
|
|
267
|
+
if (meta && direction) {
|
|
268
|
+
return {
|
|
269
|
+
tool: "form_sort_grid_column",
|
|
270
|
+
confidence: "medium",
|
|
271
|
+
params: {
|
|
272
|
+
gridColumnName: filterGridColumnName(meta),
|
|
273
|
+
sortDirection: /z to a/i.test(direction) ? "Descending" : "Ascending",
|
|
274
|
+
},
|
|
275
|
+
note: `sortDirection parsed from the recording's human-readable direction ("${direction}"). ${FILTER_MANAGER_GRID_NAME_NOTE}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (CommandName === "ApplyFiltersForTaskRecorder" && ControlType === "FilterManager") {
|
|
280
|
+
const meta = parseJson(Arguments[0]?.Value)?.[0];
|
|
281
|
+
if (meta) {
|
|
282
|
+
return {
|
|
283
|
+
tool: "form_filter_grid",
|
|
284
|
+
confidence: "medium",
|
|
285
|
+
params: {
|
|
286
|
+
gridColumnName: filterGridColumnName(meta),
|
|
287
|
+
gridColumnValue: filterOperatorToValue(meta.Operator, meta.Values?.[0] ?? ""),
|
|
288
|
+
},
|
|
289
|
+
note: FILTER_MANAGER_GRID_NAME_NOTE,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (CommandName === "ApplyFilterForTaskRecorder" && ControlType === "QuickFilter") {
|
|
294
|
+
const meta = parseJson(Arguments[2]?.Value);
|
|
295
|
+
if (meta) {
|
|
296
|
+
return {
|
|
297
|
+
tool: "form_filter_grid",
|
|
298
|
+
confidence: "medium",
|
|
299
|
+
params: { gridColumnName: meta.FieldName, gridColumnValue: meta.Value },
|
|
300
|
+
note: "gridColumnName/gridColumnValue parsed directly from the step's embedded JSON (already " +
|
|
301
|
+
`DataSource_Field form here, e.g. "ProjTable_Name", unlike the FilterManager family). ${FILTER_MANAGER_GRID_NAME_NOTE}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (CommandName === "ApplyFilters" && ControlType === "FilterPane") {
|
|
306
|
+
const metas = (parseJson(Arguments[2]?.Value) ?? []).filter((m) => (m.Values?.[0] ?? "") !== "");
|
|
307
|
+
if (metas.length > 0) {
|
|
308
|
+
const [first, ...rest] = metas;
|
|
309
|
+
return {
|
|
310
|
+
tool: "form_filter_grid",
|
|
311
|
+
confidence: "medium",
|
|
312
|
+
params: {
|
|
313
|
+
gridColumnName: filterGridColumnName(first),
|
|
314
|
+
gridColumnValue: filterOperatorToValue(first.Operator, first.Values[0]),
|
|
315
|
+
},
|
|
316
|
+
followUp: rest.map((m) => ({
|
|
317
|
+
tool: "form_filter_grid",
|
|
318
|
+
note: `Also apply gridColumnName=${filterGridColumnName(m)}, gridColumnValue=${filterOperatorToValue(m.Operator, m.Values[0])} on the same grid.`,
|
|
319
|
+
})),
|
|
320
|
+
note: "A FilterPane step can carry filters for multiple fields at once; fields with an empty " +
|
|
321
|
+
`recorded value are omitted (blank means "no filter" for that field, not "clear it"). ${FILTER_MANAGER_GRID_NAME_NOTE}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Fallback notes for the FilterManager/QuickFilter/FilterPane family above, used only if the
|
|
329
|
+
* embedded Arguments JSON is missing or fails to parse (filterManagerHint returns null and control
|
|
330
|
+
* falls through to the generic form_find_controls fallback in commandHint).
|
|
331
|
+
*/
|
|
332
|
+
const INVESTIGATED_BUT_UNVERIFIED = {
|
|
333
|
+
"GetFilters:FilterManager": "Expected empty Arguments; see filterManagerHint in this file.",
|
|
334
|
+
"ApplyFilterForTaskRecorder:QuickFilter": "Expected Arguments[2] to be JSON with FieldName/Value; see filterManagerHint in this file.",
|
|
335
|
+
"ApplyFiltersForTaskRecorder:FilterManager": "Expected Arguments[0] to be a JSON array with DataSourceName/FieldName/Operator/Values; see filterManagerHint in this file.",
|
|
336
|
+
"SetSortsForTaskRecorder:FilterManager": "Expected Arguments[0] to be a JSON array with DataSourceName/FieldName, and a trailing human-readable direction; see filterManagerHint in this file.",
|
|
337
|
+
"ApplyFilters:FilterPane": "Expected Arguments[2] to be a JSON array of per-field filter objects; see filterManagerHint in this file.",
|
|
338
|
+
};
|
|
339
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
340
|
+
/**
|
|
341
|
+
* D365FO's Task Recorder always captures Date-typed PropertyUserAction values as ISO
|
|
342
|
+
* (yyyy-MM-dd) -- confirmed across every recorded Date value in the library (35 distinct dates,
|
|
343
|
+
* all ISO). Some Date controls silently accept that format via form_set_control_values, but
|
|
344
|
+
* others reject it outright ("Dates must be in the format M/d/yyyy") -- confirmed live on a
|
|
345
|
+
* vendor invoice's Invoice date field, which errored on "2026-07-08" and only succeeded once
|
|
346
|
+
* reformatted to "7/8/2026". Reformatting unconditionally is safe: the target format matches the
|
|
347
|
+
* session's own Context.controlValueFormatSettings.DateFormat, which every d365-erp-mcp response
|
|
348
|
+
* already documents as the format to use.
|
|
349
|
+
*/
|
|
350
|
+
function formatDateForControl(value) {
|
|
351
|
+
const match = ISO_DATE.exec(value);
|
|
352
|
+
if (!match)
|
|
353
|
+
return value;
|
|
354
|
+
const [, year, month, day] = match;
|
|
355
|
+
return `${Number(month)}/${Number(day)}/${Number(year)}`;
|
|
356
|
+
}
|
|
357
|
+
function propertyHint(step) {
|
|
358
|
+
if (step.node.kind !== "PropertyUserAction")
|
|
359
|
+
return null;
|
|
360
|
+
const { ControlName, ControlType, Value, ListContext, UserActionType } = step.node;
|
|
361
|
+
const formattedValue = ControlType === "Date" && Value ? formatDateForControl(Value) : Value;
|
|
362
|
+
// UserActionType="Input" means the user typed/edited a value directly (confirmed live against
|
|
363
|
+
// a grid-bound field, SalesLine_SalesDeliverNow1: form_set_control_values worked directly, no
|
|
364
|
+
// filter needed). Only fall back to form_filter_grid when the action type is NOT a confirmed
|
|
365
|
+
// direct input but a ListContext is still present (unverified action types).
|
|
366
|
+
if (ListContext && UserActionType !== "Input") {
|
|
367
|
+
return {
|
|
368
|
+
tool: "form_filter_grid",
|
|
369
|
+
confidence: "medium",
|
|
370
|
+
params: { gridName: ListContext, gridColumnName: ControlName, gridColumnValue: formattedValue },
|
|
371
|
+
note: `Value was entered on a grid-bound control (UserActionType="${UserActionType}"); may be a filter rather than a cell edit.`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
// The recorder captures the invoice number's grid-cell name; the live Invoice number field
|
|
375
|
+
// on the vendor invoice dialog is PurchParmTable_Num (confirmed live, 2026-10-28/29 agent runs).
|
|
376
|
+
const liveControlName = ControlName === "PurchParmTable_gridParmTableNum" ? "PurchParmTable_Num" : ControlName;
|
|
377
|
+
return {
|
|
378
|
+
tool: "form_set_control_values",
|
|
379
|
+
confidence: "high",
|
|
380
|
+
params: { setControlValues: [{ controlName: liveControlName, value: formattedValue }] },
|
|
381
|
+
followUp: [
|
|
382
|
+
{
|
|
383
|
+
tool: "form_save_form",
|
|
384
|
+
note: "Confirmed live: the form reports 'has been modified and needs to be saved' after " +
|
|
385
|
+
"form_set_control_values. Edits are not persisted until form_save_form is called.",
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** Maps a normalized TestStep to a best-effort d365-erp-mcp tool call hint. Null for Unknown nodes. */
|
|
391
|
+
export function mapStep(step) {
|
|
392
|
+
return skipHint(step) ?? menuItemHint(step) ?? commandHint(step) ?? propertyHint(step);
|
|
393
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface BridgeConfig {
|
|
2
|
+
/** The environment's base URL, e.g. https://contoso.operations.dynamics.com */
|
|
3
|
+
environmentUrl: string;
|
|
4
|
+
tenantId: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
requestTimeoutMs: number;
|
|
8
|
+
}
|
|
9
|
+
export interface HttpResponse {
|
|
10
|
+
status: number;
|
|
11
|
+
body: string;
|
|
12
|
+
sessionId: string | null;
|
|
13
|
+
}
|
|
14
|
+
/** What the bridge needs from the outside world; tests replace each one. */
|
|
15
|
+
export interface BridgeDeps {
|
|
16
|
+
post(body: string, sessionId: string | null, token: string, timeoutMs: number): Promise<HttpResponse>;
|
|
17
|
+
getToken(forceRefresh: boolean): Promise<string>;
|
|
18
|
+
write(line: string): void;
|
|
19
|
+
log(text: string): void;
|
|
20
|
+
sleep(ms: number): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export interface BridgeOptions {
|
|
23
|
+
requestTimeoutMs?: number;
|
|
24
|
+
/** `initialize` must answer within the client's own connect timeout (30 s in Claude Code). */
|
|
25
|
+
initializeTimeoutMs?: number;
|
|
26
|
+
/** Waits between attempts while the environment restarts (about 1 minute in total). */
|
|
27
|
+
retryDelaysMs?: number[];
|
|
28
|
+
}
|
|
29
|
+
interface JsonRpcMessage {
|
|
30
|
+
jsonrpc?: string;
|
|
31
|
+
id?: string | number | null;
|
|
32
|
+
method?: string;
|
|
33
|
+
params?: {
|
|
34
|
+
name?: string;
|
|
35
|
+
};
|
|
36
|
+
error?: {
|
|
37
|
+
message?: string;
|
|
38
|
+
};
|
|
39
|
+
result?: unknown;
|
|
40
|
+
}
|
|
41
|
+
/** The JSON-RPC messages in a JSON or server-sent-events body; anything else (HTML) is skipped. */
|
|
42
|
+
export declare function parseMessages(body: string): JsonRpcMessage[];
|
|
43
|
+
export declare function isReplaySafe(message: JsonRpcMessage | null): boolean;
|
|
44
|
+
/** The message-handling core, independent of stdio, HTTPS and MSAL. */
|
|
45
|
+
export declare function createBridge(deps: BridgeDeps, options?: BridgeOptions): {
|
|
46
|
+
handle: (line: string) => Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
/** Reads the bridge settings from the environment variables SETUP.md documents. */
|
|
49
|
+
export declare function configFromEnv(env: NodeJS.ProcessEnv): BridgeConfig;
|
|
50
|
+
/** `trxa mcp-bridge`: runs until stdin closes. */
|
|
51
|
+
export declare function runMcpBridge(env?: NodeJS.ProcessEnv): Promise<void>;
|
|
52
|
+
export {};
|