@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/dist/crawler.js +52 -6
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +499 -10
- package/dist/job-queue.js.map +2 -2
- package/dist/locator-extractor.js +35 -1
- package/dist/locator-extractor.js.map +2 -2
- package/dist/test-executor.js +102 -0
- package/dist/test-executor.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +59 -7
- package/src/job-queue.ts +469 -7
- package/src/locator-extractor.ts +41 -3
- package/src/test-executor.ts +122 -0
- package/types/crawler.d.ts +9 -0
- package/types/locator-extractor.d.ts +2 -0
- package/types/test-executor.d.ts +7 -0
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) } })
|