laxy-verify 1.2.0 → 1.2.1

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/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
+ }