playwright-intelligence-engine 2.0.1 → 2.0.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.
Files changed (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. package/src/selector/selector-scorer.ts +344 -344
@@ -1,32 +1,32 @@
1
- import { z } from 'zod';
2
-
3
- export const AppElementSchema = z.object({
4
- tag: z.string().min(1),
5
- role: z.string().optional(),
6
- accessibleName: z.string().optional(),
7
- label: z.string().optional(),
8
- testId: z.string().optional(),
9
- placeholder: z.string().optional(),
10
- type: z.string().optional(),
11
- frameSelector: z.string().optional(),
12
- count: z.number().int().min(1).default(1),
13
- visible: z.boolean().default(true),
14
- });
15
- export type AppElement = z.infer<typeof AppElementSchema>;
16
-
17
- export const AppRouteSchema = z.object({
18
- route: z.string().min(1),
19
- title: z.string().optional(),
20
- elements: z.array(AppElementSchema).default([]),
21
- });
22
- export type AppRoute = z.infer<typeof AppRouteSchema>;
23
-
24
- export const AppModelSchema = z.object({
25
- version: z.literal('1').default('1'),
26
- baseUrl: z.string().min(1),
27
- fingerprint: z.string().min(1),
28
- capturedAt: z.string(),
29
- routes: z.array(AppRouteSchema).default([]),
30
- });
31
- export type AppModel = z.infer<typeof AppModelSchema>;
32
-
1
+ import { z } from 'zod';
2
+
3
+ export const AppElementSchema = z.object({
4
+ tag: z.string().min(1),
5
+ role: z.string().optional(),
6
+ accessibleName: z.string().optional(),
7
+ label: z.string().optional(),
8
+ testId: z.string().optional(),
9
+ placeholder: z.string().optional(),
10
+ type: z.string().optional(),
11
+ frameSelector: z.string().optional(),
12
+ count: z.number().int().min(1).default(1),
13
+ visible: z.boolean().default(true),
14
+ });
15
+ export type AppElement = z.infer<typeof AppElementSchema>;
16
+
17
+ export const AppRouteSchema = z.object({
18
+ route: z.string().min(1),
19
+ title: z.string().optional(),
20
+ elements: z.array(AppElementSchema).default([]),
21
+ });
22
+ export type AppRoute = z.infer<typeof AppRouteSchema>;
23
+
24
+ export const AppModelSchema = z.object({
25
+ version: z.literal('1').default('1'),
26
+ baseUrl: z.string().min(1),
27
+ fingerprint: z.string().min(1),
28
+ capturedAt: z.string(),
29
+ routes: z.array(AppRouteSchema).default([]),
30
+ });
31
+ export type AppModel = z.infer<typeof AppModelSchema>;
32
+
@@ -1,263 +1,263 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import crypto from 'node:crypto';
4
- import { AppModel, AppModelSchema, AppRoute, AppElement } from './app-model.js';
5
-
6
- export interface CrawlerOptions {
7
- baseUrl: string;
8
- fingerprint?: string;
9
- maxRoutes?: number;
10
- maxDepth?: number;
11
- storageStatePath?: string;
12
- cacheDir?: string;
13
- browser?: any;
14
- page?: any;
15
- }
16
-
17
- export function computeCacheKey(baseUrl: string, fingerprint?: string): string {
18
- const normalized = baseUrl.replace(/\/+$/, '').toLowerCase();
19
- const raw = `${normalized}::${fingerprint ?? 'default'}`;
20
- return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
21
- }
22
-
23
- export async function getCachedAppModel(cacheDir: string, baseUrl: string, fingerprint?: string): Promise<AppModel | null> {
24
- try {
25
- const key = computeCacheKey(baseUrl, fingerprint);
26
- const filePath = path.join(cacheDir, `app-model-${key}.json`);
27
- const content = await fs.readFile(filePath, 'utf8');
28
- const parsed = JSON.parse(content);
29
- const validated = AppModelSchema.safeParse(parsed);
30
- if (validated.success) {
31
- return validated.data;
32
- }
33
- return null;
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
- export async function saveCachedAppModel(cacheDir: string, model: AppModel): Promise<void> {
40
- try {
41
- await fs.mkdir(cacheDir, { recursive: true });
42
- const key = computeCacheKey(model.baseUrl, model.fingerprint);
43
- const filePath = path.join(cacheDir, `app-model-${key}.json`);
44
- await fs.writeFile(filePath, JSON.stringify(model, null, 2), 'utf8');
45
- } catch {
46
- // Non-fatal if cache cannot be written
47
- }
48
- }
49
-
50
- async function extractFrameSelector(frame: any): Promise<string> {
51
- try {
52
- if (typeof frame.name === 'function' && frame.name()) {
53
- return `iframe[name="${frame.name()}"]`;
54
- }
55
- if (typeof frame.frameElement === 'function') {
56
- const handle = await frame.frameElement();
57
- if (handle) {
58
- const selector = await handle.evaluate((el: any) => {
59
- const title = el.getAttribute('title');
60
- if (title) return `iframe[title="${title}"]`;
61
- const name = el.getAttribute('name');
62
- if (name) return `iframe[name="${name}"]`;
63
- const id = el.getAttribute('id');
64
- if (id) return `iframe#${id}`;
65
- const src = el.getAttribute('src');
66
- if (src) return `iframe[src="${src}"]`;
67
- return 'iframe';
68
- });
69
- if (selector) return selector;
70
- }
71
- }
72
- } catch {
73
- // Fallback if frame element lookup fails
74
- }
75
- return 'iframe';
76
- }
77
-
78
- async function collectFrameElements(frameHandle: any, frameSelector?: string): Promise<AppElement[]> {
79
- try {
80
- const raw = await frameHandle.$$eval('button, a, input, select, textarea, [role="button"], [role="textbox"], [role="status"], [role="list"], [role="listitem"], ul, ol, heading, h1, h2, h3', (nodes: any[]) => {
81
- return nodes.map((el) => {
82
- const tag = el.tagName.toLowerCase();
83
- const type = el.getAttribute('type') || undefined;
84
- let role = el.getAttribute('role');
85
- if (!role) {
86
- if (tag === 'button') role = 'button';
87
- else if (tag === 'input') {
88
- if (type === 'checkbox') role = 'checkbox';
89
- else if (type === 'radio') role = 'radio';
90
- else if (type === 'submit' || type === 'button') role = 'button';
91
- else role = 'textbox';
92
- } else if (tag === 'textarea') role = 'textbox';
93
- else if (tag === 'select') role = 'combobox';
94
- else if (/^h[1-6]$/.test(tag)) role = 'heading';
95
- else if (tag === 'ul' || tag === 'ol') role = 'list';
96
- }
97
- const placeholder = el.getAttribute('placeholder') || undefined;
98
- const accessibleName = el.getAttribute('aria-label') || el.innerText?.trim() || placeholder || undefined;
99
- const label = el.getAttribute('aria-label') || el.getAttribute('name') || undefined;
100
- const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || undefined;
101
- const visible = Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
102
- return { tag, role, accessibleName, label, testId, placeholder, type, visible };
103
- });
104
- });
105
-
106
- const elements: AppElement[] = raw.map((item: any) => ({
107
- tag: item.tag,
108
- role: item.role,
109
- accessibleName: item.accessibleName,
110
- label: item.label,
111
- testId: item.testId,
112
- placeholder: item.placeholder,
113
- type: item.type,
114
- frameSelector,
115
- count: 1,
116
- visible: item.visible,
117
- }));
118
- return elements;
119
- } catch {
120
- if (frameSelector) {
121
- return [{
122
- tag: 'iframe',
123
- role: 'cross-origin-unavailable',
124
- frameSelector,
125
- count: 1,
126
- visible: true,
127
- }];
128
- }
129
- return [];
130
- }
131
- }
132
-
133
- export class AppCrawler {
134
- public static async crawl(options: CrawlerOptions): Promise<AppModel> {
135
- const cacheDir = options.cacheDir ?? path.join(process.cwd(), '.pie', 'cache');
136
- const fingerprint = options.fingerprint ?? 'default';
137
-
138
- // 1. Check cache
139
- const cached = await getCachedAppModel(cacheDir, options.baseUrl, fingerprint);
140
- if (cached) {
141
- return cached;
142
- }
143
-
144
- const maxRoutes = options.maxRoutes ?? 20;
145
- const maxDepth = options.maxDepth ?? 2;
146
- const baseHost = new URL(options.baseUrl).host;
147
-
148
- const routes: AppRoute[] = [];
149
- let browserToClose: any = null;
150
-
151
- try {
152
- let page = options.page;
153
- if (!page) {
154
- if (options.browser) {
155
- const context = await options.browser.newContext({
156
- storageState: options.storageStatePath,
157
- });
158
- page = await context.newPage();
159
- } else {
160
- // Attempt dynamic import of playwright
161
- try {
162
- const { chromium } = await import('@playwright/test');
163
- browserToClose = await chromium.launch({ headless: true });
164
- const context = await browserToClose.newContext({
165
- storageState: options.storageStatePath,
166
- });
167
- page = await context.newPage();
168
- } catch {
169
- // If browser cannot be launched, return minimal bounded model
170
- const fallbackModel: AppModel = {
171
- version: '1',
172
- baseUrl: options.baseUrl,
173
- fingerprint,
174
- capturedAt: new Date().toISOString(),
175
- routes: [{ route: '/', elements: [] }],
176
- };
177
- return fallbackModel;
178
- }
179
- }
180
- }
181
-
182
- const queue: Array<{ url: string; depth: number }> = [{ url: options.baseUrl, depth: 0 }];
183
- const visited = new Set<string>();
184
-
185
- while (queue.length > 0 && routes.length < maxRoutes) {
186
- const item = queue.shift()!;
187
- if (visited.has(item.url)) continue;
188
- visited.add(item.url);
189
-
190
- try {
191
- await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
192
- } catch {
193
- continue;
194
- }
195
-
196
- const elements: AppElement[] = [];
197
-
198
- // Main frame elements
199
- const mainElements = await collectFrameElements(page, undefined);
200
- elements.push(...mainElements);
201
-
202
- // Child frame elements
203
- if (typeof page.frames === 'function') {
204
- const childFrames = page.frames().slice(1);
205
- for (const childFrame of childFrames) {
206
- const frameSelector = await extractFrameSelector(childFrame);
207
- const childElements = await collectFrameElements(childFrame, frameSelector);
208
- elements.push(...childElements);
209
- }
210
- }
211
-
212
- const urlObj = new URL(item.url);
213
- routes.push({
214
- route: urlObj.pathname || '/',
215
- title: typeof page.title === 'function' ? await page.title().catch(() => undefined) : undefined,
216
- elements,
217
- });
218
-
219
- // Bounded exploration: collect links without clicking destructive elements
220
- if (item.depth < maxDepth && routes.length < maxRoutes) {
221
- try {
222
- const hrefs: string[] = await page.$$eval('a[href]', (anchors: any[]) =>
223
- anchors.map((a) => a.getAttribute('href')).filter(Boolean),
224
- );
225
- for (const href of hrefs) {
226
- try {
227
- const resolved = new URL(href, item.url);
228
- // Allowlisted host only, no logout/delete/submit paths
229
- if (resolved.host === baseHost && !/logout|signout|delete|destroy/i.test(resolved.pathname)) {
230
- const cleaned = resolved.origin + resolved.pathname;
231
- if (!visited.has(cleaned) && !queue.some((q) => q.url === cleaned)) {
232
- queue.push({ url: cleaned, depth: item.depth + 1 });
233
- }
234
- }
235
- } catch {
236
- // Ignore invalid URLs
237
- }
238
- }
239
- } catch {
240
- // Ignore link evaluation failure
241
- }
242
- }
243
- }
244
- } catch {
245
- // Bounded fallback
246
- } finally {
247
- if (browserToClose) {
248
- await browserToClose.close().catch(() => {});
249
- }
250
- }
251
-
252
- const model: AppModel = {
253
- version: '1',
254
- baseUrl: options.baseUrl,
255
- fingerprint,
256
- capturedAt: new Date().toISOString(),
257
- routes: routes.length > 0 ? routes : [{ route: '/', elements: [] }],
258
- };
259
-
260
- await saveCachedAppModel(cacheDir, model);
261
- return model;
262
- }
263
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { AppModel, AppModelSchema, AppRoute, AppElement } from './app-model.js';
5
+
6
+ export interface CrawlerOptions {
7
+ baseUrl: string;
8
+ fingerprint?: string;
9
+ maxRoutes?: number;
10
+ maxDepth?: number;
11
+ storageStatePath?: string;
12
+ cacheDir?: string;
13
+ browser?: any;
14
+ page?: any;
15
+ }
16
+
17
+ export function computeCacheKey(baseUrl: string, fingerprint?: string): string {
18
+ const normalized = baseUrl.replace(/\/+$/, '').toLowerCase();
19
+ const raw = `${normalized}::${fingerprint ?? 'default'}`;
20
+ return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
21
+ }
22
+
23
+ export async function getCachedAppModel(cacheDir: string, baseUrl: string, fingerprint?: string): Promise<AppModel | null> {
24
+ try {
25
+ const key = computeCacheKey(baseUrl, fingerprint);
26
+ const filePath = path.join(cacheDir, `app-model-${key}.json`);
27
+ const content = await fs.readFile(filePath, 'utf8');
28
+ const parsed = JSON.parse(content);
29
+ const validated = AppModelSchema.safeParse(parsed);
30
+ if (validated.success) {
31
+ return validated.data;
32
+ }
33
+ return null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export async function saveCachedAppModel(cacheDir: string, model: AppModel): Promise<void> {
40
+ try {
41
+ await fs.mkdir(cacheDir, { recursive: true });
42
+ const key = computeCacheKey(model.baseUrl, model.fingerprint);
43
+ const filePath = path.join(cacheDir, `app-model-${key}.json`);
44
+ await fs.writeFile(filePath, JSON.stringify(model, null, 2), 'utf8');
45
+ } catch {
46
+ // Non-fatal if cache cannot be written
47
+ }
48
+ }
49
+
50
+ async function extractFrameSelector(frame: any): Promise<string> {
51
+ try {
52
+ if (typeof frame.name === 'function' && frame.name()) {
53
+ return `iframe[name="${frame.name()}"]`;
54
+ }
55
+ if (typeof frame.frameElement === 'function') {
56
+ const handle = await frame.frameElement();
57
+ if (handle) {
58
+ const selector = await handle.evaluate((el: any) => {
59
+ const title = el.getAttribute('title');
60
+ if (title) return `iframe[title="${title}"]`;
61
+ const name = el.getAttribute('name');
62
+ if (name) return `iframe[name="${name}"]`;
63
+ const id = el.getAttribute('id');
64
+ if (id) return `iframe#${id}`;
65
+ const src = el.getAttribute('src');
66
+ if (src) return `iframe[src="${src}"]`;
67
+ return 'iframe';
68
+ });
69
+ if (selector) return selector;
70
+ }
71
+ }
72
+ } catch {
73
+ // Fallback if frame element lookup fails
74
+ }
75
+ return 'iframe';
76
+ }
77
+
78
+ async function collectFrameElements(frameHandle: any, frameSelector?: string): Promise<AppElement[]> {
79
+ try {
80
+ const raw = await frameHandle.$$eval('button, a, input, select, textarea, [role="button"], [role="textbox"], [role="status"], [role="list"], [role="listitem"], ul, ol, heading, h1, h2, h3', (nodes: any[]) => {
81
+ return nodes.map((el) => {
82
+ const tag = el.tagName.toLowerCase();
83
+ const type = el.getAttribute('type') || undefined;
84
+ let role = el.getAttribute('role');
85
+ if (!role) {
86
+ if (tag === 'button') role = 'button';
87
+ else if (tag === 'input') {
88
+ if (type === 'checkbox') role = 'checkbox';
89
+ else if (type === 'radio') role = 'radio';
90
+ else if (type === 'submit' || type === 'button') role = 'button';
91
+ else role = 'textbox';
92
+ } else if (tag === 'textarea') role = 'textbox';
93
+ else if (tag === 'select') role = 'combobox';
94
+ else if (/^h[1-6]$/.test(tag)) role = 'heading';
95
+ else if (tag === 'ul' || tag === 'ol') role = 'list';
96
+ }
97
+ const placeholder = el.getAttribute('placeholder') || undefined;
98
+ const accessibleName = el.getAttribute('aria-label') || el.innerText?.trim() || placeholder || undefined;
99
+ const label = el.getAttribute('aria-label') || el.getAttribute('name') || undefined;
100
+ const testId = el.getAttribute('data-testid') || el.getAttribute('data-test') || undefined;
101
+ const visible = Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
102
+ return { tag, role, accessibleName, label, testId, placeholder, type, visible };
103
+ });
104
+ });
105
+
106
+ const elements: AppElement[] = raw.map((item: any) => ({
107
+ tag: item.tag,
108
+ role: item.role,
109
+ accessibleName: item.accessibleName,
110
+ label: item.label,
111
+ testId: item.testId,
112
+ placeholder: item.placeholder,
113
+ type: item.type,
114
+ frameSelector,
115
+ count: 1,
116
+ visible: item.visible,
117
+ }));
118
+ return elements;
119
+ } catch {
120
+ if (frameSelector) {
121
+ return [{
122
+ tag: 'iframe',
123
+ role: 'cross-origin-unavailable',
124
+ frameSelector,
125
+ count: 1,
126
+ visible: true,
127
+ }];
128
+ }
129
+ return [];
130
+ }
131
+ }
132
+
133
+ export class AppCrawler {
134
+ public static async crawl(options: CrawlerOptions): Promise<AppModel> {
135
+ const cacheDir = options.cacheDir ?? path.join(process.cwd(), '.pie', 'cache');
136
+ const fingerprint = options.fingerprint ?? 'default';
137
+
138
+ // 1. Check cache
139
+ const cached = await getCachedAppModel(cacheDir, options.baseUrl, fingerprint);
140
+ if (cached) {
141
+ return cached;
142
+ }
143
+
144
+ const maxRoutes = options.maxRoutes ?? 20;
145
+ const maxDepth = options.maxDepth ?? 2;
146
+ const baseHost = new URL(options.baseUrl).host;
147
+
148
+ const routes: AppRoute[] = [];
149
+ let browserToClose: any = null;
150
+
151
+ try {
152
+ let page = options.page;
153
+ if (!page) {
154
+ if (options.browser) {
155
+ const context = await options.browser.newContext({
156
+ storageState: options.storageStatePath,
157
+ });
158
+ page = await context.newPage();
159
+ } else {
160
+ // Attempt dynamic import of playwright
161
+ try {
162
+ const { chromium } = await import('@playwright/test');
163
+ browserToClose = await chromium.launch({ headless: true });
164
+ const context = await browserToClose.newContext({
165
+ storageState: options.storageStatePath,
166
+ });
167
+ page = await context.newPage();
168
+ } catch {
169
+ // If browser cannot be launched, return minimal bounded model
170
+ const fallbackModel: AppModel = {
171
+ version: '1',
172
+ baseUrl: options.baseUrl,
173
+ fingerprint,
174
+ capturedAt: new Date().toISOString(),
175
+ routes: [{ route: '/', elements: [] }],
176
+ };
177
+ return fallbackModel;
178
+ }
179
+ }
180
+ }
181
+
182
+ const queue: Array<{ url: string; depth: number }> = [{ url: options.baseUrl, depth: 0 }];
183
+ const visited = new Set<string>();
184
+
185
+ while (queue.length > 0 && routes.length < maxRoutes) {
186
+ const item = queue.shift()!;
187
+ if (visited.has(item.url)) continue;
188
+ visited.add(item.url);
189
+
190
+ try {
191
+ await page.goto(item.url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
192
+ } catch {
193
+ continue;
194
+ }
195
+
196
+ const elements: AppElement[] = [];
197
+
198
+ // Main frame elements
199
+ const mainElements = await collectFrameElements(page, undefined);
200
+ elements.push(...mainElements);
201
+
202
+ // Child frame elements
203
+ if (typeof page.frames === 'function') {
204
+ const childFrames = page.frames().slice(1);
205
+ for (const childFrame of childFrames) {
206
+ const frameSelector = await extractFrameSelector(childFrame);
207
+ const childElements = await collectFrameElements(childFrame, frameSelector);
208
+ elements.push(...childElements);
209
+ }
210
+ }
211
+
212
+ const urlObj = new URL(item.url);
213
+ routes.push({
214
+ route: urlObj.pathname || '/',
215
+ title: typeof page.title === 'function' ? await page.title().catch(() => undefined) : undefined,
216
+ elements,
217
+ });
218
+
219
+ // Bounded exploration: collect links without clicking destructive elements
220
+ if (item.depth < maxDepth && routes.length < maxRoutes) {
221
+ try {
222
+ const hrefs: string[] = await page.$$eval('a[href]', (anchors: any[]) =>
223
+ anchors.map((a) => a.getAttribute('href')).filter(Boolean),
224
+ );
225
+ for (const href of hrefs) {
226
+ try {
227
+ const resolved = new URL(href, item.url);
228
+ // Allowlisted host only, no logout/delete/submit paths
229
+ if (resolved.host === baseHost && !/logout|signout|delete|destroy/i.test(resolved.pathname)) {
230
+ const cleaned = resolved.origin + resolved.pathname;
231
+ if (!visited.has(cleaned) && !queue.some((q) => q.url === cleaned)) {
232
+ queue.push({ url: cleaned, depth: item.depth + 1 });
233
+ }
234
+ }
235
+ } catch {
236
+ // Ignore invalid URLs
237
+ }
238
+ }
239
+ } catch {
240
+ // Ignore link evaluation failure
241
+ }
242
+ }
243
+ }
244
+ } catch {
245
+ // Bounded fallback
246
+ } finally {
247
+ if (browserToClose) {
248
+ await browserToClose.close().catch(() => {});
249
+ }
250
+ }
251
+
252
+ const model: AppModel = {
253
+ version: '1',
254
+ baseUrl: options.baseUrl,
255
+ fingerprint,
256
+ capturedAt: new Date().toISOString(),
257
+ routes: routes.length > 0 ? routes : [{ route: '/', elements: [] }],
258
+ };
259
+
260
+ await saveCachedAppModel(cacheDir, model);
261
+ return model;
262
+ }
263
+ }
@@ -1,3 +1,3 @@
1
- export * from './app-model.js';
2
- export * from './crawler.js';
3
-
1
+ export * from './app-model.js';
2
+ export * from './crawler.js';
3
+