@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,179 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { AppiumDriver, locatorsFor } from "./appium-driver.js";
|
|
7
|
+
import { normalizeTargets, targetLabel } from "./browser-matrix.js";
|
|
8
|
+
import type { DeviceSessionHost } from "./device-clouds/index.js";
|
|
9
|
+
import { finishedSession, startSession, uploadApp } from "../integrations/device-farm.js";
|
|
10
|
+
import { ELEMENT_KEY } from "../integrations/webdriver-client.js";
|
|
11
|
+
|
|
12
|
+
const PNG = Buffer.from("png").toString("base64");
|
|
13
|
+
|
|
14
|
+
/** A WebDriver hub with a page that has a few labelled elements. */
|
|
15
|
+
function fakeHub(elements: Record<string, { displayed: boolean }>) {
|
|
16
|
+
const calls: Array<{ method: string; path: string; body: any }> = [];
|
|
17
|
+
const typed: string[] = [];
|
|
18
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
19
|
+
const url = new URL(String(input));
|
|
20
|
+
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
21
|
+
const method = init?.method ?? "GET";
|
|
22
|
+
const pathname = url.pathname.replace(/^\/wd\/hub/, "");
|
|
23
|
+
calls.push({ method, path: pathname, body });
|
|
24
|
+
const ok = (value: unknown) => Response.json({ value });
|
|
25
|
+
if (pathname === "/session" && method === "POST") return ok({ sessionId: "S1", capabilities: body.capabilities.alwaysMatch });
|
|
26
|
+
if (pathname === "/session/S1/elements") {
|
|
27
|
+
const match = Object.keys(elements).find((label) => String(body.value).includes(label));
|
|
28
|
+
return ok(match ? [{ [ELEMENT_KEY]: match }] : []);
|
|
29
|
+
}
|
|
30
|
+
const element = pathname.match(/^\/session\/S1\/element\/([^/]+)\/(displayed|click|value|clear)$/);
|
|
31
|
+
if (element) {
|
|
32
|
+
if (element[2] === "displayed") return ok(elements[decodeURIComponent(element[1])]?.displayed ?? false);
|
|
33
|
+
if (element[2] === "value") typed.push(body.text);
|
|
34
|
+
return ok(null);
|
|
35
|
+
}
|
|
36
|
+
if (pathname === "/session/S1/element/active") return ok({ [ELEMENT_KEY]: "Email" });
|
|
37
|
+
if (pathname === "/session/S1/screenshot") return ok(PNG);
|
|
38
|
+
if (pathname === "/session/S1" && method === "DELETE") return ok(null);
|
|
39
|
+
if (pathname === "/session/S1/url") return ok(null);
|
|
40
|
+
return Response.json({ value: { error: "unknown command", message: pathname } }, { status: 404 });
|
|
41
|
+
}) as unknown as typeof fetch;
|
|
42
|
+
return { calls, typed, doFetch };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function workspace(flow: string) {
|
|
46
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bugmole-appium-"));
|
|
47
|
+
const flowPath = path.join(dir, "flow.yaml");
|
|
48
|
+
fs.writeFileSync(flowPath, flow);
|
|
49
|
+
return { dir, flowPath };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const webDevice = { kind: "device", provider: "browserstack", credentialId: "cred_1", platform: "ios", deviceName: "iPhone 15", osVersion: "17", browserName: "safari" };
|
|
53
|
+
|
|
54
|
+
test("device targets are validated and labelled", () => {
|
|
55
|
+
const [target] = normalizeTargets([webDevice, webDevice]);
|
|
56
|
+
assert.equal(target.id, "device-browserstack-iphone-15-17-safari");
|
|
57
|
+
assert.equal(target.browser, "webkit");
|
|
58
|
+
assert.equal(targetLabel(target), "iPhone 15 (17) · Safari");
|
|
59
|
+
assert.throws(() => normalizeTargets([{ ...webDevice, provider: "saucelabs" }]), /Unknown device cloud/);
|
|
60
|
+
assert.throws(() => normalizeTargets([{ ...webDevice, credentialId: undefined }]), /connection/);
|
|
61
|
+
assert.throws(() => normalizeTargets([{ ...webDevice, browserName: undefined }]), /browser .* or an uploaded app/);
|
|
62
|
+
assert.throws(() => normalizeTargets([{ ...webDevice, provider: "bugmole", credentialId: undefined, deviceArn: "arn:aws:s3:::bucket" }]), /isn't a Device Farm device/);
|
|
63
|
+
const bugmole = normalizeTargets([{ kind: "device", provider: "bugmole", platform: "android", deviceName: "Pixel 8", appBuildId: "app_1", real: true }])[0];
|
|
64
|
+
assert.equal(bugmole.cloud?.credentialId, undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("labels are found safely on the web, Android and iOS", () => {
|
|
68
|
+
const web = locatorsFor(`Don't "stop"`, "web");
|
|
69
|
+
assert.ok(web.every((locator) => locator.using === "xpath"));
|
|
70
|
+
assert.match(web[0].value, /concat\('Don', "'", 't "stop"'\)/);
|
|
71
|
+
assert.equal(locatorsFor('Say "hi"', "android")[1].value, 'new UiSelector().text("Say \\"hi\\"")');
|
|
72
|
+
assert.equal(locatorsFor("It's", "ios")[1].value, "label == 'It\\'s' OR name == 'It\\'s' OR value == 'It\\'s'");
|
|
73
|
+
assert.equal(locatorsFor("Order .*", "android")[0].value, 'new UiSelector().textMatches("Order .*")');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a mobile web flow runs on a cloud device and reports each step", async () => {
|
|
77
|
+
const hub = fakeHub({ "Sign in": { displayed: true }, Email: { displayed: true }, Welcome: { displayed: true } });
|
|
78
|
+
const finished: Array<Record<string, unknown>> = [];
|
|
79
|
+
const host: DeviceSessionHost = {
|
|
80
|
+
async open({ device }) {
|
|
81
|
+
assert.equal(device.deviceName, "iPhone 15");
|
|
82
|
+
return {
|
|
83
|
+
hubUrl: "https://user:key@hub.test/wd/hub",
|
|
84
|
+
capabilities: { platformName: "iOS", browserName: "Safari" },
|
|
85
|
+
finish: async (outcome) => {
|
|
86
|
+
finished.push(outcome);
|
|
87
|
+
return { links: [{ label: "Session", url: "https://cloud.test/s/1" }] };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
const { dir, flowPath } = workspace(`url: https://shop.test\n---\n- launchApp\n- tapOn: "Sign in"\n- tapOn: "Email"\n- inputText: "ada@shop.test"\n- assertVisible: "Welcome"\n`);
|
|
93
|
+
const [target] = normalizeTargets([webDevice]);
|
|
94
|
+
const driver = new AppiumDriver({ fetch: hub.doFetch, sleep: async () => undefined, access: { credentials: {}, appBuilds: {}, bugmole: undefined } });
|
|
95
|
+
// The browserstack host is replaced by our fake through the access map's bugmole slot.
|
|
96
|
+
const result = await driver.run({
|
|
97
|
+
runId: "run_1", projectId: "shop", journeyId: "signin", flowPath, platform: "web", target: { baseUrl: "https://shop.test" },
|
|
98
|
+
timeoutMs: 60_000, artifactsDir: dir, runTarget: { ...target, cloud: { ...target.cloud!, provider: "bugmole" } },
|
|
99
|
+
}).catch((error) => error);
|
|
100
|
+
// Without a host for "bugmole" the run is blocked, never crashes.
|
|
101
|
+
assert.equal(result.status, "blocked");
|
|
102
|
+
assert.match(result.message, /only available to runs on Bugmole Cloud/);
|
|
103
|
+
|
|
104
|
+
const withHost = new AppiumDriver({ fetch: hub.doFetch, sleep: async () => undefined, access: { credentials: {}, appBuilds: {}, bugmole: host } });
|
|
105
|
+
const passed = await withHost.run({
|
|
106
|
+
runId: "run_1", projectId: "shop", journeyId: "signin", flowPath, platform: "web", target: { baseUrl: "https://shop.test" },
|
|
107
|
+
timeoutMs: 60_000, artifactsDir: dir, runTarget: { ...target, cloud: { ...target.cloud!, provider: "bugmole", credentialId: undefined } },
|
|
108
|
+
});
|
|
109
|
+
assert.equal(passed.status, "passed", passed.message);
|
|
110
|
+
assert.deepEqual(passed.steps?.map((step) => step.status), ["passed", "passed", "passed", "passed", "passed"]);
|
|
111
|
+
assert.deepEqual(hub.typed, ["ada@shop.test"]);
|
|
112
|
+
const create = hub.calls.find((call) => call.path === "/session")!;
|
|
113
|
+
assert.deepEqual(create.body.capabilities.alwaysMatch, { platformName: "iOS", browserName: "Safari" });
|
|
114
|
+
assert.equal(hub.calls.find((call) => call.path === "/session/S1/url")?.body.url, "https://shop.test");
|
|
115
|
+
assert.ok(hub.calls.some((call) => call.method === "DELETE"));
|
|
116
|
+
assert.deepEqual(finished, [{ passed: true, webdriverSessionId: "S1", reason: undefined }]);
|
|
117
|
+
const log = fs.readFileSync(path.join(dir, "shop", "run_1", "artifacts", passed.artifacts.at(-1)!.path.split("/artifacts/")[1]), "utf8");
|
|
118
|
+
assert.match(log, /Session: https:\/\/cloud\.test\/s\/1/);
|
|
119
|
+
assert.ok(!log.includes("ada@shop.test"), "typed text stays out of the log");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a missing element fails the step, screenshots the device, and tells the cloud", async () => {
|
|
123
|
+
const hub = fakeHub({ Hidden: { displayed: false } });
|
|
124
|
+
const outcomes: Array<{ passed: boolean }> = [];
|
|
125
|
+
const host: DeviceSessionHost = { open: async () => ({ hubUrl: "https://hub.test/wd/hub", capabilities: {}, finish: async (outcome) => { outcomes.push(outcome); return { links: [] }; } }) };
|
|
126
|
+
const { dir, flowPath } = workspace(`url: https://shop.test\n---\n- launchApp\n- tapOn: "Hidden"\n- assertVisible: "Never"\n`);
|
|
127
|
+
const [target] = normalizeTargets([{ ...webDevice, provider: "bugmole", credentialId: undefined }]);
|
|
128
|
+
const driver = new AppiumDriver({ fetch: hub.doFetch, sleep: async () => undefined, defaultTimeoutMs: 0, access: { credentials: {}, appBuilds: {}, bugmole: host } });
|
|
129
|
+
const result = await driver.run({ runId: "r", projectId: "p", journeyId: "j", flowPath, platform: "web", target: {}, timeoutMs: 1, artifactsDir: dir, runTarget: target });
|
|
130
|
+
assert.equal(result.status, "failed");
|
|
131
|
+
assert.equal(result.failureCode, "step_failed");
|
|
132
|
+
assert.match(result.message, /Couldn't find "Hidden"/);
|
|
133
|
+
assert.deepEqual(result.steps?.map((step) => step.status), ["passed", "failed"]);
|
|
134
|
+
assert.ok(result.steps?.[1].screenshotPath?.endsWith("failure.png"));
|
|
135
|
+
assert.deepEqual(outcomes.map((outcome) => outcome.passed), [false]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a native flow without an app build is blocked before a device is booked", async () => {
|
|
139
|
+
const { dir, flowPath } = workspace(`appId: com.shop\n---\n- launchApp\n`);
|
|
140
|
+
const [target] = normalizeTargets([{ kind: "device", provider: "kobiton", credentialId: "c", platform: "android", deviceName: "Pixel 8", appBuildId: "app_missing" }]);
|
|
141
|
+
let opened = false;
|
|
142
|
+
const driver = new AppiumDriver({ access: { credentials: {}, appBuilds: {}, bugmole: { open: async () => { opened = true; throw new Error("no"); } } } });
|
|
143
|
+
const result = await driver.run({ runId: "r", projectId: "p", journeyId: "j", flowPath, platform: "android", target: {}, timeoutMs: 1, artifactsDir: dir, runTarget: target });
|
|
144
|
+
assert.equal(result.status, "blocked");
|
|
145
|
+
assert.equal(result.failureCode, "app_missing");
|
|
146
|
+
assert.equal(opened, false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("Device Farm uploads the app, waits for a device, and reports metered minutes", async () => {
|
|
150
|
+
const calls: string[] = [];
|
|
151
|
+
let polls = 0;
|
|
152
|
+
const doFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
153
|
+
const target = new Headers(init?.headers).get("x-amz-target")?.split(".")[1] ?? `PUT ${String(input)}`;
|
|
154
|
+
calls.push(target);
|
|
155
|
+
assert.ok(target.startsWith("PUT") || new Headers(init?.headers).get("authorization")?.startsWith("AWS4-HMAC-SHA256"));
|
|
156
|
+
const body = init?.body && !target.startsWith("PUT") ? JSON.parse(String(init.body)) : {};
|
|
157
|
+
switch (target) {
|
|
158
|
+
case "CreateUpload": return Response.json({ upload: { arn: "arn:upload", url: "https://s3.test/put" } });
|
|
159
|
+
case "GetUpload": return Response.json({ upload: { status: polls++ ? "SUCCEEDED" : "PROCESSING" } });
|
|
160
|
+
case "CreateRemoteAccessSession":
|
|
161
|
+
assert.equal(body.appArn, "arn:upload");
|
|
162
|
+
assert.deepEqual(body.configuration, { parameters: { "appium:version": "3" } });
|
|
163
|
+
return Response.json({ remoteAccessSession: { arn: "arn:session", status: "PENDING" } });
|
|
164
|
+
case "GetRemoteAccessSession":
|
|
165
|
+
return Response.json({ remoteAccessSession: calls.includes("StopRemoteAccessSession")
|
|
166
|
+
? { arn: "arn:session", status: "COMPLETED", deviceMinutes: { metered: 4 } }
|
|
167
|
+
: { arn: "arn:session", status: "RUNNING", endpoints: { remoteDriverEndpoint: "https://appium.devicefarm.test/wd" } } });
|
|
168
|
+
case "StopRemoteAccessSession": return Response.json({});
|
|
169
|
+
default: return new Response("", { status: target.startsWith("PUT") ? 200 : 400 });
|
|
170
|
+
}
|
|
171
|
+
}) as unknown as typeof fetch;
|
|
172
|
+
const options = { credentials: { accessKeyId: "AKIA", secretAccessKey: "s" }, projectArn: "arn:aws:devicefarm:us-west-2:1:project:x", fetch: doFetch };
|
|
173
|
+
const appArn = await uploadApp(options, { name: "shop.apk", platform: "android", bytes: new Uint8Array([1]) }, 0);
|
|
174
|
+
const session = await startSession(options, { deviceArn: "arn:aws:devicefarm:us-west-2::device:ABC", name: "run", appArn }, 0);
|
|
175
|
+
assert.equal(session.remoteDriverEndpoint, "https://appium.devicefarm.test/wd");
|
|
176
|
+
await (await import("../integrations/device-farm.js")).stopSession(options, session.arn);
|
|
177
|
+
assert.equal((await finishedSession(options, session.arn, 0)).meteredMinutes, 4);
|
|
178
|
+
assert.deepEqual(calls.slice(0, 5), ["CreateUpload", "PUT https://s3.test/put", "GetUpload", "GetUpload", "CreateRemoteAccessSession"]);
|
|
179
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Runs Bugmole flows on real devices and emulators in device clouds, over
|
|
2
|
+
// WebDriver (Appium). Mobile web flows run in the device's browser with the
|
|
3
|
+
// same step meanings as the Playwright driver; native app flows find elements
|
|
4
|
+
// by their visible text or accessibility label.
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { WebDriverSession, type Locator } from "../integrations/webdriver-client.js";
|
|
8
|
+
import type { AppDriver, DriverArtifact, DriverContext, DriverPlatform, DriverResult, DriverStepResult } from "./driver.js";
|
|
9
|
+
import { hostFor, cloudDeviceLabel, type CloudDevice, type DeviceCloudAccess, type SessionReport } from "./device-clouds/index.js";
|
|
10
|
+
import {
|
|
11
|
+
describeStepForLog,
|
|
12
|
+
describeStepLabel,
|
|
13
|
+
interpolateStep,
|
|
14
|
+
isPatternText,
|
|
15
|
+
parseMaestroStyleFlow,
|
|
16
|
+
resolveFlowUrl,
|
|
17
|
+
type FlowStep,
|
|
18
|
+
type ParsedFlow,
|
|
19
|
+
} from "./flow-language.js";
|
|
20
|
+
|
|
21
|
+
export type AppiumDriverOptions = {
|
|
22
|
+
access?: DeviceCloudAccess;
|
|
23
|
+
/** Test seams. */
|
|
24
|
+
fetch?: typeof fetch;
|
|
25
|
+
createSession?: typeof WebDriverSession.create;
|
|
26
|
+
sleep?: (ms: number) => Promise<void>;
|
|
27
|
+
defaultTimeoutMs?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULT_WAIT_MS = 10_000;
|
|
31
|
+
const POLL_MS = 500;
|
|
32
|
+
|
|
33
|
+
function xpathLiteral(text: string): string {
|
|
34
|
+
if (!text.includes("'")) return `'${text}'`;
|
|
35
|
+
if (!text.includes('"')) return `"${text}"`;
|
|
36
|
+
return `concat(${text.split("'").map((part) => `'${part}'`).join(`, "'", `)})`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where to look for a person-visible label, most specific first. */
|
|
40
|
+
export function locatorsFor(text: string, mode: "web" | "android" | "ios"): Locator[] {
|
|
41
|
+
const pattern = isPatternText(text);
|
|
42
|
+
if (mode === "web") {
|
|
43
|
+
const literal = xpathLiteral(pattern ? text.replace(/\.\*/g, "") : text);
|
|
44
|
+
const match = pattern ? `contains(normalize-space(.), ${literal})` : `normalize-space(.)=${literal}`;
|
|
45
|
+
return [
|
|
46
|
+
{ using: "xpath", value: `//*[self::a or self::button or @role='button' or @role='link' or @role='tab' or @role='menuitem'][${match}]` },
|
|
47
|
+
{ using: "xpath", value: `//label[${match}]/following::*[self::input or self::textarea or self::select][1]` },
|
|
48
|
+
{ using: "xpath", value: `//*[@aria-label=${literal} or @placeholder=${literal} or @title=${literal} or @alt=${literal} or @value=${literal}]` },
|
|
49
|
+
{ using: "xpath", value: `//*[${match}][not(*[${match}])]` },
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
if (mode === "android") {
|
|
53
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
54
|
+
return pattern
|
|
55
|
+
? [{ using: "-android uiautomator", value: `new UiSelector().textMatches("${escaped}")` }, { using: "-android uiautomator", value: `new UiSelector().descriptionMatches("${escaped}")` }]
|
|
56
|
+
: [
|
|
57
|
+
{ using: "accessibility id", value: text },
|
|
58
|
+
{ using: "-android uiautomator", value: `new UiSelector().text("${escaped}")` },
|
|
59
|
+
{ using: "-android uiautomator", value: `new UiSelector().description("${escaped}")` },
|
|
60
|
+
{ using: "-android uiautomator", value: `new UiSelector().textContains("${escaped}")` },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
64
|
+
return pattern
|
|
65
|
+
? [{ using: "-ios predicate string", value: `label MATCHES '${escaped}' OR name MATCHES '${escaped}' OR value MATCHES '${escaped}'` }]
|
|
66
|
+
: [
|
|
67
|
+
{ using: "accessibility id", value: text },
|
|
68
|
+
{ using: "-ios predicate string", value: `label == '${escaped}' OR name == '${escaped}' OR value == '${escaped}'` },
|
|
69
|
+
{ using: "-ios predicate string", value: `label CONTAINS '${escaped}'` },
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const KEYS: Record<string, string> = { enter: "", tab: "", backspace: "", escape: "", back: "", "arrow down": "", "arrow up": "" };
|
|
74
|
+
|
|
75
|
+
export class AppiumDriver implements AppDriver {
|
|
76
|
+
readonly id = "appium";
|
|
77
|
+
|
|
78
|
+
constructor(private readonly options: AppiumDriverOptions = {}) {}
|
|
79
|
+
|
|
80
|
+
supports(_platform: DriverPlatform): boolean {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async run(context: DriverContext): Promise<DriverResult> {
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
const device = context.runTarget?.cloud;
|
|
87
|
+
if (!device) return { status: "blocked", message: "This run target isn't a cloud device", artifacts: [], failureCode: "driver_error" };
|
|
88
|
+
const content = await fs.readFile(context.flowPath, "utf8");
|
|
89
|
+
let flow: ParsedFlow;
|
|
90
|
+
try {
|
|
91
|
+
flow = parseMaestroStyleFlow(content, context.target.baseUrl, { rebaseOrigin: context.rebaseOrigin });
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return { status: "blocked", message: `The flow couldn't be read: ${error instanceof Error ? error.message : error}`, artifacts: [], failureCode: "flow_invalid" };
|
|
94
|
+
}
|
|
95
|
+
const mode: "web" | "android" | "ios" = device.browserName ? "web" : device.platform;
|
|
96
|
+
const app = device.appBuildId ? this.options.access?.appBuilds[device.appBuildId] : undefined;
|
|
97
|
+
if (mode !== "web" && !app) {
|
|
98
|
+
return { status: "blocked", message: "Upload an app build for this device, or run the flow in the device's browser", artifacts: [], failureCode: "app_missing" };
|
|
99
|
+
}
|
|
100
|
+
const outDir = path.join(context.artifactsDir, context.projectId, context.runId, "artifacts", context.runTarget?.id ?? "device");
|
|
101
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
102
|
+
const artifacts: DriverArtifact[] = [];
|
|
103
|
+
const steps: DriverStepResult[] = [];
|
|
104
|
+
const log: string[] = [`Device: ${cloudDeviceLabel(device)} on ${device.provider}`];
|
|
105
|
+
const name = `${context.journeyId} · ${cloudDeviceLabel(device)}`;
|
|
106
|
+
let session: WebDriverSession | null = null;
|
|
107
|
+
let report: SessionReport | undefined;
|
|
108
|
+
let failure: { message: string; code: string } | null = null;
|
|
109
|
+
let finish: ((outcome: { passed: boolean; webdriverSessionId: string | null; reason?: string }) => Promise<SessionReport>) | undefined;
|
|
110
|
+
try {
|
|
111
|
+
const host = hostFor(this.options.access, device, this.options.fetch);
|
|
112
|
+
const opened = await host.open({ device, name, app });
|
|
113
|
+
finish = opened.finish;
|
|
114
|
+
log.push("Waiting for the device…");
|
|
115
|
+
session = await (this.options.createSession ?? WebDriverSession.create)({
|
|
116
|
+
hubUrl: opened.hubUrl,
|
|
117
|
+
capabilities: opened.capabilities,
|
|
118
|
+
authorization: opened.authorization,
|
|
119
|
+
fetch: this.options.fetch,
|
|
120
|
+
});
|
|
121
|
+
log.push(`Session ${session.id} started`);
|
|
122
|
+
const flowContext: Record<string, string> = {};
|
|
123
|
+
const runSteps = async (list: FlowStep[], counter: { n: number }): Promise<void> => {
|
|
124
|
+
for (const rawStep of list) {
|
|
125
|
+
const index = counter.n++;
|
|
126
|
+
const began = Date.now();
|
|
127
|
+
const step = interpolateStep(rawStep, flowContext);
|
|
128
|
+
try {
|
|
129
|
+
if (step.type === "retry") {
|
|
130
|
+
let lastError: unknown;
|
|
131
|
+
for (let attempt = 0; attempt <= step.maxRetries; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
await runSteps(step.steps, { n: counter.n });
|
|
134
|
+
lastError = undefined;
|
|
135
|
+
break;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
lastError = error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
counter.n += step.steps.length;
|
|
141
|
+
if (lastError) throw lastError;
|
|
142
|
+
} else {
|
|
143
|
+
await this.step(session!, step, mode, flow, context, outDir, artifacts);
|
|
144
|
+
}
|
|
145
|
+
steps.push({ sequenceNumber: index, status: "passed", label: describeStepLabel(step), durationMs: Date.now() - began });
|
|
146
|
+
log.push(`Step ${index + 1}: ${describeStepForLog(step)} — ok`);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
149
|
+
steps.push({ sequenceNumber: index, status: "failed", label: describeStepLabel(step), message, durationMs: Date.now() - began });
|
|
150
|
+
log.push(`Step ${index + 1}: ${describeStepForLog(step)} — ${message}`);
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
await runSteps(flow.steps, { n: 0 });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
failure = { message: error instanceof Error ? error.message : String(error), code: session ? "step_failed" : "device_unavailable" };
|
|
158
|
+
if (session) {
|
|
159
|
+
try {
|
|
160
|
+
const shot = path.join(outDir, "failure.png");
|
|
161
|
+
await fs.writeFile(shot, await session.screenshot());
|
|
162
|
+
artifacts.push({ path: shot, contentType: "image/png", name: "failure.png" });
|
|
163
|
+
steps[steps.length - 1] = { ...steps[steps.length - 1], screenshotPath: shot };
|
|
164
|
+
} catch {
|
|
165
|
+
// A device that dropped off can't take a screenshot; the log says why.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
const sessionId = session?.id ?? null;
|
|
170
|
+
await session?.delete();
|
|
171
|
+
if (finish) {
|
|
172
|
+
report = await finish({ passed: !failure, webdriverSessionId: sessionId, reason: failure?.message }).catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (report?.videoUrl) {
|
|
176
|
+
try {
|
|
177
|
+
const video = await (this.options.fetch ?? fetch)(report.videoUrl);
|
|
178
|
+
if (video.ok) {
|
|
179
|
+
const file = path.join(outDir, "device.mp4");
|
|
180
|
+
await fs.writeFile(file, new Uint8Array(await video.arrayBuffer()));
|
|
181
|
+
artifacts.push({ path: file, contentType: "video/mp4", name: "device.mp4" });
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
log.push("The device recording couldn't be downloaded");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const link of report?.links ?? []) log.push(`${link.label}: ${link.url}`);
|
|
188
|
+
if (report?.meteredMinutes !== undefined) log.push(`Device minutes: ${report.meteredMinutes}`);
|
|
189
|
+
const logFile = path.join(outDir, "device.log");
|
|
190
|
+
await fs.writeFile(logFile, `${log.join("\n")}\n`, "utf8");
|
|
191
|
+
artifacts.push({ path: logFile, contentType: "text/plain", name: "device.log" });
|
|
192
|
+
return {
|
|
193
|
+
status: failure ? (failure.code === "device_unavailable" ? "blocked" : "failed") : "passed",
|
|
194
|
+
message: failure ? failure.message : `Passed on ${cloudDeviceLabel(device)}`,
|
|
195
|
+
artifacts,
|
|
196
|
+
steps,
|
|
197
|
+
durationMs: Date.now() - startedAt,
|
|
198
|
+
...(failure ? { failureCode: failure.code, recoverable: failure.code === "device_unavailable" } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async find(session: WebDriverSession, text: string, mode: "web" | "android" | "ios", index = 0, timeoutMs = this.options.defaultTimeoutMs ?? DEFAULT_WAIT_MS): Promise<string | null> {
|
|
203
|
+
const deadline = Date.now() + timeoutMs;
|
|
204
|
+
const sleep = this.options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
205
|
+
for (;;) {
|
|
206
|
+
for (const locator of locatorsFor(text, mode)) {
|
|
207
|
+
const found = await session.findElements(locator).catch(() => [] as string[]);
|
|
208
|
+
const visible: string[] = [];
|
|
209
|
+
for (const element of found) {
|
|
210
|
+
if (await session.isDisplayed(element)) visible.push(element);
|
|
211
|
+
if (visible.length > index) return visible[index];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (Date.now() >= deadline) return null;
|
|
215
|
+
await sleep(POLL_MS);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async step(session: WebDriverSession, step: FlowStep, mode: "web" | "android" | "ios", flow: ParsedFlow, context: DriverContext, outDir: string, artifacts: DriverArtifact[]): Promise<void> {
|
|
220
|
+
const sleep = this.options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
221
|
+
switch (step.type) {
|
|
222
|
+
case "launchApp":
|
|
223
|
+
if (mode === "web") await session.navigate(flow.url);
|
|
224
|
+
return;
|
|
225
|
+
case "stopApp":
|
|
226
|
+
return;
|
|
227
|
+
case "openLink": {
|
|
228
|
+
const url = resolveFlowUrl(step.url, flow.url, flow.rebasedFrom, flow.rebasedOnto);
|
|
229
|
+
await session.navigate(url);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
case "tapOn": {
|
|
233
|
+
const element = await this.find(session, step.text, mode, step.index);
|
|
234
|
+
if (!element) {
|
|
235
|
+
if (step.optional) return;
|
|
236
|
+
throw new Error(`Couldn't find "${step.text}" on screen`);
|
|
237
|
+
}
|
|
238
|
+
await session.click(element);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
case "inputText": {
|
|
242
|
+
const focused = await session.activeElement();
|
|
243
|
+
if (focused) await session.sendKeys(focused, step.text);
|
|
244
|
+
else await session.keys(step.text);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case "eraseText": {
|
|
248
|
+
const focused = await session.activeElement();
|
|
249
|
+
if (!focused) throw new Error("Nothing is focused to erase");
|
|
250
|
+
await session.clear(focused);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
case "pressKey":
|
|
254
|
+
await session.keys(KEYS[step.key.toLowerCase()] ?? step.key);
|
|
255
|
+
return;
|
|
256
|
+
case "assertVisible":
|
|
257
|
+
case "assertClickable": {
|
|
258
|
+
const element = await this.find(session, step.text, mode, step.type === "assertClickable" ? step.index : 0, step.timeoutMs);
|
|
259
|
+
if (!element) throw new Error(`Expected "${step.text}" to be visible`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case "assertNotVisible": {
|
|
263
|
+
const deadline = Date.now() + (step.timeoutMs ?? DEFAULT_WAIT_MS);
|
|
264
|
+
for (;;) {
|
|
265
|
+
if (!(await this.find(session, step.text, mode, 0, 0))) return;
|
|
266
|
+
if (Date.now() >= deadline) throw new Error(`Expected "${step.text}" to disappear`);
|
|
267
|
+
await sleep(POLL_MS);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
case "scrollUntilVisible": {
|
|
271
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
272
|
+
if (await this.find(session, step.text, mode, 0, 0)) return;
|
|
273
|
+
if (mode === "web") await session.execute("window.scrollBy(0, Math.round(window.innerHeight * 0.8))");
|
|
274
|
+
else await session.execute("mobile: swipe", [{ direction: "up" }]).catch(() => session.execute("mobile: scroll", [{ direction: "down" }]));
|
|
275
|
+
}
|
|
276
|
+
throw new Error(`Scrolled but never found "${step.text}"`);
|
|
277
|
+
}
|
|
278
|
+
case "wait":
|
|
279
|
+
await sleep(step.ms);
|
|
280
|
+
return;
|
|
281
|
+
case "takeScreenshot": {
|
|
282
|
+
const file = path.join(outDir, `${step.name.replace(/[^a-zA-Z0-9_-]+/g, "-")}.png`);
|
|
283
|
+
await fs.writeFile(file, await session.screenshot());
|
|
284
|
+
artifacts.push({ path: file, contentType: "image/png", name: path.basename(file) });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case "requestTempEmail":
|
|
288
|
+
case "openEmailLink":
|
|
289
|
+
throw new Error(`"${step.type}" isn't available on device clouds yet; run this flow in a desktop or emulated browser`);
|
|
290
|
+
default:
|
|
291
|
+
void context;
|
|
292
|
+
throw new Error(`Unsupported step`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
blockerFingerprint,
|
|
8
|
+
readBlockerResolution,
|
|
9
|
+
writeBlockerResolution,
|
|
10
|
+
} from "./blocker-resolution.js";
|
|
11
|
+
|
|
12
|
+
function config(root: string, projectId = "lumis") {
|
|
13
|
+
return {
|
|
14
|
+
runtime: { artifacts_dir: root, spec_dir: path.join(root, "spec") },
|
|
15
|
+
project: { id: projectId, environment_id: "android-local" },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("blocker resolution memory", () => {
|
|
20
|
+
it("stores and reuses a project/environment-scoped clarification", async () => {
|
|
21
|
+
const root = fs.mkdtempSync(path.join(tmpdir(), "bugmole-blocker-"));
|
|
22
|
+
const cfg = config(root);
|
|
23
|
+
const input = {
|
|
24
|
+
category: "journey-access",
|
|
25
|
+
reason: "Runtime access is blocked for /account.",
|
|
26
|
+
route: "/account",
|
|
27
|
+
nodeId: "account",
|
|
28
|
+
environmentId: "android-local",
|
|
29
|
+
};
|
|
30
|
+
const resolution = await writeBlockerResolution(cfg, {
|
|
31
|
+
...input,
|
|
32
|
+
question: "Which account should be used?",
|
|
33
|
+
resolution: "Use the authenticated owner account already configured on the device.",
|
|
34
|
+
});
|
|
35
|
+
assert.equal(resolution.fingerprint, blockerFingerprint(input));
|
|
36
|
+
assert.equal((await readBlockerResolution(cfg, input))?.resolution, resolution.resolution);
|
|
37
|
+
assert.equal(
|
|
38
|
+
await readBlockerResolution(config(root, "other-project"), input),
|
|
39
|
+
null,
|
|
40
|
+
);
|
|
41
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not reuse expired guidance", async () => {
|
|
45
|
+
const root = fs.mkdtempSync(path.join(tmpdir(), "bugmole-blocker-expired-"));
|
|
46
|
+
const cfg = config(root);
|
|
47
|
+
const input = {
|
|
48
|
+
category: "device",
|
|
49
|
+
reason: "Device is unavailable.",
|
|
50
|
+
environmentId: "android-local",
|
|
51
|
+
};
|
|
52
|
+
await writeBlockerResolution(cfg, {
|
|
53
|
+
...input,
|
|
54
|
+
question: "Which device should be started?",
|
|
55
|
+
resolution: "Start the configured Nokia device.",
|
|
56
|
+
expiresAt: new Date(Date.now() - 1000).toISOString(),
|
|
57
|
+
});
|
|
58
|
+
assert.equal(await readBlockerResolution(cfg, input), null);
|
|
59
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns a clarification request and accepts the user's resolution", async () => {
|
|
63
|
+
const root = fs.mkdtempSync(path.join(tmpdir(), "bugmole-blocker-flow-"));
|
|
64
|
+
const cfg = config(root);
|
|
65
|
+
fs.mkdirSync(path.join(root, "spec"), { recursive: true });
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path.join(root, "spec", "journeys.graph.json"),
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
version: 2,
|
|
70
|
+
projectId: "lumis",
|
|
71
|
+
journeys: [{
|
|
72
|
+
id: "account-flow",
|
|
73
|
+
name: "Account flow",
|
|
74
|
+
actor: "user",
|
|
75
|
+
nodeIds: ["account"],
|
|
76
|
+
edgeIds: [],
|
|
77
|
+
revision: 1,
|
|
78
|
+
status: "draft",
|
|
79
|
+
}],
|
|
80
|
+
nodes: [{
|
|
81
|
+
id: "account",
|
|
82
|
+
title: "Account",
|
|
83
|
+
kind: "screen",
|
|
84
|
+
route: "/account",
|
|
85
|
+
runtimeProbe: { status: "blocked" },
|
|
86
|
+
}],
|
|
87
|
+
edges: [],
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
const { callTool } = await import("../mcp/server.js");
|
|
91
|
+
const blocked = await callTool({
|
|
92
|
+
name: "bugmole_journey_next",
|
|
93
|
+
arguments: { journeyId: "account-flow" },
|
|
94
|
+
}, cfg);
|
|
95
|
+
assert.equal(blocked.status, "blocked");
|
|
96
|
+
assert.equal(blocked.clarification.status, "needed");
|
|
97
|
+
const resolved = await callTool({
|
|
98
|
+
name: "bugmole_resolve_blocker",
|
|
99
|
+
arguments: {
|
|
100
|
+
journeyId: "account-flow",
|
|
101
|
+
category: "journey-access",
|
|
102
|
+
reason: blocked.reason,
|
|
103
|
+
route: "/account",
|
|
104
|
+
nodeId: "account",
|
|
105
|
+
question: blocked.clarification.question,
|
|
106
|
+
resolution: "Use the authenticated owner session on the configured device.",
|
|
107
|
+
},
|
|
108
|
+
}, cfg);
|
|
109
|
+
assert.equal(resolved.status, "success");
|
|
110
|
+
assert.equal(resolved.nextTask.status, "exhausted");
|
|
111
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
112
|
+
});
|
|
113
|
+
});
|