@sudobility/testomniac_runner_service 0.1.139 → 0.1.142

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 (92) hide show
  1. package/CLAUDE.md +35 -24
  2. package/dist/analyzer/page-analyzer/index.d.ts +4 -154
  3. package/dist/analyzer/page-analyzer/index.d.ts.map +1 -1
  4. package/dist/analyzer/page-analyzer/index.js +112 -2531
  5. package/dist/analyzer/page-analyzer/index.js.map +1 -1
  6. package/dist/analyzer/page-analyzer/types.d.ts +0 -1
  7. package/dist/analyzer/page-analyzer/types.d.ts.map +1 -1
  8. package/dist/api/client.d.ts +4 -6
  9. package/dist/api/client.d.ts.map +1 -1
  10. package/dist/api/client.js +6 -19
  11. package/dist/api/client.js.map +1 -1
  12. package/dist/index.d.ts +0 -4
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +0 -5
  15. package/dist/index.js.map +1 -1
  16. package/dist/orchestrator/login-manager.d.ts +2 -11
  17. package/dist/orchestrator/login-manager.d.ts.map +1 -1
  18. package/dist/orchestrator/login-manager.js.map +1 -1
  19. package/dist/orchestrator/runner.d.ts.map +1 -1
  20. package/dist/orchestrator/runner.js +34 -28
  21. package/dist/orchestrator/runner.js.map +1 -1
  22. package/dist/orchestrator/test-interaction-executor.d.ts.map +1 -1
  23. package/dist/orchestrator/test-interaction-executor.js +82 -97
  24. package/dist/orchestrator/test-interaction-executor.js.map +1 -1
  25. package/dist/scanner/login-detector.d.ts +2 -14
  26. package/dist/scanner/login-detector.d.ts.map +1 -1
  27. package/dist/scanner/login-detector.js.map +1 -1
  28. package/package.json +4 -18
  29. package/dist/ai/analyzer.d.ts +0 -17
  30. package/dist/ai/analyzer.d.ts.map +0 -1
  31. package/dist/ai/analyzer.js +0 -55
  32. package/dist/ai/analyzer.js.map +0 -1
  33. package/dist/ai/input-generator.d.ts +0 -9
  34. package/dist/ai/input-generator.d.ts.map +0 -1
  35. package/dist/ai/input-generator.js +0 -20
  36. package/dist/ai/input-generator.js.map +0 -1
  37. package/dist/ai/persona-generator.d.ts +0 -7
  38. package/dist/ai/persona-generator.d.ts.map +0 -1
  39. package/dist/ai/persona-generator.js +0 -23
  40. package/dist/ai/persona-generator.js.map +0 -1
  41. package/dist/ai/use-case-generator.d.ts +0 -7
  42. package/dist/ai/use-case-generator.d.ts.map +0 -1
  43. package/dist/ai/use-case-generator.js +0 -23
  44. package/dist/ai/use-case-generator.js.map +0 -1
  45. package/dist/analyzer/page-analyzer/generators/content.d.ts +0 -4
  46. package/dist/analyzer/page-analyzer/generators/content.d.ts.map +0 -1
  47. package/dist/analyzer/page-analyzer/generators/content.js +0 -63
  48. package/dist/analyzer/page-analyzer/generators/content.js.map +0 -1
  49. package/dist/analyzer/page-analyzer/generators/dialogs.d.ts +0 -4
  50. package/dist/analyzer/page-analyzer/generators/dialogs.d.ts.map +0 -1
  51. package/dist/analyzer/page-analyzer/generators/dialogs.js +0 -46
  52. package/dist/analyzer/page-analyzer/generators/dialogs.js.map +0 -1
  53. package/dist/analyzer/page-analyzer/generators/e2e.d.ts +0 -4
  54. package/dist/analyzer/page-analyzer/generators/e2e.d.ts.map +0 -1
  55. package/dist/analyzer/page-analyzer/generators/e2e.js +0 -37
  56. package/dist/analyzer/page-analyzer/generators/e2e.js.map +0 -1
  57. package/dist/analyzer/page-analyzer/generators/forms.d.ts +0 -4
  58. package/dist/analyzer/page-analyzer/generators/forms.d.ts.map +0 -1
  59. package/dist/analyzer/page-analyzer/generators/forms.js +0 -95
  60. package/dist/analyzer/page-analyzer/generators/forms.js.map +0 -1
  61. package/dist/analyzer/page-analyzer/generators/hover-follow-up.d.ts +0 -4
  62. package/dist/analyzer/page-analyzer/generators/hover-follow-up.d.ts.map +0 -1
  63. package/dist/analyzer/page-analyzer/generators/hover-follow-up.js +0 -140
  64. package/dist/analyzer/page-analyzer/generators/hover-follow-up.js.map +0 -1
  65. package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.d.ts +0 -4
  66. package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.d.ts.map +0 -1
  67. package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.js +0 -61
  68. package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.js.map +0 -1
  69. package/dist/analyzer/page-analyzer/generators/login.d.ts +0 -15
  70. package/dist/analyzer/page-analyzer/generators/login.d.ts.map +0 -1
  71. package/dist/analyzer/page-analyzer/generators/login.js +0 -218
  72. package/dist/analyzer/page-analyzer/generators/login.js.map +0 -1
  73. package/dist/analyzer/page-analyzer/generators/navigation.d.ts +0 -15
  74. package/dist/analyzer/page-analyzer/generators/navigation.d.ts.map +0 -1
  75. package/dist/analyzer/page-analyzer/generators/navigation.js +0 -165
  76. package/dist/analyzer/page-analyzer/generators/navigation.js.map +0 -1
  77. package/dist/analyzer/page-analyzer/generators/render.d.ts +0 -4
  78. package/dist/analyzer/page-analyzer/generators/render.d.ts.map +0 -1
  79. package/dist/analyzer/page-analyzer/generators/render.js +0 -31
  80. package/dist/analyzer/page-analyzer/generators/render.js.map +0 -1
  81. package/dist/analyzer/page-analyzer/generators/scaffolds.d.ts +0 -4
  82. package/dist/analyzer/page-analyzer/generators/scaffolds.d.ts.map +0 -1
  83. package/dist/analyzer/page-analyzer/generators/scaffolds.js +0 -64
  84. package/dist/analyzer/page-analyzer/generators/scaffolds.js.map +0 -1
  85. package/dist/analyzer/page-analyzer/generators/semantic-journeys.d.ts +0 -4
  86. package/dist/analyzer/page-analyzer/generators/semantic-journeys.d.ts.map +0 -1
  87. package/dist/analyzer/page-analyzer/generators/semantic-journeys.js +0 -37
  88. package/dist/analyzer/page-analyzer/generators/semantic-journeys.js.map +0 -1
  89. package/dist/analyzer/page-analyzer/generators/variants.d.ts +0 -4
  90. package/dist/analyzer/page-analyzer/generators/variants.d.ts.map +0 -1
  91. package/dist/analyzer/page-analyzer/generators/variants.js +0 -60
  92. package/dist/analyzer/page-analyzer/generators/variants.js.map +0 -1
@@ -1,149 +1,44 @@
1
1
  import { PlaywrightAction, ExpectationType, ExpectationSeverity, } from "@sudobility/testomniac_types";
2
2
  import { buildReplaySelectorFromActionableItem, matchesActionableItemSelector, } from "../../browser/replay-selector";
3
- import { computeHashes, computeActionableHash, sha256, normalizeHtml, htmlToMarkdown, } from "../../browser/page-utils";
4
- import { getBody, getContentBody } from "../../scanner/html-decomposer";
5
3
  import { createHash } from "node:crypto";
6
- import { fillValuePlanner } from "../../planners/fill-value-planner";
7
- import { AUTH_URL_PATTERNS, SIGNUP_URL_PATTERNS } from "../../config/constants";
8
4
  import { InMemoryDedupStore } from "../../storage/dedup-store";
9
- import { generateHoverFollowUpCases } from "./generators/hover-follow-up";
10
- import { generateNavigationTestInteractions } from "./generators/navigation";
11
- import { generateScaffoldTestInteractions } from "./generators/scaffolds";
12
- import { generateRenderTestInteractions } from "./generators/render";
13
- import { generateFormTestInteractions } from "./generators/forms";
14
- import { generateE2ETestInteractions } from "./generators/e2e";
15
- import { generateSemanticJourneyTestInteractions } from "./generators/semantic-journeys";
16
- import { generateDialogLifecycleTestInteractions } from "./generators/dialogs";
17
- import { generateKeyboardAndDisclosureTestInteractions } from "./generators/keyboard-disclosure";
18
- import { generateVariantTestInteractions } from "./generators/variants";
19
- import { generateContentTestInteractions } from "./generators/content";
20
- import { generateLoginTestInteractions } from "./generators/login";
21
5
  function logAnalyzer(step, details) {
22
6
  console.info("[PageAnalyzer]", step, details ?? {});
23
7
  }
24
8
  /**
25
- * Normalize a URL path for dedup comparison:
26
- * 1. Remove query params with empty values (`foo=` or `foo`)
27
- * 2. Sort remaining params so order doesn't matter
9
+ * PageAnalyzer provides finding dedup, expectation generation, and
10
+ * hover-to-click transformation during discovery mode.
28
11
  *
29
- * `/store/?b=2&a=1` and `/store/?a=1&b=2` same key.
30
- * `/store/?filternum=0&pagenum=1` keeps both (non-empty values).
31
- * `/store/?foo=&bar` drops both (empty values).
32
- */
33
- function normalizePathForDedup(raw) {
34
- const qIndex = raw.indexOf("?");
35
- if (qIndex === -1)
36
- return raw;
37
- const base = raw.slice(0, qIndex);
38
- const search = raw.slice(qIndex + 1);
39
- if (!search)
40
- return base;
41
- const kept = search
42
- .split("&")
43
- .filter(pair => {
44
- const eqIndex = pair.indexOf("=");
45
- if (eqIndex === -1)
46
- return false; // bare key, no value
47
- return pair.slice(eqIndex + 1).length > 0; // non-empty value
48
- })
49
- .sort();
50
- return kept.length > 0 ? `${base}?${kept.join("&")}` : base;
51
- }
52
- /** Strip the query string entirely to get the base path. */
53
- function basePathOf(raw) {
54
- const qIndex = raw.indexOf("?");
55
- return qIndex === -1 ? raw : raw.slice(0, qIndex);
56
- }
57
- /**
58
- * PageAnalyzer generates expectations and discovers new test elements
59
- * during discovery mode.
12
+ * Test generators have been moved server-side to testomniac_api.
60
13
  */
61
14
  export class PageAnalyzer {
62
15
  store;
63
- surfacesCache = null;
64
16
  constructor(dedupStore) {
65
17
  this.store = dedupStore ?? new InMemoryDedupStore();
66
18
  }
67
- async getCachedSurfaces(context) {
68
- if (!this.surfacesCache) {
69
- this.surfacesCache = await context.api.getTestSurfacesByRunner(context.runnerId);
70
- }
71
- return this.surfacesCache;
72
- }
73
- invalidateSurfacesCache() {
74
- this.surfacesCache = null;
75
- }
76
- /** Check whether generation already happened for a given path in this run. */
77
- hasGeneratedForPath(path) {
78
- return this.store.has("generatedPaths", normalizePathForDedup(path));
79
- }
80
- /**
81
- * Check whether a (actionType, replaySelector) pair has already been
82
- * generated for any URL variant of the same base path.
83
- */
84
- hasGeneratedSelectorForBasePath(path, actionType, replaySelector) {
85
- const key = `${basePathOf(path)}\0${actionType}\0${replaySelector}`;
86
- return this.store.has("generatedSelectors", key);
87
- }
88
- /**
89
- * Record that a (actionType, replaySelector) was generated under a base
90
- * path so future URL variants can skip it.
91
- */
92
- markGeneratedSelectorForBasePath(path, actionType, replaySelector) {
93
- const key = `${basePathOf(path)}\0${actionType}\0${replaySelector}`;
94
- return this.store.add("generatedSelectors", key);
95
- }
96
- /**
97
- * Normalize finding text for dedup: strip leading count numbers that vary
98
- * between evaluations. Preserves URLs, status codes, and other content.
99
- *
100
- * "[page-health] 5 broken image(s)" → "[page-health] broken image(s)"
101
- * "3 significant console warning(s)" → "significant console warning(s)"
102
- * "Page returned HTTP 404 for …" → unchanged
103
- */
104
19
  static normalizeFindingText(text) {
105
20
  return text.replace(/^(\[[^\]]+\]\s*)\d+\s+/, "$1").replace(/^\d+\s+/, "");
106
21
  }
107
- /**
108
- * Returns true if an equivalent page-scoped finding has already been
109
- * recorded during this run.
110
- */
111
22
  hasReportedPageFinding(_path, title, description) {
112
23
  const key = `${PageAnalyzer.normalizeFindingText(title)}\0${PageAnalyzer.normalizeFindingText(description)}`;
113
24
  return this.store.has("reportedPageFindings", key);
114
25
  }
115
- /**
116
- * Mark a page-scoped finding as recorded so it is not duplicated.
117
- */
118
26
  markPageFindingReported(_path, title, description) {
119
27
  const key = `${PageAnalyzer.normalizeFindingText(title)}\0${PageAnalyzer.normalizeFindingText(description)}`;
120
28
  return this.store.add("reportedPageFindings", key);
121
29
  }
122
- /** Check whether a finding with the given stable key has been reported. */
123
30
  hasReportedFindingByKey(key) {
124
31
  return this.store.has("reportedFindingKeys", key);
125
32
  }
126
- /** Mark a stable finding key as reported. */
127
33
  markReportedFindingByKey(key) {
128
34
  return this.store.add("reportedFindingKeys", key);
129
35
  }
130
- /**
131
- * Check if a finding with this description has already been reported.
132
- */
133
36
  hasReportedDescription(description) {
134
37
  return this.store.has("reportedDescriptions", PageAnalyzer.normalizeFindingText(description));
135
38
  }
136
- /** Mark a finding description as reported. */
137
39
  markReportedDescription(description) {
138
40
  return this.store.add("reportedDescriptions", PageAnalyzer.normalizeFindingText(description));
139
41
  }
140
- /**
141
- * Check whether full test generation already ran for a page state with the
142
- * same visible actionable items (by hash).
143
- */
144
- hasGeneratedForActionableHash(hash) {
145
- return this.store.has("generatedActionableHashes", hash);
146
- }
147
42
  /**
148
43
  * Generate baseline expectations for a test element.
149
44
  * Called BEFORE expertises evaluate.
@@ -272,273 +167,11 @@ export class PageAnalyzer {
272
167
  * Generate new test elements for scaffolds and page content.
273
168
  * Called AFTER expertises evaluate and the target page state is established.
274
169
  */
275
- async generateTestInteractions(testInteraction, context) {
276
- logAnalyzer("generate:start", {
277
- sourceTitle: testInteraction.title,
278
- sourceType: testInteraction.type,
279
- sourcePriority: testInteraction.priority,
280
- sourceSurfaceTags: testInteraction.surface_tags,
281
- currentTestInteractionId: context.currentTestInteractionId,
282
- currentTestSurfaceId: context.currentTestSurfaceId,
283
- currentSurfaceRunId: context.currentSurfaceRunId ?? null,
284
- beginningPageStateId: context.beginningPageStateId,
285
- currentPageStateId: context.currentPageStateId,
286
- currentPath: context.currentPath,
287
- actionableItemsCount: context.actionableItems.length,
288
- formsCount: context.forms.length,
289
- scaffoldsCount: context.scaffolds.length,
290
- });
291
- // Skip generation if the current path is outside the scan scope boundary
292
- if (context.scanScopePath &&
293
- !context.currentPath.startsWith(context.scanScopePath)) {
294
- logAnalyzer("generate:out-of-scope", {
295
- sourceTitle: testInteraction.title,
296
- currentPath: context.currentPath,
297
- scanScopePath: context.scanScopePath,
298
- });
299
- return;
300
- }
301
- const normalizedContext = this.normalizeContext(context);
302
- const isHover = this.isHoverOnly(testInteraction);
303
- const currentPath = normalizedContext.currentPath.trim();
304
- const dedupPath = normalizePathForDedup(currentPath);
305
- // For non-hover interactions: check cheap path/hash guards BEFORE the
306
- // expensive ensureTargetPageState call so we can skip all the API work
307
- // (scaffold resolution, hash computation, page-state creation) when the
308
- // page has already been covered in this run.
309
- if (!isHover) {
310
- if (await this.store.has("generatedPaths", dedupPath)) {
311
- logAnalyzer("generate:page-already-covered", {
312
- sourceTitle: testInteraction.title,
313
- currentTestInteractionId: normalizedContext.currentTestInteractionId,
314
- currentPath,
315
- dedupPath,
316
- });
317
- return;
318
- }
319
- const actionableHash = await computeActionableHash(normalizedContext.actionableItems);
320
- if (await this.store.has("generatedActionableHashes", actionableHash)) {
321
- logAnalyzer("generate:actionable-items-already-covered", {
322
- sourceTitle: testInteraction.title,
323
- currentTestInteractionId: normalizedContext.currentTestInteractionId,
324
- currentPath,
325
- actionableHash,
326
- });
327
- return;
328
- }
329
- }
330
- const currentPageStateId = await this.ensureTargetPageState(normalizedContext);
331
- const resolvedContext = {
332
- ...normalizedContext,
333
- currentPageStateId,
334
- };
335
- logAnalyzer("generate:resolved-context", {
336
- sourceTitle: testInteraction.title,
337
- sourceType: testInteraction.type,
338
- currentTestInteractionId: resolvedContext.currentTestInteractionId,
339
- currentTestSurfaceId: resolvedContext.currentTestSurfaceId,
340
- currentSurfaceRunId: resolvedContext.currentSurfaceRunId ?? null,
341
- beginningPageStateId: resolvedContext.beginningPageStateId,
342
- currentPageStateId: resolvedContext.currentPageStateId,
343
- currentPath: resolvedContext.currentPath,
344
- });
345
- // Hover-only interactions have their own same-page-state handling inside
346
- // generateHoverFollowUpCases (including click-follow-up reconciliation),
347
- // so let them through to that dedicated path.
348
- if (isHover) {
349
- logAnalyzer("generate:hover-only", {
350
- sourceTitle: testInteraction.title,
351
- currentTestInteractionId: resolvedContext.currentTestInteractionId,
352
- currentPageStateId: resolvedContext.currentPageStateId,
353
- });
354
- await generateHoverFollowUpCases(this, testInteraction, resolvedContext);
355
- return;
356
- }
357
- // For non-hover interactions: skip generation when the end page state is
358
- // the same as the starting page state — the interaction did not cause a
359
- // meaningful change, so there is nothing new to discover or test.
360
- if (resolvedContext.beginningPageStateId > 0 &&
361
- currentPageStateId === resolvedContext.beginningPageStateId) {
362
- logAnalyzer("generate:same-page-state", {
363
- sourceTitle: testInteraction.title,
364
- currentTestInteractionId: resolvedContext.currentTestInteractionId,
365
- currentPageStateId,
366
- beginningPageStateId: resolvedContext.beginningPageStateId,
367
- });
368
- return;
369
- }
370
- // Re-check actionableHash (already passed the guard above, but we need
371
- // to record it and the path now that we're committed to generation).
372
- const actionableHash = await computeActionableHash(resolvedContext.actionableItems);
373
- // Only mark the path as covered if actionable items were found.
374
- // If the page hadn't fully loaded yet (e.g. SPA hydration), the first
375
- // pass may see 0 items. Keeping the path open lets a later test
376
- // (Render, which waits for load state) re-run the full generation pass
377
- // with the complete HTML.
378
- if (resolvedContext.actionableItems.length > 0) {
379
- await this.store.add("generatedPaths", dedupPath);
380
- await this.store.add("generatedActionableHashes", actionableHash);
381
- }
382
- // If a hover+click navigated to a new page, create a direct navigation
383
- // interaction and use it as the dependency instead of the hover chain
384
- let contextForFullPass = resolvedContext;
385
- const normalizedStartingPath = (testInteraction.startingPath || "/").trim();
386
- if (this.isHoverBased(testInteraction) &&
387
- normalizedStartingPath !== currentPath) {
388
- const navInteraction = this.buildNavigationTestInteraction(currentPath, resolvedContext.sizeClass, resolvedContext.uid, resolvedContext.currentPageStateId > 0
389
- ? resolvedContext.currentPageStateId
390
- : undefined);
391
- const navSurfaceId = resolvedContext.navigationSurface.id;
392
- const saved = await resolvedContext.api.ensureTestInteraction(resolvedContext.runnerId, navSurfaceId, navInteraction, resolvedContext.testEnvironmentId);
393
- // Also create a run for it so the runner can execute it
394
- const surfaceRuns = await resolvedContext.api.getOpenTestSurfaceRuns(resolvedContext.bundleRun.id);
395
- const navSurfaceRun = surfaceRuns.find(sr => sr.testSurfaceId === navSurfaceId);
396
- if (navSurfaceRun) {
397
- try {
398
- await resolvedContext.api.createTestInteractionRun({
399
- testInteractionId: saved.id,
400
- testSurfaceRunId: navSurfaceRun.id,
401
- });
402
- }
403
- catch (err) {
404
- // Run may already exist
405
- logAnalyzer("create-interaction-run:skipped", {
406
- reason: "may already exist",
407
- error: err instanceof Error ? err.message : String(err),
408
- });
409
- }
410
- }
411
- logAnalyzer("generate:navigation-dependency-created", {
412
- sourceTitle: testInteraction.title,
413
- navigationInteractionId: saved.id,
414
- navigationPath: currentPath,
415
- originalDependencyId: resolvedContext.currentTestInteractionId,
416
- });
417
- contextForFullPass = {
418
- ...resolvedContext,
419
- currentTestInteractionId: saved.id,
420
- };
421
- }
422
- logAnalyzer("generate:full-pass", {
423
- sourceTitle: testInteraction.title,
424
- currentTestInteractionId: contextForFullPass.currentTestInteractionId,
425
- currentPageStateId: contextForFullPass.currentPageStateId,
426
- });
427
- // Collect all standard generator outputs (they return data, no API calls)
428
- const outputs = [];
429
- outputs.push(await generateRenderTestInteractions(this, contextForFullPass));
430
- outputs.push(await generateFormTestInteractions(this, contextForFullPass));
431
- // Login test generation: calls API directly (dependency chains)
432
- if (contextForFullPass.loginDetection?.isLoginPage) {
433
- await generateLoginTestInteractions(this, contextForFullPass, contextForFullPass.loginDetection, contextForFullPass.loginConfig);
434
- }
435
- outputs.push(await generateSemanticJourneyTestInteractions(this, contextForFullPass));
436
- outputs.push(await generateE2ETestInteractions(this, contextForFullPass));
437
- outputs.push(await generateDialogLifecycleTestInteractions(this, contextForFullPass));
438
- outputs.push(await generateScaffoldTestInteractions(this, contextForFullPass));
439
- outputs.push(await generateContentTestInteractions(this, contextForFullPass));
440
- outputs.push(await generateKeyboardAndDisclosureTestInteractions(this, contextForFullPass));
441
- outputs.push(await generateVariantTestInteractions(this, contextForFullPass));
442
- // Batch all collected generator outputs in one API call
443
- const allCreates = outputs.flatMap(o => o.creates);
444
- const allReconciles = outputs.flatMap(o => o.reconciles);
445
- if (allCreates.length > 0 || allReconciles.length > 0) {
446
- const batchResult = await contextForFullPass.api.generateAllSurfaceInteractions({
447
- runnerId: contextForFullPass.runnerId,
448
- testEnvironmentId: contextForFullPass.testEnvironmentId,
449
- sizeClass: contextForFullPass.sizeClass,
450
- testSurfaceBundleId: contextForFullPass.bundleRun.testSurfaceBundleId,
451
- testSurfaceBundleRunId: contextForFullPass.bundleRun.id,
452
- surfaces: allCreates,
453
- reconcileOnly: allReconciles,
454
- });
455
- // Emit events for all created surfaces
456
- for (const item of batchResult.results) {
457
- contextForFullPass.events.onTestSurfaceCreated({
458
- surfaceId: item.surface.id,
459
- title: item.surface.title,
460
- });
461
- }
462
- }
463
- // Navigation calls API directly (no surface creation, no reconcile)
464
- await generateNavigationTestInteractions(this, contextForFullPass);
465
- }
466
- async reconcileGeneratedSurfaceElements(context, params) {
467
- let surfaceId;
468
- if (params.surfaceId != null) {
469
- surfaceId = params.surfaceId;
470
- }
471
- else {
472
- const surface = await this.findExistingSurfaceByTitle(context, params.surfaceTitle);
473
- if (!surface) {
474
- logAnalyzer("reconcile:surface-missing", {
475
- requestedSurfaceId: params.surfaceId ?? null,
476
- requestedSurfaceTitle: params.surfaceTitle,
477
- dependencyTestInteractionId: params.dependencyTestInteractionId ?? null,
478
- desiredKeysCount: params.desiredKeys.length,
479
- });
480
- return;
481
- }
482
- surfaceId = surface.id;
483
- }
484
- const result = await context.api.reconcileTestInteractions({
485
- testSurfaceId: surfaceId,
486
- desiredKeys: params.desiredKeys,
487
- dependencyTestInteractionId: params.dependencyTestInteractionId,
488
- });
489
- logAnalyzer("reconcile:completed", {
490
- surfaceId,
491
- surfaceTitle: params.surfaceTitle,
492
- dependencyTestInteractionId: params.dependencyTestInteractionId ?? null,
493
- desiredKeysCount: params.desiredKeys.length,
494
- retiredIds: result.retiredIds,
495
- });
496
- }
497
- async findExistingSurfaceByTitle(context, title) {
498
- const surfaces = await this.getCachedSurfaces(context);
499
- const surface = surfaces.find(candidate => candidate.title === title);
500
- return surface ? { id: surface.id, title: surface.title } : null;
501
- }
502
- getScaffoldSurfaceItems(context, scaffold) {
503
- return this.normalizeActionableItems(context.actionableItems).filter(item => {
504
- if (!this.isSurfaceCandidate(item) || !item.selector)
505
- return false;
506
- return (context.scaffoldSelectorByItemSelector[item.selector] ===
507
- scaffold.selector);
508
- });
509
- }
510
- async ensureSurfaceRun(api, testSurfaceId, bundleRunId) {
511
- const openSurfaceRuns = await api.getOpenTestSurfaceRuns(bundleRunId);
512
- const existing = openSurfaceRuns.find(surfaceRun => surfaceRun.testSurfaceId === testSurfaceId);
513
- if (existing) {
514
- logAnalyzer("surface-run:reused", {
515
- bundleRunId,
516
- testSurfaceId,
517
- surfaceRunId: existing.id,
518
- });
519
- return existing;
520
- }
521
- const created = await api.createTestSurfaceRun({
522
- testSurfaceId,
523
- testSurfaceBundleRunId: bundleRunId,
524
- });
525
- logAnalyzer("surface-run:created", {
526
- bundleRunId,
527
- testSurfaceId,
528
- surfaceRunId: created.id,
529
- });
530
- return created;
531
- }
532
170
  isMouseActionable(item) {
533
171
  return (item.visible &&
534
172
  !item.disabled &&
535
173
  (item.actionKind === "click" || item.actionKind === "navigate"));
536
174
  }
537
- /**
538
- * Returns true if the item is a link with a non-browser protocol (mailto:,
539
- * tel:, ftp:, etc.). Clicking such links launches external applications
540
- * which cannot be controlled or closed by the test runner.
541
- */
542
175
  isNonBrowserLink(item) {
543
176
  if (!item.href)
544
177
  return false;
@@ -553,81 +186,6 @@ export class PageAnalyzer {
553
186
  // Anything with a scheme that isn't http/https is non-browser
554
187
  return /^[a-z][a-z0-9+.-]*:/i.test(href);
555
188
  }
556
- isSurfaceCandidate(item) {
557
- if (!item.visible || !item.selector || item.disabled)
558
- return false;
559
- return ["click", "navigate", "fill", "select", "radio_select"].includes(item.actionKind);
560
- }
561
- extractRelativePath(href) {
562
- try {
563
- const url = new URL(href, "http://placeholder");
564
- return url.pathname + url.search;
565
- }
566
- catch (err) {
567
- logAnalyzer("extract-relative-path:failed", {
568
- href,
569
- error: err instanceof Error ? err.message : String(err),
570
- });
571
- return null;
572
- }
573
- }
574
- buildNavigationTestInteraction(path, sizeClass, uid, startingPageStateId) {
575
- return {
576
- title: `Navigate to ${path}`,
577
- type: "navigation",
578
- sizeClass,
579
- surface_tags: ["navigation"],
580
- priority: 3,
581
- startingPageStateId,
582
- startingPath: path,
583
- steps: [
584
- {
585
- action: {
586
- actionType: PlaywrightAction.Goto,
587
- path,
588
- playwrightCode: `await page.goto('${path}')`,
589
- description: `Navigate to ${path}`,
590
- },
591
- expectations: [],
592
- description: `Navigate to ${path}`,
593
- continueOnFailure: false,
594
- },
595
- ],
596
- globalExpectations: [],
597
- uid,
598
- generatedKey: this.buildGeneratedKey("navigation", startingPageStateId, path),
599
- };
600
- }
601
- buildHoverTestInteraction(item, startingPath, sizeClass, uid, startingPageStateId, dependencyTestInteractionId) {
602
- const label = this.describeActionableItem(item);
603
- const replaySelector = buildReplaySelectorFromActionableItem(item);
604
- return {
605
- title: `Hover over ${label}`,
606
- type: "interaction",
607
- sizeClass,
608
- surface_tags: ["interaction", "hover"],
609
- priority: 1,
610
- dependencyTestInteractionId,
611
- startingPageStateId,
612
- startingPath,
613
- steps: [
614
- {
615
- action: {
616
- actionType: PlaywrightAction.Hover,
617
- path: replaySelector,
618
- playwrightCode: `await page.hover('${replaySelector}')`,
619
- description: `Hover over ${label}`,
620
- },
621
- expectations: [],
622
- description: `Hover over ${label}`,
623
- continueOnFailure: true,
624
- },
625
- ],
626
- globalExpectations: [],
627
- uid,
628
- generatedKey: this.buildGeneratedKey("hover", startingPageStateId, dependencyTestInteractionId, replaySelector),
629
- };
630
- }
631
189
  buildClickTestInteraction(item, startingPath, sizeClass, uid, startingPageStateId, dependencyTestInteractionId) {
632
190
  const label = this.describeActionableItem(item);
633
191
  const replaySelector = buildReplaySelectorFromActionableItem(item);
@@ -665,13 +223,6 @@ export class PageAnalyzer {
665
223
  return (steps.length === 1 &&
666
224
  steps[0]?.action?.actionType === PlaywrightAction.Hover);
667
225
  }
668
- isHoverBased(testInteraction) {
669
- const steps = Array.isArray(testInteraction.steps)
670
- ? testInteraction.steps
671
- : [];
672
- return (steps.length > 0 &&
673
- steps[0]?.action?.actionType === PlaywrightAction.Hover);
674
- }
675
226
  getPrimarySelector(testInteraction) {
676
227
  const steps = Array.isArray(testInteraction.steps)
677
228
  ? testInteraction.steps
@@ -684,22 +235,6 @@ export class PageAnalyzer {
684
235
  const selector = item.selector;
685
236
  return stableKey ?? selector ?? null;
686
237
  }
687
- withGeneratedKey(testInteraction, ...parts) {
688
- return {
689
- ...testInteraction,
690
- generatedKey: this.buildGeneratedKey(...parts),
691
- };
692
- }
693
- getGeneratedKey(testInteraction) {
694
- return (testInteraction.generatedKey?.trim() || testInteraction.title).trim();
695
- }
696
- getPersistedGeneratedKey(testInteraction) {
697
- const generatedKey = testInteraction.generatedKey?.trim();
698
- if (generatedKey)
699
- return generatedKey;
700
- const title = testInteraction.title?.trim();
701
- return title || null;
702
- }
703
238
  buildGeneratedKey(...parts) {
704
239
  const normalized = parts
705
240
  .map(part => (part == null ? "" : String(part).trim()))
@@ -717,1821 +252,122 @@ export class PageAnalyzer {
717
252
  .slice(0, 80);
718
253
  return prefix ? `${prefix}:${digest}` : digest;
719
254
  }
720
- buildStepSignature(steps) {
721
- return steps
722
- .map(step => [
723
- step.action?.actionType ?? "",
724
- step.action?.path ?? "",
725
- step.action?.value ?? "",
726
- step.action?.description ?? "",
727
- ].join("|"))
728
- .join("||");
729
- }
730
- async ensureTargetPageState(context) {
731
- // Fast path: page state already known — just link scaffolds
732
- if (context.currentPageStateId > 0) {
733
- const scaffoldIdsBySelector = await this.ensureScaffolds(context);
734
- for (const item of context.actionableItems) {
735
- if (!item.selector)
736
- continue;
737
- const scaffoldSelector = context.scaffoldSelectorByItemSelector[item.selector] ?? null;
738
- if (!scaffoldSelector)
739
- continue;
740
- const scaffoldId = scaffoldIdsBySelector.get(scaffoldSelector);
741
- if (scaffoldId) {
742
- item.scaffoldId = scaffoldId;
743
- }
744
- }
745
- if (scaffoldIdsBySelector.size > 0) {
746
- await context.api.linkPageStateScaffolds(context.currentPageStateId, Array.from(new Set(scaffoldIdsBySelector.values())));
747
- }
748
- context.events.onPageStateCreated({
749
- pageStateId: context.currentPageStateId,
750
- pageId: context.pageId,
751
- });
752
- await this.ensureStoredForms(context.currentPageStateId, context);
753
- return context.currentPageStateId;
754
- }
755
- // Combined endpoint: scaffolds + match + create in one round-trip
756
- const hashes = await computeHashes(context.html, context.actionableItems);
757
- let fixedBodyHash;
758
- if (context.scaffolds.length > 0) {
759
- const body = getBody(context.html);
760
- const { contentBody } = getContentBody(body, context.scaffolds);
761
- fixedBodyHash = await sha256(normalizeHtml(contentBody));
762
- }
763
- const result = await context.api.ensurePageStateCombined({
764
- pageId: context.pageId > 0 ? context.pageId : undefined,
765
- relativePath: context.pageId === 0 ? context.currentPath : undefined,
766
- runnerId: context.runnerId,
767
- testEnvironmentId: context.testEnvironmentId,
768
- sizeClass: context.sizeClass,
769
- screenshotPath: context.screenshotPath,
770
- html: context.html,
771
- contentText: htmlToMarkdown(context.html).slice(0, 5000),
772
- hashes,
773
- fixedBodyHash,
774
- actionableItems: context.actionableItems,
775
- scaffolds: context.scaffolds.map(scaffold => ({
776
- type: scaffold.type,
777
- html: scaffold.outerHtml,
778
- hash: scaffold.hash,
779
- selector: scaffold.selector,
780
- })),
781
- scaffoldSelectorByItemSelector: context.scaffoldSelectorByItemSelector,
782
- });
783
- // Part A: server resolved the page — update context
784
- if (result.pageId && context.pageId === 0) {
785
- context.pageId = result.pageId;
786
- context.pageRequiresLogin = result.requiresLogin;
255
+ describeActionableItem(item) {
256
+ const described = (item.accessibleName ||
257
+ item.textContent ||
258
+ String(item.attributes?._containerTitle ?? "") ||
259
+ String(item.attributes?.labelText ?? "") ||
260
+ String(item.attributes?.placeholder ?? "")).trim();
261
+ if (described)
262
+ return described;
263
+ const selector = item.selector ?? "";
264
+ if (selector.includes("data-tmnc-id")) {
265
+ const role = item.role?.trim();
266
+ const inputType = item.inputType?.trim();
267
+ if (role)
268
+ return role;
269
+ if (inputType)
270
+ return inputType;
271
+ if (item.actionKind === "navigate")
272
+ return "link";
273
+ if (item.actionKind === "fill")
274
+ return "input";
275
+ if (item.actionKind === "select")
276
+ return "select";
277
+ return "control";
787
278
  }
788
- // Map scaffold IDs back to in-memory actionable items for generators
789
- for (const item of context.actionableItems) {
790
- if (!item.selector)
791
- continue;
792
- const scaffoldSelector = context.scaffoldSelectorByItemSelector[item.selector] ?? null;
793
- if (!scaffoldSelector)
279
+ return selector || item.actionKind || "control";
280
+ }
281
+ semanticText(item) {
282
+ return [
283
+ item.accessibleName || "",
284
+ item.textContent || "",
285
+ String(item.attributes?.labelText ?? ""),
286
+ String(item.attributes?.placeholder ?? ""),
287
+ String(item.attributes?.name ?? ""),
288
+ String(item.attributes?.id ?? ""),
289
+ String(item.attributes?._containerTitle ?? ""),
290
+ String(item.attributes?._containerCtaStyle ?? ""),
291
+ item.href || "",
292
+ item.selector,
293
+ ]
294
+ .join(" ")
295
+ .toLowerCase();
296
+ }
297
+ /**
298
+ * Max representatives per action style. Product grids can have dozens of
299
+ * cards each producing a unique container fingerprint (because the product
300
+ * title differs), but the CTA buttons ("ADD TO CART", "Select Options") are
301
+ * functionally identical. Capping per style prevents 14× duplicate hover
302
+ * tests for the same button type across different cards.
303
+ */
304
+ static MAX_REPS_PER_STYLE = 2;
305
+ selectRepresentativeItems(items, maxPerStyle = PageAnalyzer.MAX_REPS_PER_STYLE) {
306
+ const groups = new Map();
307
+ const passthrough = [];
308
+ for (const item of items) {
309
+ const containerFingerprint = String(item.attributes?._containerFingerprint ?? "").trim();
310
+ if (!containerFingerprint) {
311
+ passthrough.push(item);
794
312
  continue;
795
- const scaffoldId = result.scaffoldIdsBySelector[scaffoldSelector];
796
- if (scaffoldId) {
797
- item.scaffoldId = scaffoldId;
798
313
  }
314
+ const key = `${containerFingerprint}|${this.representativeActionStyle(item)}`;
315
+ const bucket = groups.get(key) ?? [];
316
+ bucket.push(item);
317
+ groups.set(key, bucket);
799
318
  }
800
- // Scaffold screenshots are now updated server-side in ensure-page-state
801
- context.events.onPageStateCreated({
802
- pageStateId: result.pageStateId,
803
- pageId: result.pageId ?? context.pageId,
804
- });
805
- await this.ensureStoredForms(result.pageStateId, context);
806
- return result.pageStateId;
807
- }
808
- async ensureScaffolds(context) {
809
- const scaffoldIdsBySelector = new Map();
810
- if (context.scaffolds.length === 0)
811
- return scaffoldIdsBySelector;
812
- const items = context.scaffolds.map(scaffold => ({
813
- runnerId: context.runnerId,
814
- type: scaffold.type,
815
- html: scaffold.outerHtml,
816
- hash: scaffold.hash,
817
- }));
818
- const results = await context.api.findOrCreateScaffoldBatch(items);
819
- for (let i = 0; i < context.scaffolds.length; i++) {
820
- const scaffold = context.scaffolds[i];
821
- const result = results[i];
822
- scaffoldIdsBySelector.set(scaffold.selector, result.id);
823
- // Link page state screenshot to scaffold if it doesn't have one
824
- if (!result.screenshotPath && context.screenshotPath) {
825
- try {
826
- await context.api.updateScaffoldScreenshot(result.id, context.screenshotPath);
827
- }
828
- catch {
829
- // Best effort — scaffold screenshot is optional
830
- }
831
- }
319
+ const representatives = Array.from(groups.values()).map(group => this.pickRepresentativeItem(group));
320
+ // Cap per action style: when many different containers share the same
321
+ // functional action (e.g. 14 product cards each with "ADD TO CART"),
322
+ // keep at most maxPerStyle representatives per style.
323
+ // Include passthrough items in the cap — items without a container
324
+ // fingerprint (e.g. product links in a grid that wasn't detected as a
325
+ // repeated container) should still be deduplicated by functional style.
326
+ const allCandidates = [...passthrough, ...representatives];
327
+ const byStyle = new Map();
328
+ for (const rep of allCandidates) {
329
+ const style = this.representativeActionStyle(rep);
330
+ const bucket = byStyle.get(style) ?? [];
331
+ bucket.push(rep);
332
+ byStyle.set(style, bucket);
832
333
  }
833
- return scaffoldIdsBySelector;
334
+ return Array.from(byStyle.values()).flatMap(group => group.length <= maxPerStyle ? group : group.slice(0, maxPerStyle));
834
335
  }
835
- async ensureStoredForms(pageStateId, context) {
836
- const forms = this.normalizeForms(context.forms);
837
- if (forms.length === 0)
838
- return;
839
- const existing = await context.api.getFormsByPageState(pageStateId);
840
- const existingSelectors = new Set(existing.map(form => form.selector));
841
- for (const form of forms) {
842
- if (existingSelectors.has(form.selector))
843
- continue;
844
- await context.api.insertForm(pageStateId, form, this.identifyFormType(form, context.currentPath));
336
+ representativeActionStyle(item) {
337
+ const text = this.semanticText(item);
338
+ const sourceHints = String(item.attributes?._sourceHints ?? "").toLowerCase();
339
+ const containerTitle = String(item.attributes?._containerTitle ?? "").toLowerCase();
340
+ if (sourceHints.includes("promoted-target") ||
341
+ sourceHints.includes("cursor-pointer")) {
342
+ return "tile-click";
845
343
  }
846
- }
847
- normalizeContext(context) {
848
- return {
849
- ...context,
850
- html: typeof context.html === "string" ? context.html : "",
851
- scaffolds: Array.isArray(context.scaffolds) ? context.scaffolds : [],
852
- scaffoldSelectorByItemSelector: context.scaffoldSelectorByItemSelector &&
853
- typeof context.scaffoldSelectorByItemSelector === "object"
854
- ? context.scaffoldSelectorByItemSelector
855
- : {},
856
- actionableItems: this.normalizeActionableItems(context.actionableItems),
857
- forms: this.normalizeForms(context.forms),
858
- journeySteps: Array.isArray(context.journeySteps)
859
- ? context.journeySteps
860
- : [],
861
- };
862
- }
863
- normalizeActionableItems(items) {
864
- return Array.isArray(items) ? items : [];
865
- }
866
- normalizeForms(forms) {
867
- return Array.isArray(forms) ? forms : [];
868
- }
869
- buildRenderTestInteraction(currentPath, sizeClass, uid, startingPageStateId, pageId) {
870
- return {
871
- title: `Render — ${currentPath}`,
872
- type: "render",
873
- sizeClass,
874
- surface_tags: ["render"],
875
- priority: 2,
876
- page_id: pageId,
877
- startingPageStateId,
878
- startingPath: currentPath,
879
- steps: [
880
- {
881
- action: {
882
- actionType: PlaywrightAction.Goto,
883
- path: currentPath,
884
- playwrightCode: `await page.goto('${currentPath}')`,
885
- description: `Navigate to ${currentPath}`,
886
- },
887
- expectations: [
888
- {
889
- expectationType: ExpectationType.PageLoaded,
890
- severity: ExpectationSeverity.MustPass,
891
- description: `Page ${currentPath} should load`,
892
- playwrightCode: "await page.waitForLoadState('networkidle')",
893
- },
894
- ],
895
- description: `Navigate to ${currentPath}`,
896
- continueOnFailure: false,
897
- },
898
- {
899
- action: {
900
- actionType: PlaywrightAction.WaitForLoadState,
901
- playwrightCode: "await page.waitForLoadState('networkidle')",
902
- description: "Wait for page to settle",
903
- },
904
- expectations: [],
905
- description: "Wait for page to settle",
906
- continueOnFailure: true,
907
- },
908
- {
909
- action: {
910
- actionType: PlaywrightAction.Screenshot,
911
- value: `render-${this.slugify(currentPath)}`,
912
- playwrightCode: `await page.screenshot({ fullPage: true })`,
913
- description: "Capture screenshot",
914
- },
915
- expectations: [],
916
- description: "Capture screenshot",
917
- continueOnFailure: true,
918
- },
919
- ],
920
- globalExpectations: this.defaultFlowExpectations("Render page without runtime errors"),
921
- uid,
922
- generatedKey: this.buildGeneratedKey("render", startingPageStateId, pageId, currentPath),
923
- };
924
- }
925
- buildFormTestInteraction(form, formLabel, formType, currentPath, sizeClass, uid, startingPageStateId, validValues) {
926
- const steps = this.buildFormSteps(form, validValues, undefined);
927
- return {
928
- title: `Form — ${formLabel}`,
929
- type: "form",
930
- sizeClass,
931
- surface_tags: ["form", formType],
932
- priority: this.formPriority(formType),
933
- startingPageStateId,
934
- startingPath: currentPath,
935
- steps,
936
- globalExpectations: [
937
- ...this.defaultFlowExpectations(`Form ${formLabel} should execute cleanly`),
938
- ...(formType === "login" || formType === "signup"
939
- ? [
940
- this.makeExpectation(ExpectationType.NavigationOrStateChanged, `${formLabel} should advance authentication state after a successful submit`, {
941
- severity: ExpectationSeverity.ShouldPass,
942
- }),
943
- this.makeExpectation(ExpectationType.ErrorStateCleared, `${formLabel} should not leave a visible error state after a successful authentication submit`, {
944
- severity: ExpectationSeverity.ShouldPass,
945
- }),
946
- ]
947
- : []),
948
- this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit without client-side errors`),
949
- this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} should trigger a backend mutation request`, {
950
- expectedValue: "mutation",
951
- }),
952
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, `Submitting ${formLabel} should not trigger duplicate mutation requests`),
953
- this.makeExpectation("feedback_visible", `Submitting ${formLabel} should provide visible user feedback`, {
954
- expectedTextTokens: [
955
- "success",
956
- "saved",
957
- "submitted",
958
- "thank",
959
- "done",
960
- ],
961
- forbiddenTextTokens: ["error", "failed", "try again"],
962
- }),
963
- this.makeExpectation("feedback_not_duplicated", `Submitting ${formLabel} should not show duplicate feedback messages`),
964
- this.makeExpectation("field_error_clears_after_fix", `Validation errors on ${formLabel} should clear once the fields are corrected`),
965
- ],
966
- uid,
967
- generatedKey: this.buildGeneratedKey("form-positive", startingPageStateId, form.selector, formType),
968
- };
969
- }
970
- buildNegativeFormTestInteraction(form, formLabel, formType, omittedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
971
- const steps = this.buildFormSteps(form, validValues, omittedField.selector, this.buildValuePreservationExpectations(form, validValues, omittedField.selector));
972
- return {
973
- title: `Form Negative — ${formLabel} (missing ${this.fieldLabel(omittedField)})`,
974
- type: "form_negative",
975
- sizeClass,
976
- surface_tags: ["form", "negative", formType],
977
- priority: this.formPriority(formType) + 1,
978
- startingPageStateId,
979
- startingPath: currentPath,
980
- steps,
981
- globalExpectations: [
982
- ...this.defaultFlowExpectations(`Negative form check for ${formLabel}`),
983
- this.makeExpectation(ExpectationType.ValidationMessageVisible, `Validation feedback should appear when ${this.fieldLabel(omittedField)} is omitted`),
984
- this.makeExpectation(ExpectationType.ErrorStateVisible, `Omitting ${this.fieldLabel(omittedField)} should surface an error state`),
985
- this.makeExpectation("required_error_shown_for_field", `${this.fieldLabel(omittedField)} should show a required-field error when omitted`, {
986
- targetPath: omittedField.selector,
987
- }),
988
- ],
989
- uid,
990
- generatedKey: this.buildGeneratedKey("form-negative", startingPageStateId, form.selector, formType, omittedField.selector),
991
- };
992
- }
993
- buildFormCorrectionTestInteraction(form, formLabel, formType, correctedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
994
- const steps = this.buildFormSteps(form, validValues, correctedField.selector, this.buildValuePreservationExpectations(form, validValues, correctedField.selector));
995
- const correctionValue = validValues[correctedField.selector];
996
- if (correctionValue) {
997
- const correctionStep = this.buildFieldStep(correctedField, correctionValue, this.makeExpectation("field_error_clears_after_fix", `${this.fieldLabel(correctedField)} should clear its validation error after correction`, {
998
- targetPath: correctedField.selector,
999
- }));
1000
- if (correctionStep) {
1001
- steps.push(correctionStep);
1002
- }
1003
- }
1004
- steps.push(...this.buildSubmitSteps(form.submitSelector));
1005
- return {
1006
- title: `Form Correction — ${formLabel} (fix ${this.fieldLabel(correctedField)})`,
1007
- type: "form",
1008
- sizeClass,
1009
- surface_tags: ["form", "correction", formType],
1010
- priority: this.formPriority(formType) + 1,
1011
- startingPageStateId,
1012
- startingPath: currentPath,
1013
- steps,
1014
- globalExpectations: [
1015
- ...this.defaultFlowExpectations(`Correction flow for ${formLabel}`),
1016
- this.makeExpectation(ExpectationType.ErrorStateCleared, `Error state for ${formLabel} should clear after correcting ${this.fieldLabel(correctedField)}`),
1017
- this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit after correcting ${this.fieldLabel(correctedField)}`),
1018
- this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} after correction should trigger a backend mutation request`, {
1019
- expectedValue: "mutation",
1020
- }),
1021
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, `Submitting ${formLabel} after correction should not trigger duplicate mutation requests`),
1022
- this.makeExpectation("feedback_visible", `Form ${formLabel} should show success feedback after correction`, {
1023
- expectedTextTokens: [
1024
- "success",
1025
- "saved",
1026
- "submitted",
1027
- "thank",
1028
- "done",
1029
- ],
1030
- forbiddenTextTokens: ["error", "required", "invalid"],
1031
- }),
1032
- this.makeExpectation("feedback_not_duplicated", `Form ${formLabel} should not show duplicate feedback after correction`),
1033
- ],
1034
- uid,
1035
- generatedKey: this.buildGeneratedKey("form-correction", startingPageStateId, form.selector, formType, correctedField.selector),
1036
- };
1037
- }
1038
- buildPasswordTestInteractions(form, formLabel, formType, currentPath, sizeClass, uid, startingPageStateId, validValues, passwordRequirements) {
1039
- const passwordFields = form.fields.filter(field => field.type === "password");
1040
- if (passwordFields.length === 0)
1041
- return [];
1042
- const variants = this.generatePasswordVariants(passwordRequirements);
1043
- return variants.map(variant => {
1044
- const values = { ...validValues };
1045
- for (const field of passwordFields) {
1046
- values[field.selector] = variant.password;
1047
- }
1048
- return {
1049
- title: `Password — ${formLabel} (${variant.description})`,
1050
- type: "password",
1051
- sizeClass,
1052
- surface_tags: ["form", "password", formType],
1053
- priority: variant.shouldFail ? 2 : 1,
1054
- startingPageStateId,
1055
- startingPath: currentPath,
1056
- steps: this.buildFormSteps(form, values, undefined),
1057
- globalExpectations: [
1058
- ...this.defaultFlowExpectations(`Password flow ${variant.description}`),
1059
- {
1060
- expectationType: variant.shouldFail
1061
- ? ExpectationType.ValidationMessageVisible
1062
- : ExpectationType.FormSubmittedSuccessfully,
1063
- severity: ExpectationSeverity.ShouldPass,
1064
- description: variant.shouldFail
1065
- ? `Password validation should reject ${variant.description}`
1066
- : `Password validation should accept ${variant.description}`,
1067
- playwrightCode: "// checked by password follow-up validation",
1068
- },
1069
- ],
1070
- uid,
1071
- generatedKey: this.buildGeneratedKey("password", startingPageStateId, form.selector, formType, variant.description, variant.password),
1072
- };
1073
- });
1074
- }
1075
- buildE2ETestInteraction(currentPath, sizeClass, uid, startingPageStateId, journeySteps) {
1076
- return {
1077
- title: `E2E — Journey to ${currentPath}`,
1078
- type: "e2e",
1079
- sizeClass,
1080
- surface_tags: ["e2e"],
1081
- priority: 2,
1082
- startingPageStateId,
1083
- startingPath: currentPath,
1084
- steps: journeySteps.map(step => ({
1085
- ...step,
1086
- expectations: [],
1087
- })),
1088
- globalExpectations: this.defaultFlowExpectations(`Journey to ${currentPath} should complete without runtime errors`),
1089
- uid,
1090
- generatedKey: this.buildGeneratedKey("dependency-journey", startingPageStateId, currentPath, this.buildStepSignature(journeySteps)),
1091
- };
1092
- }
1093
- buildFormSteps(form, valuesBySelector, omittedSelector, postSubmitExpectations = []) {
1094
- const steps = [];
1095
- for (const field of form.fields) {
1096
- if (field.selector === omittedSelector)
1097
- continue;
1098
- const analyzerField = field;
1099
- const fieldIsImmutable = Boolean(analyzerField.disabled ||
1100
- analyzerField.readOnly ||
1101
- this.looksVisuallyDisabledField(analyzerField));
1102
- if (fieldIsImmutable)
1103
- continue;
1104
- const value = valuesBySelector[field.selector];
1105
- if (!value || this.isSkippableFieldType(field))
1106
- continue;
1107
- const step = this.buildFieldStep(field, value);
1108
- if (step)
1109
- steps.push(step);
1110
- }
1111
- steps.push(...this.buildSubmitSteps(form.submitSelector, postSubmitExpectations));
1112
- return steps;
1113
- }
1114
- buildFieldStep(field, value, trailingExpectation) {
1115
- const analyzerField = field;
1116
- const fieldIsImmutable = Boolean(analyzerField.disabled ||
1117
- analyzerField.readOnly ||
1118
- this.looksVisuallyDisabledField(analyzerField));
1119
- if (fieldIsImmutable)
1120
- return null;
1121
- if (this.isCheckboxLike(field)) {
1122
- return {
1123
- action: {
1124
- actionType: PlaywrightAction.Check,
1125
- path: field.selector,
1126
- value,
1127
- playwrightCode: `await page.locator('${field.selector}').check()`,
1128
- description: `Check ${this.fieldLabel(field)}`,
1129
- },
1130
- expectations: [
1131
- this.makeExpectation(ExpectationType.ElementChecked, `Checking ${this.fieldLabel(field)} should update the control state`, {
1132
- targetPath: field.selector,
1133
- }),
1134
- ...(trailingExpectation ? [trailingExpectation] : []),
1135
- ],
1136
- description: `Check ${this.fieldLabel(field)}`,
1137
- continueOnFailure: false,
1138
- };
1139
- }
1140
- if (this.isSelectLike(field)) {
1141
- return {
1142
- action: {
1143
- actionType: PlaywrightAction.SelectOption,
1144
- path: field.selector,
1145
- value,
1146
- playwrightCode: `await page.locator('${field.selector}').selectOption('${value}')`,
1147
- description: `Select ${this.fieldLabel(field)}`,
1148
- },
1149
- expectations: [
1150
- this.makeExpectation(ExpectationType.InputValue, `Selecting ${this.fieldLabel(field)} should update the selected option`, {
1151
- targetPath: field.selector,
1152
- expectedValue: value,
1153
- }),
1154
- ...(trailingExpectation ? [trailingExpectation] : []),
1155
- ],
1156
- description: `Select ${this.fieldLabel(field)}`,
1157
- continueOnFailure: false,
1158
- };
1159
- }
1160
- return {
1161
- action: {
1162
- actionType: PlaywrightAction.Type,
1163
- path: field.selector,
1164
- value,
1165
- playwrightCode: `await page.locator('${field.selector}').type('${this.escapeSingleQuotes(value)}')`,
1166
- description: `Fill ${this.fieldLabel(field)}`,
1167
- },
1168
- expectations: [
1169
- this.makeExpectation(ExpectationType.InputValue, `Typing into ${this.fieldLabel(field)} should update the control value`, {
1170
- targetPath: field.selector,
1171
- expectedValue: value,
1172
- }),
1173
- ...(trailingExpectation ? [trailingExpectation] : []),
1174
- ],
1175
- description: `Fill ${this.fieldLabel(field)}`,
1176
- continueOnFailure: false,
1177
- };
1178
- }
1179
- buildSubmitSteps(submitSelector, postSubmitExpectations = []) {
1180
- if (!submitSelector)
1181
- return [];
1182
- return [
1183
- {
1184
- action: {
1185
- actionType: PlaywrightAction.Click,
1186
- path: submitSelector,
1187
- playwrightCode: `await page.locator('${submitSelector}').click()`,
1188
- description: "Submit form",
1189
- },
1190
- expectations: [],
1191
- description: "Submit form",
1192
- continueOnFailure: false,
1193
- },
1194
- {
1195
- action: {
1196
- actionType: PlaywrightAction.WaitForLoadState,
1197
- playwrightCode: "await page.waitForLoadState('networkidle')",
1198
- description: "Wait for post-submit state",
1199
- },
1200
- expectations: postSubmitExpectations,
1201
- description: "Wait for post-submit state",
1202
- continueOnFailure: true,
1203
- },
1204
- ];
1205
- }
1206
- buildValuePreservationExpectations(form, valuesBySelector, omittedSelector) {
1207
- const expectations = [];
1208
- for (const field of form.fields) {
1209
- if (field.selector === omittedSelector)
1210
- continue;
1211
- const analyzerField = field;
1212
- const fieldIsImmutable = Boolean(analyzerField.disabled ||
1213
- analyzerField.readOnly ||
1214
- this.looksVisuallyDisabledField(analyzerField));
1215
- if (fieldIsImmutable)
1216
- continue;
1217
- const value = valuesBySelector[field.selector];
1218
- if (!value || this.isSkippableFieldType(field))
1219
- continue;
1220
- if (this.isCheckboxLike(field)) {
1221
- expectations.push(this.makeExpectation(ExpectationType.ElementChecked, `${this.fieldLabel(field)} should preserve its selected state after validation feedback`, {
1222
- targetPath: field.selector,
1223
- }));
1224
- continue;
1225
- }
1226
- expectations.push(this.makeExpectation(ExpectationType.InputValue, `${this.fieldLabel(field)} should preserve its value after validation feedback`, {
1227
- targetPath: field.selector,
1228
- expectedValue: value,
1229
- }));
1230
- }
1231
- return expectations;
1232
- }
1233
- planFormValues(form, actionableItems) {
1234
- const values = {};
1235
- for (const field of form.fields) {
1236
- const analyzerField = field;
1237
- if (analyzerField.disabled ||
1238
- analyzerField.readOnly ||
1239
- this.looksVisuallyDisabledField(analyzerField)) {
1240
- continue;
1241
- }
1242
- if (this.isCheckboxLike(field)) {
1243
- values[field.selector] = "true";
1244
- continue;
1245
- }
1246
- if (this.isSelectLike(field)) {
1247
- const option = field.options?.find(value => value && value.trim().length > 0);
1248
- if (option)
1249
- values[field.selector] = option;
1250
- continue;
1251
- }
1252
- const item = actionableItems.find(candidate => candidate.selector === field.selector);
1253
- const fallbackItem = item ?? {
1254
- stableKey: field.selector,
1255
- selector: field.selector,
1256
- tagName: field.type === "textarea" ? "TEXTAREA" : "INPUT",
1257
- inputType: field.type,
1258
- actionKind: "fill",
1259
- accessibleName: field.label,
1260
- disabled: false,
1261
- visible: true,
1262
- attributes: {
1263
- name: field.name,
1264
- placeholder: field.placeholder,
1265
- labelText: field.label,
1266
- },
1267
- };
1268
- values[field.selector] = fillValuePlanner.planValue(fallbackItem);
1269
- }
1270
- return values;
1271
- }
1272
- identifyFormType(form, currentPath) {
1273
- const url = currentPath.toLowerCase();
1274
- const fields = form.fields;
1275
- const isLoginUrl = AUTH_URL_PATTERNS.some(pattern => url.includes(pattern));
1276
- const isSignupUrl = SIGNUP_URL_PATTERNS.some(pattern => url.includes(pattern));
1277
- const hasPassword = fields.some(field => field.type === "password");
1278
- const hasEmail = fields.some(field => {
1279
- const label = field.label.toLowerCase();
1280
- return (field.type === "email" ||
1281
- field.name.toLowerCase() === "email" ||
1282
- label.includes("email"));
1283
- });
1284
- const hasUsername = fields.some(field => {
1285
- const signal = `${field.name} ${field.label}`.toLowerCase();
1286
- return signal.includes("username") || signal.includes("user name");
1287
- });
1288
- const hasName = fields.some(field => {
1289
- const signal = `${field.name} ${field.label}`.toLowerCase();
1290
- return (signal.includes("name") &&
1291
- !signal.includes("username") &&
1292
- !signal.includes("email"));
1293
- });
1294
- const hasMessage = fields.some(field => {
1295
- const signal = `${field.name} ${field.label}`.toLowerCase();
1296
- return signal.includes("message") || signal.includes("subject");
1297
- });
1298
- const passwordCount = fields.filter(field => field.type === "password").length;
1299
- if (hasMessage)
1300
- return "other";
1301
- if (!hasPassword)
1302
- return "other";
1303
- if (hasEmail || hasUsername) {
1304
- if (passwordCount >= 2 || hasName || isSignupUrl)
1305
- return "signup";
1306
- if (isLoginUrl)
1307
- return "login";
1308
- if (fields.length <= 2)
1309
- return "login";
1310
- return hasName ? "signup" : "login";
1311
- }
1312
- if (isSignupUrl)
1313
- return "signup";
1314
- if (isLoginUrl)
1315
- return "login";
1316
- return "other";
1317
- }
1318
- isSearchForm(form) {
1319
- if (String(form.method || "").toUpperCase() === "GET") {
1320
- return true;
1321
- }
1322
- return form.fields.some(field => this.isSearchField(field));
1323
- }
1324
- isSearchField(field) {
1325
- const text = [
1326
- field.type,
1327
- field.label,
1328
- field.name,
1329
- field.placeholder,
1330
- field.selector,
1331
- ]
1332
- .filter(Boolean)
1333
- .join(" ")
1334
- .toLowerCase();
1335
- return field.type === "search" || /\bsearch\b/.test(text);
1336
- }
1337
- buildSearchTestInteractions(form, formLabel, currentPath, sizeClass, uid, startingPageStateId, validValues, actionableItems) {
1338
- const searchField = form.fields.find(field => this.isSearchField(field));
1339
- if (!searchField)
1340
- return [];
1341
- const searchValues = {
1342
- ...validValues,
1343
- [searchField.selector]: "test",
1344
- };
1345
- const noResultsValues = {
1346
- ...validValues,
1347
- [searchField.selector]: this.improbableSearchQuery(),
1348
- };
1349
- const tests = [
1350
- {
1351
- title: `Search — ${formLabel}`,
1352
- type: "form",
1353
- sizeClass,
1354
- surface_tags: ["form", "search"],
1355
- priority: 2,
1356
- startingPageStateId,
1357
- startingPath: currentPath,
1358
- steps: this.buildFormSteps(form, searchValues, undefined),
1359
- globalExpectations: [
1360
- ...this.defaultFlowExpectations(`Search flow ${formLabel}`),
1361
- this.makeExpectation(ExpectationType.NetworkRequestMade, `Searching via ${formLabel} should issue a GET request`, {
1362
- expectedValue: "GET",
1363
- timeoutMs: 3000,
1364
- expectedTextTokens: ["search", "q=", "query", "term"],
1365
- }),
1366
- this.makeExpectation(ExpectationType.NavigationOrStateChanged, `Searching via ${formLabel} should change the page or result state`),
1367
- this.makeExpectation(ExpectationType.ResultsChanged, `Searching via ${formLabel} should change the visible results`),
1368
- this.makeExpectation(ExpectationType.LoadingCompletes, `Search results for ${formLabel} should finish loading`),
1369
- ],
1370
- uid,
1371
- generatedKey: this.buildGeneratedKey("search", startingPageStateId, form.selector, searchField.selector),
1372
- },
1373
- {
1374
- title: `Search Empty State — ${formLabel}`,
1375
- type: "form",
1376
- sizeClass,
1377
- surface_tags: ["form", "search", "empty-state"],
1378
- priority: 3,
1379
- startingPageStateId,
1380
- startingPath: currentPath,
1381
- steps: this.buildFormSteps(form, noResultsValues, undefined),
1382
- globalExpectations: [
1383
- ...this.defaultFlowExpectations(`Empty-state search flow ${formLabel}`),
1384
- this.makeExpectation(ExpectationType.NetworkRequestMade, `No-result search via ${formLabel} should still issue a GET request`, {
1385
- expectedValue: "GET",
1386
- timeoutMs: 3000,
1387
- expectedTextTokens: ["search", "q=", "query", "term"],
1388
- }),
1389
- this.makeExpectation(ExpectationType.NavigationOrStateChanged, `No-result search via ${formLabel} should change the page or result state`),
1390
- this.makeExpectation(ExpectationType.EmptyStateVisible, `No-result search via ${formLabel} should show an empty state`),
1391
- this.makeExpectation(ExpectationType.LoadingCompletes, `No-result search for ${formLabel} should finish loading`),
1392
- ],
1393
- uid,
1394
- generatedKey: this.buildGeneratedKey("search-empty", startingPageStateId, form.selector, searchField.selector),
1395
- },
1396
- {
1397
- title: `Search Recovery — ${formLabel}`,
1398
- type: "form",
1399
- sizeClass,
1400
- surface_tags: ["form", "search", "recovery"],
1401
- priority: 3,
1402
- startingPageStateId,
1403
- startingPath: currentPath,
1404
- steps: [
1405
- ...this.buildFormSteps(form, noResultsValues, undefined),
1406
- ...this.buildSearchRecoverySteps(form, searchField, "test"),
1407
- ],
1408
- globalExpectations: [
1409
- ...this.defaultFlowExpectations(`Search recovery flow ${formLabel}`),
1410
- this.makeExpectation(ExpectationType.NetworkRequestMade, `Recovering search results via ${formLabel} should issue GET requests`, {
1411
- expectedValue: "GET",
1412
- timeoutMs: 3000,
1413
- expectedTextTokens: ["search", "q=", "query", "term"],
1414
- }),
1415
- this.makeExpectation(ExpectationType.LoadingCompletes, `Search recovery for ${formLabel} should finish loading`),
1416
- ],
1417
- uid,
1418
- generatedKey: this.buildGeneratedKey("search-recovery", startingPageStateId, form.selector, searchField.selector),
1419
- },
1420
- ];
1421
- const clearAction = actionableItems.find(item => this.isSearchClearItem(item));
1422
- if (clearAction) {
1423
- const clearSteps = this.buildFormSteps(form, searchValues, undefined);
1424
- clearSteps.push(this.buildJourneyAction(clearAction, "Clear search", [
1425
- this.makeExpectation(ExpectationType.ResultsRestored, `Clearing ${formLabel} should restore the baseline results`),
1426
- this.makeExpectation(ExpectationType.InputValue, `Clearing ${formLabel} should empty the search field`, {
1427
- targetPath: searchField.selector,
1428
- expectedValue: "",
1429
- }),
1430
- ]));
1431
- tests.push({
1432
- title: `Search Clear Restore — ${formLabel}`,
1433
- type: "form",
1434
- sizeClass,
1435
- surface_tags: ["form", "search", "restore"],
1436
- priority: 3,
1437
- startingPageStateId,
1438
- startingPath: currentPath,
1439
- steps: clearSteps,
1440
- globalExpectations: [
1441
- ...this.defaultFlowExpectations(`Search clear flow ${formLabel}`),
1442
- this.makeExpectation(ExpectationType.ResultsRestored, `Clearing ${formLabel} should restore the initial results baseline`),
1443
- ],
1444
- uid,
1445
- generatedKey: this.buildGeneratedKey("search-clear", startingPageStateId, form.selector, searchField.selector, clearAction.selector),
1446
- });
1447
- }
1448
- return tests;
1449
- }
1450
- buildSearchRecoverySteps(form, searchField, recoveryValue) {
1451
- const steps = [];
1452
- const refillStep = this.buildFieldStep(searchField, recoveryValue);
1453
- if (refillStep) {
1454
- steps.push(refillStep);
1455
- }
1456
- steps.push(...this.buildSubmitSteps(form.submitSelector, [
1457
- this.makeExpectation(ExpectationType.ResultsChanged, `${this.fieldLabel(searchField)} should recover from the empty state to a different result set`),
1458
- this.makeExpectation(ExpectationType.InputValue, `${this.fieldLabel(searchField)} should preserve the recovery query after resubmission`, {
1459
- targetPath: searchField.selector,
1460
- expectedValue: recoveryValue,
1461
- }),
1462
- this.makeExpectation(ExpectationType.LoadingCompletes, "Recovered search results should finish loading"),
1463
- ]));
1464
- return steps;
1465
- }
1466
- describeForm(form, index) {
1467
- const namedField = form.fields.find(field => field.label || field.name);
1468
- const descriptor = namedField?.label || namedField?.name || `form ${index + 1}`;
1469
- return `${descriptor} @ ${form.selector}`;
1470
- }
1471
- buildSemanticJourneyTestInteractions(context) {
1472
- const items = this.selectRepresentativeItems(context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector)));
1473
- const journeys = [];
1474
- const collectionCount = this.estimateCollectionCount(context.html);
1475
- const addToCart = items.find(item => this.isAddToCartItem(item));
1476
- const checkout = items.find(item => this.isCheckoutItem(item));
1477
- const createCollectionAction = items.find(item => this.isCreateCollectionAction(item));
1478
- if (addToCart && checkout) {
1479
- journeys.push(this.buildJourneyTestInteraction("Commerce journey", ["commerce", "cart", "checkout"], context, [
1480
- this.buildJourneyAction(addToCart, "Add item to cart", [
1481
- this.makeExpectation("navigation_or_state_changed", "Adding an item to cart should update the page state"),
1482
- this.makeExpectation("count_changed", "Adding an item should update a visible count", {
1483
- expectedCountDelta: 1,
1484
- }),
1485
- this.makeExpectation("cart_summary_changed", "Adding an item should update the cart summary"),
1486
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Adding an item should trigger a backend mutation request", {
1487
- expectedValue: "mutation",
1488
- timeoutMs: 3000,
1489
- }),
1490
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Adding an item should not trigger duplicate mutation requests"),
1491
- this.makeExpectation("feedback_visible", "Adding an item should provide visible feedback", {
1492
- expectedTextTokens: ["added", "success", "cart", "bag"],
1493
- forbiddenTextTokens: ["error", "failed"],
1494
- }),
1495
- this.makeExpectation("feedback_not_duplicated", "Adding an item should not show duplicate feedback messages"),
1496
- this.makeExpectation("loading_completes", "Cart update should complete loading"),
1497
- this.makeExpectation("page_responsive", "Page should remain responsive after cart update"),
1498
- ]),
1499
- this.waitStep(700, "Wait for cart state"),
1500
- this.buildJourneyAction(checkout, "Proceed to checkout", [
1501
- this.makeExpectation("navigation_or_state_changed", "Proceeding to checkout should change the page state"),
1502
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Proceeding to checkout should trigger a network request or document transition", {
1503
- expectedValue: "ANY",
1504
- timeoutMs: 3000,
1505
- expectedTextTokens: ["checkout"],
1506
- }),
1507
- this.makeExpectation("loading_completes", "Checkout transition should complete loading"),
1508
- this.makeExpectation("page_responsive", "Page should remain responsive during checkout transition"),
1509
- ]),
1510
- ]));
1511
- }
1512
- const removeItem = items.find(item => this.isRemoveItemAction(item));
1513
- if (removeItem) {
1514
- const removeExpectations = [
1515
- this.makeExpectation("navigation_or_state_changed", "Removing an item should change the page state"),
1516
- this.makeExpectation("count_changed", "Removing an item should update a visible count", {
1517
- expectedCountDelta: -1,
1518
- }),
1519
- this.makeExpectation("cart_summary_changed", "Removing an item should update the cart summary"),
1520
- this.makeExpectation("row_count_changed", "Removing an item should change the visible row or item count", {
1521
- expectedCountDelta: -1,
1522
- }),
1523
- this.makeExpectation("results_changed", "Removing an item should change the visible collection state"),
1524
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Removing an item should trigger a backend mutation request", {
1525
- expectedValue: "mutation",
1526
- timeoutMs: 3000,
1527
- }),
1528
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Removing an item should not trigger duplicate mutation requests"),
1529
- this.makeExpectation("feedback_visible", "Removing an item should provide visible feedback", {
1530
- expectedTextTokens: ["removed", "updated", "cart", "bag"],
1531
- forbiddenTextTokens: ["error", "failed"],
1532
- }),
1533
- this.makeExpectation("feedback_not_duplicated", "Removing an item should not show duplicate feedback messages"),
1534
- this.makeExpectation("loading_completes", "Removal flow should complete loading"),
1535
- this.makeExpectation("page_responsive", "Page should remain responsive after removal"),
1536
- ];
1537
- if (this.estimateCollectionCount(context.html) <= 1) {
1538
- removeExpectations.push(this.makeExpectation(ExpectationType.EmptyStateVisible, "Removing the last visible item should show an empty state or zero-results message", {
1539
- expectedTextTokens: [
1540
- "no items",
1541
- "no products",
1542
- "empty",
1543
- "no results",
1544
- "nothing found",
1545
- ],
1546
- severity: ExpectationSeverity.ShouldPass,
1547
- }));
1548
- }
1549
- journeys.push(this.buildJourneyTestInteraction("Remove item from collection", ["commerce", "remove"], context, [
1550
- this.buildJourneyAction(removeItem, "Remove item", removeExpectations),
1551
- ]));
1552
- }
1553
- if (createCollectionAction) {
1554
- const createExpectations = [
1555
- this.makeExpectation("navigation_or_state_changed", "Creating or adding a record should change the page state"),
1556
- this.makeExpectation("row_count_changed", "Creating or adding a record should increase the visible row or item count", {
1557
- expectedCountDelta: 1,
1558
- }),
1559
- this.makeExpectation("results_changed", "Creating or adding a record should change the visible collection state"),
1560
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Creating or adding a record should trigger a backend mutation request", {
1561
- expectedValue: "mutation",
1562
- timeoutMs: 3000,
1563
- }),
1564
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Creating or adding a record should not trigger duplicate mutation requests"),
1565
- this.makeExpectation("feedback_not_duplicated", "Creating or adding a record should not duplicate visible feedback"),
1566
- this.makeExpectation("loading_completes", "Create/add flow should complete loading"),
1567
- this.makeExpectation("page_responsive", "Page should remain responsive after creating or adding a record"),
1568
- ];
1569
- journeys.push(this.buildJourneyTestInteraction("Create or add collection record", ["list", "crud", "create"], context, [
1570
- this.buildJourneyAction(createCollectionAction, "Create or add record", createExpectations),
1571
- ]));
1572
- }
1573
- const authEntry = items.find(item => this.isAuthEntryItem(item));
1574
- if (authEntry) {
1575
- journeys.push(this.buildJourneyTestInteraction("Authentication entry journey", ["auth"], context, [
1576
- this.buildJourneyAction(authEntry, "Open authentication entry point", [
1577
- this.makeExpectation("navigation_or_state_changed", "Authentication entry should open the next auth state"),
1578
- this.makeExpectation("loading_completes", "Authentication entry flow should settle"),
1579
- this.makeExpectation("page_responsive", "Page should remain responsive when opening auth flow"),
1580
- ]),
1581
- ]));
1582
- }
1583
- const protectedAction = items.find(item => item !== authEntry && this.isProtectedActionItem(item));
1584
- if (authEntry && protectedAction) {
1585
- journeys.push(this.buildJourneyTestInteraction("Protected action auth gate journey", ["auth", "protected"], context, [
1586
- this.buildJourneyAction(protectedAction, "Open protected action", [
1587
- this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Protected action should open a gated state, redirect, or login requirement"),
1588
- this.makeExpectation(ExpectationType.PageResponsive, "Page should remain responsive when auth gating is triggered"),
1589
- this.makeExpectation(ExpectationType.LoadingCompletes, "Protected action gate should settle cleanly"),
1590
- ]),
1591
- ]));
1592
- }
1593
- const logoutAction = items.find(item => this.isLogoutAction(item));
1594
- if (logoutAction) {
1595
- journeys.push(this.buildJourneyTestInteraction("Logout journey", ["auth", "logout"], context, [
1596
- this.buildJourneyAction(logoutAction, "Log out", [
1597
- this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Logging out should change application state"),
1598
- this.makeExpectation(ExpectationType.PageResponsive, "Page should remain responsive during logout"),
1599
- this.makeExpectation(ExpectationType.LoadingCompletes, "Logout should settle cleanly"),
1600
- this.makeExpectation(ExpectationType.FeedbackVisible, "Logout should provide visible confirmation or a clear state change", {
1601
- forbiddenTextTokens: ["error", "failed"],
1602
- }),
1603
- this.makeExpectation(ExpectationType.ErrorStateCleared, "Logout should not leave visible recoverable error state behind", {
1604
- severity: ExpectationSeverity.ShouldPass,
1605
- }),
1606
- ]),
1607
- ]));
1608
- }
1609
- const retryAction = items.find(item => this.isRetryAction(item));
1610
- if (retryAction) {
1611
- journeys.push(this.buildJourneyTestInteraction("Retry recovery journey", ["recovery", "retry"], context, [
1612
- this.buildJourneyAction(retryAction, "Retry failed action", [
1613
- this.makeExpectation(ExpectationType.ErrorStateCleared, "Retrying should clear any visible recoverable error state"),
1614
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Retrying should trigger a follow-up request or state transition", {
1615
- expectedValue: "ANY",
1616
- timeoutMs: 3000,
1617
- }),
1618
- this.makeExpectation("navigation_or_state_changed", "Retrying should change page state or visibly advance recovery"),
1619
- this.makeExpectation("loading_completes", "Retry recovery should complete loading"),
1620
- this.makeExpectation("page_responsive", "Page should remain responsive during retry recovery"),
1621
- this.makeExpectation("feedback_not_duplicated", "Retry recovery should not duplicate visible feedback"),
1622
- this.makeExpectation("feedback_visible", "Retry recovery should show updated feedback or state confirmation", {
1623
- forbiddenTextTokens: ["error", "failed", "try again"],
1624
- }),
1625
- ]),
1626
- ]));
1627
- }
1628
- const mediaCandidate = items.find(item => this.isMediaOpenItem(item));
1629
- if (mediaCandidate) {
1630
- const mediaExpectations = [
1631
- this.makeExpectation("modal_opened", "Opening media should reveal a modal, overlay, or new state"),
1632
- this.makeExpectation("media_loaded", "Opened media should load successfully"),
1633
- ];
1634
- if (this.isVideoLikeItem(mediaCandidate)) {
1635
- mediaExpectations.push(this.makeExpectation("video_playable", "Opened video should be playable"));
1636
- }
1637
- journeys.push(this.buildJourneyTestInteraction("Media open journey", ["media"], context, [
1638
- this.buildJourneyAction(mediaCandidate, "Open media", mediaExpectations),
1639
- ]));
1640
- }
1641
- for (const quantityAction of items.filter(item => this.isQuantityAction(item))) {
1642
- const quantityDelta = this.inferQuantityDelta(quantityAction);
1643
- const quantityExpectations = [
1644
- this.makeExpectation("count_changed", "Adjusting quantity should update a visible count or quantity indicator", quantityDelta == null
1645
- ? undefined
1646
- : { expectedCountDelta: quantityDelta }),
1647
- this.makeExpectation("cart_summary_changed", "Adjusting quantity should update subtotal, totals, or line pricing"),
1648
- this.makeExpectation("results_changed", "Adjusting quantity should change the visible cart or line-item state"),
1649
- this.makeExpectation(ExpectationType.NetworkRequestMade, "Adjusting quantity should trigger a backend mutation request", {
1650
- expectedValue: "mutation",
1651
- timeoutMs: 3000,
1652
- }),
1653
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, "Adjusting quantity should not trigger duplicate mutation requests"),
1654
- this.makeExpectation("feedback_not_duplicated", "Quantity adjustment should not duplicate visible feedback"),
1655
- this.makeExpectation("loading_completes", "Quantity adjustment should complete loading"),
1656
- this.makeExpectation("page_responsive", "Page should remain responsive during quantity adjustment"),
1657
- ].filter(Boolean);
1658
- journeys.push(this.buildJourneyTestInteraction(quantityDelta === -1
1659
- ? "Quantity decrease journey"
1660
- : quantityDelta === 1
1661
- ? "Quantity increase journey"
1662
- : "Quantity adjustment journey", ["commerce", "quantity"], context, [
1663
- this.buildJourneyAction(quantityAction, quantityDelta === -1
1664
- ? "Decrease quantity"
1665
- : quantityDelta === 1
1666
- ? "Increase quantity"
1667
- : "Adjust quantity", quantityExpectations),
1668
- ]));
1669
- }
1670
- const filterAction = items.find(item => this.isFilterAction(item));
1671
- if (filterAction) {
1672
- journeys.push(this.buildJourneyTestInteraction("Filter results journey", ["filter"], context, [
1673
- this.buildJourneyAction(filterAction, "Apply filter", [
1674
- this.makeExpectation("navigation_or_state_changed", "Applying a filter should change the result state"),
1675
- this.makeExpectation("results_changed", "Applying a filter should change the visible result summary"),
1676
- this.makeExpectation("loading_completes", "Filter update should complete loading"),
1677
- ]),
1678
- ]));
1679
- journeys.push(this.buildJourneyTestInteraction("Filter persistence journey", ["filter", "reload"], context, [
1680
- this.buildJourneyAction(filterAction, "Apply filter", [
1681
- this.makeExpectation("navigation_or_state_changed", "Applying a filter should change the result state"),
1682
- ]),
1683
- this.waitStep(500, "Wait for filtered state"),
1684
- this.buildReloadStep([
1685
- this.makeExpectation("state_persists_after_reload", "Filter state should persist after reload", {
1686
- expectedTextTokens: ["filter", "results", "showing", "items"],
1687
- }),
1688
- ]),
1689
- ]));
1690
- }
1691
- const sortAction = items.find(item => this.isSortAction(item));
1692
- if (sortAction) {
1693
- journeys.push(this.buildJourneyTestInteraction("Sort results journey", ["sort"], context, [
1694
- this.buildJourneyAction(sortAction, "Change sort order", [
1695
- this.makeExpectation("navigation_or_state_changed", "Changing sort should update the result state"),
1696
- this.makeExpectation("collection_order_changed", "Changing sort should change the visible collection ordering"),
1697
- this.makeExpectation("loading_completes", "Sort update should complete loading"),
1698
- ]),
1699
- ]));
1700
- }
1701
- const paginationAction = items.find(item => this.isPaginationAction(item));
1702
- if (paginationAction) {
1703
- journeys.push(this.buildJourneyTestInteraction("Pagination journey", ["list", "pagination"], context, [
1704
- this.buildJourneyAction(paginationAction, "Paginate list", [
1705
- this.makeExpectation(ExpectationType.NavigationOrStateChanged, "Pagination should change the visible list state"),
1706
- this.makeExpectation("results_changed", "Pagination should change the visible collection contents"),
1707
- this.makeExpectation("row_count_changed", "Pagination should change the visible rows or items"),
1708
- this.makeExpectation("collection_order_changed", "Pagination should change the visible collection ordering or composition"),
1709
- this.makeExpectation(ExpectationType.LoadingCompletes, "Pagination should complete loading"),
1710
- ]),
1711
- ]));
1712
- }
1713
- if (addToCart) {
1714
- journeys.push(this.buildJourneyTestInteraction("Cart persistence journey", ["commerce", "reload"], context, [
1715
- this.buildJourneyAction(addToCart, "Add item to cart", [
1716
- this.makeExpectation("navigation_or_state_changed", "Adding an item should change page state before reload"),
1717
- ]),
1718
- this.waitStep(500, "Wait for updated cart state"),
1719
- this.buildReloadStep([
1720
- this.makeExpectation("state_persists_after_reload", "Cart state should persist after reload", {
1721
- expectedTextTokens: [
1722
- "cart",
1723
- "bag",
1724
- "basket",
1725
- "qty",
1726
- "quantity",
1727
- ],
1728
- }),
1729
- ]),
1730
- ]));
1731
- }
1732
- if (collectionCount > 0 && removeItem && createCollectionAction) {
1733
- journeys.push(this.buildJourneyTestInteraction("Collection mutation recovery journey", ["list", "crud", "recovery"], context, [
1734
- this.buildJourneyAction(removeItem, "Remove record", [
1735
- this.makeExpectation("row_count_changed", "Removing a record should change the visible collection", {
1736
- expectedCountDelta: -1,
1737
- }),
1738
- this.makeExpectation("results_changed", "Removing a record should change the visible collection contents"),
1739
- ]),
1740
- this.waitStep(500, "Wait for collection mutation"),
1741
- this.buildJourneyAction(createCollectionAction, "Add record back", [
1742
- this.makeExpectation("row_count_changed", "Adding a record back should restore visible collection size", {
1743
- expectedCountDelta: 1,
1744
- }),
1745
- this.makeExpectation("results_changed", "Adding a record back should change the visible collection contents again"),
1746
- this.makeExpectation("loading_completes", "Collection recovery should complete loading"),
1747
- ]),
1748
- ]));
1749
- }
1750
- const backCandidate = items.find(item => item.actionKind === "navigate" ||
1751
- this.isMediaOpenItem(item) ||
1752
- this.isAuthEntryItem(item));
1753
- if (backCandidate) {
1754
- journeys.push(this.buildJourneyTestInteraction("Back and forward navigation journey", ["navigation", "history"], context, [
1755
- this.buildJourneyAction(backCandidate, "Navigate to next state", [
1756
- this.makeExpectation("navigation_or_state_changed", "Navigation should move to a different state"),
1757
- ]),
1758
- this.waitStep(500, "Wait for next state"),
1759
- this.buildBackStep([
1760
- this.makeExpectation("back_navigation_restores_state", "Back navigation should restore the previous state"),
1761
- ]),
1762
- this.waitStep(300, "Wait after back navigation"),
1763
- this.buildForwardStep([
1764
- this.makeExpectation("forward_navigation_reapplies_state", "Forward navigation should reapply the later state"),
1765
- ]),
1766
- ]));
1767
- }
1768
- return journeys;
1769
- }
1770
- buildJourneyTestInteraction(title, surfaceTags, context, steps) {
1771
- return {
1772
- title: `${title} — ${context.currentPath}`,
1773
- type: "e2e",
1774
- sizeClass: context.sizeClass,
1775
- surface_tags: ["e2e", ...surfaceTags],
1776
- priority: 2,
1777
- startingPageStateId: context.currentPageStateId,
1778
- startingPath: context.currentPath,
1779
- steps,
1780
- globalExpectations: this.defaultFlowExpectations(`${title} should complete without runtime errors`),
1781
- uid: context.uid,
1782
- generatedKey: this.buildGeneratedKey("semantic-journey", context.currentPageStateId, context.currentPath, this.buildStepSignature(steps)),
1783
- };
1784
- }
1785
- buildJourneyAction(item, description, expectations) {
1786
- const signal = this.describeActionableItem(item);
1787
- const action = this.buildSemanticAction(item, description, signal);
1788
- return {
1789
- action,
1790
- expectations,
1791
- description: `${description}: ${signal}`,
1792
- continueOnFailure: false,
1793
- };
1794
- }
1795
- waitStep(ms, description) {
1796
- return {
1797
- action: {
1798
- actionType: PlaywrightAction.WaitForTimeout,
1799
- value: String(ms),
1800
- playwrightCode: `await page.waitForTimeout(${ms})`,
1801
- description,
1802
- },
1803
- expectations: [],
1804
- description,
1805
- continueOnFailure: true,
1806
- };
1807
- }
1808
- buildReloadStep(expectations) {
1809
- return {
1810
- action: {
1811
- actionType: PlaywrightAction.Reload,
1812
- playwrightCode: "await page.reload({ waitUntil: 'networkidle' })",
1813
- description: "Reload page",
1814
- },
1815
- expectations,
1816
- description: "Reload page",
1817
- continueOnFailure: false,
1818
- };
1819
- }
1820
- buildBackStep(expectations) {
1821
- return {
1822
- action: {
1823
- actionType: PlaywrightAction.GoBack,
1824
- playwrightCode: "await page.goBack()",
1825
- description: "Go back",
1826
- },
1827
- expectations,
1828
- description: "Go back",
1829
- continueOnFailure: false,
1830
- };
1831
- }
1832
- buildForwardStep(expectations) {
1833
- return {
1834
- action: {
1835
- actionType: PlaywrightAction.GoForward,
1836
- playwrightCode: "await page.goForward()",
1837
- description: "Go forward",
1838
- },
1839
- expectations,
1840
- description: "Go forward",
1841
- continueOnFailure: false,
1842
- };
1843
- }
1844
- defaultFlowExpectations(description) {
1845
- return [
1846
- {
1847
- expectationType: ExpectationType.PageLoaded,
1848
- severity: ExpectationSeverity.MustPass,
1849
- description,
1850
- playwrightCode: "await page.waitForLoadState('networkidle')",
1851
- },
1852
- {
1853
- expectationType: ExpectationType.NoNetworkErrors,
1854
- severity: ExpectationSeverity.ShouldPass,
1855
- description: "No network errors during flow",
1856
- playwrightCode: "// checked by TesterExpertise",
1857
- },
1858
- {
1859
- expectationType: ExpectationType.NoConsoleErrors,
1860
- severity: ExpectationSeverity.ShouldPass,
1861
- description: "No console errors during flow",
1862
- playwrightCode: "// checked by TesterExpertise",
1863
- },
1864
- ];
1865
- }
1866
- formPriority(formType) {
1867
- if (formType === "login" || formType === "signup")
1868
- return 1;
1869
- return 2;
1870
- }
1871
- isNegativeCandidateField(field) {
1872
- const analyzerField = field;
1873
- return (field.required &&
1874
- !analyzerField.disabled &&
1875
- !analyzerField.readOnly &&
1876
- !this.looksVisuallyDisabledField(analyzerField) &&
1877
- !this.isCheckboxLike(field) &&
1878
- !this.isSkippableFieldType(field));
1879
- }
1880
- isPasswordScenario(formType, form) {
1881
- return (formType !== "other" &&
1882
- form.fields.some(field => field.type === "password"));
1883
- }
1884
- isCheckboxLike(field) {
1885
- return field.type === "checkbox" || field.type === "radio";
1886
- }
1887
- buildKeyboardAndDisclosureTestInteractions(context) {
1888
- const items = this.selectRepresentativeItems(context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector)));
1889
- const tests = [];
1890
- for (const item of items) {
1891
- if (this.isDisclosureItem(item)) {
1892
- tests.push(this.buildDisclosureToggleTestInteraction(item, context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
1893
- tests.push(this.buildKeyboardActivateTestInteraction(item, "Enter", "Activate disclosure with Enter", [
1894
- this.makeExpectation("expanded_state_changed", "Enter should toggle the disclosure state", {
1895
- targetPath: item.selector,
1896
- }),
1897
- ], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
1898
- tests.push(this.buildKeyboardActivateTestInteraction(item, " ", "Activate disclosure with Space", [
1899
- this.makeExpectation("expanded_state_changed", "Space should toggle the disclosure state", {
1900
- targetPath: item.selector,
1901
- }),
1902
- ], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
1903
- continue;
1904
- }
1905
- if (this.isKeyboardPrimaryAction(item)) {
1906
- tests.push(this.buildKeyboardActivateTestInteraction(item, "Enter", "Activate with Enter", [
1907
- this.makeExpectation("navigation_or_state_changed", "Enter key activation should change the page or control state"),
1908
- ], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
1909
- }
1910
- if (this.isKeyboardToggleAction(item)) {
1911
- tests.push(this.buildKeyboardActivateTestInteraction(item, " ", "Toggle with Space", [
1912
- this.makeExpectation(ExpectationType.ElementChecked, "Space key activation should toggle the control state", {
1913
- targetPath: item.selector,
1914
- }),
1915
- ], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
1916
- }
1917
- }
1918
- return tests;
1919
- }
1920
- buildVariantTestInteractions(context) {
1921
- const items = this.selectRepresentativeItems(context.actionableItems.filter(item => item.visible &&
1922
- !item.disabled &&
1923
- Boolean(item.selector) &&
1924
- this.isVariantSelector(item)));
1925
- const tests = items
1926
- .map(item => this.buildVariantTestInteraction(item, context))
1927
- .filter((item) => Boolean(item));
1928
- const purchaseAction = context.actionableItems.find(item => item.visible &&
1929
- !item.disabled &&
1930
- Boolean(item.selector) &&
1931
- (this.isAddToCartItem(item) || this.isCheckoutItem(item)));
1932
- for (const item of items) {
1933
- const purchaseJourney = this.buildVariantPurchaseJourney(item, purchaseAction, context);
1934
- if (purchaseJourney)
1935
- tests.push(purchaseJourney);
1936
- const requiredField = this.findRequiredVariantField(item, context.forms);
1937
- if (requiredField && purchaseAction) {
1938
- tests.push(this.buildRequiredVariantGuardTestInteraction(item, requiredField, purchaseAction, context));
1939
- }
1940
- }
1941
- return tests;
1942
- }
1943
- buildVariantTestInteraction(item, context) {
1944
- const plannedValue = this.extractSelectableValue(item);
1945
- if (!plannedValue || !item.selector)
1946
- return null;
1947
- const label = this.describeActionableItem(item);
1948
- return {
1949
- title: `Variant selection ${label}`,
1950
- type: "interaction",
1951
- sizeClass: context.sizeClass,
1952
- surface_tags: ["variant", "selection"],
1953
- priority: 2,
1954
- startingPageStateId: context.currentPageStateId,
1955
- startingPath: context.currentPath,
1956
- steps: [
1957
- {
1958
- action: {
1959
- actionType: PlaywrightAction.SelectOption,
1960
- path: item.selector,
1961
- value: plannedValue,
1962
- playwrightCode: `await page.locator('${item.selector}').selectOption('${this.escapeSingleQuotes(plannedValue)}')`,
1963
- description: `Select variant ${label}`,
1964
- },
1965
- expectations: [
1966
- this.makeExpectation(ExpectationType.InputValue, `Selecting ${label} should update the chosen variant option`, {
1967
- targetPath: item.selector,
1968
- expectedValue: plannedValue,
1969
- }),
1970
- this.makeExpectation(ExpectationType.VariantStateChanged, `Selecting ${label} should change product state`, {
1971
- targetPath: item.selector,
1972
- expectedValue: plannedValue,
1973
- }),
1974
- ],
1975
- description: `Select variant ${label}`,
1976
- continueOnFailure: false,
1977
- },
1978
- ],
1979
- globalExpectations: this.defaultFlowExpectations("Variant selection should complete without runtime errors"),
1980
- uid: context.uid,
1981
- generatedKey: this.buildGeneratedKey("variant-selection", context.currentPageStateId, item.selector, plannedValue),
1982
- };
1983
- }
1984
- buildVariantPurchaseJourney(item, purchaseAction, context) {
1985
- const plannedValue = this.extractSelectableValue(item);
1986
- if (!plannedValue || !item.selector || !purchaseAction?.selector) {
1987
- return null;
1988
- }
1989
- const label = this.describeActionableItem(item);
1990
- const purchaseLabel = this.describeActionableItem(purchaseAction);
1991
- const purchaseExpectations = this.isAddToCartItem(purchaseAction)
1992
- ? [
1993
- this.makeExpectation(ExpectationType.NetworkRequestMade, `${purchaseLabel} should trigger a backend mutation request after selecting ${label}`, {
1994
- expectedValue: "mutation",
1995
- timeoutMs: 3000,
1996
- }),
1997
- this.makeExpectation(ExpectationType.NoDuplicateMutationRequests, `${purchaseLabel} should not trigger duplicate mutation requests after selecting ${label}`),
1998
- this.makeExpectation(ExpectationType.CountChanged, `${purchaseLabel} should update a visible count after selecting ${label}`, {
1999
- expectedCountDelta: 1,
2000
- }),
2001
- this.makeExpectation(ExpectationType.CartSummaryChanged, `${purchaseLabel} should update cart summary after selecting ${label}`),
2002
- this.makeExpectation(ExpectationType.FeedbackVisible, `${purchaseLabel} should provide visible feedback after selecting ${label}`, {
2003
- expectedTextTokens: ["added", "success", "cart", "bag"],
2004
- forbiddenTextTokens: ["error", "failed"],
2005
- }),
2006
- ]
2007
- : [
2008
- this.makeExpectation(ExpectationType.NetworkRequestMade, `${purchaseLabel} should trigger a request or transition after selecting ${label}`, {
2009
- expectedValue: "ANY",
2010
- timeoutMs: 3000,
2011
- expectedTextTokens: ["checkout", "buy", "order"],
2012
- }),
2013
- this.makeExpectation(ExpectationType.NavigationOrStateChanged, `${purchaseLabel} should advance the purchase flow after selecting ${label}`),
2014
- ];
2015
- return {
2016
- title: `Variant purchase journey ${label}`,
2017
- type: "interaction",
2018
- sizeClass: context.sizeClass,
2019
- surface_tags: ["variant", "purchase"],
2020
- priority: 2,
2021
- startingPageStateId: context.currentPageStateId,
2022
- startingPath: context.currentPath,
2023
- steps: [
2024
- {
2025
- action: {
2026
- actionType: PlaywrightAction.SelectOption,
2027
- path: item.selector,
2028
- value: plannedValue,
2029
- playwrightCode: `await page.locator('${item.selector}').selectOption('${this.escapeSingleQuotes(plannedValue)}')`,
2030
- description: `Select variant ${label}`,
2031
- },
2032
- expectations: [
2033
- this.makeExpectation(ExpectationType.InputValue, `Selecting ${label} should update the chosen option`, {
2034
- targetPath: item.selector,
2035
- expectedValue: plannedValue,
2036
- }),
2037
- this.makeExpectation(ExpectationType.VariantStateChanged, `Selecting ${label} should update product state before purchase`, {
2038
- targetPath: item.selector,
2039
- expectedValue: plannedValue,
2040
- }),
2041
- ],
2042
- description: `Select variant ${label}`,
2043
- continueOnFailure: false,
2044
- },
2045
- this.buildJourneyAction(purchaseAction, `Purchase with selected ${label}`, [
2046
- ...purchaseExpectations,
2047
- this.makeExpectation(ExpectationType.LoadingCompletes, `${purchaseLabel} should complete loading after selecting ${label}`),
2048
- this.makeExpectation(ExpectationType.PageResponsive, `Page should remain responsive while completing ${purchaseLabel}`),
2049
- ]),
2050
- ],
2051
- globalExpectations: this.defaultFlowExpectations(`Variant purchase journey for ${label} should execute cleanly`),
2052
- uid: context.uid,
2053
- generatedKey: this.buildGeneratedKey("variant-purchase", context.currentPageStateId, item.selector, plannedValue, purchaseAction.selector),
2054
- };
2055
- }
2056
- buildRequiredVariantGuardTestInteraction(item, requiredField, purchaseAction, context) {
2057
- const label = this.describeActionableItem(item);
2058
- const purchaseLabel = this.describeActionableItem(purchaseAction);
2059
- return {
2060
- title: `Variant required guard ${label}`,
2061
- type: "interaction",
2062
- sizeClass: context.sizeClass,
2063
- surface_tags: ["variant", "validation", "guard"],
2064
- priority: 3,
2065
- startingPageStateId: context.currentPageStateId,
2066
- startingPath: context.currentPath,
2067
- steps: [
2068
- this.buildJourneyAction(purchaseAction, `Attempt ${purchaseLabel} without selecting ${label}`, [
2069
- this.makeExpectation(ExpectationType.RequiredErrorShownForField, `${label} should show required validation before ${purchaseLabel}`, {
2070
- targetPath: requiredField.selector,
2071
- }),
2072
- this.makeExpectation(ExpectationType.PageResponsive, `Page should remain responsive when ${purchaseLabel} is blocked by missing ${label}`),
2073
- ]),
2074
- ],
2075
- globalExpectations: this.defaultFlowExpectations(`${label} should be enforced before ${purchaseLabel}`),
2076
- uid: context.uid,
2077
- generatedKey: this.buildGeneratedKey("variant-guard", context.currentPageStateId, item.selector, requiredField.selector, purchaseAction.selector),
2078
- };
2079
- }
2080
- findRequiredVariantField(item, forms) {
2081
- for (const form of forms) {
2082
- const field = form.fields.find(field => field.selector === item.selector &&
2083
- field.required &&
2084
- this.isSearchField(field) === false);
2085
- if (field)
2086
- return field;
2087
- }
2088
- return undefined;
2089
- }
2090
- buildDisclosureToggleTestInteraction(item, startingPath, sizeClass, uid, startingPageStateId) {
2091
- const label = this.describeActionableItem(item);
2092
- const replaySelector = buildReplaySelectorFromActionableItem(item);
2093
- return {
2094
- title: `Toggle disclosure ${label}`,
2095
- type: "interaction",
2096
- sizeClass,
2097
- surface_tags: ["disclosure", "click"],
2098
- priority: 3,
2099
- startingPageStateId,
2100
- startingPath,
2101
- steps: [
2102
- {
2103
- action: {
2104
- actionType: PlaywrightAction.Click,
2105
- path: replaySelector,
2106
- playwrightCode: `await page.click('${replaySelector}')`,
2107
- description: `Toggle disclosure ${label}`,
2108
- },
2109
- expectations: [
2110
- this.makeExpectation("expanded_state_changed", "Clicking the disclosure should toggle its expanded state", {
2111
- targetPath: replaySelector,
2112
- }),
2113
- ],
2114
- description: `Toggle disclosure ${label}`,
2115
- continueOnFailure: false,
2116
- },
2117
- ],
2118
- globalExpectations: this.defaultFlowExpectations("Disclosure interaction should complete without runtime errors"),
2119
- uid,
2120
- generatedKey: this.buildGeneratedKey("disclosure-click", startingPageStateId, replaySelector),
2121
- };
2122
- }
2123
- buildKeyboardActivateTestInteraction(item, key, titlePrefix, expectations, startingPath, sizeClass, uid, startingPageStateId) {
2124
- const label = this.describeActionableItem(item);
2125
- const replaySelector = buildReplaySelectorFromActionableItem(item);
2126
- return {
2127
- title: `${titlePrefix} ${label}`,
2128
- type: "interaction",
2129
- sizeClass,
2130
- surface_tags: [
2131
- "keyboard",
2132
- key.trim() === "" ? "space" : key.toLowerCase(),
2133
- ],
2134
- priority: 3,
2135
- startingPageStateId,
2136
- startingPath,
2137
- steps: [
2138
- {
2139
- action: {
2140
- actionType: PlaywrightAction.Focus,
2141
- path: replaySelector,
2142
- playwrightCode: `await page.locator('${replaySelector}').focus()`,
2143
- description: `Focus ${label}`,
2144
- },
2145
- expectations: [
2146
- this.makeExpectation(ExpectationType.ElementFocused, `${label} should be keyboard-focusable`, {
2147
- targetPath: replaySelector,
2148
- }),
2149
- ],
2150
- description: `Focus ${label}`,
2151
- continueOnFailure: false,
2152
- },
2153
- {
2154
- action: {
2155
- actionType: PlaywrightAction.Press,
2156
- value: key,
2157
- playwrightCode: `await page.keyboard.press('${key === " " ? "Space" : key}')`,
2158
- description: `${titlePrefix} ${label}`,
2159
- },
2160
- expectations,
2161
- description: `${titlePrefix} ${label}`,
2162
- continueOnFailure: false,
2163
- },
2164
- ],
2165
- globalExpectations: this.defaultFlowExpectations("Keyboard activation should complete without runtime errors"),
2166
- uid,
2167
- generatedKey: this.buildGeneratedKey("keyboard", startingPageStateId, key === " " ? "space" : key, replaySelector),
2168
- };
2169
- }
2170
- buildDialogCloseTestInteraction(item, startingPath, sizeClass, uid, startingPageStateId) {
2171
- const label = this.describeActionableItem(item);
2172
- const replaySelector = buildReplaySelectorFromActionableItem(item);
2173
- return {
2174
- title: `Close dialog via ${label}`,
2175
- type: "interaction",
2176
- sizeClass,
2177
- surface_tags: ["dialog", "close"],
2178
- priority: 2,
2179
- startingPageStateId,
2180
- startingPath,
2181
- steps: [
2182
- {
2183
- action: {
2184
- actionType: PlaywrightAction.Click,
2185
- path: replaySelector,
2186
- playwrightCode: `await page.click('${replaySelector}')`,
2187
- description: `Close dialog via ${label}`,
2188
- },
2189
- expectations: [
2190
- this.makeExpectation("dialog_closed", "Close action should dismiss the open dialog"),
2191
- this.makeExpectation("focus_returned", "Focus should return after the dialog closes"),
2192
- ],
2193
- description: `Close dialog via ${label}`,
2194
- continueOnFailure: false,
2195
- },
2196
- ],
2197
- globalExpectations: this.defaultFlowExpectations("Dialog should close cleanly"),
2198
- uid,
2199
- generatedKey: this.buildGeneratedKey("dialog-close", startingPageStateId, replaySelector),
2200
- };
2201
- }
2202
- buildEscapeDialogTestInteraction(startingPath, sizeClass, uid, startingPageStateId) {
2203
- return {
2204
- title: "Close dialog with Escape",
2205
- type: "interaction",
2206
- sizeClass,
2207
- surface_tags: ["dialog", "escape"],
2208
- priority: 2,
2209
- startingPageStateId,
2210
- startingPath,
2211
- steps: [
2212
- {
2213
- action: {
2214
- actionType: PlaywrightAction.Press,
2215
- value: "Escape",
2216
- playwrightCode: "await page.keyboard.press('Escape')",
2217
- description: "Press Escape",
2218
- },
2219
- expectations: [
2220
- this.makeExpectation("dialog_closed", "Escape should dismiss the open dialog"),
2221
- this.makeExpectation("focus_returned", "Focus should return after Escape closes the dialog"),
2222
- ],
2223
- description: "Press Escape",
2224
- continueOnFailure: false,
2225
- },
2226
- ],
2227
- globalExpectations: this.defaultFlowExpectations("Dialog should close on Escape without runtime errors"),
2228
- uid,
2229
- generatedKey: this.buildGeneratedKey("dialog-escape", startingPageStateId, startingPath),
2230
- };
2231
- }
2232
- shouldUseDirectControlInteraction(item) {
2233
- const role = (item.role ?? "").toLowerCase();
2234
- const inputType = (item.inputType ?? "").toLowerCase();
2235
- return (this.looksVisuallyDisabledButEnabled(item) ||
2236
- item.actionKind === "fill" ||
2237
- item.actionKind === "select" ||
2238
- item.actionKind === "radio_select" ||
2239
- role === "tab" ||
2240
- role === "radio" ||
2241
- role === "checkbox" ||
2242
- role === "switch" ||
2243
- inputType === "radio" ||
2244
- inputType === "checkbox");
2245
- }
2246
- buildControlInteractionTestInteraction(item, startingPath, sizeClass, uid, startingPageStateId, dependencyTestInteractionId) {
2247
- const label = this.describeActionableItem(item);
2248
- const replaySelector = buildReplaySelectorFromActionableItem(item);
2249
- const role = (item.role ?? "").toLowerCase();
2250
- const inputType = (item.inputType ?? "").toLowerCase();
2251
- const isTab = role === "tab";
2252
- const isFillControl = item.actionKind === "fill";
2253
- const isSelectControl = item.actionKind === "select";
2254
- const isRadioControl = item.actionKind === "radio_select" ||
2255
- role === "radio" ||
2256
- inputType === "radio";
2257
- const isCheckboxControl = role === "checkbox" || role === "switch" || inputType === "checkbox";
2258
- const expectsChecked = isRadioControl || isCheckboxControl || isTab;
2259
- const expectsInputValue = isFillControl || isSelectControl;
2260
- const plannedValue = isSelectControl
2261
- ? this.extractSelectableValue(item)
2262
- : isFillControl
2263
- ? fillValuePlanner.planValue(item)
2264
- : undefined;
2265
- const isImmutable = this.looksVisuallyDisabledButEnabled(item);
2266
- let actionType = PlaywrightAction.Click;
2267
- let actionValue;
2268
- let playwrightCode = `await page.click('${replaySelector}')`;
2269
- let description = `${isTab ? "Select" : "Activate"} ${label}`;
2270
- if (isFillControl) {
2271
- actionType = PlaywrightAction.Type;
2272
- actionValue = plannedValue;
2273
- playwrightCode = `await page.locator('${replaySelector}').type('${this.escapeSingleQuotes(plannedValue ?? "")}')`;
2274
- description = `Type into ${label}`;
2275
- }
2276
- else if (isSelectControl) {
2277
- actionType = PlaywrightAction.SelectOption;
2278
- actionValue = plannedValue;
2279
- playwrightCode = `await page.locator('${replaySelector}').selectOption('${this.escapeSingleQuotes(plannedValue ?? "")}')`;
2280
- description = `Select ${label}`;
2281
- }
2282
- else if (isRadioControl) {
2283
- actionType = PlaywrightAction.Click;
2284
- playwrightCode = `await page.click('${replaySelector}')`;
2285
- description = `Select ${label}`;
2286
- }
2287
- const expectations = isImmutable
2288
- ? this.buildImmutableControlExpectations(item, label, replaySelector)
2289
- : expectsInputValue
2290
- ? [
2291
- this.makeExpectation(ExpectationType.InputValue, `${description} should update the control value`, {
2292
- targetPath: replaySelector,
2293
- expectedValue: plannedValue,
2294
- }),
2295
- ]
2296
- : expectsChecked
2297
- ? [
2298
- this.makeExpectation(ExpectationType.ElementChecked, `${label} should react to user input`, {
2299
- targetPath: replaySelector,
2300
- }),
2301
- ]
2302
- : [];
2303
- return {
2304
- title: isImmutable ? `Visually Disabled ${description}` : description,
2305
- type: "interaction",
2306
- sizeClass,
2307
- surface_tags: [
2308
- "interaction",
2309
- isImmutable ? "visually-disabled" : "enabled",
2310
- role || inputType || item.actionKind || "control",
2311
- ],
2312
- priority: 3,
2313
- dependencyTestInteractionId,
2314
- startingPageStateId,
2315
- startingPath,
2316
- steps: [
2317
- {
2318
- action: {
2319
- actionType,
2320
- path: replaySelector,
2321
- value: actionValue,
2322
- playwrightCode,
2323
- description,
2324
- },
2325
- expectations,
2326
- description,
2327
- continueOnFailure: isImmutable,
2328
- },
2329
- ],
2330
- globalExpectations: this.defaultFlowExpectations(isImmutable
2331
- ? `${label} should remain non-interactive despite appearing disabled`
2332
- : `${label} should react without runtime errors`),
2333
- uid,
2334
- generatedKey: this.buildGeneratedKey("control", startingPageStateId, dependencyTestInteractionId, isImmutable ? "immutable" : "enabled", role || inputType || item.actionKind || "control", replaySelector),
2335
- };
2336
- }
2337
- buildImmutableControlExpectations(item, label, replaySelector) {
2338
- const role = (item.role ?? "").toLowerCase();
2339
- const inputType = (item.inputType ?? "").toLowerCase();
2340
- if (item.actionKind === "fill" || item.actionKind === "select") {
2341
- return [
2342
- this.makeExpectation(ExpectationType.InputValue, `${label} should not respond to user input while it appears disabled`, {
2343
- targetPath: replaySelector,
2344
- expectNoChange: true,
2345
- }),
2346
- ];
2347
- }
2348
- if (role === "tab" ||
2349
- role === "radio" ||
2350
- role === "checkbox" ||
2351
- role === "switch" ||
2352
- inputType === "radio" ||
2353
- inputType === "checkbox" ||
2354
- item.actionKind === "radio_select") {
2355
- return [
2356
- this.makeExpectation(ExpectationType.ElementChecked, `${label} should not change state while it appears disabled`, {
2357
- targetPath: replaySelector,
2358
- expectNoChange: true,
2359
- }),
2360
- ];
2361
- }
2362
- return [
2363
- this.makeExpectation(ExpectationType.UrlUnchanged, `${label} should not trigger navigation while it appears disabled`, {
2364
- expectNoChange: true,
2365
- }),
2366
- ];
2367
- }
2368
- extractSelectableValue(item) {
2369
- const rawOptions = item.attributes?.options;
2370
- const parsedOptions = Array.isArray(rawOptions)
2371
- ? rawOptions
2372
- : typeof rawOptions === "string"
2373
- ? this.parseSelectOptions(rawOptions)
2374
- : [];
2375
- return parsedOptions.find((value) => typeof value === "string" && value.trim().length > 0);
2376
- }
2377
- parseSelectOptions(rawOptions) {
2378
- try {
2379
- const parsed = JSON.parse(rawOptions);
2380
- return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
2381
- }
2382
- catch (err) {
2383
- logAnalyzer("parse-select-options:json-fallback", {
2384
- error: err instanceof Error ? err.message : String(err),
2385
- });
2386
- return rawOptions
2387
- .split(",")
2388
- .map(value => value.trim())
2389
- .filter(Boolean);
2390
- }
2391
- }
2392
- looksVisuallyDisabledButEnabled(item) {
2393
- if (item.disabled)
2394
- return false;
2395
- const attrs = Object.entries(item.attributes ?? {})
2396
- .map(([key, value]) => `${key}=${String(value)}`)
2397
- .join(" ")
2398
- .toLowerCase();
2399
- return this.hasDisabledAppearanceSignal(attrs);
2400
- }
2401
- looksVisuallyDisabledField(field) {
2402
- if (field.disabled)
2403
- return false;
2404
- return this.hasDisabledAppearanceSignal((field.appearanceHint ?? "").toLowerCase());
2405
- }
2406
- hasDisabledAppearanceSignal(value) {
2407
- if (!value)
2408
- return false;
2409
- return (value.includes("aria-disabled=true") ||
2410
- value.includes("cursor-not-allowed") ||
2411
- value.includes("pointer-events:none") ||
2412
- value.includes("pointer-events: none") ||
2413
- value.includes("opacity-50") ||
2414
- value.includes("opacity:0.5") ||
2415
- value.includes("opacity: 0.5") ||
2416
- value.includes("opacity-40") ||
2417
- value.includes("disabled"));
2418
- }
2419
- describeActionableItem(item) {
2420
- const described = (item.accessibleName ||
2421
- item.textContent ||
2422
- String(item.attributes?._containerTitle ?? "") ||
2423
- String(item.attributes?.labelText ?? "") ||
2424
- String(item.attributes?.placeholder ?? "")).trim();
2425
- if (described)
2426
- return described;
2427
- const selector = item.selector ?? "";
2428
- if (selector.includes("data-tmnc-id")) {
2429
- const role = item.role?.trim();
2430
- const inputType = item.inputType?.trim();
2431
- if (role)
2432
- return role;
2433
- if (inputType)
2434
- return inputType;
2435
- if (item.actionKind === "navigate")
2436
- return "link";
2437
- if (item.actionKind === "fill")
2438
- return "input";
2439
- if (item.actionKind === "select")
2440
- return "select";
2441
- return "control";
2442
- }
2443
- return selector || item.actionKind || "control";
2444
- }
2445
- semanticText(item) {
2446
- return [
2447
- item.accessibleName || "",
2448
- item.textContent || "",
2449
- String(item.attributes?.labelText ?? ""),
2450
- String(item.attributes?.placeholder ?? ""),
2451
- String(item.attributes?.name ?? ""),
2452
- String(item.attributes?.id ?? ""),
2453
- String(item.attributes?._containerTitle ?? ""),
2454
- String(item.attributes?._containerCtaStyle ?? ""),
2455
- item.href || "",
2456
- item.selector,
2457
- ]
2458
- .join(" ")
2459
- .toLowerCase();
2460
- }
2461
- /**
2462
- * Max representatives per action style. Product grids can have dozens of
2463
- * cards each producing a unique container fingerprint (because the product
2464
- * title differs), but the CTA buttons ("ADD TO CART", "Select Options") are
2465
- * functionally identical. Capping per style prevents 14× duplicate hover
2466
- * tests for the same button type across different cards.
2467
- */
2468
- static MAX_REPS_PER_STYLE = 2;
2469
- selectRepresentativeItems(items, maxPerStyle = PageAnalyzer.MAX_REPS_PER_STYLE) {
2470
- const groups = new Map();
2471
- const passthrough = [];
2472
- for (const item of items) {
2473
- const containerFingerprint = String(item.attributes?._containerFingerprint ?? "").trim();
2474
- if (!containerFingerprint) {
2475
- passthrough.push(item);
2476
- continue;
2477
- }
2478
- const key = `${containerFingerprint}|${this.representativeActionStyle(item)}`;
2479
- const bucket = groups.get(key) ?? [];
2480
- bucket.push(item);
2481
- groups.set(key, bucket);
2482
- }
2483
- const representatives = Array.from(groups.values()).map(group => this.pickRepresentativeItem(group));
2484
- // Cap per action style: when many different containers share the same
2485
- // functional action (e.g. 14 product cards each with "ADD TO CART"),
2486
- // keep at most maxPerStyle representatives per style.
2487
- // Include passthrough items in the cap — items without a container
2488
- // fingerprint (e.g. product links in a grid that wasn't detected as a
2489
- // repeated container) should still be deduplicated by functional style.
2490
- const allCandidates = [...passthrough, ...representatives];
2491
- const byStyle = new Map();
2492
- for (const rep of allCandidates) {
2493
- const style = this.representativeActionStyle(rep);
2494
- const bucket = byStyle.get(style) ?? [];
2495
- bucket.push(rep);
2496
- byStyle.set(style, bucket);
2497
- }
2498
- return Array.from(byStyle.values()).flatMap(group => group.length <= maxPerStyle ? group : group.slice(0, maxPerStyle));
2499
- }
2500
- representativeActionStyle(item) {
2501
- const text = this.semanticText(item);
2502
- const sourceHints = String(item.attributes?._sourceHints ?? "").toLowerCase();
2503
- const containerTitle = String(item.attributes?._containerTitle ?? "").toLowerCase();
2504
- if (sourceHints.includes("promoted-target") ||
2505
- sourceHints.includes("cursor-pointer")) {
2506
- return "tile-click";
2507
- }
2508
- if (item.actionKind === "navigate" &&
2509
- containerTitle &&
2510
- (text.includes(containerTitle) ||
2511
- (item.href && !this.isCheckoutItem(item)))) {
2512
- return "tile-click";
2513
- }
2514
- if (this.isAddToCartItem(item))
2515
- return "cta:add-to-cart";
2516
- if (/\blogin for pricing\b/.test(text))
2517
- return "cta:login-for-pricing";
2518
- if (/\bselect options\b/.test(text))
2519
- return "cta:select-options";
2520
- if (this.isCheckoutItem(item))
2521
- return "cta:checkout";
2522
- if (this.isRemoveItemAction(item))
2523
- return "cta:remove";
2524
- if (item.actionKind === "navigate")
2525
- return "navigate";
2526
- if (item.actionKind === "select")
2527
- return "select";
2528
- if (item.actionKind === "fill")
2529
- return "fill";
2530
- return `${item.actionKind}:${text
2531
- .replace(/\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b/g, "email")
2532
- .replace(/\$\s?\d[\d,.]*/g, "price")
2533
- .replace(/\d+/g, "n")
2534
- .slice(0, 80)}`;
344
+ if (item.actionKind === "navigate" &&
345
+ containerTitle &&
346
+ (text.includes(containerTitle) ||
347
+ (item.href && !this.isCheckoutItem(item)))) {
348
+ return "tile-click";
349
+ }
350
+ if (this.isAddToCartItem(item))
351
+ return "cta:add-to-cart";
352
+ if (/\blogin for pricing\b/.test(text))
353
+ return "cta:login-for-pricing";
354
+ if (/\bselect options\b/.test(text))
355
+ return "cta:select-options";
356
+ if (this.isCheckoutItem(item))
357
+ return "cta:checkout";
358
+ if (this.isRemoveItemAction(item))
359
+ return "cta:remove";
360
+ if (item.actionKind === "navigate")
361
+ return "navigate";
362
+ if (item.actionKind === "select")
363
+ return "select";
364
+ if (item.actionKind === "fill")
365
+ return "fill";
366
+ return `${item.actionKind}:${text
367
+ .replace(/\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b/g, "email")
368
+ .replace(/\$\s?\d[\d,.]*/g, "price")
369
+ .replace(/\d+/g, "n")
370
+ .slice(0, 80)}`;
2535
371
  }
2536
372
  pickRepresentativeItem(items) {
2537
373
  return [...items].sort((left, right) => {
@@ -2568,260 +404,5 @@ export class PageAnalyzer {
2568
404
  isRemoveItemAction(item) {
2569
405
  return /\b(remove|delete|trash|clear item|remove item|dismiss|archive|hide item|close item)\b/.test(this.semanticText(item));
2570
406
  }
2571
- estimateCollectionCount(html) {
2572
- const tableRows = (html.match(/<tr\b/gi) ?? []).length;
2573
- if (tableRows > 0)
2574
- return tableRows;
2575
- const listItems = (html.match(/<li\b/gi) ?? []).length;
2576
- if (listItems > 0)
2577
- return listItems;
2578
- const cards = (html.match(/<(?:article|section|div)\b[^>]*(?:product|card|result|item|row)[^>]*>/gi) ?? []).length;
2579
- return cards;
2580
- }
2581
- isAuthEntryItem(item) {
2582
- return /\b(sign up|register|create account|sign in|log in|login)\b/.test(this.semanticText(item));
2583
- }
2584
- isProtectedActionItem(item) {
2585
- return /\b(checkout|pricing|view price|account|settings|billing|subscription|dashboard|admin|manage account|saved items|favorites|download)\b/.test(this.semanticText(item));
2586
- }
2587
- isLogoutAction(item) {
2588
- return /\b(log out|logout|sign out)\b/.test(this.semanticText(item));
2589
- }
2590
- isRetryAction(item) {
2591
- return /\b(retry|try again|resend|send again|reload|refresh|reconnect|resume)\b/.test(this.semanticText(item));
2592
- }
2593
- isCreateCollectionAction(item) {
2594
- const text = this.semanticText(item);
2595
- if (this.isAddToCartItem(item))
2596
- return false;
2597
- return /\b(add row|add record|add item|new row|new record|create item|create record|add another|new item)\b/.test(text);
2598
- }
2599
- isMediaOpenItem(item) {
2600
- const text = this.semanticText(item);
2601
- return (item.tagName === "VIDEO" ||
2602
- item.tagName === "AUDIO" ||
2603
- /\b(image|photo|gallery|video|play|watch|zoom|preview)\b/.test(text));
2604
- }
2605
- isVideoLikeItem(item) {
2606
- return (item.tagName === "VIDEO" ||
2607
- /\b(video|play|watch)\b/.test(this.semanticText(item)));
2608
- }
2609
- isQuantityAction(item) {
2610
- return /\b(qty|quantity|increase|decrease|increment|decrement|plus|minus)\b/.test(this.semanticText(item));
2611
- }
2612
- inferQuantityDelta(item) {
2613
- const text = this.semanticText(item);
2614
- if (/\b(decrease|decrement|minus|remove one|lower qty|lower quantity)\b/.test(text)) {
2615
- return -1;
2616
- }
2617
- if (/\b(increase|increment|plus|add one|raise qty|raise quantity)\b/.test(text)) {
2618
- return 1;
2619
- }
2620
- return undefined;
2621
- }
2622
- isFilterAction(item) {
2623
- return /\b(filter|refine|apply filter|show results|category|brand|size|color|price)\b/.test(this.semanticText(item));
2624
- }
2625
- isSearchClearItem(item) {
2626
- return /\b(clear search|clear results|reset search|reset filters|clear|reset)\b/.test(this.semanticText(item));
2627
- }
2628
- isSortAction(item) {
2629
- return /\b(sort|order by|best selling|price low|price high|newest|featured)\b/.test(this.semanticText(item));
2630
- }
2631
- isPaginationAction(item) {
2632
- return /\b(next|previous|prev|page \d+|load more|show more|older|newer)\b/.test(this.semanticText(item));
2633
- }
2634
- isVariantSelector(item) {
2635
- if (item.actionKind !== "select") {
2636
- return false;
2637
- }
2638
- return /\b(variant|option|size|color|colour|style|material|finish|width|length|currency|language|locale|region|country|sort|order|per\s*page|show|display|view)\b/.test(this.semanticText(item));
2639
- }
2640
- isDialogCloseItem(item) {
2641
- return /\b(close|dismiss|cancel|done|x)\b/.test(this.semanticText(item));
2642
- }
2643
- pageHasOpenDialog(html) {
2644
- return (/role=["']dialog["']/i.test(html) ||
2645
- /role=["']alertdialog["']/i.test(html) ||
2646
- /aria-modal=["']true["']/i.test(html) ||
2647
- /\bmodal\b/i.test(html) ||
2648
- /\boverlay\b/i.test(html));
2649
- }
2650
- isDisclosureItem(item) {
2651
- const expanded = String(item.attributes?.["aria-expanded"] ?? "").toLowerCase();
2652
- return expanded === "true" || expanded === "false";
2653
- }
2654
- isKeyboardPrimaryAction(item) {
2655
- const role = (item.role ?? "").toLowerCase();
2656
- return (item.actionKind === "navigate" ||
2657
- role === "button" ||
2658
- role === "link" ||
2659
- role === "menuitem" ||
2660
- item.tagName === "BUTTON" ||
2661
- item.tagName === "A");
2662
- }
2663
- isKeyboardToggleAction(item) {
2664
- const role = (item.role ?? "").toLowerCase();
2665
- const inputType = (item.inputType ?? "").toLowerCase();
2666
- return (role === "checkbox" ||
2667
- role === "switch" ||
2668
- role === "radio" ||
2669
- inputType === "checkbox" ||
2670
- inputType === "radio");
2671
- }
2672
- buildSemanticAction(item, description, signal) {
2673
- if (item.actionKind === "select") {
2674
- const value = this.extractSelectableValue(item);
2675
- return {
2676
- actionType: PlaywrightAction.SelectOption,
2677
- path: item.selector,
2678
- value,
2679
- playwrightCode: `await page.locator('${item.selector}').selectOption('${this.escapeSingleQuotes(value ?? "")}')`,
2680
- description: `${description}: ${signal}`,
2681
- };
2682
- }
2683
- if (item.actionKind === "fill") {
2684
- const value = this.isQuantityAction(item)
2685
- ? "2"
2686
- : fillValuePlanner.planValue(item);
2687
- return {
2688
- actionType: PlaywrightAction.Type,
2689
- path: item.selector,
2690
- value,
2691
- playwrightCode: `await page.locator('${item.selector}').type('${this.escapeSingleQuotes(value)}')`,
2692
- description: `${description}: ${signal}`,
2693
- };
2694
- }
2695
- return {
2696
- actionType: PlaywrightAction.Click,
2697
- path: item.selector,
2698
- playwrightCode: `await page.click('${item.selector}')`,
2699
- description: `${description}: ${signal}`,
2700
- };
2701
- }
2702
- makeExpectation(expectationType, description, extras) {
2703
- return {
2704
- expectationType,
2705
- severity: ExpectationSeverity.ShouldPass,
2706
- description,
2707
- playwrightCode: "// evaluated by TesterExpertise",
2708
- ...(extras ?? {}),
2709
- };
2710
- }
2711
- isSelectLike(field) {
2712
- return field.type === "select" || field.type === "select-one";
2713
- }
2714
- isSkippableFieldType(field) {
2715
- return ["hidden", "submit", "button", "image", "file"].includes(field.type);
2716
- }
2717
- fieldLabel(field) {
2718
- return field.label || field.name || field.selector;
2719
- }
2720
- improbableSearchQuery() {
2721
- return "zzzz-no-results-testomniac";
2722
- }
2723
- slugify(value) {
2724
- return (value
2725
- .toLowerCase()
2726
- .replace(/[^a-z0-9]+/g, "-")
2727
- .replace(/^-+|-+$/g, "") || "page");
2728
- }
2729
- escapeSingleQuotes(value) {
2730
- return value.replace(/'/g, "\\'");
2731
- }
2732
- extractVisibleText(html) {
2733
- return html
2734
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
2735
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
2736
- .replace(/<[^>]+>/g, " ")
2737
- .replace(/\s+/g, " ")
2738
- .trim();
2739
- }
2740
- detectPasswordRequirements(visibleText) {
2741
- const lower = visibleText.toLowerCase();
2742
- const requirements = {
2743
- requiresUppercase: false,
2744
- requiresLowercase: false,
2745
- requiresNumber: false,
2746
- requiresSpecial: false,
2747
- noSpaces: false,
2748
- };
2749
- const lengthMatch = lower.match(/(?:at least|minimum|min\.?)\s*(\d+)\s*character/i) ||
2750
- lower.match(/(\d+)\+?\s*character/i);
2751
- if (lengthMatch) {
2752
- requirements.minLength = Number.parseInt(lengthMatch[1], 10);
2753
- }
2754
- if (/uppercase|capital letter/i.test(lower))
2755
- requirements.requiresUppercase = true;
2756
- if (/lowercase/i.test(lower))
2757
- requirements.requiresLowercase = true;
2758
- if (/number|digit|\d/i.test(lower) &&
2759
- /must|require|contain|include/i.test(lower)) {
2760
- requirements.requiresNumber = true;
2761
- }
2762
- if (/special character|symbol|[!@#$%^&*]/i.test(lower) &&
2763
- /must|require|contain|include/i.test(lower)) {
2764
- requirements.requiresSpecial = true;
2765
- }
2766
- if (/no\s*spaces/i.test(lower))
2767
- requirements.noSpaces = true;
2768
- return requirements;
2769
- }
2770
- generatePasswordVariants(requirements) {
2771
- const variants = [];
2772
- const minimumLength = Math.max(requirements.minLength ?? 8, 8);
2773
- let validPassword = "Aa1!";
2774
- while (validPassword.length < minimumLength) {
2775
- validPassword += "xY2@".charAt(validPassword.length % 4);
2776
- }
2777
- if (requirements.minLength) {
2778
- variants.push({
2779
- password: validPassword.slice(0, Math.max(requirements.minLength - 1, 1)),
2780
- description: "too short password",
2781
- shouldFail: true,
2782
- });
2783
- }
2784
- if (requirements.requiresUppercase) {
2785
- variants.push({
2786
- password: validPassword.toLowerCase(),
2787
- description: "missing uppercase password",
2788
- shouldFail: true,
2789
- });
2790
- }
2791
- if (requirements.requiresLowercase) {
2792
- variants.push({
2793
- password: validPassword.toUpperCase(),
2794
- description: "missing lowercase password",
2795
- shouldFail: true,
2796
- });
2797
- }
2798
- if (requirements.requiresNumber) {
2799
- variants.push({
2800
- password: validPassword.replace(/\d/g, "a"),
2801
- description: "missing number password",
2802
- shouldFail: true,
2803
- });
2804
- }
2805
- if (requirements.requiresSpecial) {
2806
- variants.push({
2807
- password: validPassword.replace(/[^a-zA-Z0-9]/g, "a"),
2808
- description: "missing special character password",
2809
- shouldFail: true,
2810
- });
2811
- }
2812
- if (requirements.noSpaces) {
2813
- variants.push({
2814
- password: `${validPassword.slice(0, 4)} ${validPassword.slice(4)}`,
2815
- description: "password with spaces",
2816
- shouldFail: true,
2817
- });
2818
- }
2819
- variants.push({
2820
- password: validPassword,
2821
- description: "valid password",
2822
- shouldFail: false,
2823
- });
2824
- return variants;
2825
- }
2826
407
  }
2827
408
  //# sourceMappingURL=index.js.map