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,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
|
+
}>;
|
package/dist/planrun.js
ADDED
|
@@ -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
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -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
|
+
}
|