@sun-asterisk/sungen 3.2.18 → 3.2.19

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 (249) 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 +4 -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 +6 -5
  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/types.d.ts +9 -8
  67. package/dist/exporters/types.d.ts.map +1 -1
  68. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +1 -0
  69. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  70. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +1 -0
  71. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
  72. package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
  73. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  74. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +1 -0
  75. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  76. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  77. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  78. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  79. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  80. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  81. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  82. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  83. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  84. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +10 -0
  85. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  86. package/dist/generators/test-generator/code-generator.js +70 -7
  87. package/dist/generators/test-generator/code-generator.js.map +1 -1
  88. package/dist/generators/test-generator/diagnostics.d.ts +7 -1
  89. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  90. package/dist/generators/test-generator/diagnostics.js +6 -0
  91. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  92. package/dist/generators/test-generator/recipe-expander.d.ts +1 -1
  93. package/dist/generators/test-generator/recipe-expander.d.ts.map +1 -1
  94. package/dist/generators/test-generator/recipe-expander.js +1 -1
  95. package/dist/generators/test-generator/recipe-expander.js.map +1 -1
  96. package/dist/generators/test-generator/step-mapper.d.ts +17 -0
  97. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  98. package/dist/generators/test-generator/step-mapper.js +108 -17
  99. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  100. package/dist/generators/test-generator/template-engine.d.ts +1 -0
  101. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  102. package/dist/generators/test-generator/template-engine.js +1 -1
  103. package/dist/generators/test-generator/template-engine.js.map +1 -1
  104. package/dist/generators/test-generator/utils/data-resolver.d.ts +7 -1
  105. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  106. package/dist/generators/test-generator/utils/data-resolver.js +8 -4
  107. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  108. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +2 -2
  109. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  110. package/dist/generators/test-generator/utils/runtime-data-transformer.js +56 -11
  111. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  112. package/dist/generators/test-generator/utils/selector-resolver.d.ts +18 -2
  113. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  114. package/dist/generators/test-generator/utils/selector-resolver.js +29 -6
  115. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  116. package/dist/harness/audit.d.ts.map +1 -1
  117. package/dist/harness/audit.js +8 -7
  118. package/dist/harness/audit.js.map +1 -1
  119. package/dist/harness/capability-plan.d.ts.map +1 -1
  120. package/dist/harness/capability-plan.js +2 -3
  121. package/dist/harness/capability-plan.js.map +1 -1
  122. package/dist/harness/catalog/drivers.yaml +1 -1
  123. package/dist/harness/challenge.d.ts.map +1 -1
  124. package/dist/harness/challenge.js +1 -55
  125. package/dist/harness/challenge.js.map +1 -1
  126. package/dist/harness/data-driven-lint.d.ts.map +1 -1
  127. package/dist/harness/data-driven-lint.js +14 -9
  128. package/dist/harness/data-driven-lint.js.map +1 -1
  129. package/dist/harness/depth-lint.d.ts.map +1 -1
  130. package/dist/harness/depth-lint.js +1 -3
  131. package/dist/harness/depth-lint.js.map +1 -1
  132. package/dist/harness/flow-check.d.ts.map +1 -1
  133. package/dist/harness/flow-check.js +9 -7
  134. package/dist/harness/flow-check.js.map +1 -1
  135. package/dist/harness/flow-plan.d.ts.map +1 -1
  136. package/dist/harness/flow-plan.js +4 -3
  137. package/dist/harness/flow-plan.js.map +1 -1
  138. package/dist/harness/manifest.js +2 -2
  139. package/dist/harness/manifest.js.map +1 -1
  140. package/dist/harness/parse.d.ts +12 -0
  141. package/dist/harness/parse.d.ts.map +1 -1
  142. package/dist/harness/parse.js +28 -1
  143. package/dist/harness/parse.js.map +1 -1
  144. package/dist/harness/script-check.d.ts.map +1 -1
  145. package/dist/harness/script-check.js +52 -30
  146. package/dist/harness/script-check.js.map +1 -1
  147. package/dist/harness/secret-scan.d.ts +3 -1
  148. package/dist/harness/secret-scan.d.ts.map +1 -1
  149. package/dist/harness/secret-scan.js +14 -10
  150. package/dist/harness/secret-scan.js.map +1 -1
  151. package/dist/harness/sensors.d.ts.map +1 -1
  152. package/dist/harness/sensors.js +15 -4
  153. package/dist/harness/sensors.js.map +1 -1
  154. package/dist/harness/trace.js +1 -1
  155. package/dist/harness/trace.js.map +1 -1
  156. package/dist/harness/unit-paths.d.ts +16 -0
  157. package/dist/harness/unit-paths.d.ts.map +1 -1
  158. package/dist/harness/unit-paths.js +39 -2
  159. package/dist/harness/unit-paths.js.map +1 -1
  160. package/dist/index.d.ts +1 -0
  161. package/dist/index.d.ts.map +1 -1
  162. package/dist/index.js +3 -1
  163. package/dist/index.js.map +1 -1
  164. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  165. package/dist/orchestrator/screen-manager.js +8 -2
  166. package/dist/orchestrator/screen-manager.js.map +1 -1
  167. package/dist/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  168. package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  169. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -0
  170. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +51 -2
  171. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +6 -0
  172. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +6 -0
  173. package/dist/orchestrator/templates/playwright.config.ts +33 -2
  174. package/dist/orchestrator/templates/specs-mock.d.ts +66 -0
  175. package/dist/orchestrator/templates/specs-mock.d.ts.map +1 -0
  176. package/dist/orchestrator/templates/specs-mock.js +137 -0
  177. package/dist/orchestrator/templates/specs-mock.js.map +1 -0
  178. package/dist/orchestrator/templates/specs-mock.ts +179 -0
  179. package/dist/orchestrator/test-data-guide.d.ts.map +1 -1
  180. package/dist/orchestrator/test-data-guide.js +4 -0
  181. package/dist/orchestrator/test-data-guide.js.map +1 -1
  182. package/package.json +3 -3
  183. package/src/capabilities/discover.ts +4 -1
  184. package/src/cli/commands/add.ts +4 -2
  185. package/src/cli/commands/dashboard.ts +10 -1
  186. package/src/cli/commands/delivery.ts +19 -1
  187. package/src/cli/commands/generate.ts +4 -1
  188. package/src/dashboard/html-renderer.ts +8 -2
  189. package/src/dashboard/snapshot-builder.ts +3 -0
  190. package/src/dashboard/types.ts +4 -0
  191. package/src/exporters/api-environment.ts +3 -20
  192. package/src/exporters/api-testcase-formatter.ts +8 -2
  193. package/src/exporters/csv-exporter.ts +47 -8
  194. package/src/exporters/env-qa.ts +41 -0
  195. package/src/exporters/feature-parser.ts +17 -38
  196. package/src/exporters/json-exporter.ts +71 -24
  197. package/src/exporters/matrix/build.ts +6 -5
  198. package/src/exporters/matrix/gates.ts +3 -2
  199. package/src/exporters/matrix/step-buckets.ts +15 -0
  200. package/src/exporters/qa-env-secret-denylist.ts +118 -0
  201. package/src/exporters/scenario-merger.ts +80 -42
  202. package/src/exporters/step-formatter.ts +67 -66
  203. package/src/exporters/types.ts +9 -8
  204. package/src/generators/test-generator/adapters/adapter-interface.ts +1 -1
  205. package/src/generators/test-generator/adapters/appium/appium-adapter.ts +1 -1
  206. package/src/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  207. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +1 -1
  208. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  209. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  210. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  211. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  212. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  213. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  214. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  215. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +10 -0
  216. package/src/generators/test-generator/code-generator.ts +82 -7
  217. package/src/generators/test-generator/diagnostics.ts +7 -1
  218. package/src/generators/test-generator/recipe-expander.ts +2 -2
  219. package/src/generators/test-generator/step-mapper.ts +106 -15
  220. package/src/generators/test-generator/template-engine.ts +2 -2
  221. package/src/generators/test-generator/utils/data-resolver.ts +8 -5
  222. package/src/generators/test-generator/utils/runtime-data-transformer.ts +57 -11
  223. package/src/generators/test-generator/utils/selector-resolver.ts +29 -5
  224. package/src/harness/audit.ts +10 -9
  225. package/src/harness/capability-plan.ts +2 -2
  226. package/src/harness/catalog/drivers.yaml +1 -1
  227. package/src/harness/challenge.ts +2 -3
  228. package/src/harness/data-driven-lint.ts +13 -9
  229. package/src/harness/depth-lint.ts +2 -4
  230. package/src/harness/flow-check.ts +9 -7
  231. package/src/harness/flow-plan.ts +4 -3
  232. package/src/harness/manifest.ts +3 -3
  233. package/src/harness/parse.ts +28 -1
  234. package/src/harness/script-check.ts +52 -32
  235. package/src/harness/secret-scan.ts +11 -7
  236. package/src/harness/sensors.ts +15 -4
  237. package/src/harness/trace.ts +2 -2
  238. package/src/harness/unit-paths.ts +39 -2
  239. package/src/index.ts +1 -0
  240. package/src/orchestrator/screen-manager.ts +7 -2
  241. package/src/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  242. package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  243. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +2 -0
  244. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +51 -2
  245. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +6 -0
  246. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +6 -0
  247. package/src/orchestrator/templates/playwright.config.ts +33 -2
  248. package/src/orchestrator/templates/specs-mock.ts +179 -0
  249. package/src/orchestrator/test-data-guide.ts +4 -0
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
+ import { featureFilesFor } from './unit-paths';
14
15
  import { loadScenarios } from './parse';
15
16
  import { parseScenarios } from './capability-plan';
16
17
  import { readTextFile } from './read-text';
@@ -29,8 +30,9 @@ export interface FlowCheckReport {
29
30
  function listDirs(p: string): string[] {
30
31
  return fs.existsSync(p) ? fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : [];
31
32
  }
32
- function featurePath(base: string, kind: 'screens' | 'flows', name: string): string {
33
- return path.join(base, 'qa', kind, name, 'features', `${name}.feature`);
33
+ /** Every `.feature` of the unit — a unit is a directory, so a sub-feature counts too. */
34
+ function featurePaths(base: string, kind: 'screens' | 'flows', name: string): string[] {
35
+ return featureFilesFor(path.join(base, 'qa', kind, name), name);
34
36
  }
35
37
  function featureTags(fp: string): string[] {
36
38
  if (!fs.existsSync(fp)) return [];
@@ -57,7 +59,7 @@ export interface FlowScenario { flow: string; name: string; haystack: string; de
57
59
  export function loadFlowScenarios(cwd: string): FlowScenario[] {
58
60
  const out: FlowScenario[] = [];
59
61
  for (const f of listDirs(path.join(cwd, 'qa', 'flows'))) {
60
- for (const s of loadScenarios(featurePath(cwd, 'flows', f))) {
62
+ for (const s of featurePaths(cwd, 'flows', f).flatMap(loadScenarios)) {
61
63
  out.push({ flow: f, name: s.name, haystack: s.haystack, deep: s.hasDataAssertion });
62
64
  }
63
65
  }
@@ -74,7 +76,7 @@ export function buildFlowCheck(cwd: string, onlyFlow?: string): FlowCheckReport
74
76
  // A. Deferral integrity (screens).
75
77
  const deferrals: Deferral[] = [];
76
78
  for (const sc of screens) {
77
- for (const s of parseScenarios(featurePath(cwd, 'screens', sc))) {
79
+ for (const s of featurePaths(cwd, 'screens', sc).flatMap(parseScenarios)) {
78
80
  // A deferral is the first-class `@deferred:flow` tag (H6) OR the legacy `@manual` + a
79
81
  // "deferred to a flow" comment (back-compat). Either marks a cross-screen case owned by a flow.
80
82
  const isDeferral = s.deferredToFlow || (s.manual && /deferred to a flow/i.test(s.reason));
@@ -98,9 +100,9 @@ export function buildFlowCheck(cwd: string, onlyFlow?: string): FlowCheckReport
98
100
  // B. Run-test contract (per flow).
99
101
  const contracts: Contract[] = [];
100
102
  for (const f of flows) {
101
- const fp = featurePath(cwd, 'flows', f);
102
- const parallel = featureTags(fp).some((t) => /^@parallel$/i.test(t));
103
- const scs = loadScenarios(fp);
103
+ const fps = featurePaths(cwd, 'flows', f);
104
+ const parallel = fps.flatMap(featureTags).some((t) => /^@parallel$/i.test(t));
105
+ const scs = fps.flatMap(loadScenarios);
104
106
  let cartStateCount = 0;
105
107
  for (const s of scs) {
106
108
  const h = s.haystack;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
+ import { featureFilesFor } from './unit-paths';
12
13
  import { parse as parseYaml } from 'yaml';
13
14
  import { MANUAL_REASONS, inferReasonCode } from './capability-plan';
14
15
  import { buildFlowCheck, Contract } from './flow-check';
@@ -78,9 +79,9 @@ export interface FlowPlan {
78
79
  }
79
80
 
80
81
  export function buildFlowPlan(cwd: string, flow: string): FlowPlan {
81
- const featurePath = path.join(cwd, 'qa', 'flows', flow, 'features', `${flow}.feature`);
82
- const selectorsPath = path.join(cwd, 'qa', 'flows', flow, 'selectors', `${flow}.yaml`);
83
- const scenarios = parseFlowScenarios(featurePath);
82
+ const flowDir = path.join(cwd, 'qa', 'flows', flow);
83
+ const selectorsPath = path.join(flowDir, 'selectors', `${flow}.yaml`);
84
+ const scenarios = featureFilesFor(flowDir, flow).flatMap(parseFlowScenarios);
84
85
  const keys = selectorKeys(selectorsPath);
85
86
 
86
87
  // Legs = distinct screen namespaces.
@@ -11,7 +11,7 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { createHash } from 'crypto';
14
- import { featureBasename, reportSlug } from './unit-paths';
14
+ import { featureFilesFor, reportSlug } from './unit-paths';
15
15
  import { readTextFile } from './read-text';
16
16
 
17
17
  export interface SpecSection { name: string; hash: string }
@@ -99,13 +99,13 @@ function parseFeatureSections(featurePath: string): { scenario: string; vpCode?:
99
99
 
100
100
  export function buildManifest(screenDir: string, screenName: string): Manifest {
101
101
  const specPath = path.join(screenDir, 'requirements', 'spec.md');
102
- const featurePath = path.join(screenDir, 'features', `${featureBasename(screenName)}.feature`);
102
+ const featureFiles = featureFilesFor(screenDir, screenName);
103
103
  const specSections = parseSpecSections(specPath);
104
104
  const specMap: Record<string, string> = {};
105
105
  for (const s of specSections) specMap[s.name] = s.hash;
106
106
 
107
107
  const specNames = Object.keys(specMap);
108
- const featScenarios = parseFeatureSections(featurePath);
108
+ const featScenarios = featureFiles.flatMap(parseFeatureSections);
109
109
  const entries: ManifestEntry[] = featScenarios.map((f) => {
110
110
  const matched = matchSection(f.section, specNames);
111
111
  return {
@@ -8,6 +8,7 @@
8
8
  import * as fs from 'fs';
9
9
  import { GherkinParser, ParsedScenario, ParsedStep } from '../generators/gherkin-parser';
10
10
  import { readTextFile } from './read-text';
11
+ import { featureFilesFor } from './unit-paths';
11
12
 
12
13
  export type Priority = 'high' | 'normal' | 'low' | 'unknown';
13
14
 
@@ -175,7 +176,12 @@ function classifyScenario(sc: ParsedScenario): ScenarioInfo {
175
176
 
176
177
  if (last === 'Then') {
177
178
  thenCount++;
178
- if (/\{\{|contains|match data|toHaveText/i.test(step.text)) hasData = true;
179
+ // Storage assertions count as data-bearing: 4 of the 5 shapes carry no {{ref}}
180
+ // (`exists`, `does not exist`, `is not empty`, `key matching "…"`), yet they
181
+ // assert persisted state — without this they'd be scored `shallow`, penalizing
182
+ // exactly the checks the review encourages. `tab order` is a STRUCTURAL
183
+ // assertion (a data table of focus stops) with the same no-{{ref}} property.
184
+ if (/\{\{|contains|match data|toHaveText|in (local|session) storage|tab order/i.test(step.text)) hasData = true;
179
185
  }
180
186
  }
181
187
 
@@ -211,3 +217,24 @@ export function loadScenarios(featurePath: string): ScenarioInfo[] {
211
217
  .filter((s) => !s.stepsName && !s.hookType) // skip @steps/@hook blocks
212
218
  .map(classifyScenario);
213
219
  }
220
+
221
+ /**
222
+ * Scenarios of a WHOLE unit — every `.feature` in its features/ directory, not just the
223
+ * canonical one. Sub-features created by `add --feature` used to be invisible to every
224
+ * sensor; the score they produced was a subset presented as the whole.
225
+ */
226
+ export function loadUnitScenarios(unitDir: string, unit: string): ScenarioInfo[] {
227
+ return featureFilesFor(unitDir, unit).flatMap(loadScenarios);
228
+ }
229
+
230
+ /**
231
+ * Raw Gherkin of a WHOLE unit, concatenated in `featureFilesFor` order — the input for the
232
+ * text-level sensors (isolation risk, serial cascade, manual oracle, …). Missing files
233
+ * contribute nothing, so a unit with no features yields '' exactly as before.
234
+ */
235
+ export function readUnitFeatureText(unitDir: string, unit: string): string {
236
+ return featureFilesFor(unitDir, unit)
237
+ .filter((p) => fs.existsSync(p))
238
+ .map((p) => readTextFile(p))
239
+ .join('\n');
240
+ }
@@ -15,8 +15,8 @@
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
17
  import * as os from 'os';
18
- import { loadScenarios, ScenarioInfo } from './parse';
19
- import { featureBasename } from './unit-paths';
18
+ import { loadUnitScenarios, ScenarioInfo } from './parse';
19
+ import { featureFilesFor } from './unit-paths';
20
20
  import { readTextFile } from './read-text';
21
21
 
22
22
  export interface ScriptCheckResult {
@@ -120,7 +120,12 @@ function specSubdir(dir: string, name: string, kind: UnitKind): string {
120
120
  return kind === 'flow' ? path.join(dir, 'flows', name) : kind === 'api' ? path.join(dir, 'api', name) : path.join(dir, name);
121
121
  }
122
122
 
123
- function findSpec(dir: string, name: string, kind: UnitKind): string | null {
123
+ /**
124
+ * EVERY compiled spec of the unit. A unit compiles to one `.spec.ts` per `.feature`, so
125
+ * taking only the first hit checked a multi-feature screen against a fraction of its own
126
+ * compiled output — every sub-feature scenario then read as MISSING in spec.
127
+ */
128
+ function findSpecs(dir: string, name: string, kind: UnitKind): string[] {
124
129
  // Screens compile to <dir>/<name>/<feature>.spec.ts
125
130
  // Flows compile to <dir>/flows/<name>/<feature>.spec.ts
126
131
  // Api compile to <dir>/api/<name>/<feature>.spec.ts
@@ -137,37 +142,45 @@ function findSpec(dir: string, name: string, kind: UnitKind): string | null {
137
142
  }
138
143
  };
139
144
  const scoped = specSubdir(dir, name, kind);
140
- if (!fs.existsSync(scoped)) return null; // no spec for this target (e.g. not compiled yet)
145
+ if (!fs.existsSync(scoped)) return []; // no spec for this target (e.g. not compiled yet)
141
146
  walk(scoped);
142
- return hits[0] ?? null;
147
+ return hits.sort((a, b) => a.localeCompare(b, 'en'));
143
148
  }
144
149
 
145
150
  export async function runScriptCheck(screenDir: string, screenName: string, kind: UnitKind): Promise<ScriptCheckResult> {
146
- const featurePath = path.join(screenDir, 'features', `${featureBasename(screenName)}.feature`);
147
- const scenarios = loadScenarios(featurePath);
151
+ const featureFiles = featureFilesFor(screenDir, screenName);
152
+ const scenarios = loadUnitScenarios(screenDir, screenName);
148
153
  const automated = scenarios.filter((s) => !s.manual);
149
154
  const manual = scenarios.filter((s) => s.manual);
150
155
 
151
- const committedSpec = findSpec(path.join(process.cwd(), 'specs', 'generated'), screenName, kind);
156
+ const committedSpecs = findSpecs(path.join(process.cwd(), 'specs', 'generated'), screenName, kind);
157
+ // Representative path for the report; the checks below read ALL of them.
158
+ const committedSpec = committedSpecs[0] ?? null;
152
159
 
153
160
  const findings: string[] = [];
154
161
  let specTitles: string[] = [];
155
162
  let specSrc = '';
156
163
  if (committedSpec) {
157
- specSrc = readTextFile(committedSpec);
158
- specTitles = extractTestTitles(specSrc);
164
+ specSrc = committedSpecs.map((p) => readTextFile(p)).join('\n');
165
+ specTitles = committedSpecs.flatMap((p) => extractTestTitles(readTextFile(p)));
159
166
  } else {
160
167
  findings.push('No generated spec found under specs/generated/ — run `sungen generate` / `/sungen:run-test` first.');
161
168
  }
162
169
 
163
170
  // A. Structural 1:1
164
- // A @cases scenario emits ONE source test() inside a per-row loop, titled
165
- // `<name> — ${__row.__label}` — match that literal title, not the bare name.
166
- const expectedTitle = (s: ScenarioInfo) => (s.casesDataset ? `${s.name} ${'${'}__row.__label}` : s.name);
167
- const specTitleSet = new Set(specTitles);
168
- const expectedSet = new Set(automated.map(expectedTitle));
169
- const missingInSpec = automated.filter((s) => !specTitleSet.has(expectedTitle(s))).map((s) => s.name);
170
- const extraInSpec = specTitles.filter((t) => !expectedSet.has(t));
171
+ // A @cases scenario emits ONE source test() inside a per-row loop. Its title
172
+ // appears in the spec as `'<name> — ' + __row.__label` (CONCAT what
173
+ // scenario.hbs emits today; the extracted literal is `<name> —`) or as the
174
+ // legacy template form `<name> — ${__row.__label}`. This check used to expect
175
+ // ONLY the template form, so on the current codegen every @cases scenario was
176
+ // reported MISSING (+ its test EXTRA) despite compiling and running fine.
177
+ // Canonicalize the extracted title to the bare scenario name so both shapes
178
+ // (and plain titles) compare on the same footing.
179
+ const canonSpecTitle = (t: string) => t.replace(/\s*—(\s*\$\{__row\.__label\})?$/, '');
180
+ const specTitleSet = new Set(specTitles.map(canonSpecTitle));
181
+ const expectedSet = new Set(automated.map((s) => s.name));
182
+ const missingInSpec = automated.filter((s) => !specTitleSet.has(s.name)).map((s) => s.name);
183
+ const extraInSpec = specTitles.filter((t) => !expectedSet.has(canonSpecTitle(t)));
171
184
  const countMatch = committedSpec ? automated.length === specTitles.length : false;
172
185
  if (committedSpec && !countMatch) {
173
186
  findings.push(`Count mismatch: ${automated.length} automated scenarios vs ${specTitles.length} test() blocks.`);
@@ -192,25 +205,32 @@ export async function runScriptCheck(screenDir: string, screenName: string, kind
192
205
  const gen = kind === 'api'
193
206
  ? new CodeGenerator({ framework, runtimeData: true })
194
207
  : new CodeGenerator({ framework, screenName, runtimeData: true, flowMode: kind === 'flow' });
195
- await gen.generateAllTests(qaSourceDir, tmp, [featurePath]);
196
- const fresh = findSpec(tmp, screenName, kind);
197
- if (fresh) {
198
- const a = normalize(specSrc);
208
+ await gen.generateAllTests(qaSourceDir, tmp, featureFiles);
209
+ // Pair each committed spec with its regenerated twin by filename — one `.feature`
210
+ // compiles to one `.spec.ts`, so a multi-feature unit has several of each.
211
+ const freshByName = new Map(findSpecs(tmp, screenName, kind).map((p) => [path.basename(p), p]));
212
+ const multi = committedSpecs.length > 1;
213
+ for (const committed of committedSpecs) {
214
+ const fresh = freshByName.get(path.basename(committed));
215
+ if (!fresh) continue;
216
+ const a = normalize(readTextFile(committed));
199
217
  const b = normalize(readTextFile(fresh));
200
- if (a !== b) {
201
- drift = 'drift';
202
- // collect a few differing lines
203
- const al = a.split('\n'), bl = b.split('\n');
204
- const max = Math.max(al.length, bl.length);
205
- for (let i = 0, shown = 0; i < max && shown < 6; i++) {
206
- if (al[i] !== bl[i]) {
207
- driftHunks.push(` L${i + 1}\n committed: ${(al[i] ?? '∅').trim().slice(0, 100)}\n expected : ${(bl[i] ?? '∅').trim().slice(0, 100)}`);
208
- shown++;
209
- }
218
+ if (a === b) continue;
219
+ drift = 'drift';
220
+ // collect a few differing lines
221
+ const al = a.split('\n'), bl = b.split('\n');
222
+ const max = Math.max(al.length, bl.length);
223
+ const where = multi ? `${path.basename(committed)} ` : '';
224
+ for (let i = 0, shown = 0; i < max && shown < 6 && driftHunks.length < 6; i++) {
225
+ if (al[i] !== bl[i]) {
226
+ driftHunks.push(` ${where}L${i + 1}\n committed: ${(al[i] ?? '∅').trim().slice(0, 100)}\n expected : ${(bl[i] ?? '∅').trim().slice(0, 100)}`);
227
+ shown++;
210
228
  }
211
- findings.push('DRIFT: committed spec differs from a fresh regenerate → spec was hand-edited or the .feature changed without `sungen generate`. The test code no longer reflects the Gherkin.');
212
229
  }
213
230
  }
231
+ if (drift === 'drift') {
232
+ findings.push('DRIFT: committed spec differs from a fresh regenerate → spec was hand-edited or the .feature changed without `sungen generate`. The test code no longer reflects the Gherkin.');
233
+ }
214
234
  fs.rmSync(tmp, { recursive: true, force: true });
215
235
  } catch (e) {
216
236
  findings.push(`Drift check skipped (regenerate failed): ${e instanceof Error ? e.message : e}`);
@@ -37,15 +37,19 @@ function scanText(text: string, file: string): SecretHit[] {
37
37
  return hits;
38
38
  }
39
39
 
40
- /** Scan a screen/flow dir's test-data/*.yaml for likely real secrets. */
40
+ /** Scan a unit dir's committed yaml sinks for likely real secrets: test-data/*.yaml,
41
+ * mock/*.yaml (mock response bodies are a credential magnet — pasted DevTools
42
+ * responses carry real tokens), and api/*.yaml (endpoint catalogs carry headers). */
41
43
  export function scanTestDataSecrets(baseDir: string): SecretHit[] {
42
- const tdDir = path.join(baseDir, 'test-data');
43
- if (!fs.existsSync(tdDir)) return [];
44
44
  const hits: SecretHit[] = [];
45
- for (const f of fs.readdirSync(tdDir)) {
46
- if (!/\.ya?ml$/i.test(f)) continue;
47
- const p = path.join(tdDir, f);
48
- try { hits.push(...scanText(fs.readFileSync(p, 'utf-8'), path.join('test-data', f))); } catch { /* ignore */ }
45
+ for (const sub of ['test-data', 'mock', 'api']) {
46
+ const dir = path.join(baseDir, sub);
47
+ if (!fs.existsSync(dir)) continue;
48
+ for (const f of fs.readdirSync(dir)) {
49
+ if (!/\.ya?ml$/i.test(f)) continue;
50
+ const p = path.join(dir, f);
51
+ try { hits.push(...scanText(fs.readFileSync(p, 'utf-8'), path.join(sub, f))); } catch { /* ignore */ }
52
+ }
49
53
  }
50
54
  return hits;
51
55
  }
@@ -279,8 +279,12 @@ export interface AutomatableManualResult {
279
279
 
280
280
  // Genuine-judgment markers (M6/M8/M9 territory): visual/responsive/a11y/mock/network/
281
281
  // external/empty-state — these legitimately stay @manual (or need a future driver).
282
+ // `focus order` is no longer a judgment marker — `Then User see tab order:` automates
283
+ // it deterministically. `keyboard` is NARROWED (keyboard-only navigation / focus traps
284
+ // still need a human or AT), not removed: a scenario merely mentioning the keyboard is
285
+ // automatable, a keyboard-ONLY audit is not.
282
286
  const JUDGMENT_MARKER =
283
- /\b(visual|responsive|layout|breakpoint|mobile|tablet|viewport|accessib|a11y|keyboard|screen reader|focus order|\baria\b|empty[- ]?(state|product|list|category|cart)|no[- ]?result|missing (image|product|data)|placeholder|fallback|slow|failing|offline|network|loading|spinner|external|new tab|video tutorial|email|mailbox|download|payment gateway|exploratory|not worth)\b/;
287
+ /\b(visual|responsive|layout|breakpoint|mobile|tablet|viewport|accessib|a11y|keyboard[- ]only|keyboard trap|screen reader|\baria\b|empty[- ]?(state|product|list|category|cart)|no[- ]?result|missing (image|product|data)|placeholder|fallback|slow|failing|offline|network|loading|spinner|external|new tab|video tutorial|email|mailbox|download|payment gateway|exploratory|not worth)\b/;
284
288
 
285
289
  /**
286
290
  * Automatable-@manual (TQ-2) — a `@manual` scenario whose steps are fully DSL-expressible
@@ -637,7 +641,10 @@ const CLAIM_RULES: ClaimRule[] = [
637
641
  {
638
642
  claim: 'independent/separate',
639
643
  title: /\b(independent|separate|isolat(ed|es)|per[- ]tab|two tabs|each tab)\b/,
640
- proof: /\bcontext\b|tab a|tab b|second (tab|context)/,
644
+ // A storage `does not exist` check on the OTHER tenant's key proves isolation
645
+ // ("the other shop's token is not exposed"). Only the absence shape counts — a
646
+ // positive `exists` check looks the same whether or not the claim holds.
647
+ proof: /\bcontext\b|tab a|tab b|second (tab|context)|does not exist/,
641
648
  need: 'a multi-context proof (tab A vs tab B)',
642
649
  hint: 'independence across tabs/contexts is rarely DSL-expressible — mark @manual with a clear setup/action/oracle.',
643
650
  severity: 'warn',
@@ -653,7 +660,7 @@ const CLAIM_RULES: ClaimRule[] = [
653
660
  {
654
661
  claim: 'announces/aria',
655
662
  title: /\b(announce[sd]?|aria|screen[- ]reader|programmatically associated)\b/,
656
- proof: /aria|role|@manual|describedby|is focused/,
663
+ proof: /aria|role|@manual|describedby|is focused|tab order/,
657
664
  need: 'an aria/role assertion (or @manual with a screen-reader oracle)',
658
665
  hint: 'ARIA announcement is usually not DSL-expressible — assert aria attributes if possible, else @manual with an NVDA/VoiceOver oracle.',
659
666
  severity: 'warn',
@@ -668,7 +675,11 @@ const CLAIM_RULES: ClaimRule[] = [
668
675
  // authoritative recall layer for the residue this can't judge structurally.
669
676
  claim: 'negative-claim/absence',
670
677
  title: /(\bno\b|\bnot\b|n['’]t\b|\bnever\b|\bwithout\b|\bcannot\b|prevent|block|avoid|reject|disabl|\bdeny\b|denies|\bkhông\b|\bchưa\b)/i,
671
- proof: /is hidden|are hidden|is empty|no longer|not complete|disabl|invalid|rejected|\berror\b|\bcount\b|row with \{\{|table with|\bremember\b|\bexactly\b|tohavecount/i,
678
+ // `does not exist` covers the storage-absence shape the broad "any storage
679
+ // assertion" alternative would let a positive `exists` silence a negative claim.
680
+ // `tab order` proves "focus never lands on X"-style claims (the table enumerates
681
+ // exactly where focus goes).
682
+ proof: /is hidden|are hidden|is empty|no longer|not complete|disabl|invalid|rejected|\berror\b|\bcount\b|row with \{\{|table with|\bremember\b|\bexactly\b|tohavecount|does not exist|tab order/i,
672
683
  need: 'a proof of the ABSENCE — a contrast/empty/hidden/error/count assertion, or @manual with an oracle',
673
684
  hint: 'a negative claim ("no / not / không …") is not proven by a positive `see [X]` that looks the same whether or not the claim holds. Assert the contrast (state hidden/empty, error shown, count unchanged), or mark @manual.',
674
685
  severity: 'warn',
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
- import { reportSlug } from './unit-paths';
16
+ import { featureFilesFor, reportSlug } from './unit-paths';
17
17
  import { segmentRuns, latestRunEvents, LedgerEvent } from './ledger';
18
18
  import { readTextFile } from './read-text';
19
19
 
@@ -96,7 +96,7 @@ export function buildTrace(screenDir: string, screenName: string): TraceReport {
96
96
  const scRaw = readJson(path.join(process.cwd(), '.sungen', 'reports', `${reportSlug(screenName)}-script-check.json`));
97
97
  const drift = scRaw ? scRaw.drift : null;
98
98
 
99
- const manual = parseManual(path.join(screenDir, 'features', `${screenName}.feature`));
99
+ const manual = featureFilesFor(screenDir, screenName).flatMap(parseManual);
100
100
 
101
101
  // Bottlenecks
102
102
  const bottlenecks: string[] = [];
@@ -1,14 +1,51 @@
1
1
  /**
2
2
  * Unit-path helpers (api-flow fix). A unit id may be a bare name (`orders`, `login`) or a nested
3
- * api-flow id (`flows/<flow>`). Two derivations the harness/CLI need:
3
+ * api-flow id (`flows/<flow>`). Three derivations the harness/CLI need:
4
4
  * - featureBasename: the `.feature` filename — the LAST path segment (`flows/x` → `x`), so
5
5
  * `<dir>/features/<basename>.feature` resolves (the bug: the full id looked for
6
6
  * `features/flows/x.feature` → 0 scenarios).
7
+ * - featureFilesFor: EVERY `.feature` file of the unit — a unit is a directory, not a file.
7
8
  * - reportSlug: a flat key for `.sungen/reports/<slug>-*.json` + `.sungen/ledger/<slug>.jsonl`
8
9
  * (`flows/x` → `flows-x`), so artifacts never nest under a `flows/` subdir and read/write agree.
9
- * Bare names (no slash) are unchanged by both → no regression for screens/flows/areas.
10
+ * Bare names (no slash) are unchanged by all three → no regression for screens/flows/areas.
10
11
  */
12
+ import * as fs from 'fs';
11
13
  import * as path from 'path';
12
14
 
13
15
  export const featureBasename = (unit: string): string => path.basename(unit);
14
16
  export const reportSlug = (unit: string): string => unit.replace(/[\\/]+/g, '-');
17
+
18
+ /**
19
+ * Every `.feature` file belonging to one unit, in a stable order.
20
+ *
21
+ * A unit is a DIRECTORY of features, not one file. `sungen add --screen x --feature y`
22
+ * scaffolds `x-y.feature` beside `x.feature`, and `--path` without `--feature` names the
23
+ * file after the URL segment — so `<unit>.feature` may be one of several, or absent
24
+ * entirely. `generate` and `delivery` have always enumerated the directory; the harness
25
+ * read the single canonical name and scored only that file, silently dropping every
26
+ * sub-feature from coverage/depth/traceability with no warning.
27
+ *
28
+ * Order: the canonical `<unit>.feature` first so report ordering stays put when a
29
+ * sub-feature is added later, then the rest alphabetically. Falls back to the canonical
30
+ * path when the directory is missing or empty, so callers keep the "file not found"
31
+ * behaviour (and error text) they already have.
32
+ */
33
+ export function featureFilesFor(unitDir: string, unit: string): string[] {
34
+ const dir = path.join(unitDir, 'features');
35
+ const canonicalName = `${featureBasename(unit)}.feature`;
36
+ const canonical = path.join(dir, canonicalName);
37
+
38
+ let names: string[];
39
+ try {
40
+ names = fs.readdirSync(dir).filter((f) => f.endsWith('.feature'));
41
+ } catch {
42
+ return [canonical]; // no features/ dir — let the caller report the missing file
43
+ }
44
+ if (names.length === 0) return [canonical];
45
+
46
+ names.sort((a, b) => a.localeCompare(b, 'en'));
47
+ return [
48
+ ...names.filter((n) => n === canonicalName),
49
+ ...names.filter((n) => n !== canonicalName),
50
+ ].map((n) => path.join(dir, n));
51
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export type { ResolvedAppProfile } from './generators/test-generator/app-profile
20
20
  export type { MappedStep } from './generators/test-generator/step-mapper';
21
21
  export type { ParsedStep } from './generators/gherkin-parser';
22
22
  export { getPathCode, inferPath, resolvePathVariables } from './generators/test-generator/utils/path-inference';
23
+ export { LIST_MEMBER_ROLES } from './generators/test-generator/utils/selector-resolver';
23
24
 
24
25
  // --- Precondition-annotation override grammar (shared by the @query / @api driver codegen) ---
25
26
  export { parseQueryOverrides } from './harness/annotation-overrides';
@@ -8,6 +8,7 @@ import * as path from 'path';
8
8
  import { chromium } from '@playwright/test';
9
9
  import { readCapabilities } from '../harness/capability';
10
10
  import { testDataHeader, ensureTestDataGuideFiles } from './test-data-guide';
11
+ import { resolveWebBaseUrl } from '../exporters/env-qa';
11
12
 
12
13
  export interface ScreenOptions {
13
14
  name: string;
@@ -304,8 +305,12 @@ export class ScreenManager {
304
305
  if (!fs.existsSync(configPath)) return null;
305
306
 
306
307
  const content = fs.readFileSync(configPath, 'utf-8');
307
- const match = content.match(/baseURL:\s*['"]([^'"]+)['"]/);
308
- return match ? match[1] : null;
308
+ // Both config shapes: legacy literal and env-aware resolveBaseUrl('x') wrapper.
309
+ const match = content.match(/baseURL:\s*(?:resolveBaseUrl\(\s*)?['"]([^'"]+)['"]/);
310
+ if (!match) return null;
311
+ // SUNGEN_BASE_URL (shell > .env.qa.<SUNGEN_ENV> > .env.qa) overrides the static
312
+ // value — screenshots must capture the same host the tests will run against.
313
+ return resolveWebBaseUrl(this.cwd, match[1]);
309
314
  }
310
315
 
311
316
  private validateScreenName(name: string): void {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: dashboard
3
3
  description: "Build a single-file HTML dashboard with test cases + pass/fail results, history trends, compare runs, and CSV/XLSX export."
4
- argument-hint: "[screen-name...] (omit for all)"
4
+ argument-hint: "[screen-name...] [--env <env>] (omit names for all)"
5
5
  order: 60
6
6
  claude-tools: "Bash, Read, AskUserQuestion"
7
7
  copilot-tools: "[read, execute, edit, vscode/askQuestions]"
@@ -16,6 +16,7 @@ You are a **QA Reporting Engineer**. Your job is to invoke the deterministic `su
16
16
  Parse **screens** from `$ARGUMENTS`:
17
17
  - If empty → CLI will include **all** screens and flows.
18
18
  - If provided → pass them through.
19
+ - **`--env <env>`** — optional (accept `--locale` as an alias). Pass through as `sungen dashboard --env <env>`: the dashboard is built for that environment/locale only — it picks the `<name>-test-result.<env>.json` files, and Trends/Compare only see history snapshots of the same env (a dev run never blends into the uat trend line). Omit → the base environment.
19
20
 
20
21
  ## Steps
21
22
 
@@ -61,7 +61,7 @@ Stop here for mobile — do not continue to the web path.
61
61
 
62
62
  Parse from `$ARGUMENTS`:
63
63
  - **name** — screen or flow name. If missing, ask the user.
64
- - **`--env <locale>`** — optional. Sets `SUNGEN_ENV=<locale>` for the test run so the runtime test-data resolver merges `<name>.<locale>.yaml` over the base, and `playwright.config.ts` writes results to `<name>-test-result.<locale>.json`. Accept `--locale <locale>` as an alias.
64
+ - **`--env <locale>`** — optional. Sets `SUNGEN_ENV=<locale>` for the test run so the runtime test-data resolver merges `<name>.<locale>.yaml` over the base, and `playwright.config.ts` writes results to `<name>-test-result.<locale>.json`. Accept `--locale <locale>` as an alias. The value can also be a plain ENVIRONMENT name (`dev`, `uat`, `stg`): the same overlay applies to test-data AND `.env.qa.<env>` overlays `.env.qa` (secrets, API/DB datasource URLs); when `SUNGEN_BASE_URL` is declared there, the web baseURL switches too — no config edit, no recompile.
65
65
  - **`--ios`**, **`--android`**, **`--both`** (mobile only) — optional. Sets `MOBILE_PLATFORM` for the test run. Default: `android` if not specified. Parsed into `MOBILE_PLATFORM=<value>` and used in WebdriverIO test runs.
66
66
 
67
67
  If `--env` is passed but no value follows, ask the user which locale to use.
@@ -172,6 +172,8 @@ The CLI reads the **per-target result file first** (co-located with `.spec.ts`),
172
172
  | Test Environment | `playwright.config.ts` baseURL + project name |
173
173
  | Note | Error message + trace path (for failed tests) |
174
174
 
175
+ **Runtime-secret redaction (all cells, all unit kinds):** a failed test's error message carries the RESOLVED `${QA_*}` credential (runtime substituted it before asserting), and cell content can too. The exporter builds a per-unit denylist — env vars referenced as `${QA_*}` in the unit's test data whose NAME looks sensitive (`QA_ADMIN_PASSWORD` yes; `QA_BASE_URL`, `QA_ADMIN_EMAIL` no) — resolving values from shell/CI env > `.env.qa.<SUNGEN_ENV>` > `.env.qa`, and erases every occurrence (`***`) from Note, dashboard errorMessage, Steps, Expected results and Test Data. Value-based only: fake demo values (`password123`), error-message strings (`Password is required`) and element names (`[Password]`) stay readable. If a sensitive ref can't be resolved while the run has failures, delivery prints a "redaction coverage incomplete" warning — set the variable in the env before exporting. `sungen delivery` also runs the same secret scan as `generate` and warns if test-data holds a literal token-shaped value.
176
+
175
177
  ---
176
178
 
177
179
  ## XLSX sheets — Overview + Auto / Manual split
@@ -62,7 +62,7 @@ User click [T] button and dismiss [Cancel] alert # compiler registers the liste
62
62
  User click [OK | Cancel] alert # two-step form: must come BEFORE the trigger
63
63
  User fill [T] alert with {{v}}
64
64
  User see [message text] alert
65
- User press Escape key | Enter on [T] field
65
+ User press Escape key | [Enter] key | Tab key 5 times | Enter on [T] field
66
66
  User wait for N seconds | [T] page
67
67
  User wait for [T] TYPE is visible | hidden | enabled | disabled # ANY reference (v3.3)
68
68
  User wait for [T] TYPE with {{v}} # until it shows the value
@@ -103,9 +103,15 @@ two content-filtered queries can hit different rows if the table re-renders in b
103
103
  # 5. State: User see [T] button is disabled | checkbox is checked | dialog with {{v}} is hidden
104
104
  # 6. Attribute (toHaveAttribute): User see [T] image | link with {{v}}
105
105
  # 7. Count: User see [T] row with {{count}}
106
- # 8. Page: User see [T] page
106
+ # 8. Page: User see [T] page | page with {{url}} ← `with` also asserts the query string
107
107
  ```
108
108
 
109
+ **Pattern 8 — assert the query, not just the path.** Bare `see [T] page` only checks the base path,
110
+ so a search/filter test PASSES when the query string is wrong or gone entirely. When the URL carries
111
+ state worth proving, pass it: `Then User see [Search] page with {{search_query}}` compiles to the
112
+ path assertion PLUS `expect(page.url()).toContain(<value>)`. Same for `Then User is on [T] page with
113
+ {{v}}`. A dynamic segment in the selector value (`/users/:id/profile`) wildcards to `[^/]+` on its own.
114
+
109
115
  ### Collection / all-card (P5)
110
116
 
111
117
  ```
@@ -142,6 +148,49 @@ first contact row:
142
148
  ```
143
149
  → compiles to `expect(table.locator('tbody tr:first-child')).toContainText(v)` — the exact row must hold the value — and still enters row scope for `[Col] column` checks.
144
150
 
151
+ ### Browser storage (web)
152
+
153
+ ```
154
+ Then User see [KEY] in local storage exists
155
+ Then User see [KEY] in local storage does not exist
156
+ Then User see [KEY] in local storage is not empty
157
+ Then User see [KEY] in local storage has length {{n}}
158
+ Then User see key matching "PATTERN" in local storage # regex over key names
159
+ # same five forms with `session storage`
160
+ ```
161
+
162
+ `[KEY]` is a **storage key, not a selector** — never add it to selectors.yaml. It may embed `{{vars}}`: `[{{exclusive_code}}_ACCESS_TOKEN]`. The check runs inside the browser and returns only a boolean, so a failure message never contains the stored value (safe for tokens). There is deliberately no `equals {{expected}}` form. ⚠️ `expect [KEY] in local storage …` is NOT valid (`expect` reads `{{response}}` refs only) — the compiler warns SG-W011.
163
+
164
+ ### Tab order (web only)
165
+
166
+ ```
167
+ Then User see tab order:
168
+ | Ref |
169
+ | [Station Dropdown] |
170
+ | [Departure Date] |
171
+ | [Search Button] button |
172
+ ```
173
+
174
+ Focuses row 1 (the pinned origin — there is no separate `focus [X]` step) and asserts it actually HOLDS focus (a non-focusable ref fails loudly), then presses Tab per following row and asserts it receives focus. Cell refs ARE selector references (resolved via selectors.yaml; optional element type after the ref). Requires the `| Ref |` header and ≥2 element rows — a missing header or single row is a compile error, never an empty pass. A mismatch reports the expected ref + the actual focused element (shadow-DOM-aware tag/role/name). Limitations: web only (no Appium); declare tab order OUTSIDE `use dialog`/frame scope (cell locators render page-rooted — put `scope: dialog` on the selector ENTRIES if the elements live in a dialog); focus traps / dynamic comboboxes / `tabindex=-1` reordering / WebKit differences may need `@manual` keyboard-only audits — those stay legitimate manuals.
175
+
176
+ ### Network mocking (optional Mock Driver — `sungen capability add mock`)
177
+
178
+ ```gherkin
179
+ @mock
180
+ Scenario: search shows the error state on server failure
181
+ Given [reservations-check-500] mock is active # install a NAMED catalog entry
182
+ When User click [Search] button
183
+ Then User see [Error Toast] message
184
+ And User see [reservations-check-500] mock called {{n}} times # exact count; "called 1 time" also valid
185
+ ```
186
+
187
+ `[name]` is a **catalog reference, not a selector** — entries live in `qa/screens/<name>/mock/mocks.yaml` (`method`, `url` glob or `re:` regex, `status`, optional `headers`/`body`/`delay_ms`; a `"{{var}}"` body reads test-data at runtime). No inline `[GET /path]` forms. Rules:
188
+ - **Fail-on-unmocked (default):** once a scenario has any mock, unmatched fetch/XHR requests FAIL the test (documents/assets still load). Opt out per scenario with `@mock:passthrough`.
189
+ - **Mocks that must intercept page-load requests go in Background, BEFORE the navigation step** — installing after a navigation warns SG-W012 (load-time requests were not intercepted).
190
+ - No `abort` entry (a network error is not a timeout) — simulate timeouts with a large `delay_ms`.
191
+ - mocks.yaml is committed: credentials in bodies must be `${QA_*}` refs, never pasted DevTools responses (the secret scan covers `mock/*.yaml`).
192
+ - Web only. Requires the driver: without it, `@mock` is a hard compile error pointing at `sungen capability add mock`.
193
+
145
194
  ### Database verification (optional Data Driver)
146
195
 
147
196
  Read-only DB-state checks. **Prefer named queries** — SQL lives in `qa/screens/<screen>/database/queries.yaml` (reviewed once, parameterized). Invoke with the `@query:<name>` annotation; it binds the result rows to `{{name}}`, then assert with `expect`:
@@ -55,6 +55,12 @@ When running Phase 0 for a **flow** (`qa/flows/<name>/`), check existing screen
55
55
  1. **Confirm with the user**: *"Generate selectors from the live page via Playwright MCP now?"* — offer **Yes, scan live page** / **Skip (use existing selectors.yaml)** / **Cancel**.
56
56
  {{/cap}}
57
57
  2. **Collect references**: parse the `.feature` file for every `[Reference]` element + its type (e.g. `[Submit] button`, `[Email] field`). Deduplicate.
58
+
59
+ ⛔ **Non-selector bracket refs — never hunt the DOM for these, never write a selectors.yaml entry:**
60
+ - `[KEY] in local storage …` / `[KEY] in session storage …` — a **storage key**, checked via `page.evaluate`, not an element (the key may embed `{{vars}}`, e.g. `[{{code}}_ACCESS_TOKEN]`).
61
+ - Bracket refs consumed by other non-DOM step families (`@mock` route refs) follow the same rule: if the step's grammar does not target a UI element, the bracket content is NOT a selector.
62
+ Writing a fabricated entry for these breaks the real step (the pattern parses the bracket itself) and pollutes selectors.yaml.
63
+ ✅ **Tab-order table cells ARE selector refs** — each `| [Ref] |` row under `Then User see tab order:` resolves through selectors.yaml like any element (verify them once; they are the same refs the scenario's other steps use — don't double-count them as new elements).
58
64
  3. **Ensure page selector**: if missing, ask user for URL path and write it first.
59
65
  4. **Navigate**:
60
66
  - Read `baseURL` from `playwright.config.ts`.
@@ -254,6 +254,12 @@ success toast:
254
254
 
255
255
  Use this for every toast/alert/spinner content assertion. Do NOT fall back to `type: locator, value: '[role="status"]'` — `name: null` keeps role semantics.
256
256
 
257
+ **List-member roles need no opt-out.** For `option`, `listitem` and `cell` the compiler never defaults
258
+ the name at all — those name themselves from their content, and the value being asserted is already
259
+ carried by `.filter({ hasText })`. Omit `name:` and the locator comes out as `getByRole('option')`.
260
+ Declare `name:` only when the DOM really does expose an accessible name worth filtering on; an
261
+ explicit one is always honoured.
262
+
257
263
  ### Types requiring YAML entry (no auto-infer)
258
264
 
259
265
  These types need explicit `selectors.yaml` entries: