@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
|
@@ -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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { explorationPrompt, parseExploration } from "./ai-exploration.js";
|
|
3
|
+
import { explorationPrompt, parseCheckedExploration, parseExploration } from "./ai-exploration.js";
|
|
4
4
|
import type { DiscoveryResult } from "./site-discovery.js";
|
|
5
5
|
|
|
6
6
|
const result: DiscoveryResult = {
|
|
@@ -30,3 +30,36 @@ test("parseExploration only accepts well-formed journeys with at least two steps
|
|
|
30
30
|
assert.equal(flows[0].name, "Sign up then buy");
|
|
31
31
|
assert.equal(flows[0].kind, "journey");
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
// The real case that prompted the check: app.bugmole.com's sign-in page has one
|
|
35
|
+
// button and no links, and the model wrote journeys through a "Forgot
|
|
36
|
+
// password?" link and a sign-up page that don't exist.
|
|
37
|
+
const signInOnly: DiscoveryResult = {
|
|
38
|
+
baseUrl: "https://app.test",
|
|
39
|
+
pages: [{ route: "/login", url: "https://app.test/login", title: "Bugmole", heading: "Welcome back", path: [], links: [], forms: [], controls: ["Continue with Aegis →"] }],
|
|
40
|
+
flows: [],
|
|
41
|
+
events: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test("the prompt shows the buttons discovery saw, and forbids inventing others", () => {
|
|
45
|
+
const prompt = explorationPrompt(signInOnly);
|
|
46
|
+
assert.match(prompt, /"buttons":\["Continue with Aegis →"\]/);
|
|
47
|
+
assert.match(prompt, /Never invent a page, link or button/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("journeys through controls or pages discovery never saw are set aside with the reason", () => {
|
|
51
|
+
const reply = JSON.stringify({
|
|
52
|
+
journeys: [
|
|
53
|
+
{ name: "Forgot password", steps: [{ openLink: "/login" }, { tapOn: "Forgot password?" }, { assertVisible: "Reset your password" }] },
|
|
54
|
+
{ name: "Sign up", steps: [{ openLink: "/signup" }, { assertVisible: "Create account" }] },
|
|
55
|
+
{ name: "Hidden check", steps: [{ openLink: "/login" }, { assertVisible: "Remember me" }, { tapOn: "Continue with Aegis" }] },
|
|
56
|
+
{ name: "Continue to Aegis", steps: [{ openLink: "/login" }, { assertVisible: "Welcome back" }, { tapOn: "Continue with Aegis" }, { assertVisible: "Sign in to Aegis" }] },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
const { flows, setAside } = parseCheckedExploration(reply, signInOnly);
|
|
60
|
+
assert.deepEqual(flows.map((flow) => flow.name), ["Continue to Aegis"], "the last assertion may be new text: it is the outcome");
|
|
61
|
+
assert.deepEqual(setAside.map((item) => item.name), ["Forgot password", "Sign up", "Hidden check"]);
|
|
62
|
+
assert.match(setAside[0].reason, /taps “Forgot password\?”/);
|
|
63
|
+
assert.match(setAside[1].reason, /opens \/signup/);
|
|
64
|
+
assert.match(setAside[2].reason, /expects “Remember me”/);
|
|
65
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
import { parseMaestroStyleFlow } from "./flow-language.js";
|
|
6
6
|
import { extractJson } from "./platform-ai.js";
|
|
7
|
-
import { slugify, type DiscoveredFlow, type DiscoveryResult } from "./site-discovery.js";
|
|
7
|
+
import { slugify, type DiscoveredFlow, type DiscoveredPage, type DiscoveryResult } from "./site-discovery.js";
|
|
8
8
|
|
|
9
9
|
const MAX_JOURNEYS = 8;
|
|
10
10
|
const MAX_STEPS = 40;
|
|
@@ -19,12 +19,13 @@ export function explorationPrompt(result: DiscoveryResult, persona?: Exploration
|
|
|
19
19
|
reachedBy: page.path,
|
|
20
20
|
links: page.links.slice(0, 15).map((link) => link.label),
|
|
21
21
|
forms: page.forms.map((form) => ({ name: form.name, fields: form.fields.map((field) => `${field.label} (${field.type}${field.required ? ", required" : ""})`), submit: form.submit })),
|
|
22
|
+
...(page.controls?.length ? { buttons: page.controls.slice(0, 20) } : {}),
|
|
22
23
|
}));
|
|
23
24
|
return [
|
|
24
25
|
"You are a QA engineer. Below is a map of a web app a crawler just explored.",
|
|
25
26
|
...(persona ? [`Decide what's worth testing from this persona's point of view — ${persona.name}: ${persona.description}`] : []),
|
|
26
27
|
`Write up to ${MAX_JOURNEYS} end-to-end user journeys worth testing that span several pages (for example: sign up, then buy something).`,
|
|
27
|
-
"Only use pages, links, buttons and fields that appear in the map. Don't repeat single-page visits; those already exist.",
|
|
28
|
+
"Only use pages, links, buttons and fields that appear in the map, with their exact text. Never invent a page, link or button; if the map is too small for a journey, return fewer journeys or none. Don't repeat single-page visits; those already exist.",
|
|
28
29
|
"Use obvious sample data (qa@example.com, Bugmole). Write passwords as \"{{secret.APP_PASSWORD}}\". Never submit destructive actions (delete, cancel, unsubscribe).",
|
|
29
30
|
"Steps: openLink: \"/path\"; tapOn: \"<visible text>\"; inputText: \"<text>\"; assertVisible: \"<text>\"; scrollUntilVisible: { element: \"<text>\" }.",
|
|
30
31
|
"Every journey starts with openLink and ends with an assertVisible that proves it worked.",
|
|
@@ -35,7 +36,99 @@ export function explorationPrompt(result: DiscoveryResult, persona?: Exploration
|
|
|
35
36
|
].join("\n");
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
const normalize = (value: string) => value.toLowerCase().replace(/\s+/g, " ").replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Everything a visitor could see or type into on the explored pages, and the
|
|
43
|
+
* routes that exist. A journey step outside this is something the model made
|
|
44
|
+
* up, however plausible ("Forgot password?" on a page that has no such link).
|
|
45
|
+
*/
|
|
46
|
+
function observedOn(pages: readonly DiscoveredPage[], baseUrl: string) {
|
|
47
|
+
const words = new Set<string>();
|
|
48
|
+
const add = (value?: string) => {
|
|
49
|
+
const normal = value ? normalize(value) : "";
|
|
50
|
+
if (normal) words.add(normal);
|
|
51
|
+
};
|
|
52
|
+
const routes = new Set<string>(["/"]);
|
|
53
|
+
for (const page of pages) {
|
|
54
|
+
routes.add(page.route);
|
|
55
|
+
add(page.title);
|
|
56
|
+
add(page.heading);
|
|
57
|
+
for (const link of page.links) {
|
|
58
|
+
add(link.label);
|
|
59
|
+
add(link.menu);
|
|
60
|
+
routes.add(link.route);
|
|
61
|
+
}
|
|
62
|
+
for (const form of page.forms) {
|
|
63
|
+
add(form.name);
|
|
64
|
+
add(form.submit);
|
|
65
|
+
for (const field of form.fields) add(field.label);
|
|
66
|
+
}
|
|
67
|
+
for (const control of page.controls ?? []) add(control);
|
|
68
|
+
}
|
|
69
|
+
const seen = (text: string) => {
|
|
70
|
+
const target = normalize(text);
|
|
71
|
+
if (!target) return false;
|
|
72
|
+
for (const word of words) {
|
|
73
|
+
if (word === target || (target.length >= 3 && word.includes(target)) || (word.length >= 3 && target.includes(word))) return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
const routeExists = (link: string) => {
|
|
78
|
+
try {
|
|
79
|
+
const url = new URL(link, baseUrl);
|
|
80
|
+
if (url.origin !== new URL(baseUrl).origin) return false;
|
|
81
|
+
return routes.has(url.pathname) || routes.has(url.pathname.replace(/\/$/, "") || "/");
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
return { seen, routeExists };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Why a journey can't be trusted, or null. Taps, scrolls and assertions along
|
|
91
|
+
* the way must name something observed, and every openLink a real route. The
|
|
92
|
+
* last assertVisible may name new text: it is the outcome the journey proves
|
|
93
|
+
* (a confirmation after a form), which discovery, being read-only, never saw.
|
|
94
|
+
*/
|
|
95
|
+
function unobservedStep(steps: readonly unknown[], observed: ReturnType<typeof observedOn>): string | null {
|
|
96
|
+
for (const [index, raw] of steps.entries()) {
|
|
97
|
+
const step = raw as Record<string, unknown>;
|
|
98
|
+
if (typeof step?.openLink === "string" && !observed.routeExists(step.openLink)) return `opens ${step.openLink}, which wasn't found`;
|
|
99
|
+
const tap = typeof step?.tapOn === "string" ? step.tapOn : undefined;
|
|
100
|
+
if (tap && !observed.seen(tap)) return `taps “${tap}”, which isn't on any explored page`;
|
|
101
|
+
const scroll = (step?.scrollUntilVisible as { element?: unknown } | undefined)?.element;
|
|
102
|
+
if (typeof scroll === "string" && !observed.seen(scroll)) return `looks for “${scroll}”, which isn't on any explored page`;
|
|
103
|
+
const assertion = typeof step?.assertVisible === "string" ? step.assertVisible : undefined;
|
|
104
|
+
if (assertion && index < steps.length - 1 && !observed.seen(assertion)) return `expects “${assertion}”, which isn't on any explored page`;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type CheckedExploration = { flows: DiscoveredFlow[]; setAside: Array<{ name: string; reason: string }> };
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parses the model's journeys and keeps only those built from what discovery
|
|
113
|
+
* actually saw on `pages`; the rest are set aside with the reason.
|
|
114
|
+
*/
|
|
115
|
+
export function parseCheckedExploration(reply: string, result: Pick<DiscoveryResult, "baseUrl" | "pages" | "flows">): CheckedExploration {
|
|
116
|
+
const observed = observedOn(result.pages, result.baseUrl);
|
|
117
|
+
const setAside: CheckedExploration["setAside"] = [];
|
|
118
|
+
const flows = parseExploration(reply, result.baseUrl, result.flows, (name, steps) => {
|
|
119
|
+
const reason = unobservedStep(steps, observed);
|
|
120
|
+
if (reason) setAside.push({ name, reason });
|
|
121
|
+
return !reason;
|
|
122
|
+
});
|
|
123
|
+
return { flows, setAside };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function parseExploration(
|
|
127
|
+
reply: string,
|
|
128
|
+
baseUrl: string,
|
|
129
|
+
existing: readonly DiscoveredFlow[],
|
|
130
|
+
accept: (name: string, steps: readonly unknown[]) => boolean = () => true,
|
|
131
|
+
): DiscoveredFlow[] {
|
|
39
132
|
const data = extractJson(reply) as { journeys?: unknown } | null;
|
|
40
133
|
if (!data || !Array.isArray(data.journeys)) return [];
|
|
41
134
|
const taken = new Set(existing.map((flow) => flow.id));
|
|
@@ -44,6 +137,7 @@ export function parseExploration(reply: string, baseUrl: string, existing: reado
|
|
|
44
137
|
const name = typeof journey?.name === "string" ? journey.name.trim().slice(0, 80) : "";
|
|
45
138
|
const steps = Array.isArray(journey?.steps) ? journey.steps.slice(0, MAX_STEPS) : [];
|
|
46
139
|
if (!name || steps.length < 2) continue;
|
|
140
|
+
if (!accept(name, steps)) continue;
|
|
47
141
|
const content = `url: ${baseUrl}\nname: ${JSON.stringify(name)}\n---\n${YAML.stringify(steps)}`;
|
|
48
142
|
let parsed;
|
|
49
143
|
try {
|
|
@@ -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;
|
|
@@ -139,6 +139,12 @@ export class AppiumDriver implements AppDriver {
|
|
|
139
139
|
}
|
|
140
140
|
counter.n += step.steps.length;
|
|
141
141
|
if (lastError) throw lastError;
|
|
142
|
+
} else if (step.type === "assertLayout" || step.type === "assertLinks") {
|
|
143
|
+
// Page checks read the page's layout and links, which a device
|
|
144
|
+
// cloud session doesn't expose; they run in the browser targets.
|
|
145
|
+
steps.push({ sequenceNumber: index, status: "passed", label: describeStepLabel(step), message: "Not checked on device clouds; checked in the browser targets", durationMs: 0 });
|
|
146
|
+
log.push(`Step ${index + 1}: ${describeStepForLog(step)} — skipped (checked in browsers, not on device clouds)`);
|
|
147
|
+
continue;
|
|
142
148
|
} else {
|
|
143
149
|
await this.step(session!, step, mode, flow, context, outDir, artifacts);
|
|
144
150
|
}
|
|
@@ -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
|
+
}
|