@detiq/crawler 1.1.5 → 1.1.6
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/package.json +1 -1
- package/src/crawler.ts +87 -10
- package/src/failure-analysis-agent.ts +3 -2
- package/src/gap-pass.ts +1 -0
- package/src/job-queue.ts +3 -2
- package/src/url-pattern-completer.ts +1 -0
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { z } from 'zod';
|
|
|
28
28
|
import fs from 'node:fs/promises';
|
|
29
29
|
import path from 'node:path';
|
|
30
30
|
import { AppBrain, recordLLMCall } from '@detiq/app-brain';
|
|
31
|
-
import { aiVisualCompare, askTracked, estimateCost, getTaskAgentLLMConfig } from '@detiq/agents';
|
|
31
|
+
import { aiVisualCompare, askTracked, deterministicSeed, estimateCost, getTaskAgentLLMConfig } from '@detiq/agents';
|
|
32
32
|
import { config } from '@detiq/core';
|
|
33
33
|
import { pHashVerdict, mergeAiVisualVerdict } from './visual-diff-verdict.js';
|
|
34
34
|
import type { Credentials } from './auth.js';
|
|
@@ -1125,6 +1125,14 @@ export async function crawlProject(opts: {
|
|
|
1125
1125
|
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => { });
|
|
1126
1126
|
await waitForStableDOM(page);
|
|
1127
1127
|
|
|
1128
|
+
// Capture homepage fingerprint for soft-404 detection. SPA catch-all routes return HTTP 200
|
|
1129
|
+
// but render identical homepage content for every unrecognised path. We fingerprint here
|
|
1130
|
+
// (pre-auth, stable DOM) and skip any BFS page that matches during the main crawl.
|
|
1131
|
+
const homeFingerprintTitle = await page.title().catch(() => '');
|
|
1132
|
+
const homeFingerprintElemCount = await page.evaluate(() =>
|
|
1133
|
+
document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
1134
|
+
).catch(() => -1);
|
|
1135
|
+
|
|
1128
1136
|
// Dedicated logged-out/auth discovery phase. This runs before credentials or
|
|
1129
1137
|
// storageState are applied, so public pages and auth recovery flows remain
|
|
1130
1138
|
// visible even when the main crawl later becomes authenticated.
|
|
@@ -1669,6 +1677,25 @@ export async function crawlProject(opts: {
|
|
|
1669
1677
|
await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
|
|
1670
1678
|
}
|
|
1671
1679
|
|
|
1680
|
+
// Wait for SPA JS to render components before extracting elements or running soft-404 check.
|
|
1681
|
+
// domcontentloaded fires before JS executes — networkidle ensures React/Vue/Angular hydration.
|
|
1682
|
+
await workerPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
|
|
1683
|
+
|
|
1684
|
+
// Soft-404 detection for lite-worker: skip SPA catch-all URLs that mirror homepage
|
|
1685
|
+
if (homeFingerprintElemCount >= 0 && url !== startUrl) {
|
|
1686
|
+
const wTitle = await workerPage.title().catch(() => '');
|
|
1687
|
+
if (wTitle === homeFingerprintTitle && wTitle !== '') {
|
|
1688
|
+
const wElemCount = await workerPage.evaluate(() =>
|
|
1689
|
+
document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
1690
|
+
).catch(() => -1);
|
|
1691
|
+
if (wElemCount >= 0 && wElemCount === homeFingerprintElemCount) {
|
|
1692
|
+
console.log(`[lite-worker] skip ${url} — soft-404 (homepage fingerprint match)`);
|
|
1693
|
+
visited.set(canonUrl, '_soft404_');
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1672
1699
|
const pathParts = new URL(url).pathname.split('/').filter(Boolean);
|
|
1673
1700
|
const name = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
1674
1701
|
|
|
@@ -2157,6 +2184,18 @@ export async function crawlProject(opts: {
|
|
|
2157
2184
|
seenDomHashes.add(preDomHash); // register so future 404 pages also hit hash-dedup
|
|
2158
2185
|
continue;
|
|
2159
2186
|
}
|
|
2187
|
+
// Soft-404: SPA catch-all routes return 200 but render identical homepage content.
|
|
2188
|
+
// Skip any non-homepage URL whose title + interactive element count matches the homepage.
|
|
2189
|
+
if (homeFingerprintElemCount >= 0 && url !== startUrl && pageTitle === homeFingerprintTitle && pageTitle !== '') {
|
|
2190
|
+
const pElemCount = await page.evaluate(() =>
|
|
2191
|
+
document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
2192
|
+
).catch(() => -1);
|
|
2193
|
+
if (pElemCount >= 0 && pElemCount === homeFingerprintElemCount) {
|
|
2194
|
+
console.log(`[crawler] skip ${url} — soft-404 (homepage fingerprint match, title="${pageTitle}" elems=${pElemCount})`);
|
|
2195
|
+
seenDomHashes.add(preDomHash);
|
|
2196
|
+
continue;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2160
2199
|
// Do NOT add real pages to seenDomHashes — server-rendered sites (WooCommerce, WordPress)
|
|
2161
2200
|
// use the same template for many legitimate pages; registering the hash would skip them all.
|
|
2162
2201
|
|
|
@@ -2537,11 +2576,14 @@ export async function crawlProject(opts: {
|
|
|
2537
2576
|
const snippet = extracted.markdown.slice(0, 1200);
|
|
2538
2577
|
// Skip LLM when content is too thin — output would be useless
|
|
2539
2578
|
if (snippet.trim().length < 80) return undefined;
|
|
2579
|
+
const summarySeed = deterministicSeed(`${opts.tenantId}:${opts.projectId ?? ''}:page-summary:${url}`);
|
|
2540
2580
|
const { text, usage } = await askTracked(
|
|
2541
2581
|
'You are a QA analyst. Output ONLY a single complete sentence (no preamble, no quotes) describing what this screen does or shows. Be specific. Stop after the first sentence.',
|
|
2542
2582
|
`Screen: "${name}"\nURL: ${url}\n\nContent:\n${snippet}`,
|
|
2543
2583
|
llmCfg,
|
|
2544
2584
|
200,
|
|
2585
|
+
undefined,
|
|
2586
|
+
{ temperature: 0, seed: summarySeed },
|
|
2545
2587
|
);
|
|
2546
2588
|
if (usage) {
|
|
2547
2589
|
recordLLMCall({
|
|
@@ -4045,13 +4087,23 @@ async function domFallbackElements(page: any): Promise<Array<{ meaning: string;
|
|
|
4045
4087
|
* Headings, meta tags, cards, chips, FAQ items, nav/footer structure, section text.
|
|
4046
4088
|
* Previously fallback-only; now always runs alongside domFallbackElements so
|
|
4047
4089
|
* the test generator sees ALL page content, not just interactive widgets. */
|
|
4048
|
-
async function contentFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string }>> {
|
|
4090
|
+
async function contentFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number> }>> {
|
|
4049
4091
|
return page.evaluate(() => {
|
|
4050
|
-
const out: { meaning: string; role: string; notes?: string }[] = [];
|
|
4092
|
+
const out: { meaning: string; role: string; notes?: string; boundingRect?: Record<string, number> }[] = [];
|
|
4051
4093
|
const seen = new Set<string>();
|
|
4052
|
-
const
|
|
4094
|
+
const captureRect = (el: Element): Record<string, number> | undefined => {
|
|
4095
|
+
try {
|
|
4096
|
+
const r = el.getBoundingClientRect();
|
|
4097
|
+
if (r.width === 0 && r.height === 0) return undefined;
|
|
4098
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, right: r.right, bottom: r.bottom, left: r.left };
|
|
4099
|
+
} catch { return undefined; }
|
|
4100
|
+
};
|
|
4101
|
+
const addUniq = (item: { meaning: string; role: string; notes?: string }, el?: Element) => {
|
|
4053
4102
|
const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
|
|
4054
|
-
if (!seen.has(key) && item.meaning.trim().length > 1) {
|
|
4103
|
+
if (!seen.has(key) && item.meaning.trim().length > 1) {
|
|
4104
|
+
seen.add(key);
|
|
4105
|
+
out.push(el ? { ...item, boundingRect: captureRect(el) } : item);
|
|
4106
|
+
}
|
|
4055
4107
|
};
|
|
4056
4108
|
const isVisible = (el: Element) => {
|
|
4057
4109
|
const s = window.getComputedStyle(el);
|
|
@@ -4072,7 +4124,7 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4072
4124
|
document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((h: any) => {
|
|
4073
4125
|
if (!isVisible(h)) return;
|
|
4074
4126
|
const text = h.textContent?.replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
4075
|
-
if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) });
|
|
4127
|
+
if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) }, h);
|
|
4076
4128
|
});
|
|
4077
4129
|
|
|
4078
4130
|
// 3. FAQ / accordion — <details>/<summary>, ARIA pattern, and class-name scan
|
|
@@ -4113,7 +4165,7 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4113
4165
|
document.querySelectorAll('[class*="card" i]:not(nav):not(header):not(footer), [class*="chip" i], [class*="badge" i], [class*="tag" i], [class*="pill" i]').forEach((el: any) => {
|
|
4114
4166
|
if (!isVisible(el)) return;
|
|
4115
4167
|
const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
|
|
4116
|
-
if (text && text.length > 2 && text.length < 200) addUniq({ meaning: text, role: 'content-card' });
|
|
4168
|
+
if (text && text.length > 2 && text.length < 200) addUniq({ meaning: text, role: 'content-card' }, el);
|
|
4117
4169
|
});
|
|
4118
4170
|
|
|
4119
4171
|
// 5. Feature / service list items inside main content (capped per category)
|
|
@@ -4123,14 +4175,14 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4123
4175
|
if (listItemCount >= 30) return;
|
|
4124
4176
|
if (!isVisible(li)) return;
|
|
4125
4177
|
const text = li.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
|
|
4126
|
-
if (text && text.length > 3 && text.length < 200) { addUniq({ meaning: text, role: 'list-item' }); listItemCount++; }
|
|
4178
|
+
if (text && text.length > 3 && text.length < 200) { addUniq({ meaning: text, role: 'list-item' }, li); listItemCount++; }
|
|
4127
4179
|
});
|
|
4128
4180
|
|
|
4129
4181
|
// 6. First paragraph in each section (key descriptive copy)
|
|
4130
4182
|
mainContent.querySelectorAll('section > p:first-of-type, article > p:first-of-type, [class*="section" i] > p:first-of-type').forEach((p: any) => {
|
|
4131
4183
|
if (!isVisible(p)) return;
|
|
4132
4184
|
const text = p.textContent?.replace(/\s+/g, ' ').trim().slice(0, 250);
|
|
4133
|
-
if (text && text.length > 10) addUniq({ meaning: text, role: 'section-paragraph' });
|
|
4185
|
+
if (text && text.length > 10) addUniq({ meaning: text, role: 'section-paragraph' }, p);
|
|
4134
4186
|
});
|
|
4135
4187
|
|
|
4136
4188
|
// 7. CTA section text (standalone paragraphs near buttons that aren't form fields)
|
|
@@ -4562,7 +4614,7 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4562
4614
|
document.querySelectorAll('[role="alert"], [role="status"], [role="banner"]:not(header), .alert, .notice, .notification, .warning-banner, .error-banner, .success-banner, [class*="alert-" i]:not(button), [class*="toast" i]:not(button), [class*="snackbar" i]').forEach((el: any) => {
|
|
4563
4615
|
if (!isVisible(el)) return;
|
|
4564
4616
|
const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
|
|
4565
|
-
if (text && text.length > 5) addUniq({ meaning: text, role: 'alert-banner', notes: JSON.stringify({ ariaRole: el.getAttribute('role') }) });
|
|
4617
|
+
if (text && text.length > 5) addUniq({ meaning: text, role: 'alert-banner', notes: JSON.stringify({ ariaRole: el.getAttribute('role') }) }, el);
|
|
4566
4618
|
});
|
|
4567
4619
|
|
|
4568
4620
|
// 27. Pricing / plan cards — structured pricing content
|
|
@@ -5339,6 +5391,21 @@ async function runAuthDiscoveryPhase(
|
|
|
5339
5391
|
try { enqueue(new URL(p, origin).toString(), 'seed_path'); } catch { /* ignore bad seed */ }
|
|
5340
5392
|
}
|
|
5341
5393
|
|
|
5394
|
+
// Capture homepage DOM fingerprint for soft-404 detection.
|
|
5395
|
+
// SPA catch-all sites return 200 for every path but render identical content.
|
|
5396
|
+
// If a seeded path produces the same element count + title as the homepage, it's
|
|
5397
|
+
// a phantom route — skip it so auth paths don't pollute the discovered URL list.
|
|
5398
|
+
let homeTitle = '';
|
|
5399
|
+
let homeElemCount = -1;
|
|
5400
|
+
try {
|
|
5401
|
+
await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => null);
|
|
5402
|
+
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
|
|
5403
|
+
homeTitle = await page.title().catch(() => '');
|
|
5404
|
+
homeElemCount = await page.evaluate(() =>
|
|
5405
|
+
document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
5406
|
+
).catch(() => -1);
|
|
5407
|
+
} catch { /* best-effort */ }
|
|
5408
|
+
|
|
5342
5409
|
let captured = 0;
|
|
5343
5410
|
let ordinal = 0;
|
|
5344
5411
|
await onProgress?.(10, 'Discovering public and auth flows');
|
|
@@ -5352,6 +5419,16 @@ async function runAuthDiscoveryPhase(
|
|
|
5352
5419
|
await waitForStableDOM(page);
|
|
5353
5420
|
await acceptCookieConsent(page, { waitMs: 400 }).catch(() => { });
|
|
5354
5421
|
|
|
5422
|
+
// Soft-404 detection: SPA returns 200 for unknown paths but renders homepage.
|
|
5423
|
+
// Skip if title + element count match the homepage fingerprint exactly.
|
|
5424
|
+
if (current.source === 'seed_path' && homeElemCount >= 0) {
|
|
5425
|
+
const pageTitle = await page.title().catch(() => '');
|
|
5426
|
+
const elemCount = await page.evaluate(() =>
|
|
5427
|
+
document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
|
|
5428
|
+
).catch(() => -1);
|
|
5429
|
+
if (pageTitle === homeTitle && elemCount === homeElemCount) continue;
|
|
5430
|
+
}
|
|
5431
|
+
|
|
5355
5432
|
const finalUrl = page.url?.() ?? current.url;
|
|
5356
5433
|
const captureKey = normalizeAuthUrl(finalUrl);
|
|
5357
5434
|
if (!capturedKeys.has(captureKey)) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { askTrackedWithImages, askTracked } from '@detiq/agents';
|
|
2
|
+
import { askTrackedWithImages, askTracked, deterministicSeed } from '@detiq/agents';
|
|
3
3
|
import type { LLMConfig } from '@detiq/agents';
|
|
4
4
|
import { AppBrain } from '@detiq/app-brain';
|
|
5
5
|
|
|
@@ -41,7 +41,8 @@ Error: ${ctx.error}${consoleSection}${networkSection}`;
|
|
|
41
41
|
} catch { /* fall through to text-only */ }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const
|
|
44
|
+
const failureSeed = deterministicSeed(`${ctx.tenantId ?? ''}:failure:${(ctx.stepDescription ?? '').slice(0, 100)}:${ctx.error.slice(0, 200)}`);
|
|
45
|
+
const result = await askTracked('', userText, llmCfg, 256, undefined, { temperature: 0, seed: failureSeed });
|
|
45
46
|
return result.text || null;
|
|
46
47
|
} catch {
|
|
47
48
|
return null;
|
package/src/gap-pass.ts
CHANGED
|
@@ -75,6 +75,7 @@ Respond with ONLY valid JSON, no markdown:
|
|
|
75
75
|
const response = await client.messages.create({
|
|
76
76
|
model: llmCfg.model ?? 'claude-haiku-4-5-20251001',
|
|
77
77
|
max_tokens: 1024,
|
|
78
|
+
temperature: 0,
|
|
78
79
|
// @ts-ignore
|
|
79
80
|
system: [{ type: 'text', text: 'You are a web application coverage analyst. Respond with valid JSON only.', cache_control: { type: 'ephemeral' } }],
|
|
80
81
|
messages: [{ role: 'user', content: prompt }],
|
package/src/job-queue.ts
CHANGED
|
@@ -1273,7 +1273,7 @@ async function processGenerateTestsFromReqsJob(jobId: string, config: any) {
|
|
|
1273
1273
|
try {
|
|
1274
1274
|
await updateJobProgress(tenantId, jobId, 5, 'Loading requirements');
|
|
1275
1275
|
|
|
1276
|
-
const { askTracked, getTaskAgentLLMConfig } = await import('@detiq/agents');
|
|
1276
|
+
const { askTracked, deterministicSeed, getTaskAgentLLMConfig } = await import('@detiq/agents');
|
|
1277
1277
|
const llmCfg = await getTaskAgentLLMConfig(tenantId, 'testGeneration');
|
|
1278
1278
|
|
|
1279
1279
|
const all = await AppBrain.listRequirements(tenantId, projectId, {}) as any[];
|
|
@@ -1321,7 +1321,8 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1321
1321
|
|
|
1322
1322
|
let parsed: { cases: any[] } = { cases: [] };
|
|
1323
1323
|
try {
|
|
1324
|
-
const
|
|
1324
|
+
const reqSeed = deterministicSeed(`${tenantId}:${projectId}:req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
|
|
1325
|
+
const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
|
|
1325
1326
|
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => {});
|
|
1326
1327
|
const json = text.match(/\{[\s\S]*\}/)?.[0];
|
|
1327
1328
|
if (json) parsed = JSON.parse(json);
|
|
@@ -72,6 +72,7 @@ Respond with ONLY valid JSON, no explanation:
|
|
|
72
72
|
const response = await client.messages.create({
|
|
73
73
|
model: llmCfg.model ?? 'claude-haiku-4-5-20251001',
|
|
74
74
|
max_tokens: 1024,
|
|
75
|
+
temperature: 0,
|
|
75
76
|
// @ts-ignore: cache_control supported in beta
|
|
76
77
|
system: [{ type: 'text', text: 'You are a URL pattern analyst. Always respond with valid JSON only.', cache_control: { type: 'ephemeral' } }],
|
|
77
78
|
messages: [{ role: 'user', content: prompt }],
|