@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/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/content-chunker.ts +2 -2
- package/src/crawler.ts +95 -30
- package/src/frontier.ts +5 -3
- package/src/gap-pass.ts +1 -1
- package/src/http-engine.ts +9 -4
- package/src/job-queue.ts +350 -18
- package/src/locator-extractor.ts +41 -3
- package/src/policies/crawl-safety.ts +7 -1
- package/src/profiles/domain-profile.ts +24 -1
- package/src/rate-limiter.ts +2 -2
- package/src/test-executor.ts +122 -0
- package/src/url-pattern-completer.ts +1 -1
- package/src/visual-diff-verdict.ts +4 -2
- 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'
|
|
@@ -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: {
|
|
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
|
-
|
|
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'))
|
|
2358
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
|
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) } })
|
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>();
|
|
@@ -6,7 +6,13 @@ export interface CrawlSafetyDecision {
|
|
|
6
6
|
severity: 'info' | 'warn' | 'block';
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
const
|
|
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 {
|