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.
Files changed (155) hide show
  1. package/.claude/skills/replay-trxa-testplan/SKILL.md +627 -0
  2. package/LICENSE +120 -0
  3. package/MANUAL.md +556 -0
  4. package/README.md +91 -3
  5. package/SETUP.md +401 -0
  6. package/STUDIO.md +427 -0
  7. package/dist/analyze.d.ts +58 -0
  8. package/dist/analyze.js +122 -0
  9. package/dist/bpm.d.ts +40 -0
  10. package/dist/bpm.js +60 -0
  11. package/dist/build-test-script.d.ts +24 -0
  12. package/dist/build-test-script.js +40 -0
  13. package/dist/csv.d.ts +1 -0
  14. package/dist/csv.js +11 -0
  15. package/dist/extract.d.ts +14 -0
  16. package/dist/extract.js +34 -0
  17. package/dist/fixture-schema.d.ts +52 -0
  18. package/dist/fixture-schema.js +112 -0
  19. package/dist/fixture-store.d.ts +37 -0
  20. package/dist/fixture-store.js +75 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.js +850 -0
  23. package/dist/library-archive.d.ts +37 -0
  24. package/dist/library-archive.js +106 -0
  25. package/dist/mapping.d.ts +25 -0
  26. package/dist/mapping.js +393 -0
  27. package/dist/mcp-bridge.d.ts +52 -0
  28. package/dist/mcp-bridge.js +290 -0
  29. package/dist/normalize.d.ts +21 -0
  30. package/dist/normalize.js +43 -0
  31. package/dist/parse.d.ts +3 -0
  32. package/dist/parse.js +233 -0
  33. package/dist/plan-runner.d.ts +27 -0
  34. package/dist/plan-runner.js +36 -0
  35. package/dist/plan-schema.d.ts +27 -0
  36. package/dist/plan-schema.js +45 -0
  37. package/dist/planrun-schema.d.ts +53 -0
  38. package/dist/planrun-schema.js +111 -0
  39. package/dist/planrun.d.ts +20 -0
  40. package/dist/planrun.js +46 -0
  41. package/dist/report-schema.d.ts +92 -0
  42. package/dist/report-schema.js +115 -0
  43. package/dist/report.d.ts +45 -0
  44. package/dist/report.js +77 -0
  45. package/dist/schema.d.ts +96 -0
  46. package/dist/schema.js +10 -0
  47. package/dist/scrub.d.ts +44 -0
  48. package/dist/scrub.js +166 -0
  49. package/dist/suggest-inputs.d.ts +19 -0
  50. package/dist/suggest-inputs.js +92 -0
  51. package/dist/suite-runner.d.ts +59 -0
  52. package/dist/suite-runner.js +204 -0
  53. package/dist/suite-schema.d.ts +59 -0
  54. package/dist/suite-schema.js +95 -0
  55. package/dist/testcase-schema.d.ts +102 -0
  56. package/dist/testcase-schema.js +132 -0
  57. package/dist/token-usage.d.ts +38 -0
  58. package/dist/token-usage.js +95 -0
  59. package/media/studio/fixtures-test-cases.png +0 -0
  60. package/media/studio/plans-editor.png +0 -0
  61. package/media/studio/recordings-create-test-case.png +0 -0
  62. package/media/studio/run-dialog.png +0 -0
  63. package/media/studio/settings-agent-runs.png +0 -0
  64. package/media/studio/settings-working-folder.png +0 -0
  65. package/media/studio/studio-recordings.png +0 -0
  66. package/media/studio/suites-canvas.png +0 -0
  67. package/media/studio/suites-inspector.png +0 -0
  68. package/media/studio/testcases-expectations.png +0 -0
  69. package/media/studio/testcases-outputs.png +0 -0
  70. package/media/studio/testrun-details.png +0 -0
  71. package/media/studio/testruns-list.png +0 -0
  72. package/package.json +62 -6
  73. package/studio/client-dist/assets/CreatePanel-C5bb5OIs.js +6 -0
  74. package/studio/client-dist/assets/FactTile-G1pURLys.js +1 -0
  75. package/studio/client-dist/assets/FieldLabel-DPmIEQ08.js +1 -0
  76. package/studio/client-dist/assets/FixtureNamePicker-137AAgwZ.js +1 -0
  77. package/studio/client-dist/assets/Fixtures-D0t-soph.js +1 -0
  78. package/studio/client-dist/assets/GeneralSection-CWkTeSX2.js +1 -0
  79. package/studio/client-dist/assets/ListPaneHeader-DXKqX36N.js +1 -0
  80. package/studio/client-dist/assets/Plans-C70evkGc.js +1 -0
  81. package/studio/client-dist/assets/Recordings-BT0dyVLs.js +1 -0
  82. package/studio/client-dist/assets/Settings-k-J3u5lH.js +1 -0
  83. package/studio/client-dist/assets/Suites-DLioOiRN.css +1 -0
  84. package/studio/client-dist/assets/Suites-DQhhMxjT.js +7 -0
  85. package/studio/client-dist/assets/TestCases-BdiopVGI.js +1 -0
  86. package/studio/client-dist/assets/TestRuns-CylAe-3t.js +9 -0
  87. package/studio/client-dist/assets/api-DJZ703_F.js +24 -0
  88. package/studio/client-dist/assets/bucket-18-viuwGcaj.js +1 -0
  89. package/studio/client-dist/assets/fixtureName-CTZT_jKu.js +1 -0
  90. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  91. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  92. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  93. package/studio/client-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  94. package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  95. package/studio/client-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  96. package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  97. package/studio/client-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  98. package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  99. package/studio/client-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  100. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-LoaTnByX.woff +0 -0
  101. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-300-normal-RhT0Ayi3.woff2 +0 -0
  102. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-Bf1n82Tb.woff +0 -0
  103. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-italic-D9J6q4_O.woff2 +0 -0
  104. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  105. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  106. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  107. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  108. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-Bbd5v1xK.woff +0 -0
  109. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-300-normal-DqmgDkIN.woff2 +0 -0
  110. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-BELOEI8R.woff2 +0 -0
  111. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-italic-CfSXsIbO.woff +0 -0
  112. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  113. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  114. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  115. package/studio/client-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  116. package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-J-CBqZoJ.woff2 +0 -0
  117. package/studio/client-dist/assets/ibm-plex-sans-greek-300-normal-STc8XiDr.woff +0 -0
  118. package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-BEZwmy7D.woff2 +0 -0
  119. package/studio/client-dist/assets/ibm-plex-sans-greek-400-italic-CTYLy0lX.woff +0 -0
  120. package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  121. package/studio/client-dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  122. package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  123. package/studio/client-dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  124. package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-C9Of02-5.woff +0 -0
  125. package/studio/client-dist/assets/ibm-plex-sans-latin-300-normal-b4i7YzLG.woff2 +0 -0
  126. package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CZTNEAuW.woff2 +0 -0
  127. package/studio/client-dist/assets/ibm-plex-sans-latin-400-italic-CsGl1sm0.woff +0 -0
  128. package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  129. package/studio/client-dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  130. package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  131. package/studio/client-dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  132. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-B8Zgwjh3.woff +0 -0
  133. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-300-normal-BLFuR0P1.woff2 +0 -0
  134. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-Du9zzoL2.woff2 +0 -0
  135. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-italic-V_9i1P_m.woff +0 -0
  136. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  137. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  138. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  139. package/studio/client-dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  140. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-CkYRZD2a.woff +0 -0
  141. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-300-normal-SDfX6ktj.woff2 +0 -0
  142. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-78jqtfZH.woff +0 -0
  143. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-italic-BbRnwouv.woff2 +0 -0
  144. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  145. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  146. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  147. package/studio/client-dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  148. package/studio/client-dist/assets/index-cxBc2iRv.js +10 -0
  149. package/studio/client-dist/assets/index-wjWuVHXN.css +1 -0
  150. package/studio/client-dist/assets/status-DUGxC7G7.js +1 -0
  151. package/studio/client-dist/assets/testCaseTree-Cl1-bc5O.js +1 -0
  152. package/studio/client-dist/axtr-icon.svg +2 -0
  153. package/studio/client-dist/favicon.svg +2 -0
  154. package/studio/client-dist/index.html +15 -0
  155. package/studio/server/dist/index.js +3176 -0
@@ -0,0 +1,204 @@
1
+ import { TERMINAL_STATUSES } from "./report-schema.js";
2
+ function entryById(suite, testCaseId) {
3
+ const entry = suite.entries.find((e) => e.testCaseId === testCaseId);
4
+ if (!entry)
5
+ throw new Error(`Suite "${suite.id}" has no entry for testCaseId "${testCaseId}"`);
6
+ return entry;
7
+ }
8
+ /**
9
+ * Kahn's algorithm over `dependsOn` edges. Throws on an unknown dependsOn id or a cycle --
10
+ * these must never be left to LLM judgment, unlike everything in mapping.ts's hints.
11
+ */
12
+ export function topologicalOrder(suite) {
13
+ const ids = new Set(suite.entries.map((e) => e.testCaseId));
14
+ for (const entry of suite.entries) {
15
+ for (const dep of entry.dependsOn) {
16
+ if (!ids.has(dep)) {
17
+ throw new Error(`Suite "${suite.id}": entry "${entry.testCaseId}" depends on unknown testCaseId "${dep}"`);
18
+ }
19
+ }
20
+ // A runIf's testCaseId must be a direct dependency: nextStep only evaluates runIf once every
21
+ // dependsOn has passed, so anything else would either be unresolved (not yet run) or -- if it
22
+ // were allowed to be a transitive-but-not-direct ancestor -- silently rely on ordering the
23
+ // schema doesn't actually guarantee here.
24
+ if (entry.runIf && !entry.dependsOn.includes(entry.runIf.testCaseId)) {
25
+ throw new Error(`Suite "${suite.id}": entry "${entry.testCaseId}"'s runIf references "${entry.runIf.testCaseId}", ` +
26
+ `which must be one of its own dependsOn`);
27
+ }
28
+ }
29
+ const inDegree = new Map();
30
+ for (const entry of suite.entries)
31
+ inDegree.set(entry.testCaseId, entry.dependsOn.length);
32
+ const dependents = new Map();
33
+ for (const entry of suite.entries) {
34
+ for (const dep of entry.dependsOn) {
35
+ dependents.set(dep, [...(dependents.get(dep) ?? []), entry.testCaseId]);
36
+ }
37
+ }
38
+ const queue = suite.entries.filter((e) => e.dependsOn.length === 0).map((e) => e.testCaseId);
39
+ const order = [];
40
+ while (queue.length > 0) {
41
+ const id = queue.shift();
42
+ order.push(id);
43
+ for (const dependent of dependents.get(id) ?? []) {
44
+ const remaining = inDegree.get(dependent) - 1;
45
+ inDegree.set(dependent, remaining);
46
+ if (remaining === 0)
47
+ queue.push(dependent);
48
+ }
49
+ }
50
+ if (order.length !== suite.entries.length) {
51
+ const stuck = suite.entries.map((e) => e.testCaseId).filter((id) => !order.includes(id));
52
+ throw new Error(`Suite "${suite.id}" has a dependency cycle involving: ${stuck.join(", ")}`);
53
+ }
54
+ return order;
55
+ }
56
+ /** Resolves a test case's declared `inputs` to concrete values for one suite run, following
57
+ * each `inputBindings` entry to a fixture, an earlier test case's captured output, or a
58
+ * literal. Throws a clear, specific error rather than silently defaulting -- a missing or
59
+ * not-yet-produced value should stop the run, not get guessed by the LLM. */
60
+ export function resolveInputs(entry, testCase, testRun, fixtures) {
61
+ const resolved = {};
62
+ for (const param of testCase.inputs) {
63
+ const binding = entry.inputBindings[param.name];
64
+ if (!binding) {
65
+ if (param.required) {
66
+ throw new Error(`Test case "${testCase.id}": no inputBindings entry for required param "${param.name}"`);
67
+ }
68
+ continue;
69
+ }
70
+ if (binding.kind === "fixture") {
71
+ // Ignoring case: a fixture's name is also its file name, so two names differing only in
72
+ // case can't coexist on Windows and are the same fixture.
73
+ const fixture = fixtures.find((f) => f.name.toLowerCase() === binding.fixtureName.toLowerCase());
74
+ if (!fixture) {
75
+ throw new Error(`Test case "${testCase.id}", param "${param.name}": no fixture named "${binding.fixtureName}" found`);
76
+ }
77
+ const values = fixture.testCases[testCase.id];
78
+ if (!values) {
79
+ throw new Error(`Test case "${testCase.id}", param "${param.name}": fixture "${binding.fixtureName}" has no values for this test case`);
80
+ }
81
+ const value = values[binding.param];
82
+ if (value === undefined) {
83
+ throw new Error(`Test case "${testCase.id}", param "${param.name}": fixture "${binding.fixtureName}" has no value for "${binding.param}"`);
84
+ }
85
+ resolved[param.name] = value;
86
+ }
87
+ else if (binding.kind === "testCaseOutput") {
88
+ const upstream = testRun.results[binding.testCaseId];
89
+ if (!upstream || upstream.status !== "passed") {
90
+ throw new Error(`Test case "${testCase.id}", param "${param.name}": upstream test case "${binding.testCaseId}" ` +
91
+ `has not passed yet (status: ${upstream?.status ?? "unknown"}) -- its output isn't available`);
92
+ }
93
+ const value = upstream.outputs[binding.outputName];
94
+ if (value === undefined) {
95
+ throw new Error(`Test case "${testCase.id}", param "${param.name}": upstream test case "${binding.testCaseId}" ` +
96
+ `did not capture an output named "${binding.outputName}"`);
97
+ }
98
+ resolved[param.name] = value;
99
+ }
100
+ else if (binding.kind === "literal") {
101
+ resolved[param.name] = binding.value;
102
+ }
103
+ else {
104
+ // "autogenerated" -- deliberately left out of `resolved`. There's no deterministic value to
105
+ // produce here; the orchestrating LLM invents one at replay time (see cmdResolveNext in
106
+ // index.ts, which diffs testCase.inputs against this function's return value to find the
107
+ // gap and surface it as `autogeneratedParams`).
108
+ }
109
+ }
110
+ return resolved;
111
+ }
112
+ function compareValues(comparator, actual, expected) {
113
+ switch (comparator) {
114
+ case "equals":
115
+ return actual === expected;
116
+ case "notEquals":
117
+ return actual !== expected;
118
+ case "contains":
119
+ return actual.includes(expected);
120
+ case "greaterThan":
121
+ return Number(actual) > Number(expected);
122
+ case "lessThan":
123
+ return Number(actual) < Number(expected);
124
+ }
125
+ }
126
+ /** Evaluates a `runIf` gate against the run so far. `condition.testCaseId` is guaranteed (by
127
+ * `topologicalOrder`'s validation) to be one of the entry's own `dependsOn`, so by the time this
128
+ * is called that upstream test case has already passed -- this only ever reads its captured
129
+ * *output*, never its status (see `RunCondition`'s doc comment in `suite-schema.ts` for why a
130
+ * status check would be vacuous here). An upstream that didn't capture the referenced output at
131
+ * all evaluates to false (skip), the same "missing means doesn't match" behavior a real value
132
+ * comparison would need anyway. */
133
+ export function evaluateRunCondition(condition, testRun) {
134
+ const actual = testRun.results[condition.testCaseId]?.outputs[condition.outputName];
135
+ if (actual === undefined)
136
+ return false;
137
+ return compareValues(condition.comparator, actual, condition.value);
138
+ }
139
+ function isRunnable(entry, testRun) {
140
+ return entry.dependsOn.every((dep) => testRun.results[dep]?.status === "passed");
141
+ }
142
+ /**
143
+ * Computes the next action for a suite run given its current TestRun:
144
+ * - If any pending entry now has a failed/blocked/skipped dependency, report it as newly-blocked
145
+ * so the caller can record that status before asking again (propagation is explicit, not
146
+ * silent) -- a skipped dependency counts the same as a failed one here, since nothing downstream
147
+ * of it can ever satisfy "every dependency passed" otherwise, which would leave it stuck waiting
148
+ * forever instead of reaching a terminal status.
149
+ * - Otherwise, if any pending-and-otherwise-runnable entry's `runIf` evaluates false, report it as
150
+ * newly-skipped the same way, before considering anything runnable.
151
+ * - Otherwise return every pending entry (topological order, for determinism) that's runnable and
152
+ * (if it has one) passes its `runIf` -- usually just one, but a suite whose DAG has two or more
153
+ * entries that share a satisfied dependency without depending on each other (e.g. two entries
154
+ * that both only depend on a common upstream entry) surfaces all of them at once here. `nextStep`
155
+ * itself makes no scheduling decision beyond exposing what's currently unblocked -- whether the
156
+ * caller works through `testCaseIds` one at a time or hands them to concurrent workers is
157
+ * entirely the caller's call; nothing here assumes either.
158
+ * - Otherwise, if every entry has a terminal status, the suite is complete.
159
+ * - Otherwise ("waiting"): nothing is pending-and-runnable right now, but something is still
160
+ * `"running"` -- a normal mid-flight state, not an error. A root entry (no dependencies) is
161
+ * always immediately runnable once pending, so this can only happen when every not-yet-terminal
162
+ * entry is already `"running"`.
163
+ */
164
+ export function nextStep(suite, testRun) {
165
+ const order = topologicalOrder(suite);
166
+ const newlyBlocked = order.filter((id) => {
167
+ const result = testRun.results[id];
168
+ if (!result || result.status !== "pending")
169
+ return false;
170
+ const entry = entryById(suite, id);
171
+ return entry.dependsOn.some((dep) => {
172
+ const depStatus = testRun.results[dep]?.status;
173
+ return depStatus === "failed" || depStatus === "blocked" || depStatus === "skipped";
174
+ });
175
+ });
176
+ if (newlyBlocked.length > 0)
177
+ return { status: "newly-blocked", blockedIds: newlyBlocked };
178
+ const newlySkipped = order.filter((id) => {
179
+ const result = testRun.results[id];
180
+ if (!result || result.status !== "pending")
181
+ return false;
182
+ const entry = entryById(suite, id);
183
+ if (!isRunnable(entry, testRun))
184
+ return false;
185
+ return entry.runIf !== undefined && !evaluateRunCondition(entry.runIf, testRun);
186
+ });
187
+ if (newlySkipped.length > 0)
188
+ return { status: "newly-skipped", skippedIds: newlySkipped };
189
+ const runnable = order.filter((id) => {
190
+ const result = testRun.results[id];
191
+ if (!result || result.status !== "pending")
192
+ return false;
193
+ const entry = entryById(suite, id);
194
+ if (!isRunnable(entry, testRun))
195
+ return false;
196
+ return entry.runIf === undefined || evaluateRunCondition(entry.runIf, testRun);
197
+ });
198
+ if (runnable.length > 0)
199
+ return { status: "runnable", testCaseIds: runnable };
200
+ const allTerminal = order.every((id) => TERMINAL_STATUSES.has(testRun.results[id]?.status ?? ""));
201
+ if (allTerminal)
202
+ return { status: "complete" };
203
+ return { status: "waiting" };
204
+ }
@@ -0,0 +1,59 @@
1
+ /** A TestSuite is a DAG of test cases: each entry declares which earlier entries must pass
2
+ * first (`dependsOn`) and where each of its declared inputs comes from (`inputBindings`) --
3
+ * a named fixture, an earlier test case's captured output, a literal value, or (deliberately no
4
+ * extra fields) `autogenerated`, which leaves the value for the orchestrating LLM to invent at
5
+ * replay time using the param's own `description`/`example` as guidance -- see
6
+ * `resolveInputs` (`suite-runner.ts`) and `cmdResolveNext` (`index.ts`) for how the gap is
7
+ * surfaced rather than resolved here. */
8
+ import type { Comparator } from "./testcase-schema.js";
9
+ export type InputBinding = {
10
+ kind: "fixture";
11
+ fixtureName: string;
12
+ param: string;
13
+ } | {
14
+ kind: "testCaseOutput";
15
+ testCaseId: string;
16
+ outputName: string;
17
+ } | {
18
+ kind: "literal";
19
+ value: string;
20
+ } | {
21
+ kind: "autogenerated";
22
+ };
23
+ /** Gates whether an entry runs at all, evaluated once its `dependsOn` have already passed --
24
+ * deliberately checked against a captured *output*, not status: by the time this is evaluated,
25
+ * `testCaseId` (which must be one of this entry's own `dependsOn`, enforced by
26
+ * `topologicalOrder`) is already guaranteed `"passed"`, so a status-based condition would always
27
+ * be vacuously true. Comparator semantics match `Expectation`'s (`testcase-schema.ts`). If the
28
+ * condition evaluates false, `nextStep` marks the entry `"skipped"` instead of running it -- see
29
+ * `suite-runner.ts`. No general branching/looping is supported; this is intentionally the
30
+ * smallest useful primitive (see ENHANCEMENTS.md's Area 7 for the scoping discussion). */
31
+ export interface RunCondition {
32
+ testCaseId: string;
33
+ outputName: string;
34
+ comparator: Comparator;
35
+ value: string;
36
+ }
37
+ export interface TestSuiteEntry {
38
+ testCaseId: string;
39
+ dependsOn: string[];
40
+ inputBindings: Record<string, InputBinding>;
41
+ /** Company (`companyId`) to pass on this entry's test case's *first* navigation call only --
42
+ * subsequent calls in the same test case inherit whatever company that navigation opened, and
43
+ * any mid-flow company jump (e.g. an intercompany order button) is picked up from the live
44
+ * `FormState.Company` rather than re-specified here. Omit for single-company suites. */
45
+ legalEntity?: string;
46
+ /** Optional conditional-skip gate -- see `RunCondition` above. Omit to always run once
47
+ * dependencies pass (the previous, only behavior). */
48
+ runIf?: RunCondition;
49
+ }
50
+ export interface TestSuite {
51
+ id: string;
52
+ name: string;
53
+ description: string;
54
+ entries: TestSuiteEntry[];
55
+ }
56
+ /** Validates a parsed JSON object against the TestSuite shape. Does not check for
57
+ * cycles or unknown dependsOn ids -- that's suite-runner.ts's job, since it needs the full set
58
+ * of entries to reason about the graph. */
59
+ export declare function validateTestSuite(value: unknown): TestSuite;
@@ -0,0 +1,95 @@
1
+ /** A TestSuite is a DAG of test cases: each entry declares which earlier entries must pass
2
+ * first (`dependsOn`) and where each of its declared inputs comes from (`inputBindings`) --
3
+ * a named fixture, an earlier test case's captured output, a literal value, or (deliberately no
4
+ * extra fields) `autogenerated`, which leaves the value for the orchestrating LLM to invent at
5
+ * replay time using the param's own `description`/`example` as guidance -- see
6
+ * `resolveInputs` (`suite-runner.ts`) and `cmdResolveNext` (`index.ts`) for how the gap is
7
+ * surfaced rather than resolved here. */
8
+ function fail(field, reason) {
9
+ throw new Error(`Invalid TestSuite: ${field} ${reason}`);
10
+ }
11
+ function assertString(value, field) {
12
+ if (typeof value !== "string" || value.length === 0)
13
+ fail(field, "must be a non-empty string");
14
+ }
15
+ function validateInputBinding(value, field) {
16
+ if (typeof value !== "object" || value === null)
17
+ fail(field, "must be an object");
18
+ const v = value;
19
+ if (v.kind === "fixture") {
20
+ assertString(v.fixtureName, `${field}.fixtureName`);
21
+ assertString(v.param, `${field}.param`);
22
+ }
23
+ else if (v.kind === "testCaseOutput") {
24
+ assertString(v.testCaseId, `${field}.testCaseId`);
25
+ assertString(v.outputName, `${field}.outputName`);
26
+ }
27
+ else if (v.kind === "literal") {
28
+ assertString(v.value, `${field}.value`);
29
+ }
30
+ else if (v.kind === "autogenerated") {
31
+ // no extra fields
32
+ }
33
+ else {
34
+ fail(`${field}.kind`, 'must be "fixture", "testCaseOutput", "literal", or "autogenerated"');
35
+ }
36
+ return v;
37
+ }
38
+ const VALID_COMPARATORS = ["equals", "notEquals", "contains", "greaterThan", "lessThan"];
39
+ function validateRunCondition(value, field) {
40
+ if (typeof value !== "object" || value === null)
41
+ fail(field, "must be an object");
42
+ const v = value;
43
+ assertString(v.testCaseId, `${field}.testCaseId`);
44
+ assertString(v.outputName, `${field}.outputName`);
45
+ if (!VALID_COMPARATORS.includes(v.comparator)) {
46
+ fail(`${field}.comparator`, `must be one of ${VALID_COMPARATORS.join(", ")}`);
47
+ }
48
+ assertString(v.value, `${field}.value`);
49
+ return v;
50
+ }
51
+ function validateTestSuiteEntry(value, index) {
52
+ if (typeof value !== "object" || value === null)
53
+ fail(`entries[${index}]`, "must be an object");
54
+ const v = value;
55
+ assertString(v.testCaseId, `entries[${index}].testCaseId`);
56
+ if (!Array.isArray(v.dependsOn) || !v.dependsOn.every((d) => typeof d === "string")) {
57
+ fail(`entries[${index}].dependsOn`, "must be an array of testCaseId strings");
58
+ }
59
+ if (typeof v.inputBindings !== "object" || v.inputBindings === null || Array.isArray(v.inputBindings)) {
60
+ fail(`entries[${index}].inputBindings`, "must be an object mapping param names to bindings");
61
+ }
62
+ const inputBindings = {};
63
+ for (const [param, binding] of Object.entries(v.inputBindings)) {
64
+ inputBindings[param] = validateInputBinding(binding, `entries[${index}].inputBindings.${param}`);
65
+ }
66
+ if (v.legalEntity !== undefined)
67
+ assertString(v.legalEntity, `entries[${index}].legalEntity`);
68
+ const runIf = v.runIf !== undefined ? validateRunCondition(v.runIf, `entries[${index}].runIf`) : undefined;
69
+ return {
70
+ testCaseId: v.testCaseId,
71
+ dependsOn: v.dependsOn,
72
+ inputBindings,
73
+ ...(v.legalEntity !== undefined ? { legalEntity: v.legalEntity } : {}),
74
+ ...(runIf !== undefined ? { runIf } : {}),
75
+ };
76
+ }
77
+ /** Validates a parsed JSON object against the TestSuite shape. Does not check for
78
+ * cycles or unknown dependsOn ids -- that's suite-runner.ts's job, since it needs the full set
79
+ * of entries to reason about the graph. */
80
+ export function validateTestSuite(value) {
81
+ if (typeof value !== "object" || value === null)
82
+ fail("suite", "must be an object");
83
+ const v = value;
84
+ assertString(v.id, "id");
85
+ assertString(v.name, "name");
86
+ assertString(v.description, "description");
87
+ if (!Array.isArray(v.entries))
88
+ fail("entries", "must be an array");
89
+ return {
90
+ id: v.id,
91
+ name: v.name,
92
+ description: v.description,
93
+ entries: v.entries.map(validateTestSuiteEntry),
94
+ };
95
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * A TestCase -- the atomic pass/fail unit -- wraps a testscript.json (the mechanics) with the
3
+ * layer that was missing: named inputs bound to specific steps/controls, outputs a later test
4
+ * case can consume, and expectations to check -- so the LLM never has to guess business data or
5
+ * judge pass/fail from vibes.
6
+ */
7
+ export type BindTarget = {
8
+ kind: "control";
9
+ stepSequence: number;
10
+ controlName: string;
11
+ } | {
12
+ kind: "grid-row-selector";
13
+ stepSequence: number;
14
+ note: string;
15
+ };
16
+ export interface InputParamSpec {
17
+ name: string;
18
+ description: string;
19
+ /** Recorded value from the source .axtr, if suggest-inputs drafted this from a PropertyUserAction
20
+ * or a grid-filter step's recorded gridColumnValue. */
21
+ example?: string;
22
+ /** The control's human-readable D365FO label (e.g. "Quantity"), if suggest-inputs drafted this
23
+ * from a PropertyUserAction whose recorded ControlLabel was non-empty. Distinct from `name`,
24
+ * which is the internal control identifier (e.g. "PurchLine_PurchQtyGrid"). */
25
+ controlLabel?: string;
26
+ required: boolean;
27
+ bindTo: BindTarget;
28
+ }
29
+ /**
30
+ * `filter` (and `Expectation`'s `filter`/`expectedValue` below) may contain `{{paramName}}`
31
+ * placeholders referencing this test case's resolved `inputs` (e.g. `CustomerAccount eq
32
+ * '{{customerAccount}}'`). This is deliberately plain string substitution the orchestrating LLM
33
+ * performs before calling `data_find_entities` -- not a templating engine in this codebase --
34
+ * consistent with trxa-tool computing only what must not be left to LLM judgment (DAG order,
35
+ * binding resolution) and leaving simple substitution to the orchestrator.
36
+ */
37
+ /**
38
+ * `uiField` needs at least one of `controlName`/`hint`:
39
+ * - `controlName` — the exact AOT control name to read off `FormState` at `afterStepSequence`.
40
+ * Free (no extra tool call -- the value is already in the response of a step being replayed
41
+ * anyway) and exactly reproducible. Prefer this whenever the control name is known.
42
+ * - `hint` — plain-English description of the value to find (e.g. "the newly assigned purchase
43
+ * order number"), for when the control name isn't known at authoring time -- common for values
44
+ * Task Recorder never captured because nobody clicked them (an auto-assigned ID shown after
45
+ * Save, say). The replaying LLM resolves this against the live `FormState` and is expected to
46
+ * write the resolved `controlName` back into this file, upgrading the hint to an exact match so
47
+ * future replays don't need to re-interpret `FormState` every time.
48
+ */
49
+ export type OutputCaptureSpec = {
50
+ name: string;
51
+ kind: "odata";
52
+ entitySet: string;
53
+ filter: string;
54
+ field: string;
55
+ } | {
56
+ name: string;
57
+ kind: "uiField";
58
+ afterStepSequence: number;
59
+ controlName?: string;
60
+ hint?: string;
61
+ };
62
+ export type Comparator = "equals" | "notEquals" | "contains" | "greaterThan" | "lessThan";
63
+ export type Expectation = {
64
+ description: string;
65
+ checkAfterStepSequence: number;
66
+ kind: "odata";
67
+ entitySet: string;
68
+ filter: string;
69
+ expectedField: string;
70
+ expectedValue: string;
71
+ comparator: Comparator;
72
+ } | {
73
+ description: string;
74
+ checkAfterStepSequence: number;
75
+ kind: "uiMessage";
76
+ severity: "Info" | "Warning" | "Error";
77
+ /** Plain substring match against a message's text (not a regex) -- checked against
78
+ * `FormState.Messages` at replay time. Empty string means "any message of this severity",
79
+ * e.g. `{ severity: "Error", pattern: "", shouldExist: false }` asserts no error occurred
80
+ * at all, regardless of its text. */
81
+ pattern: string;
82
+ /** Whether a message matching `severity`+`pattern` should be present (`true`) or absent
83
+ * (`false`) after the step -- not about whether a record/order exists. */
84
+ shouldExist: boolean;
85
+ };
86
+ export interface TestCase {
87
+ id: string;
88
+ name: string;
89
+ description: string;
90
+ /** Path to the testscript.json this test case replays, relative to the test case file. */
91
+ testScriptPath: string;
92
+ /** The source recording's folder under `recordings/` (e.g. "Accounts Payable/Purchase Orders"),
93
+ * captured at creation time so Studio's Test Cases list can group by it. Absent for a
94
+ * top-level recording or a test case created before this field existed -- both render under
95
+ * an "Ungrouped" bucket rather than being backfilled. */
96
+ sourceRecordingFolder?: string;
97
+ inputs: InputParamSpec[];
98
+ outputs: OutputCaptureSpec[];
99
+ expectations: Expectation[];
100
+ }
101
+ /** Validates a parsed JSON object against the TestCase shape, throwing a descriptive error on the first problem found. */
102
+ export declare function validateTestCase(value: unknown): TestCase;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * A TestCase -- the atomic pass/fail unit -- wraps a testscript.json (the mechanics) with the
3
+ * layer that was missing: named inputs bound to specific steps/controls, outputs a later test
4
+ * case can consume, and expectations to check -- so the LLM never has to guess business data or
5
+ * judge pass/fail from vibes.
6
+ */
7
+ function fail(field, reason) {
8
+ throw new Error(`Invalid TestCase: ${field} ${reason}`);
9
+ }
10
+ function assertString(value, field) {
11
+ if (typeof value !== "string" || value.length === 0)
12
+ fail(field, "must be a non-empty string");
13
+ }
14
+ function assertBindTarget(value, field) {
15
+ if (typeof value !== "object" || value === null)
16
+ fail(field, "must be an object");
17
+ const v = value;
18
+ if (typeof v.stepSequence !== "number")
19
+ fail(`${field}.stepSequence`, "must be a number");
20
+ if (v.kind === "control") {
21
+ assertString(v.controlName, `${field}.controlName`);
22
+ }
23
+ else if (v.kind === "grid-row-selector") {
24
+ assertString(v.note, `${field}.note`);
25
+ }
26
+ else {
27
+ fail(`${field}.kind`, 'must be "control" or "grid-row-selector"');
28
+ }
29
+ }
30
+ function validateInputParamSpec(value, index) {
31
+ if (typeof value !== "object" || value === null)
32
+ fail(`inputs[${index}]`, "must be an object");
33
+ const v = value;
34
+ assertString(v.name, `inputs[${index}].name`);
35
+ assertString(v.description, `inputs[${index}].description`);
36
+ if (typeof v.required !== "boolean")
37
+ fail(`inputs[${index}].required`, "must be a boolean");
38
+ assertBindTarget(v.bindTo, `inputs[${index}].bindTo`);
39
+ if (v.example !== undefined)
40
+ assertString(v.example, `inputs[${index}].example`);
41
+ if (v.controlLabel !== undefined)
42
+ assertString(v.controlLabel, `inputs[${index}].controlLabel`);
43
+ return v;
44
+ }
45
+ function validateOutputCaptureSpec(value, index) {
46
+ if (typeof value !== "object" || value === null)
47
+ fail(`outputs[${index}]`, "must be an object");
48
+ const v = value;
49
+ assertString(v.name, `outputs[${index}].name`);
50
+ if (v.kind === "odata") {
51
+ assertString(v.entitySet, `outputs[${index}].entitySet`);
52
+ assertString(v.filter, `outputs[${index}].filter`);
53
+ assertString(v.field, `outputs[${index}].field`);
54
+ }
55
+ else if (v.kind === "uiField") {
56
+ if (typeof v.afterStepSequence !== "number") {
57
+ fail(`outputs[${index}].afterStepSequence`, "must be a number");
58
+ }
59
+ if (v.controlName !== undefined && typeof v.controlName !== "string") {
60
+ fail(`outputs[${index}].controlName`, "must be a string");
61
+ }
62
+ if (v.hint !== undefined && typeof v.hint !== "string") {
63
+ fail(`outputs[${index}].hint`, "must be a string");
64
+ }
65
+ if (!v.controlName && !v.hint) {
66
+ fail(`outputs[${index}]`, "must have a non-empty controlName or hint");
67
+ }
68
+ }
69
+ else {
70
+ fail(`outputs[${index}].kind`, 'must be "odata" or "uiField"');
71
+ }
72
+ return v;
73
+ }
74
+ function validateExpectation(value, index) {
75
+ if (typeof value !== "object" || value === null) {
76
+ fail(`expectations[${index}]`, "must be an object");
77
+ }
78
+ const v = value;
79
+ assertString(v.description, `expectations[${index}].description`);
80
+ if (typeof v.checkAfterStepSequence !== "number") {
81
+ fail(`expectations[${index}].checkAfterStepSequence`, "must be a number");
82
+ }
83
+ if (v.kind === "odata") {
84
+ assertString(v.entitySet, `expectations[${index}].entitySet`);
85
+ assertString(v.filter, `expectations[${index}].filter`);
86
+ assertString(v.expectedField, `expectations[${index}].expectedField`);
87
+ assertString(v.expectedValue, `expectations[${index}].expectedValue`);
88
+ assertString(v.comparator, `expectations[${index}].comparator`);
89
+ }
90
+ else if (v.kind === "uiMessage") {
91
+ assertString(v.severity, `expectations[${index}].severity`);
92
+ // Empty string is valid here (means "any message of this severity") -- assertString would
93
+ // reject it, since every other string field in this schema treats empty as missing.
94
+ if (typeof v.pattern !== "string")
95
+ fail(`expectations[${index}].pattern`, "must be a string");
96
+ if (typeof v.shouldExist !== "boolean") {
97
+ fail(`expectations[${index}].shouldExist`, "must be a boolean");
98
+ }
99
+ }
100
+ else {
101
+ fail(`expectations[${index}].kind`, 'must be "odata" or "uiMessage"');
102
+ }
103
+ return v;
104
+ }
105
+ /** Validates a parsed JSON object against the TestCase shape, throwing a descriptive error on the first problem found. */
106
+ export function validateTestCase(value) {
107
+ if (typeof value !== "object" || value === null)
108
+ fail("testcase", "must be an object");
109
+ const v = value;
110
+ assertString(v.id, "id");
111
+ assertString(v.name, "name");
112
+ assertString(v.description, "description");
113
+ assertString(v.testScriptPath, "testScriptPath");
114
+ if (v.sourceRecordingFolder !== undefined)
115
+ assertString(v.sourceRecordingFolder, "sourceRecordingFolder");
116
+ if (!Array.isArray(v.inputs))
117
+ fail("inputs", "must be an array");
118
+ if (!Array.isArray(v.outputs))
119
+ fail("outputs", "must be an array");
120
+ if (!Array.isArray(v.expectations))
121
+ fail("expectations", "must be an array");
122
+ return {
123
+ id: v.id,
124
+ name: v.name,
125
+ description: v.description,
126
+ testScriptPath: v.testScriptPath,
127
+ ...(v.sourceRecordingFolder !== undefined ? { sourceRecordingFolder: v.sourceRecordingFolder } : {}),
128
+ inputs: v.inputs.map(validateInputParamSpec),
129
+ outputs: v.outputs.map(validateOutputCaptureSpec),
130
+ expectations: v.expectations.map(validateExpectation),
131
+ };
132
+ }
@@ -0,0 +1,38 @@
1
+ /** Measures real per-turn token usage from Claude Code's own session transcript, instead of
2
+ * relying on the orchestrating LLM to self-report a guess (`record-result --tokens` used to be
3
+ * guesswork with no ground truth -- see report-schema.ts). Claude Code logs a `usage` object
4
+ * (`input_tokens`/`output_tokens`/`cache_creation_input_tokens`/`cache_read_input_tokens`) on
5
+ * every assistant turn to `~/.claude/projects/<project>/<session>.jsonl` -- the same data
6
+ * community tools like ccusage read to produce cost reports.
7
+ *
8
+ * CONFIRMED UNRELIABLE with concurrent sessions (live test, 2026-08-20): running one headless
9
+ * suite replay alongside one sibling session sharing this project directory inflated the
10
+ * measured total by +24%; running it alongside two concurrent siblings inflated it by +98% (the
11
+ * reported number was nearly double the true one, cross-checked against each headless
12
+ * invocation's own authoritative `total_cost_usd`/`usage`). This is not a rare edge case -- it's
13
+ * close to the default way headless mode gets used (something running in the background while
14
+ * other work continues, or multiple suites run in parallel, exactly what triggered it here).
15
+ * Treat `tokensUsed` as a rough order-of-magnitude signal, not a trustworthy number, whenever more
16
+ * than one Claude Code session might have touched this project directory during the measurement
17
+ * window. See `findLatestTranscript`'s own comment for why there's currently no clean fix. */
18
+ export declare function defaultClaudeProjectsRoot(): string;
19
+ /** Recursively finds every `.jsonl` file under `root`, returning the most recently modified one.
20
+ * Claude Code appends to the active session's transcript on every turn, so "most recently
21
+ * modified across ~/.claude/projects" is a *rough* stand-in for "this session's own transcript" --
22
+ * but it's a race, not a guarantee: any sibling session that happens to write a turn more recently
23
+ * wins, and a fast-writing sibling (e.g. another suite replaying in parallel, one turn every few
24
+ * seconds) wins that race constantly. Live-tested 2026-08-20: this measurably picked up unrelated
25
+ * sessions' usage, inflating results by 24%-98% depending on how much sibling activity overlapped
26
+ * the measurement window (see token-usage.ts's top-of-file comment for the numbers). There is
27
+ * currently no clean fix available here -- Claude Code doesn't expose a session/transcript
28
+ * identifier to Bash-tool subprocesses (open upstream feature requests, e.g.
29
+ * anthropics/claude-code#18629, unresolved as of this writing), so "newest file" is the best
30
+ * available signal without that, not a reliable one. Not worth chasing a fancier heuristic here
31
+ * until that's exposed -- a heuristic can narrow the odds of picking wrong, never eliminate them,
32
+ * and the honest fix is a real identifier, not more machinery layered on a guess. */
33
+ export declare function findLatestTranscript(root: string): string | null;
34
+ /** Sums real token usage for every assistant turn in `transcriptPath` whose `timestamp` falls
35
+ * within [`sinceIso`, `untilIso`]. Still an approximation -- it attributes tokens by wall-clock
36
+ * overlap with a test case's execution window, not a verified causal link -- but it's a measured
37
+ * number from Claude Code's own log, not a self-reported guess. */
38
+ export declare function measureTokens(transcriptPath: string, sinceIso: string, untilIso: string): number;