@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
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// roles here, and act on Aegis's deprovisioning events.
|
|
4
4
|
import { type Env, memberOfWorkspace, requestJson, response } from "../core.js";
|
|
5
5
|
import { escapeHtml } from "../email/sender.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { entitlementsFor } from "../../billing/plan-catalog.js";
|
|
7
|
+
import { blockedBy, effectiveFeatures, type FeatureState } from "../feature-access.js";
|
|
8
8
|
import { planChangedHooks } from "../hooks.js";
|
|
9
9
|
import { registerJob } from "../jobs/index.js";
|
|
10
10
|
import { enqueue, registerOutboxHandler } from "../notifications/outbox.js";
|
|
@@ -76,12 +76,28 @@ export function losesAccess(previous: Allowed, next: Allowed): boolean {
|
|
|
76
76
|
return (previous.sso && !next.sso) || (previous.scim && !next.scim);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* What the workspace is granted: the shared evaluator's answer, except that a
|
|
81
|
+
* rollout block (kill switch, beta, an incident override) falls back to the
|
|
82
|
+
* plan — it hides the feature without telling Aegis to revoke it.
|
|
83
|
+
*/
|
|
84
|
+
async function grantedFeatures(env: SsoEnv, workspaceId: string) {
|
|
85
|
+
const { plan, features: evaluated } = await effectiveFeatures(env, workspaceId);
|
|
86
|
+
const byPlan = entitlementsFor(plan.plan, plan.overrides).features;
|
|
87
|
+
const granted = (state: FeatureState, planValue: boolean) => blockedBy(state) === "rollout" ? planValue : state.enabled;
|
|
88
|
+
return {
|
|
89
|
+
evaluated,
|
|
90
|
+
features: { sso: granted(evaluated.sso, byPlan.sso), scim: granted(evaluated.scim, byPlan.scim) },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
async function availability(env: SsoEnv, workspaceId: string) {
|
|
80
|
-
const { features } = await
|
|
81
|
-
|
|
95
|
+
const { evaluated, features } = await grantedFeatures(env, workspaceId);
|
|
96
|
+
const ssoBlocked = blockedBy(evaluated.sso);
|
|
97
|
+
if (ssoBlocked === "rollout" || !aegisOrgConfigured(env) || !registryUrl(env)) {
|
|
82
98
|
return { available: false, reason: "Single sign-on isn't available yet.", features };
|
|
83
99
|
}
|
|
84
|
-
if (
|
|
100
|
+
if (ssoBlocked === "plan") return { available: false, reason: "Single sign-on is included in Business and Enterprise.", features };
|
|
85
101
|
return { available: true, features };
|
|
86
102
|
}
|
|
87
103
|
|
|
@@ -251,8 +267,8 @@ async function passthrough(context: AuthedRouteContext, workspace: WorkspaceSsoR
|
|
|
251
267
|
if (!params) continue;
|
|
252
268
|
if (Object.values(params).some((value) => !SAFE_ID.test(value))) return response({ error: "Not found" }, 404);
|
|
253
269
|
if (entry.needs === "scim") {
|
|
254
|
-
const { features } = await
|
|
255
|
-
if (!features.scim) return response({ error: "SCIM provisioning is included in Enterprise." }, 402);
|
|
270
|
+
const { features } = await effectiveFeatures(env, workspace.id);
|
|
271
|
+
if (!features.scim.enabled) return response({ error: "SCIM provisioning is included in Enterprise." }, 402);
|
|
256
272
|
}
|
|
257
273
|
let body: Record<string, unknown> | undefined;
|
|
258
274
|
if (entry.fields) {
|
|
@@ -395,7 +411,7 @@ async function ownerEmails(env: Env, workspaceId: string): Promise<string[]> {
|
|
|
395
411
|
export async function syncSsoEntitlements(env: SsoEnv, workspaceId: string, now = new Date()): Promise<void> {
|
|
396
412
|
const workspace = await workspaceSso(env, workspaceId);
|
|
397
413
|
if (!workspace?.aegisOrgId) return;
|
|
398
|
-
const { features } = await
|
|
414
|
+
const { features } = await grantedFeatures(env, workspaceId);
|
|
399
415
|
const previous = decodeAllowed(workspace.entitlement);
|
|
400
416
|
const allowed = planAllows(features);
|
|
401
417
|
let graceUntil = workspace.graceUntil;
|
|
@@ -255,14 +255,15 @@ test("losing SSO starts a 14-day notice before Aegis turns it off", async () =>
|
|
|
255
255
|
assert.equal(row().ent, "sso");
|
|
256
256
|
assert.equal(row().grace, new Date(now.getTime() + SSO_GRACE_DAYS * 86_400_000).toISOString());
|
|
257
257
|
assert.deepEqual(aegis.org.entitlements, { sso: true, scim: false }, "still on during the notice");
|
|
258
|
-
|
|
258
|
+
// The outbox queues on the real clock, so drain on it too; the synced dates stay fixed.
|
|
259
|
+
await drainOutbox(env, new Date());
|
|
259
260
|
assert.equal(email.sent.length, 1);
|
|
260
261
|
assert.match(email.sent[0].subject, /Single sign-on for Acme ends on 2026-10-15/);
|
|
261
262
|
|
|
262
263
|
// A daily re-sync during the notice changes nothing and sends no second email.
|
|
263
264
|
await syncSsoEntitlements(env, workspaceId, new Date("2026-10-05T00:00:00Z"));
|
|
264
265
|
assert.equal(aegis.org.entitlements.sso, true);
|
|
265
|
-
await drainOutbox(env, new Date(
|
|
266
|
+
await drainOutbox(env, new Date());
|
|
266
267
|
assert.equal(email.sent.length, 1);
|
|
267
268
|
|
|
268
269
|
await syncSsoEntitlements(env, workspaceId, new Date("2026-10-16T00:00:00Z"));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedProject } from "./testing/harness.js";
|
|
4
|
+
|
|
5
|
+
async function inviteAndJoin(env: Parameters<typeof call>[0], inviter: string, workspaceId: string, who: string, role: string) {
|
|
6
|
+
const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: inviter, body: { email: `${who}@example.com`, role } });
|
|
7
|
+
assert.equal(invited.status, 201, JSON.stringify(invited.json));
|
|
8
|
+
const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
|
|
9
|
+
assert.equal((await call(env, "POST", `/api/invitations/${token}/accept`, { as: who })).status, 200);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function taskApi(env: Parameters<typeof call>[0], projectId: string, owner: string) {
|
|
13
|
+
let key = 0;
|
|
14
|
+
return {
|
|
15
|
+
create: (taskType: string, as = owner) => call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
16
|
+
as,
|
|
17
|
+
body: { title: `A ${taskType} task`, description: "Do the thing on https://shop.test", taskType, idempotencyKey: `k${key += 1}` },
|
|
18
|
+
}),
|
|
19
|
+
claim: (taskId: string) => call(env, "POST", `/api/tasks/${taskId}/claim`, { as: owner, body: { workerId: "worker-1" } }),
|
|
20
|
+
approve: (taskId: string, as = owner) => call(env, "POST", `/api/tasks/${taskId}/approve`, { as }),
|
|
21
|
+
setMode: (taskApprovalMode: string, as = owner) => call(env, "PATCH", `/api/projects/${projectId}/settings`, { as, body: { taskApprovalMode } }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("in auto mode, investigations start by themselves and code changes wait for a manager's approval", async () => {
|
|
26
|
+
const { env } = createTestEnv();
|
|
27
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
28
|
+
await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
|
|
29
|
+
const tasks = taskApi(env, projectId, owner);
|
|
30
|
+
|
|
31
|
+
const investigation = (await tasks.create("investigation")).json.task;
|
|
32
|
+
assert.equal(investigation.approvedBy, "auto:auto");
|
|
33
|
+
assert.ok(investigation.approvedAt);
|
|
34
|
+
assert.equal((await tasks.claim(investigation.id)).status, 200);
|
|
35
|
+
|
|
36
|
+
const code = (await tasks.create("code")).json.task;
|
|
37
|
+
assert.equal(code.approvedAt, null, "a code change waits");
|
|
38
|
+
const early = await tasks.claim(code.id);
|
|
39
|
+
assert.equal(early.status, 409);
|
|
40
|
+
assert.match(early.json.error, /waiting for approval/);
|
|
41
|
+
|
|
42
|
+
assert.equal((await tasks.approve(code.id, "user_member")).status, 403, "members can queue tasks but not approve them");
|
|
43
|
+
const approved = await tasks.approve(code.id);
|
|
44
|
+
assert.equal(approved.status, 200, JSON.stringify(approved.json));
|
|
45
|
+
assert.equal(approved.json.task.approvedBy, owner);
|
|
46
|
+
assert.equal((await tasks.approve(code.id)).status, 409, "approving twice");
|
|
47
|
+
assert.equal((await tasks.claim(code.id)).status, 200);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("manual holds every task, extreme holds none, and only managers change the mode", async () => {
|
|
51
|
+
const { env } = createTestEnv();
|
|
52
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
53
|
+
await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
|
|
54
|
+
const tasks = taskApi(env, projectId, owner);
|
|
55
|
+
|
|
56
|
+
assert.equal((await tasks.setMode("manual", "user_member")).status, 403);
|
|
57
|
+
assert.equal((await tasks.setMode("reckless")).status, 422);
|
|
58
|
+
assert.equal((await tasks.setMode("manual")).status, 200);
|
|
59
|
+
const listed = await call(env, "GET", "/api/projects", { as: owner });
|
|
60
|
+
assert.equal(listed.json.projects.find((project: { id: string }) => project.id === projectId).taskApprovalMode, "manual");
|
|
61
|
+
assert.equal((await tasks.create("investigation")).json.task.approvedAt, null, "manual holds even an investigation");
|
|
62
|
+
|
|
63
|
+
assert.equal((await tasks.setMode("extreme")).status, 200);
|
|
64
|
+
const code = (await tasks.create("code")).json.task;
|
|
65
|
+
assert.equal(code.approvedBy, "auto:extreme");
|
|
66
|
+
assert.equal((await tasks.claim(code.id)).status, 200);
|
|
67
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedProject, seedVerifiedDomain } from "./testing/harness.js";
|
|
4
|
+
import { runnerLauncher, type LaunchRequest } from "./runner/dispatch.js";
|
|
5
|
+
|
|
6
|
+
const CLOUD = { RUNNER_TOKEN_SECRET: "runner-test-secret", FEATURE_CLOUD_RUNNERS: "on", REGISTRY_PUBLIC_URL: "https://registry.test" };
|
|
7
|
+
const VERIFIED = { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" };
|
|
8
|
+
|
|
9
|
+
function fakeLauncher() {
|
|
10
|
+
const launches: LaunchRequest[] = [];
|
|
11
|
+
const original = runnerLauncher.start;
|
|
12
|
+
runnerLauncher.start = async (_env, launch) => {
|
|
13
|
+
launches.push(launch);
|
|
14
|
+
};
|
|
15
|
+
return { launches, restore: () => { runnerLauncher.start = original; } };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A project on Bugmole storage (it runs in the cloud) or on the customer's own storage. */
|
|
19
|
+
async function project(storage: "managed" | "byo") {
|
|
20
|
+
const setup = createTestEnv(CLOUD);
|
|
21
|
+
const seeded = await seedProject(setup.env, { storageProvider: storage === "managed" ? "bugmole" : "r2" });
|
|
22
|
+
seedVerifiedDomain(setup.db, seeded.workspaceId, "shop.test");
|
|
23
|
+
const { env, db } = setup;
|
|
24
|
+
const { owner, projectId } = seeded;
|
|
25
|
+
let key = 0;
|
|
26
|
+
const api = {
|
|
27
|
+
create: (taskType: string, extra: Record<string, unknown> = {}) => call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
28
|
+
as: owner,
|
|
29
|
+
headers: VERIFIED,
|
|
30
|
+
body: { title: `A ${taskType} task`, description: "Explore https://shop.test", taskType, idempotencyKey: `k${key += 1}`, ...extra },
|
|
31
|
+
}),
|
|
32
|
+
resume: (taskId: string, body: Record<string, unknown> = {}) => call(env, "POST", `/api/tasks/${taskId}/resume`, { as: owner, headers: VERIFIED, body }),
|
|
33
|
+
approve: (taskId: string) => call(env, "POST", `/api/tasks/${taskId}/approve`, { as: owner }),
|
|
34
|
+
setMode: (taskApprovalMode: string) => call(env, "PATCH", `/api/projects/${projectId}/settings`, { as: owner, body: { taskApprovalMode } }),
|
|
35
|
+
/** Parks a task the way a worker that gave up (or the dispatcher) leaves it. */
|
|
36
|
+
park: (taskId: string, status = "blocked") => {
|
|
37
|
+
db.db.prepare("UPDATE agent_tasks SET status = ?, attempts = 3, error_message = 'gave up' WHERE id = ?").run(status, taskId);
|
|
38
|
+
},
|
|
39
|
+
/** A `bugmole serve` on someone's machine polling the task list with its API key. */
|
|
40
|
+
machinePolls: async () => {
|
|
41
|
+
const created = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "laptop", projectIds: [projectId] } });
|
|
42
|
+
assert.equal(created.status, 201, JSON.stringify(created.json));
|
|
43
|
+
const listed = await call(env, "GET", `/api/projects/${projectId}/tasks`, { token: created.json.token });
|
|
44
|
+
assert.equal(listed.status, 200);
|
|
45
|
+
},
|
|
46
|
+
row: (taskId: string) => db.db.prepare("SELECT status, execution_mode AS executionMode, approved_at AS approvedAt, approved_by AS approvedBy FROM agent_tasks WHERE id = ?").get(taskId) as Record<string, unknown>,
|
|
47
|
+
};
|
|
48
|
+
return { ...setup, ...seeded, api };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("resuming a machine-only task on a cloud project with no machine connected is refused, and says what to do", async () => {
|
|
52
|
+
const { env, owner, projectId, api } = await project("managed");
|
|
53
|
+
const investigation = (await api.create("investigation")).json.task;
|
|
54
|
+
api.park(investigation.id);
|
|
55
|
+
|
|
56
|
+
const refused = await api.resume(investigation.id);
|
|
57
|
+
assert.equal(refused.status, 409);
|
|
58
|
+
assert.equal(refused.json.code, "needs_local_worker");
|
|
59
|
+
assert.equal(refused.json.canRunInCloud, false, "only discovery can run on Bugmole Cloud");
|
|
60
|
+
assert.match(refused.json.error, /npx bugmole serve/);
|
|
61
|
+
assert.equal(api.row(investigation.id).status, "blocked", "not silently queued");
|
|
62
|
+
|
|
63
|
+
// The dashboard's own reads don't count as a machine.
|
|
64
|
+
const dashboardList = await call(env, "GET", `/api/projects/${projectId}/tasks`, { as: owner });
|
|
65
|
+
assert.equal(dashboardList.json.localWorkerSeenAt, null);
|
|
66
|
+
assert.equal((await api.resume(investigation.id)).status, 409);
|
|
67
|
+
|
|
68
|
+
await api.machinePolls();
|
|
69
|
+
const listed = await call(env, "GET", `/api/projects/${projectId}/tasks`, { as: owner });
|
|
70
|
+
assert.ok(listed.json.localWorkerSeenAt, "the dashboard can see a machine is connected");
|
|
71
|
+
const resumed = await api.resume(investigation.id);
|
|
72
|
+
assert.equal(resumed.status, 200, JSON.stringify(resumed.json));
|
|
73
|
+
assert.equal(resumed.json.task.status, "queued");
|
|
74
|
+
assert.equal(resumed.json.task.attempts, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a machine-only discovery on a cloud project can be resumed on Bugmole Cloud, which starts it straight away", async () => {
|
|
78
|
+
const launcher = fakeLauncher();
|
|
79
|
+
try {
|
|
80
|
+
const { api } = await project("managed");
|
|
81
|
+
const discovery = (await api.create("discovery")).json.task;
|
|
82
|
+
assert.equal(discovery.executionMode, "self");
|
|
83
|
+
api.park(discovery.id, "error");
|
|
84
|
+
|
|
85
|
+
const refused = await api.resume(discovery.id);
|
|
86
|
+
assert.equal(refused.status, 409);
|
|
87
|
+
assert.equal(refused.json.canRunInCloud, true);
|
|
88
|
+
assert.match(refused.json.error, /Bugmole Cloud/);
|
|
89
|
+
|
|
90
|
+
assert.equal((await api.resume(discovery.id, { executionMode: "self" })).status, 409, "asking for the machine again changes nothing");
|
|
91
|
+
assert.equal((await api.resume(discovery.id, { executionMode: "elsewhere" })).status, 422);
|
|
92
|
+
|
|
93
|
+
const moved = await api.resume(discovery.id, { executionMode: "cloud" });
|
|
94
|
+
assert.equal(moved.status, 200, JSON.stringify(moved.json));
|
|
95
|
+
assert.equal(moved.json.task.executionMode, "cloud");
|
|
96
|
+
assert.deepEqual(launcher.launches.map((launch) => [launch.kind, launch.id]), [["task", discovery.id]], "handed to the dispatcher on resume");
|
|
97
|
+
assert.equal(api.row(discovery.id).status, "running");
|
|
98
|
+
} finally {
|
|
99
|
+
launcher.restore();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("only discovery can be moved to Bugmole Cloud", async () => {
|
|
104
|
+
const { api } = await project("managed");
|
|
105
|
+
const code = (await api.create("investigation")).json.task;
|
|
106
|
+
api.park(code.id);
|
|
107
|
+
const moved = await api.resume(code.id, { executionMode: "cloud" });
|
|
108
|
+
assert.equal(moved.status, 422);
|
|
109
|
+
assert.match(moved.json.error, /Only discovery/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a cloud task that failed is dispatched again as soon as it's resumed", async () => {
|
|
113
|
+
const launcher = fakeLauncher();
|
|
114
|
+
try {
|
|
115
|
+
const { db, api } = await project("managed");
|
|
116
|
+
const discovery = (await api.create("discovery", { executionMode: "cloud" })).json.task;
|
|
117
|
+
assert.equal(launcher.launches.length, 1);
|
|
118
|
+
// The runner died and the dispatcher failed it, after pushing its next try out.
|
|
119
|
+
db.db.prepare(`
|
|
120
|
+
UPDATE agent_tasks SET status = 'error', lease_expires_at = NULL, dispatch_attempts = 3,
|
|
121
|
+
next_dispatch_at = ?, billed_at = CURRENT_TIMESTAMP, billed_minutes = 2 WHERE id = ?
|
|
122
|
+
`).run(new Date(Date.now() + 3_600_000).toISOString(), discovery.id);
|
|
123
|
+
|
|
124
|
+
const resumed = await api.resume(discovery.id);
|
|
125
|
+
assert.equal(resumed.status, 200, JSON.stringify(resumed.json));
|
|
126
|
+
assert.equal(launcher.launches.length, 2, "not left for the next cron tick");
|
|
127
|
+
const row = db.db.prepare("SELECT status, billed_at AS billedAt, dispatch_attempts AS dispatchAttempts FROM agent_tasks WHERE id = ?").get(discovery.id) as Record<string, unknown>;
|
|
128
|
+
assert.equal(row.status, "running");
|
|
129
|
+
assert.equal(row.billedAt, null, "the new attempt is metered on its own");
|
|
130
|
+
assert.equal(row.dispatchAttempts, 1);
|
|
131
|
+
} finally {
|
|
132
|
+
launcher.restore();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("on a project that keeps its work on a machine, resume still queues ahead of `bugmole serve`", async () => {
|
|
137
|
+
const { api } = await project("byo");
|
|
138
|
+
const investigation = (await api.create("investigation")).json.task;
|
|
139
|
+
api.park(investigation.id);
|
|
140
|
+
const resumed = await api.resume(investigation.id);
|
|
141
|
+
assert.equal(resumed.status, 200, JSON.stringify(resumed.json));
|
|
142
|
+
assert.equal(resumed.json.task.status, "queued");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("resume keeps a person's approval, and lets the project's current mode decide an automatic one", async () => {
|
|
146
|
+
const launcher = fakeLauncher();
|
|
147
|
+
try {
|
|
148
|
+
const { owner, api } = await project("byo");
|
|
149
|
+
const code = (await api.create("code")).json.task;
|
|
150
|
+
assert.equal(code.approvedAt, null);
|
|
151
|
+
assert.equal((await api.approve(code.id)).status, 200);
|
|
152
|
+
const investigation = (await api.create("investigation")).json.task;
|
|
153
|
+
assert.equal(investigation.approvedBy, "auto:auto");
|
|
154
|
+
api.park(code.id);
|
|
155
|
+
api.park(investigation.id);
|
|
156
|
+
|
|
157
|
+
assert.equal((await api.setMode("manual")).status, 200);
|
|
158
|
+
const resumedCode = await api.resume(code.id);
|
|
159
|
+
assert.equal(resumedCode.json.task.approvedBy, owner, "a person already said yes");
|
|
160
|
+
assert.ok(resumedCode.json.task.approvedAt);
|
|
161
|
+
|
|
162
|
+
const resumedInvestigation = await api.resume(investigation.id);
|
|
163
|
+
assert.equal(resumedInvestigation.status, 200);
|
|
164
|
+
assert.equal(resumedInvestigation.json.task.approvedAt, null, "manual mode now holds it for approval");
|
|
165
|
+
assert.equal(resumedInvestigation.json.task.status, "queued");
|
|
166
|
+
assert.equal((await api.approve(investigation.id)).status, 200);
|
|
167
|
+
|
|
168
|
+
assert.equal((await api.setMode("extreme")).status, 200);
|
|
169
|
+
const held = (await api.create("code")).json.task;
|
|
170
|
+
assert.equal(held.approvedBy, "auto:extreme");
|
|
171
|
+
api.park(held.id);
|
|
172
|
+
assert.equal((await api.setMode("auto")).status, 200);
|
|
173
|
+
assert.equal((await api.resume(held.id)).json.task.approvedAt, null, "auto mode holds code changes again");
|
|
174
|
+
} finally {
|
|
175
|
+
launcher.restore();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("a cloud discovery resumed into waiting for approval isn't dispatched until someone approves it", async () => {
|
|
180
|
+
const launcher = fakeLauncher();
|
|
181
|
+
try {
|
|
182
|
+
const { api } = await project("managed");
|
|
183
|
+
const discovery = (await api.create("discovery", { executionMode: "cloud" })).json.task;
|
|
184
|
+
assert.equal(launcher.launches.length, 1);
|
|
185
|
+
api.park(discovery.id, "error");
|
|
186
|
+
assert.equal((await api.setMode("manual")).status, 200);
|
|
187
|
+
const resumed = await api.resume(discovery.id);
|
|
188
|
+
assert.equal(resumed.status, 200);
|
|
189
|
+
assert.equal(resumed.json.task.approvedAt, null);
|
|
190
|
+
assert.equal(launcher.launches.length, 1, "waits for approval");
|
|
191
|
+
assert.equal((await api.approve(discovery.id)).status, 200);
|
|
192
|
+
assert.equal(launcher.launches.length, 2);
|
|
193
|
+
} finally {
|
|
194
|
+
launcher.restore();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedProject } from "./testing/harness.js";
|
|
4
|
+
|
|
5
|
+
async function inviteAndJoin(env: Parameters<typeof call>[0], inviter: string, workspaceId: string, who: string, role: string) {
|
|
6
|
+
const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: inviter, body: { email: `${who}@example.com`, role } });
|
|
7
|
+
assert.equal(invited.status, 201, JSON.stringify(invited.json));
|
|
8
|
+
const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
|
|
9
|
+
assert.equal((await call(env, "POST", `/api/invitations/${token}/accept`, { as: who })).status, 200);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function setup(mode: string) {
|
|
13
|
+
const { env, db } = createTestEnv();
|
|
14
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
15
|
+
assert.equal((await call(env, "PATCH", `/api/projects/${projectId}/settings`, { as: owner, body: { taskApprovalMode: mode } })).status, 200);
|
|
16
|
+
const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "laptop", projectIds: [projectId] } });
|
|
17
|
+
assert.equal(key.status, 201, JSON.stringify(key.json));
|
|
18
|
+
const token: string = key.json.token;
|
|
19
|
+
let n = 0;
|
|
20
|
+
const api = {
|
|
21
|
+
create: async (taskType: string) => {
|
|
22
|
+
const created = await call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
23
|
+
as: owner, body: { title: `A ${taskType} task`, description: "Do the thing on https://shop.test", taskType, idempotencyKey: `k${n += 1}` },
|
|
24
|
+
});
|
|
25
|
+
assert.ok([200, 201].includes(created.status), JSON.stringify(created.json));
|
|
26
|
+
const task = created.json.task;
|
|
27
|
+
if (task.approvedAt === null) assert.equal((await call(env, "POST", `/api/tasks/${task.id}/approve`, { as: owner })).status, 200);
|
|
28
|
+
assert.equal((await call(env, "POST", `/api/tasks/${task.id}/claim`, { token, body: { workerId: "agent-1" } })).status, 200);
|
|
29
|
+
return task.id as string;
|
|
30
|
+
},
|
|
31
|
+
complete: (taskId: string, extra: Record<string, unknown> = {}) =>
|
|
32
|
+
call(env, "POST", `/api/tasks/${taskId}`, { token, body: { workerId: "agent-1", status: "completed", completionSummary: "Done", ...extra } }),
|
|
33
|
+
report: (taskId: string, status: string) => call(env, "POST", `/api/tasks/${taskId}`, { token, body: { workerId: "agent-1", status } }),
|
|
34
|
+
accept: (taskId: string, as: string | { token: string } = owner, body: unknown = {}) =>
|
|
35
|
+
call(env, "POST", `/api/tasks/${taskId}/accept`, typeof as === "string" ? { as, body } : { token: as.token, body }),
|
|
36
|
+
sendBack: (taskId: string, note: string, as = owner) => call(env, "POST", `/api/tasks/${taskId}/send-back`, { as, body: { note } }),
|
|
37
|
+
};
|
|
38
|
+
return { env, db, owner, workspaceId, projectId, token, api };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("manual mode: every completed task goes to review, and a manager accepts it", async () => {
|
|
42
|
+
const { api, owner, db } = await setup("manual");
|
|
43
|
+
const taskId = await api.create("investigation");
|
|
44
|
+
const done = await api.complete(taskId);
|
|
45
|
+
assert.equal(done.status, 200, JSON.stringify(done.json));
|
|
46
|
+
assert.equal(done.json.task.status, "in_review");
|
|
47
|
+
assert.equal(done.json.inReview, true);
|
|
48
|
+
assert.match(done.json.message, /reviews this task/);
|
|
49
|
+
assert.equal(done.json.task.completedAt, null, "not completed until someone accepts it");
|
|
50
|
+
|
|
51
|
+
// Reporting completed again is a no-op; anything else is refused.
|
|
52
|
+
const again = await api.complete(taskId);
|
|
53
|
+
assert.equal(again.status, 200);
|
|
54
|
+
assert.equal(again.json.inReview, true);
|
|
55
|
+
assert.equal((await api.report(taskId, "running")).status, 409, "a heartbeat can't pull it out of review");
|
|
56
|
+
|
|
57
|
+
const accepted = await api.accept(taskId, owner, { note: "Looks right" });
|
|
58
|
+
assert.equal(accepted.status, 200, JSON.stringify(accepted.json));
|
|
59
|
+
assert.equal(accepted.json.task.status, "completed");
|
|
60
|
+
assert.equal(accepted.json.task.reviewedBy, owner);
|
|
61
|
+
assert.ok(accepted.json.task.reviewedAt);
|
|
62
|
+
assert.ok(accepted.json.task.completedAt);
|
|
63
|
+
assert.equal((await api.accept(taskId)).status, 409, "accepting twice");
|
|
64
|
+
|
|
65
|
+
const audit = db.db.prepare("SELECT action FROM audit_log WHERE target_id = ? ORDER BY rowid").all(taskId) as Array<{ action: string }>;
|
|
66
|
+
assert.ok(audit.some((row) => row.action === "task.review_accepted"), JSON.stringify(audit));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("auto mode reviews code and ui tasks only; extreme reviews nothing", async () => {
|
|
70
|
+
const auto = await setup("auto");
|
|
71
|
+
const investigation = await auto.api.create("investigation");
|
|
72
|
+
assert.equal((await auto.api.complete(investigation)).json.task.status, "completed");
|
|
73
|
+
const code = await auto.api.create("code");
|
|
74
|
+
assert.equal((await auto.api.complete(code)).json.task.status, "in_review");
|
|
75
|
+
const ui = await auto.api.create("ui");
|
|
76
|
+
assert.equal((await auto.api.complete(ui, { parityAuditId: "audit-1", parityMatchScore: 97 })).json.task.status, "in_review");
|
|
77
|
+
|
|
78
|
+
const extreme = await setup("extreme");
|
|
79
|
+
const extremeCode = await extreme.api.create("code");
|
|
80
|
+
const done = await extreme.api.complete(extremeCode);
|
|
81
|
+
assert.equal(done.json.task.status, "completed");
|
|
82
|
+
assert.equal(done.json.inReview, undefined);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("sending back queues the task again with the note, keeping its approval", async () => {
|
|
86
|
+
const { api, owner } = await setup("manual");
|
|
87
|
+
const taskId = await api.create("code");
|
|
88
|
+
await api.complete(taskId);
|
|
89
|
+
assert.equal((await api.sendBack(taskId, " ")).status, 422, "a note is required");
|
|
90
|
+
const sent = await api.sendBack(taskId, "The button still overlaps the header on mobile.");
|
|
91
|
+
assert.equal(sent.status, 200, JSON.stringify(sent.json));
|
|
92
|
+
assert.equal(sent.json.task.status, "queued");
|
|
93
|
+
assert.equal(sent.json.task.reviewNote, "The button still overlaps the header on mobile.");
|
|
94
|
+
assert.match(sent.json.task.progressMessage, /Sent back from review/);
|
|
95
|
+
assert.equal(sent.json.task.approvedBy, owner, "the person's approval stands");
|
|
96
|
+
assert.equal(sent.json.task.workerId, null);
|
|
97
|
+
assert.equal(sent.json.task.attempts, 0);
|
|
98
|
+
assert.equal((await api.sendBack(taskId, "again")).status, 409, "only a task in review can be sent back");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("only a person who manages the project reviews: not a member, not an API key", async () => {
|
|
102
|
+
const { env, api, token, workspaceId, owner } = await setup("manual");
|
|
103
|
+
await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
|
|
104
|
+
const taskId = await api.create("code");
|
|
105
|
+
await api.complete(taskId);
|
|
106
|
+
assert.equal((await api.accept(taskId, "user_member")).status, 403);
|
|
107
|
+
assert.equal((await api.sendBack(taskId, "no", "user_member")).status, 403);
|
|
108
|
+
const byKey = await api.accept(taskId, { token });
|
|
109
|
+
assert.equal(byKey.status, 403, "an agent can't accept its own work");
|
|
110
|
+
assert.match(byKey.json.error, /person/);
|
|
111
|
+
assert.equal((await api.accept(taskId, "user_stranger")).status, 404);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a task in review can't be resumed; send it back instead", async () => {
|
|
115
|
+
const { env, api, owner } = await setup("manual");
|
|
116
|
+
const taskId = await api.create("code");
|
|
117
|
+
await api.complete(taskId);
|
|
118
|
+
const resumed = await call(env, "POST", `/api/tasks/${taskId}/resume`, { as: owner, body: {} });
|
|
119
|
+
assert.equal(resumed.status, 409);
|
|
120
|
+
assert.match(resumed.json.error, /review/);
|
|
121
|
+
});
|
|
@@ -124,7 +124,7 @@ export class AppiumDriver implements AppDriver {
|
|
|
124
124
|
for (const rawStep of list) {
|
|
125
125
|
const index = counter.n++;
|
|
126
126
|
const began = Date.now();
|
|
127
|
-
const step = interpolateStep(rawStep, flowContext);
|
|
127
|
+
const step = interpolateStep(rawStep, flowContext, { env: { ...process.env, ...context.environment } });
|
|
128
128
|
try {
|
|
129
129
|
if (step.type === "retry") {
|
|
130
130
|
let lastError: unknown;
|
|
@@ -0,0 +1,74 @@
|
|
|
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 { ControlPlaneClient } from "../registry/control-plane-client.js";
|
|
7
|
+
import { applyAcceptedProposals } from "./apply-proposals.js";
|
|
8
|
+
import { startLocalRegistryStub } from "./local-registry-stub.js";
|
|
9
|
+
|
|
10
|
+
const loginCase = (title: string) => ({
|
|
11
|
+
title, nodeId: "screen:/login", intent: "Prove a person can sign in.",
|
|
12
|
+
steps: ["Open /login", "Sign in"], expected: ["The dashboard opens"], confidence: "high", basis: ["spec"],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("an accepted proposal is written to the project's own storage as a new revision, once", async () => {
|
|
16
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-proposals-"));
|
|
17
|
+
const stub = await startLocalRegistryStub({ directory: path.join(directory, "registry") });
|
|
18
|
+
try {
|
|
19
|
+
const artifacts = path.join(directory, "artifacts");
|
|
20
|
+
const cfg = { project: { id: "demo" }, runtime: { spec_dir: path.join(directory, "spec"), artifacts_dir: artifacts }, storage: { provider: "local", localDirectory: artifacts } };
|
|
21
|
+
const setFile = path.join(artifacts, "projects", "demo", "test-cases", "login.json");
|
|
22
|
+
await fs.mkdir(path.dirname(setFile), { recursive: true });
|
|
23
|
+
await fs.writeFile(setFile, JSON.stringify({ schemaVersion: 1, caseSetId: "login", journeyId: "j", journeyRevision: 1, cases: [{ caseId: "TC-login", ...loginCase("Sign in works") }] }));
|
|
24
|
+
|
|
25
|
+
const client = new ControlPlaneClient({ registryUrl: stub.origin, apiKey: "local", projectId: "demo" });
|
|
26
|
+
const { proposal } = await client.proposeTestCaseChange({ caseSetId: "login", caseId: "TC-login", action: "change", baseRevision: 1, case: loginCase("Sign in with a password works"), summary: "Name the path", source: "cli" });
|
|
27
|
+
assert.equal(proposal.status, "accepted");
|
|
28
|
+
|
|
29
|
+
const first = await applyAcceptedProposals(cfg, client);
|
|
30
|
+
assert.deepEqual(first.map((item) => item.outcome), ["applied"]);
|
|
31
|
+
const stored = JSON.parse(await fs.readFile(setFile, "utf8"));
|
|
32
|
+
assert.deepEqual(stored.cases.map((item: { revision?: number; status?: string; title: string }) => [item.revision ?? 1, item.status ?? "active", item.title]), [
|
|
33
|
+
[1, "obsolete", "Sign in works"],
|
|
34
|
+
[2, "active", "Sign in with a password works"],
|
|
35
|
+
]);
|
|
36
|
+
assert.deepEqual(await applyAcceptedProposals(cfg, client), [], "applied proposals aren't applied again");
|
|
37
|
+
|
|
38
|
+
await client.proposeTestCaseChange({ caseSetId: "login", caseId: "TC-login", action: "retire", baseRevision: 1, summary: "Stale", source: "cli" });
|
|
39
|
+
const stale = await applyAcceptedProposals(cfg, client);
|
|
40
|
+
assert.equal(stale[0].outcome, "outdated", "retiring revision 1 after revision 2 exists");
|
|
41
|
+
} finally {
|
|
42
|
+
await stub.close();
|
|
43
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("bugmole propose reads the revision being changed from storage and refuses a bad file before sending anything", async () => {
|
|
48
|
+
const { proposeFromFile } = await import("./propose-cli.js");
|
|
49
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "bugmole-propose-cli-"));
|
|
50
|
+
const stub = await startLocalRegistryStub({ directory: path.join(directory, "registry") });
|
|
51
|
+
try {
|
|
52
|
+
const artifacts = path.join(directory, "artifacts");
|
|
53
|
+
const cfg = { project: { id: "demo" }, runtime: { artifacts_dir: artifacts }, storage: { provider: "local", localDirectory: artifacts } };
|
|
54
|
+
const setFile = path.join(artifacts, "projects", "demo", "test-cases", "login.json");
|
|
55
|
+
await fs.mkdir(path.dirname(setFile), { recursive: true });
|
|
56
|
+
await fs.writeFile(setFile, JSON.stringify({ schemaVersion: 1, caseSetId: "login", journeyId: "j", journeyRevision: 1, cases: [{ caseId: "TC-login", ...loginCase("Sign in works") }] }));
|
|
57
|
+
const client = new ControlPlaneClient({ registryUrl: stub.origin, apiKey: "local", projectId: "demo" });
|
|
58
|
+
|
|
59
|
+
const bad = path.join(directory, "bad.json");
|
|
60
|
+
await fs.writeFile(bad, JSON.stringify({ caseSetId: "login", summary: "x", changes: [{ caseId: "TC-login", action: "change", case: loginCase("ok") }, { caseId: "TC-nope", action: "retire" }] }));
|
|
61
|
+
await assert.rejects(proposeFromFile(cfg, bad, client), /change 2: TC-nope has no current revision/);
|
|
62
|
+
assert.equal((await client.listUnappliedProposals()).proposals.length, 0, "nothing was sent");
|
|
63
|
+
|
|
64
|
+
const good = path.join(directory, "good.json");
|
|
65
|
+
await fs.writeFile(good, JSON.stringify({ caseSetId: "login", summary: "Name the password path", changes: [{ caseId: "TC-login", action: "change", case: loginCase("Sign in with a password works") }] }));
|
|
66
|
+
const [proposal] = await proposeFromFile(cfg, good, client);
|
|
67
|
+
assert.equal(proposal.baseRevision, 1);
|
|
68
|
+
const stored = JSON.parse(await fs.readFile(setFile, "utf8"));
|
|
69
|
+
assert.equal(stored.cases.find((item: { status?: string }) => item.status === "active").revision, 2, "accepted on arrival locally and applied");
|
|
70
|
+
} finally {
|
|
71
|
+
await stub.close();
|
|
72
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Writes accepted proposals into a project's own storage. On Bugmole storage
|
|
2
|
+
// the registry applies them itself; a project that keeps its files elsewhere
|
|
3
|
+
// has them applied here, by `bugmole serve` or the CLI that proposed them.
|
|
4
|
+
import type { ControlPlaneClient, RegistryProposal } from "../registry/control-plane-client.js";
|
|
5
|
+
import { applyTestCaseChange, type CaseSetDocument, type RevisionedCase, type TestCaseChange } from "../registry/test-case-revisions.js";
|
|
6
|
+
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
7
|
+
import { projectPrefix } from "../storage/keys.js";
|
|
8
|
+
|
|
9
|
+
type Client = Pick<ControlPlaneClient, "listUnappliedProposals" | "reportProposalApplied">;
|
|
10
|
+
|
|
11
|
+
export function changeFromProposal(proposal: RegistryProposal): TestCaseChange {
|
|
12
|
+
const proposed = { ...(proposal.proposed ?? {}), caseId: proposal.caseId } as RevisionedCase;
|
|
13
|
+
if (proposal.action === "add") return { action: "add", case: proposed };
|
|
14
|
+
if (proposal.action === "retire") return { action: "retire", caseId: proposal.caseId, baseRevision: proposal.baseRevision ?? 0 };
|
|
15
|
+
return { action: "change", caseId: proposal.caseId, baseRevision: proposal.baseRevision ?? 0, case: proposed };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Applies every accepted, unapplied proposal; returns what happened to each. */
|
|
19
|
+
export async function applyAcceptedProposals(cfg: any, client: Client): Promise<Array<{ id: string; outcome: "applied" | "outdated"; detail: string }>> {
|
|
20
|
+
if (cfg.storage?.provider === "bugmole") return [];
|
|
21
|
+
const { proposals } = await client.listUnappliedProposals();
|
|
22
|
+
const store = createProjectObjectStore(cfg);
|
|
23
|
+
const projectId = cfg.project?.id ?? "default";
|
|
24
|
+
const done: Array<{ id: string; outcome: "applied" | "outdated"; detail: string }> = [];
|
|
25
|
+
for (const proposal of proposals) {
|
|
26
|
+
const key = `${projectPrefix(projectId)}/test-cases/${proposal.caseSetId}.json`;
|
|
27
|
+
const current = (await store.has(key)) ? JSON.parse(await store.getText(key)) as CaseSetDocument : null;
|
|
28
|
+
const outcome = current
|
|
29
|
+
? applyTestCaseChange(current, changeFromProposal(proposal), { proposalId: proposal.id })
|
|
30
|
+
: { ok: false as const, reason: "missing" as const, message: `There's no case set ${proposal.caseSetId}` };
|
|
31
|
+
if (outcome.ok) {
|
|
32
|
+
await store.put(key, `${JSON.stringify(outcome.caseSet, null, 2)}\n`, "application/json");
|
|
33
|
+
await client.reportProposalApplied(proposal.id, { outcome: "applied", revision: outcome.revision });
|
|
34
|
+
done.push({ id: proposal.id, outcome: "applied", detail: `${proposal.caseId} revision ${outcome.revision}` });
|
|
35
|
+
} else {
|
|
36
|
+
await client.reportProposalApplied(proposal.id, { outcome: "outdated", message: outcome.message });
|
|
37
|
+
done.push({ id: proposal.id, outcome: "outdated", detail: outcome.message });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return done;
|
|
41
|
+
}
|