@bugmole/cli 0.4.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/LICENSE +18 -4
- package/package.json +7 -4
- 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 +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -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/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- 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 +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- 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 +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- 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 +494 -173
- 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.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- 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 +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- 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/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -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/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -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 +87 -7
- 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 +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -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 +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- 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-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -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 +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { executeRegistryRun } from "./run-job.js";
|
|
4
|
+
|
|
5
|
+
test("only a passing run promotes the journey it ran, and before it reports done", async () => {
|
|
6
|
+
for (const status of ["success", "partial", "error"] as const) {
|
|
7
|
+
const order: string[] = [];
|
|
8
|
+
const client = { updateRun: async (_id: string, update: Record<string, unknown>) => { if (update.eventType === "completed") order.push("completed"); return {}; } };
|
|
9
|
+
const run = { id: `run_${status}`, planId: "discovered-home", journeyId: "discovered-home", status: "claimed" } as never;
|
|
10
|
+
await executeRegistryRun({}, client as never, run, {
|
|
11
|
+
projectId: "demo",
|
|
12
|
+
workerId: "w1",
|
|
13
|
+
execute: (async () => ({ status, stepsExecuted: 1, stepsPassed: 1, stepsFailed: 0 })) as never,
|
|
14
|
+
stabilize: async (_cfg, promoted) => {
|
|
15
|
+
order.push(`stabilize:${promoted.id}`);
|
|
16
|
+
return promoted.journeyId ?? null;
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
assert.deepEqual(order, status === "success" ? ["stabilize:run_success", "completed"] : ["completed"], status);
|
|
20
|
+
}
|
|
21
|
+
});
|
package/src/runtime/run-job.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizeTargets } from "./browser-matrix.js";
|
|
|
8
8
|
import { execute } from "./executor.js";
|
|
9
9
|
import type { FailureAnalysis, FailureContext } from "./failure-analysis.js";
|
|
10
10
|
import { loadDeviceCloudAccess, type DeviceCloudAccess } from "./device-clouds/index.js";
|
|
11
|
+
import { stabilizeAfterPassingRun } from "./journey-editor.js";
|
|
11
12
|
|
|
12
13
|
/** The registry expires a claim 60s after its last update, so a running run renews it well inside that. */
|
|
13
14
|
export const RUN_HEARTBEAT_MS = 30_000;
|
|
@@ -56,6 +57,8 @@ export type ExecuteRunOptions = {
|
|
|
56
57
|
heartbeatMs?: number;
|
|
57
58
|
/** Loads device-cloud credentials and app builds, for runs that target cloud devices. */
|
|
58
59
|
loadDeviceAccess?: () => Promise<DeviceCloudAccess>;
|
|
60
|
+
/** Promotes the journey a passing run proved; replaceable in tests. */
|
|
61
|
+
stabilize?: typeof stabilizeAfterPassingRun;
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
/** How a worker fetches a run's device access from the registry. */
|
|
@@ -112,6 +115,11 @@ export async function executeRegistryRun(
|
|
|
112
115
|
},
|
|
113
116
|
});
|
|
114
117
|
clearInterval(heartbeat);
|
|
118
|
+
// Before reporting done, so a dashboard that reloads the graph on
|
|
119
|
+
// completion already sees the flow this run proved.
|
|
120
|
+
if (result.status === "success") {
|
|
121
|
+
await (options.stabilize ?? stabilizeAfterPassingRun)(cfg, run).catch(() => null);
|
|
122
|
+
}
|
|
115
123
|
await client.updateRun(run.id, {
|
|
116
124
|
workerId,
|
|
117
125
|
status: result.status === "success" ? "success" : result.status === "partial" ? "partial" : "error",
|
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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Gives a flow the secrets it types, from wherever the project keeps them
|
|
2
|
+
// (see secret-sources.ts), for exactly as long as the flow runs: each value is
|
|
3
|
+
// read, attached to that flow's driver context as BUGMOLE_FLOW_SECRET_<NAME>,
|
|
4
|
+
// and registered for redaction. Keeping resolved values off process.env makes
|
|
5
|
+
// concurrent browser/device targets safe.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import type { AppDriver, DriverContext, DriverResult } from "./driver.js";
|
|
9
|
+
import { FLOW_SECRET_ENV_PREFIX, referencedSecretNames } from "./flow-language.js";
|
|
10
|
+
import { redactSecrets, registerSecretValue } from "./secret-redaction.js";
|
|
11
|
+
import { defaultLocalReference, describeReference, resolveSecret, SOURCE_LABEL, type SecretDeps, type SecretReference } from "./secret-sources.js";
|
|
12
|
+
|
|
13
|
+
export type FlowSecretOptions = {
|
|
14
|
+
map: Record<string, SecretReference>;
|
|
15
|
+
projectId: string;
|
|
16
|
+
deps?: SecretDeps;
|
|
17
|
+
/**
|
|
18
|
+
* Look in this machine's secure store for names the map doesn't list (default
|
|
19
|
+
* true). Off on a cloud runner, which has none: the driver reports
|
|
20
|
+
* the missing secret itself.
|
|
21
|
+
*/
|
|
22
|
+
localFallback?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function blocked(message: string): DriverResult {
|
|
26
|
+
return { status: "blocked", message, artifacts: [], failureCode: "secret_unavailable", recoverable: false };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wraps a driver so each flow gets the secrets it references. A name already
|
|
31
|
+
* set in the process environment (BUGMOLE_FLOW_SECRET_<NAME>, or a Bugmole
|
|
32
|
+
* Cloud runner's released secrets) is used as is; resolved names are carried
|
|
33
|
+
* in the individual DriverContext.
|
|
34
|
+
*/
|
|
35
|
+
export function withFlowSecrets(driver: AppDriver, options: FlowSecretOptions): AppDriver {
|
|
36
|
+
return {
|
|
37
|
+
id: driver.id,
|
|
38
|
+
supports: (platform) => driver.supports(platform),
|
|
39
|
+
async run(context: DriverContext): Promise<DriverResult> {
|
|
40
|
+
let content: string;
|
|
41
|
+
try {
|
|
42
|
+
content = await fs.readFile(context.flowPath, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return driver.run(context);
|
|
45
|
+
}
|
|
46
|
+
const env = options.deps?.env ?? process.env;
|
|
47
|
+
const needed = referencedSecretNames(content).filter((name) => !env[`${FLOW_SECRET_ENV_PREFIX}${name}`]);
|
|
48
|
+
if (!needed.length) return driver.run(context);
|
|
49
|
+
|
|
50
|
+
const values: Record<string, string> = {};
|
|
51
|
+
for (const name of needed) {
|
|
52
|
+
const mapped = options.map[name];
|
|
53
|
+
if (!mapped && options.localFallback === false) continue;
|
|
54
|
+
const reference = mapped ?? defaultLocalReference(options.projectId, name);
|
|
55
|
+
try {
|
|
56
|
+
values[name] = await resolveSecret(reference, options.deps);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
59
|
+
return blocked(
|
|
60
|
+
`The flow types {{secret.${name}}}, but ${SOURCE_LABEL[reference.kind]} (${describeReference(reference)}) couldn't supply it: ${redactSecrets(reason)}.`
|
|
61
|
+
+ (reference.kind === "local" ? ` Store it with: bugmole secrets set ${name}` : ""),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Keep resolved values on this flow's context instead of mutating the
|
|
67
|
+
// process environment. Browser/device matrices run flows concurrently;
|
|
68
|
+
// global variables would let one target remove another target's secret.
|
|
69
|
+
const environment = { ...(context.environment ?? {}) };
|
|
70
|
+
for (const [name, value] of Object.entries(values)) {
|
|
71
|
+
environment[`${FLOW_SECRET_ENV_PREFIX}${name}`] = value;
|
|
72
|
+
registerSecretValue(value);
|
|
73
|
+
}
|
|
74
|
+
return driver.run({ ...context, environment });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|