@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.
Files changed (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,620 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { Browser, BrowserContext, Locator, Page } from "playwright";
4
+ import type { AppDriver, DriverContext, DriverPlatform, DriverResult, DriverStepResult } from "./driver.js";
5
+ import {
6
+ contextOptionsFor,
7
+ installHint,
8
+ isMissingBrowserError,
9
+ launcherFor,
10
+ targetId,
11
+ type RunTarget,
12
+ } from "./browser-matrix.js";
13
+ import {
14
+ countFlowSteps,
15
+ interpolateStep,
16
+ describeAddressForLog,
17
+ describeLinkForLog,
18
+ describeStepForLog,
19
+ describeStepLabel,
20
+ isPatternText,
21
+ parseMaestroStyleFlow,
22
+ resolveFlowUrl,
23
+ type FlowStep,
24
+ type ParsedFlow,
25
+ } from "./flow-language.js";
26
+ import { analyzeFailure, rawFlowSteps, type FailureContext } from "./failure-analysis.js";
27
+ import { createTempEmailInbox, deleteTempEmailInbox, extractFirstLink, waitForTempEmailMessage, type TempEmailInbox } from "../integrations/temp-email.js";
28
+
29
+ export { parseMaestroStyleFlow } from "./flow-language.js";
30
+ export type { FlowStep, FlowViewport, ParsedFlow } from "./flow-language.js";
31
+
32
+ /**
33
+ * Drives web journeys with Playwright from Maestro-style flow files.
34
+ *
35
+ * Exists as the reliable alternative to the Maestro web driver, whose Beta web
36
+ * support can hang indefinitely on interaction commands in some environments
37
+ * (confirmed against Maestro's own documented example flow, both headless and
38
+ * headed) while Playwright drives the same pages without issue.
39
+ */
40
+
41
+ const defaultStepTimeoutMs = 15_000;
42
+
43
+ async function fileExists(target: string): Promise<boolean> {
44
+ try {
45
+ await fs.access(target);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Visibility as a person experiences it: rendered text, the value a person
54
+ * typed into a field, and the accessible name of a control they can see.
55
+ * Matching only rendered text would report an edited script line as missing.
56
+ *
57
+ * Authored as source text because it is evaluated inside the page, not here.
58
+ */
59
+ const visibilityProbe = `(pattern, isRegex) => {
60
+ const matches = (value) => {
61
+ if (!value) return false;
62
+ return isRegex ? new RegExp(pattern).test(value) : value.includes(pattern);
63
+ };
64
+ const isVisible = (element) => {
65
+ const rect = element.getBoundingClientRect();
66
+ // Screen-reader-only text is clipped to a 1x1 box. It is real content for
67
+ // assistive technology and must not count as visible to a person, or a
68
+ // flow could never assert that a hidden label stayed hidden.
69
+ if (rect.width < 2 || rect.height < 2) return false;
70
+ const style = window.getComputedStyle(element);
71
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
72
+ return true;
73
+ };
74
+ for (const element of Array.from(document.querySelectorAll("body *"))) {
75
+ if (!isVisible(element)) continue;
76
+ const tag = element.tagName;
77
+ if (tag === "INPUT" || tag === "TEXTAREA") {
78
+ if (matches(element.value) || matches(element.placeholder)) return true;
79
+ }
80
+ if (matches(element.getAttribute("aria-label"))) return true;
81
+ const ownText = Array.from(element.childNodes)
82
+ .filter((node) => node.nodeType === 3)
83
+ .map((node) => node.nodeValue || "")
84
+ .join("");
85
+ if (matches(ownText)) return true;
86
+ }
87
+ return false;
88
+ }`;
89
+
90
+ async function waitForVisibility(page: Page, text: string, shouldBeVisible: boolean, timeoutMs: number): Promise<void> {
91
+ const isRegex = isPatternText(text);
92
+ const deadline = Date.now() + timeoutMs;
93
+ for (;;) {
94
+ // Compiled inside the page: Playwright evaluates a bare string as an
95
+ // expression and drops the argument, so the probe is passed as data.
96
+ const visible = await page.evaluate(
97
+ (args: [string, string, boolean]) =>
98
+ (new Function(`return (${args[0]})`)() as (pattern: string, isRegex: boolean) => boolean)(args[1], args[2]),
99
+ [visibilityProbe, text, isRegex] as [string, string, boolean],
100
+ );
101
+ if (visible === shouldBeVisible) return;
102
+ if (Date.now() >= deadline) {
103
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for "${text}" to be ${shouldBeVisible ? "visible" : "hidden"}.`);
104
+ }
105
+ await page.waitForTimeout(200);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * True when an element is the actual hit-test target at its own center point
111
+ * -- not just painted, but reachable to a real tap. Compiled inside the page
112
+ * like visibilityProbe, since it references window/document that this file's
113
+ * lib config does not type-check. An element whose true center sits outside
114
+ * the viewport reports unreachable: a person would have to scroll to it
115
+ * first, same as here.
116
+ */
117
+ const reachabilityProbe = `(element) => {
118
+ const rect = element.getBoundingClientRect();
119
+ if (rect.width < 1 || rect.height < 1) return false;
120
+ const style = window.getComputedStyle(element);
121
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
122
+ const cx = rect.left + rect.width / 2;
123
+ const cy = rect.top + rect.height / 2;
124
+ if (cx < 0 || cx > window.innerWidth || cy < 0 || cy > window.innerHeight) return false;
125
+ const top = document.elementFromPoint(cx, cy);
126
+ return top === element || element.contains(top);
127
+ }`;
128
+
129
+ /**
130
+ * What a person could see on the page when a step failed: the controls and
131
+ * headings they could act on, any error messages, and fields that are empty
132
+ * or invalid. Field values are never read, only whether they are empty.
133
+ */
134
+ const failureProbe = `() => {
135
+ const visible = (element) => {
136
+ const rect = element.getBoundingClientRect();
137
+ if (rect.width < 2 || rect.height < 2) return false;
138
+ const style = window.getComputedStyle(element);
139
+ return style.visibility !== "hidden" && style.display !== "none" && Number(style.opacity) !== 0;
140
+ };
141
+ const text = (element) => (element.innerText || element.textContent || "").replace(/\\s+/g, " ").trim();
142
+ const labelOf = (field) => {
143
+ const own = field.labels && field.labels[0] ? text(field.labels[0]) : "";
144
+ return own || field.getAttribute("aria-label") || field.getAttribute("placeholder") || field.getAttribute("name") || "";
145
+ };
146
+ const seen = new Set();
147
+ const candidates = [];
148
+ const add = (role, value) => {
149
+ const clean = (value || "").replace(/\\s+/g, " ").trim();
150
+ if (!clean || clean.length > 80 || seen.has(role + clean)) return;
151
+ seen.add(role + clean);
152
+ candidates.push({ role, text: clean });
153
+ };
154
+ for (const element of document.querySelectorAll("button, [role=button], input[type=submit]")) {
155
+ if (visible(element)) add("button", text(element) || element.getAttribute("aria-label") || element.value);
156
+ }
157
+ for (const element of document.querySelectorAll("a[href]")) if (visible(element)) add("link", text(element) || element.getAttribute("aria-label"));
158
+ for (const element of document.querySelectorAll("h1, h2, h3")) if (visible(element)) add("heading", text(element));
159
+ const invalidFields = [];
160
+ for (const field of document.querySelectorAll("input, textarea, select")) {
161
+ if (!visible(field) || field.type === "hidden") continue;
162
+ const label = labelOf(field);
163
+ if (label) add("field", label);
164
+ const empty = !field.value;
165
+ const invalid = field.getAttribute("aria-invalid") === "true" || (typeof field.matches === "function" && field.matches(":invalid"));
166
+ if (label && (invalid || (empty && field.required))) invalidFields.push({ label, type: field.type || field.tagName.toLowerCase(), empty });
167
+ }
168
+ const alerts = [];
169
+ for (const element of document.querySelectorAll("[role=alert], [aria-live=assertive], [aria-live=polite], .error, .invalid-feedback, [class*=error], [data-error]")) {
170
+ const message = text(element);
171
+ if (visible(element) && message && message.length <= 160 && !alerts.includes(message)) alerts.push(message);
172
+ }
173
+ return { url: location.href, title: document.title, candidates: candidates.slice(0, 80), alerts: alerts.slice(0, 10), invalidFields: invalidFields.slice(0, 10) };
174
+ }`;
175
+
176
+ /**
177
+ * Polls waitForClickable's target until it is reachable. Catches a real bug
178
+ * class assertVisible cannot -- an unrelated fixed-position element (a
179
+ * mascot, a floating action button, a toast) sitting on top of a link or
180
+ * button and silently swallowing the tap.
181
+ */
182
+ async function waitForClickable(page: Page, locator: Locator, text: string, timeoutMs: number): Promise<void> {
183
+ const deadline = Date.now() + timeoutMs;
184
+ for (;;) {
185
+ const handle = await locator.elementHandle({ timeout: 1_000 }).catch(() => null);
186
+ const reachable = handle
187
+ ? await page
188
+ .evaluate(
189
+ (args: [string, unknown]) => (new Function("element", `return (${args[0]})(element)`))(args[1]) as boolean,
190
+ [reachabilityProbe, handle] as [string, unknown],
191
+ )
192
+ .catch(() => false)
193
+ : false;
194
+ await handle?.dispose().catch(() => undefined);
195
+ if (reachable) return;
196
+ if (Date.now() >= deadline) {
197
+ throw new Error(`"${text}" did not become clickable within ${timeoutMs}ms -- it is either off-screen or something else is on top of it.`);
198
+ }
199
+ await page.waitForTimeout(200);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Candidate locators in the order a person resolves a target: the control they
205
+ * can name, then the field labelled that way, then anything reading that way.
206
+ */
207
+ /** Playwright needs its own ffmpeg build to write a video; nothing else does. */
208
+ export function isMissingVideoEncoder(error: unknown): boolean {
209
+ const message = error instanceof Error ? error.message : String(error);
210
+ return /ffmpeg/i.test(message) && /doesn't exist|not found|ENOENT|requires ffmpeg/i.test(message);
211
+ }
212
+
213
+ function tapCandidates(page: Page, text: string): Locator[] {
214
+ const matcher = isPatternText(text) ? new RegExp(text) : text;
215
+ return [
216
+ page.getByRole("button", { name: matcher }),
217
+ page.getByRole("link", { name: matcher }),
218
+ page.getByLabel(matcher),
219
+ page.getByPlaceholder(matcher),
220
+ page.getByText(matcher, { exact: false }),
221
+ ];
222
+ }
223
+
224
+ async function resolveTapTarget(page: Page, text: string, index: number): Promise<Locator | null> {
225
+ for (const candidate of tapCandidates(page, text)) {
226
+ if ((await candidate.count()) > index) return candidate.nth(index);
227
+ }
228
+ return null;
229
+ }
230
+
231
+ export type PlaywrightDriverOptions = {
232
+ headless?: boolean;
233
+ };
234
+
235
+ export class PlaywrightDriver implements AppDriver {
236
+ readonly id = "playwright";
237
+ private readonly headless: boolean;
238
+
239
+ constructor(options: PlaywrightDriverOptions = {}) {
240
+ this.headless = options.headless ?? true;
241
+ }
242
+
243
+ supports(platform: DriverPlatform): boolean {
244
+ return platform === "web";
245
+ }
246
+
247
+ async run(context: DriverContext): Promise<DriverResult> {
248
+ const startedAt = Date.now();
249
+ const result = await this.runFlow(context);
250
+ return { ...result, durationMs: Date.now() - startedAt };
251
+ }
252
+
253
+ private async runFlow(context: DriverContext): Promise<DriverResult> {
254
+ if (!this.supports(context.platform)) {
255
+ return {
256
+ status: "blocked",
257
+ message: `Playwright driver does not support platform "${context.platform}".`,
258
+ artifacts: [],
259
+ failureCode: "unsupported_platform",
260
+ };
261
+ }
262
+
263
+ const target: RunTarget = context.runTarget ?? { id: targetId("chromium"), browser: "chromium" };
264
+ const emulation = contextOptionsFor(target);
265
+ if ("unsupported" in emulation) {
266
+ return { status: "blocked", message: emulation.unsupported, artifacts: [], failureCode: "unsupported_target" };
267
+ }
268
+
269
+ let flow: ParsedFlow;
270
+ let flowContent = "";
271
+ try {
272
+ const content = await fs.readFile(context.flowPath, "utf8");
273
+ flowContent = content;
274
+ flow = parseMaestroStyleFlow(content, context.target.baseUrl, { rebaseOrigin: context.rebaseOrigin });
275
+ } catch (error) {
276
+ return {
277
+ status: "blocked",
278
+ message: error instanceof Error ? error.message : "Failed to read/parse flow file.",
279
+ artifacts: [],
280
+ failureCode: "invalid_flow",
281
+ };
282
+ }
283
+
284
+ // Each target gets its own directory: targets of one run execute
285
+ // concurrently and would otherwise overwrite each other's evidence.
286
+ const artifactsDir = context.runTarget
287
+ ? path.join(context.artifactsDir, context.projectId, context.runId, context.journeyId, target.id)
288
+ : path.join(context.artifactsDir, context.projectId, context.runId, context.journeyId);
289
+ const videoDir = path.join(artifactsDir, "video");
290
+ await fs.mkdir(videoDir, { recursive: true });
291
+ const log: string[] = [];
292
+ const steps: DriverStepResult[] = [];
293
+ const pageErrors: string[] = [];
294
+ const consoleErrors: string[] = [];
295
+ const screenshots: { path: string; name: string }[] = [];
296
+ // Populated by requestTempEmail and consumed by interpolateStep/openEmailLink.
297
+ // Inboxes are deleted best-effort in the outer finally below, whether the
298
+ // flow passed or failed.
299
+ const flowContext: Record<string, string> = {};
300
+ const flowInboxes: Record<string, TempEmailInbox> = {};
301
+ const totalSteps = countFlowSteps(flow.steps);
302
+ const stepTimeoutMs = Math.min(context.timeoutMs, defaultStepTimeoutMs);
303
+ let browser: Browser | undefined;
304
+ let browserContext: BrowserContext | undefined;
305
+ let currentStepStartedAt: number | undefined;
306
+
307
+ const writeLog = async () => {
308
+ const logPath = path.join(artifactsDir, "playwright.log");
309
+ await fs.writeFile(logPath, log.join("\n"), "utf8").catch(() => undefined);
310
+ return logPath;
311
+ };
312
+ const stopTrace = async (): Promise<string | undefined> => {
313
+ if (!browserContext) return undefined;
314
+ const tracePath = path.join(artifactsDir, "playwright-trace.zip");
315
+ try {
316
+ await browserContext.tracing.stop({ path: tracePath });
317
+ return tracePath;
318
+ } catch {
319
+ return undefined;
320
+ }
321
+ };
322
+
323
+ try {
324
+ const launcher = launcherFor(target.browser);
325
+ try {
326
+ browser = await launcher.type.launch({ headless: this.headless, ...(launcher.channel ? { channel: launcher.channel } : {}) });
327
+ } catch (error) {
328
+ if (!isMissingBrowserError(error)) throw error;
329
+ const hint = installHint(target.browser);
330
+ log.push(hint);
331
+ return {
332
+ status: "blocked",
333
+ message: hint,
334
+ artifacts: [{ path: await writeLog(), contentType: "text/plain", name: "playwright.log" }],
335
+ failureCode: "browser_not_installed",
336
+ };
337
+ }
338
+ const storageState = context.sessionStatePath && (await fileExists(context.sessionStatePath)) ? context.sessionStatePath : undefined;
339
+ // Record at the page's real size; Playwright's default shrinks to 800px.
340
+ const videoSize = flow.viewport ?? emulation.options.viewport ?? { width: 1280, height: 720 };
341
+ const contextOptions = {
342
+ ...emulation.options,
343
+ storageState,
344
+ ...(flow.viewport ? { viewport: flow.viewport } : {}),
345
+ };
346
+ const openContext = async (withVideo: boolean) => {
347
+ const opened = await browser!.newContext(
348
+ withVideo ? { ...contextOptions, recordVideo: { dir: videoDir, size: videoSize } } : contextOptions,
349
+ );
350
+ // The trace is the step-by-step record a person opens to debug a run.
351
+ await opened.tracing.start({ screenshots: true, snapshots: true }).catch(() => undefined);
352
+ return opened;
353
+ };
354
+ browserContext = await openContext(true);
355
+ let page: Page;
356
+ try {
357
+ page = await browserContext.newPage();
358
+ } catch (error) {
359
+ // A worker without Playwright's ffmpeg can still run the test; it just
360
+ // can't record video. Losing the video beats losing the whole run.
361
+ if (!isMissingVideoEncoder(error)) throw error;
362
+ log.push("NOTE: no video recorded — Playwright's ffmpeg is missing. Run `npx playwright install ffmpeg` on this worker to record video.");
363
+ await browserContext.close().catch(() => undefined);
364
+ browserContext = await openContext(false);
365
+ page = await browserContext.newPage();
366
+ }
367
+ page.setDefaultTimeout(stepTimeoutMs);
368
+ page.on("pageerror", (error) => {
369
+ pageErrors.push(error.message);
370
+ log.push(`PAGE ERROR: ${error.message}`);
371
+ });
372
+ page.on("console", (message) => {
373
+ if (message.type() === "error") {
374
+ log.push(`CONSOLE ERROR: ${message.text()}`);
375
+ consoleErrors.push(message.text());
376
+ }
377
+ });
378
+
379
+ const capture = async (name: string) => {
380
+ const screenshotPath = path.join(artifactsDir, `${name.replace(/[^a-z0-9_-]+/gi, "-")}.png`);
381
+ await page.screenshot({ path: screenshotPath, fullPage: true });
382
+ screenshots.push({ path: screenshotPath, name: path.basename(screenshotPath) });
383
+ return screenshotPath;
384
+ };
385
+
386
+ const runStep = async (rawStep: FlowStep): Promise<void> => {
387
+ const step = interpolateStep(rawStep, flowContext);
388
+ switch (step.type) {
389
+ case "launchApp":
390
+ await page.goto(flow.url, { waitUntil: "load" });
391
+ return;
392
+ case "stopApp":
393
+ await page.goto("about:blank");
394
+ return;
395
+ case "openLink":
396
+ await page.goto(resolveFlowUrl(step.url, flow.url, flow.rebasedFrom, flow.rebasedOnto), { waitUntil: "load" });
397
+ return;
398
+ case "tapOn": {
399
+ const target = await resolveTapTarget(page, step.text, step.index);
400
+ if (!target) {
401
+ if (step.optional) {
402
+ log.push(`Skipped optional tapOn "${step.text}" (no match).`);
403
+ return;
404
+ }
405
+ throw new Error(`No element matching "${step.text}" at index ${step.index}.`);
406
+ }
407
+ await target.scrollIntoViewIfNeeded();
408
+ await target.click();
409
+ return;
410
+ }
411
+ case "inputText":
412
+ await page.keyboard.type(step.text);
413
+ return;
414
+ case "eraseText":
415
+ // Clears the focused field the way a person would: select all, delete.
416
+ await page.keyboard.press("ControlOrMeta+a");
417
+ await page.keyboard.press("Delete");
418
+ return;
419
+ case "pressKey":
420
+ await page.keyboard.press(step.key);
421
+ return;
422
+ case "assertVisible":
423
+ await waitForVisibility(page, step.text, true, step.timeoutMs ?? stepTimeoutMs);
424
+ return;
425
+ case "assertNotVisible":
426
+ await waitForVisibility(page, step.text, false, step.timeoutMs ?? stepTimeoutMs);
427
+ return;
428
+ case "assertClickable": {
429
+ const target = await resolveTapTarget(page, step.text, step.index);
430
+ if (!target) throw new Error(`No element matching "${step.text}" at index ${step.index}.`);
431
+ await waitForClickable(page, target, step.text, step.timeoutMs ?? stepTimeoutMs);
432
+ return;
433
+ }
434
+ case "scrollUntilVisible": {
435
+ const target = await resolveTapTarget(page, step.text, 0);
436
+ if (!target) throw new Error(`No element matching "${step.text}" to scroll to.`);
437
+ await target.scrollIntoViewIfNeeded();
438
+ return;
439
+ }
440
+ case "wait":
441
+ await page.waitForTimeout(step.ms);
442
+ return;
443
+ case "takeScreenshot":
444
+ await capture(step.name);
445
+ return;
446
+ case "retry": {
447
+ let lastError: unknown;
448
+ for (let attempt = 0; attempt <= step.maxRetries; attempt += 1) {
449
+ try {
450
+ for (const inner of step.steps) await runStep(inner);
451
+ return;
452
+ } catch (error) {
453
+ lastError = error;
454
+ log.push(`Retry attempt ${attempt + 1} failed: ${error instanceof Error ? error.message : String(error)}`);
455
+ }
456
+ }
457
+ throw lastError instanceof Error ? lastError : new Error("Retry block failed.");
458
+ }
459
+ case "requestTempEmail": {
460
+ const inbox = await createTempEmailInbox();
461
+ flowContext[step.as] = inbox.address;
462
+ flowInboxes[step.as] = inbox;
463
+ log.push(`Requested temp email inbox "${step.as}": ${describeAddressForLog(inbox.address)}`);
464
+ return;
465
+ }
466
+ case "openEmailLink": {
467
+ const inbox = flowInboxes[step.as];
468
+ if (!inbox) {
469
+ throw new Error(`openEmailLink references inbox "${step.as}", but no requestTempEmail step bound that name yet.`);
470
+ }
471
+ const message = await waitForTempEmailMessage(inbox, {
472
+ from: step.from,
473
+ subject: step.subject,
474
+ timeoutMs: step.timeoutMs ?? stepTimeoutMs,
475
+ });
476
+ const link = extractFirstLink(message, step.match);
477
+ log.push(`openEmailLink: message "${message.subject}" from ${message.from} -> ${describeLinkForLog(link)}`);
478
+ await page.goto(link, { waitUntil: "load" });
479
+ return;
480
+ }
481
+ }
482
+ };
483
+
484
+ for (const [index, step] of flow.steps.entries()) {
485
+ const stepStartedAt = Date.now();
486
+ currentStepStartedAt = stepStartedAt;
487
+ log.push(`Step ${index + 1}: ${describeStepForLog(step)}`);
488
+ await runStep(step);
489
+ steps.push({
490
+ sequenceNumber: index,
491
+ status: "passed",
492
+ label: describeStepLabel(step),
493
+ message: describeStepForLog(step),
494
+ durationMs: Date.now() - stepStartedAt,
495
+ timestamp: new Date().toISOString(),
496
+ });
497
+ }
498
+
499
+ const finalScreenshot = await capture("playwright-final");
500
+ if (context.sessionStatePath) await browserContext.storageState({ path: context.sessionStatePath });
501
+ const tracePath = await stopTrace();
502
+ const logPath = await writeLog();
503
+ const videoPath = await page.video()?.path();
504
+ await browserContext.close();
505
+ browserContext = undefined;
506
+
507
+ const artifacts = [
508
+ ...screenshots.map((shot) => ({ path: shot.path, contentType: "image/png", name: shot.name })),
509
+ { path: logPath, contentType: "text/plain", name: "playwright.log" },
510
+ ...(tracePath ? [{ path: tracePath, contentType: "application/zip", name: "playwright-trace.zip" }] : []),
511
+ ...(videoPath ? [{ path: videoPath, contentType: "video/webm", name: "playwright-run.webm" }] : []),
512
+ ];
513
+
514
+ // An uncaught page error means the journey crashed the app, whatever the
515
+ // assertions said; reporting it as passed would be false evidence.
516
+ if (pageErrors.length > 0 && !flow.ignorePageErrors) {
517
+ return {
518
+ status: "failed",
519
+ message: `Flow steps completed but the page raised ${pageErrors.length} uncaught error(s): ${pageErrors[0]}`,
520
+ artifacts,
521
+ recoverable: true,
522
+ failureCode: "page_error",
523
+ steps,
524
+ };
525
+ }
526
+
527
+ return {
528
+ status: "passed",
529
+ message: `Playwright flow completed (${totalSteps} step(s)); final screenshot at ${finalScreenshot}.`,
530
+ artifacts,
531
+ steps,
532
+ };
533
+ } catch (error) {
534
+ const message = error instanceof Error ? error.message : "Playwright flow failed.";
535
+ log.push(`ERROR: ${message}`);
536
+ // The step whose action threw failed; steps after it never ran and must
537
+ // not be reported as either passed or failed.
538
+ const failedStepIndex = steps.length;
539
+ steps.push({
540
+ sequenceNumber: failedStepIndex,
541
+ status: "failed",
542
+ ...(failedStepIndex < flow.steps.length ? { label: describeStepLabel(flow.steps[failedStepIndex]) } : {}),
543
+ message,
544
+ ...(currentStepStartedAt !== undefined ? { durationMs: Date.now() - currentStepStartedAt } : {}),
545
+ timestamp: new Date().toISOString(),
546
+ });
547
+ for (let index = steps.length; index < flow.steps.length; index += 1) {
548
+ steps.push({ sequenceNumber: index, status: "blocked", label: describeStepLabel(flow.steps[index]), message: "Not reached; an earlier step failed." });
549
+ }
550
+ const artifacts: DriverResult["artifacts"] = [];
551
+ try {
552
+ const failurePage = browserContext?.pages()[0];
553
+ if (failurePage && failedStepIndex < flow.steps.length) {
554
+ // Evidence for the analysis: what was on screen when the step failed.
555
+ const snapshot = await failurePage
556
+ .evaluate((source: string) => (new Function(`return (${source})`)() as () => unknown)(), failureProbe)
557
+ .catch(() => null) as Omit<FailureContext, "failedStepIndex" | "error" | "pageErrors" | "consoleErrors"> | null;
558
+ const failureContext: FailureContext = {
559
+ url: snapshot?.url,
560
+ title: snapshot?.title,
561
+ failedStepIndex,
562
+ failedStep: describeStepForLog(flow.steps[failedStepIndex]),
563
+ error: message,
564
+ pageErrors: [...pageErrors],
565
+ consoleErrors: consoleErrors.slice(0, 10),
566
+ candidates: snapshot?.candidates ?? [],
567
+ alerts: snapshot?.alerts ?? [],
568
+ invalidFields: snapshot?.invalidFields ?? [],
569
+ };
570
+ const contextPath = path.join(artifactsDir, "failure-context.json");
571
+ await fs.writeFile(contextPath, `${JSON.stringify(failureContext, null, 2)}\n`, "utf8");
572
+ artifacts.push({ path: contextPath, contentType: "application/json", name: "failure-context.json" });
573
+ try {
574
+ const analysis = analyzeFailure(flow.steps, rawFlowSteps(flowContent), failureContext);
575
+ const analysisPath = path.join(artifactsDir, "analysis.json");
576
+ await fs.writeFile(analysisPath, `${JSON.stringify(analysis, null, 2)}\n`, "utf8");
577
+ artifacts.push({ path: analysisPath, contentType: "application/json", name: "analysis.json" });
578
+ log.push(`ANALYSIS: ${analysis.title}`);
579
+ } catch (analysisError) {
580
+ log.push(`Analysis unavailable: ${analysisError instanceof Error ? analysisError.message : String(analysisError)}`);
581
+ }
582
+ }
583
+ if (failurePage) {
584
+ const failurePath = path.join(artifactsDir, "playwright-failure.png");
585
+ await failurePage.screenshot({ path: failurePath, fullPage: true });
586
+ artifacts.push({ path: failurePath, contentType: "image/png", name: "playwright-failure.png" });
587
+ }
588
+ } catch {
589
+ // A failure screenshot is best-effort; the error itself is captured below.
590
+ }
591
+ if (pageErrors.length > 0) log.push(`Uncaught page errors: ${pageErrors.join(" | ")}`);
592
+ const tracePath = await stopTrace();
593
+ if (tracePath) artifacts.push({ path: tracePath, contentType: "application/zip", name: "playwright-trace.zip" });
594
+ artifacts.push({ path: await writeLog(), contentType: "text/plain", name: "playwright.log" });
595
+ try {
596
+ const videoPath = await browserContext?.pages()[0]?.video()?.path();
597
+ await browserContext?.close();
598
+ browserContext = undefined;
599
+ if (videoPath) artifacts.push({ path: videoPath, contentType: "video/webm", name: "playwright-run.webm" });
600
+ } catch {
601
+ // Video finalization is best-effort; the run's failure is already captured above.
602
+ }
603
+ return {
604
+ status: "failed",
605
+ message,
606
+ artifacts,
607
+ recoverable: true,
608
+ failureCode: "flow_failed",
609
+ steps,
610
+ };
611
+ } finally {
612
+ await browserContext?.close().catch(() => undefined);
613
+ await browser?.close();
614
+ // Every requestTempEmail inbox gets deleted here, whether the flow
615
+ // passed or failed -- these are disposable by design and a QA run
616
+ // should not accumulate live mail.tm accounts every time it executes.
617
+ await Promise.all(Object.values(flowInboxes).map((inbox) => deleteTempEmailInbox(inbox)));
618
+ }
619
+ }
620
+ }