@detiq/crawler 1.1.14 → 1.1.15

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.
Files changed (109) hide show
  1. package/Dockerfile +1 -1
  2. package/dist/accessibility-tree-crawler.js +156 -0
  3. package/dist/accessibility-tree-crawler.js.map +7 -0
  4. package/dist/advanced-ui-probes.js +310 -0
  5. package/dist/advanced-ui-probes.js.map +7 -0
  6. package/dist/content-chunker.js +2 -2
  7. package/dist/content-chunker.js.map +2 -2
  8. package/dist/crawl-session-manager.js +145 -0
  9. package/dist/crawl-session-manager.js.map +7 -0
  10. package/dist/crawler.js +1142 -263
  11. package/dist/crawler.js.map +2 -2
  12. package/dist/crud-interaction.js +264 -0
  13. package/dist/crud-interaction.js.map +7 -0
  14. package/dist/dropdown-enumerator.js +230 -0
  15. package/dist/dropdown-enumerator.js.map +7 -0
  16. package/dist/filter-pagination-crawler.js +299 -0
  17. package/dist/filter-pagination-crawler.js.map +7 -0
  18. package/dist/frontier.js +5 -3
  19. package/dist/frontier.js.map +2 -2
  20. package/dist/gap-pass.js +47 -14
  21. package/dist/gap-pass.js.map +2 -2
  22. package/dist/http-engine.js +28 -15
  23. package/dist/http-engine.js.map +2 -2
  24. package/dist/index.js +15 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/job-queue.js +12 -5
  27. package/dist/job-queue.js.map +2 -2
  28. package/dist/locator-extractor.js +23 -0
  29. package/dist/locator-extractor.js.map +2 -2
  30. package/dist/multi-context-handler.js +182 -0
  31. package/dist/multi-context-handler.js.map +7 -0
  32. package/dist/nested-window-crawler.js +172 -0
  33. package/dist/nested-window-crawler.js.map +7 -0
  34. package/dist/network-action-correlator.js +209 -0
  35. package/dist/network-action-correlator.js.map +7 -0
  36. package/dist/performance-metrics-collector.js +155 -0
  37. package/dist/performance-metrics-collector.js.map +7 -0
  38. package/dist/performance-metrics.js.map +2 -2
  39. package/dist/policies/crawl-safety.js +5 -1
  40. package/dist/policies/crawl-safety.js.map +2 -2
  41. package/dist/profiles/domain-profile.js +21 -1
  42. package/dist/profiles/domain-profile.js.map +2 -2
  43. package/dist/rate-limiter.js +2 -2
  44. package/dist/rate-limiter.js.map +2 -2
  45. package/dist/screen-reader.js +1 -0
  46. package/dist/screen-reader.js.map +2 -2
  47. package/dist/service-worker-interceptor.js +136 -0
  48. package/dist/service-worker-interceptor.js.map +7 -0
  49. package/dist/session-capture.js +4 -2
  50. package/dist/session-capture.js.map +2 -2
  51. package/dist/shadow-dom-crawler.js +132 -0
  52. package/dist/shadow-dom-crawler.js.map +7 -0
  53. package/dist/sitemap.js +16 -10
  54. package/dist/sitemap.js.map +2 -2
  55. package/dist/state-graph.js +193 -0
  56. package/dist/state-graph.js.map +7 -0
  57. package/dist/stealth/human-behavior.js +36 -3
  58. package/dist/stealth/human-behavior.js.map +2 -2
  59. package/dist/url-pattern-completer.js +48 -14
  60. package/dist/url-pattern-completer.js.map +2 -2
  61. package/dist/visual-diff-verdict.js +3 -1
  62. package/dist/visual-diff-verdict.js.map +2 -2
  63. package/package.json +2 -1
  64. package/src/accessibility-tree-crawler.ts +246 -0
  65. package/src/advanced-ui-probes.ts +470 -0
  66. package/src/crawl-session-manager.ts +231 -0
  67. package/src/crawler.ts +1050 -134
  68. package/src/crud-interaction.ts +386 -0
  69. package/src/dropdown-enumerator.ts +312 -0
  70. package/src/filter-pagination-crawler.ts +500 -0
  71. package/src/gap-pass.ts +58 -16
  72. package/src/index.ts +14 -1
  73. package/src/locator-extractor.ts +45 -0
  74. package/src/multi-context-handler.ts +296 -0
  75. package/src/nested-window-crawler.ts +310 -0
  76. package/src/network-action-correlator.ts +320 -0
  77. package/src/performance-metrics-collector.ts +243 -0
  78. package/src/performance-metrics.ts +1 -1
  79. package/src/screen-reader.ts +4 -1
  80. package/src/service-worker-interceptor.ts +185 -0
  81. package/src/session-capture.ts +3 -0
  82. package/src/shadow-dom-crawler.ts +198 -0
  83. package/src/state-graph.ts +280 -0
  84. package/src/stealth/human-behavior.ts +38 -4
  85. package/src/url-pattern-completer.ts +61 -15
  86. package/tsconfig.json +1 -1
  87. package/types/accessibility-tree-crawler.d.ts +44 -0
  88. package/types/advanced-ui-probes.d.ts +47 -0
  89. package/types/crawl-session-manager.d.ts +44 -0
  90. package/types/crawler.d.ts +3 -1
  91. package/types/crud-interaction.d.ts +31 -0
  92. package/types/dropdown-enumerator.d.ts +20 -0
  93. package/types/filter-pagination-crawler.d.ts +52 -0
  94. package/types/gap-pass.d.ts +1 -0
  95. package/types/index.d.ts +14 -1
  96. package/types/job-queue.d.ts +18 -9
  97. package/types/locator-extractor.d.ts +7 -0
  98. package/types/multi-context-handler.d.ts +55 -0
  99. package/types/nested-window-crawler.d.ts +41 -0
  100. package/types/network-action-correlator.d.ts +47 -0
  101. package/types/performance-metrics-collector.d.ts +50 -0
  102. package/types/performance-metrics.d.ts +1 -2
  103. package/types/screen-reader.d.ts +1 -2
  104. package/types/service-worker-interceptor.d.ts +36 -0
  105. package/types/session-capture.d.ts +1 -1
  106. package/types/shadow-dom-crawler.d.ts +35 -0
  107. package/types/sitemap.d.ts +2 -2
  108. package/types/state-graph.d.ts +59 -0
  109. package/types/stealth/human-behavior.d.ts +1 -1
package/src/crawler.ts CHANGED
@@ -74,6 +74,17 @@ import { screensToSitemapUrls, writeSitemap } from './sitemap-writer.js';
74
74
  import { generateOpenApiSpec, specToYaml } from './openapi-generator.js';
75
75
  import { WebhookNotifier } from './webhook-notifier.js';
76
76
  import { generatePlaywrightTest } from './playwright-recorder.js';
77
+ import { enumerateDropdowns } from './dropdown-enumerator.js';
78
+ import { probeCrudFlows } from './crud-interaction.js';
79
+ import { crawlFiltersAndPagination } from './filter-pagination-crawler.js';
80
+ import { probeMultiContext } from './multi-context-handler.js';
81
+ import { probeAdvancedUI } from './advanced-ui-probes.js';
82
+ import { ScreenTransitionGraph } from './state-graph.js';
83
+ import { crawlNestedContexts } from './nested-window-crawler.js';
84
+ import { crawlShadowDom } from './shadow-dom-crawler.js';
85
+ import { crawlAccessibilityTree } from './accessibility-tree-crawler.js';
86
+ import { collectPerfMetrics } from './performance-metrics.js';
87
+ import { inspectServiceWorker } from './service-worker-interceptor.js';
77
88
 
78
89
  async function getCrawlTuning() {
79
90
  const { getSystemConfig } = await import('@detiq/core');
@@ -141,12 +152,27 @@ function isGeneratedDynamicPath(pathname: string): boolean {
141
152
  return normalized.split('/').filter(Boolean).some(isGeneratedPathSegment);
142
153
  }
143
154
 
155
+ // File extensions that trigger browser downloads rather than page renders.
156
+ // URLs ending with these are skipped from the BFS queue entirely.
157
+ const DOWNLOAD_EXT_RE = /\.(pdf|zip|gz|tar|rar|7z|exe|dmg|pkg|deb|rpm|msi|apk|ipa|doc|docx|xls|xlsx|ppt|pptx|csv|json|xml|yaml|yml|txt|log|sql|db|sqlite|mp4|mp3|avi|mov|mkv|webm|wav|ogg|flac|jpg|jpeg|png|gif|bmp|svg|webp|ico|woff|woff2|ttf|eot|otf|bin|iso|img|vhd|vmdk)(\?.*)?$/i;
158
+
159
+ // API endpoint path patterns — these return data (JSON/CSV/binary), not rendered pages.
160
+ // Crawling them triggers file downloads and wastes BFS slots.
161
+ const API_PATH_RE = /^\/api\//i;
162
+
163
+ // Export/download path segments that appear in apps — these return file data even without
164
+ // a file extension in the URL (e.g. /admin/exports/report, /download/invoice/123).
165
+ const EXPORT_PATH_RE = /\/(exports?|downloads?|export-csv|export-pdf|export-excel|download-report|generate-report|report-download)(\/|$|\?)/i;
166
+
144
167
  function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
145
168
  try {
146
169
  const u = new URL(href);
147
170
  if (!sameSite(u.toString(), siteOrigin)) return false;
148
171
  if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
149
172
  if (isGeneratedDynamicPath(u.pathname)) return false;
173
+ if (DOWNLOAD_EXT_RE.test(u.pathname)) return false;
174
+ if (API_PATH_RE.test(u.pathname)) return false;
175
+ if (EXPORT_PATH_RE.test(u.pathname)) return false;
150
176
  return true;
151
177
  } catch {
152
178
  return false;
@@ -695,7 +721,9 @@ export async function crawlProject(opts: {
695
721
  payload: { profile: domainProfile, routeDecision },
696
722
  }).catch((err: any) => console.warn('[crawler] profile evidence save failed:', err?.message ?? err));
697
723
 
698
- if (!stagehandConfigured) {
724
+ if (!stagehandConfigured && !opts.discoveryMode) {
725
+ // Discovery mode never calls Stagehand AI — DOM link extraction only.
726
+ // Skip this guard so URL discovery works even without an AI provider configured.
699
727
  throw new Error(
700
728
  configuredProvider
701
729
  ? `No API key configured for provider "${configuredProvider}". Go to Workspace → Settings → AI Provider.`
@@ -1021,14 +1049,81 @@ export async function crawlProject(opts: {
1021
1049
  if (pwContext) {
1022
1050
  // P4: pass XHR URL discovery callback — adds relative paths to xhrDiscoveredPaths
1023
1051
  attachNetworkObserver(pwContext, capturedApiEndpoints, (relPath) => xhrDiscoveredPaths.add(relPath));
1024
- // OAuth/SSO popup handler — attach before login so we don't miss it
1052
+ // Popup/new-window handler — fires for every window.open() call.
1053
+ // Handles two cases:
1054
+ // 1. OAuth/SSO provider windows — wait for close, then continue login flow.
1055
+ // 2. App-generated popups (help, report, lightbox-in-window) — screenshot + save as screen.
1056
+ const OAUTH_PROVIDER_RE = /google\.|github\.|microsoft\.|facebook\.|twitter\.|linkedin\.|okta\.|auth0\.|ping\.|onelogin\.|sso\.|oauth\.|\/authorize\?|\/oauth2\//i;
1025
1057
  pwContext.on('page', (popup: any) => {
1026
- const popupUrl = popup.url();
1027
- logger.info({ url: popupUrl }, '[crawler] OAuth popup detected — waiting for close');
1028
- popup.waitForEvent('close', { timeout: 120_000 })
1029
- .then(() => logger.info('[crawler] OAuth popup closed — SSO flow complete'))
1030
- .catch(() => logger.warn('[crawler] OAuth popup timed out or errored'));
1058
+ // Wait for the popup to navigate to a real URL (about:blank initially)
1059
+ popup.waitForURL((u: string) => !u.startsWith('about:'), { timeout: 10_000 })
1060
+ .then(async () => {
1061
+ const popupUrl = popup.url();
1062
+ if (OAUTH_PROVIDER_RE.test(popupUrl)) {
1063
+ // OAuth flow — just wait for it to close
1064
+ logger.info({ url: popupUrl }, '[crawler] OAuth popup navigated to provider');
1065
+ popup.waitForEvent('close', { timeout: 120_000 })
1066
+ .then(() => logger.info('[crawler] OAuth popup closed — SSO flow complete'))
1067
+ .catch(() => logger.warn('[crawler] OAuth popup timed out or errored'));
1068
+ return;
1069
+ }
1070
+ // App popup — capture as a screen (skip API/export/download URLs)
1071
+ try {
1072
+ const _popupPath = (() => { try { return new URL(popupUrl).pathname; } catch { return ''; } })();
1073
+ if (API_PATH_RE.test(_popupPath) || EXPORT_PATH_RE.test(_popupPath) || DOWNLOAD_EXT_RE.test(_popupPath)) return;
1074
+ } catch {}
1075
+ logger.info({ url: popupUrl }, '[crawler] App popup detected — capturing');
1076
+ try {
1077
+ await popup.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => {});
1078
+ await popup.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
1079
+ const popupTitle = await popup.title().catch(() => '');
1080
+ const popupName = popupTitle || (() => { try { return new URL(popupUrl).pathname.split('/').filter(Boolean).pop() || 'Popup'; } catch { return 'Popup'; } })();
1081
+ const popupHash = popupUrl.replace(/[^a-z0-9]/gi, '').slice(-12);
1082
+ const popupShotPath = path.join(opts.screenshotDir ?? '/tmp', `popup-${popupHash}.png`);
1083
+ await waitForScreenshotReady(popup, popup);
1084
+ await popup.screenshot({ path: popupShotPath, fullPage: false }).catch(() => {});
1085
+ const popupScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, popupName, popupUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
1086
+ if (popupScreen) {
1087
+ const cloudUrl = await uploadToCloud(popupShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(popupShotPath)}`).catch(() => undefined);
1088
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, popupScreen.id, popupShotPath, popupScreen.version, 'DESKTOP', cloudUrl).catch(() => {});
1089
+ const popupEls = await domFallbackElements(popup, popup).catch(() => []);
1090
+ for (const el of popupEls) {
1091
+ AppBrain.saveElement(opts.tenantId, opts.projectId, popupScreen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {});
1092
+ }
1093
+ logger.info({ url: popupUrl, name: popupName, elementCount: popupEls.length }, '[crawler] App popup screen saved');
1094
+ }
1095
+ } catch (popupErr: any) {
1096
+ logger.warn({ url: popupUrl, err: popupErr?.message }, '[crawler] App popup capture failed — non-fatal');
1097
+ }
1098
+ })
1099
+ .catch(() => {}); // popup may close before navigating (race condition)
1031
1100
  });
1101
+ // Cancel any file downloads triggered by link navigation — the crawler captures
1102
+ // pages, not binary files. Without this, clicking download links opens the browser
1103
+ // download UI and stalls the crawl waiting for a page load that never comes.
1104
+ pwContext.on('download', (dl: any) => { dl.cancel().catch(() => {}); });
1105
+
1106
+ // Route-level interception: immediately abort navigation to known API/export/download
1107
+ // paths that would trigger file downloads. Uses URL-only matching (no double-fetch)
1108
+ // to avoid the extra network round-trip that was causing "Failed to fetch" crashes.
1109
+ // The download event handler above catches any remaining downloads that slip through.
1110
+ try {
1111
+ await pwContext.route('**/*', async (route: any, request: any) => {
1112
+ const resourceType: string = request.resourceType?.() ?? '';
1113
+ if (resourceType !== 'document') { await route.continue().catch(() => {}); return; }
1114
+ try {
1115
+ const u = new URL(request.url());
1116
+ if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) {
1117
+ logger.info({ url: request.url() }, '[crawler] route: aborting API/export/download navigation');
1118
+ await route.abort('failed').catch(() => {});
1119
+ return;
1120
+ }
1121
+ } catch {}
1122
+ await route.continue().catch(() => {});
1123
+ });
1124
+ } catch (routeErr: any) {
1125
+ logger.warn({ err: routeErr?.message }, '[crawler] route interception setup failed — downloads may occur');
1126
+ }
1032
1127
  }
1033
1128
  // Wire rawPage to the real Playwright page from this CDP connection.
1034
1129
  // This gives us a true Playwright Page that supports all events (.on('response'), etc.)
@@ -1148,6 +1243,78 @@ export async function crawlProject(opts: {
1148
1243
  document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
1149
1244
  ).catch(() => -1);
1150
1245
 
1246
+ // ── Pre-auth public-page discovery ───────────────────────────────────────
1247
+ // The page is unauthenticated here. On sites where login redirects users
1248
+ // away from the marketing/landing pages (e.g. "/" → app dashboard), BFS
1249
+ // (which runs after auth) will never discover those public pages. We extract
1250
+ // nav links from the current unauthenticated view and visit/capture each one
1251
+ // before the session is applied, so the marketing pages are captured correctly.
1252
+ const preAuthPublicCaptured = new Set<string>();
1253
+ if (!opts.discoveryMode) {
1254
+ const _appOrigin = new URL(opts.appUrl).origin;
1255
+ // Simple URL normalizer for pre-auth use (canonicalize() declared later in scope)
1256
+ const _norm = (u: string) => { try { const p = new URL(u); p.hash = ''; return p.href.replace(/\/$/, ''); } catch { return u; } };
1257
+ const _startNorm = _norm(opts.appUrl);
1258
+ const _authPathRe = /\/(sign.?in|sign.?up|log.?in|register|auth|oauth|sso|forgot.?password|reset.?password)(\/|$|\?)/i;
1259
+ const _skipPathRe = /\/(terms|privacy|legal|cookie-policy|changelog|blog|imprint)([-/]|$)/i;
1260
+
1261
+ const publicNavLinks: string[] = await page.evaluate((originStr: string) => {
1262
+ return Array.from(document.querySelectorAll('a[href]'))
1263
+ .map((a: Element) => {
1264
+ try {
1265
+ const u = new URL((a as HTMLAnchorElement).getAttribute('href')!, window.location.href);
1266
+ return u.origin === originStr ? u.href.split('#')[0] : '';
1267
+ } catch { return ''; }
1268
+ })
1269
+ .filter((h: string, i: number, arr: string[]) => h && arr.indexOf(h) === i)
1270
+ .slice(0, 30);
1271
+ }, _appOrigin).catch(() => [] as string[]);
1272
+
1273
+ for (const link of publicNavLinks) {
1274
+ if (_norm(link) === _startNorm) continue;
1275
+ try {
1276
+ const { pathname } = new URL(link);
1277
+ if (_authPathRe.test(pathname) || _skipPathRe.test(pathname) || API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
1278
+ await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
1279
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
1280
+ const finalUrl = page.url();
1281
+ const finalPath = new URL(finalUrl).pathname;
1282
+ if (_authPathRe.test(finalPath)) continue; // redirected to auth — skip
1283
+ if (_norm(finalUrl) === _startNorm) continue; // SPA catch-all redirect
1284
+ // Read interactive elements first — SPA pages show 0 elements without auth.
1285
+ // Only lock as pre-auth if the page actually has content; otherwise BFS handles it authenticated.
1286
+ const preAuthElements = await readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>);
1287
+ if (preAuthElements.length === 0) {
1288
+ logger.info({ url: finalUrl }, '[crawler] pre-auth page has 0 elements — deferring to authenticated BFS');
1289
+ continue;
1290
+ }
1291
+ const pathParts = finalPath.split('/').filter(Boolean);
1292
+ const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
1293
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
1294
+ if (!screen) continue;
1295
+ const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
1296
+ await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {});
1297
+ const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
1298
+ await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl).catch(() => {});
1299
+ // Save elements found in the pre-auth view
1300
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
1301
+ for (const el of preAuthElements) {
1302
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.label, el.role, el.expectedData, undefined, undefined, true, undefined, undefined, undefined).catch(() => {});
1303
+ }
1304
+ preAuthPublicCaptured.add(finalUrl);
1305
+ logger.info({ url: finalUrl, screenId: screen.id, elements: preAuthElements.length }, '[crawler] pre-auth public page captured');
1306
+ } catch (err: any) {
1307
+ logger.warn({ url: link, err: err?.message }, '[crawler] pre-auth public page visit failed — skipping');
1308
+ }
1309
+ }
1310
+
1311
+ // Navigate back to start so auth phase and fingerprint comparison are correct
1312
+ if (preAuthPublicCaptured.size > 0) {
1313
+ await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 20_000 }).catch(() => {});
1314
+ await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
1315
+ }
1316
+ }
1317
+
1151
1318
  // Dedicated logged-out/auth discovery phase. This runs before credentials or
1152
1319
  // storageState are applied, so public pages and auth recovery flows remain
1153
1320
  // visible even when the main crawl later becomes authenticated.
@@ -1159,6 +1326,18 @@ export async function crawlProject(opts: {
1159
1326
  }
1160
1327
  }
1161
1328
 
1329
+ // Auth discovery opens OAuth sign-in popups (Google, GitHub, etc.) which fire CDP events that
1330
+ // can permanently corrupt the Playwright isolated world. Verify the world is intact; if not,
1331
+ // re-navigate to appUrl to force a fresh execution context before BFS begins.
1332
+ {
1333
+ const worldOk = await page.evaluate(() => true).catch(() => false);
1334
+ if (!worldOk) {
1335
+ logger.warn('[crawler] isolated world corrupted after auth discovery — re-navigating to recover');
1336
+ await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
1337
+ await page.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
1338
+ }
1339
+ }
1340
+
1162
1341
  let didLogin = false; // track whether AI login was actually performed
1163
1342
 
1164
1343
  if (opts.authToken) {
@@ -1179,14 +1358,50 @@ export async function crawlProject(opts: {
1179
1358
  };
1180
1359
 
1181
1360
  if (state.cookies?.length) {
1182
- // page.context() is not available on Stagehand V3 proxy — use the secondary Playwright client
1361
+ // Apply cookies to both the secondary Playwright context AND Stagehand's own context.
1362
+ // Stagehand V3 uses a separate browser context from the CDP secondary client, so
1363
+ // cookies added only to playwrightBrowser.contexts()[0] don't reach Stagehand's pages.
1183
1364
  const browserCtx = playwrightBrowser?.contexts()[0];
1184
1365
  if (browserCtx) {
1185
1366
  await browserCtx.addCookies(state.cookies as any);
1186
1367
  }
1368
+ // Also apply to Stagehand's context via its page proxy
1369
+ try {
1370
+ const stagehandCtx = (page as any).context?.();
1371
+ if (stagehandCtx && typeof stagehandCtx.addCookies === 'function') {
1372
+ await stagehandCtx.addCookies(state.cookies as any);
1373
+ }
1374
+ } catch { /* Stagehand V3 proxy may not expose context() */ }
1187
1375
  }
1188
1376
 
1189
1377
  if (state.origins?.length) {
1378
+ // Apply localStorage via addInitScript to ALL contexts:
1379
+ // 1. Secondary Playwright context (rawPage navigations)
1380
+ // 2. Stagehand's page directly (Stagehand V3 uses its own context separate from playwrightBrowser)
1381
+ // Without #2, Stagehand's BFS navigations never get localStorage → pages load unauthenticated.
1382
+ const originCtx = playwrightBrowser?.contexts()[0];
1383
+ if (originCtx) {
1384
+ await originCtx.addInitScript((origins: typeof state.origins) => {
1385
+ for (const entry of (origins ?? [])) {
1386
+ if (window.location.origin === entry.origin) {
1387
+ for (const item of entry.localStorage) {
1388
+ try { localStorage.setItem(item.name, item.value); } catch { /* quota/sandboxed */ }
1389
+ }
1390
+ }
1391
+ }
1392
+ }, state.origins);
1393
+ }
1394
+ // Stagehand's page — covers all BFS navigations via Stagehand
1395
+ await page.addInitScript((origins: typeof state.origins) => {
1396
+ for (const entry of (origins ?? [])) {
1397
+ if (window.location.origin === entry.origin) {
1398
+ for (const item of entry.localStorage) {
1399
+ try { localStorage.setItem(item.name, item.value); } catch { /* quota/sandboxed */ }
1400
+ }
1401
+ }
1402
+ }
1403
+ }, state.origins);
1404
+ // Also cover rawPage for the initial navigation below
1190
1405
  await rawPage.addInitScript((origins: typeof state.origins) => {
1191
1406
  for (const entry of (origins ?? [])) {
1192
1407
  if (window.location.origin === entry.origin) {
@@ -1396,8 +1611,17 @@ export async function crawlProject(opts: {
1396
1611
  [
1397
1612
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'ref', 'source', '_gl', 'fbclid', 'gclid',
1398
1613
  'ts', 'timestamp', 'cache', 'cacheBust', 'cache_bust', '_', 'nonce', 'session', 'sid',
1614
+ // App content/state params — these change the content rendered within the same
1615
+ // page template (a playground example, a search query, a tab selection) but do NOT
1616
+ // represent structurally distinct pages. Not stripping them causes URL explosion:
1617
+ // every unique ?cmd=DSN-XXXXX&example=... or ?q=foo is queued as a new screen.
1618
+ 'cmd', 'example', 'query', 'q', 'search', 'keyword', 'keywords',
1619
+ 'tab', 'view', 'mode', 'sort', 'order', 'direction', 'orderby',
1620
+ 'page', 'p', 'offset', 'limit', 'per_page', 'size', 'rows',
1621
+ // Export format params — same endpoint, different output format, not a new screen
1622
+ 'format', 'output', 'export_format', 'download_format', 'fileformat', 'file_format',
1399
1623
  // WooCommerce / e-commerce action params
1400
- 'add-to-cart', 'add_to_wishlist', 'quantity', 'wc-ajax', 'orderby',
1624
+ 'add-to-cart', 'add_to_wishlist', 'quantity', 'wc-ajax',
1401
1625
  'min_price', 'max_price', 'removed_item', 'restore', 'remove_item',
1402
1626
  'undo_item', 'coupon_code', 'apply_coupon', 'added-to-cart',
1403
1627
  'wc_error', 'wc_notice', 'wc_notice_type', 'update_cart',
@@ -1440,10 +1664,14 @@ export async function crawlProject(opts: {
1440
1664
  const gitInfo = getGitInfo();
1441
1665
 
1442
1666
  const visited = new Map<string, string>(); // key = canonical URL
1667
+ const crawlWarnings: string[] = []; // non-fatal step failures surfaced in job result
1443
1668
  // Pre-populate from a previous failed job so already-captured URLs are skipped
1444
1669
  if (opts.skipUrls?.length) {
1445
1670
  for (const u of opts.skipUrls) visited.set(canonicalize(u), 'skipped');
1446
1671
  }
1672
+ // Pre-auth public pages already captured above — mark visited so BFS (post-auth)
1673
+ // doesn't overwrite them with authenticated redirects showing app content instead.
1674
+ for (const u of preAuthPublicCaptured) visited.set(canonicalize(u), 'pre-auth');
1447
1675
  let checkpointSuccessCount = 0;
1448
1676
  let maxSeenTotal = 0; // track high-water mark so progress % never goes backwards
1449
1677
  let lastEmittedPct = 20; // floor above auth-discovery + login phases (max ~19%)
@@ -1504,6 +1732,10 @@ export async function crawlProject(opts: {
1504
1732
  .filter((s: any) => s.classification !== 'REACHABLE')
1505
1733
  .map((s: any) => s.id as string),
1506
1734
  );
1735
+ // Snapshot queued set BEFORE filter so canonical dedup works correctly during filter.
1736
+ // Without this, multiple prior screens with the same canonical (e.g. playground?cmd=X1,
1737
+ // playground?cmd=X2 → same canonical) all pass the filter since queued isn't updated yet.
1738
+ const seenReseedCanon = new Set<string>(queued);
1507
1739
  const priorScreens = (priorMap?.screens ?? [])
1508
1740
  .filter((s: any) => s.status !== 'PENDING_REMOVAL')
1509
1741
  .filter((s: any) => {
@@ -1515,10 +1747,15 @@ export async function crawlProject(opts: {
1515
1747
  if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
1516
1748
  // Don't re-seed generated dynamic routes — they produce duplicate templates.
1517
1749
  if (isGeneratedDynamicPath(pathname)) return false;
1750
+ // Don't re-seed API/export/download paths — they were misidentified as screens.
1751
+ if (!isUsefulNavigationUrl(u, startUrl)) return false;
1752
+ // Canonical dedup: mark seen immediately during filter so duplicates are caught.
1753
+ const canon = canonicalize(u);
1754
+ if (seenReseedCanon.has(canon)) return false;
1755
+ seenReseedCanon.add(canon);
1518
1756
  return true;
1519
1757
  } catch { return false; }
1520
- })
1521
- .filter((s: any) => !queued.has(canonicalize(s.url as string)));
1758
+ });
1522
1759
  let orphanSeedCount = 0;
1523
1760
  for (const s of priorScreens) {
1524
1761
  const u = s.url as string;
@@ -1580,6 +1817,7 @@ export async function crawlProject(opts: {
1580
1817
  try {
1581
1818
  const u = new URL(sUrl);
1582
1819
  if (u.origin !== sitemapOrigin) continue;
1820
+ if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) continue;
1583
1821
  await page.goto(sUrl, { waitUntil: 'domcontentloaded', timeout: 12_000 });
1584
1822
  await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
1585
1823
  await waitForStableDOM(page);
@@ -1664,6 +1902,9 @@ export async function crawlProject(opts: {
1664
1902
  // that slip through canonicalize() if any new filter param type is encountered.
1665
1903
  let totalAttemptsThisSegment = 0;
1666
1904
  const maxAttempts = Math.max(segmentSize * 4, 200);
1905
+ // Count consecutive login-wall redirects — if too many, abort early (auth-wall site, no creds)
1906
+ let loginWallRedirectCount = 0;
1907
+ const LOGIN_WALL_ABORT_THRESHOLD = Math.max(8, Math.ceil(segmentSize * 0.4));
1667
1908
 
1668
1909
  let pendingSessionRestore: (() => Promise<void>) | null = null;
1669
1910
 
@@ -1687,6 +1928,8 @@ export async function crawlProject(opts: {
1687
1928
  try {
1688
1929
  const { pathname } = new URL(url);
1689
1930
  if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
1931
+ // Skip API/export paths — these return data files, not navigable pages
1932
+ if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
1690
1933
  } catch { continue; }
1691
1934
 
1692
1935
  // Claim URL before first await — prevents duplicate processing across parallel workers
@@ -1701,6 +1944,28 @@ export async function crawlProject(opts: {
1701
1944
  // Wait for SPA JS to render components before extracting elements or running soft-404 check.
1702
1945
  // domcontentloaded fires before JS executes — networkidle ensures React/Vue/Angular hydration.
1703
1946
  await workerPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
1947
+ // Wait for DOM to stop mutating (React/Vue render cycles) before element extraction.
1948
+ await waitForStableDOM(workerPage, 6_000, 400).catch(() => {});
1949
+ // Dismiss cookie banners / GDPR overlays so underlying elements are accessible.
1950
+ await dismissOverlays(workerPage).catch(() => {});
1951
+
1952
+ // Auth wall detection: if navigation redirected to a login page, skip this URL.
1953
+ // Lite worker cannot re-authenticate (no Stagehand access) — mark and skip.
1954
+ const lwCurrentUrl = workerPage.url();
1955
+ const lwPath = (() => { try { return new URL(lwCurrentUrl).pathname.toLowerCase(); } catch { return ''; } })();
1956
+ const lwIsAuthWall = /^\/(login|signin|sign-in|auth|authenticate|session\/new|account\/login|user\/login)/.test(lwPath) && lwCurrentUrl !== url;
1957
+ const lwHasLoginForm = lwIsAuthWall ? false : await workerPage.evaluate(() => {
1958
+ const inputs = document.querySelectorAll('input[type="password"]');
1959
+ return inputs.length > 0;
1960
+ }).catch(() => false);
1961
+ if (lwIsAuthWall) {
1962
+ logger.warn({ url, landed: lwCurrentUrl }, '[lite-worker] auth wall detected — queuing for main worker re-auth');
1963
+ visited.set(canonUrl, '_auth_wall_');
1964
+ if (!revisitQueue.includes(url)) revisitQueue.push(url);
1965
+ continue;
1966
+ }
1967
+ // lwHasLoginForm (direct sign-in/register page): process normally — capture the form elements.
1968
+ // Do NOT add to revisitQueue; P3a cannot do anything useful with a login page revisit.
1704
1969
 
1705
1970
  // Soft-404 detection for lite-worker: skip SPA catch-all URLs that mirror homepage
1706
1971
  if (homeFingerprintElemCount >= 0 && url !== startUrl) {
@@ -1725,8 +1990,23 @@ export async function crawlProject(opts: {
1725
1990
 
1726
1991
  const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
1727
1992
 
1993
+ let liteElements = await domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>);
1994
+ if (liteElements.length === 0) {
1995
+ // domFallbackElements returned nothing — try broader visible-element selector as last resort.
1996
+ liteElements = await workerPage.evaluate(() => {
1997
+ const sel = 'button,a[href],input,select,textarea,[role],[tabindex="0"],[onclick],[data-action]';
1998
+ return Array.from(document.querySelectorAll(sel))
1999
+ .filter((el: any) => el.offsetParent !== null)
2000
+ .slice(0, 150)
2001
+ .map((el: any) => ({
2002
+ meaning: el.getAttribute('aria-label') || el.textContent?.trim().slice(0, 100) || el.tagName.toLowerCase(),
2003
+ role: el.getAttribute('role') || el.tagName.toLowerCase(),
2004
+ expectedData: null, notes: null, boundingRect: null, ariaState: null, parentLandmark: null,
2005
+ }));
2006
+ }).catch(() => []) as any[];
2007
+ }
1728
2008
  const [elements] = await Promise.all([
1729
- domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>),
2009
+ Promise.resolve(liteElements),
1730
2010
  workerPage.screenshot({ path: shotPath, fullPage: true })
1731
2011
  .then(async () => {
1732
2012
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
@@ -1735,25 +2015,137 @@ export async function crawlProject(opts: {
1735
2015
  .catch(() => {}),
1736
2016
  ]);
1737
2017
 
2018
+ // Nav-link pass — merged into main save cycle so clearScreenElements covers them too.
2019
+ // extractElements (main BFS) never runs for lite-worker screens, so nav/footer roles
2020
+ // must be captured here. Running before clear+save prevents additive accumulation.
2021
+ const _navEls = await (workerPage.evaluate as any)(() => {
2022
+ if (typeof (globalThis as any).__name === 'undefined') (globalThis as any).__name = (fn: any) => fn;
2023
+ const seen = new Set<string>();
2024
+ const items: Array<{meaning: string; role: string; notes: string}> = [];
2025
+ function isV(el: Element) {
2026
+ const s = window.getComputedStyle(el);
2027
+ return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0';
2028
+ }
2029
+ document.querySelectorAll('nav a, [role="navigation"] a, header a, footer nav a, [aria-label*="navigation" i] a').forEach((a: any) => {
2030
+ if (!isV(a)) return;
2031
+ const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
2032
+ const href = a.href ?? '';
2033
+ if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
2034
+ let landmark = 'Site Navigation';
2035
+ let cur: any = a.parentElement;
2036
+ while (cur) {
2037
+ const t = cur.tagName?.toLowerCase();
2038
+ const r = cur.getAttribute?.('role')?.toLowerCase();
2039
+ if (t === 'header' || r === 'banner') { landmark = 'Header'; break; }
2040
+ if (t === 'footer' || r === 'contentinfo') { landmark = 'Footer'; break; }
2041
+ if (t === 'nav' || r === 'navigation') { landmark = cur.getAttribute('aria-label')?.trim() || 'Site Navigation'; break; }
2042
+ cur = cur.parentElement;
2043
+ }
2044
+ const key = `nav|${label.toLowerCase()}|${href}`;
2045
+ if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'nav-link', notes: JSON.stringify({ href: href.slice(0, 150), landmark }) }); }
2046
+ });
2047
+ let fc = 0;
2048
+ document.querySelectorAll('footer a, [role="contentinfo"] a, [class*="footer" i] a').forEach((a: any) => {
2049
+ if (fc >= 20 || !isV(a)) return;
2050
+ const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 60);
2051
+ const href = a.href ?? '';
2052
+ if (!label || !href) return;
2053
+ const key = `footer|${label.toLowerCase()}|${href}`;
2054
+ if (!seen.has(key)) { seen.add(key); items.push({ meaning: label, role: 'footer-link', notes: JSON.stringify({ href: href.slice(0, 150) }) }); fc++; }
2055
+ });
2056
+ return items;
2057
+ }).catch(() => [] as any[]);
2058
+
2059
+ const allElements = [
2060
+ ...elements,
2061
+ ..._navEls.map((ne: any) => ({ ...ne, expectedData: undefined, boundingRect: undefined, ariaState: undefined, parentLandmark: undefined })),
2062
+ ];
2063
+
2064
+ // Persist lite-worker elements to DB — without this, 80% of screens have 0 DB elements.
2065
+ if (allElements.length > 0) {
2066
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
2067
+ for (let _ei = 0; _ei < allElements.length; _ei += 4) {
2068
+ const _batch = allElements.slice(_ei, _ei + 4);
2069
+ await Promise.all(_batch.map((el: any) =>
2070
+ AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {})
2071
+ ));
2072
+ }
2073
+ logger.info({ url, screenId: screen.id, elements: elements.length, navLinks: _navEls.length }, '[lite-worker] screen captured');
2074
+ } else if (!revisitQueue.includes(url)) {
2075
+ // 0-element: queue for main BFS re-visit with full Stagehand AI extraction
2076
+ logger.warn({ url, screenId: screen.id }, '[lite-worker] 0 elements — queued for P3a revisit');
2077
+ revisitQueue.push(url);
2078
+ }
2079
+
2080
+ // Non-mutating probes for lite-worker screens: shadow DOM, a11y tree, service worker.
2081
+ // Awaited with 15s total timeout — these are read-only and safe on the shared worker page.
2082
+ if (screen?.id) {
2083
+ const [shadowRes, a11yRes, swRes] = await Promise.race([
2084
+ Promise.allSettled([
2085
+ crawlShadowDom(workerPage),
2086
+ crawlAccessibilityTree(workerPage),
2087
+ inspectServiceWorker(workerPage),
2088
+ ]),
2089
+ new Promise<PromiseSettledResult<never>[]>((resolve) =>
2090
+ setTimeout(() => resolve([
2091
+ { status: 'rejected' as const, reason: 'timeout' },
2092
+ { status: 'rejected' as const, reason: 'timeout' },
2093
+ { status: 'rejected' as const, reason: 'timeout' },
2094
+ ]), 15_000)
2095
+ ),
2096
+ ]);
2097
+ const litePayload: Record<string, unknown> = { screenId: screen.id, url };
2098
+ if (shadowRes.status === 'fulfilled' && shadowRes.value && (shadowRes.value as any).shadowHostCount > 0)
2099
+ litePayload.shadowDom = shadowRes.value;
2100
+ if (a11yRes.status === 'fulfilled' && a11yRes.value)
2101
+ litePayload.accessibilityTree = a11yRes.value;
2102
+ if (swRes.status === 'fulfilled' && (swRes.value as any).supported)
2103
+ litePayload.serviceWorker = swRes.value;
2104
+ if (Object.keys(litePayload).length > 2) {
2105
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2106
+ jobId: opts.jobId, type: 'PER_SCREEN_PROBES', payload: litePayload,
2107
+ }).catch(() => {});
2108
+ }
2109
+ }
2110
+
1738
2111
  const nextLinks: string[] = await (workerPage.evaluate as any)((originStr: string) => {
1739
- return Array.from(document.querySelectorAll('a[href]'))
2112
+ return Array.from(document.querySelectorAll('a[href],[data-href]'))
1740
2113
  .map((a: any) => {
1741
- try { return new URL(a.getAttribute('href')!, window.location.href).href; }
2114
+ const raw = a.getAttribute('href') || a.getAttribute('data-href') || '';
2115
+ try { return new URL(raw, window.location.href).href; }
1742
2116
  catch { return ''; }
1743
2117
  })
1744
- .filter((h: string) => h && new URL(h).origin === originStr && !h.includes('#'))
2118
+ .filter((h: string) => {
2119
+ if (!h) return false;
2120
+ try {
2121
+ const u = new URL(h);
2122
+ if (u.origin !== originStr) return false;
2123
+ // Keep hash-router paths (#/route, #!/route) but drop plain fragment anchors (#section)
2124
+ if (u.hash && !u.hash.startsWith('#/') && !u.hash.startsWith('#!/')) return false;
2125
+ return true;
2126
+ } catch { return false; }
2127
+ })
1745
2128
  .slice(0, 200);
1746
2129
  }, origin).catch(() => []);
1747
2130
 
1748
2131
  for (const link of nextLinks) {
2132
+ if (!isUsefulNavigationUrl(link, origin)) continue;
1749
2133
  const cLink = canonicalize(link);
1750
- if (!visited.has(cLink)) {
2134
+ if (!visited.has(cLink) && !queued.has(cLink)) {
2135
+ queued.add(cLink);
1751
2136
  queue.push(link);
1752
2137
  edges.push({ fromUrl: url, toUrl: link });
1753
2138
  }
1754
2139
  }
1755
2140
 
1756
2141
  discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
2142
+ // Lite workers also advance the progress bar — without this 4/5 workers are invisible.
2143
+ const lwEffMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
2144
+ maxSeenTotal = Math.max(maxSeenTotal, lwEffMax);
2145
+ const lwRawPct = 20 + Math.round(Math.min(57, (visited.size / Math.max(1, maxSeenTotal)) * 57));
2146
+ const lwPct = Math.max(lastEmittedPct, lwRawPct);
2147
+ lastEmittedPct = lwPct;
2148
+ await onProgress?.(lwPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`).catch(() => {});
1757
2149
  } catch (err: any) {
1758
2150
  logger.warn({ url, err: err?.message ?? String(err) }, '[lite-worker] URL processing failed');
1759
2151
  discovered.push({ url, error: err?.message ?? String(err) });
@@ -1762,7 +2154,7 @@ export async function crawlProject(opts: {
1762
2154
  }
1763
2155
 
1764
2156
  const liteWorkerPromises: Promise<void>[] = [];
1765
- if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
2157
+ if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser && !opts.discoveryMode) {
1766
2158
  const pwCtx = playwrightBrowser.contexts()[0];
1767
2159
  for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1768
2160
  const wp = await pwCtx.newPage();
@@ -1793,11 +2185,14 @@ export async function crawlProject(opts: {
1793
2185
 
1794
2186
  const url = queue.shift()!;
1795
2187
  const canonUrl = canonicalize(url);
1796
- logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, '[crawler] visiting URL');
1797
2188
  const apiObservationStartIndex = capturedApiEndpoints.length;
1798
2189
 
1799
2190
  // Skip if already visited (canonical match — catches ID-parameterized duplicates)
1800
2191
  if (visited.has(canonUrl)) continue;
2192
+ // Claim immediately before any await — prevents lite workers from picking up the same URL
2193
+ // concurrently while this iteration is suspended (race that causes repeated visits).
2194
+ visited.set(canonUrl, '_pending_');
2195
+ logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size}/${segmentSize}` }, '[crawler] visiting URL');
1801
2196
 
1802
2197
  // robots.txt compliance — skip disallowed URLs
1803
2198
  if (robotsCache && !await robotsCache.isAllowed(url)) {
@@ -1819,6 +2214,13 @@ export async function crawlProject(opts: {
1819
2214
  console.log(`[crawler] skip generated dynamic route: ${url}`);
1820
2215
  continue;
1821
2216
  }
2217
+ // Skip API/export/download paths — navigating to these triggers file downloads.
2218
+ // The main BFS uses Stagehand's context which has no route interception.
2219
+ if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) {
2220
+ console.log(`[crawler] skip API/export/download path: ${url}`);
2221
+ visited.set(canonUrl, '_skipped_api_');
2222
+ continue;
2223
+ }
1822
2224
  } catch { /* invalid url, let it fall through */ }
1823
2225
 
1824
2226
  try {
@@ -1914,13 +2316,18 @@ export async function crawlProject(opts: {
1914
2316
  }
1915
2317
  rawPage.off('response', onRedirectResponse);
1916
2318
  if (redirectHops.length > 0) {
2319
+ const isCircularRedirect = new Set(redirectHops.map(h => h.from)).size < redirectHops.length;
1917
2320
  redirectRegistry.register({
1918
2321
  originalUrl: url, finalUrl: page.url(),
1919
2322
  hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
1920
- isCircular: new Set(redirectHops.map(h => h.from)).size < redirectHops.length,
2323
+ isCircular: isCircularRedirect,
1921
2324
  hopCount: redirectHops.length,
1922
2325
  totalDurationMs: Date.now() - navStart,
1923
2326
  });
2327
+ if (isCircularRedirect) {
2328
+ logger.warn({ url, hops: redirectHops.map(h => h.from) }, '[crawler] circular redirect detected — skipping URL');
2329
+ continue;
2330
+ }
1924
2331
  }
1925
2332
  // Update exponential moving average for AutoThrottle
1926
2333
  const navMs = Date.now() - navStart;
@@ -1939,8 +2346,54 @@ export async function crawlProject(opts: {
1939
2346
  // 3. Modal capture — screenshot app dialogs BEFORE dismissal (they are testable UI states)
1940
2347
  // 4. Overlay dismissal — removes cookie banners/modals before element extraction
1941
2348
  // 5. Lazy-load scroll — triggers IntersectionObserver for off-screen images/components
1942
- await page.waitForLoadState('load', { timeout: 8_000 }).catch(() => { });
1943
- await waitForStableDOM(page);
2349
+ await (rawPage ?? page).waitForLoadState('load', { timeout: 8_000 }).catch(() => { });
2350
+ await waitForStableDOM(page, 8_000, 400, rawPage ?? undefined);
2351
+ // If DOM is sparse after stability check (< 15 interactive elements), SPA may still be
2352
+ // fetching data — skeleton is DOM-stable but empty. Extra networkidle pass catches it.
2353
+ const sparseCheck = await (rawPage ?? page).evaluate(() =>
2354
+ document.querySelectorAll('button,a,input,select,textarea,[role="button"],[aria-label]').length
2355
+ ).catch(() => 99);
2356
+ if (sparseCheck < 15) {
2357
+ await (rawPage ?? page).waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => {});
2358
+ await waitForStableDOM(page, 4_000, 300, rawPage ?? undefined);
2359
+ }
2360
+ // Wait for SPA auth-check / loading spinners to resolve before screenshotting.
2361
+ // SPAs often show a "Checking authentication..." or "Loading..." state after networkidle
2362
+ // while client-side auth verifies tokens. waitForStableDOM catches DOM mutations but
2363
+ // returns as soon as the spinner DOM is stable — this catches the spinner itself.
2364
+ // Stagehand V3 proxy may not expose waitForFunction — use try/catch, not .catch(),
2365
+ // because calling an undefined method throws synchronously before a Promise is created.
2366
+ try {
2367
+ const spinnerPage = rawPage ?? page;
2368
+ if (typeof spinnerPage.waitForFunction === 'function') {
2369
+ await spinnerPage.waitForFunction(() => {
2370
+ const body = document.body;
2371
+ if (!body) return true;
2372
+ const text = body.innerText?.toLowerCase() ?? '';
2373
+ // Text-based loading indicators
2374
+ if (
2375
+ text.includes('checking authentication') ||
2376
+ text.includes('checking auth') ||
2377
+ text.includes('authenticating') ||
2378
+ text.includes('verifying session') ||
2379
+ text.includes('please wait') ||
2380
+ text.includes('loading data') ||
2381
+ (text.includes('loading') && text.length < 200)
2382
+ ) return false;
2383
+ // ARIA-based: aria-busy on any visible element
2384
+ const busyEl = body.querySelector('[aria-busy="true"]');
2385
+ if (busyEl && (busyEl as HTMLElement).offsetParent !== null) return false;
2386
+ // Data attributes: React Query / SWR / TanStack in-flight indicators
2387
+ if (body.querySelector('[data-loading="true"],[data-pending="true"],[data-fetching="true"]')) return false;
2388
+ // Indeterminate progress bars
2389
+ if (body.querySelector('progress:not([value])')) return false;
2390
+ // Common CSS class patterns for loading skeletons
2391
+ const skeleton = body.querySelector('.skeleton,.skeleton-loader,.shimmer,.pulse-loader,.content-loader');
2392
+ if (skeleton && (skeleton as HTMLElement).offsetParent !== null) return false;
2393
+ return true;
2394
+ }, { timeout: 12_000 });
2395
+ }
2396
+ } catch { /* timed out or unsupported — screenshot whatever is there */ }
1944
2397
 
1945
2398
  if (!opts.discoveryMode) {
1946
2399
  // Capture app-generated modals BEFORE dismissing — cookie/GDPR banners excluded.
@@ -1958,20 +2411,25 @@ export async function crawlProject(opts: {
1958
2411
  const modalHash = url.replace(/[^a-z0-9]/gi, '').slice(-12);
1959
2412
  const modalShotPath = path.join(opts.screenshotDir ?? '/tmp', `modal-${modalHash}.png`);
1960
2413
  await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => { });
1961
- const modalName = await page.evaluate(() => {
2414
+ const modalName = await (rawPage ?? page).evaluate(() => {
1962
2415
  const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
1963
2416
  return h?.textContent?.trim() ?? 'Modal';
1964
2417
  }).catch(() => 'Modal');
1965
2418
  const modalScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, modalName, url, opts.jobId, discoveryMethodFor(url)).catch(() => null);
1966
2419
  if (modalScreen) {
1967
2420
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, 'DESKTOP').catch(() => { });
1968
- logger.info({ url, name: modalName }, '[crawler] captured app modal before dismiss');
2421
+ // Extract elements from the modal BEFORE it is dismissed — use CDP path via rawPage
2422
+ const modalElements = await domFallbackElements(page, rawPage ?? undefined).catch(() => []);
2423
+ for (const el of modalElements) {
2424
+ AppBrain.saveElement(opts.tenantId, opts.projectId, modalScreen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {});
2425
+ }
2426
+ logger.info({ url, name: modalName, elementCount: modalElements.length }, '[crawler] captured app modal before dismiss');
1969
2427
  }
1970
2428
  }
1971
2429
  } catch { /* non-fatal */ }
1972
2430
  await dismissOverlays(page);
1973
2431
  // Enhanced infinite scroll — handles lazy-load, infinite lists, and virtual scrollers
1974
- await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => { });
2432
+ await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }, rawPage ?? undefined).catch(() => { });
1975
2433
  }
1976
2434
  if (cdpSession?.off) cdpSession.off('Network.responseReceived', secHeadersCdp);
1977
2435
  wsDetector.detach();
@@ -2002,6 +2460,22 @@ export async function crawlProject(opts: {
2002
2460
  BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)
2003
2461
  );
2004
2462
  if (redirectedToError) {
2463
+ // Track login-wall redirects: if the site sends every URL to /login without credentials,
2464
+ // abort early rather than burning all maxAttempts on the same login page.
2465
+ if (BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)) {
2466
+ loginWallRedirectCount++;
2467
+ if (!opts.credentials && loginWallRedirectCount >= LOGIN_WALL_ABORT_THRESHOLD) {
2468
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2469
+ jobId: opts.jobId,
2470
+ type: 'AUTH_FAILURE',
2471
+ payload: {
2472
+ url, landed: landedUrl,
2473
+ reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`,
2474
+ },
2475
+ }).catch(() => {});
2476
+ throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
2477
+ }
2478
+ }
2005
2479
  // We asked for /dashboard but landed on /session-expired — session expired mid-crawl.
2006
2480
  logger.warn({ requested: url, landed: landedUrl }, '[crawler] Mid-crawl redirect to auth/error — attempting re-auth');
2007
2481
  let reauthed = false;
@@ -2016,8 +2490,8 @@ export async function crawlProject(opts: {
2016
2490
  if (reauthed) {
2017
2491
  logger.info({ url }, '[crawler] Re-auth succeeded — retrying URL');
2018
2492
  await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => { });
2019
- await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
2020
- await waitForStableDOM(page);
2493
+ await (rawPage ?? page).waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
2494
+ await waitForStableDOM(page, 8_000, 400, rawPage ?? undefined);
2021
2495
  const afterReauthUrl = page.url();
2022
2496
  if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
2023
2497
  logger.warn({ url }, '[crawler] Still redirecting after re-auth — skipping this URL');
@@ -2033,6 +2507,13 @@ export async function crawlProject(opts: {
2033
2507
  throw new Error('[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.');
2034
2508
  }
2035
2509
  } else {
2510
+ // Discovery mode: skip auth-required URLs and continue discovering other links.
2511
+ // Throwing here aborts the entire BFS — in discovery mode we want to continue
2512
+ // visiting other queued URLs (e.g. sitemap seeds) that may not require auth.
2513
+ if (opts.discoveryMode) {
2514
+ logger.info({ url, landed: landedUrl }, '[discover] skipping auth-required URL — no credentials, continuing BFS');
2515
+ continue;
2516
+ }
2036
2517
  logger.warn('[crawler] Session expired mid-crawl, no credentials to recover — skipping URL');
2037
2518
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2038
2519
  jobId: opts.jobId,
@@ -2047,6 +2528,8 @@ export async function crawlProject(opts: {
2047
2528
  if (!redirectedToError && landedUrl !== url) {
2048
2529
  logger.info({ requested: url, landed: landedUrl }, '[crawler] navigated with redirect');
2049
2530
  }
2531
+ // Successful non-login page visit: reset the consecutive login-wall counter
2532
+ if (!redirectedToError) loginWallRedirectCount = 0;
2050
2533
 
2051
2534
  // Discovery mode fast-path: record URL + extract DOM links only; skip screenshots/DB/AI.
2052
2535
  // IMPORTANT: DOM hash dedup is intentionally DISABLED in discovery mode — SPAs use identical
@@ -2194,8 +2677,10 @@ export async function crawlProject(opts: {
2194
2677
  }
2195
2678
 
2196
2679
  // Normal crawl mode: DOM hash dedup — detect duplicate-structure pages (SPA 404s, template clones).
2680
+ // Skip dedup when hash is empty (computeDomHash failed) — two failed-hash pages would
2681
+ // incorrectly match each other and the second would be silently dropped.
2197
2682
  const preDomHash = await computeDomHash(page);
2198
- if (seenDomHashes.has(preDomHash)) {
2683
+ if (preDomHash !== '' && seenDomHashes.has(preDomHash)) {
2199
2684
  console.log(`[crawler] skip ${url} — duplicate DOM structure (same content as prior page)`);
2200
2685
  continue;
2201
2686
  }
@@ -2467,31 +2952,33 @@ export async function crawlProject(opts: {
2467
2952
  logger.info({ url, cachedCount: cachedLinks.length }, '[crawler] link-discovery: using cached recipe — AI will still run and merge');
2468
2953
  }
2469
2954
 
2470
- // Emit progress now — navigation is done and the page is stable.
2471
- // This fires before the 30-90s parallel screenshot+AI phase, preventing a silent gap in the UI.
2472
- // Phase 1 (link discovery): 5–20% over first 60s of wall-clock time so users see movement
2473
- // before visited.size grows enough to drive the 20–80% phase.
2474
- const elapsedDiscoverySec = (Date.now() - crawlLoopStart) / 1000;
2475
- const discoveryPct = Math.round(5 + Math.min(15, (elapsedDiscoverySec / 60) * 15));
2476
- const preCapturePct = Math.max(lastEmittedPct, discoveryPct);
2477
- lastEmittedPct = preCapturePct;
2478
- await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} · ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2479
-
2480
- // P3a: Async content detection — get raw DOM element count before AI extraction.
2481
- // The AI extraction takes 30-90s; any async-loaded content will be visible after it completes.
2482
- // Compare pre vs post count to detect dashboards that populate after initial load.
2955
+ // Emit progress now — navigation done, page stable.
2956
+ // Use the same visited-count formula as the post-capture block so progress
2957
+ // advances on EVERY URL visit (not just on successful screen saves).
2958
+ {
2959
+ const effMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
2960
+ maxSeenTotal = Math.max(maxSeenTotal, effMax);
2961
+ const rawPrePct = 20 + Math.round(Math.min(57, (visited.size / Math.max(1, maxSeenTotal)) * 57));
2962
+ const preCapturePct = Math.max(lastEmittedPct, rawPrePct);
2963
+ lastEmittedPct = preCapturePct;
2964
+ await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} · ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2965
+ }
2966
+
2967
+ await waitForScreenshotReady(page, rawPage ?? undefined);
2968
+
2969
+ // P3a: Async content detection — count AFTER networkidle+lazy-load so lazy-fetched content
2970
+ // is included. Previously captured before waitForScreenshotReady which caused AI skip logic
2971
+ // to see skeleton element counts instead of real populated counts.
2483
2972
  const preDomCount = await page.evaluate(() =>
2484
2973
  document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2485
2974
  ).catch(() => 0);
2486
-
2487
- await waitForScreenshotReady(page);
2488
2975
  await onProgress?.(lastEmittedPct, `Capturing ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2489
2976
 
2490
2977
  // FM-4: capture device pixel ratio for coordinate scaling in element highlighting
2491
- const pageDpr: number = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
2978
+ const pageDpr: number = await (rawPage ?? page).evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
2492
2979
 
2493
2980
  // Extract elements first so we can highlight them in the screenshot
2494
- const elements = await extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
2981
+ const elements = await extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch((err: any) => {
2495
2982
  console.error('[crawler] extractElements failed:', err);
2496
2983
  return [] as Awaited<ReturnType<typeof extractElements>>;
2497
2984
  });
@@ -2550,8 +3037,8 @@ export async function crawlProject(opts: {
2550
3037
  .catch((err: any) => console.error('[crawler] page.screenshot failed:', err)),
2551
3038
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2552
3039
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2553
- // Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
2554
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < aiMinElements)
3040
+ // Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
3041
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined)
2555
3042
  .then((freshLinks) => {
2556
3043
  if (aiSkipCount > 0) aiSkipCount--;
2557
3044
  aiErrorStreak = 0;
@@ -2582,7 +3069,7 @@ export async function crawlProject(opts: {
2582
3069
  }
2583
3070
  return fallback;
2584
3071
  }),
2585
- readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
3072
+ readScreen(rawPage ?? page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
2586
3073
  ]);
2587
3074
 
2588
3075
  // Cleanup highlights
@@ -2601,6 +3088,115 @@ export async function crawlProject(opts: {
2601
3088
  }
2602
3089
  }).catch(() => { });
2603
3090
 
3091
+ // Per-screen probes: split read-only (awaited, safe on main page) vs mutating (isolated page).
3092
+ // READ-ONLY probes are awaited before BFS navigates — DOM must reflect current screen.
3093
+ // MUTATING probes (dropdowns/CRUD/filters/advanced) open a fresh page to isolate state changes.
3094
+ {
3095
+ // Resolve browser context from rawPage (actual Playwright page, not Stagehand proxy).
3096
+ const safeContext: import('playwright').BrowserContext | null =
3097
+ typeof (rawPage as any)?.context === 'function'
3098
+ ? (rawPage as any).context()
3099
+ : typeof (page as any)?.context === 'function'
3100
+ ? (page as any).context()
3101
+ : playwrightBrowser?.contexts()[0] ?? null;
3102
+
3103
+ // --- Read-only probes: awaited with 25s timeout, never mutate page state ---
3104
+ const roTimeout = new Promise<PromiseSettledResult<never>[]>((resolve) =>
3105
+ setTimeout(() => resolve([
3106
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3107
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3108
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3109
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3110
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3111
+ ]), 25_000)
3112
+ );
3113
+ const [nestedCtxRes, shadowRes, a11yRes, perfRes, swRes] = await Promise.race([
3114
+ Promise.allSettled([
3115
+ crawlNestedContexts(rawPage ?? page, safeContext, url),
3116
+ crawlShadowDom(rawPage ?? page),
3117
+ crawlAccessibilityTree(rawPage ?? page),
3118
+ collectPerfMetrics(rawPage ?? page, url),
3119
+ inspectServiceWorker(rawPage ?? page),
3120
+ ]),
3121
+ roTimeout,
3122
+ ]);
3123
+
3124
+ const roPayload: Record<string, unknown> = { screenId: screen.id, url };
3125
+ if (nestedCtxRes.status === 'fulfilled' && nestedCtxRes.value && (nestedCtxRes.value as any).totalContextsFound > 0)
3126
+ roPayload.nestedContexts = nestedCtxRes.value;
3127
+ if (shadowRes.status === 'fulfilled' && shadowRes.value && (shadowRes.value as any).shadowHostCount > 0)
3128
+ roPayload.shadowDom = shadowRes.value;
3129
+ if (a11yRes.status === 'fulfilled' && a11yRes.value)
3130
+ roPayload.accessibilityTree = a11yRes.value;
3131
+ if (perfRes.status === 'fulfilled' && (perfRes.value as any).requestCount > 0)
3132
+ roPayload.performance = perfRes.value;
3133
+ if (swRes.status === 'fulfilled' && (swRes.value as any).supported)
3134
+ roPayload.serviceWorker = swRes.value;
3135
+ if (Object.keys(roPayload).length > 2) {
3136
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3137
+ jobId: opts.jobId, type: 'PER_SCREEN_PROBES', payload: roPayload,
3138
+ }).catch(() => {});
3139
+ }
3140
+
3141
+ // --- Mutating probes (interactive): isolated page, fire-and-forget, only when interactive ---
3142
+ if (screen?.id && opts.interactive !== false && safeContext) {
3143
+ void (async () => {
3144
+ let probePage: import('playwright').Page | null = null;
3145
+ try {
3146
+ // Cap concurrent probe pages: if too many already open, skip to prevent resource exhaustion.
3147
+ const openProbePages = safeContext.pages().length;
3148
+ if (openProbePages > 8) {
3149
+ logger.warn({ url, openProbePages }, '[crawler] probe page cap reached — skipping mutating probes for this screen');
3150
+ return;
3151
+ }
3152
+ probePage = await safeContext.newPage();
3153
+ await probePage.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
3154
+ await probePage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
3155
+ await waitForStableDOM(probePage as any, 4_000, 400).catch(() => {});
3156
+
3157
+ const muTimeout = new Promise<PromiseSettledResult<never>[]>((resolve) =>
3158
+ setTimeout(() => resolve([
3159
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3160
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3161
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3162
+ { status: 'rejected' as const, reason: 'probe-timeout' },
3163
+ ]), 60_000)
3164
+ );
3165
+ const [dropEnumRes, crudRes, filterRes, advancedRes] = await Promise.race([
3166
+ Promise.allSettled([
3167
+ enumerateDropdowns(probePage),
3168
+ probeCrudFlows(probePage, name),
3169
+ crawlFiltersAndPagination(probePage),
3170
+ probeAdvancedUI(probePage),
3171
+ ]),
3172
+ muTimeout,
3173
+ ]);
3174
+
3175
+ const muPayload: Record<string, unknown> = { screenId: screen.id, url };
3176
+ if (dropEnumRes.status === 'fulfilled' && (dropEnumRes.value as any).totalOptionsFound > 0)
3177
+ muPayload.dropdowns = dropEnumRes.value;
3178
+ if (crudRes.status === 'fulfilled' && ((crudRes.value as any).flows?.length > 0 || (crudRes.value as any).toastsObserved?.length > 0))
3179
+ muPayload.crudFlows = crudRes.value;
3180
+ if (filterRes.status === 'fulfilled' && (filterRes.value as any).filters?.length > 0)
3181
+ muPayload.filtersAndPagination = filterRes.value;
3182
+ if (advancedRes.status === 'fulfilled' && advancedRes.value &&
3183
+ ((advancedRes.value as any).datePickers?.length > 0 || (advancedRes.value as any).sliders?.length > 0 ||
3184
+ (advancedRes.value as any).typeaheads?.length > 0 || (advancedRes.value as any).dragDrop?.library !== 'none'))
3185
+ muPayload.advancedUI = advancedRes.value;
3186
+ if (Object.keys(muPayload).length > 2) {
3187
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3188
+ jobId: opts.jobId, type: 'PER_SCREEN_PROBES_MUTATING', payload: muPayload,
3189
+ }).catch(() => {});
3190
+ }
3191
+ } catch {
3192
+ // non-fatal — probe failures must not block BFS
3193
+ } finally {
3194
+ await probePage?.close().catch(() => {});
3195
+ }
3196
+ })();
3197
+ }
3198
+ }
3199
+
2604
3200
  // Capture DOM snapshot for structural analysis, and extract Markdown/text
2605
3201
  // content from the same DOM (crawl4AI-parity text output) — no extra fetch.
2606
3202
  try {
@@ -2876,6 +3472,34 @@ export async function crawlProject(opts: {
2876
3472
  }
2877
3473
 
2878
3474
  const linkMappings = await extractLinkMappings(page, origin);
3475
+
3476
+ // Auth-wall guard: check if the page drifted to a login/error URL during the async
3477
+ // element extraction window (30-90s). If so, the `elements` array contains login-page
3478
+ // elements — saving them would corrupt the screen's element catalog.
3479
+ const postExtractUrl = (() => { try { return page.url(); } catch { return url; } })();
3480
+ const postExtractParsed = (() => { try { return new URL(postExtractUrl); } catch { return new URL(url); } })();
3481
+ const driftedToAuthWall = !weWantedThisPage && (
3482
+ BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname)
3483
+ );
3484
+ if (driftedToAuthWall) {
3485
+ logger.warn({ url, postExtractUrl }, '[crawler] Page drifted to auth wall during element extraction — skipping element save to prevent login-page element contamination');
3486
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3487
+ screenId: screen.id,
3488
+ jobId: opts.jobId,
3489
+ type: 'AUTH_FAILURE',
3490
+ payload: { url, landed: postExtractUrl, reason: 'Session expired during element extraction. Re-capture session and re-crawl this page.' },
3491
+ }).catch(() => {});
3492
+ } else {
3493
+ if (elements.length === 0) {
3494
+ // Extraction returned nothing — keep existing elements rather than wiping them.
3495
+ // Causes: page.evaluate failure, contentFallbackElements threw, SPA not yet hydrated.
3496
+ logger.warn({ url, screenId: screen.id }, '[crawler] extractElements returned 0 — preserving existing elements to avoid data loss');
3497
+ // Queue for re-visit with longer wait — SPA content may not have hydrated yet
3498
+ if (!revisitQueue.includes(url)) {
3499
+ revisitQueue.push(url);
3500
+ logger.info({ url }, '[crawler] 0-element page queued for re-visit with extended hydration wait');
3501
+ }
3502
+ } else {
2879
3503
  // Clear existing elements before re-saving — prevents duplicates on re-crawl
2880
3504
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
2881
3505
  // Batch element saves: 4 concurrent DB writes instead of serial round-trips
@@ -2895,6 +3519,7 @@ export async function crawlProject(opts: {
2895
3519
  AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, (saved as any).id, selectors).catch(console.warn);
2896
3520
  }));
2897
3521
  }
3522
+ } // end inner else: elements.length > 0
2898
3523
  for (const lm of linkMappings) {
2899
3524
  const safety = assessActionSafety(lm.label, domainProfile);
2900
3525
  if (!safety.allowed) {
@@ -3056,6 +3681,7 @@ export async function crawlProject(opts: {
3056
3681
  } catch { /* non-fatal — Skyvern unavailable */ }
3057
3682
  }
3058
3683
  }
3684
+ } // end auth-wall guard else
3059
3685
 
3060
3686
  if (rawPerf) {
3061
3687
  performanceMetrics.push({ url, screenName: name, ...rawPerf });
@@ -3169,16 +3795,16 @@ export async function crawlProject(opts: {
3169
3795
  if (visited.size >= maxScreens) break;
3170
3796
  try {
3171
3797
  await page.goto(skyvernUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
3172
- await page.waitForTimeout(1500);
3798
+ await new Promise<void>(r => setTimeout(r, 1500));
3173
3799
  const skyvernName = new URL(skyvernUrl).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
3174
3800
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, 'SKYVERN_AI');
3175
3801
  const shotPath = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
3176
- await waitForScreenshotReady(page);
3802
+ await waitForScreenshotReady(page, rawPage ?? undefined);
3177
3803
  await page.screenshot({ path: shotPath, fullPage: true });
3178
3804
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP');
3179
3805
  const [elems, links, linkMappings] = await Promise.all([
3180
- extractElements(stagehand, page, elementLLM ?? undefined),
3181
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0),
3806
+ extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
3807
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined),
3182
3808
  extractLinkMappings(page, origin),
3183
3809
  ]);
3184
3810
  for (const el of elems) {
@@ -3209,15 +3835,15 @@ export async function crawlProject(opts: {
3209
3835
  if (!isUsefulNavigationUrl(href, origin)) continue;
3210
3836
  try {
3211
3837
  await page.goto(href, { waitUntil: 'domcontentloaded', timeout: 20000 });
3212
- await page.waitForTimeout(1000);
3838
+ await new Promise<void>(r => setTimeout(r, 1000));
3213
3839
  const nm = new URL(href).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
3214
3840
  const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, 'SKYVERN_AI');
3215
3841
  const sp = `${opts.screenshotDir ?? '/tmp'}/${sc.id}-v${sc.version}.png`;
3216
- await waitForScreenshotReady(page);
3842
+ await waitForScreenshotReady(page, rawPage ?? undefined);
3217
3843
  await page.screenshot({ path: sp, fullPage: true });
3218
3844
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, 'DESKTOP');
3219
3845
  const [elems2, linkMappings2] = await Promise.all([
3220
- extractElements(stagehand, page, elementLLM ?? undefined),
3846
+ extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
3221
3847
  extractLinkMappings(page, origin),
3222
3848
  ]);
3223
3849
  for (const el of elems2) {
@@ -3265,7 +3891,9 @@ export async function crawlProject(opts: {
3265
3891
  );
3266
3892
  }
3267
3893
  } catch (err) {
3268
- logger.warn({ err: String(err) }, '[P1] URL pattern completion failed — non-fatal, continuing');
3894
+ const msg = String(err);
3895
+ logger.warn({ err: msg }, '[P1] URL pattern completion failed — non-fatal, continuing');
3896
+ crawlWarnings.push(`url-completer skipped: ${msg.includes('401') || msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('authentication') ? 'LLM API key invalid or missing' : msg}`);
3269
3897
  }
3270
3898
 
3271
3899
  // P3a: Async content re-visit — revisit pages where DOM grew significantly during extraction.
@@ -3274,23 +3902,52 @@ export async function crawlProject(opts: {
3274
3902
  await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content…`);
3275
3903
  logger.info({ count: revisitQueue.length }, '[P3a] Starting async content re-visit pass');
3276
3904
  for (const revisitUrl of revisitQueue.slice(0, 10)) { // cap at 10 re-visits
3277
- const existingScreenId = visited.get(canonicalize(revisitUrl));
3278
- if (!existingScreenId) continue;
3905
+ const rawScreenId = visited.get(canonicalize(revisitUrl));
3906
+ // Auth-wall pages are stored as '_auth_wall_' — not a real screen ID.
3907
+ // Navigate with the authenticated main page; it will redirect to the real content.
3908
+ const isAuthWallRevisit = rawScreenId === '_auth_wall_';
3909
+ if (!rawScreenId) continue;
3279
3910
  try {
3280
3911
  await page.goto(revisitUrl, { waitUntil: 'networkidle', timeout: 25_000 });
3281
- await page.waitForTimeout(2_000);
3912
+ await new Promise<void>(r => setTimeout(r, 2_000));
3913
+ await waitForScreenshotReady(page, rawPage ?? undefined);
3914
+
3915
+ // After navigation, check where we actually landed (auth may redirect)
3916
+ const landedUrl = (rawPage ?? page).url?.() ?? revisitUrl;
3917
+ const landedCanon = canonicalize(landedUrl);
3918
+ let existingScreenId = isAuthWallRevisit
3919
+ ? (visited.get(landedCanon) ?? null) // use screen at redirected URL if known
3920
+ : rawScreenId;
3921
+
3922
+ // If auth-wall redirect landed on an unvisited page, save it as a new screen
3923
+ if (isAuthWallRevisit && (!existingScreenId || existingScreenId.startsWith('_')) && !visited.has(landedCanon)) {
3924
+ const pathParts = new URL(landedUrl).pathname.split('/').filter(Boolean);
3925
+ const screenName = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
3926
+ const newScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, landedUrl, opts.jobId, 'LINK_FOLLOW').catch(() => null);
3927
+ if (newScreen) {
3928
+ visited.set(landedCanon, newScreen.id);
3929
+ existingScreenId = newScreen.id;
3930
+ }
3931
+ }
3932
+
3933
+ if (!existingScreenId || existingScreenId.startsWith('_')) {
3934
+ logger.warn({ url: revisitUrl, landed: landedUrl }, '[P3a] Re-visit skipped — could not resolve screen ID');
3935
+ continue;
3936
+ }
3937
+
3282
3938
  const shotPath2 = `${opts.screenshotDir ?? '/tmp'}/${existingScreenId}-revisit.png`;
3283
- await waitForScreenshotReady(page);
3284
3939
  await page.screenshot({ path: shotPath2, fullPage: true });
3285
- // Update screenshot with the more complete render
3286
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP').catch(() => { });
3940
+ await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP', undefined).catch(() => {});
3287
3941
  // Re-extract elements with the async-loaded content now visible
3288
- const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []);
3289
- for (const el of revisitElements) {
3290
- await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
3942
+ const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []);
3943
+ if (revisitElements.length > 0) {
3944
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {});
3945
+ for (const el of revisitElements) {
3946
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
3947
+ }
3291
3948
  }
3292
3949
  // Harvest any new links revealed by async content
3293
- const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []);
3950
+ const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []);
3294
3951
  for (const href of revisitLinks) {
3295
3952
  const canon = canonicalize(href);
3296
3953
  if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
@@ -3298,13 +3955,66 @@ export async function crawlProject(opts: {
3298
3955
  queue.push(href);
3299
3956
  }
3300
3957
  }
3301
- logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, '[P3a] Re-visit complete');
3958
+ logger.info({ url: revisitUrl, landed: landedUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, '[P3a] Re-visit complete');
3302
3959
  } catch (err) {
3303
3960
  logger.warn({ url: revisitUrl, err: String(err) }, '[P3a] Re-visit failed — non-fatal');
3304
3961
  }
3305
3962
  }
3306
3963
  }
3307
3964
 
3965
+ // P3b: Extended BFS — process any links discovered during P3a revisit pass.
3966
+ // Without this, auth-wall pages redirect to real app pages, AI discovers new links,
3967
+ // but those links are pushed to queue and NEVER processed (main BFS already exited).
3968
+ if (queue.length > 0 && visited.size < maxScreens) {
3969
+ const p3bCount = Math.min(queue.length, maxScreens - visited.size);
3970
+ logger.info({ newLinks: p3bCount }, '[P3b] Extended BFS — processing links discovered during revisit pass');
3971
+ await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)…`);
3972
+ while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
3973
+ const href = queue.shift();
3974
+ if (!href) break;
3975
+ const canonHref = canonicalize(href);
3976
+ if (visited.has(canonHref)) continue;
3977
+ if (!isUsefulNavigationUrl(href, origin)) continue;
3978
+ visited.set(canonHref, '_pending_');
3979
+ try {
3980
+ await page.goto(href, { waitUntil: 'networkidle', timeout: 25_000 }).catch(() => {});
3981
+ await waitForScreenshotReady(page, rawPage ?? undefined);
3982
+ const pathParts = new URL(href).pathname.split('/').filter(Boolean);
3983
+ const nm = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
3984
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, 'LINK_FOLLOW');
3985
+ visited.set(canonHref, screen.id);
3986
+ const sp = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
3987
+ await page.screenshot({ path: sp, fullPage: true }).catch(() => {});
3988
+ const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => '');
3989
+ await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, sp, screen.version, 'DESKTOP', cloudUrl3).catch(() => {});
3990
+ const [p3bElems, p3bLinks] = await Promise.all([
3991
+ extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
3992
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined).catch(() => []),
3993
+ ]);
3994
+ if (p3bElems.length > 0) {
3995
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
3996
+ for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
3997
+ const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
3998
+ await Promise.all(_b3.map((el: any) =>
3999
+ AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {})
4000
+ ));
4001
+ }
4002
+ }
4003
+ for (const link of p3bLinks) {
4004
+ const c = canonicalize(link);
4005
+ if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
4006
+ queued.add(c); queue.push(link);
4007
+ }
4008
+ }
4009
+ discovered.push({ url: href, screenId: screen.id, elements: p3bElems.length, version: screen.version });
4010
+ logger.info({ url: href, elements: p3bElems.length, newLinks: p3bLinks.length }, '[P3b] Extended BFS screen captured');
4011
+ } catch (err) {
4012
+ logger.warn({ url: href, err: String(err) }, '[P3b] Extended BFS URL failed');
4013
+ visited.set(canonHref, '_error_');
4014
+ }
4015
+ }
4016
+ }
4017
+
3308
4018
  // P6: Gap Pass — LLM gap analysis on discovered screens to find structurally missing pages.
3309
4019
  // Runs after P3a re-visits so the gap analysis has the most complete screen list possible.
3310
4020
  // Confirmed gap URLs (HEAD=200) are visited directly with the existing browser.
@@ -3344,16 +4054,16 @@ export async function crawlProject(opts: {
3344
4054
  logger.warn({ gapUrl, landed: page.url() }, '[P6] Gap URL redirected to login — skipping');
3345
4055
  continue;
3346
4056
  }
3347
- await page.waitForTimeout(1_500);
4057
+ await new Promise<void>(r => setTimeout(r, 1_500));
3348
4058
  const gapName = gap.category;
3349
4059
  const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, 'ENTRY_POINT');
3350
4060
  const gapShot = `${opts.screenshotDir ?? '/tmp'}/${gapScreen.id}-v${gapScreen.version}.png`;
3351
- await waitForScreenshotReady(page);
4061
+ await waitForScreenshotReady(page, rawPage ?? undefined);
3352
4062
  await page.screenshot({ path: gapShot, fullPage: true });
3353
4063
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, 'DESKTOP');
3354
4064
  const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
3355
- extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []),
3356
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []),
4065
+ extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
4066
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []),
3357
4067
  extractLinkMappings(page, origin).catch(() => []),
3358
4068
  ]);
3359
4069
  for (const el of gapElems) {
@@ -3380,14 +4090,23 @@ export async function crawlProject(opts: {
3380
4090
  logger.info({ total: gapCandidates.length }, '[P6] Gap pass: no confirmed gaps found');
3381
4091
  }
3382
4092
  } catch (err) {
3383
- logger.warn({ err: String(err) }, '[P6] Gap pass failed — non-fatal, continuing');
4093
+ const msg = String(err);
4094
+ logger.warn({ err: msg }, '[P6] Gap pass failed — non-fatal, continuing');
4095
+ crawlWarnings.push(`gap-analysis skipped: ${msg.includes('401') || msg.toLowerCase().includes('api key') || msg.toLowerCase().includes('authentication') ? 'LLM API key invalid or missing' : msg}`);
3384
4096
  }
3385
4097
  }
3386
4098
 
3387
4099
  // After crawl: mark screens not re-discovered as PENDING_REMOVAL (non-destructive diff)
3388
4100
  // Only run if crawl actually found screens — 0 discovered means failed/aborted run, not evidence of removal
3389
4101
  if (!selective && visited.size > 0) {
3390
- const discoveredUrls = Array.from(visited.keys());
4102
+ // Only pass canonical URLs of screens that were actually visited (saveScreen called).
4103
+ // Exclude marker strings (_skipped_api_, _pending_, _soft404_, pre-auth, skipped)
4104
+ // so that skipped API/export paths don't pollute the discoveredSet and prevent removal
4105
+ // of old API-path screens from previous crawls.
4106
+ const VISIT_MARKER_RE = /^_|^pre-auth$|^skipped$/;
4107
+ const discoveredUrls = Array.from(visited.entries())
4108
+ .filter(([, v]) => typeof v === 'string' && !VISIT_MARKER_RE.test(v))
4109
+ .map(([k]) => k);
3391
4110
  await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
3392
4111
  }
3393
4112
 
@@ -3579,6 +4298,7 @@ export async function crawlProject(opts: {
3579
4298
  ignoreHTTPSErrors: true,
3580
4299
  ...(mainStorageState ? { storageState: mainStorageState } : {}),
3581
4300
  });
4301
+ mobileCtx.on('download', (dl: any) => { dl.cancel().catch(() => {}); });
3582
4302
  const mobilePage = await mobileCtx.newPage();
3583
4303
 
3584
4304
  for (const disc of discovered) {
@@ -3588,12 +4308,15 @@ export async function crawlProject(opts: {
3588
4308
  await mobilePage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
3589
4309
  await dismissOverlays(mobilePage);
3590
4310
  try { await mobilePage.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' }); } catch { /* ignore */ }
4311
+ // mobilePage is a real Playwright page — pass it as rawPage so CDP wheel events
4312
+ // trigger virtual scrollers (react-window / TanStack Virtual) at mobile viewports.
4313
+ await infiniteScrollToBottom(mobilePage, { maxScrolls: 20, scrollDelay: 300, stabilizeMs: 400 }, mobilePage).catch(() => { });
3591
4314
  const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
3592
- await waitForScreenshotReady(mobilePage);
4315
+ await waitForScreenshotReady(mobilePage, mobilePage);
3593
4316
  // Screenshot and DOM element extraction run in parallel
3594
4317
  const [, vpElements] = await Promise.all([
3595
4318
  mobilePage.screenshot({ path: shotPath, fullPage: true }),
3596
- domFallbackElements(mobilePage),
4319
+ domFallbackElements(mobilePage, mobilePage),
3597
4320
  ]);
3598
4321
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
3599
4322
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version!, profile, cloudUrl);
@@ -3638,6 +4361,26 @@ export async function crawlProject(opts: {
3638
4361
  await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
3639
4362
  }
3640
4363
 
4364
+ // Step 7: End-of-crawl state graph coverage report.
4365
+ // Per-screen probes (dropdown, CRUD, filter, DnD, nested-contexts, shadow-DOM,
4366
+ // accessibility-tree, performance, service-worker) run per-screen inside the BFS
4367
+ // loop above and are saved as PER_SCREEN_PROBES evidence.
4368
+ try {
4369
+ await onProgress?.(90, 'Generating state graph coverage report');
4370
+ const stateGraph = new ScreenTransitionGraph();
4371
+ const coveragePayload: Record<string, unknown> = {
4372
+ stateGraph: stateGraph.getCoverageReport(),
4373
+ totalScreensCrawled: discovered.filter(d => !!d.screenId).length,
4374
+ };
4375
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
4376
+ jobId: opts.jobId,
4377
+ type: 'CRAWL_COVERAGE_REPORT',
4378
+ payload: coveragePayload,
4379
+ }).catch(() => { });
4380
+ } catch (extErr) {
4381
+ logger.warn({ err: String(extErr) }, '[coverage-report] non-fatal error');
4382
+ }
4383
+
3641
4384
  await onProgress?.(97, 'Running post-crawl analysis');
3642
4385
 
3643
4386
  // ── Per-crawl summaries (fire-and-forget, non-blocking) ────────────────
@@ -3714,6 +4457,17 @@ export async function crawlProject(opts: {
3714
4457
  const screensSucceeded = discovered.filter(d => !!d.screenId).length;
3715
4458
  const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
3716
4459
 
4460
+ // Detect systematic element/link extraction failure across all screens
4461
+ if (screensSucceeded >= 3) {
4462
+ const screensWithElements = discovered.filter(d => (d.elements ?? 0) > 0 && d.screenId).length;
4463
+ if (screensWithElements === 0) {
4464
+ crawlWarnings.push(
4465
+ `element-extraction: 0 elements found across all ${screensSucceeded} screens — ` +
4466
+ `DOM evaluate may be failing (check server logs for "page.evaluate threw" warnings)`,
4467
+ );
4468
+ }
4469
+ }
4470
+
3717
4471
  return {
3718
4472
  screensFound: screensSucceeded, // successful captures only (not errors)
3719
4473
  screensErrored, // pages visited but failed to capture
@@ -3726,6 +4480,7 @@ export async function crawlProject(opts: {
3726
4480
  segmentIndex: segIdx, // which segment this was
3727
4481
  hasMoreSegments,
3728
4482
  domHashes: Array.from(seenDomHashes), // carry forward to next segment to avoid re-screenshotting
4483
+ crawlWarnings: crawlWarnings.length > 0 ? crawlWarnings : undefined,
3729
4484
  };
3730
4485
  } finally {
3731
4486
  // Log accumulated Stagehand LLM usage (extract/act/observe calls) to cost ledger.
@@ -3760,11 +4515,13 @@ export async function crawlProject(opts: {
3760
4515
  // Elements whose meanings indicate UI state toggles or noise — filtered from both DOM and AI results.
3761
4516
  const ELEMENT_NOISE = /^(cookie|consent|privacy|accept|decline|dismiss|close|copy|©|v\d|switch to (dark|light)|dark mode|light mode|toggle (dark|light|theme)|theme (toggle|switch)|share|tweet|follow|subscribe to newsletter|skip to|back to top|\s*$)/i;
3762
4517
 
3763
- async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
4518
+ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, rawPage?: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
3764
4519
  // llmCfg reserved for per-task model selection (future: create task-scoped Stagehand)
3765
4520
  // DOM-first: fast, no LLM cost, covers server-rendered and most SPAs.
3766
4521
  // Threshold >= 2 so a lone dark-mode button doesn't suppress AI fallback for rich pages.
3767
- const domElements = await domFallbackElements(page).catch((err: any) => {
4522
+ // rawPage: real Playwright CDP page used for page.evaluate() — avoids Stagehand's proxy
4523
+ // re-bundling the function with esbuild (which injects __name, breaking browser eval).
4524
+ const domElements = await domFallbackElements(page, rawPage).catch((err: any) => {
3768
4525
  console.warn('[crawler] domFallbackElements failed (page context issue or DOM error):', err?.message ?? err);
3769
4526
  return [] as Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>;
3770
4527
  });
@@ -3811,7 +4568,10 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
3811
4568
  // Always capture content elements (headings, meta, cards, nav, FAQ) regardless of interactive count.
3812
4569
  // Previously this was fallback-only; now both paths run together so static sections are
3813
4570
  // always visible to the test generator even on pages with forms.
3814
- const contentElements = await contentFallbackElements(page);
4571
+ const contentElements = await contentFallbackElements(page).catch((err: any) => {
4572
+ console.warn('[crawler] contentFallbackElements failed (page context issue):', err?.message ?? err);
4573
+ return [] as Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number> }>;
4574
+ });
3815
4575
 
3816
4576
  // Cross-source text deduplication: strips wrapper phrases like "Button to expand '...' details"
3817
4577
  // and "... FAQ button" so duplicate FAQ buttons from DOM + content sources are collapsed.
@@ -3867,14 +4627,14 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
3867
4627
  }
3868
4628
  }
3869
4629
 
3870
- async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false): Promise<string[]> {
4630
+ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false, rawPage?: any): Promise<string[]> {
3871
4631
  // Expand collapsed nav menus (hamburger, aria-haspopup, Radix closed triggers) so hidden routes
3872
4632
  // become part of the DOM before querying. This catches SPA nav dropdowns that only render on click.
3873
4633
  const navExpanded = await expandNavMenus(page);
3874
4634
 
3875
4635
  // DOM-first: <a href> links — standard apps. AI supplements when DOM coverage is < 20 unique links.
3876
4636
  // Threshold of 20 (not 8) because nav sidebars quickly provide 8+ links while missing deep content URLs.
3877
- const domLinks = await domFallbackLinks(page, origin);
4637
+ const domLinks = await domFallbackLinks(page, origin, rawPage);
3878
4638
 
3879
4639
  // Interact with tabs, carousels, and accordions to reveal hidden content and discover more links.
3880
4640
  const interactiveLinks = await interactAndCollectLinks(page, origin);
@@ -3929,8 +4689,9 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
3929
4689
  }
3930
4690
  }
3931
4691
 
3932
- export async function domFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
3933
- return page.evaluate(() => {
4692
+ export async function domFallbackElements(page: any, rawPage?: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
4693
+ // Named function so we can .toString() it for CDP Runtime.evaluate fallback
4694
+ function _browserEval() {
3934
4695
  const out: { meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }[] = [];
3935
4696
  const seen = new Set<string>();
3936
4697
  // UI chrome noise: cookie banners, theme toggles, version strings, social/share buttons
@@ -3949,8 +4710,8 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
3949
4710
  return results;
3950
4711
  };
3951
4712
 
3952
- // Expanded selector — all interactive ARIA roles + native form elements + rich editors
3953
- const selector = 'input:not([type=hidden]),select,textarea,button,[role="button"],[role="checkbox"],[role="radio"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],[role="gridcell"],[role="link"],a[role="button"],[contenteditable="true"]';
4713
+ // Expanded selector — all interactive ARIA roles + native form elements + rich editors + bare nav links
4714
+ const selector = 'input:not([type=hidden]),select,textarea,button,a[href],[role="button"],[role="checkbox"],[role="radio"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],[role="gridcell"],[role="link"],[contenteditable="true"]';
3954
4715
 
3955
4716
  querySelectorAllDeep(document, selector).forEach((el: any) => {
3956
4717
  const style = window.getComputedStyle(el);
@@ -3969,14 +4730,13 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
3969
4730
  const ariaRole = el.getAttribute('role');
3970
4731
  const isActionEl = tag === 'button' || tag === 'a' || ariaRole === 'button';
3971
4732
 
3972
- // Skip plain anchor links inside <header> or <footer> — navigation chrome with no test value.
4733
+ // Skip plain anchor links inside <footer> only — footer chrome (sitemap, legal links) has no test value.
4734
+ // Header nav links ARE test targets (SPA primary navigation lives in <header>) — do NOT skip.
3973
4735
  // Do NOT skip <nav> elements: SPA dashboard sidebars live in <nav> and are primary test targets.
3974
- // Buttons inside nav (e.g. collapse, settings) are kept; only <a> links are skipped.
3975
- if (tag === 'a' || (ariaRole === 'button' && tag !== 'button')) {
4736
+ if (tag === 'a') {
3976
4737
  let p = el.parentElement;
3977
4738
  while (p) {
3978
- const ptag = p.tagName?.toLowerCase();
3979
- if (['header', 'footer'].includes(ptag)) return;
4739
+ if (p.tagName?.toLowerCase() === 'footer') return;
3980
4740
  p = p.parentElement;
3981
4741
  }
3982
4742
  }
@@ -4157,7 +4917,64 @@ export async function domFallbackElements(page: any): Promise<Array<{ meaning: s
4157
4917
  ['email', 'password', 'text', 'tel', 'date', 'number', 'select', 'textarea', 'url', 'file'].includes(e.role) ? 0 : 1;
4158
4918
  out.sort((a, b) => priority(a) - priority(b));
4159
4919
  return out; // Cap removed
4160
- }).then((elems: any[]) => elems.map((e: any) => ({ ...e, isInteractive: true }))).catch(() => []);
4920
+ }
4921
+ // CDP Runtime.evaluate bypasses Playwright's isolated world entirely.
4922
+ // The "__name is not defined" error comes from Playwright's own UtilityScript being absent
4923
+ // from the isolated world (destroyed by navigation events in Stagehand's CDP session).
4924
+ // Solution: when rawPage (a real Playwright Page) is available, use CDP Runtime.evaluate
4925
+ // as the PRIMARY path — no isolated world needed, no __name dependency.
4926
+ //
4927
+ // esbuild injects __name() calls for named arrow-function constants inside _browserEval
4928
+ // (e.g. querySelectorAllDeep). When _browserEval.toString() is sent to the browser,
4929
+ // the browser throws "ReferenceError: __name is not defined". Fix: prepend a tiny
4930
+ // __name polyfill that just returns the function (name-setting is non-critical).
4931
+ const _browserEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_browserEval.toString()})()})();`;
4932
+
4933
+ if (rawPage && typeof rawPage.context === 'function') {
4934
+ try {
4935
+ const ctx = rawPage.context();
4936
+ if (typeof ctx?.newCDPSession === 'function') {
4937
+ const cdp = await ctx.newCDPSession(rawPage);
4938
+ try {
4939
+ const evalResult = await cdp.send('Runtime.evaluate', {
4940
+ expression: _browserEvalStr,
4941
+ returnByValue: true,
4942
+ awaitPromise: false,
4943
+ });
4944
+ if (evalResult?.exceptionDetails) {
4945
+ console.warn('[crawler] domFallbackElements CDP primary error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
4946
+ } else if (Array.isArray(evalResult?.result?.value)) {
4947
+ return (evalResult.result.value as any[]).map((e: any) => ({ ...e, isInteractive: true }));
4948
+ }
4949
+ } finally {
4950
+ cdp.detach().catch(() => {});
4951
+ }
4952
+ }
4953
+ } catch (cdpPrimaryErr: any) {
4954
+ console.warn('[crawler] domFallbackElements CDP primary failed, trying page.evaluate:', cdpPrimaryErr?.message ?? String(cdpPrimaryErr));
4955
+ }
4956
+ }
4957
+
4958
+ // Fallback: pass the pre-serialized string expression so esbuild's __name() wrapper
4959
+ // for nested named functions never reaches the browser (Function.prototype.toString
4960
+ // returns the source including __name() calls for any nested named arrow constants).
4961
+ // Playwright accepts a JS expression string directly — no isolated-world __name leakage.
4962
+ const evalPage = rawPage ?? page;
4963
+ const _doEvaluate = (): Promise<any> => evalPage.evaluate(_browserEvalStr as any);
4964
+ let result: any[];
4965
+ try {
4966
+ result = await _doEvaluate();
4967
+ } catch (err1: any) {
4968
+ // Second attempt after a brief wait — isolated world may need a tick to settle
4969
+ await new Promise<void>(r => setTimeout(r, 500));
4970
+ try {
4971
+ result = await _doEvaluate();
4972
+ } catch (err2: any) {
4973
+ console.warn('[crawler] domFallbackElements page.evaluate threw (attempt 2):', err2?.message ?? String(err2));
4974
+ return [];
4975
+ }
4976
+ }
4977
+ return (result ?? []).map((e: any) => ({ ...e, isInteractive: true }));
4161
4978
  }
4162
4979
 
4163
4980
  /** Capture content anatomy for EVERY page — not just static ones.
@@ -5029,15 +5846,12 @@ async function expandNavMenus(page: any): Promise<boolean> {
5029
5846
  } catch { return false; }
5030
5847
  }
5031
5848
 
5032
- async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
5033
- return page.evaluate((origin: string) => {
5849
+ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promise<string[]> {
5850
+ // Named function so .toString() gives the raw function body for CDP evaluation.
5851
+ // esbuild wraps nested named arrow-function constants with __name() — prepend a
5852
+ // tiny polyfill so the browser has it when we serialize this function as a string.
5853
+ function _linkEval(originStr: string) {
5034
5854
  const logout = /logout|log out|sign out|delete/i;
5035
- // Hash-routed SPAs (React Router hash history, Vue Router hash mode, old
5036
- // AngularJS) encode the actual route as the fragment — "#/dashboard",
5037
- // "#!/settings" — as distinct from a same-page scroll anchor like "#top"
5038
- // or "#section-2". Treating every hash as a scroll anchor (stripping it
5039
- // entirely) collapses an entire hash-routed app down to its single base
5040
- // URL, discovering only ~1 screen no matter how many routes it has.
5041
5855
  const isRouteHash = (hash: string) => /^#!?\//.test(hash);
5042
5856
  const seen = new Set<string>();
5043
5857
  const add = (rawHref: string, textContent: string) => {
@@ -5048,11 +5862,9 @@ async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
5048
5862
  try {
5049
5863
  const u = new URL(rawHref, location.href);
5050
5864
  if (!isRouteHash(u.hash)) u.hash = '';
5051
- if (new URL(u).origin === origin) seen.add(u.toString());
5865
+ if (new URL(u).origin === originStr) seen.add(u.toString());
5052
5866
  } catch { /* ignore */ }
5053
5867
  };
5054
-
5055
- // Iterative BFS shadow DOM traversal — avoids stack overflow on deeply nested shadow roots
5056
5868
  const allRoots: Array<Document | ShadowRoot> = [document];
5057
5869
  const rootSeen = new WeakSet<Document | ShadowRoot>();
5058
5870
  rootSeen.add(document);
@@ -5073,12 +5885,48 @@ async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
5073
5885
  rootSeen.add(el.shadowRoot);
5074
5886
  allRoots.push(el.shadowRoot);
5075
5887
  }
5076
- } catch (_) { /* closed/cross-origin shadow root — skip */ }
5888
+ } catch (_) { /* closed/cross-origin shadow root */ }
5077
5889
  });
5078
- } catch (_) { /* element threw — skip this root */ }
5890
+ } catch (_) { /* element threw — skip root */ }
5079
5891
  }
5080
5892
  return Array.from(seen);
5081
- }, origin).catch(() => []);
5893
+ }
5894
+
5895
+ const _linkEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_linkEval.toString()})(${JSON.stringify(origin)})})();`;
5896
+
5897
+ // CDP primary — bypasses Stagehand isolated world entirely
5898
+ if (rawPage && typeof rawPage.context === 'function') {
5899
+ try {
5900
+ const ctx = rawPage.context();
5901
+ if (typeof ctx?.newCDPSession === 'function') {
5902
+ const cdp = await ctx.newCDPSession(rawPage);
5903
+ try {
5904
+ const evalResult = await cdp.send('Runtime.evaluate', {
5905
+ expression: _linkEvalStr,
5906
+ returnByValue: true,
5907
+ awaitPromise: false,
5908
+ });
5909
+ if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
5910
+ return evalResult.result.value as string[];
5911
+ }
5912
+ if (evalResult?.exceptionDetails) {
5913
+ console.warn('[crawler] domFallbackLinks CDP error:', evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
5914
+ }
5915
+ } finally {
5916
+ cdp.detach().catch(() => {});
5917
+ }
5918
+ }
5919
+ } catch (cdpErr: any) {
5920
+ console.warn('[crawler] domFallbackLinks CDP failed:', cdpErr?.message ?? String(cdpErr));
5921
+ }
5922
+ }
5923
+
5924
+ // Fallback: pass string expression so esbuild __name wrapper never reaches browser
5925
+ const evalPage = rawPage ?? page;
5926
+ return (evalPage.evaluate(_linkEvalStr as any) as Promise<string[]>).catch((err: any) => {
5927
+ console.warn('[crawler] domFallbackLinks page.evaluate threw:', err?.message ?? String(err));
5928
+ return [];
5929
+ });
5082
5930
  }
5083
5931
 
5084
5932
  /** Extract label→href pairs for all in-app anchor links — used to seed KAG element transitions. */
@@ -5190,57 +6038,122 @@ async function dismissOverlays(page: any): Promise<void> {
5190
6038
  }
5191
6039
  } catch { /* overlay not present — skip */ }
5192
6040
  }
5193
- // Brief settle after dismissals
5194
- await page.waitForTimeout(300).catch(() => { });
6041
+ // Brief settle after dismissals — Node.js timer, no dependency on page.waitForTimeout proxy
6042
+ await new Promise<void>(r => setTimeout(r, 300));
5195
6043
  }
5196
6044
 
5197
6045
  /**
5198
6046
  * Scroll the full page height to trigger IntersectionObserver-based lazy loads
5199
6047
  * (images, components, infinite-scroll content). Scrolls back to top before capture.
5200
6048
  */
5201
- async function scrollToTriggerLazyLoad(page: any): Promise<void> {
6049
+ async function scrollToTriggerLazyLoad(page: any, rawPage?: any): Promise<void> {
6050
+ const evalPage = rawPage ?? page;
5202
6051
  try {
5203
- await page.evaluate(async () => {
5204
- const step = Math.max(window.innerHeight, 400);
5205
- const total = Math.min(document.body.scrollHeight, 12_000); // cap at 12k px to avoid infinite pages
5206
- for (let y = step; y < total; y += step) {
5207
- window.scrollTo(0, y);
5208
- await new Promise<void>(r => setTimeout(r, 120));
6052
+ // Scroll the full page in viewport-sized steps.
6053
+ // window.scrollTo handles standard scroll containers and triggers IntersectionObserver.
6054
+ const { scrollHeight, innerHeight, innerWidth } = await evalPage.evaluate(() => ({
6055
+ scrollHeight: Math.min(document.body.scrollHeight, 12_000),
6056
+ innerHeight: window.innerHeight || 800,
6057
+ innerWidth: window.innerWidth || 1280,
6058
+ })).catch(() => ({ scrollHeight: 0, innerHeight: 800, innerWidth: 1280 }));
6059
+
6060
+ if (scrollHeight > innerHeight) {
6061
+ // CDP wheel events trigger virtual scrollers (react-window / TanStack Virtual / AG Grid)
6062
+ // which ignore window.scrollTo but listen on their own container via wheel events.
6063
+ let cdp: any = null;
6064
+ if (rawPage && typeof rawPage.context === 'function') {
6065
+ try {
6066
+ const ctx = rawPage.context();
6067
+ if (typeof ctx?.newCDPSession === 'function') cdp = await ctx.newCDPSession(rawPage);
6068
+ } catch { cdp = null; }
5209
6069
  }
5210
- window.scrollTo(0, 0);
5211
- });
6070
+
6071
+ const step = Math.max(innerHeight, 400);
6072
+ for (let y = step; y < scrollHeight; y += step) {
6073
+ await evalPage.evaluate((pos: number) => window.scrollTo(0, pos), y).catch(() => {});
6074
+ if (cdp) {
6075
+ await cdp.send('Input.dispatchMouseEvent', {
6076
+ type: 'mouseWheel',
6077
+ x: Math.floor(innerWidth / 2),
6078
+ y: Math.floor(innerHeight / 2),
6079
+ deltaX: 0,
6080
+ deltaY: step,
6081
+ modifiers: 0,
6082
+ }).catch(() => {});
6083
+ }
6084
+ // Node.js-side wait — does NOT depend on page.waitForTimeout (Stagehand proxy may lack it)
6085
+ await new Promise<void>(r => setTimeout(r, 150));
6086
+ }
6087
+ // Scroll back to top
6088
+ await evalPage.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
6089
+ if (cdp) cdp.detach().catch(() => {});
6090
+ }
6091
+
5212
6092
  // Let lazy-triggered content render before extraction
5213
- await page.waitForTimeout(400).catch(() => { });
5214
- // Re-check DOM stability after scroll-triggered mutations
5215
- await waitForStableDOM(page, 3_000, 300);
6093
+ await new Promise<void>(r => setTimeout(r, 400));
6094
+ // Wait for networkidle: IntersectionObserver fires on scroll but async data fetches
6095
+ // (React Query, SWR, Apollo) triggered by it may still be in-flight. networkidle ensures
6096
+ // all XHR/fetch requests spawned by the scroll complete before DOM stability check.
6097
+ const scrollWaitPage = rawPage ?? page;
6098
+ if (typeof scrollWaitPage.waitForLoadState === 'function') {
6099
+ await scrollWaitPage.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
6100
+ }
6101
+ // Re-check DOM stability (shadow-piercing) after scroll-triggered mutations + data fetches
6102
+ await waitForStableDOM(page, 4_000, 500, rawPage);
5216
6103
  } catch { /* scroll failed — non-fatal, proceed */ }
5217
6104
  }
5218
6105
 
5219
- async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400): Promise<void> {
6106
+ async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400, rawPage?: any): Promise<void> {
6107
+ const evalPage = rawPage ?? page; // prefer rawPage — avoids Stagehand proxy + __name isolated-world issues
5220
6108
  const deadline = Date.now() + maxWait;
5221
6109
  let lastCount = -1;
5222
6110
  let stableAt = 0;
6111
+ let evalErrors = 0;
5223
6112
  while (Date.now() < deadline) {
5224
- const count: number = await page.evaluate(() => document.querySelectorAll('*').length).catch(() => 0);
5225
- if (count !== lastCount) {
5226
- lastCount = count;
5227
- stableAt = Date.now();
5228
- } else if (Date.now() - stableAt >= stabilityMs) {
5229
- return;
6113
+ const count: number = await evalPage.evaluate(() => {
6114
+ // Shadow-piercing counter: querySelectorAll('*') only counts light-DOM elements;
6115
+ // shadow roots are separate trees. Counting both ensures changes inside web components
6116
+ // (e.g. Material UI, Lit, Shoelace) reset the stability timer correctly.
6117
+ let c = 0;
6118
+ const visit = (root: Document | ShadowRoot) => {
6119
+ const els = root.querySelectorAll('*');
6120
+ c += els.length;
6121
+ els.forEach((el: any) => { if (el.shadowRoot) visit(el.shadowRoot); });
6122
+ };
6123
+ visit(document);
6124
+ return c;
6125
+ }).catch(() => -1);
6126
+ if (count === -1) {
6127
+ // evaluate failed (isolated world broken) — after 3 consecutive failures, wait stabilityMs and bail
6128
+ if (++evalErrors >= 3) {
6129
+ await new Promise(r => setTimeout(r, stabilityMs));
6130
+ return;
6131
+ }
6132
+ } else {
6133
+ evalErrors = 0;
6134
+ if (count !== lastCount) {
6135
+ lastCount = count;
6136
+ stableAt = Date.now();
6137
+ } else if (Date.now() - stableAt >= stabilityMs) {
6138
+ return;
6139
+ }
5230
6140
  }
5231
6141
  await new Promise(r => setTimeout(r, 100));
5232
6142
  }
5233
6143
  // Timed out — DOM may still be loading, proceed anyway
5234
6144
  }
5235
6145
 
5236
- async function waitForScreenshotReady(page: any): Promise<void> {
5237
- const hardDeadline = Date.now() + 20_000;
6146
+ async function waitForScreenshotReady(page: any, rawPage?: any): Promise<void> {
6147
+ const hardDeadline = Date.now() + 30_000;
5238
6148
  const withHardCap = <T>(p: Promise<T>): Promise<T | void> =>
5239
6149
  Promise.race([p, new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
6150
+ // Use rawPage for load-state waits — Stagehand proxy may not expose waitForLoadState
6151
+ const waitPage = rawPage ?? page;
6152
+ const evalPage = rawPage ?? page;
5240
6153
  try {
5241
- await withHardCap(page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => { }));
5242
- await withHardCap(page.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => { }));
5243
- await withHardCap(page.evaluate(() => {
6154
+ await withHardCap(waitPage.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => { }));
6155
+ await withHardCap(waitPage.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => { }));
6156
+ await withHardCap(evalPage.evaluate(() => {
5244
6157
  if (document.readyState === 'complete') return true;
5245
6158
  return new Promise<boolean>((resolve) => {
5246
6159
  const done = () => resolve(true);
@@ -5248,13 +6161,13 @@ async function waitForScreenshotReady(page: any): Promise<void> {
5248
6161
  setTimeout(done, 4_000);
5249
6162
  });
5250
6163
  }).catch(() => { }));
5251
- await withHardCap(page.evaluate(async () => {
6164
+ await withHardCap(evalPage.evaluate(async () => {
5252
6165
  const fonts = (document as any).fonts;
5253
6166
  if (fonts?.ready) await fonts.ready;
5254
6167
  }).catch(() => { }));
5255
- await withHardCap(scrollToTriggerLazyLoad(page));
5256
- await withHardCap(waitForStableDOM(page, 4_000, 700));
5257
- await withHardCap(page.evaluate(async () => {
6168
+ await withHardCap(scrollToTriggerLazyLoad(rawPage ?? page, rawPage));
6169
+ await withHardCap(waitForStableDOM(page, 4_000, 700, rawPage));
6170
+ await withHardCap(evalPage.evaluate(async () => {
5258
6171
  const pending = Array.from(document.images).filter((img) => !img.complete);
5259
6172
  if (pending.length === 0) return;
5260
6173
  await Promise.race([
@@ -5265,7 +6178,7 @@ async function waitForScreenshotReady(page: any): Promise<void> {
5265
6178
  new Promise<void>((resolve) => setTimeout(resolve, 4_000)),
5266
6179
  ]);
5267
6180
  }).catch(() => { }));
5268
- await withHardCap(waitForStableDOM(page, 2_000, 500));
6181
+ await withHardCap(waitForStableDOM(page, 2_000, 500, rawPage));
5269
6182
  } catch { /* readiness is best-effort; never fail the crawl for a continuously mutating page */ }
5270
6183
  }
5271
6184
 
@@ -5490,6 +6403,9 @@ async function runAuthDiscoveryPhase(
5490
6403
 
5491
6404
  while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
5492
6405
  const current = queue.shift()!;
6406
+ const _authPct = 10 + Math.round((captured / AUTH_DISCOVERY_MAX_SCREENS) * 7);
6407
+ const _authPath = current.url.replace(/^https?:\/\/[^/]+/, '') || '/';
6408
+ await onProgress?.(_authPct, `Auth discovery · ${_authPath} · ${captured} found`);
5493
6409
  try {
5494
6410
  const response = await page.goto(current.url, { waitUntil: 'domcontentloaded', timeout: tuning.navTimeoutMs }).catch(() => null);
5495
6411
  if (response && response.status && response.status() >= 400) continue;