@sun-asterisk/sungen 3.2.18 → 3.2.20

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 (264) hide show
  1. package/dist/capabilities/discover.d.ts.map +1 -1
  2. package/dist/capabilities/discover.js +4 -1
  3. package/dist/capabilities/discover.js.map +1 -1
  4. package/dist/cli/commands/add.d.ts.map +1 -1
  5. package/dist/cli/commands/add.js +4 -2
  6. package/dist/cli/commands/add.js.map +1 -1
  7. package/dist/cli/commands/dashboard.d.ts.map +1 -1
  8. package/dist/cli/commands/dashboard.js +10 -1
  9. package/dist/cli/commands/dashboard.js.map +1 -1
  10. package/dist/cli/commands/delivery.d.ts.map +1 -1
  11. package/dist/cli/commands/delivery.js +19 -1
  12. package/dist/cli/commands/delivery.js.map +1 -1
  13. package/dist/cli/commands/generate.d.ts.map +1 -1
  14. package/dist/cli/commands/generate.js +10 -1
  15. package/dist/cli/commands/generate.js.map +1 -1
  16. package/dist/dashboard/html-renderer.d.ts.map +1 -1
  17. package/dist/dashboard/html-renderer.js +7 -2
  18. package/dist/dashboard/html-renderer.js.map +1 -1
  19. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  20. package/dist/dashboard/snapshot-builder.js +3 -0
  21. package/dist/dashboard/snapshot-builder.js.map +1 -1
  22. package/dist/dashboard/types.d.ts +4 -0
  23. package/dist/dashboard/types.d.ts.map +1 -1
  24. package/dist/exporters/api-environment.d.ts.map +1 -1
  25. package/dist/exporters/api-environment.js +4 -24
  26. package/dist/exporters/api-environment.js.map +1 -1
  27. package/dist/exporters/api-testcase-formatter.d.ts +6 -0
  28. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
  29. package/dist/exporters/api-testcase-formatter.js +11 -4
  30. package/dist/exporters/api-testcase-formatter.js.map +1 -1
  31. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  32. package/dist/exporters/csv-exporter.js +40 -9
  33. package/dist/exporters/csv-exporter.js.map +1 -1
  34. package/dist/exporters/env-qa.d.ts +13 -0
  35. package/dist/exporters/env-qa.d.ts.map +1 -0
  36. package/dist/exporters/env-qa.js +82 -0
  37. package/dist/exporters/env-qa.js.map +1 -0
  38. package/dist/exporters/feature-parser.d.ts.map +1 -1
  39. package/dist/exporters/feature-parser.js +16 -33
  40. package/dist/exporters/feature-parser.js.map +1 -1
  41. package/dist/exporters/json-exporter.d.ts.map +1 -1
  42. package/dist/exporters/json-exporter.js +57 -20
  43. package/dist/exporters/json-exporter.js.map +1 -1
  44. package/dist/exporters/matrix/build.d.ts.map +1 -1
  45. package/dist/exporters/matrix/build.js +15 -6
  46. package/dist/exporters/matrix/build.js.map +1 -1
  47. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  48. package/dist/exporters/matrix/gates.js +3 -2
  49. package/dist/exporters/matrix/gates.js.map +1 -1
  50. package/dist/exporters/matrix/step-buckets.d.ts +13 -0
  51. package/dist/exporters/matrix/step-buckets.d.ts.map +1 -0
  52. package/dist/exporters/matrix/step-buckets.js +7 -0
  53. package/dist/exporters/matrix/step-buckets.js.map +1 -0
  54. package/dist/exporters/qa-env-secret-denylist.d.ts +43 -0
  55. package/dist/exporters/qa-env-secret-denylist.d.ts.map +1 -0
  56. package/dist/exporters/qa-env-secret-denylist.js +143 -0
  57. package/dist/exporters/qa-env-secret-denylist.js.map +1 -0
  58. package/dist/exporters/scenario-merger.d.ts +20 -13
  59. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  60. package/dist/exporters/scenario-merger.js +55 -30
  61. package/dist/exporters/scenario-merger.js.map +1 -1
  62. package/dist/exporters/step-formatter.d.ts +42 -33
  63. package/dist/exporters/step-formatter.d.ts.map +1 -1
  64. package/dist/exporters/step-formatter.js +68 -68
  65. package/dist/exporters/step-formatter.js.map +1 -1
  66. package/dist/exporters/test-data-resolver.d.ts +15 -0
  67. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  68. package/dist/exporters/test-data-resolver.js +34 -1
  69. package/dist/exporters/test-data-resolver.js.map +1 -1
  70. package/dist/exporters/types.d.ts +9 -8
  71. package/dist/exporters/types.d.ts.map +1 -1
  72. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +2 -0
  73. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  74. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +2 -0
  75. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
  76. package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
  77. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  78. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +2 -0
  79. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  80. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  81. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +6 -0
  82. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  83. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  84. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  85. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  86. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
  87. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  88. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  89. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +15 -0
  90. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  91. package/dist/generators/test-generator/code-generator.js +78 -7
  92. package/dist/generators/test-generator/code-generator.js.map +1 -1
  93. package/dist/generators/test-generator/diagnostics.d.ts +16 -1
  94. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  95. package/dist/generators/test-generator/diagnostics.js +15 -0
  96. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  97. package/dist/generators/test-generator/recipe-expander.d.ts +1 -1
  98. package/dist/generators/test-generator/recipe-expander.d.ts.map +1 -1
  99. package/dist/generators/test-generator/recipe-expander.js +1 -1
  100. package/dist/generators/test-generator/recipe-expander.js.map +1 -1
  101. package/dist/generators/test-generator/step-mapper.d.ts +41 -0
  102. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  103. package/dist/generators/test-generator/step-mapper.js +161 -17
  104. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  105. package/dist/generators/test-generator/template-engine.d.ts +2 -0
  106. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  107. package/dist/generators/test-generator/template-engine.js +1 -1
  108. package/dist/generators/test-generator/template-engine.js.map +1 -1
  109. package/dist/generators/test-generator/utils/data-resolver.d.ts +18 -1
  110. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  111. package/dist/generators/test-generator/utils/data-resolver.js +28 -4
  112. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  113. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +2 -2
  114. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  115. package/dist/generators/test-generator/utils/runtime-data-transformer.js +56 -11
  116. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  117. package/dist/generators/test-generator/utils/selector-resolver.d.ts +18 -2
  118. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  119. package/dist/generators/test-generator/utils/selector-resolver.js +29 -6
  120. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  121. package/dist/harness/audit.d.ts.map +1 -1
  122. package/dist/harness/audit.js +8 -7
  123. package/dist/harness/audit.js.map +1 -1
  124. package/dist/harness/capability-plan.d.ts.map +1 -1
  125. package/dist/harness/capability-plan.js +2 -3
  126. package/dist/harness/capability-plan.js.map +1 -1
  127. package/dist/harness/catalog/drivers.yaml +1 -1
  128. package/dist/harness/challenge.d.ts.map +1 -1
  129. package/dist/harness/challenge.js +1 -55
  130. package/dist/harness/challenge.js.map +1 -1
  131. package/dist/harness/data-driven-lint.d.ts.map +1 -1
  132. package/dist/harness/data-driven-lint.js +14 -9
  133. package/dist/harness/data-driven-lint.js.map +1 -1
  134. package/dist/harness/depth-lint.d.ts.map +1 -1
  135. package/dist/harness/depth-lint.js +1 -3
  136. package/dist/harness/depth-lint.js.map +1 -1
  137. package/dist/harness/flow-check.d.ts.map +1 -1
  138. package/dist/harness/flow-check.js +9 -7
  139. package/dist/harness/flow-check.js.map +1 -1
  140. package/dist/harness/flow-plan.d.ts.map +1 -1
  141. package/dist/harness/flow-plan.js +4 -3
  142. package/dist/harness/flow-plan.js.map +1 -1
  143. package/dist/harness/manifest.js +2 -2
  144. package/dist/harness/manifest.js.map +1 -1
  145. package/dist/harness/parse.d.ts +12 -0
  146. package/dist/harness/parse.d.ts.map +1 -1
  147. package/dist/harness/parse.js +28 -1
  148. package/dist/harness/parse.js.map +1 -1
  149. package/dist/harness/script-check.d.ts.map +1 -1
  150. package/dist/harness/script-check.js +61 -31
  151. package/dist/harness/script-check.js.map +1 -1
  152. package/dist/harness/secret-scan.d.ts +3 -1
  153. package/dist/harness/secret-scan.d.ts.map +1 -1
  154. package/dist/harness/secret-scan.js +27 -11
  155. package/dist/harness/secret-scan.js.map +1 -1
  156. package/dist/harness/sensors.d.ts.map +1 -1
  157. package/dist/harness/sensors.js +15 -4
  158. package/dist/harness/sensors.js.map +1 -1
  159. package/dist/harness/trace.js +1 -1
  160. package/dist/harness/trace.js.map +1 -1
  161. package/dist/harness/unit-paths.d.ts +16 -0
  162. package/dist/harness/unit-paths.d.ts.map +1 -1
  163. package/dist/harness/unit-paths.js +39 -2
  164. package/dist/harness/unit-paths.js.map +1 -1
  165. package/dist/index.d.ts +1 -0
  166. package/dist/index.d.ts.map +1 -1
  167. package/dist/index.js +3 -1
  168. package/dist/index.js.map +1 -1
  169. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  170. package/dist/orchestrator/screen-manager.js +8 -2
  171. package/dist/orchestrator/screen-manager.js.map +1 -1
  172. package/dist/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  173. package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  174. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +8 -0
  175. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
  176. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +79 -2
  177. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +7 -1
  178. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +12 -3
  179. package/dist/orchestrator/templates/playwright.config.ts +33 -2
  180. package/dist/orchestrator/templates/specs-mock.d.ts +66 -0
  181. package/dist/orchestrator/templates/specs-mock.d.ts.map +1 -0
  182. package/dist/orchestrator/templates/specs-mock.js +137 -0
  183. package/dist/orchestrator/templates/specs-mock.js.map +1 -0
  184. package/dist/orchestrator/templates/specs-mock.ts +179 -0
  185. package/dist/orchestrator/templates/specs-url-assert.d.ts +67 -0
  186. package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -0
  187. package/dist/orchestrator/templates/specs-url-assert.js +121 -0
  188. package/dist/orchestrator/templates/specs-url-assert.js.map +1 -0
  189. package/dist/orchestrator/templates/specs-url-assert.ts +134 -0
  190. package/dist/orchestrator/test-data-guide.d.ts.map +1 -1
  191. package/dist/orchestrator/test-data-guide.js +4 -0
  192. package/dist/orchestrator/test-data-guide.js.map +1 -1
  193. package/package.json +3 -3
  194. package/src/capabilities/discover.ts +4 -1
  195. package/src/cli/commands/add.ts +4 -2
  196. package/src/cli/commands/dashboard.ts +10 -1
  197. package/src/cli/commands/delivery.ts +19 -1
  198. package/src/cli/commands/generate.ts +9 -1
  199. package/src/dashboard/html-renderer.ts +8 -2
  200. package/src/dashboard/snapshot-builder.ts +3 -0
  201. package/src/dashboard/types.ts +4 -0
  202. package/src/exporters/api-environment.ts +3 -20
  203. package/src/exporters/api-testcase-formatter.ts +8 -2
  204. package/src/exporters/csv-exporter.ts +47 -8
  205. package/src/exporters/env-qa.ts +41 -0
  206. package/src/exporters/feature-parser.ts +17 -38
  207. package/src/exporters/json-exporter.ts +71 -24
  208. package/src/exporters/matrix/build.ts +15 -6
  209. package/src/exporters/matrix/gates.ts +3 -2
  210. package/src/exporters/matrix/step-buckets.ts +15 -0
  211. package/src/exporters/qa-env-secret-denylist.ts +118 -0
  212. package/src/exporters/scenario-merger.ts +80 -42
  213. package/src/exporters/step-formatter.ts +67 -66
  214. package/src/exporters/test-data-resolver.ts +34 -1
  215. package/src/exporters/types.ts +9 -8
  216. package/src/generators/test-generator/adapters/adapter-interface.ts +1 -1
  217. package/src/generators/test-generator/adapters/appium/appium-adapter.ts +1 -1
  218. package/src/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  219. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +1 -1
  220. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +6 -0
  221. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  222. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  223. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  224. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  225. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
  226. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  227. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  228. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +15 -0
  229. package/src/generators/test-generator/code-generator.ts +91 -8
  230. package/src/generators/test-generator/diagnostics.ts +16 -1
  231. package/src/generators/test-generator/recipe-expander.ts +2 -2
  232. package/src/generators/test-generator/step-mapper.ts +160 -15
  233. package/src/generators/test-generator/template-engine.ts +2 -2
  234. package/src/generators/test-generator/utils/data-resolver.ts +27 -5
  235. package/src/generators/test-generator/utils/runtime-data-transformer.ts +57 -11
  236. package/src/generators/test-generator/utils/selector-resolver.ts +29 -5
  237. package/src/harness/audit.ts +10 -9
  238. package/src/harness/capability-plan.ts +2 -2
  239. package/src/harness/catalog/drivers.yaml +1 -1
  240. package/src/harness/challenge.ts +2 -3
  241. package/src/harness/data-driven-lint.ts +13 -9
  242. package/src/harness/depth-lint.ts +2 -4
  243. package/src/harness/flow-check.ts +9 -7
  244. package/src/harness/flow-plan.ts +4 -3
  245. package/src/harness/manifest.ts +3 -3
  246. package/src/harness/parse.ts +28 -1
  247. package/src/harness/script-check.ts +61 -33
  248. package/src/harness/secret-scan.ts +23 -8
  249. package/src/harness/sensors.ts +15 -4
  250. package/src/harness/trace.ts +2 -2
  251. package/src/harness/unit-paths.ts +39 -2
  252. package/src/index.ts +1 -0
  253. package/src/orchestrator/screen-manager.ts +7 -2
  254. package/src/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  255. package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  256. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +8 -0
  257. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
  258. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +79 -2
  259. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +7 -1
  260. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +12 -3
  261. package/src/orchestrator/templates/playwright.config.ts +33 -2
  262. package/src/orchestrator/templates/specs-mock.ts +179 -0
  263. package/src/orchestrator/templates/specs-url-assert.ts +134 -0
  264. package/src/orchestrator/test-data-guide.ts +4 -0
@@ -0,0 +1,134 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Sungen URL assertion helper — auto-generated into specs/url-assert.ts. DO NOT EDIT.
4
+ *
5
+ * Every Then-side URL assertion (`see [X] page`, `is on [X] page`) compiles to one call here.
6
+ *
7
+ * Why a helper and not an inline predicate. The predicate used to be emitted inline into every
8
+ * spec, so each correction to what "the URL matches" MEANS was a breaking change for every
9
+ * generated suite in the field: regenerate everything, re-review every newly-red test. Behind a
10
+ * helper the same correction ships as a runtime patch. The generator's job shrinks to what it
11
+ * alone knows — the pathname pattern — and the semantics live in one readable place.
12
+ *
13
+ * Why a predicate and not two assertions. The shape before this one was
14
+ * await expect(page).toHaveURL(/\/vi\/search/); // unanchored: passes on /vi/search-x
15
+ * expect(page.url()).toContain('<query>'); // synchronous, one-shot: never retries
16
+ * — two half-checks, neither anchored, and nothing ever compared pathname and query together.
17
+ * `toHaveURL(predicate)` is web-first (it retries like any other assertion), receives a parsed
18
+ * URL, and needs no regex escaping of runtime values, which is what forced the split.
19
+ */
20
+
21
+ export interface UrlExpectation {
22
+ /**
23
+ * Anchored pathname pattern, compiled by the generator from the page selector's `value`
24
+ * (`:id` segments already wildcarded). Anchored against `u.pathname` ONLY: pathname never
25
+ * carries the query, so `^…$` is exact there — the same source that was unsafe to anchor
26
+ * against a full URL.
27
+ */
28
+ pathname: RegExp;
29
+ /**
30
+ * The `with {{v}}` value, raw from test-data: '?q=1' · '/vi/search?q=1' · '#tab' · ''.
31
+ * `undefined` means the step declared nothing, which is itself an assertion — see below.
32
+ */
33
+ declared?: string;
34
+ /** The test-data key behind `declared`, so a failure can name what an author must go fix. */
35
+ dataRef?: string;
36
+ }
37
+
38
+ /**
39
+ * Join a page's URL with the `with {{v}}` value for a NAVIGATION step
40
+ * (`Given User is on [X] page with {{v}}`).
41
+ *
42
+ * Why this is not a plain `base + path + value`. It used to be, and an author writing the value as a
43
+ * whole relative URL — the habit the assertion side already had to accommodate — navigated to
44
+ * `/vi/search/vi/search?q=1`: the page's own path, twice. The Then side then asserted the real path
45
+ * correctly, so the scenario could never go green.
46
+ *
47
+ * `base` (the configured baseURL, possibly '') and `path` (the page selector's value) arrive
48
+ * SEPARATELY so a value that carries its own rooted path replaces the page's path without also
49
+ * discarding a path prefix baked into the baseURL — a deployment under `https://host/tenant-a`
50
+ * would otherwise navigate to the wrong tenant, and only when a `with {{v}}` was present, which is
51
+ * exactly the kind of inconsistency nobody would think to look for.
52
+ *
53
+ * What it deliberately does NOT do is sanitize. A test may legitimately drive a MALFORMED query to
54
+ * prove the app rejects it (issue #464), so anything that is not a duplicated path is appended
55
+ * verbatim: no encoding, no normalising, no reordering.
56
+ */
57
+ export function joinPath(base: string, path: string, declared?: string | null): string {
58
+ if (declared === undefined || declared === null || declared === '') return base + path;
59
+ const value = String(declared);
60
+ // A query/fragment appends — the overwhelmingly common shape, and the only one #464 needs.
61
+ if (value.startsWith('?') || value.startsWith('#')) return base + path + value;
62
+ // An absolute URL replaces everything: the author pasted a full address.
63
+ if (/^[a-z]+:\/\//i.test(value)) return value;
64
+ // A rooted path carries its own path, so it replaces the PAGE's path while everything the
65
+ // baseURL contributed — scheme, host, and any path prefix — is kept.
66
+ if (value.startsWith('/')) return base + value;
67
+ // Anything else is raw input the test wants appended as-is.
68
+ return base + path + value;
69
+ }
70
+
71
+ /**
72
+ * `u.pathname` is percent-encoded; a regex built from a selector's literal `value` is not, so a
73
+ * non-ASCII route (`/読取結果一覧`) could never match. Decoding can throw on a lone `%`, in which
74
+ * case the raw form is all there is.
75
+ */
76
+ function decodedPath(pathname: string): string {
77
+ try {
78
+ return decodeURIComponent(pathname);
79
+ } catch {
80
+ return pathname;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Build the predicate for `expect(page).toHaveURL(...)`.
86
+ *
87
+ * Query semantics are deliberately asymmetric:
88
+ * no `with {{v}}` → the URL must carry NO query. A bare page means a bare page, so
89
+ * `/vi/search` does not pass on `/vi/search?x=1`.
90
+ * `with {{v}}` → every declared param must match; extra params are tolerated, so a
91
+ * tracking/UTM param the test never cared about cannot turn it red.
92
+ * A declared fragment is asserted too; a declared value carrying no query
93
+ * at all constrains the path only (the compiler warns: SG-W014).
94
+ */
95
+ export function urlMatches({ pathname, declared, dataRef }: UrlExpectation): (u: URL) => boolean {
96
+ return (u: URL): boolean => {
97
+ if (!pathname.test(u.pathname) && !pathname.test(decodedPath(u.pathname))) return false;
98
+
99
+ // `dataRef`, not `declared`, decides which rule applies. Both "the step declared nothing" and
100
+ // "the step declared {{v}} but the key resolved to nothing at runtime" arrive here as
101
+ // undefined, and they mean opposite things: the first is a real assertion (a bare page must be
102
+ // bare), the second is broken test-data. Only the generator knows which, and it says so by
103
+ // emitting `dataRef` exactly when the step carried a `with {{v}}`.
104
+ if (!dataRef) return u.search === '';
105
+
106
+ // Silence is the whole bug class this assertion exists to kill, so an unusable declared value
107
+ // is an ERROR, not a quiet verdict. Returning false would read as "the app is wrong"; these
108
+ // throws say "your test-data is wrong, and here is the key". An empty value used to parse to
109
+ // an empty param list, and `[].every()` is vacuously TRUE — it passed on any query at all.
110
+ if (declared === undefined || declared === null) {
111
+ throw new Error(`url assertion: test-data key "${dataRef}" is missing — nothing to assert`);
112
+ }
113
+ if (String(declared).trim() === '') {
114
+ throw new Error(`url assertion: test-data key "${dataRef}" is empty — nothing to assert`);
115
+ }
116
+
117
+ // Parsing against the live pathname as base lets ONE expression handle every shape an author
118
+ // writes: '?q=1' (query only), '/vi/search?q=1' (a whole relative URL — its path is ignored,
119
+ // the page selector already owns the path), and '#tab' (fragment only). The predecessor
120
+ // regex-stripped the prefix instead, which silently turned a value with NO query
121
+ // ('/vi/search') into a bogus param NAME — an assertion that could never be satisfied.
122
+ const d = new URL(String(declared), 'http://sungen.invalid' + u.pathname);
123
+
124
+ // getAll, not get: a multi-select filter (?tag=a&tag=b) is the exact case this assertion is
125
+ // for, and `get()` returns only the first value — so the correct URL failed. Comparing as a
126
+ // subset per key keeps this consistent with "extra params are tolerated", and makes param
127
+ // order meaningless, which is what a URL actually means.
128
+ const queryOk = [...d.searchParams].every(([k, v]) => u.searchParams.getAll(k).includes(v));
129
+
130
+ // Only assert a fragment the author actually declared.
131
+ const hashOk = !d.hash || d.hash === u.hash;
132
+ return queryOk && hashOk;
133
+ };
134
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"test-data-guide.d.ts","sourceRoot":"","sources":["../../src/orchestrator/test-data-guide.ts"],"names":[],"mappings":"AAaA,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAgBvD;AAuGD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAoB1D"}
1
+ {"version":3,"file":"test-data-guide.d.ts","sourceRoot":"","sources":["../../src/orchestrator/test-data-guide.ts"],"names":[],"mappings":"AAaA,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAgBvD;AA2GD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAoB1D"}
@@ -163,6 +163,10 @@ const ENV_QA_EXAMPLE = `# Secret test data referenced as \${QA_*} in qa/**/test-
163
163
  # Per-environment overrides: .env.qa.<env> + run with SUNGEN_ENV=<env>.
164
164
  #
165
165
  # QA_ADMIN_PASSWORD=
166
+ #
167
+ # Web baseURL for this environment — overrides the static value in
168
+ # playwright.config.ts (declare per env: .env.qa.dev, .env.qa.uat, ...).
169
+ # SUNGEN_BASE_URL=
166
170
  `;
167
171
  /**
168
172
  * Idempotently create qa/TEST-DATA-GUIDE.md and .env.qa.example, and make sure
@@ -1 +1 @@
1
- {"version":3,"file":"test-data-guide.js","sourceRoot":"","sources":["../../src/orchestrator/test-data-guide.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAcA,wCAgBC;AA2GD,4DAoBC;AA7JD;;;;;;;;;GASG;AACH,uCAAyB;AACzB,2CAA6B;AAE7B,oEAAoE;AACpE,SAAgB,cAAc,CAAC,QAAgB;IAC7C,OAAO;QACL,KAAK,QAAQ,+DAA+D;QAC5E,+EAA+E;QAC/E,sBAAsB;QACtB,2EAA2E;QAC3E,4EAA4E;QAC5E,wEAAwE;QACxE,iCAAiC;QACjC,8EAA8E;QAC9E,yEAAyE;QACzE,8EAA8E;QAC9E,0EAA0E;QAC1E,+EAA+E;QAC/E,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,MAAM,QAAQ,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4FhB,CAAC;AAEF,MAAM,cAAc,GAAG;;;;;CAKtB,CAAC;AAEF;;;GAGG;AACH,SAAgB,wBAAwB,CAAC,GAAW;IAClD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,EAAE,oBAAoB,CAAC,CAAC;IAC7D,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrE,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IACjD,CAAC;IACD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC;IACtD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;QAChC,EAAE,CAAC,aAAa,CAAC,WAAW,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;IACzD,CAAC;IACD,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC;IACnD,IAAI,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,EAAE,CAAC,YAAY,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACvF,6EAA6E;QAC7E,oEAAoE;QACpE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1F,EAAE,CAAC,cAAc,CAAC,aAAa,EAAE,iFAAiF,CAAC,CAAC;QACtH,CAAC;IACH,CAAC;SAAM,CAAC;QACN,EAAE,CAAC,aAAa,CAAC,aAAa,EAAE,+EAA+E,EAAE,OAAO,CAAC,CAAC;IAC5H,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"test-data-guide.js","sourceRoot":"","sources":["../../src/orchestrator/test-data-guide.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAcA,wCAgBC;AA+GD,4DAoBC;AAjKD;;;;;;;;;GASG;AACH,uCAAyB;AACzB,2CAA6B;AAE7B,oEAAoE;AACpE,SAAgB,cAAc,CAAC,QAAgB;IAC7C,OAAO;QACL,KAAK,QAAQ,+DAA+D;QAC5E,+EAA+E;QAC/E,sBAAsB;QACtB,2EAA2E;QAC3E,4EAA4E;QAC5E,wEAAwE;QACxE,iCAAiC;QACjC,8EAA8E;QAC9E,yEAAyE;QACzE,8EAA8E;QAC9E,0EAA0E;QAC1E,+EAA+E;QAC/E,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,MAAM,QAAQ,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4FhB,CAAC;AAEF,MAAM,cAAc,GAAG;;;;;;;;;CAStB,CAAC;AAEF;;;GAGG;AACH,SAAgB,wBAAwB,CAAC,GAAW;IAClD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,EAAE,oBAAoB,CAAC,CAAC;IAC7D,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QACrE,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IACjD,CAAC;IACD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC;IACtD,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;QAChC,EAAE,CAAC,aAAa,CAAC,WAAW,EAAE,cAAc,EAAE,OAAO,CAAC,CAAC;IACzD,CAAC;IACD,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC;IACnD,IAAI,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QACjC,MAAM,KAAK,GAAG,EAAE,CAAC,YAAY,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACvF,6EAA6E;QAC7E,oEAAoE;QACpE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1F,EAAE,CAAC,cAAc,CAAC,aAAa,EAAE,iFAAiF,CAAC,CAAC;QACtH,CAAC;IACH,CAAC;SAAM,CAAC;QACN,EAAE,CAAC,aAAa,CAAC,aAAa,EAAE,+EAA+E,EAAE,OAAO,CAAC,CAAC;IAC5H,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.18",
3
+ "version": "3.2.20",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -39,8 +39,8 @@
39
39
  "@babel/types": "^7.28.5",
40
40
  "@cucumber/gherkin": "^37.0.0",
41
41
  "@cucumber/messages": "^31.0.0",
42
- "@sungen/driver-data-factory": "3.2.18",
43
- "@sungen/driver-ui": "3.2.18",
42
+ "@sungen/driver-data-factory": "3.2.20",
43
+ "@sungen/driver-ui": "3.2.20",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.2",
46
46
  "dotenv": "^17.2.3",
@@ -27,7 +27,10 @@ import { LOCAL_DRIVERS, registerCoreCapability } from './builtins';
27
27
  * move to their packages (R5.5/R5.6) they join this list and leave `LOCAL_DRIVERS`; eventually this
28
28
  * becomes a scan of installed `@sungen/driver-*` packages (R5.7).
29
29
  */
30
- const EXTERNAL_DRIVERS = ['@sungen/driver-ui', '@sungen/driver-db', '@sungen/driver-api', '@sungen/driver-mobile', '@sungen/driver-data-factory'];
30
+ // driver-mock appended at the END: ordering = registration = composition (tie-breaks
31
+ // between equal-priority patterns follow registration order) — inserting mid-list
32
+ // shifts them and shows up as golden diffs.
33
+ const EXTERNAL_DRIVERS = ['@sungen/driver-ui', '@sungen/driver-db', '@sungen/driver-api', '@sungen/driver-mobile', '@sungen/driver-data-factory', '@sungen/driver-mock'];
31
34
 
32
35
  /**
33
36
  * Bundled (non-opt-in) drivers: shipped as a core dependency (`drivers.yaml` `bundled: true`), so the
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { featureFilesFor } from '../../harness/unit-paths';
4
5
 
5
6
  export function registerAddCommand(program: Command): void {
6
7
  program
@@ -27,8 +28,9 @@ export function registerAddCommand(program: Command): void {
27
28
  // When --figma is set on an existing screen, skip scaffolding and
28
29
  // just refresh Figma-derived files (spec_figma.md, ui/*.png).
29
30
  const screenDir = path.join(process.cwd(), 'qa', 'screens', options.screen);
30
- const featureFile = path.join(screenDir, 'features', `${options.screen}.feature`);
31
- const screenAlreadyExists = fs.existsSync(featureFile);
31
+ // ANY `.feature` means the screen exists — its file may be named after the --path
32
+ // segment or be a `--feature` sub-feature, not `<screen>.feature`.
33
+ const screenAlreadyExists = featureFilesFor(screenDir, options.screen).some((f) => fs.existsSync(f));
32
34
 
33
35
  if (!(options.figma && screenAlreadyExists)) {
34
36
  const { ScreenManager } = require('../../orchestrator/screen-manager');
@@ -12,6 +12,7 @@ import * as path from 'path';
12
12
  import * as fs from 'fs';
13
13
  import { execSync } from 'child_process';
14
14
  import { EnvironmentInfo } from '../../exporters/types';
15
+ import { resolveWebBaseUrl } from '../../exporters/env-qa';
15
16
  import {
16
17
  buildDashboardSnapshot,
17
18
  DashboardTarget,
@@ -42,11 +43,14 @@ function getEnvironment(cwd: string): EnvironmentInfo {
42
43
  const configPath = path.join(cwd, 'playwright.config.ts');
43
44
  if (fs.existsSync(configPath)) {
44
45
  const c = fs.readFileSync(configPath, 'utf-8');
45
- const baseMatch = c.match(/baseURL:\s*['"]([^'"]+)['"]/);
46
+ // Both config shapes: legacy literal and env-aware resolveBaseUrl('x') wrapper.
47
+ const baseMatch = c.match(/baseURL:\s*(?:resolveBaseUrl\(\s*)?['"]([^'"]+)['"]/);
46
48
  if (baseMatch) baseURL = baseMatch[1];
47
49
  const projMatch = c.match(/name:\s*['"]([^'"]+)['"]/);
48
50
  if (projMatch) projectName = projMatch[1];
49
51
  }
52
+ // Same SUNGEN_BASE_URL override as delivery — the two surfaces must agree.
53
+ baseURL = resolveWebBaseUrl(cwd, baseURL);
50
54
 
51
55
  let executor = process.env.CI_USER || process.env.USER || '';
52
56
  try {
@@ -61,6 +65,7 @@ interface DashboardOptions {
61
65
  noHistory?: boolean;
62
66
  open?: boolean;
63
67
  maxHistory?: string;
68
+ env?: string;
64
69
  }
65
70
 
66
71
  export function registerDashboardCommand(program: Command): void {
@@ -71,9 +76,13 @@ export function registerDashboardCommand(program: Command): void {
71
76
  .option('--no-history', 'Do not persist this run under qa/dashboard/history/')
72
77
  .option('--max-history <n>', `Limit snapshots included in dashboard stats (default: ${DEFAULT_MAX_HISTORY}). Older files are kept on disk.`)
73
78
  .option('--open', 'Open the rendered HTML in the default browser when done')
79
+ .option('--env <env>', 'Build the dashboard for one environment/locale (sets SUNGEN_ENV): picks that env\'s result files, and Trends/Compare only see history snapshots of the same env')
74
80
  .action(async (names: string[], options: DashboardOptions) => {
75
81
  try {
76
82
  const cwd = process.cwd();
83
+ // Same convention as run-test/delivery: SUNGEN_ENV drives result-file
84
+ // resolution, locale pairing, and (new) history scoping downstream.
85
+ if (options.env) process.env.SUNGEN_ENV = options.env;
77
86
 
78
87
  // 1. Scope detection
79
88
  let targets: DashboardTarget[];
@@ -24,7 +24,9 @@ import {
24
24
  import { renderXlsxMultiSheet, writeXlsx, buildApiDetailRows, addApiDetailSheet } from '../../exporters/xlsx-exporter';
25
25
  import { loadApiCatalog } from '../../exporters/api-catalog-loader';
26
26
  import { resolveApiEnvironment } from '../../exporters/api-environment';
27
+ import { resolveWebBaseUrl } from '../../exporters/env-qa';
27
28
  import { resolvePlatformAppId } from '../../exporters/mobile-app-id';
29
+ import { scanTestDataSecrets } from '../../harness/secret-scan';
28
30
  import { EnvironmentInfo, PreflightCheck, ScreenSummary, TestCaseRow, ApiCatalogEntry } from '../../exporters/types';
29
31
  import { mapFilePath } from '../../exporters/matrix/map-loader';
30
32
  import {
@@ -343,11 +345,16 @@ function getEnvironment(cwd: string): EnvironmentInfo {
343
345
  const configPath = path.join(cwd, 'playwright.config.ts');
344
346
  if (fs.existsSync(configPath)) {
345
347
  const c = fs.readFileSync(configPath, 'utf-8');
346
- const baseMatch = c.match(/baseURL:\s*['"]([^'"]+)['"]/);
348
+ // Both config shapes: the legacy literal `baseURL: 'x'` and the env-aware
349
+ // `baseURL: resolveBaseUrl('x')` (the quoted value is the static fallback).
350
+ const baseMatch = c.match(/baseURL:\s*(?:resolveBaseUrl\(\s*)?['"]([^'"]+)['"]/);
347
351
  if (baseMatch) baseURL = baseMatch[1];
348
352
  const projMatch = c.match(/name:\s*['"]([^'"]+)['"]/);
349
353
  if (projMatch) projectName = projMatch[1];
350
354
  }
355
+ // SUNGEN_BASE_URL (shell > .env.qa.<SUNGEN_ENV> > .env.qa) overrides the static
356
+ // value — the Test Environment column must show the host the run actually hit.
357
+ baseURL = resolveWebBaseUrl(cwd, baseURL);
351
358
 
352
359
  let executor = process.env.CI_USER || process.env.USER || '';
353
360
  try {
@@ -894,6 +901,17 @@ export function registerDeliveryCommand(program: Command): void {
894
901
  }
895
902
  }
896
903
 
904
+ // Security S0 — warn (never block) if test-data looks to hold a real secret.
905
+ // Deliverables are COMMITTED files; delivery is the last stop before a
906
+ // hardcoded credential lands in one. Same scan `generate` runs.
907
+ const scanDirs = [...new Set(toExport.map((t) => qaDir(cwd, t)))];
908
+ const secretHits = scanDirs.flatMap((d) => scanTestDataSecrets(d));
909
+ if (secretHits.length) {
910
+ log(`\n⚠️ Possible secret(s) in committed test-data (review — do NOT commit real credentials):`);
911
+ for (const h of secretHits.slice(0, 10)) log(` ${h.file}:${h.line} — ${h.reason}`);
912
+ log(` Move real secrets to an env overlay / CI secret; keep test-data placeholders only.\n`);
913
+ }
914
+
897
915
  // 3. Export
898
916
  const legacy = options.legacy || options.full;
899
917
  if (!legacy) {
@@ -208,11 +208,19 @@ export function registerGenerateCommand(program: Command): void {
208
208
  }
209
209
 
210
210
  // Security S0 — warn (never block) if test-data looks to hold a real secret.
211
+ // Scoped to the unit being generated: --area used to fall through to the
212
+ // enumerate-everything branch, so `generate --area X` printed OTHER units'
213
+ // secret-scan and dead-data lint findings under X's run (field report: one
214
+ // unit's "service_url is dead data" warning repeated on all 10 API units).
211
215
  const scanDirs: string[] = [];
212
216
  if (screenName) scanDirs.push(path.join(process.cwd(), 'qa', 'screens', screenName));
213
217
  else if (flowName) scanDirs.push(path.join(process.cwd(), 'qa', 'flows', flowName));
218
+ else if (apiName) scanDirs.push(path.join(process.cwd(), 'qa', 'api', apiName));
214
219
  else {
215
- for (const base of ['screens', 'flows']) {
220
+ // qa/api units are yaml sinks too (apis.yaml headers, mock bodies once shared);
221
+ // api/flows nests one level deeper. A file entry in the list is harmless —
222
+ // scanTestDataSecrets only reads known subdirs of each entry.
223
+ for (const base of ['screens', 'flows', 'api', path.join('api', 'flows')]) {
216
224
  const d = path.join(process.cwd(), 'qa', base);
217
225
  if (fs.existsSync(d)) for (const n of fs.readdirSync(d)) scanDirs.push(path.join(d, n));
218
226
  }
@@ -48,8 +48,14 @@ export function buildPayload(
48
48
  historyLimit?: number,
49
49
  ): DashboardPayload {
50
50
  const all = readHistory(cwd, historyLimit);
51
- // Exclude `current` from history if it's been written there already.
52
- const history = all.filter((s) => s.runId !== current.runId);
51
+ // Exclude `current` from history if it's been written there already, and keep
52
+ // ONLY snapshots of the SAME environment: qa/dashboard/history/ accumulates
53
+ // dev/uat/stg runs side by side, and blending them gave Trends one mixed
54
+ // pass-rate line and let Compare diff across environments. Legacy snapshots
55
+ // without the field count as base runs ('').
56
+ const currentEnv = current.environment?.sungenEnv || '';
57
+ const history = all.filter((s) =>
58
+ s.runId !== current.runId && (s.environment?.sungenEnv || '') === currentEnv);
53
59
  return { current, history };
54
60
  }
55
61
 
@@ -541,6 +541,9 @@ function collectEnvironment(cwd: string, env: EnvironmentInfo) {
541
541
  sungenVersion: getPackageVersion(),
542
542
  gitBranch,
543
543
  gitSha,
544
+ // Which environment this snapshot belongs to — dev/uat/stg runs must never
545
+ // blend into one trend line ('' = base run).
546
+ sungenEnv: process.env.SUNGEN_ENV || '',
544
547
  };
545
548
  }
546
549
 
@@ -23,6 +23,10 @@ export interface SnapshotEnvironment {
23
23
  sungenVersion: string;
24
24
  gitBranch?: string;
25
25
  gitSha?: string;
26
+ /** SUNGEN_ENV the snapshot was built under ('' = base run, absent on legacy
27
+ * snapshots). Trends/Compare must never mix runs of different environments,
28
+ * so the payload builder filters history by this field. */
29
+ sungenEnv?: string;
26
30
  }
27
31
 
28
32
  export interface AggregateSummary {
@@ -19,6 +19,9 @@ import * as fs from 'fs';
19
19
  import * as path from 'path';
20
20
  import { parse as parseYaml } from 'yaml';
21
21
  import { ApiCatalogEntry } from './types';
22
+ // .env.qa reading (runtime-precedence, local map) moved to ./env-qa so the web
23
+ // baseURL resolver shares the exact same rules.
24
+ import { buildEnvMap } from './env-qa';
22
25
 
23
26
  interface ApiDatasourceConf {
24
27
  kind?: string;
@@ -26,26 +29,6 @@ interface ApiDatasourceConf {
26
29
  baseUrl?: string;
27
30
  }
28
31
 
29
- /** Read KEY=value lines from one dotenv-style file (quotes stripped, comments ignored). */
30
- function readEnvFile(file: string, into: Record<string, string>): void {
31
- if (!fs.existsSync(file)) return;
32
- for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
33
- const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
34
- if (m && into[m[1]] === undefined) into[m[1]] = m[2].replace(/^["']|["']$/g, '');
35
- }
36
- }
37
-
38
- /** Shell env > .env.qa.<SUNGEN_ENV> > .env.qa — same precedence as the runtime's
39
- * loadEnvQa, but built as a LOCAL map (the exporter must not mutate process.env). */
40
- function buildEnvMap(cwd: string): Record<string, string> {
41
- const map: Record<string, string> = {};
42
- for (const name of [`.env.qa.${process.env.SUNGEN_ENV || ''}`, '.env.qa']) {
43
- if (!name.endsWith('.')) readEnvFile(path.join(cwd, name), map);
44
- }
45
- for (const [k, v] of Object.entries(process.env)) if (v !== undefined) map[k] = v;
46
- return map;
47
- }
48
-
49
32
  function loadDatasources(cwd: string): Record<string, ApiDatasourceConf> {
50
33
  const file = [path.join(cwd, 'datasources.yaml'), path.join(cwd, 'qa', 'datasources.yaml')]
51
34
  .find((f) => fs.existsSync(f));
@@ -295,7 +295,7 @@ function formatBodyFile(bodyFile: unknown, args: Record<string, string>, localiz
295
295
  * test-data keys — catches variants like confirmPassword / new_password /
296
296
  * password_confirmation / access_token / client_secret / otp / passcode.
297
297
  */
298
- const SENSITIVE_NAME = /pass(word|code)?|passwd|pwd|secret|token|otp|api[-_]?key|apikey|credential|authorization/i;
298
+ export const SENSITIVE_NAME = /pass(word|code)?|passwd|pwd|secret|token|otp|api[-_]?key|apikey|credential|authorization/i;
299
299
  function isSensitiveField(key: string): boolean {
300
300
  return SENSITIVE_NAME.test(key);
301
301
  }
@@ -319,7 +319,13 @@ export function collectSecretValues(testData: Record<string, string>): string[]
319
319
  export function redactSecrets(text: string, secrets: string[]): string {
320
320
  if (secrets.length === 0 || !text) return text;
321
321
  let out = text;
322
- for (const s of secrets) out = out.split(s).join('***');
322
+ for (const s of secrets) {
323
+ // Floor lives HERE, not only in the collectors: an empty string would split('')
324
+ // and interleave `***` between every character; a 1-2 char value masks
325
+ // coincidental text. Callers building their own denylist must not bypass it.
326
+ if (!s || s.length < 3) continue;
327
+ out = out.split(s).join('***');
328
+ }
323
329
  return out;
324
330
  }
325
331
 
@@ -15,7 +15,7 @@ import {
15
15
  mapVpToCategory2,
16
16
  splitVpAndName,
17
17
  } from './feature-parser';
18
- import { formatFeatureSteps, formatPrecondition, keepActor, stripActor } from './step-formatter';
18
+ import { formatPrecondition, groupExpectedByAnchor, keepActor, stripActor } from './step-formatter';
19
19
  import { formatTestData, substituteTestDataVars } from './test-data-resolver';
20
20
  import {
21
21
  formatExecutedDate,
@@ -26,6 +26,7 @@ import { ApiCatalogEntry, EnvironmentInfo, PlaywrightResult, ScreenSummary, Test
26
26
  import { collectSecretValues, formatApiExpected, formatApiRequest, hasApiCalls, redactSecrets } from './api-testcase-formatter';
27
27
  import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
28
28
  import { formatVariantRow, resolveResultVariants } from './result-variants';
29
+ import { collectQaEnvSecretDenylist, warnIfRedactionIncomplete } from './qa-env-secret-denylist';
29
30
 
30
31
  export interface BuildCsvInput {
31
32
  screen: string;
@@ -65,6 +66,10 @@ export interface BuildCsvInput {
65
66
  export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
66
67
  const rows: TestCaseRow[] = [];
67
68
  let fallbackIndex = 1;
69
+ // Runtime failure messages embed RESOLVED ${QA_*} credentials (Expected:/Received:
70
+ // carry the substituted value) and are lifted into the committed Note column below —
71
+ // build the value denylist once per unit so every Note gets scrubbed.
72
+ const qaEnvSecrets = collectQaEnvSecretDenylist(input.testData);
68
73
 
69
74
  for (const m of input.merged) {
70
75
  // Data-driven (@cases): expand to one CSV row per executed dataset input (Playwright titles
@@ -83,9 +88,9 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
83
88
  const testcaseType = extractTestcaseType(m.feature.tags);
84
89
  const authRole = extractAuthRole(m.feature.tags);
85
90
 
86
- // Pre-condition is auth-only. Steps section = Background + @extend base
87
- // (entire) + own pre-Then. Expected = own's Then-onwards only.
88
- const precondition = formatPrecondition(authRole);
91
+ // Pre-condition = auth line + feature Background steps (kept out of Steps
92
+ // so the numbering matches the .feature scenario). Steps section = @extend
93
+ // base (entire) + own pre-Then. Expected = own's Then-onwards only.
89
94
  const localize = (text: string): string => {
90
95
  // 1. resolve [Reference] selector keys → their label/value; 2. resolve {{var}} test-data
91
96
  // placeholders → concrete values, so Steps / Expected results show what QA actually verifies
@@ -98,13 +103,29 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
98
103
  };
99
104
  const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
100
105
  localize(s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text));
101
- const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
102
- const expectedLines = m.resolvedExpected.map(formatStep).filter(Boolean);
106
+ const precondition = formatPrecondition(
107
+ authRole,
108
+ (m.resolvedBackgroundSteps ?? []).map(formatStep).filter(Boolean)
109
+ );
110
+ const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
111
+ // Scenarios whose assertions verify different moments render Expected as
112
+ // "— after step N" groups (shared rule in groupExpectedByAnchor); the
113
+ // common all-after-last-step shape keeps the plain checklist.
114
+ const { lines: expectedLines, grouped } = groupExpectedByAnchor(
115
+ m.resolvedExpected
116
+ .map((s) => ({ text: formatStep(s), anchorStep: s.anchorStep }))
117
+ .filter((e) => e.text),
118
+ m.resolvedAnchorActionCount ?? 0
119
+ );
120
+ // When group headers reference step numbers, the Steps cell must show
121
+ // them — number even a single line.
103
122
  let steps =
104
123
  stepLines.length === 0 ? '' :
105
- stepLines.length === 1 ? stepLines[0] :
124
+ stepLines.length === 1 && !grouped ? stepLines[0] :
106
125
  stepLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
126
+ // Grouped cells carry their own per-group numbering — join verbatim.
107
127
  let expectedResults =
128
+ grouped ? expectedLines.join('\n') :
108
129
  expectedLines.length === 0 ? '' :
109
130
  expectedLines.length === 1 ? expectedLines[0] :
110
131
  expectedLines.map((l, i) => `${i + 1}. ${l}`).join('\n');
@@ -138,6 +159,19 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
138
159
  const variantRow = formatVariantRow(variant, '\n');
139
160
  if (variantRow) testData = testData ? `${testData}\n${variantRow}` : variantRow;
140
161
 
162
+ // Runtime-secret pass over EVERY kind's content cells (screen/flow included, not
163
+ // only api): a sensitive ${QA_*} VALUE that reached cell content — a hardcoded
164
+ // copy in test-data, a @cases dataset row, a locale substitution — is erased.
165
+ // Value-based only: fake demo values ("password123"), error-message strings
166
+ // ("Password is required") and element names ([Password]) never match and stay
167
+ // readable. The key-name-based pass above stays API-only by design — applied to
168
+ // screen cells it would mangle exactly those strings.
169
+ if (qaEnvSecrets.values.length > 0) {
170
+ steps = redactSecrets(steps, qaEnvSecrets.values);
171
+ expectedResults = redactSecrets(expectedResults, qaEnvSecrets.values);
172
+ testData = redactSecrets(testData, qaEnvSecrets.values);
173
+ }
174
+
141
175
  // Status for this (possibly per-input) row — resolved into `variant` above.
142
176
  const result: PlaywrightResult | undefined = variant.result;
143
177
 
@@ -156,7 +190,7 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
156
190
  // first, those already-passed/failed rows were mislabelled N/A ("not compiled").
157
191
  testResult = statusToTestResult(result.status);
158
192
  executedDate = formatExecutedDate(result.startTime);
159
- note = formatNote(result, expectedResults);
193
+ note = redactSecrets(formatNote(result, expectedResults), qaEnvSecrets.values);
160
194
  environment = formatEnv(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
161
195
  executor = input.env.executor;
162
196
  } else if (!m.spec) {
@@ -202,6 +236,11 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
202
236
  }
203
237
  }
204
238
 
239
+ // A sensitive ${QA_*} ref that resolved to nothing here can still appear RESOLVED in
240
+ // failure text (the run may have happened in another shell, e.g. CI artifacts exported
241
+ // locally) — never export silently unredacted.
242
+ warnIfRedactionIncomplete(qaEnvSecrets, rows.some((r) => r.testResult === 'Failed'), input.screen);
243
+
205
244
  return rows;
206
245
  }
207
246
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * .env.qa readers shared by the CLI surfaces (delivery, dashboard, screen capture)
3
+ * — the same precedence the RUNTIME uses (specs-test-data / specs-api loadEnvQa):
4
+ *
5
+ * shell env > .env.qa.<SUNGEN_ENV> > .env.qa
6
+ *
7
+ * Built as a LOCAL map: unlike the runtime's loadEnvQa, the CLI must never
8
+ * mutate process.env (it keeps running after the lookup).
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+
13
+ /** Read KEY=value lines from one dotenv-style file (quotes stripped, comments ignored). */
14
+ export function readEnvFile(file: string, into: Record<string, string>): void {
15
+ if (!fs.existsSync(file)) return;
16
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
17
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
18
+ if (m && into[m[1]] === undefined) into[m[1]] = m[2].replace(/^["']|["']$/g, '');
19
+ }
20
+ }
21
+
22
+ /** Merged env view for a project dir, with the runtime's precedence. */
23
+ export function buildEnvMap(cwd: string): Record<string, string> {
24
+ const map: Record<string, string> = {};
25
+ for (const name of [`.env.qa.${process.env.SUNGEN_ENV || ''}`, '.env.qa']) {
26
+ if (!name.endsWith('.')) readEnvFile(path.join(cwd, name), map);
27
+ }
28
+ for (const [k, v] of Object.entries(process.env)) if (v !== undefined) map[k] = v;
29
+ return map;
30
+ }
31
+
32
+ /**
33
+ * The web baseURL a run under the CURRENT SUNGEN_ENV actually uses.
34
+ * `SUNGEN_BASE_URL` (shell > .env.qa.<SUNGEN_ENV> > .env.qa) overrides the static
35
+ * value written in playwright.config.ts — mirroring the scaffolded config's own
36
+ * resolveBaseUrl(), so the Test Environment column and screenshot capture report
37
+ * the host the tests really hit.
38
+ */
39
+ export function resolveWebBaseUrl(cwd: string, staticBaseUrl: string): string {
40
+ return buildEnvMap(cwd).SUNGEN_BASE_URL || staticBaseUrl;
41
+ }