@sun-asterisk/sungen 3.2.24 → 3.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/dist/capabilities/context-router.d.ts.map +1 -1
  2. package/dist/capabilities/context-router.js +15 -7
  3. package/dist/capabilities/context-router.js.map +1 -1
  4. package/dist/cli/commands/capability.d.ts.map +1 -1
  5. package/dist/cli/commands/capability.js +6 -48
  6. package/dist/cli/commands/capability.js.map +1 -1
  7. package/dist/cli/commands/changed.d.ts +14 -0
  8. package/dist/cli/commands/changed.d.ts.map +1 -0
  9. package/dist/cli/commands/changed.js +128 -0
  10. package/dist/cli/commands/changed.js.map +1 -0
  11. package/dist/cli/commands/delivery.d.ts.map +1 -1
  12. package/dist/cli/commands/delivery.js +25 -1
  13. package/dist/cli/commands/delivery.js.map +1 -1
  14. package/dist/cli/commands/generate.js +1 -1
  15. package/dist/cli/commands/generate.js.map +1 -1
  16. package/dist/cli/commands/results-merge.d.ts +19 -0
  17. package/dist/cli/commands/results-merge.d.ts.map +1 -0
  18. package/dist/cli/commands/results-merge.js +191 -0
  19. package/dist/cli/commands/results-merge.js.map +1 -0
  20. package/dist/cli/commands/update.d.ts.map +1 -1
  21. package/dist/cli/commands/update.js +100 -12
  22. package/dist/cli/commands/update.js.map +1 -1
  23. package/dist/cli/index.js +4 -0
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/exporters/api-catalog-loader.d.ts.map +1 -1
  26. package/dist/exporters/api-catalog-loader.js +10 -3
  27. package/dist/exporters/api-catalog-loader.js.map +1 -1
  28. package/dist/exporters/api-testcase-formatter.d.ts +9 -0
  29. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
  30. package/dist/exporters/api-testcase-formatter.js +55 -3
  31. package/dist/exporters/api-testcase-formatter.js.map +1 -1
  32. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  33. package/dist/exporters/csv-exporter.js +10 -1
  34. package/dist/exporters/csv-exporter.js.map +1 -1
  35. package/dist/exporters/json-exporter.d.ts.map +1 -1
  36. package/dist/exporters/json-exporter.js +6 -1
  37. package/dist/exporters/json-exporter.js.map +1 -1
  38. package/dist/exporters/matrix/build.d.ts.map +1 -1
  39. package/dist/exporters/matrix/build.js +10 -0
  40. package/dist/exporters/matrix/build.js.map +1 -1
  41. package/dist/exporters/matrix/export.js +1 -1
  42. package/dist/exporters/matrix/export.js.map +1 -1
  43. package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
  44. package/dist/exporters/matrix/render-csv.js +5 -1
  45. package/dist/exporters/matrix/render-csv.js.map +1 -1
  46. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  47. package/dist/exporters/matrix/render-xlsx.js +4 -1
  48. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  49. package/dist/exporters/matrix/types.d.ts +7 -0
  50. package/dist/exporters/matrix/types.d.ts.map +1 -1
  51. package/dist/exporters/matrix/types.js.map +1 -1
  52. package/dist/exporters/playwright-report-parser.d.ts +17 -2
  53. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  54. package/dist/exporters/playwright-report-parser.js +68 -8
  55. package/dist/exporters/playwright-report-parser.js.map +1 -1
  56. package/dist/exporters/results-merge.d.ts +46 -0
  57. package/dist/exporters/results-merge.d.ts.map +1 -0
  58. package/dist/exporters/results-merge.js +148 -0
  59. package/dist/exporters/results-merge.js.map +1 -0
  60. package/dist/exporters/selector-key-resolver.d.ts +4 -0
  61. package/dist/exporters/selector-key-resolver.d.ts.map +1 -1
  62. package/dist/exporters/selector-key-resolver.js +101 -1
  63. package/dist/exporters/selector-key-resolver.js.map +1 -1
  64. package/dist/exporters/types.d.ts +8 -1
  65. package/dist/exporters/types.d.ts.map +1 -1
  66. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  67. package/dist/exporters/xlsx-report-builder.js +5 -4
  68. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  69. package/dist/generators/gherkin-parser/index.d.ts +2 -0
  70. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  71. package/dist/generators/gherkin-parser/index.js +52 -1
  72. package/dist/generators/gherkin-parser/index.js.map +1 -1
  73. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +20 -10
  74. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  75. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +2 -11
  76. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
  77. package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
  78. package/dist/generators/test-generator/adapters/appium/templates/imports.hbs +3 -0
  79. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/date-picker-unsupported.hbs +4 -0
  80. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/lifecycle-missing-app-id.hbs +5 -0
  81. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/matches-format-assertion.hbs +15 -0
  82. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +5 -2
  83. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +185 -26
  84. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +2 -11
  85. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  86. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  87. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  88. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +17 -1
  89. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/attribute-assertion.hbs +6 -1
  90. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/have-value-assertion.hbs +18 -1
  91. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-filter-assertion.hbs +5 -1
  92. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/matches-format-assertion.hbs +5 -0
  93. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +4 -2
  94. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element-with-text.hbs +4 -1
  95. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -1
  96. package/dist/generators/test-generator/code-generator.d.ts +6 -0
  97. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  98. package/dist/generators/test-generator/code-generator.js +80 -7
  99. package/dist/generators/test-generator/code-generator.js.map +1 -1
  100. package/dist/generators/test-generator/diagnostics.d.ts +60 -6
  101. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  102. package/dist/generators/test-generator/diagnostics.js +59 -5
  103. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  104. package/dist/generators/test-generator/patterns/expect-patterns.d.ts +7 -0
  105. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
  106. package/dist/generators/test-generator/patterns/expect-patterns.js +36 -2
  107. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
  108. package/dist/generators/test-generator/patterns/index.d.ts +19 -12
  109. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  110. package/dist/generators/test-generator/patterns/index.js +58 -38
  111. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  112. package/dist/generators/test-generator/patterns/types.d.ts +23 -0
  113. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  114. package/dist/generators/test-generator/patterns/unsupported-step-code.d.ts +18 -0
  115. package/dist/generators/test-generator/patterns/unsupported-step-code.d.ts.map +1 -0
  116. package/dist/generators/test-generator/patterns/unsupported-step-code.js +23 -0
  117. package/dist/generators/test-generator/patterns/unsupported-step-code.js.map +1 -0
  118. package/dist/generators/test-generator/recipe-expander.js +9 -1
  119. package/dist/generators/test-generator/recipe-expander.js.map +1 -1
  120. package/dist/generators/test-generator/reset-tier.d.ts +68 -0
  121. package/dist/generators/test-generator/reset-tier.d.ts.map +1 -0
  122. package/dist/generators/test-generator/reset-tier.js +94 -0
  123. package/dist/generators/test-generator/reset-tier.js.map +1 -0
  124. package/dist/generators/test-generator/step-mapper.d.ts +39 -0
  125. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  126. package/dist/generators/test-generator/step-mapper.js +144 -5
  127. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  128. package/dist/generators/test-generator/template-engine.d.ts +2 -10
  129. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  130. package/dist/generators/test-generator/template-engine.js +16 -2
  131. package/dist/generators/test-generator/template-engine.js.map +1 -1
  132. package/dist/generators/test-generator/utils/format-pattern.d.ts +35 -0
  133. package/dist/generators/test-generator/utils/format-pattern.d.ts.map +1 -0
  134. package/dist/generators/test-generator/utils/format-pattern.js +82 -0
  135. package/dist/generators/test-generator/utils/format-pattern.js.map +1 -0
  136. package/dist/generators/test-generator/utils/js-literal-scanner.d.ts +42 -0
  137. package/dist/generators/test-generator/utils/js-literal-scanner.d.ts.map +1 -0
  138. package/dist/generators/test-generator/utils/js-literal-scanner.js +142 -0
  139. package/dist/generators/test-generator/utils/js-literal-scanner.js.map +1 -0
  140. package/dist/generators/test-generator/utils/mask-refs-for-matching.d.ts +18 -0
  141. package/dist/generators/test-generator/utils/mask-refs-for-matching.d.ts.map +1 -0
  142. package/dist/generators/test-generator/utils/mask-refs-for-matching.js +25 -0
  143. package/dist/generators/test-generator/utils/mask-refs-for-matching.js.map +1 -0
  144. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +9 -1
  145. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  146. package/dist/generators/test-generator/utils/runtime-data-transformer.js +124 -63
  147. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  148. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  149. package/dist/generators/test-generator/utils/selector-resolver.js +20 -1
  150. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  151. package/dist/generators/test-generator/utils/spec-syntax-check.d.ts +14 -0
  152. package/dist/generators/test-generator/utils/spec-syntax-check.d.ts.map +1 -0
  153. package/dist/generators/test-generator/utils/spec-syntax-check.js +26 -0
  154. package/dist/generators/test-generator/utils/spec-syntax-check.js.map +1 -0
  155. package/dist/harness/annotation-overrides.d.ts +0 -12
  156. package/dist/harness/annotation-overrides.d.ts.map +1 -1
  157. package/dist/harness/annotation-overrides.js +9 -5
  158. package/dist/harness/annotation-overrides.js.map +1 -1
  159. package/dist/harness/audit.js +4 -4
  160. package/dist/harness/audit.js.map +1 -1
  161. package/dist/harness/capability-plan.d.ts +2 -1
  162. package/dist/harness/capability-plan.d.ts.map +1 -1
  163. package/dist/harness/capability-plan.js +8 -3
  164. package/dist/harness/capability-plan.js.map +1 -1
  165. package/dist/harness/capability.d.ts +11 -0
  166. package/dist/harness/capability.d.ts.map +1 -1
  167. package/dist/harness/capability.js +8 -0
  168. package/dist/harness/capability.js.map +1 -1
  169. package/dist/harness/catalog/drivers.yaml +5 -0
  170. package/dist/harness/parse.d.ts.map +1 -1
  171. package/dist/harness/parse.js +5 -1
  172. package/dist/harness/parse.js.map +1 -1
  173. package/dist/harness/run-manifest.d.ts +58 -0
  174. package/dist/harness/run-manifest.d.ts.map +1 -0
  175. package/dist/harness/run-manifest.js +264 -0
  176. package/dist/harness/run-manifest.js.map +1 -0
  177. package/dist/harness/viewpoint-ledger.d.ts +1 -1
  178. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  179. package/dist/harness/viewpoint-ledger.js +25 -4
  180. package/dist/harness/viewpoint-ledger.js.map +1 -1
  181. package/dist/index.d.ts +1 -0
  182. package/dist/index.d.ts.map +1 -1
  183. package/dist/index.js +5 -1
  184. package/dist/index.js.map +1 -1
  185. package/dist/orchestrator/lockstep-version.d.ts +22 -0
  186. package/dist/orchestrator/lockstep-version.d.ts.map +1 -0
  187. package/dist/orchestrator/lockstep-version.js +58 -0
  188. package/dist/orchestrator/lockstep-version.js.map +1 -0
  189. package/dist/orchestrator/project-deps-sync.d.ts +39 -0
  190. package/dist/orchestrator/project-deps-sync.d.ts.map +1 -0
  191. package/dist/orchestrator/project-deps-sync.js +175 -0
  192. package/dist/orchestrator/project-deps-sync.js.map +1 -0
  193. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  194. package/dist/orchestrator/project-initializer.js +5 -2
  195. package/dist/orchestrator/project-initializer.js.map +1 -1
  196. package/dist/orchestrator/templates/ai-src/commands/run-test.md +109 -0
  197. package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
  198. package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
  199. package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +49 -0
  200. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +14 -3
  201. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +110 -6
  202. package/dist/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +1 -1
  203. package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +8 -0
  204. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
  205. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +9 -0
  206. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +15 -0
  207. package/dist/orchestrator/templates/env.appium.example +10 -0
  208. package/dist/orchestrator/templates/specs-api.d.ts +4 -2
  209. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  210. package/dist/orchestrator/templates/specs-api.js +233 -10
  211. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  212. package/dist/orchestrator/templates/specs-api.ts +214 -11
  213. package/dist/orchestrator/templates/specs-regex-escape.d.ts +13 -0
  214. package/dist/orchestrator/templates/specs-regex-escape.d.ts.map +1 -0
  215. package/dist/orchestrator/templates/specs-regex-escape.js +19 -0
  216. package/dist/orchestrator/templates/specs-regex-escape.js.map +1 -0
  217. package/dist/orchestrator/templates/specs-regex-escape.ts +16 -0
  218. package/dist/orchestrator/templates/specs-test-data.ts +53 -6
  219. package/dist/orchestrator/templates/wdio.conf.ts +28 -9
  220. package/package.json +8 -6
  221. package/src/capabilities/context-router.ts +13 -5
  222. package/src/cli/commands/capability.ts +4 -38
  223. package/src/cli/commands/changed.ts +104 -0
  224. package/src/cli/commands/delivery.ts +27 -2
  225. package/src/cli/commands/generate.ts +1 -1
  226. package/src/cli/commands/results-merge.ts +153 -0
  227. package/src/cli/commands/update.ts +72 -13
  228. package/src/cli/index.ts +4 -0
  229. package/src/exporters/api-catalog-loader.ts +10 -2
  230. package/src/exporters/api-testcase-formatter.ts +59 -3
  231. package/src/exporters/csv-exporter.ts +11 -2
  232. package/src/exporters/json-exporter.ts +7 -2
  233. package/src/exporters/matrix/build.ts +9 -0
  234. package/src/exporters/matrix/export.ts +1 -1
  235. package/src/exporters/matrix/render-csv.ts +5 -1
  236. package/src/exporters/matrix/render-xlsx.ts +4 -1
  237. package/src/exporters/matrix/types.ts +7 -0
  238. package/src/exporters/playwright-report-parser.ts +68 -9
  239. package/src/exporters/results-merge.ts +150 -0
  240. package/src/exporters/selector-key-resolver.ts +81 -1
  241. package/src/exporters/types.ts +8 -1
  242. package/src/exporters/xlsx-report-builder.ts +5 -4
  243. package/src/generators/gherkin-parser/index.ts +55 -1
  244. package/src/generators/test-generator/adapters/adapter-interface.ts +23 -1
  245. package/src/generators/test-generator/adapters/appium/appium-adapter.ts +2 -2
  246. package/src/generators/test-generator/adapters/appium/templates/imports.hbs +3 -0
  247. package/src/generators/test-generator/adapters/appium/templates/steps/actions/date-picker-unsupported.hbs +4 -0
  248. package/src/generators/test-generator/adapters/appium/templates/steps/actions/lifecycle-missing-app-id.hbs +5 -0
  249. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/matches-format-assertion.hbs +15 -0
  250. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +5 -2
  251. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +185 -26
  252. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +2 -2
  253. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  254. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +17 -1
  255. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/attribute-assertion.hbs +6 -1
  256. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/have-value-assertion.hbs +18 -1
  257. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-filter-assertion.hbs +5 -1
  258. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/matches-format-assertion.hbs +5 -0
  259. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +4 -2
  260. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element-with-text.hbs +4 -1
  261. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -1
  262. package/src/generators/test-generator/code-generator.ts +86 -7
  263. package/src/generators/test-generator/diagnostics.ts +60 -6
  264. package/src/generators/test-generator/patterns/expect-patterns.ts +37 -2
  265. package/src/generators/test-generator/patterns/index.ts +58 -33
  266. package/src/generators/test-generator/patterns/types.ts +36 -0
  267. package/src/generators/test-generator/patterns/unsupported-step-code.ts +19 -0
  268. package/src/generators/test-generator/recipe-expander.ts +9 -1
  269. package/src/generators/test-generator/reset-tier.ts +93 -0
  270. package/src/generators/test-generator/step-mapper.ts +149 -6
  271. package/src/generators/test-generator/template-engine.ts +18 -3
  272. package/src/generators/test-generator/utils/format-pattern.ts +79 -0
  273. package/src/generators/test-generator/utils/js-literal-scanner.ts +132 -0
  274. package/src/generators/test-generator/utils/mask-refs-for-matching.ts +21 -0
  275. package/src/generators/test-generator/utils/runtime-data-transformer.ts +126 -76
  276. package/src/generators/test-generator/utils/selector-resolver.ts +22 -1
  277. package/src/generators/test-generator/utils/spec-syntax-check.ts +22 -0
  278. package/src/harness/annotation-overrides.ts +10 -5
  279. package/src/harness/audit.ts +4 -4
  280. package/src/harness/capability-plan.ts +10 -5
  281. package/src/harness/capability.ts +16 -0
  282. package/src/harness/catalog/drivers.yaml +5 -0
  283. package/src/harness/parse.ts +5 -1
  284. package/src/harness/run-manifest.ts +254 -0
  285. package/src/harness/viewpoint-ledger.ts +26 -5
  286. package/src/index.ts +1 -0
  287. package/src/orchestrator/lockstep-version.ts +46 -0
  288. package/src/orchestrator/project-deps-sync.ts +175 -0
  289. package/src/orchestrator/project-initializer.ts +5 -2
  290. package/src/orchestrator/templates/ai-src/commands/run-test.md +109 -0
  291. package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
  292. package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
  293. package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +49 -0
  294. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +14 -3
  295. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +110 -6
  296. package/src/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +1 -1
  297. package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +8 -0
  298. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
  299. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +9 -0
  300. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +15 -0
  301. package/src/orchestrator/templates/env.appium.example +10 -0
  302. package/src/orchestrator/templates/specs-api.ts +214 -11
  303. package/src/orchestrator/templates/specs-regex-escape.ts +16 -0
  304. package/src/orchestrator/templates/specs-test-data.ts +53 -6
  305. package/src/orchestrator/templates/wdio.conf.ts +28 -9
@@ -58,6 +58,124 @@ function substitute(text: string, params: Record<string, any>): string {
58
58
  function substituteRaw(text: string, params: Record<string, any>): string {
59
59
  return text.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, p) => String(params[p] ?? ''));
60
60
  }
61
+
62
+ /** The five predefined XML entities — a bound value containing a bare `&` or `<` would
63
+ * otherwise re-create exactly the invalid_xml rejection this encoding exists to avoid. */
64
+ function escapeXml(value: string): string {
65
+ return value
66
+ .replace(/&/g, '&amp;')
67
+ .replace(/</g, '&lt;')
68
+ .replace(/>/g, '&gt;')
69
+ .replace(/"/g, '&quot;')
70
+ .replace(/'/g, '&apos;');
71
+ }
72
+
73
+ /**
74
+ * Bind `:param` tokens into an XML string template by WHITELIST of the passed param
75
+ * names — a generic `:word` scan would mangle namespace prefixes (soapenv:Envelope).
76
+ * Longest name first + a word-boundary lookahead so `:id` can never eat the prefix
77
+ * of `:id_2` or the head of `:idx`. Values are XML-escaped by default; a token the
78
+ * template wraps as `<![CDATA[:param]]>` binds raw inside its CDATA section instead.
79
+ * Replacer FUNCTIONS everywhere — a value containing `$&`-style sequences must never
80
+ * be interpreted as a replacement pattern.
81
+ */
82
+ function bindXmlParams(template: string, params: Record<string, any>): string {
83
+ let out = template;
84
+ for (const name of Object.keys(params).sort((a, b) => b.length - a.length)) {
85
+ const value = String(params[name] ?? '');
86
+ // A value containing "]]>" would terminate the CDATA section early and ship
87
+ // malformed XML — split it across two sections so the document stays valid.
88
+ const cdataSafe = value.split(']]>').join(']]]]><![CDATA[>');
89
+ out = out.replace(new RegExp(`<!\\[CDATA\\[:${name}\\]\\]>`, 'g'), () => `<![CDATA[${cdataSafe}]]>`);
90
+ out = out.replace(new RegExp(`:${name}(?![A-Za-z0-9_])`, 'g'), () => escapeXml(value));
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Decode an XML-ish response body: Content-Type charset, else the XML declaration's
96
+ * encoding (read from an ASCII-compatible prefix), else UTF-8. Unknown charsets fall
97
+ * back to UTF-8 rather than failing the whole request. */
98
+ function decodeXmlAwareText(bytes: Buffer, contentType: string): string {
99
+ // A UTF-16 BOM means the latin1 declaration scan below would see NUL-laden
100
+ // garbage that still "parses" \u2014 decode by the BOM first.
101
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes);
102
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes);
103
+ const head = bytes.subarray(0, 200).toString('latin1');
104
+ const declared = (head.match(/^\uFEFF?\s*<\?xml[^>]*\bencoding=["']([^"']+)["']/i) || [])[1];
105
+ // Candidates in precedence order; an unsupported CT charset falls back to the
106
+ // XML declaration's encoding rather than straight to a mojibake UTF-8 decode.
107
+ const candidates = [(contentType.match(/charset=["']?([^;"'\s]+)/i) || [])[1], declared, 'utf-8'];
108
+ for (const charset of candidates) {
109
+ if (!charset) continue;
110
+ try { return new TextDecoder(charset).decode(bytes); } catch { /* next candidate */ }
111
+ }
112
+ return new TextDecoder('utf-8').decode(bytes);
113
+ }
114
+
115
+ /**
116
+ * Parse an XML response body to a plain object, or null on a real parse failure (the
117
+ * caller then keeps the raw string, matching the non-JSON behavior).
118
+ *
119
+ * Locked parser posture:
120
+ * - processEntities/htmlEntities OFF — fast-xml-parser's entity processing (ON by
121
+ * default, including DOCTYPE entities) is exactly the XXE-shaped surface a QA tool
122
+ * must not expose to a live server's response. The five predefined entities are
123
+ * decoded AFTERWARDS by our own pass.
124
+ * - parseTagValue/parseAttributeValue OFF — `<error_code>01000001</error_code>` must
125
+ * stay a string; numeric coercion destroys leading zeros.
126
+ * - attributes kept with the `@_` prefix (`{{r.body.tag.@_id}}`).
127
+ * - repeated elements: XML cannot distinguish "one item" from "a list of one" — the
128
+ * catalog declares `arrayPaths:` (dot jpaths INCLUDING the root element) and those
129
+ * paths are ALWAYS arrays, so `[0]` access works for 1 and N occurrences alike
130
+ * (an EMPTY parent has no child key at all — no array to index).
131
+ */
132
+ function parseXmlBody(text: string, label: string, arrayPaths?: string[]): any | null {
133
+ let XMLParser: any;
134
+ try {
135
+ // Lazy require: generated projects created before this capability existed don't
136
+ // have the dep; they must fail with the install command, not MODULE_NOT_FOUND.
137
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
138
+ ({ XMLParser } = require('fast-xml-parser'));
139
+ } catch {
140
+ throw new Error(`API Driver: ${label} — the response is XML but "fast-xml-parser" is not installed in this project. Run: npm install --save-dev fast-xml-parser`);
141
+ }
142
+ const arraySet = new Set(arrayPaths || []);
143
+ const parser = new XMLParser({
144
+ ignoreAttributes: false,
145
+ attributeNamePrefix: '@_',
146
+ processEntities: false,
147
+ htmlEntities: false,
148
+ parseTagValue: false,
149
+ parseAttributeValue: false,
150
+ trimValues: true,
151
+ isArray: (_name: string, jpath: string) => arraySet.has(jpath),
152
+ });
153
+ try {
154
+ return decodeXmlEntitiesDeep(parser.parse(text));
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ /** Decode the five predefined XML entities in every string leaf (`&amp;` last, so a
161
+ * literal `&amp;lt;` decodes to `&lt;` and not further). */
162
+ function decodeXmlEntitiesDeep(value: any): any {
163
+ if (typeof value === 'string') {
164
+ return value
165
+ .replace(/&lt;/g, '<')
166
+ .replace(/&gt;/g, '>')
167
+ .replace(/&quot;/g, '"')
168
+ .replace(/&apos;/g, "'")
169
+ .replace(/&amp;/g, '&');
170
+ }
171
+ if (Array.isArray(value)) return value.map(decodeXmlEntitiesDeep);
172
+ if (value !== null && typeof value === 'object') {
173
+ const out: Record<string, any> = {};
174
+ for (const [k, v] of Object.entries(value)) out[k] = decodeXmlEntitiesDeep(v);
175
+ return out;
176
+ }
177
+ return value;
178
+ }
61
179
  /** Delete any case-insensitive variant of a header (HTTP header names are case-insensitive, but a
62
180
  * plain object keys on exact case — so a datasource `Content-Type` and our `content-type` would
63
181
  * both be sent). Call before setting, or to let fetch own the header (multipart boundary). */
@@ -94,7 +212,11 @@ function loadStorageStateCookieHeader(storageStatePath: string, url: string): st
94
212
  const targetIsSecure = target.protocol === 'https:';
95
213
  const matches = cookies.filter((c) => {
96
214
  const domain = (c.domain || '').replace(/^\./, '');
97
- const domainOk = !domain || target.hostname === domain || target.hostname.endsWith(`.${domain}`);
215
+ // A cookie with NO domain must match nothing, not everything — Playwright
216
+ // always records a domain, so a domainless entry only comes from a hand-
217
+ // edited state file, and treating it as a wildcard would attach the session
218
+ // cookie to any origin a response-bound absolute URL points at.
219
+ const domainOk = !!domain && (target.hostname === domain || target.hostname.endsWith(`.${domain}`));
98
220
  const pathOk = target.pathname.startsWith(c.path || '/');
99
221
  const notExpired = c.expires === undefined || c.expires < 0 || c.expires > nowSec;
100
222
  // A Secure cookie must never leave over plaintext. Playwright's own cookie jar enforces this,
@@ -201,22 +323,72 @@ class ApiClient {
201
323
  */
202
324
  async call(
203
325
  label: string,
204
- req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string },
326
+ req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart' | 'xml'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string; arrayPaths?: string[] },
205
327
  params: Record<string, any> = {},
206
328
  opts: { storageState?: string } = {},
207
329
  ): Promise<{ status: number; ok: boolean; body: any; headers: Record<string, string> }> {
208
330
  const { conf } = this.cfg(req.datasource);
209
331
  const base = (conf.base_url || conf.baseUrl || '').replace(/\/$/, '');
210
332
  if (!base) throw new Error(`API Driver: ${label} — datasource has no base_url (set it in .env.qa).`);
211
- const urlPath = substitute(req.path, params); // path params (:id) bind at runtime
212
333
 
213
- const headers: Record<string, string> = { ...(conf.headers || {}) };
334
+ // Whole-token path (`path: ":url"`): bind RAW and use an absolute http(s)
335
+ // value directly — an export/download link returned by a prior response
336
+ // (often on another origin, e.g. blob storage). substitute() would percent-
337
+ // encode the whole URL into `https%3A%2F%2F…` and joinApiUrl would glue it
338
+ // onto base_url. Raw binding also keeps presigned query escapes (%2F)
339
+ // untouched; new URL() is used for VALIDATION only (a response value can be
340
+ // anything — file:/data: schemes are refused, never fetched).
341
+ let fullUrl: string;
342
+ let absolute = false;
343
+ const wholeToken = req.path.match(/^:([A-Za-z_][A-Za-z0-9_]*)$/);
344
+ if (wholeToken) {
345
+ // The bound value MUST be an absolute http(s) URL. Anything else — empty
346
+ // string (a broken export returning {"url":""}), a relative path, or a
347
+ // data:/mailto:/file: form — is refused loudly: falling through to the
348
+ // legacy base_url join would silently GET the API root and let the
349
+ // scenario false-pass on exactly the failure it exists to catch. (A
350
+ // whole-token path was never valid before this feature, so there is no
351
+ // legacy behavior to preserve.)
352
+ const wholeVal = String(params[wholeToken[1]] ?? '');
353
+ let parsed: URL | null = null;
354
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(wholeVal)) {
355
+ try { parsed = new URL(wholeVal); } catch { parsed = null; }
356
+ }
357
+ if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
358
+ throw new Error(`API Driver: ${label} — :${wholeToken[1]} must be an absolute http(s) URL (the value is bound from a live response); got: ${JSON.stringify(wholeVal)}`);
359
+ }
360
+ if (parsed.username || parsed.password) {
361
+ throw new Error(`API Driver: ${label} — :${wholeToken[1]} must not carry credentials (user:pass@host): fetch refuses such URLs.`);
362
+ }
363
+ // Raw string, not parsed.toString(): URL normalization could rewrite the
364
+ // presigned query escapes (%2F) the storage host signed.
365
+ fullUrl = wholeVal;
366
+ absolute = true;
367
+ } else if (/^https?:\/\//i.test(req.path)) {
368
+ // Literal absolute catalog path — its :params (if any) bind with the normal encoding.
369
+ fullUrl = substitute(req.path, params);
370
+ absolute = true;
371
+ } else {
372
+ fullUrl = joinApiUrl(base, substitute(req.path, params)); // path params (:id) bind at runtime
373
+ }
374
+
375
+ // Cross-origin absolute URL: the datasource's own headers must NOT travel to
376
+ // another origin — auth tokens, cookies and tenant keys live in conf.headers
377
+ // with no metadata separating them from benign defaults, so ALL of them stay
378
+ // home; only headers declared on the entry itself are sent. An unparseable
379
+ // base counts as a different origin (fail closed).
380
+ const sameOrigin = !absolute || (() => {
381
+ try { return new URL(fullUrl).origin === new URL(base).origin; } catch { return false; }
382
+ })();
383
+ const headers: Record<string, string> = sameOrigin ? { ...(conf.headers || {}) } : {};
214
384
  // catalog headers; :param tokens bind at runtime — raw (no URL-encoding, unlike the path)
215
385
  for (const [k, v] of Object.entries(req.headers || {}))
216
386
  headers[k] = substituteRaw(String(v), params);
217
387
  // Body: substitute `:param` into the body template (object values), then encode per `encoding`.
388
+ // An XML body is a raw STRING template and binds by param-name WHITELIST below —
389
+ // the generic whole-value substitution here must not touch it.
218
390
  let body: any;
219
- if (req.body !== undefined && req.body !== null) {
391
+ if (req.body !== undefined && req.body !== null && req.encoding !== 'xml') {
220
392
  body = JSON.parse(JSON.stringify(req.body).replace(/":([A-Za-z_][A-Za-z0-9_]*)"/g, (_m, p) => JSON.stringify(params[p] ?? null)));
221
393
  }
222
394
  // Map the wire format to a fetch request body (#345): json → JSON string (application/json,
@@ -244,6 +416,17 @@ class ApiClient {
244
416
  // drop any datasource default Content-Type so it doesn't conflict with / duplicate it.
245
417
  deleteHeader(headers, 'content-type');
246
418
  fetchBody = buildMultipart(req.files, body, params, label);
419
+ } else if (req.encoding === 'xml' && req.body !== undefined && req.body !== null) {
420
+ // XML body: the catalog carries a raw string template (any shape — attributes,
421
+ // namespaces, CDATA, nesting). :params bind by WHITELIST of the passed param
422
+ // names, never by a generic `:word` scan — that scan would mangle namespace
423
+ // prefixes (soapenv:Envelope, xsi:type) into bound values. A BODYLESS xml
424
+ // entry is fine — a GET whose xml RESPONSE needs arrayPaths carries no body.
425
+ if (typeof req.body !== 'string') {
426
+ throw new Error(`API Driver: ${label} — encoding: xml requires body: to be a string template (a YAML block scalar holding the raw XML).`);
427
+ }
428
+ fetchBody = bindXmlParams(req.body, params);
429
+ if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/xml';
247
430
  } else if (body !== undefined) {
248
431
  const enc = req.encoding ?? 'json';
249
432
  if (enc === 'form') {
@@ -266,9 +449,6 @@ class ApiClient {
266
449
  throw new Error(`API Driver: ${label} — a ${req.method.toUpperCase()} request cannot carry a body. Remove body:/files:/bodyFile: from the catalog entry, or change its method to POST/PUT/PATCH.`);
267
450
  }
268
451
 
269
- // Full URL (not a baseURL-relative path) so a base path component like /api/v3 is preserved.
270
- const fullUrl = joinApiUrl(base, urlPath);
271
-
272
452
  // @hybrid: `storageState` is the @auth role's saved session (Playwright's `context.storageState()`
273
453
  // JSON) — reuse its cookies (scoped to `fullUrl`'s domain/path) so the request shares the
274
454
  // browser's authenticated session without a browser/Playwright context.
@@ -292,9 +472,32 @@ class ApiClient {
292
472
  ...(fetchBody !== undefined ? { body: fetchBody } : {}),
293
473
  signal: controller.signal,
294
474
  });
295
- const text = await res.text();
475
+ // Read bytes, not res.text(): the fetch spec hard-decodes text() as UTF-8, so a
476
+ // Shift_JIS/ISO-8859-1 XML response (common from OTA backends) would mojibake
477
+ // silently. XML-ish responses decode by Content-Type charset, then the XML
478
+ // declaration's encoding, then UTF-8; everything else keeps the UTF-8 decode
479
+ // res.text() always performed.
480
+ const bytes = Buffer.from(await res.arrayBuffer());
481
+ const contentType = res.headers.get('content-type') || '';
482
+ // XML detection: an explicit xml Content-Type (text/xml, application/xml,
483
+ // application/soap+xml — never text/html), else — only when the CT is absent or
484
+ // octet-stream — a sniff REQUIRING the `<?xml` declaration. Never sniff on a bare
485
+ // `<`: the parser does not validate, so an HTML 502 page would come back as a
486
+ // junk object instead of the raw string an author can at least read.
487
+ const ctIsXml = /xml/i.test(contentType) && !/html/i.test(contentType);
488
+ const maySniff = !contentType || /octet-stream/i.test(contentType);
489
+ // Non-XML branch uses TextDecoder, NOT Buffer.toString: the fetch spec's
490
+ // "UTF-8 decode" (what res.text() did) STRIPS a leading BOM, and a BOM'd
491
+ // JSON response must keep parsing as an object exactly as before.
492
+ const text = ctIsXml || maySniff ? decodeXmlAwareText(bytes, contentType) : new TextDecoder('utf-8').decode(bytes);
493
+ const isXml = (ctIsXml || (maySniff && /^\uFEFF?\s*<\?xml/.test(text))) && text.length > 0;
296
494
  let parsed: any = text;
297
- try { parsed = text ? JSON.parse(text) : null; } catch { /* non-JSON → keep text */ }
495
+ if (isXml) {
496
+ const xml = parseXmlBody(text, label, req.arrayPaths);
497
+ parsed = xml !== null ? xml : text; // real parse failure → keep the raw string
498
+ } else {
499
+ try { parsed = text ? JSON.parse(text) : null; } catch { /* non-JSON → keep text */ }
500
+ }
298
501
  return { status: res.status, ok: res.ok, body: parsed, headers: Object.fromEntries(res.headers.entries()) };
299
502
  } finally {
300
503
  clearTimeout(timer);
@@ -310,7 +513,7 @@ class ApiClient {
310
513
  */
311
514
  async callN(
312
515
  label: string,
313
- req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string },
516
+ req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart' | 'xml'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string; arrayPaths?: string[] },
314
517
  params: Record<string, any> = {},
315
518
  n = 1,
316
519
  opts: { storageState?: string } = {},
@@ -0,0 +1,16 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Sungen regex-escape helper — auto-generated into specs/regex-escape.ts. DO NOT EDIT.
4
+ *
5
+ * Test-data values are LITERAL TEXT. When a generated assertion needs an anchored
6
+ * match (`filter({ hasText: /^…$/ })`), the runtime value is interpolated into a
7
+ * `new RegExp(...)` — so a value containing `( [ $ . * +` would either throw a
8
+ * SyntaxError or silently match the wrong element unless it is escaped HERE, at
9
+ * runtime, where the real value is finally known. Template-time escaping cannot do
10
+ * this: at generate time the value is still a placeholder marker.
11
+ */
12
+
13
+ /** Escape a runtime string so RegExp treats every character literally. */
14
+ export function escapeRegExp(text: string): string {
15
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+ }
@@ -50,16 +50,42 @@ export class TestDataLoader {
50
50
  if (value === undefined || value === null) {
51
51
  throw new Error(`Test data key not found: ${key}`);
52
52
  }
53
- // A projected array (e.g. `{{q.rows[*].name}}`) serializes as a JSON list so the full
54
- // ordered set is preserved for the assert-compare — String([...]) would flatten it to a
55
- // lossy comma-join that can't be distinguished from a scalar containing commas.
56
- const result = Array.isArray(value)
57
- ? this.interpolate(JSON.stringify(value))
53
+ // A projected array (e.g. `{{q.rows[*].name}}`) and a PLAIN object (an API response
54
+ // body) serialize as JSON text — String([...]) flattens to a lossy comma-join and
55
+ // String({}) is "[object Object]", both un-assertable. Embedded `{{ref}}`s interpolate
56
+ // INSIDE the value before stringify, so a ref carrying quotes/backslashes can never
57
+ // corrupt the serialized JSON (the old order substituted raw text into JSON text).
58
+ // Only PLAIN objects take this path: Date/Buffer/ObjectId values bound by @query from
59
+ // a real driver keep their String()/toString() output — JSON.stringify would wrap a
60
+ // Date in quotes and flip every assertion that already compares its string form.
61
+ const result = isJsonContainer(value)
62
+ ? JSON.stringify(this.interpolateDeep(value))
58
63
  : this.interpolate(String(value));
59
64
  assertNoUnresolvedEnvRef(key, result);
60
65
  return result;
61
66
  }
62
67
 
68
+ /**
69
+ * Interpolate `{{ref}}`s in every string leaf AND key of a JSON container,
70
+ * recursively (matching the old whole-JSON-text pass). Non-plain nested
71
+ * objects are passed through untouched — JSON.stringify serializes them via
72
+ * toJSON. A truly cyclic object throws a RangeError; every current bind
73
+ * source (JSON.parse output, DB rows, YAML after load-time resolution) is
74
+ * acyclic. Built with Object.fromEntries, which DEFINES data properties — a
75
+ * body field literally named "__proto__" survives instead of silently
76
+ * rewiring the result's prototype to external input.
77
+ */
78
+ private interpolateDeep(value: any): any {
79
+ if (typeof value === 'string') return this.interpolate(value);
80
+ if (Array.isArray(value)) return value.map((v) => this.interpolateDeep(v));
81
+ if (isPlainObject(value)) {
82
+ return Object.fromEntries(
83
+ Object.entries(value).map(([k, v]) => [this.interpolate(k), this.interpolateDeep(v)])
84
+ );
85
+ }
86
+ return value;
87
+ }
88
+
63
89
  /**
64
90
  * Resolve a key to its RAW, uncoerced value (array/object/number kept as-is) — for binding
65
91
  * DB query / API request params, where type-strict sinks (MySQL numeric columns, MongoDB,
@@ -68,7 +94,8 @@ export class TestDataLoader {
68
94
  * as get()), so an override like `token={{login.body.token}}` or `id={{prefix}}-001` still
69
95
  * interpolates — only the final scalar/array/object type is preserved uncoerced. Arrays and
70
96
  * objects are returned as-is; refs embedded inside their elements are not interpolated (unlike
71
- * get()'s JSON-text pass), since raw() callers bind structured values directly, not text.
97
+ * get(), which interpolates every string leaf of a plain container before serializing), since
98
+ * raw() callers bind structured values directly, not text.
72
99
  * Throws on missing, mirroring get()'s guard.
73
100
  */
74
101
  raw(key: string): any {
@@ -202,6 +229,26 @@ export class TestDataLoader {
202
229
  }
203
230
  }
204
231
 
232
+ /**
233
+ * A value get() must serialize as JSON: an array, or a PLAIN object (literal or
234
+ * null-prototype). Class instances at the TOP level (Date, Buffer, ObjectId…,
235
+ * and driver row wrappers like mysql2's RowDataPacket) are NOT containers —
236
+ * they keep the String()/toString() path their existing assertions rely on;
237
+ * nested inside a plain container they serialize through JSON.stringify/toJSON.
238
+ */
239
+ function isJsonContainer(value: any): boolean {
240
+ return Array.isArray(value) || isPlainObject(value);
241
+ }
242
+
243
+ // Prototype check only — a `value.constructor === Object` probe is defeated by
244
+ // a body FIELD literally named "constructor" (JSON.parse gives it as an own
245
+ // property), which would silently fall back to "[object Object]" again.
246
+ function isPlainObject(value: any): boolean {
247
+ if (value === null || typeof value !== 'object') return false;
248
+ const proto = Object.getPrototypeOf(value);
249
+ return proto === Object.prototype || proto === null;
250
+ }
251
+
205
252
  function loadYamlSync(filePath: string): Record<string, any> | null {
206
253
  if (!fs.existsSync(filePath)) return null;
207
254
  const content = fs.readFileSync(filePath, 'utf-8');
@@ -233,6 +233,25 @@ function restoreConnectivity(hook: string): void {
233
233
  console.log('[sungen] connectivity self-heal: no ICMP reply within ~30s — continuing (fine if this network blocks ping; otherwise the device may need a cold boot).');
234
234
  }
235
235
 
236
+ // Evidence readability: render a dot at every injected tap so screen recordings show WHERE the
237
+ // test touched. Android-only (iOS has no supported equivalent). The pre-run value is remembered
238
+ // and restored on onComplete, so a developer's own device setting is not permanently mutated.
239
+ let showTouchesBefore: string | null = null;
240
+ function setShowTouches(hook: 'onPrepare' | 'onComplete'): void {
241
+ if (!RUN_ANDROID) return;
242
+ const adbBin = process.env.ANDROID_HOME ? path.join(process.env.ANDROID_HOME, 'platform-tools', 'adb') : 'adb';
243
+ const adb = `"${adbBin}" -s ${process.env.ANDROID_UDID || '__ANDROID_UDID__'} shell`;
244
+ const sh = (cmd: string) => execSync(`${adb} ${cmd}`, { timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
245
+ try {
246
+ if (hook === 'onPrepare') {
247
+ showTouchesBefore = sh('settings get system show_touches');
248
+ if (showTouchesBefore !== '1') sh('settings put system show_touches 1');
249
+ } else if (showTouchesBefore !== null && showTouchesBefore !== '1') {
250
+ sh(`settings put system show_touches ${showTouchesBefore === 'null' ? '0' : showTouchesBefore}`);
251
+ }
252
+ } catch { /* device gone / key absent — cosmetic feature, never block the run */ }
253
+ }
254
+
236
255
  export const config: WebdriverIO.Config = {
237
256
  runner: 'local',
238
257
  tsConfigPath: './tsconfig.json',
@@ -265,13 +284,13 @@ export const config: WebdriverIO.Config = {
265
284
  'appium:udid': process.env.ANDROID_UDID || '__ANDROID_UDID__',
266
285
  // APP_APK installs from a file (Appium reads pkg/activity from the manifest);
267
286
  // otherwise target an already-installed app by package + activity.
268
- ...(APP_APK
269
- ? { 'appium:app': APP_APK }
270
- : {
271
- 'appium:appPackage': APP_PACKAGE,
272
- // Omit when unset UiAutomator2 launches the package's default launcher activity.
273
- ...(HAS_REAL_APP_ACTIVITY ? { 'appium:appActivity': APP_ACTIVITY } : {}),
274
- }),
287
+ ...(APP_APK ? { 'appium:app': APP_APK } : {}),
288
+ // Explicit APP_PACKAGE/APP_ACTIVITY are passed even alongside APP_APK: the driver's
289
+ // APK-manifest parse can flake (empty activity → bare MAIN/LAUNCHER intent, which
290
+ // Android rejects as "matches multiple activities"), and explicit caps override it.
291
+ ...(process.env.APP_PACKAGE || !APP_APK ? { 'appium:appPackage': APP_PACKAGE } : {}),
292
+ // Omit when unset → UiAutomator2 launches the package's default launcher activity.
293
+ ...(HAS_REAL_APP_ACTIVITY ? { 'appium:appActivity': APP_ACTIVITY } : {}),
275
294
  'appium:noReset': true,
276
295
  'appium:autoGrantPermissions': true,
277
296
  'appium:newCommandTimeout': 300,
@@ -385,8 +404,8 @@ export const config: WebdriverIO.Config = {
385
404
  // Connectivity self-heal (#615): onPrepare heals a device wedged by a previous KILLED run
386
405
  // (its onComplete never fired) before the session starts; onComplete restores after this run,
387
406
  // pass or fail. Both run in the launcher process, so they fire even when the worker/session died.
388
- onPrepare: () => restoreConnectivity('onPrepare'),
389
- onComplete: () => restoreConnectivity('onComplete'),
407
+ onPrepare: () => { restoreConnectivity('onPrepare'); setShowTouches('onPrepare'); },
408
+ onComplete: () => { restoreConnectivity('onComplete'); setShowTouches('onComplete'); },
390
409
 
391
410
  // iOS + locale: XCUITest's `noReset:false` does NOT actually clear the app's data container
392
411
  // (verified live 2026-06-11) — a Flutter app that persisted its language on a previous launch keeps