@detiq/crawler 1.1.2 → 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 +199 -17
- package/src/failure-analysis-agent.ts +3 -2
- package/src/gap-pass.ts +1 -0
- package/src/job-queue.ts +5 -4
- package/src/sitemap.ts +5 -3
- 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.
|
|
@@ -1638,6 +1646,111 @@ export async function crawlProject(opts: {
|
|
|
1638
1646
|
|
|
1639
1647
|
let pendingSessionRestore: (() => Promise<void>) | null = null;
|
|
1640
1648
|
|
|
1649
|
+
// F10: Parallel lite-worker browser tabs — pull from shared queue while main loop runs.
|
|
1650
|
+
// Each worker uses a fresh Playwright page from the same browser context so it shares
|
|
1651
|
+
// cookies/session with the main page. DOM-only extraction (no Stagehand AI).
|
|
1652
|
+
// Default 5 = 3-5× crawl throughput on a single browser context. Set CRAWL_PAGE_PARALLELISM=1 to disable.
|
|
1653
|
+
const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? '5', 10));
|
|
1654
|
+
|
|
1655
|
+
async function runLiteWorker(workerPage: any): Promise<void> {
|
|
1656
|
+
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
1657
|
+
if (opts.abortSignal?.aborted) break;
|
|
1658
|
+
if (++totalAttemptsThisSegment > maxAttempts) break;
|
|
1659
|
+
|
|
1660
|
+
const url = queue.shift();
|
|
1661
|
+
if (!url) break;
|
|
1662
|
+
|
|
1663
|
+
const canonUrl = canonicalize(url);
|
|
1664
|
+
if (visited.has(canonUrl)) continue;
|
|
1665
|
+
|
|
1666
|
+
try {
|
|
1667
|
+
const { pathname } = new URL(url);
|
|
1668
|
+
if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
|
|
1669
|
+
} catch { continue; }
|
|
1670
|
+
|
|
1671
|
+
// Claim URL before first await — prevents duplicate processing across parallel workers
|
|
1672
|
+
visited.set(canonUrl, '_pending_');
|
|
1673
|
+
|
|
1674
|
+
try {
|
|
1675
|
+
const navErr = await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null as null, (e: any) => e);
|
|
1676
|
+
if (navErr && /timeout/i.test(String((navErr as any)?.message ?? navErr))) {
|
|
1677
|
+
await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
|
|
1678
|
+
}
|
|
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
|
+
|
|
1699
|
+
const pathParts = new URL(url).pathname.split('/').filter(Boolean);
|
|
1700
|
+
const name = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
|
|
1701
|
+
|
|
1702
|
+
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
|
|
1703
|
+
visited.set(canonUrl, screen.id);
|
|
1704
|
+
|
|
1705
|
+
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
1706
|
+
|
|
1707
|
+
const [elements] = await Promise.all([
|
|
1708
|
+
domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>),
|
|
1709
|
+
workerPage.screenshot({ path: shotPath, fullPage: true })
|
|
1710
|
+
.then(async () => {
|
|
1711
|
+
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
|
|
1712
|
+
return (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl);
|
|
1713
|
+
})
|
|
1714
|
+
.catch(() => {}),
|
|
1715
|
+
]);
|
|
1716
|
+
|
|
1717
|
+
const nextLinks: string[] = await (workerPage.evaluate as any)((originStr: string) => {
|
|
1718
|
+
return Array.from(document.querySelectorAll('a[href]'))
|
|
1719
|
+
.map((a: any) => {
|
|
1720
|
+
try { return new URL(a.getAttribute('href')!, window.location.href).href; }
|
|
1721
|
+
catch { return ''; }
|
|
1722
|
+
})
|
|
1723
|
+
.filter((h: string) => h && new URL(h).origin === originStr && !h.includes('#'))
|
|
1724
|
+
.slice(0, 200);
|
|
1725
|
+
}, origin).catch(() => []);
|
|
1726
|
+
|
|
1727
|
+
for (const link of nextLinks) {
|
|
1728
|
+
const cLink = canonicalize(link);
|
|
1729
|
+
if (!visited.has(cLink)) {
|
|
1730
|
+
queue.push(link);
|
|
1731
|
+
edges.push({ fromUrl: url, toUrl: link });
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
|
|
1736
|
+
} catch (err: any) {
|
|
1737
|
+
logger.warn({ url, err: err?.message ?? String(err) }, '[lite-worker] URL processing failed');
|
|
1738
|
+
discovered.push({ url, error: err?.message ?? String(err) });
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const liteWorkerPromises: Promise<void>[] = [];
|
|
1744
|
+
if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
|
|
1745
|
+
const pwCtx = playwrightBrowser.contexts()[0];
|
|
1746
|
+
for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
|
|
1747
|
+
const wp = await pwCtx.newPage();
|
|
1748
|
+
await wp.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }).catch(() => {});
|
|
1749
|
+
wp.setDefaultTimeout(45_000);
|
|
1750
|
+
liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {})));
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1641
1754
|
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
1642
1755
|
if (pendingSessionRestore) {
|
|
1643
1756
|
await pendingSessionRestore();
|
|
@@ -1767,9 +1880,17 @@ export async function crawlProject(opts: {
|
|
|
1767
1880
|
}
|
|
1768
1881
|
}
|
|
1769
1882
|
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1883
|
+
// Try fast 15 s first; only escalate to 30 s on timeout so happy-path pages don't wait
|
|
1884
|
+
const navErr15 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null, (e: any) => e);
|
|
1885
|
+
if (navErr15) {
|
|
1886
|
+
if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
|
|
1887
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
|
|
1888
|
+
logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
|
|
1889
|
+
});
|
|
1890
|
+
} else {
|
|
1891
|
+
logger.warn({ url, err: navErr15?.message ?? String(navErr15) }, '[crawler] navigation failed — skipping');
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1773
1894
|
rawPage.off('response', onRedirectResponse);
|
|
1774
1895
|
if (redirectHops.length > 0) {
|
|
1775
1896
|
redirectRegistry.register({
|
|
@@ -2063,6 +2184,18 @@ export async function crawlProject(opts: {
|
|
|
2063
2184
|
seenDomHashes.add(preDomHash); // register so future 404 pages also hit hash-dedup
|
|
2064
2185
|
continue;
|
|
2065
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
|
+
}
|
|
2066
2199
|
// Do NOT add real pages to seenDomHashes — server-rendered sites (WooCommerce, WordPress)
|
|
2067
2200
|
// use the same template for many legitimate pages; registering the hash would skip them all.
|
|
2068
2201
|
|
|
@@ -2350,7 +2483,8 @@ export async function crawlProject(opts: {
|
|
|
2350
2483
|
}),
|
|
2351
2484
|
// Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
|
|
2352
2485
|
// Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
|
|
2353
|
-
|
|
2486
|
+
// Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
|
|
2487
|
+
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < 5)
|
|
2354
2488
|
.then((freshLinks) => {
|
|
2355
2489
|
if (aiSkipCount > 0) aiSkipCount--;
|
|
2356
2490
|
aiErrorStreak = 0;
|
|
@@ -2442,11 +2576,14 @@ export async function crawlProject(opts: {
|
|
|
2442
2576
|
const snippet = extracted.markdown.slice(0, 1200);
|
|
2443
2577
|
// Skip LLM when content is too thin — output would be useless
|
|
2444
2578
|
if (snippet.trim().length < 80) return undefined;
|
|
2579
|
+
const summarySeed = deterministicSeed(`${opts.tenantId}:${opts.projectId ?? ''}:page-summary:${url}`);
|
|
2445
2580
|
const { text, usage } = await askTracked(
|
|
2446
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.',
|
|
2447
2582
|
`Screen: "${name}"\nURL: ${url}\n\nContent:\n${snippet}`,
|
|
2448
2583
|
llmCfg,
|
|
2449
2584
|
200,
|
|
2585
|
+
undefined,
|
|
2586
|
+
{ temperature: 0, seed: summarySeed },
|
|
2450
2587
|
);
|
|
2451
2588
|
if (usage) {
|
|
2452
2589
|
recordLLMCall({
|
|
@@ -2909,6 +3046,9 @@ export async function crawlProject(opts: {
|
|
|
2909
3046
|
}
|
|
2910
3047
|
}
|
|
2911
3048
|
|
|
3049
|
+
// Wait for any lite workers still processing URLs from the shared queue
|
|
3050
|
+
if (liteWorkerPromises.length > 0) await Promise.all(liteWorkerPromises);
|
|
3051
|
+
|
|
2912
3052
|
// Discovery mode: return collected URLs and skip all post-crawl analysis phases.
|
|
2913
3053
|
// Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
|
|
2914
3054
|
if (opts.discoveryMode) {
|
|
@@ -3947,13 +4087,23 @@ async function domFallbackElements(page: any): Promise<Array<{ meaning: string;
|
|
|
3947
4087
|
* Headings, meta tags, cards, chips, FAQ items, nav/footer structure, section text.
|
|
3948
4088
|
* Previously fallback-only; now always runs alongside domFallbackElements so
|
|
3949
4089
|
* the test generator sees ALL page content, not just interactive widgets. */
|
|
3950
|
-
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> }>> {
|
|
3951
4091
|
return page.evaluate(() => {
|
|
3952
|
-
const out: { meaning: string; role: string; notes?: string }[] = [];
|
|
4092
|
+
const out: { meaning: string; role: string; notes?: string; boundingRect?: Record<string, number> }[] = [];
|
|
3953
4093
|
const seen = new Set<string>();
|
|
3954
|
-
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) => {
|
|
3955
4102
|
const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
|
|
3956
|
-
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
|
+
}
|
|
3957
4107
|
};
|
|
3958
4108
|
const isVisible = (el: Element) => {
|
|
3959
4109
|
const s = window.getComputedStyle(el);
|
|
@@ -3974,7 +4124,7 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
3974
4124
|
document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((h: any) => {
|
|
3975
4125
|
if (!isVisible(h)) return;
|
|
3976
4126
|
const text = h.textContent?.replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
3977
|
-
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);
|
|
3978
4128
|
});
|
|
3979
4129
|
|
|
3980
4130
|
// 3. FAQ / accordion — <details>/<summary>, ARIA pattern, and class-name scan
|
|
@@ -4015,7 +4165,7 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4015
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) => {
|
|
4016
4166
|
if (!isVisible(el)) return;
|
|
4017
4167
|
const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
|
|
4018
|
-
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);
|
|
4019
4169
|
});
|
|
4020
4170
|
|
|
4021
4171
|
// 5. Feature / service list items inside main content (capped per category)
|
|
@@ -4025,14 +4175,14 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4025
4175
|
if (listItemCount >= 30) return;
|
|
4026
4176
|
if (!isVisible(li)) return;
|
|
4027
4177
|
const text = li.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
|
|
4028
|
-
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++; }
|
|
4029
4179
|
});
|
|
4030
4180
|
|
|
4031
4181
|
// 6. First paragraph in each section (key descriptive copy)
|
|
4032
4182
|
mainContent.querySelectorAll('section > p:first-of-type, article > p:first-of-type, [class*="section" i] > p:first-of-type').forEach((p: any) => {
|
|
4033
4183
|
if (!isVisible(p)) return;
|
|
4034
4184
|
const text = p.textContent?.replace(/\s+/g, ' ').trim().slice(0, 250);
|
|
4035
|
-
if (text && text.length > 10) addUniq({ meaning: text, role: 'section-paragraph' });
|
|
4185
|
+
if (text && text.length > 10) addUniq({ meaning: text, role: 'section-paragraph' }, p);
|
|
4036
4186
|
});
|
|
4037
4187
|
|
|
4038
4188
|
// 7. CTA section text (standalone paragraphs near buttons that aren't form fields)
|
|
@@ -4464,7 +4614,7 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
4464
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) => {
|
|
4465
4615
|
if (!isVisible(el)) return;
|
|
4466
4616
|
const text = el.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
|
|
4467
|
-
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);
|
|
4468
4618
|
});
|
|
4469
4619
|
|
|
4470
4620
|
// 27. Pricing / plan cards — structured pricing content
|
|
@@ -5052,7 +5202,7 @@ async function hasMeaningfulAuthOrPublicState(page: any, requestedUrl: string):
|
|
|
5052
5202
|
}
|
|
5053
5203
|
})();
|
|
5054
5204
|
|
|
5055
|
-
const
|
|
5205
|
+
const domState = await page.evaluate(() => {
|
|
5056
5206
|
const text = (document.body?.innerText ?? '').toLowerCase();
|
|
5057
5207
|
const inputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[];
|
|
5058
5208
|
const hasPassword = inputs.some((i) => i.type === 'password');
|
|
@@ -5062,9 +5212,16 @@ async function hasMeaningfulAuthOrPublicState(page: any, requestedUrl: string):
|
|
|
5062
5212
|
});
|
|
5063
5213
|
const hasAuthCopy = /(sign in|log in|login|sign up|create account|register|forgot password|reset password|verify email|one-time|otp|continue with email)/i.test(text);
|
|
5064
5214
|
const hasPublicNav = /(pricing|contact|features|documentation|docs|about|privacy|terms)/i.test(text);
|
|
5065
|
-
|
|
5066
|
-
|
|
5215
|
+
// Soft-404: page says "not found" / "404" with no real auth form
|
|
5216
|
+
const isSoft404 = !hasPassword && !hasIdentityInput &&
|
|
5217
|
+
/(page not found|404|this page (could not|doesn'?t) (be found|exist)|we couldn'?t find|no longer exists|been moved|doesn'?t exist)/i.test(text);
|
|
5218
|
+
return { hasPassword, hasIdentityInput, hasAuthCopy, hasPublicNav, isSoft404 };
|
|
5219
|
+
}).catch(() => ({ hasPassword: false, hasIdentityInput: false, hasAuthCopy: false, hasPublicNav: false, isSoft404: false }));
|
|
5220
|
+
|
|
5221
|
+
// Soft-404 with no auth form — path match alone is not enough
|
|
5222
|
+
if (domState.isSoft404) return false;
|
|
5067
5223
|
|
|
5224
|
+
const domLooksRelevant = domState.hasPassword || domState.hasIdentityInput || domState.hasAuthCopy || domState.hasPublicNav;
|
|
5068
5225
|
return pathLooksRelevant || domLooksRelevant;
|
|
5069
5226
|
}
|
|
5070
5227
|
|
|
@@ -5234,6 +5391,21 @@ async function runAuthDiscoveryPhase(
|
|
|
5234
5391
|
try { enqueue(new URL(p, origin).toString(), 'seed_path'); } catch { /* ignore bad seed */ }
|
|
5235
5392
|
}
|
|
5236
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
|
+
|
|
5237
5409
|
let captured = 0;
|
|
5238
5410
|
let ordinal = 0;
|
|
5239
5411
|
await onProgress?.(10, 'Discovering public and auth flows');
|
|
@@ -5247,6 +5419,16 @@ async function runAuthDiscoveryPhase(
|
|
|
5247
5419
|
await waitForStableDOM(page);
|
|
5248
5420
|
await acceptCookieConsent(page, { waitMs: 400 }).catch(() => { });
|
|
5249
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
|
+
|
|
5250
5432
|
const finalUrl = page.url?.() ?? current.url;
|
|
5251
5433
|
const captureKey = normalizeAuthUrl(finalUrl);
|
|
5252
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
|
@@ -191,7 +191,7 @@ const runningControllers = new Map<string, AbortController>();
|
|
|
191
191
|
const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
192
192
|
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
193
193
|
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
|
|
194
|
-
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) ||
|
|
194
|
+
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
|
|
195
195
|
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15_000;
|
|
196
196
|
// How many headless browsers a single RUN_TEST_SUITE job launches at once —
|
|
197
197
|
// distinct from WORKER_CONCURRENCY (how many *jobs* the poll loop claims at
|
|
@@ -199,7 +199,7 @@ const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS)
|
|
|
199
199
|
// primary scale-out path for running more tests at once is still horizontal
|
|
200
200
|
// (more worker containers / SQS), not raising this past what one container
|
|
201
201
|
// can safely hold — see startJobWorkerLoop's doc comment.
|
|
202
|
-
const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY) ||
|
|
202
|
+
const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY) || 5;
|
|
203
203
|
|
|
204
204
|
type ClaimedJob = { id: string; tenantId: string; projectId: string; type: string; config: any };
|
|
205
205
|
|
|
@@ -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);
|
package/src/sitemap.ts
CHANGED
|
@@ -111,9 +111,11 @@ export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth
|
|
|
111
111
|
const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin);
|
|
112
112
|
const collected = [...pageUrls];
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
if (subSitemapUrls.length > 0 && collected.length < MAX_URLS) {
|
|
115
|
+
const subResults = await Promise.all(
|
|
116
|
+
subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher))
|
|
117
|
+
);
|
|
118
|
+
for (const sub of subResults) collected.push(...sub);
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
return [...new Set(collected)].slice(0, MAX_URLS);
|
|
@@ -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 }],
|