@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,308 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
2
|
+
import type { Page } from "playwright";
|
|
3
|
+
import { launcherFor } from "./browser-matrix.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Discovery from nothing but a URL: a real browser opens the app and moves
|
|
7
|
+
* through it the way a first-time visitor does — following the links a person
|
|
8
|
+
* can see — and writes down every page and form it meets as runnable flows.
|
|
9
|
+
*
|
|
10
|
+
* It is read-only by design: it only follows same-origin links, never submits
|
|
11
|
+
* a form or presses a button, and steers clear of links that sign out or
|
|
12
|
+
* delete things.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type DiscoveryEvent = {
|
|
16
|
+
at: string;
|
|
17
|
+
kind: "visit" | "click" | "form" | "page" | "flows" | "done" | "skip" | "error";
|
|
18
|
+
text: string;
|
|
19
|
+
route?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DiscoveredField = { label: string; type: string; required: boolean };
|
|
23
|
+
export type DiscoveredForm = { name: string; fields: DiscoveredField[]; submit?: string };
|
|
24
|
+
|
|
25
|
+
export type DiscoveredPage = {
|
|
26
|
+
route: string;
|
|
27
|
+
url: string;
|
|
28
|
+
title: string;
|
|
29
|
+
heading?: string;
|
|
30
|
+
/** The link labels a visitor clicks, from the home page, to reach this page. */
|
|
31
|
+
path: string[];
|
|
32
|
+
links: Array<{ label: string; route: string }>;
|
|
33
|
+
forms: DiscoveredForm[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type DiscoveredFlow = {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
/** "journey" flows span several pages and are written by AI exploration. */
|
|
40
|
+
kind: "page" | "form" | "journey";
|
|
41
|
+
route: string;
|
|
42
|
+
/** Path under the spec directory, e.g. flows/discovered/sign-up.yaml. */
|
|
43
|
+
file: string;
|
|
44
|
+
content: string;
|
|
45
|
+
steps: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DiscoveryResult = {
|
|
49
|
+
baseUrl: string;
|
|
50
|
+
pages: DiscoveredPage[];
|
|
51
|
+
flows: DiscoveredFlow[];
|
|
52
|
+
events: DiscoveryEvent[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type DiscoveryOptions = {
|
|
56
|
+
maxPages?: number;
|
|
57
|
+
maxDepth?: number;
|
|
58
|
+
headless?: boolean;
|
|
59
|
+
navigationTimeoutMs?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Pause after each page, as a person would while reading it. Keeps the load
|
|
62
|
+
* on the app gentle. Defaults to 600ms (BUGMOLE_DISCOVERY_DWELL_MS).
|
|
63
|
+
*/
|
|
64
|
+
dwellMs?: number;
|
|
65
|
+
onEvent?: (event: DiscoveryEvent, snapshot: DiscoveryResult) => void | Promise<void>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const UNSAFE_LINK = /\b(log ?out|sign ?out|delete|remove|destroy|unsubscribe|deactivate|cancel (?:my )?(?:account|subscription))\b/i;
|
|
69
|
+
|
|
70
|
+
export function slugify(value: string): string {
|
|
71
|
+
return value.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "home";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The route a same-origin link leads to, or null for anything a visitor would not follow. */
|
|
75
|
+
export function discoverableRoute(href: string, baseUrl: string): string | null {
|
|
76
|
+
let url: URL;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(href, baseUrl);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const base = new URL(baseUrl);
|
|
83
|
+
if (url.origin !== base.origin || !/^https?:$/.test(url.protocol)) return null;
|
|
84
|
+
if (/\.(png|jpe?g|gif|svg|webp|ico|pdf|zip|css|js|json|xml|txt|mp4|webm)$/i.test(url.pathname)) return null;
|
|
85
|
+
const route = url.pathname.replace(/\/+$/, "") || "/";
|
|
86
|
+
return route;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function describeRoute(page: Pick<DiscoveredPage, "route" | "heading" | "title">): string {
|
|
90
|
+
if (page.route === "/") return "home page";
|
|
91
|
+
return page.heading || page.title || page.route;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sampleValue(field: DiscoveredField): string {
|
|
95
|
+
const hint = `${field.type} ${field.label}`.toLowerCase();
|
|
96
|
+
if (field.type === "password" || hint.includes("password")) return "{{secret.APP_PASSWORD}}";
|
|
97
|
+
if (hint.includes("email")) return "qa@example.com";
|
|
98
|
+
if (hint.includes("phone") || field.type === "tel") return "+1 555 010 0100";
|
|
99
|
+
if (field.type === "number") return "1";
|
|
100
|
+
if (field.type === "url" || hint.includes("website")) return "https://example.com";
|
|
101
|
+
if (hint.includes("name")) return "Bugmole";
|
|
102
|
+
return "Test";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const FILLABLE = new Set(["text", "email", "password", "tel", "number", "url", "search", "textarea"]);
|
|
106
|
+
|
|
107
|
+
/** Flows a visitor could run for each discovered page and form. */
|
|
108
|
+
export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): DiscoveredFlow[] {
|
|
109
|
+
const flows: DiscoveredFlow[] = [];
|
|
110
|
+
const used = new Set<string>();
|
|
111
|
+
const uniqueId = (base: string) => {
|
|
112
|
+
let id = base;
|
|
113
|
+
for (let suffix = 2; used.has(id); suffix += 1) id = `${base}-${suffix}`;
|
|
114
|
+
used.add(id);
|
|
115
|
+
return id;
|
|
116
|
+
};
|
|
117
|
+
const header = (name: string) => `url: ${baseUrl}\nname: ${JSON.stringify(name)}\n---\n`;
|
|
118
|
+
const pathSteps = (page: DiscoveredPage) => page.path.map((label) => ({ tapOn: label }));
|
|
119
|
+
|
|
120
|
+
for (const page of pages) {
|
|
121
|
+
const pageName = page.route === "/" ? "Open the home page" : `Visit ${describeRoute(page)}`;
|
|
122
|
+
const landmark = page.heading ?? page.title;
|
|
123
|
+
if (landmark) {
|
|
124
|
+
const id = uniqueId(`discovered-${slugify(page.route === "/" ? "home" : describeRoute(page))}`);
|
|
125
|
+
const steps: unknown[] = ["launchApp", ...pathSteps(page), { assertVisible: landmark }];
|
|
126
|
+
flows.push({
|
|
127
|
+
id,
|
|
128
|
+
name: pageName,
|
|
129
|
+
kind: "page",
|
|
130
|
+
route: page.route,
|
|
131
|
+
file: `flows/discovered/${id}.yaml`,
|
|
132
|
+
content: header(pageName) + YAML.stringify(steps),
|
|
133
|
+
steps: steps.map(stepLabel),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const form of page.forms) {
|
|
137
|
+
const fields = form.fields.filter((field) => FILLABLE.has(field.type)).slice(0, 8);
|
|
138
|
+
if (fields.length === 0) continue;
|
|
139
|
+
const name = `Fill in ${form.name}`;
|
|
140
|
+
const id = uniqueId(`discovered-${slugify(form.name)}-form`);
|
|
141
|
+
const steps: unknown[] = [
|
|
142
|
+
"launchApp",
|
|
143
|
+
...pathSteps(page),
|
|
144
|
+
...fields.flatMap((field) => [{ tapOn: field.label }, { inputText: sampleValue(field) }]),
|
|
145
|
+
// Discovery never submits; the flow proves the form is ready to.
|
|
146
|
+
...(form.submit ? [{ assertClickable: form.submit }] : []),
|
|
147
|
+
];
|
|
148
|
+
flows.push({
|
|
149
|
+
id,
|
|
150
|
+
name,
|
|
151
|
+
kind: "form",
|
|
152
|
+
route: page.route,
|
|
153
|
+
file: `flows/discovered/${id}.yaml`,
|
|
154
|
+
content: header(name) + YAML.stringify(steps),
|
|
155
|
+
steps: steps.map(stepLabel),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return flows;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stepLabel(step: unknown): string {
|
|
163
|
+
if (typeof step === "string") return step === "launchApp" ? "Open the app" : step;
|
|
164
|
+
const [[key, value]] = Object.entries(step as Record<string, unknown>);
|
|
165
|
+
const text = String(value);
|
|
166
|
+
switch (key) {
|
|
167
|
+
case "tapOn": return `Tap “${text}”`;
|
|
168
|
+
case "inputText": return text.startsWith("{{secret.") ? "Type the password" : `Type “${text}”`;
|
|
169
|
+
case "assertVisible": return `See “${text}”`;
|
|
170
|
+
case "assertClickable": return `“${text}” is ready`;
|
|
171
|
+
default: return key;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Reads what a visitor sees on the current page. Field values are never read. */
|
|
176
|
+
const pageProbe = `() => {
|
|
177
|
+
const visible = (element) => {
|
|
178
|
+
const rect = element.getBoundingClientRect();
|
|
179
|
+
if (rect.width < 2 || rect.height < 2) return false;
|
|
180
|
+
const style = window.getComputedStyle(element);
|
|
181
|
+
return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) !== 0;
|
|
182
|
+
};
|
|
183
|
+
const text = (element) => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
|
|
184
|
+
const heading = Array.from(document.querySelectorAll("h1, h2")).find((element) => visible(element) && text(element));
|
|
185
|
+
const links = [];
|
|
186
|
+
for (const link of document.querySelectorAll("a[href]")) {
|
|
187
|
+
if (!visible(link)) continue;
|
|
188
|
+
const label = text(link) || link.getAttribute("aria-label") || "";
|
|
189
|
+
if (label && label.length <= 60) links.push({ label, href: link.href });
|
|
190
|
+
}
|
|
191
|
+
const labelOf = (field) => {
|
|
192
|
+
const own = field.labels && field.labels[0] ? text(field.labels[0]) : "";
|
|
193
|
+
return own || field.getAttribute("aria-label") || field.getAttribute("placeholder") || "";
|
|
194
|
+
};
|
|
195
|
+
const forms = [];
|
|
196
|
+
for (const form of document.querySelectorAll("form")) {
|
|
197
|
+
if (!visible(form)) continue;
|
|
198
|
+
const fields = [];
|
|
199
|
+
for (const field of form.querySelectorAll("input, textarea, select")) {
|
|
200
|
+
const type = field.tagName === "TEXTAREA" ? "textarea" : field.tagName === "SELECT" ? "select" : (field.type || "text");
|
|
201
|
+
if (!visible(field) || ["hidden", "submit", "button", "checkbox", "radio", "file"].includes(type)) continue;
|
|
202
|
+
const label = labelOf(field);
|
|
203
|
+
if (label) fields.push({ label, type, required: field.required });
|
|
204
|
+
}
|
|
205
|
+
if (!fields.length) continue;
|
|
206
|
+
const submit = form.querySelector("button[type=submit], button:not([type]), input[type=submit]");
|
|
207
|
+
const submitText = submit ? (text(submit) || submit.value || "") : "";
|
|
208
|
+
const legend = form.getAttribute("aria-label") || (form.querySelector("legend, h1, h2, h3") ? text(form.querySelector("legend, h1, h2, h3")) : "");
|
|
209
|
+
forms.push({ name: legend || submitText || "form", fields, submit: submitText || undefined });
|
|
210
|
+
}
|
|
211
|
+
return { title: document.title, heading: heading ? text(heading) : undefined, links, forms };
|
|
212
|
+
}`;
|
|
213
|
+
|
|
214
|
+
type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string }>; forms: DiscoveredForm[] };
|
|
215
|
+
|
|
216
|
+
async function readPage(page: Page): Promise<Probe> {
|
|
217
|
+
return page.evaluate((source: string) => (new Function(`return (${source})`)() as () => Probe)(), pageProbe);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function discoverSite(baseUrl: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
|
|
221
|
+
const start = new URL(baseUrl);
|
|
222
|
+
const root = `${start.origin}${start.pathname}`;
|
|
223
|
+
const maxPages = options.maxPages ?? 12;
|
|
224
|
+
const maxDepth = options.maxDepth ?? 2;
|
|
225
|
+
const timeout = options.navigationTimeoutMs ?? 15_000;
|
|
226
|
+
const envDwell = Number(process.env.BUGMOLE_DISCOVERY_DWELL_MS);
|
|
227
|
+
const dwellMs = options.dwellMs ?? (Number.isFinite(envDwell) && envDwell >= 0 ? envDwell : 600);
|
|
228
|
+
const result: DiscoveryResult = { baseUrl: root, pages: [], flows: [], events: [] };
|
|
229
|
+
const emit = async (kind: DiscoveryEvent["kind"], text: string, route?: string) => {
|
|
230
|
+
const event: DiscoveryEvent = { at: new Date().toISOString(), kind, text, ...(route ? { route } : {}) };
|
|
231
|
+
result.events.push(event);
|
|
232
|
+
await options.onEvent?.(event, result);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const { type, channel } = launcherFor("chromium");
|
|
236
|
+
const browser = await type.launch({ headless: options.headless ?? true, ...(channel ? { channel } : {}) });
|
|
237
|
+
try {
|
|
238
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
239
|
+
const page = await context.newPage();
|
|
240
|
+
page.setDefaultTimeout(timeout);
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
// Breadth-first, so pages close to home are found first.
|
|
243
|
+
const queue: Array<{ route: string; path: string[]; parent?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
|
|
244
|
+
|
|
245
|
+
while (queue.length > 0 && result.pages.length < maxPages) {
|
|
246
|
+
const next = queue.shift()!;
|
|
247
|
+
if (seen.has(next.route)) continue;
|
|
248
|
+
seen.add(next.route);
|
|
249
|
+
try {
|
|
250
|
+
if (next.path.length === 0) {
|
|
251
|
+
await emit("visit", "Visiting home page", next.route);
|
|
252
|
+
await page.goto(root, { waitUntil: "load" });
|
|
253
|
+
} else {
|
|
254
|
+
// Reach the page the way a visitor would: from its parent, by clicking.
|
|
255
|
+
const parentUrl = new URL(next.parent ?? "/", root).toString();
|
|
256
|
+
if (discoverableRoute(page.url(), root) !== next.parent) await page.goto(parentUrl, { waitUntil: "load" });
|
|
257
|
+
const label = next.path[next.path.length - 1];
|
|
258
|
+
await emit("click", `Clicking “${label}”`, next.route);
|
|
259
|
+
await page.getByRole("link", { name: label, exact: true }).first().click();
|
|
260
|
+
await page.waitForLoadState("load").catch(() => undefined);
|
|
261
|
+
await page.waitForTimeout(250);
|
|
262
|
+
}
|
|
263
|
+
const route = discoverableRoute(page.url(), root) ?? next.route;
|
|
264
|
+
if (route !== next.route && seen.has(route)) continue;
|
|
265
|
+
seen.add(route);
|
|
266
|
+
const probe = await readPage(page);
|
|
267
|
+
const links: DiscoveredPage["links"] = [];
|
|
268
|
+
for (const link of probe.links) {
|
|
269
|
+
const target = discoverableRoute(link.href, root);
|
|
270
|
+
if (!target || target === route || links.some((existing) => existing.route === target)) continue;
|
|
271
|
+
if (UNSAFE_LINK.test(link.label)) {
|
|
272
|
+
await emit("skip", `Skipping “${link.label}” (it could change data)`, target);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
links.push({ label: link.label, route: target });
|
|
276
|
+
}
|
|
277
|
+
const discovered: DiscoveredPage = {
|
|
278
|
+
route,
|
|
279
|
+
url: page.url(),
|
|
280
|
+
title: probe.title,
|
|
281
|
+
...(probe.heading ? { heading: probe.heading } : {}),
|
|
282
|
+
path: next.path,
|
|
283
|
+
links,
|
|
284
|
+
forms: probe.forms,
|
|
285
|
+
};
|
|
286
|
+
result.pages.push(discovered);
|
|
287
|
+
await emit("page", `Found ${describeRoute(discovered)} (${links.length} link${links.length === 1 ? "" : "s"})`, route);
|
|
288
|
+
for (const form of probe.forms) {
|
|
289
|
+
await emit("form", `Found a form: ${form.name} (${form.fields.map((field) => field.label).join(", ")})`, route);
|
|
290
|
+
}
|
|
291
|
+
if (dwellMs > 0) await page.waitForTimeout(dwellMs);
|
|
292
|
+
if (next.path.length < maxDepth) {
|
|
293
|
+
for (const link of links) {
|
|
294
|
+
if (!seen.has(link.route)) queue.push({ route: link.route, path: [...next.path, link.label], parent: route });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await emit("error", `Could not open ${next.route}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`, next.route);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
result.flows = flowsFor(root, result.pages);
|
|
302
|
+
await emit("flows", `Discovered ${result.flows.length} flow${result.flows.length === 1 ? "" : "s"} across ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`);
|
|
303
|
+
await emit("done", "Exploration complete");
|
|
304
|
+
return result;
|
|
305
|
+
} finally {
|
|
306
|
+
await browser.close();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { cloudDeviceLabel } from "./device-clouds/types.js";
|
|
2
|
+
import type { DriverResult, DriverStepResult } from "./driver.js";
|
|
3
|
+
import type { BrowserName, RunTarget } from "./browser-matrix.js";
|
|
4
|
+
|
|
5
|
+
export type TargetStatus = "queued" | "running" | "passed" | "failed" | "blocked";
|
|
6
|
+
|
|
7
|
+
/** One browser/device's outcome within a run, as reported to the registry and dashboard. */
|
|
8
|
+
export type TargetResult = {
|
|
9
|
+
targetId: string;
|
|
10
|
+
browser: BrowserName;
|
|
11
|
+
device?: string;
|
|
12
|
+
/** Set when the target ran on a device cloud. */
|
|
13
|
+
cloudProvider?: string;
|
|
14
|
+
status: TargetStatus;
|
|
15
|
+
startedAt?: string;
|
|
16
|
+
completedAt?: string;
|
|
17
|
+
durationMs?: number;
|
|
18
|
+
stepsPassed: number;
|
|
19
|
+
stepsFailed: number;
|
|
20
|
+
failureCode?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
recoveryAttempts?: number;
|
|
23
|
+
artifactKeys: string[];
|
|
24
|
+
finalScreenshotKey?: string;
|
|
25
|
+
logKey?: string;
|
|
26
|
+
traceKey?: string;
|
|
27
|
+
videoKey?: string;
|
|
28
|
+
analysisKey?: string;
|
|
29
|
+
/** Each step as it ran, for the run's trace timeline. Typed text is masked. */
|
|
30
|
+
steps?: Array<{ index: number; status: "passed" | "failed" | "blocked"; label?: string; durationMs?: number; message?: string }>;
|
|
31
|
+
/** A short form of analysis.json, so the dashboard can show it without a fetch. */
|
|
32
|
+
analysis?: { title: string; category: "test" | "app" | "environment"; source: "rules" | "ai"; fixSummary?: string };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function queuedTargetResult(target: RunTarget): TargetResult {
|
|
36
|
+
return {
|
|
37
|
+
targetId: target.id,
|
|
38
|
+
browser: target.browser,
|
|
39
|
+
...(target.device ? { device: target.device } : {}),
|
|
40
|
+
// A cloud device shows as the device people picked, with where it ran.
|
|
41
|
+
...(target.cloud ? { device: cloudDeviceLabel(target.cloud), cloudProvider: target.cloud.provider } : {}),
|
|
42
|
+
status: "queued",
|
|
43
|
+
stepsPassed: 0,
|
|
44
|
+
stepsFailed: 0,
|
|
45
|
+
artifactKeys: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function stepCounts(result: DriverResult): { stepsPassed: number; stepsFailed: number } {
|
|
50
|
+
const steps = result.steps ?? [];
|
|
51
|
+
return {
|
|
52
|
+
stepsPassed: steps.filter((step) => step.status === "passed").length,
|
|
53
|
+
stepsFailed: steps.filter((step) => step.status === "failed").length,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Runs `work` for every item with at most `limit` in flight at once, keeping
|
|
59
|
+
* results in input order. A throwing item does not stop the others; callers
|
|
60
|
+
* turn failures into results themselves.
|
|
61
|
+
*/
|
|
62
|
+
export async function runPool<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
63
|
+
const results = new Array<R>(items.length);
|
|
64
|
+
let next = 0;
|
|
65
|
+
const lanes = Math.max(1, Math.min(limit, items.length));
|
|
66
|
+
await Promise.all(
|
|
67
|
+
Array.from({ length: lanes }, async () => {
|
|
68
|
+
for (;;) {
|
|
69
|
+
const index = next;
|
|
70
|
+
next += 1;
|
|
71
|
+
if (index >= items.length) return;
|
|
72
|
+
results[index] = await work(items[index], index);
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolveTargetConcurrency(
|
|
80
|
+
configured: unknown,
|
|
81
|
+
targetCount: number,
|
|
82
|
+
env: Record<string, string | undefined> = process.env,
|
|
83
|
+
): number {
|
|
84
|
+
const fromEnv = Number(env.BUGMOLE_TARGET_CONCURRENCY);
|
|
85
|
+
if (Number.isInteger(fromEnv) && fromEnv > 0) return fromEnv;
|
|
86
|
+
const fromConfig = Number(configured);
|
|
87
|
+
if (Number.isInteger(fromConfig) && fromConfig > 0) return fromConfig;
|
|
88
|
+
return Math.max(1, Math.min(targetCount, 4));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stepRank: Record<DriverStepResult["status"], number> = { passed: 0, blocked: 1, failed: 2 };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Folds per-target driver results into the one verdict the rest of the
|
|
95
|
+
* pipeline (step correspondence, run status) already understands.
|
|
96
|
+
*
|
|
97
|
+
* - Any failed target fails the run.
|
|
98
|
+
* - Targets that could not run at all (a browser not installed, an
|
|
99
|
+
* unsupported pairing) are reported but do not fail a run whose other
|
|
100
|
+
* targets passed; if none could run, the run is blocked.
|
|
101
|
+
* - Per-step statuses take the worst outcome across targets that ran, and
|
|
102
|
+
* are only produced when those targets agree on the step count, the same
|
|
103
|
+
* rule the executor applies to a single driver.
|
|
104
|
+
*/
|
|
105
|
+
export function aggregateTargetResults(results: ReadonlyArray<{ target: RunTarget; result: DriverResult }>): DriverResult {
|
|
106
|
+
const ran = results.filter(({ result }) => result.status !== "blocked");
|
|
107
|
+
const failed = results.filter(({ result }) => result.status === "failed");
|
|
108
|
+
const describe = ({ target, result }: { target: RunTarget; result: DriverResult }) => `${target.id}: ${result.message}`;
|
|
109
|
+
|
|
110
|
+
let status: DriverResult["status"];
|
|
111
|
+
let message: string;
|
|
112
|
+
if (failed.length > 0) {
|
|
113
|
+
status = "failed";
|
|
114
|
+
message = `${failed.length} of ${results.length} target(s) failed. ${failed.map(describe).join(" | ")}`;
|
|
115
|
+
} else if (ran.length === 0) {
|
|
116
|
+
status = "blocked";
|
|
117
|
+
message = `No target could run. ${results.map(describe).join(" | ")}`;
|
|
118
|
+
} else {
|
|
119
|
+
status = "passed";
|
|
120
|
+
const skipped = results.length - ran.length;
|
|
121
|
+
message = `${ran.length} target(s) passed${skipped ? `; ${skipped} could not run` : ""}.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let steps: DriverStepResult[] | undefined;
|
|
125
|
+
const stepLists = ran.map(({ result }) => result.steps);
|
|
126
|
+
if (stepLists.length > 0 && stepLists.every((list) => list && list.length === stepLists[0]!.length)) {
|
|
127
|
+
steps = stepLists[0]!.map((first, index) =>
|
|
128
|
+
stepLists.reduce<DriverStepResult>((worst, list) => {
|
|
129
|
+
const candidate = list![index];
|
|
130
|
+
return stepRank[candidate.status] > stepRank[worst.status] ? candidate : worst;
|
|
131
|
+
}, first),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const firstProblem = failed[0] ?? (ran.length === 0 ? results[0] : undefined);
|
|
136
|
+
return {
|
|
137
|
+
status,
|
|
138
|
+
message,
|
|
139
|
+
artifacts: [],
|
|
140
|
+
...(firstProblem?.result.failureCode ? { failureCode: firstProblem.result.failureCode } : {}),
|
|
141
|
+
...(failed.length > 0 ? { recoverable: failed.every(({ result }) => result.recoverable) } : {}),
|
|
142
|
+
...(steps ? { steps } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { verdictsFromRun } from "./test-case-verdicts.js";
|
|
4
|
+
|
|
5
|
+
const cases = [
|
|
6
|
+
{ caseId: "TC-home", nodeId: "screen:/" },
|
|
7
|
+
{ caseId: "TC-login", nodeId: "screen:/login" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
test("a node the run passed judges its case passed", () => {
|
|
11
|
+
const [home] = verdictsFromRun([cases[0]], [{ nodeId: "screen:/", outcome: "PASS", status: "success" }]);
|
|
12
|
+
assert.equal(home.status, "passed");
|
|
13
|
+
assert.match(home.detail, /screen:\//);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("a node the run failed judges its case failed, carrying the reason", () => {
|
|
17
|
+
const [home] = verdictsFromRun([cases[0]], [{
|
|
18
|
+
nodeId: "screen:/",
|
|
19
|
+
outcome: "FAIL",
|
|
20
|
+
status: "failed",
|
|
21
|
+
reason: { code: "ASSERT_VISIBLE", message: "h1 was not found" },
|
|
22
|
+
}]);
|
|
23
|
+
assert.equal(home.status, "failed");
|
|
24
|
+
assert.match(home.detail, /h1 was not found/);
|
|
25
|
+
assert.match(home.detail, /ASSERT_VISIBLE/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a case the run never reached is blocked, never passed", () => {
|
|
29
|
+
// The whole point: silence is not evidence. Marking this passed would let
|
|
30
|
+
// a run that touched one node claim coverage over a whole flow.
|
|
31
|
+
const verdicts = verdictsFromRun(cases, [{ nodeId: "screen:/", outcome: "PASS", status: "success" }]);
|
|
32
|
+
const login = verdicts.find((verdict) => verdict.caseId === "TC-login")!;
|
|
33
|
+
assert.equal(login.status, "blocked");
|
|
34
|
+
assert.match(login.detail, /never exercised/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("every case gets a verdict, so the flow cannot stay queued forever", () => {
|
|
38
|
+
// A case left unjudged is indistinguishable from one never run, which is
|
|
39
|
+
// what made the pipeline re-queue the same flow indefinitely.
|
|
40
|
+
const verdicts = verdictsFromRun(cases, []);
|
|
41
|
+
assert.equal(verdicts.length, cases.length);
|
|
42
|
+
assert.ok(verdicts.every((verdict) => verdict.status !== "passed"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a blocked node reports why it could not be checked", () => {
|
|
46
|
+
const [home] = verdictsFromRun([cases[0]], [{
|
|
47
|
+
nodeId: "screen:/",
|
|
48
|
+
status: "blocked",
|
|
49
|
+
reason: { code: "NO_SESSION", message: "not authenticated" },
|
|
50
|
+
}]);
|
|
51
|
+
assert.equal(home.status, "blocked");
|
|
52
|
+
assert.match(home.detail, /not authenticated/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("an UNKNOWN outcome is blocked rather than quietly passed", () => {
|
|
56
|
+
const [home] = verdictsFromRun([cases[0]], [{ nodeId: "screen:/", outcome: "UNKNOWN" }]);
|
|
57
|
+
assert.equal(home.status, "blocked");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a failure anywhere at the node outranks a pass elsewhere at it", () => {
|
|
61
|
+
const [home] = verdictsFromRun([cases[0]], [
|
|
62
|
+
{ nodeId: "screen:/", outcome: "PASS", status: "success" },
|
|
63
|
+
{ nodeId: "screen:/", outcome: "FAIL", status: "failed", reason: { message: "second action failed" } },
|
|
64
|
+
]);
|
|
65
|
+
assert.equal(home.status, "failed");
|
|
66
|
+
assert.match(home.detail, /second action failed/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an assumption already flagged blocked keeps its own reason", () => {
|
|
70
|
+
const [only] = verdictsFromRun(
|
|
71
|
+
[{ caseId: "TC-x", nodeId: "screen:/", blockedReason: "needs a seeded account" }],
|
|
72
|
+
[{ nodeId: "screen:/", outcome: "PASS", status: "success" }],
|
|
73
|
+
);
|
|
74
|
+
assert.equal(only.status, "blocked");
|
|
75
|
+
assert.equal(only.detail, "needs a seeded account");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("a crashed run names the real error instead of a generic 'recorded nothing'", () => {
|
|
79
|
+
// A run that threw reached no node at all. Without the reason every case
|
|
80
|
+
// would read "the run recorded nothing here", hiding why.
|
|
81
|
+
const verdicts = verdictsFromRun(cases, [], "The run failed 3 times. Last error: browser launch failed");
|
|
82
|
+
assert.equal(verdicts.length, 2);
|
|
83
|
+
assert.ok(verdicts.every((verdict) => verdict.status === "blocked"));
|
|
84
|
+
assert.match(verdicts[0].detail, /browser launch failed/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the crash reason does not override what the run actually observed", () => {
|
|
88
|
+
const [home] = verdictsFromRun(
|
|
89
|
+
[cases[0]],
|
|
90
|
+
[{ nodeId: "screen:/", outcome: "PASS", status: "success" }],
|
|
91
|
+
"some run-level failure",
|
|
92
|
+
);
|
|
93
|
+
assert.equal(home.status, "passed");
|
|
94
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a run into test case verdicts.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline's run stage is satisfied only when every case in a flow has a
|
|
5
|
+
* recorded verdict, and the run is what is supposed to produce them. Without
|
|
6
|
+
* this the two halves never met: runs recorded step counts and evidence, the
|
|
7
|
+
* cases stayed "planned", and the pipeline re-queued the same flow forever
|
|
8
|
+
* waiting for something no run ever wrote.
|
|
9
|
+
*
|
|
10
|
+
* Cases are pinned to a nodeId and the executor reports per-node outcomes, so
|
|
11
|
+
* the join is the node: a case is judged by what happened at the step it is
|
|
12
|
+
* about.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type NodeOutcome = {
|
|
16
|
+
nodeId: string;
|
|
17
|
+
/** The driver's verdict for this node. */
|
|
18
|
+
outcome?: "PASS" | "FAIL" | "UNKNOWN";
|
|
19
|
+
status?: "success" | "blocked" | "failed";
|
|
20
|
+
reason?: { code?: string; message?: string };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type JudgeableCase = {
|
|
24
|
+
caseId: string;
|
|
25
|
+
nodeId: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
blockedReason?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CaseVerdict = {
|
|
31
|
+
caseId: string;
|
|
32
|
+
status: "passed" | "failed" | "blocked" | "needs_setup";
|
|
33
|
+
detail: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function describe(node: NodeOutcome): string {
|
|
37
|
+
const message = node.reason?.message?.trim();
|
|
38
|
+
const code = node.reason?.code?.trim();
|
|
39
|
+
if (message) return code ? `${message} (${code})` : message;
|
|
40
|
+
if (code) return code;
|
|
41
|
+
return "no reason recorded";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Judge one flow's cases from what the run observed at each node.
|
|
46
|
+
*
|
|
47
|
+
* Every case gets a verdict, including the ones the run never reached —
|
|
48
|
+
* those are "blocked" with that stated as the reason. That completeness is
|
|
49
|
+
* the point: a case left unjudged is indistinguishable from one nobody has
|
|
50
|
+
* run yet, so it would keep the flow queued forever. "Blocked with a reason"
|
|
51
|
+
* is a real answer and lets the pipeline move on and surface it.
|
|
52
|
+
*
|
|
53
|
+
* A case is never marked passed on absence of evidence. If the run said
|
|
54
|
+
* nothing about its node, that is not a pass.
|
|
55
|
+
*/
|
|
56
|
+
export function verdictsFromRun(
|
|
57
|
+
cases: JudgeableCase[],
|
|
58
|
+
nodes: NodeOutcome[],
|
|
59
|
+
/**
|
|
60
|
+
* Why the run reached nothing, when it failed outright rather than simply
|
|
61
|
+
* not visiting a node. Without it a crashed run reads as "recorded nothing
|
|
62
|
+
* here", which hides the actual cause behind a generic message.
|
|
63
|
+
*/
|
|
64
|
+
unreachedReason?: string,
|
|
65
|
+
): CaseVerdict[] {
|
|
66
|
+
const byNode = new Map<string, NodeOutcome[]>();
|
|
67
|
+
for (const node of nodes) {
|
|
68
|
+
if (!node?.nodeId) continue;
|
|
69
|
+
const list = byNode.get(node.nodeId) ?? [];
|
|
70
|
+
list.push(node);
|
|
71
|
+
byNode.set(node.nodeId, list);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return cases.map((testCase) => {
|
|
75
|
+
// An assumption the author already flagged as blocked stays blocked: the
|
|
76
|
+
// run cannot un-block something that was never executable.
|
|
77
|
+
if (testCase.blockedReason) {
|
|
78
|
+
return {
|
|
79
|
+
caseId: testCase.caseId,
|
|
80
|
+
status: "blocked" as const,
|
|
81
|
+
detail: testCase.blockedReason,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const observed = byNode.get(testCase.nodeId) ?? [];
|
|
86
|
+
if (observed.length === 0) {
|
|
87
|
+
return {
|
|
88
|
+
caseId: testCase.caseId,
|
|
89
|
+
status: "blocked" as const,
|
|
90
|
+
detail: unreachedReason
|
|
91
|
+
?? `The run recorded nothing at ${testCase.nodeId}, so this case was never exercised.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const failed = observed.filter((node) => node.outcome === "FAIL" || node.status === "failed");
|
|
96
|
+
if (failed.length > 0) {
|
|
97
|
+
return {
|
|
98
|
+
caseId: testCase.caseId,
|
|
99
|
+
status: "failed" as const,
|
|
100
|
+
detail: `The run failed at ${testCase.nodeId}: ${describe(failed[0])}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const blocked = observed.filter((node) => node.status === "blocked" || node.outcome === "UNKNOWN");
|
|
105
|
+
if (blocked.length > 0) {
|
|
106
|
+
return {
|
|
107
|
+
caseId: testCase.caseId,
|
|
108
|
+
status: "blocked" as const,
|
|
109
|
+
detail: `The run could not check ${testCase.nodeId}: ${describe(blocked[0])}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
caseId: testCase.caseId,
|
|
115
|
+
status: "passed" as const,
|
|
116
|
+
detail: `The run passed every step at ${testCase.nodeId}`
|
|
117
|
+
+ `${observed.length > 1 ? ` (${observed.length} actions)` : ""}.`,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|