@detiq/crawler 1.1.11 → 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) =>
@@ -2348,31 +2635,57 @@ async function processRepoScanJob(jobId: string, config: any) {
2348
2635
  const lang = framework.includes('java') ? 'java'
2349
2636
  : (framework.includes('py') || framework === 'pytest') ? 'python' : 'js';
2350
2637
 
2351
- const configFilesToFetch: Array<{ path: string; type: string }> = [];
2638
+ const configFilesToFetch: Array<{ path: string; type: string; dirPrefix: string }> = [];
2352
2639
  const tryAdd = (candidates: string[], type: string) => {
2353
- for (const c of candidates) { if (pathSet.has(c)) { configFilesToFetch.push({ path: c, type }); break; } }
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
+ }
2354
2652
  };
2355
2653
 
2356
2654
  if (lang === 'js') {
2357
- if (framework.startsWith('playwright')) tryAdd(['playwright.config.ts', 'playwright.config.js'], 'playwright');
2358
- if (framework.startsWith('cypress')) tryAdd(['cypress.config.ts', 'cypress.config.js'], 'cypress');
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
+ }
2359
2663
  if (framework === 'jest-ts' || framework === 'jest-js') {
2360
2664
  tryAdd(['jest.config.ts', 'jest.config.js', 'jest.config.mjs'], 'jest');
2665
+ scanTree(/(?:^|\/)jest\.config\.(ts|js|mjs|cjs)$/, 'jest');
2361
2666
  tryAdd(['package.json'], 'package-json');
2362
2667
  }
2363
- if (framework === 'webdriverio') tryAdd(['wdio.conf.ts', 'wdio.conf.js', 'wdio.config.ts', 'wdio.config.js'], 'wdio');
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
+ }
2364
2672
  if (framework === 'cucumber' || framework === 'gherkin') {
2365
2673
  tryAdd(['.cucumber.js', '.cucumber.cjs', 'cucumber.json', '.cucumber.yaml', '.cucumber.yml'], 'cucumber');
2366
2674
  tryAdd(['package.json'], 'package-json');
2367
2675
  }
2368
2676
  tryAdd(['tsconfig.json'], 'tsconfig');
2677
+ scanTree(/(?:^|\/)tsconfig(?:\.base)?\.json$/, 'tsconfig');
2369
2678
  } else if (lang === 'python') {
2370
2679
  tryAdd(['pytest.ini'], 'pytest-ini');
2680
+ scanTree(/(?:^|\/)pytest\.ini$/, 'pytest-ini');
2371
2681
  tryAdd(['pyproject.toml'], 'pyproject-toml');
2682
+ scanTree(/(?:^|\/)pyproject\.toml$/, 'pyproject-toml');
2372
2683
  tryAdd(['setup.cfg'], 'setup-cfg');
2373
2684
  } else if (lang === 'java') {
2374
2685
  tryAdd(['pom.xml'], 'pom-xml');
2686
+ scanTree(/(?:^|\/)pom\.xml$/, 'pom-xml');
2375
2687
  tryAdd(['build.gradle.kts', 'build.gradle'], 'build-gradle');
2688
+ scanTree(/(?:^|\/)build\.gradle(?:\.kts)?$/, 'build-gradle');
2376
2689
  tryAdd(['testng.xml', 'src/test/resources/testng.xml'], 'testng-xml');
2377
2690
  }
2378
2691
 
@@ -2384,7 +2697,7 @@ async function processRepoScanJob(jobId: string, config: any) {
2384
2697
  };
2385
2698
 
2386
2699
  const fetched = await Promise.all(
2387
- configFilesToFetch.map(async ({ path: cfgPath, type }) => {
2700
+ configFilesToFetch.map(async ({ path: cfgPath, type, dirPrefix }) => {
2388
2701
  const content = await fetchFileContent(token, conn.repoFullName, cfgPath).catch(() => null);
2389
2702
  if (!content) return null;
2390
2703
  const r: typeof configPaths = {};
@@ -2437,6 +2750,15 @@ async function processRepoScanJob(jobId: string, config: any) {
2437
2750
  if (pm) { const fp = pm[1].match(/['"`]([^'"`]+)['"`]/)?.[1]; if (fp) { const gr = globRoot(fp); if (gr) r.testDir = gr; } }
2438
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 */ } }
2439
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
+ }
2440
2762
  return r;
2441
2763
  })
2442
2764
  );
@@ -2477,7 +2799,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2477
2799
  .slice(0, 60)
2478
2800
  : [];
2479
2801
  const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
2480
- 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);
2481
2804
  if (freshProfile) styleProfile = freshProfile;
2482
2805
  } catch { /* non-fatal */ }
2483
2806
  }
@@ -2587,6 +2910,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2587
2910
  pageObjectInventory,
2588
2911
  testPaths,
2589
2912
  extractedTestCount: allExtracted.length,
2913
+ treeWasTruncated: treeResult.truncated,
2914
+ extractedTests: allExtracted,
2590
2915
  });
2591
2916
  } catch (e: any) {
2592
2917
  logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] repo skill files generation failed (non-fatal)');
@@ -2598,7 +2923,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2598
2923
  );
2599
2924
  aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
2600
2925
  } catch (err: any) {
2601
- 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}` : ''}`;
2602
2928
  logger.error({ jobId, repoConnectionId, err: msg }, '[repo-scan] failed');
2603
2929
  await withTenant(tenantId, (tx) =>
2604
2930
  tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: 'FAILED', scanError: msg.slice(0, 500) } })
@@ -56,6 +56,7 @@ export function elementToPlaywrightLocator(element: {
56
56
  text?: string | null;
57
57
  notes?: unknown;
58
58
  selector?: string | null;
59
+ ariaState?: Record<string, unknown> | null;
59
60
  }): PlaywrightLocator | null {
60
61
  const attrs = parseNotes(element.notes);
61
62
  const testId = attrs['data-testid'];
@@ -66,6 +67,12 @@ export function elementToPlaywrightLocator(element: {
66
67
  const text = (element.text ?? '').trim();
67
68
  const meaning = (element.meaning ?? '').trim();
68
69
  const role = mapRole(element.role);
70
+ const ariaState: Record<string, string> = {};
71
+ if (element.ariaState && typeof element.ariaState === 'object') {
72
+ for (const [k, v] of Object.entries(element.ariaState)) {
73
+ if (typeof v === 'string') ariaState[k] = v;
74
+ }
75
+ }
69
76
 
70
77
  const label = meaning || text || testId || ariaLabel || placeholder || id || name || 'element';
71
78
  const key = toKey(label);
@@ -106,7 +113,19 @@ export function elementToPlaywrightLocator(element: {
106
113
  }
107
114
  }
108
115
 
109
- // Priority 3: checkbox/radio → getByLabel
116
+ // Priority 3: checkbox/radio → getByRole with optional checked state from ariaState
117
+ if ((role === 'checkbox' || role === 'radio') && meaning) {
118
+ const isChecked = ariaState['checked'];
119
+ const stateOpt = isChecked === 'true' ? `, { name: ${JSON.stringify(meaning)}, checked: true }` : isChecked === 'false' ? `, { name: ${JSON.stringify(meaning)}, checked: false }` : `, { name: ${JSON.stringify(meaning)} }`;
120
+ return {
121
+ key,
122
+ expression: `page.getByRole(${JSON.stringify(role)}${stateOpt})`,
123
+ elementType: role,
124
+ confidence: 'high',
125
+ source: 'role-name',
126
+ description: meaning,
127
+ };
128
+ }
110
129
  if ((role === 'checkbox' || role === 'radio') && ariaLabel) {
111
130
  return {
112
131
  key,
@@ -118,11 +137,29 @@ export function elementToPlaywrightLocator(element: {
118
137
  };
119
138
  }
120
139
 
121
- // Priority 4: button/link with meaningful name → getByRole
140
+ // Priority 3b: tab/option/treeitem → getByRole with optional selected state
141
+ const tabLikeRoles = ['tab', 'option', 'treeitem', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'gridcell'];
142
+ if (tabLikeRoles.includes(role ?? '') && meaning) {
143
+ const isSelected = ariaState['selected'];
144
+ const isCurrent = ariaState['current'];
145
+ const stateOpt = isSelected === 'true' ? `, { name: ${JSON.stringify(meaning)}, selected: true }` : isCurrent ? `, { name: ${JSON.stringify(meaning)} }` : `, { name: ${JSON.stringify(meaning)} }`;
146
+ return {
147
+ key,
148
+ expression: `page.getByRole(${JSON.stringify(role)}${stateOpt})`,
149
+ elementType: role ?? 'element',
150
+ confidence: 'high',
151
+ source: 'role-name',
152
+ description: meaning,
153
+ };
154
+ }
155
+
156
+ // Priority 4: button/link with meaningful name → getByRole; include pressed state for toggles
122
157
  if ((role === 'button' || role === 'link') && meaning) {
158
+ const isPressed = ariaState['pressed'];
159
+ const stateOpt = isPressed === 'true' ? `, { name: ${JSON.stringify(meaning)}, pressed: true }` : isPressed === 'false' ? `, { name: ${JSON.stringify(meaning)}, pressed: false }` : `, { name: ${JSON.stringify(meaning)} }`;
123
160
  return {
124
161
  key,
125
- expression: `page.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(meaning)} })`,
162
+ expression: `page.getByRole(${JSON.stringify(role)}${stateOpt})`,
126
163
  elementType: role,
127
164
  confidence: 'high',
128
165
  source: 'role-name',
@@ -194,6 +231,7 @@ export function elementsToPlaywrightLocators(elements: Array<{
194
231
  notes?: unknown;
195
232
  selector?: string | null;
196
233
  isInteractive?: boolean | null;
234
+ ariaState?: Record<string, unknown> | null;
197
235
  }>): PlaywrightLocator[] {
198
236
  const CONFIDENCE_RANK = { high: 0, medium: 1, low: 2 };
199
237
  const byKey = new Map<string, PlaywrightLocator>();