@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.2",
3
+ "version": "1.1.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
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
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
1771
- logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
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
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0)
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 addUniq = (item: { meaning: string; role: string; notes?: string }) => {
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) { seen.add(key); out.push(item); }
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 domLooksRelevant = await page.evaluate(() => {
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
- return hasPassword || hasIdentityInput || hasAuthCopy || hasPublicNav;
5066
- }).catch(() => false);
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 result = await askTracked('', userText, llmCfg, 256);
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) || 3;
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) || 3;
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 { text, usage } = await askTracked(system, user, llmCfg);
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
- for (const subUrl of subSitemapUrls) {
115
- if (collected.length >= MAX_URLS) break;
116
- collected.push(...(await fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher)));
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 }],