@detiq/crawler 1.1.21 → 1.1.22
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/Dockerfile +5 -0
- package/dist/crawler.js +20 -7
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +7 -1
- package/dist/job-queue.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +27 -6
- package/src/job-queue.ts +98 -86
- package/types/job-queue.d.ts +3 -1
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -102,7 +102,7 @@ const _crawlLLMCache = new Map<string, any>();
|
|
|
102
102
|
async function getCachedCrawlLLM(tenantId: string): Promise<any> {
|
|
103
103
|
if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
|
|
104
104
|
const cfg = await getTaskAgentLLMConfig(tenantId, 'crawl').catch(() => null);
|
|
105
|
-
_crawlLLMCache.set(tenantId, cfg);
|
|
105
|
+
if (cfg) _crawlLLMCache.set(tenantId, cfg);
|
|
106
106
|
return cfg;
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -128,7 +128,9 @@ interface PerformanceMetric {
|
|
|
128
128
|
const ALLOWED_NUMERIC_PATHS = new Set(['/2fa', '/404', '/500', '/403', '/401']);
|
|
129
129
|
const UUID_PATH_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
130
130
|
const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
|
|
131
|
-
|
|
131
|
+
// Match 4+ digit pure numeric IDs only — aligns with canonicalize()'s /\d{4,}/ threshold.
|
|
132
|
+
// 1-3 digit segments (page/2/, /tag/books/page/3/) are valid pagination — do NOT skip them.
|
|
133
|
+
const NUMERIC_ID_PATH_SEGMENT_RE = /^\d{4,}$/;
|
|
132
134
|
const COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE = /^\d+-\d+$/;
|
|
133
135
|
|
|
134
136
|
function normalizePathForNumericGuard(pathname: string): string {
|
|
@@ -1955,6 +1957,7 @@ export async function crawlProject(opts: {
|
|
|
1955
1957
|
};
|
|
1956
1958
|
const edges: Array<{ fromUrl: string; toUrl: string }> = [];
|
|
1957
1959
|
const pendingTransitions: Array<{ fromScreenId: string; elementMeaning: string; targetUrl: string }> = [];
|
|
1960
|
+
const pendingSummaryWrites: Promise<void>[] = [];
|
|
1958
1961
|
const discovered: Array<{
|
|
1959
1962
|
url: string; screenId?: string; elements?: number;
|
|
1960
1963
|
elementFacts?: Array<{ meaning: string; role: string; expectedData?: string }>;
|
|
@@ -3450,6 +3453,7 @@ export async function crawlProject(opts: {
|
|
|
3450
3453
|
const summaryPromise = (async () => {
|
|
3451
3454
|
try {
|
|
3452
3455
|
const llmCfg = await getCachedCrawlLLM(opts.tenantId);
|
|
3456
|
+
if (!llmCfg) return undefined;
|
|
3453
3457
|
const snippet = extracted.markdown.slice(0, 1200);
|
|
3454
3458
|
// Skip LLM when content is too thin — output would be useless
|
|
3455
3459
|
if (snippet.trim().length < 80) return undefined;
|
|
@@ -3487,9 +3491,15 @@ export async function crawlProject(opts: {
|
|
|
3487
3491
|
heading: extracted.title || name,
|
|
3488
3492
|
}).catch((err: any) => console.warn('[crawler] markdown extraction save failed:', err));
|
|
3489
3493
|
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3494
|
+
pendingSummaryWrites.push(
|
|
3495
|
+
summaryPromise.then((aiSummary) => {
|
|
3496
|
+
if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch((e: any) => {
|
|
3497
|
+
logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary DB save failed');
|
|
3498
|
+
});
|
|
3499
|
+
}).catch((e: any) => {
|
|
3500
|
+
logger.warn({ screenId: screen.id, err: String(e?.message ?? e) }, '[crawler] aiSummary generation failed');
|
|
3501
|
+
})
|
|
3502
|
+
);
|
|
3493
3503
|
}
|
|
3494
3504
|
|
|
3495
3505
|
const structuredJson = summarizeStructuredElements(atElements);
|
|
@@ -3989,9 +3999,11 @@ export async function crawlProject(opts: {
|
|
|
3989
3999
|
|
|
3990
4000
|
// Discovery mode: return collected URLs and skip all post-crawl analysis phases.
|
|
3991
4001
|
// Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
|
|
4002
|
+
// Filter out sentinel strings (_pending_, _auth_wall_, _soft404_, etc.) that mark
|
|
4003
|
+
// in-progress or skipped entries — only keep actual http(s) URLs.
|
|
3992
4004
|
if (opts.discoveryMode) {
|
|
3993
4005
|
return {
|
|
3994
|
-
discoveredUrls: Array.from(visited.values()),
|
|
4006
|
+
discoveredUrls: Array.from(visited.values()).filter((v: string) => v.startsWith('http')),
|
|
3995
4007
|
screensFound: 0,
|
|
3996
4008
|
screensErrored: 0,
|
|
3997
4009
|
screensSkippedToNextSegment: 0,
|
|
@@ -4768,6 +4780,15 @@ export async function crawlProject(opts: {
|
|
|
4768
4780
|
const screensSucceeded = discovered.filter(d => !!d.screenId).length;
|
|
4769
4781
|
const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
|
|
4770
4782
|
|
|
4783
|
+
// Drain pending AI summary writes before marking the job done — ensures descriptions
|
|
4784
|
+
// are visible immediately after crawl completes. Cap wait at 30 s per screen, 60 s total.
|
|
4785
|
+
if (pendingSummaryWrites.length > 0) {
|
|
4786
|
+
await Promise.race([
|
|
4787
|
+
Promise.allSettled(pendingSummaryWrites),
|
|
4788
|
+
new Promise<void>((resolve) => setTimeout(resolve, Math.min(60_000, pendingSummaryWrites.length * 3_000))),
|
|
4789
|
+
]);
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4771
4792
|
// Detect systematic element/link extraction failure across all screens
|
|
4772
4793
|
if (screensSucceeded >= 3) {
|
|
4773
4794
|
const screensWithElements = discovered.filter(d => (d.elements ?? 0) > 0 && d.screenId).length;
|
package/src/job-queue.ts
CHANGED
|
@@ -86,7 +86,7 @@ export function generateTOTP(secret: string): string {
|
|
|
86
86
|
const hmac = createHmac('sha1', Buffer.from(bytes)).update(buf).digest();
|
|
87
87
|
const offset = hmac[hmac.length - 1] & 0xf;
|
|
88
88
|
const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) |
|
|
89
|
-
|
|
89
|
+
((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
|
|
90
90
|
return String(code % 1_000_000).padStart(6, '0');
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -94,12 +94,12 @@ function deriveFallbackTargetPath(ref: string, framework: string): string {
|
|
|
94
94
|
const slug = (ref ?? 'test').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'test';
|
|
95
95
|
const base = framework.startsWith('cypress') ? 'cypress/e2e/'
|
|
96
96
|
: (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? 'tests/'
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
: (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'src/test/java/'
|
|
98
|
+
: 'tests/';
|
|
99
99
|
const ext = (framework === 'playwright-py' || framework === 'pytest' || framework === 'selenium-py') ? '_test.py'
|
|
100
100
|
: (framework === 'playwright-java' || framework === 'selenium-java' || framework === 'junit') ? 'Test.java'
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
: framework.includes('ts') ? '.spec.ts'
|
|
102
|
+
: '.spec.js';
|
|
103
103
|
return `${base}${slug}${ext}`;
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -109,23 +109,23 @@ function makeJobName(type: string): string {
|
|
|
109
109
|
const ts = now.getTime();
|
|
110
110
|
const label = type === 'CRAWL' ? 'Crawl'
|
|
111
111
|
: type === 'CAPTURE_STEPS' ? 'CaptureSteps'
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
112
|
+
: type === 'NATIVE_CRAWL' ? 'NativeCrawl'
|
|
113
|
+
: type === 'GENERATE_TESTS' ? 'GenerateTests'
|
|
114
|
+
: type === 'VISUAL_REGRESSION' ? 'VisualRegression'
|
|
115
|
+
: type === 'AI_EDIT' ? 'AIEdit'
|
|
116
|
+
: type === 'API_TESTS' ? 'ApiTests'
|
|
117
|
+
: type === 'SECURITY_TESTS' ? 'SecurityTests'
|
|
118
|
+
: type === 'INTEGRATION_TESTS' ? 'IntegrationTests'
|
|
119
|
+
: type === 'GENERATE_TESTS_FROM_REQS' ? 'GenerateTestsFromReqs'
|
|
120
|
+
: type === 'GENERATE_DATA' ? 'GenerateData'
|
|
121
|
+
: type === 'GENERATE_SCRIPT' ? 'GenerateScript'
|
|
122
|
+
: type === 'GENERATE_TEST_PLAN' ? 'GenerateTestPlan'
|
|
123
|
+
: type === 'IMPORT_TESTS' ? 'ImportTests'
|
|
124
|
+
: type === 'GENERATE_SCREEN_TESTS' ? 'GenerateScreenTests'
|
|
125
|
+
: type === 'BATCH_GENERATE_ALIGNED' ? 'BatchGenerateAligned'
|
|
126
|
+
: type === 'CREATE_PR' ? 'CreatePR'
|
|
127
|
+
: type === 'NEW_PAGES_PIPELINE' ? 'NewPagesPipeline'
|
|
128
|
+
: type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
129
129
|
return `${label}_${date}_${ts}`;
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -195,6 +195,9 @@ const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
|
195
195
|
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
196
196
|
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
|
|
197
197
|
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
|
|
198
|
+
// Max simultaneous DISCOVER jobs — each launches a Playwright browser and holds
|
|
199
|
+
// DB connections, so running many concurrently exhausts resources. Default 2.
|
|
200
|
+
const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
|
|
198
201
|
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15_000;
|
|
199
202
|
// How many headless browsers a single RUN_TEST_SUITE job launches at once —
|
|
200
203
|
// distinct from WORKER_CONCURRENCY (how many *jobs* the poll loop claims at
|
|
@@ -221,14 +224,21 @@ export async function claimSpecificJob(jobId: string): Promise<ClaimedJob | null
|
|
|
221
224
|
}
|
|
222
225
|
|
|
223
226
|
/** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
|
|
224
|
-
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
227
|
+
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
228
|
+
* DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
|
|
229
|
+
* already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
|
|
225
230
|
export async function claimNextQueuedJob(): Promise<ClaimedJob | null> {
|
|
231
|
+
const maxDiscover = MAX_CONCURRENT_DISCOVER;
|
|
226
232
|
const rows = await prismaAdmin.$queryRaw<ClaimedJob[]>`
|
|
227
233
|
UPDATE "PlatformJob"
|
|
228
234
|
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
229
235
|
WHERE id = (
|
|
230
236
|
SELECT id FROM "PlatformJob"
|
|
231
237
|
WHERE status = 'QUEUED'
|
|
238
|
+
AND (
|
|
239
|
+
type != 'DISCOVER'
|
|
240
|
+
OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
|
|
241
|
+
)
|
|
232
242
|
ORDER BY "queuedAt" ASC
|
|
233
243
|
FOR UPDATE SKIP LOCKED
|
|
234
244
|
LIMIT 1
|
|
@@ -315,7 +325,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
|
315
325
|
await prismaAdmin.platformJob.update({
|
|
316
326
|
where: { id: job.id },
|
|
317
327
|
data: { status: 'FAILED', error: `Unknown job type: ${job.type}`, finishedAt: new Date() },
|
|
318
|
-
}).catch(() => {});
|
|
328
|
+
}).catch(() => { });
|
|
319
329
|
return;
|
|
320
330
|
}
|
|
321
331
|
const stopTimer = crawlJobDurationSeconds.startTimer({ type: job.type });
|
|
@@ -335,7 +345,7 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
|
335
345
|
await prismaAdmin.platformJob.update({
|
|
336
346
|
where: { id: job.id },
|
|
337
347
|
data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() },
|
|
338
|
-
}).catch(() => {});
|
|
348
|
+
}).catch(() => { });
|
|
339
349
|
}
|
|
340
350
|
}
|
|
341
351
|
|
|
@@ -390,7 +400,7 @@ export function stopJobWorkerLoop(): void {
|
|
|
390
400
|
/** Create a job record that tracks a synchronous operation (telemetry only, no background processor). */
|
|
391
401
|
export async function createTrackedJob(
|
|
392
402
|
type: string,
|
|
393
|
-
config: { tenantId: string; projectId: string;
|
|
403
|
+
config: { tenantId: string; projectId: string;[k: string]: any }
|
|
394
404
|
): Promise<string> {
|
|
395
405
|
const name = makeJobName(type);
|
|
396
406
|
const job = await withTenant(config.tenantId, (tx) =>
|
|
@@ -414,7 +424,7 @@ export async function finishTrackedJob(tenantId: string, jobId: string, error?:
|
|
|
414
424
|
...(error ? { error, nextRetryAt } : { progress: 100 }),
|
|
415
425
|
},
|
|
416
426
|
})
|
|
417
|
-
).catch(() => {});
|
|
427
|
+
).catch(() => { });
|
|
418
428
|
}
|
|
419
429
|
|
|
420
430
|
/** Enqueue a generic platform job — returns the job ID immediately. */
|
|
@@ -691,11 +701,11 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
691
701
|
// Build runtime credentials: convert stored totpSecret → getOneTimeCode function
|
|
692
702
|
const runtimeCreds = config.credentials
|
|
693
703
|
? {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
704
|
+
...config.credentials,
|
|
705
|
+
...(config.credentials.totpSecret
|
|
706
|
+
? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
|
|
707
|
+
: {}),
|
|
708
|
+
}
|
|
699
709
|
: undefined;
|
|
700
710
|
|
|
701
711
|
const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
@@ -997,11 +1007,11 @@ export async function aggregateJobTokens(tenantId: string, jobId: string, projec
|
|
|
997
1007
|
await tx.platformJob.update({
|
|
998
1008
|
where: { id: jobId },
|
|
999
1009
|
data: {
|
|
1000
|
-
totalPromptTokens:
|
|
1010
|
+
totalPromptTokens: row.totalPromptTokens,
|
|
1001
1011
|
totalCompletionTokens: row.totalCompletionTokens,
|
|
1002
|
-
totalCacheReadTokens:
|
|
1012
|
+
totalCacheReadTokens: row.totalCacheReadTokens,
|
|
1003
1013
|
totalEstimatedCostUsd: Number(row.totalEstimatedCostUsd),
|
|
1004
|
-
llmCallCount:
|
|
1014
|
+
llmCallCount: row.llmCallCount,
|
|
1005
1015
|
},
|
|
1006
1016
|
});
|
|
1007
1017
|
});
|
|
@@ -1180,10 +1190,10 @@ async function processFigmaImportJob(jobId: string, config: any) {
|
|
|
1180
1190
|
const figmaElements = figma.extractElements((frame as any).node);
|
|
1181
1191
|
for (const el of figmaElements) {
|
|
1182
1192
|
const role = el.type === 'COMPONENT' || el.type === 'COMPONENT_SET' ? 'COMPONENT'
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1193
|
+
: el.type === 'INSTANCE' ? 'COMPONENT'
|
|
1194
|
+
: el.type === 'TEXT' ? 'TEXT'
|
|
1195
|
+
: el.type === 'FRAME' || el.type === 'GROUP' ? 'REGION'
|
|
1196
|
+
: el.type;
|
|
1187
1197
|
const boundingRect = el.boundingBox
|
|
1188
1198
|
? { x: Math.round(el.boundingBox.x), y: Math.round(el.boundingBox.y), width: Math.round(el.boundingBox.width), height: Math.round(el.boundingBox.height) }
|
|
1189
1199
|
: undefined;
|
|
@@ -1261,7 +1271,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
|
|
|
1261
1271
|
deviceFarm,
|
|
1262
1272
|
},
|
|
1263
1273
|
})
|
|
1264
|
-
).catch(() => {});
|
|
1274
|
+
).catch(() => { });
|
|
1265
1275
|
|
|
1266
1276
|
const workspaceDeviceFarm = await AppBrain.getTenantDeviceFarmConfig(tenantId).catch(() => null);
|
|
1267
1277
|
|
|
@@ -1328,7 +1338,7 @@ async function processNativeCrawlJob(jobId: string, config: any) {
|
|
|
1328
1338
|
} catch (err: any) {
|
|
1329
1339
|
await withTenant(tenantId, (tx) =>
|
|
1330
1340
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() } })
|
|
1331
|
-
).catch(() => {});
|
|
1341
|
+
).catch(() => { });
|
|
1332
1342
|
}
|
|
1333
1343
|
}
|
|
1334
1344
|
|
|
@@ -1387,7 +1397,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1387
1397
|
try {
|
|
1388
1398
|
const reqSeed = deterministicSeed(`${tenantId}:${projectId}:req-cases:${reqs.map((r: any) => r.reqId).sort().join('|').slice(0, 300)}`);
|
|
1389
1399
|
const { text, usage } = await askTracked(system, user, llmCfg, undefined, undefined, { temperature: 0, seed: reqSeed });
|
|
1390
|
-
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => {});
|
|
1400
|
+
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: 'generate_tests_from_reqs', label: 'test_case_generation', usage }).catch(() => { });
|
|
1391
1401
|
const json = text.match(/\{[\s\S]*\}/)?.[0];
|
|
1392
1402
|
if (json) parsed = JSON.parse(json);
|
|
1393
1403
|
} catch (e: any) {
|
|
@@ -1396,8 +1406,8 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1396
1406
|
|
|
1397
1407
|
await updateJobProgress(tenantId, jobId, 70, `Saving ${parsed.cases?.length ?? 0} test cases`);
|
|
1398
1408
|
|
|
1399
|
-
const VALID_KINDS = new Set(['POSITIVE','NEGATIVE','EDGE','STATE_TRANSITION','ACCESSIBILITY','SECURITY','INTEGRATION','E2E','API','EXPLORATORY','UAT','PERFORMANCE','COMPATIBILITY','L10N','USABILITY','LOAD','UNIT','MUTATION','AB_TEST','ALPHA_BETA','SYSTEM','SMOKE','REGRESSION','SANITY']);
|
|
1400
|
-
const VALID_PRIORITIES = new Set(['CRITICAL','HIGH','MEDIUM','LOW']);
|
|
1409
|
+
const VALID_KINDS = new Set(['POSITIVE', 'NEGATIVE', 'EDGE', 'STATE_TRANSITION', 'ACCESSIBILITY', 'SECURITY', 'INTEGRATION', 'E2E', 'API', 'EXPLORATORY', 'UAT', 'PERFORMANCE', 'COMPATIBILITY', 'L10N', 'USABILITY', 'LOAD', 'UNIT', 'MUTATION', 'AB_TEST', 'ALPHA_BETA', 'SYSTEM', 'SMOKE', 'REGRESSION', 'SANITY']);
|
|
1410
|
+
const VALID_PRIORITIES = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
|
1401
1411
|
const normalized = (parsed.cases ?? []).map((c: any) => ({
|
|
1402
1412
|
ref: c.reqRef ?? null,
|
|
1403
1413
|
traceTo: c.reqRef ?? null,
|
|
@@ -1419,7 +1429,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1419
1429
|
await Promise.all(
|
|
1420
1430
|
reqs
|
|
1421
1431
|
.filter((r: any) => coveredReqIds.has(r.reqId))
|
|
1422
|
-
.map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => {}))
|
|
1432
|
+
.map((r: any) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: 'covered' }).catch(() => { }))
|
|
1423
1433
|
);
|
|
1424
1434
|
|
|
1425
1435
|
await withTenant(tenantId, (tx) =>
|
|
@@ -1433,7 +1443,7 @@ Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
|
1433
1443
|
logger.error({ jobId, err: msg }, 'processGenerateTestsFromReqsJob failed');
|
|
1434
1444
|
await withTenant(tenantId, (tx) =>
|
|
1435
1445
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
|
|
1436
|
-
).catch(() => {});
|
|
1446
|
+
).catch(() => { });
|
|
1437
1447
|
}
|
|
1438
1448
|
}
|
|
1439
1449
|
|
|
@@ -1544,7 +1554,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
|
|
|
1544
1554
|
} : {}),
|
|
1545
1555
|
});
|
|
1546
1556
|
}
|
|
1547
|
-
await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => {});
|
|
1557
|
+
await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => { });
|
|
1548
1558
|
} catch (err: any) {
|
|
1549
1559
|
failed++;
|
|
1550
1560
|
runResults.push({ testCaseId: tc.id, status: 'ERROR' });
|
|
@@ -1553,7 +1563,7 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
|
|
|
1553
1563
|
testCaseId: tc.id,
|
|
1554
1564
|
status: 'ERROR',
|
|
1555
1565
|
error: String(err?.message ?? err).slice(0, 500),
|
|
1556
|
-
}).catch(() => {});
|
|
1566
|
+
}).catch(() => { });
|
|
1557
1567
|
}
|
|
1558
1568
|
}));
|
|
1559
1569
|
await updateJobProgress(
|
|
@@ -1592,10 +1602,10 @@ async function processRunTestSuiteJob(jobId: string, config: any) {
|
|
|
1592
1602
|
} catch (err: any) {
|
|
1593
1603
|
const msg = String(err?.message ?? err).slice(0, 500);
|
|
1594
1604
|
logger.error({ jobId, testRunId, err: msg }, 'processRunTestSuiteJob failed');
|
|
1595
|
-
await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => {});
|
|
1605
|
+
await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => { });
|
|
1596
1606
|
await withTenant(tenantId, (tx) =>
|
|
1597
1607
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg } })
|
|
1598
|
-
).catch(() => {});
|
|
1608
|
+
).catch(() => { });
|
|
1599
1609
|
}
|
|
1600
1610
|
}
|
|
1601
1611
|
|
|
@@ -1840,12 +1850,12 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1840
1850
|
await onProgress(52, 'Authenticated spider…');
|
|
1841
1851
|
const runtimeCreds = crawlCfg.username
|
|
1842
1852
|
? {
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1853
|
+
username: crawlCfg.username,
|
|
1854
|
+
password: crawlCfg.password ?? '',
|
|
1855
|
+
...(crawlCfg.totpSecret
|
|
1856
|
+
? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret!) }
|
|
1857
|
+
: {}),
|
|
1858
|
+
}
|
|
1849
1859
|
: undefined;
|
|
1850
1860
|
|
|
1851
1861
|
const authResult = await crawlProject({
|
|
@@ -1897,7 +1907,9 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1897
1907
|
}
|
|
1898
1908
|
|
|
1899
1909
|
// Persist discovered URLs to project crawl config
|
|
1900
|
-
|
|
1910
|
+
// Filter sentinel strings (_pending_, _auth_wall_, etc.) that may have leaked in
|
|
1911
|
+
// via onUrlsDiscovered callbacks fired before visited-map entries were updated.
|
|
1912
|
+
const discoveredUrls = Array.from(allUrls).filter(u => u.startsWith('http'));
|
|
1901
1913
|
const existingCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => ({}));
|
|
1902
1914
|
await AppBrain.saveCrawlConfig(tenantId, projectId, {
|
|
1903
1915
|
...(existingCfg as any),
|
|
@@ -2164,7 +2176,7 @@ async function processImportTestsJob(jobId: string, config: any) {
|
|
|
2164
2176
|
status: lr.needsReview ? 'PENDING_REVIEW' : undefined,
|
|
2165
2177
|
},
|
|
2166
2178
|
})
|
|
2167
|
-
).catch(() => {}) // non-fatal per-case
|
|
2179
|
+
).catch(() => { }) // non-fatal per-case
|
|
2168
2180
|
)
|
|
2169
2181
|
);
|
|
2170
2182
|
labeled = labelResults.length;
|
|
@@ -2258,7 +2270,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
|
|
|
2258
2270
|
testCaseId: tc.id,
|
|
2259
2271
|
styleProfile: conn.styleProfile,
|
|
2260
2272
|
pageObjectInventory: conn.pageObjectInventory ?? undefined,
|
|
2261
|
-
}).catch(() => {})
|
|
2273
|
+
}).catch(() => { })
|
|
2262
2274
|
);
|
|
2263
2275
|
}
|
|
2264
2276
|
}
|
|
@@ -2272,7 +2284,7 @@ async function processGenerateScreenTestsJob(jobId: string, config: any) {
|
|
|
2272
2284
|
logger.error({ jobId, screenId, err: msg }, 'processGenerateScreenTestsJob failed');
|
|
2273
2285
|
await withTenant(tenantId, (tx: any) =>
|
|
2274
2286
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg.slice(0, 500), finishedAt: new Date() } })
|
|
2275
|
-
).catch(() => {});
|
|
2287
|
+
).catch(() => { });
|
|
2276
2288
|
}
|
|
2277
2289
|
}
|
|
2278
2290
|
|
|
@@ -2424,7 +2436,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2424
2436
|
heartbeatAt: new Date(),
|
|
2425
2437
|
},
|
|
2426
2438
|
})
|
|
2427
|
-
).catch(() => {});
|
|
2439
|
+
).catch(() => { });
|
|
2428
2440
|
|
|
2429
2441
|
try {
|
|
2430
2442
|
const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
|
|
@@ -2433,7 +2445,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2433
2445
|
result.healTier = heal.healedVia;
|
|
2434
2446
|
result.healReasoning = heal.healReasoning;
|
|
2435
2447
|
}
|
|
2436
|
-
await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => {});
|
|
2448
|
+
await page.waitForLoadState('networkidle', { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? '2000', 10) }).catch(() => { });
|
|
2437
2449
|
result.status = result.healed ? 'HEALED' : 'PASSED';
|
|
2438
2450
|
} catch (err: any) {
|
|
2439
2451
|
result.status = step.optional ? 'SKIPPED' : 'FAILED';
|
|
@@ -2444,7 +2456,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2444
2456
|
result.url = currentUrl;
|
|
2445
2457
|
const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === 'FAILED' ? '-err' : ''}.png`;
|
|
2446
2458
|
const ssPath = path.join(screenshotDir, ssFilename);
|
|
2447
|
-
await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {});
|
|
2459
|
+
await page.screenshot({ path: ssPath, fullPage: false }).catch(() => { });
|
|
2448
2460
|
result.screenshotPath = ssPath;
|
|
2449
2461
|
|
|
2450
2462
|
let cloudUrl: string | undefined;
|
|
@@ -2466,13 +2478,13 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2466
2478
|
const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? 'ENTRY_POINT' : 'LINK_FOLLOW').catch(() => null) as any;
|
|
2467
2479
|
if (screen?.id) {
|
|
2468
2480
|
result.screenId = screen.id;
|
|
2469
|
-
await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => {});
|
|
2481
|
+
await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, 'DESKTOP', cloudUrl).catch(() => { });
|
|
2470
2482
|
for (const el of elements) {
|
|
2471
2483
|
await AppBrain.saveElement(
|
|
2472
2484
|
tenantId, projectId, screen.id,
|
|
2473
2485
|
el.meaning, el.role, el.expectedData, el.notes,
|
|
2474
2486
|
undefined, true, el.boundingRect, el.ariaState, el.parentLandmark,
|
|
2475
|
-
).catch(() => {});
|
|
2487
|
+
).catch(() => { });
|
|
2476
2488
|
}
|
|
2477
2489
|
for (const loc of elementsToPlaywrightLocators(elements as any)) {
|
|
2478
2490
|
if (seenLocatorExpressions.has(loc.expression)) continue;
|
|
@@ -2491,7 +2503,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2491
2503
|
}
|
|
2492
2504
|
|
|
2493
2505
|
if (locators.length > 0) {
|
|
2494
|
-
await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => {});
|
|
2506
|
+
await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators as any).catch(() => { });
|
|
2495
2507
|
}
|
|
2496
2508
|
|
|
2497
2509
|
const firstScreen = screens[0];
|
|
@@ -2503,7 +2515,7 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2503
2515
|
captureStepScreenshots: stepResults as any,
|
|
2504
2516
|
},
|
|
2505
2517
|
})
|
|
2506
|
-
).catch(() => {});
|
|
2518
|
+
).catch(() => { });
|
|
2507
2519
|
|
|
2508
2520
|
const finalResult = {
|
|
2509
2521
|
stepResults,
|
|
@@ -2522,11 +2534,11 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2522
2534
|
const error = String(err?.message ?? err);
|
|
2523
2535
|
await withTenant(tenantId, (tx) =>
|
|
2524
2536
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error, finishedAt: new Date(), heartbeatAt: new Date() } })
|
|
2525
|
-
).catch(() => {});
|
|
2537
|
+
).catch(() => { });
|
|
2526
2538
|
logger.error({ jobId, testCaseId, err: error }, 'processCaptureStepsJob failed');
|
|
2527
2539
|
throw err;
|
|
2528
2540
|
} finally {
|
|
2529
|
-
await browser?.close?.().catch(() => {});
|
|
2541
|
+
await browser?.close?.().catch(() => { });
|
|
2530
2542
|
}
|
|
2531
2543
|
}
|
|
2532
2544
|
|
|
@@ -2562,28 +2574,28 @@ jobDispatchTable = {
|
|
|
2562
2574
|
await prismaAdmin.platformJob.update({
|
|
2563
2575
|
where: { id: jobId },
|
|
2564
2576
|
data: { status: 'FAILED', error: 'Test generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2565
|
-
}).catch(() => {});
|
|
2577
|
+
}).catch(() => { });
|
|
2566
2578
|
},
|
|
2567
2579
|
GENERATE_DATA: async (jobId: string, _config: any) => {
|
|
2568
2580
|
logger.error({ jobId }, '[job-worker] GENERATE_DATA claimed by worker — API did not process it in-process');
|
|
2569
2581
|
await prismaAdmin.platformJob.update({
|
|
2570
2582
|
where: { id: jobId },
|
|
2571
2583
|
data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2572
|
-
}).catch(() => {});
|
|
2584
|
+
}).catch(() => { });
|
|
2573
2585
|
},
|
|
2574
2586
|
GENERATE_SCRIPT: async (jobId: string, _config: any) => {
|
|
2575
2587
|
logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
|
|
2576
2588
|
await prismaAdmin.platformJob.update({
|
|
2577
2589
|
where: { id: jobId },
|
|
2578
2590
|
data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2579
|
-
}).catch(() => {});
|
|
2591
|
+
}).catch(() => { });
|
|
2580
2592
|
},
|
|
2581
2593
|
GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
|
|
2582
2594
|
logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
|
|
2583
2595
|
await prismaAdmin.platformJob.update({
|
|
2584
2596
|
where: { id: jobId },
|
|
2585
2597
|
data: { status: 'FAILED', error: 'Test plan generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2586
|
-
}).catch(() => {});
|
|
2598
|
+
}).catch(() => { });
|
|
2587
2599
|
},
|
|
2588
2600
|
};
|
|
2589
2601
|
|
|
@@ -2880,14 +2892,14 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2880
2892
|
const testPathSet = new Set(testPaths);
|
|
2881
2893
|
const sourceFilePaths = (language === 'typescript' || language === 'javascript')
|
|
2882
2894
|
? treeResult.paths
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2895
|
+
.filter((p: string) =>
|
|
2896
|
+
(p.endsWith('.ts') || p.endsWith('.js')) &&
|
|
2897
|
+
!testPathSet.has(p) &&
|
|
2898
|
+
!p.endsWith('.d.ts') &&
|
|
2899
|
+
!p.includes('node_modules/') &&
|
|
2900
|
+
!p.includes('dist/') &&
|
|
2901
|
+
!p.includes('.config.'))
|
|
2902
|
+
.slice(0, 60)
|
|
2891
2903
|
: [];
|
|
2892
2904
|
const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
|
|
2893
2905
|
// Pass full tree paths separately so detectDataDirs can find testdata/*.json, fixtures/*.yaml etc.
|
|
@@ -3019,10 +3031,10 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
3019
3031
|
logger.error({ jobId, repoConnectionId, err: msg }, '[repo-scan] failed');
|
|
3020
3032
|
await withTenant(tenantId, (tx) =>
|
|
3021
3033
|
tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: 'FAILED', scanError: msg.slice(0, 500) } })
|
|
3022
|
-
).catch(() => {});
|
|
3034
|
+
).catch(() => { });
|
|
3023
3035
|
await withTenant(tenantId, (tx) =>
|
|
3024
3036
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3025
|
-
).catch(() => {});
|
|
3037
|
+
).catch(() => { });
|
|
3026
3038
|
}
|
|
3027
3039
|
}
|
|
3028
3040
|
|
|
@@ -3147,7 +3159,7 @@ async function processBatchGenerateAlignedJob(jobId: string, config: any) {
|
|
|
3147
3159
|
logger.error({ jobId, err: msg }, '[batch-generate-aligned] failed');
|
|
3148
3160
|
await withTenant(tenantId, (tx) =>
|
|
3149
3161
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3150
|
-
).catch(() => {});
|
|
3162
|
+
).catch(() => { });
|
|
3151
3163
|
}
|
|
3152
3164
|
}
|
|
3153
3165
|
|
|
@@ -3276,7 +3288,7 @@ async function processCreatePRJob(jobId: string, config: any) {
|
|
|
3276
3288
|
logger.error({ jobId, err: msg }, '[create-pr] failed');
|
|
3277
3289
|
await withTenant(tenantId, (tx) =>
|
|
3278
3290
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3279
|
-
).catch(() => {});
|
|
3291
|
+
).catch(() => { });
|
|
3280
3292
|
}
|
|
3281
3293
|
}
|
|
3282
3294
|
|
|
@@ -3380,6 +3392,6 @@ async function processNewPagesPipelineJob(jobId: string, config: any) {
|
|
|
3380
3392
|
logger.error({ jobId, err: msg }, '[new-pages-pipeline] failed');
|
|
3381
3393
|
await withTenant(tenantId, (tx) =>
|
|
3382
3394
|
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', finishedAt: new Date(), error: msg.slice(0, 1000) } })
|
|
3383
|
-
).catch(() => {});
|
|
3395
|
+
).catch(() => { });
|
|
3384
3396
|
}
|
|
3385
3397
|
}
|
package/types/job-queue.d.ts
CHANGED
|
@@ -78,7 +78,9 @@ type ClaimedJob = {
|
|
|
78
78
|
/** Atomically claims one specific QUEUED job for this worker. Returns null if it was already claimed. */
|
|
79
79
|
export declare function claimSpecificJob(jobId: string): Promise<ClaimedJob | null>;
|
|
80
80
|
/** Atomically claims the oldest QUEUED job across all tenants. SKIP LOCKED makes this
|
|
81
|
-
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
81
|
+
* race-safe against any number of other pollers doing the same thing concurrently.
|
|
82
|
+
* DISCOVER jobs are subject to MAX_CONCURRENT_DISCOVER — skipped when that many are
|
|
83
|
+
* already RUNNING to prevent Playwright/DB pool exhaustion from simultaneous crawls. */
|
|
82
84
|
export declare function claimNextQueuedJob(): Promise<ClaimedJob | null>;
|
|
83
85
|
/** Requeues RUNNING jobs whose heartbeat has gone stale — the worker that claimed them
|
|
84
86
|
* died (crash, restart, OOM) without ever marking them DONE/FAILED. Idempotent and
|