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.
- package/README.md +229 -229
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/core/version.d.ts +3 -3
- package/dist/index.js +81 -24
- package/package.json +76 -76
- package/src/authoring/author.ts +81 -81
- package/src/authoring/generation-contract.ts +283 -283
- package/src/authoring/hybrid-author.ts +115 -115
- package/src/authoring/index.ts +7 -7
- package/src/authoring/llm-author.ts +282 -282
- package/src/authoring/naming/name-plan.ts +28 -28
- package/src/authoring/naming/name-planner.ts +339 -339
- package/src/authoring/naming/naming-linter.ts +292 -292
- package/src/cli/index.ts +1238 -1172
- package/src/collectors/git-diff-collector.ts +190 -190
- package/src/core/version.ts +25 -25
- package/src/diagnostics/diagnostic-report.ts +204 -204
- package/src/diagnostics/failure-classifier.ts +701 -701
- package/src/evaluation/repository-runner.ts +197 -197
- package/src/gate/quality.ts +101 -101
- package/src/generator/architecture-generator.ts +582 -582
- package/src/generator/assertion-quality-gate.ts +88 -88
- package/src/generator/candidate-writer.ts +288 -288
- package/src/generator/test-generator.ts +52 -52
- package/src/index.ts +107 -107
- package/src/intelligence/evidence-bundle.ts +300 -300
- package/src/intelligence/generation-agent.ts +561 -561
- package/src/intelligence/generation-verifier.ts +1115 -1115
- package/src/intelligence/locator-disambiguation-engine.ts +331 -331
- package/src/intelligence/project-intelligence.ts +140 -140
- package/src/intelligence/repository-profile.ts +796 -796
- package/src/intelligence/trace-proposal-workflow.ts +318 -318
- package/src/intelligence/workflow-planner.ts +966 -966
- package/src/mcp/server.ts +970 -970
- package/src/perception/app-model.ts +32 -32
- package/src/perception/crawler.ts +263 -263
- package/src/perception/index.ts +3 -3
- package/src/repair/assertion-hasher.ts +386 -386
- package/src/repair/diff-allowlist.ts +612 -612
- package/src/repair/intent-validator.ts +761 -761
- package/src/repair/repair-planner.ts +467 -467
- package/src/repair/statement-patcher.ts +544 -544
- package/src/reproduce/interactive-workflow-runner.ts +828 -828
- package/src/sandbox/execution-runner.ts +408 -408
- package/src/selector/abstention-policy.ts +175 -175
- package/src/selector/candidate-extractor.ts +343 -343
- package/src/selector/repair-candidate-ranker.ts +92 -92
- 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
|
+
}
|
package/src/perception/index.ts
CHANGED
|
@@ -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
|
+
|