@detiq/crawler 0.1.3
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 +49 -0
- package/README.md +11 -0
- package/dist/accessibility-auditor.d.ts +29 -0
- package/dist/accessibility-auditor.js +57 -0
- package/dist/accessibility-auditor.js.map +7 -0
- package/dist/ai-login.d.ts +32 -0
- package/dist/ai-login.js +177 -0
- package/dist/ai-login.js.map +7 -0
- package/dist/auth-token-rotator.d.ts +47 -0
- package/dist/auth-token-rotator.js +58 -0
- package/dist/auth-token-rotator.js.map +7 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.js +329 -0
- package/dist/auth.js.map +7 -0
- package/dist/browser-extension.d.ts +37 -0
- package/dist/browser-extension.js +113 -0
- package/dist/browser-extension.js.map +7 -0
- package/dist/captcha-solver.d.ts +16 -0
- package/dist/captcha-solver.js +105 -0
- package/dist/captcha-solver.js.map +7 -0
- package/dist/color-contrast.d.ts +30 -0
- package/dist/color-contrast.js +78 -0
- package/dist/color-contrast.js.map +7 -0
- package/dist/content-chunker.d.ts +18 -0
- package/dist/content-chunker.js +152 -0
- package/dist/content-chunker.js.map +7 -0
- package/dist/content-dedup.d.ts +23 -0
- package/dist/content-dedup.js +60 -0
- package/dist/content-dedup.js.map +7 -0
- package/dist/cookie-consent.d.ts +25 -0
- package/dist/cookie-consent.js +78 -0
- package/dist/cookie-consent.js.map +7 -0
- package/dist/cookie-jar.d.ts +20 -0
- package/dist/cookie-jar.js +61 -0
- package/dist/cookie-jar.js.map +7 -0
- package/dist/crawler.d.ts +179 -0
- package/dist/crawler.js +3672 -0
- package/dist/crawler.js.map +7 -0
- package/dist/custom-js-injector.d.ts +42 -0
- package/dist/custom-js-injector.js +50 -0
- package/dist/custom-js-injector.js.map +7 -0
- package/dist/dark-mode-capture.d.ts +23 -0
- package/dist/dark-mode-capture.js +75 -0
- package/dist/dark-mode-capture.js.map +7 -0
- package/dist/dead-link-detector.d.ts +37 -0
- package/dist/dead-link-detector.js +50 -0
- package/dist/dead-link-detector.js.map +7 -0
- package/dist/element-hash.d.ts +9 -0
- package/dist/element-hash.js +9 -0
- package/dist/element-hash.js.map +7 -0
- package/dist/email-otp.d.ts +42 -0
- package/dist/email-otp.js +177 -0
- package/dist/email-otp.js.map +7 -0
- package/dist/engine-router.d.ts +47 -0
- package/dist/engine-router.js +94 -0
- package/dist/engine-router.js.map +7 -0
- package/dist/export/bulk-export.d.ts +26 -0
- package/dist/export/bulk-export.js +76 -0
- package/dist/export/bulk-export.js.map +7 -0
- package/dist/export/vector-export.d.ts +76 -0
- package/dist/export/vector-export.js +85 -0
- package/dist/export/vector-export.js.map +7 -0
- package/dist/extractors/advanced.d.ts +54 -0
- package/dist/extractors/advanced.js +245 -0
- package/dist/extractors/advanced.js.map +7 -0
- package/dist/extractors/artifact-envelope.d.ts +63 -0
- package/dist/extractors/artifact-envelope.js +63 -0
- package/dist/extractors/artifact-envelope.js.map +7 -0
- package/dist/failure-analysis-agent.d.ts +16 -0
- package/dist/failure-analysis-agent.js +40 -0
- package/dist/failure-analysis-agent.js.map +7 -0
- package/dist/feature-flag-detector.d.ts +30 -0
- package/dist/feature-flag-detector.js +55 -0
- package/dist/feature-flag-detector.js.map +7 -0
- package/dist/form-detector.d.ts +34 -0
- package/dist/form-detector.js +58 -0
- package/dist/form-detector.js.map +7 -0
- package/dist/frontier.d.ts +32 -0
- package/dist/frontier.js +83 -0
- package/dist/frontier.js.map +7 -0
- package/dist/gap-pass.d.ts +35 -0
- package/dist/gap-pass.js +81 -0
- package/dist/gap-pass.js.map +7 -0
- package/dist/graphql-detector.d.ts +39 -0
- package/dist/graphql-detector.js +86 -0
- package/dist/graphql-detector.js.map +7 -0
- package/dist/http-engine.d.ts +80 -0
- package/dist/http-engine.js +304 -0
- package/dist/http-engine.js.map +7 -0
- package/dist/incremental-diff.d.ts +59 -0
- package/dist/incremental-diff.js +61 -0
- package/dist/incremental-diff.js.map +7 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +7 -0
- package/dist/input-fuzzer.d.ts +30 -0
- package/dist/input-fuzzer.js +105 -0
- package/dist/input-fuzzer.js.map +7 -0
- package/dist/interactive-probe.d.ts +104 -0
- package/dist/interactive-probe.js +442 -0
- package/dist/interactive-probe.js.map +7 -0
- package/dist/job-queue.d.ts +171 -0
- package/dist/job-queue.js +2256 -0
- package/dist/job-queue.js.map +7 -0
- package/dist/js-coverage.d.ts +29 -0
- package/dist/js-coverage.js +56 -0
- package/dist/js-coverage.js.map +7 -0
- package/dist/js-router-detector.d.ts +17 -0
- package/dist/js-router-detector.js +90 -0
- package/dist/js-router-detector.js.map +7 -0
- package/dist/language-detector.d.ts +22 -0
- package/dist/language-detector.js +57 -0
- package/dist/language-detector.js.map +7 -0
- package/dist/link-classifier.d.ts +23 -0
- package/dist/link-classifier.js +40 -0
- package/dist/link-classifier.js.map +7 -0
- package/dist/live-view.d.ts +36 -0
- package/dist/live-view.js +100 -0
- package/dist/live-view.js.map +7 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +10 -0
- package/dist/logger.js.map +7 -0
- package/dist/markdown-extract.d.ts +8 -0
- package/dist/markdown-extract.js +54 -0
- package/dist/markdown-extract.js.map +7 -0
- package/dist/native-crawler.d.ts +69 -0
- package/dist/native-crawler.js +839 -0
- package/dist/native-crawler.js.map +7 -0
- package/dist/network-observer.d.ts +49 -0
- package/dist/network-observer.js +149 -0
- package/dist/network-observer.js.map +7 -0
- package/dist/network-throttle.d.ts +25 -0
- package/dist/network-throttle.js +34 -0
- package/dist/network-throttle.js.map +7 -0
- package/dist/openapi-generator.d.ts +49 -0
- package/dist/openapi-generator.js +89 -0
- package/dist/openapi-generator.js.map +7 -0
- package/dist/pdf-extractor.d.ts +28 -0
- package/dist/pdf-extractor.js +81 -0
- package/dist/pdf-extractor.js.map +7 -0
- package/dist/performance-metrics.d.ts +53 -0
- package/dist/performance-metrics.js +83 -0
- package/dist/performance-metrics.js.map +7 -0
- package/dist/phash.d.ts +38 -0
- package/dist/phash.js +115 -0
- package/dist/phash.js.map +7 -0
- package/dist/pii-scrubber.d.ts +21 -0
- package/dist/pii-scrubber.js +75 -0
- package/dist/pii-scrubber.js.map +7 -0
- package/dist/playwright-recorder.d.ts +39 -0
- package/dist/playwright-recorder.js +123 -0
- package/dist/playwright-recorder.js.map +7 -0
- package/dist/policies/crawl-safety.d.ts +9 -0
- package/dist/policies/crawl-safety.js +37 -0
- package/dist/policies/crawl-safety.js.map +7 -0
- package/dist/profiles/domain-profile.d.ts +19 -0
- package/dist/profiles/domain-profile.js +140 -0
- package/dist/profiles/domain-profile.js.map +7 -0
- package/dist/proxy.d.ts +50 -0
- package/dist/proxy.js +165 -0
- package/dist/proxy.js.map +7 -0
- package/dist/rate-limiter.d.ts +31 -0
- package/dist/rate-limiter.js +64 -0
- package/dist/rate-limiter.js.map +7 -0
- package/dist/redirect-tracker.d.ts +37 -0
- package/dist/redirect-tracker.js +65 -0
- package/dist/redirect-tracker.js.map +7 -0
- package/dist/redis-frontier.d.ts +48 -0
- package/dist/redis-frontier.js +126 -0
- package/dist/redis-frontier.js.map +7 -0
- package/dist/redis-queue.d.ts +48 -0
- package/dist/redis-queue.js +165 -0
- package/dist/redis-queue.js.map +7 -0
- package/dist/response-cache.d.ts +38 -0
- package/dist/response-cache.js +140 -0
- package/dist/response-cache.js.map +7 -0
- package/dist/responsive-detector.d.ts +29 -0
- package/dist/responsive-detector.js +46 -0
- package/dist/responsive-detector.js.map +7 -0
- package/dist/retry-policy.d.ts +20 -0
- package/dist/retry-policy.js +54 -0
- package/dist/retry-policy.js.map +7 -0
- package/dist/robots-txt.d.ts +9 -0
- package/dist/robots-txt.js +77 -0
- package/dist/robots-txt.js.map +7 -0
- package/dist/screen-reader.d.ts +16 -0
- package/dist/screen-reader.js +46 -0
- package/dist/screen-reader.js.map +7 -0
- package/dist/screenshot-masker.d.ts +28 -0
- package/dist/screenshot-masker.js +62 -0
- package/dist/screenshot-masker.js.map +7 -0
- package/dist/seo-checker.d.ts +33 -0
- package/dist/seo-checker.js +91 -0
- package/dist/seo-checker.js.map +7 -0
- package/dist/session-capture.d.ts +35 -0
- package/dist/session-capture.js +267 -0
- package/dist/session-capture.js.map +7 -0
- package/dist/session-export.d.ts +49 -0
- package/dist/session-export.js +117 -0
- package/dist/session-export.js.map +7 -0
- package/dist/sitemap-writer.d.ts +21 -0
- package/dist/sitemap-writer.js +47 -0
- package/dist/sitemap-writer.js.map +7 -0
- package/dist/sitemap.d.ts +51 -0
- package/dist/sitemap.js +152 -0
- package/dist/sitemap.js.map +7 -0
- package/dist/skyvern.d.ts +56 -0
- package/dist/skyvern.js +129 -0
- package/dist/skyvern.js.map +7 -0
- package/dist/sqs-queue.d.ts +2 -0
- package/dist/sqs-queue.js +47 -0
- package/dist/sqs-queue.js.map +7 -0
- package/dist/sso-token.d.ts +29 -0
- package/dist/sso-token.js +76 -0
- package/dist/sso-token.js.map +7 -0
- package/dist/stealth/human-behavior.d.ts +20 -0
- package/dist/stealth/human-behavior.js +168 -0
- package/dist/stealth/human-behavior.js.map +7 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +77 -0
- package/dist/telemetry.js.map +7 -0
- package/dist/test-executor.d.ts +67 -0
- package/dist/test-executor.js +311 -0
- package/dist/test-executor.js.map +7 -0
- package/dist/tls-fingerprint.d.ts +50 -0
- package/dist/tls-fingerprint.js +81 -0
- package/dist/tls-fingerprint.js.map +7 -0
- package/dist/url-pattern-completer.d.ts +28 -0
- package/dist/url-pattern-completer.js +87 -0
- package/dist/url-pattern-completer.js.map +7 -0
- package/dist/visual-diff-verdict.d.ts +40 -0
- package/dist/visual-diff-verdict.js +25 -0
- package/dist/visual-diff-verdict.js.map +7 -0
- package/dist/webhook-notifier.d.ts +30 -0
- package/dist/webhook-notifier.js +76 -0
- package/dist/webhook-notifier.js.map +7 -0
- package/dist/websocket-detector.d.ts +24 -0
- package/dist/websocket-detector.js +60 -0
- package/dist/websocket-detector.js.map +7 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +33 -0
- package/dist/worker.js.map +7 -0
- package/package.json +45 -0
- package/src/accessibility-auditor.ts +102 -0
- package/src/ai-login.ts +218 -0
- package/src/auth-token-rotator.ts +89 -0
- package/src/auth.ts +396 -0
- package/src/browser-extension.ts +186 -0
- package/src/captcha-solver.ts +143 -0
- package/src/color-contrast.ts +116 -0
- package/src/content-chunker.ts +170 -0
- package/src/content-dedup.ts +99 -0
- package/src/cookie-consent.ts +106 -0
- package/src/cookie-jar.ts +91 -0
- package/src/crawler.ts +4305 -0
- package/src/custom-js-injector.ts +93 -0
- package/src/dark-mode-capture.ts +110 -0
- package/src/dead-link-detector.ts +76 -0
- package/src/element-hash.ts +16 -0
- package/src/email-otp.ts +252 -0
- package/src/engine-router.ts +141 -0
- package/src/export/bulk-export.ts +85 -0
- package/src/export/vector-export.ts +160 -0
- package/src/extractors/advanced.ts +283 -0
- package/src/extractors/artifact-envelope.ts +111 -0
- package/src/failure-analysis-agent.ts +49 -0
- package/src/feature-flag-detector.ts +97 -0
- package/src/form-detector.ts +103 -0
- package/src/frontier.test.ts +64 -0
- package/src/frontier.ts +112 -0
- package/src/gap-pass.ts +122 -0
- package/src/graphql-detector.ts +129 -0
- package/src/http-engine.ts +433 -0
- package/src/incremental-diff.ts +121 -0
- package/src/index.ts +35 -0
- package/src/input-fuzzer.ts +129 -0
- package/src/interactive-probe.ts +623 -0
- package/src/job-queue.test.ts +120 -0
- package/src/job-queue.ts +2701 -0
- package/src/js-coverage.ts +87 -0
- package/src/js-router-detector.ts +112 -0
- package/src/language-detector.ts +82 -0
- package/src/link-classifier.ts +65 -0
- package/src/live-view.ts +141 -0
- package/src/logger.ts +9 -0
- package/src/markdown-extract.test.ts +73 -0
- package/src/markdown-extract.ts +76 -0
- package/src/native-crawler.ts +1106 -0
- package/src/network-observer.ts +204 -0
- package/src/network-throttle.ts +69 -0
- package/src/openapi-generator.ts +135 -0
- package/src/pdf-extractor.ts +123 -0
- package/src/performance-metrics.ts +134 -0
- package/src/phash.ts +151 -0
- package/src/pii-scrubber.ts +99 -0
- package/src/playwright-recorder.ts +170 -0
- package/src/policies/crawl-safety.ts +46 -0
- package/src/profiles/domain-profile.ts +167 -0
- package/src/proxy.ts +238 -0
- package/src/rate-limiter.ts +87 -0
- package/src/redirect-tracker.ts +97 -0
- package/src/redis-frontier.ts +143 -0
- package/src/redis-queue.ts +208 -0
- package/src/response-cache.ts +150 -0
- package/src/responsive-detector.ts +92 -0
- package/src/retry-policy.ts +87 -0
- package/src/robots-txt.ts +96 -0
- package/src/screen-reader.ts +89 -0
- package/src/screenshot-masker.ts +98 -0
- package/src/seo-checker.ts +134 -0
- package/src/session-capture.ts +325 -0
- package/src/session-export.test.ts +75 -0
- package/src/session-export.ts +172 -0
- package/src/sitemap-writer.ts +65 -0
- package/src/sitemap.test.ts +131 -0
- package/src/sitemap.ts +224 -0
- package/src/skyvern.ts +199 -0
- package/src/sqs-queue.ts +49 -0
- package/src/sso-token.ts +120 -0
- package/src/stealth/human-behavior.ts +191 -0
- package/src/telemetry.ts +122 -0
- package/src/test-executor.test.ts +72 -0
- package/src/test-executor.ts +424 -0
- package/src/tls-fingerprint.ts +111 -0
- package/src/url-pattern-completer.ts +114 -0
- package/src/visual-diff-verdict.test.ts +80 -0
- package/src/visual-diff-verdict.ts +57 -0
- package/src/webhook-notifier.ts +109 -0
- package/src/websocket-detector.ts +83 -0
- package/src/worker.ts +48 -0
- package/tsconfig.json +13 -0
- package/types/accessibility-auditor.d.ts +29 -0
- package/types/ai-login.d.ts +32 -0
- package/types/auth-token-rotator.d.ts +47 -0
- package/types/auth.d.ts +20 -0
- package/types/browser-extension.d.ts +37 -0
- package/types/bull-worker.d.ts +6 -0
- package/types/captcha-solver.d.ts +16 -0
- package/types/color-contrast.d.ts +30 -0
- package/types/content-chunker.d.ts +18 -0
- package/types/content-dedup.d.ts +23 -0
- package/types/cookie-consent.d.ts +25 -0
- package/types/cookie-jar.d.ts +20 -0
- package/types/crawler.d.ts +179 -0
- package/types/custom-js-injector.d.ts +42 -0
- package/types/dark-mode-capture.d.ts +23 -0
- package/types/dead-link-detector.d.ts +37 -0
- package/types/element-hash.d.ts +9 -0
- package/types/email-otp.d.ts +42 -0
- package/types/engine-router.d.ts +47 -0
- package/types/export/bulk-export.d.ts +26 -0
- package/types/export/vector-export.d.ts +76 -0
- package/types/extractors/advanced.d.ts +54 -0
- package/types/extractors/artifact-envelope.d.ts +63 -0
- package/types/failure-analysis-agent.d.ts +16 -0
- package/types/feature-flag-detector.d.ts +30 -0
- package/types/form-detector.d.ts +34 -0
- package/types/frontier.d.ts +32 -0
- package/types/gap-pass.d.ts +35 -0
- package/types/graphql-detector.d.ts +39 -0
- package/types/http-engine.d.ts +80 -0
- package/types/incremental-diff.d.ts +59 -0
- package/types/index.d.ts +35 -0
- package/types/input-fuzzer.d.ts +30 -0
- package/types/interactive-probe.d.ts +104 -0
- package/types/job-queue.d.ts +171 -0
- package/types/js-coverage.d.ts +29 -0
- package/types/js-router-detector.d.ts +17 -0
- package/types/language-detector.d.ts +22 -0
- package/types/link-classifier.d.ts +23 -0
- package/types/live-view.d.ts +36 -0
- package/types/logger.d.ts +2 -0
- package/types/markdown-extract.d.ts +8 -0
- package/types/native-crawler.d.ts +69 -0
- package/types/network-observer.d.ts +49 -0
- package/types/network-throttle.d.ts +25 -0
- package/types/openapi-generator.d.ts +49 -0
- package/types/pdf-extractor.d.ts +28 -0
- package/types/performance-metrics.d.ts +53 -0
- package/types/phash.d.ts +38 -0
- package/types/pii-scrubber.d.ts +21 -0
- package/types/playwright-recorder.d.ts +39 -0
- package/types/policies/crawl-safety.d.ts +9 -0
- package/types/profiles/domain-profile.d.ts +19 -0
- package/types/proxy.d.ts +50 -0
- package/types/rate-limiter.d.ts +31 -0
- package/types/redirect-tracker.d.ts +37 -0
- package/types/redis-frontier.d.ts +48 -0
- package/types/redis-queue.d.ts +48 -0
- package/types/response-cache.d.ts +38 -0
- package/types/responsive-detector.d.ts +29 -0
- package/types/retry-policy.d.ts +20 -0
- package/types/robots-txt.d.ts +9 -0
- package/types/screen-reader.d.ts +16 -0
- package/types/screenshot-masker.d.ts +28 -0
- package/types/seo-checker.d.ts +33 -0
- package/types/session-capture.d.ts +38 -0
- package/types/session-export.d.ts +49 -0
- package/types/sitemap-writer.d.ts +21 -0
- package/types/sitemap.d.ts +51 -0
- package/types/skyvern.d.ts +56 -0
- package/types/sqs-queue.d.ts +2 -0
- package/types/sso-token.d.ts +29 -0
- package/types/stealth/human-behavior.d.ts +20 -0
- package/types/telemetry.d.ts +34 -0
- package/types/test-executor.d.ts +67 -0
- package/types/tls-fingerprint.d.ts +50 -0
- package/types/url-pattern-completer.d.ts +28 -0
- package/types/visual-diff-verdict.d.ts +40 -0
- package/types/webhook-notifier.d.ts +30 -0
- package/types/websocket-detector.d.ts +24 -0
- package/types/worker.d.ts +1 -0
|
@@ -0,0 +1,2256 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { withTenant, prismaAdmin } from "@qip/database";
|
|
3
|
+
import { crawlJobsTotal, crawlJobDurationSeconds } from "@qip/core";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
async function notifyAdmins(tenantId, data) {
|
|
6
|
+
try {
|
|
7
|
+
const members = await withTenant(
|
|
8
|
+
tenantId,
|
|
9
|
+
(tx) => tx.membership.findMany({
|
|
10
|
+
where: { tenantId, role: { in: ["OWNER", "ADMIN"] } },
|
|
11
|
+
select: { userId: true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
await withTenant(
|
|
15
|
+
tenantId,
|
|
16
|
+
(tx) => tx.notification.createMany({
|
|
17
|
+
data: members.map((m) => ({
|
|
18
|
+
id: `notif_${crypto.randomUUID()}`,
|
|
19
|
+
tenantId,
|
|
20
|
+
userId: m.userId,
|
|
21
|
+
type: data.type,
|
|
22
|
+
title: data.title,
|
|
23
|
+
body: data.body,
|
|
24
|
+
metadata: data.metadata
|
|
25
|
+
}))
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error("[notify] failed:", e);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
import { AppBrain, buildArtifactKey, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall } from "@qip/app-brain";
|
|
33
|
+
import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile } from "@qip/agents";
|
|
34
|
+
import { crawlProject } from "./crawler.js";
|
|
35
|
+
import { httpCrawl } from "./http-engine.js";
|
|
36
|
+
import { hashContent, normalizeText } from "./incremental-diff.js";
|
|
37
|
+
import { discoverProjectUrls } from "./sitemap.js";
|
|
38
|
+
import { executeTestPlan } from "./test-executor.js";
|
|
39
|
+
import { runNativeCrawl } from "./native-crawler.js";
|
|
40
|
+
import { jira, confluence, figma } from "@qip/connectors";
|
|
41
|
+
import { decrypt, config as platformConfig, dispatchWebhook, isFeatureEnabled, interpolatePlaceholders } from "@qip/core";
|
|
42
|
+
import { createHmac } from "node:crypto";
|
|
43
|
+
import fs from "node:fs/promises";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import os from "node:os";
|
|
46
|
+
function generateTOTP(secret) {
|
|
47
|
+
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
48
|
+
const clean = secret.toUpperCase().replace(/\s/g, "").replace(/=/g, "");
|
|
49
|
+
let bits = 0, value = 0;
|
|
50
|
+
const bytes = [];
|
|
51
|
+
for (const ch of clean) {
|
|
52
|
+
const idx = BASE32.indexOf(ch);
|
|
53
|
+
if (idx === -1) continue;
|
|
54
|
+
value = value << 5 | idx;
|
|
55
|
+
bits += 5;
|
|
56
|
+
if (bits >= 8) {
|
|
57
|
+
bits -= 8;
|
|
58
|
+
bytes.push(value >> bits & 255);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const counter = Math.floor(Date.now() / 3e4);
|
|
62
|
+
const buf = Buffer.alloc(8);
|
|
63
|
+
buf.writeBigInt64BE(BigInt(counter));
|
|
64
|
+
const hmac = createHmac("sha1", Buffer.from(bytes)).update(buf).digest();
|
|
65
|
+
const offset = hmac[hmac.length - 1] & 15;
|
|
66
|
+
const code = (hmac[offset] & 127) << 24 | (hmac[offset + 1] & 255) << 16 | (hmac[offset + 2] & 255) << 8 | hmac[offset + 3] & 255;
|
|
67
|
+
return String(code % 1e6).padStart(6, "0");
|
|
68
|
+
}
|
|
69
|
+
function deriveFallbackTargetPath(ref, framework) {
|
|
70
|
+
const slug = (ref ?? "test").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "test";
|
|
71
|
+
const base = framework.startsWith("cypress") ? "cypress/e2e/" : framework === "playwright-py" || framework === "pytest" || framework === "selenium-py" ? "tests/" : framework === "playwright-java" || framework === "selenium-java" || framework === "junit" ? "src/test/java/" : "tests/";
|
|
72
|
+
const ext = framework === "playwright-py" || framework === "pytest" || framework === "selenium-py" ? "_test.py" : framework === "playwright-java" || framework === "selenium-java" || framework === "junit" ? "Test.java" : framework.includes("ts") ? ".spec.ts" : ".spec.js";
|
|
73
|
+
return `${base}${slug}${ext}`;
|
|
74
|
+
}
|
|
75
|
+
function makeJobName(type) {
|
|
76
|
+
const now = /* @__PURE__ */ new Date();
|
|
77
|
+
const date = now.toISOString().slice(0, 10);
|
|
78
|
+
const ts = now.getTime();
|
|
79
|
+
const label = type === "CRAWL" ? "Crawl" : type === "NATIVE_CRAWL" ? "NativeCrawl" : type === "GENERATE_TESTS" ? "GenerateTests" : type === "VISUAL_REGRESSION" ? "VisualRegression" : type === "AI_EDIT" ? "AIEdit" : type === "API_TESTS" ? "ApiTests" : type === "SECURITY_TESTS" ? "SecurityTests" : type === "INTEGRATION_TESTS" ? "IntegrationTests" : type === "GENERATE_TESTS_FROM_REQS" ? "GenerateTestsFromReqs" : type === "GENERATE_DATA" ? "GenerateData" : type === "GENERATE_SCRIPT" ? "GenerateScript" : type === "GENERATE_TEST_PLAN" ? "GenerateTestPlan" : type === "IMPORT_TESTS" ? "ImportTests" : type === "GENERATE_SCREEN_TESTS" ? "GenerateScreenTests" : type === "BATCH_GENERATE_ALIGNED" ? "BatchGenerateAligned" : type === "CREATE_PR" ? "CreatePR" : type === "NEW_PAGES_PIPELINE" ? "NewPagesPipeline" : type.charAt(0) + type.slice(1).toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
80
|
+
return `${label}_${date}_${ts}`;
|
|
81
|
+
}
|
|
82
|
+
const runningControllers = /* @__PURE__ */ new Map();
|
|
83
|
+
const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
84
|
+
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES ?? 30);
|
|
85
|
+
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS ?? 15e3);
|
|
86
|
+
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY ?? 3);
|
|
87
|
+
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS ?? 15e3);
|
|
88
|
+
const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY ?? 3);
|
|
89
|
+
let jobDispatchTable = null;
|
|
90
|
+
async function claimSpecificJob(jobId) {
|
|
91
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
92
|
+
UPDATE "PlatformJob"
|
|
93
|
+
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
94
|
+
WHERE id = ${jobId} AND status = 'QUEUED'
|
|
95
|
+
RETURNING id, "tenantId" AS "tenantId", "projectId" AS "projectId", type, config
|
|
96
|
+
`;
|
|
97
|
+
return rows[0] ?? null;
|
|
98
|
+
}
|
|
99
|
+
async function claimNextQueuedJob() {
|
|
100
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
101
|
+
UPDATE "PlatformJob"
|
|
102
|
+
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
103
|
+
WHERE id = (
|
|
104
|
+
SELECT id FROM "PlatformJob"
|
|
105
|
+
WHERE status = 'QUEUED'
|
|
106
|
+
ORDER BY "queuedAt" ASC
|
|
107
|
+
FOR UPDATE SKIP LOCKED
|
|
108
|
+
LIMIT 1
|
|
109
|
+
)
|
|
110
|
+
RETURNING id, "tenantId" AS "tenantId", "projectId" AS "projectId", type, config
|
|
111
|
+
`;
|
|
112
|
+
return rows[0] ?? null;
|
|
113
|
+
}
|
|
114
|
+
async function requeueStaleRunningJobs() {
|
|
115
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
116
|
+
UPDATE "PlatformJob"
|
|
117
|
+
SET status = 'QUEUED',
|
|
118
|
+
"workerId" = NULL,
|
|
119
|
+
result = jsonb_set(
|
|
120
|
+
COALESCE(result::jsonb, '{}'::jsonb),
|
|
121
|
+
'{step}',
|
|
122
|
+
to_jsonb('Worker heartbeat lost — queued for recovery'::text),
|
|
123
|
+
true
|
|
124
|
+
)
|
|
125
|
+
WHERE status = 'RUNNING'
|
|
126
|
+
AND COALESCE("heartbeatAt", "startedAt") < NOW() - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
|
|
127
|
+
RETURNING id
|
|
128
|
+
`;
|
|
129
|
+
if (rows.length > 0) {
|
|
130
|
+
logger.warn({ jobIds: rows.map((r) => r.id), staleTimeoutMinutes: STALE_JOB_TIMEOUT_MINUTES }, "[job-worker] requeued stale RUNNING job(s)");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function dispatchClaimedJob(job) {
|
|
134
|
+
const dispatcher = jobDispatchTable?.[job.type];
|
|
135
|
+
if (!dispatcher) {
|
|
136
|
+
logger.error({ jobId: job.id, type: job.type }, "[job-worker] no dispatcher registered for job type");
|
|
137
|
+
await prismaAdmin.platformJob.update({
|
|
138
|
+
where: { id: job.id },
|
|
139
|
+
data: { status: "FAILED", error: `Unknown job type: ${job.type}`, finishedAt: /* @__PURE__ */ new Date() }
|
|
140
|
+
}).catch(() => {
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const stopTimer = crawlJobDurationSeconds.startTimer({ type: job.type });
|
|
145
|
+
try {
|
|
146
|
+
await dispatcher(job.id, job.config);
|
|
147
|
+
stopTimer();
|
|
148
|
+
const finished = await prismaAdmin.platformJob.findUnique({ where: { id: job.id }, select: { status: true } }).catch(() => null);
|
|
149
|
+
crawlJobsTotal.inc({ type: job.type, status: finished?.status === "FAILED" ? "failed" : "success" });
|
|
150
|
+
} catch (err) {
|
|
151
|
+
stopTimer();
|
|
152
|
+
crawlJobsTotal.inc({ type: job.type, status: "failed" });
|
|
153
|
+
logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, "[job-worker] job threw uncaught error");
|
|
154
|
+
await prismaAdmin.platformJob.update({
|
|
155
|
+
where: { id: job.id },
|
|
156
|
+
data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() }
|
|
157
|
+
}).catch(() => {
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
let workerLoopTimer = null;
|
|
162
|
+
function startJobWorkerLoop(opts) {
|
|
163
|
+
if (workerLoopTimer) return;
|
|
164
|
+
const pollIntervalMs = opts?.pollIntervalMs ?? WORKER_POLL_INTERVAL_MS;
|
|
165
|
+
const concurrency = opts?.concurrency ?? WORKER_CONCURRENCY;
|
|
166
|
+
logger.info({ workerId: WORKER_ID, pollIntervalMs, concurrency }, "[job-worker] starting");
|
|
167
|
+
const tick = async () => {
|
|
168
|
+
try {
|
|
169
|
+
await requeueStaleRunningJobs();
|
|
170
|
+
} catch (err) {
|
|
171
|
+
logger.error({ err: String(err) }, "[job-worker] stale-job sweep failed");
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
for (let i = 0; i < concurrency; i++) {
|
|
175
|
+
const job = await claimNextQueuedJob();
|
|
176
|
+
if (!job) break;
|
|
177
|
+
void dispatchClaimedJob(job);
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logger.error({ err: String(err) }, "[job-worker] claim loop failed");
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
workerLoopTimer = setInterval(tick, pollIntervalMs);
|
|
184
|
+
void tick();
|
|
185
|
+
}
|
|
186
|
+
function stopJobWorkerLoop() {
|
|
187
|
+
if (workerLoopTimer) clearInterval(workerLoopTimer);
|
|
188
|
+
workerLoopTimer = null;
|
|
189
|
+
}
|
|
190
|
+
async function createTrackedJob(type, config) {
|
|
191
|
+
const name = makeJobName(type);
|
|
192
|
+
const job = await withTenant(
|
|
193
|
+
config.tenantId,
|
|
194
|
+
(tx) => tx.platformJob.create({
|
|
195
|
+
data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), config }
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
return job.id;
|
|
199
|
+
}
|
|
200
|
+
async function finishTrackedJob(tenantId, jobId, error) {
|
|
201
|
+
await withTenant(
|
|
202
|
+
tenantId,
|
|
203
|
+
(tx) => tx.platformJob.update({
|
|
204
|
+
where: { id: jobId },
|
|
205
|
+
data: { status: error ? "FAILED" : "DONE", finishedAt: /* @__PURE__ */ new Date(), ...error ? { error } : { progress: 100 } }
|
|
206
|
+
})
|
|
207
|
+
).catch(() => {
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
async function enqueueJob(type, config) {
|
|
211
|
+
const { tenantId, projectId } = config;
|
|
212
|
+
const name = makeJobName(type);
|
|
213
|
+
const job = await withTenant(
|
|
214
|
+
tenantId,
|
|
215
|
+
(tx) => tx.platformJob.create({
|
|
216
|
+
data: {
|
|
217
|
+
tenantId,
|
|
218
|
+
projectId,
|
|
219
|
+
type,
|
|
220
|
+
name,
|
|
221
|
+
status: "QUEUED",
|
|
222
|
+
config
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
if (type === "CRAWL" && process.env.JOB_QUEUE_BACKEND === "sqs") {
|
|
227
|
+
const { sendJobToSqs } = await import("./sqs-queue.js");
|
|
228
|
+
await sendJobToSqs(job.id, tenantId, config);
|
|
229
|
+
} else {
|
|
230
|
+
setImmediate(() => claimSpecificJob(job.id).then((claimed) => claimed && dispatchClaimedJob(claimed)));
|
|
231
|
+
}
|
|
232
|
+
return job.id;
|
|
233
|
+
}
|
|
234
|
+
async function getJob(tenantId, jobId) {
|
|
235
|
+
return withTenant(
|
|
236
|
+
tenantId,
|
|
237
|
+
(tx) => tx.platformJob.findUnique({ where: { id: jobId } })
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
async function listJobs(tenantId, projectId) {
|
|
241
|
+
return withTenant(
|
|
242
|
+
tenantId,
|
|
243
|
+
(tx) => tx.platformJob.findMany({
|
|
244
|
+
where: { tenantId, projectId },
|
|
245
|
+
orderBy: { queuedAt: "desc" },
|
|
246
|
+
take: 50
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
async function cancelJob(tenantId, jobId) {
|
|
251
|
+
const controller = runningControllers.get(jobId);
|
|
252
|
+
if (controller) {
|
|
253
|
+
controller.abort();
|
|
254
|
+
runningControllers.delete(jobId);
|
|
255
|
+
}
|
|
256
|
+
await withTenant(
|
|
257
|
+
tenantId,
|
|
258
|
+
(tx) => tx.platformJob.update({
|
|
259
|
+
where: { id: jobId },
|
|
260
|
+
data: { status: "CANCELLED", finishedAt: /* @__PURE__ */ new Date() }
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
async function rerunJob(tenantId, jobId) {
|
|
265
|
+
const job = await withTenant(
|
|
266
|
+
tenantId,
|
|
267
|
+
(tx) => tx.platformJob.findUnique({ where: { id: jobId } })
|
|
268
|
+
);
|
|
269
|
+
if (!job) throw new Error("Job not found");
|
|
270
|
+
return enqueueJob(job.type, { ...job.config, tenantId });
|
|
271
|
+
}
|
|
272
|
+
const jobCheckpoints = /* @__PURE__ */ new Map();
|
|
273
|
+
async function resumeJob(tenantId, jobId) {
|
|
274
|
+
const job = await withTenant(
|
|
275
|
+
tenantId,
|
|
276
|
+
(tx) => tx.platformJob.findUnique({ where: { id: jobId } })
|
|
277
|
+
);
|
|
278
|
+
if (!job) throw new Error("Job not found");
|
|
279
|
+
const checkpoint = job.result?.checkpoint;
|
|
280
|
+
const visitedUrls = checkpoint?.visitedUrls ?? [];
|
|
281
|
+
const pendingQueue = checkpoint?.pendingQueue ?? [];
|
|
282
|
+
if (pendingQueue.length === 0 && visitedUrls.length === 0) {
|
|
283
|
+
return enqueueJob(job.type, { ...job.config, tenantId });
|
|
284
|
+
}
|
|
285
|
+
return enqueueJob(job.type, {
|
|
286
|
+
...job.config,
|
|
287
|
+
tenantId,
|
|
288
|
+
skipUrls: visitedUrls.length > 0 ? visitedUrls : void 0,
|
|
289
|
+
resumeUrls: pendingQueue.length > 0 ? pendingQueue : void 0
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
async function updateJobProgress(tenantId, jobId, progress, step) {
|
|
293
|
+
try {
|
|
294
|
+
await withTenant(tenantId, async (tx) => {
|
|
295
|
+
const current = await tx.platformJob.findUnique({
|
|
296
|
+
where: { id: jobId },
|
|
297
|
+
select: { status: true }
|
|
298
|
+
});
|
|
299
|
+
if (current?.status === "CANCELLED") {
|
|
300
|
+
runningControllers.get(jobId)?.abort();
|
|
301
|
+
runningControllers.delete(jobId);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await tx.platformJob.update({
|
|
305
|
+
where: { id: jobId },
|
|
306
|
+
data: { progress, result: { step }, heartbeatAt: /* @__PURE__ */ new Date() }
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function heartbeatJob(tenantId, jobId) {
|
|
313
|
+
try {
|
|
314
|
+
await withTenant(tenantId, async (tx) => {
|
|
315
|
+
const current = await tx.platformJob.findUnique({
|
|
316
|
+
where: { id: jobId },
|
|
317
|
+
select: { status: true }
|
|
318
|
+
});
|
|
319
|
+
if (current?.status === "CANCELLED") {
|
|
320
|
+
runningControllers.get(jobId)?.abort();
|
|
321
|
+
runningControllers.delete(jobId);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (current?.status !== "RUNNING") return;
|
|
325
|
+
await tx.platformJob.update({
|
|
326
|
+
where: { id: jobId },
|
|
327
|
+
data: { heartbeatAt: /* @__PURE__ */ new Date(), workerId: WORKER_ID }
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
} catch {
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function investigateScreenChanges(tenantId, projectId, changedScreens) {
|
|
334
|
+
const [jiraCfg, confluenceCfg] = await Promise.all([
|
|
335
|
+
AppBrain.getConnectorConfig(tenantId, projectId, "JIRA"),
|
|
336
|
+
AppBrain.getConnectorConfig(tenantId, projectId, "CONFLUENCE")
|
|
337
|
+
]);
|
|
338
|
+
for (const screen of changedScreens) {
|
|
339
|
+
const searchTerm = screen.url ?? screen.name;
|
|
340
|
+
const changeSummary = `Screen "${screen.name}" changed: +${screen.addedElements.length} elements, -${screen.removedElements.length} elements`;
|
|
341
|
+
const detail = {
|
|
342
|
+
screenId: screen.id,
|
|
343
|
+
screenName: screen.name,
|
|
344
|
+
url: screen.url,
|
|
345
|
+
addedElements: screen.addedElements,
|
|
346
|
+
removedElements: screen.removedElements,
|
|
347
|
+
jiraTickets: [],
|
|
348
|
+
confluencePages: []
|
|
349
|
+
};
|
|
350
|
+
if (jiraCfg?.baseUrl && jiraCfg?.email && jiraCfg?.apiToken) {
|
|
351
|
+
try {
|
|
352
|
+
detail.jiraTickets = await jira.searchIssues(
|
|
353
|
+
{ baseUrl: jiraCfg.baseUrl, email: jiraCfg.email, apiToken: jiraCfg.apiToken, projectKey: jiraCfg.projectKey },
|
|
354
|
+
searchTerm
|
|
355
|
+
);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
logger.warn({ searchTerm, err: e }, "Jira search failed");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (confluenceCfg?.baseUrl && confluenceCfg?.email && confluenceCfg?.apiToken) {
|
|
361
|
+
try {
|
|
362
|
+
detail.confluencePages = await confluence.searchPages(
|
|
363
|
+
{ baseUrl: confluenceCfg.baseUrl, email: confluenceCfg.email, apiToken: confluenceCfg.apiToken, spaceKey: confluenceCfg.spaceKey },
|
|
364
|
+
searchTerm
|
|
365
|
+
);
|
|
366
|
+
} catch (e) {
|
|
367
|
+
logger.warn({ searchTerm, err: e }, "Confluence search failed");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
await AppBrain.logChange(tenantId, projectId, "MODIFIED", changeSummary, detail);
|
|
371
|
+
logger.info({ screen: screen.name, jiraTickets: detail.jiraTickets.length, confluencePages: detail.confluencePages.length }, "Logged screen change investigation");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function processJob(jobId, config, opts) {
|
|
375
|
+
const { tenantId, projectId } = config;
|
|
376
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
377
|
+
await withTenant(
|
|
378
|
+
tenantId,
|
|
379
|
+
(tx) => tx.platformJob.update({
|
|
380
|
+
where: { id: jobId },
|
|
381
|
+
data: { status: "RUNNING", startedAt: jobStartedAt, heartbeatAt: jobStartedAt, workerId: WORKER_ID }
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
const controller = new AbortController();
|
|
385
|
+
runningControllers.set(jobId, controller);
|
|
386
|
+
const heartbeatTimer = setInterval(() => {
|
|
387
|
+
void heartbeatJob(tenantId, jobId);
|
|
388
|
+
}, JOB_HEARTBEAT_INTERVAL_MS);
|
|
389
|
+
const onProgress = async (pct, step) => {
|
|
390
|
+
await updateJobProgress(tenantId, jobId, pct, step);
|
|
391
|
+
await opts?.onProgress?.(pct);
|
|
392
|
+
};
|
|
393
|
+
const DEFAULT_MAX_SCREENS = 150;
|
|
394
|
+
try {
|
|
395
|
+
if (!config.maxScreens || !config.deviceProfiles || config.interactive === void 0 || config.useSitemap === void 0 || !config.traversalStrategy) {
|
|
396
|
+
const proj = await withTenant(
|
|
397
|
+
tenantId,
|
|
398
|
+
(tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
399
|
+
);
|
|
400
|
+
const perProject = proj?.crawlConfig?.maxScreens;
|
|
401
|
+
if (!config.maxScreens) config.maxScreens = perProject || DEFAULT_MAX_SCREENS;
|
|
402
|
+
if (!config.deviceProfiles) config.deviceProfiles = proj?.crawlConfig?.deviceProfiles ?? ["DESKTOP"];
|
|
403
|
+
if (config.interactive === void 0) config.interactive = proj?.crawlConfig?.interactive ?? false;
|
|
404
|
+
if (config.useSitemap === void 0) config.useSitemap = proj?.crawlConfig?.useSitemap ?? false;
|
|
405
|
+
if (!config.sitemapUrl) config.sitemapUrl = proj?.crawlConfig?.sitemapUrl ?? void 0;
|
|
406
|
+
if (!config.traversalStrategy) config.traversalStrategy = proj?.crawlConfig?.traversalStrategy ?? "BFS";
|
|
407
|
+
if (!config.bestFirstKeywords) config.bestFirstKeywords = proj?.crawlConfig?.bestFirstKeywords ?? void 0;
|
|
408
|
+
if (!config.segmentSize) config.segmentSize = proj?.crawlConfig?.segmentSize ?? 9999999;
|
|
409
|
+
if (!config.selectiveUrls?.length && (!config.startUrls || config.startUrls.length === 0)) {
|
|
410
|
+
const saved = proj?.crawlConfig?.seedUrls ?? [];
|
|
411
|
+
if (saved.length > 0) config.startUrls = saved;
|
|
412
|
+
}
|
|
413
|
+
if (config.interactive === void 0) config.interactive = proj?.crawlConfig?.interactive ?? false;
|
|
414
|
+
}
|
|
415
|
+
const runtimeCreds = config.credentials ? {
|
|
416
|
+
...config.credentials,
|
|
417
|
+
...config.credentials.totpSecret ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) } : {}
|
|
418
|
+
} : void 0;
|
|
419
|
+
const result = await crawlProject({
|
|
420
|
+
...config,
|
|
421
|
+
credentials: runtimeCreds,
|
|
422
|
+
abortSignal: controller.signal,
|
|
423
|
+
jobId,
|
|
424
|
+
onCheckpoint: async (visitedUrls, pendingQueue) => {
|
|
425
|
+
jobCheckpoints.set(jobId, { visitedUrls, pendingQueue });
|
|
426
|
+
}
|
|
427
|
+
}, onProgress);
|
|
428
|
+
jobCheckpoints.delete(jobId);
|
|
429
|
+
const pendingUrls = result?.pendingUrls ?? [];
|
|
430
|
+
if (pendingUrls.length > 0) {
|
|
431
|
+
logger.info({ pendingUrls: pendingUrls.length }, "Crawl finished with pending URLs \u2014 not auto-queuing (segmented crawls disabled)");
|
|
432
|
+
}
|
|
433
|
+
await withTenant(
|
|
434
|
+
tenantId,
|
|
435
|
+
(tx) => tx.platformJob.update({
|
|
436
|
+
where: { id: jobId },
|
|
437
|
+
data: {
|
|
438
|
+
status: "DONE",
|
|
439
|
+
result: { ...result },
|
|
440
|
+
progress: 100,
|
|
441
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
);
|
|
445
|
+
clearInterval(heartbeatTimer);
|
|
446
|
+
runningControllers.delete(jobId);
|
|
447
|
+
{
|
|
448
|
+
const crawledUrls = (result.discovered ?? []).filter((d) => !!d.screenId && !!d.url).map((d) => d.url);
|
|
449
|
+
if (crawledUrls.length > 0) {
|
|
450
|
+
(async () => {
|
|
451
|
+
const cfg = await AppBrain.getCrawlConfig(tenantId, projectId);
|
|
452
|
+
const existing = cfg?.seedUrls ?? [];
|
|
453
|
+
const merged = Array.from(/* @__PURE__ */ new Set([...existing, ...crawledUrls]));
|
|
454
|
+
if (merged.length > existing.length) {
|
|
455
|
+
await AppBrain.saveCrawlConfig(tenantId, projectId, { seedUrls: merged });
|
|
456
|
+
logger.info({ added: merged.length - existing.length, total: merged.length }, "[crawl] auto-seeded crawled URLs into crawlConfig.seedUrls");
|
|
457
|
+
}
|
|
458
|
+
})().catch((err) => logger.warn({ err: err?.message }, "[crawl] auto-seed seedUrls failed (non-fatal)"));
|
|
459
|
+
}
|
|
460
|
+
notifyAdmins(tenantId, {
|
|
461
|
+
type: "crawl.done",
|
|
462
|
+
title: "Crawl completed",
|
|
463
|
+
body: `Crawl for project finished successfully.`,
|
|
464
|
+
metadata: { projectId, jobId }
|
|
465
|
+
}).catch(console.error);
|
|
466
|
+
dispatchWebhook(tenantId, projectId, "crawl.done", {
|
|
467
|
+
jobId,
|
|
468
|
+
screensFound: result?.screensFound ?? result?.screens?.length ?? 0
|
|
469
|
+
}).catch(console.error);
|
|
470
|
+
}
|
|
471
|
+
setTimeout(() => {
|
|
472
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
473
|
+
}, 8e3);
|
|
474
|
+
setImmediate(async () => {
|
|
475
|
+
try {
|
|
476
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
477
|
+
if (projectMap?.screens?.length) {
|
|
478
|
+
const screenCount = projectMap.screens.length;
|
|
479
|
+
if (screenCount > 300) {
|
|
480
|
+
await withTenant(
|
|
481
|
+
tenantId,
|
|
482
|
+
(tx) => tx.project.update({ where: { id: projectId }, data: { oversized: true } })
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
await generateTaxonomySkillFiles(tenantId, projectId, projectMap.screens, result.apiEndpoints, result.performanceMetrics);
|
|
486
|
+
const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
487
|
+
if (tenantStorageCfg) {
|
|
488
|
+
let uploaded = 0;
|
|
489
|
+
for (const screen of projectMap.screens) {
|
|
490
|
+
for (const shot of screen.screenshots ?? []) {
|
|
491
|
+
if (!shot.storagePath || shot.cloudUrl) continue;
|
|
492
|
+
try {
|
|
493
|
+
const filename = `${screen.id}-v${shot.version}.png`;
|
|
494
|
+
const key = buildArtifactKey(tenantId, projectId, "screenshots", filename, tenantStorageCfg.prefix);
|
|
495
|
+
const cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, shot.storagePath, key);
|
|
496
|
+
await AppBrain.updateScreenshotCloudUrl(tenantId, shot.id, cloudUrl);
|
|
497
|
+
uploaded++;
|
|
498
|
+
} catch (uploadErr) {
|
|
499
|
+
logger.warn({ screenId: screen.id, err: uploadErr }, "Failed to upload screenshot to cloud storage");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (uploaded > 0) logger.info({ uploaded, provider: tenantStorageCfg.provider }, "Uploaded screenshots to cloud storage");
|
|
504
|
+
const { existsSync } = await import("node:fs");
|
|
505
|
+
let domUploaded = 0;
|
|
506
|
+
let mdUploaded = 0;
|
|
507
|
+
for (const screen of projectMap.screens) {
|
|
508
|
+
const domPath = screen.domSnapshotPath;
|
|
509
|
+
if (domPath && !domPath.startsWith("https://") && existsSync(domPath)) {
|
|
510
|
+
try {
|
|
511
|
+
const key = buildArtifactKey(tenantId, projectId, "dom-snapshots", `${screen.id}.json`, tenantStorageCfg.prefix);
|
|
512
|
+
const cloudUrl = await uploadFileToTenantStorage(tenantStorageCfg, domPath, key, "application/json");
|
|
513
|
+
await AppBrain.updateScreenDomSnapshot(tenantId, screen.id, cloudUrl);
|
|
514
|
+
domUploaded++;
|
|
515
|
+
} catch (err) {
|
|
516
|
+
logger.warn({ screenId: screen.id, err }, "Failed to upload DOM snapshot to cloud storage");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const mdPath = screen.markdownPath;
|
|
520
|
+
if (mdPath && !mdPath.startsWith("https://") && existsSync(mdPath)) {
|
|
521
|
+
try {
|
|
522
|
+
const key = buildArtifactKey(tenantId, projectId, "markdown", `${screen.id}.md`, tenantStorageCfg.prefix);
|
|
523
|
+
const cloudUrl = await uploadFileToTenantStorage(tenantStorageCfg, mdPath, key, "text/markdown");
|
|
524
|
+
await AppBrain.updateScreenMarkdownPath(tenantId, screen.id, cloudUrl);
|
|
525
|
+
mdUploaded++;
|
|
526
|
+
} catch (err) {
|
|
527
|
+
logger.warn({ screenId: screen.id, err }, "Failed to upload markdown to cloud storage");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (domUploaded > 0) logger.info({ domUploaded, provider: tenantStorageCfg.provider }, "Uploaded DOM snapshots to cloud storage");
|
|
532
|
+
if (mdUploaded > 0) logger.info({ mdUploaded, provider: tenantStorageCfg.provider }, "Uploaded markdown to cloud storage");
|
|
533
|
+
}
|
|
534
|
+
await groupScreensWithAI(tenantId, projectId, projectMap.screens, jobId, jobStartedAt);
|
|
535
|
+
try {
|
|
536
|
+
await withTenant(tenantId, async (tx) => {
|
|
537
|
+
const staleTests = await tx.$queryRaw`
|
|
538
|
+
SELECT tc.id
|
|
539
|
+
FROM "TestCase" tc
|
|
540
|
+
JOIN "Screen" s ON tc."screenId" = s.id
|
|
541
|
+
WHERE tc."tenantId" = ${tenantId}
|
|
542
|
+
AND s."projectId" = ${projectId}
|
|
543
|
+
AND s."elementHashChangedAt" IS NOT NULL
|
|
544
|
+
AND tc."genAt" IS NOT NULL
|
|
545
|
+
AND s."elementHashChangedAt" > tc."genAt"
|
|
546
|
+
AND tc.status != 'PENDING_REVIEW'
|
|
547
|
+
AND tc.status != 'RETIRED'
|
|
548
|
+
`;
|
|
549
|
+
if (staleTests.length > 0) {
|
|
550
|
+
const ids = staleTests.map((t) => t.id);
|
|
551
|
+
await tx.testCase.updateMany({
|
|
552
|
+
where: { id: { in: ids } },
|
|
553
|
+
data: { status: "PENDING_REVIEW" }
|
|
554
|
+
});
|
|
555
|
+
logger.info({ count: staleTests.length, projectId }, "Auto-flagged stale test cases as PENDING_REVIEW");
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
} catch (staleErr) {
|
|
559
|
+
logger.warn({ err: staleErr }, "Failed to auto-flag stale tests");
|
|
560
|
+
}
|
|
561
|
+
const changed = projectMap.screens.filter((s) => s.hasChange);
|
|
562
|
+
if (changed.length > 0) {
|
|
563
|
+
await investigateScreenChanges(tenantId, projectId, changed);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch (skillErr) {
|
|
567
|
+
logger.error({ projectId, err: skillErr }, "Taxonomy generation failed");
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
} catch (err) {
|
|
571
|
+
clearInterval(heartbeatTimer);
|
|
572
|
+
runningControllers.delete(jobId);
|
|
573
|
+
const aborted = String(err).includes("CRAWL_ABORTED");
|
|
574
|
+
const checkpoint = jobCheckpoints.get(jobId);
|
|
575
|
+
jobCheckpoints.delete(jobId);
|
|
576
|
+
await withTenant(
|
|
577
|
+
tenantId,
|
|
578
|
+
(tx) => tx.platformJob.update({
|
|
579
|
+
where: { id: jobId },
|
|
580
|
+
data: {
|
|
581
|
+
status: aborted ? "CANCELLED" : "FAILED",
|
|
582
|
+
error: aborted ? "Stopped by user" : String(err),
|
|
583
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
584
|
+
...checkpoint ? { result: { checkpoint } } : {}
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
);
|
|
588
|
+
if (!aborted) {
|
|
589
|
+
notifyAdmins(tenantId, {
|
|
590
|
+
type: "crawl.failed",
|
|
591
|
+
title: "Crawl failed",
|
|
592
|
+
body: `Crawl for project failed: ${String(err)}`,
|
|
593
|
+
metadata: { projectId, jobId, error: String(err) }
|
|
594
|
+
}).catch(console.error);
|
|
595
|
+
dispatchWebhook(tenantId, projectId, "crawl.failed", {
|
|
596
|
+
jobId,
|
|
597
|
+
error: String(err)
|
|
598
|
+
}).catch(console.error);
|
|
599
|
+
}
|
|
600
|
+
} finally {
|
|
601
|
+
runningControllers.delete(jobId);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function aggregateJobTokens(tenantId, jobId, projectId, startedAt) {
|
|
605
|
+
try {
|
|
606
|
+
await withTenant(tenantId, async (tx) => {
|
|
607
|
+
const rows = await tx.$queryRaw`
|
|
608
|
+
SELECT
|
|
609
|
+
COALESCE(SUM("promptTokens"), 0)::INT AS "totalPromptTokens",
|
|
610
|
+
COALESCE(SUM("completionTokens"), 0)::INT AS "totalCompletionTokens",
|
|
611
|
+
COALESCE(SUM("cacheReadTokens"), 0)::INT AS "totalCacheReadTokens",
|
|
612
|
+
COALESCE(SUM("estimatedCostUsd"), 0.0) AS "totalEstimatedCostUsd",
|
|
613
|
+
COUNT(*)::INT AS "llmCallCount"
|
|
614
|
+
FROM "LLMCallLog"
|
|
615
|
+
WHERE "tenantId" = ${tenantId}
|
|
616
|
+
AND "jobId" = ${jobId}
|
|
617
|
+
`;
|
|
618
|
+
const row = rows[0];
|
|
619
|
+
if (!row || !row.llmCallCount) return;
|
|
620
|
+
await tx.platformJob.update({
|
|
621
|
+
where: { id: jobId },
|
|
622
|
+
data: {
|
|
623
|
+
totalPromptTokens: row.totalPromptTokens,
|
|
624
|
+
totalCompletionTokens: row.totalCompletionTokens,
|
|
625
|
+
totalCacheReadTokens: row.totalCacheReadTokens,
|
|
626
|
+
totalEstimatedCostUsd: Number(row.totalEstimatedCostUsd),
|
|
627
|
+
llmCallCount: row.llmCallCount
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
} catch (err) {
|
|
632
|
+
console.warn("[job-tokens] aggregation failed:", err);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function processApiTestsJob(jobId, config) {
|
|
636
|
+
const { tenantId, projectId } = config;
|
|
637
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
638
|
+
await withTenant(
|
|
639
|
+
tenantId,
|
|
640
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
641
|
+
);
|
|
642
|
+
try {
|
|
643
|
+
const { generateApiTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@qip/agents");
|
|
644
|
+
await updateJobProgress(tenantId, jobId, 20, "Loading API contracts and endpoints\u2026");
|
|
645
|
+
const result = await generateApiTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
|
|
646
|
+
await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} API test cases`);
|
|
647
|
+
refreshCoverageMatrix(tenantId, projectId).catch((e) => console.warn("[coverage-matrix]", e));
|
|
648
|
+
refreshTestCaseInventory(tenantId, projectId).catch((e) => console.warn("[tc-inventory]", e));
|
|
649
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
650
|
+
await withTenant(
|
|
651
|
+
tenantId,
|
|
652
|
+
(tx) => tx.platformJob.update({
|
|
653
|
+
where: { id: jobId },
|
|
654
|
+
data: {
|
|
655
|
+
status: "DONE",
|
|
656
|
+
progress: 100,
|
|
657
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
658
|
+
result: { cases: result.cases?.length ?? 0, suiteName: result.suiteName }
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
);
|
|
662
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
663
|
+
} catch (err) {
|
|
664
|
+
await withTenant(
|
|
665
|
+
tenantId,
|
|
666
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async function processSecurityTestsJob(jobId, config) {
|
|
671
|
+
const { tenantId, projectId } = config;
|
|
672
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
673
|
+
await withTenant(
|
|
674
|
+
tenantId,
|
|
675
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
676
|
+
);
|
|
677
|
+
try {
|
|
678
|
+
const { generateSecurityTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@qip/agents");
|
|
679
|
+
await updateJobProgress(tenantId, jobId, 20, "Mapping OWASP attack surface\u2026");
|
|
680
|
+
const result = await generateSecurityTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
|
|
681
|
+
await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} security test cases`);
|
|
682
|
+
refreshCoverageMatrix(tenantId, projectId).catch((e) => console.warn("[coverage-matrix]", e));
|
|
683
|
+
refreshTestCaseInventory(tenantId, projectId).catch((e) => console.warn("[tc-inventory]", e));
|
|
684
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
685
|
+
await withTenant(
|
|
686
|
+
tenantId,
|
|
687
|
+
(tx) => tx.platformJob.update({
|
|
688
|
+
where: { id: jobId },
|
|
689
|
+
data: {
|
|
690
|
+
status: "DONE",
|
|
691
|
+
progress: 100,
|
|
692
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
693
|
+
result: { cases: result.cases?.length ?? 0, suiteName: result.suiteName }
|
|
694
|
+
}
|
|
695
|
+
})
|
|
696
|
+
);
|
|
697
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
await withTenant(
|
|
700
|
+
tenantId,
|
|
701
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async function processIntegrationTestsJob(jobId, config) {
|
|
706
|
+
const { tenantId, projectId } = config;
|
|
707
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
708
|
+
await withTenant(
|
|
709
|
+
tenantId,
|
|
710
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
711
|
+
);
|
|
712
|
+
try {
|
|
713
|
+
const { generateIntegrationTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@qip/agents");
|
|
714
|
+
await updateJobProgress(tenantId, jobId, 20, "Analysing API contracts and data models\u2026");
|
|
715
|
+
const result = await generateIntegrationTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
|
|
716
|
+
await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} integration test cases`);
|
|
717
|
+
refreshCoverageMatrix(tenantId, projectId).catch((e) => console.warn("[coverage-matrix]", e));
|
|
718
|
+
refreshTestCaseInventory(tenantId, projectId).catch((e) => console.warn("[tc-inventory]", e));
|
|
719
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
720
|
+
await withTenant(
|
|
721
|
+
tenantId,
|
|
722
|
+
(tx) => tx.platformJob.update({
|
|
723
|
+
where: { id: jobId },
|
|
724
|
+
data: {
|
|
725
|
+
status: "DONE",
|
|
726
|
+
progress: 100,
|
|
727
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
728
|
+
result: { cases: result.cases?.length ?? 0, suiteName: result.suiteName }
|
|
729
|
+
}
|
|
730
|
+
})
|
|
731
|
+
);
|
|
732
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
await withTenant(
|
|
735
|
+
tenantId,
|
|
736
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
async function processFigmaImportJob(jobId, config) {
|
|
741
|
+
const { tenantId, projectId, fileKey } = config;
|
|
742
|
+
await withTenant(
|
|
743
|
+
tenantId,
|
|
744
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
745
|
+
);
|
|
746
|
+
try {
|
|
747
|
+
const connector = await withTenant(
|
|
748
|
+
tenantId,
|
|
749
|
+
(tx) => tx.connector.findFirst({ where: { projectId, kind: "FIGMA", tenantId } })
|
|
750
|
+
);
|
|
751
|
+
if (!connector) throw new Error("Figma connector not configured. Add your Figma token in Project Settings \u2192 Integrations.");
|
|
752
|
+
const cfg = connector.config;
|
|
753
|
+
const accessToken = cfg.accessToken_enc ? decrypt(cfg.accessToken_enc) : "";
|
|
754
|
+
if (!accessToken) throw new Error("Figma access token missing. Edit the Figma connector and add your Personal Access Token.");
|
|
755
|
+
const resolvedFileKey = fileKey ?? cfg.fileKey ?? "";
|
|
756
|
+
if (!resolvedFileKey) throw new Error("No Figma file key. Paste the full Figma file URL as the App URL.");
|
|
757
|
+
const creds = { accessToken, fileKey: resolvedFileKey };
|
|
758
|
+
await updateJobProgress(tenantId, jobId, 5, "Listing frames from Figma file\u2026");
|
|
759
|
+
const frames = await figma.listScreens(creds, resolvedFileKey);
|
|
760
|
+
if (frames.length === 0) {
|
|
761
|
+
await withTenant(
|
|
762
|
+
tenantId,
|
|
763
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", progress: 100, result: { screensImported: 0 }, finishedAt: /* @__PURE__ */ new Date() } })
|
|
764
|
+
);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
await updateJobProgress(tenantId, jobId, 15, `Fetching images for ${frames.length} frames\u2026`);
|
|
768
|
+
const nodeIds = frames.map((f) => f.id);
|
|
769
|
+
const imageUrls = await figma.getScreenImages(creds, resolvedFileKey, nodeIds, 1);
|
|
770
|
+
const screenshotDir = path.join(platformConfig.screenshotDir, projectId);
|
|
771
|
+
await fs.mkdir(screenshotDir, { recursive: true });
|
|
772
|
+
const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
773
|
+
let saved = 0;
|
|
774
|
+
for (const frame of frames) {
|
|
775
|
+
try {
|
|
776
|
+
const pct = Math.round(15 + saved / frames.length * 80);
|
|
777
|
+
await updateJobProgress(tenantId, jobId, pct, `Saving frame ${saved + 1}/${frames.length}: ${frame.name}`);
|
|
778
|
+
const screen = await AppBrain.saveScreen(tenantId, projectId, frame.name, `figma://file/${resolvedFileKey}/node/${frame.id}`);
|
|
779
|
+
const imgUrl = imageUrls[frame.id];
|
|
780
|
+
if (imgUrl) {
|
|
781
|
+
const imgRes = await fetch(imgUrl);
|
|
782
|
+
if (imgRes.ok) {
|
|
783
|
+
const buf = Buffer.from(await imgRes.arrayBuffer());
|
|
784
|
+
const filename = `${screen.id}-v1.png`;
|
|
785
|
+
const storagePath = path.join(screenshotDir, filename);
|
|
786
|
+
await fs.writeFile(storagePath, buf);
|
|
787
|
+
let cloudUrl;
|
|
788
|
+
if (tenantStorageCfg) {
|
|
789
|
+
try {
|
|
790
|
+
const key = buildArtifactKey(tenantId, projectId, "screenshots", filename, tenantStorageCfg.prefix);
|
|
791
|
+
cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, storagePath, key) ?? void 0;
|
|
792
|
+
} catch (e) {
|
|
793
|
+
console.warn("[figma-import] cloud upload failed:", e);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
await AppBrain.saveScreenshot(tenantId, projectId, screen.id, storagePath, 1, "DESKTOP", cloudUrl);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (frame.node) {
|
|
800
|
+
const figmaElements = figma.extractElements(frame.node);
|
|
801
|
+
for (const el of figmaElements) {
|
|
802
|
+
const role = el.type === "COMPONENT" || el.type === "COMPONENT_SET" ? "COMPONENT" : el.type === "INSTANCE" ? "COMPONENT" : el.type === "TEXT" ? "TEXT" : el.type === "FRAME" || el.type === "GROUP" ? "REGION" : el.type;
|
|
803
|
+
const boundingRect = el.boundingBox ? { x: Math.round(el.boundingBox.x), y: Math.round(el.boundingBox.y), width: Math.round(el.boundingBox.width), height: Math.round(el.boundingBox.height) } : void 0;
|
|
804
|
+
await AppBrain.saveElement(
|
|
805
|
+
tenantId,
|
|
806
|
+
projectId,
|
|
807
|
+
screen.id,
|
|
808
|
+
el.name,
|
|
809
|
+
role,
|
|
810
|
+
void 0,
|
|
811
|
+
el.description ?? void 0,
|
|
812
|
+
void 0,
|
|
813
|
+
el.isInteractive,
|
|
814
|
+
boundingRect,
|
|
815
|
+
void 0,
|
|
816
|
+
void 0
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
saved++;
|
|
821
|
+
} catch (err) {
|
|
822
|
+
console.warn(`[figma-import] Failed to save frame:`, err);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
await withTenant(
|
|
826
|
+
tenantId,
|
|
827
|
+
(tx) => tx.platformJob.update({
|
|
828
|
+
where: { id: jobId },
|
|
829
|
+
data: { status: "DONE", progress: 100, result: { screensImported: saved, totalFrames: frames.length }, finishedAt: /* @__PURE__ */ new Date() }
|
|
830
|
+
})
|
|
831
|
+
);
|
|
832
|
+
setImmediate(async () => {
|
|
833
|
+
try {
|
|
834
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
835
|
+
if (projectMap?.screens?.length) {
|
|
836
|
+
await generateTaxonomySkillFiles(tenantId, projectId, projectMap.screens);
|
|
837
|
+
await groupScreensWithAI(tenantId, projectId, projectMap.screens, jobId, /* @__PURE__ */ new Date());
|
|
838
|
+
}
|
|
839
|
+
} catch (err) {
|
|
840
|
+
console.error("[figma-import] post-import grouping failed:", err);
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
} catch (err) {
|
|
844
|
+
await withTenant(
|
|
845
|
+
tenantId,
|
|
846
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async function processNativeCrawlJob(jobId, config) {
|
|
851
|
+
const { tenantId, projectId } = config;
|
|
852
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
853
|
+
try {
|
|
854
|
+
const build = await withTenant(
|
|
855
|
+
tenantId,
|
|
856
|
+
(tx) => tx.nativeAppBuild.findFirst({
|
|
857
|
+
where: { tenantId, projectId },
|
|
858
|
+
orderBy: { uploadedAt: "desc" }
|
|
859
|
+
})
|
|
860
|
+
);
|
|
861
|
+
if (!build) throw new Error("No native app build found for project");
|
|
862
|
+
if (build.fileFormat === "aab") {
|
|
863
|
+
throw new Error(
|
|
864
|
+
"AAB format cannot be installed directly by Appium. Convert to APK using bundletool first, then re-upload the APK."
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
const deviceFarm = config.deviceFarm ?? "local_emulator";
|
|
868
|
+
await withTenant(
|
|
869
|
+
tenantId,
|
|
870
|
+
(tx) => tx.platformJob.update({
|
|
871
|
+
where: { id: jobId },
|
|
872
|
+
data: {
|
|
873
|
+
deviceModel: config.deviceModel ?? null,
|
|
874
|
+
osVersion: config.osVersion ?? null,
|
|
875
|
+
deviceFarm
|
|
876
|
+
}
|
|
877
|
+
})
|
|
878
|
+
).catch(() => {
|
|
879
|
+
});
|
|
880
|
+
const workspaceDeviceFarm = await AppBrain.getTenantDeviceFarmConfig(tenantId).catch(() => null);
|
|
881
|
+
await runNativeCrawl({
|
|
882
|
+
tenantId,
|
|
883
|
+
projectId,
|
|
884
|
+
jobId,
|
|
885
|
+
platform: build.platform,
|
|
886
|
+
appS3Path: build.s3Path,
|
|
887
|
+
packageName: build.packageName ?? void 0,
|
|
888
|
+
deviceModel: config.deviceModel,
|
|
889
|
+
osVersion: config.osVersion,
|
|
890
|
+
deviceFarm,
|
|
891
|
+
appiumServerUrl: workspaceDeviceFarm?.appiumServerUrl,
|
|
892
|
+
browserstackUsername: workspaceDeviceFarm?.browserstackUsername,
|
|
893
|
+
browserstackAccessKey: workspaceDeviceFarm?.browserstackAccessKey,
|
|
894
|
+
awsAccessKeyId: workspaceDeviceFarm?.awsAccessKeyId,
|
|
895
|
+
awsSecretAccessKey: workspaceDeviceFarm?.awsSecretAccessKey,
|
|
896
|
+
awsRegion: workspaceDeviceFarm?.awsRegion,
|
|
897
|
+
awsDeviceFarmProjectArn: workspaceDeviceFarm?.awsDeviceFarmProjectArn
|
|
898
|
+
});
|
|
899
|
+
setImmediate(async () => {
|
|
900
|
+
try {
|
|
901
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
902
|
+
const screens = projectMap?.screens ?? [];
|
|
903
|
+
if (screens.length > 0) {
|
|
904
|
+
await generateTaxonomySkillFiles(tenantId, projectId, screens);
|
|
905
|
+
await groupScreensWithAI(tenantId, projectId, screens, jobId, jobStartedAt);
|
|
906
|
+
try {
|
|
907
|
+
await withTenant(tenantId, async (tx) => {
|
|
908
|
+
const staleTests = await tx.$queryRaw`
|
|
909
|
+
SELECT tc.id
|
|
910
|
+
FROM "TestCase" tc
|
|
911
|
+
JOIN "Screen" s ON tc."screenId" = s.id
|
|
912
|
+
WHERE tc."tenantId" = ${tenantId}
|
|
913
|
+
AND s."projectId" = ${projectId}
|
|
914
|
+
AND s."elementHashChangedAt" IS NOT NULL
|
|
915
|
+
AND tc."genAt" IS NOT NULL
|
|
916
|
+
AND s."elementHashChangedAt" > tc."genAt"
|
|
917
|
+
AND tc.status != 'PENDING_REVIEW'
|
|
918
|
+
AND tc.status != 'RETIRED'
|
|
919
|
+
`;
|
|
920
|
+
if (staleTests.length > 0) {
|
|
921
|
+
const ids = staleTests.map((t) => t.id);
|
|
922
|
+
await tx.testCase.updateMany({
|
|
923
|
+
where: { id: { in: ids } },
|
|
924
|
+
data: { status: "PENDING_REVIEW" }
|
|
925
|
+
});
|
|
926
|
+
logger.info({ count: staleTests.length, projectId }, "Auto-flagged stale test cases as PENDING_REVIEW");
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
} catch (staleErr) {
|
|
930
|
+
logger.warn({ err: staleErr }, "Failed to auto-flag stale tests");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
} catch (e) {
|
|
934
|
+
logger.error({ err: e }, "post-native-crawl processing failed");
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
} catch (err) {
|
|
938
|
+
await withTenant(
|
|
939
|
+
tenantId,
|
|
940
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
941
|
+
).catch(() => {
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async function processGenerateTestsFromReqsJob(jobId, config) {
|
|
946
|
+
const { tenantId, projectId, reqIds, suiteId, suiteName } = config;
|
|
947
|
+
try {
|
|
948
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading requirements");
|
|
949
|
+
const { askTracked, getTaskAgentLLMConfig } = await import("@qip/agents");
|
|
950
|
+
const llmCfg = await getTaskAgentLLMConfig(tenantId, "testGeneration");
|
|
951
|
+
const all = await AppBrain.listRequirements(tenantId, projectId, {});
|
|
952
|
+
const reqs = reqIds?.length ? all.filter((r) => reqIds.includes(r.reqId)) : all.filter((r) => r.status === "unbuilt" || r.status === "pending");
|
|
953
|
+
if (reqs.length === 0) {
|
|
954
|
+
await updateJobProgress(tenantId, jobId, 100, "No pending requirements found");
|
|
955
|
+
await withTenant(
|
|
956
|
+
tenantId,
|
|
957
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { created: 0, skipped: 0 } } })
|
|
958
|
+
);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
let resolvedSuiteId = suiteId;
|
|
962
|
+
if (!resolvedSuiteId) {
|
|
963
|
+
const name = suiteName?.trim() || "Requirements Tests";
|
|
964
|
+
const suite = await withTenant(tenantId, async (tx) => {
|
|
965
|
+
const existing = await tx.testSuite.findFirst({ where: { tenantId, projectId, name } });
|
|
966
|
+
if (existing) return existing;
|
|
967
|
+
return tx.testSuite.create({ data: { tenantId, projectId, name } });
|
|
968
|
+
});
|
|
969
|
+
resolvedSuiteId = suite.id;
|
|
970
|
+
}
|
|
971
|
+
await updateJobProgress(tenantId, jobId, 20, `Generating tests for ${reqs.length} requirements`);
|
|
972
|
+
const reqBlock = reqs.map((r) => [
|
|
973
|
+
`## ${r.reqId}: ${r.title}`,
|
|
974
|
+
`Priority: ${r.priority} | Type: ${r.type}`,
|
|
975
|
+
r.acceptanceCriteria?.length ? `Acceptance Criteria:
|
|
976
|
+
${r.acceptanceCriteria.map((c) => `- ${c}`).join("\n")}` : "",
|
|
977
|
+
r.businessRules?.length ? `Business Rules:
|
|
978
|
+
${r.businessRules.map((b) => `- ${b}`).join("\n")}` : ""
|
|
979
|
+
].filter(Boolean).join("\n")).join("\n\n");
|
|
980
|
+
const system = `You are ZeTa-AI's test case generator. Convert requirements into structured test cases.
|
|
981
|
+
Return ONLY valid JSON: { "cases": [ { "title": string, "kind": string, "priority": string, "preconditions": string, "steps": [{"action": string, "data": string, "expected": string}], "expectedResult": string, "tags": string[], "reqRef": string } ] }
|
|
982
|
+
Kind must be one of: POSITIVE, NEGATIVE, EDGE, SECURITY, ACCESSIBILITY, INTEGRATION, E2E, SMOKE, REGRESSION.
|
|
983
|
+
Priority must be one of: CRITICAL, HIGH, MEDIUM, LOW.
|
|
984
|
+
Generate 1-3 test cases per requirement (positive + negative + edge if applicable).
|
|
985
|
+
Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
986
|
+
const user = `Generate test cases for these requirements:
|
|
987
|
+
|
|
988
|
+
${reqBlock}`;
|
|
989
|
+
let parsed = { cases: [] };
|
|
990
|
+
try {
|
|
991
|
+
const { text, usage } = await askTracked(system, user, llmCfg);
|
|
992
|
+
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: "generate_tests_from_reqs", label: "test_case_generation", usage }).catch(() => {
|
|
993
|
+
});
|
|
994
|
+
const json = text.match(/\{[\s\S]*\}/)?.[0];
|
|
995
|
+
if (json) parsed = JSON.parse(json);
|
|
996
|
+
} catch (e) {
|
|
997
|
+
throw new Error("AI failed to generate test cases: " + (e.message ?? "unknown"));
|
|
998
|
+
}
|
|
999
|
+
await updateJobProgress(tenantId, jobId, 70, `Saving ${parsed.cases?.length ?? 0} test cases`);
|
|
1000
|
+
const VALID_KINDS = /* @__PURE__ */ 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"]);
|
|
1001
|
+
const VALID_PRIORITIES = /* @__PURE__ */ new Set(["CRITICAL", "HIGH", "MEDIUM", "LOW"]);
|
|
1002
|
+
const normalized = (parsed.cases ?? []).map((c) => ({
|
|
1003
|
+
ref: c.reqRef ?? null,
|
|
1004
|
+
traceTo: c.reqRef ?? null,
|
|
1005
|
+
title: c.title || "Untitled",
|
|
1006
|
+
kind: VALID_KINDS.has((c.kind ?? "").toUpperCase()) ? c.kind.toUpperCase() : "POSITIVE",
|
|
1007
|
+
priority: VALID_PRIORITIES.has((c.priority ?? "").toUpperCase()) ? c.priority.toUpperCase() : "MEDIUM",
|
|
1008
|
+
preconditions: c.preconditions ?? "",
|
|
1009
|
+
steps: Array.isArray(c.steps) ? c.steps : [],
|
|
1010
|
+
expectedResult: c.expectedResult ?? "",
|
|
1011
|
+
tags: Array.isArray(c.tags) ? c.tags : []
|
|
1012
|
+
}));
|
|
1013
|
+
const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
|
|
1014
|
+
tenantId,
|
|
1015
|
+
projectId,
|
|
1016
|
+
resolvedSuiteId,
|
|
1017
|
+
normalized,
|
|
1018
|
+
{ duplicateMode: "skip" }
|
|
1019
|
+
);
|
|
1020
|
+
const coveredReqIds = new Set(normalized.map((c) => c.ref).filter(Boolean));
|
|
1021
|
+
await Promise.all(
|
|
1022
|
+
reqs.filter((r) => coveredReqIds.has(r.reqId)).map((r) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: "covered" }).catch(() => {
|
|
1023
|
+
}))
|
|
1024
|
+
);
|
|
1025
|
+
await withTenant(
|
|
1026
|
+
tenantId,
|
|
1027
|
+
(tx) => tx.platformJob.update({
|
|
1028
|
+
where: { id: jobId },
|
|
1029
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { created: created.length, skipped, suiteId: resolvedSuiteId }, progress: 100 }
|
|
1030
|
+
})
|
|
1031
|
+
);
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
const msg = e?.message ?? "Unknown error";
|
|
1034
|
+
logger.error({ jobId, err: msg }, "processGenerateTestsFromReqsJob failed");
|
|
1035
|
+
await withTenant(
|
|
1036
|
+
tenantId,
|
|
1037
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg } })
|
|
1038
|
+
).catch(() => {
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
async function processRunTestSuiteJob(jobId, config) {
|
|
1043
|
+
const { tenantId, projectId, testRunId, suiteId, storageState, browser, shardIndex, totalShards } = config;
|
|
1044
|
+
const runStart = Date.now();
|
|
1045
|
+
await withTenant(
|
|
1046
|
+
tenantId,
|
|
1047
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1048
|
+
);
|
|
1049
|
+
try {
|
|
1050
|
+
let testCases = await AppBrain.listTestCases(tenantId, projectId, {
|
|
1051
|
+
status: "APPROVED",
|
|
1052
|
+
...suiteId ? { suiteId } : {}
|
|
1053
|
+
});
|
|
1054
|
+
if (typeof totalShards === "number" && totalShards > 1 && typeof shardIndex === "number") {
|
|
1055
|
+
testCases = testCases.filter((_, i) => i % totalShards === shardIndex);
|
|
1056
|
+
}
|
|
1057
|
+
if (testCases.length === 0) {
|
|
1058
|
+
await AppBrain.completeTestRun(tenantId, testRunId, { total: 0, passed: 0, failed: 0, skipped: 0, duration: 0 });
|
|
1059
|
+
await withTenant(
|
|
1060
|
+
tenantId,
|
|
1061
|
+
(tx) => tx.platformJob.update({
|
|
1062
|
+
where: { id: jobId },
|
|
1063
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result: { total: 0 } }
|
|
1064
|
+
})
|
|
1065
|
+
);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const aiHealingEnabled = await isFeatureEnabled(tenantId, "AI_SELF_HEALING");
|
|
1069
|
+
const screenshotDir = config.screenshotDir || platformConfig.screenshotDir || "/tmp/zeta-screenshots";
|
|
1070
|
+
const VALID_BROWSERS = ["chromium", "firefox", "webkit"];
|
|
1071
|
+
const validBrowser = VALID_BROWSERS.includes(browser) ? browser : "chromium";
|
|
1072
|
+
let passed = 0;
|
|
1073
|
+
let failed = 0;
|
|
1074
|
+
const runResults = [];
|
|
1075
|
+
for (let i = 0; i < testCases.length; i += TEST_EXEC_CONCURRENCY) {
|
|
1076
|
+
const batch = testCases.slice(i, i + TEST_EXEC_CONCURRENCY);
|
|
1077
|
+
await Promise.all(batch.map(async (tc) => {
|
|
1078
|
+
try {
|
|
1079
|
+
const full = await AppBrain.getTestCaseForExecution(tenantId, tc.id);
|
|
1080
|
+
const baseUrl = full?.screen?.project?.appUrl ?? "";
|
|
1081
|
+
const execProjectId = full?.screen?.project?.id ?? projectId;
|
|
1082
|
+
const dataRows = full?.dataSet?.rows ?? [];
|
|
1083
|
+
const runs = dataRows.length > 0 ? dataRows : [null];
|
|
1084
|
+
for (const row of runs) {
|
|
1085
|
+
let tcForPlan = full ?? tc;
|
|
1086
|
+
if (row !== null) {
|
|
1087
|
+
const rawSteps = Array.isArray(tcForPlan.steps) ? tcForPlan.steps : [];
|
|
1088
|
+
tcForPlan = {
|
|
1089
|
+
...tcForPlan,
|
|
1090
|
+
steps: rawSteps.map((s) => interpolatePlaceholders(s, row.data)),
|
|
1091
|
+
title: interpolatePlaceholders(tcForPlan.title ?? "", row.data),
|
|
1092
|
+
preconditions: tcForPlan.preconditions ? interpolatePlaceholders(tcForPlan.preconditions, row.data) : tcForPlan.preconditions
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
const plan = await generateExecutionPlan(tenantId, tcForPlan, baseUrl);
|
|
1096
|
+
const result = await executeTestPlan(plan, {
|
|
1097
|
+
screenshotDir,
|
|
1098
|
+
testRunId,
|
|
1099
|
+
maxRuns: 1,
|
|
1100
|
+
browser: validBrowser,
|
|
1101
|
+
storageState,
|
|
1102
|
+
healCtx: aiHealingEnabled ? { tenantId, projectId: execProjectId } : void 0
|
|
1103
|
+
});
|
|
1104
|
+
const status = result.status === "PASSED" ? "PASSED" : result.status === "ERROR" ? "ERROR" : "FAILED";
|
|
1105
|
+
if (status === "PASSED") passed++;
|
|
1106
|
+
else failed++;
|
|
1107
|
+
runResults.push({ testCaseId: tc.id, status });
|
|
1108
|
+
await AppBrain.saveTestResult(tenantId, {
|
|
1109
|
+
runId: testRunId,
|
|
1110
|
+
testCaseId: tc.id,
|
|
1111
|
+
status,
|
|
1112
|
+
duration: result.totalDuration,
|
|
1113
|
+
...result.rootCauseSummary ? { failureAnalysis: result.rootCauseSummary, failureAnalysisAt: /* @__PURE__ */ new Date() } : {},
|
|
1114
|
+
...row !== null ? { dataRowId: row.id, dataRowIndex: row.rowIndex } : {},
|
|
1115
|
+
...result.consoleLogs?.length || result.networkErrors?.length ? {
|
|
1116
|
+
logs: {
|
|
1117
|
+
consoleLogs: result.consoleLogs ?? [],
|
|
1118
|
+
networkErrors: result.networkErrors ?? []
|
|
1119
|
+
}
|
|
1120
|
+
} : {}
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => {
|
|
1124
|
+
});
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
failed++;
|
|
1127
|
+
runResults.push({ testCaseId: tc.id, status: "ERROR" });
|
|
1128
|
+
await AppBrain.saveTestResult(tenantId, {
|
|
1129
|
+
runId: testRunId,
|
|
1130
|
+
testCaseId: tc.id,
|
|
1131
|
+
status: "ERROR",
|
|
1132
|
+
error: String(err?.message ?? err).slice(0, 500)
|
|
1133
|
+
}).catch(() => {
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}));
|
|
1137
|
+
await updateJobProgress(
|
|
1138
|
+
tenantId,
|
|
1139
|
+
jobId,
|
|
1140
|
+
Math.round(Math.min(i + TEST_EXEC_CONCURRENCY, testCases.length) / testCases.length * 100),
|
|
1141
|
+
`Ran ${Math.min(i + TEST_EXEC_CONCURRENCY, testCases.length)}/${testCases.length} test cases`
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
await AppBrain.completeTestRun(tenantId, testRunId, {
|
|
1145
|
+
total: testCases.length,
|
|
1146
|
+
passed,
|
|
1147
|
+
failed,
|
|
1148
|
+
skipped: 0,
|
|
1149
|
+
duration: Date.now() - runStart
|
|
1150
|
+
});
|
|
1151
|
+
await withTenant(
|
|
1152
|
+
tenantId,
|
|
1153
|
+
(tx) => tx.platformJob.update({
|
|
1154
|
+
where: { id: jobId },
|
|
1155
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result: { total: testCases.length, passed, failed } }
|
|
1156
|
+
})
|
|
1157
|
+
);
|
|
1158
|
+
notifyRunCompletion(
|
|
1159
|
+
tenantId,
|
|
1160
|
+
projectId,
|
|
1161
|
+
testRunId,
|
|
1162
|
+
failed === 0 ? "COMPLETED" : "FAILED",
|
|
1163
|
+
{ passed, failed, total: testCases.length },
|
|
1164
|
+
runResults
|
|
1165
|
+
).catch(console.error);
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
const msg = String(err?.message ?? err).slice(0, 500);
|
|
1168
|
+
logger.error({ jobId, testRunId, err: msg }, "processRunTestSuiteJob failed");
|
|
1169
|
+
await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => {
|
|
1170
|
+
});
|
|
1171
|
+
await withTenant(
|
|
1172
|
+
tenantId,
|
|
1173
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg } })
|
|
1174
|
+
).catch(() => {
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
async function processGenerateSkillFilesJob(jobId, config) {
|
|
1179
|
+
const { tenantId, projectId } = config;
|
|
1180
|
+
await withTenant(
|
|
1181
|
+
tenantId,
|
|
1182
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1183
|
+
);
|
|
1184
|
+
try {
|
|
1185
|
+
await updateJobProgress(tenantId, jobId, 10, "Loading project data\u2026");
|
|
1186
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
1187
|
+
if (!projectMap) throw new Error("Project not found.");
|
|
1188
|
+
const screens = projectMap.screens ?? [];
|
|
1189
|
+
const lastCrawl = await withTenant(
|
|
1190
|
+
tenantId,
|
|
1191
|
+
(tx) => tx.platformJob.findFirst({
|
|
1192
|
+
where: { projectId, type: "CRAWL", status: "DONE" },
|
|
1193
|
+
orderBy: { finishedAt: "desc" },
|
|
1194
|
+
select: { result: true }
|
|
1195
|
+
})
|
|
1196
|
+
);
|
|
1197
|
+
const lastResult = lastCrawl?.result ?? {};
|
|
1198
|
+
await updateJobProgress(tenantId, jobId, 25, `Generating ${screens.length} app intelligence skill files\u2026`);
|
|
1199
|
+
await generateTaxonomySkillFiles(tenantId, projectId, screens, lastResult.apiEndpoints, lastResult.performanceMetrics);
|
|
1200
|
+
await updateJobProgress(tenantId, jobId, 85, "Refreshing testing intelligence skill files\u2026");
|
|
1201
|
+
await Promise.allSettled([
|
|
1202
|
+
refreshTestCasesSkillFile(tenantId, projectId),
|
|
1203
|
+
refreshTestDataSkillFile(tenantId, projectId),
|
|
1204
|
+
refreshTestScriptSkillFile(tenantId, projectId),
|
|
1205
|
+
refreshRequirementsCoverageSkillFile(tenantId, projectId)
|
|
1206
|
+
]);
|
|
1207
|
+
await withTenant(
|
|
1208
|
+
tenantId,
|
|
1209
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date() } })
|
|
1210
|
+
);
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
await withTenant(
|
|
1213
|
+
tenantId,
|
|
1214
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() } })
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
async function processDataRetentionPurge() {
|
|
1219
|
+
logger.info("[data-retention] starting purge run");
|
|
1220
|
+
const llmCutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
|
|
1221
|
+
const llmResult = await prismaAdmin.lLMCallLog.deleteMany({
|
|
1222
|
+
where: { calledAt: { lt: llmCutoff } }
|
|
1223
|
+
});
|
|
1224
|
+
logger.info({ deleted: llmResult.count }, "[data-retention] LLMCallLog purged");
|
|
1225
|
+
const tenants = await prismaAdmin.tenant.findMany({ select: { id: true } });
|
|
1226
|
+
let auditTotal = 0;
|
|
1227
|
+
for (const { id: tenantId } of tenants) {
|
|
1228
|
+
const setting = await prismaAdmin.tenantSetting.findFirst({
|
|
1229
|
+
where: { tenantId },
|
|
1230
|
+
orderBy: { dataRetentionDays: "asc" },
|
|
1231
|
+
select: { dataRetentionDays: true }
|
|
1232
|
+
});
|
|
1233
|
+
const retentionDays = setting?.dataRetentionDays ?? 365;
|
|
1234
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1e3);
|
|
1235
|
+
const result = await prismaAdmin.auditLog.deleteMany({
|
|
1236
|
+
where: { tenantId, createdAt: { lt: cutoff } }
|
|
1237
|
+
});
|
|
1238
|
+
auditTotal += result.count;
|
|
1239
|
+
}
|
|
1240
|
+
logger.info({ deleted: auditTotal, tenants: tenants.length }, "[data-retention] AuditLog purged");
|
|
1241
|
+
}
|
|
1242
|
+
async function processDiscoverJob(jobId, config, opts) {
|
|
1243
|
+
const { tenantId, projectId } = config;
|
|
1244
|
+
const controller = new AbortController();
|
|
1245
|
+
runningControllers.set(jobId, controller);
|
|
1246
|
+
const allUrls = /* @__PURE__ */ new Set();
|
|
1247
|
+
let lastPartialFlush = 0;
|
|
1248
|
+
const flushPartialUrls = async (pct, step) => {
|
|
1249
|
+
const now = Date.now();
|
|
1250
|
+
if (now - lastPartialFlush < 5e3 && allUrls.size < 5) return;
|
|
1251
|
+
lastPartialFlush = now;
|
|
1252
|
+
try {
|
|
1253
|
+
await withTenant(
|
|
1254
|
+
tenantId,
|
|
1255
|
+
(tx) => tx.platformJob.update({
|
|
1256
|
+
where: { id: jobId },
|
|
1257
|
+
data: { progress: pct, result: { step, partialUrls: Array.from(allUrls) }, heartbeatAt: /* @__PURE__ */ new Date() }
|
|
1258
|
+
})
|
|
1259
|
+
);
|
|
1260
|
+
} catch {
|
|
1261
|
+
}
|
|
1262
|
+
await opts?.onProgress?.(pct);
|
|
1263
|
+
};
|
|
1264
|
+
const onProgress = async (pct, step) => {
|
|
1265
|
+
await updateJobProgress(tenantId, jobId, pct, step);
|
|
1266
|
+
await opts?.onProgress?.(pct);
|
|
1267
|
+
};
|
|
1268
|
+
try {
|
|
1269
|
+
await withTenant(
|
|
1270
|
+
tenantId,
|
|
1271
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1272
|
+
);
|
|
1273
|
+
const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null) ?? {};
|
|
1274
|
+
const appUrl = config.appUrl ?? crawlCfg.appUrl;
|
|
1275
|
+
if (!appUrl) throw new Error("No app URL configured. Save the crawl settings first.");
|
|
1276
|
+
const maxDiscoverUrls = config.maxDiscoverUrls ?? 2e3;
|
|
1277
|
+
const proxyUrl = config.proxyUrl ?? crawlCfg.proxyUrl;
|
|
1278
|
+
const screenshotDir = os.tmpdir();
|
|
1279
|
+
await onProgress(3, "Seeding from sitemap/robots.txt\u2026");
|
|
1280
|
+
const sitemapResult = await discoverProjectUrls(appUrl, {
|
|
1281
|
+
sitemapUrl: config.sitemapUrl ?? crawlCfg.sitemapUrl,
|
|
1282
|
+
proxyUrl
|
|
1283
|
+
}).catch(() => ({ candidates: [], sitemapFound: false }));
|
|
1284
|
+
const sitemapUrls = sitemapResult.candidates.map((c) => c.url);
|
|
1285
|
+
for (const u of sitemapUrls) allUrls.add(u);
|
|
1286
|
+
logger.info({ count: sitemapUrls.length, sitemapFound: sitemapResult.sitemapFound }, "[discover] Phase 1 sitemap done");
|
|
1287
|
+
await onProgress(8, `Unauthenticated spider from ${appUrl}\u2026`);
|
|
1288
|
+
const unauthResult = await crawlProject({
|
|
1289
|
+
tenantId,
|
|
1290
|
+
projectId,
|
|
1291
|
+
appUrl,
|
|
1292
|
+
discoveryMode: true,
|
|
1293
|
+
startUrls: sitemapUrls.length > 0 ? sitemapUrls : void 0,
|
|
1294
|
+
screenshotDir,
|
|
1295
|
+
maxScreens: maxDiscoverUrls,
|
|
1296
|
+
proxyUrl,
|
|
1297
|
+
useFirefox: crawlCfg.useFirefox,
|
|
1298
|
+
abortSignal: controller.signal,
|
|
1299
|
+
jobId,
|
|
1300
|
+
onUrlsDiscovered: async (urls) => {
|
|
1301
|
+
for (const u of urls) allUrls.add(u);
|
|
1302
|
+
const pct = 8 + Math.round(allUrls.size / Math.max(1, maxDiscoverUrls) * 42);
|
|
1303
|
+
await flushPartialUrls(Math.min(pct, 49), `Discovered ${allUrls.size} URLs\u2026`);
|
|
1304
|
+
}
|
|
1305
|
+
}, async (pct, step) => {
|
|
1306
|
+
await flushPartialUrls(8 + Math.round(pct * 0.42), `Unauthenticated: ${step}`);
|
|
1307
|
+
}).catch((err) => {
|
|
1308
|
+
if (err?.message === "CRAWL_ABORTED") throw err;
|
|
1309
|
+
logger.warn({ err: err?.message }, "[discover] Phase 2 unauthenticated spider failed \u2014 continuing with partial results");
|
|
1310
|
+
return { discoveredUrls: [] };
|
|
1311
|
+
});
|
|
1312
|
+
for (const u of unauthResult.discoveredUrls ?? []) allUrls.add(u);
|
|
1313
|
+
logger.info({ count: allUrls.size }, "[discover] Phase 2 unauth spider done");
|
|
1314
|
+
let resolvedSso = {};
|
|
1315
|
+
if (crawlCfg.ssoProviderId) {
|
|
1316
|
+
const ssoProviders = await AppBrain.getWorkspaceSsoProviders(tenantId).catch(() => []);
|
|
1317
|
+
const ssoProvider = ssoProviders.find((p) => p.id === crawlCfg.ssoProviderId);
|
|
1318
|
+
if (ssoProvider) {
|
|
1319
|
+
resolvedSso = {
|
|
1320
|
+
ssoProvider: ssoProvider.provider,
|
|
1321
|
+
ssoDomain: ssoProvider.domain,
|
|
1322
|
+
ssoClientId: ssoProvider.clientId,
|
|
1323
|
+
ssoClientSecret: ssoProvider.clientSecret,
|
|
1324
|
+
ssoScope: ssoProvider.scope
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
const hasCreds = !!(crawlCfg.username || crawlCfg.storageState || crawlCfg.authToken || crawlCfg.chromeProfilePath || crawlCfg.httpBasicUsername && crawlCfg.httpBasicPassword);
|
|
1329
|
+
if (hasCreds && !controller.signal.aborted) {
|
|
1330
|
+
await onProgress(52, "Authenticated spider\u2026");
|
|
1331
|
+
const runtimeCreds = crawlCfg.username ? {
|
|
1332
|
+
username: crawlCfg.username,
|
|
1333
|
+
password: crawlCfg.password ?? "",
|
|
1334
|
+
...crawlCfg.totpSecret ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret) } : {}
|
|
1335
|
+
} : void 0;
|
|
1336
|
+
const authResult = await crawlProject({
|
|
1337
|
+
tenantId,
|
|
1338
|
+
projectId,
|
|
1339
|
+
appUrl,
|
|
1340
|
+
discoveryMode: true,
|
|
1341
|
+
credentials: runtimeCreds,
|
|
1342
|
+
storageState: crawlCfg.storageState,
|
|
1343
|
+
httpBasicUsername: crawlCfg.httpBasicUsername,
|
|
1344
|
+
httpBasicPassword: crawlCfg.httpBasicPassword,
|
|
1345
|
+
authToken: crawlCfg.authToken,
|
|
1346
|
+
authTokenHeader: crawlCfg.authTokenHeader,
|
|
1347
|
+
authTokenPrefix: crawlCfg.authTokenPrefix,
|
|
1348
|
+
startUrls: sitemapUrls.length > 0 ? sitemapUrls : void 0,
|
|
1349
|
+
screenshotDir,
|
|
1350
|
+
maxScreens: maxDiscoverUrls,
|
|
1351
|
+
proxyUrl,
|
|
1352
|
+
chromeProfilePath: crawlCfg.chromeProfilePath,
|
|
1353
|
+
useFirefox: crawlCfg.useFirefox,
|
|
1354
|
+
captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
|
|
1355
|
+
captchaSolverProvider: crawlCfg.captchaSolverProvider,
|
|
1356
|
+
mailslurpApiKey: crawlCfg.mailslurpApiKey,
|
|
1357
|
+
mailslurpInboxId: crawlCfg.mailslurpInboxId,
|
|
1358
|
+
loginInstructions: crawlCfg.loginInstructions,
|
|
1359
|
+
...resolvedSso,
|
|
1360
|
+
abortSignal: controller.signal,
|
|
1361
|
+
jobId,
|
|
1362
|
+
onUrlsDiscovered: async (urls) => {
|
|
1363
|
+
for (const u of urls) allUrls.add(u);
|
|
1364
|
+
const pct = 52 + Math.round(allUrls.size / Math.max(1, maxDiscoverUrls) * 38);
|
|
1365
|
+
await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)\u2026`);
|
|
1366
|
+
}
|
|
1367
|
+
}, async (pct, step) => {
|
|
1368
|
+
await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
|
|
1369
|
+
}).catch((err) => {
|
|
1370
|
+
if (err?.message === "CRAWL_ABORTED") throw err;
|
|
1371
|
+
logger.warn({ err: err?.message }, "[discover] Phase 3 authenticated spider failed \u2014 using unauthenticated results");
|
|
1372
|
+
return { discoveredUrls: [] };
|
|
1373
|
+
});
|
|
1374
|
+
for (const u of authResult.discoveredUrls ?? []) {
|
|
1375
|
+
allUrls.add(u);
|
|
1376
|
+
}
|
|
1377
|
+
logger.info({ count: allUrls.size }, "[discover] Phase 3 auth spider done");
|
|
1378
|
+
}
|
|
1379
|
+
const discoveredUrls = Array.from(allUrls);
|
|
1380
|
+
const existingCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => ({}));
|
|
1381
|
+
await AppBrain.saveCrawlConfig(tenantId, projectId, {
|
|
1382
|
+
...existingCfg,
|
|
1383
|
+
discoveredUrls,
|
|
1384
|
+
discoveredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1385
|
+
});
|
|
1386
|
+
await onProgress(100, `Done \u2014 ${discoveredUrls.length} URLs discovered`);
|
|
1387
|
+
await withTenant(
|
|
1388
|
+
tenantId,
|
|
1389
|
+
(tx) => tx.platformJob.update({
|
|
1390
|
+
where: { id: jobId },
|
|
1391
|
+
data: {
|
|
1392
|
+
status: "DONE",
|
|
1393
|
+
result: {
|
|
1394
|
+
discoveredUrls,
|
|
1395
|
+
count: discoveredUrls.length,
|
|
1396
|
+
sitemapFound: sitemapResult.sitemapFound
|
|
1397
|
+
},
|
|
1398
|
+
progress: 100,
|
|
1399
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
1400
|
+
}
|
|
1401
|
+
})
|
|
1402
|
+
);
|
|
1403
|
+
notifyAdmins(tenantId, {
|
|
1404
|
+
type: "discovery.done",
|
|
1405
|
+
title: "URL Discovery completed",
|
|
1406
|
+
body: `Found ${discoveredUrls.length} URLs for the project.`,
|
|
1407
|
+
metadata: { projectId, jobId }
|
|
1408
|
+
}).catch(console.error);
|
|
1409
|
+
} catch (err) {
|
|
1410
|
+
logger.error({ err: err?.message, jobId }, "[discover] Job failed");
|
|
1411
|
+
await withTenant(
|
|
1412
|
+
tenantId,
|
|
1413
|
+
(tx) => tx.platformJob.update({
|
|
1414
|
+
where: { id: jobId },
|
|
1415
|
+
data: {
|
|
1416
|
+
status: err?.message === "CRAWL_ABORTED" ? "CANCELLED" : "FAILED",
|
|
1417
|
+
result: { error: err?.message ?? String(err) },
|
|
1418
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
1419
|
+
}
|
|
1420
|
+
})
|
|
1421
|
+
).catch(console.error);
|
|
1422
|
+
} finally {
|
|
1423
|
+
runningControllers.delete(jobId);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
async function processMonitorJob(jobId, config, opts) {
|
|
1427
|
+
const { tenantId, projectId } = config;
|
|
1428
|
+
const controller = new AbortController();
|
|
1429
|
+
runningControllers.set(jobId, controller);
|
|
1430
|
+
try {
|
|
1431
|
+
await withTenant(
|
|
1432
|
+
tenantId,
|
|
1433
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1434
|
+
);
|
|
1435
|
+
const proj = await withTenant(
|
|
1436
|
+
tenantId,
|
|
1437
|
+
(tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
1438
|
+
);
|
|
1439
|
+
const crawlCfg = proj?.crawlConfig ?? {};
|
|
1440
|
+
const monitorUrls = config.monitorUrls ?? crawlCfg.monitorUrls ?? [];
|
|
1441
|
+
if (monitorUrls.length === 0) {
|
|
1442
|
+
throw new Error("No monitor URLs configured. Add monitorUrls to crawl config or job config.");
|
|
1443
|
+
}
|
|
1444
|
+
await updateJobProgress(tenantId, jobId, 10, `Monitoring ${monitorUrls.length} URL(s)...`);
|
|
1445
|
+
const storedBaselines = crawlCfg.monitorBaselines ?? {};
|
|
1446
|
+
const projectScreens = await withTenant(
|
|
1447
|
+
tenantId,
|
|
1448
|
+
(tx) => tx.screen.findMany({ where: { projectId }, select: { url: true, elementHash: true } })
|
|
1449
|
+
).catch(() => []);
|
|
1450
|
+
const screenHashMap = {};
|
|
1451
|
+
for (const s of projectScreens) {
|
|
1452
|
+
if (s.url && s.elementHash) screenHashMap[s.url] = s.elementHash;
|
|
1453
|
+
}
|
|
1454
|
+
const results = [];
|
|
1455
|
+
const newBaselines = { ...storedBaselines };
|
|
1456
|
+
for (let i = 0; i < monitorUrls.length; i++) {
|
|
1457
|
+
if (controller.signal.aborted) break;
|
|
1458
|
+
const url = monitorUrls[i];
|
|
1459
|
+
const pct = 10 + Math.floor(i / monitorUrls.length * 80);
|
|
1460
|
+
await updateJobProgress(tenantId, jobId, pct, `Checking ${url}...`);
|
|
1461
|
+
opts?.onProgress?.(pct);
|
|
1462
|
+
try {
|
|
1463
|
+
const crawlResult = await httpCrawl({ appUrl: url, maxScreens: 1, respectRobotsTxt: false, timeoutMs: 2e4 });
|
|
1464
|
+
const screen = crawlResult.screens[0];
|
|
1465
|
+
if (!screen) {
|
|
1466
|
+
results.push({ url, changed: false, reason: "fetch_empty", currentHash: "", previousHash: null });
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
const currentHash = hashContent(normalizeText(screen.markdown));
|
|
1470
|
+
const previousHash = storedBaselines[url] ?? screenHashMap[url] ?? null;
|
|
1471
|
+
newBaselines[url] = currentHash;
|
|
1472
|
+
if (!previousHash) {
|
|
1473
|
+
results.push({ url, changed: false, reason: "new_baseline", currentHash, previousHash: null });
|
|
1474
|
+
} else if (previousHash === currentHash) {
|
|
1475
|
+
results.push({ url, changed: false, reason: "unchanged", currentHash, previousHash });
|
|
1476
|
+
} else {
|
|
1477
|
+
results.push({ url, changed: true, reason: "content_changed", currentHash, previousHash });
|
|
1478
|
+
}
|
|
1479
|
+
} catch (fetchErr) {
|
|
1480
|
+
results.push({ url, changed: false, reason: `error:${fetchErr?.message ?? String(fetchErr)}`, currentHash: "", previousHash: null });
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
await withTenant(
|
|
1484
|
+
tenantId,
|
|
1485
|
+
(tx) => tx.project.update({
|
|
1486
|
+
where: { id: projectId },
|
|
1487
|
+
data: { crawlConfig: { ...crawlCfg, monitorBaselines: newBaselines } }
|
|
1488
|
+
})
|
|
1489
|
+
).catch((e) => logger.warn({ err: e }, "[monitor] baseline persist failed"));
|
|
1490
|
+
const changedUrls = results.filter((r) => r.changed);
|
|
1491
|
+
if (changedUrls.length > 0) {
|
|
1492
|
+
dispatchWebhook(tenantId, projectId, "content.changed", {
|
|
1493
|
+
jobId,
|
|
1494
|
+
changedUrls: changedUrls.map((r) => ({ url: r.url, previousHash: r.previousHash, currentHash: r.currentHash })),
|
|
1495
|
+
totalChecked: results.length,
|
|
1496
|
+
changedCount: changedUrls.length
|
|
1497
|
+
}).catch(console.error);
|
|
1498
|
+
}
|
|
1499
|
+
await updateJobProgress(tenantId, jobId, 100, "Monitor check complete");
|
|
1500
|
+
await withTenant(
|
|
1501
|
+
tenantId,
|
|
1502
|
+
(tx) => tx.platformJob.update({
|
|
1503
|
+
where: { id: jobId },
|
|
1504
|
+
data: {
|
|
1505
|
+
status: "DONE",
|
|
1506
|
+
progress: 100,
|
|
1507
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
1508
|
+
result: { results, changedCount: changedUrls.length, totalChecked: results.length }
|
|
1509
|
+
}
|
|
1510
|
+
})
|
|
1511
|
+
);
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
await withTenant(
|
|
1514
|
+
tenantId,
|
|
1515
|
+
(tx) => tx.platformJob.update({
|
|
1516
|
+
where: { id: jobId },
|
|
1517
|
+
data: { status: "FAILED", error: String(err?.message ?? err), finishedAt: /* @__PURE__ */ new Date() }
|
|
1518
|
+
})
|
|
1519
|
+
);
|
|
1520
|
+
} finally {
|
|
1521
|
+
runningControllers.delete(jobId);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
async function processImportTestsJob(jobId, config) {
|
|
1525
|
+
const { tenantId, projectId, artifactId, autoLabel, sourcePlatform } = config;
|
|
1526
|
+
const jobStart = /* @__PURE__ */ new Date();
|
|
1527
|
+
let suiteId = config.suiteId ?? "new";
|
|
1528
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading artifact");
|
|
1529
|
+
const artifact = await withTenant(
|
|
1530
|
+
tenantId,
|
|
1531
|
+
(tx) => tx.projectArtifact.findUnique({ where: { id: artifactId } })
|
|
1532
|
+
);
|
|
1533
|
+
if (!artifact || artifact.tenantId !== tenantId) {
|
|
1534
|
+
throw new Error(`Artifact ${artifactId} not found`);
|
|
1535
|
+
}
|
|
1536
|
+
const content = artifact.content ?? "";
|
|
1537
|
+
const filename = artifact.originalName ?? artifact.name ?? "";
|
|
1538
|
+
const kind = artifact.kind ?? "";
|
|
1539
|
+
function detectFormat(fn, k, c) {
|
|
1540
|
+
if (fn.endsWith(".feature")) return "GHERKIN";
|
|
1541
|
+
if (fn.match(/\.(xlsx|xls|csv)$/i) || k === "SPREADSHEET") return "EXCEL";
|
|
1542
|
+
if (fn.endsWith(".txt") && c && c.split("\n").some((l) => l.trim().startsWith("|"))) return "EXCEL";
|
|
1543
|
+
return "AI_EXTRACT";
|
|
1544
|
+
}
|
|
1545
|
+
const format = detectFormat(filename, kind, content);
|
|
1546
|
+
await updateJobProgress(tenantId, jobId, 15, `Parsing ${filename}`);
|
|
1547
|
+
const { parseTestCases, labelTestCasesBatch } = await import("@qip/agents");
|
|
1548
|
+
let res = await parseTestCases(content, format, tenantId, artifact.columnMapping ?? void 0, void 0, void 0, sourcePlatform);
|
|
1549
|
+
if (res.cases.length === 0 && format !== "AI_EXTRACT") {
|
|
1550
|
+
await updateJobProgress(tenantId, jobId, 25, "Retrying with AI extraction");
|
|
1551
|
+
try {
|
|
1552
|
+
res = await parseTestCases(content, "AI_EXTRACT", tenantId, void 0, void 0, void 0, sourcePlatform);
|
|
1553
|
+
} catch {
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const parsedCases = res.cases;
|
|
1557
|
+
if (parsedCases.length === 0) {
|
|
1558
|
+
await withTenant(
|
|
1559
|
+
tenantId,
|
|
1560
|
+
(tx) => tx.platformJob.update({
|
|
1561
|
+
where: { id: jobId },
|
|
1562
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result: { imported: 0, skipped: 0, labeled: 0 } }
|
|
1563
|
+
})
|
|
1564
|
+
);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
await updateJobProgress(tenantId, jobId, 35, `Parsed ${parsedCases.length} cases \u2014 creating suite`);
|
|
1568
|
+
if (!suiteId || suiteId === "new") {
|
|
1569
|
+
const suiteName = filename.replace(/\.[^/.]+$/, "") || "Imported Tests";
|
|
1570
|
+
const suite = await withTenant(
|
|
1571
|
+
tenantId,
|
|
1572
|
+
(tx) => tx.testSuite.create({
|
|
1573
|
+
data: { tenantId, projectId, name: suiteName }
|
|
1574
|
+
})
|
|
1575
|
+
);
|
|
1576
|
+
suiteId = suite.id;
|
|
1577
|
+
}
|
|
1578
|
+
await updateJobProgress(tenantId, jobId, 50, `Importing ${parsedCases.length} cases`);
|
|
1579
|
+
const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
|
|
1580
|
+
tenantId,
|
|
1581
|
+
projectId,
|
|
1582
|
+
suiteId,
|
|
1583
|
+
parsedCases,
|
|
1584
|
+
{ duplicateMode: "skip", artifactId }
|
|
1585
|
+
);
|
|
1586
|
+
let labeled = 0;
|
|
1587
|
+
if (autoLabel && created.length > 0) {
|
|
1588
|
+
await updateJobProgress(tenantId, jobId, 75, `AI labeling ${created.length} cases`);
|
|
1589
|
+
try {
|
|
1590
|
+
const labelInput = created.map((c) => ({
|
|
1591
|
+
id: c.id,
|
|
1592
|
+
title: c.title,
|
|
1593
|
+
description: c.description ?? "",
|
|
1594
|
+
stepsText: (Array.isArray(c.steps) ? c.steps.join(" ") : String(c.steps ?? "")).slice(0, 200)
|
|
1595
|
+
}));
|
|
1596
|
+
const labelResults = await labelTestCasesBatch(labelInput, tenantId);
|
|
1597
|
+
await Promise.all(
|
|
1598
|
+
labelResults.map(
|
|
1599
|
+
(lr) => withTenant(
|
|
1600
|
+
tenantId,
|
|
1601
|
+
(tx) => tx.testCase.update({
|
|
1602
|
+
where: { id: lr.id },
|
|
1603
|
+
data: {
|
|
1604
|
+
priority: lr.priority ?? void 0,
|
|
1605
|
+
kind: lr.kind ?? void 0,
|
|
1606
|
+
tags: lr.tags ?? void 0,
|
|
1607
|
+
status: lr.needsReview ? "PENDING_REVIEW" : void 0
|
|
1608
|
+
}
|
|
1609
|
+
})
|
|
1610
|
+
).catch(() => {
|
|
1611
|
+
})
|
|
1612
|
+
// non-fatal per-case
|
|
1613
|
+
)
|
|
1614
|
+
);
|
|
1615
|
+
labeled = labelResults.length;
|
|
1616
|
+
} catch {
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
const casesNeedingData = created.filter((c) => {
|
|
1620
|
+
const inline = c.testDataInline;
|
|
1621
|
+
return inline && typeof inline === "object" && Object.keys(inline).length > 0;
|
|
1622
|
+
});
|
|
1623
|
+
if (casesNeedingData.length > 0) {
|
|
1624
|
+
const cap = Math.min(casesNeedingData.length, 20);
|
|
1625
|
+
await updateJobProgress(tenantId, jobId, 90, `Generating test data for ${cap} cases`);
|
|
1626
|
+
const { generateTestData, refreshTestDataSkillFile: refreshTestDataSkillFile2 } = await import("@qip/agents");
|
|
1627
|
+
for (let i = 0; i < cap; i++) {
|
|
1628
|
+
const c = casesNeedingData[i];
|
|
1629
|
+
await generateTestData({ tenantId, projectId, testCaseId: c.id, jobId }).catch((e) => {
|
|
1630
|
+
logger.warn({ err: e?.message, testCaseId: c.id }, "[import] test data generation failed");
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
refreshTestDataSkillFile2(tenantId, projectId).catch(console.error);
|
|
1634
|
+
}
|
|
1635
|
+
if (created.length > 0) {
|
|
1636
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
1637
|
+
}
|
|
1638
|
+
await updateJobProgress(tenantId, jobId, 98, `Done \u2014 ${created.length} imported`);
|
|
1639
|
+
await withTenant(
|
|
1640
|
+
tenantId,
|
|
1641
|
+
(tx) => tx.platformJob.update({
|
|
1642
|
+
where: { id: jobId },
|
|
1643
|
+
data: {
|
|
1644
|
+
status: "DONE",
|
|
1645
|
+
progress: 100,
|
|
1646
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
1647
|
+
result: {
|
|
1648
|
+
imported: created.length,
|
|
1649
|
+
skipped: skipped ?? 0,
|
|
1650
|
+
labeled,
|
|
1651
|
+
suiteId,
|
|
1652
|
+
artifactId,
|
|
1653
|
+
artifactName: filename
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
})
|
|
1657
|
+
);
|
|
1658
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
|
|
1659
|
+
}
|
|
1660
|
+
async function processGenerateScreenTestsJob(jobId, config) {
|
|
1661
|
+
const { tenantId, projectId, screenId, repoConnectionId, triggerAlignedScripts } = config;
|
|
1662
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
1663
|
+
try {
|
|
1664
|
+
await withTenant(
|
|
1665
|
+
tenantId,
|
|
1666
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
1667
|
+
);
|
|
1668
|
+
const { DualContextOrchestrator } = await import("@qip/agents");
|
|
1669
|
+
await updateJobProgress(tenantId, jobId, 10, "Generating test cases for screen");
|
|
1670
|
+
const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null);
|
|
1671
|
+
const deviceProfiles = crawlCfg?.deviceProfiles ?? ["DESKTOP"];
|
|
1672
|
+
for (const viewport of deviceProfiles) {
|
|
1673
|
+
await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport });
|
|
1674
|
+
}
|
|
1675
|
+
await updateJobProgress(tenantId, jobId, 80, "Test cases generated");
|
|
1676
|
+
if (triggerAlignedScripts && repoConnectionId) {
|
|
1677
|
+
const conn = await withTenant(
|
|
1678
|
+
tenantId,
|
|
1679
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
1680
|
+
);
|
|
1681
|
+
if (conn?.styleProfile) {
|
|
1682
|
+
const { generateScriptAligned, mapWithConcurrency, SCRIPT_GEN_CONCURRENCY } = await import("@qip/agents");
|
|
1683
|
+
const cases = await withTenant(
|
|
1684
|
+
tenantId,
|
|
1685
|
+
(tx) => tx.testCase.findMany({ where: { tenantId, projectId, screenId, status: "APPROVED" }, select: { id: true }, take: 50 })
|
|
1686
|
+
);
|
|
1687
|
+
await mapWithConcurrency(
|
|
1688
|
+
cases,
|
|
1689
|
+
SCRIPT_GEN_CONCURRENCY,
|
|
1690
|
+
(tc) => generateScriptAligned({
|
|
1691
|
+
tenantId,
|
|
1692
|
+
projectId,
|
|
1693
|
+
testCaseId: tc.id,
|
|
1694
|
+
styleProfile: conn.styleProfile,
|
|
1695
|
+
pageObjectInventory: conn.pageObjectInventory ?? void 0
|
|
1696
|
+
}).catch(() => {
|
|
1697
|
+
})
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
await withTenant(
|
|
1702
|
+
tenantId,
|
|
1703
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date() } })
|
|
1704
|
+
);
|
|
1705
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
const msg = err?.message ?? String(err);
|
|
1708
|
+
logger.error({ jobId, screenId, err: msg }, "processGenerateScreenTestsJob failed");
|
|
1709
|
+
await withTenant(
|
|
1710
|
+
tenantId,
|
|
1711
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", error: msg.slice(0, 500), finishedAt: /* @__PURE__ */ new Date() } })
|
|
1712
|
+
).catch(() => {
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
jobDispatchTable = {
|
|
1717
|
+
CRAWL: processJob,
|
|
1718
|
+
DISCOVER: processDiscoverJob,
|
|
1719
|
+
MONITOR: processMonitorJob,
|
|
1720
|
+
NATIVE_CRAWL: processNativeCrawlJob,
|
|
1721
|
+
API_TESTS: processApiTestsJob,
|
|
1722
|
+
SECURITY_TESTS: processSecurityTestsJob,
|
|
1723
|
+
INTEGRATION_TESTS: processIntegrationTestsJob,
|
|
1724
|
+
FIGMA_IMPORT: processFigmaImportJob,
|
|
1725
|
+
GENERATE_SKILL_FILES: processGenerateSkillFilesJob,
|
|
1726
|
+
GENERATE_TESTS_FROM_REQS: processGenerateTestsFromReqsJob,
|
|
1727
|
+
GENERATE_SCREEN_TESTS: processGenerateScreenTestsJob,
|
|
1728
|
+
RUN_TEST_SUITE: processRunTestSuiteJob,
|
|
1729
|
+
REPO_SCAN: processRepoScanJob,
|
|
1730
|
+
IMPORT_TESTS: processImportTestsJob,
|
|
1731
|
+
BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
|
|
1732
|
+
CREATE_PR: processCreatePRJob,
|
|
1733
|
+
NEW_PAGES_PIPELINE: processNewPagesPipelineJob
|
|
1734
|
+
};
|
|
1735
|
+
async function processRepoScanJob(jobId, config) {
|
|
1736
|
+
const { repoConnectionId, tenantId, projectId } = config;
|
|
1737
|
+
const jobStart = /* @__PURE__ */ new Date();
|
|
1738
|
+
try {
|
|
1739
|
+
await updateJobProgress(tenantId, jobId, 5, "Starting repo scan");
|
|
1740
|
+
const {
|
|
1741
|
+
fetchRepoTree,
|
|
1742
|
+
fetchFileContent,
|
|
1743
|
+
detectFramework,
|
|
1744
|
+
extractTestIds,
|
|
1745
|
+
extractStyleProfile,
|
|
1746
|
+
buildPageObjectInventory
|
|
1747
|
+
} = await import("@qip/agents");
|
|
1748
|
+
const conn = await withTenant(
|
|
1749
|
+
tenantId,
|
|
1750
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
1751
|
+
);
|
|
1752
|
+
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
1753
|
+
const { decrypt: decrypt2 } = await import("@qip/core");
|
|
1754
|
+
const token = decrypt2(conn.tokenEnc);
|
|
1755
|
+
await withTenant(
|
|
1756
|
+
tenantId,
|
|
1757
|
+
(tx) => tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: "SCANNING", scanError: null } })
|
|
1758
|
+
);
|
|
1759
|
+
await updateJobProgress(tenantId, jobId, 15, "Fetching repo tree");
|
|
1760
|
+
const treeResult = await fetchRepoTree(token, conn.repoFullName, "HEAD");
|
|
1761
|
+
const frameworks = detectFramework(treeResult.paths);
|
|
1762
|
+
const primary = frameworks[0];
|
|
1763
|
+
await updateJobProgress(tenantId, jobId, 30, `Detected framework: ${primary?.framework ?? "unknown"}`);
|
|
1764
|
+
const storedGlob = conn.testGlob || "";
|
|
1765
|
+
const testGlob = storedGlob && storedGlob !== "**/*.spec.ts" ? storedGlob : primary?.testGlob || storedGlob || "**/*.spec.ts";
|
|
1766
|
+
const framework = primary?.framework ?? "unknown";
|
|
1767
|
+
const language = primary?.language ?? "typescript";
|
|
1768
|
+
const { minimatch } = await import("minimatch").catch(() => ({ minimatch: null }));
|
|
1769
|
+
const isPythonTestFile = (p) => p.endsWith(".py") && !p.endsWith("__init__.py") && !p.endsWith("conftest.py") && (p.endsWith("_test.py") || /\/test_[^/]+\.py$/.test(p) || /^test_[^/]+\.py$/.test(p) || p.includes("/tests/") || p.includes("/test/"));
|
|
1770
|
+
const isJavaTestFile = (p) => p.endsWith(".java") && (/[A-Z]\w*Tests?\.java$/.test(p) || /\/tests?\//.test(p) && /[A-Z][a-z]/.test(p.split("/").pop() ?? ""));
|
|
1771
|
+
const testPaths = treeResult.paths.filter((p) => {
|
|
1772
|
+
if (language === "java") return isJavaTestFile(p);
|
|
1773
|
+
if (language === "python") return isPythonTestFile(p);
|
|
1774
|
+
if (minimatch) return minimatch(p, testGlob, { matchBase: true });
|
|
1775
|
+
return p.endsWith(".spec.ts") || p.endsWith(".spec.js") || p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith("_test.py") || p.includes("/test_");
|
|
1776
|
+
}).slice(0, 500);
|
|
1777
|
+
await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
|
|
1778
|
+
const allExtracted = [];
|
|
1779
|
+
const styleSampleFiles = [];
|
|
1780
|
+
let processed = 0;
|
|
1781
|
+
await withTenant(tenantId, (tx) => tx.repoTestFile.deleteMany({ where: { repoConnectionId } }));
|
|
1782
|
+
for (const filePath of testPaths) {
|
|
1783
|
+
const content = await fetchFileContent(token, conn.repoFullName, filePath);
|
|
1784
|
+
if (!content) continue;
|
|
1785
|
+
const extracted = extractTestIds(content, filePath, framework);
|
|
1786
|
+
allExtracted.push(...extracted);
|
|
1787
|
+
await withTenant(
|
|
1788
|
+
tenantId,
|
|
1789
|
+
(tx) => tx.repoTestFile.create({
|
|
1790
|
+
data: {
|
|
1791
|
+
tenantId,
|
|
1792
|
+
projectId,
|
|
1793
|
+
repoConnectionId,
|
|
1794
|
+
filePath,
|
|
1795
|
+
framework,
|
|
1796
|
+
extractedIds: extracted.map((e) => ({ name: e.name, normalizedName: e.normalizedName, lineNumber: e.lineNumber })),
|
|
1797
|
+
rawSample: content.slice(0, 2e3),
|
|
1798
|
+
lastScannedAt: /* @__PURE__ */ new Date()
|
|
1799
|
+
}
|
|
1800
|
+
})
|
|
1801
|
+
);
|
|
1802
|
+
if (styleSampleFiles.length < 5) styleSampleFiles.push({ path: filePath, content: content.slice(0, 3e3) });
|
|
1803
|
+
processed++;
|
|
1804
|
+
if (processed % 10 === 0) {
|
|
1805
|
+
await updateJobProgress(tenantId, jobId, 40 + Math.floor(processed / testPaths.length * 40), `Processed ${processed}/${testPaths.length} files`);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
let styleProfile = conn.styleProfile ?? null;
|
|
1809
|
+
if (styleSampleFiles.length > 0) {
|
|
1810
|
+
try {
|
|
1811
|
+
const mainSourcePaths = language === "java" ? treeResult.paths.filter((p) => /^src\/main\/java\//.test(p)).slice(0, 50) : [];
|
|
1812
|
+
const testPathSet = new Set(testPaths);
|
|
1813
|
+
const sourceFilePaths = language === "typescript" || language === "javascript" ? treeResult.paths.filter((p) => (p.endsWith(".ts") || p.endsWith(".js")) && !testPathSet.has(p) && !p.endsWith(".d.ts") && !p.includes("node_modules/") && !p.includes("dist/") && !p.includes(".config.")).slice(0, 60) : [];
|
|
1814
|
+
const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
|
|
1815
|
+
const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile);
|
|
1816
|
+
if (freshProfile) styleProfile = freshProfile;
|
|
1817
|
+
} catch {
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
let pageObjectInventory = conn.pageObjectInventory ?? [];
|
|
1821
|
+
try {
|
|
1822
|
+
const freshInventory = await buildPageObjectInventory(token, conn.repoFullName, treeResult.paths, framework);
|
|
1823
|
+
if (freshInventory.length > 0) {
|
|
1824
|
+
pageObjectInventory = freshInventory;
|
|
1825
|
+
await updateJobProgress(tenantId, jobId, 82, `Found ${pageObjectInventory.length} page objects`);
|
|
1826
|
+
}
|
|
1827
|
+
} catch {
|
|
1828
|
+
}
|
|
1829
|
+
await updateJobProgress(tenantId, jobId, 85, "Running coverage mapping");
|
|
1830
|
+
const { buildCoverageMap } = await import("@qip/agents");
|
|
1831
|
+
const testCases = await withTenant(
|
|
1832
|
+
tenantId,
|
|
1833
|
+
(tx) => tx.testCase.findMany({ where: { projectId, tenantId, status: { not: "RETIRED" } }, select: { id: true, title: true, ref: true } })
|
|
1834
|
+
);
|
|
1835
|
+
const mappings = await buildCoverageMap(testCases, allExtracted, tenantId);
|
|
1836
|
+
for (const m of mappings) {
|
|
1837
|
+
const existing = await withTenant(
|
|
1838
|
+
tenantId,
|
|
1839
|
+
(tx) => tx.coverageMapping.findUnique({ where: { testCaseId: m.testCaseId } })
|
|
1840
|
+
);
|
|
1841
|
+
if (existing?.manualOverride) continue;
|
|
1842
|
+
await withTenant(
|
|
1843
|
+
tenantId,
|
|
1844
|
+
(tx) => tx.coverageMapping.upsert({
|
|
1845
|
+
where: { testCaseId: m.testCaseId },
|
|
1846
|
+
create: { tenantId, projectId, testCaseId: m.testCaseId, repoConnectionId, status: m.status, matchedFilePath: m.matchedFilePath ?? null, scriptTestName: m.scriptTestName ?? null, lineNumber: m.lineNumber ?? null, confidence: m.confidence, matchMethod: m.matchMethod },
|
|
1847
|
+
update: { status: m.status, matchedFilePath: m.matchedFilePath ?? null, scriptTestName: m.scriptTestName ?? null, lineNumber: m.lineNumber ?? null, confidence: m.confidence, matchMethod: m.matchMethod }
|
|
1848
|
+
})
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
const mapped = mappings.filter((m) => m.status === "MAPPED").length;
|
|
1852
|
+
const unmapped = mappings.filter((m) => m.status === "UNMAPPED").length;
|
|
1853
|
+
const ambiguous = mappings.filter((m) => m.status === "AMBIGUOUS").length;
|
|
1854
|
+
const coveragePercent = testCases.length > 0 ? mapped / testCases.length * 100 : 0;
|
|
1855
|
+
await withTenant(
|
|
1856
|
+
tenantId,
|
|
1857
|
+
(tx) => tx.coverageSnapshot.create({
|
|
1858
|
+
data: { tenantId, projectId, totalCases: testCases.length, mappedCases: mapped, unmappedCases: unmapped, ambiguousCases: ambiguous, coveragePercent }
|
|
1859
|
+
})
|
|
1860
|
+
);
|
|
1861
|
+
const resolvedStyleProfile = (() => {
|
|
1862
|
+
const sp = styleProfile ?? {};
|
|
1863
|
+
if (framework === "selenium-java" && !sp.testStructurePattern) {
|
|
1864
|
+
const hasMavenMain = treeResult.paths.some((p) => /^src\/main\/java\//.test(p));
|
|
1865
|
+
if (hasMavenMain) return { ...sp, testStructurePattern: "two-tier" };
|
|
1866
|
+
}
|
|
1867
|
+
return sp;
|
|
1868
|
+
})();
|
|
1869
|
+
await withTenant(
|
|
1870
|
+
tenantId,
|
|
1871
|
+
(tx) => tx.gitHubRepoConnection.update({
|
|
1872
|
+
where: { id: repoConnectionId },
|
|
1873
|
+
data: {
|
|
1874
|
+
scanStatus: "DONE",
|
|
1875
|
+
detectedFramework: framework,
|
|
1876
|
+
configFilePath: primary?.configPath ?? null,
|
|
1877
|
+
testGlob,
|
|
1878
|
+
testFileCount: testPaths.length,
|
|
1879
|
+
extractedIdCount: allExtracted.length,
|
|
1880
|
+
styleProfile: resolvedStyleProfile,
|
|
1881
|
+
pageObjectInventory: pageObjectInventory.length > 0 ? pageObjectInventory : void 0,
|
|
1882
|
+
treeSnapshot: JSON.stringify(treeResult.paths).slice(0, 1e5),
|
|
1883
|
+
lastScannedAt: /* @__PURE__ */ new Date(),
|
|
1884
|
+
lastSyncAt: /* @__PURE__ */ new Date()
|
|
1885
|
+
}
|
|
1886
|
+
})
|
|
1887
|
+
);
|
|
1888
|
+
try {
|
|
1889
|
+
const { generateRepoSkillFiles } = await import("@qip/agents");
|
|
1890
|
+
await generateRepoSkillFiles(tenantId, projectId, {
|
|
1891
|
+
repoFullName: conn.repoFullName,
|
|
1892
|
+
framework,
|
|
1893
|
+
language,
|
|
1894
|
+
testGlob,
|
|
1895
|
+
styleProfile: resolvedStyleProfile,
|
|
1896
|
+
pageObjectInventory,
|
|
1897
|
+
testPaths,
|
|
1898
|
+
extractedTestCount: allExtracted.length
|
|
1899
|
+
});
|
|
1900
|
+
} catch (e) {
|
|
1901
|
+
logger.warn({ jobId, repoConnectionId, err: e?.message }, "[repo-scan] repo skill files generation failed (non-fatal)");
|
|
1902
|
+
}
|
|
1903
|
+
await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
|
|
1904
|
+
await withTenant(
|
|
1905
|
+
tenantId,
|
|
1906
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { testFileCount: testPaths.length, extractedIdCount: allExtracted.length, mapped, unmapped, ambiguous, coveragePercent } } })
|
|
1907
|
+
);
|
|
1908
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
const msg = String(err?.message ?? err);
|
|
1911
|
+
logger.error({ jobId, repoConnectionId, err: msg }, "[repo-scan] failed");
|
|
1912
|
+
await withTenant(
|
|
1913
|
+
tenantId,
|
|
1914
|
+
(tx) => tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: "FAILED", scanError: msg.slice(0, 500) } })
|
|
1915
|
+
).catch(() => {
|
|
1916
|
+
});
|
|
1917
|
+
await withTenant(
|
|
1918
|
+
tenantId,
|
|
1919
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
1920
|
+
).catch(() => {
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
async function processBatchGenerateAlignedJob(jobId, config) {
|
|
1925
|
+
const { tenantId, projectId, repoConnectionId, scope = "UNMAPPED", testCaseIds, force = false } = config;
|
|
1926
|
+
try {
|
|
1927
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading repo connection");
|
|
1928
|
+
const conn = await withTenant(
|
|
1929
|
+
tenantId,
|
|
1930
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
1931
|
+
);
|
|
1932
|
+
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
1933
|
+
const { decrypt: decrypt2 } = await import("@qip/core");
|
|
1934
|
+
const {
|
|
1935
|
+
generateScriptAligned,
|
|
1936
|
+
deriveRepoTargetPath,
|
|
1937
|
+
validateGeneratedScript,
|
|
1938
|
+
mapWithConcurrency,
|
|
1939
|
+
SCRIPT_GEN_CONCURRENCY
|
|
1940
|
+
} = await import("@qip/agents");
|
|
1941
|
+
const styleProfile = conn.styleProfile;
|
|
1942
|
+
const pageObjectInventory = conn.pageObjectInventory ?? [];
|
|
1943
|
+
const framework = conn.detectedFramework ?? "playwright-ts";
|
|
1944
|
+
const language = styleProfile?.language ?? "typescript";
|
|
1945
|
+
let caseIds;
|
|
1946
|
+
if (testCaseIds && testCaseIds.length > 0) {
|
|
1947
|
+
caseIds = testCaseIds;
|
|
1948
|
+
} else if (scope === "UNMAPPED") {
|
|
1949
|
+
const mappings = await withTenant(
|
|
1950
|
+
tenantId,
|
|
1951
|
+
(tx) => tx.coverageMapping.findMany({
|
|
1952
|
+
where: { tenantId, projectId, repoConnectionId, status: "UNMAPPED" },
|
|
1953
|
+
select: { testCaseId: true }
|
|
1954
|
+
})
|
|
1955
|
+
);
|
|
1956
|
+
caseIds = mappings.map((m) => m.testCaseId);
|
|
1957
|
+
} else {
|
|
1958
|
+
const cases = await withTenant(
|
|
1959
|
+
tenantId,
|
|
1960
|
+
(tx) => tx.testCase.findMany({
|
|
1961
|
+
where: { tenantId, projectId, status: { not: "RETIRED" } },
|
|
1962
|
+
select: { id: true }
|
|
1963
|
+
})
|
|
1964
|
+
);
|
|
1965
|
+
caseIds = cases.map((c) => c.id);
|
|
1966
|
+
}
|
|
1967
|
+
await updateJobProgress(tenantId, jobId, 15, `Generating scripts for ${caseIds.length} test cases`);
|
|
1968
|
+
let done = 0;
|
|
1969
|
+
const errors = [];
|
|
1970
|
+
await mapWithConcurrency(caseIds, SCRIPT_GEN_CONCURRENCY, async (tcId) => {
|
|
1971
|
+
try {
|
|
1972
|
+
if (!force) {
|
|
1973
|
+
const existing = await withTenant(
|
|
1974
|
+
tenantId,
|
|
1975
|
+
(tx) => tx.testScript.findFirst({ where: { testCaseId: tcId } })
|
|
1976
|
+
);
|
|
1977
|
+
if (existing) {
|
|
1978
|
+
done++;
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const tc = await withTenant(
|
|
1983
|
+
tenantId,
|
|
1984
|
+
(tx) => tx.testCase.findUnique({
|
|
1985
|
+
where: { id: tcId },
|
|
1986
|
+
select: { id: true, ref: true, title: true, screen: { select: { url: true } } }
|
|
1987
|
+
})
|
|
1988
|
+
);
|
|
1989
|
+
const screenUrl = tc?.screen?.url ?? "";
|
|
1990
|
+
const repoTargetPath = styleProfile ? deriveRepoTargetPath(screenUrl, styleProfile, framework) : deriveFallbackTargetPath(tc?.ref ?? tc?.title ?? tcId, framework);
|
|
1991
|
+
const result = await generateScriptAligned({
|
|
1992
|
+
tenantId,
|
|
1993
|
+
projectId,
|
|
1994
|
+
testCaseId: tcId,
|
|
1995
|
+
styleProfile,
|
|
1996
|
+
pageObjectInventory,
|
|
1997
|
+
repoTargetPath,
|
|
1998
|
+
save: true
|
|
1999
|
+
});
|
|
2000
|
+
if (result.code) {
|
|
2001
|
+
const validation = await validateGeneratedScript(result.code, framework, language);
|
|
2002
|
+
await withTenant(
|
|
2003
|
+
tenantId,
|
|
2004
|
+
(tx) => tx.testScript.updateMany({
|
|
2005
|
+
where: { testCaseId: tcId },
|
|
2006
|
+
data: {
|
|
2007
|
+
validationStatus: validation.ok ? "PASS" : "FAIL",
|
|
2008
|
+
validationErrors: validation.ok ? null : validation.errors.slice(0, 2e3)
|
|
2009
|
+
}
|
|
2010
|
+
})
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
done++;
|
|
2014
|
+
} catch (e) {
|
|
2015
|
+
errors.push(`${tcId}: ${String(e.message).slice(0, 100)}`);
|
|
2016
|
+
}
|
|
2017
|
+
if (done % 5 === 0) {
|
|
2018
|
+
await updateJobProgress(tenantId, jobId, 15 + Math.floor(done / caseIds.length * 80), `Generated ${done}/${caseIds.length}`);
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
refreshTestScriptSkillFile(tenantId, projectId).catch(console.error);
|
|
2022
|
+
await withTenant(
|
|
2023
|
+
tenantId,
|
|
2024
|
+
(tx) => tx.platformJob.update({
|
|
2025
|
+
where: { id: jobId },
|
|
2026
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { done, errors: errors.slice(0, 20), total: caseIds.length } }
|
|
2027
|
+
})
|
|
2028
|
+
);
|
|
2029
|
+
} catch (err) {
|
|
2030
|
+
const msg = String(err?.message ?? err);
|
|
2031
|
+
logger.error({ jobId, err: msg }, "[batch-generate-aligned] failed");
|
|
2032
|
+
await withTenant(
|
|
2033
|
+
tenantId,
|
|
2034
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
2035
|
+
).catch(() => {
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
async function processCreatePRJob(jobId, config) {
|
|
2040
|
+
const { tenantId, projectId, repoConnectionId, testScriptIds, crawlJobId } = config;
|
|
2041
|
+
try {
|
|
2042
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading connection");
|
|
2043
|
+
const conn = await withTenant(
|
|
2044
|
+
tenantId,
|
|
2045
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
2046
|
+
);
|
|
2047
|
+
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
2048
|
+
const { decrypt: decrypt2 } = await import("@qip/core");
|
|
2049
|
+
const token = decrypt2(conn.tokenEnc);
|
|
2050
|
+
const { pushScriptsAsPR } = await import("@qip/agents");
|
|
2051
|
+
const framework = conn.detectedFramework ?? "playwright-ts";
|
|
2052
|
+
let scripts;
|
|
2053
|
+
if (testScriptIds && testScriptIds.length > 0) {
|
|
2054
|
+
scripts = await withTenant(
|
|
2055
|
+
tenantId,
|
|
2056
|
+
(tx) => tx.testScript.findMany({
|
|
2057
|
+
where: { id: { in: testScriptIds }, tenantId },
|
|
2058
|
+
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
|
|
2059
|
+
})
|
|
2060
|
+
);
|
|
2061
|
+
} else {
|
|
2062
|
+
scripts = await withTenant(
|
|
2063
|
+
tenantId,
|
|
2064
|
+
(tx) => tx.testScript.findMany({
|
|
2065
|
+
where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ["PASS", "PENDING"] } },
|
|
2066
|
+
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
|
|
2067
|
+
})
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
if (scripts.length === 0) throw new Error("No generated scripts found to push.");
|
|
2071
|
+
const files = scripts.map((s) => {
|
|
2072
|
+
const path2 = s.testCase?.repoTargetPath ?? deriveFallbackTargetPath(s.testCase?.ref ?? s.testCase?.title ?? s.id, framework);
|
|
2073
|
+
return { path: path2, content: s.code };
|
|
2074
|
+
});
|
|
2075
|
+
if (files.length === 0) throw new Error("No scripts to push.");
|
|
2076
|
+
await updateJobProgress(tenantId, jobId, 20, `Pushing ${files.length} scripts to GitHub`);
|
|
2077
|
+
const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2078
|
+
const branchName = `zeta/new-coverage-${now}-${jobId.slice(0, 8)}`;
|
|
2079
|
+
const prBody = buildPRBody(scripts, projectId);
|
|
2080
|
+
const prResult = await pushScriptsAsPR({
|
|
2081
|
+
token,
|
|
2082
|
+
repo: conn.repoFullName,
|
|
2083
|
+
baseBranch: conn.defaultBranch,
|
|
2084
|
+
branchName,
|
|
2085
|
+
files,
|
|
2086
|
+
prTitle: `ZeTa: Add test coverage for ${files.length} new scenario${files.length === 1 ? "" : "s"}`,
|
|
2087
|
+
prBody,
|
|
2088
|
+
draft: false
|
|
2089
|
+
});
|
|
2090
|
+
const pr = await withTenant(
|
|
2091
|
+
tenantId,
|
|
2092
|
+
(tx) => tx.gitHubPR.create({
|
|
2093
|
+
data: {
|
|
2094
|
+
tenantId,
|
|
2095
|
+
projectId,
|
|
2096
|
+
repoConnectionId,
|
|
2097
|
+
prNumber: prResult.prNumber,
|
|
2098
|
+
prUrl: prResult.prUrl,
|
|
2099
|
+
branch: prResult.branch,
|
|
2100
|
+
headSha: prResult.headSha,
|
|
2101
|
+
status: "OPEN",
|
|
2102
|
+
crawlJobId: crawlJobId ?? null,
|
|
2103
|
+
scriptCount: files.length,
|
|
2104
|
+
prBody: prBody.slice(0, 1e4)
|
|
2105
|
+
}
|
|
2106
|
+
})
|
|
2107
|
+
);
|
|
2108
|
+
const scriptIds = scripts.map((s) => s.id);
|
|
2109
|
+
await withTenant(
|
|
2110
|
+
tenantId,
|
|
2111
|
+
(tx) => tx.testScript.updateMany({
|
|
2112
|
+
where: { id: { in: scriptIds } },
|
|
2113
|
+
data: { pushedToPR: true, pushedPRId: pr.id }
|
|
2114
|
+
})
|
|
2115
|
+
);
|
|
2116
|
+
await notifyAdmins(tenantId, {
|
|
2117
|
+
type: "GITHUB_PR_CREATED",
|
|
2118
|
+
title: "ZeTa created a GitHub PR",
|
|
2119
|
+
body: `${files.length} new test script${files.length === 1 ? "" : "s"} pushed. PR #${prResult.prNumber}: ${prResult.prUrl}`,
|
|
2120
|
+
metadata: { prUrl: prResult.prUrl, prNumber: prResult.prNumber, scriptCount: files.length }
|
|
2121
|
+
});
|
|
2122
|
+
await updateJobProgress(tenantId, jobId, 100, `PR #${prResult.prNumber} created`);
|
|
2123
|
+
await withTenant(
|
|
2124
|
+
tenantId,
|
|
2125
|
+
(tx) => tx.platformJob.update({
|
|
2126
|
+
where: { id: jobId },
|
|
2127
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { prUrl: prResult.prUrl, prNumber: prResult.prNumber, scriptCount: files.length } }
|
|
2128
|
+
})
|
|
2129
|
+
);
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
const msg = String(err?.message ?? err);
|
|
2132
|
+
logger.error({ jobId, err: msg }, "[create-pr] failed");
|
|
2133
|
+
await withTenant(
|
|
2134
|
+
tenantId,
|
|
2135
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
2136
|
+
).catch(() => {
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
function buildPRBody(scripts, projectId) {
|
|
2141
|
+
const lines = [
|
|
2142
|
+
"## ZeTa \u2014 Automated Test Coverage",
|
|
2143
|
+
"",
|
|
2144
|
+
`Generated by ZeTa Quality Intelligence Platform for project \`${projectId}\`.`,
|
|
2145
|
+
"",
|
|
2146
|
+
"### Scripts Added",
|
|
2147
|
+
"",
|
|
2148
|
+
"| Test Case | Target Path |",
|
|
2149
|
+
"|---|---|"
|
|
2150
|
+
];
|
|
2151
|
+
for (const s of scripts.slice(0, 30)) {
|
|
2152
|
+
const title = s.testCase?.title ?? s.id;
|
|
2153
|
+
const path2 = s.testCase?.repoTargetPath ?? "(unknown)";
|
|
2154
|
+
lines.push(`| ${title.replace(/\|/g, "\\|").slice(0, 60)} | \`${path2}\` |`);
|
|
2155
|
+
}
|
|
2156
|
+
if (scripts.length > 30) lines.push(`| ... and ${scripts.length - 30} more | |`);
|
|
2157
|
+
lines.push("", "---", "_Review each test carefully before merging. ZeTa generates scripts aligned to your existing framework conventions._");
|
|
2158
|
+
return lines.join("\n");
|
|
2159
|
+
}
|
|
2160
|
+
async function processNewPagesPipelineJob(jobId, config) {
|
|
2161
|
+
const { tenantId, projectId, crawlJobId, repoConnectionId, autoPR = false } = config;
|
|
2162
|
+
try {
|
|
2163
|
+
await updateJobProgress(tenantId, jobId, 5, "Detecting new screens");
|
|
2164
|
+
const conn = await withTenant(
|
|
2165
|
+
tenantId,
|
|
2166
|
+
(tx) => tx.gitHubRepoConnection.findFirst({
|
|
2167
|
+
where: repoConnectionId ? { id: repoConnectionId, tenantId } : { tenantId, projectId, status: "ACTIVE" }
|
|
2168
|
+
})
|
|
2169
|
+
);
|
|
2170
|
+
const sinceDate = conn?.lastSyncAt ?? /* @__PURE__ */ new Date(0);
|
|
2171
|
+
const newScreens = await withTenant(
|
|
2172
|
+
tenantId,
|
|
2173
|
+
(tx) => tx.screen.findMany({
|
|
2174
|
+
where: {
|
|
2175
|
+
tenantId,
|
|
2176
|
+
projectId,
|
|
2177
|
+
lastSeenAt: { gt: sinceDate },
|
|
2178
|
+
status: { not: "DELETED" }
|
|
2179
|
+
},
|
|
2180
|
+
select: { id: true, url: true, name: true },
|
|
2181
|
+
take: 100
|
|
2182
|
+
})
|
|
2183
|
+
);
|
|
2184
|
+
if (newScreens.length === 0) {
|
|
2185
|
+
await updateJobProgress(tenantId, jobId, 100, "No new screens to process");
|
|
2186
|
+
await withTenant(
|
|
2187
|
+
tenantId,
|
|
2188
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { newScreens: 0 } } })
|
|
2189
|
+
);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
await updateJobProgress(tenantId, jobId, 15, `Found ${newScreens.length} new screens`);
|
|
2193
|
+
const generationJobs = [];
|
|
2194
|
+
for (const screen of newScreens) {
|
|
2195
|
+
const caseCount = await withTenant(
|
|
2196
|
+
tenantId,
|
|
2197
|
+
(tx) => tx.testCase.count({ where: { tenantId, screenId: screen.id } })
|
|
2198
|
+
);
|
|
2199
|
+
if (caseCount === 0) {
|
|
2200
|
+
const jid = await enqueueJob("GENERATE_SCREEN_TESTS", {
|
|
2201
|
+
tenantId,
|
|
2202
|
+
projectId,
|
|
2203
|
+
screenId: screen.id,
|
|
2204
|
+
triggerAlignedScripts: conn != null,
|
|
2205
|
+
repoConnectionId: conn?.id ?? null
|
|
2206
|
+
});
|
|
2207
|
+
generationJobs.push(jid);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
await updateJobProgress(tenantId, jobId, 60, `Queued ${generationJobs.length} test generation jobs`);
|
|
2211
|
+
if (conn && autoPR) {
|
|
2212
|
+
await enqueueJob("BATCH_GENERATE_ALIGNED", {
|
|
2213
|
+
tenantId,
|
|
2214
|
+
projectId,
|
|
2215
|
+
repoConnectionId: conn.id,
|
|
2216
|
+
scope: "UNMAPPED"
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
await withTenant(
|
|
2220
|
+
tenantId,
|
|
2221
|
+
(tx) => tx.platformJob.update({
|
|
2222
|
+
where: { id: jobId },
|
|
2223
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { newScreens: newScreens.length, generationJobs: generationJobs.length } }
|
|
2224
|
+
})
|
|
2225
|
+
);
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
const msg = String(err?.message ?? err);
|
|
2228
|
+
logger.error({ jobId, err: msg }, "[new-pages-pipeline] failed");
|
|
2229
|
+
await withTenant(
|
|
2230
|
+
tenantId,
|
|
2231
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "FAILED", finishedAt: /* @__PURE__ */ new Date(), error: msg.slice(0, 1e3) } })
|
|
2232
|
+
).catch(() => {
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
export {
|
|
2237
|
+
aggregateJobTokens,
|
|
2238
|
+
cancelJob,
|
|
2239
|
+
claimNextQueuedJob,
|
|
2240
|
+
claimSpecificJob,
|
|
2241
|
+
createTrackedJob,
|
|
2242
|
+
enqueueJob,
|
|
2243
|
+
finishTrackedJob,
|
|
2244
|
+
generateTOTP,
|
|
2245
|
+
getJob,
|
|
2246
|
+
listJobs,
|
|
2247
|
+
processDataRetentionPurge,
|
|
2248
|
+
processJob,
|
|
2249
|
+
requeueStaleRunningJobs,
|
|
2250
|
+
rerunJob,
|
|
2251
|
+
resumeJob,
|
|
2252
|
+
startJobWorkerLoop,
|
|
2253
|
+
stopJobWorkerLoop,
|
|
2254
|
+
updateJobProgress
|
|
2255
|
+
};
|
|
2256
|
+
//# sourceMappingURL=job-queue.js.map
|