@detiq/crawler 1.1.11 → 1.1.13

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'
@@ -380,12 +383,18 @@ export async function createTrackedJob(
380
383
  return job.id;
381
384
  }
382
385
 
383
- /** Mark a tracked job DONE or FAILED. Safe to call in finally — swallows errors. */
386
+ /** Mark a tracked job DONE or FAILED. Safe to call in finally — swallows errors.
387
+ * Gap 30: When marking FAILED, sets nextRetryAt (1 min) so the retry worker picks it up promptly. */
384
388
  export async function finishTrackedJob(tenantId: string, jobId: string, error?: string): Promise<void> {
389
+ const nextRetryAt = error ? new Date(Date.now() + 60_000) : undefined;
385
390
  await withTenant(tenantId, (tx) =>
386
391
  tx.platformJob.update({
387
392
  where: { id: jobId },
388
- data: { status: error ? 'FAILED' : 'DONE', finishedAt: new Date(), ...(error ? { error } : { progress: 100 }) },
393
+ data: {
394
+ status: error ? 'FAILED' : 'DONE',
395
+ finishedAt: new Date(),
396
+ ...(error ? { error, nextRetryAt } : { progress: 100 }),
397
+ },
389
398
  })
390
399
  ).catch(() => {});
391
400
  }
@@ -468,7 +477,7 @@ export async function cancelJob(tenantId: string, jobId: string): Promise<void>
468
477
  await withTenant(tenantId, (tx) =>
469
478
  tx.platformJob.update({
470
479
  where: { id: jobId },
471
- data: { status: 'CANCELLED', finishedAt: new Date() },
480
+ data: { status: 'CANCELLED', finishedAt: new Date(), heartbeatAt: null, workerId: null },
472
481
  })
473
482
  );
474
483
  }
@@ -634,7 +643,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
634
643
  await opts?.onProgress?.(pct);
635
644
  };
636
645
 
637
- const DEFAULT_MAX_SCREENS = 150;
646
+ const DEFAULT_MAX_SCREENS = parseInt(process.env.CRAWL_DEFAULT_MAX_SCREENS ?? '') || 150;
638
647
 
639
648
  try {
640
649
  // Per-project maxScreens + deviceProfiles + seedUrls + interactive probing + sitemap seeding + traversal strategy from crawlConfig override job config defaults.
@@ -783,6 +792,8 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
783
792
  }
784
793
  if (fieldDetectAdded > 0 || detectLimit > 0) {
785
794
  logger.info({ screens: detectLimit, added: fieldDetectAdded, projectId }, '[field-detect] catalog populated from crawl');
795
+ // Refresh FIELD_CATALOG skill file after catalog is populated (GAP-SF-20)
796
+ refreshFieldCatalogSkillFile(tenantId, projectId).catch(console.error);
786
797
  }
787
798
  } catch (fdErr) {
788
799
  logger.warn({ err: fdErr }, '[field-detect] post-crawl field detection failed');
@@ -1524,6 +1535,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
1524
1535
  { passed, failed, total: testCases.length },
1525
1536
  runResults,
1526
1537
  ).catch(console.error);
1538
+ refreshTestExecutionHistorySkillFile(tenantId, projectId).catch(console.error);
1527
1539
  } catch (err: any) {
1528
1540
  const msg = String(err?.message ?? err).slice(0, 500);
1529
1541
  logger.error({ jobId, testRunId, err: msg }, 'processRunTestSuiteJob failed');
@@ -1563,6 +1575,10 @@ async function processGenerateSkillFilesJob(jobId: string, config: any) {
1563
1575
  refreshTestDataSkillFile(tenantId, projectId),
1564
1576
  refreshTestScriptSkillFile(tenantId, projectId),
1565
1577
  refreshRequirementsCoverageSkillFile(tenantId, projectId),
1578
+ refreshTestExecutionHistorySkillFile(tenantId, projectId),
1579
+ refreshVisualRegressionHistorySkillFile(tenantId, projectId),
1580
+ refreshLocatorStrategyWithHealingData(tenantId, projectId),
1581
+ refreshFieldCatalogSkillFile(tenantId, projectId),
1566
1582
  ]);
1567
1583
  await withTenant(tenantId, (tx) =>
1568
1584
  tx.platformJob.update({ where: { id: jobId }, data: { status: 'DONE', progress: 100, finishedAt: new Date() } })
@@ -1601,7 +1617,7 @@ export async function processDataRetentionPurge(): Promise<void> {
1601
1617
  orderBy: { dataRetentionDays: 'asc' },
1602
1618
  select: { dataRetentionDays: true },
1603
1619
  });
1604
- const retentionDays = setting?.dataRetentionDays ?? 365;
1620
+ const retentionDays = setting?.dataRetentionDays ?? (parseInt(process.env.DEFAULT_DATA_RETENTION_DAYS ?? '') || 365);
1605
1621
  const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
1606
1622
 
1607
1623
  const result = await prismaAdmin.auditLog.deleteMany({
@@ -2205,6 +2221,260 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2205
2221
  }
2206
2222
  }
2207
2223
 
2224
+ type CaptureStepStatus = 'PASSED' | 'FAILED' | 'HEALED' | 'SKIPPED';
2225
+ type CaptureStepResult = {
2226
+ stepNumber: number;
2227
+ description: string;
2228
+ status: CaptureStepStatus;
2229
+ screenshotPath?: string;
2230
+ screenshotUrl?: string;
2231
+ screenId?: string;
2232
+ url?: string;
2233
+ elementsCount?: number;
2234
+ healed?: boolean;
2235
+ healTier?: 'mechanical' | 'ai';
2236
+ healReasoning?: string;
2237
+ error?: string;
2238
+ };
2239
+
2240
+ function captureStepText(step: unknown): string {
2241
+ if (typeof step === 'string') return step;
2242
+ if (!step || typeof step !== 'object') return '';
2243
+ const obj = step as Record<string, unknown>;
2244
+ return [obj.action, obj.text, obj.label, obj.data, obj.expected]
2245
+ .filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
2246
+ .join(' -> ');
2247
+ }
2248
+
2249
+ function firstQuoted(text: string): string | undefined {
2250
+ return (text.match(/"([^"]+)"/)?.[1] ?? text.match(/'([^']+)'/)?.[1])?.trim();
2251
+ }
2252
+
2253
+ function fallbackExecutionPlan(rawSteps: unknown[]): ExecutionStep[] {
2254
+ const plan: ExecutionStep[] = [];
2255
+ for (const raw of rawSteps) {
2256
+ const text = captureStepText(raw).trim();
2257
+ if (!text) continue;
2258
+ const stepNumber = plan.length + 1;
2259
+ const url = text.match(/https?:\/\/[^\s"']+/)?.[0];
2260
+
2261
+ if (/^navigate\b|^go to\b|^open\b/i.test(text) && url) {
2262
+ plan.push({ stepNumber, description: text, action: 'navigate', url });
2263
+ continue;
2264
+ }
2265
+
2266
+ const urlContains = text.match(/verify url contains:\s*["']?([^"']+)["']?/i);
2267
+ if (urlContains) {
2268
+ plan.push({ stepNumber, description: text, action: 'assert_url', value: urlContains[1].trim() });
2269
+ continue;
2270
+ }
2271
+
2272
+ const inputMatch = text.match(/(?:enter|type|fill)\s+["']([^"']+)["']\s+(?:in|into)\s+(.+?)(?:\s+field|\s+input|\s+box|\s+area)?(?:\s*→.*)?$/i);
2273
+ if (inputMatch) {
2274
+ const selector = inputMatch[2].replace(/^(the\s+)?/i, '').replace(/\s+(field|input|box|area)$/i, '').trim();
2275
+ plan.push({ stepNumber, description: text, action: 'fill', selectorStrategy: 'label', selector, value: inputMatch[1].trim() });
2276
+ continue;
2277
+ }
2278
+
2279
+ const quoted = firstQuoted(text);
2280
+ if (/^(click|tap|navigate menu|check|select)\b/i.test(text) && quoted) {
2281
+ plan.push({ stepNumber, description: text, action: 'click', selectorStrategy: 'text', selector: quoted });
2282
+ continue;
2283
+ }
2284
+ if (/^(verify|assert|expect)\b/i.test(text) && quoted) {
2285
+ plan.push({ stepNumber, description: text, action: 'assert_visible', selectorStrategy: 'text', selector: quoted });
2286
+ continue;
2287
+ }
2288
+ if (/execute script/i.test(text)) {
2289
+ const script = text.split(':').slice(1).join(':').replace(/\s*→.*$/, '').trim();
2290
+ if (script) plan.push({ stepNumber, description: text, action: 'script', value: script });
2291
+ }
2292
+ }
2293
+ return plan;
2294
+ }
2295
+
2296
+ function screenNameFromUrl(url: string, fallback: string): string {
2297
+ try {
2298
+ const u = new URL(url);
2299
+ return u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).pop() || u.hostname || fallback;
2300
+ } catch {
2301
+ return fallback;
2302
+ }
2303
+ }
2304
+
2305
+ async function processCaptureStepsJob(jobId: string, config: any) {
2306
+ const { tenantId, projectId, testCaseId } = config as { tenantId: string; projectId: string; testCaseId: string };
2307
+ const screenshotDir = config.screenshotDir || platformConfig.screenshotDir || process.env.SCREENSHOT_DIR || '/tmp/zeta-screenshots';
2308
+ await fs.mkdir(screenshotDir, { recursive: true });
2309
+
2310
+ await withTenant(tenantId, (tx) =>
2311
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'RUNNING', startedAt: new Date(), progress: 2 } })
2312
+ );
2313
+
2314
+ let browser: any;
2315
+ try {
2316
+ await updateJobProgress(tenantId, jobId, 5, 'Loading test case');
2317
+ const [tc, crawlCfg, tenantStorageCfg] = await Promise.all([
2318
+ AppBrain.getTestCaseForExecution(tenantId, testCaseId) as Promise<any>,
2319
+ AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null),
2320
+ getTenantStorageConfig(tenantId).catch(() => null),
2321
+ ]);
2322
+ if (!tc) throw new Error('Test case not found');
2323
+
2324
+ const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
2325
+ const stepTexts = rawSteps.map(captureStepText).filter(Boolean);
2326
+ if (stepTexts.length === 0) throw new Error('Test case has no steps to capture');
2327
+
2328
+ const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
2329
+ let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
2330
+ plan = Array.isArray(plan)
2331
+ ? plan.filter(Boolean).map((step: any, idx: number) => ({ ...step, stepNumber: idx + 1 }))
2332
+ : [];
2333
+ if (plan.length === 0) plan = fallbackExecutionPlan(rawSteps);
2334
+ if (plan.length === 0) throw new Error('Could not generate execution plan from steps');
2335
+
2336
+ const { chromium } = await import('playwright');
2337
+ browser = await chromium.launch({
2338
+ headless: true,
2339
+ ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
2340
+ });
2341
+ let parsedStorageState: any;
2342
+ try {
2343
+ parsedStorageState = config.storageState ? JSON.parse(config.storageState) : crawlCfg?.storageState ? JSON.parse(crawlCfg.storageState) : undefined;
2344
+ } catch {
2345
+ parsedStorageState = undefined;
2346
+ }
2347
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, ...(parsedStorageState ? { storageState: parsedStorageState } : {}) });
2348
+ const page = await context.newPage();
2349
+ const aiHealingEnabled = await isFeatureEnabled(tenantId, 'AI_SELF_HEALING').catch(() => false);
2350
+
2351
+ const stepResults: CaptureStepResult[] = [];
2352
+ const screens: Array<{ stepNumber: number; screenId: string; url: string; screenshotPath?: string; screenshotUrl?: string; isNewScreen: boolean }> = [];
2353
+ const locators: any[] = [];
2354
+ const seenLocatorExpressions = new Set<string>();
2355
+ const seenScreenKeys = new Set<string>();
2356
+ let lastStateKey = '';
2357
+
2358
+ for (let i = 0; i < plan.length; i++) {
2359
+ const step = plan[i];
2360
+ const description = step.description || stepTexts[i] || `Step ${i + 1}`;
2361
+ const stepNumber = i + 1;
2362
+ const result: CaptureStepResult = { stepNumber, description, status: 'FAILED' };
2363
+ await withTenant(tenantId, (tx) =>
2364
+ tx.platformJob.update({
2365
+ where: { id: jobId },
2366
+ data: {
2367
+ progress: Math.min(90, Math.round((i / Math.max(plan.length, 1)) * 90)),
2368
+ result: { currentStep: { stepNumber, description } } as any,
2369
+ heartbeatAt: new Date(),
2370
+ },
2371
+ })
2372
+ ).catch(() => {});
2373
+
2374
+ try {
2375
+ const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
2376
+ if (heal.healed) {
2377
+ result.healed = true;
2378
+ result.healTier = heal.healedVia;
2379
+ result.healReasoning = heal.healReasoning;
2380
+ }
2381
+ await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => {});
2382
+ result.status = result.healed ? 'HEALED' : 'PASSED';
2383
+ } catch (err: any) {
2384
+ result.status = step.optional ? 'SKIPPED' : 'FAILED';
2385
+ result.error = String(err?.message ?? err).slice(0, 300);
2386
+ }
2387
+
2388
+ const currentUrl = page.url();
2389
+ result.url = currentUrl;
2390
+ const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === 'FAILED' ? '-err' : ''}.png`;
2391
+ const ssPath = path.join(screenshotDir, ssFilename);
2392
+ await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {});
2393
+ result.screenshotPath = ssPath;
2394
+
2395
+ let cloudUrl: string | undefined;
2396
+ if (tenantStorageCfg) {
2397
+ const key = buildArtifactKey(tenantId, projectId, 'screenshots', ssFilename, tenantStorageCfg.prefix);
2398
+ cloudUrl = (await uploadScreenshotToTenantStorage(tenantStorageCfg, ssPath, key).catch(() => undefined)) ?? undefined;
2399
+ if (cloudUrl) result.screenshotUrl = cloudUrl;
2400
+ }
2401
+
2402
+ const elements = await domFallbackElements(page).catch(() => []);
2403
+ result.elementsCount = elements.length;
2404
+ const stateHash = hashContent(elements.map((el) => `${el.role}:${el.meaning}:${el.notes ?? ''}`).join('|'));
2405
+ const screenKey = `${currentUrl || 'about:blank'}:${stateHash}`;
2406
+ const shouldPersistScreen = currentUrl && currentUrl !== 'about:blank' && (i === 0 || screenKey !== lastStateKey || !seenScreenKeys.has(screenKey));
2407
+ lastStateKey = screenKey;
2408
+
2409
+ if (shouldPersistScreen) {
2410
+ const screenName = screenNameFromUrl(currentUrl, tc.title ?? `Step ${stepNumber}`);
2411
+ const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? 'ENTRY_POINT' : 'LINK_FOLLOW').catch(() => null) as any;
2412
+ if (screen?.id) {
2413
+ result.screenId = screen.id;
2414
+ await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => {});
2415
+ for (const el of elements) {
2416
+ await AppBrain.saveElement(
2417
+ tenantId, projectId, screen.id,
2418
+ el.meaning, el.role, el.expectedData, el.notes,
2419
+ undefined, true, el.boundingRect, el.ariaState, el.parentLandmark,
2420
+ ).catch(() => {});
2421
+ }
2422
+ for (const loc of elementsToPlaywrightLocators(elements as any)) {
2423
+ if (seenLocatorExpressions.has(loc.expression)) continue;
2424
+ seenLocatorExpressions.add(loc.expression);
2425
+ locators.push(loc);
2426
+ }
2427
+ if (!seenScreenKeys.has(screenKey)) {
2428
+ screens.push({ stepNumber, screenId: screen.id, url: currentUrl, screenshotPath: ssPath, screenshotUrl: cloudUrl, isNewScreen: true });
2429
+ seenScreenKeys.add(screenKey);
2430
+ }
2431
+ }
2432
+ }
2433
+
2434
+ stepResults.push(result);
2435
+ if (result.status === 'FAILED') break;
2436
+ }
2437
+
2438
+ if (locators.length > 0) {
2439
+ await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => {});
2440
+ }
2441
+
2442
+ const firstScreen = screens[0];
2443
+ await withTenant(tenantId, (tx) =>
2444
+ (tx as any).testCase.update({
2445
+ where: { id: testCaseId },
2446
+ data: {
2447
+ ...(firstScreen?.screenId ? { screenId: firstScreen.screenId } : {}),
2448
+ captureStepScreenshots: stepResults as any,
2449
+ },
2450
+ })
2451
+ ).catch(() => {});
2452
+
2453
+ const finalResult = {
2454
+ stepResults,
2455
+ screens,
2456
+ locators,
2457
+ totalSteps: plan.length,
2458
+ capturedScreens: screens.length,
2459
+ };
2460
+ await withTenant(tenantId, (tx) =>
2461
+ tx.platformJob.update({
2462
+ where: { id: jobId },
2463
+ data: { status: 'DONE', progress: 100, result: finalResult as any, finishedAt: new Date(), heartbeatAt: new Date() },
2464
+ })
2465
+ );
2466
+ } catch (err: any) {
2467
+ const error = String(err?.message ?? err);
2468
+ await withTenant(tenantId, (tx) =>
2469
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error, finishedAt: new Date(), heartbeatAt: new Date() } })
2470
+ ).catch(() => {});
2471
+ logger.error({ jobId, testCaseId, err: error }, 'processCaptureStepsJob failed');
2472
+ throw err;
2473
+ } finally {
2474
+ await browser?.close?.().catch(() => {});
2475
+ }
2476
+ }
2477
+
2208
2478
  // Registers the type→processor dispatch table used by the durable worker (both the
2209
2479
  // enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
2210
2480
  // bottom so it can reference every process*Job function by name; function
@@ -2213,6 +2483,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
2213
2483
  // against the actual functions above.
2214
2484
  jobDispatchTable = {
2215
2485
  CRAWL: processJob,
2486
+ CAPTURE_STEPS: processCaptureStepsJob,
2216
2487
  DISCOVER: processDiscoverJob,
2217
2488
  MONITOR: processMonitorJob,
2218
2489
  NATIVE_CRAWL: processNativeCrawlJob,
@@ -2240,7 +2511,7 @@ async function processRepoScanJob(jobId: string, config: any) {
2240
2511
  const {
2241
2512
  fetchRepoTree, fetchFileContent, detectFramework,
2242
2513
  extractTestIds, extractStyleProfile,
2243
- buildPageObjectInventory,
2514
+ buildPageObjectInventory, resolveFixtureAliasesFromImports,
2244
2515
  } = await import('@detiq/agents');
2245
2516
 
2246
2517
  const conn = await withTenant(tenantId, (tx) =>
@@ -2260,6 +2531,9 @@ async function processRepoScanJob(jobId: string, config: any) {
2260
2531
  await updateJobProgress(tenantId, jobId, 15, 'Fetching repo tree');
2261
2532
  // Use HEAD so it always resolves to the actual default branch regardless of stored value
2262
2533
  const treeResult = await fetchRepoTree(token, conn.repoFullName, 'HEAD');
2534
+ if (treeResult.truncated) {
2535
+ console.warn(`[repo-scan] ${conn.repoFullName}: tree truncated (>100k blobs) — detection may be incomplete`);
2536
+ }
2263
2537
 
2264
2538
  const frameworks = detectFramework(treeResult.paths);
2265
2539
  const primary = frameworks[0];
@@ -2295,6 +2569,21 @@ async function processRepoScanJob(jobId: string, config: any) {
2295
2569
 
2296
2570
  await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
2297
2571
 
2572
+ if (testPaths.length === 0) {
2573
+ logger.warn({ jobId, repoConnectionId, framework }, '[repo-scan] no test files matched glob — scan complete with 0 results');
2574
+ await withTenant(tenantId, (tx) =>
2575
+ tx.gitHubRepoConnection.update({
2576
+ where: { id: repoConnectionId },
2577
+ data: { scanStatus: 'DONE', testFileCount: 0, extractedIdCount: 0, lastScannedAt: new Date(), lastSyncAt: new Date() },
2578
+ })
2579
+ );
2580
+ await updateJobProgress(tenantId, jobId, 100, 'Done: 0 test files found');
2581
+ await withTenant(tenantId, (tx) =>
2582
+ tx.platformJob.update({ where: { id: jobId }, data: { status: 'DONE', finishedAt: new Date(), result: { testFileCount: 0, extractedIdCount: 0 } as any } })
2583
+ );
2584
+ return;
2585
+ }
2586
+
2298
2587
  // Extract test IDs from each file
2299
2588
  const allExtracted: any[] = [];
2300
2589
  const styleSampleFiles: Array<{ path: string; content: string }> = [];
@@ -2307,7 +2596,11 @@ async function processRepoScanJob(jobId: string, config: any) {
2307
2596
  const content = await fetchFileContent(token, conn.repoFullName, filePath);
2308
2597
  if (!content) continue;
2309
2598
 
2310
- const extracted = extractTestIds(content, filePath, framework);
2599
+ // G-07: multi-file fixture tracing — resolve non-obvious aliases before extraction
2600
+ const extraAliases = await resolveFixtureAliasesFromImports(
2601
+ content, token, conn.repoFullName, filePath,
2602
+ ).catch(() => new Set<string>());
2603
+ const extracted = extractTestIds(content, filePath, framework, extraAliases);
2311
2604
  allExtracted.push(...extracted);
2312
2605
 
2313
2606
  await withTenant(tenantId, (tx) =>
@@ -2348,31 +2641,57 @@ async function processRepoScanJob(jobId: string, config: any) {
2348
2641
  const lang = framework.includes('java') ? 'java'
2349
2642
  : (framework.includes('py') || framework === 'pytest') ? 'python' : 'js';
2350
2643
 
2351
- const configFilesToFetch: Array<{ path: string; type: string }> = [];
2644
+ const configFilesToFetch: Array<{ path: string; type: string; dirPrefix: string }> = [];
2352
2645
  const tryAdd = (candidates: string[], type: string) => {
2353
- for (const c of candidates) { if (pathSet.has(c)) { configFilesToFetch.push({ path: c, type }); break; } }
2646
+ for (const c of candidates) { if (pathSet.has(c)) { configFilesToFetch.push({ path: c, type, dirPrefix: '' }); break; } }
2647
+ };
2648
+ // Monorepo fallback: when root-level config is absent, find shallowest match anywhere in tree.
2649
+ // The dirPrefix (e.g. 'packages/e2e/') is prepended to testDir so paths are repo-root-relative.
2650
+ const scanTree = (pattern: RegExp, type: string) => {
2651
+ if (configFilesToFetch.some(f => f.type === type)) return;
2652
+ const all = (treeResult.paths as string[]).filter(p => pattern.test(p)).sort((a, b) => a.split('/').length - b.split('/').length);
2653
+ if (all[0]) {
2654
+ const lastSlash = all[0].lastIndexOf('/');
2655
+ const dirPrefix = lastSlash > 0 ? all[0].slice(0, lastSlash + 1) : '';
2656
+ configFilesToFetch.push({ path: all[0], type, dirPrefix });
2657
+ }
2354
2658
  };
2355
2659
 
2356
2660
  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');
2661
+ if (framework.startsWith('playwright')) {
2662
+ tryAdd(['playwright.config.ts', 'playwright.config.js'], 'playwright');
2663
+ scanTree(/(?:^|\/)playwright\.config\.(ts|js|mts)$/, 'playwright');
2664
+ }
2665
+ if (framework.startsWith('cypress')) {
2666
+ tryAdd(['cypress.config.ts', 'cypress.config.js'], 'cypress');
2667
+ scanTree(/(?:^|\/)cypress\.config\.(ts|js)$/, 'cypress');
2668
+ }
2359
2669
  if (framework === 'jest-ts' || framework === 'jest-js') {
2360
2670
  tryAdd(['jest.config.ts', 'jest.config.js', 'jest.config.mjs'], 'jest');
2671
+ scanTree(/(?:^|\/)jest\.config\.(ts|js|mjs|cjs)$/, 'jest');
2361
2672
  tryAdd(['package.json'], 'package-json');
2362
2673
  }
2363
- if (framework === 'webdriverio') tryAdd(['wdio.conf.ts', 'wdio.conf.js', 'wdio.config.ts', 'wdio.config.js'], 'wdio');
2674
+ if (framework === 'webdriverio') {
2675
+ tryAdd(['wdio.conf.ts', 'wdio.conf.js', 'wdio.config.ts', 'wdio.config.js'], 'wdio');
2676
+ scanTree(/(?:^|\/)wdio\.(?:conf|config)\.(ts|js|mjs)$/, 'wdio');
2677
+ }
2364
2678
  if (framework === 'cucumber' || framework === 'gherkin') {
2365
2679
  tryAdd(['.cucumber.js', '.cucumber.cjs', 'cucumber.json', '.cucumber.yaml', '.cucumber.yml'], 'cucumber');
2366
2680
  tryAdd(['package.json'], 'package-json');
2367
2681
  }
2368
2682
  tryAdd(['tsconfig.json'], 'tsconfig');
2683
+ scanTree(/(?:^|\/)tsconfig(?:\.base)?\.json$/, 'tsconfig');
2369
2684
  } else if (lang === 'python') {
2370
2685
  tryAdd(['pytest.ini'], 'pytest-ini');
2686
+ scanTree(/(?:^|\/)pytest\.ini$/, 'pytest-ini');
2371
2687
  tryAdd(['pyproject.toml'], 'pyproject-toml');
2688
+ scanTree(/(?:^|\/)pyproject\.toml$/, 'pyproject-toml');
2372
2689
  tryAdd(['setup.cfg'], 'setup-cfg');
2373
2690
  } else if (lang === 'java') {
2374
2691
  tryAdd(['pom.xml'], 'pom-xml');
2692
+ scanTree(/(?:^|\/)pom\.xml$/, 'pom-xml');
2375
2693
  tryAdd(['build.gradle.kts', 'build.gradle'], 'build-gradle');
2694
+ scanTree(/(?:^|\/)build\.gradle(?:\.kts)?$/, 'build-gradle');
2376
2695
  tryAdd(['testng.xml', 'src/test/resources/testng.xml'], 'testng-xml');
2377
2696
  }
2378
2697
 
@@ -2384,7 +2703,7 @@ async function processRepoScanJob(jobId: string, config: any) {
2384
2703
  };
2385
2704
 
2386
2705
  const fetched = await Promise.all(
2387
- configFilesToFetch.map(async ({ path: cfgPath, type }) => {
2706
+ configFilesToFetch.map(async ({ path: cfgPath, type, dirPrefix }) => {
2388
2707
  const content = await fetchFileContent(token, conn.repoFullName, cfgPath).catch(() => null);
2389
2708
  if (!content) return null;
2390
2709
  const r: typeof configPaths = {};
@@ -2437,6 +2756,15 @@ async function processRepoScanJob(jobId: string, config: any) {
2437
2756
  if (pm) { const fp = pm[1].match(/['"`]([^'"`]+)['"`]/)?.[1]; if (fp) { const gr = globRoot(fp); if (gr) r.testDir = gr; } }
2438
2757
  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
2758
  }
2759
+ // Monorepo workspace prefix: config found at e.g. packages/frontend/playwright.config.ts.
2760
+ // testDir from config is relative to that file ('tests' or 'e2e'). Prepend the directory
2761
+ // so the final path is repo-root-relative ('packages/frontend/tests').
2762
+ if (dirPrefix && r.testDir && !r.testDir.startsWith(dirPrefix)) {
2763
+ r.testDir = dirPrefix + r.testDir;
2764
+ }
2765
+ if (dirPrefix && r.javaTestSourceDir && !r.javaTestSourceDir.startsWith(dirPrefix)) {
2766
+ r.javaTestSourceDir = dirPrefix + r.javaTestSourceDir;
2767
+ }
2440
2768
  return r;
2441
2769
  })
2442
2770
  );
@@ -2477,7 +2805,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2477
2805
  .slice(0, 60)
2478
2806
  : [];
2479
2807
  const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
2480
- const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile);
2808
+ // Pass full tree paths separately so detectDataDirs can find testdata/*.json, fixtures/*.yaml etc.
2809
+ const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile, treeResult.paths);
2481
2810
  if (freshProfile) styleProfile = freshProfile;
2482
2811
  } catch { /* non-fatal */ }
2483
2812
  }
@@ -2587,6 +2916,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2587
2916
  pageObjectInventory,
2588
2917
  testPaths,
2589
2918
  extractedTestCount: allExtracted.length,
2919
+ treeWasTruncated: treeResult.truncated,
2920
+ extractedTests: allExtracted,
2590
2921
  });
2591
2922
  } catch (e: any) {
2592
2923
  logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] repo skill files generation failed (non-fatal)');
@@ -2598,7 +2929,8 @@ async function processRepoScanJob(jobId: string, config: any) {
2598
2929
  );
2599
2930
  aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
2600
2931
  } catch (err: any) {
2601
- const msg = String(err?.message ?? err);
2932
+ const cause = err?.cause?.message ?? (typeof err?.cause === 'string' ? err.cause : '');
2933
+ const msg = `${String(err?.message ?? err)}${cause ? ` — ${cause}` : ''}`;
2602
2934
  logger.error({ jobId, repoConnectionId, err: msg }, '[repo-scan] failed');
2603
2935
  await withTenant(tenantId, (tx) =>
2604
2936
  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>();
@@ -6,7 +6,13 @@ export interface CrawlSafetyDecision {
6
6
  severity: 'info' | 'warn' | 'block';
7
7
  }
8
8
 
9
- const DESTRUCTIVE_ACTION = /\b(delete|remove|destroy|deactivate|disable|cancel|refund|void|transfer|withdraw|pay now|place order|submit order|confirm payment|send money|publish|approve|reject|bulk update|drop)\b/i;
9
+ const _extraDestructive = process.env.CRAWL_EXTRA_DESTRUCTIVE_PATTERNS
10
+ ? process.env.CRAWL_EXTRA_DESTRUCTIVE_PATTERNS.split(',').map((p) => p.trim()).filter(Boolean)
11
+ : [];
12
+ const DESTRUCTIVE_ACTION = new RegExp(
13
+ `\\b(delete|remove|destroy|deactivate|disable|cancel|refund|void|transfer|withdraw|pay now|place order|submit order|confirm payment|send money|publish|approve|reject|bulk update|drop${_extraDestructive.length ? `|${_extraDestructive.join('|')}` : ''})\\b`,
14
+ 'i',
15
+ );
10
16
  const SECRET_LIKE = /\b(api[_ -]?key|secret|token|password|authorization|cookie|set-cookie)\b/i;
11
17
 
12
18
  export function assessActionSafety(label: string, profile: DomainProfile): CrawlSafetyDecision {