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/crawler.js
CHANGED
|
@@ -1,357 +1,357 @@
|
|
|
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.crawlApp = crawlApp;
|
|
7
|
-
exports.buildScenariosFromCrawl = buildScenariosFromCrawl;
|
|
8
|
-
/**
|
|
9
|
-
* Runtime crawler — BFS page exploration via Puppeteer.
|
|
10
|
-
* Discovers routes, forms, buttons, and interaction points to auto-generate
|
|
11
|
-
* E2E scenarios that cover more of the app than DOM snapshot alone.
|
|
12
|
-
*/
|
|
13
|
-
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
14
|
-
const DEFAULT_MAX_DEPTH = 3;
|
|
15
|
-
const DEFAULT_MAX_PAGES = 10;
|
|
16
|
-
const DEFAULT_TIMEOUT = 15000;
|
|
17
|
-
function isInternalPath(href, baseOrigin) {
|
|
18
|
-
if (!href)
|
|
19
|
-
return false;
|
|
20
|
-
if (href.startsWith("#") || href.startsWith("javascript:"))
|
|
21
|
-
return false;
|
|
22
|
-
if (href.startsWith("/")) {
|
|
23
|
-
if (href.startsWith("/_next/") || href.startsWith("/api/"))
|
|
24
|
-
return false;
|
|
25
|
-
if (/\.[a-z0-9]{2,8}$/i.test(href))
|
|
26
|
-
return false;
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
try {
|
|
30
|
-
const url = new URL(href);
|
|
31
|
-
return url.origin === baseOrigin && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/_next/");
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
function normalizePath(href, _baseOrigin) {
|
|
38
|
-
if (href.startsWith("/"))
|
|
39
|
-
return href.split("?")[0].split("#")[0];
|
|
40
|
-
try {
|
|
41
|
-
const url = new URL(href);
|
|
42
|
-
return url.pathname;
|
|
43
|
-
}
|
|
44
|
-
catch {
|
|
45
|
-
return href;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
async function crawlApp(baseUrl, options) {
|
|
49
|
-
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
50
|
-
const maxPages = options?.maxPages ?? DEFAULT_MAX_PAGES;
|
|
51
|
-
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
52
|
-
const origin = new URL(baseUrl).origin;
|
|
53
|
-
const visited = new Set();
|
|
54
|
-
const queue = [{ path: "/", depth: 0 }];
|
|
55
|
-
const pages = [];
|
|
56
|
-
const browser = await puppeteer_1.default.launch({
|
|
57
|
-
headless: true,
|
|
58
|
-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
59
|
-
});
|
|
60
|
-
try {
|
|
61
|
-
while (queue.length > 0 && pages.length < maxPages) {
|
|
62
|
-
const item = queue.shift();
|
|
63
|
-
// Normalize to avoid duplicates from trailing slashes
|
|
64
|
-
const normalizedPath = item.path.replace(/\/$/, "") || "/";
|
|
65
|
-
if (visited.has(normalizedPath))
|
|
66
|
-
continue;
|
|
67
|
-
if (item.depth > maxDepth)
|
|
68
|
-
continue;
|
|
69
|
-
visited.add(normalizedPath);
|
|
70
|
-
const page = await browser.newPage();
|
|
71
|
-
const consoleErrors = [];
|
|
72
|
-
page.on("console", (msg) => {
|
|
73
|
-
if (msg.type() === "error")
|
|
74
|
-
consoleErrors.push(msg.text());
|
|
75
|
-
});
|
|
76
|
-
try {
|
|
77
|
-
await page.setViewport({ width: 1280, height: 720 });
|
|
78
|
-
const targetUrl = new URL(normalizedPath, baseUrl).href;
|
|
79
|
-
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout });
|
|
80
|
-
await page.waitForSelector("body", { timeout: 5000 });
|
|
81
|
-
await new Promise((r) => setTimeout(r, 800));
|
|
82
|
-
const pageInfo = await page.evaluate((baseOrigin) => {
|
|
83
|
-
const title = document.title || "";
|
|
84
|
-
const links = [];
|
|
85
|
-
const buttons = [];
|
|
86
|
-
const forms = [];
|
|
87
|
-
// Collect internal links
|
|
88
|
-
for (const a of Array.from(document.querySelectorAll("a[href]"))) {
|
|
89
|
-
const href = a.getAttribute("href");
|
|
90
|
-
if (href)
|
|
91
|
-
links.push(href);
|
|
92
|
-
}
|
|
93
|
-
// Collect buttons (non-form)
|
|
94
|
-
for (const btn of Array.from(document.querySelectorAll("button, [role='button']"))) {
|
|
95
|
-
const el = btn;
|
|
96
|
-
const ariaLabel = el.getAttribute("aria-label");
|
|
97
|
-
const text = el.textContent?.trim().slice(0, 30);
|
|
98
|
-
if (ariaLabel) {
|
|
99
|
-
buttons.push(`button[aria-label='${ariaLabel}']`);
|
|
100
|
-
}
|
|
101
|
-
else if (el.getAttribute("data-testid")) {
|
|
102
|
-
buttons.push(`[data-testid='${el.getAttribute("data-testid")}']`);
|
|
103
|
-
}
|
|
104
|
-
else if (el.getAttribute("type") === "submit") {
|
|
105
|
-
buttons.push("button[type='submit']");
|
|
106
|
-
}
|
|
107
|
-
else if (text) {
|
|
108
|
-
buttons.push("button");
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// Collect forms
|
|
112
|
-
const formElements = document.querySelectorAll("form");
|
|
113
|
-
for (let i = 0; i < formElements.length; i++) {
|
|
114
|
-
const form = formElements[i];
|
|
115
|
-
const formSelector = form.getAttribute("data-testid")
|
|
116
|
-
? `form[data-testid='${form.getAttribute("data-testid")}']`
|
|
117
|
-
: form.getAttribute("aria-label")
|
|
118
|
-
? `form[aria-label='${form.getAttribute("aria-label")}']`
|
|
119
|
-
: formElements.length === 1
|
|
120
|
-
? "form"
|
|
121
|
-
: `form:nth-of-type(${i + 1})`;
|
|
122
|
-
const inputs = [];
|
|
123
|
-
for (const input of Array.from(form.querySelectorAll("input, textarea, select"))) {
|
|
124
|
-
const el = input;
|
|
125
|
-
const type = el.type || "text";
|
|
126
|
-
if (["hidden", "submit"].includes(type))
|
|
127
|
-
continue;
|
|
128
|
-
let sel = "";
|
|
129
|
-
if (el.getAttribute("name"))
|
|
130
|
-
sel = `${el.tagName.toLowerCase()}[name='${el.getAttribute("name")}']`;
|
|
131
|
-
else if (el.getAttribute("placeholder"))
|
|
132
|
-
sel = `${el.tagName.toLowerCase()}[placeholder='${el.getAttribute("placeholder")}']`;
|
|
133
|
-
else if (el.getAttribute("aria-label"))
|
|
134
|
-
sel = `${el.tagName.toLowerCase()}[aria-label='${el.getAttribute("aria-label")}']`;
|
|
135
|
-
else if (el.id)
|
|
136
|
-
sel = `#${el.id}`;
|
|
137
|
-
else
|
|
138
|
-
sel = el.tagName.toLowerCase();
|
|
139
|
-
inputs.push({ selector: sel, type, placeholder: el.placeholder || undefined });
|
|
140
|
-
}
|
|
141
|
-
let submitSelector;
|
|
142
|
-
const submitBtn = form.querySelector("button[type='submit'], input[type='submit']");
|
|
143
|
-
if (submitBtn) {
|
|
144
|
-
submitSelector = submitBtn.getAttribute("type") === "submit"
|
|
145
|
-
? `${submitBtn.tagName.toLowerCase()}[type='submit']`
|
|
146
|
-
: "button";
|
|
147
|
-
}
|
|
148
|
-
if (inputs.length > 0) {
|
|
149
|
-
forms.push({ selector: formSelector, inputs, submitSelector });
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
// Also detect standalone inputs (not in a form)
|
|
153
|
-
const standaloneInputs = document.querySelectorAll("input:not(form input), textarea:not(form textarea)");
|
|
154
|
-
if (standaloneInputs.length > 0 && formElements.length === 0) {
|
|
155
|
-
const inputs = [];
|
|
156
|
-
for (const input of Array.from(standaloneInputs)) {
|
|
157
|
-
const el = input;
|
|
158
|
-
const type = el.type || "text";
|
|
159
|
-
if (["hidden", "submit"].includes(type))
|
|
160
|
-
continue;
|
|
161
|
-
let sel = "";
|
|
162
|
-
if (el.getAttribute("placeholder"))
|
|
163
|
-
sel = `${el.tagName.toLowerCase()}[placeholder='${el.getAttribute("placeholder")}']`;
|
|
164
|
-
else if (el.getAttribute("name"))
|
|
165
|
-
sel = `${el.tagName.toLowerCase()}[name='${el.getAttribute("name")}']`;
|
|
166
|
-
else
|
|
167
|
-
sel = el.tagName.toLowerCase();
|
|
168
|
-
inputs.push({ selector: sel, type, placeholder: el.placeholder || undefined });
|
|
169
|
-
}
|
|
170
|
-
const nearbyBtn = document.querySelector("button, [role='button']");
|
|
171
|
-
if (inputs.length > 0) {
|
|
172
|
-
forms.push({
|
|
173
|
-
selector: "body",
|
|
174
|
-
inputs,
|
|
175
|
-
submitSelector: nearbyBtn ? "button" : undefined,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return { title, links, buttons: Array.from(new Set(buttons)), forms };
|
|
180
|
-
}, origin);
|
|
181
|
-
const internalLinks = pageInfo.links
|
|
182
|
-
.filter((href) => isInternalPath(href, origin))
|
|
183
|
-
.map((href) => normalizePath(href, origin));
|
|
184
|
-
const crawlPage = {
|
|
185
|
-
url: new URL(normalizedPath, baseUrl).href,
|
|
186
|
-
path: normalizedPath,
|
|
187
|
-
title: pageInfo.title,
|
|
188
|
-
forms: pageInfo.forms,
|
|
189
|
-
buttons: pageInfo.buttons.slice(0, 10),
|
|
190
|
-
internalLinks: Array.from(new Set(internalLinks)),
|
|
191
|
-
hasConsoleErrors: consoleErrors.length > 0,
|
|
192
|
-
consoleErrors,
|
|
193
|
-
};
|
|
194
|
-
pages.push(crawlPage);
|
|
195
|
-
// Queue discovered internal links
|
|
196
|
-
for (const linkPath of crawlPage.internalLinks) {
|
|
197
|
-
if (!visited.has(linkPath.replace(/\/$/, "") || "/")) {
|
|
198
|
-
queue.push({ path: linkPath, depth: item.depth + 1 });
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
// Page failed to load — skip
|
|
204
|
-
}
|
|
205
|
-
finally {
|
|
206
|
-
await page.close();
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
finally {
|
|
211
|
-
await browser.close();
|
|
212
|
-
}
|
|
213
|
-
return {
|
|
214
|
-
pages,
|
|
215
|
-
totalLinks: pages.reduce((sum, p) => sum + p.internalLinks.length, 0),
|
|
216
|
-
crawledCount: pages.length,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* Generate E2E scenarios from crawl results.
|
|
221
|
-
*/
|
|
222
|
-
function buildScenariosFromCrawl(crawlResult, tier) {
|
|
223
|
-
void tier;
|
|
224
|
-
const scenarios = [];
|
|
225
|
-
const limit = 6;
|
|
226
|
-
// Scenario 1: Root page render
|
|
227
|
-
const rootPage = crawlResult.pages.find((p) => p.path === "/");
|
|
228
|
-
if (rootPage) {
|
|
229
|
-
scenarios.push({
|
|
230
|
-
name: "Root page render",
|
|
231
|
-
steps: [
|
|
232
|
-
{ type: "check_visible", selector: "body", description: "Body should render" },
|
|
233
|
-
{ type: "check_healthy_page", description: "Page should not be an error screen" },
|
|
234
|
-
{ type: "scroll", description: "Page should scroll" },
|
|
235
|
-
{ type: "check_visible", selector: "body", description: "Page should remain stable" },
|
|
236
|
-
],
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
// Scenario 2+: Form interactions (one per discovered form, across pages)
|
|
240
|
-
for (const page of crawlResult.pages) {
|
|
241
|
-
if (scenarios.length >= limit)
|
|
242
|
-
break;
|
|
243
|
-
for (const form of page.forms) {
|
|
244
|
-
if (scenarios.length >= limit)
|
|
245
|
-
break;
|
|
246
|
-
const steps = [];
|
|
247
|
-
// If form is on a non-root page, start from that route
|
|
248
|
-
if (page.path !== "/") {
|
|
249
|
-
steps.push({
|
|
250
|
-
type: "goto",
|
|
251
|
-
gotoUrl: page.path,
|
|
252
|
-
description: `Navigate to ${page.path}`,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
// Fill inputs
|
|
256
|
-
for (const input of form.inputs.slice(0, 3)) {
|
|
257
|
-
const fillValue = getSmartFillValue(input.type, input.placeholder);
|
|
258
|
-
steps.push({
|
|
259
|
-
type: "clear_fill",
|
|
260
|
-
selector: input.selector,
|
|
261
|
-
value: fillValue,
|
|
262
|
-
description: `Fill ${input.selector}`,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
// Submit
|
|
266
|
-
if (form.submitSelector) {
|
|
267
|
-
steps.push({
|
|
268
|
-
type: "click",
|
|
269
|
-
selector: form.submitSelector,
|
|
270
|
-
description: "Submit the form",
|
|
271
|
-
});
|
|
272
|
-
steps.push({
|
|
273
|
-
type: "wait",
|
|
274
|
-
duration: 800,
|
|
275
|
-
description: "Wait for response",
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
// Check page still healthy after submit
|
|
279
|
-
steps.push({
|
|
280
|
-
type: "check_visible",
|
|
281
|
-
selector: "body",
|
|
282
|
-
description: "Page should remain stable after interaction",
|
|
283
|
-
});
|
|
284
|
-
const scenarioName = page.path === "/"
|
|
285
|
-
? "Primary form interaction"
|
|
286
|
-
: `Form interaction on ${page.path}`;
|
|
287
|
-
scenarios.push({
|
|
288
|
-
name: scenarioName,
|
|
289
|
-
steps: steps.slice(0, 5),
|
|
290
|
-
initialUrl: page.path !== "/" ? page.path : undefined,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
// Scenario: Navigation between pages
|
|
295
|
-
const navPages = crawlResult.pages
|
|
296
|
-
.filter((p) => p.path !== "/" && p.internalLinks.length > 0)
|
|
297
|
-
.slice(0, 2);
|
|
298
|
-
for (const navPage of navPages) {
|
|
299
|
-
if (scenarios.length >= limit)
|
|
300
|
-
break;
|
|
301
|
-
scenarios.push({
|
|
302
|
-
name: `Navigation to ${navPage.path}`,
|
|
303
|
-
steps: [
|
|
304
|
-
{
|
|
305
|
-
type: "click",
|
|
306
|
-
selector: `a[href='${navPage.path}']`,
|
|
307
|
-
description: `Navigate to ${navPage.path}`,
|
|
308
|
-
},
|
|
309
|
-
{ type: "wait", duration: 1000, description: "Wait for navigation" },
|
|
310
|
-
{ type: "check_healthy_page", description: "Destination should not be error" },
|
|
311
|
-
{ type: "check_visible", selector: "body", description: "Destination should render" },
|
|
312
|
-
],
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
// Scenario: Button interactions
|
|
316
|
-
for (const page of crawlResult.pages) {
|
|
317
|
-
if (scenarios.length >= limit)
|
|
318
|
-
break;
|
|
319
|
-
const nonFormButtons = page.buttons.filter((b) => !b.includes("submit")).slice(0, 1);
|
|
320
|
-
for (const btnSelector of nonFormButtons) {
|
|
321
|
-
if (scenarios.length >= limit)
|
|
322
|
-
break;
|
|
323
|
-
const steps = [];
|
|
324
|
-
if (page.path !== "/") {
|
|
325
|
-
steps.push({
|
|
326
|
-
type: "goto",
|
|
327
|
-
gotoUrl: page.path,
|
|
328
|
-
description: `Navigate to ${page.path}`,
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
steps.push({ type: "check_visible", selector: btnSelector, description: `Button should be visible` }, { type: "click", selector: btnSelector, description: `Click ${btnSelector}` }, { type: "wait", duration: 800, description: "Wait for response" }, { type: "check_visible", selector: "body", description: "Page should remain stable" });
|
|
332
|
-
scenarios.push({
|
|
333
|
-
name: `Button interaction on ${page.path}`,
|
|
334
|
-
steps: steps.slice(0, 5),
|
|
335
|
-
initialUrl: page.path !== "/" ? page.path : undefined,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return scenarios.slice(0, limit);
|
|
340
|
-
}
|
|
341
|
-
function getSmartFillValue(inputType, placeholder) {
|
|
342
|
-
if (inputType === "email" || placeholder?.toLowerCase().includes("email"))
|
|
343
|
-
return "test@laxy.dev";
|
|
344
|
-
if (inputType === "password")
|
|
345
|
-
return "Test1234!";
|
|
346
|
-
if (inputType === "number")
|
|
347
|
-
return "42";
|
|
348
|
-
if (inputType === "tel" || placeholder?.toLowerCase().includes("phone"))
|
|
349
|
-
return "010-1234-5678";
|
|
350
|
-
if (inputType === "url")
|
|
351
|
-
return "https://example.com";
|
|
352
|
-
if (placeholder?.toLowerCase().includes("search") || placeholder?.toLowerCase().includes("검색"))
|
|
353
|
-
return "laxy verify";
|
|
354
|
-
if (placeholder?.toLowerCase().includes("name") || placeholder?.toLowerCase().includes("이름"))
|
|
355
|
-
return "Laxy User";
|
|
356
|
-
return "laxy-verify-test";
|
|
357
|
-
}
|
|
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.crawlApp = crawlApp;
|
|
7
|
+
exports.buildScenariosFromCrawl = buildScenariosFromCrawl;
|
|
8
|
+
/**
|
|
9
|
+
* Runtime crawler — BFS page exploration via Puppeteer.
|
|
10
|
+
* Discovers routes, forms, buttons, and interaction points to auto-generate
|
|
11
|
+
* E2E scenarios that cover more of the app than DOM snapshot alone.
|
|
12
|
+
*/
|
|
13
|
+
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
14
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
15
|
+
const DEFAULT_MAX_PAGES = 10;
|
|
16
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
17
|
+
function isInternalPath(href, baseOrigin) {
|
|
18
|
+
if (!href)
|
|
19
|
+
return false;
|
|
20
|
+
if (href.startsWith("#") || href.startsWith("javascript:"))
|
|
21
|
+
return false;
|
|
22
|
+
if (href.startsWith("/")) {
|
|
23
|
+
if (href.startsWith("/_next/") || href.startsWith("/api/"))
|
|
24
|
+
return false;
|
|
25
|
+
if (/\.[a-z0-9]{2,8}$/i.test(href))
|
|
26
|
+
return false;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(href);
|
|
31
|
+
return url.origin === baseOrigin && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/_next/");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function normalizePath(href, _baseOrigin) {
|
|
38
|
+
if (href.startsWith("/"))
|
|
39
|
+
return href.split("?")[0].split("#")[0];
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(href);
|
|
42
|
+
return url.pathname;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return href;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function crawlApp(baseUrl, options) {
|
|
49
|
+
const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
50
|
+
const maxPages = options?.maxPages ?? DEFAULT_MAX_PAGES;
|
|
51
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
52
|
+
const origin = new URL(baseUrl).origin;
|
|
53
|
+
const visited = new Set();
|
|
54
|
+
const queue = [{ path: "/", depth: 0 }];
|
|
55
|
+
const pages = [];
|
|
56
|
+
const browser = await puppeteer_1.default.launch({
|
|
57
|
+
headless: true,
|
|
58
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
while (queue.length > 0 && pages.length < maxPages) {
|
|
62
|
+
const item = queue.shift();
|
|
63
|
+
// Normalize to avoid duplicates from trailing slashes
|
|
64
|
+
const normalizedPath = item.path.replace(/\/$/, "") || "/";
|
|
65
|
+
if (visited.has(normalizedPath))
|
|
66
|
+
continue;
|
|
67
|
+
if (item.depth > maxDepth)
|
|
68
|
+
continue;
|
|
69
|
+
visited.add(normalizedPath);
|
|
70
|
+
const page = await browser.newPage();
|
|
71
|
+
const consoleErrors = [];
|
|
72
|
+
page.on("console", (msg) => {
|
|
73
|
+
if (msg.type() === "error")
|
|
74
|
+
consoleErrors.push(msg.text());
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
await page.setViewport({ width: 1280, height: 720 });
|
|
78
|
+
const targetUrl = new URL(normalizedPath, baseUrl).href;
|
|
79
|
+
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout });
|
|
80
|
+
await page.waitForSelector("body", { timeout: 5000 });
|
|
81
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
82
|
+
const pageInfo = await page.evaluate((baseOrigin) => {
|
|
83
|
+
const title = document.title || "";
|
|
84
|
+
const links = [];
|
|
85
|
+
const buttons = [];
|
|
86
|
+
const forms = [];
|
|
87
|
+
// Collect internal links
|
|
88
|
+
for (const a of Array.from(document.querySelectorAll("a[href]"))) {
|
|
89
|
+
const href = a.getAttribute("href");
|
|
90
|
+
if (href)
|
|
91
|
+
links.push(href);
|
|
92
|
+
}
|
|
93
|
+
// Collect buttons (non-form)
|
|
94
|
+
for (const btn of Array.from(document.querySelectorAll("button, [role='button']"))) {
|
|
95
|
+
const el = btn;
|
|
96
|
+
const ariaLabel = el.getAttribute("aria-label");
|
|
97
|
+
const text = el.textContent?.trim().slice(0, 30);
|
|
98
|
+
if (ariaLabel) {
|
|
99
|
+
buttons.push(`button[aria-label='${ariaLabel}']`);
|
|
100
|
+
}
|
|
101
|
+
else if (el.getAttribute("data-testid")) {
|
|
102
|
+
buttons.push(`[data-testid='${el.getAttribute("data-testid")}']`);
|
|
103
|
+
}
|
|
104
|
+
else if (el.getAttribute("type") === "submit") {
|
|
105
|
+
buttons.push("button[type='submit']");
|
|
106
|
+
}
|
|
107
|
+
else if (text) {
|
|
108
|
+
buttons.push("button");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Collect forms
|
|
112
|
+
const formElements = document.querySelectorAll("form");
|
|
113
|
+
for (let i = 0; i < formElements.length; i++) {
|
|
114
|
+
const form = formElements[i];
|
|
115
|
+
const formSelector = form.getAttribute("data-testid")
|
|
116
|
+
? `form[data-testid='${form.getAttribute("data-testid")}']`
|
|
117
|
+
: form.getAttribute("aria-label")
|
|
118
|
+
? `form[aria-label='${form.getAttribute("aria-label")}']`
|
|
119
|
+
: formElements.length === 1
|
|
120
|
+
? "form"
|
|
121
|
+
: `form:nth-of-type(${i + 1})`;
|
|
122
|
+
const inputs = [];
|
|
123
|
+
for (const input of Array.from(form.querySelectorAll("input, textarea, select"))) {
|
|
124
|
+
const el = input;
|
|
125
|
+
const type = el.type || "text";
|
|
126
|
+
if (["hidden", "submit"].includes(type))
|
|
127
|
+
continue;
|
|
128
|
+
let sel = "";
|
|
129
|
+
if (el.getAttribute("name"))
|
|
130
|
+
sel = `${el.tagName.toLowerCase()}[name='${el.getAttribute("name")}']`;
|
|
131
|
+
else if (el.getAttribute("placeholder"))
|
|
132
|
+
sel = `${el.tagName.toLowerCase()}[placeholder='${el.getAttribute("placeholder")}']`;
|
|
133
|
+
else if (el.getAttribute("aria-label"))
|
|
134
|
+
sel = `${el.tagName.toLowerCase()}[aria-label='${el.getAttribute("aria-label")}']`;
|
|
135
|
+
else if (el.id)
|
|
136
|
+
sel = `#${el.id}`;
|
|
137
|
+
else
|
|
138
|
+
sel = el.tagName.toLowerCase();
|
|
139
|
+
inputs.push({ selector: sel, type, placeholder: el.placeholder || undefined });
|
|
140
|
+
}
|
|
141
|
+
let submitSelector;
|
|
142
|
+
const submitBtn = form.querySelector("button[type='submit'], input[type='submit']");
|
|
143
|
+
if (submitBtn) {
|
|
144
|
+
submitSelector = submitBtn.getAttribute("type") === "submit"
|
|
145
|
+
? `${submitBtn.tagName.toLowerCase()}[type='submit']`
|
|
146
|
+
: "button";
|
|
147
|
+
}
|
|
148
|
+
if (inputs.length > 0) {
|
|
149
|
+
forms.push({ selector: formSelector, inputs, submitSelector });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Also detect standalone inputs (not in a form)
|
|
153
|
+
const standaloneInputs = document.querySelectorAll("input:not(form input), textarea:not(form textarea)");
|
|
154
|
+
if (standaloneInputs.length > 0 && formElements.length === 0) {
|
|
155
|
+
const inputs = [];
|
|
156
|
+
for (const input of Array.from(standaloneInputs)) {
|
|
157
|
+
const el = input;
|
|
158
|
+
const type = el.type || "text";
|
|
159
|
+
if (["hidden", "submit"].includes(type))
|
|
160
|
+
continue;
|
|
161
|
+
let sel = "";
|
|
162
|
+
if (el.getAttribute("placeholder"))
|
|
163
|
+
sel = `${el.tagName.toLowerCase()}[placeholder='${el.getAttribute("placeholder")}']`;
|
|
164
|
+
else if (el.getAttribute("name"))
|
|
165
|
+
sel = `${el.tagName.toLowerCase()}[name='${el.getAttribute("name")}']`;
|
|
166
|
+
else
|
|
167
|
+
sel = el.tagName.toLowerCase();
|
|
168
|
+
inputs.push({ selector: sel, type, placeholder: el.placeholder || undefined });
|
|
169
|
+
}
|
|
170
|
+
const nearbyBtn = document.querySelector("button, [role='button']");
|
|
171
|
+
if (inputs.length > 0) {
|
|
172
|
+
forms.push({
|
|
173
|
+
selector: "body",
|
|
174
|
+
inputs,
|
|
175
|
+
submitSelector: nearbyBtn ? "button" : undefined,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { title, links, buttons: Array.from(new Set(buttons)), forms };
|
|
180
|
+
}, origin);
|
|
181
|
+
const internalLinks = pageInfo.links
|
|
182
|
+
.filter((href) => isInternalPath(href, origin))
|
|
183
|
+
.map((href) => normalizePath(href, origin));
|
|
184
|
+
const crawlPage = {
|
|
185
|
+
url: new URL(normalizedPath, baseUrl).href,
|
|
186
|
+
path: normalizedPath,
|
|
187
|
+
title: pageInfo.title,
|
|
188
|
+
forms: pageInfo.forms,
|
|
189
|
+
buttons: pageInfo.buttons.slice(0, 10),
|
|
190
|
+
internalLinks: Array.from(new Set(internalLinks)),
|
|
191
|
+
hasConsoleErrors: consoleErrors.length > 0,
|
|
192
|
+
consoleErrors,
|
|
193
|
+
};
|
|
194
|
+
pages.push(crawlPage);
|
|
195
|
+
// Queue discovered internal links
|
|
196
|
+
for (const linkPath of crawlPage.internalLinks) {
|
|
197
|
+
if (!visited.has(linkPath.replace(/\/$/, "") || "/")) {
|
|
198
|
+
queue.push({ path: linkPath, depth: item.depth + 1 });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Page failed to load — skip
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await page.close();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
await browser.close();
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
pages,
|
|
215
|
+
totalLinks: pages.reduce((sum, p) => sum + p.internalLinks.length, 0),
|
|
216
|
+
crawledCount: pages.length,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Generate E2E scenarios from crawl results.
|
|
221
|
+
*/
|
|
222
|
+
function buildScenariosFromCrawl(crawlResult, tier) {
|
|
223
|
+
void tier;
|
|
224
|
+
const scenarios = [];
|
|
225
|
+
const limit = 6;
|
|
226
|
+
// Scenario 1: Root page render
|
|
227
|
+
const rootPage = crawlResult.pages.find((p) => p.path === "/");
|
|
228
|
+
if (rootPage) {
|
|
229
|
+
scenarios.push({
|
|
230
|
+
name: "Root page render",
|
|
231
|
+
steps: [
|
|
232
|
+
{ type: "check_visible", selector: "body", description: "Body should render" },
|
|
233
|
+
{ type: "check_healthy_page", description: "Page should not be an error screen" },
|
|
234
|
+
{ type: "scroll", description: "Page should scroll" },
|
|
235
|
+
{ type: "check_visible", selector: "body", description: "Page should remain stable" },
|
|
236
|
+
],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// Scenario 2+: Form interactions (one per discovered form, across pages)
|
|
240
|
+
for (const page of crawlResult.pages) {
|
|
241
|
+
if (scenarios.length >= limit)
|
|
242
|
+
break;
|
|
243
|
+
for (const form of page.forms) {
|
|
244
|
+
if (scenarios.length >= limit)
|
|
245
|
+
break;
|
|
246
|
+
const steps = [];
|
|
247
|
+
// If form is on a non-root page, start from that route
|
|
248
|
+
if (page.path !== "/") {
|
|
249
|
+
steps.push({
|
|
250
|
+
type: "goto",
|
|
251
|
+
gotoUrl: page.path,
|
|
252
|
+
description: `Navigate to ${page.path}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// Fill inputs
|
|
256
|
+
for (const input of form.inputs.slice(0, 3)) {
|
|
257
|
+
const fillValue = getSmartFillValue(input.type, input.placeholder);
|
|
258
|
+
steps.push({
|
|
259
|
+
type: "clear_fill",
|
|
260
|
+
selector: input.selector,
|
|
261
|
+
value: fillValue,
|
|
262
|
+
description: `Fill ${input.selector}`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// Submit
|
|
266
|
+
if (form.submitSelector) {
|
|
267
|
+
steps.push({
|
|
268
|
+
type: "click",
|
|
269
|
+
selector: form.submitSelector,
|
|
270
|
+
description: "Submit the form",
|
|
271
|
+
});
|
|
272
|
+
steps.push({
|
|
273
|
+
type: "wait",
|
|
274
|
+
duration: 800,
|
|
275
|
+
description: "Wait for response",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// Check page still healthy after submit
|
|
279
|
+
steps.push({
|
|
280
|
+
type: "check_visible",
|
|
281
|
+
selector: "body",
|
|
282
|
+
description: "Page should remain stable after interaction",
|
|
283
|
+
});
|
|
284
|
+
const scenarioName = page.path === "/"
|
|
285
|
+
? "Primary form interaction"
|
|
286
|
+
: `Form interaction on ${page.path}`;
|
|
287
|
+
scenarios.push({
|
|
288
|
+
name: scenarioName,
|
|
289
|
+
steps: steps.slice(0, 5),
|
|
290
|
+
initialUrl: page.path !== "/" ? page.path : undefined,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Scenario: Navigation between pages
|
|
295
|
+
const navPages = crawlResult.pages
|
|
296
|
+
.filter((p) => p.path !== "/" && p.internalLinks.length > 0)
|
|
297
|
+
.slice(0, 2);
|
|
298
|
+
for (const navPage of navPages) {
|
|
299
|
+
if (scenarios.length >= limit)
|
|
300
|
+
break;
|
|
301
|
+
scenarios.push({
|
|
302
|
+
name: `Navigation to ${navPage.path}`,
|
|
303
|
+
steps: [
|
|
304
|
+
{
|
|
305
|
+
type: "click",
|
|
306
|
+
selector: `a[href='${navPage.path}']`,
|
|
307
|
+
description: `Navigate to ${navPage.path}`,
|
|
308
|
+
},
|
|
309
|
+
{ type: "wait", duration: 1000, description: "Wait for navigation" },
|
|
310
|
+
{ type: "check_healthy_page", description: "Destination should not be error" },
|
|
311
|
+
{ type: "check_visible", selector: "body", description: "Destination should render" },
|
|
312
|
+
],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
// Scenario: Button interactions
|
|
316
|
+
for (const page of crawlResult.pages) {
|
|
317
|
+
if (scenarios.length >= limit)
|
|
318
|
+
break;
|
|
319
|
+
const nonFormButtons = page.buttons.filter((b) => !b.includes("submit")).slice(0, 1);
|
|
320
|
+
for (const btnSelector of nonFormButtons) {
|
|
321
|
+
if (scenarios.length >= limit)
|
|
322
|
+
break;
|
|
323
|
+
const steps = [];
|
|
324
|
+
if (page.path !== "/") {
|
|
325
|
+
steps.push({
|
|
326
|
+
type: "goto",
|
|
327
|
+
gotoUrl: page.path,
|
|
328
|
+
description: `Navigate to ${page.path}`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
steps.push({ type: "check_visible", selector: btnSelector, description: `Button should be visible` }, { type: "click", selector: btnSelector, description: `Click ${btnSelector}` }, { type: "wait", duration: 800, description: "Wait for response" }, { type: "check_visible", selector: "body", description: "Page should remain stable" });
|
|
332
|
+
scenarios.push({
|
|
333
|
+
name: `Button interaction on ${page.path}`,
|
|
334
|
+
steps: steps.slice(0, 5),
|
|
335
|
+
initialUrl: page.path !== "/" ? page.path : undefined,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return scenarios.slice(0, limit);
|
|
340
|
+
}
|
|
341
|
+
function getSmartFillValue(inputType, placeholder) {
|
|
342
|
+
if (inputType === "email" || placeholder?.toLowerCase().includes("email"))
|
|
343
|
+
return "test@laxy.dev";
|
|
344
|
+
if (inputType === "password")
|
|
345
|
+
return "Test1234!";
|
|
346
|
+
if (inputType === "number")
|
|
347
|
+
return "42";
|
|
348
|
+
if (inputType === "tel" || placeholder?.toLowerCase().includes("phone"))
|
|
349
|
+
return "010-1234-5678";
|
|
350
|
+
if (inputType === "url")
|
|
351
|
+
return "https://example.com";
|
|
352
|
+
if (placeholder?.toLowerCase().includes("search") || placeholder?.toLowerCase().includes("검색"))
|
|
353
|
+
return "laxy verify";
|
|
354
|
+
if (placeholder?.toLowerCase().includes("name") || placeholder?.toLowerCase().includes("이름"))
|
|
355
|
+
return "Laxy User";
|
|
356
|
+
return "laxy-verify-test";
|
|
357
|
+
}
|