@detiq/crawler 1.1.21 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.21",
3
+ "version": "1.1.23",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/crawler.ts CHANGED
@@ -102,7 +102,7 @@ const _crawlLLMCache = new Map<string, any>();
102
102
  async function getCachedCrawlLLM(tenantId: string): Promise<any> {
103
103
  if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
104
104
  const cfg = await getTaskAgentLLMConfig(tenantId, 'crawl').catch(() => null);
105
- _crawlLLMCache.set(tenantId, cfg);
105
+ if (cfg) _crawlLLMCache.set(tenantId, cfg);
106
106
  return cfg;
107
107
  }
108
108
 
@@ -128,7 +128,9 @@ interface PerformanceMetric {
128
128
  const ALLOWED_NUMERIC_PATHS = new Set(['/2fa', '/404', '/500', '/403', '/401']);
129
129
  const UUID_PATH_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
130
130
  const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
131
- const NUMERIC_ID_PATH_SEGMENT_RE = /^\d[\d-]*$/;
131
+ // Match 4+ digit pure numeric IDs only — aligns with canonicalize()'s /\d{4,}/ threshold.
132
+ // 1-3 digit segments (page/2/, /tag/books/page/3/) are valid pagination — do NOT skip them.
133
+ const NUMERIC_ID_PATH_SEGMENT_RE = /^\d{4,}$/;
132
134
  const COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE = /^\d+-\d+$/;
133
135
 
134
136
  function normalizePathForNumericGuard(pathname: string): string {
@@ -1955,6 +1957,7 @@ export async function crawlProject(opts: {
1955
1957
  };
1956
1958
  const edges: Array<{ fromUrl: string; toUrl: string }> = [];
1957
1959
  const pendingTransitions: Array<{ fromScreenId: string; elementMeaning: string; targetUrl: string }> = [];
1960
+ const pendingSummaryWrites: Promise<void>[] = [];
1958
1961
  const discovered: Array<{
1959
1962
  url: string; screenId?: string; elements?: number;
1960
1963
  elementFacts?: Array<{ meaning: string; role: string; expectedData?: string }>;
@@ -3450,6 +3453,7 @@ export async function crawlProject(opts: {
3450
3453
  const summaryPromise = (async () => {
3451
3454
  try {
3452
3455
  const llmCfg = await getCachedCrawlLLM(opts.tenantId);
3456
+ if (!llmCfg) return undefined;
3453
3457
  const snippet = extracted.markdown.slice(0, 1200);
3454
3458
  // Skip LLM when content is too thin — output would be useless
3455
3459
  if (snippet.trim().length < 80) return undefined;
@@ -3487,9 +3491,15 @@ export async function crawlProject(opts: {
3487
3491
  heading: extracted.title || name,
3488
3492
  }).catch((err: any) => console.warn('[crawler] markdown extraction save failed:', err));
3489
3493
 
3490
- summaryPromise.then((aiSummary) => {
3491
- if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch(() => { });
3492
- });
3494
+ pendingSummaryWrites.push(
3495
+ summaryPromise.then((aiSummary) => {
3496
+ if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch((e: any) => {
3497
+ logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary DB save failed');
3498
+ });
3499
+ }).catch((e: any) => {
3500
+ logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary generation failed');
3501
+ })
3502
+ );
3493
3503
  }
3494
3504
 
3495
3505
  const structuredJson = summarizeStructuredElements(atElements);
@@ -3989,9 +3999,11 @@ export async function crawlProject(opts: {
3989
3999
 
3990
4000
  // Discovery mode: return collected URLs and skip all post-crawl analysis phases.
3991
4001
  // Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
4002
+ // Filter out sentinel strings (_pending_, _auth_wall_, _soft404_, etc.) that mark
4003
+ // in-progress or skipped entries — only keep actual http(s) URLs.
3992
4004
  if (opts.discoveryMode) {
3993
4005
  return {
3994
- discoveredUrls: Array.from(visited.values()),
4006
+ discoveredUrls: Array.from(visited.values()).filter((v: string) => v.startsWith('http')),
3995
4007
  screensFound: 0,
3996
4008
  screensErrored: 0,
3997
4009
  screensSkippedToNextSegment: 0,
@@ -4768,6 +4780,15 @@ export async function crawlProject(opts: {
4768
4780
  const screensSucceeded = discovered.filter(d => !!d.screenId).length;
4769
4781
  const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
4770
4782
 
4783
+ // Drain pending AI summary writes before marking the job done — ensures descriptions
4784
+ // are visible immediately after crawl completes. Cap wait at 30 s per screen, 60 s total.
4785
+ if (pendingSummaryWrites.length > 0) {
4786
+ await Promise.race([
4787
+ Promise.allSettled(pendingSummaryWrites),
4788
+ new Promise<void>((resolve) => setTimeout(resolve, Math.min(60_000, pendingSummaryWrites.length * 3_000))),
4789
+ ]);
4790
+ }
4791
+
4771
4792
  // Detect systematic element/link extraction failure across all screens
4772
4793
  if (screensSucceeded >= 3) {
4773
4794
  const screensWithElements = discovered.filter(d => (d.elements ?? 0) > 0 && d.screenId).length;
package/src/job-queue.ts CHANGED
@@ -86,7 +86,7 @@ export function generateTOTP(secret: string): string {
86
86
  const hmac = createHmac('sha1', Buffer.from(bytes)).update(buf).digest();
87
87
  const offset = hmac[hmac.length - 1] & 0xf;
88
88
  const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) |
89
- ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
89
+ ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
90
90
  return String(code % 1_000_000).padStart(6, '0');
91
91
  }
92
92
 
@@ -94,12 +94,12 @@ function deriveFallbackTargetPath(ref: string, framework: string): string {
94
94
  const slug = (ref ?? 'test').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'test';
95
95
  const base = framework.startsWith('cypress') ? 'cypress/e2e/'
96
96
  : (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? 'tests/'
97
- : (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'src/test/java/'
98
- : 'tests/';
97
+ : (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'src/test/java/'
98
+ : 'tests/';
99
99
  const ext = (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? '_test.py'
100
100
  : (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'Test.java'
101
- : framework.includes('ts') ? '.spec.ts'
102
- : '.spec.js';
101
+ : framework.includes('ts') ? '.spec.ts'
102
+ : '.spec.js';
103
103
  return `${base}${slug}${ext}`;
104
104
  }
105
105
 
@@ -109,23 +109,23 @@ function makeJobName(type: string): string {
109
109
  const ts = now.getTime();
110
110
  const label = type === 'CRAWL' ? 'Crawl'
111
111
  : type === 'CAPTURE_STEPS' ? 'CaptureSteps'
112
- : type === 'NATIVE_CRAWL' ? 'NativeCrawl'
113
- : type === 'GENERATE_TESTS' ? 'GenerateTests'
114
- : type === 'VISUAL_REGRESSION' ? 'VisualRegression'
115
- : type === 'AI_EDIT' ? 'AIEdit'
116
- : type === 'API_TESTS' ? 'ApiTests'
117
- : type === 'SECURITY_TESTS' ? 'SecurityTests'
118
- : type === 'INTEGRATION_TESTS' ? 'IntegrationTests'
119
- : type === 'GENERATE_TESTS_FROM_REQS' ? 'GenerateTestsFromReqs'
120
- : type === 'GENERATE_DATA' ? 'GenerateData'
121
- : type === 'GENERATE_SCRIPT' ? 'GenerateScript'
122
- : type === 'GENERATE_TEST_PLAN' ? 'GenerateTestPlan'
123
- : type === 'IMPORT_TESTS' ? 'ImportTests'
124
- : type === 'GENERATE_SCREEN_TESTS' ? 'GenerateScreenTests'
125
- : type === 'BATCH_GENERATE_ALIGNED' ? 'BatchGenerateAligned'
126
- : type === 'CREATE_PR' ? 'CreatePR'
127
- : type === 'NEW_PAGES_PIPELINE' ? 'NewPagesPipeline'
128
- : type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
112
+ : type === 'NATIVE_CRAWL' ? 'NativeCrawl'
113
+ : type === 'GENERATE_TESTS' ? 'GenerateTests'
114
+ : type === 'VISUAL_REGRESSION' ? 'VisualRegression'
115
+ : type === 'AI_EDIT' ? 'AIEdit'
116
+ : type === 'API_TESTS' ? 'ApiTests'
117
+ : type === 'SECURITY_TESTS' ? 'SecurityTests'
118
+ : type === 'INTEGRATION_TESTS' ? 'IntegrationTests'
119
+ : type === 'GENERATE_TESTS_FROM_REQS' ? 'GenerateTestsFromReqs'
120
+ : type === 'GENERATE_DATA' ? 'GenerateData'
121
+ : type === 'GENERATE_SCRIPT' ? 'GenerateScript'
122
+ : type === 'GENERATE_TEST_PLAN' ? 'GenerateTestPlan'
123
+ : type === 'IMPORT_TESTS' ? 'ImportTests'
124
+ : type === 'GENERATE_SCREEN_TESTS' ? 'GenerateScreenTests'
125
+ : type === 'BATCH_GENERATE_ALIGNED' ? 'BatchGenerateAligned'
126
+ : type === 'CREATE_PR' ? 'CreatePR'
127
+ : type === 'NEW_PAGES_PIPELINE' ? 'NewPagesPipeline'
128
+ : type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
129
129
  return `${label}_${date}_${ts}`;
130
130
  }
131
131
 
@@ -195,6 +195,9 @@ const WORKER_ID = `${os.hostname()}:${process.pid}`;
195
195
  const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
196
196
  const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
197
197
  const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
198
+ // Max simultaneous DISCOVER jobs — each launches a Playwright browser and holds
199
+ // DB connections, so running many concurrently exhausts resources. Default 2.
200
+ const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
198
201
  const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15_000;
199
202
  // How many headless browsers a single RUN_TEST_SUITE job launches at once —
200
203
  // distinct from WORKER_CONCURRENCY (how many *jobs* the poll loop claims at
@@ -221,14 +224,21 @@ export async function claimSpecificJob(jobId: string): Promise<ClaimedJob | null
221
224
  }
222
225
 
223
226
  /** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
224
- * race-safe against any number of other pollers doing the same thing concurrently. */
227
+ * race-safe against any number of other pollers doing the same thing concurrently.
228
+ * DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
229
+ * already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
225
230
  export async function claimNextQueuedJob(): Promise<ClaimedJob | null> {
231
+ const maxDiscover = MAX_CONCURRENT_DISCOVER;
226
232
  const rows = await prismaAdmin.$queryRaw<ClaimedJob[]>`
227
233
  UPDATE "PlatformJob"
228
234
  SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
229
235
  WHERE id = (
230
236
  SELECT id FROM "PlatformJob"
231
237
  WHERE status = 'QUEUED'
238
+ AND (
239
+ type != 'DISCOVER'
240
+ OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
241
+ )
232
242
  ORDER BY "queuedAt" ASC
233
243
  FOR UPDATE SKIP LOCKED
234
244
  LIMIT 1
@@ -315,7 +325,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
315
325
  await prismaAdmin.platformJob.update({
316
326
  where: { id: job.id },
317
327
  data: { status: 'FAILED', error: `Unknown job type: ${job.type}`, finishedAt: new Date() },
318
- }).catch(() => {});
328
+ }).catch(() => { });
319
329
  return;
320
330
  }
321
331
  const stopTimer = crawlJobDurationSeconds.startTimer({ type: job.type });
@@ -335,7 +345,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
335
345
  await prismaAdmin.platformJob.update({
336
346
  where: { id: job.id },
337
347
  data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() },
338
- }).catch(() => {});
348
+ }).catch(() => { });
339
349
  }
340
350
  }
341
351
 
@@ -362,10 +372,15 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
362
372
  logger.error({ err: String(err) }, '[job-worker] stale-job sweep failed');
363
373
  }
364
374
  try {
365
- for (let i = 0; i < concurrency; i++) {
366
- const job = await claimNextQueuedJob();
367
- if (!job) break;
368
- void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
375
+ const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
376
+ if (pauseRow?.value === 'true') {
377
+ logger.info('[job-worker] queue is paused — skipping claim');
378
+ } else {
379
+ for (let i = 0; i < concurrency; i++) {
380
+ const job = await claimNextQueuedJob();
381
+ if (!job) break;
382
+ void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
383
+ }
369
384
  }
370
385
  } catch (err) {
371
386
  logger.error({ err: String(err) }, '[job-worker] claim loop failed');
@@ -390,7 +405,7 @@ export function stopJobWorkerLoop(): void {
390
405
  /** Create a job record that tracks a synchronous operation (telemetry only, no background processor). */
391
406
  export async function createTrackedJob(
392
407
  type: string,
393
- config: { tenantId: string; projectId: string; [k: string]: any }
408
+ config: { tenantId: string; projectId: string;[k: string]: any }
394
409
  ): Promise<string> {
395
410
  const name = makeJobName(type);
396
411
  const job = await withTenant(config.tenantId, (tx) =>
@@ -414,7 +429,7 @@ export async function finishTrackedJob(tenantId: string, jobId: string, error?:
414
429
  ...(error ? { error, nextRetryAt } : { progress: 100 }),
415
430
  },
416
431
  })
417
- ).catch(() => {});
432
+ ).catch(() => { });
418
433
  }
419
434
 
420
435
  /** Enqueue a generic platform job — returns the job ID immediately. */
@@ -691,11 +706,11 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
691
706
  // Build runtime credentials: convert stored totpSecret → getOneTimeCode function
692
707
  const runtimeCreds = config.credentials
693
708
  ? {
694
- ...config.credentials,
695
- ...(config.credentials.totpSecret
696
- ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
697
- : {}),
698
- }
709
+ ...config.credentials,
710
+ ...(config.credentials.totpSecret
711
+ ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
712
+ : {}),
713
+ }
699
714
  : undefined;
700
715
 
701
716
  const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
@@ -997,11 +1012,11 @@ export async function aggregateJobTokens(tenantId: string, jobId: string, projec
997
1012
  await tx.platformJob.update({
998
1013
  where: { id: jobId },
999
1014
  data: {
1000
- totalPromptTokens: row.totalPromptTokens,
1015
+ totalPromptTokens: row.totalPromptTokens,
1001
1016
  totalCompletionTokens: row.totalCompletionTokens,
1002
- totalCacheReadTokens: row.totalCacheReadTokens,
1017
+ totalCacheReadTokens: row.totalCacheReadTokens,
1003
1018
  totalEstimatedCostUsd: Number(row.totalEstimatedCostUsd),
1004
- llmCallCount: row.llmCallCount,
1019
+ llmCallCount: row.llmCallCount,
1005
1020
  },
1006
1021
  });
1007
1022
  });
@@ -1180,10 +1195,10 @@ async function processFigmaImportJob(jobId: string, config: any) {
1180
1195
  const figmaElements = figma.extractElements((frame as any).node);
1181
1196
  for (const el of figmaElements) {
1182
1197
  const role = el.type === 'COMPONENT' || el.type === 'COMPONENT_SET' ? 'COMPONENT'
1183
- : el.type === 'INSTANCE' ? 'COMPONENT'
1184
- : el.type === 'TEXT' ? 'TEXT'
1185
- : el.type === 'FRAME' || el.type === 'GROUP' ? 'REGION'
1186
- : el.type;
1198
+ : el.type === 'INSTANCE' ? 'COMPONENT'
1199
+ : el.type === 'TEXT' ? 'TEXT'
1200
+ : el.type === 'FRAME' || el.type === 'GROUP' ? 'REGION'
1201
+ : el.type;
1187
1202
  const boundingRect = el.boundingBox
1188
1203
  ? { x: Math.round(el.boundingBox.x), y: Math.round(el.boundingBox.y), width: Math.round(el.boundingBox.width), height: Math.round(el.boundingBox.height) }
1189
1204
  : undefined;
@@ -1261,7 +1276,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
1261
1276
  deviceFarm,
1262
1277
  },
1263
1278
  })
1264
- ).catch(() => {});
1279
+ ).catch(() => { });
1265
1280
 
1266
1281
  const workspaceDeviceFarm = await AppBrain.getTenantDeviceFarmConfig(tenantId).catch(() => null);
1267
1282
 
@@ -1328,7 +1343,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
1328
1343
  } catch (err: any) {
1329
1344
  await withTenant(tenantId, (tx) =>
1330
1345
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() } })
1331
- ).catch(() => {});
1346
+ ).catch(() => { });
1332
1347
  }
1333
1348
  }
1334
1349
 
@@ -1387,7 +1402,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1387
1402
  try {
1388
1403
  const reqSeed = deterministicSeed(`${tenantId}:${projectId}:req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
1389
1404
  const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
1390
- if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => {});
1405
+ if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
1391
1406
  const json = text.match(/\{[\s\S]*\}/)?.[0];
1392
1407
  if (json) parsed = JSON.parse(json);
1393
1408
  } catch (e: any) {
@@ -1396,8 +1411,8 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1396
1411
 
1397
1412
  await updateJobProgress(tenantId, jobId, 70, `Saving ${parsed.cases?.length ?? 0} test cases`);
1398
1413
 
1399
- const VALID_KINDS = new Set(['POSITIVE','NEGATIVE','EDGE','STATE_TRANSITION','ACCESSIBILITY','SECURITY','INTEGRATION','E2E','API','EXPLORATORY','UAT','PERFORMANCE','COMPATIBILITY','L10N','USABILITY','LOAD','UNIT','MUTATION','AB_TEST','ALPHA_BETA','SYSTEM','SMOKE','REGRESSION','SANITY']);
1400
- const VALID_PRIORITIES = new Set(['CRITICAL','HIGH','MEDIUM','LOW']);
1414
+ const VALID_KINDS = new Set(['POSITIVE', 'NEGATIVE', 'EDGE', 'STATE_TRANSITION', 'ACCESSIBILITY', 'SECURITY', 'INTEGRATION', 'E2E', 'API', 'EXPLORATORY', 'UAT', 'PERFORMANCE', 'COMPATIBILITY', 'L10N', 'USABILITY', 'LOAD', 'UNIT', 'MUTATION', 'AB_TEST', 'ALPHA_BETA', 'SYSTEM', 'SMOKE', 'REGRESSION', 'SANITY']);
1415
+ const VALID_PRIORITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
1401
1416
  const normalized = (parsed.cases ?? []).map((c: any) => ({
1402
1417
  ref: c.reqRef ?? null,
1403
1418
  traceTo: c.reqRef ?? null,
@@ -1419,7 +1434,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1419
1434
  await Promise.all(
1420
1435
  reqs
1421
1436
  .filter((r: any) => coveredReqIds.has(r.reqId))
1422
- .map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => {}))
1437
+ .map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => { }))
1423
1438
  );
1424
1439
 
1425
1440
  await withTenant(tenantId, (tx) =>
@@ -1433,7 +1448,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
1433
1448
  logger.error({ jobId, err: msg }, 'processGenerateTestsFromReqsJob failed');
1434
1449
  await withTenant(tenantId, (tx) =>
1435
1450
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
1436
- ).catch(() => {});
1451
+ ).catch(() => { });
1437
1452
  }
1438
1453
  }
1439
1454
 
@@ -1544,7 +1559,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1544
1559
  } : {}),
1545
1560
  });
1546
1561
  }
1547
- await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => {});
1562
+ await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => { });
1548
1563
  } catch (err: any) {
1549
1564
  failed++;
1550
1565
  runResults.push({ testCaseId: tc.id, status: 'ERROR' });
@@ -1553,7 +1568,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1553
1568
  testCaseId: tc.id,
1554
1569
  status: 'ERROR',
1555
1570
  error: String(err?.message ?? err).slice(0, 500),
1556
- }).catch(() => {});
1571
+ }).catch(() => { });
1557
1572
  }
1558
1573
  }));
1559
1574
  await updateJobProgress(
@@ -1592,10 +1607,10 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1592
1607
  } catch (err: any) {
1593
1608
  const msg = String(err?.message ?? err).slice(0, 500);
1594
1609
  logger.error({ jobId, testRunId, err: msg }, 'processRunTestSuiteJob failed');
1595
- await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => {});
1610
+ await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => { });
1596
1611
  await withTenant(tenantId, (tx) =>
1597
1612
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
1598
- ).catch(() => {});
1613
+ ).catch(() => { });
1599
1614
  }
1600
1615
  }
1601
1616
 
@@ -1840,12 +1855,12 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1840
1855
  await onProgress(52, 'Authenticated spider…');
1841
1856
  const runtimeCreds = crawlCfg.username
1842
1857
  ? {
1843
- username: crawlCfg.username,
1844
- password: crawlCfg.password ?? '',
1845
- ...(crawlCfg.totpSecret
1846
- ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret!) }
1847
- : {}),
1848
- }
1858
+ username: crawlCfg.username,
1859
+ password: crawlCfg.password ?? '',
1860
+ ...(crawlCfg.totpSecret
1861
+ ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret!) }
1862
+ : {}),
1863
+ }
1849
1864
  : undefined;
1850
1865
 
1851
1866
  const authResult = await crawlProject({
@@ -1897,7 +1912,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1897
1912
  }
1898
1913
 
1899
1914
  // Persist discovered URLs to project crawl config
1900
- const discoveredUrls = Array.from(allUrls);
1915
+ // Filter sentinel strings (_pending_, _auth_wall_, etc.) that may have leaked in
1916
+ // via onUrlsDiscovered callbacks fired before visited-map entries were updated.
1917
+ const discoveredUrls = Array.from(allUrls).filter(u => u.startsWith('http'));
1901
1918
  const existingCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => ({}));
1902
1919
  await AppBrain.saveCrawlConfig(tenantId, projectId, {
1903
1920
  ...(existingCfg as any),
@@ -2164,7 +2181,7 @@ async function processImportTestsJob(jobId: string, config: any) {
2164
2181
  status: lr.needsReview ? 'PENDING_REVIEW' : undefined,
2165
2182
  },
2166
2183
  })
2167
- ).catch(() => {}) // non-fatal per-case
2184
+ ).catch(() => { }) // non-fatal per-case
2168
2185
  )
2169
2186
  );
2170
2187
  labeled = labelResults.length;
@@ -2258,7 +2275,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2258
2275
  testCaseId: tc.id,
2259
2276
  styleProfile: conn.styleProfile,
2260
2277
  pageObjectInventory: conn.pageObjectInventory ?? undefined,
2261
- }).catch(() => {})
2278
+ }).catch(() => { })
2262
2279
  );
2263
2280
  }
2264
2281
  }
@@ -2272,7 +2289,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2272
2289
  logger.error({ jobId, screenId, err: msg }, 'processGenerateScreenTestsJob failed');
2273
2290
  await withTenant(tenantId, (tx: any) =>
2274
2291
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg.slice(0, 500), finishedAt: new Date() } })
2275
- ).catch(() => {});
2292
+ ).catch(() => { });
2276
2293
  }
2277
2294
  }
2278
2295
 
@@ -2377,10 +2394,35 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2377
2394
  if (!tc) throw new Error('Test case not found');
2378
2395
 
2379
2396
  const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
2380
- const stepTexts = rawSteps.map(captureStepText).filter(Boolean);
2397
+ if (rawSteps.length === 0) throw new Error('Test case has no steps to capture');
2398
+
2399
+ // Resolve env tokens from testDataInline so {{fze.env.URL}} → concrete URL before execution plan
2400
+ const _tdi = ((tc as any).testDataInline ?? {}) as Record<string, unknown>;
2401
+ const _tdiStartUrl = typeof _tdi['startUrl'] === 'string' && (_tdi['startUrl'] as string).startsWith('http')
2402
+ ? (_tdi['startUrl'] as string) : '';
2403
+ const _recordedUrls = Array.isArray(_tdi['recordedUrls']) ? (_tdi['recordedUrls'] as unknown[]).map(String) : [];
2404
+ const _recordedStartUrl = _recordedUrls.find((u) => u.startsWith('http')) ?? '';
2405
+
2406
+ const baseUrl = config.baseUrl || _tdiStartUrl || _recordedStartUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
2407
+
2408
+ // Build env token → concrete value map from testDataInline for step text substitution
2409
+ const _envTokenMap: Record<string, string> = {};
2410
+ for (const [k, v] of Object.entries(_tdi)) {
2411
+ if (typeof v === 'string' && v.startsWith('http')) _envTokenMap[k] = v;
2412
+ }
2413
+ if (_recordedStartUrl && !_envTokenMap['startUrl']) _envTokenMap['startUrl'] = _recordedStartUrl;
2414
+
2415
+ // Replace {{fze.env.X}} and [env:X] tokens in step texts with concrete values from testDataInline.
2416
+ // IMPORTANT: only substitute if we have a concrete value for that key.
2417
+ // Fall back to [key] placeholder — never fall back to baseUrl (would inject URL into credential fields).
2418
+ const resolveStepText = (text: string): string =>
2419
+ text
2420
+ .replace(/\{\{\s*fze\.env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`)
2421
+ .replace(/\[env:([A-Za-z_][A-Za-z0-9_]*)\]/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`);
2422
+
2423
+ const stepTexts = rawSteps.map(captureStepText).filter(Boolean).map(resolveStepText);
2381
2424
  if (stepTexts.length === 0) throw new Error('Test case has no steps to capture');
2382
2425
 
2383
- const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
2384
2426
  let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
2385
2427
  plan = Array.isArray(plan)
2386
2428
  ? plan.filter(Boolean).map((step: any, idx: number) => ({ ...step, stepNumber: idx + 1 }))
@@ -2424,7 +2466,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2424
2466
  heartbeatAt: new Date(),
2425
2467
  },
2426
2468
  })
2427
- ).catch(() => {});
2469
+ ).catch(() => { });
2428
2470
 
2429
2471
  try {
2430
2472
  const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
@@ -2433,7 +2475,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2433
2475
  result.healTier = heal.healedVia;
2434
2476
  result.healReasoning = heal.healReasoning;
2435
2477
  }
2436
- await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => {});
2478
+ await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => { });
2437
2479
  result.status = result.healed ? 'HEALED' : 'PASSED';
2438
2480
  } catch (err: any) {
2439
2481
  result.status = step.optional ? 'SKIPPED' : 'FAILED';
@@ -2444,7 +2486,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2444
2486
  result.url = currentUrl;
2445
2487
  const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === 'FAILED' ? '-err' : ''}.png`;
2446
2488
  const ssPath = path.join(screenshotDir, ssFilename);
2447
- await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {});
2489
+ await page.screenshot({ path: ssPath, fullPage: false }).catch(() => { });
2448
2490
  result.screenshotPath = ssPath;
2449
2491
 
2450
2492
  let cloudUrl: string | undefined;
@@ -2466,13 +2508,13 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2466
2508
  const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? 'ENTRY_POINT' : 'LINK_FOLLOW').catch(() => null) as any;
2467
2509
  if (screen?.id) {
2468
2510
  result.screenId = screen.id;
2469
- await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => {});
2511
+ await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => { });
2470
2512
  for (const el of elements) {
2471
2513
  await AppBrain.saveElement(
2472
2514
  tenantId, projectId, screen.id,
2473
2515
  el.meaning, el.role, el.expectedData, el.notes,
2474
2516
  undefined, true, el.boundingRect, el.ariaState, el.parentLandmark,
2475
- ).catch(() => {});
2517
+ ).catch(() => { });
2476
2518
  }
2477
2519
  for (const loc of elementsToPlaywrightLocators(elements as any)) {
2478
2520
  if (seenLocatorExpressions.has(loc.expression)) continue;
@@ -2491,7 +2533,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2491
2533
  }
2492
2534
 
2493
2535
  if (locators.length > 0) {
2494
- await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => {});
2536
+ await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => { });
2495
2537
  }
2496
2538
 
2497
2539
  const firstScreen = screens[0];
@@ -2503,7 +2545,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2503
2545
  captureStepScreenshots: stepResults as any,
2504
2546
  },
2505
2547
  })
2506
- ).catch(() => {});
2548
+ ).catch(() => { });
2507
2549
 
2508
2550
  const finalResult = {
2509
2551
  stepResults,
@@ -2522,11 +2564,11 @@ async function processCaptureStepsJob(jobId: string, config: any) {
2522
2564
  const error = String(err?.message ?? err);
2523
2565
  await withTenant(tenantId, (tx) =>
2524
2566
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error, finishedAt: new Date(), heartbeatAt: new Date() } })
2525
- ).catch(() => {});
2567
+ ).catch(() => { });
2526
2568
  logger.error({ jobId, testCaseId, err: error }, 'processCaptureStepsJob failed');
2527
2569
  throw err;
2528
2570
  } finally {
2529
- await browser?.close?.().catch(() => {});
2571
+ await browser?.close?.().catch(() => { });
2530
2572
  }
2531
2573
  }
2532
2574
 
@@ -2562,28 +2604,28 @@ jobDispatchTable = {
2562
2604
  await prismaAdmin.platformJob.update({
2563
2605
  where: { id: jobId },
2564
2606
  data: { status: 'FAILED', error: 'Test generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2565
- }).catch(() => {});
2607
+ }).catch(() => { });
2566
2608
  },
2567
2609
  GENERATE_DATA: async (jobId: string, _config: any) => {
2568
2610
  logger.error({ jobId }, '[job-worker] GENERATE_DATA claimed by worker — API did not process it in-process');
2569
2611
  await prismaAdmin.platformJob.update({
2570
2612
  where: { id: jobId },
2571
2613
  data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2572
- }).catch(() => {});
2614
+ }).catch(() => { });
2573
2615
  },
2574
2616
  GENERATE_SCRIPT: async (jobId: string, _config: any) => {
2575
2617
  logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
2576
2618
  await prismaAdmin.platformJob.update({
2577
2619
  where: { id: jobId },
2578
2620
  data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2579
- }).catch(() => {});
2621
+ }).catch(() => { });
2580
2622
  },
2581
2623
  GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
2582
2624
  logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
2583
2625
  await prismaAdmin.platformJob.update({
2584
2626
  where: { id: jobId },
2585
2627
  data: { status: 'FAILED', error: 'Test plan generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
2586
- }).catch(() => {});
2628
+ }).catch(() => { });
2587
2629
  },
2588
2630
  };
2589
2631
 
@@ -2880,14 +2922,14 @@ async function processRepoScanJob(jobId: string, config: any) {
2880
2922
  const testPathSet = new Set(testPaths);
2881
2923
  const sourceFilePaths = (language === 'typescript' || language === 'javascript')
2882
2924
  ? treeResult.paths
2883
- .filter((p: string) =>
2884
- (p.endsWith('.ts') || p.endsWith('.js')) &&
2885
- !testPathSet.has(p) &&
2886
- !p.endsWith('.d.ts') &&
2887
- !p.includes('node_modules/') &&
2888
- !p.includes('dist/') &&
2889
- !p.includes('.config.'))
2890
- .slice(0, 60)
2925
+ .filter((p: string) =>
2926
+ (p.endsWith('.ts') || p.endsWith('.js')) &&
2927
+ !testPathSet.has(p) &&
2928
+ !p.endsWith('.d.ts') &&
2929
+ !p.includes('node_modules/') &&
2930
+ !p.includes('dist/') &&
2931
+ !p.includes('.config.'))
2932
+ .slice(0, 60)
2891
2933
  : [];
2892
2934
  const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
2893
2935
  // Pass full tree paths separately so detectDataDirs can find testdata/*.json, fixtures/*.yaml etc.
@@ -3019,10 +3061,10 @@ async function processRepoScanJob(jobId: string, config: any) {
3019
3061
  logger.error({ jobId, repoConnectionId, err: msg }, '[repo-scan] failed');
3020
3062
  await withTenant(tenantId, (tx) =>
3021
3063
  tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: 'FAILED', scanError: msg.slice(0, 500) } })
3022
- ).catch(() => {});
3064
+ ).catch(() => { });
3023
3065
  await withTenant(tenantId, (tx) =>
3024
3066
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3025
- ).catch(() => {});
3067
+ ).catch(() => { });
3026
3068
  }
3027
3069
  }
3028
3070
 
@@ -3147,7 +3189,7 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
3147
3189
  logger.error({ jobId, err: msg }, '[batch-generate-aligned] failed');
3148
3190
  await withTenant(tenantId, (tx) =>
3149
3191
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3150
- ).catch(() => {});
3192
+ ).catch(() => { });
3151
3193
  }
3152
3194
  }
3153
3195
 
@@ -3276,7 +3318,7 @@ async function processCreatePRJob(jobId: string, config: any) {
3276
3318
  logger.error({ jobId, err: msg }, '[create-pr] failed');
3277
3319
  await withTenant(tenantId, (tx) =>
3278
3320
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3279
- ).catch(() => {});
3321
+ ).catch(() => { });
3280
3322
  }
3281
3323
  }
3282
3324
 
@@ -3380,6 +3422,6 @@ async function processNewPagesPipelineJob(jobId: string, config: any) {
3380
3422
  logger.error({ jobId, err: msg }, '[new-pages-pipeline] failed');
3381
3423
  await withTenant(tenantId, (tx) =>
3382
3424
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
3383
- ).catch(() => {});
3425
+ ).catch(() => { });
3384
3426
  }
3385
3427
  }
@@ -78,7 +78,9 @@ type ClaimedJob = {
78
78
  /** Atomically claims one specific QUEUED job for this worker. Returns null if it was already claimed. */
79
79
  export declare function claimSpecificJob(jobId: string): Promise<ClaimedJob | null>;
80
80
  /** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
81
- * race-safe against any number of other pollers doing the same thing concurrently. */
81
+ * race-safe against any number of other pollers doing the same thing concurrently.
82
+ * DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
83
+ * already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
82
84
  export declare function claimNextQueuedJob(): Promise<ClaimedJob | null>;
83
85
  /** Requeues RUNNING jobs whose heartbeat has gone stale — the worker that claimed them
84
86
  * died (crash, restart, OOM) without ever marking them DONE/FAILED. Idempotent and