@detiq/crawler 1.1.22 → 1.1.24
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 +173 -28
- package/dist/crawler.js.map +2 -2
- package/dist/extractors/branding-extractor.js +104 -0
- package/dist/extractors/branding-extractor.js.map +7 -0
- package/dist/extractors/summary-extractor.js +36 -0
- package/dist/extractors/summary-extractor.js.map +7 -0
- package/dist/frontier.js +20 -2
- package/dist/frontier.js.map +2 -2
- package/dist/incremental-diff.js +76 -0
- package/dist/incremental-diff.js.map +2 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +2 -2
- package/dist/interactive-probe.js +108 -17
- package/dist/interactive-probe.js.map +2 -2
- package/dist/job-queue.js +430 -67
- package/dist/job-queue.js.map +2 -2
- package/dist/sqs-queue.js +78 -10
- package/dist/sqs-queue.js.map +2 -2
- package/dist/worker.js +7 -7
- package/dist/worker.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +208 -30
- package/src/extractors/branding-extractor.ts +159 -0
- package/src/extractors/summary-extractor.ts +49 -0
- package/src/frontier.ts +31 -1
- package/src/incremental-diff.ts +138 -0
- package/src/index.ts +1 -1
- package/src/interactive-probe.ts +129 -20
- package/src/job-queue.ts +525 -69
- package/src/sqs-queue.ts +92 -12
- package/src/worker.ts +13 -11
- package/types/crawler.d.ts +20 -0
- package/types/extractors/branding-extractor.d.ts +46 -0
- package/types/extractors/summary-extractor.d.ts +10 -0
- package/types/frontier.d.ts +9 -1
- package/types/incremental-diff.d.ts +57 -0
- package/types/index.d.ts +1 -1
- package/types/interactive-probe.d.ts +5 -1
- package/types/job-queue.d.ts +53 -0
- package/types/sqs-queue.d.ts +12 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface SummaryResult {
|
|
2
|
+
summary: string;
|
|
3
|
+
wordCount: number;
|
|
4
|
+
method: 'llm' | 'truncated';
|
|
5
|
+
model?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function extractSummary(
|
|
9
|
+
markdown: string,
|
|
10
|
+
options?: { model?: string; maxTokens?: number },
|
|
11
|
+
): Promise<SummaryResult> {
|
|
12
|
+
// Truncate to 8000 chars to stay within token budget
|
|
13
|
+
const input = markdown.slice(0, 8000);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Dynamic import to avoid hard dependency if agents package unavailable
|
|
17
|
+
const { ask } = await import('@detiq/agents').catch(() => ({ ask: null as any }));
|
|
18
|
+
|
|
19
|
+
if (!ask) {
|
|
20
|
+
return makeTruncated(input);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const summary: string = await ask(
|
|
24
|
+
'Summarize the following web page content in 2-4 concise sentences. Focus on the main purpose, key features, and primary value proposition. Return only the summary text, no preamble.',
|
|
25
|
+
input,
|
|
26
|
+
options?.model ? { provider: 'anthropic', model: options.model } : undefined,
|
|
27
|
+
options?.maxTokens ?? 300,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
summary: summary.trim(),
|
|
32
|
+
wordCount: markdown.split(/\s+/).length,
|
|
33
|
+
method: 'llm',
|
|
34
|
+
model: options?.model,
|
|
35
|
+
};
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.warn('[summary-extractor] LLM call failed, using truncated fallback:', err);
|
|
38
|
+
return makeTruncated(input);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeTruncated(input: string): SummaryResult {
|
|
43
|
+
const excerpt = input.slice(0, 500).trim();
|
|
44
|
+
return {
|
|
45
|
+
summary: excerpt + (input.length > 500 ? '...' : ''),
|
|
46
|
+
wordCount: excerpt.split(/\s+/).length,
|
|
47
|
+
method: 'truncated',
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/frontier.ts
CHANGED
|
@@ -11,6 +11,23 @@
|
|
|
11
11
|
|
|
12
12
|
export type TraversalStrategy = 'BFS' | 'DFS' | 'BEST_FIRST';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Simple glob-like pattern matching against a URL's pathname.
|
|
16
|
+
* Supports `*` (matches any single path segment characters, not `/`)
|
|
17
|
+
* and `**` (matches anything including `/`).
|
|
18
|
+
*/
|
|
19
|
+
export function matchesPattern(url: string, patterns: string[]): boolean {
|
|
20
|
+
try {
|
|
21
|
+
const path = new URL(url).pathname;
|
|
22
|
+
return patterns.some(pattern => {
|
|
23
|
+
const regex = new RegExp('^' + pattern.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$');
|
|
24
|
+
return regex.test(path);
|
|
25
|
+
});
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
interface FrontierEntry {
|
|
15
32
|
url: string;
|
|
16
33
|
score: number;
|
|
@@ -47,13 +64,24 @@ export class CrawlFrontier {
|
|
|
47
64
|
private readonly strategy: TraversalStrategy;
|
|
48
65
|
private readonly keywords: string[];
|
|
49
66
|
private readonly urlFilter: RegExp | null;
|
|
67
|
+
private readonly includePatterns: string[];
|
|
68
|
+
private readonly excludePatterns: string[];
|
|
50
69
|
|
|
51
|
-
constructor(
|
|
70
|
+
constructor(
|
|
71
|
+
strategy: TraversalStrategy,
|
|
72
|
+
seedUrls: string[],
|
|
73
|
+
keywords: string[] = [],
|
|
74
|
+
urlFilter?: string,
|
|
75
|
+
includePatterns?: string[],
|
|
76
|
+
excludePatterns?: string[],
|
|
77
|
+
) {
|
|
52
78
|
this.strategy = strategy;
|
|
53
79
|
this.keywords = keywords.map((k) => k.toLowerCase()).filter(Boolean);
|
|
54
80
|
this.urlFilter = urlFilter
|
|
55
81
|
? (() => { try { return new RegExp(urlFilter, 'i'); } catch { return null; } })()
|
|
56
82
|
: null;
|
|
83
|
+
this.includePatterns = includePatterns ?? [];
|
|
84
|
+
this.excludePatterns = excludePatterns ?? [];
|
|
57
85
|
for (const url of seedUrls) this.push(url);
|
|
58
86
|
}
|
|
59
87
|
|
|
@@ -68,6 +96,8 @@ export class CrawlFrontier {
|
|
|
68
96
|
*/
|
|
69
97
|
push(url: string, pushOpts?: { parentElements?: number }): void {
|
|
70
98
|
if (this.urlFilter && !this.urlFilter.test(url)) return;
|
|
99
|
+
if (this.includePatterns.length && !matchesPattern(url, this.includePatterns)) return;
|
|
100
|
+
if (this.excludePatterns.length && matchesPattern(url, this.excludePatterns)) return;
|
|
71
101
|
const sparseBoost =
|
|
72
102
|
this.strategy === 'BEST_FIRST' &&
|
|
73
103
|
pushOpts?.parentElements !== undefined &&
|
package/src/incremental-diff.ts
CHANGED
|
@@ -119,3 +119,141 @@ export function partitionByChange(
|
|
|
119
119
|
}
|
|
120
120
|
return { toProcess, toSkip };
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
// ─── Typed monitor diffs (GAP-01) ───────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/** A single line-level change in text content (markdown, html, summary). */
|
|
126
|
+
export interface TextChange {
|
|
127
|
+
type: 'added' | 'removed';
|
|
128
|
+
line: number;
|
|
129
|
+
content: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A change to a specific JSON path between two scraped JSON objects. */
|
|
133
|
+
export interface JsonChange {
|
|
134
|
+
path: string;
|
|
135
|
+
old: unknown;
|
|
136
|
+
new: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Set-based change for collections like links or images. */
|
|
140
|
+
export interface SetChange {
|
|
141
|
+
added: string[];
|
|
142
|
+
removed: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Screenshot pixel-level change. changed is percentage (0-100). */
|
|
146
|
+
export interface ImageChange {
|
|
147
|
+
size: number;
|
|
148
|
+
changed: number;
|
|
149
|
+
mask?: string; // base64 diff mask
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** All possible diffs for a single monitor tick. */
|
|
153
|
+
export interface MonitorDiffs {
|
|
154
|
+
markdown?: TextChange[];
|
|
155
|
+
html?: TextChange[];
|
|
156
|
+
json?: JsonChange[];
|
|
157
|
+
summary?: TextChange[];
|
|
158
|
+
links?: SetChange;
|
|
159
|
+
images?: SetChange;
|
|
160
|
+
branding?: JsonChange[];
|
|
161
|
+
screenshot?: ImageChange;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Compute line-level text diffs between two strings.
|
|
166
|
+
* Returns TextChange[] showing exactly which lines were added or removed.
|
|
167
|
+
* No external dependencies — pure JS Myers-inspired algorithm.
|
|
168
|
+
*/
|
|
169
|
+
export function computeTextDiffs(previous: string, current: string): TextChange[] {
|
|
170
|
+
const prevLines = previous.split('\n');
|
|
171
|
+
const currLines = current.split('\n');
|
|
172
|
+
const changes: TextChange[] = [];
|
|
173
|
+
|
|
174
|
+
const prevSet = new Set(prevLines);
|
|
175
|
+
const currSet = new Set(currLines);
|
|
176
|
+
|
|
177
|
+
let lineNum = 0;
|
|
178
|
+
for (const line of prevLines) {
|
|
179
|
+
if (!currSet.has(line)) {
|
|
180
|
+
changes.push({ type: 'removed', line: lineNum, content: line });
|
|
181
|
+
}
|
|
182
|
+
lineNum++;
|
|
183
|
+
}
|
|
184
|
+
lineNum = 0;
|
|
185
|
+
for (const line of currLines) {
|
|
186
|
+
if (!prevSet.has(line)) {
|
|
187
|
+
changes.push({ type: 'added', line: lineNum, content: line });
|
|
188
|
+
}
|
|
189
|
+
lineNum++;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return changes;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Compute set-based diffs for URL collections (links, images).
|
|
197
|
+
*/
|
|
198
|
+
export function computeSetDiffs(previous: string[], current: string[]): SetChange {
|
|
199
|
+
const prevSet = new Set(previous);
|
|
200
|
+
const currSet = new Set(current);
|
|
201
|
+
return {
|
|
202
|
+
added: current.filter(u => !prevSet.has(u)),
|
|
203
|
+
removed: previous.filter(u => !currSet.has(u)),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Flatten a JSON object into dot-notation path/value pairs.
|
|
209
|
+
* Used to compute JsonChange[] between two JSON extraction results.
|
|
210
|
+
*/
|
|
211
|
+
export function flattenJson(obj: unknown, prefix = ''): Record<string, unknown> {
|
|
212
|
+
if (typeof obj !== 'object' || obj === null) {
|
|
213
|
+
return { [prefix]: obj };
|
|
214
|
+
}
|
|
215
|
+
const result: Record<string, unknown> = {};
|
|
216
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
217
|
+
const path = prefix ? `${prefix}.${k}` : k;
|
|
218
|
+
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
219
|
+
Object.assign(result, flattenJson(v, path));
|
|
220
|
+
} else {
|
|
221
|
+
result[path] = v;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Compute JSON path-level diffs between two extraction results.
|
|
229
|
+
*/
|
|
230
|
+
export function computeJsonDiffs(previous: unknown, current: unknown): JsonChange[] {
|
|
231
|
+
const prevFlat = flattenJson(previous);
|
|
232
|
+
const currFlat = flattenJson(current);
|
|
233
|
+
const allKeys = new Set([...Object.keys(prevFlat), ...Object.keys(currFlat)]);
|
|
234
|
+
const changes: JsonChange[] = [];
|
|
235
|
+
for (const path of allKeys) {
|
|
236
|
+
const oldVal = prevFlat[path];
|
|
237
|
+
const newVal = currFlat[path];
|
|
238
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
239
|
+
changes.push({ path, old: oldVal, new: newVal });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return changes;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Count total number of changes across all diff types.
|
|
247
|
+
*/
|
|
248
|
+
export function countMonitorDiffs(diffs: MonitorDiffs): number {
|
|
249
|
+
let count = 0;
|
|
250
|
+
if (diffs.markdown) count += diffs.markdown.length;
|
|
251
|
+
if (diffs.html) count += diffs.html.length;
|
|
252
|
+
if (diffs.json) count += diffs.json.length;
|
|
253
|
+
if (diffs.summary) count += diffs.summary.length;
|
|
254
|
+
if (diffs.links) count += diffs.links.added.length + diffs.links.removed.length;
|
|
255
|
+
if (diffs.images) count += diffs.images.added.length + diffs.images.removed.length;
|
|
256
|
+
if (diffs.branding) count += diffs.branding.length;
|
|
257
|
+
if (diffs.screenshot) count += diffs.screenshot.changed > 0 ? 1 : 0;
|
|
258
|
+
return count;
|
|
259
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export * from './visual-diff-verdict.js';
|
|
|
3
3
|
export * from './auth.js';
|
|
4
4
|
export * from './screen-reader.js';
|
|
5
5
|
export * from './job-queue.js';
|
|
6
|
-
export { startSqsWorker } from './sqs-queue.js';
|
|
6
|
+
export { startSqsWorker, getDlqStats, redriveFromDlq } from './sqs-queue.js';
|
|
7
7
|
export * from './session-capture.js';
|
|
8
8
|
export * from './session-export.js';
|
|
9
9
|
export * from './test-executor.js';
|
package/src/interactive-probe.ts
CHANGED
|
@@ -43,12 +43,16 @@ export interface ModalObservation {
|
|
|
43
43
|
title: string;
|
|
44
44
|
content: string;
|
|
45
45
|
fields: string[];
|
|
46
|
+
stateId?: string;
|
|
47
|
+
domSnapshot?: string;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export interface ConditionalReveal {
|
|
49
51
|
triggerElement: string;
|
|
50
52
|
triggerValue: string;
|
|
51
53
|
revealedText: string;
|
|
54
|
+
stateId?: string;
|
|
55
|
+
domSnapshot?: string;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
export interface TooltipCapture {
|
|
@@ -196,27 +200,52 @@ async function findSubmitButton(page: Page): Promise<string | null> {
|
|
|
196
200
|
});
|
|
197
201
|
}
|
|
198
202
|
|
|
199
|
-
/** Find
|
|
203
|
+
/** Find modal-trigger buttons and clickable row-level items (e.g. test-case rows opening side panels). */
|
|
200
204
|
async function findModalTriggers(page: Page): Promise<Array<{ selector: string; label: string }>> {
|
|
201
205
|
return page.evaluate(() => {
|
|
202
206
|
const SKIP = /submit|send|save|create|log.?in|sign.?in|register|continue|checkout|buy|order|search|close|cancel|dismiss/i;
|
|
203
207
|
const NAV_PARENTS = new Set(['NAV', 'HEADER', 'FOOTER']);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
208
|
+
|
|
209
|
+
function isVisible(el: Element): boolean {
|
|
210
|
+
const s = window.getComputedStyle(el as HTMLElement);
|
|
211
|
+
return s.display !== 'none' && s.visibility !== 'hidden' && (s.opacity !== '0');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const seen = new Set<string>();
|
|
215
|
+
const results: Array<{ selector: string; label: string }> = [];
|
|
216
|
+
|
|
217
|
+
// Standard button / role=button elements
|
|
218
|
+
for (const el of Array.from(document.querySelectorAll('button, [role="button"]'))) {
|
|
219
|
+
if (!isVisible(el)) continue;
|
|
220
|
+
const label = ((el as HTMLElement).innerText?.trim() || el.getAttribute('aria-label') || '').slice(0, 60);
|
|
221
|
+
if (!label || SKIP.test(label) || seen.has(label)) continue;
|
|
222
|
+
let p = el.parentElement; let inNav = false;
|
|
223
|
+
while (p) { if (NAV_PARENTS.has(p.tagName)) { inNav = true; break; } p = p.parentElement; }
|
|
224
|
+
if (inNav) continue;
|
|
225
|
+
seen.add(label);
|
|
226
|
+
results.push({ selector: `button:has-text("${label.slice(0, 40)}")`, label });
|
|
227
|
+
if (results.length >= 15) break;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Row-level clickable elements — table/list rows that open side panels on click
|
|
231
|
+
// (e.g. test-case rows → ScreenPreviewPanel, ScriptPopup)
|
|
232
|
+
const rowCandidates = Array.from(document.querySelectorAll(
|
|
233
|
+
'[role="row"][tabindex="0"], tr[tabindex="0"], [role="listitem"][tabindex="0"], [class*="list-item"][tabindex="0"], [class*="table-row"][tabindex="0"]'
|
|
234
|
+
)).filter(isVisible).slice(0, 3);
|
|
235
|
+
for (const row of rowCandidates) {
|
|
236
|
+
const ariaLabel = row.getAttribute('aria-label');
|
|
237
|
+
const textLabel = (row as HTMLElement).innerText?.trim().split('\n')[0].trim().slice(0, 60);
|
|
238
|
+
const label = (ariaLabel || textLabel || '').slice(0, 60);
|
|
239
|
+
if (!label || seen.has(label)) continue;
|
|
240
|
+
seen.add(label);
|
|
241
|
+
// Use aria-label selector when available; fall back to row role + text content
|
|
242
|
+
const selector = ariaLabel
|
|
243
|
+
? `[aria-label="${ariaLabel}"]`
|
|
244
|
+
: `[role="row"]:has-text("${label.slice(0, 40)}")`;
|
|
245
|
+
results.push({ selector, label });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return results.slice(0, 20);
|
|
220
249
|
});
|
|
221
250
|
}
|
|
222
251
|
|
|
@@ -245,7 +274,17 @@ async function probeConditionals(page: Page): Promise<ConditionalReveal[]> {
|
|
|
245
274
|
.join(' | ');
|
|
246
275
|
});
|
|
247
276
|
if (revealed) {
|
|
248
|
-
|
|
277
|
+
const domSnapshot = await page.content();
|
|
278
|
+
const stateId = await page.evaluate(() => {
|
|
279
|
+
let hash = 0;
|
|
280
|
+
const str = document.body.innerText;
|
|
281
|
+
for (let i = 0; i < str.length; i++) {
|
|
282
|
+
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
|
283
|
+
hash = hash & hash;
|
|
284
|
+
}
|
|
285
|
+
return Math.abs(hash).toString(16);
|
|
286
|
+
});
|
|
287
|
+
results.push({ triggerElement: label, triggerValue: opt.text || opt.value, revealedText: revealed.slice(0, 200), stateId, domSnapshot });
|
|
249
288
|
}
|
|
250
289
|
}
|
|
251
290
|
}
|
|
@@ -366,11 +405,21 @@ export async function probeScreen(
|
|
|
366
405
|
const triggers = await findModalTriggers(page);
|
|
367
406
|
for (const trigger of triggers) {
|
|
368
407
|
try {
|
|
369
|
-
const btn = page.locator(
|
|
408
|
+
const btn = page.locator(trigger.selector).first();
|
|
370
409
|
const domBefore = await page.evaluate(() => document.body.innerText.length);
|
|
371
410
|
|
|
372
411
|
await btn.click({ timeout: 1500 });
|
|
373
|
-
|
|
412
|
+
// Adaptive wait: some triggers launch async jobs (e.g. script generation 5–30s).
|
|
413
|
+
// Wait for a visible dialog/panel rather than a fixed sleep.
|
|
414
|
+
try {
|
|
415
|
+
await page.waitForSelector(
|
|
416
|
+
'[role="dialog"],[role="alertdialog"],[aria-modal="true"],[data-state="open"],[class*="modal" i]:not([class*="backdrop" i]),[class*="Dialog"]:not([class*="Overlay"])',
|
|
417
|
+
{ timeout: 8000, state: 'visible' }
|
|
418
|
+
);
|
|
419
|
+
} catch {
|
|
420
|
+
// No dialog appeared — wait briefly for accordion/panel DOM settle
|
|
421
|
+
await page.waitForTimeout(300);
|
|
422
|
+
}
|
|
374
423
|
|
|
375
424
|
// Check if a dialog/modal appeared
|
|
376
425
|
const dialogText = await page.evaluate(() => {
|
|
@@ -387,12 +436,26 @@ export async function probeScreen(
|
|
|
387
436
|
return { title, content, fields };
|
|
388
437
|
});
|
|
389
438
|
|
|
439
|
+
const domSnapshot = await page.content();
|
|
440
|
+
const stateId = await page.evaluate(() => {
|
|
441
|
+
// simple simHash placeholder
|
|
442
|
+
let hash = 0;
|
|
443
|
+
const str = document.body.innerText;
|
|
444
|
+
for (let i = 0; i < str.length; i++) {
|
|
445
|
+
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
|
446
|
+
hash = hash & hash;
|
|
447
|
+
}
|
|
448
|
+
return Math.abs(hash).toString(16);
|
|
449
|
+
});
|
|
450
|
+
|
|
390
451
|
if (dialogText && dialogText.content.length > 20) {
|
|
391
452
|
result.modals.push({
|
|
392
453
|
triggerElement: trigger.label,
|
|
393
454
|
title: dialogText.title,
|
|
394
455
|
content: dialogText.content,
|
|
395
456
|
fields: dialogText.fields.slice(0, 10),
|
|
457
|
+
stateId,
|
|
458
|
+
domSnapshot,
|
|
396
459
|
});
|
|
397
460
|
} else {
|
|
398
461
|
// No modal appeared — check for accordion/disclosure panel reveals
|
|
@@ -416,6 +479,8 @@ export async function probeScreen(
|
|
|
416
479
|
title: accordionText.heading || `${trigger.label} (revealed)`,
|
|
417
480
|
content: accordionText.content,
|
|
418
481
|
fields: [],
|
|
482
|
+
stateId,
|
|
483
|
+
domSnapshot,
|
|
419
484
|
});
|
|
420
485
|
}
|
|
421
486
|
}
|
|
@@ -640,6 +705,7 @@ export async function probeAllScreens(
|
|
|
640
705
|
projectId: string,
|
|
641
706
|
discovered: Array<{ url: string; screenId?: string; elements?: any[] }>,
|
|
642
707
|
onProgress?: (pct: number, step: string) => Promise<void>,
|
|
708
|
+
jobId?: string,
|
|
643
709
|
): Promise<void> {
|
|
644
710
|
const probeable = discovered.filter((d) => d.screenId && d.url && !d.url.includes('#'));
|
|
645
711
|
if (probeable.length === 0) return;
|
|
@@ -677,6 +743,49 @@ export async function probeAllScreens(
|
|
|
677
743
|
await AppBrain.saveKnowledgeChunk(tenantId, projectId, 'probe', screen.screenId!, probeMarkdown, `Probe — ${screen.url}`).catch(console.warn);
|
|
678
744
|
}
|
|
679
745
|
|
|
746
|
+
// Save Virtual Screens — synthetic URL: parent_url + #probe:<stateId>
|
|
747
|
+
const virtualScreensToSave: Array<{ stateId: string; triggerAction: string; overlayType: string }> = [];
|
|
748
|
+
for (const m of result.modals) {
|
|
749
|
+
if (m.stateId) {
|
|
750
|
+
virtualScreensToSave.push({
|
|
751
|
+
stateId: m.stateId,
|
|
752
|
+
triggerAction: `click ${m.triggerElement}`,
|
|
753
|
+
overlayType: 'modal',
|
|
754
|
+
});
|
|
755
|
+
// Also save as a first-class Screen so it appears in the screen library
|
|
756
|
+
// and participates in test case generation. Use a synthetic URL.
|
|
757
|
+
const modalUrl = `${screen.url}#probe:${m.stateId}`;
|
|
758
|
+
const modalTitle = m.title || `${m.triggerElement} Modal`;
|
|
759
|
+
AppBrain.saveScreen(tenantId, projectId, modalTitle, modalUrl, jobId, 'INTERACTIVE_PROBE')
|
|
760
|
+
.then((modalScreen: any) => {
|
|
761
|
+
// Link modal back to parent via DiscoveredLink
|
|
762
|
+
if (modalScreen?.id && screen.screenId) {
|
|
763
|
+
AppBrain.saveDiscoveredLinks(tenantId, projectId, screen.screenId!, [{
|
|
764
|
+
href: modalUrl,
|
|
765
|
+
text: m.triggerElement,
|
|
766
|
+
linkType: 'BUTTON_LINK',
|
|
767
|
+
source: 'INTERACTIVE',
|
|
768
|
+
toScreenId: modalScreen.id,
|
|
769
|
+
jobId,
|
|
770
|
+
}]).catch(() => {});
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
.catch(() => {});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
for (const c of result.conditionals) {
|
|
777
|
+
if ((c as any).stateId) {
|
|
778
|
+
virtualScreensToSave.push({
|
|
779
|
+
stateId: (c as any).stateId,
|
|
780
|
+
triggerAction: `change ${c.triggerElement} to ${c.triggerValue}`,
|
|
781
|
+
overlayType: 'panel',
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (virtualScreensToSave.length > 0) {
|
|
786
|
+
await (AppBrain as any).saveVirtualScreens(tenantId, projectId, screen.screenId!, virtualScreensToSave).catch(console.warn);
|
|
787
|
+
}
|
|
788
|
+
|
|
680
789
|
if (result.validationErrors.length > 0) {
|
|
681
790
|
console.log(`[probe] ${screen.url}: ${result.validationErrors.length} validation errors, ${result.modals.length} modals`);
|
|
682
791
|
}
|