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,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;
|