@detiq/crawler 1.1.22 → 1.1.24
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 +173 -28
- package/dist/crawler.js.map +2 -2
- package/dist/extractors/branding-extractor.js +104 -0
- package/dist/extractors/branding-extractor.js.map +7 -0
- package/dist/extractors/summary-extractor.js +36 -0
- package/dist/extractors/summary-extractor.js.map +7 -0
- package/dist/frontier.js +20 -2
- package/dist/frontier.js.map +2 -2
- package/dist/incremental-diff.js +76 -0
- package/dist/incremental-diff.js.map +2 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +2 -2
- package/dist/interactive-probe.js +108 -17
- package/dist/interactive-probe.js.map +2 -2
- package/dist/job-queue.js +430 -67
- package/dist/job-queue.js.map +2 -2
- package/dist/sqs-queue.js +78 -10
- package/dist/sqs-queue.js.map +2 -2
- package/dist/worker.js +7 -7
- package/dist/worker.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +208 -30
- package/src/extractors/branding-extractor.ts +159 -0
- package/src/extractors/summary-extractor.ts +49 -0
- package/src/frontier.ts +31 -1
- package/src/incremental-diff.ts +138 -0
- package/src/index.ts +1 -1
- package/src/interactive-probe.ts +129 -20
- package/src/job-queue.ts +525 -69
- package/src/sqs-queue.ts +92 -12
- package/src/worker.ts +13 -11
- package/types/crawler.d.ts +20 -0
- package/types/extractors/branding-extractor.d.ts +46 -0
- package/types/extractors/summary-extractor.d.ts +10 -0
- package/types/frontier.d.ts +9 -1
- package/types/incremental-diff.d.ts +57 -0
- package/types/index.d.ts +1 -1
- package/types/interactive-probe.d.ts +5 -1
- package/types/job-queue.d.ts +53 -0
- package/types/sqs-queue.d.ts +12 -1
package/src/job-queue.ts
CHANGED
|
@@ -178,6 +178,40 @@ export interface PlatformJobConfig {
|
|
|
178
178
|
ssoScope?: string;
|
|
179
179
|
/** HTTP/HTTPS/SOCKS5 proxy URL — routes browser through this proxy for bot-protected sites. */
|
|
180
180
|
proxyUrl?: string;
|
|
181
|
+
// ─── scrapegraph gap fields ──────────────────────────────────────────────
|
|
182
|
+
/** HTML extraction mode. 'normal'=raw, 'reader'=Readability article, 'prune'=strip nav/ads. Default: 'normal'. */
|
|
183
|
+
htmlMode?: 'normal' | 'reader' | 'prune';
|
|
184
|
+
/** Per-request screenshot dimensions and quality. Defaults: 1440×900, quality 80. */
|
|
185
|
+
screenshotConfig?: {
|
|
186
|
+
fullPage?: boolean;
|
|
187
|
+
width?: number;
|
|
188
|
+
height?: number;
|
|
189
|
+
quality?: number;
|
|
190
|
+
};
|
|
191
|
+
/** Enable/disable Playwright stealth plugin. Default: true (existing behaviour). */
|
|
192
|
+
stealth?: boolean;
|
|
193
|
+
/** Mock mode — intercept all navigation and return synthetic content. Good for CI. */
|
|
194
|
+
mock?: boolean | { minKb?: number; maxKb?: number; minSleep?: number; maxSleep?: number };
|
|
195
|
+
/** MIME type allowlist — crawler skips URLs returning disallowed content types. */
|
|
196
|
+
allowedTypes?: string[];
|
|
197
|
+
/** Glob-style include patterns for URL paths (e.g. '/blog/*'). Only matching paths crawled. */
|
|
198
|
+
includePatterns?: string[];
|
|
199
|
+
/** Glob-style exclude patterns for URL paths (e.g. '/admin/*'). Matching paths skipped. */
|
|
200
|
+
excludePatterns?: string[];
|
|
201
|
+
/** Allow crawler to follow links to external domains. Default: false. */
|
|
202
|
+
allowExternal?: boolean;
|
|
203
|
+
/** Requested output formats for a single-pass multi-format extraction. */
|
|
204
|
+
requestedFormats?: Array<'markdown' | 'html' | 'links' | 'images' | 'summary' | 'json' | 'branding' | 'screenshot'>;
|
|
205
|
+
/** LLM chunker configuration for AI extraction passes. */
|
|
206
|
+
chunkerConfig?: { size?: number | 'dynamic'; overlap?: number };
|
|
207
|
+
/** ISO-3166-1 alpha-2 country code for geo-targeted proxy routing (e.g. 'us', 'gb'). */
|
|
208
|
+
proxyCountry?: string;
|
|
209
|
+
/** When true, after the primary crawl auto-enqueue one CRAWL job per credentialProfile. */
|
|
210
|
+
crawlAllRoles?: boolean;
|
|
211
|
+
/** Decrypted credential profiles used when crawlAllRoles is true. */
|
|
212
|
+
credentialProfiles?: Array<{ id: string; name: string; username: string; password: string }>;
|
|
213
|
+
/** Set on role-crawl sub-jobs — profile name tagged on discovered screens via roleAccess column. */
|
|
214
|
+
roleProfileName?: string;
|
|
181
215
|
}
|
|
182
216
|
|
|
183
217
|
/** In-memory map of jobId → AbortController for RUNNING jobs. */
|
|
@@ -318,7 +352,7 @@ export async function requeueOrphanedJobs(): Promise<void> {
|
|
|
318
352
|
|
|
319
353
|
/** Dispatches a claimed job to its type's processor. Last-resort safety net — every
|
|
320
354
|
* process*Job function already catches and records its own failures. */
|
|
321
|
-
async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
355
|
+
export async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
322
356
|
const dispatcher = jobDispatchTable?.[job.type];
|
|
323
357
|
if (!dispatcher) {
|
|
324
358
|
logger.error({ jobId: job.id, type: job.type }, '[job-worker] no dispatcher registered for job type');
|
|
@@ -344,13 +378,58 @@ async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
|
|
|
344
378
|
logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, '[job-worker] job threw uncaught error');
|
|
345
379
|
await prismaAdmin.platformJob.update({
|
|
346
380
|
where: { id: job.id },
|
|
347
|
-
data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date() },
|
|
381
|
+
data: { status: 'FAILED', error: String(err?.message ?? err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) },
|
|
348
382
|
}).catch(() => { });
|
|
349
383
|
}
|
|
350
384
|
}
|
|
351
385
|
|
|
352
386
|
let workerLoopTimer: ReturnType<typeof setInterval> | null = null;
|
|
353
387
|
|
|
388
|
+
/** Re-promotes FAILED jobs to QUEUED if they are eligible for retry based on per-type limits. */
|
|
389
|
+
export async function retryEligibleFailedJobs(): Promise<void> {
|
|
390
|
+
const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
|
|
391
|
+
UPDATE "PlatformJob"
|
|
392
|
+
SET status = 'QUEUED',
|
|
393
|
+
"retryCount" = "retryCount" + 1,
|
|
394
|
+
"nextRetryAt" = NOW() + (INTERVAL '1 minute' * POWER(2, "retryCount")),
|
|
395
|
+
"workerId" = NULL
|
|
396
|
+
WHERE status = 'FAILED'
|
|
397
|
+
AND dlq = false
|
|
398
|
+
AND "nextRetryAt" <= NOW()
|
|
399
|
+
AND "retryCount" < CASE
|
|
400
|
+
WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
|
|
401
|
+
WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
|
|
402
|
+
WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
|
|
403
|
+
ELSE 1
|
|
404
|
+
END
|
|
405
|
+
RETURNING id
|
|
406
|
+
`;
|
|
407
|
+
if (rows.length > 0) {
|
|
408
|
+
logger.info({ jobIds: rows.map((r: any) => r.id) }, '[job-worker] requeued failed job(s) for retry');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Promotes FAILED jobs to DLQ if they exceed their per-type retry limits. */
|
|
413
|
+
export async function promoteToDeadLetterQueue(): Promise<void> {
|
|
414
|
+
const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
|
|
415
|
+
UPDATE "PlatformJob"
|
|
416
|
+
SET dlq = true,
|
|
417
|
+
"dlqReason" = error
|
|
418
|
+
WHERE status = 'FAILED'
|
|
419
|
+
AND dlq = false
|
|
420
|
+
AND "retryCount" >= CASE
|
|
421
|
+
WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
|
|
422
|
+
WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
|
|
423
|
+
WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
|
|
424
|
+
ELSE 1
|
|
425
|
+
END
|
|
426
|
+
RETURNING id
|
|
427
|
+
`;
|
|
428
|
+
if (rows.length > 0) {
|
|
429
|
+
logger.error({ jobIds: rows.map((r: any) => r.id) }, '[job-worker] promoted failed job(s) to DLQ');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
354
433
|
/**
|
|
355
434
|
* Starts the durable job worker poll loop. This is what makes job processing
|
|
356
435
|
* survive a restart — enqueueJob()'s immediate same-process claim attempt is
|
|
@@ -367,15 +446,22 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
|
|
|
367
446
|
|
|
368
447
|
const tick = async () => {
|
|
369
448
|
try {
|
|
449
|
+
await retryEligibleFailedJobs();
|
|
450
|
+
await promoteToDeadLetterQueue();
|
|
370
451
|
await requeueStaleRunningJobs();
|
|
371
452
|
} catch (err) {
|
|
372
|
-
logger.error({ err: String(err) }, '[job-worker] stale
|
|
453
|
+
logger.error({ err: String(err) }, '[job-worker] stale/retry job sweep failed');
|
|
373
454
|
}
|
|
374
455
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
456
|
+
const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
|
|
457
|
+
if (pauseRow?.value === 'true') {
|
|
458
|
+
logger.info('[job-worker] queue is paused — skipping claim');
|
|
459
|
+
} else {
|
|
460
|
+
for (let i = 0; i < concurrency; i++) {
|
|
461
|
+
const job = await claimNextQueuedJob();
|
|
462
|
+
if (!job) break;
|
|
463
|
+
void dispatchClaimedJob(job); // not awaited — runs concurrently while the loop keeps polling
|
|
464
|
+
}
|
|
379
465
|
}
|
|
380
466
|
} catch (err) {
|
|
381
467
|
logger.error({ err: String(err) }, '[job-worker] claim loop failed');
|
|
@@ -449,9 +535,9 @@ export async function enqueueJob(type: string, config: any): Promise<string> {
|
|
|
449
535
|
// latency optimization so a job doesn't sit idle until the next poll tick. The
|
|
450
536
|
// claim is atomic (WHERE status='QUEUED'), so it's safe even if the poll loop's
|
|
451
537
|
// own tick fires concurrently and claims this same job first.
|
|
452
|
-
if (
|
|
538
|
+
if (process.env.JOB_QUEUE_BACKEND === 'sqs') {
|
|
453
539
|
const { sendJobToSqs } = await import('./sqs-queue.js');
|
|
454
|
-
await sendJobToSqs(job.id
|
|
540
|
+
await sendJobToSqs(job.id);
|
|
455
541
|
} else if (process.env.DISABLE_WORKER !== 'true') {
|
|
456
542
|
// Skip inline dispatch when DISABLE_WORKER=true — the dedicated crawler process
|
|
457
543
|
// will pick this up via its poll loop. Inline dispatch in the API container
|
|
@@ -906,8 +992,8 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
906
992
|
WHERE tc."tenantId" = ${tenantId}
|
|
907
993
|
AND s."projectId" = ${projectId}
|
|
908
994
|
AND s."elementHashChangedAt" IS NOT NULL
|
|
909
|
-
AND tc."
|
|
910
|
-
AND s."elementHashChangedAt" > tc."
|
|
995
|
+
AND tc."createdAt" IS NOT NULL
|
|
996
|
+
AND s."elementHashChangedAt" > tc."createdAt"
|
|
911
997
|
AND tc.status != 'PENDING_REVIEW'
|
|
912
998
|
AND tc.status != 'RETIRED'
|
|
913
999
|
`;
|
|
@@ -962,6 +1048,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
962
1048
|
status: aborted ? 'CANCELLED' : 'FAILED',
|
|
963
1049
|
error: aborted ? 'Stopped by user' : String(err),
|
|
964
1050
|
finishedAt: new Date(),
|
|
1051
|
+
...(aborted ? {} : { nextRetryAt: new Date(Date.now() + 60_000) }),
|
|
965
1052
|
...(checkpoint ? { result: { checkpoint } as any } : {}),
|
|
966
1053
|
},
|
|
967
1054
|
})
|
|
@@ -1046,7 +1133,7 @@ async function processApiTestsJob(jobId: string, config: any) {
|
|
|
1046
1133
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1047
1134
|
} catch (err: any) {
|
|
1048
1135
|
await withTenant(tenantId, (tx) =>
|
|
1049
|
-
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date() } })
|
|
1136
|
+
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
|
|
1050
1137
|
);
|
|
1051
1138
|
}
|
|
1052
1139
|
}
|
|
@@ -1077,7 +1164,7 @@ async function processSecurityTestsJob(jobId: string, config: any) {
|
|
|
1077
1164
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1078
1165
|
} catch (err: any) {
|
|
1079
1166
|
await withTenant(tenantId, (tx) =>
|
|
1080
|
-
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date() } })
|
|
1167
|
+
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
|
|
1081
1168
|
);
|
|
1082
1169
|
}
|
|
1083
1170
|
}
|
|
@@ -1108,7 +1195,7 @@ async function processIntegrationTestsJob(jobId: string, config: any) {
|
|
|
1108
1195
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1109
1196
|
} catch (err: any) {
|
|
1110
1197
|
await withTenant(tenantId, (tx) =>
|
|
1111
|
-
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date() } })
|
|
1198
|
+
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: String(err), finishedAt: new Date(), nextRetryAt: new Date(Date.now() + 60_000) } })
|
|
1112
1199
|
);
|
|
1113
1200
|
}
|
|
1114
1201
|
}
|
|
@@ -1313,8 +1400,8 @@ async function processNativeCrawlJob(jobId: string, config: any) {
|
|
|
1313
1400
|
WHERE tc."tenantId" = ${tenantId}
|
|
1314
1401
|
AND s."projectId" = ${projectId}
|
|
1315
1402
|
AND s."elementHashChangedAt" IS NOT NULL
|
|
1316
|
-
AND tc."
|
|
1317
|
-
AND s."elementHashChangedAt" > tc."
|
|
1403
|
+
AND tc."createdAt" IS NOT NULL
|
|
1404
|
+
AND s."elementHashChangedAt" > tc."createdAt"
|
|
1318
1405
|
AND tc.status != 'PENDING_REVIEW'
|
|
1319
1406
|
AND tc.status != 'RETIRED'
|
|
1320
1407
|
`;
|
|
@@ -1858,45 +1945,52 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1858
1945
|
}
|
|
1859
1946
|
: undefined;
|
|
1860
1947
|
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
:
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1948
|
+
const PHASE3_TIMEOUT_MS = 20 * 60_000; // 20-minute cap on authenticated spider
|
|
1949
|
+
const authResult = await Promise.race([
|
|
1950
|
+
crawlProject({
|
|
1951
|
+
tenantId,
|
|
1952
|
+
projectId,
|
|
1953
|
+
appUrl,
|
|
1954
|
+
discoveryMode: true,
|
|
1955
|
+
credentials: runtimeCreds,
|
|
1956
|
+
storageState: crawlCfg.storageState,
|
|
1957
|
+
httpBasicUsername: crawlCfg.httpBasicUsername,
|
|
1958
|
+
httpBasicPassword: crawlCfg.httpBasicPassword,
|
|
1959
|
+
authToken: crawlCfg.authToken,
|
|
1960
|
+
authTokenHeader: crawlCfg.authTokenHeader,
|
|
1961
|
+
authTokenPrefix: crawlCfg.authTokenPrefix,
|
|
1962
|
+
startUrls: crawlCfg.postLoginUrl
|
|
1963
|
+
? [crawlCfg.postLoginUrl, ...(sitemapUrls.length > 0 ? sitemapUrls : [])]
|
|
1964
|
+
: (sitemapUrls.length > 0 ? sitemapUrls : undefined),
|
|
1965
|
+
screenshotDir,
|
|
1966
|
+
maxScreens: maxDiscoverUrls,
|
|
1967
|
+
proxyUrl,
|
|
1968
|
+
chromeProfilePath: crawlCfg.chromeProfilePath,
|
|
1969
|
+
useFirefox: crawlCfg.useFirefox,
|
|
1970
|
+
storageConfig: discoverStorageConfig,
|
|
1971
|
+
captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
|
|
1972
|
+
captchaSolverProvider: crawlCfg.captchaSolverProvider,
|
|
1973
|
+
mailslurpApiKey: crawlCfg.mailslurpApiKey,
|
|
1974
|
+
mailslurpInboxId: crawlCfg.mailslurpInboxId,
|
|
1975
|
+
loginInstructions: crawlCfg.loginInstructions,
|
|
1976
|
+
...resolvedSso,
|
|
1977
|
+
abortSignal: controller.signal,
|
|
1978
|
+
jobId,
|
|
1979
|
+
onUrlsDiscovered: async (urls: string[]) => {
|
|
1980
|
+
for (const u of urls) allUrls.add(u);
|
|
1981
|
+
const pct = 52 + Math.round((allUrls.size / Math.max(1, maxDiscoverUrls)) * 38);
|
|
1982
|
+
await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)…`);
|
|
1983
|
+
},
|
|
1984
|
+
}, async (pct: number, step: string) => {
|
|
1985
|
+
await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
|
|
1986
|
+
}),
|
|
1987
|
+
new Promise<never>((_, rej) => setTimeout(() => rej(new Error('AUTH_PHASE_TIMEOUT')), PHASE3_TIMEOUT_MS)),
|
|
1988
|
+
]).catch((err: any) => {
|
|
1898
1989
|
if (err?.message === 'CRAWL_ABORTED') throw err;
|
|
1899
|
-
|
|
1990
|
+
const reason = err?.message === 'AUTH_PHASE_TIMEOUT'
|
|
1991
|
+
? 'Authenticated spider timed out after 20 minutes'
|
|
1992
|
+
: err?.message;
|
|
1993
|
+
logger.warn({ reason }, '[discover] Phase 3 authenticated spider failed — using unauthenticated results');
|
|
1900
1994
|
return { discoveredUrls: [] as string[] };
|
|
1901
1995
|
});
|
|
1902
1996
|
|
|
@@ -2389,10 +2483,35 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2389
2483
|
if (!tc) throw new Error('Test case not found');
|
|
2390
2484
|
|
|
2391
2485
|
const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
2392
|
-
|
|
2486
|
+
if (rawSteps.length === 0) throw new Error('Test case has no steps to capture');
|
|
2487
|
+
|
|
2488
|
+
// Resolve env tokens from testDataInline so {{fze.env.URL}} → concrete URL before execution plan
|
|
2489
|
+
const _tdi = ((tc as any).testDataInline ?? {}) as Record<string, unknown>;
|
|
2490
|
+
const _tdiStartUrl = typeof _tdi['startUrl'] === 'string' && (_tdi['startUrl'] as string).startsWith('http')
|
|
2491
|
+
? (_tdi['startUrl'] as string) : '';
|
|
2492
|
+
const _recordedUrls = Array.isArray(_tdi['recordedUrls']) ? (_tdi['recordedUrls'] as unknown[]).map(String) : [];
|
|
2493
|
+
const _recordedStartUrl = _recordedUrls.find((u) => u.startsWith('http')) ?? '';
|
|
2494
|
+
|
|
2495
|
+
const baseUrl = config.baseUrl || _tdiStartUrl || _recordedStartUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
|
|
2496
|
+
|
|
2497
|
+
// Build env token → concrete value map from testDataInline for step text substitution
|
|
2498
|
+
const _envTokenMap: Record<string, string> = {};
|
|
2499
|
+
for (const [k, v] of Object.entries(_tdi)) {
|
|
2500
|
+
if (typeof v === 'string' && v.startsWith('http')) _envTokenMap[k] = v;
|
|
2501
|
+
}
|
|
2502
|
+
if (_recordedStartUrl && !_envTokenMap['startUrl']) _envTokenMap['startUrl'] = _recordedStartUrl;
|
|
2503
|
+
|
|
2504
|
+
// Replace {{fze.env.X}} and [env:X] tokens in step texts with concrete values from testDataInline.
|
|
2505
|
+
// IMPORTANT: only substitute if we have a concrete value for that key.
|
|
2506
|
+
// Fall back to [key] placeholder — never fall back to baseUrl (would inject URL into credential fields).
|
|
2507
|
+
const resolveStepText = (text: string): string =>
|
|
2508
|
+
text
|
|
2509
|
+
.replace(/\{\{\s*fze\.env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`)
|
|
2510
|
+
.replace(/\[env:([A-Za-z_][A-Za-z0-9_]*)\]/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`);
|
|
2511
|
+
|
|
2512
|
+
const stepTexts = rawSteps.map(captureStepText).filter(Boolean).map(resolveStepText);
|
|
2393
2513
|
if (stepTexts.length === 0) throw new Error('Test case has no steps to capture');
|
|
2394
2514
|
|
|
2395
|
-
const baseUrl = config.baseUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || '';
|
|
2396
2515
|
let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
|
|
2397
2516
|
plan = Array.isArray(plan)
|
|
2398
2517
|
? plan.filter(Boolean).map((step: any, idx: number) => ({ ...step, stepNumber: idx + 1 }))
|
|
@@ -2542,6 +2661,322 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2542
2661
|
}
|
|
2543
2662
|
}
|
|
2544
2663
|
|
|
2664
|
+
// ── Multi-role crawl orchestrator ────────────────────────────────────────────
|
|
2665
|
+
// Runs a primary CRAWL to discover all screens, then fans out one ROLE_CRAWL
|
|
2666
|
+
// job per credential profile. Role jobs run concurrently via the worker pool.
|
|
2667
|
+
|
|
2668
|
+
async function processMultiRoleCrawlJob(jobId: string, config: any) {
|
|
2669
|
+
const { tenantId, projectId } = config;
|
|
2670
|
+
try {
|
|
2671
|
+
await withTenant(tenantId, (tx) =>
|
|
2672
|
+
tx.platformJob.updateMany({
|
|
2673
|
+
where: { id: jobId, status: { not: 'CANCELLED' } },
|
|
2674
|
+
data: { status: 'RUNNING', startedAt: new Date(), heartbeatAt: new Date(), workerId: WORKER_ID },
|
|
2675
|
+
})
|
|
2676
|
+
);
|
|
2677
|
+
|
|
2678
|
+
await updateJobProgress(tenantId, jobId, 5, 'Loading project crawl config…');
|
|
2679
|
+
|
|
2680
|
+
// Load saved project crawlConfig for primary crawl parameters
|
|
2681
|
+
const proj = await withTenant(tenantId, (tx) =>
|
|
2682
|
+
(tx as any).project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
2683
|
+
) as { crawlConfig: any } | null;
|
|
2684
|
+
const savedCrawlConfig = proj?.crawlConfig ?? {};
|
|
2685
|
+
|
|
2686
|
+
const credentialProfiles: Array<{ id: string; name: string; username: string; password: string }> =
|
|
2687
|
+
config.credentialProfiles ?? [];
|
|
2688
|
+
|
|
2689
|
+
// ── Step 1: enqueue primary CRAWL ────────────────────────────────────────
|
|
2690
|
+
await updateJobProgress(tenantId, jobId, 10, 'Enqueuing primary crawl…');
|
|
2691
|
+
const primaryJobId = await enqueueJob('CRAWL', {
|
|
2692
|
+
tenantId,
|
|
2693
|
+
projectId,
|
|
2694
|
+
appUrl: config.appUrl || savedCrawlConfig.appUrl,
|
|
2695
|
+
credentials: savedCrawlConfig.username && savedCrawlConfig.password
|
|
2696
|
+
? { username: savedCrawlConfig.username, password: savedCrawlConfig.password, ...(savedCrawlConfig.totpSecret ? { totpSecret: savedCrawlConfig.totpSecret } : {}) }
|
|
2697
|
+
: undefined,
|
|
2698
|
+
storageState: savedCrawlConfig.storageState,
|
|
2699
|
+
screenshotDir: platformConfig.screenshotDir,
|
|
2700
|
+
startUrls: savedCrawlConfig.seedUrls?.length ? savedCrawlConfig.seedUrls : undefined,
|
|
2701
|
+
deviceProfiles: savedCrawlConfig.deviceProfiles ?? ['DESKTOP'],
|
|
2702
|
+
loginInstructions: savedCrawlConfig.loginInstructions,
|
|
2703
|
+
proxyUrl: savedCrawlConfig.proxyUrl,
|
|
2704
|
+
useSitemap: true,
|
|
2705
|
+
sitemapUrl: savedCrawlConfig.sitemapUrl,
|
|
2706
|
+
});
|
|
2707
|
+
logger.info({ primaryJobId, projectId }, '[multi-role-crawl] primary CRAWL enqueued');
|
|
2708
|
+
|
|
2709
|
+
// ── Step 2: poll until primary CRAWL is done (2-hour timeout) ────────────
|
|
2710
|
+
const PRIMARY_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
2711
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
2712
|
+
const deadline = Date.now() + PRIMARY_TIMEOUT_MS;
|
|
2713
|
+
let primaryStatus = 'QUEUED';
|
|
2714
|
+
while (Date.now() < deadline) {
|
|
2715
|
+
const primaryJob = await prismaAdmin.platformJob.findUnique({
|
|
2716
|
+
where: { id: primaryJobId },
|
|
2717
|
+
select: { status: true },
|
|
2718
|
+
}).catch(() => null);
|
|
2719
|
+
primaryStatus = primaryJob?.status ?? 'UNKNOWN';
|
|
2720
|
+
if (primaryStatus === 'DONE' || primaryStatus === 'FAILED' || primaryStatus === 'CANCELLED') break;
|
|
2721
|
+
await updateJobProgress(tenantId, jobId, 20, `Waiting for primary crawl (${primaryStatus})…`);
|
|
2722
|
+
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
if (primaryStatus !== 'DONE') {
|
|
2726
|
+
throw new Error(`Primary crawl ${primaryJobId} ended with status ${primaryStatus} — aborting role crawls`);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// ── Step 3: collect all known screen URLs ────────────────────────────────
|
|
2730
|
+
await updateJobProgress(tenantId, jobId, 50, 'Collecting screen URLs for role crawls…');
|
|
2731
|
+
const screens = await prismaAdmin.screen.findMany({
|
|
2732
|
+
where: { tenantId, projectId, status: 'ACTIVE' },
|
|
2733
|
+
select: { id: true, url: true },
|
|
2734
|
+
}).catch(() => [] as Array<{ id: string; url: string | null }>);
|
|
2735
|
+
const screenUrls = screens.filter((s) => s.url).map((s) => s.url as string);
|
|
2736
|
+
logger.info({ screenCount: screenUrls.length, projectId }, '[multi-role-crawl] screen URLs collected');
|
|
2737
|
+
|
|
2738
|
+
// ── Step 4: enqueue one ROLE_CRAWL per credential profile ────────────────
|
|
2739
|
+
if (credentialProfiles.length === 0) {
|
|
2740
|
+
logger.warn({ projectId, jobId }, '[multi-role-crawl] no credential profiles — nothing to role-crawl');
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
const roleJobs: Array<{ roleName: string; jobId: string }> = [];
|
|
2744
|
+
for (const profile of credentialProfiles) {
|
|
2745
|
+
if (!profile.username || !profile.password) continue;
|
|
2746
|
+
const roleJobId = await enqueueJob('ROLE_CRAWL', {
|
|
2747
|
+
tenantId,
|
|
2748
|
+
projectId,
|
|
2749
|
+
appUrl: config.appUrl || savedCrawlConfig.appUrl,
|
|
2750
|
+
credentials: { username: profile.username, password: profile.password },
|
|
2751
|
+
roleProfileName: profile.name,
|
|
2752
|
+
parentJobId: jobId,
|
|
2753
|
+
selectiveUrls: config.fullReCrawl ? undefined : (screenUrls.length > 0 ? screenUrls : undefined),
|
|
2754
|
+
startUrls: config.fullReCrawl ? (savedCrawlConfig.seedUrls ?? []) : undefined,
|
|
2755
|
+
maxScreens: config.fullReCrawl ? undefined : screenUrls.length + 10,
|
|
2756
|
+
screenshotDir: platformConfig.screenshotDir,
|
|
2757
|
+
storageState: savedCrawlConfig.storageState,
|
|
2758
|
+
deviceProfiles: savedCrawlConfig.deviceProfiles ?? ['DESKTOP'],
|
|
2759
|
+
loginInstructions: savedCrawlConfig.loginInstructions,
|
|
2760
|
+
proxyUrl: savedCrawlConfig.proxyUrl,
|
|
2761
|
+
});
|
|
2762
|
+
roleJobs.push({ roleName: profile.name, jobId: roleJobId });
|
|
2763
|
+
logger.info({ roleJobId, roleName: profile.name, projectId }, '[multi-role-crawl] ROLE_CRAWL enqueued');
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// ── Step 5: poll until ALL role crawls complete ───────────────────────────
|
|
2767
|
+
const ROLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
2768
|
+
const roleDeadline = Date.now() + ROLE_TIMEOUT_MS;
|
|
2769
|
+
const roleResults: Array<{ roleName: string; jobId: string; status: string; screensTagged?: number }> = [];
|
|
2770
|
+
|
|
2771
|
+
while (Date.now() < roleDeadline && roleJobs.length > 0) {
|
|
2772
|
+
const pending: typeof roleJobs = [];
|
|
2773
|
+
for (const rj of roleJobs) {
|
|
2774
|
+
const rJob = await prismaAdmin.platformJob.findUnique({
|
|
2775
|
+
where: { id: rj.jobId },
|
|
2776
|
+
select: { status: true, result: true },
|
|
2777
|
+
}).catch(() => null);
|
|
2778
|
+
const st = rJob?.status ?? 'UNKNOWN';
|
|
2779
|
+
if (st === 'DONE' || st === 'FAILED' || st === 'CANCELLED') {
|
|
2780
|
+
roleResults.push({
|
|
2781
|
+
roleName: rj.roleName,
|
|
2782
|
+
jobId: rj.jobId,
|
|
2783
|
+
status: st,
|
|
2784
|
+
screensTagged: (rJob?.result as any)?.screensTagged,
|
|
2785
|
+
});
|
|
2786
|
+
} else {
|
|
2787
|
+
pending.push(rj);
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
roleJobs.length = 0;
|
|
2791
|
+
roleJobs.push(...pending);
|
|
2792
|
+
if (roleJobs.length === 0) break;
|
|
2793
|
+
const pct = Math.round(60 + ((roleResults.length / (roleResults.length + roleJobs.length)) * 35));
|
|
2794
|
+
await updateJobProgress(tenantId, jobId, pct, `Role crawls: ${roleResults.length} done, ${roleJobs.length} pending…`);
|
|
2795
|
+
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// Any still-pending jobs timed out — record them as timed out
|
|
2799
|
+
for (const rj of roleJobs) {
|
|
2800
|
+
roleResults.push({ roleName: rj.roleName, jobId: rj.jobId, status: 'TIMEOUT' });
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
await withTenant(tenantId, (tx) =>
|
|
2804
|
+
tx.platformJob.update({
|
|
2805
|
+
where: { id: jobId },
|
|
2806
|
+
data: {
|
|
2807
|
+
status: 'DONE',
|
|
2808
|
+
progress: 100,
|
|
2809
|
+
finishedAt: new Date(),
|
|
2810
|
+
result: { primaryJobId, roleJobs: roleResults } as any,
|
|
2811
|
+
},
|
|
2812
|
+
})
|
|
2813
|
+
);
|
|
2814
|
+
logger.info({ jobId, primaryJobId, roleCount: roleResults.length, projectId }, '[multi-role-crawl] completed');
|
|
2815
|
+
} catch (err: any) {
|
|
2816
|
+
const msg = String(err?.message ?? err);
|
|
2817
|
+
logger.error({ jobId, err: msg }, '[multi-role-crawl] failed');
|
|
2818
|
+
await withTenant(tenantId, (tx) =>
|
|
2819
|
+
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg, finishedAt: new Date() } })
|
|
2820
|
+
).catch(() => { });
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// ── Role-specific sub-crawl ───────────────────────────────────────────────────
|
|
2825
|
+
// Crawls the app as a specific credential profile and tags all discovered
|
|
2826
|
+
// screens with the role name in the roleAccess array column.
|
|
2827
|
+
|
|
2828
|
+
async function processRoleCrawlJob(jobId: string, config: any) {
|
|
2829
|
+
const { tenantId, projectId } = config;
|
|
2830
|
+
try {
|
|
2831
|
+
const claimed = await withTenant(tenantId, (tx) =>
|
|
2832
|
+
tx.platformJob.updateMany({
|
|
2833
|
+
where: { id: jobId, status: { not: 'CANCELLED' } },
|
|
2834
|
+
data: { status: 'RUNNING', startedAt: new Date(), heartbeatAt: new Date(), workerId: WORKER_ID },
|
|
2835
|
+
})
|
|
2836
|
+
);
|
|
2837
|
+
if (claimed.count === 0) return;
|
|
2838
|
+
|
|
2839
|
+
const controller = new AbortController();
|
|
2840
|
+
runningControllers.set(jobId, controller);
|
|
2841
|
+
const heartbeatTimer = setInterval(() => void heartbeatJob(tenantId, jobId), JOB_HEARTBEAT_INTERVAL_MS);
|
|
2842
|
+
|
|
2843
|
+
const onProgress = async (pct: number, step: string) => {
|
|
2844
|
+
await updateJobProgress(tenantId, jobId, pct, step);
|
|
2845
|
+
};
|
|
2846
|
+
|
|
2847
|
+
try {
|
|
2848
|
+
// Load project crawlConfig for proxy, storageState overrides
|
|
2849
|
+
const proj = await withTenant(tenantId, (tx) =>
|
|
2850
|
+
(tx as any).project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
2851
|
+
) as { crawlConfig: any } | null;
|
|
2852
|
+
const savedCrawlConfig = proj?.crawlConfig ?? {};
|
|
2853
|
+
|
|
2854
|
+
// Build runtime credentials with TOTP support
|
|
2855
|
+
const runtimeCreds = config.credentials
|
|
2856
|
+
? {
|
|
2857
|
+
...config.credentials,
|
|
2858
|
+
...(config.credentials.totpSecret
|
|
2859
|
+
? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) }
|
|
2860
|
+
: {}),
|
|
2861
|
+
}
|
|
2862
|
+
: undefined;
|
|
2863
|
+
|
|
2864
|
+
const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
2865
|
+
|
|
2866
|
+
const result = await crawlProject({
|
|
2867
|
+
...config,
|
|
2868
|
+
credentials: runtimeCreds,
|
|
2869
|
+
storageState: config.storageState ?? savedCrawlConfig.storageState,
|
|
2870
|
+
proxyUrl: config.proxyUrl ?? savedCrawlConfig.proxyUrl,
|
|
2871
|
+
loginInstructions: config.loginInstructions ?? savedCrawlConfig.loginInstructions,
|
|
2872
|
+
abortSignal: controller.signal,
|
|
2873
|
+
jobId,
|
|
2874
|
+
storageConfig: storageConfigForCrawl,
|
|
2875
|
+
}, onProgress);
|
|
2876
|
+
|
|
2877
|
+
clearInterval(heartbeatTimer);
|
|
2878
|
+
runningControllers.delete(jobId);
|
|
2879
|
+
|
|
2880
|
+
// Tag discovered screens with this role
|
|
2881
|
+
const roleName = config.roleProfileName as string;
|
|
2882
|
+
const discovered: any[] = (result as any).discovered ?? [];
|
|
2883
|
+
const roleScreenIds = discovered.filter((d: any) => d.screenId).map((d: any) => d.screenId as string);
|
|
2884
|
+
|
|
2885
|
+
if (roleScreenIds.length > 0 && roleName) {
|
|
2886
|
+
await prismaAdmin.$executeRaw`
|
|
2887
|
+
UPDATE "Screen"
|
|
2888
|
+
SET "roleAccess" = array(SELECT DISTINCT unnest(array_append("roleAccess", ${roleName}::text)))
|
|
2889
|
+
WHERE id = ANY(${roleScreenIds}::text[])
|
|
2890
|
+
AND "tenantId" = ${tenantId}
|
|
2891
|
+
`.catch((e: any) => logger.warn({ err: e?.message, roleName, count: roleScreenIds.length }, '[role-crawl] roleAccess tag failed (non-fatal)'));
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
await withTenant(tenantId, (tx) =>
|
|
2895
|
+
tx.platformJob.update({
|
|
2896
|
+
where: { id: jobId },
|
|
2897
|
+
data: {
|
|
2898
|
+
status: 'DONE',
|
|
2899
|
+
progress: 100,
|
|
2900
|
+
finishedAt: new Date(),
|
|
2901
|
+
result: { screensTagged: roleScreenIds.length, roleName } as any,
|
|
2902
|
+
},
|
|
2903
|
+
})
|
|
2904
|
+
);
|
|
2905
|
+
logger.info({ jobId, roleName, screensTagged: roleScreenIds.length, projectId }, '[role-crawl] completed');
|
|
2906
|
+
} catch (err: any) {
|
|
2907
|
+
clearInterval(heartbeatTimer);
|
|
2908
|
+
runningControllers.delete(jobId);
|
|
2909
|
+
const aborted = String(err).includes('CRAWL_ABORTED');
|
|
2910
|
+
await withTenant(tenantId, (tx) =>
|
|
2911
|
+
tx.platformJob.update({
|
|
2912
|
+
where: { id: jobId },
|
|
2913
|
+
data: {
|
|
2914
|
+
status: aborted ? 'CANCELLED' : 'FAILED',
|
|
2915
|
+
error: aborted ? 'Stopped by user' : String(err),
|
|
2916
|
+
finishedAt: new Date(),
|
|
2917
|
+
},
|
|
2918
|
+
})
|
|
2919
|
+
);
|
|
2920
|
+
} finally {
|
|
2921
|
+
runningControllers.delete(jobId);
|
|
2922
|
+
}
|
|
2923
|
+
} catch (err: any) {
|
|
2924
|
+
const msg = String(err?.message ?? err);
|
|
2925
|
+
logger.error({ jobId, err: msg }, '[role-crawl] outer failure');
|
|
2926
|
+
await withTenant(tenantId, (tx) =>
|
|
2927
|
+
tx.platformJob.update({ where: { id: jobId }, data: { status: 'FAILED', error: msg, finishedAt: new Date() } })
|
|
2928
|
+
).catch(() => { });
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
async function processGenerateScriptJob(jobId: string, config: any): Promise<void> {
|
|
2933
|
+
const { tenantId, projectId, language } = config;
|
|
2934
|
+
try {
|
|
2935
|
+
await updateJobProgress(tenantId, jobId, 1, 'Loading test cases…').catch(() => {});
|
|
2936
|
+
|
|
2937
|
+
if (config.packId) {
|
|
2938
|
+
// Pack script (regression / smoke / sanity / etc.)
|
|
2939
|
+
const { generatePackScript } = await import('@detiq/agents');
|
|
2940
|
+
const result = await generatePackScript({ tenantId, projectId, packType: config.packId, language });
|
|
2941
|
+
await withTenant(tenantId, (tx) =>
|
|
2942
|
+
tx.platformJob.update({
|
|
2943
|
+
where: { id: jobId },
|
|
2944
|
+
data: { status: 'DONE', progress: 100, finishedAt: new Date(), result: result as any },
|
|
2945
|
+
})
|
|
2946
|
+
);
|
|
2947
|
+
} else {
|
|
2948
|
+
// Suite script
|
|
2949
|
+
const { generateSuiteScript } = await import('@detiq/agents');
|
|
2950
|
+
let progressChain: Promise<void> = Promise.resolve();
|
|
2951
|
+
const result = await generateSuiteScript({
|
|
2952
|
+
tenantId, projectId, suiteId: config.suiteId, language,
|
|
2953
|
+
caseIds: config.caseIds ?? undefined,
|
|
2954
|
+
useV2: config.useV2,
|
|
2955
|
+
styleProfile: config.repoStyleProfile ?? undefined,
|
|
2956
|
+
pageObjectInventory: config.repoPageObjects ?? undefined,
|
|
2957
|
+
jobId,
|
|
2958
|
+
onProgress: (done: number, total: number) => {
|
|
2959
|
+
progressChain = progressChain
|
|
2960
|
+
.then(() => updateJobProgress(tenantId, jobId, Math.floor((done / total) * 95), `Generated ${done}/${total} scripts`))
|
|
2961
|
+
.catch(() => {});
|
|
2962
|
+
},
|
|
2963
|
+
});
|
|
2964
|
+
await progressChain.catch(() => {});
|
|
2965
|
+
await import('@detiq/agents')
|
|
2966
|
+
.then(({ refreshScriptInventory }) => refreshScriptInventory(tenantId, projectId))
|
|
2967
|
+
.catch((e: any) => logger.warn({ err: e?.message }, '[generate-script] refreshScriptInventory failed'));
|
|
2968
|
+
await withTenant(tenantId, (tx) =>
|
|
2969
|
+
tx.platformJob.update({
|
|
2970
|
+
where: { id: jobId },
|
|
2971
|
+
data: { status: 'DONE', progress: 100, finishedAt: new Date(), result: result as any },
|
|
2972
|
+
})
|
|
2973
|
+
);
|
|
2974
|
+
}
|
|
2975
|
+
} catch (e: any) {
|
|
2976
|
+
await finishTrackedJob(tenantId, jobId, e?.message ?? 'generate-script failed');
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2545
2980
|
// Registers the type→processor dispatch table used by the durable worker (both the
|
|
2546
2981
|
// enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
|
|
2547
2982
|
// bottom so it can reference every process*Job function by name; function
|
|
@@ -2567,7 +3002,10 @@ jobDispatchTable = {
|
|
|
2567
3002
|
BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
|
|
2568
3003
|
CREATE_PR: processCreatePRJob,
|
|
2569
3004
|
NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
|
|
2570
|
-
|
|
3005
|
+
MULTI_ROLE_CRAWL: processMultiRoleCrawlJob,
|
|
3006
|
+
ROLE_CRAWL: processRoleCrawlJob,
|
|
3007
|
+
GENERATE_SCRIPT: processGenerateScriptJob,
|
|
3008
|
+
// The following job types are still processed in-process by the API.
|
|
2571
3009
|
// If the worker claims one, the API crashed after creating it — mark failed so it's visible.
|
|
2572
3010
|
GENERATE_TESTS: async (jobId: string, _config: any) => {
|
|
2573
3011
|
logger.error({ jobId }, '[job-worker] GENERATE_TESTS claimed by worker — API did not process it in-process');
|
|
@@ -2583,13 +3021,6 @@ jobDispatchTable = {
|
|
|
2583
3021
|
data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2584
3022
|
}).catch(() => { });
|
|
2585
3023
|
},
|
|
2586
|
-
GENERATE_SCRIPT: async (jobId: string, _config: any) => {
|
|
2587
|
-
logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
|
|
2588
|
-
await prismaAdmin.platformJob.update({
|
|
2589
|
-
where: { id: jobId },
|
|
2590
|
-
data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2591
|
-
}).catch(() => { });
|
|
2592
|
-
},
|
|
2593
3024
|
GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
|
|
2594
3025
|
logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
|
|
2595
3026
|
await prismaAdmin.platformJob.update({
|
|
@@ -2630,6 +3061,12 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2630
3061
|
const treeResult = await fetchRepoTree(token, conn.repoFullName, 'HEAD');
|
|
2631
3062
|
if (treeResult.truncated) {
|
|
2632
3063
|
console.warn(`[repo-scan] ${conn.repoFullName}: tree truncated (>100k blobs) — detection may be incomplete`);
|
|
3064
|
+
await withTenant(tenantId, (tx) =>
|
|
3065
|
+
tx.gitHubRepoConnection.update({
|
|
3066
|
+
where: { id: repoConnectionId },
|
|
3067
|
+
data: { scanError: 'Repository tree was truncated by GitHub (>100k files) — some test files in deep subdirectories may be missing from this scan.' },
|
|
3068
|
+
})
|
|
3069
|
+
);
|
|
2633
3070
|
}
|
|
2634
3071
|
|
|
2635
3072
|
const frameworks = detectFramework(treeResult.paths);
|
|
@@ -2657,12 +3094,20 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2657
3094
|
/[A-Z]\w*Tests?\.java$/.test(p) ||
|
|
2658
3095
|
(/\/tests?\//.test(p) && /[A-Z][a-z]/.test(p.split('/').pop() ?? ''))
|
|
2659
3096
|
);
|
|
3097
|
+
// Build list of globs to try — primary + common variants so we don't miss .test.ts alongside .spec.ts
|
|
3098
|
+
const testGlobs = minimatch
|
|
3099
|
+
? Array.from(new Set([
|
|
3100
|
+
testGlob,
|
|
3101
|
+
...(testGlob === '**/*.spec.ts' ? ['**/*.test.ts', '**/*.cy.ts'] : []),
|
|
3102
|
+
...(testGlob === '**/*.spec.js' ? ['**/*.test.js', '**/*.cy.js'] : []),
|
|
3103
|
+
]))
|
|
3104
|
+
: [];
|
|
2660
3105
|
const testPaths = treeResult.paths.filter((p) => {
|
|
2661
3106
|
if (language === 'java') return isJavaTestFile(p);
|
|
2662
3107
|
if (language === 'python') return isPythonTestFile(p);
|
|
2663
|
-
if (
|
|
2664
|
-
return p.endsWith('.spec.ts') || p.endsWith('.spec.js') || p.endsWith('.test.ts') || p.endsWith('.test.js') || p.endsWith('_test.py') || p.includes('/test_');
|
|
2665
|
-
}).slice(0,
|
|
3108
|
+
if (testGlobs.length) return testGlobs.some(g => (minimatch as any)(p, g, { matchBase: true }));
|
|
3109
|
+
return p.endsWith('.spec.ts') || p.endsWith('.spec.js') || p.endsWith('.test.ts') || p.endsWith('.test.js') || p.endsWith('.cy.ts') || p.endsWith('.cy.js') || p.endsWith('_test.py') || p.includes('/test_');
|
|
3110
|
+
}).slice(0, 2000); // cap at 2000 test files
|
|
2666
3111
|
|
|
2667
3112
|
await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
|
|
2668
3113
|
|
|
@@ -3019,6 +3464,17 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
3019
3464
|
} catch (e: any) {
|
|
3020
3465
|
logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] repo skill files generation failed (non-fatal)');
|
|
3021
3466
|
}
|
|
3467
|
+
try {
|
|
3468
|
+
const { alignSkillFile } = await import('@detiq/agents');
|
|
3469
|
+
await alignSkillFile(tenantId, projectId, {
|
|
3470
|
+
framework,
|
|
3471
|
+
language,
|
|
3472
|
+
styleProfile: resolvedStyleProfile,
|
|
3473
|
+
repoFullName: conn.repoFullName,
|
|
3474
|
+
});
|
|
3475
|
+
} catch (e: any) {
|
|
3476
|
+
logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] alignSkillFile failed (non-fatal)');
|
|
3477
|
+
}
|
|
3022
3478
|
|
|
3023
3479
|
await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
|
|
3024
3480
|
await withTenant(tenantId, (tx) =>
|
|
@@ -3187,7 +3643,7 @@ async function processCreatePRJob(jobId: string, config: any) {
|
|
|
3187
3643
|
if (testScriptIds && testScriptIds.length > 0) {
|
|
3188
3644
|
scripts = await withTenant(tenantId, (tx) =>
|
|
3189
3645
|
(tx as any).testScript.findMany({
|
|
3190
|
-
where: { id: { in: testScriptIds }, tenantId },
|
|
3646
|
+
where: { id: { in: testScriptIds }, tenantId, code: { not: null } },
|
|
3191
3647
|
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
|
|
3192
3648
|
})
|
|
3193
3649
|
);
|
|
@@ -3195,7 +3651,7 @@ async function processCreatePRJob(jobId: string, config: any) {
|
|
|
3195
3651
|
// All scripts that haven't been pushed yet
|
|
3196
3652
|
scripts = await withTenant(tenantId, (tx) =>
|
|
3197
3653
|
(tx as any).testScript.findMany({
|
|
3198
|
-
where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] } },
|
|
3654
|
+
where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] }, code: { not: null } },
|
|
3199
3655
|
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
|
|
3200
3656
|
})
|
|
3201
3657
|
);
|