@detiq/crawler 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (412) hide show
  1. package/Dockerfile +49 -0
  2. package/README.md +11 -0
  3. package/dist/accessibility-auditor.d.ts +29 -0
  4. package/dist/accessibility-auditor.js +57 -0
  5. package/dist/accessibility-auditor.js.map +7 -0
  6. package/dist/ai-login.d.ts +32 -0
  7. package/dist/ai-login.js +177 -0
  8. package/dist/ai-login.js.map +7 -0
  9. package/dist/auth-token-rotator.d.ts +47 -0
  10. package/dist/auth-token-rotator.js +58 -0
  11. package/dist/auth-token-rotator.js.map +7 -0
  12. package/dist/auth.d.ts +20 -0
  13. package/dist/auth.js +329 -0
  14. package/dist/auth.js.map +7 -0
  15. package/dist/browser-extension.d.ts +37 -0
  16. package/dist/browser-extension.js +113 -0
  17. package/dist/browser-extension.js.map +7 -0
  18. package/dist/captcha-solver.d.ts +16 -0
  19. package/dist/captcha-solver.js +105 -0
  20. package/dist/captcha-solver.js.map +7 -0
  21. package/dist/color-contrast.d.ts +30 -0
  22. package/dist/color-contrast.js +78 -0
  23. package/dist/color-contrast.js.map +7 -0
  24. package/dist/content-chunker.d.ts +18 -0
  25. package/dist/content-chunker.js +152 -0
  26. package/dist/content-chunker.js.map +7 -0
  27. package/dist/content-dedup.d.ts +23 -0
  28. package/dist/content-dedup.js +60 -0
  29. package/dist/content-dedup.js.map +7 -0
  30. package/dist/cookie-consent.d.ts +25 -0
  31. package/dist/cookie-consent.js +78 -0
  32. package/dist/cookie-consent.js.map +7 -0
  33. package/dist/cookie-jar.d.ts +20 -0
  34. package/dist/cookie-jar.js +61 -0
  35. package/dist/cookie-jar.js.map +7 -0
  36. package/dist/crawler.d.ts +179 -0
  37. package/dist/crawler.js +3672 -0
  38. package/dist/crawler.js.map +7 -0
  39. package/dist/custom-js-injector.d.ts +42 -0
  40. package/dist/custom-js-injector.js +50 -0
  41. package/dist/custom-js-injector.js.map +7 -0
  42. package/dist/dark-mode-capture.d.ts +23 -0
  43. package/dist/dark-mode-capture.js +75 -0
  44. package/dist/dark-mode-capture.js.map +7 -0
  45. package/dist/dead-link-detector.d.ts +37 -0
  46. package/dist/dead-link-detector.js +50 -0
  47. package/dist/dead-link-detector.js.map +7 -0
  48. package/dist/element-hash.d.ts +9 -0
  49. package/dist/element-hash.js +9 -0
  50. package/dist/element-hash.js.map +7 -0
  51. package/dist/email-otp.d.ts +42 -0
  52. package/dist/email-otp.js +177 -0
  53. package/dist/email-otp.js.map +7 -0
  54. package/dist/engine-router.d.ts +47 -0
  55. package/dist/engine-router.js +94 -0
  56. package/dist/engine-router.js.map +7 -0
  57. package/dist/export/bulk-export.d.ts +26 -0
  58. package/dist/export/bulk-export.js +76 -0
  59. package/dist/export/bulk-export.js.map +7 -0
  60. package/dist/export/vector-export.d.ts +76 -0
  61. package/dist/export/vector-export.js +85 -0
  62. package/dist/export/vector-export.js.map +7 -0
  63. package/dist/extractors/advanced.d.ts +54 -0
  64. package/dist/extractors/advanced.js +245 -0
  65. package/dist/extractors/advanced.js.map +7 -0
  66. package/dist/extractors/artifact-envelope.d.ts +63 -0
  67. package/dist/extractors/artifact-envelope.js +63 -0
  68. package/dist/extractors/artifact-envelope.js.map +7 -0
  69. package/dist/failure-analysis-agent.d.ts +16 -0
  70. package/dist/failure-analysis-agent.js +40 -0
  71. package/dist/failure-analysis-agent.js.map +7 -0
  72. package/dist/feature-flag-detector.d.ts +30 -0
  73. package/dist/feature-flag-detector.js +55 -0
  74. package/dist/feature-flag-detector.js.map +7 -0
  75. package/dist/form-detector.d.ts +34 -0
  76. package/dist/form-detector.js +58 -0
  77. package/dist/form-detector.js.map +7 -0
  78. package/dist/frontier.d.ts +32 -0
  79. package/dist/frontier.js +83 -0
  80. package/dist/frontier.js.map +7 -0
  81. package/dist/gap-pass.d.ts +35 -0
  82. package/dist/gap-pass.js +81 -0
  83. package/dist/gap-pass.js.map +7 -0
  84. package/dist/graphql-detector.d.ts +39 -0
  85. package/dist/graphql-detector.js +86 -0
  86. package/dist/graphql-detector.js.map +7 -0
  87. package/dist/http-engine.d.ts +80 -0
  88. package/dist/http-engine.js +304 -0
  89. package/dist/http-engine.js.map +7 -0
  90. package/dist/incremental-diff.d.ts +59 -0
  91. package/dist/incremental-diff.js +61 -0
  92. package/dist/incremental-diff.js.map +7 -0
  93. package/dist/index.d.ts +35 -0
  94. package/dist/index.js +61 -0
  95. package/dist/index.js.map +7 -0
  96. package/dist/input-fuzzer.d.ts +30 -0
  97. package/dist/input-fuzzer.js +105 -0
  98. package/dist/input-fuzzer.js.map +7 -0
  99. package/dist/interactive-probe.d.ts +104 -0
  100. package/dist/interactive-probe.js +442 -0
  101. package/dist/interactive-probe.js.map +7 -0
  102. package/dist/job-queue.d.ts +171 -0
  103. package/dist/job-queue.js +2256 -0
  104. package/dist/job-queue.js.map +7 -0
  105. package/dist/js-coverage.d.ts +29 -0
  106. package/dist/js-coverage.js +56 -0
  107. package/dist/js-coverage.js.map +7 -0
  108. package/dist/js-router-detector.d.ts +17 -0
  109. package/dist/js-router-detector.js +90 -0
  110. package/dist/js-router-detector.js.map +7 -0
  111. package/dist/language-detector.d.ts +22 -0
  112. package/dist/language-detector.js +57 -0
  113. package/dist/language-detector.js.map +7 -0
  114. package/dist/link-classifier.d.ts +23 -0
  115. package/dist/link-classifier.js +40 -0
  116. package/dist/link-classifier.js.map +7 -0
  117. package/dist/live-view.d.ts +36 -0
  118. package/dist/live-view.js +100 -0
  119. package/dist/live-view.js.map +7 -0
  120. package/dist/logger.d.ts +2 -0
  121. package/dist/logger.js +10 -0
  122. package/dist/logger.js.map +7 -0
  123. package/dist/markdown-extract.d.ts +8 -0
  124. package/dist/markdown-extract.js +54 -0
  125. package/dist/markdown-extract.js.map +7 -0
  126. package/dist/native-crawler.d.ts +69 -0
  127. package/dist/native-crawler.js +839 -0
  128. package/dist/native-crawler.js.map +7 -0
  129. package/dist/network-observer.d.ts +49 -0
  130. package/dist/network-observer.js +149 -0
  131. package/dist/network-observer.js.map +7 -0
  132. package/dist/network-throttle.d.ts +25 -0
  133. package/dist/network-throttle.js +34 -0
  134. package/dist/network-throttle.js.map +7 -0
  135. package/dist/openapi-generator.d.ts +49 -0
  136. package/dist/openapi-generator.js +89 -0
  137. package/dist/openapi-generator.js.map +7 -0
  138. package/dist/pdf-extractor.d.ts +28 -0
  139. package/dist/pdf-extractor.js +81 -0
  140. package/dist/pdf-extractor.js.map +7 -0
  141. package/dist/performance-metrics.d.ts +53 -0
  142. package/dist/performance-metrics.js +83 -0
  143. package/dist/performance-metrics.js.map +7 -0
  144. package/dist/phash.d.ts +38 -0
  145. package/dist/phash.js +115 -0
  146. package/dist/phash.js.map +7 -0
  147. package/dist/pii-scrubber.d.ts +21 -0
  148. package/dist/pii-scrubber.js +75 -0
  149. package/dist/pii-scrubber.js.map +7 -0
  150. package/dist/playwright-recorder.d.ts +39 -0
  151. package/dist/playwright-recorder.js +123 -0
  152. package/dist/playwright-recorder.js.map +7 -0
  153. package/dist/policies/crawl-safety.d.ts +9 -0
  154. package/dist/policies/crawl-safety.js +37 -0
  155. package/dist/policies/crawl-safety.js.map +7 -0
  156. package/dist/profiles/domain-profile.d.ts +19 -0
  157. package/dist/profiles/domain-profile.js +140 -0
  158. package/dist/profiles/domain-profile.js.map +7 -0
  159. package/dist/proxy.d.ts +50 -0
  160. package/dist/proxy.js +165 -0
  161. package/dist/proxy.js.map +7 -0
  162. package/dist/rate-limiter.d.ts +31 -0
  163. package/dist/rate-limiter.js +64 -0
  164. package/dist/rate-limiter.js.map +7 -0
  165. package/dist/redirect-tracker.d.ts +37 -0
  166. package/dist/redirect-tracker.js +65 -0
  167. package/dist/redirect-tracker.js.map +7 -0
  168. package/dist/redis-frontier.d.ts +48 -0
  169. package/dist/redis-frontier.js +126 -0
  170. package/dist/redis-frontier.js.map +7 -0
  171. package/dist/redis-queue.d.ts +48 -0
  172. package/dist/redis-queue.js +165 -0
  173. package/dist/redis-queue.js.map +7 -0
  174. package/dist/response-cache.d.ts +38 -0
  175. package/dist/response-cache.js +140 -0
  176. package/dist/response-cache.js.map +7 -0
  177. package/dist/responsive-detector.d.ts +29 -0
  178. package/dist/responsive-detector.js +46 -0
  179. package/dist/responsive-detector.js.map +7 -0
  180. package/dist/retry-policy.d.ts +20 -0
  181. package/dist/retry-policy.js +54 -0
  182. package/dist/retry-policy.js.map +7 -0
  183. package/dist/robots-txt.d.ts +9 -0
  184. package/dist/robots-txt.js +77 -0
  185. package/dist/robots-txt.js.map +7 -0
  186. package/dist/screen-reader.d.ts +16 -0
  187. package/dist/screen-reader.js +46 -0
  188. package/dist/screen-reader.js.map +7 -0
  189. package/dist/screenshot-masker.d.ts +28 -0
  190. package/dist/screenshot-masker.js +62 -0
  191. package/dist/screenshot-masker.js.map +7 -0
  192. package/dist/seo-checker.d.ts +33 -0
  193. package/dist/seo-checker.js +91 -0
  194. package/dist/seo-checker.js.map +7 -0
  195. package/dist/session-capture.d.ts +35 -0
  196. package/dist/session-capture.js +267 -0
  197. package/dist/session-capture.js.map +7 -0
  198. package/dist/session-export.d.ts +49 -0
  199. package/dist/session-export.js +117 -0
  200. package/dist/session-export.js.map +7 -0
  201. package/dist/sitemap-writer.d.ts +21 -0
  202. package/dist/sitemap-writer.js +47 -0
  203. package/dist/sitemap-writer.js.map +7 -0
  204. package/dist/sitemap.d.ts +51 -0
  205. package/dist/sitemap.js +152 -0
  206. package/dist/sitemap.js.map +7 -0
  207. package/dist/skyvern.d.ts +56 -0
  208. package/dist/skyvern.js +129 -0
  209. package/dist/skyvern.js.map +7 -0
  210. package/dist/sqs-queue.d.ts +2 -0
  211. package/dist/sqs-queue.js +47 -0
  212. package/dist/sqs-queue.js.map +7 -0
  213. package/dist/sso-token.d.ts +29 -0
  214. package/dist/sso-token.js +76 -0
  215. package/dist/sso-token.js.map +7 -0
  216. package/dist/stealth/human-behavior.d.ts +20 -0
  217. package/dist/stealth/human-behavior.js +168 -0
  218. package/dist/stealth/human-behavior.js.map +7 -0
  219. package/dist/telemetry.d.ts +34 -0
  220. package/dist/telemetry.js +77 -0
  221. package/dist/telemetry.js.map +7 -0
  222. package/dist/test-executor.d.ts +67 -0
  223. package/dist/test-executor.js +311 -0
  224. package/dist/test-executor.js.map +7 -0
  225. package/dist/tls-fingerprint.d.ts +50 -0
  226. package/dist/tls-fingerprint.js +81 -0
  227. package/dist/tls-fingerprint.js.map +7 -0
  228. package/dist/url-pattern-completer.d.ts +28 -0
  229. package/dist/url-pattern-completer.js +87 -0
  230. package/dist/url-pattern-completer.js.map +7 -0
  231. package/dist/visual-diff-verdict.d.ts +40 -0
  232. package/dist/visual-diff-verdict.js +25 -0
  233. package/dist/visual-diff-verdict.js.map +7 -0
  234. package/dist/webhook-notifier.d.ts +30 -0
  235. package/dist/webhook-notifier.js +76 -0
  236. package/dist/webhook-notifier.js.map +7 -0
  237. package/dist/websocket-detector.d.ts +24 -0
  238. package/dist/websocket-detector.js +60 -0
  239. package/dist/websocket-detector.js.map +7 -0
  240. package/dist/worker.d.ts +1 -0
  241. package/dist/worker.js +33 -0
  242. package/dist/worker.js.map +7 -0
  243. package/package.json +45 -0
  244. package/src/accessibility-auditor.ts +102 -0
  245. package/src/ai-login.ts +218 -0
  246. package/src/auth-token-rotator.ts +89 -0
  247. package/src/auth.ts +396 -0
  248. package/src/browser-extension.ts +186 -0
  249. package/src/captcha-solver.ts +143 -0
  250. package/src/color-contrast.ts +116 -0
  251. package/src/content-chunker.ts +170 -0
  252. package/src/content-dedup.ts +99 -0
  253. package/src/cookie-consent.ts +106 -0
  254. package/src/cookie-jar.ts +91 -0
  255. package/src/crawler.ts +4305 -0
  256. package/src/custom-js-injector.ts +93 -0
  257. package/src/dark-mode-capture.ts +110 -0
  258. package/src/dead-link-detector.ts +76 -0
  259. package/src/element-hash.ts +16 -0
  260. package/src/email-otp.ts +252 -0
  261. package/src/engine-router.ts +141 -0
  262. package/src/export/bulk-export.ts +85 -0
  263. package/src/export/vector-export.ts +160 -0
  264. package/src/extractors/advanced.ts +283 -0
  265. package/src/extractors/artifact-envelope.ts +111 -0
  266. package/src/failure-analysis-agent.ts +49 -0
  267. package/src/feature-flag-detector.ts +97 -0
  268. package/src/form-detector.ts +103 -0
  269. package/src/frontier.test.ts +64 -0
  270. package/src/frontier.ts +112 -0
  271. package/src/gap-pass.ts +122 -0
  272. package/src/graphql-detector.ts +129 -0
  273. package/src/http-engine.ts +433 -0
  274. package/src/incremental-diff.ts +121 -0
  275. package/src/index.ts +35 -0
  276. package/src/input-fuzzer.ts +129 -0
  277. package/src/interactive-probe.ts +623 -0
  278. package/src/job-queue.test.ts +120 -0
  279. package/src/job-queue.ts +2701 -0
  280. package/src/js-coverage.ts +87 -0
  281. package/src/js-router-detector.ts +112 -0
  282. package/src/language-detector.ts +82 -0
  283. package/src/link-classifier.ts +65 -0
  284. package/src/live-view.ts +141 -0
  285. package/src/logger.ts +9 -0
  286. package/src/markdown-extract.test.ts +73 -0
  287. package/src/markdown-extract.ts +76 -0
  288. package/src/native-crawler.ts +1106 -0
  289. package/src/network-observer.ts +204 -0
  290. package/src/network-throttle.ts +69 -0
  291. package/src/openapi-generator.ts +135 -0
  292. package/src/pdf-extractor.ts +123 -0
  293. package/src/performance-metrics.ts +134 -0
  294. package/src/phash.ts +151 -0
  295. package/src/pii-scrubber.ts +99 -0
  296. package/src/playwright-recorder.ts +170 -0
  297. package/src/policies/crawl-safety.ts +46 -0
  298. package/src/profiles/domain-profile.ts +167 -0
  299. package/src/proxy.ts +238 -0
  300. package/src/rate-limiter.ts +87 -0
  301. package/src/redirect-tracker.ts +97 -0
  302. package/src/redis-frontier.ts +143 -0
  303. package/src/redis-queue.ts +208 -0
  304. package/src/response-cache.ts +150 -0
  305. package/src/responsive-detector.ts +92 -0
  306. package/src/retry-policy.ts +87 -0
  307. package/src/robots-txt.ts +96 -0
  308. package/src/screen-reader.ts +89 -0
  309. package/src/screenshot-masker.ts +98 -0
  310. package/src/seo-checker.ts +134 -0
  311. package/src/session-capture.ts +325 -0
  312. package/src/session-export.test.ts +75 -0
  313. package/src/session-export.ts +172 -0
  314. package/src/sitemap-writer.ts +65 -0
  315. package/src/sitemap.test.ts +131 -0
  316. package/src/sitemap.ts +224 -0
  317. package/src/skyvern.ts +199 -0
  318. package/src/sqs-queue.ts +49 -0
  319. package/src/sso-token.ts +120 -0
  320. package/src/stealth/human-behavior.ts +191 -0
  321. package/src/telemetry.ts +122 -0
  322. package/src/test-executor.test.ts +72 -0
  323. package/src/test-executor.ts +424 -0
  324. package/src/tls-fingerprint.ts +111 -0
  325. package/src/url-pattern-completer.ts +114 -0
  326. package/src/visual-diff-verdict.test.ts +80 -0
  327. package/src/visual-diff-verdict.ts +57 -0
  328. package/src/webhook-notifier.ts +109 -0
  329. package/src/websocket-detector.ts +83 -0
  330. package/src/worker.ts +48 -0
  331. package/tsconfig.json +13 -0
  332. package/types/accessibility-auditor.d.ts +29 -0
  333. package/types/ai-login.d.ts +32 -0
  334. package/types/auth-token-rotator.d.ts +47 -0
  335. package/types/auth.d.ts +20 -0
  336. package/types/browser-extension.d.ts +37 -0
  337. package/types/bull-worker.d.ts +6 -0
  338. package/types/captcha-solver.d.ts +16 -0
  339. package/types/color-contrast.d.ts +30 -0
  340. package/types/content-chunker.d.ts +18 -0
  341. package/types/content-dedup.d.ts +23 -0
  342. package/types/cookie-consent.d.ts +25 -0
  343. package/types/cookie-jar.d.ts +20 -0
  344. package/types/crawler.d.ts +179 -0
  345. package/types/custom-js-injector.d.ts +42 -0
  346. package/types/dark-mode-capture.d.ts +23 -0
  347. package/types/dead-link-detector.d.ts +37 -0
  348. package/types/element-hash.d.ts +9 -0
  349. package/types/email-otp.d.ts +42 -0
  350. package/types/engine-router.d.ts +47 -0
  351. package/types/export/bulk-export.d.ts +26 -0
  352. package/types/export/vector-export.d.ts +76 -0
  353. package/types/extractors/advanced.d.ts +54 -0
  354. package/types/extractors/artifact-envelope.d.ts +63 -0
  355. package/types/failure-analysis-agent.d.ts +16 -0
  356. package/types/feature-flag-detector.d.ts +30 -0
  357. package/types/form-detector.d.ts +34 -0
  358. package/types/frontier.d.ts +32 -0
  359. package/types/gap-pass.d.ts +35 -0
  360. package/types/graphql-detector.d.ts +39 -0
  361. package/types/http-engine.d.ts +80 -0
  362. package/types/incremental-diff.d.ts +59 -0
  363. package/types/index.d.ts +35 -0
  364. package/types/input-fuzzer.d.ts +30 -0
  365. package/types/interactive-probe.d.ts +104 -0
  366. package/types/job-queue.d.ts +171 -0
  367. package/types/js-coverage.d.ts +29 -0
  368. package/types/js-router-detector.d.ts +17 -0
  369. package/types/language-detector.d.ts +22 -0
  370. package/types/link-classifier.d.ts +23 -0
  371. package/types/live-view.d.ts +36 -0
  372. package/types/logger.d.ts +2 -0
  373. package/types/markdown-extract.d.ts +8 -0
  374. package/types/native-crawler.d.ts +69 -0
  375. package/types/network-observer.d.ts +49 -0
  376. package/types/network-throttle.d.ts +25 -0
  377. package/types/openapi-generator.d.ts +49 -0
  378. package/types/pdf-extractor.d.ts +28 -0
  379. package/types/performance-metrics.d.ts +53 -0
  380. package/types/phash.d.ts +38 -0
  381. package/types/pii-scrubber.d.ts +21 -0
  382. package/types/playwright-recorder.d.ts +39 -0
  383. package/types/policies/crawl-safety.d.ts +9 -0
  384. package/types/profiles/domain-profile.d.ts +19 -0
  385. package/types/proxy.d.ts +50 -0
  386. package/types/rate-limiter.d.ts +31 -0
  387. package/types/redirect-tracker.d.ts +37 -0
  388. package/types/redis-frontier.d.ts +48 -0
  389. package/types/redis-queue.d.ts +48 -0
  390. package/types/response-cache.d.ts +38 -0
  391. package/types/responsive-detector.d.ts +29 -0
  392. package/types/retry-policy.d.ts +20 -0
  393. package/types/robots-txt.d.ts +9 -0
  394. package/types/screen-reader.d.ts +16 -0
  395. package/types/screenshot-masker.d.ts +28 -0
  396. package/types/seo-checker.d.ts +33 -0
  397. package/types/session-capture.d.ts +38 -0
  398. package/types/session-export.d.ts +49 -0
  399. package/types/sitemap-writer.d.ts +21 -0
  400. package/types/sitemap.d.ts +51 -0
  401. package/types/skyvern.d.ts +56 -0
  402. package/types/sqs-queue.d.ts +2 -0
  403. package/types/sso-token.d.ts +29 -0
  404. package/types/stealth/human-behavior.d.ts +20 -0
  405. package/types/telemetry.d.ts +34 -0
  406. package/types/test-executor.d.ts +67 -0
  407. package/types/tls-fingerprint.d.ts +50 -0
  408. package/types/url-pattern-completer.d.ts +28 -0
  409. package/types/visual-diff-verdict.d.ts +40 -0
  410. package/types/webhook-notifier.d.ts +30 -0
  411. package/types/websocket-detector.d.ts +24 -0
  412. package/types/worker.d.ts +1 -0
@@ -0,0 +1,72 @@
1
+ import http from 'node:http';
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { executeTestPlan } from './test-executor.js';
5
+ import type { ExecutionStep } from '@detiq/agents';
6
+
7
+ async function startFixtureServer(html: string): Promise<{ url: string; close: () => Promise<void> }> {
8
+ const server = http.createServer((_req, res) => {
9
+ res.writeHead(200, { 'Content-Type': 'text/html' });
10
+ res.end(html);
11
+ });
12
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
13
+ const address = server.address();
14
+ const port = typeof address === 'object' && address ? address.port : 0;
15
+ return {
16
+ url: `http://127.0.0.1:${port}`,
17
+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
18
+ };
19
+ }
20
+
21
+ // No AI healing context is passed in these tests (healCtx omitted), so the AI tier
22
+ // (which needs a real LLM call) never engages — this is regression coverage for the
23
+ // pre-existing mechanical strategy-fallback tier that self-healing wiring must not break.
24
+
25
+ test('resolveLocator: primary strategy succeeds when the selector/strategy pair is correct', async () => {
26
+ const fixture = await startFixtureServer('<html><body><button>Submit</button></body></html>');
27
+ try {
28
+ const plan: ExecutionStep[] = [
29
+ { stepNumber: 1, description: 'navigate', action: 'navigate', url: fixture.url },
30
+ { stepNumber: 2, description: 'click Submit', action: 'click', selector: 'Submit', selectorStrategy: 'text' },
31
+ ];
32
+ const result = await executeTestPlan(plan, { screenshotDir: '/tmp', testRunId: 'test-primary-ok' });
33
+ assert.equal(result.status, 'PASSED');
34
+ assert.equal(result.stepResults[1].healed, false);
35
+ } finally {
36
+ await fixture.close();
37
+ }
38
+ });
39
+
40
+ test('resolveLocator: mechanical tier heals when the strategy is wrong but the text still matches', async () => {
41
+ // "label" strategy looks for a form control associated with a <label> — a bare
42
+ // <button> has no such association, so the primary lookup must fail and fall
43
+ // through to the "text" strategy, which does find it.
44
+ const fixture = await startFixtureServer('<html><body><button>Submit</button></body></html>');
45
+ try {
46
+ const plan: ExecutionStep[] = [
47
+ { stepNumber: 1, description: 'navigate', action: 'navigate', url: fixture.url },
48
+ { stepNumber: 2, description: 'click Submit', action: 'click', selector: 'Submit', selectorStrategy: 'label' },
49
+ ];
50
+ const result = await executeTestPlan(plan, { screenshotDir: '/tmp', testRunId: 'test-mechanical-heal' });
51
+ assert.equal(result.status, 'PASSED');
52
+ assert.equal(result.stepResults[1].healed, true);
53
+ assert.equal(result.stepResults[1].healedVia, 'mechanical');
54
+ } finally {
55
+ await fixture.close();
56
+ }
57
+ });
58
+
59
+ test('resolveLocator: fails cleanly (no healCtx configured) when no strategy matches at all', async () => {
60
+ const fixture = await startFixtureServer('<html><body><button>Submit</button></body></html>');
61
+ try {
62
+ const plan: ExecutionStep[] = [
63
+ { stepNumber: 1, description: 'navigate', action: 'navigate', url: fixture.url },
64
+ { stepNumber: 2, description: 'click Nonexistent', action: 'click', selector: 'Nonexistent Button', selectorStrategy: 'text' },
65
+ ];
66
+ const result = await executeTestPlan(plan, { screenshotDir: '/tmp', testRunId: 'test-no-match' });
67
+ assert.equal(result.status, 'FAILED');
68
+ assert.equal(result.stepResults[1].healed, undefined);
69
+ } finally {
70
+ await fixture.close();
71
+ }
72
+ });
@@ -0,0 +1,424 @@
1
+ import { chromium, firefox, webkit, type Page, type Locator } from 'playwright';
2
+ import type { ExecutionStep } from '@detiq/agents';
3
+ import { analyzeFailure } from './failure-analysis-agent.js';
4
+
5
+ export interface StepResult {
6
+ stepNumber: number;
7
+ description: string;
8
+ status: 'PASSED' | 'FAILED' | 'SKIPPED';
9
+ duration: number;
10
+ error?: string;
11
+ screenshotPath?: string;
12
+ healed?: boolean;
13
+ /** 'mechanical' = same selector text, different Playwright locator strategy.
14
+ * 'ai' = AI Self-Healing Agent matched a semantically different element
15
+ * (the app's selector text itself changed, e.g. "Sign In" -> "Log In"). */
16
+ healedVia?: 'mechanical' | 'ai';
17
+ healReasoning?: string;
18
+ healConfidence?: number;
19
+ }
20
+
21
+ export interface ExecutionResult {
22
+ stepResults: StepResult[];
23
+ status: 'PASSED' | 'FAILED' | 'ERROR' | 'FLAKY';
24
+ errorStep?: number;
25
+ totalDuration: number;
26
+ finalScreenshotPath?: string;
27
+ retryCount?: number;
28
+ consoleLogs?: Array<{ type: string; text: string; timestamp: number }>;
29
+ networkErrors?: Array<{ url: string; status: number; method: string; timestamp: number }>;
30
+ rootCauseSummary?: string;
31
+ networkTimings?: Array<{ url: string; method: string; status: number; duration: number }>;
32
+ }
33
+
34
+ const LOCATOR_STRATEGIES: ExecutionStep['selectorStrategy'][] = ['text', 'label', 'role', 'css'];
35
+
36
+ function buildLocator(page: Page, selector: string, strategy: ExecutionStep['selectorStrategy'] = 'text'): Locator {
37
+ switch (strategy) {
38
+ case 'css': return page.locator(selector);
39
+ case 'text': return page.getByText(selector, { exact: false });
40
+ case 'role': return page.getByRole(selector as any);
41
+ case 'label': return page.getByLabel(selector);
42
+ default: return page.locator(selector);
43
+ }
44
+ }
45
+
46
+ /** Scans the live page for visible interactive elements — the "current page elements"
47
+ * fed to the AI Self-Healing Agent when mechanical locator strategies fail. */
48
+ async function extractCurrentPageElements(page: Page): Promise<Array<{ meaning: string; role: string; ariaLabel?: string; textContent?: string }>> {
49
+ return page.evaluate(() => {
50
+ const els = Array.from(document.querySelectorAll<HTMLElement>(
51
+ 'a, button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [aria-label]'
52
+ ));
53
+ return els
54
+ .filter((el) => {
55
+ const rect = el.getBoundingClientRect();
56
+ return rect.width > 0 && rect.height > 0;
57
+ })
58
+ .slice(0, 200)
59
+ .map((el) => {
60
+ const ariaLabel = el.getAttribute('aria-label') ?? undefined;
61
+ const textContent = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 100) || undefined;
62
+ const role = el.getAttribute('role') ?? el.tagName.toLowerCase();
63
+ return { meaning: ariaLabel || textContent || role, role, ariaLabel, textContent };
64
+ });
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Resolves a step's target element in three tiers:
70
+ * 1. Exact selector + strategy as generated.
71
+ * 2. Mechanical: same selector text, other Playwright locator strategies
72
+ * (handles a strategy mismatch, not an actual UI change).
73
+ * 3. AI: when tenantId/projectId are supplied, asks the Self-Healing Agent to find
74
+ * the current page's best semantic match for the failed step description — the
75
+ * tier that actually survives the app's selector text changing.
76
+ */
77
+ async function resolveLocator(
78
+ page: Page,
79
+ step: ExecutionStep,
80
+ healCtx?: { tenantId: string; projectId: string },
81
+ ): Promise<{ loc: Locator; healed: boolean; healedVia?: 'mechanical' | 'ai'; healReasoning?: string; healConfidence?: number } | null> {
82
+ const { selector, selectorStrategy = 'text' } = step;
83
+ if (!selector) return null;
84
+
85
+ const primary = buildLocator(page, selector, selectorStrategy);
86
+ const attached = await primary.first().isVisible({ timeout: 1500 }).catch(() => false);
87
+ if (attached) return { loc: primary, healed: false };
88
+
89
+ // Tier 2 — mechanical: try other strategies before giving up
90
+ for (const strategy of LOCATOR_STRATEGIES) {
91
+ if (strategy === selectorStrategy) continue;
92
+ const loc = buildLocator(page, selector, strategy);
93
+ const visible = await loc.first().isVisible({ timeout: 800 }).catch(() => false);
94
+ if (visible) return { loc, healed: true, healedVia: 'mechanical' };
95
+ }
96
+
97
+ // Tier 3 — AI: the selector text itself may no longer match anything on the page.
98
+ if (healCtx) {
99
+ try {
100
+ const { healElement } = await import('@detiq/agents');
101
+ const currentPageElements = await extractCurrentPageElements(page);
102
+ const result = await healElement({
103
+ tenantId: healCtx.tenantId,
104
+ projectId: healCtx.projectId,
105
+ failedElementMeaning: step.description || selector,
106
+ currentPageElements,
107
+ });
108
+ if (result.healed && result.updatedTest) {
109
+ const healedLoc = page.getByText(result.updatedTest.elementMeaning, { exact: false });
110
+ const visible = await healedLoc.first().isVisible({ timeout: 1500 }).catch(() => false);
111
+ if (visible) {
112
+ return {
113
+ loc: healedLoc,
114
+ healed: true,
115
+ healedVia: 'ai',
116
+ healReasoning: result.reasoning,
117
+ healConfidence: result.confidence,
118
+ };
119
+ }
120
+ }
121
+ } catch {
122
+ // AI healing is a best-effort last resort — any failure here (LLM error, bad
123
+ // JSON, etc.) just falls through to the caller's normal Playwright error below.
124
+ }
125
+ }
126
+
127
+ // Return primary — caller will fail with a meaningful Playwright error
128
+ return { loc: primary, healed: false };
129
+ }
130
+
131
+ function applyHealInfo(result: StepResult, r: { healed: boolean; healedVia?: 'mechanical' | 'ai'; healReasoning?: string; healConfidence?: number }): void {
132
+ result.healed = r.healed;
133
+ result.healedVia = r.healedVia;
134
+ result.healReasoning = r.healReasoning;
135
+ result.healConfidence = r.healConfidence;
136
+ }
137
+
138
+ async function runPlan(
139
+ page: Page,
140
+ plan: ExecutionStep[],
141
+ options: { screenshotDir: string; testRunId: string; onStepComplete?: (result: StepResult) => void; healCtx?: { tenantId: string; projectId: string } },
142
+ consoleLogs: Array<{ type: string; text: string; timestamp: number }>,
143
+ networkErrors: Array<{ url: string; status: number; method: string; timestamp: number }>,
144
+ ): Promise<{ stepResults: StepResult[]; errorStep?: number; finalScreenshotPath?: string; consoleLogs: typeof consoleLogs; networkErrors: typeof networkErrors }> {
145
+ const stepResults: StepResult[] = [];
146
+ let errorStep: number | undefined;
147
+ let finalScreenshotPath: string | undefined;
148
+
149
+ for (const step of plan) {
150
+ const stepStart = Date.now();
151
+ const result: StepResult = {
152
+ stepNumber: step.stepNumber,
153
+ description: step.description,
154
+ status: 'FAILED',
155
+ duration: 0,
156
+ };
157
+
158
+ try {
159
+ const timeout = step.timeout ?? 8000;
160
+
161
+ switch (step.action) {
162
+ case 'navigate':
163
+ await page.goto(step.url!, { waitUntil: 'domcontentloaded', timeout });
164
+ break;
165
+
166
+ case 'click': {
167
+ const r = await resolveLocator(page, step, options.healCtx);
168
+ if (!r) throw new Error('Could not resolve locator');
169
+ await r.loc.first().click({ timeout });
170
+ applyHealInfo(result, r);
171
+ break;
172
+ }
173
+
174
+ case 'fill': {
175
+ const r = await resolveLocator(page, step, options.healCtx);
176
+ if (!r) throw new Error('Could not resolve locator');
177
+ await r.loc.first().fill(step.value ?? '', { timeout });
178
+ applyHealInfo(result, r);
179
+ break;
180
+ }
181
+
182
+ case 'select': {
183
+ const r = await resolveLocator(page, step, options.healCtx);
184
+ if (!r) throw new Error('Could not resolve locator');
185
+ await r.loc.first().selectOption(step.value ?? '', { timeout });
186
+ applyHealInfo(result, r);
187
+ break;
188
+ }
189
+
190
+ case 'hover': {
191
+ const r = await resolveLocator(page, step, options.healCtx);
192
+ if (!r) throw new Error('Could not resolve locator');
193
+ await r.loc.first().hover({ timeout });
194
+ applyHealInfo(result, r);
195
+ break;
196
+ }
197
+
198
+ case 'press_key':
199
+ await page.keyboard.press(step.value ?? 'Enter');
200
+ break;
201
+
202
+ case 'assert_visible': {
203
+ const r = await resolveLocator(page, step, options.healCtx);
204
+ if (!r) throw new Error('Could not resolve locator');
205
+ await r.loc.first().waitFor({ state: 'visible', timeout });
206
+ applyHealInfo(result, r);
207
+ break;
208
+ }
209
+
210
+ case 'assert_text': {
211
+ const r = await resolveLocator(page, step, options.healCtx);
212
+ if (!r) throw new Error('Could not resolve locator');
213
+ const text = await r.loc.first().textContent({ timeout });
214
+ if (!text?.includes(step.value ?? '')) {
215
+ throw new Error(`Expected text "${step.value}" not found. Got: "${text?.slice(0, 100)}"`);
216
+ }
217
+ applyHealInfo(result, r);
218
+ break;
219
+ }
220
+
221
+ case 'assert_url': {
222
+ const url = page.url();
223
+ if (!url.includes(step.value ?? '')) {
224
+ throw new Error(`Expected URL to contain "${step.value}". Got: "${url}"`);
225
+ }
226
+ break;
227
+ }
228
+
229
+ case 'wait':
230
+ await page.waitForTimeout(step.timeout ?? 2000);
231
+ break;
232
+
233
+ case 'screenshot': {
234
+ const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;
235
+ await page.screenshot({ path: ssPath, fullPage: true });
236
+ result.screenshotPath = ssPath;
237
+ break;
238
+ }
239
+ }
240
+
241
+ result.status = 'PASSED';
242
+ } catch (err: any) {
243
+ result.status = step.optional ? 'SKIPPED' : 'FAILED';
244
+ result.error = err.message;
245
+ if (!step.optional) {
246
+ errorStep = step.stepNumber;
247
+ }
248
+ }
249
+
250
+ try {
251
+ await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => {});
252
+ if (!result.screenshotPath) {
253
+ const ssPath = `${options.screenshotDir}/${options.testRunId}-step-${step.stepNumber}.png`;
254
+ await page.screenshot({ path: ssPath, fullPage: true });
255
+ result.screenshotPath = ssPath;
256
+ }
257
+ } catch { /* Screenshot evidence is best-effort and should not fail the run. */ }
258
+
259
+ result.duration = Date.now() - stepStart;
260
+ stepResults.push(result);
261
+ options.onStepComplete?.(result);
262
+
263
+ if (result.status === 'FAILED') break;
264
+ }
265
+
266
+ try {
267
+ const finalPath = `${options.screenshotDir}/${options.testRunId}-final.png`;
268
+ await page.screenshot({ path: finalPath, fullPage: true });
269
+ finalScreenshotPath = finalPath;
270
+ } catch { /* non-fatal */ }
271
+
272
+ return { stepResults, errorStep, finalScreenshotPath, consoleLogs, networkErrors };
273
+ }
274
+
275
+ function interpolate(template: string | undefined, data: Record<string, string>): string | undefined {
276
+ if (!template) return template;
277
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] ?? `{{${key}}}`);
278
+ }
279
+
280
+ export async function executeTestPlan(
281
+ plan: ExecutionStep[],
282
+ options: {
283
+ screenshotDir: string;
284
+ testRunId: string;
285
+ viewport?: { width: number; height: number };
286
+ onStepComplete?: (result: StepResult) => void;
287
+ /** Run the test up to this many times to detect flakiness (default 1). */
288
+ maxRuns?: number;
289
+ browser?: 'chromium' | 'firefox' | 'webkit';
290
+ /** Data row for parameterized execution: substitutes {{varname}} in step fields. */
291
+ dataRow?: Record<string, string>;
292
+ /** Enables the AI Self-Healing Agent tier when a step's selector can't be resolved
293
+ * even after trying alternate locator strategies. Omit to keep mechanical-only
294
+ * healing (no LLM calls during execution). */
295
+ healCtx?: { tenantId: string; projectId: string };
296
+ /** Decrypted Playwright storageState JSON — restores a logged-in session so
297
+ * steps don't have to re-authenticate. Without this, any test case behind a
298
+ * login wall fails at the first authenticated step. */
299
+ storageState?: string;
300
+ },
301
+ ): Promise<ExecutionResult> {
302
+ if (options.dataRow && Object.keys(options.dataRow).length > 0) {
303
+ const data = options.dataRow;
304
+ plan = plan.map((step) => ({
305
+ ...step,
306
+ url: interpolate(step.url, data),
307
+ selector: interpolate(step.selector, data),
308
+ value: interpolate(step.value, data),
309
+ description: interpolate(step.description, data) ?? step.description,
310
+ }));
311
+ }
312
+ const maxRuns = options.maxRuns ?? 1;
313
+ const browserEngine = { chromium, firefox, webkit }[options.browser ?? 'chromium'] ?? chromium;
314
+ const start = Date.now();
315
+
316
+ const outcomes: ('PASSED' | 'FAILED')[] = [];
317
+ let lastResult: Awaited<ReturnType<typeof runPlan>> | null = null;
318
+ let lastNetworkTimings: Array<{ url: string; method: string; status: number; duration: number }> = [];
319
+
320
+ for (let run = 0; run < maxRuns; run++) {
321
+ const browser = await browserEngine.launch({
322
+ headless: true,
323
+ // Optional override for environments with a non-standard/pre-provisioned
324
+ // browser install (e.g. a custom base image with only system Chromium).
325
+ ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
326
+ });
327
+ let parsedStorageState: any;
328
+ try {
329
+ parsedStorageState = options.storageState ? JSON.parse(options.storageState) : undefined;
330
+ } catch {
331
+ parsedStorageState = undefined;
332
+ }
333
+ const context = await browser.newContext({
334
+ viewport: options.viewport ?? { width: 1440, height: 900 },
335
+ ...(parsedStorageState ? { storageState: parsedStorageState } : {}),
336
+ });
337
+ const page = await context.newPage();
338
+
339
+ await page
340
+ .addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' })
341
+ .catch(() => {});
342
+
343
+ const consoleLogs: Array<{ type: string; text: string; timestamp: number }> = [];
344
+ const networkErrors: Array<{ url: string; status: number; method: string; timestamp: number }> = [];
345
+ const networkTimings: Array<{ url: string; method: string; status: number; duration: number }> = [];
346
+ const requestStartTimes = new Map<string, number>();
347
+
348
+ page.on('console', msg => {
349
+ if (consoleLogs.length < 100) {
350
+ consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500), timestamp: Date.now() });
351
+ }
352
+ });
353
+ page.on('request', request => {
354
+ const url = request.url();
355
+ if (url.includes('/api/') || url.match(/\/(graphql|rest|v\d)\//)) {
356
+ requestStartTimes.set(request.url(), Date.now());
357
+ }
358
+ });
359
+ page.on('response', response => {
360
+ const url = response.url();
361
+ const start = requestStartTimes.get(url);
362
+ if (start && networkTimings.length < 200) {
363
+ networkTimings.push({ url: url.slice(0, 300), method: response.request().method(), status: response.status(), duration: Date.now() - start });
364
+ requestStartTimes.delete(url);
365
+ }
366
+ if (!response.ok() && !url.includes('favicon') && networkErrors.length < 50) {
367
+ networkErrors.push({
368
+ url: url.slice(0, 300),
369
+ status: response.status(),
370
+ method: response.request().method(),
371
+ timestamp: Date.now(),
372
+ });
373
+ }
374
+ });
375
+
376
+ lastResult = await runPlan(page, plan, options, consoleLogs, networkErrors);
377
+ lastNetworkTimings = networkTimings;
378
+ await browser.close();
379
+
380
+ const passed = !lastResult.stepResults.some((r) => r.status === 'FAILED');
381
+ outcomes.push(passed ? 'PASSED' : 'FAILED');
382
+
383
+ // If all subsequent runs are consistent with first, no need to keep going
384
+ if (run > 0 && outcomes.every((o) => o === outcomes[0])) break;
385
+ }
386
+
387
+ const passCount = outcomes.filter((o) => o === 'PASSED').length;
388
+ const failCount = outcomes.filter((o) => o === 'FAILED').length;
389
+
390
+ let status: ExecutionResult['status'];
391
+ if (maxRuns > 1 && passCount > 0 && failCount > 0) {
392
+ status = 'FLAKY';
393
+ } else if (passCount === outcomes.length) {
394
+ status = 'PASSED';
395
+ } else {
396
+ status = 'FAILED';
397
+ }
398
+
399
+ let rootCauseSummary: string | undefined;
400
+ if (status === 'FAILED' || status === 'FLAKY') {
401
+ const failedStep = lastResult!.stepResults.find(s => s.status === 'FAILED');
402
+ rootCauseSummary = await analyzeFailure({
403
+ tenantId: options.healCtx?.tenantId,
404
+ error: failedStep?.error ?? 'Unknown error',
405
+ stepDescription: failedStep?.description,
406
+ consoleLogs: lastResult!.consoleLogs,
407
+ networkErrors: lastResult!.networkErrors,
408
+ screenshotPath: lastResult!.finalScreenshotPath,
409
+ }).catch(() => undefined) ?? undefined;
410
+ }
411
+
412
+ return {
413
+ stepResults: lastResult!.stepResults,
414
+ status,
415
+ errorStep: lastResult!.errorStep,
416
+ totalDuration: Date.now() - start,
417
+ finalScreenshotPath: lastResult!.finalScreenshotPath,
418
+ retryCount: outcomes.length - 1,
419
+ consoleLogs: lastResult!.consoleLogs,
420
+ networkErrors: lastResult!.networkErrors,
421
+ rootCauseSummary,
422
+ networkTimings: lastNetworkTimings,
423
+ };
424
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * TLS fingerprint spoofing — Chrome JA3 cipher suite ordering.
3
+ *
4
+ * Node.js TLS doesn't expose JA3 hash control directly, but ordering
5
+ * cipher suites to match Chrome's TLS ClientHello reduces fingerprint
6
+ * detectability vs the default OpenSSL order.
7
+ *
8
+ * For full JA3 bypass, pair with the browser-stealth engine (Camoufox).
9
+ */
10
+
11
+ import { Agent as HttpsAgent } from 'node:https';
12
+ import { connect as tlsConnect } from 'node:tls';
13
+
14
+ // Chrome 120+ TLS 1.3 + 1.2 cipher suite order
15
+ export const CHROME_CIPHERS = [
16
+ 'TLS_AES_128_GCM_SHA256',
17
+ 'TLS_AES_256_GCM_SHA384',
18
+ 'TLS_CHACHA20_POLY1305_SHA256',
19
+ 'ECDHE-ECDSA-AES128-GCM-SHA256',
20
+ 'ECDHE-RSA-AES128-GCM-SHA256',
21
+ 'ECDHE-ECDSA-AES256-GCM-SHA384',
22
+ 'ECDHE-RSA-AES256-GCM-SHA384',
23
+ 'ECDHE-ECDSA-CHACHA20-POLY1305',
24
+ 'ECDHE-RSA-CHACHA20-POLY1305',
25
+ 'ECDHE-RSA-AES128-SHA',
26
+ 'ECDHE-RSA-AES256-SHA',
27
+ 'AES128-GCM-SHA256',
28
+ 'AES256-GCM-SHA384',
29
+ 'AES128-SHA',
30
+ 'AES256-SHA',
31
+ ].join(':');
32
+
33
+ // Chrome's ECDH curve preference order
34
+ export const CHROME_CURVES = 'X25519:P-256:P-384';
35
+
36
+ export interface TlsFingerprintOptions {
37
+ rejectUnauthorized?: boolean;
38
+ keepAlive?: boolean;
39
+ keepAliveMsecs?: number;
40
+ maxSockets?: number;
41
+ }
42
+
43
+ /**
44
+ * Returns an HTTPS agent configured with Chrome-like TLS settings.
45
+ * Drop-in replacement for the default agent in fetch/got/axios.
46
+ */
47
+ export function createChromeTlsAgent(opts: TlsFingerprintOptions = {}): HttpsAgent {
48
+ return new HttpsAgent({
49
+ ciphers: CHROME_CIPHERS,
50
+ ecdhCurve: CHROME_CURVES,
51
+ minVersion: 'TLSv1.2',
52
+ maxVersion: 'TLSv1.3',
53
+ rejectUnauthorized: opts.rejectUnauthorized ?? true,
54
+ keepAlive: opts.keepAlive ?? true,
55
+ keepAliveMsecs: opts.keepAliveMsecs ?? 10_000,
56
+ maxSockets: opts.maxSockets ?? 50,
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Probe a host and return its JA3-relevant TLS parameters.
62
+ * Useful for verifying that the fingerprint changed vs default.
63
+ */
64
+ export async function probeTlsFingerprint(host: string, port = 443): Promise<{
65
+ protocol: string;
66
+ cipher: string;
67
+ authorized: boolean;
68
+ subjectCN: string | null;
69
+ }> {
70
+ return new Promise((resolve, reject) => {
71
+ const socket = tlsConnect({
72
+ host,
73
+ port,
74
+ servername: host,
75
+ ciphers: CHROME_CIPHERS,
76
+ ecdhCurve: CHROME_CURVES,
77
+ minVersion: 'TLSv1.2',
78
+ rejectUnauthorized: false,
79
+ }, () => {
80
+ const cert = socket.getPeerCertificate();
81
+ resolve({
82
+ protocol: socket.getProtocol() ?? 'unknown',
83
+ cipher: socket.getCipher()?.name ?? 'unknown',
84
+ authorized: socket.authorized,
85
+ subjectCN: cert?.subject?.CN ?? null,
86
+ });
87
+ socket.destroy();
88
+ });
89
+ socket.on('error', reject);
90
+ setTimeout(() => { socket.destroy(); reject(new Error('TLS probe timeout')); }, 5_000);
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Node.js fetch dispatcher options with Chrome TLS agent.
96
+ * Pass as the `dispatcher` option to undici or as `agent` to node-fetch.
97
+ */
98
+ export const chromeTlsAgent = createChromeTlsAgent();
99
+
100
+ export const CHROME_TLS_HEADERS = {
101
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
102
+ 'Accept-Language': 'en-US,en;q=0.9',
103
+ 'sec-ch-ua': '"Google Chrome";v="120", "Chromium";v="120", "Not-A.Brand";v="99"',
104
+ 'sec-ch-ua-mobile': '?0',
105
+ 'sec-ch-ua-platform': '"Windows"',
106
+ 'Sec-Fetch-Dest': 'document',
107
+ 'Sec-Fetch-Mode': 'navigate',
108
+ 'Sec-Fetch-Site': 'none',
109
+ 'Sec-Fetch-User': '?1',
110
+ 'Upgrade-Insecure-Requests': '1',
111
+ };