@taodigital/crawler 0.0.0-stage → 1.2.0
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 +82 -0
- package/README.md +10 -2
- 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/accessibility-tree-crawler.d.ts +44 -0
- package/dist/accessibility-tree-crawler.js +156 -0
- package/dist/accessibility-tree-crawler.js.map +7 -0
- package/dist/advanced-ui-probes.d.ts +47 -0
- package/dist/advanced-ui-probes.js +310 -0
- package/dist/advanced-ui-probes.js.map +7 -0
- package/dist/ai-login.d.ts +32 -0
- package/dist/ai-login.js +250 -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 +348 -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/crawl-session-manager.d.ts +44 -0
- package/dist/crawl-session-manager.js +145 -0
- package/dist/crawl-session-manager.js.map +7 -0
- package/dist/crawler.d.ts +233 -0
- package/dist/crawler.js +6239 -0
- package/dist/crawler.js.map +7 -0
- package/dist/crud-interaction.d.ts +31 -0
- package/dist/crud-interaction.js +264 -0
- package/dist/crud-interaction.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/dropdown-enumerator.d.ts +20 -0
- package/dist/dropdown-enumerator.js +230 -0
- package/dist/dropdown-enumerator.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 +64 -0
- package/dist/extractors/advanced.js +265 -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/extractors/branding-extractor.d.ts +46 -0
- package/dist/extractors/branding-extractor.js +104 -0
- package/dist/extractors/branding-extractor.js.map +7 -0
- package/dist/extractors/summary-extractor.d.ts +10 -0
- package/dist/extractors/summary-extractor.js +36 -0
- package/dist/extractors/summary-extractor.js.map +7 -0
- package/dist/failure-analysis-agent.d.ts +16 -0
- package/dist/failure-analysis-agent.js +42 -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/filter-pagination-crawler.d.ts +52 -0
- package/dist/filter-pagination-crawler.js +299 -0
- package/dist/filter-pagination-crawler.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 +40 -0
- package/dist/frontier.js +103 -0
- package/dist/frontier.js.map +7 -0
- package/dist/gap-pass.d.ts +36 -0
- package/dist/gap-pass.js +115 -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 +318 -0
- package/dist/http-engine.js.map +7 -0
- package/dist/incremental-diff.d.ts +116 -0
- package/dist/incremental-diff.js +137 -0
- package/dist/incremental-diff.js.map +7 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +83 -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 +144 -0
- package/dist/interactive-probe.js +865 -0
- package/dist/interactive-probe.js.map +7 -0
- package/dist/job-queue.d.ts +297 -0
- package/dist/job-queue.js +3812 -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 +105 -0
- package/dist/live-view.js.map +7 -0
- package/dist/locator-extractor.d.ts +46 -0
- package/dist/locator-extractor.js +220 -0
- package/dist/locator-extractor.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/multi-context-handler.d.ts +55 -0
- package/dist/multi-context-handler.js +182 -0
- package/dist/multi-context-handler.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/nested-window-crawler.d.ts +41 -0
- package/dist/nested-window-crawler.js +172 -0
- package/dist/nested-window-crawler.js.map +7 -0
- package/dist/network-action-correlator.d.ts +47 -0
- package/dist/network-action-correlator.js +209 -0
- package/dist/network-action-correlator.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-collector.d.ts +50 -0
- package/dist/performance-metrics-collector.js +155 -0
- package/dist/performance-metrics-collector.js.map +7 -0
- package/dist/performance-metrics.d.ts +52 -0
- package/dist/performance-metrics.js +83 -0
- package/dist/performance-metrics.js.map +7 -0
- package/dist/phash.d.ts +50 -0
- package/dist/phash.js +136 -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 +41 -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 +160 -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 +15 -0
- package/dist/screen-reader.js +48 -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/service-worker-interceptor.d.ts +36 -0
- package/dist/service-worker-interceptor.js +136 -0
- package/dist/service-worker-interceptor.js.map +7 -0
- package/dist/session-capture.d.ts +39 -0
- package/dist/session-capture.js +274 -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/shadow-dom-crawler.d.ts +35 -0
- package/dist/shadow-dom-crawler.js +132 -0
- package/dist/shadow-dom-crawler.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 +160 -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 +13 -0
- package/dist/sqs-queue.js +115 -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/state-graph.d.ts +59 -0
- package/dist/state-graph.js +193 -0
- package/dist/state-graph.js.map +7 -0
- package/dist/stealth/human-behavior.d.ts +20 -0
- package/dist/stealth/human-behavior.js +202 -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 +81 -0
- package/dist/test-executor.js +443 -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 +122 -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 +27 -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 +49 -4
- package/src/accessibility-auditor.ts +102 -0
- package/src/accessibility-tree-crawler.ts +246 -0
- package/src/advanced-ui-probes.ts +470 -0
- package/src/ai-login.ts +298 -0
- package/src/auth-token-rotator.ts +89 -0
- package/src/auth.ts +418 -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/crawl-session-manager.ts +231 -0
- package/src/crawler.ts +7123 -0
- package/src/crawler.ts.rej +12 -0
- package/src/crud-interaction.ts +386 -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/dropdown-enumerator.ts +312 -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 +309 -0
- package/src/extractors/artifact-envelope.ts +111 -0
- package/src/extractors/branding-extractor.ts +159 -0
- package/src/extractors/summary-extractor.ts +49 -0
- package/src/failure-analysis-agent.ts +51 -0
- package/src/feature-flag-detector.ts +97 -0
- package/src/filter-pagination-crawler.ts +500 -0
- package/src/form-detector.ts +103 -0
- package/src/frontier.test.ts +64 -0
- package/src/frontier.ts +144 -0
- package/src/gap-pass.ts +165 -0
- package/src/graphql-detector.ts +129 -0
- package/src/http-engine.ts +453 -0
- package/src/incremental-diff.ts +259 -0
- package/src/index.ts +52 -0
- package/src/input-fuzzer.ts +129 -0
- package/src/interactive-probe.ts +1136 -0
- package/src/job-queue.test.ts +187 -0
- package/src/job-queue.ts +4504 -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 +146 -0
- package/src/locator-extractor.ts +309 -0
- package/src/logger.ts +9 -0
- package/src/markdown-extract.test.ts +73 -0
- package/src/markdown-extract.ts +76 -0
- package/src/multi-context-handler.ts +296 -0
- package/src/native-crawler.ts +1108 -0
- package/src/nested-window-crawler.ts +310 -0
- package/src/network-action-correlator.ts +320 -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-collector.ts +243 -0
- package/src/performance-metrics.ts +134 -0
- package/src/phash.ts +184 -0
- package/src/pii-scrubber.ts +99 -0
- package/src/playwright-recorder.ts +170 -0
- package/src/policies/crawl-safety.ts +52 -0
- package/src/profiles/domain-profile.ts +190 -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 +98 -0
- package/src/screenshot-masker.ts +98 -0
- package/src/seo-checker.ts +134 -0
- package/src/service-worker-interceptor.ts +185 -0
- package/src/session-capture.ts +335 -0
- package/src/session-export.test.ts +75 -0
- package/src/session-export.ts +172 -0
- package/src/shadow-dom-crawler.ts +198 -0
- package/src/sitemap-writer.ts +65 -0
- package/src/sitemap.test.ts +127 -0
- package/src/sitemap.ts +233 -0
- package/src/skyvern.ts +199 -0
- package/src/sqs-queue.ts +129 -0
- package/src/sso-token.ts +120 -0
- package/src/state-graph.ts +280 -0
- package/src/stealth/human-behavior.ts +227 -0
- package/src/telemetry.ts +122 -0
- package/src/test-executor.test.ts +72 -0
- package/src/test-executor.ts +578 -0
- package/src/tls-fingerprint.ts +111 -0
- package/src/url-pattern-completer.ts +161 -0
- package/src/vendor.d.ts +20 -0
- package/src/visual-diff-verdict.test.ts +80 -0
- package/src/visual-diff-verdict.ts +59 -0
- package/src/webhook-notifier.ts +109 -0
- package/src/websocket-detector.ts +83 -0
- package/src/worker.ts +50 -0
- package/tsconfig.json +13 -0
- package/types/accessibility-auditor.d.ts +29 -0
- package/types/accessibility-tree-crawler.d.ts +44 -0
- package/types/advanced-ui-probes.d.ts +47 -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/crawl-session-manager.d.ts +44 -0
- package/types/crawler.d.ts +233 -0
- package/types/crud-interaction.d.ts +31 -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/dropdown-enumerator.d.ts +20 -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 +64 -0
- package/types/extractors/artifact-envelope.d.ts +63 -0
- package/types/extractors/branding-extractor.d.ts +46 -0
- package/types/extractors/summary-extractor.d.ts +10 -0
- package/types/failure-analysis-agent.d.ts +16 -0
- package/types/feature-flag-detector.d.ts +30 -0
- package/types/filter-pagination-crawler.d.ts +52 -0
- package/types/form-detector.d.ts +34 -0
- package/types/frontier.d.ts +40 -0
- package/types/gap-pass.d.ts +36 -0
- package/types/graphql-detector.d.ts +39 -0
- package/types/http-engine.d.ts +80 -0
- package/types/incremental-diff.d.ts +116 -0
- package/types/index.d.ts +52 -0
- package/types/input-fuzzer.d.ts +30 -0
- package/types/interactive-probe.d.ts +144 -0
- package/types/job-queue.d.ts +308 -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/locator-extractor.d.ts +46 -0
- package/types/logger.d.ts +2 -0
- package/types/markdown-extract.d.ts +8 -0
- package/types/multi-context-handler.d.ts +55 -0
- package/types/native-crawler.d.ts +69 -0
- package/types/nested-window-crawler.d.ts +41 -0
- package/types/network-action-correlator.d.ts +47 -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-collector.d.ts +50 -0
- package/types/performance-metrics.d.ts +52 -0
- package/types/phash.d.ts +50 -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 +15 -0
- package/types/screenshot-masker.d.ts +28 -0
- package/types/seo-checker.d.ts +33 -0
- package/types/service-worker-interceptor.d.ts +36 -0
- package/types/session-capture.d.ts +39 -0
- package/types/session-export.d.ts +49 -0
- package/types/shadow-dom-crawler.d.ts +35 -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 +13 -0
- package/types/sso-token.d.ts +29 -0
- package/types/state-graph.d.ts +59 -0
- package/types/stealth/human-behavior.d.ts +20 -0
- package/types/telemetry.d.ts +34 -0
- package/types/test-executor.d.ts +84 -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,3812 @@
|
|
|
1
|
+
import { withTenant, prismaAdmin } from "@taodigital/database";
|
|
2
|
+
import { crawlJobsTotal, crawlJobDurationSeconds } from "@taodigital/core";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
import { AppBrain, buildArtifactKey, getTenantStorageConfig, uploadScreenshotToTenantStorage, uploadFileToTenantStorage, notifyRunCompletion, recordLLMCall, downloadBufferFromTenantStorage, dispatchToTenantAdmins } from "@taodigital/app-brain";
|
|
5
|
+
async function notifyAdmins(tenantId, data) {
|
|
6
|
+
try {
|
|
7
|
+
await dispatchToTenantAdmins(tenantId, data);
|
|
8
|
+
} catch (e) {
|
|
9
|
+
console.error("[notify] failed:", e);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
import { generateTaxonomySkillFiles, groupScreensWithAI, generateExecutionPlan, refreshTestCasesSkillFile, refreshTestDataSkillFile, refreshTestScriptSkillFile, refreshRequirementsCoverageSkillFile, refreshTestExecutionHistorySkillFile, refreshVisualRegressionHistorySkillFile, refreshLocatorStrategyWithHealingData, refreshFieldCatalogSkillFile, detectFieldsFromHtml, mergeIntoCatalog, generateArtifactIntelligence } from "@taodigital/agents";
|
|
13
|
+
import { crawlProject, domFallbackElements, getNonHeadlessUserAgent } from "./crawler.js";
|
|
14
|
+
import { httpCrawl } from "./http-engine.js";
|
|
15
|
+
import { hashContent, normalizeText } from "./incremental-diff.js";
|
|
16
|
+
import { discoverProjectUrls } from "./sitemap.js";
|
|
17
|
+
import { executeExecutionStep, executeTestPlan } from "./test-executor.js";
|
|
18
|
+
import { runNativeCrawl } from "./native-crawler.js";
|
|
19
|
+
import { elementsToPlaywrightLocators } from "./locator-extractor.js";
|
|
20
|
+
import { jira, confluence, figma } from "@taodigital/connectors";
|
|
21
|
+
import { decrypt, config as platformConfig, dispatchWebhook, isFeatureEnabled, interpolatePlaceholders } from "@taodigital/core";
|
|
22
|
+
import { createHmac } from "node:crypto";
|
|
23
|
+
import fs from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
var FailureType = /* @__PURE__ */ ((FailureType2) => {
|
|
28
|
+
FailureType2["TRANSIENT"] = "TRANSIENT";
|
|
29
|
+
FailureType2["INFRASTRUCTURE"] = "INFRASTRUCTURE";
|
|
30
|
+
FailureType2["LOGICAL"] = "LOGICAL";
|
|
31
|
+
FailureType2["FATAL"] = "FATAL";
|
|
32
|
+
return FailureType2;
|
|
33
|
+
})(FailureType || {});
|
|
34
|
+
const BACKOFF_BY_TYPE = {
|
|
35
|
+
["TRANSIENT" /* TRANSIENT */]: { maxRetries: 5, baseDelayMs: 3e4, multiplier: 2, maxDelayMs: 3e5 },
|
|
36
|
+
["INFRASTRUCTURE" /* INFRASTRUCTURE */]: { maxRetries: 3, baseDelayMs: 6e4, multiplier: 3, maxDelayMs: 6e5 },
|
|
37
|
+
["LOGICAL" /* LOGICAL */]: { maxRetries: 1, baseDelayMs: 12e4, multiplier: 1, maxDelayMs: 12e4 },
|
|
38
|
+
["FATAL" /* FATAL */]: { maxRetries: 0, baseDelayMs: 0, multiplier: 1, maxDelayMs: 0 }
|
|
39
|
+
};
|
|
40
|
+
function classifyFailure(error) {
|
|
41
|
+
const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
42
|
+
if (msg.includes("master_enc_key") || msg.includes("missing secret") || msg.includes("invalid configuration")) {
|
|
43
|
+
return "FATAL" /* FATAL */;
|
|
44
|
+
}
|
|
45
|
+
if (msg.includes("out of memory") || msg.includes("heap out") || msg.includes("connection refused") || msg.includes("econnrefused") || msg.includes("prisma") || msg.includes("database") || msg.includes("worker exited") || msg.includes("sigkill")) {
|
|
46
|
+
return "INFRASTRUCTURE" /* INFRASTRUCTURE */;
|
|
47
|
+
}
|
|
48
|
+
if (msg.includes("validation") || msg.includes("invalid input") || msg.includes("not found") || msg.includes("unauthorized") || msg.includes("bad request") || msg.includes("400") || msg.includes("422") || msg.includes("403")) {
|
|
49
|
+
return "LOGICAL" /* LOGICAL */;
|
|
50
|
+
}
|
|
51
|
+
return "TRANSIENT" /* TRANSIENT */;
|
|
52
|
+
}
|
|
53
|
+
function computeNextRetryAt(failureType, retryCount) {
|
|
54
|
+
const config = BACKOFF_BY_TYPE[failureType];
|
|
55
|
+
if (retryCount >= config.maxRetries) return null;
|
|
56
|
+
const delayMs = Math.min(config.baseDelayMs * Math.pow(config.multiplier, retryCount), config.maxDelayMs);
|
|
57
|
+
return new Date(Date.now() + delayMs);
|
|
58
|
+
}
|
|
59
|
+
async function recordJobFailure(tenantId, jobId, err, checkpointData, options) {
|
|
60
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
61
|
+
const aborted = options?.isAborted ?? (errorMessage.includes("CRAWL_ABORTED") || errorMessage.includes("Stopped by user"));
|
|
62
|
+
if (aborted) {
|
|
63
|
+
await prismaAdmin.platformJob.update({
|
|
64
|
+
where: { id: jobId },
|
|
65
|
+
data: {
|
|
66
|
+
status: "CANCELLED",
|
|
67
|
+
error: "Stopped by user",
|
|
68
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
69
|
+
heartbeatAt: null,
|
|
70
|
+
workerId: null,
|
|
71
|
+
...checkpointData ? { checkpoint: checkpointData, checkpointAt: /* @__PURE__ */ new Date() } : {}
|
|
72
|
+
}
|
|
73
|
+
}).catch(() => {
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const failureType = classifyFailure(err);
|
|
78
|
+
const jobRow = await prismaAdmin.platformJob.findUnique({
|
|
79
|
+
where: { id: jobId },
|
|
80
|
+
select: { retryCount: true, result: true }
|
|
81
|
+
}).catch(() => null);
|
|
82
|
+
const currentRetry = jobRow?.retryCount ?? 0;
|
|
83
|
+
const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
|
|
84
|
+
const isFatal = !nextRetryAt || failureType === "FATAL" /* FATAL */;
|
|
85
|
+
await prismaAdmin.platformJob.update({
|
|
86
|
+
where: { id: jobId },
|
|
87
|
+
data: {
|
|
88
|
+
status: isFatal ? "FAILED" : "RETRY_WAIT",
|
|
89
|
+
error: errorMessage.slice(0, 2e3),
|
|
90
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
91
|
+
heartbeatAt: null,
|
|
92
|
+
workerId: null,
|
|
93
|
+
nextRetryAt,
|
|
94
|
+
retryCount: { increment: 1 },
|
|
95
|
+
failureType,
|
|
96
|
+
dlq: isFatal,
|
|
97
|
+
dlqAt: isFatal ? /* @__PURE__ */ new Date() : null,
|
|
98
|
+
dlqReason: isFatal ? errorMessage.slice(0, 1e3) : null,
|
|
99
|
+
...checkpointData ? { checkpoint: checkpointData, checkpointAt: /* @__PURE__ */ new Date() } : {},
|
|
100
|
+
result: {
|
|
101
|
+
...jobRow?.result ?? {},
|
|
102
|
+
failureType,
|
|
103
|
+
...checkpointData ? { checkpoint: checkpointData } : {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}).catch((e) => logger.error({ err: e, jobId }, "[job-queue] failed to record job failure"));
|
|
107
|
+
}
|
|
108
|
+
function generateTOTP(secret) {
|
|
109
|
+
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
110
|
+
const clean = secret.toUpperCase().replace(/\s/g, "").replace(/=/g, "");
|
|
111
|
+
let bits = 0, value = 0;
|
|
112
|
+
const bytes = [];
|
|
113
|
+
for (const ch of clean) {
|
|
114
|
+
const idx = BASE32.indexOf(ch);
|
|
115
|
+
if (idx === -1) continue;
|
|
116
|
+
value = value << 5 | idx;
|
|
117
|
+
bits += 5;
|
|
118
|
+
if (bits >= 8) {
|
|
119
|
+
bits -= 8;
|
|
120
|
+
bytes.push(value >> bits & 255);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const counter = Math.floor(Date.now() / 3e4);
|
|
124
|
+
const buf = Buffer.alloc(8);
|
|
125
|
+
buf.writeBigInt64BE(BigInt(counter));
|
|
126
|
+
const hmac = createHmac("sha1", Buffer.from(bytes)).update(buf).digest();
|
|
127
|
+
const offset = hmac[hmac.length - 1] & 15;
|
|
128
|
+
const code = (hmac[offset] & 127) << 24 | (hmac[offset + 1] & 255) << 16 | (hmac[offset + 2] & 255) << 8 | hmac[offset + 3] & 255;
|
|
129
|
+
return String(code % 1e6).padStart(6, "0");
|
|
130
|
+
}
|
|
131
|
+
function deriveFallbackTargetPath(ref, framework) {
|
|
132
|
+
const slug = (ref ?? "test").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "test";
|
|
133
|
+
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/";
|
|
134
|
+
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";
|
|
135
|
+
return `${base}${slug}${ext}`;
|
|
136
|
+
}
|
|
137
|
+
function makeJobName(type) {
|
|
138
|
+
const now = /* @__PURE__ */ new Date();
|
|
139
|
+
const date = now.toISOString().slice(0, 10);
|
|
140
|
+
const ts = now.getTime();
|
|
141
|
+
const label = type === "CRAWL" ? "Crawl" : type === "CAPTURE_STEPS" ? "CaptureSteps" : 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());
|
|
142
|
+
return `${label}_${date}_${ts}`;
|
|
143
|
+
}
|
|
144
|
+
const runningControllers = /* @__PURE__ */ new Map();
|
|
145
|
+
const WORKER_ID = `${os.hostname()}:${process.pid}`;
|
|
146
|
+
const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
|
|
147
|
+
const MAX_CONCURRENT_PER_TENANT = Number(process.env.MAX_CONCURRENT_PER_TENANT) || 4;
|
|
148
|
+
const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15e3;
|
|
149
|
+
const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
|
|
150
|
+
let activeExecutions = 0;
|
|
151
|
+
function withSemaphore(fn) {
|
|
152
|
+
activeExecutions++;
|
|
153
|
+
return fn().finally(() => {
|
|
154
|
+
activeExecutions--;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function getActiveExecutionCount() {
|
|
158
|
+
return activeExecutions;
|
|
159
|
+
}
|
|
160
|
+
const MAX_CONCURRENT_DISCOVER = Number(process.env.MAX_CONCURRENT_DISCOVER) || 2;
|
|
161
|
+
const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15e3;
|
|
162
|
+
const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY) || 5;
|
|
163
|
+
let jobDispatchTable = null;
|
|
164
|
+
async function claimSpecificJob(jobId) {
|
|
165
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
166
|
+
UPDATE "PlatformJob"
|
|
167
|
+
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
168
|
+
WHERE id = ${jobId} AND status = 'QUEUED'
|
|
169
|
+
AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
|
|
170
|
+
RETURNING id, "tenantId" AS "tenantId", "projectId" AS "projectId", type, config
|
|
171
|
+
`;
|
|
172
|
+
return rows[0] ?? null;
|
|
173
|
+
}
|
|
174
|
+
async function claimNextQueuedJob() {
|
|
175
|
+
const maxDiscover = MAX_CONCURRENT_DISCOVER;
|
|
176
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
177
|
+
UPDATE "PlatformJob"
|
|
178
|
+
SET status = 'RUNNING', "startedAt" = NOW(), "heartbeatAt" = NOW(), "workerId" = ${WORKER_ID}
|
|
179
|
+
WHERE id = (
|
|
180
|
+
SELECT id FROM "PlatformJob" p
|
|
181
|
+
WHERE status = 'QUEUED'
|
|
182
|
+
AND ("nextRetryAt" IS NULL OR "nextRetryAt" <= NOW())
|
|
183
|
+
AND (
|
|
184
|
+
type != 'DISCOVER'
|
|
185
|
+
OR (SELECT COUNT(*) FROM "PlatformJob" WHERE status = 'RUNNING' AND type = 'DISCOVER') < ${maxDiscover}
|
|
186
|
+
)
|
|
187
|
+
AND (
|
|
188
|
+
SELECT COUNT(*) FROM "PlatformJob" p2
|
|
189
|
+
WHERE p2.status = 'RUNNING'
|
|
190
|
+
AND p2."tenantId" = p."tenantId"
|
|
191
|
+
AND p2.inline = false
|
|
192
|
+
) < ${MAX_CONCURRENT_PER_TENANT}
|
|
193
|
+
ORDER BY p."priority" DESC, p."queuedAt" ASC
|
|
194
|
+
FOR UPDATE SKIP LOCKED
|
|
195
|
+
LIMIT 1
|
|
196
|
+
)
|
|
197
|
+
RETURNING id, "tenantId" AS "tenantId", "projectId" AS "projectId", type, config
|
|
198
|
+
`;
|
|
199
|
+
return rows[0] ?? null;
|
|
200
|
+
}
|
|
201
|
+
async function requeueStaleRunningJobs() {
|
|
202
|
+
const failedRows = await prismaAdmin.$queryRaw`
|
|
203
|
+
UPDATE "PlatformJob"
|
|
204
|
+
SET status = 'FAILED',
|
|
205
|
+
"finishedAt" = NOW(),
|
|
206
|
+
error = 'API process was restarted while this job was running — please retry from the UI.'
|
|
207
|
+
WHERE status = 'RUNNING'
|
|
208
|
+
AND type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
|
|
209
|
+
AND COALESCE("heartbeatAt", "startedAt") < (NOW() AT TIME ZONE 'UTC') - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
|
|
210
|
+
RETURNING id
|
|
211
|
+
`;
|
|
212
|
+
if (failedRows.length > 0) {
|
|
213
|
+
logger.warn({ jobIds: failedRows.map((r) => r.id), staleTimeoutMinutes: STALE_JOB_TIMEOUT_MINUTES }, "[job-worker] failed stale API-in-process job(s) \u2014 API restarted while running");
|
|
214
|
+
}
|
|
215
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
216
|
+
UPDATE "PlatformJob"
|
|
217
|
+
SET status = 'QUEUED',
|
|
218
|
+
"workerId" = NULL,
|
|
219
|
+
result = jsonb_set(
|
|
220
|
+
COALESCE(result::jsonb, '{}'::jsonb),
|
|
221
|
+
'{step}',
|
|
222
|
+
to_jsonb('Worker heartbeat lost — queued for recovery'::text),
|
|
223
|
+
true
|
|
224
|
+
)
|
|
225
|
+
WHERE status = 'RUNNING'
|
|
226
|
+
AND type NOT IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TESTS', 'GENERATE_TEST_PLAN')
|
|
227
|
+
AND COALESCE("heartbeatAt", "startedAt") < (NOW() AT TIME ZONE 'UTC') - (${STALE_JOB_TIMEOUT_MINUTES} * interval '1 minute')
|
|
228
|
+
RETURNING id
|
|
229
|
+
`;
|
|
230
|
+
if (rows.length > 0) {
|
|
231
|
+
logger.warn({ jobIds: rows.map((r) => r.id), staleTimeoutMinutes: STALE_JOB_TIMEOUT_MINUTES }, "[job-worker] requeued stale RUNNING job(s)");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function requeueOrphanedJobs() {
|
|
235
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
236
|
+
UPDATE "PlatformJob"
|
|
237
|
+
SET status = 'QUEUED',
|
|
238
|
+
"workerId" = NULL,
|
|
239
|
+
result = jsonb_set(
|
|
240
|
+
COALESCE(result::jsonb, '{}'::jsonb),
|
|
241
|
+
'{step}',
|
|
242
|
+
to_jsonb('Worker restarted — queued for recovery'::text),
|
|
243
|
+
true
|
|
244
|
+
)
|
|
245
|
+
WHERE status = 'RUNNING'
|
|
246
|
+
AND "workerId" IS NOT NULL
|
|
247
|
+
AND "workerId" != ${WORKER_ID}
|
|
248
|
+
RETURNING id
|
|
249
|
+
`;
|
|
250
|
+
if (rows.length > 0) {
|
|
251
|
+
logger.warn({ jobIds: rows.map((r) => r.id), workerId: WORKER_ID }, "[job-worker] requeued orphaned RUNNING job(s) from dead worker");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function dispatchClaimedJob(job) {
|
|
255
|
+
const dispatcher = jobDispatchTable?.[job.type];
|
|
256
|
+
if (!dispatcher) {
|
|
257
|
+
logger.error({ jobId: job.id, type: job.type }, "[job-worker] no dispatcher registered for job type");
|
|
258
|
+
await prismaAdmin.platformJob.update({
|
|
259
|
+
where: { id: job.id },
|
|
260
|
+
data: { status: "FAILED", error: `Unknown job type: ${job.type}`, finishedAt: /* @__PURE__ */ new Date() }
|
|
261
|
+
}).catch(() => {
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const stopTimer = crawlJobDurationSeconds.startTimer({ type: job.type });
|
|
266
|
+
try {
|
|
267
|
+
await dispatcher(job.id, job.config);
|
|
268
|
+
stopTimer();
|
|
269
|
+
const finished = await prismaAdmin.platformJob.findUnique({ where: { id: job.id }, select: { status: true } }).catch(() => null);
|
|
270
|
+
crawlJobsTotal.inc({ type: job.type, status: finished?.status === "FAILED" || finished?.status === "RETRY_WAIT" ? "failed" : "success" });
|
|
271
|
+
} catch (err) {
|
|
272
|
+
stopTimer();
|
|
273
|
+
crawlJobsTotal.inc({ type: job.type, status: "failed" });
|
|
274
|
+
logger.error({ jobId: job.id, type: job.type, err: String(err?.message ?? err) }, "[job-worker] job threw uncaught error");
|
|
275
|
+
await recordJobFailure(job.tenantId, job.id, err);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
let workerLoopTimer = null;
|
|
279
|
+
let workerShuttingDown = false;
|
|
280
|
+
async function retryEligibleFailedJobs() {
|
|
281
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
282
|
+
UPDATE "PlatformJob"
|
|
283
|
+
SET status = 'RETRY_WAIT',
|
|
284
|
+
"retryCount" = "retryCount" + 1,
|
|
285
|
+
"nextRetryAt" = NOW() + (INTERVAL '1 minute' * POWER(2, "retryCount")),
|
|
286
|
+
"workerId" = NULL
|
|
287
|
+
WHERE status = 'FAILED'
|
|
288
|
+
AND dlq = false
|
|
289
|
+
AND "nextRetryAt" IS NULL
|
|
290
|
+
AND "retryCount" < 5
|
|
291
|
+
RETURNING id
|
|
292
|
+
`;
|
|
293
|
+
if (rows.length > 0) {
|
|
294
|
+
logger.info({ jobIds: rows.map((r) => r.id) }, "[job-worker] moved failed job(s) to RETRY_WAIT");
|
|
295
|
+
}
|
|
296
|
+
const readyRows = await prismaAdmin.$queryRaw`
|
|
297
|
+
UPDATE "PlatformJob"
|
|
298
|
+
SET status = 'QUEUED',
|
|
299
|
+
"requeuedAt" = NOW()
|
|
300
|
+
WHERE status = 'RETRY_WAIT'
|
|
301
|
+
AND "nextRetryAt" <= (NOW() AT TIME ZONE 'UTC')
|
|
302
|
+
AND dlq = false
|
|
303
|
+
RETURNING id
|
|
304
|
+
`;
|
|
305
|
+
if (readyRows.length > 0) {
|
|
306
|
+
logger.info({ jobIds: readyRows.map((r) => r.id) }, "[job-worker] promoted RETRY_WAIT job(s) to QUEUED");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function promoteToDeadLetterQueue() {
|
|
310
|
+
const rows = await prismaAdmin.$queryRaw`
|
|
311
|
+
UPDATE "PlatformJob"
|
|
312
|
+
SET dlq = true,
|
|
313
|
+
"dlqAt" = NOW(),
|
|
314
|
+
"dlqReason" = error
|
|
315
|
+
WHERE status = 'FAILED'
|
|
316
|
+
AND dlq = false
|
|
317
|
+
AND "retryCount" >= CASE
|
|
318
|
+
WHEN type IN ('CRAWL', 'NATIVE_CRAWL') THEN 3
|
|
319
|
+
WHEN type IN ('GENERATE_TESTS', 'IMPORT_TESTS', 'RUN_TEST_SUITE', 'VISUAL_REGRESSION') THEN 2
|
|
320
|
+
WHEN type IN ('GENERATE_SCRIPT', 'GENERATE_DATA', 'GENERATE_TEST_PLAN') THEN 0
|
|
321
|
+
ELSE 1
|
|
322
|
+
END
|
|
323
|
+
RETURNING id
|
|
324
|
+
`;
|
|
325
|
+
if (rows.length > 0) {
|
|
326
|
+
logger.error({ jobIds: rows.map((r) => r.id) }, "[job-worker] promoted failed job(s) to DLQ");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function startJobWorkerLoop(opts) {
|
|
330
|
+
if (workerLoopTimer) return;
|
|
331
|
+
const pollIntervalMs = opts?.pollIntervalMs ?? WORKER_POLL_INTERVAL_MS;
|
|
332
|
+
const concurrency = opts?.concurrency ?? WORKER_CONCURRENCY;
|
|
333
|
+
logger.info({ workerId: WORKER_ID, pollIntervalMs, concurrency }, "[job-worker] starting");
|
|
334
|
+
const sweepTick = async () => {
|
|
335
|
+
try {
|
|
336
|
+
await retryEligibleFailedJobs();
|
|
337
|
+
await promoteToDeadLetterQueue();
|
|
338
|
+
await requeueStaleRunningJobs();
|
|
339
|
+
} catch (err) {
|
|
340
|
+
logger.error({ err: String(err) }, "[job-worker] stale/retry job sweep failed");
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
workerLoopTimer = setInterval(sweepTick, pollIntervalMs);
|
|
344
|
+
workerShuttingDown = false;
|
|
345
|
+
const pollLoop = async () => {
|
|
346
|
+
void sweepTick();
|
|
347
|
+
while (!workerShuttingDown) {
|
|
348
|
+
if (activeExecutions >= concurrency) {
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
let paused = false;
|
|
353
|
+
try {
|
|
354
|
+
const pauseRow = await prismaAdmin.systemConfig.findUnique({ where: { key: "queue.paused" } });
|
|
355
|
+
paused = pauseRow?.value === "true";
|
|
356
|
+
} catch {
|
|
357
|
+
}
|
|
358
|
+
if (paused) {
|
|
359
|
+
logger.info("[job-worker] queue is paused \u2014 skipping claim");
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
let job = null;
|
|
364
|
+
try {
|
|
365
|
+
job = await claimNextQueuedJob();
|
|
366
|
+
} catch (err) {
|
|
367
|
+
logger.error({ err: String(err) }, "[job-worker] claim failed");
|
|
368
|
+
}
|
|
369
|
+
if (!job) {
|
|
370
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
withSemaphore(() => dispatchClaimedJob(job)).catch((err) => {
|
|
374
|
+
logger.error({ err, jobId: job.id }, "[worker] unhandled job error");
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
void pollLoop();
|
|
379
|
+
}
|
|
380
|
+
function stopJobWorkerLoop() {
|
|
381
|
+
workerShuttingDown = true;
|
|
382
|
+
if (workerLoopTimer) clearInterval(workerLoopTimer);
|
|
383
|
+
workerLoopTimer = null;
|
|
384
|
+
}
|
|
385
|
+
async function createTrackedJob(type, config) {
|
|
386
|
+
const name = makeJobName(type);
|
|
387
|
+
const job = await withTenant(
|
|
388
|
+
config.tenantId,
|
|
389
|
+
(tx) => tx.platformJob.create({
|
|
390
|
+
data: { tenantId: config.tenantId, projectId: config.projectId, type, name, status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), inline: true, config }
|
|
391
|
+
})
|
|
392
|
+
);
|
|
393
|
+
return job.id;
|
|
394
|
+
}
|
|
395
|
+
async function finishTrackedJob(tenantId, jobId, error) {
|
|
396
|
+
if (!error) {
|
|
397
|
+
await withTenant(
|
|
398
|
+
tenantId,
|
|
399
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100 } })
|
|
400
|
+
).catch(() => {
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const failureType = classifyFailure(error);
|
|
405
|
+
const jobRow = await prismaAdmin.platformJob.findUnique({ where: { id: jobId }, select: { retryCount: true, result: true } }).catch(() => null);
|
|
406
|
+
const currentRetry = jobRow?.retryCount ?? 0;
|
|
407
|
+
const nextRetryAt = computeNextRetryAt(failureType, currentRetry);
|
|
408
|
+
await withTenant(
|
|
409
|
+
tenantId,
|
|
410
|
+
(tx) => tx.platformJob.update({
|
|
411
|
+
where: { id: jobId },
|
|
412
|
+
data: {
|
|
413
|
+
status: nextRetryAt ? "RETRY_WAIT" : "FAILED",
|
|
414
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
415
|
+
error,
|
|
416
|
+
nextRetryAt,
|
|
417
|
+
retryCount: { increment: 1 },
|
|
418
|
+
failureType,
|
|
419
|
+
result: { ...jobRow?.result ?? {}, failureType }
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
).catch(() => {
|
|
423
|
+
});
|
|
424
|
+
if (!nextRetryAt || failureType === "FATAL" /* FATAL */) {
|
|
425
|
+
await prismaAdmin.platformJob.update({
|
|
426
|
+
where: { id: jobId },
|
|
427
|
+
data: { dlq: true, dlqAt: /* @__PURE__ */ new Date(), dlqReason: error }
|
|
428
|
+
}).catch(() => {
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
async function enqueueJob(type, config) {
|
|
433
|
+
const { tenantId, projectId, submittedBy } = config;
|
|
434
|
+
const name = makeJobName(type);
|
|
435
|
+
const job = await withTenant(
|
|
436
|
+
tenantId,
|
|
437
|
+
(tx) => tx.platformJob.create({
|
|
438
|
+
data: {
|
|
439
|
+
tenantId,
|
|
440
|
+
projectId,
|
|
441
|
+
type,
|
|
442
|
+
name,
|
|
443
|
+
status: "QUEUED",
|
|
444
|
+
config,
|
|
445
|
+
...submittedBy ? { submittedBy } : {}
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
);
|
|
449
|
+
if (process.env.JOB_QUEUE_BACKEND === "sqs") {
|
|
450
|
+
const { sendJobToSqs } = await import("./sqs-queue.js");
|
|
451
|
+
await sendJobToSqs(job.id);
|
|
452
|
+
} else if (process.env.DISABLE_WORKER !== "true") {
|
|
453
|
+
setImmediate(() => claimSpecificJob(job.id).then((claimed) => claimed && dispatchClaimedJob(claimed)));
|
|
454
|
+
}
|
|
455
|
+
return job.id;
|
|
456
|
+
}
|
|
457
|
+
async function getJob(tenantId, jobId) {
|
|
458
|
+
return withTenant(
|
|
459
|
+
tenantId,
|
|
460
|
+
(tx) => tx.platformJob.findFirst({ where: { id: jobId, tenantId } })
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
async function listJobs(tenantId, projectId) {
|
|
464
|
+
return withTenant(tenantId, async (tx) => {
|
|
465
|
+
const jobs = await tx.platformJob.findMany({
|
|
466
|
+
where: { tenantId, projectId },
|
|
467
|
+
orderBy: { queuedAt: "desc" },
|
|
468
|
+
take: 50
|
|
469
|
+
});
|
|
470
|
+
const jobIds = jobs.map((j) => j.id);
|
|
471
|
+
if (jobIds.length === 0) return jobs;
|
|
472
|
+
const modelRows = await tx.lLMCallLog.groupBy({
|
|
473
|
+
by: ["jobId"],
|
|
474
|
+
where: { tenantId, jobId: { in: jobIds } },
|
|
475
|
+
_max: { model: true, calledAt: true }
|
|
476
|
+
});
|
|
477
|
+
const modelByJobId = new Map(
|
|
478
|
+
modelRows.filter((r) => r.jobId && r._max?.model).map((r) => [r.jobId, r._max.model])
|
|
479
|
+
);
|
|
480
|
+
return jobs.map((j) => ({ ...j, llmModel: modelByJobId.get(j.id) ?? null }));
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
async function cancelJob(tenantId, jobId) {
|
|
484
|
+
const controller = runningControllers.get(jobId);
|
|
485
|
+
if (controller) {
|
|
486
|
+
controller.abort();
|
|
487
|
+
runningControllers.delete(jobId);
|
|
488
|
+
}
|
|
489
|
+
await withTenant(
|
|
490
|
+
tenantId,
|
|
491
|
+
(tx) => tx.platformJob.update({
|
|
492
|
+
where: { id: jobId },
|
|
493
|
+
data: { status: "CANCEL_REQUESTED" }
|
|
494
|
+
})
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
async function rerunJob(tenantId, jobId) {
|
|
498
|
+
const job = await withTenant(
|
|
499
|
+
tenantId,
|
|
500
|
+
(tx) => tx.platformJob.findUnique({ where: { id: jobId } })
|
|
501
|
+
);
|
|
502
|
+
if (!job) throw new Error("Job not found");
|
|
503
|
+
return enqueueJob(job.type, { ...job.config, tenantId });
|
|
504
|
+
}
|
|
505
|
+
const jobCheckpoints = /* @__PURE__ */ new Map();
|
|
506
|
+
async function resumeJob(tenantId, jobId) {
|
|
507
|
+
const job = await withTenant(
|
|
508
|
+
tenantId,
|
|
509
|
+
(tx) => tx.platformJob.findUnique({ where: { id: jobId } })
|
|
510
|
+
);
|
|
511
|
+
if (!job) throw new Error("Job not found");
|
|
512
|
+
const checkpoint = job.checkpoint ?? job.result?.checkpoint;
|
|
513
|
+
const visitedUrls = checkpoint?.visitedUrls ?? [];
|
|
514
|
+
const pendingQueue = checkpoint?.pendingQueue ?? [];
|
|
515
|
+
if (pendingQueue.length === 0 && visitedUrls.length === 0) {
|
|
516
|
+
return enqueueJob(job.type, { ...job.config, tenantId });
|
|
517
|
+
}
|
|
518
|
+
return enqueueJob(job.type, {
|
|
519
|
+
...job.config,
|
|
520
|
+
tenantId,
|
|
521
|
+
skipUrls: [...job.config?.skipUrls ?? [], ...visitedUrls],
|
|
522
|
+
startUrls: pendingQueue.length > 0 ? pendingQueue : job.config?.startUrls
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
async function updateJobProgress(tenantId, jobId, progress, step) {
|
|
526
|
+
try {
|
|
527
|
+
await withTenant(tenantId, async (tx) => {
|
|
528
|
+
const current = await tx.platformJob.findUnique({
|
|
529
|
+
where: { id: jobId },
|
|
530
|
+
select: { status: true }
|
|
531
|
+
});
|
|
532
|
+
if (current?.status === "CANCELLED" || current?.status === "CANCEL_REQUESTED") {
|
|
533
|
+
runningControllers.get(jobId)?.abort();
|
|
534
|
+
runningControllers.delete(jobId);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
await tx.platformJob.update({
|
|
538
|
+
where: { id: jobId },
|
|
539
|
+
// GAP-09: write currentStep as a proper column in addition to result.step (backward compat)
|
|
540
|
+
data: { progress, currentStep: step, result: { step }, heartbeatAt: /* @__PURE__ */ new Date() }
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async function heartbeatJob(tenantId, jobId) {
|
|
547
|
+
try {
|
|
548
|
+
await withTenant(tenantId, async (tx) => {
|
|
549
|
+
const current = await tx.platformJob.findUnique({
|
|
550
|
+
where: { id: jobId },
|
|
551
|
+
select: { status: true }
|
|
552
|
+
});
|
|
553
|
+
if (current?.status === "CANCELLED" || current?.status === "CANCEL_REQUESTED") {
|
|
554
|
+
runningControllers.get(jobId)?.abort();
|
|
555
|
+
runningControllers.delete(jobId);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (current?.status !== "RUNNING") return;
|
|
559
|
+
await tx.platformJob.update({
|
|
560
|
+
where: { id: jobId },
|
|
561
|
+
data: { heartbeatAt: /* @__PURE__ */ new Date(), workerId: WORKER_ID }
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function investigateScreenChanges(tenantId, projectId, changedScreens) {
|
|
568
|
+
const [jiraCfg, confluenceCfg] = await Promise.all([
|
|
569
|
+
AppBrain.getConnectorConfig(tenantId, projectId, "JIRA"),
|
|
570
|
+
AppBrain.getConnectorConfig(tenantId, projectId, "CONFLUENCE")
|
|
571
|
+
]);
|
|
572
|
+
for (const screen of changedScreens) {
|
|
573
|
+
const searchTerm = screen.url ?? screen.name;
|
|
574
|
+
const changeSummary = `Screen "${screen.name}" changed: +${screen.addedElements.length} elements, -${screen.removedElements.length} elements`;
|
|
575
|
+
const detail = {
|
|
576
|
+
screenId: screen.id,
|
|
577
|
+
screenName: screen.name,
|
|
578
|
+
url: screen.url,
|
|
579
|
+
addedElements: screen.addedElements,
|
|
580
|
+
removedElements: screen.removedElements,
|
|
581
|
+
jiraTickets: [],
|
|
582
|
+
confluencePages: []
|
|
583
|
+
};
|
|
584
|
+
if (jiraCfg?.baseUrl && jiraCfg?.email && jiraCfg?.apiToken) {
|
|
585
|
+
try {
|
|
586
|
+
detail.jiraTickets = await jira.searchIssues(
|
|
587
|
+
{ baseUrl: jiraCfg.baseUrl, email: jiraCfg.email, apiToken: jiraCfg.apiToken, projectKey: jiraCfg.projectKey },
|
|
588
|
+
searchTerm
|
|
589
|
+
);
|
|
590
|
+
} catch (e) {
|
|
591
|
+
logger.warn({ searchTerm, err: e }, "Jira search failed");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (confluenceCfg?.baseUrl && confluenceCfg?.email && confluenceCfg?.apiToken) {
|
|
595
|
+
try {
|
|
596
|
+
detail.confluencePages = await confluence.searchPages(
|
|
597
|
+
{ baseUrl: confluenceCfg.baseUrl, email: confluenceCfg.email, apiToken: confluenceCfg.apiToken, spaceKey: confluenceCfg.spaceKey },
|
|
598
|
+
searchTerm
|
|
599
|
+
);
|
|
600
|
+
} catch (e) {
|
|
601
|
+
logger.warn({ searchTerm, err: e }, "Confluence search failed");
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
await AppBrain.logChange(tenantId, projectId, "MODIFIED", changeSummary, detail);
|
|
605
|
+
logger.info({ screen: screen.name, jiraTickets: detail.jiraTickets.length, confluencePages: detail.confluencePages.length }, "Logged screen change investigation");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async function processJob(jobId, config, opts) {
|
|
609
|
+
const { tenantId, projectId } = config;
|
|
610
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
611
|
+
const claimed = await withTenant(
|
|
612
|
+
tenantId,
|
|
613
|
+
(tx) => tx.platformJob.updateMany({
|
|
614
|
+
where: { id: jobId, status: { not: "CANCELLED" } },
|
|
615
|
+
data: { status: "RUNNING", startedAt: jobStartedAt, heartbeatAt: jobStartedAt, workerId: WORKER_ID }
|
|
616
|
+
})
|
|
617
|
+
);
|
|
618
|
+
if (claimed.count === 0) return;
|
|
619
|
+
const controller = new AbortController();
|
|
620
|
+
runningControllers.set(jobId, controller);
|
|
621
|
+
const heartbeatTimer = setInterval(() => {
|
|
622
|
+
void heartbeatJob(tenantId, jobId);
|
|
623
|
+
}, JOB_HEARTBEAT_INTERVAL_MS);
|
|
624
|
+
const onProgress = async (pct, step) => {
|
|
625
|
+
await updateJobProgress(tenantId, jobId, pct, step);
|
|
626
|
+
await opts?.onProgress?.(pct);
|
|
627
|
+
};
|
|
628
|
+
const DEFAULT_MAX_SCREENS = parseInt(process.env.CRAWL_DEFAULT_MAX_SCREENS ?? "") || 150;
|
|
629
|
+
try {
|
|
630
|
+
if (!config.maxScreens || !config.deviceProfiles || config.interactive === void 0 || config.useSitemap === void 0 || !config.traversalStrategy) {
|
|
631
|
+
const proj = await withTenant(
|
|
632
|
+
tenantId,
|
|
633
|
+
(tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
634
|
+
);
|
|
635
|
+
const perProject = proj?.crawlConfig?.maxScreens;
|
|
636
|
+
if (!config.maxScreens) config.maxScreens = perProject || DEFAULT_MAX_SCREENS;
|
|
637
|
+
if (!config.deviceProfiles) config.deviceProfiles = proj?.crawlConfig?.deviceProfiles ?? ["DESKTOP"];
|
|
638
|
+
if (config.interactive === void 0) config.interactive = proj?.crawlConfig?.interactive ?? false;
|
|
639
|
+
if (config.useSitemap === void 0) config.useSitemap = proj?.crawlConfig?.useSitemap ?? false;
|
|
640
|
+
if (!config.sitemapUrl) config.sitemapUrl = proj?.crawlConfig?.sitemapUrl ?? void 0;
|
|
641
|
+
if (!config.traversalStrategy) config.traversalStrategy = proj?.crawlConfig?.traversalStrategy ?? "BFS";
|
|
642
|
+
if (!config.bestFirstKeywords) config.bestFirstKeywords = proj?.crawlConfig?.bestFirstKeywords ?? void 0;
|
|
643
|
+
if (!config.segmentSize) config.segmentSize = proj?.crawlConfig?.segmentSize ?? 9999999;
|
|
644
|
+
if (!config.selectiveUrls?.length && (!config.startUrls || config.startUrls.length === 0)) {
|
|
645
|
+
const saved = proj?.crawlConfig?.seedUrls ?? [];
|
|
646
|
+
if (saved.length > 0) config.startUrls = saved;
|
|
647
|
+
}
|
|
648
|
+
if (config.interactive === void 0) config.interactive = proj?.crawlConfig?.interactive ?? false;
|
|
649
|
+
}
|
|
650
|
+
const runtimeCreds = config.credentials ? {
|
|
651
|
+
...config.credentials,
|
|
652
|
+
...config.credentials.totpSecret ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) } : {}
|
|
653
|
+
} : void 0;
|
|
654
|
+
const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
655
|
+
const result = await crawlProject({
|
|
656
|
+
...config,
|
|
657
|
+
credentials: runtimeCreds,
|
|
658
|
+
abortSignal: controller.signal,
|
|
659
|
+
jobId,
|
|
660
|
+
storageConfig: storageConfigForCrawl,
|
|
661
|
+
onCheckpoint: async (visitedUrls, pendingQueue) => {
|
|
662
|
+
jobCheckpoints.set(jobId, { visitedUrls, pendingQueue });
|
|
663
|
+
const CHECKPOINT_INTERVAL = parseInt(process.env.CRAWL_CHECKPOINT_INTERVAL ?? "10");
|
|
664
|
+
if (visitedUrls.length % CHECKPOINT_INTERVAL === 0) {
|
|
665
|
+
try {
|
|
666
|
+
await prismaAdmin.platformJob.update({
|
|
667
|
+
where: { id: jobId },
|
|
668
|
+
data: {
|
|
669
|
+
checkpoint: { visitedUrls, pendingQueue },
|
|
670
|
+
checkpointAt: /* @__PURE__ */ new Date()
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
} catch {
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}, onProgress);
|
|
678
|
+
jobCheckpoints.delete(jobId);
|
|
679
|
+
const pendingUrls = result?.pendingUrls ?? [];
|
|
680
|
+
let nextJobId;
|
|
681
|
+
if (pendingUrls.length > 0) {
|
|
682
|
+
const visitedForSkip = result.visitedUrls ?? (result.discovered ?? []).filter((d) => !!d.screenId && !!d.url).map((d) => {
|
|
683
|
+
try {
|
|
684
|
+
const _u = new URL(d.url);
|
|
685
|
+
_u.hash = "";
|
|
686
|
+
return _u.href;
|
|
687
|
+
} catch {
|
|
688
|
+
return d.url;
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
const reason = result.wasTimeout ? "timeout" : "segment-limit";
|
|
692
|
+
logger.info({ pendingUrls: pendingUrls.length, visited: visitedForSkip.length, reason }, "[crawl] auto-enqueuing continuation job");
|
|
693
|
+
nextJobId = await enqueueJob("CRAWL", {
|
|
694
|
+
...config,
|
|
695
|
+
startUrls: pendingUrls,
|
|
696
|
+
skipUrls: [...config.skipUrls ?? [], ...visitedForSkip]
|
|
697
|
+
}).catch((autoErr) => {
|
|
698
|
+
logger.warn({ err: autoErr?.message }, "[crawl] auto-segment enqueue failed (non-fatal)");
|
|
699
|
+
return void 0;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
await withTenant(
|
|
703
|
+
tenantId,
|
|
704
|
+
(tx) => tx.platformJob.update({
|
|
705
|
+
where: { id: jobId },
|
|
706
|
+
data: {
|
|
707
|
+
status: "DONE",
|
|
708
|
+
result: { ...result, ...nextJobId ? { nextJobId } : {} },
|
|
709
|
+
progress: 100,
|
|
710
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
);
|
|
714
|
+
try {
|
|
715
|
+
const priorJob = await prismaAdmin.platformJob.findFirst({
|
|
716
|
+
where: { tenantId, projectId, type: "CRAWL", status: "DONE", id: { not: jobId } },
|
|
717
|
+
orderBy: { finishedAt: "desc" }
|
|
718
|
+
});
|
|
719
|
+
if (priorJob) {
|
|
720
|
+
await prismaAdmin.platformJob.create({
|
|
721
|
+
data: { tenantId, projectId, type: "CRAWL_DIFF", name: `crawl-diff-${priorJob.id.slice(-6)}-${jobId.slice(-6)}`, status: "QUEUED", config: { fromJobId: priorJob.id, toJobId: jobId } }
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
} catch (_) {
|
|
725
|
+
}
|
|
726
|
+
clearInterval(heartbeatTimer);
|
|
727
|
+
runningControllers.delete(jobId);
|
|
728
|
+
{
|
|
729
|
+
const crawledUrls = (result.discovered ?? []).filter((d) => !!d.screenId && !!d.url).map((d) => d.url);
|
|
730
|
+
if (crawledUrls.length > 0) {
|
|
731
|
+
(async () => {
|
|
732
|
+
const cfg = await AppBrain.getCrawlConfig(tenantId, projectId);
|
|
733
|
+
const existing = cfg?.seedUrls ?? [];
|
|
734
|
+
const merged = Array.from(/* @__PURE__ */ new Set([...existing, ...crawledUrls]));
|
|
735
|
+
if (merged.length > existing.length) {
|
|
736
|
+
await AppBrain.saveCrawlConfig(tenantId, projectId, { seedUrls: merged });
|
|
737
|
+
logger.info({ added: merged.length - existing.length, total: merged.length }, "[crawl] auto-seeded crawled URLs into crawlConfig.seedUrls");
|
|
738
|
+
}
|
|
739
|
+
})().catch((err) => logger.warn({ err: err?.message }, "[crawl] auto-seed seedUrls failed (non-fatal)"));
|
|
740
|
+
}
|
|
741
|
+
notifyAdmins(tenantId, {
|
|
742
|
+
type: "crawl.done",
|
|
743
|
+
title: "Crawl completed",
|
|
744
|
+
body: `Crawl for project finished successfully.`,
|
|
745
|
+
metadata: { projectId, jobId }
|
|
746
|
+
}).catch(console.error);
|
|
747
|
+
dispatchWebhook(tenantId, projectId, "crawl.done", {
|
|
748
|
+
jobId,
|
|
749
|
+
screensFound: result?.screensFound ?? result?.screens?.length ?? 0
|
|
750
|
+
}).catch(console.error);
|
|
751
|
+
}
|
|
752
|
+
setTimeout(() => {
|
|
753
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
754
|
+
}, 8e3);
|
|
755
|
+
setImmediate(async () => {
|
|
756
|
+
try {
|
|
757
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
758
|
+
if (projectMap?.screens?.length) {
|
|
759
|
+
const screenCount = projectMap.screens.length;
|
|
760
|
+
if (screenCount > 300) {
|
|
761
|
+
await withTenant(
|
|
762
|
+
tenantId,
|
|
763
|
+
(tx) => tx.project.update({ where: { id: projectId }, data: { oversized: true } })
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
await generateTaxonomySkillFiles(tenantId, projectId, projectMap.screens, result.apiEndpoints, result.performanceMetrics);
|
|
767
|
+
try {
|
|
768
|
+
const { readFile } = await import("node:fs/promises");
|
|
769
|
+
const { existsSync } = await import("node:fs");
|
|
770
|
+
const screensWithDom = projectMap.screens.filter((s) => s.domSnapshotPath);
|
|
771
|
+
const detectLimit = Math.min(screensWithDom.length, 20);
|
|
772
|
+
let fieldDetectAdded = 0;
|
|
773
|
+
for (let si = 0; si < detectLimit; si++) {
|
|
774
|
+
const screen = screensWithDom[si];
|
|
775
|
+
try {
|
|
776
|
+
let htmlContent = null;
|
|
777
|
+
const domPath = screen.domSnapshotPath;
|
|
778
|
+
if (domPath.startsWith("https://") || domPath.startsWith("http://")) {
|
|
779
|
+
const res = await fetch(domPath);
|
|
780
|
+
if (res.ok) htmlContent = await res.text();
|
|
781
|
+
} else if (existsSync(domPath)) {
|
|
782
|
+
htmlContent = await readFile(domPath, "utf8");
|
|
783
|
+
}
|
|
784
|
+
if (!htmlContent) continue;
|
|
785
|
+
const detected = await detectFieldsFromHtml({ tenantId, projectId, html: htmlContent });
|
|
786
|
+
if (detected.length > 0) {
|
|
787
|
+
const merged = await mergeIntoCatalog({ tenantId, projectId, detected });
|
|
788
|
+
fieldDetectAdded += merged.added;
|
|
789
|
+
}
|
|
790
|
+
} catch (sErr) {
|
|
791
|
+
logger.warn({ screenId: screen.id, err: sErr }, "[field-detect] skipped screen");
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (fieldDetectAdded > 0 || detectLimit > 0) {
|
|
795
|
+
logger.info({ screens: detectLimit, added: fieldDetectAdded, projectId }, "[field-detect] catalog populated from crawl");
|
|
796
|
+
refreshFieldCatalogSkillFile(tenantId, projectId).catch(console.error);
|
|
797
|
+
}
|
|
798
|
+
} catch (fdErr) {
|
|
799
|
+
logger.warn({ err: fdErr }, "[field-detect] post-crawl field detection failed");
|
|
800
|
+
}
|
|
801
|
+
const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
802
|
+
if (tenantStorageCfg) {
|
|
803
|
+
let uploaded = 0;
|
|
804
|
+
for (const screen of projectMap.screens) {
|
|
805
|
+
for (const shot of screen.screenshots ?? []) {
|
|
806
|
+
if (!shot.storagePath || shot.cloudUrl) continue;
|
|
807
|
+
try {
|
|
808
|
+
const filename = `${screen.id}-v${shot.version}.png`;
|
|
809
|
+
const key = buildArtifactKey(tenantId, projectId, "screenshots", filename, tenantStorageCfg.prefix);
|
|
810
|
+
const cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, shot.storagePath, key);
|
|
811
|
+
await AppBrain.updateScreenshotCloudUrl(tenantId, shot.id, cloudUrl);
|
|
812
|
+
uploaded++;
|
|
813
|
+
} catch (uploadErr) {
|
|
814
|
+
logger.warn({ screenId: screen.id, err: uploadErr }, "Failed to upload screenshot to cloud storage");
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (uploaded > 0) logger.info({ uploaded, provider: tenantStorageCfg.provider }, "Uploaded screenshots to cloud storage");
|
|
819
|
+
const { existsSync } = await import("node:fs");
|
|
820
|
+
let domUploaded = 0;
|
|
821
|
+
let mdUploaded = 0;
|
|
822
|
+
for (const screen of projectMap.screens) {
|
|
823
|
+
const domPath = screen.domSnapshotPath;
|
|
824
|
+
if (domPath && !domPath.startsWith("https://") && existsSync(domPath)) {
|
|
825
|
+
try {
|
|
826
|
+
const key = buildArtifactKey(tenantId, projectId, "dom-snapshots", `${screen.id}.json`, tenantStorageCfg.prefix);
|
|
827
|
+
const cloudUrl = await uploadFileToTenantStorage(tenantStorageCfg, domPath, key, "application/json");
|
|
828
|
+
await AppBrain.updateScreenDomSnapshot(tenantId, screen.id, domPath, cloudUrl);
|
|
829
|
+
domUploaded++;
|
|
830
|
+
} catch (err) {
|
|
831
|
+
logger.warn({ screenId: screen.id, err }, "Failed to upload DOM snapshot to cloud storage");
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const mdPath = screen.markdownPath;
|
|
835
|
+
if (mdPath && !mdPath.startsWith("https://") && existsSync(mdPath)) {
|
|
836
|
+
try {
|
|
837
|
+
const key = buildArtifactKey(tenantId, projectId, "markdown", `${screen.id}.md`, tenantStorageCfg.prefix);
|
|
838
|
+
const cloudUrl = await uploadFileToTenantStorage(tenantStorageCfg, mdPath, key, "text/markdown");
|
|
839
|
+
await AppBrain.updateScreenMarkdownPath(tenantId, screen.id, cloudUrl);
|
|
840
|
+
mdUploaded++;
|
|
841
|
+
} catch (err) {
|
|
842
|
+
logger.warn({ screenId: screen.id, err }, "Failed to upload markdown to cloud storage");
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (domUploaded > 0) logger.info({ domUploaded, provider: tenantStorageCfg.provider }, "Uploaded DOM snapshots to cloud storage");
|
|
847
|
+
if (mdUploaded > 0) logger.info({ mdUploaded, provider: tenantStorageCfg.provider }, "Uploaded markdown to cloud storage");
|
|
848
|
+
}
|
|
849
|
+
await groupScreensWithAI(tenantId, projectId, projectMap.screens, jobId, jobStartedAt);
|
|
850
|
+
try {
|
|
851
|
+
await withTenant(tenantId, async (tx) => {
|
|
852
|
+
const staleTests = await tx.$queryRaw`
|
|
853
|
+
SELECT tc.id
|
|
854
|
+
FROM "TestCase" tc
|
|
855
|
+
JOIN "Screen" s ON tc."screenId" = s.id
|
|
856
|
+
WHERE tc."tenantId" = ${tenantId}
|
|
857
|
+
AND s."projectId" = ${projectId}
|
|
858
|
+
AND s."elementHashChangedAt" IS NOT NULL
|
|
859
|
+
AND tc."createdAt" IS NOT NULL
|
|
860
|
+
AND s."elementHashChangedAt" > tc."createdAt"
|
|
861
|
+
AND tc.status != 'PENDING_REVIEW'
|
|
862
|
+
AND tc.status != 'RETIRED'
|
|
863
|
+
`;
|
|
864
|
+
if (staleTests.length > 0) {
|
|
865
|
+
const ids = staleTests.map((t) => t.id);
|
|
866
|
+
await tx.testCase.updateMany({
|
|
867
|
+
where: { id: { in: ids } },
|
|
868
|
+
data: { status: "PENDING_REVIEW" }
|
|
869
|
+
});
|
|
870
|
+
logger.info({ count: staleTests.length, projectId }, "Auto-flagged stale test cases as PENDING_REVIEW");
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
} catch (staleErr) {
|
|
874
|
+
logger.warn({ err: staleErr }, "Failed to auto-flag stale tests");
|
|
875
|
+
}
|
|
876
|
+
const changed = projectMap.screens.filter((s) => s.hasChange);
|
|
877
|
+
if (changed.length > 0) {
|
|
878
|
+
await investigateScreenChanges(tenantId, projectId, changed);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} catch (skillErr) {
|
|
882
|
+
logger.error({ projectId, err: skillErr }, "Taxonomy generation failed");
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
} catch (err) {
|
|
886
|
+
clearInterval(heartbeatTimer);
|
|
887
|
+
runningControllers.delete(jobId);
|
|
888
|
+
const aborted = String(err).includes("CRAWL_ABORTED");
|
|
889
|
+
const authFailure = !aborted && (String(err).includes("AUTH_FAILURE") || String(err).includes("SESSION_EXPIRED"));
|
|
890
|
+
const checkpoint = jobCheckpoints.get(jobId);
|
|
891
|
+
jobCheckpoints.delete(jobId);
|
|
892
|
+
if (authFailure && (checkpoint?.pendingQueue?.length ?? 0) > 0) {
|
|
893
|
+
const pendingAfterAuth = checkpoint.pendingQueue;
|
|
894
|
+
logger.info({ pending: pendingAfterAuth.length }, "[crawl] auth failure mid-crawl \u2014 auto-enqueuing continuation with pending URLs");
|
|
895
|
+
enqueueJob("CRAWL", {
|
|
896
|
+
...config,
|
|
897
|
+
startUrls: pendingAfterAuth,
|
|
898
|
+
skipUrls: [...config.skipUrls ?? [], ...checkpoint.visitedUrls ?? []]
|
|
899
|
+
}).catch((enqErr) => logger.warn({ err: enqErr?.message }, "[crawl] auth-failure auto-segment enqueue failed (non-fatal)"));
|
|
900
|
+
}
|
|
901
|
+
await recordJobFailure(tenantId, jobId, err, checkpoint, { isAborted: aborted });
|
|
902
|
+
if (!aborted) {
|
|
903
|
+
notifyAdmins(tenantId, {
|
|
904
|
+
type: "crawl.failed",
|
|
905
|
+
title: "Crawl failed",
|
|
906
|
+
body: `Crawl for project failed: ${String(err)}`,
|
|
907
|
+
metadata: { projectId, jobId, error: String(err) }
|
|
908
|
+
}).catch(console.error);
|
|
909
|
+
dispatchWebhook(tenantId, projectId, "crawl.failed", {
|
|
910
|
+
jobId,
|
|
911
|
+
error: String(err)
|
|
912
|
+
}).catch(console.error);
|
|
913
|
+
}
|
|
914
|
+
} finally {
|
|
915
|
+
runningControllers.delete(jobId);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async function aggregateJobTokens(tenantId, jobId, projectId, startedAt) {
|
|
919
|
+
try {
|
|
920
|
+
await withTenant(tenantId, async (tx) => {
|
|
921
|
+
const rows = await tx.$queryRaw`
|
|
922
|
+
SELECT
|
|
923
|
+
COALESCE(SUM("promptTokens"), 0)::INT AS "totalPromptTokens",
|
|
924
|
+
COALESCE(SUM("completionTokens"), 0)::INT AS "totalCompletionTokens",
|
|
925
|
+
COALESCE(SUM("cacheReadTokens"), 0)::INT AS "totalCacheReadTokens",
|
|
926
|
+
COALESCE(SUM("estimatedCostUsd"), 0.0) AS "totalEstimatedCostUsd",
|
|
927
|
+
COUNT(*)::INT AS "llmCallCount"
|
|
928
|
+
FROM "LLMCallLog"
|
|
929
|
+
WHERE "tenantId" = ${tenantId}
|
|
930
|
+
AND "jobId" = ${jobId}
|
|
931
|
+
`;
|
|
932
|
+
const row = rows[0];
|
|
933
|
+
if (!row || !row.llmCallCount) return;
|
|
934
|
+
await tx.platformJob.update({
|
|
935
|
+
where: { id: jobId },
|
|
936
|
+
data: {
|
|
937
|
+
totalPromptTokens: row.totalPromptTokens,
|
|
938
|
+
totalCompletionTokens: row.totalCompletionTokens,
|
|
939
|
+
totalCacheReadTokens: row.totalCacheReadTokens,
|
|
940
|
+
totalEstimatedCostUsd: Number(row.totalEstimatedCostUsd),
|
|
941
|
+
llmCallCount: row.llmCallCount
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
} catch (err) {
|
|
946
|
+
console.warn("[job-tokens] aggregation failed:", err);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
async function processApiTestsJob(jobId, config) {
|
|
950
|
+
const { tenantId, projectId } = config;
|
|
951
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
952
|
+
await withTenant(
|
|
953
|
+
tenantId,
|
|
954
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
955
|
+
);
|
|
956
|
+
try {
|
|
957
|
+
const { generateApiTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@taodigital/agents");
|
|
958
|
+
await updateJobProgress(tenantId, jobId, 20, "Loading API contracts and endpoints\u2026");
|
|
959
|
+
const result = await generateApiTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
|
|
960
|
+
await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} API test cases`);
|
|
961
|
+
refreshCoverageMatrix(tenantId, projectId).catch((e) => console.warn("[coverage-matrix]", e));
|
|
962
|
+
refreshTestCaseInventory(tenantId, projectId).catch((e) => console.warn("[tc-inventory]", e));
|
|
963
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
964
|
+
enqueueJob("GENERATE_SCRIPT", { tenantId, projectId, source: "api_tests" }).catch(
|
|
965
|
+
(e) => console.warn("[GAP-007] GENERATE_SCRIPT enqueue failed (non-fatal):", e?.message ?? e)
|
|
966
|
+
);
|
|
967
|
+
await withTenant(
|
|
968
|
+
tenantId,
|
|
969
|
+
(tx) => tx.platformJob.update({
|
|
970
|
+
where: { id: jobId },
|
|
971
|
+
data: {
|
|
972
|
+
status: "DONE",
|
|
973
|
+
progress: 100,
|
|
974
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
975
|
+
result: { cases: result.cases?.length ?? 0, suiteName: result.suiteName }
|
|
976
|
+
}
|
|
977
|
+
})
|
|
978
|
+
);
|
|
979
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
980
|
+
} catch (err) {
|
|
981
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
async function processSecurityTestsJob(jobId, config) {
|
|
985
|
+
const { tenantId, projectId } = config;
|
|
986
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
987
|
+
await withTenant(
|
|
988
|
+
tenantId,
|
|
989
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
990
|
+
);
|
|
991
|
+
try {
|
|
992
|
+
const { generateSecurityTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@taodigital/agents");
|
|
993
|
+
await updateJobProgress(tenantId, jobId, 20, "Mapping OWASP attack surface\u2026");
|
|
994
|
+
const result = await generateSecurityTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
|
|
995
|
+
await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} security test cases`);
|
|
996
|
+
refreshCoverageMatrix(tenantId, projectId).catch((e) => console.warn("[coverage-matrix]", e));
|
|
997
|
+
refreshTestCaseInventory(tenantId, projectId).catch((e) => console.warn("[tc-inventory]", e));
|
|
998
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
999
|
+
await withTenant(
|
|
1000
|
+
tenantId,
|
|
1001
|
+
(tx) => tx.platformJob.update({
|
|
1002
|
+
where: { id: jobId },
|
|
1003
|
+
data: {
|
|
1004
|
+
status: "DONE",
|
|
1005
|
+
progress: 100,
|
|
1006
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
1007
|
+
result: { cases: result.cases?.length ?? 0, suiteName: result.suiteName }
|
|
1008
|
+
}
|
|
1009
|
+
})
|
|
1010
|
+
);
|
|
1011
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async function processIntegrationTestsJob(jobId, config) {
|
|
1017
|
+
const { tenantId, projectId } = config;
|
|
1018
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
1019
|
+
await withTenant(
|
|
1020
|
+
tenantId,
|
|
1021
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
1022
|
+
);
|
|
1023
|
+
try {
|
|
1024
|
+
const { generateIntegrationTests, refreshCoverageMatrix, refreshTestCaseInventory } = await import("@taodigital/agents");
|
|
1025
|
+
await updateJobProgress(tenantId, jobId, 20, "Analysing API contracts and data models\u2026");
|
|
1026
|
+
const result = await generateIntegrationTests({ tenantId, projectId, apiEndpoints: config.apiEndpoints });
|
|
1027
|
+
await updateJobProgress(tenantId, jobId, 90, `Generated ${result.cases?.length ?? 0} integration test cases`);
|
|
1028
|
+
refreshCoverageMatrix(tenantId, projectId).catch((e) => console.warn("[coverage-matrix]", e));
|
|
1029
|
+
refreshTestCaseInventory(tenantId, projectId).catch((e) => console.warn("[tc-inventory]", e));
|
|
1030
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
1031
|
+
await withTenant(
|
|
1032
|
+
tenantId,
|
|
1033
|
+
(tx) => tx.platformJob.update({
|
|
1034
|
+
where: { id: jobId },
|
|
1035
|
+
data: {
|
|
1036
|
+
status: "DONE",
|
|
1037
|
+
progress: 100,
|
|
1038
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
1039
|
+
result: { cases: result.cases?.length ?? 0, suiteName: result.suiteName }
|
|
1040
|
+
}
|
|
1041
|
+
})
|
|
1042
|
+
);
|
|
1043
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
async function processFigmaImportJob(jobId, config) {
|
|
1049
|
+
const { tenantId, projectId, fileKey } = config;
|
|
1050
|
+
await withTenant(
|
|
1051
|
+
tenantId,
|
|
1052
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1053
|
+
);
|
|
1054
|
+
try {
|
|
1055
|
+
const oauthConn = await AppBrain.getOAuthConnection(tenantId, projectId, "FIGMA");
|
|
1056
|
+
let creds;
|
|
1057
|
+
const resolvedFileKey = fileKey ?? "";
|
|
1058
|
+
if (!resolvedFileKey) throw new Error("No Figma file key. Paste the full Figma file URL as the App URL.");
|
|
1059
|
+
if (oauthConn?.accessToken && oauthConn.status === "CONNECTED") {
|
|
1060
|
+
creds = { accessToken: "", oauthToken: oauthConn.accessToken, fileKey: resolvedFileKey };
|
|
1061
|
+
} else {
|
|
1062
|
+
const connector = await withTenant(
|
|
1063
|
+
tenantId,
|
|
1064
|
+
(tx) => tx.connector.findFirst({ where: { projectId, kind: "FIGMA", tenantId } })
|
|
1065
|
+
);
|
|
1066
|
+
if (!connector) throw new Error("Figma connector not configured. Add your Figma token in Project Settings \u2192 Integrations.");
|
|
1067
|
+
const cfg = connector.config;
|
|
1068
|
+
const pat = cfg.accessToken_enc ? decrypt(cfg.accessToken_enc) : "";
|
|
1069
|
+
if (!pat) throw new Error("Figma access token missing. Edit the Figma connector and add your Personal Access Token.");
|
|
1070
|
+
creds = { accessToken: pat, fileKey: resolvedFileKey };
|
|
1071
|
+
}
|
|
1072
|
+
await updateJobProgress(tenantId, jobId, 5, "Listing frames from Figma file\u2026");
|
|
1073
|
+
const frames = await figma.listScreens(creds, resolvedFileKey);
|
|
1074
|
+
if (frames.length === 0) {
|
|
1075
|
+
await withTenant(
|
|
1076
|
+
tenantId,
|
|
1077
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", progress: 100, result: { screensImported: 0 }, finishedAt: /* @__PURE__ */ new Date() } })
|
|
1078
|
+
);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
await updateJobProgress(tenantId, jobId, 15, `Fetching images for ${frames.length} frames\u2026`);
|
|
1082
|
+
const nodeIds = frames.map((f) => f.id);
|
|
1083
|
+
const imageUrls = await figma.getScreenImages(creds, resolvedFileKey, nodeIds, 1);
|
|
1084
|
+
const screenshotDir = path.join(platformConfig.screenshotDir, projectId);
|
|
1085
|
+
await fs.mkdir(screenshotDir, { recursive: true });
|
|
1086
|
+
const tenantStorageCfg = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
1087
|
+
let saved = 0;
|
|
1088
|
+
for (const frame of frames) {
|
|
1089
|
+
try {
|
|
1090
|
+
const pct = Math.round(15 + saved / frames.length * 80);
|
|
1091
|
+
await updateJobProgress(tenantId, jobId, pct, `Saving frame ${saved + 1}/${frames.length}: ${frame.name}`);
|
|
1092
|
+
const screen = await AppBrain.saveScreen(tenantId, projectId, frame.name, `figma://file/${resolvedFileKey}/node/${frame.id}`);
|
|
1093
|
+
const imgUrl = imageUrls[frame.id];
|
|
1094
|
+
if (imgUrl) {
|
|
1095
|
+
const imgRes = await fetch(imgUrl);
|
|
1096
|
+
if (imgRes.ok) {
|
|
1097
|
+
const buf = Buffer.from(await imgRes.arrayBuffer());
|
|
1098
|
+
const filename = `${screen.id}-v1.png`;
|
|
1099
|
+
const storagePath = path.join(screenshotDir, filename);
|
|
1100
|
+
await fs.writeFile(storagePath, buf);
|
|
1101
|
+
let cloudUrl;
|
|
1102
|
+
if (tenantStorageCfg) {
|
|
1103
|
+
try {
|
|
1104
|
+
const key = buildArtifactKey(tenantId, projectId, "screenshots", filename, tenantStorageCfg.prefix);
|
|
1105
|
+
cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, storagePath, key) ?? void 0;
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
console.warn("[figma-import] cloud upload failed:", e);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const w = frame.boundingBox?.width ?? 1440;
|
|
1111
|
+
const viewport = w <= 480 ? "MOBILE_PORTRAIT" : w <= 1024 ? "TABLET" : "DESKTOP";
|
|
1112
|
+
await AppBrain.saveScreenshot(tenantId, projectId, screen.id, storagePath, 1, viewport, cloudUrl);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (frame.node) {
|
|
1116
|
+
const figmaElements = figma.extractElements(frame.node);
|
|
1117
|
+
for (const el of figmaElements) {
|
|
1118
|
+
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;
|
|
1119
|
+
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;
|
|
1120
|
+
await AppBrain.saveElement(
|
|
1121
|
+
tenantId,
|
|
1122
|
+
projectId,
|
|
1123
|
+
screen.id,
|
|
1124
|
+
el.name,
|
|
1125
|
+
role,
|
|
1126
|
+
void 0,
|
|
1127
|
+
el.description ?? void 0,
|
|
1128
|
+
void 0,
|
|
1129
|
+
el.isInteractive,
|
|
1130
|
+
boundingRect,
|
|
1131
|
+
void 0,
|
|
1132
|
+
void 0
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
saved++;
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
console.warn(`[figma-import] Failed to save frame:`, err);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
await withTenant(
|
|
1142
|
+
tenantId,
|
|
1143
|
+
(tx) => tx.platformJob.update({
|
|
1144
|
+
where: { id: jobId },
|
|
1145
|
+
data: { status: "DONE", progress: 100, result: { screensImported: saved, totalFrames: frames.length }, finishedAt: /* @__PURE__ */ new Date() }
|
|
1146
|
+
})
|
|
1147
|
+
);
|
|
1148
|
+
setImmediate(async () => {
|
|
1149
|
+
try {
|
|
1150
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
1151
|
+
if (projectMap?.screens?.length) {
|
|
1152
|
+
await generateTaxonomySkillFiles(tenantId, projectId, projectMap.screens);
|
|
1153
|
+
await groupScreensWithAI(tenantId, projectId, projectMap.screens, jobId, /* @__PURE__ */ new Date());
|
|
1154
|
+
}
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
console.error("[figma-import] post-import grouping failed:", err);
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
async function processNativeCrawlJob(jobId, config) {
|
|
1164
|
+
const { tenantId, projectId } = config;
|
|
1165
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
1166
|
+
try {
|
|
1167
|
+
const platform = config.nativePlatform;
|
|
1168
|
+
if (!platform) throw new Error("Native crawl blocked: nativePlatform is required.");
|
|
1169
|
+
const build = await withTenant(
|
|
1170
|
+
tenantId,
|
|
1171
|
+
(tx) => tx.nativeAppBuild.findFirst({
|
|
1172
|
+
where: { tenantId, projectId, platform },
|
|
1173
|
+
orderBy: { uploadedAt: "desc" }
|
|
1174
|
+
})
|
|
1175
|
+
);
|
|
1176
|
+
if (!build) throw new Error(`Native crawl blocked: no ${platform} app build found for project.`);
|
|
1177
|
+
if (build.fileFormat === "aab") {
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
"AAB format cannot be installed directly by Appium. Convert to APK using bundletool first, then re-upload the APK."
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
const deviceFarm = config.deviceFarm ?? "local_emulator";
|
|
1183
|
+
await withTenant(
|
|
1184
|
+
tenantId,
|
|
1185
|
+
(tx) => tx.platformJob.update({
|
|
1186
|
+
where: { id: jobId },
|
|
1187
|
+
data: {
|
|
1188
|
+
deviceModel: config.deviceModel ?? null,
|
|
1189
|
+
osVersion: config.osVersion ?? null,
|
|
1190
|
+
deviceFarm
|
|
1191
|
+
}
|
|
1192
|
+
})
|
|
1193
|
+
).catch(() => {
|
|
1194
|
+
});
|
|
1195
|
+
const workspaceDeviceFarm = await AppBrain.getTenantDeviceFarmConfig(tenantId).catch(() => null);
|
|
1196
|
+
if (deviceFarm === "browserstack" && (!workspaceDeviceFarm?.browserstackUsername || !workspaceDeviceFarm?.browserstackAccessKey)) {
|
|
1197
|
+
throw new Error("Native crawl blocked: BrowserStack device farm is not configured for this workspace.");
|
|
1198
|
+
}
|
|
1199
|
+
if (deviceFarm === "aws_device_farm") {
|
|
1200
|
+
throw new Error("Native crawl blocked: AWS Device Farm execution is not implemented yet. Use Local / Emulator or BrowserStack.");
|
|
1201
|
+
}
|
|
1202
|
+
await runNativeCrawl({
|
|
1203
|
+
tenantId,
|
|
1204
|
+
projectId,
|
|
1205
|
+
jobId,
|
|
1206
|
+
platform,
|
|
1207
|
+
appS3Path: build.s3Path,
|
|
1208
|
+
packageName: build.packageName ?? void 0,
|
|
1209
|
+
deviceModel: config.deviceModel,
|
|
1210
|
+
osVersion: config.osVersion,
|
|
1211
|
+
deviceFarm,
|
|
1212
|
+
appiumServerUrl: workspaceDeviceFarm?.appiumServerUrl,
|
|
1213
|
+
browserstackUsername: workspaceDeviceFarm?.browserstackUsername,
|
|
1214
|
+
browserstackAccessKey: workspaceDeviceFarm?.browserstackAccessKey,
|
|
1215
|
+
awsAccessKeyId: workspaceDeviceFarm?.awsAccessKeyId,
|
|
1216
|
+
awsSecretAccessKey: workspaceDeviceFarm?.awsSecretAccessKey,
|
|
1217
|
+
awsRegion: workspaceDeviceFarm?.awsRegion,
|
|
1218
|
+
awsDeviceFarmProjectArn: workspaceDeviceFarm?.awsDeviceFarmProjectArn
|
|
1219
|
+
});
|
|
1220
|
+
setImmediate(async () => {
|
|
1221
|
+
try {
|
|
1222
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
1223
|
+
const screens = projectMap?.screens ?? [];
|
|
1224
|
+
if (screens.length > 0) {
|
|
1225
|
+
await generateTaxonomySkillFiles(tenantId, projectId, screens);
|
|
1226
|
+
await groupScreensWithAI(tenantId, projectId, screens, jobId, jobStartedAt);
|
|
1227
|
+
try {
|
|
1228
|
+
await withTenant(tenantId, async (tx) => {
|
|
1229
|
+
const staleTests = await tx.$queryRaw`
|
|
1230
|
+
SELECT tc.id
|
|
1231
|
+
FROM "TestCase" tc
|
|
1232
|
+
JOIN "Screen" s ON tc."screenId" = s.id
|
|
1233
|
+
WHERE tc."tenantId" = ${tenantId}
|
|
1234
|
+
AND s."projectId" = ${projectId}
|
|
1235
|
+
AND s."elementHashChangedAt" IS NOT NULL
|
|
1236
|
+
AND tc."createdAt" IS NOT NULL
|
|
1237
|
+
AND s."elementHashChangedAt" > tc."createdAt"
|
|
1238
|
+
AND tc.status != 'PENDING_REVIEW'
|
|
1239
|
+
AND tc.status != 'RETIRED'
|
|
1240
|
+
`;
|
|
1241
|
+
if (staleTests.length > 0) {
|
|
1242
|
+
const ids = staleTests.map((t) => t.id);
|
|
1243
|
+
await tx.testCase.updateMany({
|
|
1244
|
+
where: { id: { in: ids } },
|
|
1245
|
+
data: { status: "PENDING_REVIEW" }
|
|
1246
|
+
});
|
|
1247
|
+
logger.info({ count: staleTests.length, projectId }, "Auto-flagged stale test cases as PENDING_REVIEW");
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
} catch (staleErr) {
|
|
1251
|
+
logger.warn({ err: staleErr }, "Failed to auto-flag stale tests");
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
logger.error({ err: e }, "post-native-crawl processing failed");
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
async function processGenerateTestsFromReqsJob(jobId, config) {
|
|
1263
|
+
const { tenantId, projectId, reqIds, suiteId, suiteName } = config;
|
|
1264
|
+
try {
|
|
1265
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading requirements");
|
|
1266
|
+
const { askTracked, deterministicSeed, getTaskAgentLLMConfig } = await import("@taodigital/agents");
|
|
1267
|
+
const llmCfg = await getTaskAgentLLMConfig(tenantId, "testGeneration");
|
|
1268
|
+
const all = await AppBrain.listRequirements(tenantId, projectId, {});
|
|
1269
|
+
let reqs = reqIds?.length ? all.filter((r) => reqIds.includes(r.reqId)) : all.filter((r) => r.status === "unbuilt" || r.status === "pending");
|
|
1270
|
+
if (reqs.length === 0) {
|
|
1271
|
+
await updateJobProgress(tenantId, jobId, 100, "No pending requirements found");
|
|
1272
|
+
await withTenant(
|
|
1273
|
+
tenantId,
|
|
1274
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { created: 0, skipped: 0 } } })
|
|
1275
|
+
);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
let resolvedSuiteId = suiteId;
|
|
1279
|
+
if (!resolvedSuiteId) {
|
|
1280
|
+
const name = suiteName?.trim() || "Requirements Tests";
|
|
1281
|
+
const suite = await withTenant(tenantId, async (tx) => {
|
|
1282
|
+
const existing = await tx.testSuite.findFirst({ where: { tenantId, projectId, name } });
|
|
1283
|
+
if (existing) return existing;
|
|
1284
|
+
return tx.testSuite.create({ data: { tenantId, projectId, name } });
|
|
1285
|
+
});
|
|
1286
|
+
resolvedSuiteId = suite.id;
|
|
1287
|
+
}
|
|
1288
|
+
const existingJob = await prismaAdmin.platformJob.findUnique({
|
|
1289
|
+
where: { id: jobId },
|
|
1290
|
+
select: { checkpoint: true }
|
|
1291
|
+
}).catch(() => null);
|
|
1292
|
+
const completedReqIds = new Set(existingJob?.checkpoint?.completedReqIds ?? []);
|
|
1293
|
+
if (completedReqIds.size > 0) {
|
|
1294
|
+
reqs = reqs.filter((r) => !completedReqIds.has(r.reqId));
|
|
1295
|
+
}
|
|
1296
|
+
await updateJobProgress(tenantId, jobId, 20, `Generating tests for ${reqs.length} requirements`);
|
|
1297
|
+
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"]);
|
|
1298
|
+
const VALID_PRIORITIES = /* @__PURE__ */ new Set(["CRITICAL", "HIGH", "MEDIUM", "LOW"]);
|
|
1299
|
+
const CHUNK_SIZE = 5;
|
|
1300
|
+
let totalCreated = existingJob?.checkpoint?.createdCount ?? 0;
|
|
1301
|
+
let totalSkipped = existingJob?.checkpoint?.skippedCount ?? 0;
|
|
1302
|
+
const finishedReqIds = Array.from(completedReqIds);
|
|
1303
|
+
for (let i = 0; i < reqs.length; i += CHUNK_SIZE) {
|
|
1304
|
+
const chunk = reqs.slice(i, i + CHUNK_SIZE);
|
|
1305
|
+
const reqBlock = chunk.map((r) => [
|
|
1306
|
+
`## ${r.reqId}: ${r.title}`,
|
|
1307
|
+
`Priority: ${r.priority} | Type: ${r.type}`,
|
|
1308
|
+
r.acceptanceCriteria?.length ? `Acceptance Criteria:
|
|
1309
|
+
${r.acceptanceCriteria.map((c) => `- ${c}`).join("\n")}` : "",
|
|
1310
|
+
r.businessRules?.length ? `Business Rules:
|
|
1311
|
+
${r.businessRules.map((b) => `- ${b}`).join("\n")}` : ""
|
|
1312
|
+
].filter(Boolean).join("\n")).join("\n\n");
|
|
1313
|
+
const system = `You are ZeTa-AI's test case generator. Convert requirements into structured test cases.
|
|
1314
|
+
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 } ] }
|
|
1315
|
+
Kind must be one of: POSITIVE, NEGATIVE, EDGE, SECURITY, ACCESSIBILITY, INTEGRATION, E2E, SMOKE, REGRESSION.
|
|
1316
|
+
Priority must be one of: CRITICAL, HIGH, MEDIUM, LOW.
|
|
1317
|
+
Generate 1-3 test cases per requirement (positive + negative + edge if applicable).
|
|
1318
|
+
Each test case must reference its reqRef (the requirement ID like REQ-001).`;
|
|
1319
|
+
const user = `Generate test cases for these requirements:
|
|
1320
|
+
|
|
1321
|
+
${reqBlock}`;
|
|
1322
|
+
let parsed = { cases: [] };
|
|
1323
|
+
try {
|
|
1324
|
+
const reqSeed = deterministicSeed(`req-cases:${chunk.map((r) => r.reqId).sort().join("|").slice(0, 300)}`);
|
|
1325
|
+
const { text, usage } = await askTracked(system, user, llmCfg, void 0, void 0, { temperature: 0, seed: reqSeed });
|
|
1326
|
+
if (usage) recordLLMCall({ tenantId, projectId, jobId, agentType: "generate_tests_from_reqs", label: "test_case_generation", usage }).catch(() => {
|
|
1327
|
+
});
|
|
1328
|
+
const json = text.match(/\{[\s\S]*\}/)?.[0];
|
|
1329
|
+
if (json) parsed = JSON.parse(json);
|
|
1330
|
+
} catch (e) {
|
|
1331
|
+
throw new Error("AI failed to generate test cases: " + (e.message ?? "unknown"));
|
|
1332
|
+
}
|
|
1333
|
+
const normalized = (parsed.cases ?? []).map((c) => ({
|
|
1334
|
+
ref: c.reqRef ?? null,
|
|
1335
|
+
traceTo: c.reqRef ?? null,
|
|
1336
|
+
title: c.title || "Untitled",
|
|
1337
|
+
kind: VALID_KINDS.has((c.kind ?? "").toUpperCase()) ? c.kind.toUpperCase() : "POSITIVE",
|
|
1338
|
+
priority: VALID_PRIORITIES.has((c.priority ?? "").toUpperCase()) ? c.priority.toUpperCase() : "MEDIUM",
|
|
1339
|
+
preconditions: c.preconditions ?? "",
|
|
1340
|
+
steps: Array.isArray(c.steps) ? c.steps : [],
|
|
1341
|
+
expectedResult: c.expectedResult ?? "",
|
|
1342
|
+
tags: Array.isArray(c.tags) ? c.tags : []
|
|
1343
|
+
}));
|
|
1344
|
+
const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
|
|
1345
|
+
tenantId,
|
|
1346
|
+
projectId,
|
|
1347
|
+
resolvedSuiteId,
|
|
1348
|
+
normalized,
|
|
1349
|
+
{ duplicateMode: "skip" }
|
|
1350
|
+
);
|
|
1351
|
+
totalCreated += created.length;
|
|
1352
|
+
totalSkipped += skipped ?? 0;
|
|
1353
|
+
const coveredReqIds = new Set(normalized.map((c) => c.ref).filter(Boolean));
|
|
1354
|
+
await Promise.all(
|
|
1355
|
+
chunk.filter((r) => coveredReqIds.has(r.reqId)).map((r) => AppBrain.updateRequirement(tenantId, projectId, r.reqId, { status: "covered" }).catch(() => {
|
|
1356
|
+
}))
|
|
1357
|
+
);
|
|
1358
|
+
for (const r of chunk) finishedReqIds.push(r.reqId);
|
|
1359
|
+
await prismaAdmin.platformJob.update({
|
|
1360
|
+
where: { id: jobId },
|
|
1361
|
+
data: {
|
|
1362
|
+
checkpoint: { completedReqIds: finishedReqIds, createdCount: totalCreated, skippedCount: totalSkipped },
|
|
1363
|
+
checkpointAt: /* @__PURE__ */ new Date()
|
|
1364
|
+
}
|
|
1365
|
+
}).catch(() => {
|
|
1366
|
+
});
|
|
1367
|
+
const progressPct = Math.round(20 + (i + chunk.length) / reqs.length * 75);
|
|
1368
|
+
await updateJobProgress(tenantId, jobId, progressPct, `Processed ${finishedReqIds.length}/${reqs.length} requirements (${totalCreated} cases generated)`);
|
|
1369
|
+
}
|
|
1370
|
+
await withTenant(
|
|
1371
|
+
tenantId,
|
|
1372
|
+
(tx) => tx.platformJob.update({
|
|
1373
|
+
where: { id: jobId },
|
|
1374
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { created: totalCreated, skipped: totalSkipped, suiteId: resolvedSuiteId }, progress: 100 }
|
|
1375
|
+
})
|
|
1376
|
+
);
|
|
1377
|
+
} catch (e) {
|
|
1378
|
+
const msg = e?.message ?? "Unknown error";
|
|
1379
|
+
logger.error({ jobId, err: msg }, "processGenerateTestsFromReqsJob failed");
|
|
1380
|
+
await recordJobFailure(tenantId, jobId, e);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
async function processRunTestSuiteJob(jobId, config) {
|
|
1384
|
+
const { tenantId, projectId, testRunId, suiteId, storageState, browser, shardIndex, totalShards } = config;
|
|
1385
|
+
const runStart = Date.now();
|
|
1386
|
+
await withTenant(
|
|
1387
|
+
tenantId,
|
|
1388
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1389
|
+
);
|
|
1390
|
+
try {
|
|
1391
|
+
let testCases = await AppBrain.listTestCases(tenantId, projectId, {
|
|
1392
|
+
status: "APPROVED",
|
|
1393
|
+
...suiteId ? { suiteId } : {}
|
|
1394
|
+
});
|
|
1395
|
+
if (typeof totalShards === "number" && totalShards > 1 && typeof shardIndex === "number") {
|
|
1396
|
+
testCases = testCases.filter((_, i) => i % totalShards === shardIndex);
|
|
1397
|
+
}
|
|
1398
|
+
if (testCases.length === 0) {
|
|
1399
|
+
await AppBrain.completeTestRun(tenantId, testRunId, { total: 0, passed: 0, failed: 0, skipped: 0, duration: 0 });
|
|
1400
|
+
await withTenant(
|
|
1401
|
+
tenantId,
|
|
1402
|
+
(tx) => tx.platformJob.update({
|
|
1403
|
+
where: { id: jobId },
|
|
1404
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result: { total: 0 } }
|
|
1405
|
+
})
|
|
1406
|
+
);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const aiHealingEnabled = await isFeatureEnabled(tenantId, "AI_SELF_HEALING");
|
|
1410
|
+
const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR ?? "/tmp/zeta-screenshots";
|
|
1411
|
+
const screenshotDir = config.screenshotDir || platformConfig.screenshotDir || SCREENSHOT_DIR;
|
|
1412
|
+
const VALID_BROWSERS = ["chromium", "firefox", "webkit"];
|
|
1413
|
+
const validBrowser = VALID_BROWSERS.includes(browser) ? browser : "chromium";
|
|
1414
|
+
let passed = 0;
|
|
1415
|
+
let failed = 0;
|
|
1416
|
+
const runResults = [];
|
|
1417
|
+
const existingJob = await prismaAdmin.platformJob.findUnique({
|
|
1418
|
+
where: { id: jobId },
|
|
1419
|
+
select: { checkpoint: true }
|
|
1420
|
+
}).catch(() => null);
|
|
1421
|
+
const completedCaseIds = new Set(existingJob?.checkpoint?.completedCaseIds ?? []);
|
|
1422
|
+
if (completedCaseIds.size > 0) {
|
|
1423
|
+
testCases = testCases.filter((tc) => !completedCaseIds.has(tc.id));
|
|
1424
|
+
passed = existingJob?.checkpoint?.passed ?? 0;
|
|
1425
|
+
failed = existingJob?.checkpoint?.failed ?? 0;
|
|
1426
|
+
}
|
|
1427
|
+
const allFinishedCaseIds = Array.from(completedCaseIds);
|
|
1428
|
+
for (let i = 0; i < testCases.length; i += TEST_EXEC_CONCURRENCY) {
|
|
1429
|
+
const batch = testCases.slice(i, i + TEST_EXEC_CONCURRENCY);
|
|
1430
|
+
await Promise.all(batch.map(async (tc) => {
|
|
1431
|
+
try {
|
|
1432
|
+
const full = await AppBrain.getTestCaseForExecution(tenantId, tc.id);
|
|
1433
|
+
const baseUrl = full?.screen?.project?.appUrl ?? "";
|
|
1434
|
+
const execProjectId = full?.screen?.project?.id ?? projectId;
|
|
1435
|
+
const dataRows = full?.dataSet?.rows ?? [];
|
|
1436
|
+
const runs = dataRows.length > 0 ? dataRows : [null];
|
|
1437
|
+
for (const row of runs) {
|
|
1438
|
+
let tcForPlan = full ?? tc;
|
|
1439
|
+
if (row !== null) {
|
|
1440
|
+
const rawSteps = Array.isArray(tcForPlan.steps) ? tcForPlan.steps : [];
|
|
1441
|
+
tcForPlan = {
|
|
1442
|
+
...tcForPlan,
|
|
1443
|
+
steps: rawSteps.map((s) => interpolatePlaceholders(s, row.data)),
|
|
1444
|
+
title: interpolatePlaceholders(tcForPlan.title ?? "", row.data),
|
|
1445
|
+
preconditions: tcForPlan.preconditions ? interpolatePlaceholders(tcForPlan.preconditions, row.data) : tcForPlan.preconditions
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
const plan = await generateExecutionPlan(tenantId, tcForPlan, baseUrl);
|
|
1449
|
+
const result = await executeTestPlan(plan, {
|
|
1450
|
+
screenshotDir,
|
|
1451
|
+
testRunId,
|
|
1452
|
+
maxRuns: 1,
|
|
1453
|
+
browser: validBrowser,
|
|
1454
|
+
storageState,
|
|
1455
|
+
healCtx: aiHealingEnabled ? { tenantId, projectId: execProjectId } : void 0
|
|
1456
|
+
});
|
|
1457
|
+
const status = result.status === "PASSED" ? "PASSED" : result.status === "ERROR" ? "ERROR" : "FAILED";
|
|
1458
|
+
if (status === "PASSED") passed++;
|
|
1459
|
+
else failed++;
|
|
1460
|
+
runResults.push({ testCaseId: tc.id, status });
|
|
1461
|
+
await AppBrain.saveTestResult(tenantId, {
|
|
1462
|
+
runId: testRunId,
|
|
1463
|
+
testCaseId: tc.id,
|
|
1464
|
+
status,
|
|
1465
|
+
duration: result.totalDuration,
|
|
1466
|
+
...result.rootCauseSummary ? { failureAnalysis: result.rootCauseSummary, failureAnalysisAt: /* @__PURE__ */ new Date() } : {},
|
|
1467
|
+
...row !== null ? { dataRowId: row.id, dataRowIndex: row.rowIndex } : {},
|
|
1468
|
+
...result.consoleLogs?.length || result.networkErrors?.length ? {
|
|
1469
|
+
logs: {
|
|
1470
|
+
consoleLogs: result.consoleLogs ?? [],
|
|
1471
|
+
networkErrors: result.networkErrors ?? []
|
|
1472
|
+
}
|
|
1473
|
+
} : {}
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
await AppBrain.recordFlakinessOutcome(tenantId, tc.id).catch(() => {
|
|
1477
|
+
});
|
|
1478
|
+
} catch (err) {
|
|
1479
|
+
failed++;
|
|
1480
|
+
runResults.push({ testCaseId: tc.id, status: "ERROR" });
|
|
1481
|
+
await AppBrain.saveTestResult(tenantId, {
|
|
1482
|
+
runId: testRunId,
|
|
1483
|
+
testCaseId: tc.id,
|
|
1484
|
+
status: "ERROR",
|
|
1485
|
+
error: String(err?.message ?? err).slice(0, 500)
|
|
1486
|
+
}).catch(() => {
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
}));
|
|
1490
|
+
for (const tc of batch) allFinishedCaseIds.push(tc.id);
|
|
1491
|
+
await prismaAdmin.platformJob.update({
|
|
1492
|
+
where: { id: jobId },
|
|
1493
|
+
data: {
|
|
1494
|
+
checkpoint: { completedCaseIds: allFinishedCaseIds, passed, failed },
|
|
1495
|
+
checkpointAt: /* @__PURE__ */ new Date()
|
|
1496
|
+
}
|
|
1497
|
+
}).catch(() => {
|
|
1498
|
+
});
|
|
1499
|
+
await updateJobProgress(
|
|
1500
|
+
tenantId,
|
|
1501
|
+
jobId,
|
|
1502
|
+
Math.round(Math.min(i + TEST_EXEC_CONCURRENCY, testCases.length) / testCases.length * 100),
|
|
1503
|
+
`Ran ${Math.min(i + TEST_EXEC_CONCURRENCY, testCases.length)}/${testCases.length} test cases`
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
await AppBrain.completeTestRun(tenantId, testRunId, {
|
|
1507
|
+
total: testCases.length,
|
|
1508
|
+
passed,
|
|
1509
|
+
failed,
|
|
1510
|
+
skipped: 0,
|
|
1511
|
+
duration: Date.now() - runStart
|
|
1512
|
+
});
|
|
1513
|
+
await withTenant(
|
|
1514
|
+
tenantId,
|
|
1515
|
+
(tx) => tx.platformJob.update({
|
|
1516
|
+
where: { id: jobId },
|
|
1517
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result: { total: testCases.length, passed, failed } }
|
|
1518
|
+
})
|
|
1519
|
+
);
|
|
1520
|
+
notifyRunCompletion(
|
|
1521
|
+
tenantId,
|
|
1522
|
+
projectId,
|
|
1523
|
+
testRunId,
|
|
1524
|
+
failed === 0 ? "COMPLETED" : "FAILED",
|
|
1525
|
+
{ passed, failed, total: testCases.length },
|
|
1526
|
+
runResults
|
|
1527
|
+
).catch(console.error);
|
|
1528
|
+
refreshTestExecutionHistorySkillFile(tenantId, projectId).catch(console.error);
|
|
1529
|
+
} catch (err) {
|
|
1530
|
+
const msg = String(err?.message ?? err).slice(0, 500);
|
|
1531
|
+
logger.error({ jobId, testRunId, err: msg }, "processRunTestSuiteJob failed");
|
|
1532
|
+
await AppBrain.failTestRun(tenantId, testRunId, msg).catch(() => {
|
|
1533
|
+
});
|
|
1534
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
async function processGenerateSkillFilesJob(jobId, config) {
|
|
1538
|
+
const { tenantId, projectId } = config;
|
|
1539
|
+
await withTenant(
|
|
1540
|
+
tenantId,
|
|
1541
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1542
|
+
);
|
|
1543
|
+
try {
|
|
1544
|
+
await updateJobProgress(tenantId, jobId, 10, "Loading project data\u2026");
|
|
1545
|
+
const projectMap = await AppBrain.getProjectMap(tenantId, projectId);
|
|
1546
|
+
if (!projectMap) throw new Error("Project not found.");
|
|
1547
|
+
const screens = projectMap.screens ?? [];
|
|
1548
|
+
const lastCrawl = await withTenant(
|
|
1549
|
+
tenantId,
|
|
1550
|
+
(tx) => tx.platformJob.findFirst({
|
|
1551
|
+
where: { projectId, type: "CRAWL", status: "DONE" },
|
|
1552
|
+
orderBy: { finishedAt: "desc" },
|
|
1553
|
+
select: { result: true }
|
|
1554
|
+
})
|
|
1555
|
+
);
|
|
1556
|
+
const lastResult = lastCrawl?.result ?? {};
|
|
1557
|
+
await updateJobProgress(tenantId, jobId, 25, `Generating ${screens.length} app intelligence skill files\u2026`);
|
|
1558
|
+
await generateTaxonomySkillFiles(tenantId, projectId, screens, lastResult.apiEndpoints, lastResult.performanceMetrics);
|
|
1559
|
+
await updateJobProgress(tenantId, jobId, 85, "Refreshing testing intelligence skill files\u2026");
|
|
1560
|
+
await Promise.allSettled([
|
|
1561
|
+
refreshTestCasesSkillFile(tenantId, projectId),
|
|
1562
|
+
refreshTestDataSkillFile(tenantId, projectId),
|
|
1563
|
+
refreshTestScriptSkillFile(tenantId, projectId),
|
|
1564
|
+
refreshRequirementsCoverageSkillFile(tenantId, projectId),
|
|
1565
|
+
refreshTestExecutionHistorySkillFile(tenantId, projectId),
|
|
1566
|
+
refreshVisualRegressionHistorySkillFile(tenantId, projectId),
|
|
1567
|
+
refreshLocatorStrategyWithHealingData(tenantId, projectId),
|
|
1568
|
+
refreshFieldCatalogSkillFile(tenantId, projectId)
|
|
1569
|
+
]);
|
|
1570
|
+
await withTenant(
|
|
1571
|
+
tenantId,
|
|
1572
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date() } })
|
|
1573
|
+
);
|
|
1574
|
+
} catch (err) {
|
|
1575
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
async function processDataRetentionPurge() {
|
|
1579
|
+
logger.info("[data-retention] starting purge run");
|
|
1580
|
+
const llmCutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
|
|
1581
|
+
const llmResult = await prismaAdmin.lLMCallLog.deleteMany({
|
|
1582
|
+
where: { calledAt: { lt: llmCutoff } }
|
|
1583
|
+
});
|
|
1584
|
+
logger.info({ deleted: llmResult.count }, "[data-retention] LLMCallLog purged");
|
|
1585
|
+
const tenants = await prismaAdmin.tenant.findMany({ select: { id: true } });
|
|
1586
|
+
let auditTotal = 0;
|
|
1587
|
+
for (const { id: tenantId } of tenants) {
|
|
1588
|
+
const setting = await prismaAdmin.tenantSetting.findFirst({
|
|
1589
|
+
where: { tenantId },
|
|
1590
|
+
orderBy: { dataRetentionDays: "asc" },
|
|
1591
|
+
select: { dataRetentionDays: true }
|
|
1592
|
+
});
|
|
1593
|
+
const retentionDays = setting?.dataRetentionDays ?? (parseInt(process.env.DEFAULT_DATA_RETENTION_DAYS ?? "") || 365);
|
|
1594
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1e3);
|
|
1595
|
+
const result = await prismaAdmin.auditLog.deleteMany({
|
|
1596
|
+
where: { tenantId, createdAt: { lt: cutoff } }
|
|
1597
|
+
});
|
|
1598
|
+
auditTotal += result.count;
|
|
1599
|
+
}
|
|
1600
|
+
logger.info({ deleted: auditTotal, tenants: tenants.length }, "[data-retention] AuditLog purged");
|
|
1601
|
+
const SESSION_RETENTION_DAYS = 365;
|
|
1602
|
+
const sessionCutoff = new Date(Date.now() - SESSION_RETENTION_DAYS * 24 * 60 * 6e4);
|
|
1603
|
+
const sessionResult = await prismaAdmin.authSession.deleteMany({
|
|
1604
|
+
where: {
|
|
1605
|
+
createdAt: { lt: sessionCutoff },
|
|
1606
|
+
revokedAt: { not: null }
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
logger.info({ deleted: sessionResult.count }, "[data-retention] AuthSession purged");
|
|
1610
|
+
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1e3);
|
|
1611
|
+
const deletedJobs = await prismaAdmin.platformJob.deleteMany({
|
|
1612
|
+
where: {
|
|
1613
|
+
status: { in: ["DONE", "FAILED"] },
|
|
1614
|
+
dlq: false,
|
|
1615
|
+
inline: false,
|
|
1616
|
+
finishedAt: { lt: ninetyDaysAgo }
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
if (deletedJobs.count > 0) {
|
|
1620
|
+
logger.info({ count: deletedJobs.count }, "[data-retention] deleted old completed jobs");
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async function processDiscoverJob(jobId, config, opts) {
|
|
1624
|
+
const { tenantId, projectId } = config;
|
|
1625
|
+
const controller = new AbortController();
|
|
1626
|
+
runningControllers.set(jobId, controller);
|
|
1627
|
+
const heartbeatTimer = setInterval(() => {
|
|
1628
|
+
void heartbeatJob(tenantId, jobId);
|
|
1629
|
+
}, JOB_HEARTBEAT_INTERVAL_MS);
|
|
1630
|
+
const allUrls = /* @__PURE__ */ new Set();
|
|
1631
|
+
let lastPartialFlush = 0;
|
|
1632
|
+
let lastFlushedPct = 0;
|
|
1633
|
+
const flushPartialUrls = async (pct, step) => {
|
|
1634
|
+
const now = Date.now();
|
|
1635
|
+
if (now - lastPartialFlush < 5e3 && allUrls.size < 5) return;
|
|
1636
|
+
const monotonic = Math.max(pct, lastFlushedPct);
|
|
1637
|
+
lastFlushedPct = monotonic;
|
|
1638
|
+
lastPartialFlush = now;
|
|
1639
|
+
try {
|
|
1640
|
+
await withTenant(
|
|
1641
|
+
tenantId,
|
|
1642
|
+
(tx) => tx.platformJob.update({
|
|
1643
|
+
where: { id: jobId },
|
|
1644
|
+
data: { progress: monotonic, result: { step, partialUrls: Array.from(allUrls) }, heartbeatAt: /* @__PURE__ */ new Date() }
|
|
1645
|
+
})
|
|
1646
|
+
);
|
|
1647
|
+
} catch {
|
|
1648
|
+
}
|
|
1649
|
+
await opts?.onProgress?.(monotonic);
|
|
1650
|
+
};
|
|
1651
|
+
const onProgress = async (pct, step) => {
|
|
1652
|
+
await updateJobProgress(tenantId, jobId, pct, step);
|
|
1653
|
+
await opts?.onProgress?.(pct);
|
|
1654
|
+
};
|
|
1655
|
+
try {
|
|
1656
|
+
await withTenant(
|
|
1657
|
+
tenantId,
|
|
1658
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1659
|
+
);
|
|
1660
|
+
const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null) ?? {};
|
|
1661
|
+
const appUrl = config.appUrl ?? crawlCfg.appUrl;
|
|
1662
|
+
if (!appUrl) throw new Error("No app URL configured. Save the crawl settings first.");
|
|
1663
|
+
const { getSystemConfig } = await import("@taodigital/core");
|
|
1664
|
+
const crawlTuning = await getSystemConfig("crawl_tuning");
|
|
1665
|
+
const maxDiscoverUrls = config.maxDiscoverUrls ?? parseInt(String(crawlTuning?.maxDiscoverUrls ?? "2000"), 10);
|
|
1666
|
+
const proxyUrl = config.proxyUrl ?? crawlCfg.proxyUrl;
|
|
1667
|
+
const screenshotDir = os.tmpdir();
|
|
1668
|
+
const discoverStorageConfig = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
1669
|
+
await onProgress(3, "Seeding from sitemap/robots.txt\u2026");
|
|
1670
|
+
const sitemapResult = await discoverProjectUrls(appUrl, {
|
|
1671
|
+
sitemapUrl: config.sitemapUrl ?? crawlCfg.sitemapUrl,
|
|
1672
|
+
proxyUrl
|
|
1673
|
+
}).catch(() => ({ candidates: [], sitemapFound: false }));
|
|
1674
|
+
const sitemapUrls = sitemapResult.candidates.map((c) => c.url);
|
|
1675
|
+
for (const u of sitemapUrls) allUrls.add(u);
|
|
1676
|
+
logger.info({ count: sitemapUrls.length, sitemapFound: sitemapResult.sitemapFound }, "[discover] Phase 1 sitemap done");
|
|
1677
|
+
await onProgress(8, `Unauthenticated spider from ${appUrl}\u2026`);
|
|
1678
|
+
const unauthResult = await crawlProject({
|
|
1679
|
+
tenantId,
|
|
1680
|
+
projectId,
|
|
1681
|
+
appUrl,
|
|
1682
|
+
discoveryMode: true,
|
|
1683
|
+
startUrls: sitemapUrls.length > 0 ? sitemapUrls : void 0,
|
|
1684
|
+
screenshotDir,
|
|
1685
|
+
maxScreens: maxDiscoverUrls,
|
|
1686
|
+
proxyUrl,
|
|
1687
|
+
useFirefox: crawlCfg.useFirefox,
|
|
1688
|
+
storageConfig: discoverStorageConfig,
|
|
1689
|
+
abortSignal: controller.signal,
|
|
1690
|
+
jobId,
|
|
1691
|
+
onUrlsDiscovered: async (urls) => {
|
|
1692
|
+
for (const u of urls) allUrls.add(u);
|
|
1693
|
+
const pct = 8 + Math.round(allUrls.size / Math.max(1, maxDiscoverUrls) * 42);
|
|
1694
|
+
await flushPartialUrls(Math.min(pct, 49), `Discovered ${allUrls.size} URLs\u2026`);
|
|
1695
|
+
}
|
|
1696
|
+
}, async (pct, step) => {
|
|
1697
|
+
await flushPartialUrls(8 + Math.round(pct * 0.42), `Unauthenticated: ${step}`);
|
|
1698
|
+
}).catch((err) => {
|
|
1699
|
+
if (err?.message === "CRAWL_ABORTED") throw err;
|
|
1700
|
+
logger.warn({ err: err?.message }, "[discover] Phase 2 unauthenticated spider failed \u2014 continuing with partial results");
|
|
1701
|
+
return { discoveredUrls: [] };
|
|
1702
|
+
});
|
|
1703
|
+
for (const u of unauthResult.discoveredUrls ?? []) allUrls.add(u);
|
|
1704
|
+
logger.info({ count: allUrls.size }, "[discover] Phase 2 unauth spider done");
|
|
1705
|
+
let resolvedSso = {};
|
|
1706
|
+
if (crawlCfg.ssoProviderId) {
|
|
1707
|
+
const ssoProviders = await AppBrain.getWorkspaceSsoProviders(tenantId).catch(() => []);
|
|
1708
|
+
const ssoProvider = ssoProviders.find((p) => p.id === crawlCfg.ssoProviderId);
|
|
1709
|
+
if (ssoProvider) {
|
|
1710
|
+
resolvedSso = {
|
|
1711
|
+
ssoProvider: ssoProvider.provider,
|
|
1712
|
+
ssoDomain: ssoProvider.domain,
|
|
1713
|
+
ssoClientId: ssoProvider.clientId,
|
|
1714
|
+
ssoClientSecret: ssoProvider.clientSecret,
|
|
1715
|
+
ssoScope: ssoProvider.scope
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
const hasCreds = !!(crawlCfg.username || crawlCfg.storageState || crawlCfg.authToken || crawlCfg.chromeProfilePath || crawlCfg.httpBasicUsername && crawlCfg.httpBasicPassword);
|
|
1720
|
+
if (hasCreds && !controller.signal.aborted) {
|
|
1721
|
+
await onProgress(52, "Authenticated spider\u2026");
|
|
1722
|
+
const runtimeCreds = crawlCfg.username ? {
|
|
1723
|
+
username: crawlCfg.username,
|
|
1724
|
+
password: crawlCfg.password ?? "",
|
|
1725
|
+
...crawlCfg.totpSecret ? { getOneTimeCode: async () => generateTOTP(crawlCfg.totpSecret) } : {}
|
|
1726
|
+
} : void 0;
|
|
1727
|
+
const PHASE3_TIMEOUT_MS = 20 * 6e4;
|
|
1728
|
+
const authResult = await Promise.race([
|
|
1729
|
+
crawlProject({
|
|
1730
|
+
tenantId,
|
|
1731
|
+
projectId,
|
|
1732
|
+
appUrl,
|
|
1733
|
+
discoveryMode: true,
|
|
1734
|
+
credentials: runtimeCreds,
|
|
1735
|
+
storageState: crawlCfg.storageState,
|
|
1736
|
+
httpBasicUsername: crawlCfg.httpBasicUsername,
|
|
1737
|
+
httpBasicPassword: crawlCfg.httpBasicPassword,
|
|
1738
|
+
authToken: crawlCfg.authToken,
|
|
1739
|
+
authTokenHeader: crawlCfg.authTokenHeader,
|
|
1740
|
+
authTokenPrefix: crawlCfg.authTokenPrefix,
|
|
1741
|
+
startUrls: crawlCfg.postLoginUrl ? [crawlCfg.postLoginUrl, ...sitemapUrls.length > 0 ? sitemapUrls : []] : sitemapUrls.length > 0 ? sitemapUrls : void 0,
|
|
1742
|
+
screenshotDir,
|
|
1743
|
+
maxScreens: maxDiscoverUrls,
|
|
1744
|
+
proxyUrl,
|
|
1745
|
+
chromeProfilePath: crawlCfg.chromeProfilePath,
|
|
1746
|
+
useFirefox: crawlCfg.useFirefox,
|
|
1747
|
+
storageConfig: discoverStorageConfig,
|
|
1748
|
+
captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
|
|
1749
|
+
captchaSolverProvider: crawlCfg.captchaSolverProvider,
|
|
1750
|
+
mailslurpApiKey: crawlCfg.mailslurpApiKey,
|
|
1751
|
+
mailslurpInboxId: crawlCfg.mailslurpInboxId,
|
|
1752
|
+
loginInstructions: crawlCfg.loginInstructions,
|
|
1753
|
+
...resolvedSso,
|
|
1754
|
+
abortSignal: controller.signal,
|
|
1755
|
+
jobId,
|
|
1756
|
+
onUrlsDiscovered: async (urls) => {
|
|
1757
|
+
for (const u of urls) allUrls.add(u);
|
|
1758
|
+
const pct = 52 + Math.round(allUrls.size / Math.max(1, maxDiscoverUrls) * 38);
|
|
1759
|
+
await flushPartialUrls(Math.min(pct, 89), `Discovered ${allUrls.size} URLs (authenticated)\u2026`);
|
|
1760
|
+
}
|
|
1761
|
+
}, async (pct, step) => {
|
|
1762
|
+
await flushPartialUrls(52 + Math.round(pct * 0.38), `Authenticated: ${step}`);
|
|
1763
|
+
}),
|
|
1764
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("AUTH_PHASE_TIMEOUT")), PHASE3_TIMEOUT_MS))
|
|
1765
|
+
]).catch((err) => {
|
|
1766
|
+
if (err?.message === "CRAWL_ABORTED") throw err;
|
|
1767
|
+
const reason = err?.message === "AUTH_PHASE_TIMEOUT" ? "Authenticated spider timed out after 20 minutes" : err?.message;
|
|
1768
|
+
logger.warn({ reason }, "[discover] Phase 3 authenticated spider failed \u2014 using unauthenticated results");
|
|
1769
|
+
return { discoveredUrls: [] };
|
|
1770
|
+
});
|
|
1771
|
+
for (const u of authResult.discoveredUrls ?? []) {
|
|
1772
|
+
allUrls.add(u);
|
|
1773
|
+
}
|
|
1774
|
+
logger.info({ count: allUrls.size }, "[discover] Phase 3 auth spider done");
|
|
1775
|
+
}
|
|
1776
|
+
const discoveredUrls = Array.from(allUrls).filter((u) => u.startsWith("http"));
|
|
1777
|
+
const existingCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => ({}));
|
|
1778
|
+
await AppBrain.saveCrawlConfig(tenantId, projectId, {
|
|
1779
|
+
...existingCfg,
|
|
1780
|
+
discoveredUrls,
|
|
1781
|
+
discoveredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1782
|
+
});
|
|
1783
|
+
await onProgress(100, `Done \u2014 ${discoveredUrls.length} URLs discovered`);
|
|
1784
|
+
await withTenant(
|
|
1785
|
+
tenantId,
|
|
1786
|
+
(tx) => tx.platformJob.update({
|
|
1787
|
+
where: { id: jobId },
|
|
1788
|
+
data: {
|
|
1789
|
+
status: "DONE",
|
|
1790
|
+
result: {
|
|
1791
|
+
discoveredUrls,
|
|
1792
|
+
count: discoveredUrls.length,
|
|
1793
|
+
sitemapFound: sitemapResult.sitemapFound
|
|
1794
|
+
},
|
|
1795
|
+
progress: 100,
|
|
1796
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
1797
|
+
}
|
|
1798
|
+
})
|
|
1799
|
+
);
|
|
1800
|
+
notifyAdmins(tenantId, {
|
|
1801
|
+
type: "discovery.done",
|
|
1802
|
+
title: "URL Discovery completed",
|
|
1803
|
+
body: `Found ${discoveredUrls.length} URLs for the project.`,
|
|
1804
|
+
metadata: { projectId, jobId }
|
|
1805
|
+
}).catch(console.error);
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
logger.error({ err: err?.message, jobId }, "[discover] Job failed");
|
|
1808
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1809
|
+
} finally {
|
|
1810
|
+
clearInterval(heartbeatTimer);
|
|
1811
|
+
runningControllers.delete(jobId);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
async function processMonitorJob(jobId, config, opts) {
|
|
1815
|
+
const { tenantId, projectId } = config;
|
|
1816
|
+
const controller = new AbortController();
|
|
1817
|
+
runningControllers.set(jobId, controller);
|
|
1818
|
+
try {
|
|
1819
|
+
await withTenant(
|
|
1820
|
+
tenantId,
|
|
1821
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
1822
|
+
);
|
|
1823
|
+
const proj = await withTenant(
|
|
1824
|
+
tenantId,
|
|
1825
|
+
(tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
1826
|
+
);
|
|
1827
|
+
const crawlCfg = proj?.crawlConfig ?? {};
|
|
1828
|
+
const monitorUrls = config.monitorUrls ?? crawlCfg.monitorUrls ?? [];
|
|
1829
|
+
if (monitorUrls.length === 0) {
|
|
1830
|
+
throw new Error("No monitor URLs configured. Add monitorUrls to crawl config or job config.");
|
|
1831
|
+
}
|
|
1832
|
+
await updateJobProgress(tenantId, jobId, 10, `Monitoring ${monitorUrls.length} URL(s)...`);
|
|
1833
|
+
const storedBaselines = crawlCfg.monitorBaselines ?? {};
|
|
1834
|
+
const projectScreens = await withTenant(
|
|
1835
|
+
tenantId,
|
|
1836
|
+
(tx) => tx.screen.findMany({ where: { projectId }, select: { url: true, elementHash: true } })
|
|
1837
|
+
).catch(() => []);
|
|
1838
|
+
const screenHashMap = {};
|
|
1839
|
+
for (const s of projectScreens) {
|
|
1840
|
+
if (s.url && s.elementHash) screenHashMap[s.url] = s.elementHash;
|
|
1841
|
+
}
|
|
1842
|
+
const results = [];
|
|
1843
|
+
const newBaselines = { ...storedBaselines };
|
|
1844
|
+
for (let i = 0; i < monitorUrls.length; i++) {
|
|
1845
|
+
if (controller.signal.aborted) break;
|
|
1846
|
+
const url = monitorUrls[i];
|
|
1847
|
+
const pct = 10 + Math.floor(i / monitorUrls.length * 80);
|
|
1848
|
+
await updateJobProgress(tenantId, jobId, pct, `Checking ${url}...`);
|
|
1849
|
+
opts?.onProgress?.(pct);
|
|
1850
|
+
try {
|
|
1851
|
+
const crawlResult = await httpCrawl({ appUrl: url, maxScreens: 1, respectRobotsTxt: false, timeoutMs: parseInt(process.env.CRAWL_TIMEOUT_MS ?? "20000", 10) });
|
|
1852
|
+
const screen = crawlResult.screens[0];
|
|
1853
|
+
if (!screen) {
|
|
1854
|
+
results.push({ url, changed: false, reason: "fetch_empty", currentHash: "", previousHash: null });
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
const currentHash = hashContent(normalizeText(screen.markdown));
|
|
1858
|
+
const previousHash = storedBaselines[url] ?? screenHashMap[url] ?? null;
|
|
1859
|
+
newBaselines[url] = currentHash;
|
|
1860
|
+
if (!previousHash) {
|
|
1861
|
+
results.push({ url, changed: false, reason: "new_baseline", currentHash, previousHash: null });
|
|
1862
|
+
} else if (previousHash === currentHash) {
|
|
1863
|
+
results.push({ url, changed: false, reason: "unchanged", currentHash, previousHash });
|
|
1864
|
+
} else {
|
|
1865
|
+
results.push({ url, changed: true, reason: "content_changed", currentHash, previousHash });
|
|
1866
|
+
}
|
|
1867
|
+
} catch (fetchErr) {
|
|
1868
|
+
results.push({ url, changed: false, reason: `error:${fetchErr?.message ?? String(fetchErr)}`, currentHash: "", previousHash: null });
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
await withTenant(
|
|
1872
|
+
tenantId,
|
|
1873
|
+
(tx) => tx.project.update({
|
|
1874
|
+
where: { id: projectId },
|
|
1875
|
+
data: { crawlConfig: { ...crawlCfg, monitorBaselines: newBaselines } }
|
|
1876
|
+
})
|
|
1877
|
+
).catch((e) => logger.warn({ err: e }, "[monitor] baseline persist failed"));
|
|
1878
|
+
const changedUrls = results.filter((r) => r.changed);
|
|
1879
|
+
if (changedUrls.length > 0) {
|
|
1880
|
+
dispatchWebhook(tenantId, projectId, "content.changed", {
|
|
1881
|
+
jobId,
|
|
1882
|
+
changedUrls: changedUrls.map((r) => ({ url: r.url, previousHash: r.previousHash, currentHash: r.currentHash })),
|
|
1883
|
+
totalChecked: results.length,
|
|
1884
|
+
changedCount: changedUrls.length
|
|
1885
|
+
}).catch(console.error);
|
|
1886
|
+
}
|
|
1887
|
+
await updateJobProgress(tenantId, jobId, 100, "Monitor check complete");
|
|
1888
|
+
await withTenant(
|
|
1889
|
+
tenantId,
|
|
1890
|
+
(tx) => tx.platformJob.update({
|
|
1891
|
+
where: { id: jobId },
|
|
1892
|
+
data: {
|
|
1893
|
+
status: "DONE",
|
|
1894
|
+
progress: 100,
|
|
1895
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
1896
|
+
result: { results, changedCount: changedUrls.length, totalChecked: results.length }
|
|
1897
|
+
}
|
|
1898
|
+
})
|
|
1899
|
+
);
|
|
1900
|
+
} catch (err) {
|
|
1901
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
1902
|
+
} finally {
|
|
1903
|
+
runningControllers.delete(jobId);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
async function processImportTestsJob(jobId, config) {
|
|
1907
|
+
const { tenantId, projectId, artifactId, autoLabel, sourcePlatform } = config;
|
|
1908
|
+
const jobStart = /* @__PURE__ */ new Date();
|
|
1909
|
+
let suiteId = config.suiteId ?? "new";
|
|
1910
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading artifact");
|
|
1911
|
+
const artifact = await withTenant(
|
|
1912
|
+
tenantId,
|
|
1913
|
+
(tx) => tx.projectArtifact.findUnique({ where: { id: artifactId } })
|
|
1914
|
+
);
|
|
1915
|
+
if (!artifact || artifact.tenantId !== tenantId) {
|
|
1916
|
+
throw new Error(`Artifact ${artifactId} not found`);
|
|
1917
|
+
}
|
|
1918
|
+
const content = artifact.content ?? "";
|
|
1919
|
+
const filename = artifact.originalName ?? artifact.name ?? "";
|
|
1920
|
+
const kind = artifact.kind ?? "";
|
|
1921
|
+
function detectFormat(fn, k, c) {
|
|
1922
|
+
if (fn.endsWith(".feature")) return "GHERKIN";
|
|
1923
|
+
if (fn.match(/\.(xlsx|xls|csv)$/i) || k === "SPREADSHEET") return "EXCEL";
|
|
1924
|
+
if (fn.endsWith(".txt") && c && c.split("\n").some((l) => l.trim().startsWith("|"))) return "EXCEL";
|
|
1925
|
+
return "AI_EXTRACT";
|
|
1926
|
+
}
|
|
1927
|
+
const format = detectFormat(filename, kind, content);
|
|
1928
|
+
await updateJobProgress(tenantId, jobId, 15, `Parsing ${filename}`);
|
|
1929
|
+
const { parseTestCases, labelTestCasesBatch } = await import("@taodigital/agents");
|
|
1930
|
+
let res = await parseTestCases(content, format, tenantId, artifact.columnMapping ?? void 0, void 0, void 0, sourcePlatform);
|
|
1931
|
+
if (res.cases.length === 0 && format !== "AI_EXTRACT") {
|
|
1932
|
+
await updateJobProgress(tenantId, jobId, 25, "Retrying with AI extraction");
|
|
1933
|
+
try {
|
|
1934
|
+
res = await parseTestCases(content, "AI_EXTRACT", tenantId, void 0, void 0, void 0, sourcePlatform);
|
|
1935
|
+
} catch {
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const parsedCases = res.cases;
|
|
1939
|
+
if (parsedCases.length === 0) {
|
|
1940
|
+
await withTenant(
|
|
1941
|
+
tenantId,
|
|
1942
|
+
(tx) => tx.platformJob.update({
|
|
1943
|
+
where: { id: jobId },
|
|
1944
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result: { imported: 0, skipped: 0, labeled: 0 } }
|
|
1945
|
+
})
|
|
1946
|
+
);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
await updateJobProgress(tenantId, jobId, 35, `Parsed ${parsedCases.length} cases \u2014 creating suite`);
|
|
1950
|
+
if (!suiteId || suiteId === "new") {
|
|
1951
|
+
const suiteName = filename.replace(/\.[^/.]+$/, "") || "Imported Tests";
|
|
1952
|
+
const suite = await withTenant(
|
|
1953
|
+
tenantId,
|
|
1954
|
+
(tx) => tx.testSuite.create({
|
|
1955
|
+
data: { tenantId, projectId, name: suiteName }
|
|
1956
|
+
})
|
|
1957
|
+
);
|
|
1958
|
+
suiteId = suite.id;
|
|
1959
|
+
}
|
|
1960
|
+
await updateJobProgress(tenantId, jobId, 50, `Importing ${parsedCases.length} cases`);
|
|
1961
|
+
const { created, skipped } = await AppBrain.importTestCasesFromArtifact(
|
|
1962
|
+
tenantId,
|
|
1963
|
+
projectId,
|
|
1964
|
+
suiteId,
|
|
1965
|
+
parsedCases,
|
|
1966
|
+
{ duplicateMode: "skip", artifactId }
|
|
1967
|
+
);
|
|
1968
|
+
let labeled = 0;
|
|
1969
|
+
if (autoLabel && created.length > 0) {
|
|
1970
|
+
await updateJobProgress(tenantId, jobId, 75, `AI labeling ${created.length} cases`);
|
|
1971
|
+
try {
|
|
1972
|
+
const labelInput = created.map((c) => ({
|
|
1973
|
+
id: c.id,
|
|
1974
|
+
title: c.title,
|
|
1975
|
+
description: c.description ?? "",
|
|
1976
|
+
stepsText: (Array.isArray(c.steps) ? c.steps.join(" ") : String(c.steps ?? "")).slice(0, 200)
|
|
1977
|
+
}));
|
|
1978
|
+
const labelResults = await labelTestCasesBatch(labelInput, tenantId);
|
|
1979
|
+
await Promise.all(
|
|
1980
|
+
labelResults.map(
|
|
1981
|
+
(lr) => withTenant(
|
|
1982
|
+
tenantId,
|
|
1983
|
+
(tx) => tx.testCase.update({
|
|
1984
|
+
where: { id: lr.id },
|
|
1985
|
+
data: {
|
|
1986
|
+
priority: lr.priority ?? void 0,
|
|
1987
|
+
kind: lr.kind ?? void 0,
|
|
1988
|
+
tags: lr.tags ?? void 0,
|
|
1989
|
+
status: lr.needsReview ? "PENDING_REVIEW" : void 0
|
|
1990
|
+
}
|
|
1991
|
+
})
|
|
1992
|
+
).catch(() => {
|
|
1993
|
+
})
|
|
1994
|
+
// non-fatal per-case
|
|
1995
|
+
)
|
|
1996
|
+
);
|
|
1997
|
+
labeled = labelResults.length;
|
|
1998
|
+
} catch {
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
const casesNeedingData = created.filter((c) => {
|
|
2002
|
+
const inline = c.testDataInline;
|
|
2003
|
+
return inline && typeof inline === "object" && Object.keys(inline).length > 0;
|
|
2004
|
+
});
|
|
2005
|
+
if (casesNeedingData.length > 0) {
|
|
2006
|
+
const cap = Math.min(casesNeedingData.length, 20);
|
|
2007
|
+
await updateJobProgress(tenantId, jobId, 90, `Generating test data for ${cap} cases`);
|
|
2008
|
+
const { generateTestData, refreshTestDataSkillFile: refreshTestDataSkillFile2 } = await import("@taodigital/agents");
|
|
2009
|
+
for (let i = 0; i < cap; i++) {
|
|
2010
|
+
const c = casesNeedingData[i];
|
|
2011
|
+
await generateTestData({ tenantId, projectId, testCaseId: c.id, jobId }).catch((e) => {
|
|
2012
|
+
logger.warn({ err: e?.message, testCaseId: c.id }, "[import] test data generation failed");
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
refreshTestDataSkillFile2(tenantId, projectId).catch(console.error);
|
|
2016
|
+
}
|
|
2017
|
+
if (created.length > 0) {
|
|
2018
|
+
refreshTestCasesSkillFile(tenantId, projectId).catch(console.error);
|
|
2019
|
+
}
|
|
2020
|
+
await updateJobProgress(tenantId, jobId, 98, `Done \u2014 ${created.length} imported`);
|
|
2021
|
+
await withTenant(
|
|
2022
|
+
tenantId,
|
|
2023
|
+
(tx) => tx.platformJob.update({
|
|
2024
|
+
where: { id: jobId },
|
|
2025
|
+
data: {
|
|
2026
|
+
status: "DONE",
|
|
2027
|
+
progress: 100,
|
|
2028
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
2029
|
+
result: {
|
|
2030
|
+
imported: created.length,
|
|
2031
|
+
skipped: skipped ?? 0,
|
|
2032
|
+
labeled,
|
|
2033
|
+
suiteId,
|
|
2034
|
+
artifactId,
|
|
2035
|
+
artifactName: filename
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
})
|
|
2039
|
+
);
|
|
2040
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
|
|
2041
|
+
}
|
|
2042
|
+
async function processGenerateScreenTestsJob(jobId, config) {
|
|
2043
|
+
const { tenantId, projectId, screenId, repoConnectionId, triggerAlignedScripts } = config;
|
|
2044
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
2045
|
+
try {
|
|
2046
|
+
await withTenant(
|
|
2047
|
+
tenantId,
|
|
2048
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } })
|
|
2049
|
+
);
|
|
2050
|
+
const { DualContextOrchestrator } = await import("@taodigital/agents");
|
|
2051
|
+
await updateJobProgress(tenantId, jobId, 10, "Generating test cases for screen");
|
|
2052
|
+
const crawlCfg = await AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null);
|
|
2053
|
+
const deviceProfiles = crawlCfg?.deviceProfiles ?? ["DESKTOP"];
|
|
2054
|
+
const existingAiCases = await withTenant(
|
|
2055
|
+
tenantId,
|
|
2056
|
+
(tx) => tx.testCase.count({ where: { tenantId, screenId, source: "AI_GENERATED" } })
|
|
2057
|
+
);
|
|
2058
|
+
const generationMode = existingAiCases > 0 ? "replace" : void 0;
|
|
2059
|
+
for (const viewport of deviceProfiles) {
|
|
2060
|
+
await DualContextOrchestrator.execute({ tenantId, projectId, targetScreenId: screenId, jobId, viewport, ...generationMode ? { mode: generationMode } : {} });
|
|
2061
|
+
}
|
|
2062
|
+
await updateJobProgress(tenantId, jobId, 80, "Test cases generated");
|
|
2063
|
+
if (triggerAlignedScripts && repoConnectionId) {
|
|
2064
|
+
const conn = await withTenant(
|
|
2065
|
+
tenantId,
|
|
2066
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
2067
|
+
);
|
|
2068
|
+
if (conn?.styleProfile) {
|
|
2069
|
+
const { generateScriptAligned, mapWithConcurrency, SCRIPT_GEN_CONCURRENCY } = await import("@taodigital/agents");
|
|
2070
|
+
const cases = await withTenant(
|
|
2071
|
+
tenantId,
|
|
2072
|
+
(tx) => tx.testCase.findMany({ where: { tenantId, projectId, screenId, status: "APPROVED" }, select: { id: true }, take: 50 })
|
|
2073
|
+
);
|
|
2074
|
+
await mapWithConcurrency(
|
|
2075
|
+
cases,
|
|
2076
|
+
SCRIPT_GEN_CONCURRENCY,
|
|
2077
|
+
(tc) => generateScriptAligned({
|
|
2078
|
+
tenantId,
|
|
2079
|
+
projectId,
|
|
2080
|
+
testCaseId: tc.id,
|
|
2081
|
+
styleProfile: conn.styleProfile,
|
|
2082
|
+
pageObjectInventory: conn.pageObjectInventory ?? void 0
|
|
2083
|
+
}).catch(() => {
|
|
2084
|
+
})
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
await withTenant(
|
|
2089
|
+
tenantId,
|
|
2090
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date() } })
|
|
2091
|
+
);
|
|
2092
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStartedAt).catch(console.error);
|
|
2093
|
+
} catch (err) {
|
|
2094
|
+
const msg = err?.message ?? String(err);
|
|
2095
|
+
logger.error({ jobId, screenId, err: msg }, "processGenerateScreenTestsJob failed");
|
|
2096
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
function captureStepText(step) {
|
|
2100
|
+
if (typeof step === "string") return step;
|
|
2101
|
+
if (!step || typeof step !== "object") return "";
|
|
2102
|
+
const obj = step;
|
|
2103
|
+
return [obj.action, obj.text, obj.label, obj.data, obj.expected].filter((v) => typeof v === "string" && v.trim().length > 0).join(" -> ");
|
|
2104
|
+
}
|
|
2105
|
+
function firstQuoted(text) {
|
|
2106
|
+
return (text.match(/"([^"]+)"/)?.[1] ?? text.match(/'([^']+)'/)?.[1])?.trim();
|
|
2107
|
+
}
|
|
2108
|
+
function fallbackExecutionPlan(rawSteps) {
|
|
2109
|
+
const plan = [];
|
|
2110
|
+
for (const raw of rawSteps) {
|
|
2111
|
+
const text = captureStepText(raw).trim();
|
|
2112
|
+
if (!text) continue;
|
|
2113
|
+
const stepNumber = plan.length + 1;
|
|
2114
|
+
const url = text.match(/https?:\/\/[^\s"']+/)?.[0];
|
|
2115
|
+
if (/^navigate\b|^go to\b|^open\b/i.test(text) && url) {
|
|
2116
|
+
plan.push({ stepNumber, description: text, action: "navigate", url });
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
const urlContains = text.match(/verify url contains:\s*["']?([^"']+)["']?/i);
|
|
2120
|
+
if (urlContains) {
|
|
2121
|
+
plan.push({ stepNumber, description: text, action: "assert_url", value: urlContains[1].trim() });
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
const inputMatch = text.match(/(?:enter|type|fill)\s+["']([^"']+)["']\s+(?:in|into)\s+(.+?)(?:\s+field|\s+input|\s+box|\s+area)?(?:\s*→.*)?$/i);
|
|
2125
|
+
if (inputMatch) {
|
|
2126
|
+
const selector = inputMatch[2].replace(/^(the\s+)?/i, "").replace(/\s+(field|input|box|area)$/i, "").trim();
|
|
2127
|
+
plan.push({ stepNumber, description: text, action: "fill", selectorStrategy: "label", selector, value: inputMatch[1].trim() });
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
const quoted = firstQuoted(text);
|
|
2131
|
+
if (/^(click|tap|navigate menu|check|select)\b/i.test(text) && quoted) {
|
|
2132
|
+
plan.push({ stepNumber, description: text, action: "click", selectorStrategy: "text", selector: quoted });
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
if (/^(verify|assert|expect)\b/i.test(text) && quoted) {
|
|
2136
|
+
plan.push({ stepNumber, description: text, action: "assert_visible", selectorStrategy: "text", selector: quoted });
|
|
2137
|
+
continue;
|
|
2138
|
+
}
|
|
2139
|
+
if (/execute script/i.test(text)) {
|
|
2140
|
+
const script = text.split(":").slice(1).join(":").replace(/\s*→.*$/, "").trim();
|
|
2141
|
+
if (script) plan.push({ stepNumber, description: text, action: "script", value: script });
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
return plan;
|
|
2145
|
+
}
|
|
2146
|
+
function screenNameFromUrl(url, fallback) {
|
|
2147
|
+
try {
|
|
2148
|
+
const u = new URL(url);
|
|
2149
|
+
return u.pathname.replace(/\/+$/, "").split("/").filter(Boolean).pop() || u.hostname || fallback;
|
|
2150
|
+
} catch {
|
|
2151
|
+
return fallback;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
async function processCaptureStepsJob(jobId, config) {
|
|
2155
|
+
const { tenantId, projectId, testCaseId } = config;
|
|
2156
|
+
const screenshotDir = config.screenshotDir || platformConfig.screenshotDir || process.env.SCREENSHOT_DIR || "/tmp/zeta-screenshots";
|
|
2157
|
+
await fs.mkdir(screenshotDir, { recursive: true });
|
|
2158
|
+
await withTenant(
|
|
2159
|
+
tenantId,
|
|
2160
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), progress: 2 } })
|
|
2161
|
+
);
|
|
2162
|
+
let browser;
|
|
2163
|
+
try {
|
|
2164
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading test case");
|
|
2165
|
+
const [tc, crawlCfg, tenantStorageCfg] = await Promise.all([
|
|
2166
|
+
AppBrain.getTestCaseForExecution(tenantId, testCaseId),
|
|
2167
|
+
AppBrain.getCrawlConfig(tenantId, projectId).catch(() => null),
|
|
2168
|
+
getTenantStorageConfig(tenantId).catch(() => null)
|
|
2169
|
+
]);
|
|
2170
|
+
if (!tc) throw new Error("Test case not found");
|
|
2171
|
+
const rawSteps = Array.isArray(tc.steps) ? tc.steps : [];
|
|
2172
|
+
if (rawSteps.length === 0) throw new Error("Test case has no steps to capture");
|
|
2173
|
+
const _tdi = tc.testDataInline ?? {};
|
|
2174
|
+
const _tdiStartUrl = typeof _tdi["startUrl"] === "string" && _tdi["startUrl"].startsWith("http") ? _tdi["startUrl"] : "";
|
|
2175
|
+
const _recordedUrls = Array.isArray(_tdi["recordedUrls"]) ? _tdi["recordedUrls"].map(String) : [];
|
|
2176
|
+
const _recordedStartUrl = _recordedUrls.find((u) => u.startsWith("http")) ?? "";
|
|
2177
|
+
const baseUrl = config.baseUrl || _tdiStartUrl || _recordedStartUrl || tc?.screen?.project?.appUrl || crawlCfg?.appUrl || "";
|
|
2178
|
+
const _envTokenMap = {};
|
|
2179
|
+
for (const [k, v] of Object.entries(_tdi)) {
|
|
2180
|
+
if (typeof v === "string" && v.startsWith("http")) _envTokenMap[k] = v;
|
|
2181
|
+
}
|
|
2182
|
+
if (_recordedStartUrl && !_envTokenMap["startUrl"]) _envTokenMap["startUrl"] = _recordedStartUrl;
|
|
2183
|
+
const resolveStepText = (text) => text.replace(/\{\{\s*fze\.env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`).replace(/\[env:([A-Za-z_][A-Za-z0-9_]*)\]/g, (_m, k) => _envTokenMap[k] ?? `[${k}]`);
|
|
2184
|
+
const stepTexts = rawSteps.map(captureStepText).filter(Boolean).map(resolveStepText);
|
|
2185
|
+
if (stepTexts.length === 0) throw new Error("Test case has no steps to capture");
|
|
2186
|
+
let plan = await generateExecutionPlan(tenantId, { ...tc, steps: stepTexts }, baseUrl).catch(() => []);
|
|
2187
|
+
plan = Array.isArray(plan) ? plan.filter(Boolean).map((step, idx) => ({ ...step, stepNumber: idx + 1 })) : [];
|
|
2188
|
+
if (plan.length === 0) plan = fallbackExecutionPlan(rawSteps);
|
|
2189
|
+
if (plan.length === 0) throw new Error("Could not generate execution plan from steps");
|
|
2190
|
+
const { chromium } = await import("playwright");
|
|
2191
|
+
browser = await chromium.launch({
|
|
2192
|
+
headless: true,
|
|
2193
|
+
...process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}
|
|
2194
|
+
});
|
|
2195
|
+
let parsedStorageState;
|
|
2196
|
+
try {
|
|
2197
|
+
parsedStorageState = config.storageState ? JSON.parse(config.storageState) : crawlCfg?.storageState ? JSON.parse(crawlCfg.storageState) : void 0;
|
|
2198
|
+
} catch {
|
|
2199
|
+
parsedStorageState = void 0;
|
|
2200
|
+
}
|
|
2201
|
+
const context = await browser.newContext({
|
|
2202
|
+
viewport: { width: 1280, height: 800 },
|
|
2203
|
+
userAgent: await getNonHeadlessUserAgent(process.env.PLAYWRIGHT_EXECUTABLE_PATH || chromium.executablePath()),
|
|
2204
|
+
...parsedStorageState ? { storageState: parsedStorageState } : {}
|
|
2205
|
+
});
|
|
2206
|
+
const page = await context.newPage();
|
|
2207
|
+
const aiHealingEnabled = await isFeatureEnabled(tenantId, "AI_SELF_HEALING").catch(() => false);
|
|
2208
|
+
const stepResults = [];
|
|
2209
|
+
const screens = [];
|
|
2210
|
+
const locators = [];
|
|
2211
|
+
const seenLocatorExpressions = /* @__PURE__ */ new Set();
|
|
2212
|
+
const seenScreenKeys = /* @__PURE__ */ new Set();
|
|
2213
|
+
let lastStateKey = "";
|
|
2214
|
+
for (let i = 0; i < plan.length; i++) {
|
|
2215
|
+
const step = plan[i];
|
|
2216
|
+
const description = step.description || stepTexts[i] || `Step ${i + 1}`;
|
|
2217
|
+
const stepNumber = i + 1;
|
|
2218
|
+
const result = { stepNumber, description, status: "FAILED" };
|
|
2219
|
+
await withTenant(
|
|
2220
|
+
tenantId,
|
|
2221
|
+
(tx) => tx.platformJob.update({
|
|
2222
|
+
where: { id: jobId },
|
|
2223
|
+
data: {
|
|
2224
|
+
progress: Math.min(90, Math.round(i / Math.max(plan.length, 1) * 90)),
|
|
2225
|
+
result: { currentStep: { stepNumber, description } },
|
|
2226
|
+
heartbeatAt: /* @__PURE__ */ new Date()
|
|
2227
|
+
}
|
|
2228
|
+
})
|
|
2229
|
+
).catch(() => {
|
|
2230
|
+
});
|
|
2231
|
+
try {
|
|
2232
|
+
const heal = await executeExecutionStep(page, step, aiHealingEnabled ? { healCtx: { tenantId, projectId } } : {});
|
|
2233
|
+
if (heal.healed) {
|
|
2234
|
+
result.healed = true;
|
|
2235
|
+
result.healTier = heal.healedVia;
|
|
2236
|
+
result.healReasoning = heal.healReasoning;
|
|
2237
|
+
}
|
|
2238
|
+
await page.waitForLoadState("networkidle", { timeout: parseInt(process.env.TEST_NETWORK_IDLE_TIMEOUT_MS ?? "2000", 10) }).catch(() => {
|
|
2239
|
+
});
|
|
2240
|
+
result.status = result.healed ? "HEALED" : "PASSED";
|
|
2241
|
+
} catch (err) {
|
|
2242
|
+
result.status = step.optional ? "SKIPPED" : "FAILED";
|
|
2243
|
+
result.error = String(err?.message ?? err).slice(0, 300);
|
|
2244
|
+
}
|
|
2245
|
+
const currentUrl = page.url();
|
|
2246
|
+
result.url = currentUrl;
|
|
2247
|
+
const ssFilename = `${testCaseId}-capture-step-${stepNumber}${result.status === "FAILED" ? "-err" : ""}.png`;
|
|
2248
|
+
const ssPath = path.join(screenshotDir, ssFilename);
|
|
2249
|
+
await page.screenshot({ path: ssPath, fullPage: false }).catch(() => {
|
|
2250
|
+
});
|
|
2251
|
+
result.screenshotPath = ssPath;
|
|
2252
|
+
let cloudUrl;
|
|
2253
|
+
if (tenantStorageCfg) {
|
|
2254
|
+
const key = buildArtifactKey(tenantId, projectId, "screenshots", ssFilename, tenantStorageCfg.prefix);
|
|
2255
|
+
cloudUrl = await uploadScreenshotToTenantStorage(tenantStorageCfg, ssPath, key).catch(() => void 0) ?? void 0;
|
|
2256
|
+
if (cloudUrl) result.screenshotUrl = cloudUrl;
|
|
2257
|
+
}
|
|
2258
|
+
const elements = await domFallbackElements(page).catch(() => []);
|
|
2259
|
+
result.elementsCount = elements.length;
|
|
2260
|
+
const stateHash = hashContent(elements.map((el) => `${el.role}:${el.meaning}:${el.notes ?? ""}`).join("|"));
|
|
2261
|
+
const screenKey = `${currentUrl || "about:blank"}:${stateHash}`;
|
|
2262
|
+
const shouldPersistScreen = currentUrl && currentUrl !== "about:blank" && (i === 0 || screenKey !== lastStateKey || !seenScreenKeys.has(screenKey));
|
|
2263
|
+
lastStateKey = screenKey;
|
|
2264
|
+
if (shouldPersistScreen) {
|
|
2265
|
+
const screenName = screenNameFromUrl(currentUrl, tc.title ?? `Step ${stepNumber}`);
|
|
2266
|
+
const screen = await AppBrain.saveScreen(tenantId, projectId, screenName, currentUrl, jobId, i === 0 ? "ENTRY_POINT" : "LINK_FOLLOW").catch(() => null);
|
|
2267
|
+
if (screen?.id) {
|
|
2268
|
+
result.screenId = screen.id;
|
|
2269
|
+
await AppBrain.saveScreenshot(tenantId, projectId, screen.id, ssPath, screen.version ?? 1, "DESKTOP", cloudUrl).catch(() => {
|
|
2270
|
+
});
|
|
2271
|
+
for (const el of elements) {
|
|
2272
|
+
await AppBrain.saveElement(
|
|
2273
|
+
tenantId,
|
|
2274
|
+
projectId,
|
|
2275
|
+
screen.id,
|
|
2276
|
+
el.meaning,
|
|
2277
|
+
el.role,
|
|
2278
|
+
el.expectedData,
|
|
2279
|
+
el.notes,
|
|
2280
|
+
void 0,
|
|
2281
|
+
true,
|
|
2282
|
+
el.boundingRect,
|
|
2283
|
+
el.ariaState,
|
|
2284
|
+
el.parentLandmark
|
|
2285
|
+
).catch(() => {
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
for (const loc of elementsToPlaywrightLocators(elements)) {
|
|
2289
|
+
if (seenLocatorExpressions.has(loc.expression)) continue;
|
|
2290
|
+
seenLocatorExpressions.add(loc.expression);
|
|
2291
|
+
locators.push(loc);
|
|
2292
|
+
}
|
|
2293
|
+
if (!seenScreenKeys.has(screenKey)) {
|
|
2294
|
+
screens.push({ stepNumber, screenId: screen.id, url: currentUrl, screenshotPath: ssPath, screenshotUrl: cloudUrl, isNewScreen: true });
|
|
2295
|
+
seenScreenKeys.add(screenKey);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
stepResults.push(result);
|
|
2300
|
+
if (result.status === "FAILED") break;
|
|
2301
|
+
}
|
|
2302
|
+
if (locators.length > 0) {
|
|
2303
|
+
await AppBrain.saveLocatorsJson(tenantId, testCaseId, locators).catch(() => {
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
const firstScreen = screens[0];
|
|
2307
|
+
await withTenant(
|
|
2308
|
+
tenantId,
|
|
2309
|
+
(tx) => tx.testCase.update({
|
|
2310
|
+
where: { id: testCaseId },
|
|
2311
|
+
data: {
|
|
2312
|
+
...firstScreen?.screenId ? { screenId: firstScreen.screenId } : {},
|
|
2313
|
+
captureStepScreenshots: stepResults
|
|
2314
|
+
}
|
|
2315
|
+
})
|
|
2316
|
+
).catch(() => {
|
|
2317
|
+
});
|
|
2318
|
+
const finalResult = {
|
|
2319
|
+
stepResults,
|
|
2320
|
+
screens,
|
|
2321
|
+
locators,
|
|
2322
|
+
totalSteps: plan.length,
|
|
2323
|
+
capturedScreens: screens.length
|
|
2324
|
+
};
|
|
2325
|
+
await withTenant(
|
|
2326
|
+
tenantId,
|
|
2327
|
+
(tx) => tx.platformJob.update({
|
|
2328
|
+
where: { id: jobId },
|
|
2329
|
+
data: { status: "DONE", progress: 100, result: finalResult, finishedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date() }
|
|
2330
|
+
})
|
|
2331
|
+
);
|
|
2332
|
+
} catch (err) {
|
|
2333
|
+
const error = String(err?.message ?? err);
|
|
2334
|
+
logger.error({ jobId, testCaseId, err: error }, "processCaptureStepsJob failed");
|
|
2335
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2336
|
+
} finally {
|
|
2337
|
+
await browser?.close?.().catch(() => {
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
async function processMultiRoleCrawlJob(jobId, config) {
|
|
2342
|
+
const { tenantId, projectId } = config;
|
|
2343
|
+
try {
|
|
2344
|
+
await withTenant(
|
|
2345
|
+
tenantId,
|
|
2346
|
+
(tx) => tx.platformJob.updateMany({
|
|
2347
|
+
where: { id: jobId, status: { not: "CANCELLED" } },
|
|
2348
|
+
data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date(), workerId: WORKER_ID }
|
|
2349
|
+
})
|
|
2350
|
+
);
|
|
2351
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading project crawl config\u2026");
|
|
2352
|
+
const proj = await withTenant(
|
|
2353
|
+
tenantId,
|
|
2354
|
+
(tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
2355
|
+
);
|
|
2356
|
+
const savedCrawlConfig = proj?.crawlConfig ?? {};
|
|
2357
|
+
const credentialProfiles = config.credentialProfiles ?? [];
|
|
2358
|
+
await updateJobProgress(tenantId, jobId, 10, "Enqueuing primary crawl\u2026");
|
|
2359
|
+
const primaryJobId = await enqueueJob("CRAWL", {
|
|
2360
|
+
tenantId,
|
|
2361
|
+
projectId,
|
|
2362
|
+
appUrl: config.appUrl || savedCrawlConfig.appUrl,
|
|
2363
|
+
credentials: savedCrawlConfig.username && savedCrawlConfig.password ? { username: savedCrawlConfig.username, password: savedCrawlConfig.password, ...savedCrawlConfig.totpSecret ? { totpSecret: savedCrawlConfig.totpSecret } : {} } : void 0,
|
|
2364
|
+
storageState: savedCrawlConfig.storageState,
|
|
2365
|
+
screenshotDir: platformConfig.screenshotDir,
|
|
2366
|
+
startUrls: savedCrawlConfig.seedUrls?.length ? savedCrawlConfig.seedUrls : void 0,
|
|
2367
|
+
deviceProfiles: savedCrawlConfig.deviceProfiles ?? ["DESKTOP"],
|
|
2368
|
+
loginInstructions: savedCrawlConfig.loginInstructions,
|
|
2369
|
+
proxyUrl: savedCrawlConfig.proxyUrl,
|
|
2370
|
+
useSitemap: true,
|
|
2371
|
+
sitemapUrl: savedCrawlConfig.sitemapUrl
|
|
2372
|
+
});
|
|
2373
|
+
logger.info({ primaryJobId, projectId }, "[multi-role-crawl] primary CRAWL enqueued");
|
|
2374
|
+
const PRIMARY_TIMEOUT_MS = 2 * 60 * 60 * 1e3;
|
|
2375
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
2376
|
+
const deadline = Date.now() + PRIMARY_TIMEOUT_MS;
|
|
2377
|
+
let primaryStatus = "QUEUED";
|
|
2378
|
+
while (Date.now() < deadline) {
|
|
2379
|
+
const primaryJob = await prismaAdmin.platformJob.findUnique({
|
|
2380
|
+
where: { id: primaryJobId },
|
|
2381
|
+
select: { status: true }
|
|
2382
|
+
}).catch(() => null);
|
|
2383
|
+
primaryStatus = primaryJob?.status ?? "UNKNOWN";
|
|
2384
|
+
if (primaryStatus === "DONE" || primaryStatus === "FAILED" || primaryStatus === "CANCELLED") break;
|
|
2385
|
+
await updateJobProgress(tenantId, jobId, 20, `Waiting for primary crawl (${primaryStatus})\u2026`);
|
|
2386
|
+
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
|
|
2387
|
+
}
|
|
2388
|
+
if (primaryStatus !== "DONE") {
|
|
2389
|
+
throw new Error(`Primary crawl ${primaryJobId} ended with status ${primaryStatus} \u2014 aborting role crawls`);
|
|
2390
|
+
}
|
|
2391
|
+
await updateJobProgress(tenantId, jobId, 50, "Collecting screen URLs for role crawls\u2026");
|
|
2392
|
+
const screens = await prismaAdmin.screen.findMany({
|
|
2393
|
+
where: { tenantId, projectId, status: "ACTIVE" },
|
|
2394
|
+
select: { id: true, url: true }
|
|
2395
|
+
}).catch(() => []);
|
|
2396
|
+
const screenUrls = screens.filter((s) => s.url).map((s) => s.url);
|
|
2397
|
+
logger.info({ screenCount: screenUrls.length, projectId }, "[multi-role-crawl] screen URLs collected");
|
|
2398
|
+
if (credentialProfiles.length === 0) {
|
|
2399
|
+
logger.warn({ projectId, jobId }, "[multi-role-crawl] no credential profiles \u2014 nothing to role-crawl");
|
|
2400
|
+
}
|
|
2401
|
+
const roleJobs = [];
|
|
2402
|
+
for (const profile of credentialProfiles) {
|
|
2403
|
+
if (!profile.username || !profile.password) continue;
|
|
2404
|
+
const roleJobId = await enqueueJob("ROLE_CRAWL", {
|
|
2405
|
+
tenantId,
|
|
2406
|
+
projectId,
|
|
2407
|
+
appUrl: config.appUrl || savedCrawlConfig.appUrl,
|
|
2408
|
+
credentials: { username: profile.username, password: profile.password },
|
|
2409
|
+
roleProfileName: profile.name,
|
|
2410
|
+
parentJobId: jobId,
|
|
2411
|
+
selectiveUrls: config.fullReCrawl ? void 0 : screenUrls.length > 0 ? screenUrls : void 0,
|
|
2412
|
+
startUrls: config.fullReCrawl ? savedCrawlConfig.seedUrls ?? [] : void 0,
|
|
2413
|
+
maxScreens: config.fullReCrawl ? void 0 : screenUrls.length + 10,
|
|
2414
|
+
screenshotDir: platformConfig.screenshotDir,
|
|
2415
|
+
storageState: savedCrawlConfig.storageState,
|
|
2416
|
+
deviceProfiles: savedCrawlConfig.deviceProfiles ?? ["DESKTOP"],
|
|
2417
|
+
loginInstructions: savedCrawlConfig.loginInstructions,
|
|
2418
|
+
proxyUrl: savedCrawlConfig.proxyUrl
|
|
2419
|
+
});
|
|
2420
|
+
roleJobs.push({ roleName: profile.name, jobId: roleJobId });
|
|
2421
|
+
logger.info({ roleJobId, roleName: profile.name, projectId }, "[multi-role-crawl] ROLE_CRAWL enqueued");
|
|
2422
|
+
}
|
|
2423
|
+
const ROLE_TIMEOUT_MS = 2 * 60 * 60 * 1e3;
|
|
2424
|
+
const roleDeadline = Date.now() + ROLE_TIMEOUT_MS;
|
|
2425
|
+
const roleResults = [];
|
|
2426
|
+
while (Date.now() < roleDeadline && roleJobs.length > 0) {
|
|
2427
|
+
const pending = [];
|
|
2428
|
+
for (const rj of roleJobs) {
|
|
2429
|
+
const rJob = await prismaAdmin.platformJob.findUnique({
|
|
2430
|
+
where: { id: rj.jobId },
|
|
2431
|
+
select: { status: true, result: true }
|
|
2432
|
+
}).catch(() => null);
|
|
2433
|
+
const st = rJob?.status ?? "UNKNOWN";
|
|
2434
|
+
if (st === "DONE" || st === "FAILED" || st === "CANCELLED") {
|
|
2435
|
+
roleResults.push({
|
|
2436
|
+
roleName: rj.roleName,
|
|
2437
|
+
jobId: rj.jobId,
|
|
2438
|
+
status: st,
|
|
2439
|
+
screensTagged: rJob?.result?.screensTagged
|
|
2440
|
+
});
|
|
2441
|
+
} else {
|
|
2442
|
+
pending.push(rj);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
roleJobs.length = 0;
|
|
2446
|
+
roleJobs.push(...pending);
|
|
2447
|
+
if (roleJobs.length === 0) break;
|
|
2448
|
+
const pct = Math.round(60 + roleResults.length / (roleResults.length + roleJobs.length) * 35);
|
|
2449
|
+
await updateJobProgress(tenantId, jobId, pct, `Role crawls: ${roleResults.length} done, ${roleJobs.length} pending\u2026`);
|
|
2450
|
+
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
|
|
2451
|
+
}
|
|
2452
|
+
for (const rj of roleJobs) {
|
|
2453
|
+
roleResults.push({ roleName: rj.roleName, jobId: rj.jobId, status: "TIMEOUT" });
|
|
2454
|
+
}
|
|
2455
|
+
await withTenant(
|
|
2456
|
+
tenantId,
|
|
2457
|
+
(tx) => tx.platformJob.update({
|
|
2458
|
+
where: { id: jobId },
|
|
2459
|
+
data: {
|
|
2460
|
+
status: "DONE",
|
|
2461
|
+
progress: 100,
|
|
2462
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
2463
|
+
result: { primaryJobId, roleJobs: roleResults }
|
|
2464
|
+
}
|
|
2465
|
+
})
|
|
2466
|
+
);
|
|
2467
|
+
logger.info({ jobId, primaryJobId, roleCount: roleResults.length, projectId }, "[multi-role-crawl] completed");
|
|
2468
|
+
} catch (err) {
|
|
2469
|
+
const msg = String(err?.message ?? err);
|
|
2470
|
+
logger.error({ jobId, err: msg }, "[multi-role-crawl] failed");
|
|
2471
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
async function processRoleCrawlJob(jobId, config) {
|
|
2475
|
+
const { tenantId, projectId } = config;
|
|
2476
|
+
try {
|
|
2477
|
+
const claimed = await withTenant(
|
|
2478
|
+
tenantId,
|
|
2479
|
+
(tx) => tx.platformJob.updateMany({
|
|
2480
|
+
where: { id: jobId, status: { not: "CANCELLED" } },
|
|
2481
|
+
data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date(), heartbeatAt: /* @__PURE__ */ new Date(), workerId: WORKER_ID }
|
|
2482
|
+
})
|
|
2483
|
+
);
|
|
2484
|
+
if (claimed.count === 0) return;
|
|
2485
|
+
const controller = new AbortController();
|
|
2486
|
+
runningControllers.set(jobId, controller);
|
|
2487
|
+
const heartbeatTimer = setInterval(() => void heartbeatJob(tenantId, jobId), JOB_HEARTBEAT_INTERVAL_MS);
|
|
2488
|
+
const onProgress = async (pct, step) => {
|
|
2489
|
+
await updateJobProgress(tenantId, jobId, pct, step);
|
|
2490
|
+
};
|
|
2491
|
+
try {
|
|
2492
|
+
const proj = await withTenant(
|
|
2493
|
+
tenantId,
|
|
2494
|
+
(tx) => tx.project.findUnique({ where: { id: projectId }, select: { crawlConfig: true } })
|
|
2495
|
+
);
|
|
2496
|
+
const savedCrawlConfig = proj?.crawlConfig ?? {};
|
|
2497
|
+
const runtimeCreds = config.credentials ? {
|
|
2498
|
+
...config.credentials,
|
|
2499
|
+
...config.credentials.totpSecret ? { getOneTimeCode: async () => generateTOTP(config.credentials.totpSecret) } : {}
|
|
2500
|
+
} : void 0;
|
|
2501
|
+
const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
|
|
2502
|
+
const result = await crawlProject({
|
|
2503
|
+
...config,
|
|
2504
|
+
credentials: runtimeCreds,
|
|
2505
|
+
storageState: config.storageState ?? savedCrawlConfig.storageState,
|
|
2506
|
+
proxyUrl: config.proxyUrl ?? savedCrawlConfig.proxyUrl,
|
|
2507
|
+
loginInstructions: config.loginInstructions ?? savedCrawlConfig.loginInstructions,
|
|
2508
|
+
abortSignal: controller.signal,
|
|
2509
|
+
jobId,
|
|
2510
|
+
storageConfig: storageConfigForCrawl
|
|
2511
|
+
}, onProgress);
|
|
2512
|
+
clearInterval(heartbeatTimer);
|
|
2513
|
+
runningControllers.delete(jobId);
|
|
2514
|
+
const roleName = config.roleProfileName;
|
|
2515
|
+
const discovered = result.discovered ?? [];
|
|
2516
|
+
const roleScreenIds = discovered.filter((d) => d.screenId).map((d) => d.screenId);
|
|
2517
|
+
if (roleScreenIds.length > 0 && roleName) {
|
|
2518
|
+
await prismaAdmin.$executeRaw`
|
|
2519
|
+
UPDATE "Screen"
|
|
2520
|
+
SET "roleAccess" = array(SELECT DISTINCT unnest(array_append("roleAccess", ${roleName}::text)))
|
|
2521
|
+
WHERE id = ANY(${roleScreenIds}::text[])
|
|
2522
|
+
AND "tenantId" = ${tenantId}
|
|
2523
|
+
`.catch((e) => logger.warn({ err: e?.message, roleName, count: roleScreenIds.length }, "[role-crawl] roleAccess tag failed (non-fatal)"));
|
|
2524
|
+
}
|
|
2525
|
+
await withTenant(
|
|
2526
|
+
tenantId,
|
|
2527
|
+
(tx) => tx.platformJob.update({
|
|
2528
|
+
where: { id: jobId },
|
|
2529
|
+
data: {
|
|
2530
|
+
status: "DONE",
|
|
2531
|
+
progress: 100,
|
|
2532
|
+
finishedAt: /* @__PURE__ */ new Date(),
|
|
2533
|
+
result: { screensTagged: roleScreenIds.length, roleName }
|
|
2534
|
+
}
|
|
2535
|
+
})
|
|
2536
|
+
);
|
|
2537
|
+
logger.info({ jobId, roleName, screensTagged: roleScreenIds.length, projectId }, "[role-crawl] completed");
|
|
2538
|
+
} catch (err) {
|
|
2539
|
+
clearInterval(heartbeatTimer);
|
|
2540
|
+
runningControllers.delete(jobId);
|
|
2541
|
+
const aborted = String(err).includes("CRAWL_ABORTED");
|
|
2542
|
+
await withTenant(
|
|
2543
|
+
tenantId,
|
|
2544
|
+
(tx) => tx.platformJob.update({
|
|
2545
|
+
where: { id: jobId },
|
|
2546
|
+
data: {
|
|
2547
|
+
status: aborted ? "CANCELLED" : "FAILED",
|
|
2548
|
+
error: aborted ? "Stopped by user" : String(err),
|
|
2549
|
+
finishedAt: /* @__PURE__ */ new Date()
|
|
2550
|
+
}
|
|
2551
|
+
})
|
|
2552
|
+
);
|
|
2553
|
+
} finally {
|
|
2554
|
+
runningControllers.delete(jobId);
|
|
2555
|
+
}
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
const msg = String(err?.message ?? err);
|
|
2558
|
+
logger.error({ jobId, err: msg }, "[role-crawl] outer failure");
|
|
2559
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
async function processGenerateScriptJob(jobId, config) {
|
|
2563
|
+
const { tenantId, projectId, language } = config;
|
|
2564
|
+
try {
|
|
2565
|
+
await updateJobProgress(tenantId, jobId, 1, "Loading test cases\u2026").catch(() => {
|
|
2566
|
+
});
|
|
2567
|
+
if (config.packId) {
|
|
2568
|
+
const { assertScriptGenerationReady, generatePackScript } = await import("@taodigital/agents");
|
|
2569
|
+
await assertScriptGenerationReady({ tenantId, projectId, packType: config.packId });
|
|
2570
|
+
const result = await generatePackScript({ tenantId, projectId, packType: config.packId, language });
|
|
2571
|
+
await withTenant(
|
|
2572
|
+
tenantId,
|
|
2573
|
+
(tx) => tx.platformJob.update({
|
|
2574
|
+
where: { id: jobId },
|
|
2575
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result }
|
|
2576
|
+
})
|
|
2577
|
+
);
|
|
2578
|
+
} else {
|
|
2579
|
+
const { assertScriptGenerationReady, generateSuiteScript } = await import("@taodigital/agents");
|
|
2580
|
+
await assertScriptGenerationReady({ tenantId, projectId, suiteId: config.suiteId, caseIds: config.caseIds ?? void 0 });
|
|
2581
|
+
let progressChain = Promise.resolve();
|
|
2582
|
+
const result = await generateSuiteScript({
|
|
2583
|
+
tenantId,
|
|
2584
|
+
projectId,
|
|
2585
|
+
suiteId: config.suiteId,
|
|
2586
|
+
language,
|
|
2587
|
+
caseIds: config.caseIds ?? void 0,
|
|
2588
|
+
useV2: config.useV2,
|
|
2589
|
+
styleProfile: config.repoStyleProfile ?? void 0,
|
|
2590
|
+
pageObjectInventory: config.repoPageObjects ?? void 0,
|
|
2591
|
+
jobId,
|
|
2592
|
+
onProgress: (done, total) => {
|
|
2593
|
+
progressChain = progressChain.then(() => updateJobProgress(tenantId, jobId, Math.floor(done / total * 95), `Generated ${done}/${total} scripts`)).catch(() => {
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
await progressChain.catch(() => {
|
|
2598
|
+
});
|
|
2599
|
+
await import("@taodigital/agents").then(({ refreshScriptInventory }) => refreshScriptInventory(tenantId, projectId)).catch((e) => logger.warn({ err: e?.message }, "[generate-script] refreshScriptInventory failed"));
|
|
2600
|
+
await withTenant(
|
|
2601
|
+
tenantId,
|
|
2602
|
+
(tx) => tx.platformJob.update({
|
|
2603
|
+
where: { id: jobId },
|
|
2604
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result }
|
|
2605
|
+
})
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
} catch (e) {
|
|
2609
|
+
await finishTrackedJob(tenantId, jobId, e?.message ?? "generate-script failed");
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
function queueImportedArtifactSkillRefresh(tenantId, projectId, reason) {
|
|
2613
|
+
setImmediate(() => {
|
|
2614
|
+
import("@taodigital/agents").then((agentsModule) => {
|
|
2615
|
+
const agents = agentsModule;
|
|
2616
|
+
return Promise.allSettled([
|
|
2617
|
+
agents.refreshArtifactKnowledgeSkillFile(tenantId, projectId),
|
|
2618
|
+
agents.refreshRequirementsCoverageSkillFile(tenantId, projectId)
|
|
2619
|
+
]);
|
|
2620
|
+
}).then((results) => {
|
|
2621
|
+
results?.forEach((result) => {
|
|
2622
|
+
if (result.status === "rejected") {
|
|
2623
|
+
console.warn(`[artifacts] skill refresh failed after ${reason}:`, result.reason?.message ?? result.reason);
|
|
2624
|
+
}
|
|
2625
|
+
});
|
|
2626
|
+
}).catch((e) => console.warn(`[artifacts] skill refresh failed after ${reason}:`, e?.message ?? e));
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
const CONTENT_DETECTED_DOC_TYPES = /* @__PURE__ */ new Set(["FUNCTIONIZE_TC", "SELENIUM_SCRIPT", "ENV_CONFIG"]);
|
|
2630
|
+
const AI_DOC_TYPES = /* @__PURE__ */ new Set([
|
|
2631
|
+
"REQUIREMENTS",
|
|
2632
|
+
"USER_STORY",
|
|
2633
|
+
"TEST_CASES",
|
|
2634
|
+
"TEST_DATA",
|
|
2635
|
+
"AUTOMATION_SCRIPT",
|
|
2636
|
+
"API_SPEC",
|
|
2637
|
+
"ARCHITECTURE",
|
|
2638
|
+
"REPORT",
|
|
2639
|
+
"OTHER",
|
|
2640
|
+
"SPREADSHEET",
|
|
2641
|
+
"DATASHEET"
|
|
2642
|
+
]);
|
|
2643
|
+
const AI_DOC_TYPE_ALIASES = {
|
|
2644
|
+
REQUIREMENT: "REQUIREMENTS",
|
|
2645
|
+
SPEC: "REQUIREMENTS",
|
|
2646
|
+
SPECIFICATION: "REQUIREMENTS",
|
|
2647
|
+
USER_STORIES: "USER_STORY",
|
|
2648
|
+
STORY: "USER_STORY",
|
|
2649
|
+
TEST_CASE: "TEST_CASES",
|
|
2650
|
+
TESTCASES: "TEST_CASES",
|
|
2651
|
+
TESTDATA: "TEST_DATA",
|
|
2652
|
+
SCRIPT: "AUTOMATION_SCRIPT",
|
|
2653
|
+
AUTOMATION: "AUTOMATION_SCRIPT"
|
|
2654
|
+
};
|
|
2655
|
+
function normalizeAiDocType(raw) {
|
|
2656
|
+
const key = String(raw ?? "").trim().toUpperCase().replace(/[\s-]+/g, "_");
|
|
2657
|
+
const mapped = AI_DOC_TYPE_ALIASES[key] ?? key;
|
|
2658
|
+
return AI_DOC_TYPES.has(mapped) ? mapped : "OTHER";
|
|
2659
|
+
}
|
|
2660
|
+
async function processArtifactIntelJob(jobId, config) {
|
|
2661
|
+
const { tenantId, projectId, artifactId, artifactName, isReprocess } = config;
|
|
2662
|
+
try {
|
|
2663
|
+
await updateJobProgress(tenantId, jobId, 10, "Downloading artifact content");
|
|
2664
|
+
const artifact = await withTenant(
|
|
2665
|
+
tenantId,
|
|
2666
|
+
(tx) => tx.projectArtifact.findUnique({ where: { id: artifactId }, select: { storageKey: true, docType: true } })
|
|
2667
|
+
);
|
|
2668
|
+
if (!artifact || !artifact.storageKey) {
|
|
2669
|
+
await updateJobProgress(tenantId, jobId, 100, "Artifact deleted or missing content, skipping.");
|
|
2670
|
+
await withTenant(
|
|
2671
|
+
tenantId,
|
|
2672
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date() } })
|
|
2673
|
+
);
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
const storageCfg = await getTenantStorageConfig(tenantId);
|
|
2677
|
+
if (!storageCfg) {
|
|
2678
|
+
throw new Error("Tenant storage configuration missing.");
|
|
2679
|
+
}
|
|
2680
|
+
const contentBuffer = await downloadBufferFromTenantStorage(storageCfg, artifact.storageKey);
|
|
2681
|
+
const content = contentBuffer.toString("utf8");
|
|
2682
|
+
await updateJobProgress(tenantId, jobId, 40, "Generating intelligence");
|
|
2683
|
+
const intel = await generateArtifactIntelligence(tenantId, projectId, artifactId, content, artifactName, jobId);
|
|
2684
|
+
const finalDocType = artifact.docType && CONTENT_DETECTED_DOC_TYPES.has(artifact.docType) ? artifact.docType : normalizeAiDocType(intel.docType);
|
|
2685
|
+
if (finalDocType !== intel.docType) {
|
|
2686
|
+
await withTenant(
|
|
2687
|
+
tenantId,
|
|
2688
|
+
(tx) => tx.projectArtifact.update({ where: { id: artifactId }, data: { docType: finalDocType } })
|
|
2689
|
+
).catch((err) => console.warn(`[artifact-intel] docType fix-up failed for "${artifactName}":`, err?.message));
|
|
2690
|
+
intel.docType = finalDocType;
|
|
2691
|
+
}
|
|
2692
|
+
await updateJobProgress(tenantId, jobId, 90, "Saving results");
|
|
2693
|
+
await withTenant(
|
|
2694
|
+
tenantId,
|
|
2695
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { result: { artifactId, artifactName, summary: intel.summary, keywords: intel.keywords, docType: intel.docType }, status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100 } })
|
|
2696
|
+
);
|
|
2697
|
+
queueImportedArtifactSkillRefresh(tenantId, projectId, isReprocess ? "artifact-reprocess" : "artifact-intel");
|
|
2698
|
+
} catch (err) {
|
|
2699
|
+
const msg = String(err?.message ?? err);
|
|
2700
|
+
logger.error({ jobId, err: msg }, "[artifact-intel] failed");
|
|
2701
|
+
await withTenant(
|
|
2702
|
+
tenantId,
|
|
2703
|
+
(tx) => tx.projectArtifact.update({ where: { id: artifactId }, data: { status: "ERROR", summary: "AI analysis failed" } })
|
|
2704
|
+
).catch(() => {
|
|
2705
|
+
});
|
|
2706
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2707
|
+
queueImportedArtifactSkillRefresh(tenantId, projectId, "artifact-intel-failed");
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
async function processPostmanRunJob(jobId, config) {
|
|
2711
|
+
const { tenantId, projectId, runId, collection, environment, timeoutMs = 3e5 } = config;
|
|
2712
|
+
await withTenant(
|
|
2713
|
+
tenantId,
|
|
2714
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: /* @__PURE__ */ new Date() } })
|
|
2715
|
+
);
|
|
2716
|
+
let historyId = null;
|
|
2717
|
+
try {
|
|
2718
|
+
await updateJobProgress(tenantId, jobId, 10, "Preparing Newman run");
|
|
2719
|
+
const tmpDir = path.join(os.tmpdir(), "zeta-newman", String(runId ?? jobId));
|
|
2720
|
+
await fs.mkdir(tmpDir, { recursive: true });
|
|
2721
|
+
try {
|
|
2722
|
+
const hist = await prismaAdmin.apiTestRunHistory.create({
|
|
2723
|
+
data: { tenantId, projectId, status: "running", source: "newman" },
|
|
2724
|
+
select: { id: true }
|
|
2725
|
+
});
|
|
2726
|
+
historyId = hist.id;
|
|
2727
|
+
} catch {
|
|
2728
|
+
}
|
|
2729
|
+
const collectionPath = path.join(tmpDir, "collection.json");
|
|
2730
|
+
const envPath = path.join(tmpDir, "environment.json");
|
|
2731
|
+
const resultsPath = path.join(tmpDir, "results.json");
|
|
2732
|
+
await fs.writeFile(collectionPath, JSON.stringify(collection ?? {}));
|
|
2733
|
+
if (environment) await fs.writeFile(envPath, JSON.stringify(environment));
|
|
2734
|
+
const iterationDataPath = path.join(tmpDir, "iteration-data.json");
|
|
2735
|
+
if (config.iterationDataJson) {
|
|
2736
|
+
await fs.writeFile(iterationDataPath, config.iterationDataJson);
|
|
2737
|
+
}
|
|
2738
|
+
const newmanArgs = [
|
|
2739
|
+
"--yes",
|
|
2740
|
+
"newman",
|
|
2741
|
+
"run",
|
|
2742
|
+
collectionPath,
|
|
2743
|
+
...environment ? ["-e", envPath] : [],
|
|
2744
|
+
...config.iterationDataJson ? ["--iteration-data", iterationDataPath] : [],
|
|
2745
|
+
"--reporters",
|
|
2746
|
+
"json",
|
|
2747
|
+
"--reporter-json-export",
|
|
2748
|
+
resultsPath,
|
|
2749
|
+
"--timeout",
|
|
2750
|
+
String(timeoutMs),
|
|
2751
|
+
"--suppress-exit-code",
|
|
2752
|
+
"--disable-unicode"
|
|
2753
|
+
];
|
|
2754
|
+
await updateJobProgress(tenantId, jobId, 30, "Running Newman");
|
|
2755
|
+
await new Promise((resolve, reject) => {
|
|
2756
|
+
const proc = spawn("npx", newmanArgs, {
|
|
2757
|
+
cwd: tmpDir,
|
|
2758
|
+
timeout: timeoutMs + 1e4,
|
|
2759
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2760
|
+
});
|
|
2761
|
+
proc.on("close", () => resolve());
|
|
2762
|
+
proc.on("error", reject);
|
|
2763
|
+
});
|
|
2764
|
+
await updateJobProgress(tenantId, jobId, 80, "Parsing Newman results");
|
|
2765
|
+
let summary = { total: 0, passed: 0, failed: 0, skipped: 0, durationMs: 0 };
|
|
2766
|
+
try {
|
|
2767
|
+
const raw = await fs.readFile(resultsPath, "utf-8");
|
|
2768
|
+
const results = JSON.parse(raw);
|
|
2769
|
+
const executions = results.run?.executions ?? [];
|
|
2770
|
+
for (const exec of executions) {
|
|
2771
|
+
const assertions = exec.assertions ?? [];
|
|
2772
|
+
const failed = assertions.filter((a) => !!a.error).length;
|
|
2773
|
+
const status = failed === 0 ? "passed" : "failed";
|
|
2774
|
+
summary.total++;
|
|
2775
|
+
if (status === "passed") summary.passed++;
|
|
2776
|
+
else summary.failed++;
|
|
2777
|
+
}
|
|
2778
|
+
const timings = results.run?.timings ?? {};
|
|
2779
|
+
summary.durationMs = (timings.completed ?? 0) - (timings.started ?? 0);
|
|
2780
|
+
logger.info({ tenantId, projectId, runId, jobId, ...summary }, "[newman] run complete");
|
|
2781
|
+
if (historyId) {
|
|
2782
|
+
const requestRows = executions.map((exec) => {
|
|
2783
|
+
const assertions = exec.assertions ?? [];
|
|
2784
|
+
const failed = assertions.filter((a) => !!a.error).length;
|
|
2785
|
+
return {
|
|
2786
|
+
runHistoryId: historyId,
|
|
2787
|
+
requestName: exec.item?.name ?? exec.item?.id ?? "Unknown",
|
|
2788
|
+
status: failed === 0 ? "passed" : "failed",
|
|
2789
|
+
statusCode: exec.response?.code ?? null,
|
|
2790
|
+
durationMs: exec.response?.responseTime ?? null,
|
|
2791
|
+
assertions: assertions.length > 0 ? assertions : void 0,
|
|
2792
|
+
requestUrl: exec.request?.url?.raw ?? null,
|
|
2793
|
+
error: failed > 0 ? assertions.find((a) => a.error)?.error?.message ?? "Assertion failed" : null
|
|
2794
|
+
};
|
|
2795
|
+
});
|
|
2796
|
+
try {
|
|
2797
|
+
await prismaAdmin.apiTestRunRequest.createMany({ data: requestRows });
|
|
2798
|
+
await prismaAdmin.apiTestRunHistory.update({
|
|
2799
|
+
where: { id: historyId },
|
|
2800
|
+
data: {
|
|
2801
|
+
status: summary.failed > 0 ? "failed" : "passed",
|
|
2802
|
+
summary,
|
|
2803
|
+
completedAt: /* @__PURE__ */ new Date()
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
} catch (e) {
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
} catch (err) {
|
|
2810
|
+
logger.error({ err, jobId }, "[newman] failed to parse results");
|
|
2811
|
+
}
|
|
2812
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
2813
|
+
});
|
|
2814
|
+
await withTenant(
|
|
2815
|
+
tenantId,
|
|
2816
|
+
(tx) => tx.platformJob.update({
|
|
2817
|
+
where: { id: jobId },
|
|
2818
|
+
data: { status: "DONE", progress: 100, finishedAt: /* @__PURE__ */ new Date(), result: summary }
|
|
2819
|
+
})
|
|
2820
|
+
);
|
|
2821
|
+
} catch (err) {
|
|
2822
|
+
logger.error({ jobId, err: String(err?.message ?? err) }, "[newman] job failed");
|
|
2823
|
+
if (historyId) {
|
|
2824
|
+
await prismaAdmin.apiTestRunHistory.update({
|
|
2825
|
+
where: { id: historyId },
|
|
2826
|
+
data: { status: "error", completedAt: /* @__PURE__ */ new Date() }
|
|
2827
|
+
}).catch(() => {
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
jobDispatchTable = {
|
|
2834
|
+
ARTIFACT_INTEL: processArtifactIntelJob,
|
|
2835
|
+
CRAWL: processJob,
|
|
2836
|
+
CAPTURE_STEPS: processCaptureStepsJob,
|
|
2837
|
+
DISCOVER: processDiscoverJob,
|
|
2838
|
+
MONITOR: processMonitorJob,
|
|
2839
|
+
NATIVE_CRAWL: processNativeCrawlJob,
|
|
2840
|
+
API_TESTS: processApiTestsJob,
|
|
2841
|
+
SECURITY_TESTS: processSecurityTestsJob,
|
|
2842
|
+
INTEGRATION_TESTS: processIntegrationTestsJob,
|
|
2843
|
+
FIGMA_IMPORT: processFigmaImportJob,
|
|
2844
|
+
GENERATE_SKILL_FILES: processGenerateSkillFilesJob,
|
|
2845
|
+
GENERATE_TESTS_FROM_REQS: processGenerateTestsFromReqsJob,
|
|
2846
|
+
GENERATE_SCREEN_TESTS: processGenerateScreenTestsJob,
|
|
2847
|
+
RUN_TEST_SUITE: processRunTestSuiteJob,
|
|
2848
|
+
REPO_SCAN: processRepoScanJob,
|
|
2849
|
+
IMPORT_TESTS: processImportTestsJob,
|
|
2850
|
+
BATCH_GENERATE_ALIGNED: processBatchGenerateAlignedJob,
|
|
2851
|
+
CREATE_PR: processCreatePRJob,
|
|
2852
|
+
NEW_PAGES_PIPELINE: processNewPagesPipelineJob,
|
|
2853
|
+
MULTI_ROLE_CRAWL: processMultiRoleCrawlJob,
|
|
2854
|
+
ROLE_CRAWL: processRoleCrawlJob,
|
|
2855
|
+
GENERATE_SCRIPT: processGenerateScriptJob,
|
|
2856
|
+
POSTMAN_RUN: processPostmanRunJob,
|
|
2857
|
+
// The following job types are still processed in-process by the API.
|
|
2858
|
+
// If the worker claims one, the API crashed after creating it — mark failed so it's visible.
|
|
2859
|
+
GENERATE_TESTS: async (jobId, _config) => {
|
|
2860
|
+
logger.error({ jobId }, "[job-worker] GENERATE_TESTS claimed by worker \u2014 API did not process it in-process");
|
|
2861
|
+
await prismaAdmin.platformJob.update({
|
|
2862
|
+
where: { id: jobId },
|
|
2863
|
+
data: { status: "FAILED", error: "Test generation was not completed by the API \u2014 please retry from the UI.", finishedAt: /* @__PURE__ */ new Date() }
|
|
2864
|
+
}).catch(() => {
|
|
2865
|
+
});
|
|
2866
|
+
},
|
|
2867
|
+
GENERATE_DATA: async (jobId, _config) => {
|
|
2868
|
+
logger.error({ jobId }, "[job-worker] GENERATE_DATA claimed by worker \u2014 API did not process it in-process");
|
|
2869
|
+
await prismaAdmin.platformJob.update({
|
|
2870
|
+
where: { id: jobId },
|
|
2871
|
+
data: { status: "FAILED", error: "Data generation was not completed by the API \u2014 please retry from the UI.", finishedAt: /* @__PURE__ */ new Date() }
|
|
2872
|
+
}).catch(() => {
|
|
2873
|
+
});
|
|
2874
|
+
},
|
|
2875
|
+
GENERATE_TEST_PLAN: async (jobId, _config) => {
|
|
2876
|
+
logger.error({ jobId }, "[job-worker] GENERATE_TEST_PLAN claimed by worker \u2014 API did not process it in-process");
|
|
2877
|
+
await prismaAdmin.platformJob.update({
|
|
2878
|
+
where: { id: jobId },
|
|
2879
|
+
data: { status: "FAILED", error: "Test plan generation was not completed by the API \u2014 please retry from the UI.", finishedAt: /* @__PURE__ */ new Date() }
|
|
2880
|
+
}).catch(() => {
|
|
2881
|
+
});
|
|
2882
|
+
},
|
|
2883
|
+
SCREENSHOT_ENHANCE: async (jobId, config) => {
|
|
2884
|
+
const { tenantId, projectId } = config;
|
|
2885
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
2886
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } }).catch(() => {
|
|
2887
|
+
});
|
|
2888
|
+
try {
|
|
2889
|
+
const cfg = config;
|
|
2890
|
+
const { analyzeScreenshotForEnhancement, diffScreenshotElementsAgainstTestCases, updateProjectContextSources } = await import("@taodigital/agents");
|
|
2891
|
+
const artifact = await prismaAdmin.projectArtifact?.findFirst({ where: { id: cfg.artifactId, tenantId } }) ?? await prismaAdmin.artifact?.findFirst({ where: { id: cfg.artifactId, tenantId } });
|
|
2892
|
+
if (!artifact) {
|
|
2893
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { skipped: true, reason: "artifact not found" } } }).catch(() => {
|
|
2894
|
+
});
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
let imageBase64 = "";
|
|
2898
|
+
if (artifact.cloudUrl) {
|
|
2899
|
+
try {
|
|
2900
|
+
const res = await fetch(artifact.cloudUrl);
|
|
2901
|
+
const buf = await res.arrayBuffer();
|
|
2902
|
+
imageBase64 = Buffer.from(buf).toString("base64");
|
|
2903
|
+
} catch (_) {
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
const analysisResult = await analyzeScreenshotForEnhancement(tenantId, projectId, cfg.artifactId, imageBase64);
|
|
2907
|
+
const testCases = await prismaAdmin.testCase.findMany({ where: { tenantId, projectId }, take: 100 });
|
|
2908
|
+
const payload = await diffScreenshotElementsAgainstTestCases(tenantId, projectId, analysisResult.elements, testCases);
|
|
2909
|
+
const enhancement = await prismaAdmin.contextEnhancement.create({
|
|
2910
|
+
data: { tenantId, projectId, sourceType: "SCREENSHOT", sourceRef: cfg.artifactId, status: payload.proposedChanges.length > 0 ? "DIFF_READY" : "APPROVED", proposedChanges: payload.proposedChanges }
|
|
2911
|
+
});
|
|
2912
|
+
if (analysisResult.elements.length > 0) {
|
|
2913
|
+
await prismaAdmin.extractedElement.createMany({ data: analysisResult.elements.map((el) => ({ tenantId, projectId, sourceEnhancementId: enhancement.id, ...el })) }).catch(() => {
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
await updateProjectContextSources(tenantId, projectId, "SCREENSHOT", { count: 1 });
|
|
2917
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100, result: { enhancementId: enhancement.id, elementsFound: analysisResult.elements.length, changesProposed: payload.proposedChanges.length } } }).catch(() => {
|
|
2918
|
+
});
|
|
2919
|
+
} catch (err) {
|
|
2920
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2921
|
+
}
|
|
2922
|
+
},
|
|
2923
|
+
FIGMA_ENHANCE: async (jobId, config) => {
|
|
2924
|
+
const { tenantId, projectId } = config;
|
|
2925
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
2926
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } }).catch(() => {
|
|
2927
|
+
});
|
|
2928
|
+
try {
|
|
2929
|
+
const { analyzeFigmaComponentsForEnhancement, updateProjectContextSources } = await import("@taodigital/agents");
|
|
2930
|
+
const figmaComponents = await prismaAdmin.figmaComponent.findMany({ where: { tenantId, projectId } });
|
|
2931
|
+
const testCases = await prismaAdmin.testCase.findMany({ where: { tenantId, projectId }, take: 100 });
|
|
2932
|
+
const payload = await analyzeFigmaComponentsForEnhancement(tenantId, projectId, figmaComponents, testCases);
|
|
2933
|
+
const enhancement = await prismaAdmin.contextEnhancement.create({
|
|
2934
|
+
data: { tenantId, projectId, sourceType: "FIGMA", status: payload.proposedChanges.length > 0 ? "DIFF_READY" : "APPROVED", proposedChanges: payload.proposedChanges }
|
|
2935
|
+
});
|
|
2936
|
+
await updateProjectContextSources(tenantId, projectId, "FIGMA", { importedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2937
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100, result: { enhancementId: enhancement.id, changesProposed: payload.proposedChanges.length } } }).catch(() => {
|
|
2938
|
+
});
|
|
2939
|
+
} catch (err) {
|
|
2940
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2941
|
+
}
|
|
2942
|
+
},
|
|
2943
|
+
CRAWL_DIFF: async (jobId, config) => {
|
|
2944
|
+
const { tenantId, projectId } = config;
|
|
2945
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
2946
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } }).catch(() => {
|
|
2947
|
+
});
|
|
2948
|
+
try {
|
|
2949
|
+
const { computeCrawlDiffs } = await import("@taodigital/agents");
|
|
2950
|
+
const { fromJobId, toJobId } = config;
|
|
2951
|
+
const result = await computeCrawlDiffs(tenantId, projectId, fromJobId, toJobId);
|
|
2952
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100, result } }).catch(() => {
|
|
2953
|
+
});
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2956
|
+
}
|
|
2957
|
+
},
|
|
2958
|
+
ANALYZE_FAILURES: async (jobId, config) => {
|
|
2959
|
+
const { tenantId, projectId } = config;
|
|
2960
|
+
const jobStartedAt = /* @__PURE__ */ new Date();
|
|
2961
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: jobStartedAt } }).catch(() => {
|
|
2962
|
+
});
|
|
2963
|
+
try {
|
|
2964
|
+
const { analyzeTestRunFailures, computeRequirementCoverage, computeCoverageGaps } = await import("@taodigital/agents");
|
|
2965
|
+
const { runId } = config;
|
|
2966
|
+
const [failures, coverage, gaps] = await Promise.all([
|
|
2967
|
+
analyzeTestRunFailures(tenantId, projectId, runId),
|
|
2968
|
+
computeRequirementCoverage(tenantId, projectId),
|
|
2969
|
+
computeCoverageGaps(tenantId, projectId)
|
|
2970
|
+
]);
|
|
2971
|
+
await prismaAdmin.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), progress: 100, result: { failures, coverage, gaps } } }).catch(() => {
|
|
2972
|
+
});
|
|
2973
|
+
} catch (err) {
|
|
2974
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
};
|
|
2978
|
+
async function processRepoScanJob(jobId, config) {
|
|
2979
|
+
const { repoConnectionId, tenantId, projectId } = config;
|
|
2980
|
+
const jobStart = /* @__PURE__ */ new Date();
|
|
2981
|
+
try {
|
|
2982
|
+
await updateJobProgress(tenantId, jobId, 5, "Starting repo scan");
|
|
2983
|
+
const {
|
|
2984
|
+
fetchRepoTree,
|
|
2985
|
+
fetchFileContent,
|
|
2986
|
+
detectFramework,
|
|
2987
|
+
extractTestIds,
|
|
2988
|
+
extractStyleProfile,
|
|
2989
|
+
buildPageObjectInventory,
|
|
2990
|
+
resolveFixtureAliasesFromImports
|
|
2991
|
+
} = await import("@taodigital/agents");
|
|
2992
|
+
const conn = await withTenant(
|
|
2993
|
+
tenantId,
|
|
2994
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
2995
|
+
);
|
|
2996
|
+
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
2997
|
+
const { decrypt: decrypt2 } = await import("@taodigital/core");
|
|
2998
|
+
const token = decrypt2(conn.tokenEnc);
|
|
2999
|
+
await withTenant(
|
|
3000
|
+
tenantId,
|
|
3001
|
+
(tx) => tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: "SCANNING", scanError: null } })
|
|
3002
|
+
);
|
|
3003
|
+
await updateJobProgress(tenantId, jobId, 15, "Fetching repo tree");
|
|
3004
|
+
const treeResult = await fetchRepoTree(token, conn.repoFullName, "HEAD");
|
|
3005
|
+
if (treeResult.truncated) {
|
|
3006
|
+
console.warn(`[repo-scan] ${conn.repoFullName}: tree truncated (>100k blobs) \u2014 detection may be incomplete`);
|
|
3007
|
+
await withTenant(
|
|
3008
|
+
tenantId,
|
|
3009
|
+
(tx) => tx.gitHubRepoConnection.update({
|
|
3010
|
+
where: { id: repoConnectionId },
|
|
3011
|
+
data: { scanError: "Repository tree was truncated by GitHub (>100k files) \u2014 some test files in deep subdirectories may be missing from this scan." }
|
|
3012
|
+
})
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
const frameworks = detectFramework(treeResult.paths);
|
|
3016
|
+
const primary = frameworks[0];
|
|
3017
|
+
await updateJobProgress(tenantId, jobId, 30, `Detected framework: ${primary?.framework ?? "unknown"}`);
|
|
3018
|
+
const storedGlob = conn.testGlob || "";
|
|
3019
|
+
const testGlob = storedGlob && storedGlob !== "**/*.spec.ts" ? storedGlob : primary?.testGlob || storedGlob || "**/*.spec.ts";
|
|
3020
|
+
const framework = primary?.framework ?? "unknown";
|
|
3021
|
+
const language = primary?.language ?? "typescript";
|
|
3022
|
+
const { minimatch } = await import("minimatch").catch(() => ({ minimatch: null }));
|
|
3023
|
+
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/"));
|
|
3024
|
+
const isJavaTestFile = (p) => p.endsWith(".java") && (/[A-Z]\w*Tests?\.java$/.test(p) || /\/tests?\//.test(p) && /[A-Z][a-z]/.test(p.split("/").pop() ?? ""));
|
|
3025
|
+
const testGlobs = minimatch ? Array.from(/* @__PURE__ */ new Set([
|
|
3026
|
+
testGlob,
|
|
3027
|
+
...testGlob === "**/*.spec.ts" ? ["**/*.test.ts", "**/*.cy.ts"] : [],
|
|
3028
|
+
...testGlob === "**/*.spec.js" ? ["**/*.test.js", "**/*.cy.js"] : []
|
|
3029
|
+
])) : [];
|
|
3030
|
+
const testPaths = treeResult.paths.filter((p) => {
|
|
3031
|
+
if (language === "java") return isJavaTestFile(p);
|
|
3032
|
+
if (language === "python") return isPythonTestFile(p);
|
|
3033
|
+
if (testGlobs.length) return testGlobs.some((g) => minimatch(p, g, { matchBase: true }));
|
|
3034
|
+
return p.endsWith(".spec.ts") || p.endsWith(".spec.js") || p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith(".cy.ts") || p.endsWith(".cy.js") || p.endsWith("_test.py") || p.includes("/test_");
|
|
3035
|
+
}).slice(0, 2e3);
|
|
3036
|
+
await updateJobProgress(tenantId, jobId, 40, `Found ${testPaths.length} test files`);
|
|
3037
|
+
if (testPaths.length === 0) {
|
|
3038
|
+
logger.warn({ jobId, repoConnectionId, framework }, "[repo-scan] no test files matched glob \u2014 scan complete with 0 results");
|
|
3039
|
+
await withTenant(
|
|
3040
|
+
tenantId,
|
|
3041
|
+
(tx) => tx.gitHubRepoConnection.update({
|
|
3042
|
+
where: { id: repoConnectionId },
|
|
3043
|
+
data: { scanStatus: "DONE", testFileCount: 0, extractedIdCount: 0, lastScannedAt: /* @__PURE__ */ new Date(), lastSyncAt: /* @__PURE__ */ new Date() }
|
|
3044
|
+
})
|
|
3045
|
+
);
|
|
3046
|
+
await updateJobProgress(tenantId, jobId, 100, "Done: 0 test files found");
|
|
3047
|
+
await withTenant(
|
|
3048
|
+
tenantId,
|
|
3049
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { testFileCount: 0, extractedIdCount: 0 } } })
|
|
3050
|
+
);
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
const allExtracted = [];
|
|
3054
|
+
const styleSampleFiles = [];
|
|
3055
|
+
let processed = 0;
|
|
3056
|
+
await withTenant(tenantId, (tx) => tx.repoTestFile.deleteMany({ where: { repoConnectionId } }));
|
|
3057
|
+
for (const filePath of testPaths) {
|
|
3058
|
+
const content = await fetchFileContent(token, conn.repoFullName, filePath);
|
|
3059
|
+
if (!content) continue;
|
|
3060
|
+
const extraAliases = await resolveFixtureAliasesFromImports(
|
|
3061
|
+
content,
|
|
3062
|
+
token,
|
|
3063
|
+
conn.repoFullName,
|
|
3064
|
+
filePath
|
|
3065
|
+
).catch(() => /* @__PURE__ */ new Set());
|
|
3066
|
+
const extracted = extractTestIds(content, filePath, framework, extraAliases);
|
|
3067
|
+
allExtracted.push(...extracted);
|
|
3068
|
+
await withTenant(
|
|
3069
|
+
tenantId,
|
|
3070
|
+
(tx) => tx.repoTestFile.create({
|
|
3071
|
+
data: {
|
|
3072
|
+
tenantId,
|
|
3073
|
+
projectId,
|
|
3074
|
+
repoConnectionId,
|
|
3075
|
+
filePath,
|
|
3076
|
+
framework,
|
|
3077
|
+
extractedIds: extracted.map((e) => ({ name: e.name, normalizedName: e.normalizedName, lineNumber: e.lineNumber })),
|
|
3078
|
+
rawSample: content.slice(0, 2e3),
|
|
3079
|
+
lastScannedAt: /* @__PURE__ */ new Date()
|
|
3080
|
+
}
|
|
3081
|
+
})
|
|
3082
|
+
);
|
|
3083
|
+
if (styleSampleFiles.length < 5) styleSampleFiles.push({ path: filePath, content: content.slice(0, 3e3) });
|
|
3084
|
+
processed++;
|
|
3085
|
+
if (processed % 10 === 0) {
|
|
3086
|
+
await updateJobProgress(tenantId, jobId, 40 + Math.floor(processed / testPaths.length * 40), `Processed ${processed}/${testPaths.length} files`);
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
const configPaths = {};
|
|
3090
|
+
try {
|
|
3091
|
+
const pathSet = new Set(treeResult.paths);
|
|
3092
|
+
const lang = framework.includes("java") ? "java" : framework.includes("py") || framework === "pytest" ? "python" : "js";
|
|
3093
|
+
const configFilesToFetch = [];
|
|
3094
|
+
const tryAdd = (candidates, type) => {
|
|
3095
|
+
for (const c of candidates) {
|
|
3096
|
+
if (pathSet.has(c)) {
|
|
3097
|
+
configFilesToFetch.push({ path: c, type, dirPrefix: "" });
|
|
3098
|
+
break;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
};
|
|
3102
|
+
const scanTree = (pattern, type) => {
|
|
3103
|
+
if (configFilesToFetch.some((f) => f.type === type)) return;
|
|
3104
|
+
const all = treeResult.paths.filter((p) => pattern.test(p)).sort((a, b) => a.split("/").length - b.split("/").length);
|
|
3105
|
+
if (all[0]) {
|
|
3106
|
+
const lastSlash = all[0].lastIndexOf("/");
|
|
3107
|
+
const dirPrefix = lastSlash > 0 ? all[0].slice(0, lastSlash + 1) : "";
|
|
3108
|
+
configFilesToFetch.push({ path: all[0], type, dirPrefix });
|
|
3109
|
+
}
|
|
3110
|
+
};
|
|
3111
|
+
if (lang === "js") {
|
|
3112
|
+
if (framework.startsWith("playwright")) {
|
|
3113
|
+
tryAdd(["playwright.config.ts", "playwright.config.js"], "playwright");
|
|
3114
|
+
scanTree(/(?:^|\/)playwright\.config\.(ts|js|mts)$/, "playwright");
|
|
3115
|
+
}
|
|
3116
|
+
if (framework.startsWith("cypress")) {
|
|
3117
|
+
tryAdd(["cypress.config.ts", "cypress.config.js"], "cypress");
|
|
3118
|
+
scanTree(/(?:^|\/)cypress\.config\.(ts|js)$/, "cypress");
|
|
3119
|
+
}
|
|
3120
|
+
if (framework === "jest-ts" || framework === "jest-js") {
|
|
3121
|
+
tryAdd(["jest.config.ts", "jest.config.js", "jest.config.mjs"], "jest");
|
|
3122
|
+
scanTree(/(?:^|\/)jest\.config\.(ts|js|mjs|cjs)$/, "jest");
|
|
3123
|
+
tryAdd(["package.json"], "package-json");
|
|
3124
|
+
}
|
|
3125
|
+
if (framework === "webdriverio") {
|
|
3126
|
+
tryAdd(["wdio.conf.ts", "wdio.conf.js", "wdio.config.ts", "wdio.config.js"], "wdio");
|
|
3127
|
+
scanTree(/(?:^|\/)wdio\.(?:conf|config)\.(ts|js|mjs)$/, "wdio");
|
|
3128
|
+
}
|
|
3129
|
+
if (framework === "cucumber" || framework === "gherkin") {
|
|
3130
|
+
tryAdd([".cucumber.js", ".cucumber.cjs", "cucumber.json", ".cucumber.yaml", ".cucumber.yml"], "cucumber");
|
|
3131
|
+
tryAdd(["package.json"], "package-json");
|
|
3132
|
+
}
|
|
3133
|
+
tryAdd(["tsconfig.json"], "tsconfig");
|
|
3134
|
+
scanTree(/(?:^|\/)tsconfig(?:\.base)?\.json$/, "tsconfig");
|
|
3135
|
+
} else if (lang === "python") {
|
|
3136
|
+
tryAdd(["pytest.ini"], "pytest-ini");
|
|
3137
|
+
scanTree(/(?:^|\/)pytest\.ini$/, "pytest-ini");
|
|
3138
|
+
tryAdd(["pyproject.toml"], "pyproject-toml");
|
|
3139
|
+
scanTree(/(?:^|\/)pyproject\.toml$/, "pyproject-toml");
|
|
3140
|
+
tryAdd(["setup.cfg"], "setup-cfg");
|
|
3141
|
+
} else if (lang === "java") {
|
|
3142
|
+
tryAdd(["pom.xml"], "pom-xml");
|
|
3143
|
+
scanTree(/(?:^|\/)pom\.xml$/, "pom-xml");
|
|
3144
|
+
tryAdd(["build.gradle.kts", "build.gradle"], "build-gradle");
|
|
3145
|
+
scanTree(/(?:^|\/)build\.gradle(?:\.kts)?$/, "build-gradle");
|
|
3146
|
+
tryAdd(["testng.xml", "src/test/resources/testng.xml"], "testng-xml");
|
|
3147
|
+
}
|
|
3148
|
+
const normDir = (s) => s.replace(/^\.\//, "").replace(/\/$/, "");
|
|
3149
|
+
const globRoot = (pattern) => {
|
|
3150
|
+
const root = pattern.replace(/\/\*\*.*$/, "").replace(/\/\*[^*].*$/, "");
|
|
3151
|
+
return root && !root.startsWith("*") && !root.startsWith("{") ? normDir(root) : null;
|
|
3152
|
+
};
|
|
3153
|
+
const fetched = await Promise.all(
|
|
3154
|
+
configFilesToFetch.map(async ({ path: cfgPath, type, dirPrefix }) => {
|
|
3155
|
+
const content = await fetchFileContent(token, conn.repoFullName, cfgPath).catch(() => null);
|
|
3156
|
+
if (!content) return null;
|
|
3157
|
+
const r = {};
|
|
3158
|
+
if (type === "playwright") {
|
|
3159
|
+
const m = content.match(/\btestDir\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
3160
|
+
if (m) r.testDir = normDir(m[1]);
|
|
3161
|
+
const bm = content.match(/\bbaseURL\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
3162
|
+
if (bm) r.baseURL = bm[1];
|
|
3163
|
+
} else if (type === "cypress") {
|
|
3164
|
+
const sm = content.match(/\bspecPattern\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
3165
|
+
if (sm) {
|
|
3166
|
+
const gr = globRoot(sm[1]);
|
|
3167
|
+
if (gr) r.testDir = gr;
|
|
3168
|
+
}
|
|
3169
|
+
const fm = content.match(/\bfixturesFolder\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
3170
|
+
if (fm) r.fixturesFolder = normDir(fm[1]);
|
|
3171
|
+
const bm = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
3172
|
+
if (bm) r.baseURL = bm[1];
|
|
3173
|
+
} else if (type === "jest") {
|
|
3174
|
+
const rm = content.match(/\broots\s*:\s*\[([^\]]+)\]/);
|
|
3175
|
+
if (rm) {
|
|
3176
|
+
const fr = rm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
|
|
3177
|
+
if (fr) r.testDir = fr.replace(/<rootDir>\/?/, "").replace(/\/$/, "") || "src";
|
|
3178
|
+
}
|
|
3179
|
+
if (!r.testDir) {
|
|
3180
|
+
const tm = content.match(/\btestMatch\s*:\s*\[([^\]]+)\]/);
|
|
3181
|
+
if (tm) {
|
|
3182
|
+
const fp = tm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
|
|
3183
|
+
if (fp) {
|
|
3184
|
+
const gr = globRoot(fp.replace(/<rootDir>\/?/, ""));
|
|
3185
|
+
if (gr) r.testDir = gr;
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
const mm = content.match(/\bmoduleNameMapper\s*:\s*\{([^}]+)\}/s);
|
|
3190
|
+
if (mm) {
|
|
3191
|
+
const map = {};
|
|
3192
|
+
for (const [, k, v] of mm[1].matchAll(/['"`]([^'"`]+)['"`]\s*:\s*['"`]([^'"`]+)['"`]/g)) map[k] = v;
|
|
3193
|
+
if (Object.keys(map).length) r.jestModuleNameMapper = map;
|
|
3194
|
+
}
|
|
3195
|
+
} else if (type === "package-json") {
|
|
3196
|
+
try {
|
|
3197
|
+
const p = JSON.parse(content);
|
|
3198
|
+
const j = p?.jest ?? {};
|
|
3199
|
+
if (Array.isArray(j.roots) && j.roots[0]) r.testDir = String(j.roots[0]).replace(/<rootDir>\/?/, "").replace(/\/$/, "") || "src";
|
|
3200
|
+
if (!r.testDir && Array.isArray(j.testMatch) && j.testMatch[0]) {
|
|
3201
|
+
const gr = globRoot(String(j.testMatch[0]).replace(/<rootDir>\/?/, ""));
|
|
3202
|
+
if (gr) r.testDir = gr;
|
|
3203
|
+
}
|
|
3204
|
+
if (j.moduleNameMapper && typeof j.moduleNameMapper === "object") r.jestModuleNameMapper = j.moduleNameMapper;
|
|
3205
|
+
} catch {
|
|
3206
|
+
}
|
|
3207
|
+
} else if (type === "tsconfig") {
|
|
3208
|
+
try {
|
|
3209
|
+
const stripped = content.replace(/\/\/[^\n]*/g, "").replace(/,(\s*[}\]])/g, "$1");
|
|
3210
|
+
const p = JSON.parse(stripped);
|
|
3211
|
+
const opts = p?.compilerOptions ?? {};
|
|
3212
|
+
if (opts.paths && typeof opts.paths === "object") r.tsconfigPaths = opts.paths;
|
|
3213
|
+
if (typeof opts.baseUrl === "string") r.tsconfigBaseUrl = opts.baseUrl;
|
|
3214
|
+
} catch {
|
|
3215
|
+
}
|
|
3216
|
+
} else if (type === "pytest-ini") {
|
|
3217
|
+
const m = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
|
|
3218
|
+
if (m) {
|
|
3219
|
+
const first = m[1].trim().split(/\s+/)[0];
|
|
3220
|
+
if (first) r.testDir = normDir(first);
|
|
3221
|
+
}
|
|
3222
|
+
} else if (type === "pyproject-toml") {
|
|
3223
|
+
const m = content.match(/testpaths\s*=\s*\[([^\]]+)\]/);
|
|
3224
|
+
if (m) {
|
|
3225
|
+
const first = m[1].match(/['"]([^'"]+)['"]/)?.[1];
|
|
3226
|
+
if (first) r.testDir = normDir(first);
|
|
3227
|
+
}
|
|
3228
|
+
if (!r.testDir) {
|
|
3229
|
+
const m2 = content.match(/testpaths\s*=\s*['"]([^'"]+)['"]/);
|
|
3230
|
+
if (m2) r.testDir = normDir(m2[1]);
|
|
3231
|
+
}
|
|
3232
|
+
} else if (type === "setup-cfg") {
|
|
3233
|
+
const m = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
|
|
3234
|
+
if (m) {
|
|
3235
|
+
const first = m[1].trim().split(/[\s,]+/)[0];
|
|
3236
|
+
if (first) r.testDir = normDir(first);
|
|
3237
|
+
}
|
|
3238
|
+
} else if (type === "pom-xml") {
|
|
3239
|
+
const m = content.match(/<testSourceDirectory>\s*([^<]+)\s*<\/testSourceDirectory>/);
|
|
3240
|
+
if (m) r.javaTestSourceDir = normDir(m[1].trim());
|
|
3241
|
+
} else if (type === "build-gradle") {
|
|
3242
|
+
const m = content.match(/srcDir[s]?\s*[=([]?\s*['"`]([^'"`]+)['"`]/) ?? content.match(/srcDir\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
|
|
3243
|
+
if (m) r.javaTestSourceDir = normDir(m[1]);
|
|
3244
|
+
} else if (type === "wdio") {
|
|
3245
|
+
const sm = content.match(/\bspecs\s*:\s*\[([^\]]+)\]/);
|
|
3246
|
+
if (sm) {
|
|
3247
|
+
const fp = sm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
|
|
3248
|
+
if (fp) {
|
|
3249
|
+
const gr = globRoot(fp);
|
|
3250
|
+
if (gr) r.testDir = gr;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
const bm = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
3254
|
+
if (bm) r.baseURL = bm[1];
|
|
3255
|
+
} else if (type === "cucumber") {
|
|
3256
|
+
const pm = content.match(/\bpaths\s*:\s*\[([^\]]+)\]/);
|
|
3257
|
+
if (pm) {
|
|
3258
|
+
const fp = pm[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
|
|
3259
|
+
if (fp) {
|
|
3260
|
+
const gr = globRoot(fp);
|
|
3261
|
+
if (gr) r.testDir = gr;
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
if (!r.testDir) {
|
|
3265
|
+
try {
|
|
3266
|
+
const p = JSON.parse(content);
|
|
3267
|
+
const paths = p?.default?.paths ?? p?.paths;
|
|
3268
|
+
if (Array.isArray(paths) && paths[0]) {
|
|
3269
|
+
const gr = globRoot(String(paths[0]));
|
|
3270
|
+
if (gr) r.testDir = gr;
|
|
3271
|
+
}
|
|
3272
|
+
} catch {
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
if (dirPrefix && r.testDir && !r.testDir.startsWith(dirPrefix)) {
|
|
3277
|
+
r.testDir = dirPrefix + r.testDir;
|
|
3278
|
+
}
|
|
3279
|
+
if (dirPrefix && r.javaTestSourceDir && !r.javaTestSourceDir.startsWith(dirPrefix)) {
|
|
3280
|
+
r.javaTestSourceDir = dirPrefix + r.javaTestSourceDir;
|
|
3281
|
+
}
|
|
3282
|
+
return r;
|
|
3283
|
+
})
|
|
3284
|
+
);
|
|
3285
|
+
for (const parsed of fetched) {
|
|
3286
|
+
if (!parsed) continue;
|
|
3287
|
+
if (parsed.testDir && !configPaths.testDir) configPaths.testDir = parsed.testDir;
|
|
3288
|
+
if (parsed.fixturesFolder && !configPaths.fixturesFolder) configPaths.fixturesFolder = parsed.fixturesFolder;
|
|
3289
|
+
if (parsed.baseURL && !configPaths.baseURL) configPaths.baseURL = parsed.baseURL;
|
|
3290
|
+
if (parsed.tsconfigPaths && !configPaths.tsconfigPaths) configPaths.tsconfigPaths = parsed.tsconfigPaths;
|
|
3291
|
+
if (parsed.tsconfigBaseUrl && !configPaths.tsconfigBaseUrl) configPaths.tsconfigBaseUrl = parsed.tsconfigBaseUrl;
|
|
3292
|
+
if (parsed.javaTestSourceDir && !configPaths.javaTestSourceDir) configPaths.javaTestSourceDir = parsed.javaTestSourceDir;
|
|
3293
|
+
if (parsed.jestModuleNameMapper && !configPaths.jestModuleNameMapper) configPaths.jestModuleNameMapper = parsed.jestModuleNameMapper;
|
|
3294
|
+
}
|
|
3295
|
+
} catch {
|
|
3296
|
+
}
|
|
3297
|
+
let styleProfile = conn.styleProfile ?? null;
|
|
3298
|
+
if (styleSampleFiles.length > 0) {
|
|
3299
|
+
try {
|
|
3300
|
+
const mainSourcePaths = language === "java" ? treeResult.paths.filter((p) => /^src\/main\/java\//.test(p)).slice(0, 50) : [];
|
|
3301
|
+
const testPathSet = new Set(testPaths);
|
|
3302
|
+
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) : [];
|
|
3303
|
+
const allPathsForProfile = [...testPaths, ...mainSourcePaths, ...sourceFilePaths].slice(0, 200);
|
|
3304
|
+
const freshProfile = await extractStyleProfile(styleSampleFiles, framework, language, tenantId, projectId, jobId, allPathsForProfile, treeResult.paths);
|
|
3305
|
+
if (freshProfile) styleProfile = freshProfile;
|
|
3306
|
+
} catch {
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
if (styleProfile && Object.keys(configPaths).length > 0) {
|
|
3310
|
+
if (configPaths.testDir) styleProfile.configTestDir = configPaths.testDir;
|
|
3311
|
+
if (configPaths.javaTestSourceDir) {
|
|
3312
|
+
styleProfile.configTestDir = configPaths.javaTestSourceDir;
|
|
3313
|
+
if (!styleProfile.testLocation) styleProfile.testLocation = configPaths.javaTestSourceDir;
|
|
3314
|
+
}
|
|
3315
|
+
if (configPaths.fixturesFolder) styleProfile.configFixturesFolder = configPaths.fixturesFolder;
|
|
3316
|
+
if (configPaths.baseURL) styleProfile.configBaseURL = configPaths.baseURL;
|
|
3317
|
+
if (configPaths.tsconfigPaths) styleProfile.tsconfigPaths = configPaths.tsconfigPaths;
|
|
3318
|
+
if (configPaths.tsconfigBaseUrl) styleProfile.tsconfigBaseUrl = configPaths.tsconfigBaseUrl;
|
|
3319
|
+
if (configPaths.jestModuleNameMapper) styleProfile.jestModuleNameMapper = configPaths.jestModuleNameMapper;
|
|
3320
|
+
}
|
|
3321
|
+
let pageObjectInventory = conn.pageObjectInventory ?? [];
|
|
3322
|
+
try {
|
|
3323
|
+
const freshInventory = await buildPageObjectInventory(token, conn.repoFullName, treeResult.paths, framework);
|
|
3324
|
+
if (freshInventory.length > 0) {
|
|
3325
|
+
pageObjectInventory = freshInventory;
|
|
3326
|
+
await updateJobProgress(tenantId, jobId, 82, `Found ${pageObjectInventory.length} page objects`);
|
|
3327
|
+
}
|
|
3328
|
+
} catch {
|
|
3329
|
+
}
|
|
3330
|
+
await updateJobProgress(tenantId, jobId, 85, "Running coverage mapping");
|
|
3331
|
+
const { buildCoverageMap } = await import("@taodigital/agents");
|
|
3332
|
+
const testCases = await withTenant(
|
|
3333
|
+
tenantId,
|
|
3334
|
+
(tx) => tx.testCase.findMany({ where: { projectId, tenantId, status: { not: "RETIRED" } }, select: { id: true, title: true, ref: true } })
|
|
3335
|
+
);
|
|
3336
|
+
const mappings = await buildCoverageMap(testCases, allExtracted, tenantId);
|
|
3337
|
+
for (const m of mappings) {
|
|
3338
|
+
const existing = await withTenant(
|
|
3339
|
+
tenantId,
|
|
3340
|
+
(tx) => tx.coverageMapping.findUnique({ where: { testCaseId: m.testCaseId } })
|
|
3341
|
+
);
|
|
3342
|
+
if (existing?.manualOverride) continue;
|
|
3343
|
+
await withTenant(
|
|
3344
|
+
tenantId,
|
|
3345
|
+
(tx) => tx.coverageMapping.upsert({
|
|
3346
|
+
where: { testCaseId: m.testCaseId },
|
|
3347
|
+
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 },
|
|
3348
|
+
update: { status: m.status, matchedFilePath: m.matchedFilePath ?? null, scriptTestName: m.scriptTestName ?? null, lineNumber: m.lineNumber ?? null, confidence: m.confidence, matchMethod: m.matchMethod }
|
|
3349
|
+
})
|
|
3350
|
+
);
|
|
3351
|
+
}
|
|
3352
|
+
const mapped = mappings.filter((m) => m.status === "MAPPED").length;
|
|
3353
|
+
const unmapped = mappings.filter((m) => m.status === "UNMAPPED").length;
|
|
3354
|
+
const ambiguous = mappings.filter((m) => m.status === "AMBIGUOUS").length;
|
|
3355
|
+
const coveragePercent = testCases.length > 0 ? mapped / testCases.length * 100 : 0;
|
|
3356
|
+
await withTenant(
|
|
3357
|
+
tenantId,
|
|
3358
|
+
(tx) => tx.coverageSnapshot.create({
|
|
3359
|
+
data: { tenantId, projectId, totalCases: testCases.length, mappedCases: mapped, unmappedCases: unmapped, ambiguousCases: ambiguous, coveragePercent }
|
|
3360
|
+
})
|
|
3361
|
+
);
|
|
3362
|
+
const resolvedStyleProfile = (() => {
|
|
3363
|
+
const sp = styleProfile ?? {};
|
|
3364
|
+
if (framework === "selenium-java" && !sp.testStructurePattern) {
|
|
3365
|
+
const hasMavenMain = treeResult.paths.some((p) => /^src\/main\/java\//.test(p));
|
|
3366
|
+
if (hasMavenMain) return { ...sp, testStructurePattern: "two-tier" };
|
|
3367
|
+
}
|
|
3368
|
+
return sp;
|
|
3369
|
+
})();
|
|
3370
|
+
await withTenant(
|
|
3371
|
+
tenantId,
|
|
3372
|
+
(tx) => tx.gitHubRepoConnection.update({
|
|
3373
|
+
where: { id: repoConnectionId },
|
|
3374
|
+
data: {
|
|
3375
|
+
scanStatus: "DONE",
|
|
3376
|
+
detectedFramework: framework,
|
|
3377
|
+
configFilePath: primary?.configPath ?? null,
|
|
3378
|
+
testGlob,
|
|
3379
|
+
testFileCount: testPaths.length,
|
|
3380
|
+
extractedIdCount: allExtracted.length,
|
|
3381
|
+
styleProfile: resolvedStyleProfile,
|
|
3382
|
+
pageObjectInventory: pageObjectInventory.length > 0 ? pageObjectInventory : void 0,
|
|
3383
|
+
treeSnapshot: JSON.stringify(treeResult.paths).slice(0, 1e5),
|
|
3384
|
+
lastScannedAt: /* @__PURE__ */ new Date(),
|
|
3385
|
+
lastSyncAt: /* @__PURE__ */ new Date()
|
|
3386
|
+
}
|
|
3387
|
+
})
|
|
3388
|
+
);
|
|
3389
|
+
try {
|
|
3390
|
+
const { generateRepoSkillFiles } = await import("@taodigital/agents");
|
|
3391
|
+
await generateRepoSkillFiles(tenantId, projectId, {
|
|
3392
|
+
repoFullName: conn.repoFullName,
|
|
3393
|
+
framework,
|
|
3394
|
+
language,
|
|
3395
|
+
testGlob,
|
|
3396
|
+
styleProfile: resolvedStyleProfile,
|
|
3397
|
+
pageObjectInventory,
|
|
3398
|
+
testPaths,
|
|
3399
|
+
extractedTestCount: allExtracted.length,
|
|
3400
|
+
treeWasTruncated: treeResult.truncated,
|
|
3401
|
+
extractedTests: allExtracted
|
|
3402
|
+
});
|
|
3403
|
+
} catch (e) {
|
|
3404
|
+
logger.warn({ jobId, repoConnectionId, err: e?.message }, "[repo-scan] repo skill files generation failed (non-fatal)");
|
|
3405
|
+
}
|
|
3406
|
+
try {
|
|
3407
|
+
const { alignSkillFile } = await import("@taodigital/agents");
|
|
3408
|
+
await alignSkillFile(tenantId, projectId, {
|
|
3409
|
+
framework,
|
|
3410
|
+
language,
|
|
3411
|
+
styleProfile: resolvedStyleProfile,
|
|
3412
|
+
repoFullName: conn.repoFullName
|
|
3413
|
+
});
|
|
3414
|
+
} catch (e) {
|
|
3415
|
+
logger.warn({ jobId, repoConnectionId, err: e?.message }, "[repo-scan] alignSkillFile failed (non-fatal)");
|
|
3416
|
+
}
|
|
3417
|
+
await updateJobProgress(tenantId, jobId, 100, `Done: ${mapped} mapped, ${unmapped} unmapped`);
|
|
3418
|
+
await withTenant(
|
|
3419
|
+
tenantId,
|
|
3420
|
+
(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 } } })
|
|
3421
|
+
);
|
|
3422
|
+
aggregateJobTokens(tenantId, jobId, projectId, jobStart).catch(console.error);
|
|
3423
|
+
} catch (err) {
|
|
3424
|
+
const cause = err?.cause?.message ?? (typeof err?.cause === "string" ? err.cause : "");
|
|
3425
|
+
const msg = `${String(err?.message ?? err)}${cause ? ` \u2014 ${cause}` : ""}`;
|
|
3426
|
+
logger.error({ jobId, repoConnectionId, err: msg }, "[repo-scan] failed");
|
|
3427
|
+
await withTenant(
|
|
3428
|
+
tenantId,
|
|
3429
|
+
(tx) => tx.gitHubRepoConnection.update({ where: { id: repoConnectionId }, data: { scanStatus: "FAILED", scanError: msg.slice(0, 500) } })
|
|
3430
|
+
).catch(() => {
|
|
3431
|
+
});
|
|
3432
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
async function processBatchGenerateAlignedJob(jobId, config) {
|
|
3436
|
+
const { tenantId, projectId, repoConnectionId, scope = "UNMAPPED", testCaseIds, force = false } = config;
|
|
3437
|
+
try {
|
|
3438
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading repo connection");
|
|
3439
|
+
const conn = await withTenant(
|
|
3440
|
+
tenantId,
|
|
3441
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
3442
|
+
);
|
|
3443
|
+
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
3444
|
+
const {
|
|
3445
|
+
generateScriptAligned,
|
|
3446
|
+
deriveRepoTargetPath,
|
|
3447
|
+
validateGeneratedScript,
|
|
3448
|
+
mapWithConcurrency,
|
|
3449
|
+
SCRIPT_GEN_CONCURRENCY
|
|
3450
|
+
} = await import("@taodigital/agents");
|
|
3451
|
+
const styleProfile = conn.styleProfile;
|
|
3452
|
+
const pageObjectInventory = conn.pageObjectInventory ?? [];
|
|
3453
|
+
const framework = conn.detectedFramework ?? "playwright-ts";
|
|
3454
|
+
const language = styleProfile?.language ?? "typescript";
|
|
3455
|
+
let caseIds;
|
|
3456
|
+
if (testCaseIds && testCaseIds.length > 0) {
|
|
3457
|
+
caseIds = testCaseIds;
|
|
3458
|
+
} else if (scope === "UNMAPPED") {
|
|
3459
|
+
const mappings = await withTenant(
|
|
3460
|
+
tenantId,
|
|
3461
|
+
(tx) => tx.coverageMapping.findMany({
|
|
3462
|
+
where: { tenantId, projectId, repoConnectionId, status: "UNMAPPED" },
|
|
3463
|
+
select: { testCaseId: true }
|
|
3464
|
+
})
|
|
3465
|
+
);
|
|
3466
|
+
caseIds = mappings.map((m) => m.testCaseId);
|
|
3467
|
+
} else {
|
|
3468
|
+
const cases = await withTenant(
|
|
3469
|
+
tenantId,
|
|
3470
|
+
(tx) => tx.testCase.findMany({
|
|
3471
|
+
where: { tenantId, projectId, status: { not: "RETIRED" } },
|
|
3472
|
+
select: { id: true }
|
|
3473
|
+
})
|
|
3474
|
+
);
|
|
3475
|
+
caseIds = cases.map((c) => c.id);
|
|
3476
|
+
}
|
|
3477
|
+
await updateJobProgress(tenantId, jobId, 15, `Generating scripts for ${caseIds.length} test cases`);
|
|
3478
|
+
let done = 0;
|
|
3479
|
+
const errors = [];
|
|
3480
|
+
await mapWithConcurrency(caseIds, SCRIPT_GEN_CONCURRENCY, async (tcId) => {
|
|
3481
|
+
try {
|
|
3482
|
+
if (!force) {
|
|
3483
|
+
const existing = await withTenant(
|
|
3484
|
+
tenantId,
|
|
3485
|
+
(tx) => tx.testScript.findFirst({ where: { testCaseId: tcId } })
|
|
3486
|
+
);
|
|
3487
|
+
if (existing) {
|
|
3488
|
+
done++;
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
const tc = await withTenant(
|
|
3493
|
+
tenantId,
|
|
3494
|
+
(tx) => tx.testCase.findUnique({
|
|
3495
|
+
where: { id: tcId },
|
|
3496
|
+
select: { id: true, ref: true, title: true, screen: { select: { url: true } } }
|
|
3497
|
+
})
|
|
3498
|
+
);
|
|
3499
|
+
const screenUrl = tc?.screen?.url ?? "";
|
|
3500
|
+
const repoTargetPath = styleProfile ? deriveRepoTargetPath(screenUrl, styleProfile, framework) : deriveFallbackTargetPath(tc?.ref ?? tc?.title ?? tcId, framework);
|
|
3501
|
+
const result = await generateScriptAligned({
|
|
3502
|
+
tenantId,
|
|
3503
|
+
projectId,
|
|
3504
|
+
testCaseId: tcId,
|
|
3505
|
+
styleProfile,
|
|
3506
|
+
pageObjectInventory,
|
|
3507
|
+
repoTargetPath,
|
|
3508
|
+
save: true
|
|
3509
|
+
});
|
|
3510
|
+
if (result.code) {
|
|
3511
|
+
const validation = await validateGeneratedScript(result.code, framework, language);
|
|
3512
|
+
await withTenant(
|
|
3513
|
+
tenantId,
|
|
3514
|
+
(tx) => tx.testScript.updateMany({
|
|
3515
|
+
where: { testCaseId: tcId },
|
|
3516
|
+
data: {
|
|
3517
|
+
validationStatus: validation.ok ? "PASS" : "FAIL",
|
|
3518
|
+
validationErrors: validation.ok ? null : validation.errors.slice(0, 2e3)
|
|
3519
|
+
}
|
|
3520
|
+
})
|
|
3521
|
+
);
|
|
3522
|
+
}
|
|
3523
|
+
done++;
|
|
3524
|
+
} catch (e) {
|
|
3525
|
+
errors.push(`${tcId}: ${String(e.message).slice(0, 100)}`);
|
|
3526
|
+
}
|
|
3527
|
+
if (done % 5 === 0) {
|
|
3528
|
+
await updateJobProgress(tenantId, jobId, 15 + Math.floor(done / caseIds.length * 80), `Generated ${done}/${caseIds.length}`);
|
|
3529
|
+
}
|
|
3530
|
+
});
|
|
3531
|
+
refreshTestScriptSkillFile(tenantId, projectId).catch(console.error);
|
|
3532
|
+
await withTenant(
|
|
3533
|
+
tenantId,
|
|
3534
|
+
(tx) => tx.platformJob.update({
|
|
3535
|
+
where: { id: jobId },
|
|
3536
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { done, errors: errors.slice(0, 20), total: caseIds.length } }
|
|
3537
|
+
})
|
|
3538
|
+
);
|
|
3539
|
+
} catch (err) {
|
|
3540
|
+
const msg = String(err?.message ?? err);
|
|
3541
|
+
logger.error({ jobId, err: msg }, "[batch-generate-aligned] failed");
|
|
3542
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
async function processCreatePRJob(jobId, config) {
|
|
3546
|
+
const { tenantId, projectId, repoConnectionId, testScriptIds, crawlJobId } = config;
|
|
3547
|
+
try {
|
|
3548
|
+
await updateJobProgress(tenantId, jobId, 5, "Loading connection");
|
|
3549
|
+
const conn = await withTenant(
|
|
3550
|
+
tenantId,
|
|
3551
|
+
(tx) => tx.gitHubRepoConnection.findFirst({ where: { id: repoConnectionId, tenantId } })
|
|
3552
|
+
);
|
|
3553
|
+
if (!conn) throw new Error(`RepoConnection ${repoConnectionId} not found`);
|
|
3554
|
+
const { decrypt: decrypt2 } = await import("@taodigital/core");
|
|
3555
|
+
const token = decrypt2(conn.tokenEnc);
|
|
3556
|
+
const { pushScriptsAsPR, getFileContent, mergeSpecContent } = await import("@taodigital/agents");
|
|
3557
|
+
const framework = conn.detectedFramework ?? "playwright-ts";
|
|
3558
|
+
let scripts;
|
|
3559
|
+
if (testScriptIds && testScriptIds.length > 0) {
|
|
3560
|
+
scripts = await withTenant(
|
|
3561
|
+
tenantId,
|
|
3562
|
+
(tx) => tx.testScript.findMany({
|
|
3563
|
+
where: { id: { in: testScriptIds }, tenantId, code: { not: null } },
|
|
3564
|
+
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
|
|
3565
|
+
})
|
|
3566
|
+
);
|
|
3567
|
+
} else {
|
|
3568
|
+
scripts = await withTenant(
|
|
3569
|
+
tenantId,
|
|
3570
|
+
(tx) => tx.testScript.findMany({
|
|
3571
|
+
where: { tenantId, projectId, pushedToPR: false, validationStatus: { in: ["PASS", "PENDING"] }, code: { not: null } },
|
|
3572
|
+
include: { testCase: { select: { title: true, ref: true, repoTargetPath: true } } }
|
|
3573
|
+
})
|
|
3574
|
+
);
|
|
3575
|
+
}
|
|
3576
|
+
if (scripts.length === 0) throw new Error("No generated scripts found to push.");
|
|
3577
|
+
const files = await Promise.all(scripts.map(async (s) => {
|
|
3578
|
+
const path2 = s.testCase?.repoTargetPath ?? deriveFallbackTargetPath(s.testCase?.ref ?? s.testCase?.title ?? s.id, framework);
|
|
3579
|
+
let content = s.code;
|
|
3580
|
+
const isSpecFile = /\.(spec|test|cy)\.(ts|js|py|java)$/.test(path2) || /_test\.py$/.test(path2);
|
|
3581
|
+
if (isSpecFile) {
|
|
3582
|
+
const existing = await getFileContent(token, conn.repoFullName, path2, conn.defaultBranch).catch(() => null);
|
|
3583
|
+
if (existing && existing.trim().length > 0) {
|
|
3584
|
+
logger.info({ path: path2 }, "[create-pr] merging new tests into existing spec file");
|
|
3585
|
+
content = mergeSpecContent(existing, s.code);
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
return { path: path2, content };
|
|
3589
|
+
}));
|
|
3590
|
+
if (files.length === 0) throw new Error("No scripts to push.");
|
|
3591
|
+
await updateJobProgress(tenantId, jobId, 20, `Pushing ${files.length} scripts to GitHub`);
|
|
3592
|
+
const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3593
|
+
const branchName = `zeta/new-coverage-${now}-${jobId.slice(0, 8)}`;
|
|
3594
|
+
const prBody = buildPRBody(scripts, projectId);
|
|
3595
|
+
const prResult = await pushScriptsAsPR({
|
|
3596
|
+
token,
|
|
3597
|
+
repo: conn.repoFullName,
|
|
3598
|
+
baseBranch: conn.defaultBranch,
|
|
3599
|
+
branchName,
|
|
3600
|
+
files,
|
|
3601
|
+
prTitle: `ZeTa: Add test coverage for ${files.length} new scenario${files.length === 1 ? "" : "s"}`,
|
|
3602
|
+
prBody,
|
|
3603
|
+
draft: false
|
|
3604
|
+
});
|
|
3605
|
+
const pr = await withTenant(
|
|
3606
|
+
tenantId,
|
|
3607
|
+
(tx) => tx.gitHubPR.create({
|
|
3608
|
+
data: {
|
|
3609
|
+
tenantId,
|
|
3610
|
+
projectId,
|
|
3611
|
+
repoConnectionId,
|
|
3612
|
+
prNumber: prResult.prNumber,
|
|
3613
|
+
prUrl: prResult.prUrl,
|
|
3614
|
+
branch: prResult.branch,
|
|
3615
|
+
headSha: prResult.headSha,
|
|
3616
|
+
status: "OPEN",
|
|
3617
|
+
crawlJobId: crawlJobId ?? null,
|
|
3618
|
+
scriptCount: files.length,
|
|
3619
|
+
prBody: prBody.slice(0, 1e4)
|
|
3620
|
+
}
|
|
3621
|
+
})
|
|
3622
|
+
);
|
|
3623
|
+
const scriptIds = scripts.map((s) => s.id);
|
|
3624
|
+
await withTenant(
|
|
3625
|
+
tenantId,
|
|
3626
|
+
(tx) => tx.testScript.updateMany({
|
|
3627
|
+
where: { id: { in: scriptIds } },
|
|
3628
|
+
data: { pushedToPR: true, pushedPRId: pr.id }
|
|
3629
|
+
})
|
|
3630
|
+
);
|
|
3631
|
+
await notifyAdmins(tenantId, {
|
|
3632
|
+
type: "GITHUB_PR_CREATED",
|
|
3633
|
+
title: "ZeTa created a GitHub PR",
|
|
3634
|
+
body: `${files.length} new test script${files.length === 1 ? "" : "s"} pushed. PR #${prResult.prNumber}: ${prResult.prUrl}`,
|
|
3635
|
+
metadata: { prUrl: prResult.prUrl, prNumber: prResult.prNumber, scriptCount: files.length }
|
|
3636
|
+
});
|
|
3637
|
+
await updateJobProgress(tenantId, jobId, 100, `PR #${prResult.prNumber} created`);
|
|
3638
|
+
await withTenant(
|
|
3639
|
+
tenantId,
|
|
3640
|
+
(tx) => tx.platformJob.update({
|
|
3641
|
+
where: { id: jobId },
|
|
3642
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { prUrl: prResult.prUrl, prNumber: prResult.prNumber, scriptCount: files.length } }
|
|
3643
|
+
})
|
|
3644
|
+
);
|
|
3645
|
+
} catch (err) {
|
|
3646
|
+
const msg = String(err?.message ?? err);
|
|
3647
|
+
logger.error({ jobId, err: msg }, "[create-pr] failed");
|
|
3648
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
function buildPRBody(scripts, projectId) {
|
|
3652
|
+
const lines = [
|
|
3653
|
+
"## ZeTa \u2014 Automated Test Coverage",
|
|
3654
|
+
"",
|
|
3655
|
+
`Generated by ZeTa Quality Intelligence Platform for project \`${projectId}\`.`,
|
|
3656
|
+
"",
|
|
3657
|
+
"### Scripts Added",
|
|
3658
|
+
"",
|
|
3659
|
+
"| Test Case | Target Path |",
|
|
3660
|
+
"|---|---|"
|
|
3661
|
+
];
|
|
3662
|
+
for (const s of scripts.slice(0, 30)) {
|
|
3663
|
+
const title = s.testCase?.title ?? s.id;
|
|
3664
|
+
const path2 = s.testCase?.repoTargetPath ?? "(unknown)";
|
|
3665
|
+
lines.push(`| ${title.replace(/\|/g, "\\|").slice(0, 60)} | \`${path2}\` |`);
|
|
3666
|
+
}
|
|
3667
|
+
if (scripts.length > 30) lines.push(`| ... and ${scripts.length - 30} more | |`);
|
|
3668
|
+
lines.push("", "---", "_Review each test carefully before merging. ZeTa generates scripts aligned to your existing framework conventions._");
|
|
3669
|
+
return lines.join("\n");
|
|
3670
|
+
}
|
|
3671
|
+
function generateK6Script(params) {
|
|
3672
|
+
const { endpoints, baseUrl, vus = 10, duration = "30s", thinkTimeSeconds = 1 } = params;
|
|
3673
|
+
const checks = endpoints.filter((ep) => ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(ep.method.toUpperCase())).map((ep) => {
|
|
3674
|
+
const method = ep.method.toUpperCase();
|
|
3675
|
+
const url = `\${__ENV.BASE_URL || '${baseUrl}'}${ep.path}`;
|
|
3676
|
+
const checkKey = `${method} ${ep.path} is 2xx`;
|
|
3677
|
+
const callExpr = method === "GET" || method === "DELETE" ? `http.${method.toLowerCase()}(\`${url}\`, params)` : `http.${method.toLowerCase()}(\`${url}\`, JSON.stringify({}), params)`;
|
|
3678
|
+
return `
|
|
3679
|
+
// ${ep.summary ?? `${method} ${ep.path}`}
|
|
3680
|
+
{
|
|
3681
|
+
const res = ${callExpr};
|
|
3682
|
+
check(res, { '${checkKey}': (r) => r.status >= 200 && r.status < 300 });
|
|
3683
|
+
errorRate.add(res.status >= 400);
|
|
3684
|
+
}`;
|
|
3685
|
+
}).join("\n");
|
|
3686
|
+
return `import http from 'k6/http';
|
|
3687
|
+
import { check, sleep } from 'k6';
|
|
3688
|
+
import { Rate } from 'k6/metrics';
|
|
3689
|
+
|
|
3690
|
+
const errorRate = new Rate('errors');
|
|
3691
|
+
|
|
3692
|
+
export const options = {
|
|
3693
|
+
vus: ${vus},
|
|
3694
|
+
duration: '${duration}',
|
|
3695
|
+
thresholds: {
|
|
3696
|
+
http_req_duration: ['p(95)<2000'],
|
|
3697
|
+
errors: ['rate<0.1'],
|
|
3698
|
+
},
|
|
3699
|
+
};
|
|
3700
|
+
|
|
3701
|
+
const params = {
|
|
3702
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3703
|
+
};
|
|
3704
|
+
|
|
3705
|
+
export default function () {
|
|
3706
|
+
${checks}
|
|
3707
|
+
sleep(${thinkTimeSeconds});
|
|
3708
|
+
}
|
|
3709
|
+
`;
|
|
3710
|
+
}
|
|
3711
|
+
async function processNewPagesPipelineJob(jobId, config) {
|
|
3712
|
+
const { tenantId, projectId, crawlJobId: _crawlJobId, repoConnectionId, autoPR = false } = config;
|
|
3713
|
+
try {
|
|
3714
|
+
await updateJobProgress(tenantId, jobId, 5, "Detecting new screens");
|
|
3715
|
+
const conn = await withTenant(
|
|
3716
|
+
tenantId,
|
|
3717
|
+
(tx) => tx.gitHubRepoConnection.findFirst({
|
|
3718
|
+
where: repoConnectionId ? { id: repoConnectionId, tenantId } : { tenantId, projectId, status: "ACTIVE" }
|
|
3719
|
+
})
|
|
3720
|
+
);
|
|
3721
|
+
const sinceDate = conn?.lastSyncAt ?? /* @__PURE__ */ new Date(0);
|
|
3722
|
+
const newScreens = await withTenant(
|
|
3723
|
+
tenantId,
|
|
3724
|
+
(tx) => tx.screen.findMany({
|
|
3725
|
+
where: {
|
|
3726
|
+
tenantId,
|
|
3727
|
+
projectId,
|
|
3728
|
+
lastSeenAt: { gt: sinceDate },
|
|
3729
|
+
status: { not: "DELETED" }
|
|
3730
|
+
},
|
|
3731
|
+
select: { id: true, url: true, name: true },
|
|
3732
|
+
take: 100
|
|
3733
|
+
})
|
|
3734
|
+
);
|
|
3735
|
+
if (newScreens.length === 0) {
|
|
3736
|
+
await updateJobProgress(tenantId, jobId, 100, "No new screens to process");
|
|
3737
|
+
await withTenant(
|
|
3738
|
+
tenantId,
|
|
3739
|
+
(tx) => tx.platformJob.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { newScreens: 0 } } })
|
|
3740
|
+
);
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
await updateJobProgress(tenantId, jobId, 15, `Found ${newScreens.length} new screens`);
|
|
3744
|
+
const generationJobs = [];
|
|
3745
|
+
for (const screen of newScreens) {
|
|
3746
|
+
const caseCount = await withTenant(
|
|
3747
|
+
tenantId,
|
|
3748
|
+
(tx) => tx.testCase.count({ where: { tenantId, screenId: screen.id } })
|
|
3749
|
+
);
|
|
3750
|
+
if (caseCount === 0) {
|
|
3751
|
+
const jid = await enqueueJob("GENERATE_SCREEN_TESTS", {
|
|
3752
|
+
tenantId,
|
|
3753
|
+
projectId,
|
|
3754
|
+
screenId: screen.id,
|
|
3755
|
+
triggerAlignedScripts: conn != null,
|
|
3756
|
+
repoConnectionId: conn?.id ?? null
|
|
3757
|
+
});
|
|
3758
|
+
generationJobs.push(jid);
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
await updateJobProgress(tenantId, jobId, 60, `Queued ${generationJobs.length} test generation jobs`);
|
|
3762
|
+
if (conn && autoPR) {
|
|
3763
|
+
await enqueueJob("BATCH_GENERATE_ALIGNED", {
|
|
3764
|
+
tenantId,
|
|
3765
|
+
projectId,
|
|
3766
|
+
repoConnectionId: conn.id,
|
|
3767
|
+
scope: "UNMAPPED"
|
|
3768
|
+
});
|
|
3769
|
+
}
|
|
3770
|
+
await withTenant(
|
|
3771
|
+
tenantId,
|
|
3772
|
+
(tx) => tx.platformJob.update({
|
|
3773
|
+
where: { id: jobId },
|
|
3774
|
+
data: { status: "DONE", finishedAt: /* @__PURE__ */ new Date(), result: { newScreens: newScreens.length, generationJobs: generationJobs.length } }
|
|
3775
|
+
})
|
|
3776
|
+
);
|
|
3777
|
+
} catch (err) {
|
|
3778
|
+
const msg = String(err?.message ?? err);
|
|
3779
|
+
logger.error({ jobId, err: msg }, "[new-pages-pipeline] failed");
|
|
3780
|
+
await recordJobFailure(tenantId, jobId, err);
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
export {
|
|
3784
|
+
FailureType,
|
|
3785
|
+
aggregateJobTokens,
|
|
3786
|
+
cancelJob,
|
|
3787
|
+
claimNextQueuedJob,
|
|
3788
|
+
claimSpecificJob,
|
|
3789
|
+
classifyFailure,
|
|
3790
|
+
createTrackedJob,
|
|
3791
|
+
dispatchClaimedJob,
|
|
3792
|
+
enqueueJob,
|
|
3793
|
+
finishTrackedJob,
|
|
3794
|
+
generateK6Script,
|
|
3795
|
+
generateTOTP,
|
|
3796
|
+
getActiveExecutionCount,
|
|
3797
|
+
getJob,
|
|
3798
|
+
listJobs,
|
|
3799
|
+
processDataRetentionPurge,
|
|
3800
|
+
processJob,
|
|
3801
|
+
promoteToDeadLetterQueue,
|
|
3802
|
+
recordJobFailure,
|
|
3803
|
+
requeueOrphanedJobs,
|
|
3804
|
+
requeueStaleRunningJobs,
|
|
3805
|
+
rerunJob,
|
|
3806
|
+
resumeJob,
|
|
3807
|
+
retryEligibleFailedJobs,
|
|
3808
|
+
startJobWorkerLoop,
|
|
3809
|
+
stopJobWorkerLoop,
|
|
3810
|
+
updateJobProgress
|
|
3811
|
+
};
|
|
3812
|
+
//# sourceMappingURL=job-queue.js.map
|