@sudobility/testomniac_runner_service 0.1.45 → 0.1.47
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/dist/analyzer/page-analyzer.d.ts +78 -1
- package/dist/analyzer/page-analyzer.d.ts.map +1 -1
- package/dist/analyzer/page-analyzer.js +1600 -6
- package/dist/analyzer/page-analyzer.js.map +1 -1
- package/dist/browser/control-snapshot.d.ts +4 -0
- package/dist/browser/control-snapshot.d.ts.map +1 -0
- package/dist/browser/control-snapshot.js +181 -0
- package/dist/browser/control-snapshot.js.map +1 -0
- package/dist/browser/dom-snapshot.d.ts.map +1 -1
- package/dist/browser/dom-snapshot.js +8 -0
- package/dist/browser/dom-snapshot.js.map +1 -1
- package/dist/browser/ui-snapshot.d.ts +9 -0
- package/dist/browser/ui-snapshot.d.ts.map +1 -0
- package/dist/browser/ui-snapshot.js +53 -0
- package/dist/browser/ui-snapshot.js.map +1 -0
- package/dist/expertise/content-expertise.d.ts +1 -0
- package/dist/expertise/content-expertise.d.ts.map +1 -1
- package/dist/expertise/content-expertise.js +38 -0
- package/dist/expertise/content-expertise.js.map +1 -1
- package/dist/expertise/tester/commerce-checks.d.ts +14 -0
- package/dist/expertise/tester/commerce-checks.d.ts.map +1 -0
- package/dist/expertise/tester/commerce-checks.js +132 -0
- package/dist/expertise/tester/commerce-checks.js.map +1 -0
- package/dist/expertise/tester/control-state.d.ts +31 -0
- package/dist/expertise/tester/control-state.d.ts.map +1 -0
- package/dist/expertise/tester/control-state.js +46 -0
- package/dist/expertise/tester/control-state.js.map +1 -0
- package/dist/expertise/tester/core-checks.d.ts +5 -0
- package/dist/expertise/tester/core-checks.d.ts.map +1 -0
- package/dist/expertise/tester/core-checks.js +46 -0
- package/dist/expertise/tester/core-checks.js.map +1 -0
- package/dist/expertise/tester/dialog-feedback-checks.d.ts +13 -0
- package/dist/expertise/tester/dialog-feedback-checks.d.ts.map +1 -0
- package/dist/expertise/tester/dialog-feedback-checks.js +82 -0
- package/dist/expertise/tester/dialog-feedback-checks.js.map +1 -0
- package/dist/expertise/tester/form-checks.d.ts +8 -0
- package/dist/expertise/tester/form-checks.d.ts.map +1 -0
- package/dist/expertise/tester/form-checks.js +50 -0
- package/dist/expertise/tester/form-checks.js.map +1 -0
- package/dist/expertise/tester/keyboard-disclosure-checks.d.ts +7 -0
- package/dist/expertise/tester/keyboard-disclosure-checks.d.ts.map +1 -0
- package/dist/expertise/tester/keyboard-disclosure-checks.js +46 -0
- package/dist/expertise/tester/keyboard-disclosure-checks.js.map +1 -0
- package/dist/expertise/tester/navigation-checks.d.ts +8 -0
- package/dist/expertise/tester/navigation-checks.d.ts.map +1 -0
- package/dist/expertise/tester/navigation-checks.js +64 -0
- package/dist/expertise/tester/navigation-checks.js.map +1 -0
- package/dist/expertise/tester/network-intent-checks.d.ts +7 -0
- package/dist/expertise/tester/network-intent-checks.d.ts.map +1 -0
- package/dist/expertise/tester/network-intent-checks.js +56 -0
- package/dist/expertise/tester/network-intent-checks.js.map +1 -0
- package/dist/expertise/tester/page-behavior-checks.d.ts +17 -0
- package/dist/expertise/tester/page-behavior-checks.d.ts.map +1 -0
- package/dist/expertise/tester/page-behavior-checks.js +144 -0
- package/dist/expertise/tester/page-behavior-checks.js.map +1 -0
- package/dist/expertise/tester/persistence-checks.d.ts +12 -0
- package/dist/expertise/tester/persistence-checks.d.ts.map +1 -0
- package/dist/expertise/tester/persistence-checks.js +109 -0
- package/dist/expertise/tester/persistence-checks.js.map +1 -0
- package/dist/expertise/tester/search-checks.d.ts +10 -0
- package/dist/expertise/tester/search-checks.d.ts.map +1 -0
- package/dist/expertise/tester/search-checks.js +111 -0
- package/dist/expertise/tester/search-checks.js.map +1 -0
- package/dist/expertise/tester/selection-control-checks.d.ts +7 -0
- package/dist/expertise/tester/selection-control-checks.d.ts.map +1 -0
- package/dist/expertise/tester/selection-control-checks.js +128 -0
- package/dist/expertise/tester/selection-control-checks.js.map +1 -0
- package/dist/expertise/tester/text-input-checks.d.ts +8 -0
- package/dist/expertise/tester/text-input-checks.d.ts.map +1 -0
- package/dist/expertise/tester/text-input-checks.js +194 -0
- package/dist/expertise/tester/text-input-checks.js.map +1 -0
- package/dist/expertise/tester/validation-checks.d.ts +10 -0
- package/dist/expertise/tester/validation-checks.d.ts.map +1 -0
- package/dist/expertise/tester/validation-checks.js +59 -0
- package/dist/expertise/tester/validation-checks.js.map +1 -0
- package/dist/expertise/tester-expertise.d.ts +0 -3
- package/dist/expertise/tester-expertise.d.ts.map +1 -1
- package/dist/expertise/tester-expertise.js +75 -49
- package/dist/expertise/tester-expertise.js.map +1 -1
- package/dist/expertise/types.d.ts +12 -1
- package/dist/expertise/types.d.ts.map +1 -1
- package/dist/extractors/form-extractor.d.ts +19 -0
- package/dist/extractors/form-extractor.d.ts.map +1 -1
- package/dist/extractors/form-extractor.js +271 -42
- package/dist/extractors/form-extractor.js.map +1 -1
- package/dist/orchestrator/test-element-executor.d.ts.map +1 -1
- package/dist/orchestrator/test-element-executor.js +74 -9
- package/dist/orchestrator/test-element-executor.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { PlaywrightAction, ExpectationType, ExpectationSeverity, } from "@sudobility/testomniac_types";
|
|
2
2
|
import { computeHashes } from "../browser/page-utils";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { fillValuePlanner } from "../planners/fill-value-planner";
|
|
5
|
+
import { AUTH_URL_PATTERNS, SIGNUP_URL_PATTERNS } from "../config/constants";
|
|
4
6
|
/**
|
|
5
7
|
* PageAnalyzer generates expectations and discovers new test elements
|
|
6
8
|
* during discovery mode.
|
|
@@ -54,10 +56,22 @@ export class PageAnalyzer {
|
|
|
54
56
|
}
|
|
55
57
|
// a. Navigation test elements — for every link on the page
|
|
56
58
|
await this.generateNavigationTestElements(resolvedContext);
|
|
57
|
-
// b.
|
|
59
|
+
// b. Render test elements — capture render coverage for the page
|
|
60
|
+
await this.generateRenderTestElements(resolvedContext);
|
|
61
|
+
// c. Form and password test elements — build form workflows from extracted forms
|
|
62
|
+
await this.generateFormTestElements(resolvedContext);
|
|
63
|
+
// d. Synthetic journey test elements — build generic business flows from common UI verbs
|
|
64
|
+
await this.generateSemanticJourneyTestElements(resolvedContext);
|
|
65
|
+
// e. Journey test elements — preserve discovered multi-step flows as standalone e2e tests
|
|
66
|
+
await this.generateE2ETestElements(resolvedContext);
|
|
67
|
+
// f. Dialog lifecycle cases — only when a dialog is already open in the analyzed state
|
|
68
|
+
await this.generateDialogLifecycleTestElements(resolvedContext);
|
|
69
|
+
// g. Scaffold test elements — for each scaffold's actionable elements
|
|
58
70
|
await this.generateScaffoldTestElements(resolvedContext);
|
|
59
|
-
//
|
|
71
|
+
// h. Content test elements — for non-scaffold actionable elements
|
|
60
72
|
await this.generateContentTestElements(resolvedContext);
|
|
73
|
+
// i. Keyboard/disclosure cases
|
|
74
|
+
await this.generateKeyboardAndDisclosureTestElements(resolvedContext);
|
|
61
75
|
}
|
|
62
76
|
async generateHoverFollowUpCases(testElement, context) {
|
|
63
77
|
const selector = this.getPrimarySelector(testElement);
|
|
@@ -119,7 +133,7 @@ export class PageAnalyzer {
|
|
|
119
133
|
const { api, runnerId, sizeClass, uid, bundleRun } = context;
|
|
120
134
|
for (const scaffold of context.scaffolds) {
|
|
121
135
|
// Find actionable items belonging to this scaffold
|
|
122
|
-
const scaffoldItems = context.actionableItems.filter(item => item.scaffoldId != null && this.
|
|
136
|
+
const scaffoldItems = context.actionableItems.filter(item => item.scaffoldId != null && this.isSurfaceCandidate(item));
|
|
123
137
|
if (scaffoldItems.length === 0)
|
|
124
138
|
continue;
|
|
125
139
|
// Ensure a test surface for this scaffold
|
|
@@ -141,7 +155,9 @@ export class PageAnalyzer {
|
|
|
141
155
|
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
142
156
|
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
143
157
|
for (const item of scaffoldItems) {
|
|
144
|
-
const testElement = this.
|
|
158
|
+
const testElement = this.shouldUseDirectControlInteraction(item)
|
|
159
|
+
? this.buildControlInteractionTestElement(item, context.currentPath, sizeClass, uid, context.currentPageStateId)
|
|
160
|
+
: this.buildHoverTestElement(item, context.currentPath, sizeClass, uid, context.currentPageStateId);
|
|
145
161
|
const tc = await api.ensureTestElement(runnerId, surface.id, testElement);
|
|
146
162
|
await api.createTestElementRun({
|
|
147
163
|
testElementId: tc.id,
|
|
@@ -150,10 +166,225 @@ export class PageAnalyzer {
|
|
|
150
166
|
}
|
|
151
167
|
}
|
|
152
168
|
}
|
|
169
|
+
async generateRenderTestElements(context) {
|
|
170
|
+
const { api, runnerId, sizeClass, uid, bundleRun } = context;
|
|
171
|
+
const surface = await api.ensureTestSurface(runnerId, {
|
|
172
|
+
title: `Render: ${context.currentPath}`,
|
|
173
|
+
description: `Render validation for ${context.currentPath}`,
|
|
174
|
+
startingPageStateId: context.currentPageStateId,
|
|
175
|
+
startingPath: context.currentPath,
|
|
176
|
+
sizeClass,
|
|
177
|
+
priority: 2,
|
|
178
|
+
surface_tags: ["render"],
|
|
179
|
+
uid,
|
|
180
|
+
});
|
|
181
|
+
context.events.onTestSurfaceCreated({
|
|
182
|
+
surfaceId: surface.id,
|
|
183
|
+
title: surface.title,
|
|
184
|
+
});
|
|
185
|
+
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
186
|
+
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
187
|
+
const testElement = this.buildRenderTestElement(context.currentPath, sizeClass, uid, context.currentPageStateId, context.pageId);
|
|
188
|
+
const tc = await api.ensureTestElement(runnerId, surface.id, testElement);
|
|
189
|
+
await api.createTestElementRun({
|
|
190
|
+
testElementId: tc.id,
|
|
191
|
+
testSurfaceRunId: surfaceRun.id,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async generateFormTestElements(context) {
|
|
195
|
+
if (context.forms.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
const { api, runnerId, sizeClass, uid, bundleRun } = context;
|
|
198
|
+
const surface = await api.ensureTestSurface(runnerId, {
|
|
199
|
+
title: `Forms: ${context.currentPath}`,
|
|
200
|
+
description: `Form workflows for ${context.currentPath}`,
|
|
201
|
+
startingPageStateId: context.currentPageStateId,
|
|
202
|
+
startingPath: context.currentPath,
|
|
203
|
+
sizeClass,
|
|
204
|
+
priority: 2,
|
|
205
|
+
surface_tags: ["form"],
|
|
206
|
+
uid,
|
|
207
|
+
});
|
|
208
|
+
context.events.onTestSurfaceCreated({
|
|
209
|
+
surfaceId: surface.id,
|
|
210
|
+
title: surface.title,
|
|
211
|
+
});
|
|
212
|
+
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
213
|
+
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
214
|
+
for (let index = 0; index < context.forms.length; index++) {
|
|
215
|
+
const form = context.forms[index];
|
|
216
|
+
const formType = this.identifyFormType(form, context.currentPath);
|
|
217
|
+
const formLabel = this.describeForm(form, index);
|
|
218
|
+
const validValues = this.planFormValues(form, context.actionableItems);
|
|
219
|
+
if (this.isSearchForm(form)) {
|
|
220
|
+
const searchTests = this.buildSearchTestElements(form, formLabel, context.currentPath, sizeClass, uid, context.currentPageStateId, validValues);
|
|
221
|
+
for (const searchTest of searchTests) {
|
|
222
|
+
const searchElement = await api.ensureTestElement(runnerId, surface.id, searchTest);
|
|
223
|
+
await api.createTestElementRun({
|
|
224
|
+
testElementId: searchElement.id,
|
|
225
|
+
testSurfaceRunId: surfaceRun.id,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const positive = this.buildFormTestElement(form, formLabel, formType, context.currentPath, sizeClass, uid, context.currentPageStateId, validValues);
|
|
231
|
+
const positiveElement = await api.ensureTestElement(runnerId, surface.id, positive);
|
|
232
|
+
await api.createTestElementRun({
|
|
233
|
+
testElementId: positiveElement.id,
|
|
234
|
+
testSurfaceRunId: surfaceRun.id,
|
|
235
|
+
});
|
|
236
|
+
for (const field of form.fields.filter(field => this.isNegativeCandidateField(field))) {
|
|
237
|
+
const negative = this.buildNegativeFormTestElement(form, formLabel, formType, field, context.currentPath, sizeClass, uid, context.currentPageStateId, validValues);
|
|
238
|
+
const negativeElement = await api.ensureTestElement(runnerId, surface.id, negative);
|
|
239
|
+
await api.createTestElementRun({
|
|
240
|
+
testElementId: negativeElement.id,
|
|
241
|
+
testSurfaceRunId: surfaceRun.id,
|
|
242
|
+
});
|
|
243
|
+
const correction = this.buildFormCorrectionTestElement(form, formLabel, formType, field, context.currentPath, sizeClass, uid, context.currentPageStateId, validValues);
|
|
244
|
+
const correctionElement = await api.ensureTestElement(runnerId, surface.id, correction);
|
|
245
|
+
await api.createTestElementRun({
|
|
246
|
+
testElementId: correctionElement.id,
|
|
247
|
+
testSurfaceRunId: surfaceRun.id,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (this.isPasswordScenario(formType, form)) {
|
|
251
|
+
const passwordTests = this.buildPasswordTestElements(form, formLabel, formType, context.currentPath, sizeClass, uid, context.currentPageStateId, validValues, this.detectPasswordRequirements(this.extractVisibleText(context.html)));
|
|
252
|
+
for (const passwordTest of passwordTests) {
|
|
253
|
+
const passwordElement = await api.ensureTestElement(runnerId, surface.id, passwordTest);
|
|
254
|
+
await api.createTestElementRun({
|
|
255
|
+
testElementId: passwordElement.id,
|
|
256
|
+
testSurfaceRunId: surfaceRun.id,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async generateE2ETestElements(context) {
|
|
263
|
+
if (context.journeySteps.length < 2)
|
|
264
|
+
return;
|
|
265
|
+
const { api, runnerId, sizeClass, uid, bundleRun } = context;
|
|
266
|
+
const surface = await api.ensureTestSurface(runnerId, {
|
|
267
|
+
title: `Journeys: ${context.currentPath}`,
|
|
268
|
+
description: `End-to-end journeys reaching ${context.currentPath}`,
|
|
269
|
+
startingPageStateId: context.currentPageStateId,
|
|
270
|
+
startingPath: context.currentPath,
|
|
271
|
+
sizeClass,
|
|
272
|
+
priority: 2,
|
|
273
|
+
surface_tags: ["e2e"],
|
|
274
|
+
uid,
|
|
275
|
+
});
|
|
276
|
+
context.events.onTestSurfaceCreated({
|
|
277
|
+
surfaceId: surface.id,
|
|
278
|
+
title: surface.title,
|
|
279
|
+
});
|
|
280
|
+
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
281
|
+
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
282
|
+
const e2e = this.buildE2ETestElement(context.currentPath, sizeClass, uid, context.currentPageStateId, context.journeySteps);
|
|
283
|
+
const tc = await api.ensureTestElement(runnerId, surface.id, e2e);
|
|
284
|
+
await api.createTestElementRun({
|
|
285
|
+
testElementId: tc.id,
|
|
286
|
+
testSurfaceRunId: surfaceRun.id,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
async generateSemanticJourneyTestElements(context) {
|
|
290
|
+
const journeys = this.buildSemanticJourneyTestElements(context);
|
|
291
|
+
if (journeys.length === 0)
|
|
292
|
+
return;
|
|
293
|
+
const { api, runnerId, bundleRun } = context;
|
|
294
|
+
const surface = await api.ensureTestSurface(runnerId, {
|
|
295
|
+
title: `Journeys: ${context.currentPath}`,
|
|
296
|
+
description: `Semantic multi-step journeys from ${context.currentPath}`,
|
|
297
|
+
startingPageStateId: context.currentPageStateId,
|
|
298
|
+
startingPath: context.currentPath,
|
|
299
|
+
sizeClass: context.sizeClass,
|
|
300
|
+
priority: 2,
|
|
301
|
+
surface_tags: ["e2e", "semantic-journey"],
|
|
302
|
+
uid: context.uid,
|
|
303
|
+
});
|
|
304
|
+
context.events.onTestSurfaceCreated({
|
|
305
|
+
surfaceId: surface.id,
|
|
306
|
+
title: surface.title,
|
|
307
|
+
});
|
|
308
|
+
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
309
|
+
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
310
|
+
for (const journey of journeys) {
|
|
311
|
+
const tc = await api.ensureTestElement(runnerId, surface.id, journey);
|
|
312
|
+
await api.createTestElementRun({
|
|
313
|
+
testElementId: tc.id,
|
|
314
|
+
testSurfaceRunId: surfaceRun.id,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async generateDialogLifecycleTestElements(context) {
|
|
319
|
+
if (!this.pageHasOpenDialog(context.html))
|
|
320
|
+
return;
|
|
321
|
+
const closeCandidates = context.actionableItems.filter(item => item.visible &&
|
|
322
|
+
!item.disabled &&
|
|
323
|
+
item.selector &&
|
|
324
|
+
this.isDialogCloseItem(item));
|
|
325
|
+
const tests = [];
|
|
326
|
+
for (const item of closeCandidates) {
|
|
327
|
+
tests.push(this.buildDialogCloseTestElement(item, context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
|
|
328
|
+
}
|
|
329
|
+
tests.push(this.buildEscapeDialogTestElement(context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
|
|
330
|
+
const { api, runnerId, bundleRun } = context;
|
|
331
|
+
const surface = await api.ensureTestSurface(runnerId, {
|
|
332
|
+
title: `Dialogs: ${context.currentPath}`,
|
|
333
|
+
description: `Dialog lifecycle checks for ${context.currentPath}`,
|
|
334
|
+
startingPageStateId: context.currentPageStateId,
|
|
335
|
+
startingPath: context.currentPath,
|
|
336
|
+
sizeClass: context.sizeClass,
|
|
337
|
+
priority: 2,
|
|
338
|
+
surface_tags: ["dialog"],
|
|
339
|
+
uid: context.uid,
|
|
340
|
+
});
|
|
341
|
+
context.events.onTestSurfaceCreated({
|
|
342
|
+
surfaceId: surface.id,
|
|
343
|
+
title: surface.title,
|
|
344
|
+
});
|
|
345
|
+
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
346
|
+
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
347
|
+
for (const test of tests) {
|
|
348
|
+
const tc = await api.ensureTestElement(runnerId, surface.id, test);
|
|
349
|
+
await api.createTestElementRun({
|
|
350
|
+
testElementId: tc.id,
|
|
351
|
+
testSurfaceRunId: surfaceRun.id,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async generateKeyboardAndDisclosureTestElements(context) {
|
|
356
|
+
const tests = this.buildKeyboardAndDisclosureTestElements(context);
|
|
357
|
+
if (tests.length === 0)
|
|
358
|
+
return;
|
|
359
|
+
const { api, runnerId, bundleRun } = context;
|
|
360
|
+
const surface = await api.ensureTestSurface(runnerId, {
|
|
361
|
+
title: `Keyboard: ${context.currentPath}`,
|
|
362
|
+
description: `Keyboard parity and disclosure checks for ${context.currentPath}`,
|
|
363
|
+
startingPageStateId: context.currentPageStateId,
|
|
364
|
+
startingPath: context.currentPath,
|
|
365
|
+
sizeClass: context.sizeClass,
|
|
366
|
+
priority: 3,
|
|
367
|
+
surface_tags: ["keyboard", "disclosure"],
|
|
368
|
+
uid: context.uid,
|
|
369
|
+
});
|
|
370
|
+
context.events.onTestSurfaceCreated({
|
|
371
|
+
surfaceId: surface.id,
|
|
372
|
+
title: surface.title,
|
|
373
|
+
});
|
|
374
|
+
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
375
|
+
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
376
|
+
for (const test of tests) {
|
|
377
|
+
const tc = await api.ensureTestElement(runnerId, surface.id, test);
|
|
378
|
+
await api.createTestElementRun({
|
|
379
|
+
testElementId: tc.id,
|
|
380
|
+
testSurfaceRunId: surfaceRun.id,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
153
384
|
async generateContentTestElements(context) {
|
|
154
385
|
const { api, runnerId, sizeClass, uid, bundleRun, pageId: _pageId, } = context;
|
|
155
386
|
// Content items are those NOT in a scaffold
|
|
156
|
-
const contentItems = context.actionableItems.filter(item => item.scaffoldId == null && this.
|
|
387
|
+
const contentItems = context.actionableItems.filter(item => item.scaffoldId == null && this.isSurfaceCandidate(item));
|
|
157
388
|
if (contentItems.length === 0)
|
|
158
389
|
return;
|
|
159
390
|
const surfaceTitle = `Page: ${context.currentPath}`;
|
|
@@ -174,7 +405,9 @@ export class PageAnalyzer {
|
|
|
174
405
|
await api.ensureBundleSurfaceLink(bundleRun.testSurfaceBundleId, surface.id);
|
|
175
406
|
const surfaceRun = await this.ensureSurfaceRun(api, surface.id, bundleRun.id);
|
|
176
407
|
for (const item of contentItems) {
|
|
177
|
-
const testElement = this.
|
|
408
|
+
const testElement = this.shouldUseDirectControlInteraction(item)
|
|
409
|
+
? this.buildControlInteractionTestElement(item, context.currentPath, sizeClass, uid, context.currentPageStateId)
|
|
410
|
+
: this.buildHoverTestElement(item, context.currentPath, sizeClass, uid, context.currentPageStateId);
|
|
178
411
|
const tc = await api.ensureTestElement(runnerId, surface.id, testElement);
|
|
179
412
|
await api.createTestElementRun({
|
|
180
413
|
testElementId: tc.id,
|
|
@@ -198,6 +431,11 @@ export class PageAnalyzer {
|
|
|
198
431
|
!item.disabled &&
|
|
199
432
|
(item.actionKind === "click" || item.actionKind === "navigate"));
|
|
200
433
|
}
|
|
434
|
+
isSurfaceCandidate(item) {
|
|
435
|
+
if (!item.visible || !item.selector || item.disabled)
|
|
436
|
+
return false;
|
|
437
|
+
return ["click", "navigate", "fill", "select", "radio_select"].includes(item.actionKind);
|
|
438
|
+
}
|
|
201
439
|
extractRelativePath(href) {
|
|
202
440
|
try {
|
|
203
441
|
const url = new URL(href, "http://placeholder");
|
|
@@ -323,6 +561,7 @@ export class PageAnalyzer {
|
|
|
323
561
|
pageStateId: context.currentPageStateId,
|
|
324
562
|
pageId: context.pageId,
|
|
325
563
|
});
|
|
564
|
+
await this.ensureStoredForms(context.currentPageStateId, context);
|
|
326
565
|
return context.currentPageStateId;
|
|
327
566
|
}
|
|
328
567
|
const hashes = await computeHashes(context.html, context.actionableItems);
|
|
@@ -335,6 +574,7 @@ export class PageAnalyzer {
|
|
|
335
574
|
pageStateId: existing.id,
|
|
336
575
|
pageId: context.pageId,
|
|
337
576
|
});
|
|
577
|
+
await this.ensureStoredForms(existing.id, context);
|
|
338
578
|
return existing.id;
|
|
339
579
|
}
|
|
340
580
|
const contentHash = createHash("sha256").update(context.html).digest("hex");
|
|
@@ -354,6 +594,7 @@ export class PageAnalyzer {
|
|
|
354
594
|
if (scaffoldIdsBySelector.size > 0) {
|
|
355
595
|
await context.api.linkPageStateScaffolds(pageState.id, Array.from(new Set(scaffoldIdsBySelector.values())));
|
|
356
596
|
}
|
|
597
|
+
await this.ensureStoredForms(pageState.id, context);
|
|
357
598
|
return pageState.id;
|
|
358
599
|
}
|
|
359
600
|
async ensureScaffolds(context) {
|
|
@@ -369,5 +610,1358 @@ export class PageAnalyzer {
|
|
|
369
610
|
}
|
|
370
611
|
return scaffoldIdsBySelector;
|
|
371
612
|
}
|
|
613
|
+
async ensureStoredForms(pageStateId, context) {
|
|
614
|
+
if (context.forms.length === 0)
|
|
615
|
+
return;
|
|
616
|
+
const existing = await context.api.getFormsByPageState(pageStateId);
|
|
617
|
+
const existingSelectors = new Set(existing.map(form => form.selector));
|
|
618
|
+
for (const form of context.forms) {
|
|
619
|
+
if (existingSelectors.has(form.selector))
|
|
620
|
+
continue;
|
|
621
|
+
await context.api.insertForm(pageStateId, form, this.identifyFormType(form, context.currentPath));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
buildRenderTestElement(currentPath, sizeClass, uid, startingPageStateId, pageId) {
|
|
625
|
+
return {
|
|
626
|
+
title: `Render — ${currentPath}`,
|
|
627
|
+
type: "render",
|
|
628
|
+
sizeClass,
|
|
629
|
+
surface_tags: ["render"],
|
|
630
|
+
priority: 2,
|
|
631
|
+
page_id: pageId,
|
|
632
|
+
startingPageStateId,
|
|
633
|
+
startingPath: currentPath,
|
|
634
|
+
steps: [
|
|
635
|
+
{
|
|
636
|
+
action: {
|
|
637
|
+
actionType: PlaywrightAction.Goto,
|
|
638
|
+
path: currentPath,
|
|
639
|
+
playwrightCode: `await page.goto('${currentPath}')`,
|
|
640
|
+
description: `Navigate to ${currentPath}`,
|
|
641
|
+
},
|
|
642
|
+
expectations: [
|
|
643
|
+
{
|
|
644
|
+
expectationType: ExpectationType.PageLoaded,
|
|
645
|
+
severity: ExpectationSeverity.MustPass,
|
|
646
|
+
description: `Page ${currentPath} should load`,
|
|
647
|
+
playwrightCode: "await page.waitForLoadState('networkidle')",
|
|
648
|
+
},
|
|
649
|
+
],
|
|
650
|
+
description: `Navigate to ${currentPath}`,
|
|
651
|
+
continueOnFailure: false,
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
action: {
|
|
655
|
+
actionType: PlaywrightAction.WaitForLoadState,
|
|
656
|
+
playwrightCode: "await page.waitForLoadState('networkidle')",
|
|
657
|
+
description: "Wait for page to settle",
|
|
658
|
+
},
|
|
659
|
+
expectations: [],
|
|
660
|
+
description: "Wait for page to settle",
|
|
661
|
+
continueOnFailure: true,
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
action: {
|
|
665
|
+
actionType: PlaywrightAction.Screenshot,
|
|
666
|
+
value: `render-${this.slugify(currentPath)}`,
|
|
667
|
+
playwrightCode: `await page.screenshot({ fullPage: true })`,
|
|
668
|
+
description: "Capture screenshot",
|
|
669
|
+
},
|
|
670
|
+
expectations: [],
|
|
671
|
+
description: "Capture screenshot",
|
|
672
|
+
continueOnFailure: true,
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
globalExpectations: this.defaultFlowExpectations("Render page without runtime errors"),
|
|
676
|
+
uid,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
buildFormTestElement(form, formLabel, formType, currentPath, sizeClass, uid, startingPageStateId, validValues) {
|
|
680
|
+
const steps = this.buildFormSteps(form, validValues, undefined);
|
|
681
|
+
return {
|
|
682
|
+
title: `Form — ${formLabel}`,
|
|
683
|
+
type: "form",
|
|
684
|
+
sizeClass,
|
|
685
|
+
surface_tags: ["form", formType],
|
|
686
|
+
priority: this.formPriority(formType),
|
|
687
|
+
startingPageStateId,
|
|
688
|
+
startingPath: currentPath,
|
|
689
|
+
steps,
|
|
690
|
+
globalExpectations: [
|
|
691
|
+
...this.defaultFlowExpectations(`Form ${formLabel} should execute cleanly`),
|
|
692
|
+
this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit without client-side errors`),
|
|
693
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} should trigger a backend mutation request`, {
|
|
694
|
+
expectedValue: "mutation",
|
|
695
|
+
}),
|
|
696
|
+
this.makeExpectation("feedback_visible", `Submitting ${formLabel} should provide visible user feedback`, {
|
|
697
|
+
expectedTextTokens: [
|
|
698
|
+
"success",
|
|
699
|
+
"saved",
|
|
700
|
+
"submitted",
|
|
701
|
+
"thank",
|
|
702
|
+
"done",
|
|
703
|
+
],
|
|
704
|
+
forbiddenTextTokens: ["error", "failed", "try again"],
|
|
705
|
+
}),
|
|
706
|
+
this.makeExpectation("field_error_clears_after_fix", `Validation errors on ${formLabel} should clear once the fields are corrected`),
|
|
707
|
+
],
|
|
708
|
+
uid,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
buildNegativeFormTestElement(form, formLabel, formType, omittedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
|
|
712
|
+
const steps = this.buildFormSteps(form, validValues, omittedField.selector);
|
|
713
|
+
return {
|
|
714
|
+
title: `Form Negative — ${formLabel} (missing ${this.fieldLabel(omittedField)})`,
|
|
715
|
+
type: "form_negative",
|
|
716
|
+
sizeClass,
|
|
717
|
+
surface_tags: ["form", "negative", formType],
|
|
718
|
+
priority: this.formPriority(formType) + 1,
|
|
719
|
+
startingPageStateId,
|
|
720
|
+
startingPath: currentPath,
|
|
721
|
+
steps,
|
|
722
|
+
globalExpectations: [
|
|
723
|
+
...this.defaultFlowExpectations(`Negative form check for ${formLabel}`),
|
|
724
|
+
this.makeExpectation(ExpectationType.ValidationMessageVisible, `Validation feedback should appear when ${this.fieldLabel(omittedField)} is omitted`),
|
|
725
|
+
this.makeExpectation("required_error_shown_for_field", `${this.fieldLabel(omittedField)} should show a required-field error when omitted`, {
|
|
726
|
+
targetPath: omittedField.selector,
|
|
727
|
+
}),
|
|
728
|
+
],
|
|
729
|
+
uid,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
buildFormCorrectionTestElement(form, formLabel, formType, correctedField, currentPath, sizeClass, uid, startingPageStateId, validValues) {
|
|
733
|
+
const steps = this.buildFormSteps(form, validValues, correctedField.selector);
|
|
734
|
+
const correctionValue = validValues[correctedField.selector];
|
|
735
|
+
if (correctionValue) {
|
|
736
|
+
const correctionStep = this.buildFieldStep(correctedField, correctionValue, this.makeExpectation("field_error_clears_after_fix", `${this.fieldLabel(correctedField)} should clear its validation error after correction`, {
|
|
737
|
+
targetPath: correctedField.selector,
|
|
738
|
+
}));
|
|
739
|
+
if (correctionStep) {
|
|
740
|
+
steps.push(correctionStep);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
steps.push(...this.buildSubmitSteps(form.submitSelector));
|
|
744
|
+
return {
|
|
745
|
+
title: `Form Correction — ${formLabel} (fix ${this.fieldLabel(correctedField)})`,
|
|
746
|
+
type: "form",
|
|
747
|
+
sizeClass,
|
|
748
|
+
surface_tags: ["form", "correction", formType],
|
|
749
|
+
priority: this.formPriority(formType) + 1,
|
|
750
|
+
startingPageStateId,
|
|
751
|
+
startingPath: currentPath,
|
|
752
|
+
steps,
|
|
753
|
+
globalExpectations: [
|
|
754
|
+
...this.defaultFlowExpectations(`Correction flow for ${formLabel}`),
|
|
755
|
+
this.makeExpectation(ExpectationType.FormSubmittedSuccessfully, `Form ${formLabel} should submit after correcting ${this.fieldLabel(correctedField)}`),
|
|
756
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, `Submitting ${formLabel} after correction should trigger a backend mutation request`, {
|
|
757
|
+
expectedValue: "mutation",
|
|
758
|
+
}),
|
|
759
|
+
this.makeExpectation("feedback_visible", `Form ${formLabel} should show success feedback after correction`, {
|
|
760
|
+
expectedTextTokens: [
|
|
761
|
+
"success",
|
|
762
|
+
"saved",
|
|
763
|
+
"submitted",
|
|
764
|
+
"thank",
|
|
765
|
+
"done",
|
|
766
|
+
],
|
|
767
|
+
forbiddenTextTokens: ["error", "required", "invalid"],
|
|
768
|
+
}),
|
|
769
|
+
],
|
|
770
|
+
uid,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
buildPasswordTestElements(form, formLabel, formType, currentPath, sizeClass, uid, startingPageStateId, validValues, passwordRequirements) {
|
|
774
|
+
const passwordFields = form.fields.filter(field => field.type === "password");
|
|
775
|
+
if (passwordFields.length === 0)
|
|
776
|
+
return [];
|
|
777
|
+
const variants = this.generatePasswordVariants(passwordRequirements);
|
|
778
|
+
return variants.map(variant => {
|
|
779
|
+
const values = { ...validValues };
|
|
780
|
+
for (const field of passwordFields) {
|
|
781
|
+
values[field.selector] = variant.password;
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
title: `Password — ${formLabel} (${variant.description})`,
|
|
785
|
+
type: "password",
|
|
786
|
+
sizeClass,
|
|
787
|
+
surface_tags: ["form", "password", formType],
|
|
788
|
+
priority: variant.shouldFail ? 2 : 1,
|
|
789
|
+
startingPageStateId,
|
|
790
|
+
startingPath: currentPath,
|
|
791
|
+
steps: this.buildFormSteps(form, values, undefined),
|
|
792
|
+
globalExpectations: [
|
|
793
|
+
...this.defaultFlowExpectations(`Password flow ${variant.description}`),
|
|
794
|
+
{
|
|
795
|
+
expectationType: variant.shouldFail
|
|
796
|
+
? ExpectationType.ValidationMessageVisible
|
|
797
|
+
: ExpectationType.FormSubmittedSuccessfully,
|
|
798
|
+
severity: ExpectationSeverity.ShouldPass,
|
|
799
|
+
description: variant.shouldFail
|
|
800
|
+
? `Password validation should reject ${variant.description}`
|
|
801
|
+
: `Password validation should accept ${variant.description}`,
|
|
802
|
+
playwrightCode: "// checked by password follow-up validation",
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
uid,
|
|
806
|
+
};
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
buildE2ETestElement(currentPath, sizeClass, uid, startingPageStateId, journeySteps) {
|
|
810
|
+
return {
|
|
811
|
+
title: `E2E — Journey to ${currentPath}`,
|
|
812
|
+
type: "e2e",
|
|
813
|
+
sizeClass,
|
|
814
|
+
surface_tags: ["e2e"],
|
|
815
|
+
priority: 2,
|
|
816
|
+
startingPageStateId,
|
|
817
|
+
startingPath: currentPath,
|
|
818
|
+
steps: journeySteps.map(step => ({
|
|
819
|
+
...step,
|
|
820
|
+
expectations: [],
|
|
821
|
+
})),
|
|
822
|
+
globalExpectations: this.defaultFlowExpectations(`Journey to ${currentPath} should complete without runtime errors`),
|
|
823
|
+
uid,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
buildFormSteps(form, valuesBySelector, omittedSelector) {
|
|
827
|
+
const steps = [];
|
|
828
|
+
for (const field of form.fields) {
|
|
829
|
+
if (field.selector === omittedSelector)
|
|
830
|
+
continue;
|
|
831
|
+
const analyzerField = field;
|
|
832
|
+
const fieldIsImmutable = Boolean(analyzerField.disabled ||
|
|
833
|
+
analyzerField.readOnly ||
|
|
834
|
+
this.looksVisuallyDisabledField(analyzerField));
|
|
835
|
+
if (fieldIsImmutable)
|
|
836
|
+
continue;
|
|
837
|
+
const value = valuesBySelector[field.selector];
|
|
838
|
+
if (!value || this.isSkippableFieldType(field))
|
|
839
|
+
continue;
|
|
840
|
+
const step = this.buildFieldStep(field, value);
|
|
841
|
+
if (step)
|
|
842
|
+
steps.push(step);
|
|
843
|
+
}
|
|
844
|
+
steps.push(...this.buildSubmitSteps(form.submitSelector));
|
|
845
|
+
return steps;
|
|
846
|
+
}
|
|
847
|
+
buildFieldStep(field, value, trailingExpectation) {
|
|
848
|
+
const analyzerField = field;
|
|
849
|
+
const fieldIsImmutable = Boolean(analyzerField.disabled ||
|
|
850
|
+
analyzerField.readOnly ||
|
|
851
|
+
this.looksVisuallyDisabledField(analyzerField));
|
|
852
|
+
if (fieldIsImmutable)
|
|
853
|
+
return null;
|
|
854
|
+
if (this.isCheckboxLike(field)) {
|
|
855
|
+
return {
|
|
856
|
+
action: {
|
|
857
|
+
actionType: PlaywrightAction.Check,
|
|
858
|
+
path: field.selector,
|
|
859
|
+
value,
|
|
860
|
+
playwrightCode: `await page.locator('${field.selector}').check()`,
|
|
861
|
+
description: `Check ${this.fieldLabel(field)}`,
|
|
862
|
+
},
|
|
863
|
+
expectations: [
|
|
864
|
+
this.makeExpectation(ExpectationType.ElementChecked, `Checking ${this.fieldLabel(field)} should update the control state`, {
|
|
865
|
+
targetPath: field.selector,
|
|
866
|
+
}),
|
|
867
|
+
...(trailingExpectation ? [trailingExpectation] : []),
|
|
868
|
+
],
|
|
869
|
+
description: `Check ${this.fieldLabel(field)}`,
|
|
870
|
+
continueOnFailure: false,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
if (this.isSelectLike(field)) {
|
|
874
|
+
return {
|
|
875
|
+
action: {
|
|
876
|
+
actionType: PlaywrightAction.SelectOption,
|
|
877
|
+
path: field.selector,
|
|
878
|
+
value,
|
|
879
|
+
playwrightCode: `await page.locator('${field.selector}').selectOption('${value}')`,
|
|
880
|
+
description: `Select ${this.fieldLabel(field)}`,
|
|
881
|
+
},
|
|
882
|
+
expectations: [
|
|
883
|
+
this.makeExpectation(ExpectationType.InputValue, `Selecting ${this.fieldLabel(field)} should update the selected option`, {
|
|
884
|
+
targetPath: field.selector,
|
|
885
|
+
expectedValue: value,
|
|
886
|
+
}),
|
|
887
|
+
...(trailingExpectation ? [trailingExpectation] : []),
|
|
888
|
+
],
|
|
889
|
+
description: `Select ${this.fieldLabel(field)}`,
|
|
890
|
+
continueOnFailure: false,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
action: {
|
|
895
|
+
actionType: PlaywrightAction.Type,
|
|
896
|
+
path: field.selector,
|
|
897
|
+
value,
|
|
898
|
+
playwrightCode: `await page.locator('${field.selector}').type('${this.escapeSingleQuotes(value)}')`,
|
|
899
|
+
description: `Fill ${this.fieldLabel(field)}`,
|
|
900
|
+
},
|
|
901
|
+
expectations: [
|
|
902
|
+
this.makeExpectation(ExpectationType.InputValue, `Typing into ${this.fieldLabel(field)} should update the control value`, {
|
|
903
|
+
targetPath: field.selector,
|
|
904
|
+
expectedValue: value,
|
|
905
|
+
}),
|
|
906
|
+
...(trailingExpectation ? [trailingExpectation] : []),
|
|
907
|
+
],
|
|
908
|
+
description: `Fill ${this.fieldLabel(field)}`,
|
|
909
|
+
continueOnFailure: false,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
buildSubmitSteps(submitSelector) {
|
|
913
|
+
if (!submitSelector)
|
|
914
|
+
return [];
|
|
915
|
+
return [
|
|
916
|
+
{
|
|
917
|
+
action: {
|
|
918
|
+
actionType: PlaywrightAction.Click,
|
|
919
|
+
path: submitSelector,
|
|
920
|
+
playwrightCode: `await page.locator('${submitSelector}').click()`,
|
|
921
|
+
description: "Submit form",
|
|
922
|
+
},
|
|
923
|
+
expectations: [],
|
|
924
|
+
description: "Submit form",
|
|
925
|
+
continueOnFailure: false,
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
action: {
|
|
929
|
+
actionType: PlaywrightAction.WaitForLoadState,
|
|
930
|
+
playwrightCode: "await page.waitForLoadState('networkidle')",
|
|
931
|
+
description: "Wait for post-submit state",
|
|
932
|
+
},
|
|
933
|
+
expectations: [],
|
|
934
|
+
description: "Wait for post-submit state",
|
|
935
|
+
continueOnFailure: true,
|
|
936
|
+
},
|
|
937
|
+
];
|
|
938
|
+
}
|
|
939
|
+
planFormValues(form, actionableItems) {
|
|
940
|
+
const values = {};
|
|
941
|
+
for (const field of form.fields) {
|
|
942
|
+
const analyzerField = field;
|
|
943
|
+
if (analyzerField.disabled ||
|
|
944
|
+
analyzerField.readOnly ||
|
|
945
|
+
this.looksVisuallyDisabledField(analyzerField)) {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (this.isCheckboxLike(field)) {
|
|
949
|
+
values[field.selector] = "true";
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (this.isSelectLike(field)) {
|
|
953
|
+
const option = field.options?.find(value => value && value.trim().length > 0);
|
|
954
|
+
if (option)
|
|
955
|
+
values[field.selector] = option;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const item = actionableItems.find(candidate => candidate.selector === field.selector);
|
|
959
|
+
const fallbackItem = item ?? {
|
|
960
|
+
stableKey: field.selector,
|
|
961
|
+
selector: field.selector,
|
|
962
|
+
tagName: field.type === "textarea" ? "TEXTAREA" : "INPUT",
|
|
963
|
+
inputType: field.type,
|
|
964
|
+
actionKind: "fill",
|
|
965
|
+
accessibleName: field.label,
|
|
966
|
+
disabled: false,
|
|
967
|
+
visible: true,
|
|
968
|
+
attributes: {
|
|
969
|
+
name: field.name,
|
|
970
|
+
placeholder: field.placeholder,
|
|
971
|
+
labelText: field.label,
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
values[field.selector] = fillValuePlanner.planValue(fallbackItem);
|
|
975
|
+
}
|
|
976
|
+
return values;
|
|
977
|
+
}
|
|
978
|
+
identifyFormType(form, currentPath) {
|
|
979
|
+
const url = currentPath.toLowerCase();
|
|
980
|
+
const fields = form.fields;
|
|
981
|
+
const isLoginUrl = AUTH_URL_PATTERNS.some(pattern => url.includes(pattern));
|
|
982
|
+
const isSignupUrl = SIGNUP_URL_PATTERNS.some(pattern => url.includes(pattern));
|
|
983
|
+
const hasPassword = fields.some(field => field.type === "password");
|
|
984
|
+
const hasEmail = fields.some(field => {
|
|
985
|
+
const label = field.label.toLowerCase();
|
|
986
|
+
return (field.type === "email" ||
|
|
987
|
+
field.name.toLowerCase() === "email" ||
|
|
988
|
+
label.includes("email"));
|
|
989
|
+
});
|
|
990
|
+
const hasUsername = fields.some(field => {
|
|
991
|
+
const signal = `${field.name} ${field.label}`.toLowerCase();
|
|
992
|
+
return signal.includes("username") || signal.includes("user name");
|
|
993
|
+
});
|
|
994
|
+
const hasName = fields.some(field => {
|
|
995
|
+
const signal = `${field.name} ${field.label}`.toLowerCase();
|
|
996
|
+
return (signal.includes("name") &&
|
|
997
|
+
!signal.includes("username") &&
|
|
998
|
+
!signal.includes("email"));
|
|
999
|
+
});
|
|
1000
|
+
const hasMessage = fields.some(field => {
|
|
1001
|
+
const signal = `${field.name} ${field.label}`.toLowerCase();
|
|
1002
|
+
return signal.includes("message") || signal.includes("subject");
|
|
1003
|
+
});
|
|
1004
|
+
const passwordCount = fields.filter(field => field.type === "password").length;
|
|
1005
|
+
if (hasMessage)
|
|
1006
|
+
return "other";
|
|
1007
|
+
if (!hasPassword)
|
|
1008
|
+
return "other";
|
|
1009
|
+
if (hasEmail || hasUsername) {
|
|
1010
|
+
if (passwordCount >= 2 || hasName || isSignupUrl)
|
|
1011
|
+
return "signup";
|
|
1012
|
+
if (isLoginUrl)
|
|
1013
|
+
return "login";
|
|
1014
|
+
if (fields.length <= 2)
|
|
1015
|
+
return "login";
|
|
1016
|
+
return hasName ? "signup" : "login";
|
|
1017
|
+
}
|
|
1018
|
+
if (isSignupUrl)
|
|
1019
|
+
return "signup";
|
|
1020
|
+
if (isLoginUrl)
|
|
1021
|
+
return "login";
|
|
1022
|
+
return "other";
|
|
1023
|
+
}
|
|
1024
|
+
isSearchForm(form) {
|
|
1025
|
+
if (String(form.method || "").toUpperCase() === "GET") {
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
return form.fields.some(field => this.isSearchField(field));
|
|
1029
|
+
}
|
|
1030
|
+
isSearchField(field) {
|
|
1031
|
+
const text = [
|
|
1032
|
+
field.type,
|
|
1033
|
+
field.label,
|
|
1034
|
+
field.name,
|
|
1035
|
+
field.placeholder,
|
|
1036
|
+
field.selector,
|
|
1037
|
+
]
|
|
1038
|
+
.filter(Boolean)
|
|
1039
|
+
.join(" ")
|
|
1040
|
+
.toLowerCase();
|
|
1041
|
+
return field.type === "search" || /\bsearch\b/.test(text);
|
|
1042
|
+
}
|
|
1043
|
+
buildSearchTestElements(form, formLabel, currentPath, sizeClass, uid, startingPageStateId, validValues) {
|
|
1044
|
+
const searchField = form.fields.find(field => this.isSearchField(field));
|
|
1045
|
+
if (!searchField)
|
|
1046
|
+
return [];
|
|
1047
|
+
const searchValues = {
|
|
1048
|
+
...validValues,
|
|
1049
|
+
[searchField.selector]: "test",
|
|
1050
|
+
};
|
|
1051
|
+
const noResultsValues = {
|
|
1052
|
+
...validValues,
|
|
1053
|
+
[searchField.selector]: this.improbableSearchQuery(),
|
|
1054
|
+
};
|
|
1055
|
+
return [
|
|
1056
|
+
{
|
|
1057
|
+
title: `Search — ${formLabel}`,
|
|
1058
|
+
type: "form",
|
|
1059
|
+
sizeClass,
|
|
1060
|
+
surface_tags: ["form", "search"],
|
|
1061
|
+
priority: 2,
|
|
1062
|
+
startingPageStateId,
|
|
1063
|
+
startingPath: currentPath,
|
|
1064
|
+
steps: this.buildFormSteps(form, searchValues, undefined),
|
|
1065
|
+
globalExpectations: [
|
|
1066
|
+
...this.defaultFlowExpectations(`Search flow ${formLabel}`),
|
|
1067
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, `Searching via ${formLabel} should issue a GET request`, {
|
|
1068
|
+
expectedValue: "GET",
|
|
1069
|
+
expectedTextTokens: ["search", "q=", "query", "term"],
|
|
1070
|
+
}),
|
|
1071
|
+
this.makeExpectation(ExpectationType.NavigationOrStateChanged, `Searching via ${formLabel} should change the page or result state`),
|
|
1072
|
+
this.makeExpectation(ExpectationType.ResultsChanged, `Searching via ${formLabel} should change the visible results`),
|
|
1073
|
+
this.makeExpectation(ExpectationType.LoadingCompletes, `Search results for ${formLabel} should finish loading`),
|
|
1074
|
+
],
|
|
1075
|
+
uid,
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
title: `Search Empty State — ${formLabel}`,
|
|
1079
|
+
type: "form",
|
|
1080
|
+
sizeClass,
|
|
1081
|
+
surface_tags: ["form", "search", "empty-state"],
|
|
1082
|
+
priority: 3,
|
|
1083
|
+
startingPageStateId,
|
|
1084
|
+
startingPath: currentPath,
|
|
1085
|
+
steps: this.buildFormSteps(form, noResultsValues, undefined),
|
|
1086
|
+
globalExpectations: [
|
|
1087
|
+
...this.defaultFlowExpectations(`Empty-state search flow ${formLabel}`),
|
|
1088
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, `No-result search via ${formLabel} should still issue a GET request`, {
|
|
1089
|
+
expectedValue: "GET",
|
|
1090
|
+
expectedTextTokens: ["search", "q=", "query", "term"],
|
|
1091
|
+
}),
|
|
1092
|
+
this.makeExpectation(ExpectationType.NavigationOrStateChanged, `No-result search via ${formLabel} should change the page or result state`),
|
|
1093
|
+
this.makeExpectation(ExpectationType.EmptyStateVisible, `No-result search via ${formLabel} should show an empty state`),
|
|
1094
|
+
this.makeExpectation(ExpectationType.LoadingCompletes, `No-result search for ${formLabel} should finish loading`),
|
|
1095
|
+
],
|
|
1096
|
+
uid,
|
|
1097
|
+
},
|
|
1098
|
+
];
|
|
1099
|
+
}
|
|
1100
|
+
describeForm(form, index) {
|
|
1101
|
+
const namedField = form.fields.find(field => field.label || field.name);
|
|
1102
|
+
const descriptor = namedField?.label || namedField?.name || `form ${index + 1}`;
|
|
1103
|
+
return `${descriptor} @ ${form.selector}`;
|
|
1104
|
+
}
|
|
1105
|
+
buildSemanticJourneyTestElements(context) {
|
|
1106
|
+
const items = context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector));
|
|
1107
|
+
const journeys = [];
|
|
1108
|
+
const addToCart = items.find(item => this.isAddToCartItem(item));
|
|
1109
|
+
const checkout = items.find(item => this.isCheckoutItem(item));
|
|
1110
|
+
if (addToCart && checkout) {
|
|
1111
|
+
journeys.push(this.buildJourneyTestElement("Commerce journey", ["commerce", "cart", "checkout"], context, [
|
|
1112
|
+
this.buildJourneyAction(addToCart, "Add item to cart", [
|
|
1113
|
+
this.makeExpectation("navigation_or_state_changed", "Adding an item to cart should update the page state"),
|
|
1114
|
+
this.makeExpectation("count_changed", "Adding an item should update a visible count", {
|
|
1115
|
+
expectedCountDelta: 1,
|
|
1116
|
+
}),
|
|
1117
|
+
this.makeExpectation("cart_summary_changed", "Adding an item should update the cart summary"),
|
|
1118
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, "Adding an item should trigger a backend mutation request", {
|
|
1119
|
+
expectedValue: "mutation",
|
|
1120
|
+
}),
|
|
1121
|
+
this.makeExpectation("feedback_visible", "Adding an item should provide visible feedback", {
|
|
1122
|
+
expectedTextTokens: ["added", "success", "cart", "bag"],
|
|
1123
|
+
forbiddenTextTokens: ["error", "failed"],
|
|
1124
|
+
}),
|
|
1125
|
+
this.makeExpectation("loading_completes", "Cart update should complete loading"),
|
|
1126
|
+
this.makeExpectation("page_responsive", "Page should remain responsive after cart update"),
|
|
1127
|
+
]),
|
|
1128
|
+
this.waitStep(700, "Wait for cart state"),
|
|
1129
|
+
this.buildJourneyAction(checkout, "Proceed to checkout", [
|
|
1130
|
+
this.makeExpectation("navigation_or_state_changed", "Proceeding to checkout should change the page state"),
|
|
1131
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, "Proceeding to checkout should trigger a network request or document transition", {
|
|
1132
|
+
expectedValue: "ANY",
|
|
1133
|
+
expectedTextTokens: ["checkout"],
|
|
1134
|
+
}),
|
|
1135
|
+
this.makeExpectation("loading_completes", "Checkout transition should complete loading"),
|
|
1136
|
+
this.makeExpectation("page_responsive", "Page should remain responsive during checkout transition"),
|
|
1137
|
+
]),
|
|
1138
|
+
]));
|
|
1139
|
+
}
|
|
1140
|
+
const removeItem = items.find(item => this.isRemoveItemAction(item));
|
|
1141
|
+
if (removeItem) {
|
|
1142
|
+
journeys.push(this.buildJourneyTestElement("Remove item from collection", ["commerce", "remove"], context, [
|
|
1143
|
+
this.buildJourneyAction(removeItem, "Remove item", [
|
|
1144
|
+
this.makeExpectation("navigation_or_state_changed", "Removing an item should change the page state"),
|
|
1145
|
+
this.makeExpectation("count_changed", "Removing an item should update a visible count", {
|
|
1146
|
+
expectedCountDelta: -1,
|
|
1147
|
+
}),
|
|
1148
|
+
this.makeExpectation("cart_summary_changed", "Removing an item should update the cart summary"),
|
|
1149
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, "Removing an item should trigger a backend mutation request", {
|
|
1150
|
+
expectedValue: "mutation",
|
|
1151
|
+
}),
|
|
1152
|
+
this.makeExpectation("feedback_visible", "Removing an item should provide visible feedback", {
|
|
1153
|
+
expectedTextTokens: ["removed", "updated", "cart", "bag"],
|
|
1154
|
+
forbiddenTextTokens: ["error", "failed"],
|
|
1155
|
+
}),
|
|
1156
|
+
this.makeExpectation("loading_completes", "Removal flow should complete loading"),
|
|
1157
|
+
this.makeExpectation("page_responsive", "Page should remain responsive after removal"),
|
|
1158
|
+
]),
|
|
1159
|
+
]));
|
|
1160
|
+
}
|
|
1161
|
+
const authEntry = items.find(item => this.isAuthEntryItem(item));
|
|
1162
|
+
if (authEntry) {
|
|
1163
|
+
journeys.push(this.buildJourneyTestElement("Authentication entry journey", ["auth"], context, [
|
|
1164
|
+
this.buildJourneyAction(authEntry, "Open authentication entry point", [
|
|
1165
|
+
this.makeExpectation("navigation_or_state_changed", "Authentication entry should open the next auth state"),
|
|
1166
|
+
this.makeExpectation("loading_completes", "Authentication entry flow should settle"),
|
|
1167
|
+
this.makeExpectation("page_responsive", "Page should remain responsive when opening auth flow"),
|
|
1168
|
+
]),
|
|
1169
|
+
]));
|
|
1170
|
+
}
|
|
1171
|
+
const mediaCandidate = items.find(item => this.isMediaOpenItem(item));
|
|
1172
|
+
if (mediaCandidate) {
|
|
1173
|
+
const mediaExpectations = [
|
|
1174
|
+
this.makeExpectation("modal_opened", "Opening media should reveal a modal, overlay, or new state"),
|
|
1175
|
+
this.makeExpectation("media_loaded", "Opened media should load successfully"),
|
|
1176
|
+
];
|
|
1177
|
+
if (this.isVideoLikeItem(mediaCandidate)) {
|
|
1178
|
+
mediaExpectations.push(this.makeExpectation("video_playable", "Opened video should be playable"));
|
|
1179
|
+
}
|
|
1180
|
+
journeys.push(this.buildJourneyTestElement("Media open journey", ["media"], context, [
|
|
1181
|
+
this.buildJourneyAction(mediaCandidate, "Open media", mediaExpectations),
|
|
1182
|
+
]));
|
|
1183
|
+
}
|
|
1184
|
+
const quantityAction = items.find(item => this.isQuantityAction(item));
|
|
1185
|
+
if (quantityAction) {
|
|
1186
|
+
journeys.push(this.buildJourneyTestElement("Quantity adjustment journey", ["commerce", "quantity"], context, [
|
|
1187
|
+
this.buildJourneyAction(quantityAction, "Adjust quantity", [
|
|
1188
|
+
this.makeExpectation("count_changed", "Adjusting quantity should update a visible count or quantity indicator"),
|
|
1189
|
+
this.makeExpectation("cart_summary_changed", "Adjusting quantity should update the summary values"),
|
|
1190
|
+
this.makeExpectation(ExpectationType.NetworkRequestMade, "Adjusting quantity should trigger a backend mutation request", {
|
|
1191
|
+
expectedValue: "mutation",
|
|
1192
|
+
}),
|
|
1193
|
+
this.makeExpectation("loading_completes", "Quantity adjustment should complete loading"),
|
|
1194
|
+
]),
|
|
1195
|
+
]));
|
|
1196
|
+
}
|
|
1197
|
+
const filterAction = items.find(item => this.isFilterAction(item));
|
|
1198
|
+
if (filterAction) {
|
|
1199
|
+
journeys.push(this.buildJourneyTestElement("Filter results journey", ["filter"], context, [
|
|
1200
|
+
this.buildJourneyAction(filterAction, "Apply filter", [
|
|
1201
|
+
this.makeExpectation("navigation_or_state_changed", "Applying a filter should change the result state"),
|
|
1202
|
+
this.makeExpectation("results_changed", "Applying a filter should change the visible result summary"),
|
|
1203
|
+
this.makeExpectation("loading_completes", "Filter update should complete loading"),
|
|
1204
|
+
]),
|
|
1205
|
+
]));
|
|
1206
|
+
journeys.push(this.buildJourneyTestElement("Filter persistence journey", ["filter", "reload"], context, [
|
|
1207
|
+
this.buildJourneyAction(filterAction, "Apply filter", [
|
|
1208
|
+
this.makeExpectation("navigation_or_state_changed", "Applying a filter should change the result state"),
|
|
1209
|
+
]),
|
|
1210
|
+
this.waitStep(500, "Wait for filtered state"),
|
|
1211
|
+
this.buildReloadStep([
|
|
1212
|
+
this.makeExpectation("state_persists_after_reload", "Filter state should persist after reload", {
|
|
1213
|
+
expectedTextTokens: ["filter", "results", "showing", "items"],
|
|
1214
|
+
}),
|
|
1215
|
+
]),
|
|
1216
|
+
]));
|
|
1217
|
+
}
|
|
1218
|
+
const sortAction = items.find(item => this.isSortAction(item));
|
|
1219
|
+
if (sortAction) {
|
|
1220
|
+
journeys.push(this.buildJourneyTestElement("Sort results journey", ["sort"], context, [
|
|
1221
|
+
this.buildJourneyAction(sortAction, "Change sort order", [
|
|
1222
|
+
this.makeExpectation("navigation_or_state_changed", "Changing sort should update the result state"),
|
|
1223
|
+
this.makeExpectation("collection_order_changed", "Changing sort should change the visible collection ordering"),
|
|
1224
|
+
this.makeExpectation("loading_completes", "Sort update should complete loading"),
|
|
1225
|
+
]),
|
|
1226
|
+
]));
|
|
1227
|
+
}
|
|
1228
|
+
if (addToCart) {
|
|
1229
|
+
journeys.push(this.buildJourneyTestElement("Cart persistence journey", ["commerce", "reload"], context, [
|
|
1230
|
+
this.buildJourneyAction(addToCart, "Add item to cart", [
|
|
1231
|
+
this.makeExpectation("navigation_or_state_changed", "Adding an item should change page state before reload"),
|
|
1232
|
+
]),
|
|
1233
|
+
this.waitStep(500, "Wait for updated cart state"),
|
|
1234
|
+
this.buildReloadStep([
|
|
1235
|
+
this.makeExpectation("state_persists_after_reload", "Cart state should persist after reload", {
|
|
1236
|
+
expectedTextTokens: [
|
|
1237
|
+
"cart",
|
|
1238
|
+
"bag",
|
|
1239
|
+
"basket",
|
|
1240
|
+
"qty",
|
|
1241
|
+
"quantity",
|
|
1242
|
+
],
|
|
1243
|
+
}),
|
|
1244
|
+
]),
|
|
1245
|
+
]));
|
|
1246
|
+
}
|
|
1247
|
+
const backCandidate = items.find(item => item.actionKind === "navigate" ||
|
|
1248
|
+
this.isMediaOpenItem(item) ||
|
|
1249
|
+
this.isAuthEntryItem(item));
|
|
1250
|
+
if (backCandidate) {
|
|
1251
|
+
journeys.push(this.buildJourneyTestElement("Back and forward navigation journey", ["navigation", "history"], context, [
|
|
1252
|
+
this.buildJourneyAction(backCandidate, "Navigate to next state", [
|
|
1253
|
+
this.makeExpectation("navigation_or_state_changed", "Navigation should move to a different state"),
|
|
1254
|
+
]),
|
|
1255
|
+
this.waitStep(500, "Wait for next state"),
|
|
1256
|
+
this.buildBackStep([
|
|
1257
|
+
this.makeExpectation("back_navigation_restores_state", "Back navigation should restore the previous state"),
|
|
1258
|
+
]),
|
|
1259
|
+
this.waitStep(300, "Wait after back navigation"),
|
|
1260
|
+
this.buildForwardStep([
|
|
1261
|
+
this.makeExpectation("forward_navigation_reapplies_state", "Forward navigation should reapply the later state"),
|
|
1262
|
+
]),
|
|
1263
|
+
]));
|
|
1264
|
+
}
|
|
1265
|
+
return journeys;
|
|
1266
|
+
}
|
|
1267
|
+
buildJourneyTestElement(title, surfaceTags, context, steps) {
|
|
1268
|
+
return {
|
|
1269
|
+
title: `${title} — ${context.currentPath}`,
|
|
1270
|
+
type: "e2e",
|
|
1271
|
+
sizeClass: context.sizeClass,
|
|
1272
|
+
surface_tags: ["e2e", ...surfaceTags],
|
|
1273
|
+
priority: 2,
|
|
1274
|
+
startingPageStateId: context.currentPageStateId,
|
|
1275
|
+
startingPath: context.currentPath,
|
|
1276
|
+
steps,
|
|
1277
|
+
globalExpectations: this.defaultFlowExpectations(`${title} should complete without runtime errors`),
|
|
1278
|
+
uid: context.uid,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
buildJourneyAction(item, description, expectations) {
|
|
1282
|
+
const signal = this.describeActionableItem(item);
|
|
1283
|
+
const action = this.buildSemanticAction(item, description, signal);
|
|
1284
|
+
return {
|
|
1285
|
+
action,
|
|
1286
|
+
expectations,
|
|
1287
|
+
description: `${description}: ${signal}`,
|
|
1288
|
+
continueOnFailure: false,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
waitStep(ms, description) {
|
|
1292
|
+
return {
|
|
1293
|
+
action: {
|
|
1294
|
+
actionType: PlaywrightAction.WaitForTimeout,
|
|
1295
|
+
value: String(ms),
|
|
1296
|
+
playwrightCode: `await page.waitForTimeout(${ms})`,
|
|
1297
|
+
description,
|
|
1298
|
+
},
|
|
1299
|
+
expectations: [],
|
|
1300
|
+
description,
|
|
1301
|
+
continueOnFailure: true,
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
buildReloadStep(expectations) {
|
|
1305
|
+
return {
|
|
1306
|
+
action: {
|
|
1307
|
+
actionType: PlaywrightAction.Reload,
|
|
1308
|
+
playwrightCode: "await page.reload({ waitUntil: 'networkidle' })",
|
|
1309
|
+
description: "Reload page",
|
|
1310
|
+
},
|
|
1311
|
+
expectations,
|
|
1312
|
+
description: "Reload page",
|
|
1313
|
+
continueOnFailure: false,
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
buildBackStep(expectations) {
|
|
1317
|
+
return {
|
|
1318
|
+
action: {
|
|
1319
|
+
actionType: PlaywrightAction.GoBack,
|
|
1320
|
+
playwrightCode: "await page.goBack()",
|
|
1321
|
+
description: "Go back",
|
|
1322
|
+
},
|
|
1323
|
+
expectations,
|
|
1324
|
+
description: "Go back",
|
|
1325
|
+
continueOnFailure: false,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
buildForwardStep(expectations) {
|
|
1329
|
+
return {
|
|
1330
|
+
action: {
|
|
1331
|
+
actionType: PlaywrightAction.GoForward,
|
|
1332
|
+
playwrightCode: "await page.goForward()",
|
|
1333
|
+
description: "Go forward",
|
|
1334
|
+
},
|
|
1335
|
+
expectations,
|
|
1336
|
+
description: "Go forward",
|
|
1337
|
+
continueOnFailure: false,
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
defaultFlowExpectations(description) {
|
|
1341
|
+
return [
|
|
1342
|
+
{
|
|
1343
|
+
expectationType: ExpectationType.PageLoaded,
|
|
1344
|
+
severity: ExpectationSeverity.MustPass,
|
|
1345
|
+
description,
|
|
1346
|
+
playwrightCode: "await page.waitForLoadState('networkidle')",
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
expectationType: ExpectationType.NoNetworkErrors,
|
|
1350
|
+
severity: ExpectationSeverity.MustPass,
|
|
1351
|
+
description: "No network errors during flow",
|
|
1352
|
+
playwrightCode: "// checked by TesterExpertise",
|
|
1353
|
+
},
|
|
1354
|
+
{
|
|
1355
|
+
expectationType: ExpectationType.NoConsoleErrors,
|
|
1356
|
+
severity: ExpectationSeverity.ShouldPass,
|
|
1357
|
+
description: "No console errors during flow",
|
|
1358
|
+
playwrightCode: "// checked by TesterExpertise",
|
|
1359
|
+
},
|
|
1360
|
+
];
|
|
1361
|
+
}
|
|
1362
|
+
formPriority(formType) {
|
|
1363
|
+
if (formType === "login" || formType === "signup")
|
|
1364
|
+
return 1;
|
|
1365
|
+
return 2;
|
|
1366
|
+
}
|
|
1367
|
+
isNegativeCandidateField(field) {
|
|
1368
|
+
const analyzerField = field;
|
|
1369
|
+
return (field.required &&
|
|
1370
|
+
!analyzerField.disabled &&
|
|
1371
|
+
!analyzerField.readOnly &&
|
|
1372
|
+
!this.looksVisuallyDisabledField(analyzerField) &&
|
|
1373
|
+
!this.isCheckboxLike(field) &&
|
|
1374
|
+
!this.isSkippableFieldType(field));
|
|
1375
|
+
}
|
|
1376
|
+
isPasswordScenario(formType, form) {
|
|
1377
|
+
return (formType !== "other" &&
|
|
1378
|
+
form.fields.some(field => field.type === "password"));
|
|
1379
|
+
}
|
|
1380
|
+
isCheckboxLike(field) {
|
|
1381
|
+
return field.type === "checkbox" || field.type === "radio";
|
|
1382
|
+
}
|
|
1383
|
+
buildKeyboardAndDisclosureTestElements(context) {
|
|
1384
|
+
const items = context.actionableItems.filter(item => item.visible && !item.disabled && Boolean(item.selector));
|
|
1385
|
+
const tests = [];
|
|
1386
|
+
for (const item of items) {
|
|
1387
|
+
if (this.isDisclosureItem(item)) {
|
|
1388
|
+
tests.push(this.buildDisclosureToggleTestElement(item, context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
|
|
1389
|
+
tests.push(this.buildKeyboardActivateTestElement(item, "Enter", "Activate disclosure with Enter", [
|
|
1390
|
+
this.makeExpectation("expanded_state_changed", "Enter should toggle the disclosure state", {
|
|
1391
|
+
targetPath: item.selector,
|
|
1392
|
+
}),
|
|
1393
|
+
], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
|
|
1394
|
+
tests.push(this.buildKeyboardActivateTestElement(item, " ", "Activate disclosure with Space", [
|
|
1395
|
+
this.makeExpectation("expanded_state_changed", "Space should toggle the disclosure state", {
|
|
1396
|
+
targetPath: item.selector,
|
|
1397
|
+
}),
|
|
1398
|
+
], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
if (this.isKeyboardPrimaryAction(item)) {
|
|
1402
|
+
tests.push(this.buildKeyboardActivateTestElement(item, "Enter", "Activate with Enter", [
|
|
1403
|
+
this.makeExpectation("navigation_or_state_changed", "Enter key activation should change the page or control state"),
|
|
1404
|
+
], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
|
|
1405
|
+
}
|
|
1406
|
+
if (this.isKeyboardToggleAction(item)) {
|
|
1407
|
+
tests.push(this.buildKeyboardActivateTestElement(item, " ", "Toggle with Space", [
|
|
1408
|
+
this.makeExpectation(ExpectationType.ElementChecked, "Space key activation should toggle the control state", {
|
|
1409
|
+
targetPath: item.selector,
|
|
1410
|
+
}),
|
|
1411
|
+
], context.currentPath, context.sizeClass, context.uid, context.currentPageStateId));
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
return tests;
|
|
1415
|
+
}
|
|
1416
|
+
buildDisclosureToggleTestElement(item, startingPath, sizeClass, uid, startingPageStateId) {
|
|
1417
|
+
const label = this.describeActionableItem(item);
|
|
1418
|
+
return {
|
|
1419
|
+
title: `Toggle disclosure ${label}`,
|
|
1420
|
+
type: "interaction",
|
|
1421
|
+
sizeClass,
|
|
1422
|
+
surface_tags: ["disclosure", "click"],
|
|
1423
|
+
priority: 3,
|
|
1424
|
+
startingPageStateId,
|
|
1425
|
+
startingPath,
|
|
1426
|
+
steps: [
|
|
1427
|
+
{
|
|
1428
|
+
action: {
|
|
1429
|
+
actionType: PlaywrightAction.Click,
|
|
1430
|
+
path: item.selector,
|
|
1431
|
+
playwrightCode: `await page.click('${item.selector}')`,
|
|
1432
|
+
description: `Toggle disclosure ${label}`,
|
|
1433
|
+
},
|
|
1434
|
+
expectations: [
|
|
1435
|
+
this.makeExpectation("expanded_state_changed", "Clicking the disclosure should toggle its expanded state", {
|
|
1436
|
+
targetPath: item.selector,
|
|
1437
|
+
}),
|
|
1438
|
+
],
|
|
1439
|
+
description: `Toggle disclosure ${label}`,
|
|
1440
|
+
continueOnFailure: false,
|
|
1441
|
+
},
|
|
1442
|
+
],
|
|
1443
|
+
globalExpectations: this.defaultFlowExpectations("Disclosure interaction should complete without runtime errors"),
|
|
1444
|
+
uid,
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
buildKeyboardActivateTestElement(item, key, titlePrefix, expectations, startingPath, sizeClass, uid, startingPageStateId) {
|
|
1448
|
+
const label = this.describeActionableItem(item);
|
|
1449
|
+
return {
|
|
1450
|
+
title: `${titlePrefix} ${label}`,
|
|
1451
|
+
type: "interaction",
|
|
1452
|
+
sizeClass,
|
|
1453
|
+
surface_tags: [
|
|
1454
|
+
"keyboard",
|
|
1455
|
+
key.trim() === "" ? "space" : key.toLowerCase(),
|
|
1456
|
+
],
|
|
1457
|
+
priority: 3,
|
|
1458
|
+
startingPageStateId,
|
|
1459
|
+
startingPath,
|
|
1460
|
+
steps: [
|
|
1461
|
+
{
|
|
1462
|
+
action: {
|
|
1463
|
+
actionType: PlaywrightAction.Focus,
|
|
1464
|
+
path: item.selector,
|
|
1465
|
+
playwrightCode: `await page.locator('${item.selector}').focus()`,
|
|
1466
|
+
description: `Focus ${label}`,
|
|
1467
|
+
},
|
|
1468
|
+
expectations: [],
|
|
1469
|
+
description: `Focus ${label}`,
|
|
1470
|
+
continueOnFailure: false,
|
|
1471
|
+
},
|
|
1472
|
+
{
|
|
1473
|
+
action: {
|
|
1474
|
+
actionType: PlaywrightAction.Press,
|
|
1475
|
+
value: key,
|
|
1476
|
+
playwrightCode: `await page.keyboard.press('${key === " " ? "Space" : key}')`,
|
|
1477
|
+
description: `${titlePrefix} ${label}`,
|
|
1478
|
+
},
|
|
1479
|
+
expectations,
|
|
1480
|
+
description: `${titlePrefix} ${label}`,
|
|
1481
|
+
continueOnFailure: false,
|
|
1482
|
+
},
|
|
1483
|
+
],
|
|
1484
|
+
globalExpectations: this.defaultFlowExpectations("Keyboard activation should complete without runtime errors"),
|
|
1485
|
+
uid,
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
buildDialogCloseTestElement(item, startingPath, sizeClass, uid, startingPageStateId) {
|
|
1489
|
+
const label = this.describeActionableItem(item);
|
|
1490
|
+
return {
|
|
1491
|
+
title: `Close dialog via ${label}`,
|
|
1492
|
+
type: "interaction",
|
|
1493
|
+
sizeClass,
|
|
1494
|
+
surface_tags: ["dialog", "close"],
|
|
1495
|
+
priority: 2,
|
|
1496
|
+
startingPageStateId,
|
|
1497
|
+
startingPath,
|
|
1498
|
+
steps: [
|
|
1499
|
+
{
|
|
1500
|
+
action: {
|
|
1501
|
+
actionType: PlaywrightAction.Click,
|
|
1502
|
+
path: item.selector,
|
|
1503
|
+
playwrightCode: `await page.click('${item.selector}')`,
|
|
1504
|
+
description: `Close dialog via ${label}`,
|
|
1505
|
+
},
|
|
1506
|
+
expectations: [
|
|
1507
|
+
this.makeExpectation("dialog_closed", "Close action should dismiss the open dialog"),
|
|
1508
|
+
this.makeExpectation("focus_returned", "Focus should return after the dialog closes"),
|
|
1509
|
+
],
|
|
1510
|
+
description: `Close dialog via ${label}`,
|
|
1511
|
+
continueOnFailure: false,
|
|
1512
|
+
},
|
|
1513
|
+
],
|
|
1514
|
+
globalExpectations: this.defaultFlowExpectations("Dialog should close cleanly"),
|
|
1515
|
+
uid,
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
buildEscapeDialogTestElement(startingPath, sizeClass, uid, startingPageStateId) {
|
|
1519
|
+
return {
|
|
1520
|
+
title: "Close dialog with Escape",
|
|
1521
|
+
type: "interaction",
|
|
1522
|
+
sizeClass,
|
|
1523
|
+
surface_tags: ["dialog", "escape"],
|
|
1524
|
+
priority: 2,
|
|
1525
|
+
startingPageStateId,
|
|
1526
|
+
startingPath,
|
|
1527
|
+
steps: [
|
|
1528
|
+
{
|
|
1529
|
+
action: {
|
|
1530
|
+
actionType: PlaywrightAction.Press,
|
|
1531
|
+
value: "Escape",
|
|
1532
|
+
playwrightCode: "await page.keyboard.press('Escape')",
|
|
1533
|
+
description: "Press Escape",
|
|
1534
|
+
},
|
|
1535
|
+
expectations: [
|
|
1536
|
+
this.makeExpectation("dialog_closed", "Escape should dismiss the open dialog"),
|
|
1537
|
+
this.makeExpectation("focus_returned", "Focus should return after Escape closes the dialog"),
|
|
1538
|
+
],
|
|
1539
|
+
description: "Press Escape",
|
|
1540
|
+
continueOnFailure: false,
|
|
1541
|
+
},
|
|
1542
|
+
],
|
|
1543
|
+
globalExpectations: this.defaultFlowExpectations("Dialog should close on Escape without runtime errors"),
|
|
1544
|
+
uid,
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
shouldUseDirectControlInteraction(item) {
|
|
1548
|
+
const role = (item.role ?? "").toLowerCase();
|
|
1549
|
+
const inputType = (item.inputType ?? "").toLowerCase();
|
|
1550
|
+
return (this.looksVisuallyDisabledButEnabled(item) ||
|
|
1551
|
+
item.actionKind === "fill" ||
|
|
1552
|
+
item.actionKind === "select" ||
|
|
1553
|
+
item.actionKind === "radio_select" ||
|
|
1554
|
+
role === "tab" ||
|
|
1555
|
+
role === "radio" ||
|
|
1556
|
+
role === "checkbox" ||
|
|
1557
|
+
role === "switch" ||
|
|
1558
|
+
inputType === "radio" ||
|
|
1559
|
+
inputType === "checkbox");
|
|
1560
|
+
}
|
|
1561
|
+
buildControlInteractionTestElement(item, startingPath, sizeClass, uid, startingPageStateId) {
|
|
1562
|
+
const label = item.accessibleName || item.textContent || item.selector;
|
|
1563
|
+
const role = (item.role ?? "").toLowerCase();
|
|
1564
|
+
const inputType = (item.inputType ?? "").toLowerCase();
|
|
1565
|
+
const isTab = role === "tab";
|
|
1566
|
+
const isFillControl = item.actionKind === "fill";
|
|
1567
|
+
const isSelectControl = item.actionKind === "select";
|
|
1568
|
+
const isRadioControl = item.actionKind === "radio_select" ||
|
|
1569
|
+
role === "radio" ||
|
|
1570
|
+
inputType === "radio";
|
|
1571
|
+
const isCheckboxControl = role === "checkbox" || role === "switch" || inputType === "checkbox";
|
|
1572
|
+
const expectsChecked = isRadioControl || isCheckboxControl || isTab;
|
|
1573
|
+
const expectsInputValue = isFillControl || isSelectControl;
|
|
1574
|
+
const plannedValue = isSelectControl
|
|
1575
|
+
? this.extractSelectableValue(item)
|
|
1576
|
+
: isFillControl
|
|
1577
|
+
? fillValuePlanner.planValue(item)
|
|
1578
|
+
: undefined;
|
|
1579
|
+
const isImmutable = this.looksVisuallyDisabledButEnabled(item);
|
|
1580
|
+
let actionType = PlaywrightAction.Click;
|
|
1581
|
+
let actionValue;
|
|
1582
|
+
let playwrightCode = `await page.click('${item.selector}')`;
|
|
1583
|
+
let description = `${isTab ? "Select" : "Activate"} ${label}`;
|
|
1584
|
+
if (isFillControl) {
|
|
1585
|
+
actionType = PlaywrightAction.Type;
|
|
1586
|
+
actionValue = plannedValue;
|
|
1587
|
+
playwrightCode = `await page.locator('${item.selector}').type('${this.escapeSingleQuotes(plannedValue ?? "")}')`;
|
|
1588
|
+
description = `Type into ${label}`;
|
|
1589
|
+
}
|
|
1590
|
+
else if (isSelectControl) {
|
|
1591
|
+
actionType = PlaywrightAction.SelectOption;
|
|
1592
|
+
actionValue = plannedValue;
|
|
1593
|
+
playwrightCode = `await page.locator('${item.selector}').selectOption('${this.escapeSingleQuotes(plannedValue ?? "")}')`;
|
|
1594
|
+
description = `Select ${label}`;
|
|
1595
|
+
}
|
|
1596
|
+
else if (isRadioControl) {
|
|
1597
|
+
actionType = PlaywrightAction.Click;
|
|
1598
|
+
playwrightCode = `await page.click('${item.selector}')`;
|
|
1599
|
+
description = `Select ${label}`;
|
|
1600
|
+
}
|
|
1601
|
+
const expectations = isImmutable
|
|
1602
|
+
? this.buildImmutableControlExpectations(item, label)
|
|
1603
|
+
: expectsInputValue
|
|
1604
|
+
? [
|
|
1605
|
+
this.makeExpectation(ExpectationType.InputValue, `${description} should update the control value`, {
|
|
1606
|
+
targetPath: item.selector,
|
|
1607
|
+
expectedValue: plannedValue,
|
|
1608
|
+
}),
|
|
1609
|
+
]
|
|
1610
|
+
: expectsChecked
|
|
1611
|
+
? [
|
|
1612
|
+
this.makeExpectation(ExpectationType.ElementChecked, `${label} should react to user input`, {
|
|
1613
|
+
targetPath: item.selector,
|
|
1614
|
+
}),
|
|
1615
|
+
]
|
|
1616
|
+
: [];
|
|
1617
|
+
return {
|
|
1618
|
+
title: isImmutable ? `Visually Disabled ${description}` : description,
|
|
1619
|
+
type: "interaction",
|
|
1620
|
+
sizeClass,
|
|
1621
|
+
surface_tags: [
|
|
1622
|
+
"interaction",
|
|
1623
|
+
isImmutable ? "visually-disabled" : "enabled",
|
|
1624
|
+
role || inputType || item.actionKind || "control",
|
|
1625
|
+
],
|
|
1626
|
+
priority: 3,
|
|
1627
|
+
startingPageStateId,
|
|
1628
|
+
startingPath,
|
|
1629
|
+
steps: [
|
|
1630
|
+
{
|
|
1631
|
+
action: {
|
|
1632
|
+
actionType,
|
|
1633
|
+
path: item.selector ?? undefined,
|
|
1634
|
+
value: actionValue,
|
|
1635
|
+
playwrightCode,
|
|
1636
|
+
description,
|
|
1637
|
+
},
|
|
1638
|
+
expectations,
|
|
1639
|
+
description,
|
|
1640
|
+
continueOnFailure: isImmutable,
|
|
1641
|
+
},
|
|
1642
|
+
],
|
|
1643
|
+
globalExpectations: this.defaultFlowExpectations(isImmutable
|
|
1644
|
+
? `${label} should remain non-interactive despite appearing disabled`
|
|
1645
|
+
: `${label} should react without runtime errors`),
|
|
1646
|
+
uid,
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
buildImmutableControlExpectations(item, label) {
|
|
1650
|
+
const role = (item.role ?? "").toLowerCase();
|
|
1651
|
+
const inputType = (item.inputType ?? "").toLowerCase();
|
|
1652
|
+
if (item.actionKind === "fill" || item.actionKind === "select") {
|
|
1653
|
+
return [
|
|
1654
|
+
this.makeExpectation(ExpectationType.InputValue, `${label} should not respond to user input while it appears disabled`, {
|
|
1655
|
+
targetPath: item.selector,
|
|
1656
|
+
expectNoChange: true,
|
|
1657
|
+
}),
|
|
1658
|
+
];
|
|
1659
|
+
}
|
|
1660
|
+
if (role === "tab" ||
|
|
1661
|
+
role === "radio" ||
|
|
1662
|
+
role === "checkbox" ||
|
|
1663
|
+
role === "switch" ||
|
|
1664
|
+
inputType === "radio" ||
|
|
1665
|
+
inputType === "checkbox" ||
|
|
1666
|
+
item.actionKind === "radio_select") {
|
|
1667
|
+
return [
|
|
1668
|
+
this.makeExpectation(ExpectationType.ElementChecked, `${label} should not change state while it appears disabled`, {
|
|
1669
|
+
targetPath: item.selector,
|
|
1670
|
+
expectNoChange: true,
|
|
1671
|
+
}),
|
|
1672
|
+
];
|
|
1673
|
+
}
|
|
1674
|
+
return [
|
|
1675
|
+
this.makeExpectation(ExpectationType.UrlUnchanged, `${label} should not trigger navigation while it appears disabled`, {
|
|
1676
|
+
expectNoChange: true,
|
|
1677
|
+
}),
|
|
1678
|
+
];
|
|
1679
|
+
}
|
|
1680
|
+
extractSelectableValue(item) {
|
|
1681
|
+
const rawOptions = item.attributes?.options;
|
|
1682
|
+
const parsedOptions = Array.isArray(rawOptions)
|
|
1683
|
+
? rawOptions
|
|
1684
|
+
: typeof rawOptions === "string"
|
|
1685
|
+
? this.parseSelectOptions(rawOptions)
|
|
1686
|
+
: [];
|
|
1687
|
+
return parsedOptions.find((value) => typeof value === "string" && value.trim().length > 0);
|
|
1688
|
+
}
|
|
1689
|
+
parseSelectOptions(rawOptions) {
|
|
1690
|
+
try {
|
|
1691
|
+
const parsed = JSON.parse(rawOptions);
|
|
1692
|
+
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
|
|
1693
|
+
}
|
|
1694
|
+
catch {
|
|
1695
|
+
return rawOptions
|
|
1696
|
+
.split(",")
|
|
1697
|
+
.map(value => value.trim())
|
|
1698
|
+
.filter(Boolean);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
looksVisuallyDisabledButEnabled(item) {
|
|
1702
|
+
if (item.disabled)
|
|
1703
|
+
return false;
|
|
1704
|
+
const attrs = Object.entries(item.attributes ?? {})
|
|
1705
|
+
.map(([key, value]) => `${key}=${String(value)}`)
|
|
1706
|
+
.join(" ")
|
|
1707
|
+
.toLowerCase();
|
|
1708
|
+
return this.hasDisabledAppearanceSignal(attrs);
|
|
1709
|
+
}
|
|
1710
|
+
looksVisuallyDisabledField(field) {
|
|
1711
|
+
if (field.disabled)
|
|
1712
|
+
return false;
|
|
1713
|
+
return this.hasDisabledAppearanceSignal((field.appearanceHint ?? "").toLowerCase());
|
|
1714
|
+
}
|
|
1715
|
+
hasDisabledAppearanceSignal(value) {
|
|
1716
|
+
if (!value)
|
|
1717
|
+
return false;
|
|
1718
|
+
return (value.includes("aria-disabled=true") ||
|
|
1719
|
+
value.includes("cursor-not-allowed") ||
|
|
1720
|
+
value.includes("pointer-events:none") ||
|
|
1721
|
+
value.includes("pointer-events: none") ||
|
|
1722
|
+
value.includes("opacity-50") ||
|
|
1723
|
+
value.includes("opacity:0.5") ||
|
|
1724
|
+
value.includes("opacity: 0.5") ||
|
|
1725
|
+
value.includes("opacity-40") ||
|
|
1726
|
+
value.includes("disabled"));
|
|
1727
|
+
}
|
|
1728
|
+
describeActionableItem(item) {
|
|
1729
|
+
return (item.accessibleName ||
|
|
1730
|
+
item.textContent ||
|
|
1731
|
+
String(item.attributes?.labelText ?? "") ||
|
|
1732
|
+
String(item.attributes?.placeholder ?? "") ||
|
|
1733
|
+
item.selector);
|
|
1734
|
+
}
|
|
1735
|
+
semanticText(item) {
|
|
1736
|
+
return [
|
|
1737
|
+
item.accessibleName || "",
|
|
1738
|
+
item.textContent || "",
|
|
1739
|
+
String(item.attributes?.labelText ?? ""),
|
|
1740
|
+
String(item.attributes?.placeholder ?? ""),
|
|
1741
|
+
String(item.attributes?.name ?? ""),
|
|
1742
|
+
String(item.attributes?.id ?? ""),
|
|
1743
|
+
item.href || "",
|
|
1744
|
+
item.selector,
|
|
1745
|
+
]
|
|
1746
|
+
.join(" ")
|
|
1747
|
+
.toLowerCase();
|
|
1748
|
+
}
|
|
1749
|
+
isAddToCartItem(item) {
|
|
1750
|
+
return /\b(add to cart|add to bag|buy now|add item)\b/.test(this.semanticText(item));
|
|
1751
|
+
}
|
|
1752
|
+
isCheckoutItem(item) {
|
|
1753
|
+
return /\b(checkout|proceed to checkout|submit order|place order)\b/.test(this.semanticText(item));
|
|
1754
|
+
}
|
|
1755
|
+
isRemoveItemAction(item) {
|
|
1756
|
+
return /\b(remove|delete|trash|clear item|remove item)\b/.test(this.semanticText(item));
|
|
1757
|
+
}
|
|
1758
|
+
isAuthEntryItem(item) {
|
|
1759
|
+
return /\b(sign up|register|create account|sign in|log in|login)\b/.test(this.semanticText(item));
|
|
1760
|
+
}
|
|
1761
|
+
isMediaOpenItem(item) {
|
|
1762
|
+
const text = this.semanticText(item);
|
|
1763
|
+
return (item.tagName === "VIDEO" ||
|
|
1764
|
+
item.tagName === "AUDIO" ||
|
|
1765
|
+
/\b(image|photo|gallery|video|play|watch|zoom|preview)\b/.test(text));
|
|
1766
|
+
}
|
|
1767
|
+
isVideoLikeItem(item) {
|
|
1768
|
+
return (item.tagName === "VIDEO" ||
|
|
1769
|
+
/\b(video|play|watch)\b/.test(this.semanticText(item)));
|
|
1770
|
+
}
|
|
1771
|
+
isQuantityAction(item) {
|
|
1772
|
+
return /\b(qty|quantity|increase|decrease|increment|decrement|plus|minus)\b/.test(this.semanticText(item));
|
|
1773
|
+
}
|
|
1774
|
+
isFilterAction(item) {
|
|
1775
|
+
return /\b(filter|refine|apply filter|show results|category|brand|size|color|price)\b/.test(this.semanticText(item));
|
|
1776
|
+
}
|
|
1777
|
+
isSortAction(item) {
|
|
1778
|
+
return /\b(sort|order by|best selling|price low|price high|newest|featured)\b/.test(this.semanticText(item));
|
|
1779
|
+
}
|
|
1780
|
+
isDialogCloseItem(item) {
|
|
1781
|
+
return /\b(close|dismiss|cancel|done|x)\b/.test(this.semanticText(item));
|
|
1782
|
+
}
|
|
1783
|
+
pageHasOpenDialog(html) {
|
|
1784
|
+
return (/role=["']dialog["']/i.test(html) ||
|
|
1785
|
+
/role=["']alertdialog["']/i.test(html) ||
|
|
1786
|
+
/aria-modal=["']true["']/i.test(html) ||
|
|
1787
|
+
/\bmodal\b/i.test(html) ||
|
|
1788
|
+
/\boverlay\b/i.test(html));
|
|
1789
|
+
}
|
|
1790
|
+
isDisclosureItem(item) {
|
|
1791
|
+
const expanded = String(item.attributes?.["aria-expanded"] ?? "").toLowerCase();
|
|
1792
|
+
return expanded === "true" || expanded === "false";
|
|
1793
|
+
}
|
|
1794
|
+
isKeyboardPrimaryAction(item) {
|
|
1795
|
+
const role = (item.role ?? "").toLowerCase();
|
|
1796
|
+
return (item.actionKind === "navigate" ||
|
|
1797
|
+
role === "button" ||
|
|
1798
|
+
role === "link" ||
|
|
1799
|
+
role === "menuitem" ||
|
|
1800
|
+
item.tagName === "BUTTON" ||
|
|
1801
|
+
item.tagName === "A");
|
|
1802
|
+
}
|
|
1803
|
+
isKeyboardToggleAction(item) {
|
|
1804
|
+
const role = (item.role ?? "").toLowerCase();
|
|
1805
|
+
const inputType = (item.inputType ?? "").toLowerCase();
|
|
1806
|
+
return (role === "checkbox" ||
|
|
1807
|
+
role === "switch" ||
|
|
1808
|
+
role === "radio" ||
|
|
1809
|
+
inputType === "checkbox" ||
|
|
1810
|
+
inputType === "radio");
|
|
1811
|
+
}
|
|
1812
|
+
buildSemanticAction(item, description, signal) {
|
|
1813
|
+
if (item.actionKind === "select") {
|
|
1814
|
+
const value = this.extractSelectableValue(item);
|
|
1815
|
+
return {
|
|
1816
|
+
actionType: PlaywrightAction.SelectOption,
|
|
1817
|
+
path: item.selector,
|
|
1818
|
+
value,
|
|
1819
|
+
playwrightCode: `await page.locator('${item.selector}').selectOption('${this.escapeSingleQuotes(value ?? "")}')`,
|
|
1820
|
+
description: `${description}: ${signal}`,
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
if (item.actionKind === "fill") {
|
|
1824
|
+
const value = this.isQuantityAction(item)
|
|
1825
|
+
? "2"
|
|
1826
|
+
: fillValuePlanner.planValue(item);
|
|
1827
|
+
return {
|
|
1828
|
+
actionType: PlaywrightAction.Type,
|
|
1829
|
+
path: item.selector,
|
|
1830
|
+
value,
|
|
1831
|
+
playwrightCode: `await page.locator('${item.selector}').type('${this.escapeSingleQuotes(value)}')`,
|
|
1832
|
+
description: `${description}: ${signal}`,
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
return {
|
|
1836
|
+
actionType: PlaywrightAction.Click,
|
|
1837
|
+
path: item.selector,
|
|
1838
|
+
playwrightCode: `await page.click('${item.selector}')`,
|
|
1839
|
+
description: `${description}: ${signal}`,
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
makeExpectation(expectationType, description, extras) {
|
|
1843
|
+
return {
|
|
1844
|
+
expectationType,
|
|
1845
|
+
severity: ExpectationSeverity.ShouldPass,
|
|
1846
|
+
description,
|
|
1847
|
+
playwrightCode: "// evaluated by TesterExpertise",
|
|
1848
|
+
...(extras ?? {}),
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
isSelectLike(field) {
|
|
1852
|
+
return field.type === "select" || field.type === "select-one";
|
|
1853
|
+
}
|
|
1854
|
+
isSkippableFieldType(field) {
|
|
1855
|
+
return ["hidden", "submit", "button", "image", "file"].includes(field.type);
|
|
1856
|
+
}
|
|
1857
|
+
fieldLabel(field) {
|
|
1858
|
+
return field.label || field.name || field.selector;
|
|
1859
|
+
}
|
|
1860
|
+
improbableSearchQuery() {
|
|
1861
|
+
return "zzzz-no-results-testomniac";
|
|
1862
|
+
}
|
|
1863
|
+
slugify(value) {
|
|
1864
|
+
return (value
|
|
1865
|
+
.toLowerCase()
|
|
1866
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1867
|
+
.replace(/^-+|-+$/g, "") || "page");
|
|
1868
|
+
}
|
|
1869
|
+
escapeSingleQuotes(value) {
|
|
1870
|
+
return value.replace(/'/g, "\\'");
|
|
1871
|
+
}
|
|
1872
|
+
extractVisibleText(html) {
|
|
1873
|
+
return html
|
|
1874
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
1875
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
1876
|
+
.replace(/<[^>]+>/g, " ")
|
|
1877
|
+
.replace(/\s+/g, " ")
|
|
1878
|
+
.trim();
|
|
1879
|
+
}
|
|
1880
|
+
detectPasswordRequirements(visibleText) {
|
|
1881
|
+
const lower = visibleText.toLowerCase();
|
|
1882
|
+
const requirements = {
|
|
1883
|
+
requiresUppercase: false,
|
|
1884
|
+
requiresLowercase: false,
|
|
1885
|
+
requiresNumber: false,
|
|
1886
|
+
requiresSpecial: false,
|
|
1887
|
+
noSpaces: false,
|
|
1888
|
+
};
|
|
1889
|
+
const lengthMatch = lower.match(/(?:at least|minimum|min\.?)\s*(\d+)\s*character/i) ||
|
|
1890
|
+
lower.match(/(\d+)\+?\s*character/i);
|
|
1891
|
+
if (lengthMatch) {
|
|
1892
|
+
requirements.minLength = Number.parseInt(lengthMatch[1], 10);
|
|
1893
|
+
}
|
|
1894
|
+
if (/uppercase|capital letter/i.test(lower))
|
|
1895
|
+
requirements.requiresUppercase = true;
|
|
1896
|
+
if (/lowercase/i.test(lower))
|
|
1897
|
+
requirements.requiresLowercase = true;
|
|
1898
|
+
if (/number|digit|\d/i.test(lower) &&
|
|
1899
|
+
/must|require|contain|include/i.test(lower)) {
|
|
1900
|
+
requirements.requiresNumber = true;
|
|
1901
|
+
}
|
|
1902
|
+
if (/special character|symbol|[!@#$%^&*]/i.test(lower) &&
|
|
1903
|
+
/must|require|contain|include/i.test(lower)) {
|
|
1904
|
+
requirements.requiresSpecial = true;
|
|
1905
|
+
}
|
|
1906
|
+
if (/no\s*spaces/i.test(lower))
|
|
1907
|
+
requirements.noSpaces = true;
|
|
1908
|
+
return requirements;
|
|
1909
|
+
}
|
|
1910
|
+
generatePasswordVariants(requirements) {
|
|
1911
|
+
const variants = [];
|
|
1912
|
+
const minimumLength = Math.max(requirements.minLength ?? 8, 8);
|
|
1913
|
+
let validPassword = "Aa1!";
|
|
1914
|
+
while (validPassword.length < minimumLength) {
|
|
1915
|
+
validPassword += "xY2@".charAt(validPassword.length % 4);
|
|
1916
|
+
}
|
|
1917
|
+
if (requirements.minLength) {
|
|
1918
|
+
variants.push({
|
|
1919
|
+
password: validPassword.slice(0, Math.max(requirements.minLength - 1, 1)),
|
|
1920
|
+
description: "too short password",
|
|
1921
|
+
shouldFail: true,
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
if (requirements.requiresUppercase) {
|
|
1925
|
+
variants.push({
|
|
1926
|
+
password: validPassword.toLowerCase(),
|
|
1927
|
+
description: "missing uppercase password",
|
|
1928
|
+
shouldFail: true,
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
if (requirements.requiresLowercase) {
|
|
1932
|
+
variants.push({
|
|
1933
|
+
password: validPassword.toUpperCase(),
|
|
1934
|
+
description: "missing lowercase password",
|
|
1935
|
+
shouldFail: true,
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
if (requirements.requiresNumber) {
|
|
1939
|
+
variants.push({
|
|
1940
|
+
password: validPassword.replace(/\d/g, "a"),
|
|
1941
|
+
description: "missing number password",
|
|
1942
|
+
shouldFail: true,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
if (requirements.requiresSpecial) {
|
|
1946
|
+
variants.push({
|
|
1947
|
+
password: validPassword.replace(/[^a-zA-Z0-9]/g, "a"),
|
|
1948
|
+
description: "missing special character password",
|
|
1949
|
+
shouldFail: true,
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
if (requirements.noSpaces) {
|
|
1953
|
+
variants.push({
|
|
1954
|
+
password: `${validPassword.slice(0, 4)} ${validPassword.slice(4)}`,
|
|
1955
|
+
description: "password with spaces",
|
|
1956
|
+
shouldFail: true,
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
variants.push({
|
|
1960
|
+
password: validPassword,
|
|
1961
|
+
description: "valid password",
|
|
1962
|
+
shouldFail: false,
|
|
1963
|
+
});
|
|
1964
|
+
return variants;
|
|
1965
|
+
}
|
|
372
1966
|
}
|
|
373
1967
|
//# sourceMappingURL=page-analyzer.js.map
|