@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.5",
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.
@@ -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 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) => {
4053
4102
  const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
4054
- 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
+ }
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 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
@@ -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);
@@ -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 }],