@bugmole/cli 0.4.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/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// valkyrie-runner: starts one Cloudflare Container per Bugmole Cloud job
|
|
2
|
+
// (a test run or a discovery task).
|
|
3
|
+
// Only the registry calls it, through a service binding; it has no public
|
|
4
|
+
// route. The container runs `bugmole run-once` and exits when the run is done.
|
|
5
|
+
import { Container } from "@cloudflare/containers";
|
|
6
|
+
|
|
7
|
+
export type LaunchRequest = { kind: "run" | "task"; id: string; projectId: string; token: string; registryUrl: string };
|
|
8
|
+
|
|
9
|
+
type RunnerEnv = {
|
|
10
|
+
CLOUD_RUNNER: DurableObjectNamespace<CloudRunner>;
|
|
11
|
+
/** Longest a container may live, in minutes; a little above the registry's run limit. */
|
|
12
|
+
RUNNER_HARD_LIMIT_MINUTES?: string;
|
|
13
|
+
/** Browsers one run drives at once; sized to the instance type. */
|
|
14
|
+
RUNNER_TARGET_CONCURRENCY?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type LaunchRecord = LaunchRequest & { startedAt: number };
|
|
18
|
+
|
|
19
|
+
const DEFAULT_HARD_LIMIT_MINUTES = 70;
|
|
20
|
+
|
|
21
|
+
export function isLaunchRequest(value: unknown): value is LaunchRequest {
|
|
22
|
+
const body = value as Partial<LaunchRequest> | null;
|
|
23
|
+
return Boolean(
|
|
24
|
+
body
|
|
25
|
+
&& (body.kind === "run" || body.kind === "task")
|
|
26
|
+
&& typeof body.id === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(body.id)
|
|
27
|
+
&& typeof body.projectId === "string" && body.projectId.length > 0
|
|
28
|
+
&& typeof body.token === "string" && body.token.startsWith("vrt_")
|
|
29
|
+
&& typeof body.registryUrl === "string" && /^https:\/\//.test(body.registryUrl),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class CloudRunner extends Container<RunnerEnv> {
|
|
34
|
+
// The run needs the customer's site and the registry.
|
|
35
|
+
enableInternet = true;
|
|
36
|
+
// Checked every couple of minutes while the run goes; see onActivityExpired.
|
|
37
|
+
sleepAfter = "2m";
|
|
38
|
+
|
|
39
|
+
async launch(request: LaunchRequest): Promise<void> {
|
|
40
|
+
const record: LaunchRecord = { ...request, startedAt: Date.now() };
|
|
41
|
+
await this.ctx.storage.put("launch", record);
|
|
42
|
+
await this.start({
|
|
43
|
+
envVars: {
|
|
44
|
+
BUGMOLE_REGISTRY_URL: request.registryUrl,
|
|
45
|
+
...(request.kind === "run" ? { BUGMOLE_RUN_ID: request.id } : { BUGMOLE_TASK_ID: request.id }),
|
|
46
|
+
BUGMOLE_RUN_TOKEN: request.token,
|
|
47
|
+
BUGMOLE_TARGET_CONCURRENCY: this.env.RUNNER_TARGET_CONCURRENCY ?? "2",
|
|
48
|
+
},
|
|
49
|
+
labels: { job: `${request.kind}:${request.id}`, project: request.projectId },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A run makes no requests to its container, so the idle timer would stop it
|
|
55
|
+
* mid-run. Keep it alive until the process exits on its own, with a hard cap
|
|
56
|
+
* in case it never does.
|
|
57
|
+
*/
|
|
58
|
+
override async onActivityExpired(): Promise<void> {
|
|
59
|
+
const record = await this.ctx.storage.get<LaunchRecord>("launch");
|
|
60
|
+
const limit = (Number(this.env.RUNNER_HARD_LIMIT_MINUTES) || DEFAULT_HARD_LIMIT_MINUTES) * 60_000;
|
|
61
|
+
if (!record || Date.now() - record.startedAt > limit) {
|
|
62
|
+
await this.destroy();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.renewActivityTimeout();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `bugmole run-once` reports its own result and exits 0 or 1. Any other exit
|
|
70
|
+
* (killed, out of memory) never reached the registry, so say so here rather
|
|
71
|
+
* than wait for the registry to notice the silence.
|
|
72
|
+
*/
|
|
73
|
+
override async onStop({ exitCode }: { exitCode: number; reason: string }): Promise<void> {
|
|
74
|
+
const record = await this.ctx.storage.get<LaunchRecord>("launch");
|
|
75
|
+
await this.ctx.storage.delete("launch");
|
|
76
|
+
if (!record || exitCode === 0 || exitCode === 1) return;
|
|
77
|
+
const errorMessage = `The cloud runner stopped unexpectedly (exit code ${exitCode}).`;
|
|
78
|
+
const path = record.kind === "run" ? `runs/${encodeURIComponent(record.id)}` : `tasks/${encodeURIComponent(record.id)}`;
|
|
79
|
+
const body = record.kind === "run"
|
|
80
|
+
? { workerId: `cloud:${record.id}`, status: "error", eventType: "failed", failureCode: "runner_crashed", errorMessage }
|
|
81
|
+
: { workerId: `cloud:task:${record.id}`, status: "error", errorMessage };
|
|
82
|
+
await fetch(`${record.registryUrl}/api/${path}`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { authorization: `Bearer ${record.token}`, "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
}).catch(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default {
|
|
91
|
+
async fetch(request: Request, env: RunnerEnv): Promise<Response> {
|
|
92
|
+
const url = new URL(request.url);
|
|
93
|
+
const match = url.pathname.match(/^\/jobs\/(run|task)\/([^/]+)\/start$/);
|
|
94
|
+
if (request.method !== "POST" || !match) return Response.json({ error: "Not found" }, { status: 404 });
|
|
95
|
+
const body = await request.json().catch(() => null);
|
|
96
|
+
if (!isLaunchRequest(body) || body.kind !== match[1] || body.id !== decodeURIComponent(match[2])) {
|
|
97
|
+
return Response.json({ error: "A job kind and id, project, runner token and registry URL are required" }, { status: 422 });
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
// One container per job: the job names the instance.
|
|
101
|
+
await env.CLOUD_RUNNER.get(env.CLOUD_RUNNER.idFromName(`${body.kind}:${body.id}`)).launch(body);
|
|
102
|
+
return Response.json({ started: true }, { status: 202 });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`[runner] couldn't start ${body.kind} ${body.id}`, error);
|
|
105
|
+
return Response.json({ error: "The runner couldn't start" }, { status: 503 });
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { callModel } from "./llm.js";
|
|
2
|
+
import type { FailureAnalysis, FailureContext } from "./failure-analysis.js";
|
|
3
|
+
|
|
4
|
+
type LlmConfig = { provider?: string; model?: string; api_key?: string; base_url?: string };
|
|
5
|
+
|
|
6
|
+
const API_PROVIDERS = new Set(["openai", "anthropic", "ollama"]);
|
|
7
|
+
|
|
8
|
+
/** The model a project configured for analysis, or null when none is usable. */
|
|
9
|
+
export function analysisModel(cfg: { llm?: LlmConfig } | undefined): Required<Pick<LlmConfig, "provider" | "model">> & LlmConfig | null {
|
|
10
|
+
const llm = cfg?.llm;
|
|
11
|
+
const provider = llm?.provider?.toLowerCase();
|
|
12
|
+
if (!provider || !API_PROVIDERS.has(provider)) return null;
|
|
13
|
+
const apiKey = llm?.api_key?.trim();
|
|
14
|
+
if (provider !== "ollama" && (!apiKey || apiKey === "not-set")) return null;
|
|
15
|
+
return { ...llm, provider, model: llm?.model || (provider === "anthropic" ? "claude-sonnet-5" : "gpt-4o-mini") };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseJson(text: string): Record<string, unknown> | null {
|
|
19
|
+
const body = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/, "");
|
|
20
|
+
const start = body.indexOf("{");
|
|
21
|
+
const end = body.lastIndexOf("}");
|
|
22
|
+
if (start < 0 || end <= start) return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(body.slice(start, end + 1)) as Record<string, unknown>;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const clip = (value: unknown, max: number) => (typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined);
|
|
31
|
+
|
|
32
|
+
export async function explainWithModel(
|
|
33
|
+
cfg: { llm?: LlmConfig } | undefined,
|
|
34
|
+
analysis: FailureAnalysis,
|
|
35
|
+
context: FailureContext,
|
|
36
|
+
call: typeof callModel = callModel,
|
|
37
|
+
): Promise<FailureAnalysis> {
|
|
38
|
+
const model = analysisModel(cfg);
|
|
39
|
+
if (!model) return analysis;
|
|
40
|
+
return explainWith(async (prompt) => ({
|
|
41
|
+
text: await call(prompt, {
|
|
42
|
+
provider: model.provider,
|
|
43
|
+
model: model.model,
|
|
44
|
+
apiKey: model.api_key,
|
|
45
|
+
baseUrl: model.base_url,
|
|
46
|
+
timeout: 20_000,
|
|
47
|
+
}),
|
|
48
|
+
// The model name is what people recognise; the provider is only the API shape.
|
|
49
|
+
model: model.model,
|
|
50
|
+
}), analysis, context);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function explanationPrompt(analysis: FailureAnalysis, context: FailureContext): string {
|
|
54
|
+
return [
|
|
55
|
+
"You are a QA engineer explaining why an end-to-end browser test failed.",
|
|
56
|
+
"Use only the evidence below. Be specific and brief. Do not invent steps or values.",
|
|
57
|
+
'Return JSON only: {"title": string (max 70 chars), "rootCause": string (max 2 sentences), "fixSummary": string (max 1 sentence, or "" if no fix)}.',
|
|
58
|
+
"",
|
|
59
|
+
`Failed step: ${context.failedStep ?? "(unknown)"}`,
|
|
60
|
+
`Error: ${context.error}`,
|
|
61
|
+
`Page: ${context.url ?? ""} ${context.title ?? ""}`,
|
|
62
|
+
`Visible messages: ${JSON.stringify(context.alerts)}`,
|
|
63
|
+
`Empty or invalid fields: ${JSON.stringify(context.invalidFields)}`,
|
|
64
|
+
`Controls and headings: ${JSON.stringify(context.candidates.slice(0, 40))}`,
|
|
65
|
+
`Uncaught page errors: ${JSON.stringify(context.pageErrors.slice(0, 3))}`,
|
|
66
|
+
`Rule-based finding: ${JSON.stringify({ category: analysis.category, title: analysis.title, rootCause: analysis.rootCause, fix: analysis.suggestedFix?.summary })}`,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lets a model explain a failure in plain words. The model only rewrites the
|
|
72
|
+
* explanation: the evidence and any fix stay the rules' checked output, so a
|
|
73
|
+
* model can never propose an edit nobody verified here. Falls back to the
|
|
74
|
+
* rules' analysis on any error.
|
|
75
|
+
*/
|
|
76
|
+
export async function explainWith(
|
|
77
|
+
call: (prompt: string) => Promise<{ text: string; model: string }>,
|
|
78
|
+
analysis: FailureAnalysis,
|
|
79
|
+
context: FailureContext,
|
|
80
|
+
): Promise<FailureAnalysis> {
|
|
81
|
+
try {
|
|
82
|
+
const answer = await call(explanationPrompt(analysis, context));
|
|
83
|
+
const reply = parseJson(answer.text);
|
|
84
|
+
if (!reply) return analysis;
|
|
85
|
+
const fixSummary = clip(reply.fixSummary, 200);
|
|
86
|
+
return {
|
|
87
|
+
...analysis,
|
|
88
|
+
source: "ai",
|
|
89
|
+
model: answer.model,
|
|
90
|
+
title: clip(reply.title, 90) ?? analysis.title,
|
|
91
|
+
rootCause: clip(reply.rootCause, 400) ?? analysis.rootCause,
|
|
92
|
+
...(analysis.suggestedFix && fixSummary ? { suggestedFix: { ...analysis.suggestedFix, summary: fixSummary } } : {}),
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return analysis;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { explorationPrompt, parseExploration } from "./ai-exploration.js";
|
|
4
|
+
import type { DiscoveryResult } from "./site-discovery.js";
|
|
5
|
+
|
|
6
|
+
const result: DiscoveryResult = {
|
|
7
|
+
baseUrl: "https://shop.test",
|
|
8
|
+
pages: [{ route: "/", url: "https://shop.test/", title: "Shop", path: [], links: [], forms: [] }],
|
|
9
|
+
flows: [],
|
|
10
|
+
events: [],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
test("the prompt names the persona and its instructions when one is given", () => {
|
|
14
|
+
const withPersona = explorationPrompt(result, { name: "Impatient power user", description: "Skip straight to checkout; ignore marketing pages." });
|
|
15
|
+
assert.match(withPersona, /Impatient power user: Skip straight to checkout; ignore marketing pages\./);
|
|
16
|
+
|
|
17
|
+
const withoutPersona = explorationPrompt(result);
|
|
18
|
+
assert.doesNotMatch(withoutPersona, /point of view/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("parseExploration only accepts well-formed journeys with at least two steps", () => {
|
|
22
|
+
const reply = JSON.stringify({
|
|
23
|
+
journeys: [
|
|
24
|
+
{ name: "Sign up then buy", steps: [{ openLink: "/signup" }, { assertVisible: "Welcome" }] },
|
|
25
|
+
{ name: "Too short", steps: [{ openLink: "/x" }] },
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
const flows = parseExploration(reply, "https://shop.test", []);
|
|
29
|
+
assert.equal(flows.length, 1);
|
|
30
|
+
assert.equal(flows[0].name, "Sign up then buy");
|
|
31
|
+
assert.equal(flows[0].kind, "journey");
|
|
32
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// AI exploration: after the crawler maps an app, a model writes the
|
|
2
|
+
// multi-page journeys a person would actually take (sign up then buy, search
|
|
3
|
+
// then filter). Every journey is parsed before it's saved.
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { parseMaestroStyleFlow } from "./flow-language.js";
|
|
6
|
+
import { extractJson } from "./platform-ai.js";
|
|
7
|
+
import { slugify, type DiscoveredFlow, type DiscoveryResult } from "./site-discovery.js";
|
|
8
|
+
|
|
9
|
+
const MAX_JOURNEYS = 8;
|
|
10
|
+
const MAX_STEPS = 40;
|
|
11
|
+
|
|
12
|
+
export type ExplorationPersona = { name: string; description: string };
|
|
13
|
+
|
|
14
|
+
export function explorationPrompt(result: DiscoveryResult, persona?: ExplorationPersona): string {
|
|
15
|
+
const pages = result.pages.slice(0, 30).map((page) => ({
|
|
16
|
+
route: page.route,
|
|
17
|
+
title: page.title,
|
|
18
|
+
heading: page.heading,
|
|
19
|
+
reachedBy: page.path,
|
|
20
|
+
links: page.links.slice(0, 15).map((link) => link.label),
|
|
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
|
+
}));
|
|
23
|
+
return [
|
|
24
|
+
"You are a QA engineer. Below is a map of a web app a crawler just explored.",
|
|
25
|
+
...(persona ? [`Decide what's worth testing from this persona's point of view — ${persona.name}: ${persona.description}`] : []),
|
|
26
|
+
`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
|
+
"Use obvious sample data (qa@example.com, Bugmole). Write passwords as \"{{secret.APP_PASSWORD}}\". Never submit destructive actions (delete, cancel, unsubscribe).",
|
|
29
|
+
"Steps: openLink: \"/path\"; tapOn: \"<visible text>\"; inputText: \"<text>\"; assertVisible: \"<text>\"; scrollUntilVisible: { element: \"<text>\" }.",
|
|
30
|
+
"Every journey starts with openLink and ends with an assertVisible that proves it worked.",
|
|
31
|
+
'Answer with JSON only: {"journeys": [{"name": "<short name>", "steps": [<step>, ...]}]}',
|
|
32
|
+
"",
|
|
33
|
+
`App: ${result.baseUrl}`,
|
|
34
|
+
JSON.stringify(pages),
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseExploration(reply: string, baseUrl: string, existing: readonly DiscoveredFlow[]): DiscoveredFlow[] {
|
|
39
|
+
const data = extractJson(reply) as { journeys?: unknown } | null;
|
|
40
|
+
if (!data || !Array.isArray(data.journeys)) return [];
|
|
41
|
+
const taken = new Set(existing.map((flow) => flow.id));
|
|
42
|
+
const flows: DiscoveredFlow[] = [];
|
|
43
|
+
for (const journey of (data.journeys as Array<Record<string, unknown>>).slice(0, MAX_JOURNEYS)) {
|
|
44
|
+
const name = typeof journey?.name === "string" ? journey.name.trim().slice(0, 80) : "";
|
|
45
|
+
const steps = Array.isArray(journey?.steps) ? journey.steps.slice(0, MAX_STEPS) : [];
|
|
46
|
+
if (!name || steps.length < 2) continue;
|
|
47
|
+
const content = `url: ${baseUrl}\nname: ${JSON.stringify(name)}\n---\n${YAML.stringify(steps)}`;
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parseMaestroStyleFlow(content, baseUrl);
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let id = `journey-${slugify(name)}`;
|
|
55
|
+
for (let suffix = 2; taken.has(id); suffix += 1) id = `journey-${slugify(name)}-${suffix}`;
|
|
56
|
+
taken.add(id);
|
|
57
|
+
const route = steps.find((step: unknown) => typeof (step as { openLink?: unknown })?.openLink === "string") as { openLink: string } | undefined;
|
|
58
|
+
flows.push({
|
|
59
|
+
id,
|
|
60
|
+
name,
|
|
61
|
+
kind: "journey",
|
|
62
|
+
route: route?.openLink.startsWith("/") ? route.openLink : "/",
|
|
63
|
+
file: `flows/discovered/${id}.yaml`,
|
|
64
|
+
content,
|
|
65
|
+
steps: parsed.steps.map((step) => step.type),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return flows;
|
|
69
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// AI test repair: when the rules can't propose a fix, ask a model for flow
|
|
2
|
+
// edits. Its answer is only accepted as edits applyFlowChanges can check and
|
|
3
|
+
// the flow parser accepts, so a wrong answer fails loudly instead of silently
|
|
4
|
+
// rewriting the test.
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { applyFlowChanges, rawFlowSteps, stepSignature, type FailureAnalysis, type FailureContext, type FlowChange } from "./failure-analysis.js";
|
|
7
|
+
import { parseMaestroStyleFlow } from "./flow-language.js";
|
|
8
|
+
import { extractJson } from "./platform-ai.js";
|
|
9
|
+
|
|
10
|
+
const STEP_REFERENCE = `Steps (YAML objects): launchApp; openLink: "<url or /path>"; tapOn: "<visible text>"; inputText: "<text>";
|
|
11
|
+
eraseText; pressKey: "Enter"; assertVisible: "<text>"; assertNotVisible: "<text>"; assertClickable: "<text>";
|
|
12
|
+
scrollUntilVisible: { element: "<text>" }; wait: <ms>. Secrets are written as "{{secret.NAME}}".`;
|
|
13
|
+
|
|
14
|
+
export function repairPrompt(flow: string, context: FailureContext, analysis?: FailureAnalysis): string {
|
|
15
|
+
const steps = rawFlowSteps(flow);
|
|
16
|
+
return [
|
|
17
|
+
"You repair end-to-end browser tests written as a list of steps. The app is probably fine; the test is out of date.",
|
|
18
|
+
"Propose the smallest edit to the step list that makes the failing step pass, using only what the page shows.",
|
|
19
|
+
"Never invent credentials or personal data. Use obvious sample values (qa@example.com) or existing {{secret.NAME}} references.",
|
|
20
|
+
STEP_REFERENCE,
|
|
21
|
+
'Answer with JSON only: {"summary": "<one sentence>", "changes": [ {"op": "replace", "index": <0-based>, "step": <step>} | {"op": "insert", "index": <0-based position>, "steps": [<step>, ...]} ]}',
|
|
22
|
+
'If the app itself is broken and the test is right, answer {"summary": "<why>", "changes": []}.',
|
|
23
|
+
"",
|
|
24
|
+
"Current steps (0-based index: step):",
|
|
25
|
+
...steps.map((step, index) => `${index}: ${JSON.stringify(step)}`),
|
|
26
|
+
"",
|
|
27
|
+
`Failed step index: ${context.failedStepIndex}`,
|
|
28
|
+
`Error: ${context.error}`,
|
|
29
|
+
`Page: ${context.url ?? ""} ${context.title ?? ""}`,
|
|
30
|
+
`Visible messages: ${JSON.stringify(context.alerts)}`,
|
|
31
|
+
`Empty or invalid fields: ${JSON.stringify(context.invalidFields)}`,
|
|
32
|
+
`Controls and headings on the page: ${JSON.stringify(context.candidates.slice(0, 60))}`,
|
|
33
|
+
analysis ? `Earlier diagnosis: ${JSON.stringify({ category: analysis.category, title: analysis.title, rootCause: analysis.rootCause })}` : "",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function describeRaw(raw: unknown): string {
|
|
38
|
+
return YAML.stringify([raw]).trim().replace(/^- /, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Turns a model reply into a checked fix. Returns null when the reply isn't a
|
|
43
|
+
* usable edit (malformed, empty, or rejected by the flow checker).
|
|
44
|
+
*/
|
|
45
|
+
export function parseRepair(reply: string, flow: string): NonNullable<FailureAnalysis["suggestedFix"]> | null {
|
|
46
|
+
const data = extractJson(reply) as { summary?: unknown; changes?: unknown } | null;
|
|
47
|
+
if (!data || !Array.isArray(data.changes) || data.changes.length === 0 || data.changes.length > 10) return null;
|
|
48
|
+
const parsedSteps = parseMaestroStyleFlow(flow, "http://localhost").steps;
|
|
49
|
+
const raw = rawFlowSteps(flow);
|
|
50
|
+
const changes: FlowChange[] = [];
|
|
51
|
+
const preview: Array<{ kind: "add" | "remove"; text: string }> = [];
|
|
52
|
+
for (const item of data.changes as Array<Record<string, unknown>>) {
|
|
53
|
+
const index = Number(item?.index);
|
|
54
|
+
if (!Number.isInteger(index)) return null;
|
|
55
|
+
if (item.op === "replace" && item.step !== undefined) {
|
|
56
|
+
const current = parsedSteps[index];
|
|
57
|
+
if (!current) return null;
|
|
58
|
+
changes.push({ op: "replace", index, expect: stepSignature(current), step: item.step });
|
|
59
|
+
preview.push({ kind: "remove", text: describeRaw(raw[index]) }, { kind: "add", text: describeRaw(item.step) });
|
|
60
|
+
} else if (item.op === "insert" && Array.isArray(item.steps) && item.steps.length > 0 && item.steps.length <= 10) {
|
|
61
|
+
changes.push({ op: "insert", index, steps: item.steps });
|
|
62
|
+
preview.push(...item.steps.map((step) => ({ kind: "add" as const, text: describeRaw(step) })));
|
|
63
|
+
} else {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
applyFlowChanges(flow, changes);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const summary = typeof data.summary === "string" && data.summary.trim() ? data.summary.trim().slice(0, 200) : "AI-proposed change to the failing steps.";
|
|
73
|
+
return { summary, changes, preview };
|
|
74
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parseRepair, repairPrompt } from "./ai-repair.js";
|
|
4
|
+
import { explorationPrompt, parseExploration } from "./ai-exploration.js";
|
|
5
|
+
import { extractJson, modelCaller, platformModelCall } from "./platform-ai.js";
|
|
6
|
+
import { anthropicMessagesUrl } from "./llm.js";
|
|
7
|
+
import type { FailureContext } from "./failure-analysis.js";
|
|
8
|
+
|
|
9
|
+
const flow = `url: https://shop.test\nname: Checkout\n---\n- launchApp\n- tapOn: "Buy now"\n- tapOn: "Continue to payment"\n- assertVisible: "Payment"\n`;
|
|
10
|
+
const context: FailureContext = {
|
|
11
|
+
failedStepIndex: 3,
|
|
12
|
+
error: 'Timed out waiting for "Payment"',
|
|
13
|
+
pageErrors: [],
|
|
14
|
+
consoleErrors: [],
|
|
15
|
+
candidates: [{ role: "textbox", text: "Email" }, { role: "button", text: "Continue to payment" }],
|
|
16
|
+
alerts: ["Please enter a valid email"],
|
|
17
|
+
invalidFields: [{ label: "Email", type: "email", empty: true }],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test("the repair prompt shows the steps, the failure and the page", () => {
|
|
21
|
+
const prompt = repairPrompt(flow, context);
|
|
22
|
+
assert.match(prompt, /2: \{"tapOn":"Continue to payment"\}/);
|
|
23
|
+
assert.match(prompt, /Please enter a valid email/);
|
|
24
|
+
assert.match(prompt, /Failed step index: 3/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("an AI repair is accepted only as edits the flow checker accepts", () => {
|
|
28
|
+
const reply = "Here you go:\n```json\n" + JSON.stringify({
|
|
29
|
+
summary: "Fill in the email before continuing.",
|
|
30
|
+
changes: [{ op: "insert", index: 2, steps: [{ tapOn: "Email" }, { inputText: "qa@example.com" }] }],
|
|
31
|
+
}) + "\n```";
|
|
32
|
+
const fix = parseRepair(reply, flow);
|
|
33
|
+
assert.ok(fix);
|
|
34
|
+
assert.equal(fix.summary, "Fill in the email before continuing.");
|
|
35
|
+
assert.deepEqual(fix.changes, [{ op: "insert", index: 2, steps: [{ tapOn: "Email" }, { inputText: "qa@example.com" }] }]);
|
|
36
|
+
assert.deepEqual(fix.preview.map((line) => line.kind), ["add", "add"]);
|
|
37
|
+
|
|
38
|
+
const replace = parseRepair(JSON.stringify({ summary: "Renamed", changes: [{ op: "replace", index: 1, step: { tapOn: "Buy" } }] }), flow);
|
|
39
|
+
assert.equal(replace?.changes[0].op, "replace");
|
|
40
|
+
assert.equal(replace?.changes[0].op === "replace" && replace.changes[0].expect.length > 0, true);
|
|
41
|
+
|
|
42
|
+
assert.equal(parseRepair(JSON.stringify({ changes: [{ op: "insert", index: 99, steps: [{ tapOn: "x" }] }] }), flow), null);
|
|
43
|
+
assert.equal(parseRepair(JSON.stringify({ changes: [{ op: "insert", index: 1, steps: [{ explode: true }] }] }), flow), null);
|
|
44
|
+
assert.equal(parseRepair(JSON.stringify({ summary: "App bug", changes: [] }), flow), null);
|
|
45
|
+
assert.equal(parseRepair("not json", flow), null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("AI journeys are parsed, named uniquely and checked", () => {
|
|
49
|
+
const result = {
|
|
50
|
+
baseUrl: "https://shop.test/",
|
|
51
|
+
pages: [{ route: "/", url: "https://shop.test/", title: "Home", path: [], links: [{ label: "Shop", route: "/shop" }], forms: [] }],
|
|
52
|
+
flows: [{ id: "journey-buy-a-desk", name: "x", kind: "page" as const, route: "/", file: "", content: "", steps: [] }],
|
|
53
|
+
events: [],
|
|
54
|
+
};
|
|
55
|
+
assert.match(explorationPrompt(result), /"links":\["Shop"\]/);
|
|
56
|
+
const flows = parseExploration(JSON.stringify({
|
|
57
|
+
journeys: [
|
|
58
|
+
{ name: "Buy a desk", steps: [{ openLink: "/shop" }, { tapOn: "Buy now" }, { assertVisible: "Payment" }] },
|
|
59
|
+
{ name: "Broken", steps: [{ openLink: "/" }, { teleport: "x" }] },
|
|
60
|
+
{ name: "Too short", steps: [{ openLink: "/" }] },
|
|
61
|
+
],
|
|
62
|
+
}), result.baseUrl, result.flows);
|
|
63
|
+
assert.equal(flows.length, 1);
|
|
64
|
+
assert.equal(flows[0].id, "journey-buy-a-desk-2");
|
|
65
|
+
assert.equal(flows[0].kind, "journey");
|
|
66
|
+
assert.equal(flows[0].route, "/shop");
|
|
67
|
+
assert.match(flows[0].content, /^url: https:\/\/shop\.test\//);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("platform AI goes through the registry with the worker's credential", async () => {
|
|
71
|
+
const requests: Array<{ url: string; auth: string | null; body: any }> = [];
|
|
72
|
+
const doFetch = (async (url: string, init: RequestInit) => {
|
|
73
|
+
requests.push({ url, auth: new Headers(init.headers).get("authorization"), body: JSON.parse(String(init.body)) });
|
|
74
|
+
return Response.json({ text: "ok", model: "claude-sonnet-5" });
|
|
75
|
+
}) as unknown as typeof fetch;
|
|
76
|
+
const env = { BUGMOLE_REGISTRY_URL: "https://registry.test/", BUGMOLE_API_KEY: "vk_1" };
|
|
77
|
+
const callModel = platformModelCall({ projectId: "shop", runId: "run_1", targetId: "chromium" }, env, doFetch);
|
|
78
|
+
assert.ok(callModel);
|
|
79
|
+
assert.deepEqual(await callModel("why?", "analysis"), { text: "ok", model: "claude-sonnet-5", source: "platform" });
|
|
80
|
+
assert.equal(requests[0].url, "https://registry.test/api/ai/complete");
|
|
81
|
+
assert.equal(requests[0].auth, "Bearer vk_1");
|
|
82
|
+
assert.deepEqual(requests[0].body, { purpose: "analysis", prompt: "why?", projectId: "shop", runId: "run_1", targetId: "chromium" });
|
|
83
|
+
|
|
84
|
+
assert.equal(platformModelCall({ projectId: "shop" }, { ...env, BUGMOLE_PLATFORM_AI: "off" }), null);
|
|
85
|
+
assert.equal(platformModelCall({ projectId: "shop" }, {}), null);
|
|
86
|
+
const own = modelCaller({ llm: { provider: "anthropic", api_key: "sk-own" } }, { projectId: "shop" }, env);
|
|
87
|
+
assert.ok(own, "a project's own key wins");
|
|
88
|
+
assert.equal(modelCaller({ llm: { provider: "manual", api_key: "not-set" } }, { projectId: "shop" }, {}), null);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("helpers read model output and gateway URLs", () => {
|
|
92
|
+
assert.deepEqual(extractJson("sure: {\"a\": 1} thanks"), { a: 1 });
|
|
93
|
+
assert.deepEqual(extractJson("[1,2]"), [1, 2]);
|
|
94
|
+
assert.equal(extractJson("nothing"), null);
|
|
95
|
+
assert.equal(anthropicMessagesUrl(), "https://api.anthropic.com/v1/messages");
|
|
96
|
+
assert.equal(anthropicMessagesUrl("https://gw.test/v1"), "https://gw.test/v1/messages");
|
|
97
|
+
assert.equal(anthropicMessagesUrl("https://gw.test/"), "https://gw.test/v1/messages");
|
|
98
|
+
assert.equal(anthropicMessagesUrl("https://gw.test/v1/messages"), "https://gw.test/v1/messages");
|
|
99
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
clampChunkSeconds,
|
|
5
|
+
deviceChunkPath,
|
|
6
|
+
localChunkPath,
|
|
7
|
+
MAX_CHUNK_SECONDS,
|
|
8
|
+
mpdecimateArgs,
|
|
9
|
+
parseDeviceFileSize,
|
|
10
|
+
screenrecordArgs,
|
|
11
|
+
startAndroidScreenRecording,
|
|
12
|
+
} from "./android-screen-record.js";
|
|
13
|
+
|
|
14
|
+
test("returns undefined instead of throwing when adb is unavailable", async () => {
|
|
15
|
+
const recording = await startAndroidScreenRecording("emulator-5554", "definitely-not-a-real-adb-binary");
|
|
16
|
+
assert.equal(recording, undefined);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a chunk can never exceed what screenrecord accepts", () => {
|
|
20
|
+
// screenrecord refuses more than 180s, and asking for more used to mean the
|
|
21
|
+
// run was silently truncated to the first three minutes.
|
|
22
|
+
assert.equal(clampChunkSeconds(600), MAX_CHUNK_SECONDS);
|
|
23
|
+
assert.equal(clampChunkSeconds(undefined), MAX_CHUNK_SECONDS);
|
|
24
|
+
assert.equal(clampChunkSeconds(60), 60);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a nonsensical chunk length falls back rather than recording nothing", () => {
|
|
28
|
+
for (const bad of [0, -30, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
29
|
+
assert.equal(clampChunkSeconds(bad), MAX_CHUNK_SECONDS);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("chunks sort in the order they were recorded", () => {
|
|
34
|
+
// Zero padded so a listing does not put chunk 10 before chunk 2.
|
|
35
|
+
const paths = [1, 2, 10].map((index) => localChunkPath("/runs/abc/run.mp4", index));
|
|
36
|
+
assert.deepEqual(paths, ["/runs/abc/run-001.mp4", "/runs/abc/run-002.mp4", "/runs/abc/run-010.mp4"]);
|
|
37
|
+
assert.deepEqual([...paths].sort(), paths);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the chunk suffix goes before the extension, not after it", () => {
|
|
41
|
+
assert.equal(localChunkPath("/runs/run.mp4", 3), "/runs/run-003.mp4");
|
|
42
|
+
// A base with no extension still gets a usable name.
|
|
43
|
+
assert.equal(localChunkPath("/runs/run", 3), "/runs/run-003");
|
|
44
|
+
// A dot in a directory name is not an extension.
|
|
45
|
+
assert.equal(localChunkPath("/runs/v1.2/run", 3), "/runs/v1.2/run-003");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("each chunk is written to its own path on the device", () => {
|
|
49
|
+
assert.equal(deviceChunkPath(1), "/sdcard/bugmole-run-001.mp4");
|
|
50
|
+
assert.notEqual(deviceChunkPath(1), deviceChunkPath(2));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("screenrecord is given a time limit, so a chunk always ends on its own", () => {
|
|
54
|
+
const args = screenrecordArgs("emulator-5554", "/sdcard/x.mp4", 90);
|
|
55
|
+
assert.deepEqual(args, ["-s", "emulator-5554", "shell", "screenrecord", "--time-limit=90", "/sdcard/x.mp4"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("skipping idle also restamps, so the video actually gets shorter", () => {
|
|
59
|
+
// mpdecimate alone drops the frames but leaves their duration behind: the
|
|
60
|
+
// video stays the same length and merely stutters.
|
|
61
|
+
const args = mpdecimateArgs("in.mp4", "out.mp4");
|
|
62
|
+
const filter = args[args.indexOf("-vf") + 1];
|
|
63
|
+
assert.match(filter, /mpdecimate/);
|
|
64
|
+
assert.match(filter, /setpts/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a settling file is read from stat, and noise is not mistaken for a size", () => {
|
|
68
|
+
// Pulling before screenrecord writes the mp4 trailer produces a file with no
|
|
69
|
+
// moov atom that no player will open, so stop() waits for this to settle.
|
|
70
|
+
assert.equal(parseDeviceFileSize("1842155\n"), 1842155);
|
|
71
|
+
assert.equal(parseDeviceFileSize("0"), 0);
|
|
72
|
+
for (const junk of ["", "stat: unknown file", "No such file or directory"]) {
|
|
73
|
+
assert.equal(parseDeviceFileSize(junk), null);
|
|
74
|
+
}
|
|
75
|
+
});
|