@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/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 +339 -13
- 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) =>
|
|
@@ -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'))
|
|
2358
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
|
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) } })
|
package/src/locator-extractor.ts
CHANGED
|
@@ -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 →
|
|
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
|
|
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)}
|
|
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>();
|