@detiq/crawler 1.1.10 → 1.1.12

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/src/job-queue.ts CHANGED
@@ -51,13 +51,15 @@ async function notifyAdmins(tenantId: string, data: {
51
51
  }
52
52
  }
53
53
  import { AppBrain, getProjectStorageConfig, buildArtifactKey, uploadArtifact, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from '@detiq/app-brain';
54
- import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, detectFieldsFromHtml, mergeIntoCatalog } from '@detiq/agents';
55
- import { crawlProject } from './crawler.js';
54
+ import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog } from '@detiq/agents';
55
+ import type { ExecutionStep } from '@detiq/agents';
56
+ import { crawlProject, domFallbackElements } from './crawler.js';
56
57
  import { httpCrawl } from './http-engine.js';
57
58
  import { hashContent, normalizeText } from './incremental-diff.js';
58
59
  import { discoverProjectUrls } from './sitemap.js';
59
- import { executeTestPlan } from './test-executor.js';
60
+ import { executeExecutionStep, executeTestPlan } from './test-executor.js';
60
61
  import { runNativeCrawl } from './native-crawler.js';
62
+ import { elementsToPlaywrightLocators } from './locator-extractor.js';
61
63
  import { jira, confluence, figma } from '@detiq/connectors';
62
64
  import { decrypt, config as platformConfig, dispatchWebhook, isFeatureEnabled, interpolatePlaceholders } from '@detiq/core';
63
65
  import { createHmac } from 'node:crypto';
@@ -106,6 +108,7 @@ function makeJobName(type: string): string {
106
108
  const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
107
109
  const ts = now.getTime();
108
110
  const label = type === 'CRAWL' ? 'Crawl'
111
+ : type === 'CAPTURE_STEPS' ? 'CaptureSteps'
109
112
  : type === 'NATIVE_CRAWL' ? 'NativeCrawl'
110
113
  : type === 'GENERATE_TESTS' ? 'GenerateTests'
111
114
  : type === 'VISUAL_REGRESSION' ? 'VisualRegression'
@@ -783,6 +786,8 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
783
786
  }
784
787
  if (fieldDetectAdded > 0 || detectLimit > 0) {
785
788
  logger.info({ screens: detectLimit, added: fieldDetectAdded, projectId }, '[field-detect] catalog populated from crawl');
789
+ // Refresh FIELD_CATALOG skill file after catalog is populated (GAP-SF-20)
790
+ refreshFieldCatalogSkillFile(tenantId, projectId).catch(console.error);
786
791
  }
787
792
  } catch (fdErr) {
788
793
  logger.warn({ err: fdErr }, '[field-detect] post-crawl field detection failed');
@@ -1524,6 +1529,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1524
1529
  { passed, failed, total: testCases.length },
1525
1530
  runResults,
1526
1531
  ).catch(console.error);
1532
+ refreshTestExecutionHistorySkillFile(tenantId, projectId).catch(console.error);
1527
1533
  } catch (err: any) {
1528
1534
  const msg = String(err?.message ?? err).slice(0, 500);
1529
1535
  logger.error({ jobId, testRunId, err: msg }, 'processRunTestSuiteJob failed');
@@ -1563,6 +1569,10 @@ async function processGenerateSkillFilesJob(jobId: string, config: any) {
1563
1569
  refreshTestDataSkillFile(tenantId, projectId),
1564
1570
  refreshTestScriptSkillFile(tenantId, projectId),
1565
1571
  refreshRequirementsCoverageSkillFile(tenantId, projectId),
1572
+ refreshTestExecutionHistorySkillFile(tenantId, projectId),
1573
+ refreshVisualRegressionHistorySkillFile(tenantId, projectId),
1574
+ refreshLocatorStrategyWithHealingData(tenantId, projectId),
1575
+ refreshFieldCatalogSkillFile(tenantId, projectId),
1566
1576
  ]);
1567
1577
  await withTenant(tenantId, (tx) =>
1568
1578
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'DONE', progress: 100, finishedAt: new Date() } })
@@ -2205,6 +2215,260 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2205
2215
  }
2206
2216
  }
2207
2217
 
2218
+ type CaptureStepStatus = 'PASSED' | 'FAILED' | 'HEALED' | 'SKIPPED';
2219
+ type CaptureStepResult = {
2220
+ stepNumber: number;
2221
+ description: string;
2222
+ status: CaptureStepStatus;
2223
+ screenshotPath?: string;
2224
+ screenshotUrl?: string;
2225
+ screenId?: string;
2226
+ url?: string;
2227
+ elementsCount?: number;
2228
+ healed?: boolean;
2229
+ healTier?: 'mechanical' | 'ai';
2230
+ healReasoning?: string;
2231
+ error?: string;
2232
+ };
2233
+
2234
+ function captureStepText(step: unknown): string {
2235
+ if (typeof step === 'string') return step;
2236
+ if (!step || typeof step !== 'object') return '';
2237
+ const obj = step as Record<string, unknown>;
2238
+ return [obj.action, obj.text, obj.label, obj.data, obj.expected]
2239
+ .filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
2240
+ .join(' -> ');
2241
+ }
2242
+
2243
+ function firstQuoted(text: string): string | undefined {
2244
+ return (text.match(/"([^"]+)"/)?.[1] ?? text.match(/'([^']+)'/)?.[1])?.trim();
2245
+ }
2246
+
2247
+ function fallbackExecutionPlan(rawSteps: unknown[]): ExecutionStep[] {
2248
+ const plan: ExecutionStep[] = [];
2249
+ for (const raw of rawSteps) {
2250
+ const text = captureStepText(raw).trim();
2251
+ if (!text) continue;
2252
+ const stepNumber = plan.length + 1;
2253
+ const url = text.match(/https?:\/\/[^\s"']+/)?.[0];
2254
+
2255
+ if (/^navigate\b|^go to\b|^open\b/i.test(text) && url) {
2256
+ plan.push({ stepNumber, description: text, action: 'navigate', url });
2257
+ continue;
2258
+ }
2259
+
2260
+ const urlContains = text.match(/verify url contains:\s*["']?([^"']+)["']?/i);
2261
+ if (urlContains) {
2262
+ plan.push({ stepNumber, description: text, action: 'assert_url', value: urlContains[1].trim() });
2263
+ continue;
2264
+ }
2265
+
2266
+ const inputMatch = text.match(/(?:enter|type|fill)\s+["']([^"']+)["']\s+(?:in|into)\s+(.+?)(?:\s+field|\s+input|\s+box|\s+area)?(?:\s*→.*)?$/i);
2267
+ if (inputMatch) {
2268
+ const selector = inputMatch[2].replace(/^(the\s+)?/i, '').replace(/\s+(field|input|box|area)$/i, '').trim();
2269
+ plan.push({ stepNumber, description: text, action: 'fill', selectorStrategy: 'label', selector, value: inputMatch[1].trim() });
2270
+ continue;
2271
+ }
2272
+
2273
+ const quoted = firstQuoted(text);
2274
+ if (/^(click|tap|navigate menu|check|select)\b/i.test(text) && quoted) {
2275
+ plan.push({ stepNumber, description: text, action: 'click', selectorStrategy: 'text', selector: quoted });
2276
+ continue;
2277
+ }
2278
+ if (/^(verify|assert|expect)\b/i.test(text) && quoted) {
2279
+ plan.push({ stepNumber, description: text, action: 'assert_visible', selectorStrategy: 'text', selector: quoted });
2280
+ continue;
2281
+ }
2282
+ if (/execute script/i.test(text)) {
2283
+ const script = text.split(':').slice(1).join(':').replace(/\s*→.*$/, '').trim();
2284
+ if (script) plan.push({ stepNumber, description: text, action: 'script', value: script });
2285
+ }
2286
+ }
2287
+ return plan;
2288
+ }
2289
+
2290
+ function screenNameFromUrl(url: string, fallback: string): string {
2291
+ try {
2292
+ const u = new URL(url);
2293
+ return u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).pop() || u.hostname || fallback;
2294
+ } catch {
2295
+ return fallback;
2296
+ }
2297
+ }
2298
+
2299
+ async function processCaptureStepsJob(jobId: string, config: any) {
2300
+ const { tenantId, projectId, testCaseId } = config as { tenantId: string; projectId: string; testCaseId: string };
2301
+ const screenshotDir = config.screenshotDir || platformConfig.screenshotDir || process.env.SCREENSHOT_DIR || '/tmp/zeta-screenshots';
2302
+ await fs.mkdir(screenshotDir, { recursive: true });
2303
+
2304
+ await withTenant(tenantId, (tx) =>
2305
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'RUNNING', startedAt: new Date(), progress: 2 } })
2306
+ );
2307
+
2308
+ let browser: any;
2309
+ try {
2310
+ await updateJobProgress(tenantId, jobId, 5, 'Loading test case');
2311
+ const [tc, crawlCfg, tenantStorageCfg] = await Promise.all([
2312
+ AppBrain.getTestCaseForExecution(tenantId, testCaseId) as Promise<any>,
2313
+ AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null),
2314
+ getTenantStorageConfig(tenantId).catch(() => null),
2315
+ ]);
2316
+ if (!tc) throw new Error('Test case not found');
2317
+
2318
+ const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
2319
+ const stepTexts = rawSteps.map(captureStepText).filter(Boolean);
2320
+ if (stepTexts.length === 0) throw new Error('Test case has no steps to capture');
2321
+
2322
+ const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
2323
+ let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
2324
+ plan = Array.isArray(plan)
2325
+ ? plan.filter(Boolean).map((step: any, idx: number) => ({ ...step, stepNumber: idx + 1 }))
2326
+ : [];
2327
+ if (plan.length === 0) plan = fallbackExecutionPlan(rawSteps);
2328
+ if (plan.length === 0) throw new Error('Could not generate execution plan from steps');
2329
+
2330
+ const { chromium } = await import('playwright');
2331
+ browser = await chromium.launch({
2332
+ headless: true,
2333
+ ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
2334
+ });
2335
+ let parsedStorageState: any;
2336
+ try {
2337
+ parsedStorageState = config.storageState ? JSON.parse(config.storageState) : crawlCfg?.storageState ? JSON.parse(crawlCfg.storageState) : undefined;
2338
+ } catch {
2339
+ parsedStorageState = undefined;
2340
+ }
2341
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, ...(parsedStorageState ? { storageState: parsedStorageState } : {}) });
2342
+ const page = await context.newPage();
2343
+ const aiHealingEnabled = await isFeatureEnabled(tenantId, 'AI_SELF_HEALING').catch(() => false);
2344
+
2345
+ const stepResults: CaptureStepResult[] = [];
2346
+ const screens: Array<{ stepNumber: number; screenId: string; url: string; screenshotPath?: string; screenshotUrl?: string; isNewScreen: boolean }> = [];
2347
+ const locators: any[] = [];
2348
+ const seenLocatorExpressions = new Set<string>();
2349
+ const seenScreenKeys = new Set<string>();
2350
+ let lastStateKey = '';
2351
+
2352
+ for (let i = 0; i < plan.length; i++) {
2353
+ const step = plan[i];
2354
+ const description = step.description || stepTexts[i] || `Step ${i + 1}`;
2355
+ const stepNumber = i + 1;
2356
+ const result: CaptureStepResult = { stepNumber, description, status: 'FAILED' };
2357
+ await withTenant(tenantId, (tx) =>
2358
+ tx.platformJob.update({
2359
+ where: { id: jobId },
2360
+ data: {
2361
+ progress: Math.min(90, Math.round((i / Math.max(plan.length, 1)) * 90)),
2362
+ result: { currentStep: { stepNumber, description } } as any,
2363
+ heartbeatAt: new Date(),
2364
+ },
2365
+ })
2366
+ ).catch(() => {});
2367
+
2368
+ try {
2369
+ const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
2370
+ if (heal.healed) {
2371
+ result.healed = true;
2372
+ result.healTier = heal.healedVia;
2373
+ result.healReasoning = heal.healReasoning;
2374
+ }
2375
+ await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => {});
2376
+ result.status = result.healed ? 'HEALED' : 'PASSED';
2377
+ } catch (err: any) {
2378
+ result.status = step.optional ? 'SKIPPED' : 'FAILED';
2379
+ result.error = String(err?.message ?? err).slice(0, 300);
2380
+ }
2381
+
2382
+ const currentUrl = page.url();
2383
+ result.url = currentUrl;
2384
+ const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === 'FAILED' ? '-err' : ''}.png`;
2385
+ const ssPath = path.join(screenshotDir, ssFilename);
2386
+ await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {});
2387
+ result.screenshotPath = ssPath;
2388
+
2389
+ let cloudUrl: string | undefined;
2390
+ if (tenantStorageCfg) {
2391
+ const key = buildArtifactKey(tenantId, projectId, 'screenshots', ssFilename, tenantStorageCfg.prefix);
2392
+ cloudUrl = (await uploadScreenshotToTenantStorage(tenantStorageCfg, ssPath, key).catch(() => undefined)) ?? undefined;
2393
+ if (cloudUrl) result.screenshotUrl = cloudUrl;
2394
+ }
2395
+
2396
+ const elements = await domFallbackElements(page).catch(() => []);
2397
+ result.elementsCount = elements.length;
2398
+ const stateHash = hashContent(elements.map((el) => `${el.role}:${el.meaning}:${el.notes ?? ''}`).join('|'));
2399
+ const screenKey = `${currentUrl || 'about:blank'}:${stateHash}`;
2400
+ const shouldPersistScreen = currentUrl && currentUrl !== 'about:blank' && (i === 0 || screenKey !== lastStateKey || !seenScreenKeys.has(screenKey));
2401
+ lastStateKey = screenKey;
2402
+
2403
+ if (shouldPersistScreen) {
2404
+ const screenName = screenNameFromUrl(currentUrl, tc.title ?? `Step ${stepNumber}`);
2405
+ const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? 'ENTRY_POINT' : 'LINK_FOLLOW').catch(() => null) as any;
2406
+ if (screen?.id) {
2407
+ result.screenId = screen.id;
2408
+ await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => {});
2409
+ for (const el of elements) {
2410
+ await AppBrain.saveElement(
2411
+ tenantId, projectId, screen.id,
2412
+ el.meaning, el.role, el.expectedData, el.notes,
2413
+ undefined, true, el.boundingRect, el.ariaState, el.parentLandmark,
2414
+ ).catch(() => {});
2415
+ }
2416
+ for (const loc of elementsToPlaywrightLocators(elements as any)) {
2417
+ if (seenLocatorExpressions.has(loc.expression)) continue;
2418
+ seenLocatorExpressions.add(loc.expression);
2419
+ locators.push(loc);
2420
+ }
2421
+ if (!seenScreenKeys.has(screenKey)) {
2422
+ screens.push({ stepNumber, screenId: screen.id, url: currentUrl, screenshotPath: ssPath, screenshotUrl: cloudUrl, isNewScreen: true });
2423
+ seenScreenKeys.add(screenKey);
2424
+ }
2425
+ }
2426
+ }
2427
+
2428
+ stepResults.push(result);
2429
+ if (result.status === 'FAILED') break;
2430
+ }
2431
+
2432
+ if (locators.length > 0) {
2433
+ await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => {});
2434
+ }
2435
+
2436
+ const firstScreen = screens[0];
2437
+ await withTenant(tenantId, (tx) =>
2438
+ (tx as any).testCase.update({
2439
+ where: { id: testCaseId },
2440
+ data: {
2441
+ ...(firstScreen?.screenId ? { screenId: firstScreen.screenId } : {}),
2442
+ captureStepScreenshots: stepResults as any,
2443
+ },
2444
+ })
2445
+ ).catch(() => {});
2446
+
2447
+ const finalResult = {
2448
+ stepResults,
2449
+ screens,
2450
+ locators,
2451
+ totalSteps: plan.length,
2452
+ capturedScreens: screens.length,
2453
+ };
2454
+ await withTenant(tenantId, (tx) =>
2455
+ tx.platformJob.update({
2456
+ where: { id: jobId },
2457
+ data: { status: 'DONE', progress: 100, result: finalResult as any, finishedAt: new Date(), heartbeatAt: new Date() },
2458
+ })
2459
+ );
2460
+ } catch (err: any) {
2461
+ const error = String(err?.message ?? err);
2462
+ await withTenant(tenantId, (tx) =>
2463
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error, finishedAt: new Date(), heartbeatAt: new Date() } })
2464
+ ).catch(() => {});
2465
+ logger.error({ jobId, testCaseId, err: error }, 'processCaptureStepsJob failed');
2466
+ throw err;
2467
+ } finally {
2468
+ await browser?.close?.().catch(() => {});
2469
+ }
2470
+ }
2471
+
2208
2472
  // Registers the type→processor dispatch table used by the durable worker (both the
2209
2473
  // enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
2210
2474
  // bottom so it can reference every process*Job function by name; function
@@ -2213,6 +2477,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2213
2477
  // against the actual functions above.
2214
2478
  jobDispatchTable = {
2215
2479
  CRAWL: processJob,
2480
+ CAPTURE_STEPS: processCaptureStepsJob,
2216
2481
  DISCOVER: processDiscoverJob,
2217
2482
  MONITOR: processMonitorJob,
2218
2483
  NATIVE_CRAWL: processNativeCrawlJob,
@@ -2240,7 +2505,7 @@ async function processRepoScanJob(jobId: string, config: any) {
2240
2505
  const {
2241
2506
  fetchRepoTree, fetchFileContent, detectFramework,
2242
2507
  extractTestIds, extractStyleProfile,
2243
- buildPageObjectInventory,
2508
+ buildPageObjectInventory, resolveFixtureAliasesFromImports,
2244
2509
  } = await import('@detiq/agents');
2245
2510
 
2246
2511
  const conn = await withTenant(tenantId, (tx) =>
@@ -2260,6 +2525,9 @@ async function processRepoScanJob(jobId: string, config: any) {
2260
2525
  await updateJobProgress(tenantId, jobId, 15, 'Fetching repo tree');
2261
2526
  // Use HEAD so it always resolves to the actual default branch regardless of stored value
2262
2527
  const treeResult = await fetchRepoTree(token, conn.repoFullName, 'HEAD');
2528
+ if (treeResult.truncated) {
2529
+ console.warn(`[repo-scan] ${conn.repoFullName}: tree truncated (>100k blobs) — detection may be incomplete`);
2530
+ }
2263
2531
 
2264
2532
  const frameworks = detectFramework(treeResult.paths);
2265
2533
  const primary = frameworks[0];
@@ -2295,6 +2563,21 @@ async function processRepoScanJob(jobId: string, config: any) {
2295
2563
 
2296
2564
  await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
2297
2565
 
2566
+ if (testPaths.length === 0) {
2567
+ logger.warn({ jobId, repoConnectionId, framework }, '[repo-scan] no test files matched glob — scan complete with 0 results');
2568
+ await withTenant(tenantId, (tx) =>
2569
+ tx.gitHubRepoConnection.update({
2570
+ where: { id: repoConnectionId },
2571
+ data: { scanStatus: 'DONE', testFileCount: 0, extractedIdCount: 0, lastScannedAt: new Date(), lastSyncAt: new Date() },
2572
+ })
2573
+ );
2574
+ await updateJobProgress(tenantId, jobId, 100, 'Done: 0 test files found');
2575
+ await withTenant(tenantId, (tx) =>
2576
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'DONE', finishedAt: new Date(), result: { testFileCount: 0, extractedIdCount: 0 } as any } })
2577
+ );
2578
+ return;
2579
+ }
2580
+
2298
2581
  // Extract test IDs from each file
2299
2582
  const allExtracted: any[] = [];
2300
2583
  const styleSampleFiles: Array<{ path: string; content: string }> = [];
@@ -2307,7 +2590,11 @@ async function processRepoScanJob(jobId: string, config: any) {
2307
2590
  const content = await fetchFileContent(token, conn.repoFullName, filePath);
2308
2591
  if (!content) continue;
2309
2592
 
2310
- const extracted = extractTestIds(content, filePath, framework);
2593
+ // G-07: multi-file fixture tracing — resolve non-obvious aliases before extraction
2594
+ const extraAliases = await resolveFixtureAliasesFromImports(
2595
+ content, token, conn.repoFullName, filePath,
2596
+ ).catch(() => new Set<string>());
2597
+ const extracted = extractTestIds(content, filePath, framework, extraAliases);
2311
2598
  allExtracted.push(...extracted);
2312
2599
 
2313
2600
  await withTenant(tenantId, (tx) =>
@@ -2333,6 +2620,161 @@ async function processRepoScanJob(jobId: string, config: any) {
2333
2620
  }
2334
2621
  }
2335
2622
 
2623
+ // Fetch and parse framework config files (playwright.config.ts, cypress.config.ts, tsconfig.json)
2624
+ // Fetch and parse framework config files for all supported frameworks.
2625
+ // Pure regex/JSON — no LLM. Results override LLM inference for path detection.
2626
+ // Covers: playwright, cypress, jest, pytest, junit/maven/gradle, selenium-java/py,
2627
+ // webdriverio, cucumber — all frameworks in DetectedFramework.
2628
+ const configPaths: {
2629
+ testDir?: string; fixturesFolder?: string; baseURL?: string;
2630
+ tsconfigPaths?: Record<string, string[]>; tsconfigBaseUrl?: string;
2631
+ javaTestSourceDir?: string; jestModuleNameMapper?: Record<string, string>;
2632
+ } = {};
2633
+ try {
2634
+ const pathSet = new Set(treeResult.paths as string[]);
2635
+ const lang = framework.includes('java') ? 'java'
2636
+ : (framework.includes('py') || framework === 'pytest') ? 'python' : 'js';
2637
+
2638
+ const configFilesToFetch: Array<{ path: string; type: string; dirPrefix: string }> = [];
2639
+ const tryAdd = (candidates: string[], type: string) => {
2640
+ for (const c of candidates) { if (pathSet.has(c)) { configFilesToFetch.push({ path: c, type, dirPrefix: '' }); break; } }
2641
+ };
2642
+ // Monorepo fallback: when root-level config is absent, find shallowest match anywhere in tree.
2643
+ // The dirPrefix (e.g. 'packages/e2e/') is prepended to testDir so paths are repo-root-relative.
2644
+ const scanTree = (pattern: RegExp, type: string) => {
2645
+ if (configFilesToFetch.some(f => f.type === type)) return;
2646
+ const all = (treeResult.paths as string[]).filter(p => pattern.test(p)).sort((a, b) => a.split('/').length - b.split('/').length);
2647
+ if (all[0]) {
2648
+ const lastSlash = all[0].lastIndexOf('/');
2649
+ const dirPrefix = lastSlash > 0 ? all[0].slice(0, lastSlash + 1) : '';
2650
+ configFilesToFetch.push({ path: all[0], type, dirPrefix });
2651
+ }
2652
+ };
2653
+
2654
+ if (lang === 'js') {
2655
+ if (framework.startsWith('playwright')) {
2656
+ tryAdd(['playwright.config.ts', 'playwright.config.js'], 'playwright');
2657
+ scanTree(/(?:^|\/)playwright\.config\.(ts|js|mts)$/, 'playwright');
2658
+ }
2659
+ if (framework.startsWith('cypress')) {
2660
+ tryAdd(['cypress.config.ts', 'cypress.config.js'], 'cypress');
2661
+ scanTree(/(?:^|\/)cypress\.config\.(ts|js)$/, 'cypress');
2662
+ }
2663
+ if (framework === 'jest-ts' || framework === 'jest-js') {
2664
+ tryAdd(['jest.config.ts', 'jest.config.js', 'jest.config.mjs'], 'jest');
2665
+ scanTree(/(?:^|\/)jest\.config\.(ts|js|mjs|cjs)$/, 'jest');
2666
+ tryAdd(['package.json'], 'package-json');
2667
+ }
2668
+ if (framework === 'webdriverio') {
2669
+ tryAdd(['wdio.conf.ts', 'wdio.conf.js', 'wdio.config.ts', 'wdio.config.js'], 'wdio');
2670
+ scanTree(/(?:^|\/)wdio\.(?:conf|config)\.(ts|js|mjs)$/, 'wdio');
2671
+ }
2672
+ if (framework === 'cucumber' || framework === 'gherkin') {
2673
+ tryAdd(['.cucumber.js', '.cucumber.cjs', 'cucumber.json', '.cucumber.yaml', '.cucumber.yml'], 'cucumber');
2674
+ tryAdd(['package.json'], 'package-json');
2675
+ }
2676
+ tryAdd(['tsconfig.json'], 'tsconfig');
2677
+ scanTree(/(?:^|\/)tsconfig(?:\.base)?\.json$/, 'tsconfig');
2678
+ } else if (lang === 'python') {
2679
+ tryAdd(['pytest.ini'], 'pytest-ini');
2680
+ scanTree(/(?:^|\/)pytest\.ini$/, 'pytest-ini');
2681
+ tryAdd(['pyproject.toml'], 'pyproject-toml');
2682
+ scanTree(/(?:^|\/)pyproject\.toml$/, 'pyproject-toml');
2683
+ tryAdd(['setup.cfg'], 'setup-cfg');
2684
+ } else if (lang === 'java') {
2685
+ tryAdd(['pom.xml'], 'pom-xml');
2686
+ scanTree(/(?:^|\/)pom\.xml$/, 'pom-xml');
2687
+ tryAdd(['build.gradle.kts', 'build.gradle'], 'build-gradle');
2688
+ scanTree(/(?:^|\/)build\.gradle(?:\.kts)?$/, 'build-gradle');
2689
+ tryAdd(['testng.xml', 'src/test/resources/testng.xml'], 'testng-xml');
2690
+ }
2691
+
2692
+ // Inline parsers — mirror parseFrameworkConfig from @detiq/agents (kept inline to avoid build dep)
2693
+ const normDir = (s: string) => s.replace(/^\.\//, '').replace(/\/$/, '');
2694
+ const globRoot = (pattern: string): string | null => {
2695
+ const root = pattern.replace(/\/\*\*.*$/, '').replace(/\/\*[^*].*$/, '');
2696
+ return (root && !root.startsWith('*') && !root.startsWith('{')) ? normDir(root) : null;
2697
+ };
2698
+
2699
+ const fetched = await Promise.all(
2700
+ configFilesToFetch.map(async ({ path: cfgPath, type, dirPrefix }) => {
2701
+ const content = await fetchFileContent(token, conn.repoFullName, cfgPath).catch(() => null);
2702
+ if (!content) return null;
2703
+ const r: typeof configPaths = {};
2704
+
2705
+ if (type === 'playwright') {
2706
+ const m = content.match(/\btestDir\s*:\s*['"`]([^'"`]+)['"`]/);
2707
+ if (m) r.testDir = normDir(m[1]);
2708
+ const bm = content.match(/\bbaseURL\s*:\s*['"`]([^'"`]+)['"`]/);
2709
+ if (bm) r.baseURL = bm[1];
2710
+ } else if (type === 'cypress') {
2711
+ const sm = content.match(/\bspecPattern\s*:\s*['"`]([^'"`]+)['"`]/);
2712
+ if (sm) { const gr = globRoot(sm[1]); if (gr) r.testDir = gr; }
2713
+ const fm = content.match(/\bfixturesFolder\s*:\s*['"`]([^'"`]+)['"`]/);
2714
+ if (fm) r.fixturesFolder = normDir(fm[1]);
2715
+ const bm = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
2716
+ if (bm) r.baseURL = bm[1];
2717
+ } else if (type === 'jest') {
2718
+ const rm = content.match(/\broots\s*:\s*\[([^\]]+)\]/);
2719
+ if (rm) { const fr = rm[1].match(/['"`]([^'"`]+)['"`]/)?.[1]; if (fr) r.testDir = fr.replace(/<rootDir>\/?/, '').replace(/\/$/, '') || 'src'; }
2720
+ if (!r.testDir) { const tm = content.match(/\btestMatch\s*:\s*\[([^\]]+)\]/); if (tm) { const fp = tm[1].match(/['"`]([^'"`]+)['"`]/)?.[1]; if (fp) { const gr = globRoot(fp.replace(/<rootDir>\/?/, '')); if (gr) r.testDir = gr; } } }
2721
+ const mm = content.match(/\bmoduleNameMapper\s*:\s*\{([^}]+)\}/s);
2722
+ if (mm) { const map: Record<string, string> = {}; for (const [, k, v] of mm[1].matchAll(/['"`]([^'"`]+)['"`]\s*:\s*['"`]([^'"`]+)['"`]/g)) map[k] = v; if (Object.keys(map).length) r.jestModuleNameMapper = map; }
2723
+ } else if (type === 'package-json') {
2724
+ try { const p = JSON.parse(content) as any; const j = p?.jest ?? {}; if (Array.isArray(j.roots) && j.roots[0]) r.testDir = String(j.roots[0]).replace(/<rootDir>\/?/, '').replace(/\/$/, '') || 'src'; if (!r.testDir && Array.isArray(j.testMatch) && j.testMatch[0]) { const gr = globRoot(String(j.testMatch[0]).replace(/<rootDir>\/?/, '')); if (gr) r.testDir = gr; } if (j.moduleNameMapper && typeof j.moduleNameMapper === 'object') r.jestModuleNameMapper = j.moduleNameMapper; } catch { /* ignore */ }
2725
+ } else if (type === 'tsconfig') {
2726
+ try { const stripped = content.replace(/\/\/[^\n]*/g, '').replace(/,(\s*[}\]])/g, '$1'); const p = JSON.parse(stripped) as any; const opts = p?.compilerOptions ?? {}; if (opts.paths && typeof opts.paths === 'object') r.tsconfigPaths = opts.paths; if (typeof opts.baseUrl === 'string') r.tsconfigBaseUrl = opts.baseUrl; } catch { /* ignore */ }
2727
+ } else if (type === 'pytest-ini') {
2728
+ const m = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
2729
+ if (m) { const first = m[1].trim().split(/\s+/)[0]; if (first) r.testDir = normDir(first); }
2730
+ } else if (type === 'pyproject-toml') {
2731
+ const m = content.match(/testpaths\s*=\s*\[([^\]]+)\]/);
2732
+ if (m) { const first = m[1].match(/['"]([^'"]+)['"]/)?.[1]; if (first) r.testDir = normDir(first); }
2733
+ if (!r.testDir) { const m2 = content.match(/testpaths\s*=\s*['"]([^'"]+)['"]/); if (m2) r.testDir = normDir(m2[1]); }
2734
+ } else if (type === 'setup-cfg') {
2735
+ const m = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
2736
+ if (m) { const first = m[1].trim().split(/[\s,]+/)[0]; if (first) r.testDir = normDir(first); }
2737
+ } else if (type === 'pom-xml') {
2738
+ const m = content.match(/<testSourceDirectory>\s*([^<]+)\s*<\/testSourceDirectory>/);
2739
+ if (m) r.javaTestSourceDir = normDir(m[1].trim());
2740
+ } else if (type === 'build-gradle') {
2741
+ const m = content.match(/srcDir[s]?\s*[=([]?\s*['"`]([^'"`]+)['"`]/) ?? content.match(/srcDir\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
2742
+ if (m) r.javaTestSourceDir = normDir(m[1]);
2743
+ } else if (type === 'wdio') {
2744
+ const sm = content.match(/\bspecs\s*:\s*\[([^\]]+)\]/);
2745
+ if (sm) { const fp = sm[1].match(/['"`]([^'"`]+)['"`]/)?.[1]; if (fp) { const gr = globRoot(fp); if (gr) r.testDir = gr; } }
2746
+ const bm = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
2747
+ if (bm) r.baseURL = bm[1];
2748
+ } else if (type === 'cucumber') {
2749
+ const pm = content.match(/\bpaths\s*:\s*\[([^\]]+)\]/);
2750
+ if (pm) { const fp = pm[1].match(/['"`]([^'"`]+)['"`]/)?.[1]; if (fp) { const gr = globRoot(fp); if (gr) r.testDir = gr; } }
2751
+ if (!r.testDir) { try { const p = JSON.parse(content) as any; const paths = p?.default?.paths ?? p?.paths; if (Array.isArray(paths) && paths[0]) { const gr = globRoot(String(paths[0])); if (gr) r.testDir = gr; } } catch { /* not JSON */ } }
2752
+ }
2753
+ // Monorepo workspace prefix: config found at e.g. packages/frontend/playwright.config.ts.
2754
+ // testDir from config is relative to that file ('tests' or 'e2e'). Prepend the directory
2755
+ // so the final path is repo-root-relative ('packages/frontend/tests').
2756
+ if (dirPrefix && r.testDir && !r.testDir.startsWith(dirPrefix)) {
2757
+ r.testDir = dirPrefix + r.testDir;
2758
+ }
2759
+ if (dirPrefix && r.javaTestSourceDir && !r.javaTestSourceDir.startsWith(dirPrefix)) {
2760
+ r.javaTestSourceDir = dirPrefix + r.javaTestSourceDir;
2761
+ }
2762
+ return r;
2763
+ })
2764
+ );
2765
+
2766
+ for (const parsed of fetched) {
2767
+ if (!parsed) continue;
2768
+ if (parsed.testDir && !configPaths.testDir) configPaths.testDir = parsed.testDir;
2769
+ if (parsed.fixturesFolder && !configPaths.fixturesFolder) configPaths.fixturesFolder = parsed.fixturesFolder;
2770
+ if (parsed.baseURL && !configPaths.baseURL) configPaths.baseURL = parsed.baseURL;
2771
+ if (parsed.tsconfigPaths && !configPaths.tsconfigPaths) configPaths.tsconfigPaths = parsed.tsconfigPaths;
2772
+ if (parsed.tsconfigBaseUrl && !configPaths.tsconfigBaseUrl) configPaths.tsconfigBaseUrl = parsed.tsconfigBaseUrl;
2773
+ if (parsed.javaTestSourceDir && !configPaths.javaTestSourceDir) configPaths.javaTestSourceDir = parsed.javaTestSourceDir;
2774
+ if (parsed.jestModuleNameMapper && !configPaths.jestModuleNameMapper) configPaths.jestModuleNameMapper = parsed.jestModuleNameMapper;
2775
+ }
2776
+ } catch { /* non-fatal */ }
2777
+
2336
2778
  // Extract style profile for aligned generation
2337
2779
  // For Java, include main source paths so LLM can detect page object location in src/main/java/
2338
2780
  // Fall back to existing styleProfile from DB if extraction fails or returns null.
@@ -2357,11 +2799,28 @@ async function processRepoScanJob(jobId: string, config: any) {
2357
2799
  .slice(0, 60)
2358
2800
  : [];
2359
2801
  const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
2360
- const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile);
2802
+ // Pass full tree paths separately so detectDataDirs can find testdata/*.json, fixtures/*.yaml etc.
2803
+ const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile, treeResult.paths);
2361
2804
  if (freshProfile) styleProfile = freshProfile;
2362
2805
  } catch { /* non-fatal */ }
2363
2806
  }
2364
2807
 
2808
+ // Merge config file ground-truth into styleProfile (Priority 0 — highest, no LLM inference needed).
2809
+ // Java: javaTestSourceDir also sets testLocation (equivalent field for Java).
2810
+ if (styleProfile && Object.keys(configPaths).length > 0) {
2811
+ if (configPaths.testDir) styleProfile.configTestDir = configPaths.testDir;
2812
+ if (configPaths.javaTestSourceDir) {
2813
+ styleProfile.configTestDir = configPaths.javaTestSourceDir;
2814
+ // Also set testLocation if not already present from LLM scan
2815
+ if (!styleProfile.testLocation) styleProfile.testLocation = configPaths.javaTestSourceDir;
2816
+ }
2817
+ if (configPaths.fixturesFolder) styleProfile.configFixturesFolder = configPaths.fixturesFolder;
2818
+ if (configPaths.baseURL) styleProfile.configBaseURL = configPaths.baseURL;
2819
+ if (configPaths.tsconfigPaths) styleProfile.tsconfigPaths = configPaths.tsconfigPaths;
2820
+ if (configPaths.tsconfigBaseUrl) styleProfile.tsconfigBaseUrl = configPaths.tsconfigBaseUrl;
2821
+ if (configPaths.jestModuleNameMapper) styleProfile.jestModuleNameMapper = configPaths.jestModuleNameMapper;
2822
+ }
2823
+
2365
2824
  // Extract page object inventory — finds POM/page classes in non-test files
2366
2825
  // Fall back to existing inventory from DB if fetch fails.
2367
2826
  let pageObjectInventory: any[] = (conn.pageObjectInventory as any[]) ?? [];
@@ -2451,6 +2910,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2451
2910
  pageObjectInventory,
2452
2911
  testPaths,
2453
2912
  extractedTestCount: allExtracted.length,
2913
+ treeWasTruncated: treeResult.truncated,
2914
+ extractedTests: allExtracted,
2454
2915
  });
2455
2916
  } catch (e: any) {
2456
2917
  logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] repo skill files generation failed (non-fatal)');
@@ -2462,7 +2923,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2462
2923
  );
2463
2924
  aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
2464
2925
  } catch (err: any) {
2465
- const msg = String(err?.message ?? err);
2926
+ const cause = err?.cause?.message ?? (typeof err?.cause === 'string' ? err.cause : '');
2927
+ const msg = `${String(err?.message ?? err)}${cause ? ` — ${cause}` : ''}`;
2466
2928
  logger.error({ jobId, repoConnectionId, err: msg }, '[repo-scan] failed');
2467
2929
  await withTenant(tenantId, (tx) =>
2468
2930
  tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: 'FAILED', scanError: msg.slice(0, 500) } })