@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.
- package/CLAUDE.md +35 -24
- package/dist/analyzer/page-analyzer/index.d.ts +4 -154
- package/dist/analyzer/page-analyzer/index.d.ts.map +1 -1
- package/dist/analyzer/page-analyzer/index.js +112 -2531
- package/dist/analyzer/page-analyzer/index.js.map +1 -1
- package/dist/analyzer/page-analyzer/types.d.ts +0 -1
- package/dist/analyzer/page-analyzer/types.d.ts.map +1 -1
- package/dist/api/client.d.ts +4 -6
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +6 -19
- package/dist/api/client.js.map +1 -1
- package/dist/index.d.ts +0 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/login-manager.d.ts +2 -11
- package/dist/orchestrator/login-manager.d.ts.map +1 -1
- package/dist/orchestrator/login-manager.js.map +1 -1
- package/dist/orchestrator/runner.d.ts.map +1 -1
- package/dist/orchestrator/runner.js +34 -28
- package/dist/orchestrator/runner.js.map +1 -1
- package/dist/orchestrator/test-interaction-executor.d.ts.map +1 -1
- package/dist/orchestrator/test-interaction-executor.js +82 -97
- package/dist/orchestrator/test-interaction-executor.js.map +1 -1
- package/dist/scanner/login-detector.d.ts +2 -14
- package/dist/scanner/login-detector.d.ts.map +1 -1
- package/dist/scanner/login-detector.js.map +1 -1
- package/package.json +4 -18
- package/dist/ai/analyzer.d.ts +0 -17
- package/dist/ai/analyzer.d.ts.map +0 -1
- package/dist/ai/analyzer.js +0 -55
- package/dist/ai/analyzer.js.map +0 -1
- package/dist/ai/input-generator.d.ts +0 -9
- package/dist/ai/input-generator.d.ts.map +0 -1
- package/dist/ai/input-generator.js +0 -20
- package/dist/ai/input-generator.js.map +0 -1
- package/dist/ai/persona-generator.d.ts +0 -7
- package/dist/ai/persona-generator.d.ts.map +0 -1
- package/dist/ai/persona-generator.js +0 -23
- package/dist/ai/persona-generator.js.map +0 -1
- package/dist/ai/use-case-generator.d.ts +0 -7
- package/dist/ai/use-case-generator.d.ts.map +0 -1
- package/dist/ai/use-case-generator.js +0 -23
- package/dist/ai/use-case-generator.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/content.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/content.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/content.js +0 -63
- package/dist/analyzer/page-analyzer/generators/content.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/dialogs.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/dialogs.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/dialogs.js +0 -46
- package/dist/analyzer/page-analyzer/generators/dialogs.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/e2e.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/e2e.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/e2e.js +0 -37
- package/dist/analyzer/page-analyzer/generators/e2e.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/forms.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/forms.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/forms.js +0 -95
- package/dist/analyzer/page-analyzer/generators/forms.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/hover-follow-up.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/hover-follow-up.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/hover-follow-up.js +0 -140
- package/dist/analyzer/page-analyzer/generators/hover-follow-up.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.js +0 -61
- package/dist/analyzer/page-analyzer/generators/keyboard-disclosure.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/login.d.ts +0 -15
- package/dist/analyzer/page-analyzer/generators/login.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/login.js +0 -218
- package/dist/analyzer/page-analyzer/generators/login.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/navigation.d.ts +0 -15
- package/dist/analyzer/page-analyzer/generators/navigation.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/navigation.js +0 -165
- package/dist/analyzer/page-analyzer/generators/navigation.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/render.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/render.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/render.js +0 -31
- package/dist/analyzer/page-analyzer/generators/render.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/scaffolds.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/scaffolds.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/scaffolds.js +0 -64
- package/dist/analyzer/page-analyzer/generators/scaffolds.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/semantic-journeys.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/semantic-journeys.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/semantic-journeys.js +0 -37
- package/dist/analyzer/page-analyzer/generators/semantic-journeys.js.map +0 -1
- package/dist/analyzer/page-analyzer/generators/variants.d.ts +0 -4
- package/dist/analyzer/page-analyzer/generators/variants.d.ts.map +0 -1
- package/dist/analyzer/page-analyzer/generators/variants.js +0 -60
- 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
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
.
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
|
334
|
+
return Array.from(byStyle.values()).flatMap(group => group.length <= maxPerStyle ? group : group.slice(0, maxPerStyle));
|
|
834
335
|
}
|
|
835
|
-
|
|
836
|
-
const
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|