@bugmole/cli 0.5.0 → 0.6.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/package.json +5 -2
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +477 -174
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.ts +4 -4
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/appium-driver.ts +1 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +67 -6
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// `bugmole propose <file>`: a developer proposes test-case changes from a
|
|
2
|
+
// file, the way an agent does with bugmole_propose_test_case.
|
|
3
|
+
//
|
|
4
|
+
// { "caseSetId": "login", "summary": "Why, for every change below",
|
|
5
|
+
// "changes": [ { "caseId": "TC-login", "action": "change", "case": { ... } },
|
|
6
|
+
// { "caseId": "TC-old", "action": "retire", "summary": "Why this one" } ] }
|
|
7
|
+
//
|
|
8
|
+
// Each change names its case; the revision being changed is read from the
|
|
9
|
+
// project's storage, so the file never has to track revision numbers.
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import { ControlPlaneClient, type RegistryProposal } from "../registry/control-plane-client.js";
|
|
12
|
+
import { activeRevision, caseProblem, revisionOf, type CaseSetDocument } from "../registry/test-case-revisions.js";
|
|
13
|
+
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
14
|
+
import { projectPrefix } from "../storage/keys.js";
|
|
15
|
+
import { applyAcceptedProposals } from "./apply-proposals.js";
|
|
16
|
+
|
|
17
|
+
type ProposalFile = {
|
|
18
|
+
caseSetId?: unknown;
|
|
19
|
+
summary?: unknown;
|
|
20
|
+
changes?: Array<{ caseId?: unknown; action?: unknown; case?: Record<string, unknown>; summary?: unknown }>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function registryClient(cfg: any, env: Record<string, string | undefined> = process.env): ControlPlaneClient {
|
|
24
|
+
const apiKey = env.BUGMOLE_API_KEY ?? cfg.registry?.api_key;
|
|
25
|
+
if (!apiKey) throw new Error("BUGMOLE_API_KEY is required to propose changes");
|
|
26
|
+
if (!cfg.project?.id) throw new Error("A project id is required to propose changes");
|
|
27
|
+
return new ControlPlaneClient({ registryUrl: env.BUGMOLE_REGISTRY_URL ?? cfg.registry?.url ?? "https://api.bugmole.com", apiKey, projectId: cfg.project.id });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Proposes every change in the file; stops at the first invalid one before sending anything. */
|
|
31
|
+
export async function proposeFromFile(cfg: any, filePath: string, client: Pick<ControlPlaneClient, "proposeTestCaseChange" | "listUnappliedProposals" | "reportProposalApplied"> = registryClient(cfg)): Promise<RegistryProposal[]> {
|
|
32
|
+
const file = JSON.parse(fs.readFileSync(filePath, "utf8")) as ProposalFile;
|
|
33
|
+
const caseSetId = typeof file.caseSetId === "string" ? file.caseSetId : "";
|
|
34
|
+
if (!caseSetId) throw new Error(`${filePath}: caseSetId is required`);
|
|
35
|
+
if (!Array.isArray(file.changes) || !file.changes.length) throw new Error(`${filePath}: changes must list at least one change`);
|
|
36
|
+
const store = createProjectObjectStore(cfg);
|
|
37
|
+
const key = `${projectPrefix(cfg.project?.id ?? "default")}/test-cases/${caseSetId}.json`;
|
|
38
|
+
if (!(await store.has(key))) throw new Error(`There's no case set ${caseSetId} in this project's storage`);
|
|
39
|
+
const caseSet = JSON.parse(await store.getText(key)) as CaseSetDocument;
|
|
40
|
+
|
|
41
|
+
const requests = file.changes.map((change, index) => {
|
|
42
|
+
const where = `${filePath}: change ${index + 1}`;
|
|
43
|
+
const caseId = typeof change.caseId === "string" ? change.caseId : "";
|
|
44
|
+
const action = change.action as "add" | "change" | "retire";
|
|
45
|
+
const summary = typeof change.summary === "string" && change.summary.trim() ? change.summary : typeof file.summary === "string" ? file.summary : "";
|
|
46
|
+
if (action !== "add" && action !== "change" && action !== "retire") throw new Error(`${where}: action must be add, change or retire`);
|
|
47
|
+
if (!summary.trim()) throw new Error(`${where}: say why in summary`);
|
|
48
|
+
const current = activeRevision(caseSet, caseId);
|
|
49
|
+
if (action !== "add" && !current) throw new Error(`${where}: ${caseId} has no current revision`);
|
|
50
|
+
if (action === "add" && current) throw new Error(`${where}: ${caseId} already exists; use change`);
|
|
51
|
+
if (action !== "retire") {
|
|
52
|
+
const problem = caseProblem({ ...(change.case ?? {}), caseId } as never);
|
|
53
|
+
if (problem) throw new Error(`${where}: ${problem}`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
caseSetId, caseId, action, summary, source: "cli" as const,
|
|
57
|
+
...(current ? { baseRevision: revisionOf(current) } : {}),
|
|
58
|
+
...(action !== "retire" ? { case: change.case } : {}),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
const proposals: RegistryProposal[] = [];
|
|
62
|
+
for (const request of requests) proposals.push((await client.proposeTestCaseChange(request)).proposal);
|
|
63
|
+
// Accepted on arrival (extreme mode) on the project's own storage: write them now.
|
|
64
|
+
if (proposals.some((proposal) => proposal.status === "accepted" && !proposal.appliedAt)) await applyAcceptedProposals(cfg, client);
|
|
65
|
+
return proposals;
|
|
66
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { recordRunVerdicts } from "./record-run-verdicts.js";
|
|
7
|
+
|
|
8
|
+
function setup() {
|
|
9
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-verdicts-"));
|
|
10
|
+
const cfg = { storage: { provider: "local", local: { directory } } };
|
|
11
|
+
const base = path.join(directory, "projects", "shop");
|
|
12
|
+
fs.mkdirSync(path.join(base, "test-cases"), { recursive: true });
|
|
13
|
+
fs.writeFileSync(path.join(base, "test-cases", "checkout-cases.json"), JSON.stringify({
|
|
14
|
+
caseSetId: "checkout-cases",
|
|
15
|
+
journeyId: "checkout",
|
|
16
|
+
cases: [
|
|
17
|
+
{ caseId: "TC-login", nodeId: "screen:/login", title: "Sign in" },
|
|
18
|
+
{ caseId: "TC-logout", nodeId: "screen:/logout", title: "Sign out" },
|
|
19
|
+
],
|
|
20
|
+
}));
|
|
21
|
+
const read = (relative: string) => JSON.parse(fs.readFileSync(path.join(base, relative), "utf8"));
|
|
22
|
+
const history = () => fs.readdirSync(path.join(base, "test-case-history", "checkout-cases"));
|
|
23
|
+
return { cfg, directory, read, history };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("a scoped retest judges only its cases and keeps every other case's latest verdict", async () => {
|
|
27
|
+
const { cfg, directory, read, history } = setup();
|
|
28
|
+
try {
|
|
29
|
+
const nodes = [
|
|
30
|
+
{ nodeId: "screen:/login", outcome: "PASS" as const, status: "success" as const },
|
|
31
|
+
{ nodeId: "screen:/logout", outcome: "FAIL" as const, status: "failed" as const, reason: { message: "Nothing happened" } },
|
|
32
|
+
];
|
|
33
|
+
const full = await recordRunVerdicts(cfg, "shop", "checkout", nodes, "run_full");
|
|
34
|
+
assert.deepEqual(full, { caseSetId: "checkout-cases", judged: 2 });
|
|
35
|
+
|
|
36
|
+
const retest = await recordRunVerdicts(
|
|
37
|
+
cfg,
|
|
38
|
+
"shop",
|
|
39
|
+
"checkout",
|
|
40
|
+
[{ nodeId: "screen:/login", outcome: "FAIL", status: "failed" }, { nodeId: "screen:/logout", outcome: "PASS", status: "success" }],
|
|
41
|
+
"run_retest",
|
|
42
|
+
undefined,
|
|
43
|
+
{ caseSetId: "checkout-cases", caseIds: ["TC-logout"] },
|
|
44
|
+
);
|
|
45
|
+
assert.deepEqual(retest, { caseSetId: "checkout-cases", judged: 1 });
|
|
46
|
+
const latest = read("test-case-results/checkout-cases.json");
|
|
47
|
+
assert.equal(latest.runId, "run_retest");
|
|
48
|
+
const byCase = Object.fromEntries(latest.results.map((item: { caseId: string; status: string }) => [item.caseId, item.status]));
|
|
49
|
+
assert.deepEqual(byCase, { "TC-login": "passed", "TC-logout": "passed" }, "the login verdict is the full run's, not the retest's");
|
|
50
|
+
|
|
51
|
+
const entries = history();
|
|
52
|
+
assert.equal(entries.length, 2);
|
|
53
|
+
const retestEntry = JSON.parse(fs.readFileSync(path.join(directory, "projects", "shop", "test-case-history", "checkout-cases", entries.find((name) => name.endsWith("-run_retest.json"))!), "utf8"));
|
|
54
|
+
assert.deepEqual(retestEntry.results.map((item: { caseId: string }) => item.caseId), ["TC-logout"], "history holds only what the retest judged");
|
|
55
|
+
} finally {
|
|
56
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
12
12
|
import { projectPrefix } from "../storage/keys.js";
|
|
13
|
+
import { putTestCaseResults, testCaseHistoryKey, testCaseResultsKey } from "../storage/test-case-results.js";
|
|
13
14
|
import { verdictsFromRun, type NodeOutcome } from "./test-case-verdicts.js";
|
|
14
15
|
|
|
15
16
|
function store(cfg: any) {
|
|
@@ -30,6 +31,12 @@ export async function recordRunVerdicts(
|
|
|
30
31
|
nodes: NodeOutcome[],
|
|
31
32
|
runId?: string,
|
|
32
33
|
unreachedReason?: string,
|
|
34
|
+
/**
|
|
35
|
+
* A retest of some cases only (a Jira ticket's case). Just those cases are
|
|
36
|
+
* judged; the other cases keep their latest verdicts, and the history entry
|
|
37
|
+
* holds only what this run judged.
|
|
38
|
+
*/
|
|
39
|
+
scope?: { caseSetId: string; caseIds: string[] } | null,
|
|
33
40
|
): Promise<{ caseSetId: string; judged: number } | null> {
|
|
34
41
|
const objects = await store(cfg).list(`${projectPrefix(projectId)}/test-cases/`);
|
|
35
42
|
for (const object of objects) {
|
|
@@ -40,9 +47,10 @@ export async function recordRunVerdicts(
|
|
|
40
47
|
} catch {
|
|
41
48
|
continue;
|
|
42
49
|
}
|
|
43
|
-
if (caseSet?.journeyId !== journeyId) continue;
|
|
50
|
+
if (scope ? caseSet?.caseSetId !== scope.caseSetId : caseSet?.journeyId !== journeyId) continue;
|
|
44
51
|
const cases = Array.isArray(caseSet.cases)
|
|
45
|
-
? caseSet.cases.filter((item: any) => typeof item?.caseId === "string" && typeof item?.nodeId === "string"
|
|
52
|
+
? caseSet.cases.filter((item: any) => typeof item?.caseId === "string" && typeof item?.nodeId === "string"
|
|
53
|
+
&& (!scope || scope.caseIds.includes(item.caseId)))
|
|
46
54
|
: [];
|
|
47
55
|
if (cases.length === 0) return null;
|
|
48
56
|
|
|
@@ -60,9 +68,35 @@ export async function recordRunVerdicts(
|
|
|
60
68
|
recordedAt: new Date().toISOString(),
|
|
61
69
|
results,
|
|
62
70
|
};
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
if (scope) {
|
|
72
|
+
await putScopedResults(cfg, projectId, record, scope.caseIds);
|
|
73
|
+
return { caseSetId: caseSet.caseSetId, judged: results.length };
|
|
74
|
+
}
|
|
75
|
+
await putTestCaseResults(store(cfg), projectId, record);
|
|
65
76
|
return { caseSetId: caseSet.caseSetId, judged: results.length };
|
|
66
77
|
}
|
|
67
78
|
return null;
|
|
68
79
|
}
|
|
80
|
+
|
|
81
|
+
/** A scoped retest: history gets this run's verdicts, the latest file keeps every other case's. */
|
|
82
|
+
async function putScopedResults(
|
|
83
|
+
cfg: any,
|
|
84
|
+
projectId: string,
|
|
85
|
+
record: { caseSetId: string; recordedAt: string; runId?: string; results: Array<{ caseId: string }> } & Record<string, unknown>,
|
|
86
|
+
caseIds: string[],
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const objects = store(cfg);
|
|
89
|
+
const latestKey = testCaseResultsKey(projectId, record.caseSetId);
|
|
90
|
+
let previous: any = null;
|
|
91
|
+
try {
|
|
92
|
+
previous = JSON.parse(await objects.getText(latestKey));
|
|
93
|
+
} catch {
|
|
94
|
+
previous = null;
|
|
95
|
+
}
|
|
96
|
+
const kept = Array.isArray(previous?.results)
|
|
97
|
+
? previous.results.filter((item: any) => typeof item?.caseId === "string" && !caseIds.includes(item.caseId))
|
|
98
|
+
: [];
|
|
99
|
+
const body = (value: unknown) => JSON.stringify(value, null, 2) + "\n";
|
|
100
|
+
await objects.put(testCaseHistoryKey(projectId, record.caseSetId, record.recordedAt, record.runId), body(record), "application/json");
|
|
101
|
+
await objects.put(latestKey, body({ ...record, results: [...kept, ...record.results] }), "application/json");
|
|
102
|
+
}
|
package/src/runtime/reporter.ts
CHANGED
|
@@ -100,7 +100,7 @@ Generated: ${timestamp}
|
|
|
100
100
|
|
|
101
101
|
## Summary
|
|
102
102
|
|
|
103
|
-
**${molesFound(summary.failed)}.**
|
|
103
|
+
**${summary.failed > 0 || summary.passed > 0 && summary.unknown === 0 ? molesFound(summary.failed) : summary.totalRuns === 0 ? "Nothing has run yet" : `${summary.passed} passed, ${summary.unknown} with no verdict`}.**
|
|
104
104
|
|
|
105
105
|
- **Total Runs**: ${summary.totalRuns}
|
|
106
106
|
- **Total Journeys**: ${summary.totalJourneys}
|
package/src/runtime/reset.ts
CHANGED
|
@@ -41,6 +41,8 @@ export async function resetProjectData(cfg: any): Promise<void> {
|
|
|
41
41
|
const specDir = path.resolve(cfg.runtime?.spec_dir || "./spec");
|
|
42
42
|
await fs.rm(path.join(specDir, "journeys.graph.json"), { force: true });
|
|
43
43
|
await fs.rm(path.join(specDir, "plans"), { recursive: true, force: true });
|
|
44
|
+
// Saved sign-in sessions (bugmole_sign_in) hold the app's cookies.
|
|
45
|
+
await fs.rm(path.resolve(artifactsDir, projectId, "sessions"), { recursive: true, force: true });
|
|
44
46
|
|
|
45
47
|
const registryUrl = process.env.BUGMOLE_REGISTRY_URL?.trim();
|
|
46
48
|
const apiKey = process.env.BUGMOLE_API_KEY?.trim();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { parseRolesCommand, runRolesCommand } from "./roles-cli.js";
|
|
7
|
+
import { parseArgs, resolveMode } from "../../scripts/bugmole.js";
|
|
8
|
+
|
|
9
|
+
const argv = (...rest: string[]) => ["node", "bugmole", "roles", ...rest];
|
|
10
|
+
|
|
11
|
+
test("`bugmole roles` lists; `suggest` needs a name and evidence, or --from-evidence", () => {
|
|
12
|
+
assert.equal(resolveMode(parseArgs(argv())), "roles");
|
|
13
|
+
assert.deepEqual(parseRolesCommand(argv(), parseArgs(argv())), { action: "list" });
|
|
14
|
+
const fromEvidence = argv("suggest", "--from-evidence");
|
|
15
|
+
assert.deepEqual(parseRolesCommand(fromEvidence, parseArgs(fromEvidence)), { action: "suggest", fromEvidence: true });
|
|
16
|
+
const noEvidence = argv("suggest", "admin");
|
|
17
|
+
assert.match((parseRolesCommand(noEvidence, parseArgs(noEvidence)) as { error: string }).error, /--evidence/);
|
|
18
|
+
const full = argv("suggest", "Admin", "--evidence", "route /admin", "--source", "role_gated_route", "--description", "Admin area");
|
|
19
|
+
assert.deepEqual(parseRolesCommand(full, parseArgs(full)), {
|
|
20
|
+
action: "suggest", fromEvidence: false, name: "admin", description: "Admin area", evidence: "route /admin", source: "role_gated_route",
|
|
21
|
+
});
|
|
22
|
+
const asPerson = argv("suggest", "admin", "--evidence", "x", "--source", "person");
|
|
23
|
+
assert.equal((parseRolesCommand(asPerson, parseArgs(asPerson)) as { source: string }).source, "agent", "the CLI can't claim to be a person");
|
|
24
|
+
assert.ok("error" in parseRolesCommand(argv("confirm"), {}));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("the CLI lists confirmed and suggested roles and can only suggest", async () => {
|
|
28
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-roles-cli-"));
|
|
29
|
+
await fs.writeFile(path.join(directory, "roles.yaml"), "roles:\n - name: user\n - name: admin\n status: suggested\n");
|
|
30
|
+
const cfg = { project: { id: "demo" }, runtime: { spec_dir: directory } };
|
|
31
|
+
const listed = await runRolesCommand(cfg, { action: "list" }, null);
|
|
32
|
+
assert.match(listed[0], /Confirmed .*: user/);
|
|
33
|
+
assert.match(listed[1], /Suggested .*: admin/);
|
|
34
|
+
|
|
35
|
+
await assert.rejects(runRolesCommand(cfg, { action: "suggest", fromEvidence: true }, null), /BUGMOLE_API_KEY/);
|
|
36
|
+
const sent: string[] = [];
|
|
37
|
+
const client = {
|
|
38
|
+
listRoles: async () => ({ roles: [] }),
|
|
39
|
+
proposeRole: async (input: { name: string; description: string; source: string; evidence: string }) => {
|
|
40
|
+
sent.push(input.name);
|
|
41
|
+
return { role: { id: "r", name: input.name, description: input.description, status: "suggested" as const, source: input.source, evidence: input.evidence } };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const out = await runRolesCommand(cfg, { action: "suggest", fromEvidence: false, name: "guest", description: "", evidence: "sign-in at /login", source: "sign_in_wall" }, client);
|
|
45
|
+
assert.deepEqual(sent, ["guest"]);
|
|
46
|
+
assert.match(out[0], /guest: suggested — a person confirms/);
|
|
47
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `bugmole roles`: list the project's roles, or suggest one (or every role
|
|
2
|
+
// the project's evidence points at) for a person to confirm. The CLI, like
|
|
3
|
+
// an agent, can only suggest; confirming happens on the dashboard's Roles page.
|
|
4
|
+
//
|
|
5
|
+
// bugmole roles list roles and their status
|
|
6
|
+
// bugmole roles suggest --from-evidence suggest what the graph, blockers and roles.yaml imply
|
|
7
|
+
// bugmole roles suggest <name> --evidence "…" [--description "…"] [--source role_gated_route]
|
|
8
|
+
import type { ControlPlaneClient } from "../registry/control-plane-client.js";
|
|
9
|
+
import { ROLE_SOURCES, normalizeRoleName } from "../registry/roles.js";
|
|
10
|
+
import { collectRoleSuggestions, projectRoles, proposeRoleSuggestions, rolesClient, type ProposedRole } from "./project-roles.js";
|
|
11
|
+
|
|
12
|
+
type RolesClient = Pick<ControlPlaneClient, "listRoles" | "proposeRole">;
|
|
13
|
+
|
|
14
|
+
export type RolesCommand =
|
|
15
|
+
| { action: "list" }
|
|
16
|
+
| { action: "suggest"; fromEvidence: true }
|
|
17
|
+
| { action: "suggest"; fromEvidence: false; name: string; description: string; evidence: string; source: string };
|
|
18
|
+
|
|
19
|
+
/** Reads `bugmole roles …` arguments; returns an error message for bad input. */
|
|
20
|
+
export function parseRolesCommand(argv: string[], args: Record<string, unknown>): RolesCommand | { error: string } {
|
|
21
|
+
const after = argv.slice(argv.indexOf("roles") + 1).filter((arg, index, all) => !arg.startsWith("--") && !(index > 0 && all[index - 1].startsWith("--") && all[index - 1] !== "--from-evidence"));
|
|
22
|
+
const sub = after[0] ?? "list";
|
|
23
|
+
if (sub === "list") return { action: "list" };
|
|
24
|
+
if (sub !== "suggest") return { error: "Usage: bugmole roles [list | suggest]" };
|
|
25
|
+
if (args.fromevidence === true) return { action: "suggest", fromEvidence: true };
|
|
26
|
+
const name = normalizeRoleName(after[1]);
|
|
27
|
+
if (!name) return { error: "Usage: bugmole roles suggest <name> --evidence \"what points at it\" [--description \"…\"], or --from-evidence" };
|
|
28
|
+
const evidence = typeof args.evidence === "string" ? args.evidence.trim() : "";
|
|
29
|
+
if (!evidence) return { error: "Say what points at this role with --evidence (a sign-in wall, a gated route or menu, a spec entry)" };
|
|
30
|
+
const source = typeof args.source === "string" && (ROLE_SOURCES as readonly string[]).includes(args.source) && args.source !== "person" ? args.source : "agent";
|
|
31
|
+
return { action: "suggest", fromEvidence: false, name, description: typeof args.description === "string" ? args.description : "", evidence, source };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Runs the command and returns the lines to print. */
|
|
35
|
+
export async function runRolesCommand(cfg: any, command: RolesCommand, client: RolesClient | null = rolesClient(cfg)): Promise<string[]> {
|
|
36
|
+
if (command.action === "list") {
|
|
37
|
+
const roles = await projectRoles(cfg, client);
|
|
38
|
+
const lines = [
|
|
39
|
+
`Confirmed (used for test cases and runs): ${roles.usable.join(", ") || "none"}`,
|
|
40
|
+
`Suggested (waiting for a person on the Roles page): ${roles.suggested.join(", ") || "none"}`,
|
|
41
|
+
];
|
|
42
|
+
if (client && !roles.registryRead) lines.push("Bugmole couldn't reach your account, so these are the roles in spec/roles.yaml only.");
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
if (!client) throw new Error("BUGMOLE_API_KEY and a project id are required to suggest roles; or add the role to roles.yaml with status: suggested");
|
|
46
|
+
let proposed: ProposedRole[];
|
|
47
|
+
if (command.fromEvidence) {
|
|
48
|
+
const roles = await projectRoles(cfg, client);
|
|
49
|
+
proposed = await proposeRoleSuggestions(collectRoleSuggestions(cfg), client, roles.usable);
|
|
50
|
+
if (!proposed.length) return ["Nothing new to suggest: the evidence points at no role that isn't already confirmed."];
|
|
51
|
+
} else {
|
|
52
|
+
const { role, alreadyDecided } = await client.proposeRole({ name: command.name, description: command.description, source: command.source, evidence: command.evidence });
|
|
53
|
+
proposed = [{ name: role.name, source: command.source, status: role.status, ...(alreadyDecided ? { alreadyDecided } : {}) }];
|
|
54
|
+
}
|
|
55
|
+
return proposed.map((role) => role.error
|
|
56
|
+
? `${role.name}: not sent (${role.error})`
|
|
57
|
+
: role.alreadyDecided
|
|
58
|
+
? `${role.name}: a person already ${role.status} it; unchanged`
|
|
59
|
+
: `${role.name}: ${role.status === "suggested" ? "suggested — a person confirms, renames or rejects it on the Roles page" : role.status}`);
|
|
60
|
+
}
|
package/src/runtime/run-once.ts
CHANGED
|
@@ -9,7 +9,11 @@ import { ControlPlaneClient, type RegistryRun, type RegistryTask } from "../regi
|
|
|
9
9
|
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
10
10
|
import { projectPrefix } from "../storage/keys.js";
|
|
11
11
|
import { executeRegistryRun, registryDeviceAccess, type StopSignal } from "./run-job.js";
|
|
12
|
-
import {
|
|
12
|
+
import { recordRunVerdicts } from "./record-run-verdicts.js";
|
|
13
|
+
import { discoveryUrl, runDiscoveryTask } from "./discovery-task.js";
|
|
14
|
+
import { fetchRunnerSecrets, injectFlowSecrets, resolveVaultReferences, signInWithFlow, type ReleasedSecrets } from "./cloud-secrets.js";
|
|
15
|
+
import { registryIdentityTokens } from "./vault-federation.js";
|
|
16
|
+
import { redactSecrets } from "./secret-redaction.js";
|
|
13
17
|
import type { execute } from "./executor.js";
|
|
14
18
|
import type { discoverSite } from "./site-discovery.js";
|
|
15
19
|
|
|
@@ -19,7 +23,13 @@ type JobBundle = {
|
|
|
19
23
|
maxMinutes?: number;
|
|
20
24
|
};
|
|
21
25
|
export type RunBundle = JobBundle & { run: RegistryRun };
|
|
22
|
-
|
|
26
|
+
/** `signInFlow` is the project's sign-in flow, run before cloud exploration. */
|
|
27
|
+
export type TaskBundle = JobBundle & {
|
|
28
|
+
task: RegistryTask;
|
|
29
|
+
signInFlow?: string | null;
|
|
30
|
+
/** How far a discovery task may explore, from the workspace's plan. */
|
|
31
|
+
discovery?: { maxPages: number; maxDepth: number };
|
|
32
|
+
};
|
|
23
33
|
|
|
24
34
|
type Env = Record<string, string | undefined>;
|
|
25
35
|
|
|
@@ -31,6 +41,8 @@ export type RunOnceDeps = {
|
|
|
31
41
|
discover?: typeof discoverSite;
|
|
32
42
|
client?: RunnerClient;
|
|
33
43
|
log?: (message: string) => void;
|
|
44
|
+
/** Runs the sign-in flow before exploring; replaceable in tests. */
|
|
45
|
+
signIn?: typeof signInWithFlow;
|
|
34
46
|
};
|
|
35
47
|
|
|
36
48
|
/** The config cloud work executes with: web, headless, Bugmole storage, no local spec. */
|
|
@@ -121,7 +133,24 @@ export async function runOnce(env: Env = process.env, deps: RunOnceDeps = {}): P
|
|
|
121
133
|
? client.updateRun(job.id, update)
|
|
122
134
|
: client.updateTask(job.id, { workerId, status: update.status, errorMessage: update.errorMessage, progressMessage: update.errorMessage });
|
|
123
135
|
|
|
136
|
+
// The project's sign-in secrets, in this process's environment only while
|
|
137
|
+
// this job runs, and redacted from everything it reports.
|
|
138
|
+
let removeSecrets = () => undefined as void;
|
|
124
139
|
try {
|
|
140
|
+
let released: ReleasedSecrets = { secrets: {}, unreadable: [], available: false };
|
|
141
|
+
try {
|
|
142
|
+
released = await resolveVaultReferences(await fetchRunnerSecrets(doFetch, registryUrl, token, job), {
|
|
143
|
+
fetch: doFetch,
|
|
144
|
+
identityToken: registryIdentityTokens(doFetch, registryUrl, token, job),
|
|
145
|
+
sessionName: `bugmole-${job.kind}-${job.id}`,
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
log(`Couldn't load the project's secrets: ${error instanceof Error ? error.message : error}`);
|
|
149
|
+
}
|
|
150
|
+
for (const [name, problem] of Object.entries(released.vaultErrors ?? {})) log(`Couldn't read ${name} from the project's vault: ${problem}`);
|
|
151
|
+
removeSecrets = injectFlowSecrets(released.secrets);
|
|
152
|
+
const secretCount = Object.keys(released.secrets).length;
|
|
153
|
+
if (secretCount) log(`Loaded ${secretCount} project secret${secretCount === 1 ? "" : "s"} for this ${job.kind}`);
|
|
125
154
|
let work: Promise<string>;
|
|
126
155
|
if (job.kind === "run") {
|
|
127
156
|
log(`Running ${bundle.run.planId} for ${projectId} (${job.id})`);
|
|
@@ -132,11 +161,43 @@ export async function runOnce(env: Env = process.env, deps: RunOnceDeps = {}): P
|
|
|
132
161
|
execute: deps.execute,
|
|
133
162
|
loadDeviceAccess: registryDeviceAccess(cfg, registryUrl, token, projectId, job.id),
|
|
134
163
|
})
|
|
135
|
-
.then((result) =>
|
|
164
|
+
.then(async (result) => {
|
|
165
|
+
// Judge the flow's cases like `bugmole serve` does, so a cloud run
|
|
166
|
+
// gives each case (and its Jira ticket) a verdict. A scoped retest
|
|
167
|
+
// judges only its own cases. Bookkeeping never fails the run.
|
|
168
|
+
if (bundle.run.journeyId) {
|
|
169
|
+
await recordRunVerdicts(
|
|
170
|
+
cfg,
|
|
171
|
+
projectId,
|
|
172
|
+
bundle.run.journeyId,
|
|
173
|
+
(result.results ?? []).map((item) => ({
|
|
174
|
+
nodeId: item.nodeId,
|
|
175
|
+
outcome: item.outcome,
|
|
176
|
+
status: item.status,
|
|
177
|
+
reason: (item as { reason?: { code?: string; message?: string } }).reason,
|
|
178
|
+
})),
|
|
179
|
+
job.id,
|
|
180
|
+
undefined,
|
|
181
|
+
bundle.run.caseScope,
|
|
182
|
+
).catch((error: unknown) => log(`Couldn't record case verdicts: ${error instanceof Error ? error.message : error}`));
|
|
183
|
+
}
|
|
184
|
+
return result.status;
|
|
185
|
+
});
|
|
136
186
|
} else {
|
|
137
187
|
log(`Exploring for ${projectId} (${job.id})`);
|
|
138
188
|
await restoreJourneyGraph(cfg);
|
|
139
|
-
|
|
189
|
+
const signInFlow = bundle.signInFlow?.trim() ? bundle.signInFlow : null;
|
|
190
|
+
const signIn = signInFlow
|
|
191
|
+
? (url: string) => (deps.signIn ?? signInWithFlow)({
|
|
192
|
+
flow: signInFlow,
|
|
193
|
+
baseUrl: discoveryUrl(bundle.task) ?? url,
|
|
194
|
+
workdir,
|
|
195
|
+
projectId,
|
|
196
|
+
jobId: job.id,
|
|
197
|
+
released,
|
|
198
|
+
})
|
|
199
|
+
: undefined;
|
|
200
|
+
work = runDiscoveryTask(cfg, client, bundle.task, workerId, deps.discover, { claimed: true, onStop: stop, signIn, ...(bundle.discovery ? { limits: bundle.discovery } : {}) })
|
|
140
201
|
.then(() => "done");
|
|
141
202
|
}
|
|
142
203
|
const outcome = await Promise.race([
|
|
@@ -157,12 +218,14 @@ export async function runOnce(env: Env = process.env, deps: RunOnceDeps = {}): P
|
|
|
157
218
|
log(`Finished: ${outcome.status}`);
|
|
158
219
|
return 0;
|
|
159
220
|
} catch (error) {
|
|
160
|
-
const message = error instanceof Error ? error.message : "Cloud work failed";
|
|
221
|
+
const message = redactSecrets(error instanceof Error ? error.message : "Cloud work failed");
|
|
161
222
|
log(`Failed: ${message}`);
|
|
162
223
|
await report({ workerId, status: "error", eventType: "failed", errorMessage: message }).catch(() => undefined);
|
|
163
224
|
return 1;
|
|
164
225
|
} finally {
|
|
165
226
|
if (deadline) clearTimeout(deadline);
|
|
227
|
+
removeSecrets();
|
|
228
|
+
// Holds the signed-in session and anything the sign-in left behind.
|
|
166
229
|
fs.rmSync(workdir, { recursive: true, force: true });
|
|
167
230
|
}
|
|
168
231
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { SCENARIO_COPY_HEADER, scenarioCopy, scenarioSource } from "../../scripts/sync-plan-catalog.mjs";
|
|
5
|
+
import { normalizeScenarioExclusions, scenarioMatrix } from "./scenario-matrix.js";
|
|
6
|
+
|
|
7
|
+
test("the dashboard's copy of the scenario matrix matches the source", async () => {
|
|
8
|
+
const [source, copy] = await Promise.all([readFile(scenarioSource, "utf8"), readFile(scenarioCopy, "utf8")]);
|
|
9
|
+
assert.equal(copy, SCENARIO_COPY_HEADER + source, "run `npm run sync:catalog`");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("a happy path is asked of every step, other categories once per journey", () => {
|
|
13
|
+
const matrix = scenarioMatrix(["a", "b"], [
|
|
14
|
+
{ caseId: "a-ok", nodeId: "a", category: "happy_path" },
|
|
15
|
+
{ caseId: "b-invalid", nodeId: "b", category: "validation" },
|
|
16
|
+
{ caseId: "old", nodeId: "b" },
|
|
17
|
+
{ caseId: "elsewhere", nodeId: "z", category: "edge_case" },
|
|
18
|
+
], [
|
|
19
|
+
{ category: "permission", reason: "One role" },
|
|
20
|
+
{ category: "error_state", nodeId: "a", reason: "Static page" },
|
|
21
|
+
]);
|
|
22
|
+
assert.deepEqual(matrix.rows[0].cells.happy_path, { state: "covered", caseIds: ["a-ok"] });
|
|
23
|
+
assert.deepEqual(matrix.rows[0].cells.error_state, { state: "excluded", reason: "Static page" });
|
|
24
|
+
assert.deepEqual(matrix.rows[1].cells.error_state, { state: "empty" });
|
|
25
|
+
assert.deepEqual(matrix.rows[1].cells.permission, { state: "excluded", reason: "One role" });
|
|
26
|
+
assert.deepEqual(matrix.uncategorized, ["old"]);
|
|
27
|
+
// error_state is excluded on a; that answers it for the journey.
|
|
28
|
+
assert.deepEqual(matrix.missing, [{ category: "happy_path", nodeId: "b" }, { category: "edge_case" }]);
|
|
29
|
+
assert.deepEqual(scenarioMatrix([], []).missing, []);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("keeps only well-formed exclusions", () => {
|
|
33
|
+
assert.deepEqual(normalizeScenarioExclusions([
|
|
34
|
+
{ category: "permission", reason: " One role " },
|
|
35
|
+
{ category: "smoke", reason: "x" },
|
|
36
|
+
{ category: "validation" },
|
|
37
|
+
{ category: "edge_case", nodeId: "a", reason: "No input" },
|
|
38
|
+
null,
|
|
39
|
+
]), [{ category: "permission", reason: "One role" }, { category: "edge_case", nodeId: "a", reason: "No input" }]);
|
|
40
|
+
assert.deepEqual(normalizeScenarioExclusions("nope"), []);
|
|
41
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Which kinds of scenario a journey's test cases cover. "Is every scenario
|
|
2
|
+
// covered?" is answerable only if each case says what kind of scenario it
|
|
3
|
+
// is: one happy-path case per step proves the step works, not that it
|
|
4
|
+
// rejects bad input or survives an error. This lays a journey's cases out as
|
|
5
|
+
// steps × categories and names what is missing, so a gap is a missing kind
|
|
6
|
+
// of scenario, not only a missing page.
|
|
7
|
+
//
|
|
8
|
+
// Keep this file dependency-free and type-strippable: `npm run sync:catalog`
|
|
9
|
+
// copies it verbatim into the dashboard (apps/qa/src/lib/scenario-matrix.ts).
|
|
10
|
+
|
|
11
|
+
export const SCENARIO_CATEGORIES = ["happy_path", "validation", "error_state", "permission", "edge_case"] as const;
|
|
12
|
+
export type ScenarioCategory = (typeof SCENARIO_CATEGORIES)[number];
|
|
13
|
+
|
|
14
|
+
export const SCENARIO_LABEL: Record<ScenarioCategory, string> = {
|
|
15
|
+
happy_path: "Happy path",
|
|
16
|
+
validation: "Invalid input",
|
|
17
|
+
error_state: "Error or empty state",
|
|
18
|
+
permission: "Permissions",
|
|
19
|
+
edge_case: "Edge case",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** What each category asks for, in the words an agent or a person writes cases from. */
|
|
23
|
+
export const SCENARIO_MEANING: Record<ScenarioCategory, string> = {
|
|
24
|
+
happy_path: "the step works for the intended user with valid input",
|
|
25
|
+
validation: "bad, missing or out-of-range input is rejected with a clear message",
|
|
26
|
+
error_state: "a failing backend, a network error or no data is handled without breaking the page",
|
|
27
|
+
permission: "a signed-out user or a role without access is kept out",
|
|
28
|
+
edge_case: "limits and unusual but valid input: long text, many items, double submits, back and refresh",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function isScenarioCategory(value: unknown): value is ScenarioCategory {
|
|
32
|
+
return typeof value === "string" && (SCENARIO_CATEGORIES as readonly string[]).includes(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A case set says a category doesn't apply, with the reason, instead of
|
|
37
|
+
* leaving a silent hole: a step with no input has nothing to validate, an
|
|
38
|
+
* app with one role has no permission scenarios. Without nodeId it covers
|
|
39
|
+
* the whole journey.
|
|
40
|
+
*/
|
|
41
|
+
export type ScenarioExclusion = { category: ScenarioCategory; nodeId?: string; reason: string };
|
|
42
|
+
|
|
43
|
+
export type ScenarioCase = { caseId: string; nodeId: string; category?: string; status?: string };
|
|
44
|
+
|
|
45
|
+
export type ScenarioCell =
|
|
46
|
+
| { state: "covered"; caseIds: string[] }
|
|
47
|
+
| { state: "excluded"; reason: string }
|
|
48
|
+
| { state: "empty" };
|
|
49
|
+
|
|
50
|
+
export type ScenarioMatrix = {
|
|
51
|
+
rows: Array<{ nodeId: string; cells: Record<ScenarioCategory, ScenarioCell> }>;
|
|
52
|
+
/** Cases written before categories existed, which the matrix can't place. */
|
|
53
|
+
uncategorized: string[];
|
|
54
|
+
/**
|
|
55
|
+
* What is missing, in order: a step with no happy-path case, then each
|
|
56
|
+
* category no step of the journey covers or excludes.
|
|
57
|
+
*/
|
|
58
|
+
missing: Array<{ category: ScenarioCategory; nodeId?: string }>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The happy path is asked of every step; the other categories once per
|
|
63
|
+
* journey, on whichever step they belong to. Asking all five of every step
|
|
64
|
+
* would fill the matrix with "not applicable" and bury the real holes.
|
|
65
|
+
*/
|
|
66
|
+
export function scenarioMatrix(nodeIds: readonly string[], cases: readonly ScenarioCase[], exclusions: readonly ScenarioExclusion[] = []): ScenarioMatrix {
|
|
67
|
+
const steps = new Set(nodeIds);
|
|
68
|
+
const uncategorized: string[] = [];
|
|
69
|
+
const rows = nodeIds.map((nodeId) => {
|
|
70
|
+
const cells = {} as Record<ScenarioCategory, ScenarioCell>;
|
|
71
|
+
for (const category of SCENARIO_CATEGORIES) {
|
|
72
|
+
const caseIds = cases.filter((item) => item.nodeId === nodeId && item.category === category).map((item) => item.caseId);
|
|
73
|
+
const excluded = exclusions.find((item) => item.category === category && (item.nodeId === nodeId || item.nodeId === undefined));
|
|
74
|
+
cells[category] = caseIds.length ? { state: "covered", caseIds } : excluded ? { state: "excluded", reason: excluded.reason } : { state: "empty" };
|
|
75
|
+
}
|
|
76
|
+
return { nodeId, cells };
|
|
77
|
+
});
|
|
78
|
+
for (const item of cases) if (steps.has(item.nodeId) && !isScenarioCategory(item.category)) uncategorized.push(item.caseId);
|
|
79
|
+
|
|
80
|
+
const missing: ScenarioMatrix["missing"] = [];
|
|
81
|
+
for (const row of rows) if (row.cells.happy_path.state === "empty") missing.push({ category: "happy_path", nodeId: row.nodeId });
|
|
82
|
+
for (const category of SCENARIO_CATEGORIES) {
|
|
83
|
+
if (category === "happy_path" || !rows.length) continue;
|
|
84
|
+
if (rows.every((row) => row.cells[category].state === "empty")) missing.push({ category });
|
|
85
|
+
}
|
|
86
|
+
return { rows, uncategorized, missing };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Exclusions as stored, keeping only well-formed ones. */
|
|
90
|
+
export function normalizeScenarioExclusions(input: unknown): ScenarioExclusion[] {
|
|
91
|
+
if (!Array.isArray(input)) return [];
|
|
92
|
+
return input.flatMap((item) => {
|
|
93
|
+
if (!item || typeof item !== "object") return [];
|
|
94
|
+
const record = item as Record<string, unknown>;
|
|
95
|
+
if (!isScenarioCategory(record.category) || typeof record.reason !== "string" || !record.reason.trim()) return [];
|
|
96
|
+
return [{ category: record.category, reason: record.reason.trim(), ...(typeof record.nodeId === "string" ? { nodeId: record.nodeId } : {}) }];
|
|
97
|
+
});
|
|
98
|
+
}
|