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
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types mirroring the WCF DataContract shape of a D365 F&O Task Recorder
|
|
3
|
+
* `Recording.xml` (namespace `.../Microsoft.Dynamics.Client.ServerForm.TaskRecording`).
|
|
4
|
+
*
|
|
5
|
+
* Verified against a real sample ("Post Packing Slip.axtr"): Scope, MenuItemUserAction,
|
|
6
|
+
* CommandUserAction, PropertyUserAction. ValidationUserAction/InfoUserAction/TaskUserAction
|
|
7
|
+
* are documented in the D365FO metadata (SysTaskRecorderSerializer.handle* methods) but did
|
|
8
|
+
* not appear in the sample, so they fall back to UnknownUserAction and are never rejected.
|
|
9
|
+
*/
|
|
10
|
+
export interface FormContext {
|
|
11
|
+
FormID: string;
|
|
12
|
+
FormName: string;
|
|
13
|
+
RecordingName: string;
|
|
14
|
+
HasMetadata: boolean;
|
|
15
|
+
Sequence: number;
|
|
16
|
+
Annotations: unknown;
|
|
17
|
+
}
|
|
18
|
+
interface NodeBase {
|
|
19
|
+
Id: string;
|
|
20
|
+
Description: string;
|
|
21
|
+
CustomDescription: string | null;
|
|
22
|
+
ParentSequence: number;
|
|
23
|
+
Sequence: number;
|
|
24
|
+
ScreenshotUri: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface ScopeNode extends NodeBase {
|
|
27
|
+
kind: "Scope";
|
|
28
|
+
Name: string;
|
|
29
|
+
IsForm: boolean;
|
|
30
|
+
IsStepGroup: boolean;
|
|
31
|
+
ScopeType: string;
|
|
32
|
+
Children: RecordingNode[];
|
|
33
|
+
}
|
|
34
|
+
interface UserActionBase extends NodeBase {
|
|
35
|
+
GlobalId: string;
|
|
36
|
+
SystemGenerated: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface MenuItemUserAction extends UserActionBase {
|
|
39
|
+
kind: "MenuItemUserAction";
|
|
40
|
+
MenuItemName: string;
|
|
41
|
+
MenuItemType: string;
|
|
42
|
+
NavigationPath: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface CommandArgument {
|
|
45
|
+
IsReference: boolean;
|
|
46
|
+
Value: string;
|
|
47
|
+
}
|
|
48
|
+
export interface CommandUserAction extends UserActionBase {
|
|
49
|
+
kind: "CommandUserAction";
|
|
50
|
+
FormId: string;
|
|
51
|
+
ControlName: string;
|
|
52
|
+
ControlType: string;
|
|
53
|
+
ControlLabel: string;
|
|
54
|
+
ControlLabelId: string;
|
|
55
|
+
CommandName: string;
|
|
56
|
+
Arguments: CommandArgument[];
|
|
57
|
+
ListContext: string | null;
|
|
58
|
+
RowIndex: number | null;
|
|
59
|
+
ReturnType: string;
|
|
60
|
+
ReturnValue: string | null;
|
|
61
|
+
TaskId: string | null;
|
|
62
|
+
}
|
|
63
|
+
export interface PropertyUserAction extends UserActionBase {
|
|
64
|
+
kind: "PropertyUserAction";
|
|
65
|
+
FormId: string;
|
|
66
|
+
ControlName: string;
|
|
67
|
+
ControlType: string;
|
|
68
|
+
ControlLabel: string;
|
|
69
|
+
ControlLabelId: string;
|
|
70
|
+
BoundDataSource: string;
|
|
71
|
+
BoundProperty: string;
|
|
72
|
+
PropertyName: string;
|
|
73
|
+
/** e.g. "Input" for a value entered by the user; other values are unverified. */
|
|
74
|
+
UserActionType: string;
|
|
75
|
+
Value: string;
|
|
76
|
+
ValueLabel: string | null;
|
|
77
|
+
IsReference: boolean;
|
|
78
|
+
ListContext: string | null;
|
|
79
|
+
RowIndex: number | null;
|
|
80
|
+
}
|
|
81
|
+
/** Fallback for node types not verified against a real sample (Validation/Info/Task user actions). */
|
|
82
|
+
export interface UnknownUserAction extends UserActionBase {
|
|
83
|
+
kind: "Unknown";
|
|
84
|
+
xsiType: string;
|
|
85
|
+
raw: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
export type RecordingNode = ScopeNode | MenuItemUserAction | CommandUserAction | PropertyUserAction | UnknownUserAction;
|
|
88
|
+
export interface Recording {
|
|
89
|
+
CanonicalId: string;
|
|
90
|
+
CurrentSequence: number;
|
|
91
|
+
Description: string;
|
|
92
|
+
Name: string;
|
|
93
|
+
FormContexts: Record<string, FormContext>;
|
|
94
|
+
RootScope: ScopeNode;
|
|
95
|
+
}
|
|
96
|
+
export {};
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types mirroring the WCF DataContract shape of a D365 F&O Task Recorder
|
|
3
|
+
* `Recording.xml` (namespace `.../Microsoft.Dynamics.Client.ServerForm.TaskRecording`).
|
|
4
|
+
*
|
|
5
|
+
* Verified against a real sample ("Post Packing Slip.axtr"): Scope, MenuItemUserAction,
|
|
6
|
+
* CommandUserAction, PropertyUserAction. ValidationUserAction/InfoUserAction/TaskUserAction
|
|
7
|
+
* are documented in the D365FO metadata (SysTaskRecorderSerializer.handle* methods) but did
|
|
8
|
+
* not appear in the sample, so they fall back to UnknownUserAction and are never rejected.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
package/dist/scrub.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips literal recorded data (account numbers, amounts, names, dates -- anything a user typed
|
|
3
|
+
* or that D365FO substituted into an auto-generated step description) out of a parsed Recording
|
|
4
|
+
* tree, replacing each with an opaque token. Every stripped value is also returned as a row so a
|
|
5
|
+
* human can review it locally and, if desired, remap it to an equivalent value from a sandbox
|
|
6
|
+
* environment for full-cycle replay testing.
|
|
7
|
+
*
|
|
8
|
+
* This is deliberately over-cautious: full-field redaction (not substring matching inside
|
|
9
|
+
* descriptions) and blanket redaction of `Unknown.raw`, since those node types are unverified and
|
|
10
|
+
* we don't know what they carry. Anything kept un-redacted is structural metadata (control names,
|
|
11
|
+
* command names, form names, menu paths) that D365FO itself defines in the AOT, not
|
|
12
|
+
* customer/business data.
|
|
13
|
+
*
|
|
14
|
+
* One deliberate exception to "full-field redaction": the FilterManager/QuickFilter/FilterPane
|
|
15
|
+
* command family (see `mapping.ts`'s `filterManagerHint`) embeds a JSON blob in one `Arguments[i]`
|
|
16
|
+
* entry that mixes a literal filter value with pure AOT structural metadata
|
|
17
|
+
* (`DataSourceName`/`FieldName`/`Operator`). Blanket-redacting that whole string used to make
|
|
18
|
+
* `analyze-library`'s own hint computation unable to parse it, so this family's steps showed up as
|
|
19
|
+
* low/unmapped in confidence counts even though `mapping.ts` resolves them correctly against real
|
|
20
|
+
* (unscrubbed) recordings. `scrubFilterFamilyArgument` below parses that one argument's JSON and
|
|
21
|
+
* redacts only the value-bearing fields, leaving the structural ones visible -- everything else in
|
|
22
|
+
* the recording keeps the blanket-redaction default above.
|
|
23
|
+
*/
|
|
24
|
+
import type { Recording } from "./schema.js";
|
|
25
|
+
export interface ScrubbedValue {
|
|
26
|
+
recordingFile: string;
|
|
27
|
+
recordingName: string;
|
|
28
|
+
nodeKind: string;
|
|
29
|
+
nodeId: string;
|
|
30
|
+
globalId: string | null;
|
|
31
|
+
sequence: number;
|
|
32
|
+
fieldPath: string;
|
|
33
|
+
token: string;
|
|
34
|
+
originalValue: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ScrubResult {
|
|
37
|
+
recording: Recording;
|
|
38
|
+
values: ScrubbedValue[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Returns a deep copy of `recording` with every literal recorded value replaced by an opaque
|
|
42
|
+
* token, plus the list of stripped values (original text included) for local-only review.
|
|
43
|
+
*/
|
|
44
|
+
export declare function scrubRecording(recording: Recording, recordingFile: string): ScrubResult;
|
package/dist/scrub.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** Command/control combos from `mapping.ts`'s `filterManagerHint`, and the one `Arguments` index
|
|
2
|
+
* that carries the DataSourceName/FieldName/Operator/Values JSON for each -- kept in sync with
|
|
3
|
+
* that function manually; update both if this family's recorded shape ever changes.
|
|
4
|
+
* `GetFilters:FilterManager` isn't listed: every occurrence confirmed live has an empty
|
|
5
|
+
* `Arguments` array (see `filterManagerHint`'s comment), so there's nothing to selectively
|
|
6
|
+
* redact -- it falls through to the blanket per-argument redaction below like any other command. */
|
|
7
|
+
const FILTER_FAMILY_JSON_ARG_INDEX = {
|
|
8
|
+
"SetSortsForTaskRecorder:FilterManager": 0,
|
|
9
|
+
"ApplyFiltersForTaskRecorder:FilterManager": 0,
|
|
10
|
+
"ApplyFilterForTaskRecorder:QuickFilter": 2,
|
|
11
|
+
"ApplyFilters:FilterPane": 2,
|
|
12
|
+
};
|
|
13
|
+
/** Field names inside this family's embedded JSON that are AOT-defined structural metadata, not
|
|
14
|
+
* user-entered data -- confirmed against `mapping.ts`'s `FilterFieldMeta` shape and the QuickFilter
|
|
15
|
+
* JSON's own `FieldName`. Every other field (including any not explicitly recognized here, e.g.
|
|
16
|
+
* QuickFilter's `ReferenceUserActionSequenceId`) is redacted the same as any other recorded
|
|
17
|
+
* value, matching this file's "deliberately over-cautious" default -- only fields confirmed safe
|
|
18
|
+
* get an exception. */
|
|
19
|
+
const FILTER_FAMILY_SAFE_FIELDS = new Set(["DataSourceName", "FieldName", "Operator"]);
|
|
20
|
+
class Scrubber {
|
|
21
|
+
recordingFile;
|
|
22
|
+
recordingName;
|
|
23
|
+
tokenPrefix;
|
|
24
|
+
counter = 0;
|
|
25
|
+
values = [];
|
|
26
|
+
constructor(recordingFile, recordingName, tokenPrefix) {
|
|
27
|
+
this.recordingFile = recordingFile;
|
|
28
|
+
this.recordingName = recordingName;
|
|
29
|
+
this.tokenPrefix = tokenPrefix;
|
|
30
|
+
}
|
|
31
|
+
/** Redacts `value` in place if non-empty/non-null, returning the token (or the original null/""). */
|
|
32
|
+
redact(value, fieldPath, node) {
|
|
33
|
+
if (value === null || value === "")
|
|
34
|
+
return value;
|
|
35
|
+
this.counter += 1;
|
|
36
|
+
const token = `<<REDACTED:${this.tokenPrefix}:${this.counter}>>`;
|
|
37
|
+
this.values.push({
|
|
38
|
+
recordingFile: this.recordingFile,
|
|
39
|
+
recordingName: this.recordingName,
|
|
40
|
+
nodeKind: "kind" in node ? node.kind : "?",
|
|
41
|
+
nodeId: node.Id,
|
|
42
|
+
globalId: node.GlobalId ?? null,
|
|
43
|
+
sequence: node.Sequence,
|
|
44
|
+
fieldPath,
|
|
45
|
+
token,
|
|
46
|
+
originalValue: value,
|
|
47
|
+
});
|
|
48
|
+
return token;
|
|
49
|
+
}
|
|
50
|
+
/** Redacts every non-empty string leaf inside an unverified `Unknown.raw` payload, in place. */
|
|
51
|
+
redactDeep(obj, fieldPath, node) {
|
|
52
|
+
if (obj === null || obj === undefined)
|
|
53
|
+
return;
|
|
54
|
+
if (Array.isArray(obj)) {
|
|
55
|
+
obj.forEach((v, i) => this.redactDeepEntry(obj, i, `${fieldPath}[${i}]`, node));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof obj === "object") {
|
|
59
|
+
for (const key of Object.keys(obj)) {
|
|
60
|
+
this.redactDeepEntry(obj, key, `${fieldPath}.${key}`, node);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
redactDeepEntry(
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic walk over unverified raw XML JSON
|
|
66
|
+
container, key, fieldPath, node) {
|
|
67
|
+
const v = container[key];
|
|
68
|
+
if (typeof v === "string") {
|
|
69
|
+
if (v === "")
|
|
70
|
+
return;
|
|
71
|
+
container[key] = this.redact(v, fieldPath, node);
|
|
72
|
+
}
|
|
73
|
+
else if (typeof v === "object" && v !== null) {
|
|
74
|
+
this.redactDeep(v, fieldPath, node);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
scrubNode(node) {
|
|
78
|
+
node.Description = this.redact(node.Description, `${node.kind}.Description`, node);
|
|
79
|
+
node.CustomDescription = this.redact(node.CustomDescription, `${node.kind}.CustomDescription`, node);
|
|
80
|
+
node.ScreenshotUri = this.redact(node.ScreenshotUri, `${node.kind}.ScreenshotUri`, node);
|
|
81
|
+
switch (node.kind) {
|
|
82
|
+
case "Scope":
|
|
83
|
+
node.Children = node.Children.map((c) => this.scrubNode(c));
|
|
84
|
+
return node;
|
|
85
|
+
case "MenuItemUserAction":
|
|
86
|
+
return node; // NavigationPath/MenuItemName/MenuItemType are structural, not data
|
|
87
|
+
case "CommandUserAction":
|
|
88
|
+
return this.scrubCommand(node);
|
|
89
|
+
case "PropertyUserAction":
|
|
90
|
+
node.Value = this.redact(node.Value, "PropertyUserAction.Value", node);
|
|
91
|
+
node.ValueLabel = this.redact(node.ValueLabel, "PropertyUserAction.ValueLabel", node);
|
|
92
|
+
return node;
|
|
93
|
+
case "Unknown":
|
|
94
|
+
this.redactDeep(node.raw, "Unknown.raw", node);
|
|
95
|
+
return node;
|
|
96
|
+
default:
|
|
97
|
+
return node;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
scrubCommand(node) {
|
|
101
|
+
const jsonArgIndex = FILTER_FAMILY_JSON_ARG_INDEX[`${node.CommandName}:${node.ControlType}`];
|
|
102
|
+
node.Arguments = node.Arguments.map((arg, i) => ({
|
|
103
|
+
...arg,
|
|
104
|
+
Value: i === jsonArgIndex
|
|
105
|
+
? this.scrubFilterFamilyArgument(arg.Value, i, node)
|
|
106
|
+
: this.redact(arg.Value, `CommandUserAction.Arguments[${i}].Value`, node),
|
|
107
|
+
}));
|
|
108
|
+
node.ReturnValue = this.redact(node.ReturnValue, "CommandUserAction.ReturnValue", node);
|
|
109
|
+
return node;
|
|
110
|
+
}
|
|
111
|
+
/** Parses one FilterManager/QuickFilter/FilterPane argument's embedded JSON (a single object for
|
|
112
|
+
* QuickFilter, an array of them for the other three) and redacts only the value-bearing fields
|
|
113
|
+
* in place, leaving `FILTER_FAMILY_SAFE_FIELDS` visible so the re-serialized JSON is still
|
|
114
|
+
* parseable by `mapping.ts`'s `filterManagerHint`. Falls back to the normal full-string redaction
|
|
115
|
+
* if the value isn't valid JSON -- a malformed/unexpected shape should never accidentally leak
|
|
116
|
+
* a literal value just because it didn't parse the way this family usually does. */
|
|
117
|
+
scrubFilterFamilyArgument(rawValue, argIndex, node) {
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(rawValue);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return this.redact(rawValue, `CommandUserAction.Arguments[${argIndex}].Value`, node);
|
|
124
|
+
}
|
|
125
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
126
|
+
entries.forEach((entry, i) => {
|
|
127
|
+
if (entry !== null && typeof entry === "object") {
|
|
128
|
+
this.redactFilterFieldEntry(entry, `CommandUserAction.Arguments[${argIndex}].Value[${i}]`, node);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return JSON.stringify(parsed);
|
|
132
|
+
}
|
|
133
|
+
/** Redacts every field of one parsed filter-metadata object except `FILTER_FAMILY_SAFE_FIELDS`,
|
|
134
|
+
* in place -- including each element of a `Values` array, which is where the actual recorded
|
|
135
|
+
* filter/sort literal lives. */
|
|
136
|
+
redactFilterFieldEntry(entry, fieldPath, node) {
|
|
137
|
+
for (const key of Object.keys(entry)) {
|
|
138
|
+
if (FILTER_FAMILY_SAFE_FIELDS.has(key))
|
|
139
|
+
continue;
|
|
140
|
+
const value = entry[key];
|
|
141
|
+
if (typeof value === "string") {
|
|
142
|
+
entry[key] = this.redact(value, `${fieldPath}.${key}`, node);
|
|
143
|
+
}
|
|
144
|
+
else if (Array.isArray(value)) {
|
|
145
|
+
entry[key] = value.map((item, i) => typeof item === "string" ? this.redact(item, `${fieldPath}.${key}[${i}]`, node) : item);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function sanitizeTokenPrefix(recordingFile) {
|
|
151
|
+
return recordingFile.replace(/\.axtr$/i, "").replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 40);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Returns a deep copy of `recording` with every literal recorded value replaced by an opaque
|
|
155
|
+
* token, plus the list of stripped values (original text included) for local-only review.
|
|
156
|
+
*/
|
|
157
|
+
export function scrubRecording(recording, recordingFile) {
|
|
158
|
+
const clone = structuredClone(recording);
|
|
159
|
+
const scrubber = new Scrubber(recordingFile, clone.Name, sanitizeTokenPrefix(recordingFile));
|
|
160
|
+
clone.Description = scrubber.redact(clone.Description, "Recording.Description", {
|
|
161
|
+
Id: clone.CanonicalId,
|
|
162
|
+
Sequence: 0,
|
|
163
|
+
});
|
|
164
|
+
clone.RootScope = scrubber.scrubNode(clone.RootScope);
|
|
165
|
+
return { recording: clone, values: scrubber.values };
|
|
166
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TestScript } from "./normalize.js";
|
|
2
|
+
import type { InputParamSpec } from "./testcase-schema.js";
|
|
3
|
+
export interface TestCaseInputDraft {
|
|
4
|
+
testScriptName: string;
|
|
5
|
+
candidates: InputParamSpec[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Scans a normalized TestScript and drafts candidate InputParamSpecs for a human to review/edit
|
|
9
|
+
* before they become a TestCase's `inputs`. Every candidate traces back to either a
|
|
10
|
+
* recorded value (PropertyUserAction) or an ambiguity mapping.ts already flagged as low
|
|
11
|
+
* confidence -- nothing here is invented, so a human reviewing the draft always has the
|
|
12
|
+
* original recorded context (`example`/`description`) to work from.
|
|
13
|
+
*
|
|
14
|
+
* CAUTION: `example` is copied verbatim from the source .axtr (a real account number, journal
|
|
15
|
+
* name, amount, etc. from whatever environment it was recorded against). If the source
|
|
16
|
+
* recording contains real business data, review/scrub `example` values before committing the
|
|
17
|
+
* resulting testcases/*.json to a shared or public repo.
|
|
18
|
+
*/
|
|
19
|
+
export declare function suggestInputs(script: TestScript): TestCaseInputDraft;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mapStep } from "./mapping.js";
|
|
2
|
+
function uniqueName(base, seen) {
|
|
3
|
+
if (!seen.has(base)) {
|
|
4
|
+
seen.add(base);
|
|
5
|
+
return base;
|
|
6
|
+
}
|
|
7
|
+
let n = 2;
|
|
8
|
+
while (seen.has(`${base}_${n}`))
|
|
9
|
+
n++;
|
|
10
|
+
const name = `${base}_${n}`;
|
|
11
|
+
seen.add(name);
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
function draftFromPropertyStep(step, seen) {
|
|
15
|
+
if (step.node.kind !== "PropertyUserAction")
|
|
16
|
+
return null;
|
|
17
|
+
const controlName = step.node.ControlName || `step${step.sequence}`;
|
|
18
|
+
const name = uniqueName(controlName, seen);
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
// ControlLabel is the human-readable label D365FO itself shows next to the field (e.g.
|
|
22
|
+
// "Quantity"), as opposed to `name`/controlName which is the internal control identifier
|
|
23
|
+
// (e.g. "PurchLine_PurchQtyGrid") -- useful for UIs that want to show a form-goer-friendly
|
|
24
|
+
// label instead of the raw control name.
|
|
25
|
+
controlLabel: step.node.ControlLabel || undefined,
|
|
26
|
+
description: step.description || `Value for ${controlName}`,
|
|
27
|
+
example: step.node.Value,
|
|
28
|
+
required: true,
|
|
29
|
+
bindTo: { kind: "control", stepSequence: step.sequence, controlName },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Grid row selection is usually recorded as a pair of steps (e.g. ChangeSelectedIndexInCache ->
|
|
34
|
+
* form_filter_grid, then NavigationAction/MarkActiveRow -> form_select_grid_row). Only the
|
|
35
|
+
* filter step needs a fresh business-key input; the follow-up select step just acts on whatever
|
|
36
|
+
* the filter resolved to, so drafting a second candidate for it would be redundant.
|
|
37
|
+
*
|
|
38
|
+
* Any `form_filter_grid` hint qualifies, regardless of confidence -- confirmed live as a real gap
|
|
39
|
+
* (not just theoretical) on "Confirm purchase order.axtr": its QuickFilter step
|
|
40
|
+
* (ApplyFiltersForTaskRecorder) resolves through `filterManagerHint` at `confidence: "medium"`,
|
|
41
|
+
* with a concrete recorded gridColumnValue (e.g. "000039", one specific purchase order from
|
|
42
|
+
* whatever environment the recording was made in) -- but "medium" there describes confidence in
|
|
43
|
+
* the replay *mechanics* (which grid column, which tool), not confidence that the recorded value
|
|
44
|
+
* is safe to hardcode. It's exactly as environment-specific as the low-confidence row-position
|
|
45
|
+
* case; only checking for `confidence === "low"` silently stopped drafting a candidate for this
|
|
46
|
+
* family the moment mapping.ts got better at the former without changing the latter.
|
|
47
|
+
*/
|
|
48
|
+
function draftFromGridFilterStep(step, seen) {
|
|
49
|
+
const hint = mapStep(step);
|
|
50
|
+
if (!hint || hint.tool !== "form_filter_grid")
|
|
51
|
+
return null;
|
|
52
|
+
const recordedValue = typeof hint.params.gridColumnValue === "string" ? hint.params.gridColumnValue : undefined;
|
|
53
|
+
const name = uniqueName(`record_${step.sequence}`, seen);
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
description: `Needs a business key to identify the record for: "${step.description}". ` +
|
|
57
|
+
(recordedValue
|
|
58
|
+
? `The recording filtered by "${recordedValue}", one specific record from that session -- `
|
|
59
|
+
: "The source recording only captured a row position, which is not portable across environments/data -- ") +
|
|
60
|
+
"supply the actual value (e.g. an account number, order id) for this environment/data here.",
|
|
61
|
+
required: true,
|
|
62
|
+
example: recordedValue,
|
|
63
|
+
bindTo: { kind: "grid-row-selector", stepSequence: step.sequence, note: step.description },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Scans a normalized TestScript and drafts candidate InputParamSpecs for a human to review/edit
|
|
68
|
+
* before they become a TestCase's `inputs`. Every candidate traces back to either a
|
|
69
|
+
* recorded value (PropertyUserAction) or an ambiguity mapping.ts already flagged as low
|
|
70
|
+
* confidence -- nothing here is invented, so a human reviewing the draft always has the
|
|
71
|
+
* original recorded context (`example`/`description`) to work from.
|
|
72
|
+
*
|
|
73
|
+
* CAUTION: `example` is copied verbatim from the source .axtr (a real account number, journal
|
|
74
|
+
* name, amount, etc. from whatever environment it was recorded against). If the source
|
|
75
|
+
* recording contains real business data, review/scrub `example` values before committing the
|
|
76
|
+
* resulting testcases/*.json to a shared or public repo.
|
|
77
|
+
*/
|
|
78
|
+
export function suggestInputs(script) {
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const candidates = [];
|
|
81
|
+
for (const step of script.steps) {
|
|
82
|
+
const fromProperty = draftFromPropertyStep(step, seen);
|
|
83
|
+
if (fromProperty) {
|
|
84
|
+
candidates.push(fromProperty);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const fromGridFilter = draftFromGridFilterStep(step, seen);
|
|
88
|
+
if (fromGridFilter)
|
|
89
|
+
candidates.push(fromGridFilter);
|
|
90
|
+
}
|
|
91
|
+
return { testScriptName: script.name, candidates };
|
|
92
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TestSuite, TestSuiteEntry, RunCondition } from "./suite-schema.js";
|
|
2
|
+
import type { TestCase } from "./testcase-schema.js";
|
|
3
|
+
import type { Fixture } from "./fixture-schema.js";
|
|
4
|
+
import { type TestRun } from "./report-schema.js";
|
|
5
|
+
/**
|
|
6
|
+
* Kahn's algorithm over `dependsOn` edges. Throws on an unknown dependsOn id or a cycle --
|
|
7
|
+
* these must never be left to LLM judgment, unlike everything in mapping.ts's hints.
|
|
8
|
+
*/
|
|
9
|
+
export declare function topologicalOrder(suite: TestSuite): string[];
|
|
10
|
+
/** Resolves a test case's declared `inputs` to concrete values for one suite run, following
|
|
11
|
+
* each `inputBindings` entry to a fixture, an earlier test case's captured output, or a
|
|
12
|
+
* literal. Throws a clear, specific error rather than silently defaulting -- a missing or
|
|
13
|
+
* not-yet-produced value should stop the run, not get guessed by the LLM. */
|
|
14
|
+
export declare function resolveInputs(entry: TestSuiteEntry, testCase: TestCase, testRun: TestRun, fixtures: Fixture[]): Record<string, string>;
|
|
15
|
+
/** Evaluates a `runIf` gate against the run so far. `condition.testCaseId` is guaranteed (by
|
|
16
|
+
* `topologicalOrder`'s validation) to be one of the entry's own `dependsOn`, so by the time this
|
|
17
|
+
* is called that upstream test case has already passed -- this only ever reads its captured
|
|
18
|
+
* *output*, never its status (see `RunCondition`'s doc comment in `suite-schema.ts` for why a
|
|
19
|
+
* status check would be vacuous here). An upstream that didn't capture the referenced output at
|
|
20
|
+
* all evaluates to false (skip), the same "missing means doesn't match" behavior a real value
|
|
21
|
+
* comparison would need anyway. */
|
|
22
|
+
export declare function evaluateRunCondition(condition: RunCondition, testRun: TestRun): boolean;
|
|
23
|
+
export type SuiteProgress = {
|
|
24
|
+
status: "runnable";
|
|
25
|
+
testCaseIds: string[];
|
|
26
|
+
} | {
|
|
27
|
+
status: "newly-blocked";
|
|
28
|
+
blockedIds: string[];
|
|
29
|
+
} | {
|
|
30
|
+
status: "newly-skipped";
|
|
31
|
+
skippedIds: string[];
|
|
32
|
+
} | {
|
|
33
|
+
status: "waiting";
|
|
34
|
+
} | {
|
|
35
|
+
status: "complete";
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Computes the next action for a suite run given its current TestRun:
|
|
39
|
+
* - If any pending entry now has a failed/blocked/skipped dependency, report it as newly-blocked
|
|
40
|
+
* so the caller can record that status before asking again (propagation is explicit, not
|
|
41
|
+
* silent) -- a skipped dependency counts the same as a failed one here, since nothing downstream
|
|
42
|
+
* of it can ever satisfy "every dependency passed" otherwise, which would leave it stuck waiting
|
|
43
|
+
* forever instead of reaching a terminal status.
|
|
44
|
+
* - Otherwise, if any pending-and-otherwise-runnable entry's `runIf` evaluates false, report it as
|
|
45
|
+
* newly-skipped the same way, before considering anything runnable.
|
|
46
|
+
* - Otherwise return every pending entry (topological order, for determinism) that's runnable and
|
|
47
|
+
* (if it has one) passes its `runIf` -- usually just one, but a suite whose DAG has two or more
|
|
48
|
+
* entries that share a satisfied dependency without depending on each other (e.g. two entries
|
|
49
|
+
* that both only depend on a common upstream entry) surfaces all of them at once here. `nextStep`
|
|
50
|
+
* itself makes no scheduling decision beyond exposing what's currently unblocked -- whether the
|
|
51
|
+
* caller works through `testCaseIds` one at a time or hands them to concurrent workers is
|
|
52
|
+
* entirely the caller's call; nothing here assumes either.
|
|
53
|
+
* - Otherwise, if every entry has a terminal status, the suite is complete.
|
|
54
|
+
* - Otherwise ("waiting"): nothing is pending-and-runnable right now, but something is still
|
|
55
|
+
* `"running"` -- a normal mid-flight state, not an error. A root entry (no dependencies) is
|
|
56
|
+
* always immediately runnable once pending, so this can only happen when every not-yet-terminal
|
|
57
|
+
* entry is already `"running"`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function nextStep(suite: TestSuite, testRun: TestRun): SuiteProgress;
|