@detiq/crawler 1.1.23 → 1.1.25
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 +408 -61
- 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 +489 -63
- 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,9 +446,11 @@ 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
456
|
const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: 'queue.paused' } });
|
|
@@ -454,9 +535,9 @@ export async function enqueueJob(type: string, config: any): Promise<string> {
|
|
|
454
535
|
// latency optimization so a job doesn't sit idle until the next poll tick. The
|
|
455
536
|
// claim is atomic (WHERE status='QUEUED'), so it's safe even if the poll loop's
|
|
456
537
|
// own tick fires concurrently and claims this same job first.
|
|
457
|
-
if (
|
|
538
|
+
if (process.env.JOB_QUEUE_BACKEND === 'sqs') {
|
|
458
539
|
const { sendJobToSqs } = await import('./sqs-queue.js');
|
|
459
|
-
await sendJobToSqs(job.id
|
|
540
|
+
await sendJobToSqs(job.id);
|
|
460
541
|
} else if (process.env.DISABLE_WORKER !== 'true') {
|
|
461
542
|
// Skip inline dispatch when DISABLE_WORKER=true — the dedicated crawler process
|
|
462
543
|
// will pick this up via its poll loop. Inline dispatch in the API container
|
|
@@ -911,8 +992,8 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
911
992
|
WHERE tc."tenantId" = ${tenantId}
|
|
912
993
|
AND s."projectId" = ${projectId}
|
|
913
994
|
AND s."elementHashChangedAt" IS NOT NULL
|
|
914
|
-
AND tc."
|
|
915
|
-
AND s."elementHashChangedAt" > tc."
|
|
995
|
+
AND tc."createdAt" IS NOT NULL
|
|
996
|
+
AND s."elementHashChangedAt" > tc."createdAt"
|
|
916
997
|
AND tc.status != 'PENDING_REVIEW'
|
|
917
998
|
AND tc.status != 'RETIRED'
|
|
918
999
|
`;
|
|
@@ -967,6 +1048,7 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
|
|
|
967
1048
|
status: aborted ? 'CANCELLED' : 'FAILED',
|
|
968
1049
|
error: aborted ? 'Stopped by user' : String(err),
|
|
969
1050
|
finishedAt: new Date(),
|
|
1051
|
+
...(aborted ? {} : { nextRetryAt: new Date(Date.now() + 60_000) }),
|
|
970
1052
|
...(checkpoint ? { result: { checkpoint } as any } : {}),
|
|
971
1053
|
},
|
|
972
1054
|
})
|
|
@@ -1051,7 +1133,7 @@ async function processApiTestsJob(jobId: string, config: any) {
|
|
|
1051
1133
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1052
1134
|
} catch (err: any) {
|
|
1053
1135
|
await withTenant(tenantId, (tx) =>
|
|
1054
|
-
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) } })
|
|
1055
1137
|
);
|
|
1056
1138
|
}
|
|
1057
1139
|
}
|
|
@@ -1082,7 +1164,7 @@ async function processSecurityTestsJob(jobId: string, config: any) {
|
|
|
1082
1164
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1083
1165
|
} catch (err: any) {
|
|
1084
1166
|
await withTenant(tenantId, (tx) =>
|
|
1085
|
-
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) } })
|
|
1086
1168
|
);
|
|
1087
1169
|
}
|
|
1088
1170
|
}
|
|
@@ -1113,7 +1195,7 @@ async function processIntegrationTestsJob(jobId: string, config: any) {
|
|
|
1113
1195
|
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1114
1196
|
} catch (err: any) {
|
|
1115
1197
|
await withTenant(tenantId, (tx) =>
|
|
1116
|
-
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) } })
|
|
1117
1199
|
);
|
|
1118
1200
|
}
|
|
1119
1201
|
}
|
|
@@ -1318,8 +1400,8 @@ async function processNativeCrawlJob(jobId: string, config: any) {
|
|
|
1318
1400
|
WHERE tc."tenantId" = ${tenantId}
|
|
1319
1401
|
AND s."projectId" = ${projectId}
|
|
1320
1402
|
AND s."elementHashChangedAt" IS NOT NULL
|
|
1321
|
-
AND tc."
|
|
1322
|
-
AND s."elementHashChangedAt" > tc."
|
|
1403
|
+
AND tc."createdAt" IS NOT NULL
|
|
1404
|
+
AND s."elementHashChangedAt" > tc."createdAt"
|
|
1323
1405
|
AND tc.status != 'PENDING_REVIEW'
|
|
1324
1406
|
AND tc.status != 'RETIRED'
|
|
1325
1407
|
`;
|
|
@@ -1863,45 +1945,52 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
|
|
|
1863
1945
|
}
|
|
1864
1946
|
: undefined;
|
|
1865
1947
|
|
|
1866
|
-
const
|
|
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
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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) => {
|
|
1903
1989
|
if (err?.message === 'CRAWL_ABORTED') throw err;
|
|
1904
|
-
|
|
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');
|
|
1905
1994
|
return { discoveredUrls: [] as string[] };
|
|
1906
1995
|
});
|
|
1907
1996
|
|
|
@@ -2572,6 +2661,322 @@ async function processCaptureStepsJob(jobId: string, config: any) {
|
|
|
2572
2661
|
}
|
|
2573
2662
|
}
|
|
2574
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
|
+
|
|
2575
2980
|
// Registers the type→processor dispatch table used by the durable worker (both the
|
|
2576
2981
|
// enqueueJob() fast-path kick and startJobWorkerLoop()'s poll). Declared at module
|
|
2577
2982
|
// bottom so it can reference every process*Job function by name; function
|
|
@@ -2597,7 +3002,10 @@ jobDispatchTable = {
|
|
|
2597
3002
|
BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
|
|
2598
3003
|
CREATE_PR: processCreatePRJob,
|
|
2599
3004
|
NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
|
|
2600
|
-
|
|
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.
|
|
2601
3009
|
// If the worker claims one, the API crashed after creating it — mark failed so it's visible.
|
|
2602
3010
|
GENERATE_TESTS: async (jobId: string, _config: any) => {
|
|
2603
3011
|
logger.error({ jobId }, '[job-worker] GENERATE_TESTS claimed by worker — API did not process it in-process');
|
|
@@ -2613,13 +3021,6 @@ jobDispatchTable = {
|
|
|
2613
3021
|
data: { status: 'FAILED', error: 'Data generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2614
3022
|
}).catch(() => { });
|
|
2615
3023
|
},
|
|
2616
|
-
GENERATE_SCRIPT: async (jobId: string, _config: any) => {
|
|
2617
|
-
logger.error({ jobId }, '[job-worker] GENERATE_SCRIPT claimed by worker — API did not process it in-process');
|
|
2618
|
-
await prismaAdmin.platformJob.update({
|
|
2619
|
-
where: { id: jobId },
|
|
2620
|
-
data: { status: 'FAILED', error: 'Script generation was not completed by the API — please retry from the UI.', finishedAt: new Date() },
|
|
2621
|
-
}).catch(() => { });
|
|
2622
|
-
},
|
|
2623
3024
|
GENERATE_TEST_PLAN: async (jobId: string, _config: any) => {
|
|
2624
3025
|
logger.error({ jobId }, '[job-worker] GENERATE_TEST_PLAN claimed by worker — API did not process it in-process');
|
|
2625
3026
|
await prismaAdmin.platformJob.update({
|
|
@@ -2660,6 +3061,12 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2660
3061
|
const treeResult = await fetchRepoTree(token, conn.repoFullName, 'HEAD');
|
|
2661
3062
|
if (treeResult.truncated) {
|
|
2662
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
|
+
);
|
|
2663
3070
|
}
|
|
2664
3071
|
|
|
2665
3072
|
const frameworks = detectFramework(treeResult.paths);
|
|
@@ -2687,12 +3094,20 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
2687
3094
|
/[A-Z]\w*Tests?\.java$/.test(p) ||
|
|
2688
3095
|
(/\/tests?\//.test(p) && /[A-Z][a-z]/.test(p.split('/').pop() ?? ''))
|
|
2689
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
|
+
: [];
|
|
2690
3105
|
const testPaths = treeResult.paths.filter((p) => {
|
|
2691
3106
|
if (language === 'java') return isJavaTestFile(p);
|
|
2692
3107
|
if (language === 'python') return isPythonTestFile(p);
|
|
2693
|
-
if (
|
|
2694
|
-
return p.endsWith('.spec.ts') || p.endsWith('.spec.js') || p.endsWith('.test.ts') || p.endsWith('.test.js') || p.endsWith('_test.py') || p.includes('/test_');
|
|
2695
|
-
}).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
|
|
2696
3111
|
|
|
2697
3112
|
await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
|
|
2698
3113
|
|
|
@@ -3049,6 +3464,17 @@ async function processRepoScanJob(jobId: string, config: any) {
|
|
|
3049
3464
|
} catch (e: any) {
|
|
3050
3465
|
logger.warn({ jobId, repoConnectionId, err: e?.message }, '[repo-scan] repo skill files generation failed (non-fatal)');
|
|
3051
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
|
+
}
|
|
3052
3478
|
|
|
3053
3479
|
await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
|
|
3054
3480
|
await withTenant(tenantId, (tx) =>
|
|
@@ -3217,7 +3643,7 @@ async function processCreatePRJob(jobId: string, config: any) {
|
|
|
3217
3643
|
if (testScriptIds && testScriptIds.length > 0) {
|
|
3218
3644
|
scripts = await withTenant(tenantId, (tx) =>
|
|
3219
3645
|
(tx as any).testScript.findMany({
|
|
3220
|
-
where: { id: { in: testScriptIds }, tenantId },
|
|
3646
|
+
where: { id: { in: testScriptIds }, tenantId, code: { not: null } },
|
|
3221
3647
|
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
|
|
3222
3648
|
})
|
|
3223
3649
|
);
|
|
@@ -3225,7 +3651,7 @@ async function processCreatePRJob(jobId: string, config: any) {
|
|
|
3225
3651
|
// All scripts that haven't been pushed yet
|
|
3226
3652
|
scripts = await withTenant(tenantId, (tx) =>
|
|
3227
3653
|
(tx as any).testScript.findMany({
|
|
3228
|
-
where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] } },
|
|
3654
|
+
where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ['PASS', 'PENDING'] }, code: { not: null } },
|
|
3229
3655
|
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } },
|
|
3230
3656
|
})
|
|
3231
3657
|
);
|