@detiq/crawler 1.1.15 → 1.1.16

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.15",
3
+ "version": "1.1.16",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/crawler.ts CHANGED
@@ -1288,6 +1288,17 @@ export async function crawlProject(opts: {
1288
1288
  logger.info({ url: finalUrl }, '[crawler] pre-auth page has 0 elements — deferring to authenticated BFS');
1289
1289
  continue;
1290
1290
  }
1291
+ // Detect auth walls that have a tiny number of elements (login button, back link, spinner).
1292
+ // These pages redirect or gate on auth — main BFS must visit them with a live session.
1293
+ // Label prefix or exact match — "Back to Learn" matches "back to", "Sign In" matches "sign in"
1294
+ const AUTH_WALL_LABEL_RE = /^(sign.?in|log.?in|login|platform access|back\s+to|checking\s+auth|get started|create account|register|continue with|join now)/i;
1295
+ // Also treat pages with only `a`-role elements (no inputs/buttons) as likely auth walls
1296
+ const allAnchorOnly = preAuthElements.every(el => el.role === 'a');
1297
+ const looksLikeAuthWall = preAuthElements.length <= 3 && (allAnchorOnly || preAuthElements.every(el => AUTH_WALL_LABEL_RE.test(el.label)));
1298
+ if (looksLikeAuthWall) {
1299
+ logger.info({ url: finalUrl, elements: preAuthElements.length, labels: preAuthElements.map(e => e.label) }, '[crawler] pre-auth auth wall — deferring to authenticated BFS');
1300
+ continue;
1301
+ }
1291
1302
  const pathParts = finalPath.split('/').filter(Boolean);
1292
1303
  const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
1293
1304
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
@@ -2026,7 +2037,16 @@ export async function crawlProject(opts: {
2026
2037
  const s = window.getComputedStyle(el);
2027
2038
  return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
2028
2039
  }
2029
- document.querySelectorAll('nav a, [role="navigation"] a, header a, footer nav a, [aria-label*="navigation" i] a').forEach((a: any) => {
2040
+ // Wide nav selector: covers semantic nav, ARIA, header links, sidebar (aside/[role=complementary]),
2041
+ // and common class-based sidebars used by admin/dashboard pages.
2042
+ const NAV_SEL = [
2043
+ 'nav a', '[role="navigation"] a', 'header a', 'footer nav a',
2044
+ '[aria-label*="navigation" i] a', '[aria-label*="sidebar" i] a',
2045
+ 'aside a', '[role="complementary"] a',
2046
+ '[class*="sidebar" i] a', '[class*="side-nav" i] a', '[class*="sidenav" i] a',
2047
+ '[class*="nav-menu" i] a', '[class*="navmenu" i] a', '[data-nav] a',
2048
+ ].join(', ');
2049
+ document.querySelectorAll(NAV_SEL).forEach((a: any) => {
2030
2050
  if (!isV(a)) return;
2031
2051
  const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
2032
2052
  const href = a.href ?? '';
@@ -2036,8 +2056,10 @@ export async function crawlProject(opts: {
2036
2056
  while (cur) {
2037
2057
  const t = cur.tagName?.toLowerCase();
2038
2058
  const r = cur.getAttribute?.('role')?.toLowerCase();
2059
+ const cl = (cur.className ?? '').toLowerCase();
2039
2060
  if (t === 'header' || r === 'banner') { landmark = 'Header'; break; }
2040
2061
  if (t === 'footer' || r === 'contentinfo') { landmark = 'Footer'; break; }
2062
+ if (t === 'aside' || r === 'complementary' || cl.includes('sidebar') || cl.includes('sidenav') || cl.includes('side-nav')) { landmark = 'Sidebar'; break; }
2041
2063
  if (t === 'nav' || r === 'navigation') { landmark = cur.getAttribute('aria-label')?.trim() || 'Site Navigation'; break; }
2042
2064
  cur = cur.parentElement;
2043
2065
  }
@@ -2053,6 +2075,42 @@ export async function crawlProject(opts: {
2053
2075
  const key = `footer|${label.toLowerCase()}|${href}`;
2054
2076
  if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'footer-link', notes: JSON.stringify({ href: href.slice(0, 150) }) }); fc++; }
2055
2077
  });
2078
+ // Grandparent-grouping fallback: detects sidebar navigation where each link is
2079
+ // individually wrapped (e.g. <div class="flex"><a>link</a></div> × many siblings).
2080
+ // Runs ALWAYS (not gated on 0 results) so header links don't block sidebar detection.
2081
+ // Deduped against hrefs already captured by the semantic pass above.
2082
+ {
2083
+ const navHrefsSeen = new Set<string>();
2084
+ for (const it of items) {
2085
+ try { const h = JSON.parse(it.notes ?? '{}').href; if (h) navHrefsSeen.add(h); } catch { /* skip */ }
2086
+ }
2087
+ const gpGroups = new Map<Element, Array<{label: string; href: string}>>();
2088
+ document.querySelectorAll('a[href]').forEach((a: any) => {
2089
+ if (!isV(a)) return;
2090
+ const href = a.href ?? '';
2091
+ if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsSeen.has(href)) return;
2092
+ const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
2093
+ if (!label || label.length > 40) return;
2094
+ const parent = a.parentElement;
2095
+ const gp = parent?.parentElement;
2096
+ if (!parent || !gp) return;
2097
+ // Only match when parent wraps exactly 1 anchor — the sidebar <div><a> pattern
2098
+ if (parent.querySelectorAll('a').length !== 1) return;
2099
+ const gpTag = gp.tagName.toLowerCase();
2100
+ if (['body', 'html', 'form'].includes(gpTag)) return;
2101
+ const arr = gpGroups.get(gp) ?? [];
2102
+ arr.push({ label, href });
2103
+ gpGroups.set(gp, arr);
2104
+ });
2105
+ for (const [, links] of gpGroups.entries()) {
2106
+ if (links.length >= 4) {
2107
+ for (const { label, href } of links) {
2108
+ const k = `cluster|${label.toLowerCase()}|${href}`;
2109
+ if (!seen.has(k)) { seen.add(k); items.push({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark: 'Sidebar' }) }); }
2110
+ }
2111
+ }
2112
+ }
2113
+ }
2056
2114
  return items;
2057
2115
  }).catch(() => [] as any[]);
2058
2116
 
@@ -2807,7 +2865,7 @@ export async function crawlProject(opts: {
2807
2865
  }
2808
2866
 
2809
2867
  // Append significant query params
2810
- const SIG_PARAMS = ['product_cat', 'category', 'cat', 'type', 'tag', 'brand', 'filter', 'tab', 'section', 'view'];
2868
+ const SIG_PARAMS = ['product_cat', 'category', 'cat', 'type', 'tag', 'brand', 'filter', 'tab', 'section', 'view', 'play', 'mode', 'id', 'page'];
2811
2869
  const paramSuffix = SIG_PARAMS
2812
2870
  .map((p) => { const v = u.searchParams.get(p); return v ? ` · ${v.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}` : ''; })
2813
2871
  .find(Boolean) ?? '';
@@ -2830,11 +2888,12 @@ export async function crawlProject(opts: {
2830
2888
  const candidate = parts[0].trim();
2831
2889
  if (candidate.length > 2 && !/^\d[\d\s]*$/.test(candidate)) return candidate;
2832
2890
  }
2833
- // Single-segment title — only use if multi-word and not brand-name-like
2891
+ // Single-segment title — only use if multi-word, not brand-name-like, and not the generic SPA app title
2834
2892
  const candidate = parts[0].trim();
2835
2893
  const isBrand = candidate.split(/\s+/).length === 1 &&
2836
2894
  (candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
2837
- if (!isBrand && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
2895
+ const isSiteGenericTitle = homeFingerprintTitle !== '' && candidate === homeFingerprintTitle;
2896
+ if (!isBrand && !isSiteGenericTitle && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
2838
2897
  return candidate;
2839
2898
  }
2840
2899
  }
@@ -3365,10 +3424,38 @@ export async function crawlProject(opts: {
3365
3424
  try {
3366
3425
  const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, 'DESKTOP', gitInfo.branch);
3367
3426
  if (baseline && baseline.storagePath !== shotPath) {
3427
+ // Resolve baseline file path — local copy may have been rotated; fall back to cloud download
3428
+ let baselinePath: string = baseline.storagePath;
3429
+ let tempBaselinePath: string | null = null;
3430
+ const localExists = await fs.access(baseline.storagePath).then(() => true, () => false);
3431
+ if (!localExists) {
3432
+ if (baseline.cloudUrl) {
3433
+ try {
3434
+ const resp = await fetch(baseline.cloudUrl);
3435
+ if (resp.ok) {
3436
+ const buf = Buffer.from(await resp.arrayBuffer());
3437
+ tempBaselinePath = `${shotPath}.baseline-tmp.png`;
3438
+ await fs.writeFile(tempBaselinePath, buf);
3439
+ baselinePath = tempBaselinePath;
3440
+ logger.info({ screenId: screen.id }, '[crawler] VisualDiff baseline downloaded from cloud storage');
3441
+ } else {
3442
+ logger.warn({ screenId: screen.id, status: resp.status }, '[crawler] VisualDiff skipped — baseline cloud fetch returned non-200');
3443
+ return;
3444
+ }
3445
+ } catch (dlErr) {
3446
+ logger.warn({ screenId: screen.id, err: String(dlErr) }, '[crawler] VisualDiff skipped — baseline cloud download failed');
3447
+ return;
3448
+ }
3449
+ } else {
3450
+ logger.warn({ screenId: screen.id }, '[crawler] VisualDiff skipped — baseline not on disk and no cloudUrl available');
3451
+ return;
3452
+ }
3453
+ }
3454
+ try {
3368
3455
  const { screenshotDiffPercent, gridLayoutDiff } = await import('./phash.js');
3369
3456
  const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
3370
- screenshotDiffPercent(baseline.storagePath, shotPath, baseline.ignoredRegions),
3371
- gridLayoutDiff(baseline.storagePath, shotPath, baseline.ignoredRegions),
3457
+ screenshotDiffPercent(baselinePath, shotPath, baseline.ignoredRegions),
3458
+ gridLayoutDiff(baselinePath, shotPath, baseline.ignoredRegions),
3372
3459
  AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, 'DESKTOP'),
3373
3460
  ]);
3374
3461
  let verdict = pHashVerdict(pHashDistance, changeLevel, [
@@ -3389,10 +3476,10 @@ export async function crawlProject(opts: {
3389
3476
  const [baselineBuffer, newBuffer] = regions.length > 0
3390
3477
  ? await (async () => {
3391
3478
  const { applyMask } = await import('./phash.js');
3392
- return Promise.all([applyMask(baseline.storagePath, regions), applyMask(shotPath, regions)]);
3479
+ return Promise.all([applyMask(baselinePath, regions), applyMask(shotPath, regions)]);
3393
3480
  })()
3394
3481
  : await Promise.all([
3395
- fs.readFile(baseline.storagePath),
3482
+ fs.readFile(baselinePath),
3396
3483
  fs.readFile(shotPath),
3397
3484
  ]);
3398
3485
  const aiResult = await aiVisualCompare({
@@ -3435,7 +3522,7 @@ export async function crawlProject(opts: {
3435
3522
  // R5: Generate and upload pixelmatch diff image (changed pixels in red)
3436
3523
  try {
3437
3524
  const { generatePixelmatchDiff } = await import('./phash.js');
3438
- const diffBuffer = await generatePixelmatchDiff(baseline.storagePath, shotPath, baseline.ignoredRegions ?? []);
3525
+ const diffBuffer = await generatePixelmatchDiff(baselinePath, shotPath, baseline.ignoredRegions ?? []);
3439
3526
  const diffTmpPath = `${shotPath}.diff.png`;
3440
3527
  await fs.writeFile(diffTmpPath, diffBuffer);
3441
3528
  const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, 'visual-diffs', `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
@@ -3459,14 +3546,12 @@ export async function crawlProject(opts: {
3459
3546
  aiSummary,
3460
3547
  });
3461
3548
  }
3549
+ } finally {
3550
+ if (tempBaselinePath) fs.unlink(tempBaselinePath).catch(() => {});
3551
+ }
3462
3552
  }
3463
3553
  } catch (err: any) {
3464
- const missing = String(err?.message ?? err).includes('Input file is missing');
3465
- if (missing) {
3466
- logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, '[crawler] VisualDiff skipped — baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.');
3467
- } else {
3468
- console.warn('[crawler] VisualDiff pipeline failed:', err);
3469
- }
3554
+ console.warn('[crawler] VisualDiff pipeline failed:', err);
3470
3555
  }
3471
3556
  })();
3472
3557
  }
@@ -5094,19 +5179,28 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
5094
5179
  const s = window.getComputedStyle(el);
5095
5180
  return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
5096
5181
  };
5097
- document.querySelectorAll('nav a, [role="navigation"] a, header a, header nav a, footer nav a, [aria-label*="navigation" i] a, [aria-label*="menu" i] a').forEach((a: any) => {
5182
+ const navSel = [
5183
+ 'nav a', '[role="navigation"] a', 'header a', 'header nav a', 'footer nav a',
5184
+ '[aria-label*="navigation" i] a', '[aria-label*="menu" i] a', '[aria-label*="sidebar" i] a',
5185
+ 'aside a', '[role="complementary"] a',
5186
+ '[class*="sidebar" i] a', '[class*="side-nav" i] a', '[class*="sidenav" i] a',
5187
+ '[class*="nav-menu" i] a', '[class*="navmenu" i] a', '[data-nav] a',
5188
+ ].join(', ');
5189
+ const navLinksFoundCount = { n: 0 };
5190
+ document.querySelectorAll(navSel).forEach((a: any) => {
5098
5191
  if (!isNavVisible(a)) return;
5099
5192
  const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
5100
5193
  const href = a.href;
5101
5194
  if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
5102
- // Walk up to find nearest nav/header/footer ancestor to get landmark label
5103
5195
  let landmark = 'Site Navigation';
5104
5196
  let el: any = a.parentElement;
5105
5197
  while (el) {
5106
5198
  const tag = el.tagName?.toLowerCase();
5107
5199
  const role = el.getAttribute?.('role')?.toLowerCase();
5200
+ const cl = (el.className ?? '').toLowerCase();
5108
5201
  if (tag === 'header' || role === 'banner') { landmark = 'Header'; break; }
5109
5202
  if (tag === 'footer' || role === 'contentinfo') { landmark = 'Footer'; break; }
5203
+ if (tag === 'aside' || role === 'complementary' || cl.includes('sidebar') || cl.includes('sidenav') || cl.includes('side-nav')) { landmark = 'Sidebar'; break; }
5110
5204
  if (tag === 'nav' || role === 'navigation') {
5111
5205
  const directLabel = el.getAttribute('aria-label')?.trim() ?? '';
5112
5206
  const labelId = el.getAttribute('aria-labelledby');
@@ -5117,7 +5211,43 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
5117
5211
  el = el.parentElement;
5118
5212
  }
5119
5213
  addUniq({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
5214
+ navLinksFoundCount.n++;
5120
5215
  });
5216
+ // Grandparent-grouping fallback: detects sidebar navigation where each link is individually
5217
+ // wrapped (e.g. <div class="flex"><a>link</a></div> × many siblings). Runs ALWAYS so header
5218
+ // links don't block sidebar detection. Deduped against hrefs already in the semantic pass.
5219
+ {
5220
+ const navHrefsAlready = new Set<string>();
5221
+ for (const item of out) {
5222
+ if (item.role === 'nav-link') {
5223
+ try { const h = JSON.parse(item.notes ?? '{}').href; if (h) navHrefsAlready.add(h); } catch { /* skip */ }
5224
+ }
5225
+ }
5226
+ const gpGroups2 = new Map<Element, Array<{label: string; href: string}>>();
5227
+ document.querySelectorAll('a[href]').forEach((a: any) => {
5228
+ if (!isNavVisible(a)) return;
5229
+ const href = a.href ?? '';
5230
+ if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsAlready.has(href)) return;
5231
+ const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
5232
+ if (!label || label.length > 40) return;
5233
+ const parent = a.parentElement;
5234
+ const gp = parent?.parentElement;
5235
+ if (!parent || !gp) return;
5236
+ if (parent.querySelectorAll('a').length !== 1) return;
5237
+ const gpTag = gp.tagName.toLowerCase();
5238
+ if (['body', 'html', 'form'].includes(gpTag)) return;
5239
+ const arr = gpGroups2.get(gp) ?? [];
5240
+ arr.push({ label, href });
5241
+ gpGroups2.set(gp, arr);
5242
+ });
5243
+ for (const [, links] of gpGroups2.entries()) {
5244
+ if (links.length >= 4) {
5245
+ for (const { label, href } of links) {
5246
+ addUniq({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark: 'Sidebar' }) });
5247
+ }
5248
+ }
5249
+ }
5250
+ }
5121
5251
 
5122
5252
  // 9. Breadcrumb items
5123
5253
  document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a: any) => {
package/src/job-queue.ts CHANGED
@@ -2502,6 +2502,15 @@ jobDispatchTable = {
2502
2502
  BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
2503
2503
  CREATE_PR: processCreatePRJob,
2504
2504
  NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
2505
+ // GENERATE_TESTS is processed in-process by the API (never QUEUED).
2506
+ // If the worker claims one, the API crashed after creating it — mark failed so it's visible.
2507
+ GENERATE_TESTS: async (jobId: string, _config: any) => {
2508
+ logger.error({ jobId }, '[job-worker] GENERATE_TESTS claimed by worker — API did not process it in-process');
2509
+ await prismaAdmin.platformJob.update({
2510
+ where: { id: jobId },
2511
+ data: { status: 'FAILED', error: 'Test generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2512
+ }).catch(() => {});
2513
+ },
2505
2514
  };
2506
2515
 
2507
2516
  async function processRepoScanJob(jobId: string, config: any) {