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,27 @@
1
+ /**
2
+ * A TestPlan groups multiple independent Test Suites for combined scheduling/reporting -- e.g.
3
+ * a whole "Accounts Receivable" testing cycle spanning several suites. Deliberately flat: no
4
+ * dependency graph and no input bindings across the suite boundary. That's what a TestSuite's
5
+ * `dependsOn`/`inputBindings` already does *within* one suite; if two suites genuinely need to
6
+ * share data, they were never really two suites -- merge their test cases into one suite
7
+ * instead. A TestPlan only composes already-independently-runnable suites for reporting.
8
+ *
9
+ * This matches TestRail's actual "Test Plan" concept (a plan bundles multiple suites/runs for
10
+ * one release) rather than suite-of-suites nesting.
11
+ */
12
+ export interface TestPlanEntry {
13
+ suiteId: string;
14
+ /** Path to the suite's JSON file, relative to this plan file. */
15
+ suitePath: string;
16
+ }
17
+ export interface TestPlan {
18
+ id: string;
19
+ name: string;
20
+ description: string;
21
+ /** Array order is the suggested run order; no dependsOn, no bindings across suites. */
22
+ suites: TestPlanEntry[];
23
+ }
24
+ /** Validates a parsed JSON object against the TestPlan shape. Does not check that referenced
25
+ * suite files actually exist or are themselves valid -- that's the `validate-plan` CLI
26
+ * command's job, since it needs filesystem access this pure function doesn't have. */
27
+ export declare function validateTestPlan(value: unknown): TestPlan;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A TestPlan groups multiple independent Test Suites for combined scheduling/reporting -- e.g.
3
+ * a whole "Accounts Receivable" testing cycle spanning several suites. Deliberately flat: no
4
+ * dependency graph and no input bindings across the suite boundary. That's what a TestSuite's
5
+ * `dependsOn`/`inputBindings` already does *within* one suite; if two suites genuinely need to
6
+ * share data, they were never really two suites -- merge their test cases into one suite
7
+ * instead. A TestPlan only composes already-independently-runnable suites for reporting.
8
+ *
9
+ * This matches TestRail's actual "Test Plan" concept (a plan bundles multiple suites/runs for
10
+ * one release) rather than suite-of-suites nesting.
11
+ */
12
+ function fail(field, reason) {
13
+ throw new Error(`Invalid TestPlan: ${field} ${reason}`);
14
+ }
15
+ function assertString(value, field) {
16
+ if (typeof value !== "string" || value.length === 0)
17
+ fail(field, "must be a non-empty string");
18
+ }
19
+ function validateTestPlanEntry(value, index) {
20
+ if (typeof value !== "object" || value === null)
21
+ fail(`suites[${index}]`, "must be an object");
22
+ const v = value;
23
+ assertString(v.suiteId, `suites[${index}].suiteId`);
24
+ assertString(v.suitePath, `suites[${index}].suitePath`);
25
+ return { suiteId: v.suiteId, suitePath: v.suitePath };
26
+ }
27
+ /** Validates a parsed JSON object against the TestPlan shape. Does not check that referenced
28
+ * suite files actually exist or are themselves valid -- that's the `validate-plan` CLI
29
+ * command's job, since it needs filesystem access this pure function doesn't have. */
30
+ export function validateTestPlan(value) {
31
+ if (typeof value !== "object" || value === null)
32
+ fail("plan", "must be an object");
33
+ const v = value;
34
+ assertString(v.id, "id");
35
+ assertString(v.name, "name");
36
+ assertString(v.description, "description");
37
+ if (!Array.isArray(v.suites))
38
+ fail("suites", "must be an array");
39
+ return {
40
+ id: v.id,
41
+ name: v.name,
42
+ description: v.description,
43
+ suites: v.suites.map(validateTestPlanEntry),
44
+ };
45
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A PlanRun is the thin, persisted record that gives a Test Plan's execution a real identity --
3
+ * `{id, planId, startedAt, suiteIds}`. The plan stays the anchor and the run a dated instance of
4
+ * it, but a run can also carry an optional `name` of its own. People refer to a run by what it
5
+ * tested ("Accounts Payable regression, PQU 10.0.48"), not by its start time, and TestRail and
6
+ * Azure DevOps Test Plans both let a run be named for that reason. The name never replaces the
7
+ * plan's identity: `planId` still says which plan it ran.
8
+ *
9
+ * `suiteIds` is a snapshot of the plan's suite composition at the moment the run started --
10
+ * confirmed live that a PlanRun without one breaks: adding "create-new-vendor" as a second suite
11
+ * to the Accounts Payable plan made an already-completed, all-passed run retroactively show as
12
+ * "Missing runs" in Studio, because status was being recomputed against the *current* plan's
13
+ * suite list instead of whatever suites existed when that run actually happened. A run is a
14
+ * historical record; it must not change shape because the plan changed after the fact. See
15
+ * plan-runner.ts's `aggregatePlanStatus`, which reads this snapshot instead of `TestPlan.suites`.
16
+ *
17
+ * `suiteNames` extends the same principle to display: it snapshots each suite's human-readable
18
+ * `name` (suiteId -> name) at that same moment, so Studio can show "Create new vendor" instead of
19
+ * "create-new-vendor" without a live lookup against the suite file -- which, per the same
20
+ * suiteIds rationale, could have been renamed or deleted by the time someone views an old run.
21
+ *
22
+ * Suite-level TestRun files (report-schema.ts) are unchanged by this -- a PlanRun's `id` is the
23
+ * same string already used as the `<runId>` suffix in `testruns/<suiteId>-<runId>.json`. This
24
+ * file's only job is to make that id a real, validated, directly-queryable record instead of an
25
+ * unvalidated convention nobody checks.
26
+ */
27
+ export interface PlanRun {
28
+ id: string;
29
+ planId: string;
30
+ startedAt: string;
31
+ suiteIds: string[];
32
+ suiteNames: Record<string, string>;
33
+ /** What people call this run, e.g. "Accounts Payable regression, PQU 10.0.48". Optional: a run
34
+ * without one is shown by its plan's name and start time. */
35
+ name?: string;
36
+ }
37
+ /** Longest run name accepted, so a pasted paragraph can't end up as a list label. */
38
+ export declare const RUN_NAME_MAX_LENGTH = 120;
39
+ /** Trims a run name and checks its length; an empty name means "no name". Shared by the plan run
40
+ * and test run schemas, the CLI's `--name` flag and Studio's rename endpoints. */
41
+ export declare function normalizeRunName(value: unknown, field?: string): string | undefined;
42
+ /** Colons aren't legal in Windows filenames, so `id` is `startedAt` with them swapped for dashes
43
+ * -- still lexically sortable, still human-readable, and trivially derivable from `startedAt`
44
+ * rather than needing a separate id-generation scheme. A caller never invents this string
45
+ * themselves (see `planrun.ts`'s `initPlanRun`), which is the whole point: `runId` used to be an
46
+ * arbitrary, unvalidated string a human or LLM picked by hand. `suites` is captured from the plan
47
+ * as it exists right now, at creation time -- never re-derived later -- and split into the two
48
+ * snapshot fields above. */
49
+ export declare function createPlanRun(planId: string, suites: {
50
+ id: string;
51
+ name: string;
52
+ }[], name?: string): PlanRun;
53
+ export declare function validatePlanRun(value: unknown): PlanRun;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A PlanRun is the thin, persisted record that gives a Test Plan's execution a real identity --
3
+ * `{id, planId, startedAt, suiteIds}`. The plan stays the anchor and the run a dated instance of
4
+ * it, but a run can also carry an optional `name` of its own. People refer to a run by what it
5
+ * tested ("Accounts Payable regression, PQU 10.0.48"), not by its start time, and TestRail and
6
+ * Azure DevOps Test Plans both let a run be named for that reason. The name never replaces the
7
+ * plan's identity: `planId` still says which plan it ran.
8
+ *
9
+ * `suiteIds` is a snapshot of the plan's suite composition at the moment the run started --
10
+ * confirmed live that a PlanRun without one breaks: adding "create-new-vendor" as a second suite
11
+ * to the Accounts Payable plan made an already-completed, all-passed run retroactively show as
12
+ * "Missing runs" in Studio, because status was being recomputed against the *current* plan's
13
+ * suite list instead of whatever suites existed when that run actually happened. A run is a
14
+ * historical record; it must not change shape because the plan changed after the fact. See
15
+ * plan-runner.ts's `aggregatePlanStatus`, which reads this snapshot instead of `TestPlan.suites`.
16
+ *
17
+ * `suiteNames` extends the same principle to display: it snapshots each suite's human-readable
18
+ * `name` (suiteId -> name) at that same moment, so Studio can show "Create new vendor" instead of
19
+ * "create-new-vendor" without a live lookup against the suite file -- which, per the same
20
+ * suiteIds rationale, could have been renamed or deleted by the time someone views an old run.
21
+ *
22
+ * Suite-level TestRun files (report-schema.ts) are unchanged by this -- a PlanRun's `id` is the
23
+ * same string already used as the `<runId>` suffix in `testruns/<suiteId>-<runId>.json`. This
24
+ * file's only job is to make that id a real, validated, directly-queryable record instead of an
25
+ * unvalidated convention nobody checks.
26
+ */
27
+ /** Longest run name accepted, so a pasted paragraph can't end up as a list label. */
28
+ export const RUN_NAME_MAX_LENGTH = 120;
29
+ /** Trims a run name and checks its length; an empty name means "no name". Shared by the plan run
30
+ * and test run schemas, the CLI's `--name` flag and Studio's rename endpoints. */
31
+ export function normalizeRunName(value, field = "name") {
32
+ if (value === undefined || value === null)
33
+ return undefined;
34
+ if (typeof value !== "string")
35
+ throw new Error(`Invalid run name: ${field} must be a string`);
36
+ const name = value.trim().replace(/\s+/g, " ");
37
+ if (name.length > RUN_NAME_MAX_LENGTH) {
38
+ throw new Error(`Invalid run name: ${field} must be at most ${RUN_NAME_MAX_LENGTH} characters`);
39
+ }
40
+ return name || undefined;
41
+ }
42
+ /** Colons aren't legal in Windows filenames, so `id` is `startedAt` with them swapped for dashes
43
+ * -- still lexically sortable, still human-readable, and trivially derivable from `startedAt`
44
+ * rather than needing a separate id-generation scheme. A caller never invents this string
45
+ * themselves (see `planrun.ts`'s `initPlanRun`), which is the whole point: `runId` used to be an
46
+ * arbitrary, unvalidated string a human or LLM picked by hand. `suites` is captured from the plan
47
+ * as it exists right now, at creation time -- never re-derived later -- and split into the two
48
+ * snapshot fields above. */
49
+ export function createPlanRun(planId, suites, name) {
50
+ const startedAt = new Date().toISOString();
51
+ const runName = normalizeRunName(name);
52
+ return {
53
+ id: startedAt.replace(/:/g, "-"),
54
+ planId,
55
+ startedAt,
56
+ suiteIds: suites.map((s) => s.id),
57
+ suiteNames: Object.fromEntries(suites.map((s) => [s.id, s.name])),
58
+ ...(runName ? { name: runName } : {}),
59
+ };
60
+ }
61
+ function fail(field, reason) {
62
+ throw new Error(`Invalid PlanRun: ${field} ${reason}`);
63
+ }
64
+ function assertString(value, field) {
65
+ if (typeof value !== "string" || value.length === 0)
66
+ fail(field, "must be a non-empty string");
67
+ }
68
+ /** Missing `suiteIds` (a PlanRun persisted before this field existed) defaults to an empty
69
+ * snapshot rather than failing validation outright -- an old run still loads and displays, it
70
+ * just can't show a per-suite breakdown, which is honest given the data actually on disk. */
71
+ function assertSuiteIds(value) {
72
+ if (value === undefined)
73
+ return [];
74
+ if (!Array.isArray(value))
75
+ fail("suiteIds", "must be an array of strings");
76
+ for (const [i, entry] of value.entries())
77
+ assertString(entry, `suiteIds[${i}]`);
78
+ return value;
79
+ }
80
+ /** Same missing-field tolerance as `assertSuiteIds`, for the same reason -- a PlanRun persisted
81
+ * before `suiteNames` existed defaults to an empty map; callers fall back to the raw suiteId for
82
+ * display rather than failing to load the run at all. */
83
+ function assertSuiteNames(value) {
84
+ if (value === undefined)
85
+ return {};
86
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
87
+ fail("suiteNames", "must be an object mapping suiteId to name");
88
+ }
89
+ const v = value;
90
+ for (const [id, name] of Object.entries(v))
91
+ assertString(name, `suiteNames.${id}`);
92
+ return v;
93
+ }
94
+ export function validatePlanRun(value) {
95
+ if (typeof value !== "object" || value === null)
96
+ fail("planRun", "must be an object");
97
+ const v = value;
98
+ assertString(v.id, "id");
99
+ assertString(v.planId, "planId");
100
+ assertString(v.startedAt, "startedAt");
101
+ const suiteIds = assertSuiteIds(v.suiteIds);
102
+ const suiteNames = assertSuiteNames(v.suiteNames);
103
+ let name;
104
+ try {
105
+ name = normalizeRunName(v.name);
106
+ }
107
+ catch (err) {
108
+ fail("name", err.message.replace(/^Invalid run name: name /, ""));
109
+ }
110
+ return { id: v.id, planId: v.planId, startedAt: v.startedAt, suiteIds, suiteNames, ...(name ? { name } : {}) };
111
+ }
@@ -0,0 +1,20 @@
1
+ import { type PlanRun } from "./planrun-schema.js";
2
+ export declare function readPlanRun(filePath: string): Promise<PlanRun>;
3
+ /** Every PlanRun file in `dir`, optionally filtered to one plan, newest-started first. Shared by
4
+ * the CLI and Studio server so the two can't drift on what "list the runs for this plan" means.
5
+ * Silently skips a file that fails to parse/validate rather than throwing -- one malformed run
6
+ * shouldn't take down the whole list, same tolerance `testruns.ts`'s listing already has. */
7
+ export declare function listPlanRuns(dir: string, planId?: string): Promise<PlanRun[]>;
8
+ /** Creates and persists a fresh PlanRun into `dir`, auto-naming the file from the generated id --
9
+ * the caller supplies a directory, not a filename, so there's no chance of inventing an arbitrary
10
+ * runId string the way `init-run`'s own `-o <path>` always required. `suites` must be the calling
11
+ * plan's current suites (id + name) -- captured once, here, so this run's status/display never
12
+ * drifts if the plan's suites are later added, removed, or renamed (see planrun-schema.ts's
13
+ * `PlanRun.suiteIds`/`suiteNames`). */
14
+ export declare function initPlanRun(planId: string, suites: {
15
+ id: string;
16
+ name: string;
17
+ }[], dir: string, name?: string): Promise<{
18
+ planRun: PlanRun;
19
+ filePath: string;
20
+ }>;
@@ -0,0 +1,46 @@
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createPlanRun, validatePlanRun } from "./planrun-schema.js";
4
+ export async function readPlanRun(filePath) {
5
+ const raw = await readFile(filePath, "utf-8");
6
+ return validatePlanRun(JSON.parse(raw));
7
+ }
8
+ /** Every PlanRun file in `dir`, optionally filtered to one plan, newest-started first. Shared by
9
+ * the CLI and Studio server so the two can't drift on what "list the runs for this plan" means.
10
+ * Silently skips a file that fails to parse/validate rather than throwing -- one malformed run
11
+ * shouldn't take down the whole list, same tolerance `testruns.ts`'s listing already has. */
12
+ export async function listPlanRuns(dir, planId) {
13
+ let files;
14
+ try {
15
+ files = (await readdir(dir)).filter((f) => f.endsWith(".json"));
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ const runs = [];
21
+ for (const file of files) {
22
+ try {
23
+ const run = await readPlanRun(path.join(dir, file));
24
+ if (!planId || run.planId === planId)
25
+ runs.push(run);
26
+ }
27
+ catch {
28
+ // Skip malformed/unrelated files.
29
+ }
30
+ }
31
+ runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
32
+ return runs;
33
+ }
34
+ /** Creates and persists a fresh PlanRun into `dir`, auto-naming the file from the generated id --
35
+ * the caller supplies a directory, not a filename, so there's no chance of inventing an arbitrary
36
+ * runId string the way `init-run`'s own `-o <path>` always required. `suites` must be the calling
37
+ * plan's current suites (id + name) -- captured once, here, so this run's status/display never
38
+ * drifts if the plan's suites are later added, removed, or renamed (see planrun-schema.ts's
39
+ * `PlanRun.suiteIds`/`suiteNames`). */
40
+ export async function initPlanRun(planId, suites, dir, name) {
41
+ const planRun = createPlanRun(planId, suites, name);
42
+ const filePath = path.join(dir, `${planRun.id}.json`);
43
+ await mkdir(dir, { recursive: true });
44
+ await writeFile(filePath, JSON.stringify(planRun, null, 2), "utf-8");
45
+ return { planRun, filePath };
46
+ }
@@ -0,0 +1,92 @@
1
+ /** Persisted, durable record of a suite run -- this is the mechanism that answers "keep track
2
+ * of each test case, expected input, and actual output" instead of relying on chat memory. */
3
+ /** "skipped" is assigned automatically by `nextStep`/`cmdResolveNext` when an entry's `runIf`
4
+ * (`suite-schema.ts`) evaluates false -- never something the orchestrating LLM passes to
5
+ * `record-result` manually, same as "blocked". Neither a pass nor a failure: a downstream entry
6
+ * that `dependsOn` a skipped one is propagated to "blocked" (see `nextStep`'s `newlyBlocked`
7
+ * check), not left waiting forever. */
8
+ export type TestCaseStatus = "pending" | "running" | "passed" | "failed" | "blocked" | "skipped";
9
+ /** Statuses a test case never leaves once reached -- used by both `suite-runner.ts` (to decide
10
+ * whether a whole suite run is done) and `plan-runner.ts` (to roll that up across suites).
11
+ * Exported from here, not duplicated in each, so the two can't drift out of sync with each other
12
+ * or with `TestCaseStatus` above. Kept as `Set<string>` rather than `Set<TestCaseStatus>` so
13
+ * callers can `.has()` a possibly-missing status (e.g. `result?.status ?? ""`) without a type
14
+ * error. */
15
+ export declare const TERMINAL_STATUSES: ReadonlySet<string>;
16
+ export interface AssertionResult {
17
+ description: string;
18
+ passed: boolean;
19
+ /** What was actually observed (an OData field value, or the matched/missing UI message). */
20
+ actual: string;
21
+ }
22
+ export interface TestCaseResult {
23
+ status: TestCaseStatus;
24
+ outputs: Record<string, string>;
25
+ assertionResults: AssertionResult[];
26
+ /** Run order this test case actually started in (1-based), assigned once by `resolve-next` the
27
+ * first time it transitions to "running" -- not the suite's declared entry order, which can
28
+ * differ once a DAG has parallel-eligible branches. */
29
+ sequence?: number;
30
+ /** Resolved input values this test case actually ran with -- captured automatically from
31
+ * `resolveInputs`'s output when `resolve-next` marks the test case "running" (deterministic
32
+ * bindings only; doesn't include values invented for `"autogenerated"` bindings, since the CLI
33
+ * never sees those). `record-result` may pass a fuller set (including autogenerated values the
34
+ * orchestrating LLM invented) to override this. */
35
+ inputs?: Record<string, string>;
36
+ /** ISO timestamp captured automatically when `resolve-next` marks this test case "running". */
37
+ startedAt?: string;
38
+ /** Milliseconds between `startedAt` and this result reaching a terminal status -- computed
39
+ * automatically by `recordResult`, not supplied by a caller. */
40
+ durationMs?: number;
41
+ /** Measured, not self-reported, via `record-result --tokens` -- but treat it as a rough
42
+ * order-of-magnitude signal, not a trustworthy number. This CLI has no way to observe token
43
+ * usage itself, so `token-usage.ts`'s `measure-tokens` sources it from Claude Code's own local
44
+ * session transcript (`~/.claude/projects/.../<session>.jsonl`). CONFIRMED UNRELIABLE whenever
45
+ * more than one Claude Code session might be active against this project directory during the
46
+ * measurement window -- live-tested 2026-08-20 running suites in parallel: +24% to +98% (nearly
47
+ * 2x) inflation from cross-session misattribution, cross-checked against each session's own
48
+ * authoritative usage total. That's not a rare edge case; it's close to the default way anyone
49
+ * actually uses this (parallel suites, or a Desktop session open alongside a background job).
50
+ * See `token-usage.ts` for why there's no clean fix available today. Also deliberately a
51
+ * Claude-Code-only bonus, not a core requirement: this whole pipeline (`resolve-next`/
52
+ * `record-result`/the replay skill's instructions) is meant to work with any sufficiently
53
+ * capable agentic LLM driving it, and nothing about pass/fail or captured outputs depends on
54
+ * this field. Running under any other agent -- or under Claude Cowork, which executes on
55
+ * Anthropic's own servers rather than the local machine -- simply leaves it unset, since
56
+ * there's no local session transcript to read either way; that's expected, not a bug. */
57
+ tokensUsed?: number;
58
+ /** Cost in USD for this test case, when the host environment can report one directly (e.g. a
59
+ * future headless `claude -p --output-format json` invocation exposes `total_cost_usd`).
60
+ * Deliberately separate from `tokensUsed`: a $ figure needs a pricing table keyed by model and
61
+ * cache-hit/miss that changes over time, which this CLI has no business maintaining -- so this
62
+ * field exists for whichever layer *does* have an authoritative cost to report, and is left
63
+ * unset otherwise rather than computed from `tokensUsed` with a guessed price. */
64
+ costUsd?: number;
65
+ /** Raw `FormState.Messages` text captured by the orchestrating LLM via `record-result
66
+ * --messages` -- the D365FO-side evidence for what actually happened, distinct from
67
+ * `assertionResults`' pass/fail judgments. A single entry's text can carry a tab-delimited
68
+ * hierarchy (confirmed live: posting blocked by credit management returned one `Warning` as
69
+ * `"Posting\tPosting\tSales order: 000963\tOrder is in credit management."`) -- this is the
70
+ * same content D365FO's web client renders as an indented "Message details" tree; there's no
71
+ * separate data source behind that flyout. The skill converts tabs to `" > "` before passing
72
+ * this flag so the hierarchy survives instead of collapsing into a run-on sentence. */
73
+ messages?: string[];
74
+ }
75
+ export interface TestRun {
76
+ /** The suite's id -- or, for a `scope: "testcase"` run, the test case's id. */
77
+ suiteId: string;
78
+ startedAt: string;
79
+ results: Record<string, TestCaseResult>;
80
+ /** What this run was started for. Absent on runs the CLI created for a suite (the original
81
+ * behavior). `testcase` marks a run of one test case on its own, with no suite around it. */
82
+ scope?: "suite" | "testcase";
83
+ /** The Studio agent run executing this record, when one created it. */
84
+ agentRunId?: string;
85
+ /** What people call this run, e.g. "Vendor onboarding, PQU 10.0.48". Optional; a suite run that
86
+ * belongs to a plan run is named by its PlanRun instead. See planrun-schema.ts's `name`. */
87
+ name?: string;
88
+ }
89
+ /** Creates a fresh TestRun with every suite entry pre-populated as "pending". */
90
+ export declare function createTestRun(suiteId: string, testCaseIds: string[], extra?: Pick<TestRun, "scope" | "agentRunId" | "name">): TestRun;
91
+ /** Validates a parsed JSON object against the TestRun shape. */
92
+ export declare function validateTestRun(value: unknown): TestRun;
@@ -0,0 +1,115 @@
1
+ import { normalizeRunName } from "./planrun-schema.js";
2
+ /** Statuses a test case never leaves once reached -- used by both `suite-runner.ts` (to decide
3
+ * whether a whole suite run is done) and `plan-runner.ts` (to roll that up across suites).
4
+ * Exported from here, not duplicated in each, so the two can't drift out of sync with each other
5
+ * or with `TestCaseStatus` above. Kept as `Set<string>` rather than `Set<TestCaseStatus>` so
6
+ * callers can `.has()` a possibly-missing status (e.g. `result?.status ?? ""`) without a type
7
+ * error. */
8
+ export const TERMINAL_STATUSES = new Set(["passed", "failed", "blocked", "skipped"]);
9
+ /** Creates a fresh TestRun with every suite entry pre-populated as "pending". */
10
+ export function createTestRun(suiteId, testCaseIds, extra = {}) {
11
+ const name = normalizeRunName(extra.name);
12
+ const results = {};
13
+ for (const id of testCaseIds) {
14
+ results[id] = { status: "pending", outputs: {}, assertionResults: [] };
15
+ }
16
+ return {
17
+ suiteId,
18
+ startedAt: new Date().toISOString(),
19
+ results,
20
+ ...(extra.scope !== undefined ? { scope: extra.scope } : {}),
21
+ ...(extra.agentRunId !== undefined ? { agentRunId: extra.agentRunId } : {}),
22
+ ...(name ? { name } : {}),
23
+ };
24
+ }
25
+ function fail(field, reason) {
26
+ throw new Error(`Invalid TestRun: ${field} ${reason}`);
27
+ }
28
+ function assertString(value, field) {
29
+ if (typeof value !== "string" || value.length === 0)
30
+ fail(field, "must be a non-empty string");
31
+ }
32
+ const VALID_STATUSES = ["pending", "running", "passed", "failed", "blocked", "skipped"];
33
+ function validateTestCaseResult(value, testCaseId) {
34
+ if (typeof value !== "object" || value === null) {
35
+ fail(`results.${testCaseId}`, "must be an object");
36
+ }
37
+ const v = value;
38
+ if (!VALID_STATUSES.includes(v.status)) {
39
+ fail(`results.${testCaseId}.status`, `must be one of ${VALID_STATUSES.join(", ")}`);
40
+ }
41
+ if (typeof v.outputs !== "object" || v.outputs === null || Array.isArray(v.outputs)) {
42
+ fail(`results.${testCaseId}.outputs`, "must be an object");
43
+ }
44
+ if (!Array.isArray(v.assertionResults)) {
45
+ fail(`results.${testCaseId}.assertionResults`, "must be an array");
46
+ }
47
+ if (v.sequence !== undefined && typeof v.sequence !== "number") {
48
+ fail(`results.${testCaseId}.sequence`, "must be a number");
49
+ }
50
+ if (v.inputs !== undefined && (typeof v.inputs !== "object" || v.inputs === null || Array.isArray(v.inputs))) {
51
+ fail(`results.${testCaseId}.inputs`, "must be an object");
52
+ }
53
+ if (v.startedAt !== undefined && typeof v.startedAt !== "string") {
54
+ fail(`results.${testCaseId}.startedAt`, "must be a string");
55
+ }
56
+ if (v.durationMs !== undefined && typeof v.durationMs !== "number") {
57
+ fail(`results.${testCaseId}.durationMs`, "must be a number");
58
+ }
59
+ if (v.tokensUsed !== undefined && typeof v.tokensUsed !== "number") {
60
+ fail(`results.${testCaseId}.tokensUsed`, "must be a number");
61
+ }
62
+ if (v.costUsd !== undefined && typeof v.costUsd !== "number") {
63
+ fail(`results.${testCaseId}.costUsd`, "must be a number");
64
+ }
65
+ if (v.messages !== undefined && (!Array.isArray(v.messages) || !v.messages.every((m) => typeof m === "string"))) {
66
+ fail(`results.${testCaseId}.messages`, "must be an array of strings");
67
+ }
68
+ return {
69
+ status: v.status,
70
+ outputs: v.outputs,
71
+ assertionResults: v.assertionResults,
72
+ ...(v.sequence !== undefined ? { sequence: v.sequence } : {}),
73
+ ...(v.inputs !== undefined ? { inputs: v.inputs } : {}),
74
+ ...(v.startedAt !== undefined ? { startedAt: v.startedAt } : {}),
75
+ ...(v.durationMs !== undefined ? { durationMs: v.durationMs } : {}),
76
+ ...(v.tokensUsed !== undefined ? { tokensUsed: v.tokensUsed } : {}),
77
+ ...(v.costUsd !== undefined ? { costUsd: v.costUsd } : {}),
78
+ ...(v.messages !== undefined ? { messages: v.messages } : {}),
79
+ };
80
+ }
81
+ /** Validates a parsed JSON object against the TestRun shape. */
82
+ export function validateTestRun(value) {
83
+ if (typeof value !== "object" || value === null)
84
+ fail("testRun", "must be an object");
85
+ const v = value;
86
+ assertString(v.suiteId, "suiteId");
87
+ assertString(v.startedAt, "startedAt");
88
+ if (typeof v.results !== "object" || v.results === null || Array.isArray(v.results)) {
89
+ fail("results", "must be an object mapping testCaseId to TestCaseResult");
90
+ }
91
+ const results = {};
92
+ for (const [id, result] of Object.entries(v.results)) {
93
+ results[id] = validateTestCaseResult(result, id);
94
+ }
95
+ if (v.scope !== undefined && v.scope !== "suite" && v.scope !== "testcase") {
96
+ fail("scope", "must be suite or testcase");
97
+ }
98
+ if (v.agentRunId !== undefined && typeof v.agentRunId !== "string")
99
+ fail("agentRunId", "must be a string");
100
+ let name;
101
+ try {
102
+ name = normalizeRunName(v.name);
103
+ }
104
+ catch (err) {
105
+ fail("name", err.message.replace(/^Invalid run name: name /, ""));
106
+ }
107
+ return {
108
+ suiteId: v.suiteId,
109
+ startedAt: v.startedAt,
110
+ results,
111
+ ...(v.scope !== undefined ? { scope: v.scope } : {}),
112
+ ...(v.agentRunId !== undefined ? { agentRunId: v.agentRunId } : {}),
113
+ ...(name ? { name } : {}),
114
+ };
115
+ }
@@ -0,0 +1,45 @@
1
+ import { type AssertionResult, type TestRun, type TestCaseStatus } from "./report-schema.js";
2
+ import type { TestSuite } from "./suite-schema.js";
3
+ /**
4
+ * A TestRun JSON file is the durable "keep track of each test case, expected input, and
5
+ * actual output" mechanism -- it persists across turns/sessions instead of living only in chat
6
+ * memory, and every update goes through recordResult so the file is always the source of truth
7
+ * for a suite run's progress.
8
+ */
9
+ export declare function readTestRun(path: string): Promise<TestRun>;
10
+ export declare function writeTestRun(testRun: TestRun, path: string): Promise<void>;
11
+ /** Creates and persists a fresh TestRun for a suite, with every entry pre-populated as "pending". */
12
+ export declare function initTestRun(suite: TestSuite, path: string, extra?: Pick<TestRun, "agentRunId" | "name">): Promise<TestRun>;
13
+ /** Creates and persists a TestRun for one test case run on its own, with no suite: a single
14
+ * pending entry keyed by the test case id. `recordResult` works on it unchanged. */
15
+ export declare function initTestCaseRun(testCaseId: string, path: string, extra?: Pick<TestRun, "agentRunId" | "name">): Promise<TestRun>;
16
+ export interface RecordResultInput {
17
+ status: TestCaseStatus;
18
+ outputs?: Record<string, string>;
19
+ assertionResults?: AssertionResult[];
20
+ /** Full resolved input set, including any values invented for "autogenerated" bindings --
21
+ * `resolve-next` itself only knows the deterministic ones (see `sequence`/`startedAt` below for
22
+ * what else this call auto-fills). Overrides whatever `resolve-next`'s "running" transition
23
+ * already captured, rather than being merged field-by-field with it. */
24
+ inputs?: Record<string, string>;
25
+ tokensUsed?: number;
26
+ costUsd?: number;
27
+ messages?: string[];
28
+ }
29
+ /** Updates one test case's result within a TestRun file and persists it back to disk. This
30
+ * is the orchestrating LLM's "check-in" call after executing a test case and evaluating its
31
+ * expectations -- it's what actually records expected-vs-actual, not chat narration.
32
+ *
33
+ * Also the single place `sequence`/`startedAt`/`durationMs` get computed -- none of these are
34
+ * caller-supplied, since a CLI call is exactly the right place to capture "when did this
35
+ * actually happen" instead of trusting the orchestrating LLM to report it accurately:
36
+ * - `sequence`/`startedAt` are assigned once, the first time a test case transitions to
37
+ * "running" (from `resolve-next`); `sequence` is one more than the highest sequence already
38
+ * assigned in this run, so it reflects actual start order even across a DAG's parallel-eligible
39
+ * branches.
40
+ * - `durationMs` is computed whenever a result reaches a terminal status and `startedAt` is
41
+ * known -- `Date.now()` minus that.
42
+ * Merges rather than replaces the existing result, since a test case's full lifecycle is two
43
+ * calls (an implicit "running" transition, then a terminal call from `record-result`) and the
44
+ * second must not erase what the first captured. */
45
+ export declare function recordResult(path: string, testCaseId: string, update: RecordResultInput): Promise<TestRun>;
package/dist/report.js ADDED
@@ -0,0 +1,77 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { createTestRun, validateTestRun, TERMINAL_STATUSES, } from "./report-schema.js";
3
+ /**
4
+ * A TestRun JSON file is the durable "keep track of each test case, expected input, and
5
+ * actual output" mechanism -- it persists across turns/sessions instead of living only in chat
6
+ * memory, and every update goes through recordResult so the file is always the source of truth
7
+ * for a suite run's progress.
8
+ */
9
+ export async function readTestRun(path) {
10
+ const raw = await readFile(path, "utf-8");
11
+ return validateTestRun(JSON.parse(raw));
12
+ }
13
+ export async function writeTestRun(testRun, path) {
14
+ await writeFile(path, JSON.stringify(testRun, null, 2), "utf-8");
15
+ }
16
+ /** Creates and persists a fresh TestRun for a suite, with every entry pre-populated as "pending". */
17
+ export async function initTestRun(suite, path, extra = {}) {
18
+ const testRun = createTestRun(suite.id, suite.entries.map((e) => e.testCaseId), { ...extra, scope: "suite" });
19
+ await writeTestRun(testRun, path);
20
+ return testRun;
21
+ }
22
+ /** Creates and persists a TestRun for one test case run on its own, with no suite: a single
23
+ * pending entry keyed by the test case id. `recordResult` works on it unchanged. */
24
+ export async function initTestCaseRun(testCaseId, path, extra = {}) {
25
+ const testRun = createTestRun(testCaseId, [testCaseId], { ...extra, scope: "testcase" });
26
+ await writeTestRun(testRun, path);
27
+ return testRun;
28
+ }
29
+ /** Updates one test case's result within a TestRun file and persists it back to disk. This
30
+ * is the orchestrating LLM's "check-in" call after executing a test case and evaluating its
31
+ * expectations -- it's what actually records expected-vs-actual, not chat narration.
32
+ *
33
+ * Also the single place `sequence`/`startedAt`/`durationMs` get computed -- none of these are
34
+ * caller-supplied, since a CLI call is exactly the right place to capture "when did this
35
+ * actually happen" instead of trusting the orchestrating LLM to report it accurately:
36
+ * - `sequence`/`startedAt` are assigned once, the first time a test case transitions to
37
+ * "running" (from `resolve-next`); `sequence` is one more than the highest sequence already
38
+ * assigned in this run, so it reflects actual start order even across a DAG's parallel-eligible
39
+ * branches.
40
+ * - `durationMs` is computed whenever a result reaches a terminal status and `startedAt` is
41
+ * known -- `Date.now()` minus that.
42
+ * Merges rather than replaces the existing result, since a test case's full lifecycle is two
43
+ * calls (an implicit "running" transition, then a terminal call from `record-result`) and the
44
+ * second must not erase what the first captured. */
45
+ export async function recordResult(path, testCaseId, update) {
46
+ const testRun = await readTestRun(path);
47
+ const existing = testRun.results[testCaseId];
48
+ if (!existing) {
49
+ throw new Error(`TestRun at ${path} has no entry for testCaseId "${testCaseId}"`);
50
+ }
51
+ const startedAt = existing.startedAt ?? (update.status === "running" ? new Date().toISOString() : undefined);
52
+ const sequence = existing.sequence ??
53
+ (update.status === "running"
54
+ ? 1 + Math.max(0, ...Object.values(testRun.results).map((r) => r.sequence ?? 0))
55
+ : undefined);
56
+ const inputs = update.inputs ?? existing.inputs;
57
+ const tokensUsed = update.tokensUsed ?? existing.tokensUsed;
58
+ const costUsd = update.costUsd ?? existing.costUsd;
59
+ const messages = update.messages ?? existing.messages;
60
+ const durationMs = TERMINAL_STATUSES.has(update.status) && startedAt !== undefined
61
+ ? Date.now() - new Date(startedAt).getTime()
62
+ : existing.durationMs;
63
+ testRun.results[testCaseId] = {
64
+ status: update.status,
65
+ outputs: update.outputs ?? existing.outputs,
66
+ assertionResults: update.assertionResults ?? existing.assertionResults,
67
+ ...(sequence !== undefined ? { sequence } : {}),
68
+ ...(inputs !== undefined ? { inputs } : {}),
69
+ ...(startedAt !== undefined ? { startedAt } : {}),
70
+ ...(durationMs !== undefined ? { durationMs } : {}),
71
+ ...(tokensUsed !== undefined ? { tokensUsed } : {}),
72
+ ...(costUsd !== undefined ? { costUsd } : {}),
73
+ ...(messages !== undefined ? { messages } : {}),
74
+ };
75
+ await writeTestRun(testRun, path);
76
+ return testRun;
77
+ }