@bugmole/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. package/src/vendor/byok.ts +371 -0
@@ -5,6 +5,7 @@ import { createProjectObjectStore } from "../storage/create-object-store.js";
5
5
  import { projectPrefix } from "../storage/keys.js";
6
6
  import { ControlPlaneClient } from "../registry/control-plane-client.js";
7
7
  import { nextJourneyTask } from "./journey-editor.js";
8
+ import { projectActorProblem, projectRoles, rolesClient } from "./project-roles.js";
8
9
 
9
10
  /** Drivers that can actually operate each kind of target. */
10
11
  const DRIVERS_BY_PLATFORM: Record<string, string[]> = {
@@ -36,6 +37,8 @@ interface PlanOptions {
36
37
  journeyId: string;
37
38
  actor: string;
38
39
  revision?: number;
40
+ /** Where the registry's role decisions come from; null skips the registry. Defaults to this machine's API key. */
41
+ rolesClient?: Pick<ControlPlaneClient, "listRoles"> | null;
39
42
  }
40
43
 
41
44
  interface PlanResult {
@@ -82,17 +85,10 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
82
85
  nextSteps: ["Select an existing journey or record the selected journey before planning."],
83
86
  };
84
87
  }
85
- if (selectedJourney.stability !== "stable" || selectedJourney.linkedToGraph !== true) {
86
- return {
87
- status: "error",
88
- error: `Journey "${journeyId}" is not stabilized. Execute and adjust the canonical flow until it is stable before planning.`,
89
- nextSteps: [
90
- `Run the canonical flow "${journeyId}"`,
91
- "Adjust the flow for any failures and rerun it",
92
- "Mark the verified run as the stabilized canonical flow",
93
- ],
94
- };
95
- }
88
+ // A draft journey can be planned: running its plan is how it becomes
89
+ // stable (bugmole_journey_stabilize takes that run's ID). Requiring
90
+ // "stable" first left no way to make the run that proves it.
91
+ const isStable = selectedJourney.stability === "stable" && selectedJourney.linkedToGraph === true;
96
92
  const flowState = nextJourneyTask(cfg, journeyId);
97
93
  if (flowState.status === "actionable") {
98
94
  return {
@@ -123,17 +119,18 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
123
119
  nextSteps: ["Create roles.yaml from the guided testing workflow, then retry planning."],
124
120
  };
125
121
  }
126
- const roles = YAML.parse(fs.readFileSync(rolesPath, "utf-8"));
127
- const knownActors = Array.isArray(roles?.roles)
128
- ? roles.roles.map((role: any) => role?.name).filter((name: unknown): name is string => typeof name === "string")
129
- : [];
130
- if (!knownActors.includes(actor)) {
122
+ // Only a role a person confirmed may be planned for: Bugmole suggests
123
+ // roles, people decide them (src/registry/roles.ts, docs/public/roles.md).
124
+ const roles = await projectRoles(cfg, options.rolesClient === undefined ? rolesClient(cfg) : options.rolesClient);
125
+ const actorIssue = projectActorProblem(roles, actor);
126
+ if (actorIssue) {
131
127
  return {
132
128
  status: "error",
133
- error: `Actor "${actor}" is not defined in roles.yaml.`,
129
+ error: actorIssue,
134
130
  nextSteps: [
135
- `Read roles.yaml and choose one of: ${knownActors.join(", ") || "(none defined)"}`,
136
- "Retry planning with a defined actor",
131
+ `Plan with a confirmed role: ${roles.usable.join(", ") || "(none confirmed yet)"}`,
132
+ ...(roles.suggested.length ? [`Ask a person to confirm or reject the suggested roles on the dashboard's Roles page: ${roles.suggested.join(", ")}`] : []),
133
+ "If the journey really needs a new role, suggest it with bugmole_role_propose and wait for a person to confirm it",
137
134
  ],
138
135
  };
139
136
  }
@@ -169,6 +166,25 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
169
166
  const steps = graphSteps;
170
167
  const planId = journeyId.replace(/[^a-z0-9._-]/gi, "_").toLowerCase();
171
168
  const planPath = path.join(plansDir, `${planId}.flow.yaml`);
169
+ const platform = cfg.project?.platform || "web";
170
+ // Without a flow a plan has nothing to run. On the web, write one from
171
+ // the journey itself: open its first screen, then follow each recorded
172
+ // transition, checking every screen it reaches.
173
+ const configuredFlow: string | undefined = cfg.execution?.maestro?.flow || undefined;
174
+ let generatedFlow: string | null = null;
175
+ if (!configuredFlow && platform === "web") {
176
+ const reference = `flows/journeys/${planId}.yaml`;
177
+ if (fs.existsSync(path.join(specDir, reference))) {
178
+ generatedFlow = reference;
179
+ } else {
180
+ const content = journeyFlow(selectedJourney, discoveredNodes, discoveredEdges);
181
+ if (content) {
182
+ generatedFlow = reference;
183
+ fs.mkdirSync(path.join(specDir, "flows", "journeys"), { recursive: true });
184
+ fs.writeFileSync(path.join(specDir, reference), content, "utf-8");
185
+ }
186
+ }
187
+ }
172
188
 
173
189
  const plan = {
174
190
  version: 2,
@@ -176,14 +192,14 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
176
192
  name: `Journey: ${journeyId}`,
177
193
  description: `Generated plan for journey ${journeyId} with actor ${actor}`,
178
194
  driver: driverForPlatform(cfg.execution?.driver, cfg.project?.platform || "web"),
179
- platform: cfg.project?.platform || "web",
195
+ platform,
180
196
  target: {
181
197
  base_url: cfg.project?.base_url || cfg.execution?.playwright?.base_url,
182
198
  app_id: cfg.project?.app_id,
183
199
  device_id: cfg.project?.device_id,
184
200
  serial: cfg.project?.serial,
185
201
  },
186
- flow: cfg.execution?.maestro?.flow || null,
202
+ flow: configuredFlow || generatedFlow,
187
203
  recovery: {
188
204
  enabled: cfg.execution?.recovery?.enabled === true,
189
205
  max_attempts: cfg.execution?.recovery?.max_attempts ?? 1,
@@ -200,7 +216,7 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
200
216
  actor: actor,
201
217
  goal: `Complete journey: ${journeyId}`,
202
218
  capabilities_required: [],
203
- preconditions: ["Test tenant / sandbox is active", "Actor account exists"],
219
+ preconditions: [`Signed in as ${actor}, or signed out if that's who the journey is for`],
204
220
  steps,
205
221
  },
206
222
  ],
@@ -263,7 +279,13 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
263
279
  status: "success",
264
280
  planId: planId,
265
281
  planPath: `plans/${planId}.flow.yaml`,
266
- message: `Plan created at ${planPath}. Review and customize as needed.${apiKey && registryUrl ? " Published to the dashboard." : ""}`,
282
+ ...(plan.flow ? { flow: plan.flow } : {}),
283
+ message: `Plan created at ${planPath}${generatedFlow ? `, with its flow at ${path.join(specDir, generatedFlow)}` : ""}.`
284
+ + (plan.flow ? "" : " It has no flow to run yet: set execution.maestro.flow, or record the journey's screens with their routes and plan again.")
285
+ + (apiKey && registryUrl ? " Published to the dashboard." : ""),
286
+ nextSteps: isStable
287
+ ? [`Run it: bugmole_run with planId ${planId}`]
288
+ : [`Run it: bugmole_run with planId ${planId}`, `When that run passes, call bugmole_journey_stabilize with journeyId ${journeyId} and the run's ID`],
267
289
  };
268
290
  } catch (error: any) {
269
291
  return {
@@ -272,3 +294,46 @@ export async function plan(cfg: any, options: PlanOptions): Promise<PlanResult>
272
294
  };
273
295
  }
274
296
  }
297
+
298
+ /**
299
+ * A web flow that walks a journey: open the first screen, then follow each
300
+ * transition in order, tapping its label when it has one and opening the
301
+ * next screen's route otherwise. Null when there is nothing to open.
302
+ */
303
+ export function journeyFlow(
304
+ journey: { id: string; name?: string; startNodeId?: string; nodeIds?: string[]; edgeIds?: string[] },
305
+ nodes: Array<{ id: string; route?: string; title?: string }>,
306
+ edges: Array<{ id: string; from: string; to: string; label?: string }>,
307
+ ): string | null {
308
+ const byId = new Map(nodes.map((node) => [node.id, node]));
309
+ const opens = (route?: string) => typeof route === "string" && route.startsWith("/") && !/[:\[*]/.test(route);
310
+ const quote = (text: string) => JSON.stringify(text);
311
+ const startId = journey.startNodeId || journey.nodeIds?.[0];
312
+ const start = startId ? byId.get(startId) : undefined;
313
+ const steps: string[] = ["- launchApp"];
314
+ if (start && opens(start.route) && start.route !== "/") steps.push(`- openLink: ${quote(start.route!)}`);
315
+ else if (!start || !opens(start.route)) {
316
+ const first = (journey.nodeIds ?? []).map((id) => byId.get(id)).find((node) => opens(node?.route));
317
+ if (!first) return null;
318
+ if (first.route !== "/") steps.push(`- openLink: ${quote(first.route!)}`);
319
+ }
320
+ steps.push("- assertLayout");
321
+ const journeyEdges = (journey.edgeIds ?? []).map((id) => edges.find((edge) => edge.id === id)).filter((edge): edge is NonNullable<typeof edge> => Boolean(edge));
322
+ for (const edge of journeyEdges) {
323
+ const target = byId.get(edge.to);
324
+ const label = typeof edge.label === "string" ? edge.label.trim() : "";
325
+ if (label && label.length <= 80 && !/^(navigate|dom|link)[:-]/i.test(label)) steps.push(`- tapOn: ${quote(label)}`);
326
+ else if (opens(target?.route)) steps.push(`- openLink: ${quote(target!.route!)}`);
327
+ else continue;
328
+ steps.push("- assertLayout");
329
+ }
330
+ return [
331
+ `# Written by bugmole_plan from journey ${journey.id}. Edit it freely:`,
332
+ "# the plan runs this file, and planning again won't overwrite your changes",
333
+ "# unless you delete it.",
334
+ `name: ${quote(journey.name || journey.id)}`,
335
+ "---",
336
+ ...steps,
337
+ "",
338
+ ].join("\n");
339
+ }
@@ -91,3 +91,167 @@ test("a worker without Playwright's ffmpeg loses the video, not the run", () =>
91
91
  assert.equal(isMissingVideoEncoder(new Error("Executable doesn't exist at /ms-playwright/chromium-1234/chrome-linux/chrome")), false);
92
92
  assert.equal(isMissingVideoEncoder(new Error("net::ERR_CONNECTION_REFUSED")), false);
93
93
  });
94
+
95
+ // ── Page checks: assertLayout and assertLinks ──
96
+
97
+ import http from "node:http";
98
+ import type { AddressInfo } from "node:net";
99
+ import { chromium } from "playwright";
100
+ import { brokenStatus, checkLayout, checkLinks, hideFocusedFieldText } from "./playwright-driver.js";
101
+
102
+ async function browserOrSkip(t: { skip: (m: string) => void }) {
103
+ try {
104
+ return await chromium.launch();
105
+ } catch {
106
+ t.skip("Chromium is not installed");
107
+ return null;
108
+ }
109
+ }
110
+
111
+ const CARD = (height: string) => `<!doctype html><meta name="viewport" content="width=device-width">
112
+ <style>body{margin:0;font:16px/1.5 sans-serif}.card{display:flex;gap:30px;${height};padding:0 18px;background:#fff;box-shadow:0 1px 3px #0002;margin:12px}</style>
113
+ <div class="card"><span>icon</span><div><b>UI changes break tests</b><p>Locators and selectors become stale after every redesign of the product.</p></div></div>`;
114
+
115
+ test("assertLayout catches text running out of a fixed-height card on a phone, and passes when it grows", async (t) => {
116
+ const browser = await browserOrSkip(t);
117
+ if (!browser) return;
118
+ try {
119
+ const page = await browser.newPage({ viewport: { width: 360, height: 800 } });
120
+ await page.setContent(CARD("height:60px"));
121
+ const problems = await checkLayout(page);
122
+ assert.equal(problems.length, 1);
123
+ assert.match(problems[0]!, /^Text runs out of its box: “icon UI changes break tests.*” \(div\.card\)/);
124
+ await page.setContent(CARD("min-height:60px"));
125
+ assert.deepEqual(await checkLayout(page), []);
126
+ } finally {
127
+ await browser.close();
128
+ }
129
+ });
130
+
131
+ test("assertLayout catches a page that scrolls sideways and text cut off without an ellipsis", async (t) => {
132
+ const browser = await browserOrSkip(t);
133
+ if (!browser) return;
134
+ try {
135
+ const page = await browser.newPage({ viewport: { width: 360, height: 800 } });
136
+ await page.setContent(`<!doctype html><body style="margin:0"><p style="width:600px">A paragraph wider than the phone</p>
137
+ <div style="width:120px;height:20px;overflow:hidden">A label much too long for its little box here</div>
138
+ <div style="width:120px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">An ellipsis is a deliberate cut</div>
139
+ <div style="overflow-x:auto"><div style="width:900px">A carousel scrolls on purpose</div></div></body>`);
140
+ const problems = await checkLayout(page);
141
+ assert.equal(problems.length, 2, problems.join("\n"));
142
+ assert.match(problems[0]!, /^The page scrolls sideways: it is 600px wide on a 360px screen, because of “A paragraph wider than the phone” \(p\)/);
143
+ assert.match(problems[1]!, /^Text is cut off: “A label much too long/);
144
+ } finally {
145
+ await browser.close();
146
+ }
147
+ });
148
+
149
+ test("assertLinks reports links that don't answer, and doesn't count robots being turned away elsewhere", async (t) => {
150
+ assert.equal(brokenStatus(404, true), true);
151
+ assert.equal(brokenStatus(403, true), true);
152
+ assert.equal(brokenStatus(403, false), false);
153
+ assert.equal(brokenStatus(429, false), false);
154
+ assert.equal(brokenStatus(410, false), true);
155
+ assert.equal(brokenStatus(503, false), false);
156
+ assert.equal(brokenStatus(504, false), false);
157
+ assert.equal(brokenStatus(500, false), true);
158
+ assert.equal(brokenStatus(503, true), true);
159
+ const browser = await browserOrSkip(t);
160
+ if (!browser) return;
161
+ const unsafeHits: string[] = [];
162
+ const server = http.createServer((req, res) => {
163
+ if (req.url === "/") {
164
+ res.setHeader("content-type", "text/html");
165
+ res.end(`<a href="/ok">ok</a><a href="/gone">gone</a><a href="/ok#part">same page</a><a href="mailto:a@b.c">mail</a>
166
+ <a href="/api/auth/logout">Sign out</a><a href="/items/9?action=delete">x</a>`);
167
+ } else if (req.url === "/api/auth/logout" || req.url?.includes("delete")) { unsafeHits.push(req.url); res.end("bye"); }
168
+ else if (req.url === "/ok") res.end("fine");
169
+ else { res.statusCode = 404; res.end("no"); }
170
+ });
171
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
172
+ try {
173
+ const page = await browser.newPage();
174
+ await page.goto(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`);
175
+ const broken = await checkLinks(page, false);
176
+ assert.deepEqual(broken.map((b) => b.replace(/^http:\/\/127\.0\.0\.1:\d+/, "")), ["/gone (404)"]);
177
+ assert.deepEqual(unsafeHits, [], "a link check must never sign out or delete");
178
+ } finally {
179
+ await browser.close();
180
+ server.close();
181
+ }
182
+ });
183
+
184
+ test("flows can ask for the page checks by name", () => {
185
+ const flow = parseMaestroStyleFlow("url: https://example.com\n---\n- launchApp\n- assertLayout\n- assertLinks\n- assertLinks:\n external: true\n");
186
+ assert.deepEqual(flow.steps.slice(1), [{ type: "assertLayout" }, { type: "assertLinks", external: false }, { type: "assertLinks", external: true }]);
187
+ });
188
+
189
+ test("assertLayout also checks with a phone's larger text, and restores the page afterwards", async (t) => {
190
+ const browser = await browserOrSkip(t);
191
+ if (!browser) return;
192
+ try {
193
+ const page = await browser.newPage({ viewport: { width: 412, height: 800 } });
194
+ // Fits at normal size with a little room; not once text is 30% larger.
195
+ await page.setContent(`<!doctype html><body style="margin:0;font:16px/20px sans-serif">
196
+ <div style="height:48px;background:#eee;margin:8px"><b>Important flows get missed</b><br>Journeys are not tested.</div></body>`);
197
+ const problems = await checkLayout(page);
198
+ assert.equal(problems.length, 1, problems.join("\n"));
199
+ assert.match(problems[0]!, /^Text runs out of its box: .* with text 130% \(a phone's larger font setting\)$/);
200
+ assert.equal(await page.evaluate(`getComputedStyle(document.querySelector("b")).fontSize`), "16px");
201
+ assert.deepEqual(await checkLayout(page, 1), []);
202
+ } finally {
203
+ await browser.close();
204
+ }
205
+ });
206
+
207
+ test("a page too tall for one screenshot still gets evidence, and its flow isn't failed for it", async () => {
208
+ const { screenshotWholePage } = await import("./playwright-driver.js");
209
+ const shots: Array<boolean | undefined> = [];
210
+ const page = {
211
+ screenshot: async (options: { fullPage?: boolean }) => {
212
+ shots.push(options.fullPage);
213
+ if (options.fullPage) throw new Error("page.screenshot: Cannot take screenshot larger than 32767 pixels on any dimension");
214
+ },
215
+ };
216
+ const log: string[] = [];
217
+ await screenshotWholePage(page as never, "/tmp/x.png", log);
218
+ assert.deepEqual(shots, [true, false]);
219
+ assert.match(log[0]!, /too tall for one screenshot/);
220
+ const broken = { screenshot: async () => { throw new Error("Target closed"); } };
221
+ await assert.rejects(screenshotWholePage(broken as never, "/tmp/x.png"), /Target closed/);
222
+ });
223
+
224
+ test("assertClickable scrolls to a button below the fold, but still fails one that is covered", async (t) => {
225
+ const browser = await browserOrSkip(t);
226
+ if (!browser) return;
227
+ try {
228
+ const { waitForClickable } = await import("./playwright-driver.js");
229
+ const page = await browser.newPage({ viewport: { width: 393, height: 659 } });
230
+ await page.setContent(`<!doctype html><body style="margin:0">
231
+ <div style="height:1200px">Tall content</div>
232
+ <button id="below">Explore this URL</button>
233
+ <div style="position:relative;height:60px"><button id="covered">Covered</button>
234
+ <div style="position:absolute;inset:0;background:#fff"></div></div></body>`);
235
+ await waitForClickable(page, page.locator("#below"), "Explore this URL", 2_000);
236
+ await assert.rejects(waitForClickable(page, page.locator("#covered"), "Covered", 1_000), /something else is on top/);
237
+ } finally {
238
+ await browser.close();
239
+ }
240
+ });
241
+
242
+
243
+ test("a secret typed into a plain text field is invisible on screen but keeps its value", async (t) => {
244
+ const browser = await browserOrSkip(t);
245
+ if (!browser) return;
246
+ try {
247
+ const page = await browser.newPage();
248
+ await page.setContent(`<input id="token" type="text" style="color:#111">`);
249
+ await page.focus("#token");
250
+ await hideFocusedFieldText(page);
251
+ await page.keyboard.type("s3cr3t-value");
252
+ assert.equal(await page.inputValue("#token"), "s3cr3t-value");
253
+ assert.equal(await page.evaluate(`getComputedStyle(document.getElementById("token")).color`), "rgba(0, 0, 0, 0)");
254
+ } finally {
255
+ await browser.close();
256
+ }
257
+ });