@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
@@ -56,27 +56,34 @@ export interface MergedScenario {
56
56
  spec: SpecTest | null;
57
57
 
58
58
  /**
59
- * When/Then/Given steps with any `@extend:<name>` chain pre-resolved
60
- * against the matching `@steps:<name>` base scenario (its steps are
61
- * prepended). Use these for surfaces that show the test case text
62
- * (dashboard Steps section) so we get the canonical feature wording
63
- * without re-parsing compiled spec.ts comments.
59
+ * Feature Background steps, kept separate from `resolvedSteps` so the
60
+ * exporters render them in the Pre-condition column. Inlining them into
61
+ * Steps shifted the step numbering against the .feature source, which made
62
+ * QA's Gherkin-vs-export review a manual re-mapping exercise.
64
63
  */
65
- resolvedWhenSteps: string[];
66
- resolvedThenSteps: string[];
67
- resolvedGivenSteps: string[];
64
+ resolvedBackgroundSteps: OrderedStep[];
68
65
  /**
69
66
  * Steps section for display, in chronological order:
70
- * Background (all) → @extend base (all incl. its Then) → own up to the
71
- * first `Then`. Base's `Then` stays here because it's mid-flow setup,
72
- * not the scenario's final assertion.
67
+ * @extend base (all incl. its Then) → own given/when-bucket steps.
68
+ * Base's `Then` stays here because it's mid-flow setup, not the
69
+ * scenario's final assertion. Background is NOT included — it renders
70
+ * as Pre-condition (see `resolvedBackgroundSteps`).
73
71
  */
74
72
  resolvedSteps: OrderedStep[];
75
73
  /**
76
- * Expected results — own scenario's first `Then` onwards. Empty when the
77
- * scenario has no `Then`.
74
+ * Expected results — own scenario's then-bucket steps (a `When` after a
75
+ * `Then` starts a new action and goes back to Steps; an `And` follows its
76
+ * preceding explicit keyword). Empty when the scenario has no `Then`.
78
77
  */
79
78
  resolvedExpected: OrderedStep[];
79
+ /**
80
+ * Count of anchor-eligible action lines (@extend base + own given/when
81
+ * steps). This — NOT the rendered Steps length — is the reference for the
82
+ * "is any assertion mid-flow?" check in groupExpectedByAnchor: manual-comment
83
+ * steps are appended to resolvedSteps but never participate in anchoring,
84
+ * so counting them would falsely mark @manual scenarios as interleaved.
85
+ */
86
+ resolvedAnchorActionCount: number;
80
87
  }
81
88
 
82
89
  /**
@@ -98,20 +105,40 @@ export function mergeFeatureAndSpec(
98
105
  if (isStepsBaseScenario(scenario)) continue; // skip @steps base scenarios
99
106
  if (isSampleScaffoldScenario(scenario)) continue; // skip default sample
100
107
 
101
- const specTest = findMatchingSpecTest(scenario, spec.tests);
108
+ const specTest = findMatchingSpecTest(scenario, spec.tests, feature.scenarios);
102
109
  const base = scenario.extendsName ? stepsBaseByName.get(scenario.extendsName) : undefined;
103
110
 
104
111
  // Order of execution for a runnable scenario:
105
- // 1. Feature Background (once) — all buckets are setup, stay in Steps.
112
+ // 1. Feature Background (once) — setup state, rendered as Pre-condition
113
+ // so Steps numbering stays aligned with the .feature scenario.
106
114
  // 2. @extend base scenario's own steps (if any) — *including* its Then,
107
115
  // because the base's Then is a mid-flow assertion, not the scenario's
108
116
  // final verification.
109
- // 3. This scenario's own steps in original sequence, split at the
110
- // first `Then`: pre-Then → Steps, Then-onwards → Expected results.
117
+ // 3. This scenario's own steps, partitioned by BUCKET (not by position):
118
+ // given/when → Steps, then → Expected results. A positional split at
119
+ // the first `Then` broke interleaved scenarios (When → Then → When →
120
+ // Then …): every later `When` action was exported as an "expected
121
+ // result". classifySteps already resolves `And` to its preceding
122
+ // explicit keyword, so an `And` after `Then` stays an assertion and
123
+ // an `And` after `When` stays an action. Relative order is preserved
124
+ // within each column.
125
+ // Each assertion also records its ANCHOR — the 1-based index of the
126
+ // last action before it in the rendered Steps column (@extend base
127
+ // steps count toward the numbering; Background does not, it renders
128
+ // as Pre-condition). The exporters use anchors to append
129
+ // "— after step N" when a scenario interleaves actions and
130
+ // assertions, restoring the correspondence the 2-column layout loses.
111
131
  const ownOrdered = scenario.orderedSteps;
112
- const firstThenIdx = ownOrdered.findIndex((s) => s.bucket === 'then');
113
- const ownPreThen = firstThenIdx >= 0 ? ownOrdered.slice(0, firstThenIdx) : ownOrdered;
114
- const ownPostThen = firstThenIdx >= 0 ? ownOrdered.slice(firstThenIdx) : [];
132
+ const baseLen = base?.orderedSteps.length ?? 0;
133
+ const ownActions: OrderedStep[] = [];
134
+ const ownAssertions: OrderedStep[] = [];
135
+ for (const s of ownOrdered) {
136
+ if (s.bucket === 'then') {
137
+ ownAssertions.push({ ...s, anchorStep: baseLen + ownActions.length });
138
+ } else {
139
+ ownActions.push(s);
140
+ }
141
+ }
115
142
 
116
143
  // A @manual scenario carries no Gherkin steps — its tester procedure lives in the
117
144
  // `# Tester verifies:` comment block. Parse it into Steps (Setup/Action) + Expected
@@ -125,49 +152,60 @@ export function mergeFeatureAndSpec(
125
152
  result.push({
126
153
  feature: scenario,
127
154
  spec: specTest,
128
- resolvedWhenSteps: [
129
- ...feature.backgroundWhenSteps,
130
- ...(base?.rawWhenSteps ?? []),
131
- ...scenario.rawWhenSteps,
132
- ],
133
- resolvedThenSteps: [
134
- ...feature.backgroundThenSteps,
135
- ...(base?.rawThenSteps ?? []),
136
- ...scenario.rawThenSteps,
137
- ],
138
- resolvedGivenSteps: [
139
- ...feature.backgroundGivenSteps,
140
- ...(base?.rawGivenSteps ?? []),
141
- ...scenario.rawGivenSteps,
142
- ],
155
+ resolvedBackgroundSteps: feature.backgroundOrderedSteps,
143
156
  resolvedSteps: [
144
- ...feature.backgroundOrderedSteps,
145
157
  ...(base?.orderedSteps ?? []),
146
- ...ownPreThen,
158
+ ...ownActions,
147
159
  ...manualParsed.steps,
148
160
  ],
149
- resolvedExpected: [...ownPostThen, ...manualParsed.expected],
161
+ resolvedExpected: [...ownAssertions, ...manualParsed.expected],
162
+ resolvedAnchorActionCount: baseLen + ownActions.length,
150
163
  });
151
164
  }
152
165
 
153
166
  return result;
154
167
  }
155
168
 
169
+ /** `@manual` / `@manual:<reason>` — a manual scenario has no automated test by definition. */
170
+ function isManualScenario(scenario: ScenarioMetadata): boolean {
171
+ return scenario.tags.some((t) => t === '@manual' || t.startsWith('@manual:'));
172
+ }
173
+
156
174
  /**
157
175
  * Find the spec test that corresponds to a given feature scenario.
158
176
  * Match by scenario title (exact match on the scenario name).
177
+ *
178
+ * The VP-ID fallback exists only to survive small TITLE drift between compile
179
+ * and export — it must never RE-ASSIGN a result to a different scenario. Field
180
+ * report: a feature carried the same VP id on many scenarios (VP-NAV-001 ×14,
181
+ * mixing @manual and automated); the old first-match fallback attached an
182
+ * automated sibling's executed result to every @manual sibling, so 7 testcases
183
+ * nobody ever ran shipped as "Passed" in the client deliverable. Two guards:
184
+ * 1. a @manual scenario never matches ANY test;
185
+ * 2. the fallback applies only when the vpId is UNIQUE on both sides —
186
+ * exactly one test AND exactly one automatable scenario carry it.
187
+ * Ambiguity → null → the row reports an honest "Pending".
159
188
  */
160
- function findMatchingSpecTest(scenario: ScenarioMetadata, tests: SpecTest[]): SpecTest | null {
189
+ function findMatchingSpecTest(
190
+ scenario: ScenarioMetadata,
191
+ tests: SpecTest[],
192
+ allScenarios: ScenarioMetadata[],
193
+ ): SpecTest | null {
194
+ if (isManualScenario(scenario)) return null;
195
+
161
196
  // Exact title match
162
197
  const exact = tests.find((t) => t.scenarioName === scenario.name);
163
198
  if (exact) return exact;
164
199
 
165
- // Fallback: match by VP-ID prefix (ignore trailing text differences)
200
+ // Fallback: match by VP-ID prefix only when unique both ways (see above).
166
201
  const vpMatch = scenario.name.match(SCENARIO_ID);
167
202
  if (vpMatch) {
168
203
  const vpId = vpMatch[1];
169
- const byVp = tests.find((t) => t.vpId === vpId);
170
- if (byVp) return byVp;
204
+ const testsWithVp = tests.filter((t) => t.vpId === vpId);
205
+ const scenariosWithVp = allScenarios.filter(
206
+ (s) => !isManualScenario(s) && s.name.match(SCENARIO_ID)?.[1] === vpId,
207
+ );
208
+ if (testsWithVp.length === 1 && scenariosWithVp.length === 1) return testsWithVp[0];
171
209
  }
172
210
 
173
211
  return null;
@@ -1,25 +1,10 @@
1
1
  /**
2
- * Convert raw Gherkin step text OR generated .spec.ts comment lines into
3
- * concise, numbered natural language for the CSV Steps/Expected columns.
2
+ * Convert raw Gherkin step text into concise natural language for the
3
+ * CSV Steps/Expected columns.
4
4
  *
5
5
  * Deterministic — no AI calls.
6
6
  */
7
7
 
8
- /**
9
- * Format a list of raw step lines for the Steps / Expected Results columns.
10
- *
11
- * - 0 lines → empty string
12
- * - 1 line → the line verbatim (no "1. " prefix — avoids Google Sheets clutter)
13
- * - ≥ 2 → each line on its own row, "1. ", "2. ", … prefixes, newline-joined
14
- * so spreadsheet cells auto-wrap one step per visual line.
15
- */
16
- export function formatNumberedSteps(lines: string[]): string {
17
- const cleaned = lines.map(cleanStepLine).filter((l) => l.length > 0);
18
- if (cleaned.length === 0) return '';
19
- if (cleaned.length === 1) return cleaned[0];
20
- return cleaned.map((line, idx) => `${idx + 1}. ${line}`).join('\n');
21
- }
22
-
23
8
  /**
24
9
  * Strip the leading actor (`User `) from a raw Gherkin step and capitalise
25
10
  * the first letter. Brackets `[ref]`, `{{var}}` placeholders and element
@@ -42,64 +27,80 @@ export function keepActor(raw: string): string {
42
27
  }
43
28
 
44
29
  /**
45
- * Number a list of raw feature steps. Mirrors `formatNumberedSteps` but
46
- * preserves the original Gherkin punctuation (no bracket-to-quote conversion).
47
- * The mapper picks between `stripActor` (When/Then) and `keepActor`
48
- * (Given) so the actor word matches the step's grammatical role.
49
- * Use this for feature-direct rendering (dashboard + delivery).
50
- */
51
- export function formatFeatureSteps(lines: string[], mapper: (s: string) => string = stripActor): string {
52
- const cleaned = lines.map(mapper).filter((l) => l.length > 0);
53
- if (cleaned.length === 0) return '';
54
- if (cleaned.length === 1) return cleaned[0];
55
- return cleaned.map((line, idx) => `${idx + 1}. ${line}`).join('\n');
56
- }
57
-
58
- /**
59
- * Clean a raw step line (either Gherkin text or .spec.ts comment).
30
+ * Group formatted Expected-results lines by their anchor step.
60
31
  *
61
- * Handles patterns like:
62
- * "User click [login] button" → "Click 'login' button"
63
- * "User fill [Email] field with {{email}}" → "Fill 'Email' field with {{email}}"
64
- * "Click Signature 2025 Creator" → "Click 'Signature 2025 Creator'"
65
- * "Assert title error has text err_xxx" → "Title error has text err_xxx"
66
- * "Open awards page" → "Open awards page"
32
+ * Single source of truth for the anchor rendering (used by csv-exporter AND
33
+ * json-exporter so the delivery sheet and the dashboard cannot drift). Final
34
+ * format per user review 2026-08-06:
35
+ *
36
+ * - When assertions verify DIFFERENT moments (more than one distinct
37
+ * anchor, or the single anchor is not the final action), the cell is
38
+ * rendered as groups — a "— after step N" header line (anchor 0 →
39
+ * "— before step 1") followed by that group's assertions. Within a
40
+ * group, lines are numbered 1..k only when the group has more than one
41
+ * assertion; a lone assertion stays bare.
42
+ * - When ALL assertions verify the final action (the common shape — every
43
+ * Then after the last When), no headers appear and the caller renders
44
+ * the plain 1..M checklist exactly as before the anchor feature.
45
+ * - Pure-assertion scenarios (actionCount 0) and manual-comment entries
46
+ * (no anchor) never produce headers; unanchored entries trail as their
47
+ * own headerless group.
48
+ *
49
+ * `grouped` tells the caller headers were emitted, so it can force-number
50
+ * the Steps cell even when it has a single line (headers reference step
51
+ * numbers QA must be able to see) and must join the lines verbatim instead
52
+ * of applying checklist numbering.
53
+ *
54
+ * Anchors are computed against the merger's action list; formatted gherkin
55
+ * lines are never empty (keepActor/stripActor on non-empty text), so the
56
+ * rendered numbering cannot drift from the anchor indices.
67
57
  */
68
- export function cleanStepLine(raw: string): string {
69
- let s = raw.trim();
70
-
71
- // Strip leading "User " actor
72
- s = s.replace(/^User\s+/i, '');
73
-
74
- // Strip Gherkin-style element decoration "[Ref] type" → "'Ref' type"
75
- // e.g., "click [Login] button" → "click 'Login' button"
76
- s = s.replace(/\[([^\]]+)\]/g, "'$1'");
77
-
78
- // Collapse multiple spaces
79
- s = s.replace(/\s+/g, ' ');
80
-
81
- // For .spec.ts comments that start with "Assert ..." keep as-is (they read naturally)
82
- // For action comments like "Click X", "Fill X with Y" keep as-is.
83
-
84
- // Capitalize first letter
85
- if (s.length > 0) s = s.charAt(0).toUpperCase() + s.slice(1);
58
+ export function groupExpectedByAnchor(
59
+ entries: Array<{ text: string; anchorStep?: number }>,
60
+ actionCount: number
61
+ ): { lines: string[]; grouped: boolean } {
62
+ const anchors = entries
63
+ .filter((e) => e.anchorStep !== undefined)
64
+ .map((e) => e.anchorStep as number);
65
+ const distinct = new Set(anchors);
66
+ const grouped =
67
+ distinct.size > 1 || (distinct.size === 1 && anchors[0] !== actionCount);
68
+ if (!grouped) return { lines: entries.map((e) => e.text), grouped };
86
69
 
87
- return s;
70
+ const lines: string[] = [];
71
+ let i = 0;
72
+ while (i < entries.length) {
73
+ const anchor = entries[i].anchorStep;
74
+ const group: string[] = [];
75
+ while (i < entries.length && entries[i].anchorStep === anchor) {
76
+ group.push(entries[i].text);
77
+ i++;
78
+ }
79
+ if (anchor !== undefined) {
80
+ lines.push(anchor === 0 ? '— before step 1' : `— after step ${anchor}`);
81
+ }
82
+ if (group.length === 1) lines.push(group[0]);
83
+ else group.forEach((t, k) => lines.push(`${k + 1}. ${t}`));
84
+ }
85
+ return { lines, grouped };
88
86
  }
89
87
 
90
88
  /**
91
- * Pre-condition — login/auth context only.
89
+ * Pre-condition — login/auth context + feature Background steps.
92
90
  *
93
91
  * `@auth:user` → `Logged in as user`
94
92
  * `@no-auth` → `Not authenticated`
95
- * (no auth tag) → empty string
93
+ * (no auth tag) → no auth line
96
94
  *
97
- * Background and Given steps are no longer echoed here — they're rendered as
98
- * the first entries in the Steps list so the chain of actions reads as one
99
- * uninterrupted sequence.
95
+ * `backgroundLines` (formatted feature Background steps) follow the auth
96
+ * line, one per line. Background belongs here rather than in Steps: inlining
97
+ * it shifted the Steps numbering against the .feature scenario, forcing QA
98
+ * to re-map step numbers between Gherkin and the exported sheet.
100
99
  */
101
- export function formatPrecondition(authRole: string | null, _givenLines: string[] = []): string {
102
- if (authRole === 'no-auth') return 'Not authenticated';
103
- if (authRole) return `Logged in as ${authRole}`;
104
- return '';
100
+ export function formatPrecondition(authRole: string | null, backgroundLines: string[] = []): string {
101
+ const lines: string[] = [];
102
+ if (authRole === 'no-auth') lines.push('Not authenticated');
103
+ else if (authRole) lines.push(`Logged in as ${authRole}`);
104
+ lines.push(...backgroundLines.filter((l) => l.length > 0));
105
+ return lines.join('\n');
105
106
  }
@@ -59,11 +59,8 @@ export interface FeatureMetadata {
59
59
  featureName: string; // e.g., "Create Kudo Modal"
60
60
  featurePath?: string; // e.g., "/kudos"
61
61
  featureTags: string[]; // Feature-level tags
62
- /** Background block classified by bucket. Shared across all runnable
63
- * scenarios — the merger is responsible for prepending these. */
64
- backgroundGivenSteps: string[];
65
- backgroundWhenSteps: string[];
66
- backgroundThenSteps: string[];
62
+ /** Background block in chronological order. Shared across all runnable
63
+ * scenarios — the merger surfaces it as `resolvedBackgroundSteps`. */
67
64
  backgroundOrderedSteps: OrderedStep[];
68
65
  scenarios: ScenarioMetadata[];
69
66
  }
@@ -71,6 +68,13 @@ export interface FeatureMetadata {
71
68
  export interface OrderedStep {
72
69
  text: string;
73
70
  bucket: 'given' | 'when' | 'then';
71
+ /**
72
+ * For then-bucket entries in `MergedScenario.resolvedExpected`: 1-based index
73
+ * of the last action line (in the rendered Steps column) this assertion
74
+ * verifies; 0 when the assertion precedes any action. Set by
75
+ * mergeFeatureAndSpec — absent on action steps and manual-comment entries.
76
+ */
77
+ anchorStep?: number;
74
78
  }
75
79
 
76
80
  export interface ScenarioMetadata {
@@ -79,9 +83,6 @@ export interface ScenarioMetadata {
79
83
  stepsName?: string; // @steps:<name>
80
84
  extendsName?: string; // @extend:<name>
81
85
  referencedVars: string[]; // Variables {{var}} used in this scenario
82
- rawGivenSteps: string[]; // Raw Given/And-after-Given step text
83
- rawWhenSteps: string[]; // Raw When/And-after-When step text
84
- rawThenSteps: string[]; // Raw Then/And-after-Then step text
85
86
  /**
86
87
  * Steps in original chronological order with each step labelled by its
87
88
  * effective bucket (And/But inherits from the prior explicit keyword).
@@ -67,7 +67,7 @@ export interface TestGeneratorAdapter {
67
67
  // Template rendering methods
68
68
  renderTestFile(data: TestFileData): string;
69
69
  renderScenario(data: ScenarioData): string;
70
- renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean }): string;
70
+ renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string;
71
71
  renderBeforeEach(data: { steps: Array<{ comment?: string; code: string }> }): string;
72
72
  renderBeforeAll(data: { steps: Array<{ comment?: string; code: string }> }): string;
73
73
  renderAfterEach(data: { steps: Array<{ comment?: string; code: string }> }): string;
@@ -31,7 +31,7 @@ export class AppiumAdapter implements TestGeneratorAdapter {
31
31
  return this.templateEngine.renderScenario(data);
32
32
  }
33
33
 
34
- renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean; needsDb?: boolean; needsApi?: boolean }): string {
34
+ renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string {
35
35
  return this.templateEngine.renderImports(options);
36
36
  }
37
37
 
@@ -1,2 +1,4 @@
1
1
  {{!-- Global key event (no target). On Android, Escape maps to the system Back button. --}}
2
- {{#if (eq key 'Escape')}}await driver.back();{{else}}await driver.keys('{{key}}');{{/if}}
2
+ {{#if repeat}}for (let i = 0; i < {{repeat}}; i++) {
3
+ {{#if (eq key 'Escape')}}await driver.back();{{else}}await driver.keys('{{key}}');{{/if}}
4
+ }{{else}}{{#if (eq key 'Escape')}}await driver.back();{{else}}await driver.keys('{{key}}');{{/if}}{{/if}}
@@ -26,7 +26,7 @@ export class PlaywrightAdapter implements TestGeneratorAdapter {
26
26
  return this.templateEngine.renderScenario(data);
27
27
  }
28
28
 
29
- renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean }): string {
29
+ renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string {
30
30
  return this.templateEngine.renderImports(options);
31
31
  }
32
32
 
@@ -9,6 +9,9 @@ import { db } from '{{basePath}}/db';
9
9
  {{#if needsApi}}
10
10
  import { api } from '{{basePath}}/api';
11
11
  {{/if}}
12
+ {{#if needsMock}}
13
+ import { mock } from '{{basePath}}/mock';
14
+ {{/if}}
12
15
 
13
16
  // This file is auto-generated from Gherkin feature files
14
17
  // DO NOT EDIT MANUALLY - changes will be overwritten
@@ -1 +1,3 @@
1
- await page.keyboard.press('{{key}}');
1
+ {{#if repeat}}for (let i = 0; i < {{repeat}}; i++) {
2
+ await page.keyboard.press('{{key}}');
3
+ }{{else}}await page.keyboard.press('{{key}}');{{/if}}
@@ -1 +1 @@
1
- await expect(page).toHaveURL(/{{pathRegex}}/);
1
+ {{> url-assertion}}
@@ -0,0 +1,4 @@
1
+ {
2
+ const ok = await page.evaluate(({ k, arg }) => {{{checkExpr}}}, { k: {{{keyExpr}}}, arg: {{{argExpr}}} });
3
+ expect(ok, {{{messageExpr}}}).toBe(true);
4
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ const __stops_{{stepCounter}} = [
3
+ {{#each cells}}
4
+ { label: '{{escapeQuotes label}}', loc: {{> locator}} },
5
+ {{/each}}
6
+ ];
7
+ const __focusReport_{{stepCounter}} = () => page.evaluate(() => {
8
+ let el = document.activeElement;
9
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
10
+ if (!el || el === document.body) return 'document.body (focus left the page content)';
11
+ const role = el.getAttribute('role') ? `[role=${el.getAttribute('role')}]` : '';
12
+ const name = el.getAttribute('aria-label') || el.getAttribute('name') || el.id || '';
13
+ return `${el.tagName.toLowerCase()}${role}${name ? ` (${name})` : ''}`;
14
+ });
15
+ const __firstLine_{{stepCounter}} = (e: unknown) => (e instanceof Error ? ` (${e.message.split('\n')[0]})` : '');
16
+ await __stops_{{stepCounter}}[0].loc.focus();
17
+ try {
18
+ await expect(__stops_{{stepCounter}}[0].loc, `tab order origin: expected [${__stops_{{stepCounter}}[0].label}] to hold focus`).toBeFocused({ timeout: 2000 });
19
+ } catch (err) {
20
+ throw new Error(`Tab order origin not focused: [${__stops_{{stepCounter}}[0].label}] did not take focus (is it focusable?), actual focus: ${await __focusReport_{{stepCounter}}()}${__firstLine_{{stepCounter}}(err)}`);
21
+ }
22
+ for (let i = 1; i < __stops_{{stepCounter}}.length; i++) {
23
+ await page.keyboard.press('Tab');
24
+ try {
25
+ await expect(__stops_{{stepCounter}}[i].loc, `tab order position ${i + 1}: expected [${__stops_{{stepCounter}}[i].label}]`).toBeFocused({ timeout: 2000 });
26
+ } catch (err) {
27
+ throw new Error(`Tab order mismatch at position ${i + 1}: expected [${__stops_{{stepCounter}}[i].label}], actual focus: ${await __focusReport_{{stepCounter}}()}${__firstLine_{{stepCounter}}(err)}`);
28
+ }
29
+ }
30
+ }
@@ -1,4 +1 @@
1
- await expect(page).toHaveURL(/{{escapeRegex path}}/);
2
- {{#if query}}
3
- expect(page.url()).toContain('{{escapeQuotes query}}');
4
- {{/if}}
1
+ {{> url-assertion}}
@@ -1 +1 @@
1
- {{#if dialogRoleName}}page.getByRole('dialog', { name: '{{escapeQuotes dialogRoleName}}' }){{else if dialogFilterText}}page.getByRole('dialog').filter({ hasText: '{{escapeQuotes dialogFilterText}}' }){{else}}page.getByRole('dialog'){{/if}}
1
+ {{#if dialogRootExpr}}{{{dialogRootExpr}}}{{#if dialogFilterText}}.filter({ hasText: '{{escapeQuotes dialogFilterText}}' }){{/if}}{{else if dialogRoleName}}page.getByRole('dialog', { name: '{{escapeQuotes dialogRoleName}}' }){{else if dialogFilterText}}page.getByRole('dialog').filter({ hasText: '{{escapeQuotes dialogFilterText}}' }){{else}}page.getByRole('dialog'){{/if}}
@@ -0,0 +1,10 @@
1
+ {{!-- The ONE shape for every Then-side URL assertion (page-assertion + route-assertion).
2
+ Both used to carry their own copy and drifted apart: `see [X] page` wildcarded a dynamic
3
+ `:id` segment but silently dropped `with {{v}}`, while `is on [X] page` asserted the query
4
+ but escaped `:id` literally into a regex that can never match. One partial, one behaviour.
5
+ `pathRegex` is pre-escaped by pathToRegexSource (`:id` → `[^/]+`); the query is asserted
6
+ separately because its value is runtime data full of regex-special characters. --}}
7
+ await expect(page).toHaveURL(/{{pathRegex}}/);
8
+ {{#if query}}
9
+ expect(page.url()).toContain('{{escapeQuotes query}}');
10
+ {{/if}}
@@ -311,11 +311,12 @@ export class CodeGenerator {
311
311
  const activeCapabilityIds = this.activeCapabilityIds(feature);
312
312
  const needsDb = activeCapabilityIds.includes('db');
313
313
  const needsApi = activeCapabilityIds.includes('api');
314
+ const needsMock = activeCapabilityIds.includes('mock');
314
315
  for (const id of activeCapabilityIds) {
315
316
  for (const h of capabilityRegistry.get(id)?.runtimeHelpers ?? []) this.syncGeneratedHelper(outputDir, h.file, h.template);
316
317
  }
317
318
 
318
- const imports = this.adapter.renderImports({ runtimeData: this.options.runtimeData, basePath, needsCleanupImport, needsDb, needsApi });
319
+ const imports = this.adapter.renderImports({ runtimeData: this.options.runtimeData, basePath, needsCleanupImport, needsDb, needsApi, needsMock });
319
320
 
320
321
  // Generate test code (async now to support AI mapping)
321
322
  const testCode = await this.generateTestCode(feature);
@@ -324,7 +325,7 @@ export class CodeGenerator {
324
325
  const raw = `${imports}\n\n${testCode}`;
325
326
  let code = raw.replace(/\n{3,}/g, '\n\n');
326
327
 
327
- // Runtime data: replace __SUNGEN_TD_ markers with testData.get() calls
328
+ // Runtime data: replace __SUNGEN_TD$…$__ markers with testData.get() calls
328
329
  if (this.options.runtimeData) {
329
330
  code = transformToRuntimeData(code);
330
331
  }
@@ -382,15 +383,40 @@ export class CodeGenerator {
382
383
  const steps: ParsedStep[] = [];
383
384
  if (feature.background?.steps) steps.push(...feature.background.steps);
384
385
  for (const sc of feature.scenarios || []) if (sc.steps) steps.push(...sc.steps);
385
- const scenarioTags = (feature.scenarios || []).flatMap((sc) => sc.tags || []);
386
+ // FEATURE-level tags count too: a feature-wide `@mock` must sync the helper and
387
+ // emit the import exactly like a per-scenario tag — reading scenario tags only
388
+ // left patterns matching while the helper/import never arrived, generating a
389
+ // spec that does not compile.
390
+ const tagPool = [
391
+ ...(feature.tags || []),
392
+ ...(feature.scenarios || []).flatMap((sc) => sc.tags || []),
393
+ ];
386
394
  const ids = new Set<string>();
387
395
  const def = capabilityRegistry.defaultCapabilityId();
388
396
  if (def) ids.add(def);
389
397
  for (const cap of capabilityRegistry.all()) {
390
- const annoMatch = (cap.annotations ?? []).some((a) => scenarioTags.some((t) => t === a || t.startsWith(a + ':')));
398
+ const annoMatch = (cap.annotations ?? []).some((a) => tagPool.some((t) => t === a || t.startsWith(a + ':')));
391
399
  const stepMatch = cap.detectsStep ? steps.some((s) => s && typeof s.text === 'string' && cap.detectsStep!(s.text)) : false;
392
400
  if (annoMatch || stepMatch) ids.add(cap.id);
393
401
  }
402
+
403
+ // Opt-in driver ABSENT while the feature clearly wants it = hard error. Discovery
404
+ // swallows a missing driver silently and unmatched steps become warn-only TODOs —
405
+ // a "mocked" scenario would then run green against the REAL backend.
406
+ const wantsMock =
407
+ tagPool.some((t) => t === '@mock' || t.startsWith('@mock:') || t === '@network') ||
408
+ steps.some((s) => s && typeof s.text === 'string' && /\[[^\]]+\]\s+mock\s+(?:is\s+active|called)\b/i.test(s.text));
409
+ if (wantsMock && !capabilityRegistry.get('mock')) {
410
+ throw new Error(
411
+ `feature "${feature.name}" uses network mocking (@mock / mock steps) but the mock capability is not installed — run: sungen capability add mock`);
412
+ }
413
+ // Web-only: the runtime rides Playwright's page.route. Failing here beats a
414
+ // confusing "Cannot find name 'mock'" tsc error in the generated appium spec.
415
+ if (wantsMock && this.adapter.frameworkName === 'appium') {
416
+ throw new Error(
417
+ `feature "${feature.name}" uses network mocking (@mock / mock steps), which is web-only — mobile drivers have no request-interception seam. Keep these scenarios @manual on mobile.`);
418
+ }
419
+
394
420
  return [...ids];
395
421
  }
396
422
 
@@ -525,10 +551,33 @@ export class CodeGenerator {
525
551
  // tab`) is silently dropped and every scenario runs on the launcher. So branch on isParallel only
526
552
  // for web; for appium always use generateBackgroundSteps().
527
553
  const isMobile = this.adapter.frameworkName === 'appium';
554
+ const mockActive = !isMobile && this.activeCapabilityIds(feature).includes('mock');
528
555
  if (isParallel && !isMobile) {
529
- background = await this.generateBeforeEach(feature.background);
556
+ background = await this.generateBeforeEach(
557
+ feature.background,
558
+ // The passthrough flag must be armed BEFORE the Background installs + goto —
559
+ // a scenario-body statement runs too late: boot-time requests of an opted-out
560
+ // scenario would be aborted and recorded as violations. beforeEach reads the
561
+ // scenario's own tags via test.info().
562
+ mockActive
563
+ ? [{
564
+ comment: '@mock:passthrough scenarios opt out before any mock installs',
565
+ code: this.indentCode("if (test.info().tags.includes('@mock:passthrough')) mock.setPassthrough(page);", 4),
566
+ }]
567
+ : [],
568
+ );
530
569
  } else {
531
570
  backgroundSteps = await this.generateBackgroundSteps(feature.background);
571
+ // @serial: Background (beforeAll) mocks + the guard must survive each scenario's
572
+ // scoped reset — snapshot them so reset() removes only scenario-added routes.
573
+ // Without this, scenario 1's reset kills the Background mocks and scenarios 2+
574
+ // run against the REAL backend with no guard.
575
+ if (mockActive && !isMobile) {
576
+ backgroundSteps.push({
577
+ comment: 'Background mocks persist across the @serial scenarios',
578
+ code: this.indentCode('await mock.snapshotBackground(page);', 4),
579
+ });
580
+ }
532
581
  }
533
582
  }
534
583
 
@@ -736,9 +785,12 @@ export class CodeGenerator {
736
785
  return steps;
737
786
  }
738
787
 
739
- private async generateBeforeEach(background: ParsedScenario): Promise<string> {
788
+ private async generateBeforeEach(
789
+ background: ParsedScenario,
790
+ preambleSteps: Array<{ comment?: string; code: string }> = [],
791
+ ): Promise<string> {
740
792
  // Map all steps
741
- const steps: Array<{ comment?: string; code: string }> = [];
793
+ const steps: Array<{ comment?: string; code: string }> = [...preambleSteps];
742
794
  for (const step of background.steps) {
743
795
  const mapped = await Promise.resolve(this.stepMapper.mapStep(step));
744
796
  steps.push({
@@ -885,6 +937,29 @@ export class CodeGenerator {
885
937
  steps.unshift(...preconditions.map((p) => ({ comment: p.comment, code: this.indentCode(p.code, 4) })));
886
938
  }
887
939
 
940
+ // Mock scenario epilogue. (a) EVERY path: the fail-on-unmocked verdict — the guard
941
+ // records+aborts unmatched requests (it must not throw inside a route handler:
942
+ // that surfaces as an unhandled rejection), so the scenario ends by asserting the
943
+ // violation list is empty. (b) @serial shared page only: a scoped reset so this
944
+ // scenario's routes/counters don't leak into the next (the router unroutes only
945
+ // what IT installed — never page.unrouteAll(), which would also kill Background
946
+ // mocks and third-party routes; the per-test path gets that free at page close).
947
+ const usesMock =
948
+ [...(featureTags || []), ...(scenario.tags || [])].some((t) => t === '@mock' || t.startsWith('@mock:') || t === '@network') ||
949
+ stepsToMap.some((s) => /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(s.text || ''));
950
+ if (usesMock && this.adapter.frameworkName !== 'appium' && capabilityRegistry.get('mock')) {
951
+ steps.push({
952
+ comment: 'Fail-on-unmocked verdict: any unmatched request aborted by the guard fails here',
953
+ code: this.indentCode('await mock.assertNoUnmocked(page);', 4),
954
+ });
955
+ if (!isParallel) {
956
+ steps.push({
957
+ comment: 'Reset mock routes and counters (shared @serial page)',
958
+ code: this.indentCode('await mock.reset(page);', 4),
959
+ });
960
+ }
961
+ }
962
+
888
963
  // Extract pass-through tags (feature + scenario, excluding functional tags)
889
964
  const tags = extractPassThroughTags(scenario.tags, featureTags);
890
965