laxy-verify 1.2.0 → 1.2.2
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/README.md +12 -17
- package/dist/audit/broken-links.d.ts +21 -21
- package/dist/audit/broken-links.js +86 -86
- package/dist/auth.d.ts +11 -11
- package/dist/auth.js +222 -222
- package/dist/cli.js +868 -806
- package/dist/comment.d.ts +21 -21
- package/dist/comment.js +125 -125
- package/dist/config.d.ts +13 -0
- package/dist/config.js +43 -3
- package/dist/crawler.d.ts +36 -36
- package/dist/crawler.js +357 -357
- package/dist/e2e.d.ts +49 -49
- package/dist/e2e.js +565 -565
- package/dist/entitlement.d.ts +11 -11
- package/dist/entitlement.js +90 -90
- package/dist/init.js +87 -87
- package/dist/multi-viewport.d.ts +31 -31
- package/dist/multi-viewport.js +298 -298
- package/dist/playwright-runner.d.ts +16 -16
- package/dist/playwright-runner.js +208 -208
- package/dist/report-markdown.d.ts +39 -39
- package/dist/report-markdown.js +386 -386
- package/dist/security-audit.d.ts +9 -9
- package/dist/security-audit.js +64 -64
- package/dist/serve.d.ts +13 -13
- package/dist/serve.js +196 -196
- package/dist/trend.d.ts +50 -50
- package/dist/trend.js +148 -148
- package/dist/verification-core/index.d.ts +3 -3
- package/dist/verification-core/index.js +19 -19
- package/dist/verification-core/report.d.ts +14 -14
- package/dist/verification-core/report.js +409 -409
- package/dist/verification-core/tier-policy.d.ts +13 -13
- package/dist/verification-core/tier-policy.js +60 -60
- package/dist/verification-core/types.d.ts +108 -108
- package/dist/verification-core/types.js +2 -2
- package/dist/visual-diff.d.ts +26 -26
- package/dist/visual-diff.js +178 -178
- package/package.json +1 -1
package/dist/e2e.js
CHANGED
|
@@ -1,565 +1,565 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.isNavigableInternalHref = isNavigableInternalHref;
|
|
7
|
-
exports.getVerificationCoverageGaps = getVerificationCoverageGaps;
|
|
8
|
-
exports.buildVerifyScenarios = buildVerifyScenarios;
|
|
9
|
-
exports.convertUserScenarios = convertUserScenarios;
|
|
10
|
-
exports.runVerifyE2E = runVerifyE2E;
|
|
11
|
-
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
12
|
-
const crawler_js_1 = require("./crawler.js");
|
|
13
|
-
function isNavigableInternalHref(href) {
|
|
14
|
-
if (!href.startsWith("/"))
|
|
15
|
-
return false;
|
|
16
|
-
if (href.startsWith("/_next/"))
|
|
17
|
-
return false;
|
|
18
|
-
if (href.startsWith("/api/"))
|
|
19
|
-
return false;
|
|
20
|
-
if (href === "/" || href.startsWith("/#"))
|
|
21
|
-
return false;
|
|
22
|
-
if (href.includes("?"))
|
|
23
|
-
return false;
|
|
24
|
-
if (/\.[a-z0-9]{2,8}$/i.test(href))
|
|
25
|
-
return false;
|
|
26
|
-
return true;
|
|
27
|
-
}
|
|
28
|
-
function normalize(value) {
|
|
29
|
-
return value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
30
|
-
}
|
|
31
|
-
function pickSelector(candidates, patterns) {
|
|
32
|
-
for (const pattern of patterns) {
|
|
33
|
-
const match = candidates.find((candidate) => pattern.test(normalize(candidate)));
|
|
34
|
-
if (match)
|
|
35
|
-
return match;
|
|
36
|
-
}
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
|
-
function getScenarioLimit(tier) {
|
|
40
|
-
void tier;
|
|
41
|
-
return 5;
|
|
42
|
-
}
|
|
43
|
-
function getVisibleAnchor(selectors) {
|
|
44
|
-
return pickSelector(selectors, [/^main$/, /^form$/, /^section$/, /^h1$/, /^h2$/, /^button/, /^a\[href/]) || "body";
|
|
45
|
-
}
|
|
46
|
-
function getClickTarget(selectors) {
|
|
47
|
-
return pickSelector(selectors, [
|
|
48
|
-
/^button\[type=['"]submit['"]\]/,
|
|
49
|
-
/^input\[type=['"]submit['"]\]/,
|
|
50
|
-
/^button\[aria-label.*(submit|continue|login|sign in|save|start|search|next)/,
|
|
51
|
-
/^input\[type=['"]checkbox['"]\]/,
|
|
52
|
-
/^\[role=['"]checkbox['"]\]$/,
|
|
53
|
-
/^a\[href=['"]\//,
|
|
54
|
-
/^button/,
|
|
55
|
-
/^\[role=['"]button['"]\]$/,
|
|
56
|
-
]);
|
|
57
|
-
}
|
|
58
|
-
function getFillTarget(selectors) {
|
|
59
|
-
return pickSelector(selectors, [
|
|
60
|
-
/^input\[type=['"]email['"]\]/,
|
|
61
|
-
/^input\[type=['"]text['"]\]/,
|
|
62
|
-
/^input\[name.*(email|query|search|name)/,
|
|
63
|
-
/^input\[placeholder.*(email|search|name|message|title)/,
|
|
64
|
-
/^textarea$/,
|
|
65
|
-
/^input/,
|
|
66
|
-
]);
|
|
67
|
-
}
|
|
68
|
-
function getFeedbackTarget(selectors) {
|
|
69
|
-
return pickSelector(selectors, [
|
|
70
|
-
/^\[role=['"]status['"]\]$/,
|
|
71
|
-
/^\[aria-live=['"](polite|assertive)['"]\]$/,
|
|
72
|
-
/^\[role=['"]alert['"]\]$/,
|
|
73
|
-
/^\[data-testid.*(error|success|toast|alert|notice|result)/,
|
|
74
|
-
/^\.(error|alert|toast|notice|success|status)/,
|
|
75
|
-
]);
|
|
76
|
-
}
|
|
77
|
-
function getRequiredFillTarget(selectors) {
|
|
78
|
-
return pickSelector(selectors, [
|
|
79
|
-
/^input\[required\]$/,
|
|
80
|
-
/^textarea\[required\]$/,
|
|
81
|
-
/^input\[aria-required=['"]true['"]\]$/,
|
|
82
|
-
/^textarea\[aria-required=['"]true['"]\]$/,
|
|
83
|
-
]);
|
|
84
|
-
}
|
|
85
|
-
function getFillValue(selector) {
|
|
86
|
-
const s = selector.toLowerCase();
|
|
87
|
-
if (/type=['"]email['"]|name.*email|placeholder.*email/.test(s))
|
|
88
|
-
return "test@example.com";
|
|
89
|
-
if (/type=['"]tel['"]|name.*(phone|tel)|placeholder.*(phone|tel)/.test(s))
|
|
90
|
-
return "555-0100";
|
|
91
|
-
if (/type=['"]number['"]/.test(s))
|
|
92
|
-
return "42";
|
|
93
|
-
if (/type=['"]password['"]/.test(s))
|
|
94
|
-
return "TestPass123!";
|
|
95
|
-
if (/type=['"]url['"]/.test(s))
|
|
96
|
-
return "https://example.com";
|
|
97
|
-
if (/type=['"]search['"]|name.*(query|search)|placeholder.*(search|query)/.test(s))
|
|
98
|
-
return "test query";
|
|
99
|
-
if (/name.*username|placeholder.*username/.test(s))
|
|
100
|
-
return "testuser";
|
|
101
|
-
if (/name.*name|placeholder.*name/.test(s))
|
|
102
|
-
return "Test User";
|
|
103
|
-
if (/textarea|name.*message|placeholder.*message/.test(s))
|
|
104
|
-
return "This is a test message.";
|
|
105
|
-
if (/name.*title|placeholder.*title/.test(s))
|
|
106
|
-
return "Test Title";
|
|
107
|
-
return "test input";
|
|
108
|
-
}
|
|
109
|
-
function getVerificationCoverageGaps(scenarios, tier) {
|
|
110
|
-
void tier;
|
|
111
|
-
const names = new Set(scenarios.map((scenario) => scenario.name));
|
|
112
|
-
const gaps = [];
|
|
113
|
-
const hasPrimaryAction = names.has("Primary form interaction") || names.has("Primary CTA interaction");
|
|
114
|
-
if (!hasPrimaryAction) {
|
|
115
|
-
gaps.push("No primary action scenario was detected, so the verify run could not validate a real user action.");
|
|
116
|
-
}
|
|
117
|
-
if (scenarios.length < 4) {
|
|
118
|
-
gaps.push("Too few meaningful scenarios were detected for a full verification pass, so this run stayed shallower than expected.");
|
|
119
|
-
}
|
|
120
|
-
return gaps;
|
|
121
|
-
}
|
|
122
|
-
function buildVerifyScenarios(snapshot, tier) {
|
|
123
|
-
const selectors = [...snapshot.selectors, ...snapshot.structures];
|
|
124
|
-
const visibleAnchor = getVisibleAnchor(selectors);
|
|
125
|
-
const fillTarget = getFillTarget(selectors);
|
|
126
|
-
const clickTarget = getClickTarget(selectors);
|
|
127
|
-
const feedbackTarget = getFeedbackTarget(selectors);
|
|
128
|
-
const requiredFillTarget = getRequiredFillTarget(selectors);
|
|
129
|
-
const localLinkTarget = pickSelector(selectors, [/^a\[href=['"]\//]);
|
|
130
|
-
const likelyFormSurface = selectors.some((selector) => /^form$/.test(normalize(selector))) ||
|
|
131
|
-
selectors.some((selector) => /^input|^textarea/.test(normalize(selector)));
|
|
132
|
-
const scenarios = [
|
|
133
|
-
{
|
|
134
|
-
name: "Initial render",
|
|
135
|
-
steps: [
|
|
136
|
-
{ type: "wait", duration: 1200, description: "Wait for hydration" },
|
|
137
|
-
{ type: "check_visible", selector: "body", description: "Body should render" },
|
|
138
|
-
{ type: "check_healthy_page", description: "Page should not be an error screen" },
|
|
139
|
-
{ type: "check_visible", selector: visibleAnchor, description: "Core UI should stay visible" },
|
|
140
|
-
],
|
|
141
|
-
},
|
|
142
|
-
];
|
|
143
|
-
if (fillTarget && likelyFormSurface) {
|
|
144
|
-
const fillValue = getFillValue(fillTarget);
|
|
145
|
-
const formScenario = {
|
|
146
|
-
name: "Primary form interaction",
|
|
147
|
-
steps: [
|
|
148
|
-
{ type: "check_visible", selector: fillTarget, description: "Input surface should be visible" },
|
|
149
|
-
{ type: "clear_fill", selector: fillTarget, value: fillValue, description: `Fill a core input field with ${fillValue}` },
|
|
150
|
-
],
|
|
151
|
-
};
|
|
152
|
-
if (clickTarget) {
|
|
153
|
-
formScenario.steps.push({ type: "click", selector: clickTarget, description: "Trigger the primary CTA" }, { type: "wait", duration: 800, description: "Wait for UI response" }, { type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Feedback or surface should remain visible" });
|
|
154
|
-
}
|
|
155
|
-
scenarios.push(formScenario);
|
|
156
|
-
}
|
|
157
|
-
else if (clickTarget) {
|
|
158
|
-
scenarios.push({
|
|
159
|
-
name: "Primary CTA interaction",
|
|
160
|
-
steps: [
|
|
161
|
-
{ type: "check_visible", selector: clickTarget, description: "CTA should be visible" },
|
|
162
|
-
{ type: "click", selector: clickTarget, description: "Trigger the primary CTA" },
|
|
163
|
-
{ type: "wait", duration: 800, description: "Wait for UI response" },
|
|
164
|
-
{ type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Core surface should stay visible" },
|
|
165
|
-
],
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
if (fillTarget && clickTarget && (requiredFillTarget || feedbackTarget)) {
|
|
169
|
-
scenarios.push({
|
|
170
|
-
name: "Validation feedback",
|
|
171
|
-
steps: [
|
|
172
|
-
{ type: "clear_fill", selector: requiredFillTarget || fillTarget, value: "", description: "Clear the required input" },
|
|
173
|
-
{ type: "click", selector: clickTarget, description: "Try the CTA without valid input" },
|
|
174
|
-
{ type: "wait", duration: 700, description: "Wait for validation" },
|
|
175
|
-
{ type: "check_validation", selector: requiredFillTarget || fillTarget, description: "Validation feedback should appear" },
|
|
176
|
-
],
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
if (localLinkTarget) {
|
|
180
|
-
scenarios.push({
|
|
181
|
-
name: "Internal navigation",
|
|
182
|
-
steps: [
|
|
183
|
-
{ type: "check_visible", selector: localLinkTarget, description: "Internal link should be visible" },
|
|
184
|
-
{ type: "click", selector: localLinkTarget, description: "Navigate using an internal link" },
|
|
185
|
-
{ type: "wait", duration: 1000, description: "Wait for navigation" },
|
|
186
|
-
{ type: "check_healthy_page", description: "Destination should not be an error screen" },
|
|
187
|
-
{ type: "check_visible", selector: "body", description: "Destination page should render" },
|
|
188
|
-
],
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
if (clickTarget && fillTarget && clickTarget !== fillTarget) {
|
|
192
|
-
const fillValue = getFillValue(fillTarget);
|
|
193
|
-
scenarios.push({
|
|
194
|
-
name: "Repeated interaction stability",
|
|
195
|
-
steps: [
|
|
196
|
-
{ type: "check_visible", selector: fillTarget, description: "Input surface should still exist" },
|
|
197
|
-
{ type: "clear_fill", selector: fillTarget, value: fillValue, description: `Repeat the core input with ${fillValue}` },
|
|
198
|
-
{ type: "click", selector: clickTarget, description: "Trigger the CTA again" },
|
|
199
|
-
{ type: "wait", duration: 800, description: "Wait for repeated interaction response" },
|
|
200
|
-
{ type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Surface should still hold after repeat" },
|
|
201
|
-
],
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
scenarios.push({
|
|
205
|
-
name: "Scroll stability",
|
|
206
|
-
steps: [
|
|
207
|
-
{ type: "check_visible", selector: visibleAnchor, description: "Initial content should render" },
|
|
208
|
-
{ type: "scroll", selector: "body", description: "Page should scroll" },
|
|
209
|
-
{ type: "wait", duration: 500, description: "Wait after scrolling" },
|
|
210
|
-
{ type: "check_visible", selector: "body", description: "Page should remain stable after scroll" },
|
|
211
|
-
],
|
|
212
|
-
});
|
|
213
|
-
return scenarios.slice(0, getScenarioLimit(tier));
|
|
214
|
-
}
|
|
215
|
-
function convertUserScenarios(userScenarios) {
|
|
216
|
-
return userScenarios.map((scenario) => {
|
|
217
|
-
const steps = [];
|
|
218
|
-
let initialUrl;
|
|
219
|
-
for (const raw of scenario.steps) {
|
|
220
|
-
if (raw.goto) {
|
|
221
|
-
if (!initialUrl) {
|
|
222
|
-
// First goto becomes the scenario's initialUrl
|
|
223
|
-
initialUrl = raw.goto;
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
// Subsequent gotos become explicit navigation steps
|
|
227
|
-
steps.push({
|
|
228
|
-
type: "goto",
|
|
229
|
-
gotoUrl: raw.goto,
|
|
230
|
-
description: `Navigate to ${raw.goto}`,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
else if (raw.fill && raw.with !== undefined) {
|
|
235
|
-
steps.push({
|
|
236
|
-
type: "clear_fill",
|
|
237
|
-
selector: raw.fill,
|
|
238
|
-
value: raw.with,
|
|
239
|
-
description: `Fill ${raw.fill} with "${raw.with}"`,
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
else if (raw.click) {
|
|
243
|
-
steps.push({
|
|
244
|
-
type: "click",
|
|
245
|
-
selector: raw.click,
|
|
246
|
-
description: `Click ${raw.click}`,
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
else if (raw.expect_visible) {
|
|
250
|
-
steps.push({
|
|
251
|
-
type: "check_visible",
|
|
252
|
-
selector: raw.expect_visible,
|
|
253
|
-
description: `Expect ${raw.expect_visible} to be visible`,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
else if (raw.expect_text) {
|
|
257
|
-
steps.push({
|
|
258
|
-
type: "check_text",
|
|
259
|
-
expectedText: raw.expect_text,
|
|
260
|
-
description: `Expect text "${raw.expect_text}" on page`,
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
else if (raw.wait !== undefined) {
|
|
264
|
-
steps.push({
|
|
265
|
-
type: "wait",
|
|
266
|
-
duration: raw.wait,
|
|
267
|
-
description: `Wait ${raw.wait}ms`,
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
return {
|
|
272
|
-
name: scenario.name,
|
|
273
|
-
steps,
|
|
274
|
-
initialUrl,
|
|
275
|
-
};
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
async function captureDomSnapshot(url) {
|
|
279
|
-
const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
|
|
280
|
-
try {
|
|
281
|
-
const page = await browser.newPage();
|
|
282
|
-
await page.setViewport({ width: 1280, height: 720 });
|
|
283
|
-
await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
|
|
284
|
-
await page.waitForSelector("body", { timeout: 5000 });
|
|
285
|
-
const snapshot = await page.evaluate(() => {
|
|
286
|
-
const isNavigableInternalHref = (href) => {
|
|
287
|
-
if (!href.startsWith("/"))
|
|
288
|
-
return false;
|
|
289
|
-
if (href.startsWith("/_next/"))
|
|
290
|
-
return false;
|
|
291
|
-
if (href.startsWith("/api/"))
|
|
292
|
-
return false;
|
|
293
|
-
if (href === "/" || href.startsWith("/#"))
|
|
294
|
-
return false;
|
|
295
|
-
if (href.includes("?"))
|
|
296
|
-
return false;
|
|
297
|
-
if (/\.[a-z0-9]{2,8}$/i.test(href))
|
|
298
|
-
return false;
|
|
299
|
-
return true;
|
|
300
|
-
};
|
|
301
|
-
const selectors = [];
|
|
302
|
-
const structures = [];
|
|
303
|
-
const nodes = Array.from(document.querySelectorAll("*")).slice(0, 250);
|
|
304
|
-
for (const node of nodes) {
|
|
305
|
-
const tag = node.tagName.toLowerCase();
|
|
306
|
-
const role = node.getAttribute("role");
|
|
307
|
-
const type = node.getAttribute("type");
|
|
308
|
-
const name = node.getAttribute("name");
|
|
309
|
-
const placeholder = node.getAttribute("placeholder");
|
|
310
|
-
const href = node.getAttribute("href");
|
|
311
|
-
const ariaLabel = node.getAttribute("aria-label");
|
|
312
|
-
const dataTestId = node.getAttribute("data-testid");
|
|
313
|
-
const ariaLive = node.getAttribute("aria-live");
|
|
314
|
-
if (["main", "form", "section", "header", "nav", "footer", "h1", "h2"].includes(tag)) {
|
|
315
|
-
structures.push(tag);
|
|
316
|
-
}
|
|
317
|
-
if (tag === "button")
|
|
318
|
-
selectors.push("button");
|
|
319
|
-
if (role === "button")
|
|
320
|
-
selectors.push("[role='button']");
|
|
321
|
-
if (type === "submit")
|
|
322
|
-
selectors.push(`${tag}[type='submit']`);
|
|
323
|
-
if (type === "checkbox")
|
|
324
|
-
selectors.push(`${tag}[type='checkbox']`);
|
|
325
|
-
if (tag === "input" || tag === "textarea")
|
|
326
|
-
selectors.push(tag);
|
|
327
|
-
if (type)
|
|
328
|
-
selectors.push(`${tag}[type='${type}']`);
|
|
329
|
-
if (name)
|
|
330
|
-
selectors.push(`${tag}[name='${name}']`);
|
|
331
|
-
if (placeholder)
|
|
332
|
-
selectors.push(`${tag}[placeholder='${placeholder}']`);
|
|
333
|
-
if (href && isNavigableInternalHref(href))
|
|
334
|
-
selectors.push(`a[href='${href}']`);
|
|
335
|
-
if (ariaLabel)
|
|
336
|
-
selectors.push(`${tag}[aria-label='${ariaLabel}']`);
|
|
337
|
-
if (role === "alert" || role === "status")
|
|
338
|
-
selectors.push(`[role='${role}']`);
|
|
339
|
-
if (ariaLive)
|
|
340
|
-
selectors.push(`[aria-live='${ariaLive}']`);
|
|
341
|
-
if (dataTestId)
|
|
342
|
-
selectors.push(`[data-testid='${dataTestId}']`);
|
|
343
|
-
if ((tag === "input" || tag === "textarea") && node.hasAttribute("required")) {
|
|
344
|
-
selectors.push(`${tag}[required]`);
|
|
345
|
-
}
|
|
346
|
-
if ((tag === "input" || tag === "textarea") && node.getAttribute("aria-required") === "true") {
|
|
347
|
-
selectors.push(`${tag}[aria-required='true']`);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return {
|
|
351
|
-
selectors: Array.from(new Set(selectors)),
|
|
352
|
-
structures: Array.from(new Set(structures)),
|
|
353
|
-
};
|
|
354
|
-
});
|
|
355
|
-
return snapshot;
|
|
356
|
-
}
|
|
357
|
-
finally {
|
|
358
|
-
await browser.close();
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
async function executeScenario(url, scenario) {
|
|
362
|
-
const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
|
|
363
|
-
const stepResults = [];
|
|
364
|
-
const consoleErrors = [];
|
|
365
|
-
try {
|
|
366
|
-
const page = await browser.newPage();
|
|
367
|
-
page.on("console", (msg) => {
|
|
368
|
-
if (msg.type() === "error") {
|
|
369
|
-
consoleErrors.push(`Console error: ${msg.text().slice(0, 200)}`);
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
page.on("pageerror", (err) => {
|
|
373
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
374
|
-
consoleErrors.push(`Uncaught error: ${msg.slice(0, 200)}`);
|
|
375
|
-
});
|
|
376
|
-
await page.setViewport({ width: 1280, height: 720 });
|
|
377
|
-
const targetUrl = scenario.initialUrl ? new URL(scenario.initialUrl, url).href : url;
|
|
378
|
-
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 20000 });
|
|
379
|
-
await page.waitForSelector("body", { timeout: 5000 });
|
|
380
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
381
|
-
for (const step of scenario.steps) {
|
|
382
|
-
const result = { description: step.description, passed: false };
|
|
383
|
-
try {
|
|
384
|
-
switch (step.type) {
|
|
385
|
-
case "click":
|
|
386
|
-
if (!step.selector)
|
|
387
|
-
throw new Error("Missing selector");
|
|
388
|
-
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
389
|
-
await page.click(step.selector);
|
|
390
|
-
break;
|
|
391
|
-
case "fill":
|
|
392
|
-
case "clear_fill":
|
|
393
|
-
if (!step.selector)
|
|
394
|
-
throw new Error("Missing selector");
|
|
395
|
-
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
396
|
-
await page.click(step.selector, { clickCount: 3 });
|
|
397
|
-
await page.keyboard.press("Backspace");
|
|
398
|
-
if (step.value) {
|
|
399
|
-
await page.type(step.selector, step.value);
|
|
400
|
-
}
|
|
401
|
-
break;
|
|
402
|
-
case "check_visible":
|
|
403
|
-
if (!step.selector)
|
|
404
|
-
throw new Error("Missing selector");
|
|
405
|
-
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
406
|
-
break;
|
|
407
|
-
case "check_text": {
|
|
408
|
-
const textToFind = step.expectedText;
|
|
409
|
-
if (!textToFind)
|
|
410
|
-
throw new Error("Missing expectedText");
|
|
411
|
-
const found = await page.evaluate((text) => {
|
|
412
|
-
const bodyText = document.body?.innerText ?? "";
|
|
413
|
-
return bodyText.includes(text);
|
|
414
|
-
}, textToFind);
|
|
415
|
-
if (!found) {
|
|
416
|
-
throw new Error(`Expected text "${textToFind}" not found on page`);
|
|
417
|
-
}
|
|
418
|
-
break;
|
|
419
|
-
}
|
|
420
|
-
case "check_healthy_page":
|
|
421
|
-
const hasErrorPageSignals = await page.evaluate(() => {
|
|
422
|
-
const title = document.title ?? "";
|
|
423
|
-
const h1 = document.querySelector("h1")?.textContent ?? "";
|
|
424
|
-
const bodyText = document.body?.innerText?.slice(0, 1200) ?? "";
|
|
425
|
-
const haystack = `${title}\n${h1}\n${bodyText}`.toLowerCase();
|
|
426
|
-
const patterns = [
|
|
427
|
-
/internal server error/,
|
|
428
|
-
/application error/,
|
|
429
|
-
/unexpected application error/,
|
|
430
|
-
/this page could not be found/,
|
|
431
|
-
/\b404\b/,
|
|
432
|
-
/\b500\b/,
|
|
433
|
-
/server error/,
|
|
434
|
-
/something went wrong/,
|
|
435
|
-
];
|
|
436
|
-
return patterns.some((pattern) => pattern.test(haystack));
|
|
437
|
-
});
|
|
438
|
-
if (hasErrorPageSignals) {
|
|
439
|
-
throw new Error("The page looks like an error screen, not a healthy app surface.");
|
|
440
|
-
}
|
|
441
|
-
break;
|
|
442
|
-
case "check_validation":
|
|
443
|
-
if (!step.selector)
|
|
444
|
-
throw new Error("Missing selector");
|
|
445
|
-
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
446
|
-
const hasValidationEvidence = await page.$eval(step.selector, (element) => {
|
|
447
|
-
const field = element;
|
|
448
|
-
const feedbackSelectors = [
|
|
449
|
-
"[role='alert']",
|
|
450
|
-
"[role='status']",
|
|
451
|
-
"[aria-live='polite']",
|
|
452
|
-
"[aria-live='assertive']",
|
|
453
|
-
"[data-testid*='error']",
|
|
454
|
-
"[data-testid*='success']",
|
|
455
|
-
"[data-testid*='toast']",
|
|
456
|
-
"[data-testid*='notice']",
|
|
457
|
-
".error",
|
|
458
|
-
".alert",
|
|
459
|
-
".toast",
|
|
460
|
-
".notice",
|
|
461
|
-
".success",
|
|
462
|
-
".status",
|
|
463
|
-
];
|
|
464
|
-
const hasVisibleFeedback = feedbackSelectors.some((selector) => Array.from(document.querySelectorAll(selector)).some((node) => {
|
|
465
|
-
const el = node;
|
|
466
|
-
const style = window.getComputedStyle(el);
|
|
467
|
-
const text = el.textContent?.trim() ?? "";
|
|
468
|
-
return style.display !== "none" && style.visibility !== "hidden" && text.length > 0;
|
|
469
|
-
}));
|
|
470
|
-
const validity = "validity" in field ? field.validity : null;
|
|
471
|
-
const invalidField = !!validity && !validity.valid;
|
|
472
|
-
const validationMessage = "validationMessage" in field ? field.validationMessage?.trim().length > 0 : false;
|
|
473
|
-
const ariaInvalid = field.getAttribute("aria-invalid") === "true";
|
|
474
|
-
return hasVisibleFeedback || invalidField || validationMessage || ariaInvalid;
|
|
475
|
-
});
|
|
476
|
-
if (!hasValidationEvidence) {
|
|
477
|
-
throw new Error(`No validation evidence found for ${step.selector}`);
|
|
478
|
-
}
|
|
479
|
-
break;
|
|
480
|
-
case "wait":
|
|
481
|
-
await new Promise((resolve) => setTimeout(resolve, step.duration ?? 1000));
|
|
482
|
-
break;
|
|
483
|
-
case "scroll":
|
|
484
|
-
if (step.selector && step.selector !== "body") {
|
|
485
|
-
await page.$eval(step.selector, (element) => {
|
|
486
|
-
element.scrollIntoView({ behavior: "instant", block: "center" });
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
else {
|
|
490
|
-
await page.evaluate(() => window.scrollBy(0, 300));
|
|
491
|
-
}
|
|
492
|
-
break;
|
|
493
|
-
case "goto": {
|
|
494
|
-
if (!step.gotoUrl)
|
|
495
|
-
throw new Error("Missing gotoUrl");
|
|
496
|
-
const gotoTarget = new URL(step.gotoUrl, page.url()).href;
|
|
497
|
-
await page.goto(gotoTarget, { waitUntil: "networkidle2", timeout: 20000 });
|
|
498
|
-
await page.waitForSelector("body", { timeout: 5000 });
|
|
499
|
-
break;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
result.passed = true;
|
|
503
|
-
}
|
|
504
|
-
catch (error) {
|
|
505
|
-
result.passed = false;
|
|
506
|
-
result.error = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
507
|
-
}
|
|
508
|
-
stepResults.push(result);
|
|
509
|
-
if (!result.passed)
|
|
510
|
-
break;
|
|
511
|
-
}
|
|
512
|
-
return {
|
|
513
|
-
name: scenario.name,
|
|
514
|
-
passed: stepResults.every((step) => step.passed),
|
|
515
|
-
steps: stepResults,
|
|
516
|
-
consoleErrors,
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
catch (error) {
|
|
520
|
-
return {
|
|
521
|
-
name: scenario.name,
|
|
522
|
-
passed: false,
|
|
523
|
-
steps: stepResults,
|
|
524
|
-
error: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
|
|
525
|
-
consoleErrors,
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
finally {
|
|
529
|
-
await browser.close();
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
async function runVerifyE2E(url, tier, userScenarios, crawlOptions) {
|
|
533
|
-
let scenarios;
|
|
534
|
-
let coverageGaps;
|
|
535
|
-
let crawlResultUsed;
|
|
536
|
-
if (userScenarios && userScenarios.length > 0) {
|
|
537
|
-
console.log(` Using ${userScenarios.length} user-defined scenario(s) from .laxy.yml`);
|
|
538
|
-
scenarios = convertUserScenarios(userScenarios);
|
|
539
|
-
coverageGaps = [];
|
|
540
|
-
}
|
|
541
|
-
else if (crawlOptions?.enabled) {
|
|
542
|
-
console.log(" Crawling app to discover routes and interactions...");
|
|
543
|
-
crawlResultUsed = await (0, crawler_js_1.crawlApp)(url, crawlOptions);
|
|
544
|
-
console.log(` Crawled ${crawlResultUsed.crawledCount} page(s), found ${crawlResultUsed.totalLinks} internal link(s)`);
|
|
545
|
-
scenarios = (0, crawler_js_1.buildScenariosFromCrawl)(crawlResultUsed, tier);
|
|
546
|
-
if (scenarios.length === 0) {
|
|
547
|
-
const snapshot = await captureDomSnapshot(url);
|
|
548
|
-
scenarios = buildVerifyScenarios(snapshot, tier);
|
|
549
|
-
}
|
|
550
|
-
coverageGaps = getVerificationCoverageGaps(scenarios, tier);
|
|
551
|
-
}
|
|
552
|
-
else {
|
|
553
|
-
const snapshot = await captureDomSnapshot(url);
|
|
554
|
-
scenarios = buildVerifyScenarios(snapshot, tier);
|
|
555
|
-
coverageGaps = getVerificationCoverageGaps(scenarios, tier);
|
|
556
|
-
}
|
|
557
|
-
const results = [];
|
|
558
|
-
for (const scenario of scenarios) {
|
|
559
|
-
results.push(await executeScenario(url, scenario));
|
|
560
|
-
}
|
|
561
|
-
const passed = results.filter((result) => result.passed).length;
|
|
562
|
-
const failed = results.length - passed;
|
|
563
|
-
const consoleErrors = Array.from(new Set(results.flatMap((r) => r.consoleErrors ?? []))).slice(0, 10);
|
|
564
|
-
return { scenarios, results, passed, failed, coverageGaps, consoleErrors, crawlResult: crawlResultUsed };
|
|
565
|
-
}
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isNavigableInternalHref = isNavigableInternalHref;
|
|
7
|
+
exports.getVerificationCoverageGaps = getVerificationCoverageGaps;
|
|
8
|
+
exports.buildVerifyScenarios = buildVerifyScenarios;
|
|
9
|
+
exports.convertUserScenarios = convertUserScenarios;
|
|
10
|
+
exports.runVerifyE2E = runVerifyE2E;
|
|
11
|
+
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
12
|
+
const crawler_js_1 = require("./crawler.js");
|
|
13
|
+
function isNavigableInternalHref(href) {
|
|
14
|
+
if (!href.startsWith("/"))
|
|
15
|
+
return false;
|
|
16
|
+
if (href.startsWith("/_next/"))
|
|
17
|
+
return false;
|
|
18
|
+
if (href.startsWith("/api/"))
|
|
19
|
+
return false;
|
|
20
|
+
if (href === "/" || href.startsWith("/#"))
|
|
21
|
+
return false;
|
|
22
|
+
if (href.includes("?"))
|
|
23
|
+
return false;
|
|
24
|
+
if (/\.[a-z0-9]{2,8}$/i.test(href))
|
|
25
|
+
return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
function normalize(value) {
|
|
29
|
+
return value.toLowerCase().replace(/\s+/g, " ").trim();
|
|
30
|
+
}
|
|
31
|
+
function pickSelector(candidates, patterns) {
|
|
32
|
+
for (const pattern of patterns) {
|
|
33
|
+
const match = candidates.find((candidate) => pattern.test(normalize(candidate)));
|
|
34
|
+
if (match)
|
|
35
|
+
return match;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function getScenarioLimit(tier) {
|
|
40
|
+
void tier;
|
|
41
|
+
return 5;
|
|
42
|
+
}
|
|
43
|
+
function getVisibleAnchor(selectors) {
|
|
44
|
+
return pickSelector(selectors, [/^main$/, /^form$/, /^section$/, /^h1$/, /^h2$/, /^button/, /^a\[href/]) || "body";
|
|
45
|
+
}
|
|
46
|
+
function getClickTarget(selectors) {
|
|
47
|
+
return pickSelector(selectors, [
|
|
48
|
+
/^button\[type=['"]submit['"]\]/,
|
|
49
|
+
/^input\[type=['"]submit['"]\]/,
|
|
50
|
+
/^button\[aria-label.*(submit|continue|login|sign in|save|start|search|next)/,
|
|
51
|
+
/^input\[type=['"]checkbox['"]\]/,
|
|
52
|
+
/^\[role=['"]checkbox['"]\]$/,
|
|
53
|
+
/^a\[href=['"]\//,
|
|
54
|
+
/^button/,
|
|
55
|
+
/^\[role=['"]button['"]\]$/,
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
function getFillTarget(selectors) {
|
|
59
|
+
return pickSelector(selectors, [
|
|
60
|
+
/^input\[type=['"]email['"]\]/,
|
|
61
|
+
/^input\[type=['"]text['"]\]/,
|
|
62
|
+
/^input\[name.*(email|query|search|name)/,
|
|
63
|
+
/^input\[placeholder.*(email|search|name|message|title)/,
|
|
64
|
+
/^textarea$/,
|
|
65
|
+
/^input/,
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
function getFeedbackTarget(selectors) {
|
|
69
|
+
return pickSelector(selectors, [
|
|
70
|
+
/^\[role=['"]status['"]\]$/,
|
|
71
|
+
/^\[aria-live=['"](polite|assertive)['"]\]$/,
|
|
72
|
+
/^\[role=['"]alert['"]\]$/,
|
|
73
|
+
/^\[data-testid.*(error|success|toast|alert|notice|result)/,
|
|
74
|
+
/^\.(error|alert|toast|notice|success|status)/,
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
function getRequiredFillTarget(selectors) {
|
|
78
|
+
return pickSelector(selectors, [
|
|
79
|
+
/^input\[required\]$/,
|
|
80
|
+
/^textarea\[required\]$/,
|
|
81
|
+
/^input\[aria-required=['"]true['"]\]$/,
|
|
82
|
+
/^textarea\[aria-required=['"]true['"]\]$/,
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
function getFillValue(selector) {
|
|
86
|
+
const s = selector.toLowerCase();
|
|
87
|
+
if (/type=['"]email['"]|name.*email|placeholder.*email/.test(s))
|
|
88
|
+
return "test@example.com";
|
|
89
|
+
if (/type=['"]tel['"]|name.*(phone|tel)|placeholder.*(phone|tel)/.test(s))
|
|
90
|
+
return "555-0100";
|
|
91
|
+
if (/type=['"]number['"]/.test(s))
|
|
92
|
+
return "42";
|
|
93
|
+
if (/type=['"]password['"]/.test(s))
|
|
94
|
+
return "TestPass123!";
|
|
95
|
+
if (/type=['"]url['"]/.test(s))
|
|
96
|
+
return "https://example.com";
|
|
97
|
+
if (/type=['"]search['"]|name.*(query|search)|placeholder.*(search|query)/.test(s))
|
|
98
|
+
return "test query";
|
|
99
|
+
if (/name.*username|placeholder.*username/.test(s))
|
|
100
|
+
return "testuser";
|
|
101
|
+
if (/name.*name|placeholder.*name/.test(s))
|
|
102
|
+
return "Test User";
|
|
103
|
+
if (/textarea|name.*message|placeholder.*message/.test(s))
|
|
104
|
+
return "This is a test message.";
|
|
105
|
+
if (/name.*title|placeholder.*title/.test(s))
|
|
106
|
+
return "Test Title";
|
|
107
|
+
return "test input";
|
|
108
|
+
}
|
|
109
|
+
function getVerificationCoverageGaps(scenarios, tier) {
|
|
110
|
+
void tier;
|
|
111
|
+
const names = new Set(scenarios.map((scenario) => scenario.name));
|
|
112
|
+
const gaps = [];
|
|
113
|
+
const hasPrimaryAction = names.has("Primary form interaction") || names.has("Primary CTA interaction");
|
|
114
|
+
if (!hasPrimaryAction) {
|
|
115
|
+
gaps.push("No primary action scenario was detected, so the verify run could not validate a real user action.");
|
|
116
|
+
}
|
|
117
|
+
if (scenarios.length < 4) {
|
|
118
|
+
gaps.push("Too few meaningful scenarios were detected for a full verification pass, so this run stayed shallower than expected.");
|
|
119
|
+
}
|
|
120
|
+
return gaps;
|
|
121
|
+
}
|
|
122
|
+
function buildVerifyScenarios(snapshot, tier) {
|
|
123
|
+
const selectors = [...snapshot.selectors, ...snapshot.structures];
|
|
124
|
+
const visibleAnchor = getVisibleAnchor(selectors);
|
|
125
|
+
const fillTarget = getFillTarget(selectors);
|
|
126
|
+
const clickTarget = getClickTarget(selectors);
|
|
127
|
+
const feedbackTarget = getFeedbackTarget(selectors);
|
|
128
|
+
const requiredFillTarget = getRequiredFillTarget(selectors);
|
|
129
|
+
const localLinkTarget = pickSelector(selectors, [/^a\[href=['"]\//]);
|
|
130
|
+
const likelyFormSurface = selectors.some((selector) => /^form$/.test(normalize(selector))) ||
|
|
131
|
+
selectors.some((selector) => /^input|^textarea/.test(normalize(selector)));
|
|
132
|
+
const scenarios = [
|
|
133
|
+
{
|
|
134
|
+
name: "Initial render",
|
|
135
|
+
steps: [
|
|
136
|
+
{ type: "wait", duration: 1200, description: "Wait for hydration" },
|
|
137
|
+
{ type: "check_visible", selector: "body", description: "Body should render" },
|
|
138
|
+
{ type: "check_healthy_page", description: "Page should not be an error screen" },
|
|
139
|
+
{ type: "check_visible", selector: visibleAnchor, description: "Core UI should stay visible" },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
if (fillTarget && likelyFormSurface) {
|
|
144
|
+
const fillValue = getFillValue(fillTarget);
|
|
145
|
+
const formScenario = {
|
|
146
|
+
name: "Primary form interaction",
|
|
147
|
+
steps: [
|
|
148
|
+
{ type: "check_visible", selector: fillTarget, description: "Input surface should be visible" },
|
|
149
|
+
{ type: "clear_fill", selector: fillTarget, value: fillValue, description: `Fill a core input field with ${fillValue}` },
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
if (clickTarget) {
|
|
153
|
+
formScenario.steps.push({ type: "click", selector: clickTarget, description: "Trigger the primary CTA" }, { type: "wait", duration: 800, description: "Wait for UI response" }, { type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Feedback or surface should remain visible" });
|
|
154
|
+
}
|
|
155
|
+
scenarios.push(formScenario);
|
|
156
|
+
}
|
|
157
|
+
else if (clickTarget) {
|
|
158
|
+
scenarios.push({
|
|
159
|
+
name: "Primary CTA interaction",
|
|
160
|
+
steps: [
|
|
161
|
+
{ type: "check_visible", selector: clickTarget, description: "CTA should be visible" },
|
|
162
|
+
{ type: "click", selector: clickTarget, description: "Trigger the primary CTA" },
|
|
163
|
+
{ type: "wait", duration: 800, description: "Wait for UI response" },
|
|
164
|
+
{ type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Core surface should stay visible" },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (fillTarget && clickTarget && (requiredFillTarget || feedbackTarget)) {
|
|
169
|
+
scenarios.push({
|
|
170
|
+
name: "Validation feedback",
|
|
171
|
+
steps: [
|
|
172
|
+
{ type: "clear_fill", selector: requiredFillTarget || fillTarget, value: "", description: "Clear the required input" },
|
|
173
|
+
{ type: "click", selector: clickTarget, description: "Try the CTA without valid input" },
|
|
174
|
+
{ type: "wait", duration: 700, description: "Wait for validation" },
|
|
175
|
+
{ type: "check_validation", selector: requiredFillTarget || fillTarget, description: "Validation feedback should appear" },
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (localLinkTarget) {
|
|
180
|
+
scenarios.push({
|
|
181
|
+
name: "Internal navigation",
|
|
182
|
+
steps: [
|
|
183
|
+
{ type: "check_visible", selector: localLinkTarget, description: "Internal link should be visible" },
|
|
184
|
+
{ type: "click", selector: localLinkTarget, description: "Navigate using an internal link" },
|
|
185
|
+
{ type: "wait", duration: 1000, description: "Wait for navigation" },
|
|
186
|
+
{ type: "check_healthy_page", description: "Destination should not be an error screen" },
|
|
187
|
+
{ type: "check_visible", selector: "body", description: "Destination page should render" },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (clickTarget && fillTarget && clickTarget !== fillTarget) {
|
|
192
|
+
const fillValue = getFillValue(fillTarget);
|
|
193
|
+
scenarios.push({
|
|
194
|
+
name: "Repeated interaction stability",
|
|
195
|
+
steps: [
|
|
196
|
+
{ type: "check_visible", selector: fillTarget, description: "Input surface should still exist" },
|
|
197
|
+
{ type: "clear_fill", selector: fillTarget, value: fillValue, description: `Repeat the core input with ${fillValue}` },
|
|
198
|
+
{ type: "click", selector: clickTarget, description: "Trigger the CTA again" },
|
|
199
|
+
{ type: "wait", duration: 800, description: "Wait for repeated interaction response" },
|
|
200
|
+
{ type: "check_visible", selector: feedbackTarget || visibleAnchor, description: "Surface should still hold after repeat" },
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
scenarios.push({
|
|
205
|
+
name: "Scroll stability",
|
|
206
|
+
steps: [
|
|
207
|
+
{ type: "check_visible", selector: visibleAnchor, description: "Initial content should render" },
|
|
208
|
+
{ type: "scroll", selector: "body", description: "Page should scroll" },
|
|
209
|
+
{ type: "wait", duration: 500, description: "Wait after scrolling" },
|
|
210
|
+
{ type: "check_visible", selector: "body", description: "Page should remain stable after scroll" },
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
return scenarios.slice(0, getScenarioLimit(tier));
|
|
214
|
+
}
|
|
215
|
+
function convertUserScenarios(userScenarios) {
|
|
216
|
+
return userScenarios.map((scenario) => {
|
|
217
|
+
const steps = [];
|
|
218
|
+
let initialUrl;
|
|
219
|
+
for (const raw of scenario.steps) {
|
|
220
|
+
if (raw.goto) {
|
|
221
|
+
if (!initialUrl) {
|
|
222
|
+
// First goto becomes the scenario's initialUrl
|
|
223
|
+
initialUrl = raw.goto;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Subsequent gotos become explicit navigation steps
|
|
227
|
+
steps.push({
|
|
228
|
+
type: "goto",
|
|
229
|
+
gotoUrl: raw.goto,
|
|
230
|
+
description: `Navigate to ${raw.goto}`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (raw.fill && raw.with !== undefined) {
|
|
235
|
+
steps.push({
|
|
236
|
+
type: "clear_fill",
|
|
237
|
+
selector: raw.fill,
|
|
238
|
+
value: raw.with,
|
|
239
|
+
description: `Fill ${raw.fill} with "${raw.with}"`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else if (raw.click) {
|
|
243
|
+
steps.push({
|
|
244
|
+
type: "click",
|
|
245
|
+
selector: raw.click,
|
|
246
|
+
description: `Click ${raw.click}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
else if (raw.expect_visible) {
|
|
250
|
+
steps.push({
|
|
251
|
+
type: "check_visible",
|
|
252
|
+
selector: raw.expect_visible,
|
|
253
|
+
description: `Expect ${raw.expect_visible} to be visible`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else if (raw.expect_text) {
|
|
257
|
+
steps.push({
|
|
258
|
+
type: "check_text",
|
|
259
|
+
expectedText: raw.expect_text,
|
|
260
|
+
description: `Expect text "${raw.expect_text}" on page`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
else if (raw.wait !== undefined) {
|
|
264
|
+
steps.push({
|
|
265
|
+
type: "wait",
|
|
266
|
+
duration: raw.wait,
|
|
267
|
+
description: `Wait ${raw.wait}ms`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
name: scenario.name,
|
|
273
|
+
steps,
|
|
274
|
+
initialUrl,
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async function captureDomSnapshot(url) {
|
|
279
|
+
const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
|
|
280
|
+
try {
|
|
281
|
+
const page = await browser.newPage();
|
|
282
|
+
await page.setViewport({ width: 1280, height: 720 });
|
|
283
|
+
await page.goto(url, { waitUntil: "networkidle2", timeout: 20000 });
|
|
284
|
+
await page.waitForSelector("body", { timeout: 5000 });
|
|
285
|
+
const snapshot = await page.evaluate(() => {
|
|
286
|
+
const isNavigableInternalHref = (href) => {
|
|
287
|
+
if (!href.startsWith("/"))
|
|
288
|
+
return false;
|
|
289
|
+
if (href.startsWith("/_next/"))
|
|
290
|
+
return false;
|
|
291
|
+
if (href.startsWith("/api/"))
|
|
292
|
+
return false;
|
|
293
|
+
if (href === "/" || href.startsWith("/#"))
|
|
294
|
+
return false;
|
|
295
|
+
if (href.includes("?"))
|
|
296
|
+
return false;
|
|
297
|
+
if (/\.[a-z0-9]{2,8}$/i.test(href))
|
|
298
|
+
return false;
|
|
299
|
+
return true;
|
|
300
|
+
};
|
|
301
|
+
const selectors = [];
|
|
302
|
+
const structures = [];
|
|
303
|
+
const nodes = Array.from(document.querySelectorAll("*")).slice(0, 250);
|
|
304
|
+
for (const node of nodes) {
|
|
305
|
+
const tag = node.tagName.toLowerCase();
|
|
306
|
+
const role = node.getAttribute("role");
|
|
307
|
+
const type = node.getAttribute("type");
|
|
308
|
+
const name = node.getAttribute("name");
|
|
309
|
+
const placeholder = node.getAttribute("placeholder");
|
|
310
|
+
const href = node.getAttribute("href");
|
|
311
|
+
const ariaLabel = node.getAttribute("aria-label");
|
|
312
|
+
const dataTestId = node.getAttribute("data-testid");
|
|
313
|
+
const ariaLive = node.getAttribute("aria-live");
|
|
314
|
+
if (["main", "form", "section", "header", "nav", "footer", "h1", "h2"].includes(tag)) {
|
|
315
|
+
structures.push(tag);
|
|
316
|
+
}
|
|
317
|
+
if (tag === "button")
|
|
318
|
+
selectors.push("button");
|
|
319
|
+
if (role === "button")
|
|
320
|
+
selectors.push("[role='button']");
|
|
321
|
+
if (type === "submit")
|
|
322
|
+
selectors.push(`${tag}[type='submit']`);
|
|
323
|
+
if (type === "checkbox")
|
|
324
|
+
selectors.push(`${tag}[type='checkbox']`);
|
|
325
|
+
if (tag === "input" || tag === "textarea")
|
|
326
|
+
selectors.push(tag);
|
|
327
|
+
if (type)
|
|
328
|
+
selectors.push(`${tag}[type='${type}']`);
|
|
329
|
+
if (name)
|
|
330
|
+
selectors.push(`${tag}[name='${name}']`);
|
|
331
|
+
if (placeholder)
|
|
332
|
+
selectors.push(`${tag}[placeholder='${placeholder}']`);
|
|
333
|
+
if (href && isNavigableInternalHref(href))
|
|
334
|
+
selectors.push(`a[href='${href}']`);
|
|
335
|
+
if (ariaLabel)
|
|
336
|
+
selectors.push(`${tag}[aria-label='${ariaLabel}']`);
|
|
337
|
+
if (role === "alert" || role === "status")
|
|
338
|
+
selectors.push(`[role='${role}']`);
|
|
339
|
+
if (ariaLive)
|
|
340
|
+
selectors.push(`[aria-live='${ariaLive}']`);
|
|
341
|
+
if (dataTestId)
|
|
342
|
+
selectors.push(`[data-testid='${dataTestId}']`);
|
|
343
|
+
if ((tag === "input" || tag === "textarea") && node.hasAttribute("required")) {
|
|
344
|
+
selectors.push(`${tag}[required]`);
|
|
345
|
+
}
|
|
346
|
+
if ((tag === "input" || tag === "textarea") && node.getAttribute("aria-required") === "true") {
|
|
347
|
+
selectors.push(`${tag}[aria-required='true']`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
selectors: Array.from(new Set(selectors)),
|
|
352
|
+
structures: Array.from(new Set(structures)),
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
return snapshot;
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
await browser.close();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function executeScenario(url, scenario) {
|
|
362
|
+
const browser = await puppeteer_1.default.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
|
|
363
|
+
const stepResults = [];
|
|
364
|
+
const consoleErrors = [];
|
|
365
|
+
try {
|
|
366
|
+
const page = await browser.newPage();
|
|
367
|
+
page.on("console", (msg) => {
|
|
368
|
+
if (msg.type() === "error") {
|
|
369
|
+
consoleErrors.push(`Console error: ${msg.text().slice(0, 200)}`);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
page.on("pageerror", (err) => {
|
|
373
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
374
|
+
consoleErrors.push(`Uncaught error: ${msg.slice(0, 200)}`);
|
|
375
|
+
});
|
|
376
|
+
await page.setViewport({ width: 1280, height: 720 });
|
|
377
|
+
const targetUrl = scenario.initialUrl ? new URL(scenario.initialUrl, url).href : url;
|
|
378
|
+
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 20000 });
|
|
379
|
+
await page.waitForSelector("body", { timeout: 5000 });
|
|
380
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
381
|
+
for (const step of scenario.steps) {
|
|
382
|
+
const result = { description: step.description, passed: false };
|
|
383
|
+
try {
|
|
384
|
+
switch (step.type) {
|
|
385
|
+
case "click":
|
|
386
|
+
if (!step.selector)
|
|
387
|
+
throw new Error("Missing selector");
|
|
388
|
+
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
389
|
+
await page.click(step.selector);
|
|
390
|
+
break;
|
|
391
|
+
case "fill":
|
|
392
|
+
case "clear_fill":
|
|
393
|
+
if (!step.selector)
|
|
394
|
+
throw new Error("Missing selector");
|
|
395
|
+
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
396
|
+
await page.click(step.selector, { clickCount: 3 });
|
|
397
|
+
await page.keyboard.press("Backspace");
|
|
398
|
+
if (step.value) {
|
|
399
|
+
await page.type(step.selector, step.value);
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
case "check_visible":
|
|
403
|
+
if (!step.selector)
|
|
404
|
+
throw new Error("Missing selector");
|
|
405
|
+
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
406
|
+
break;
|
|
407
|
+
case "check_text": {
|
|
408
|
+
const textToFind = step.expectedText;
|
|
409
|
+
if (!textToFind)
|
|
410
|
+
throw new Error("Missing expectedText");
|
|
411
|
+
const found = await page.evaluate((text) => {
|
|
412
|
+
const bodyText = document.body?.innerText ?? "";
|
|
413
|
+
return bodyText.includes(text);
|
|
414
|
+
}, textToFind);
|
|
415
|
+
if (!found) {
|
|
416
|
+
throw new Error(`Expected text "${textToFind}" not found on page`);
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "check_healthy_page":
|
|
421
|
+
const hasErrorPageSignals = await page.evaluate(() => {
|
|
422
|
+
const title = document.title ?? "";
|
|
423
|
+
const h1 = document.querySelector("h1")?.textContent ?? "";
|
|
424
|
+
const bodyText = document.body?.innerText?.slice(0, 1200) ?? "";
|
|
425
|
+
const haystack = `${title}\n${h1}\n${bodyText}`.toLowerCase();
|
|
426
|
+
const patterns = [
|
|
427
|
+
/internal server error/,
|
|
428
|
+
/application error/,
|
|
429
|
+
/unexpected application error/,
|
|
430
|
+
/this page could not be found/,
|
|
431
|
+
/\b404\b/,
|
|
432
|
+
/\b500\b/,
|
|
433
|
+
/server error/,
|
|
434
|
+
/something went wrong/,
|
|
435
|
+
];
|
|
436
|
+
return patterns.some((pattern) => pattern.test(haystack));
|
|
437
|
+
});
|
|
438
|
+
if (hasErrorPageSignals) {
|
|
439
|
+
throw new Error("The page looks like an error screen, not a healthy app surface.");
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
case "check_validation":
|
|
443
|
+
if (!step.selector)
|
|
444
|
+
throw new Error("Missing selector");
|
|
445
|
+
await page.waitForSelector(step.selector, { visible: true, timeout: 8000 });
|
|
446
|
+
const hasValidationEvidence = await page.$eval(step.selector, (element) => {
|
|
447
|
+
const field = element;
|
|
448
|
+
const feedbackSelectors = [
|
|
449
|
+
"[role='alert']",
|
|
450
|
+
"[role='status']",
|
|
451
|
+
"[aria-live='polite']",
|
|
452
|
+
"[aria-live='assertive']",
|
|
453
|
+
"[data-testid*='error']",
|
|
454
|
+
"[data-testid*='success']",
|
|
455
|
+
"[data-testid*='toast']",
|
|
456
|
+
"[data-testid*='notice']",
|
|
457
|
+
".error",
|
|
458
|
+
".alert",
|
|
459
|
+
".toast",
|
|
460
|
+
".notice",
|
|
461
|
+
".success",
|
|
462
|
+
".status",
|
|
463
|
+
];
|
|
464
|
+
const hasVisibleFeedback = feedbackSelectors.some((selector) => Array.from(document.querySelectorAll(selector)).some((node) => {
|
|
465
|
+
const el = node;
|
|
466
|
+
const style = window.getComputedStyle(el);
|
|
467
|
+
const text = el.textContent?.trim() ?? "";
|
|
468
|
+
return style.display !== "none" && style.visibility !== "hidden" && text.length > 0;
|
|
469
|
+
}));
|
|
470
|
+
const validity = "validity" in field ? field.validity : null;
|
|
471
|
+
const invalidField = !!validity && !validity.valid;
|
|
472
|
+
const validationMessage = "validationMessage" in field ? field.validationMessage?.trim().length > 0 : false;
|
|
473
|
+
const ariaInvalid = field.getAttribute("aria-invalid") === "true";
|
|
474
|
+
return hasVisibleFeedback || invalidField || validationMessage || ariaInvalid;
|
|
475
|
+
});
|
|
476
|
+
if (!hasValidationEvidence) {
|
|
477
|
+
throw new Error(`No validation evidence found for ${step.selector}`);
|
|
478
|
+
}
|
|
479
|
+
break;
|
|
480
|
+
case "wait":
|
|
481
|
+
await new Promise((resolve) => setTimeout(resolve, step.duration ?? 1000));
|
|
482
|
+
break;
|
|
483
|
+
case "scroll":
|
|
484
|
+
if (step.selector && step.selector !== "body") {
|
|
485
|
+
await page.$eval(step.selector, (element) => {
|
|
486
|
+
element.scrollIntoView({ behavior: "instant", block: "center" });
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
await page.evaluate(() => window.scrollBy(0, 300));
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
case "goto": {
|
|
494
|
+
if (!step.gotoUrl)
|
|
495
|
+
throw new Error("Missing gotoUrl");
|
|
496
|
+
const gotoTarget = new URL(step.gotoUrl, page.url()).href;
|
|
497
|
+
await page.goto(gotoTarget, { waitUntil: "networkidle2", timeout: 20000 });
|
|
498
|
+
await page.waitForSelector("body", { timeout: 5000 });
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
result.passed = true;
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
result.passed = false;
|
|
506
|
+
result.error = error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200);
|
|
507
|
+
}
|
|
508
|
+
stepResults.push(result);
|
|
509
|
+
if (!result.passed)
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
name: scenario.name,
|
|
514
|
+
passed: stepResults.every((step) => step.passed),
|
|
515
|
+
steps: stepResults,
|
|
516
|
+
consoleErrors,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
return {
|
|
521
|
+
name: scenario.name,
|
|
522
|
+
passed: false,
|
|
523
|
+
steps: stepResults,
|
|
524
|
+
error: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
|
|
525
|
+
consoleErrors,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
await browser.close();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async function runVerifyE2E(url, tier, userScenarios, crawlOptions) {
|
|
533
|
+
let scenarios;
|
|
534
|
+
let coverageGaps;
|
|
535
|
+
let crawlResultUsed;
|
|
536
|
+
if (userScenarios && userScenarios.length > 0) {
|
|
537
|
+
console.log(` Using ${userScenarios.length} user-defined scenario(s) from .laxy.yml`);
|
|
538
|
+
scenarios = convertUserScenarios(userScenarios);
|
|
539
|
+
coverageGaps = [];
|
|
540
|
+
}
|
|
541
|
+
else if (crawlOptions?.enabled) {
|
|
542
|
+
console.log(" Crawling app to discover routes and interactions...");
|
|
543
|
+
crawlResultUsed = await (0, crawler_js_1.crawlApp)(url, crawlOptions);
|
|
544
|
+
console.log(` Crawled ${crawlResultUsed.crawledCount} page(s), found ${crawlResultUsed.totalLinks} internal link(s)`);
|
|
545
|
+
scenarios = (0, crawler_js_1.buildScenariosFromCrawl)(crawlResultUsed, tier);
|
|
546
|
+
if (scenarios.length === 0) {
|
|
547
|
+
const snapshot = await captureDomSnapshot(url);
|
|
548
|
+
scenarios = buildVerifyScenarios(snapshot, tier);
|
|
549
|
+
}
|
|
550
|
+
coverageGaps = getVerificationCoverageGaps(scenarios, tier);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const snapshot = await captureDomSnapshot(url);
|
|
554
|
+
scenarios = buildVerifyScenarios(snapshot, tier);
|
|
555
|
+
coverageGaps = getVerificationCoverageGaps(scenarios, tier);
|
|
556
|
+
}
|
|
557
|
+
const results = [];
|
|
558
|
+
for (const scenario of scenarios) {
|
|
559
|
+
results.push(await executeScenario(url, scenario));
|
|
560
|
+
}
|
|
561
|
+
const passed = results.filter((result) => result.passed).length;
|
|
562
|
+
const failed = results.length - passed;
|
|
563
|
+
const consoleErrors = Array.from(new Set(results.flatMap((r) => r.consoleErrors ?? []))).slice(0, 10);
|
|
564
|
+
return { scenarios, results, passed, failed, coverageGaps, consoleErrors, crawlResult: crawlResultUsed };
|
|
565
|
+
}
|