@sun-asterisk/sungen 3.2.18 → 3.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/dist/capabilities/discover.d.ts.map +1 -1
  2. package/dist/capabilities/discover.js +4 -1
  3. package/dist/capabilities/discover.js.map +1 -1
  4. package/dist/cli/commands/add.d.ts.map +1 -1
  5. package/dist/cli/commands/add.js +4 -2
  6. package/dist/cli/commands/add.js.map +1 -1
  7. package/dist/cli/commands/dashboard.d.ts.map +1 -1
  8. package/dist/cli/commands/dashboard.js +10 -1
  9. package/dist/cli/commands/dashboard.js.map +1 -1
  10. package/dist/cli/commands/delivery.d.ts.map +1 -1
  11. package/dist/cli/commands/delivery.js +19 -1
  12. package/dist/cli/commands/delivery.js.map +1 -1
  13. package/dist/cli/commands/generate.d.ts.map +1 -1
  14. package/dist/cli/commands/generate.js +10 -1
  15. package/dist/cli/commands/generate.js.map +1 -1
  16. package/dist/dashboard/html-renderer.d.ts.map +1 -1
  17. package/dist/dashboard/html-renderer.js +7 -2
  18. package/dist/dashboard/html-renderer.js.map +1 -1
  19. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  20. package/dist/dashboard/snapshot-builder.js +3 -0
  21. package/dist/dashboard/snapshot-builder.js.map +1 -1
  22. package/dist/dashboard/types.d.ts +4 -0
  23. package/dist/dashboard/types.d.ts.map +1 -1
  24. package/dist/exporters/api-environment.d.ts.map +1 -1
  25. package/dist/exporters/api-environment.js +4 -24
  26. package/dist/exporters/api-environment.js.map +1 -1
  27. package/dist/exporters/api-testcase-formatter.d.ts +6 -0
  28. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
  29. package/dist/exporters/api-testcase-formatter.js +11 -4
  30. package/dist/exporters/api-testcase-formatter.js.map +1 -1
  31. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  32. package/dist/exporters/csv-exporter.js +40 -9
  33. package/dist/exporters/csv-exporter.js.map +1 -1
  34. package/dist/exporters/env-qa.d.ts +13 -0
  35. package/dist/exporters/env-qa.d.ts.map +1 -0
  36. package/dist/exporters/env-qa.js +82 -0
  37. package/dist/exporters/env-qa.js.map +1 -0
  38. package/dist/exporters/feature-parser.d.ts.map +1 -1
  39. package/dist/exporters/feature-parser.js +16 -33
  40. package/dist/exporters/feature-parser.js.map +1 -1
  41. package/dist/exporters/json-exporter.d.ts.map +1 -1
  42. package/dist/exporters/json-exporter.js +57 -20
  43. package/dist/exporters/json-exporter.js.map +1 -1
  44. package/dist/exporters/matrix/build.d.ts.map +1 -1
  45. package/dist/exporters/matrix/build.js +15 -6
  46. package/dist/exporters/matrix/build.js.map +1 -1
  47. package/dist/exporters/matrix/gates.d.ts.map +1 -1
  48. package/dist/exporters/matrix/gates.js +3 -2
  49. package/dist/exporters/matrix/gates.js.map +1 -1
  50. package/dist/exporters/matrix/step-buckets.d.ts +13 -0
  51. package/dist/exporters/matrix/step-buckets.d.ts.map +1 -0
  52. package/dist/exporters/matrix/step-buckets.js +7 -0
  53. package/dist/exporters/matrix/step-buckets.js.map +1 -0
  54. package/dist/exporters/qa-env-secret-denylist.d.ts +43 -0
  55. package/dist/exporters/qa-env-secret-denylist.d.ts.map +1 -0
  56. package/dist/exporters/qa-env-secret-denylist.js +143 -0
  57. package/dist/exporters/qa-env-secret-denylist.js.map +1 -0
  58. package/dist/exporters/scenario-merger.d.ts +20 -13
  59. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  60. package/dist/exporters/scenario-merger.js +55 -30
  61. package/dist/exporters/scenario-merger.js.map +1 -1
  62. package/dist/exporters/step-formatter.d.ts +42 -33
  63. package/dist/exporters/step-formatter.d.ts.map +1 -1
  64. package/dist/exporters/step-formatter.js +68 -68
  65. package/dist/exporters/step-formatter.js.map +1 -1
  66. package/dist/exporters/test-data-resolver.d.ts +15 -0
  67. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  68. package/dist/exporters/test-data-resolver.js +34 -1
  69. package/dist/exporters/test-data-resolver.js.map +1 -1
  70. package/dist/exporters/types.d.ts +9 -8
  71. package/dist/exporters/types.d.ts.map +1 -1
  72. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +2 -0
  73. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  74. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +2 -0
  75. package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
  76. package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
  77. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  78. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +2 -0
  79. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  80. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  81. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +6 -0
  82. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  83. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  84. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  85. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  86. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
  87. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  88. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  89. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +15 -0
  90. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  91. package/dist/generators/test-generator/code-generator.js +78 -7
  92. package/dist/generators/test-generator/code-generator.js.map +1 -1
  93. package/dist/generators/test-generator/diagnostics.d.ts +16 -1
  94. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  95. package/dist/generators/test-generator/diagnostics.js +15 -0
  96. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  97. package/dist/generators/test-generator/recipe-expander.d.ts +1 -1
  98. package/dist/generators/test-generator/recipe-expander.d.ts.map +1 -1
  99. package/dist/generators/test-generator/recipe-expander.js +1 -1
  100. package/dist/generators/test-generator/recipe-expander.js.map +1 -1
  101. package/dist/generators/test-generator/step-mapper.d.ts +41 -0
  102. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  103. package/dist/generators/test-generator/step-mapper.js +161 -17
  104. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  105. package/dist/generators/test-generator/template-engine.d.ts +2 -0
  106. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  107. package/dist/generators/test-generator/template-engine.js +1 -1
  108. package/dist/generators/test-generator/template-engine.js.map +1 -1
  109. package/dist/generators/test-generator/utils/data-resolver.d.ts +18 -1
  110. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  111. package/dist/generators/test-generator/utils/data-resolver.js +28 -4
  112. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  113. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +2 -2
  114. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  115. package/dist/generators/test-generator/utils/runtime-data-transformer.js +56 -11
  116. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  117. package/dist/generators/test-generator/utils/selector-resolver.d.ts +18 -2
  118. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  119. package/dist/generators/test-generator/utils/selector-resolver.js +29 -6
  120. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  121. package/dist/harness/audit.d.ts.map +1 -1
  122. package/dist/harness/audit.js +8 -7
  123. package/dist/harness/audit.js.map +1 -1
  124. package/dist/harness/capability-plan.d.ts.map +1 -1
  125. package/dist/harness/capability-plan.js +2 -3
  126. package/dist/harness/capability-plan.js.map +1 -1
  127. package/dist/harness/catalog/drivers.yaml +1 -1
  128. package/dist/harness/challenge.d.ts.map +1 -1
  129. package/dist/harness/challenge.js +1 -55
  130. package/dist/harness/challenge.js.map +1 -1
  131. package/dist/harness/data-driven-lint.d.ts.map +1 -1
  132. package/dist/harness/data-driven-lint.js +14 -9
  133. package/dist/harness/data-driven-lint.js.map +1 -1
  134. package/dist/harness/depth-lint.d.ts.map +1 -1
  135. package/dist/harness/depth-lint.js +1 -3
  136. package/dist/harness/depth-lint.js.map +1 -1
  137. package/dist/harness/flow-check.d.ts.map +1 -1
  138. package/dist/harness/flow-check.js +9 -7
  139. package/dist/harness/flow-check.js.map +1 -1
  140. package/dist/harness/flow-plan.d.ts.map +1 -1
  141. package/dist/harness/flow-plan.js +4 -3
  142. package/dist/harness/flow-plan.js.map +1 -1
  143. package/dist/harness/manifest.js +2 -2
  144. package/dist/harness/manifest.js.map +1 -1
  145. package/dist/harness/parse.d.ts +12 -0
  146. package/dist/harness/parse.d.ts.map +1 -1
  147. package/dist/harness/parse.js +28 -1
  148. package/dist/harness/parse.js.map +1 -1
  149. package/dist/harness/script-check.d.ts.map +1 -1
  150. package/dist/harness/script-check.js +61 -31
  151. package/dist/harness/script-check.js.map +1 -1
  152. package/dist/harness/secret-scan.d.ts +3 -1
  153. package/dist/harness/secret-scan.d.ts.map +1 -1
  154. package/dist/harness/secret-scan.js +27 -11
  155. package/dist/harness/secret-scan.js.map +1 -1
  156. package/dist/harness/sensors.d.ts.map +1 -1
  157. package/dist/harness/sensors.js +15 -4
  158. package/dist/harness/sensors.js.map +1 -1
  159. package/dist/harness/trace.js +1 -1
  160. package/dist/harness/trace.js.map +1 -1
  161. package/dist/harness/unit-paths.d.ts +16 -0
  162. package/dist/harness/unit-paths.d.ts.map +1 -1
  163. package/dist/harness/unit-paths.js +39 -2
  164. package/dist/harness/unit-paths.js.map +1 -1
  165. package/dist/index.d.ts +1 -0
  166. package/dist/index.d.ts.map +1 -1
  167. package/dist/index.js +3 -1
  168. package/dist/index.js.map +1 -1
  169. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  170. package/dist/orchestrator/screen-manager.js +8 -2
  171. package/dist/orchestrator/screen-manager.js.map +1 -1
  172. package/dist/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  173. package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  174. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +8 -0
  175. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
  176. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +79 -2
  177. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +7 -1
  178. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +12 -3
  179. package/dist/orchestrator/templates/playwright.config.ts +33 -2
  180. package/dist/orchestrator/templates/specs-mock.d.ts +66 -0
  181. package/dist/orchestrator/templates/specs-mock.d.ts.map +1 -0
  182. package/dist/orchestrator/templates/specs-mock.js +137 -0
  183. package/dist/orchestrator/templates/specs-mock.js.map +1 -0
  184. package/dist/orchestrator/templates/specs-mock.ts +179 -0
  185. package/dist/orchestrator/templates/specs-url-assert.d.ts +67 -0
  186. package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -0
  187. package/dist/orchestrator/templates/specs-url-assert.js +121 -0
  188. package/dist/orchestrator/templates/specs-url-assert.js.map +1 -0
  189. package/dist/orchestrator/templates/specs-url-assert.ts +134 -0
  190. package/dist/orchestrator/test-data-guide.d.ts.map +1 -1
  191. package/dist/orchestrator/test-data-guide.js +4 -0
  192. package/dist/orchestrator/test-data-guide.js.map +1 -1
  193. package/package.json +3 -3
  194. package/src/capabilities/discover.ts +4 -1
  195. package/src/cli/commands/add.ts +4 -2
  196. package/src/cli/commands/dashboard.ts +10 -1
  197. package/src/cli/commands/delivery.ts +19 -1
  198. package/src/cli/commands/generate.ts +9 -1
  199. package/src/dashboard/html-renderer.ts +8 -2
  200. package/src/dashboard/snapshot-builder.ts +3 -0
  201. package/src/dashboard/types.ts +4 -0
  202. package/src/exporters/api-environment.ts +3 -20
  203. package/src/exporters/api-testcase-formatter.ts +8 -2
  204. package/src/exporters/csv-exporter.ts +47 -8
  205. package/src/exporters/env-qa.ts +41 -0
  206. package/src/exporters/feature-parser.ts +17 -38
  207. package/src/exporters/json-exporter.ts +71 -24
  208. package/src/exporters/matrix/build.ts +15 -6
  209. package/src/exporters/matrix/gates.ts +3 -2
  210. package/src/exporters/matrix/step-buckets.ts +15 -0
  211. package/src/exporters/qa-env-secret-denylist.ts +118 -0
  212. package/src/exporters/scenario-merger.ts +80 -42
  213. package/src/exporters/step-formatter.ts +67 -66
  214. package/src/exporters/test-data-resolver.ts +34 -1
  215. package/src/exporters/types.ts +9 -8
  216. package/src/generators/test-generator/adapters/adapter-interface.ts +1 -1
  217. package/src/generators/test-generator/adapters/appium/appium-adapter.ts +1 -1
  218. package/src/generators/test-generator/adapters/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
  219. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +1 -1
  220. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +6 -0
  221. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
  222. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
  223. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
  224. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
  225. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
  226. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
  227. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
  228. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +15 -0
  229. package/src/generators/test-generator/code-generator.ts +91 -8
  230. package/src/generators/test-generator/diagnostics.ts +16 -1
  231. package/src/generators/test-generator/recipe-expander.ts +2 -2
  232. package/src/generators/test-generator/step-mapper.ts +160 -15
  233. package/src/generators/test-generator/template-engine.ts +2 -2
  234. package/src/generators/test-generator/utils/data-resolver.ts +27 -5
  235. package/src/generators/test-generator/utils/runtime-data-transformer.ts +57 -11
  236. package/src/generators/test-generator/utils/selector-resolver.ts +29 -5
  237. package/src/harness/audit.ts +10 -9
  238. package/src/harness/capability-plan.ts +2 -2
  239. package/src/harness/catalog/drivers.yaml +1 -1
  240. package/src/harness/challenge.ts +2 -3
  241. package/src/harness/data-driven-lint.ts +13 -9
  242. package/src/harness/depth-lint.ts +2 -4
  243. package/src/harness/flow-check.ts +9 -7
  244. package/src/harness/flow-plan.ts +4 -3
  245. package/src/harness/manifest.ts +3 -3
  246. package/src/harness/parse.ts +28 -1
  247. package/src/harness/script-check.ts +61 -33
  248. package/src/harness/secret-scan.ts +23 -8
  249. package/src/harness/sensors.ts +15 -4
  250. package/src/harness/trace.ts +2 -2
  251. package/src/harness/unit-paths.ts +39 -2
  252. package/src/index.ts +1 -0
  253. package/src/orchestrator/screen-manager.ts +7 -2
  254. package/src/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
  255. package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  256. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +8 -0
  257. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
  258. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +79 -2
  259. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +7 -1
  260. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +12 -3
  261. package/src/orchestrator/templates/playwright.config.ts +33 -2
  262. package/src/orchestrator/templates/specs-mock.ts +179 -0
  263. package/src/orchestrator/templates/specs-url-assert.ts +134 -0
  264. package/src/orchestrator/test-data-guide.ts +4 -0
@@ -311,20 +311,29 @@ 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
-
320
319
  // Generate test code (async now to support AI mapping)
321
320
  const testCode = await this.generateTestCode(feature);
322
321
 
322
+ // `url-assert` is imported from what the body actually emitted, not from an active capability:
323
+ // page assertions belong to the DEFAULT ui capability, so a capability flag would import the
324
+ // helper into every UI spec — including the many that touch no URL at all. Hence the body is
325
+ // rendered BEFORE the imports. Importing exactly the names used keeps the import honest; the
326
+ // helper file itself is synced only when something needs it (an api-only unit carries none).
327
+ const urlAssertImports = ['urlMatches', 'joinPath'].filter((n) => testCode.includes(`${n}(`)).join(', ');
328
+ if (urlAssertImports) this.syncGeneratedHelper(outputDir, 'url-assert.ts', 'specs-url-assert.ts');
329
+
330
+ const imports = this.adapter.renderImports({ runtimeData: this.options.runtimeData, basePath, needsCleanupImport, needsDb, needsApi, needsMock, urlAssertImports });
331
+
323
332
  // Combine and collapse any runs of 3+ newlines down to 2 (one blank line max)
324
333
  const raw = `${imports}\n\n${testCode}`;
325
334
  let code = raw.replace(/\n{3,}/g, '\n\n');
326
335
 
327
- // Runtime data: replace __SUNGEN_TD_ markers with testData.get() calls
336
+ // Runtime data: replace __SUNGEN_TD$…$__ markers with testData.get() calls
328
337
  if (this.options.runtimeData) {
329
338
  code = transformToRuntimeData(code);
330
339
  }
@@ -382,15 +391,40 @@ export class CodeGenerator {
382
391
  const steps: ParsedStep[] = [];
383
392
  if (feature.background?.steps) steps.push(...feature.background.steps);
384
393
  for (const sc of feature.scenarios || []) if (sc.steps) steps.push(...sc.steps);
385
- const scenarioTags = (feature.scenarios || []).flatMap((sc) => sc.tags || []);
394
+ // FEATURE-level tags count too: a feature-wide `@mock` must sync the helper and
395
+ // emit the import exactly like a per-scenario tag — reading scenario tags only
396
+ // left patterns matching while the helper/import never arrived, generating a
397
+ // spec that does not compile.
398
+ const tagPool = [
399
+ ...(feature.tags || []),
400
+ ...(feature.scenarios || []).flatMap((sc) => sc.tags || []),
401
+ ];
386
402
  const ids = new Set<string>();
387
403
  const def = capabilityRegistry.defaultCapabilityId();
388
404
  if (def) ids.add(def);
389
405
  for (const cap of capabilityRegistry.all()) {
390
- const annoMatch = (cap.annotations ?? []).some((a) => scenarioTags.some((t) => t === a || t.startsWith(a + ':')));
406
+ const annoMatch = (cap.annotations ?? []).some((a) => tagPool.some((t) => t === a || t.startsWith(a + ':')));
391
407
  const stepMatch = cap.detectsStep ? steps.some((s) => s && typeof s.text === 'string' && cap.detectsStep!(s.text)) : false;
392
408
  if (annoMatch || stepMatch) ids.add(cap.id);
393
409
  }
410
+
411
+ // Opt-in driver ABSENT while the feature clearly wants it = hard error. Discovery
412
+ // swallows a missing driver silently and unmatched steps become warn-only TODOs —
413
+ // a "mocked" scenario would then run green against the REAL backend.
414
+ const wantsMock =
415
+ tagPool.some((t) => t === '@mock' || t.startsWith('@mock:') || t === '@network') ||
416
+ steps.some((s) => s && typeof s.text === 'string' && /\[[^\]]+\]\s+mock\s+(?:is\s+active|called)\b/i.test(s.text));
417
+ if (wantsMock && !capabilityRegistry.get('mock')) {
418
+ throw new Error(
419
+ `feature "${feature.name}" uses network mocking (@mock / mock steps) but the mock capability is not installed — run: sungen capability add mock`);
420
+ }
421
+ // Web-only: the runtime rides Playwright's page.route. Failing here beats a
422
+ // confusing "Cannot find name 'mock'" tsc error in the generated appium spec.
423
+ if (wantsMock && this.adapter.frameworkName === 'appium') {
424
+ throw new Error(
425
+ `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.`);
426
+ }
427
+
394
428
  return [...ids];
395
429
  }
396
430
 
@@ -525,10 +559,33 @@ export class CodeGenerator {
525
559
  // tab`) is silently dropped and every scenario runs on the launcher. So branch on isParallel only
526
560
  // for web; for appium always use generateBackgroundSteps().
527
561
  const isMobile = this.adapter.frameworkName === 'appium';
562
+ const mockActive = !isMobile && this.activeCapabilityIds(feature).includes('mock');
528
563
  if (isParallel && !isMobile) {
529
- background = await this.generateBeforeEach(feature.background);
564
+ background = await this.generateBeforeEach(
565
+ feature.background,
566
+ // The passthrough flag must be armed BEFORE the Background installs + goto —
567
+ // a scenario-body statement runs too late: boot-time requests of an opted-out
568
+ // scenario would be aborted and recorded as violations. beforeEach reads the
569
+ // scenario's own tags via test.info().
570
+ mockActive
571
+ ? [{
572
+ comment: '@mock:passthrough scenarios opt out before any mock installs',
573
+ code: this.indentCode("if (test.info().tags.includes('@mock:passthrough')) mock.setPassthrough(page);", 4),
574
+ }]
575
+ : [],
576
+ );
530
577
  } else {
531
578
  backgroundSteps = await this.generateBackgroundSteps(feature.background);
579
+ // @serial: Background (beforeAll) mocks + the guard must survive each scenario's
580
+ // scoped reset — snapshot them so reset() removes only scenario-added routes.
581
+ // Without this, scenario 1's reset kills the Background mocks and scenarios 2+
582
+ // run against the REAL backend with no guard.
583
+ if (mockActive && !isMobile) {
584
+ backgroundSteps.push({
585
+ comment: 'Background mocks persist across the @serial scenarios',
586
+ code: this.indentCode('await mock.snapshotBackground(page);', 4),
587
+ });
588
+ }
532
589
  }
533
590
  }
534
591
 
@@ -736,9 +793,12 @@ export class CodeGenerator {
736
793
  return steps;
737
794
  }
738
795
 
739
- private async generateBeforeEach(background: ParsedScenario): Promise<string> {
796
+ private async generateBeforeEach(
797
+ background: ParsedScenario,
798
+ preambleSteps: Array<{ comment?: string; code: string }> = [],
799
+ ): Promise<string> {
740
800
  // Map all steps
741
- const steps: Array<{ comment?: string; code: string }> = [];
801
+ const steps: Array<{ comment?: string; code: string }> = [...preambleSteps];
742
802
  for (const step of background.steps) {
743
803
  const mapped = await Promise.resolve(this.stepMapper.mapStep(step));
744
804
  steps.push({
@@ -885,6 +945,29 @@ export class CodeGenerator {
885
945
  steps.unshift(...preconditions.map((p) => ({ comment: p.comment, code: this.indentCode(p.code, 4) })));
886
946
  }
887
947
 
948
+ // Mock scenario epilogue. (a) EVERY path: the fail-on-unmocked verdict — the guard
949
+ // records+aborts unmatched requests (it must not throw inside a route handler:
950
+ // that surfaces as an unhandled rejection), so the scenario ends by asserting the
951
+ // violation list is empty. (b) @serial shared page only: a scoped reset so this
952
+ // scenario's routes/counters don't leak into the next (the router unroutes only
953
+ // what IT installed — never page.unrouteAll(), which would also kill Background
954
+ // mocks and third-party routes; the per-test path gets that free at page close).
955
+ const usesMock =
956
+ [...(featureTags || []), ...(scenario.tags || [])].some((t) => t === '@mock' || t.startsWith('@mock:') || t === '@network') ||
957
+ stepsToMap.some((s) => /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(s.text || ''));
958
+ if (usesMock && this.adapter.frameworkName !== 'appium' && capabilityRegistry.get('mock')) {
959
+ steps.push({
960
+ comment: 'Fail-on-unmocked verdict: any unmatched request aborted by the guard fails here',
961
+ code: this.indentCode('await mock.assertNoUnmocked(page);', 4),
962
+ });
963
+ if (!isParallel) {
964
+ steps.push({
965
+ comment: 'Reset mock routes and counters (shared @serial page)',
966
+ code: this.indentCode('await mock.reset(page);', 4),
967
+ });
968
+ }
969
+ }
970
+
888
971
  // Extract pass-through tags (feature + scenario, excluding functional tags)
889
972
  const tags = extractPassThroughTags(scenario.tags, featureTags);
890
973
 
@@ -20,12 +20,27 @@
20
20
  * SG-W010 — a row step's [Ref] looks POSITIONAL (First/Last/Top/…) but has no selectors.yaml
21
21
  * entry: the label is display-only, so the step checks existence anywhere in the
22
22
  * table — an ordering rule would pass even when the row moved
23
+ * SG-W011 — a storage assertion written in `expect … in local/session storage` form:
24
+ * `expect` steps only read `{{response}}` refs, so this compiles to a silent
25
+ * no-op — the supported grammar is `User see [KEY] in local storage exists/…`
26
+ * SG-W012 — a mock-install step AFTER a navigation step in the same block: page.route()
27
+ * registered after goto() misses every request fired during page load — move
28
+ * the install before the navigation (or into Background)
29
+ * SG-W013 — a page assertion whose path came from a FALLBACK, not from a `type: page` selector
30
+ * entry: the `[Ref]` did not resolve (or resolved to a non-page entry), so the step
31
+ * asserts the feature's own path (or `/<ref>/`) instead of the page it names. The
32
+ * assertion is anchored, so this is now a test that cannot pass rather than one that
33
+ * always passed — either way it is not asserting what it reads as
34
+ * SG-W014 — `[X] page with {{v}}` where the value carries no query and no fragment: there is
35
+ * nothing for the query half to assert, so the step checks the PATH only. Not an
36
+ * error (the value may legitimately vary per environment), but the step asserts
37
+ * strictly less than it appears to
23
38
  *
24
39
  * Diagnostics never block generation by themselves; `sungen generate --strict`
25
40
  * turns any collected diagnostic into a non-zero exit.
26
41
  */
27
42
 
28
- export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010';
43
+ export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010' | 'SG-W011' | 'SG-W012' | 'SG-W013' | 'SG-W014';
29
44
 
30
45
  export interface Diagnostic {
31
46
  code: DiagnosticCode;
@@ -21,7 +21,7 @@
21
21
  export interface RecipeExpandContext {
22
22
  /** Pre-rendered locator code for the widget element (dialog/frame scoping included). */
23
23
  triggerCode: string;
24
- /** The step's value — a literal or a __SUNGEN_TD__ runtime marker. */
24
+ /** The step's value — a literal or a __SUNGEN_TD$…$__ runtime marker. */
25
25
  value: string;
26
26
  }
27
27
 
@@ -53,7 +53,7 @@ function expandPrimitive(prim: unknown, ctx: RecipeExpandContext, itemVar: strin
53
53
  const targetExpr = (v: unknown): string =>
54
54
  v === '$trigger' ? ctx.triggerCode : `page.locator('${escape(String(v))}')`;
55
55
  // Resolve a `$value`/`$item`/literal into a JS value EXPRESSION (quoted unless $item;
56
- // a quoted __SUNGEN_TD__ marker is rewritten to testData.get(...) by the runtime transform).
56
+ // a quoted __SUNGEN_TD$…$__ marker is rewritten to testData.get(...) by the runtime transform).
57
57
  const valueExpr = (v: unknown): string => {
58
58
  if (v === '$item') {
59
59
  if (!itemVar) throw new Error('$item is only valid inside forEach');
@@ -43,6 +43,8 @@ export class StepMapper {
43
43
  private tableRowDeclared: boolean = false;
44
44
  /** SG-W007: an action step already ran in this scenario (alert handlers registered after it are too late) */
45
45
  private sawActionStep: boolean = false;
46
+ /** SG-W012: a navigation already ran in this scenario (mocks installed after it miss page-load requests) */
47
+ private sawNavigationStep: boolean = false;
46
48
  /** Compile-time warning channel (SG-W001/W002); optional — absent means silent legacy behavior */
47
49
  private diagnostics?: DiagnosticsCollector;
48
50
  /** App profile (qa/app.yaml merged over presets/defaults) — the per-project BEHAVIOR layer */
@@ -91,6 +93,14 @@ export class StepMapper {
91
93
  this.featureName = featureName;
92
94
  this.featurePath = featurePath;
93
95
  this.selectorResolver.setFeatureContext(featureName);
96
+ // One StepMapper serves every feature in a run, so the step counter that names the
97
+ // generated temporaries (`__t_<n>`) used to carry over between files: a screen's spec
98
+ // then depended on how many steps the PRECEDING files happened to contain — i.e. on
99
+ // readdir order and on whether the run was `--all` or `--screen`. Same input, different
100
+ // output, which is exactly what a deterministic compiler must not do (and what made a
101
+ // multi-feature screen report permanent false DRIFT in script-check). The counter only
102
+ // needs to be unique WITHIN one spec file, so it restarts with each feature.
103
+ this.stepCounter = 0;
94
104
  }
95
105
 
96
106
  /**
@@ -137,6 +147,7 @@ export class StepMapper {
137
147
  this.tableRowScopeSteps = steps.filter((s) => this.isRowScopeEntryStep(s)).length;
138
148
  this.tableRowDeclared = false;
139
149
  this.sawActionStep = false;
150
+ this.sawNavigationStep = false;
140
151
  this.lastPrimaryKeyword = 'Given';
141
152
  this.dataResolver.clearCaptured(); // captured vars are scenario-scoped
142
153
  this.templateEngine.resetBaseContext();
@@ -181,6 +192,20 @@ export class StepMapper {
181
192
  this.sawActionStep = true;
182
193
  }
183
194
 
195
+ // A mock must be routed BEFORE the navigation whose requests it should intercept —
196
+ // page.route() registered after goto() misses everything fired during page load
197
+ // (mirror of the late-alert-listener check above). Background installs compile
198
+ // into beforeEach, before any scenario step, so they never trip this.
199
+ const isMockInstallStep = /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(step.text);
200
+ if (isMockInstallStep && this.sawNavigationStep) {
201
+ this.diagnostics?.add('SG-W012',
202
+ 'mock installed AFTER a navigation step — requests fired during page load were not intercepted. Move the mock step before the navigation, or declare it in Background',
203
+ { feature: this.featureName, step: step.text });
204
+ } else if (!isMockInstallStep && step.elementType === 'page' &&
205
+ /\b(?:is on|navigates?\s+to|opens?)\b/i.test(step.text)) {
206
+ this.sawNavigationStep = true;
207
+ }
208
+
184
209
  // Frame scope directives — intercept before pattern matching
185
210
  if (/\bswitch(?:es)?\s+to\b/i.test(step.text) &&
186
211
  (step.elementType === 'frame' || step.elementType === 'iframe')) {
@@ -221,7 +246,14 @@ export class StepMapper {
221
246
  // Given User is on [...] dialog — set dialog scope (3 cases)
222
247
  if (step.text.includes('is on') && step.elementType === 'dialog') {
223
248
  this.inDialogScope = true;
224
- const contextVars: Record<string, any> = { inDialog: true };
249
+ // setBaseContext MERGES clear every root key so a previous dialog scope in the
250
+ // same scenario cannot leak its root into this one.
251
+ const contextVars: Record<string, any> = {
252
+ inDialog: true,
253
+ dialogRootExpr: undefined,
254
+ dialogRoleName: undefined,
255
+ dialogFilterText: undefined,
256
+ };
225
257
 
226
258
  if (step.selectorRef && step.dataRef) {
227
259
  // Case 3: [panel] dialog with {{value}}
@@ -234,16 +266,18 @@ export class StepMapper {
234
266
  filterText = `\${${step.dataRef}}`;
235
267
  }
236
268
  contextVars.dialogFilterText = filterText;
269
+ // The declared entry still owns the ROOT the filter applies to — but only when it
270
+ // is a non-role strategy (locator/testid/css). A role entry keeps the bare
271
+ // getByRole('dialog') base on purpose here: the name is dynamic, hence the filter.
272
+ const rootExpr = this.renderDialogRoot(step.selectorRef, { nonRoleOnly: true });
273
+ if (rootExpr) contextVars.dialogRootExpr = rootExpr;
237
274
  } else if (step.selectorRef) {
238
- // Case 2: [Title] dialog → named dialog role
239
- let roleName = step.selectorRef;
240
- try {
241
- const resolved = this.selectorResolver.resolveSelector(step.selectorRef, this.featureName, 'dialog', 0);
242
- roleName = resolved.name || step.selectorRef;
243
- } catch {
244
- roleName = step.selectorRef;
245
- }
246
- contextVars.dialogRoleName = roleName;
275
+ // Case 2: [Title] dialog → the root the DECLARED selectors.yaml entry asks for
276
+ // (issue: an explicit `type: locator` / `value: '[role="dialog"]'` was being
277
+ // discarded in favour of getByRole('dialog', { name: <reference text> })).
278
+ const rootExpr = this.renderDialogRoot(step.selectorRef);
279
+ if (rootExpr) contextVars.dialogRootExpr = rootExpr;
280
+ else contextVars.dialogRoleName = step.selectorRef;
247
281
  }
248
282
  // Case 1: no selectorRef → plain page.getByRole('dialog')
249
283
 
@@ -299,20 +333,127 @@ export class StepMapper {
299
333
  console.log(` ✓ Pattern matched: ${step.text.substring(0, 50)}...`);
300
334
  }
301
335
  this.checkDataRefConsumed(step, mappedStep);
336
+ this.checkPageAssertionFallback(step, mappedStep);
337
+ this.checkDeclaredUrlHasNoQuery(step, mappedStep);
302
338
  return mappedStep;
303
339
  }
304
340
 
305
341
  // No pattern matched - return TODO comment
306
- this.diagnostics?.add('SG-W001', 'step matched no pattern — compiled to a no-op // TODO comment', {
307
- feature: this.featureName,
308
- step: step.text,
309
- });
342
+ if (/\bexpect\b.*\b(?:local|session)\s+storage\b/i.test(step.text)) {
343
+ // The natural first guess for a storage check — but `expect` steps only read
344
+ // {{response}} refs, so this shape silently compiles to nothing. Point at the
345
+ // supported grammar instead of emitting the generic no-pattern warning.
346
+ this.diagnostics?.add('SG-W011',
347
+ 'storage assertions use `User see [KEY] in local storage exists` (also: does not exist / is not empty / has length {{n}} / key matching "PATTERN") — the `expect …` form compiles to a no-op', {
348
+ feature: this.featureName,
349
+ step: step.text,
350
+ });
351
+ } else {
352
+ this.diagnostics?.add('SG-W001', 'step matched no pattern — compiled to a no-op // TODO comment', {
353
+ feature: this.featureName,
354
+ step: step.text,
355
+ });
356
+ }
310
357
  return {
311
358
  code: `// TODO: Implement step: ${step.text}`,
312
359
  comment: `Unrecognized step pattern: ${step.text}`,
313
360
  };
314
361
  }
315
362
 
363
+ /** Strategies the `locator` partial renders into a usable ROOT expression. Anything else
364
+ * (mobile native strategies) has no meaning as a Playwright dialog root. */
365
+ private static readonly DIALOG_ROOT_STRATEGIES = ['locator', 'testid', 'role', 'id', 'label', 'text', 'placeholder'];
366
+
367
+ /**
368
+ * Render the Playwright root expression for `Given User is on [X] dialog` from the entry
369
+ * DECLARED in selectors.yaml, through the same `locator` partial every other step renders
370
+ * with — so `type: locator`, `type: testid`, a non-`dialog` role, `name: null`, `exact:`
371
+ * and `nth:` mean here exactly what they mean everywhere else. Before this, the entry was
372
+ * read for its `name` only and the root was hardcoded to getByRole('dialog', { name }),
373
+ * silently discarding an explicit `type: locator` / `value: '[role="dialog"]'`.
374
+ *
375
+ * Returns undefined when the reference does not resolve to a usable root — the caller then
376
+ * falls back to the legacy named-dialog root, so behavior is unchanged for those entries.
377
+ */
378
+ private renderDialogRoot(selectorRef: string, opts: { nonRoleOnly?: boolean } = {}): string | undefined {
379
+ let resolved: any;
380
+ try {
381
+ resolved = this.selectorResolver.resolveSelector(selectorRef, this.featureName, 'dialog', 0);
382
+ } catch {
383
+ return undefined;
384
+ }
385
+ if (!resolved) return undefined;
386
+
387
+ // `type: role` + a `locator:` field collapses to strategy 'css', which the locator partial
388
+ // has no case for — normalize it to the raw-locator strategy it actually means.
389
+ if (resolved.strategy === 'css' && resolved.locator) {
390
+ resolved = { ...resolved, strategy: 'locator', value: resolved.locator };
391
+ }
392
+ if (!StepMapper.DIALOG_ROOT_STRATEGIES.includes(resolved.strategy)) return undefined;
393
+ if (opts.nonRoleOnly && resolved.strategy === 'role') return undefined;
394
+
395
+ // Render the root PAGE-rooted: leaving inDialog set (or `scope: dialog` on the entry
396
+ // itself) would send the partial back through dialog-root and nest the dialog in itself.
397
+ const expr = this.templateEngine.renderStep('locator', {
398
+ ...resolved,
399
+ scope: resolved.scope === 'dialog' ? undefined : resolved.scope,
400
+ inDialog: false,
401
+ }).trim();
402
+ return expr || undefined;
403
+ }
404
+
405
+ /**
406
+ * SG-W013 — a page assertion whose path came from a fallback rather than from a `type: page`
407
+ * selector entry. Both resolvers degrade quietly when `[Ref]` does not resolve: `see [X] page`
408
+ * falls back to the feature's own path, `is on [X] page` to `/<ref>/`. Before the assertion was
409
+ * anchored that produced a test which always PASSED; anchored, it produces one that can never
410
+ * pass. Neither is the assertion the author wrote, and nothing said so.
411
+ *
412
+ * Detected here rather than in the resolver on purpose: `MappedStep` carries only
413
+ * `{code, comment, imports}`, and the resolvers live in the driver packages while the collector
414
+ * lives in core. Re-asking the SAME resolver the step-mapper already owns costs three lines and
415
+ * duplicates no logic — the alternative is widening the adapter contract for every driver.
416
+ */
417
+ private checkPageAssertionFallback(step: ParsedStep, mapped: MappedStep): void {
418
+ if (!this.diagnostics || !step.selectorRef || step.elementType !== 'page') return;
419
+ if (!(mapped.code || '').includes('urlMatches(')) return;
420
+ let resolvedAsPage = false;
421
+ try {
422
+ const resolved = this.selectorResolver.resolveSelector(
423
+ step.selectorRef, this.featureName, step.elementType, step.nth
424
+ );
425
+ resolvedAsPage = resolved.selectorType === 'page' && !!resolved.value;
426
+ } catch {
427
+ resolvedAsPage = false;
428
+ }
429
+ if (resolvedAsPage) return;
430
+ this.diagnostics.add('SG-W013',
431
+ `page assertion for "[${step.selectorRef}]" has no "type: page" selector entry, so it asserts a FALLBACK path — the anchored assertion cannot match the real URL. Declare the page, e.g. "${step.selectorRef.toLowerCase()}:\\n type: page\\n value: '/your/path'"`,
432
+ { feature: this.featureName, step: step.text });
433
+ }
434
+
435
+ /**
436
+ * SG-W014 — `[X] page with {{v}}` where the value carries neither a query nor a fragment, so the
437
+ * query half of the assertion has nothing to check and the step asserts the PATH only.
438
+ *
439
+ * Deliberately a warning, not an error, and deliberately judged on the BASE test-data value: an
440
+ * env overlay can supply a query the base file lacks, so failing generation here would block a
441
+ * suite over data that is fine at run time. But it must not be silent either — asserting less
442
+ * than the step reads as is exactly the false-positive class this assertion exists to remove.
443
+ * Distinct from SG-W002: there the value is DROPPED, here it is used and found to hold no query.
444
+ */
445
+ private checkDeclaredUrlHasNoQuery(step: ParsedStep, mapped: MappedStep): void {
446
+ if (!this.diagnostics || !step.dataRef || step.elementType !== 'page') return;
447
+ if (!(mapped.code || '').includes('urlMatches(')) return;
448
+ // The base value, never resolveData(): in runtime mode that returns the marker, which carries
449
+ // no query by construction — the diagnostic would then fire on every single step.
450
+ const value = this.dataResolver.peekBaseValue(step.dataRef, this.featureName);
451
+ if (value == null || /[?#]/.test(value)) return;
452
+ this.diagnostics.add('SG-W014',
453
+ `{{${step.dataRef}}} = "${value}" carries no query and no fragment, so this page assertion checks the PATH only. Pass a value like "?q=…" (or drop the "with" and let the step assert a bare page).`,
454
+ { feature: this.featureName, step: step.text });
455
+ }
456
+
316
457
  /**
317
458
  * SG-W002 — a step carried a parsed {{dataRef}} but the matched pattern's generated
318
459
  * code never uses it (neither the runtime marker, the ${ref} fallback, nor the
@@ -323,7 +464,11 @@ export class StepMapper {
323
464
  private checkDataRefConsumed(step: ParsedStep, mapped: MappedStep): void {
324
465
  if (!step.dataRef || !this.diagnostics) return;
325
466
  const code = mapped.code || '';
326
- if (code.includes(DataResolver.encodeMarker(step.dataRef))) return; // runtime marker
467
+ // Runtime marker in both spellings: templates that embed the value into a regex
468
+ // literal run it through the escapeRegex helper, which escapes the marker's `$`
469
+ // delimiters, so the code may carry `__SUNGEN_TD\$key\$__` instead of the plain form.
470
+ const marker = DataResolver.encodeMarker(step.dataRef);
471
+ if (code.includes(marker) || code.includes(marker.replace(/\$/g, '\\$'))) return;
327
472
  if (code.includes(`\${${step.dataRef}}`)) return; // unresolved fallback
328
473
  if (code.includes(step.dataRef)) return; // testData.set('ref'), comments
329
474
  try {
@@ -277,8 +277,8 @@ export class TemplateEngine {
277
277
  this.baseContext = { ...this.persistentContext };
278
278
  }
279
279
 
280
- renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean }): string {
281
- return this.render('imports', { runtimeData: options?.runtimeData, basePath: options?.basePath || '..', isParallel: options?.isParallel, needsCleanupImport: options?.needsCleanupImport, needsDb: options?.needsDb, needsApi: options?.needsApi });
280
+ renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean; urlAssertImports?: string }): string {
281
+ return this.render('imports', { runtimeData: options?.runtimeData, basePath: options?.basePath || '..', isParallel: options?.isParallel, needsCleanupImport: options?.needsCleanupImport, needsDb: options?.needsDb, needsApi: options?.needsApi, needsMock: options?.needsMock, urlAssertImports: options?.urlAssertImports });
282
282
  }
283
283
 
284
284
  renderTestFile(data: {
@@ -64,6 +64,25 @@ export class DataResolver {
64
64
  return this.resolveValue(dataRef, featureName);
65
65
  }
66
66
 
67
+ /**
68
+ * The value as the BASE test-data file spells it, whatever the mode — for compile-time
69
+ * diagnostics that must judge the value itself, not the `__SUNGEN_TD$…$__` marker that
70
+ * `resolveData` returns in runtime mode.
71
+ *
72
+ * Returns undefined rather than throwing when the ref cannot be read (unknown path, or a
73
+ * captured var that only exists at runtime): a diagnostic must never break generation. Callers
74
+ * must treat the result as the base value only — an env overlay can change it at run time, so
75
+ * anything said about it belongs in a warning, never a gate.
76
+ */
77
+ peekBaseValue(dataRef: string, featureName?: string): string | undefined {
78
+ if (this.capturedVars.has(dataRef)) return undefined;
79
+ try {
80
+ return this.resolveValue(dataRef, featureName);
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
67
86
  private resolveValue(dataRef: string, featureName?: string): string {
68
87
  const { fileName, valuePath } = this.parseDataRef(dataRef, featureName);
69
88
  const data = this.loadDataFile(fileName);
@@ -112,12 +131,15 @@ export class DataResolver {
112
131
  return { fileName: parts[0], valuePath: parts.slice(1) };
113
132
  }
114
133
 
134
+ /**
135
+ * Encode a test-data ref as an opaque wire marker: `__SUNGEN_TD$<key>$__`,
136
+ * dots escaped to `_D_`. `$` delimiters keep the key boundary unambiguous
137
+ * when the surrounding text continues with underscores (`{{code}}_SUFFIX`)
138
+ * while the marker stays a single JS-identifier-safe token. This is the ONLY
139
+ * producer; the only decoder lives in runtime-data-transformer.ts.
140
+ */
115
141
  static encodeMarker(ref: string): string {
116
- return `__SUNGEN_TD_${ref.replace(/\./g, '_D_')}__`;
117
- }
118
-
119
- static decodeMarker(encoded: string): string {
120
- return encoded.replace(/^__SUNGEN_TD_/, '').replace(/__$/, '').replace(/_D_/g, '.');
142
+ return `__SUNGEN_TD$${ref.replace(/\./g, '_D_')}$__`;
121
143
  }
122
144
 
123
145
  /**
@@ -1,8 +1,54 @@
1
- const MARKER_PATTERN = /__SUNGEN_TD_([A-Za-z0-9_]+)__/;
1
+ /**
2
+ * Wire format of a runtime test-data marker: `__SUNGEN_TD$<key>$__`.
3
+ *
4
+ * `$` delimits the encoded key on both sides. The key alphabet is [A-Za-z0-9_]
5
+ * (dots in refs are escaped to `_D_` by DataResolver.encodeMarker), so a `$`
6
+ * can never occur INSIDE a key — the `$__` terminator stays unambiguous even
7
+ * when the surrounding literal continues with underscore text. The previous
8
+ * `__SUNGEN_TD_<key>__` format could not tell its `__` terminator apart from a
9
+ * `_`-leading suffix: in `'{{code}}_ACCESS_TOKEN'` the greedy key class
10
+ * swallowed one `_` of the `___` run and decoding produced `get('code_')` plus
11
+ * a detached `ACCESS_TOKEN`. `$` is also identifier-safe — the whole marker
12
+ * still parses as ONE bare JS token, so it survives the bare-identifier
13
+ * (numeric) positions Pass 3 handles.
14
+ *
15
+ * Single source of truth: every pass and every whole-literal probe below is
16
+ * built from these constants. Invariant: every marker producer goes through
17
+ * DataResolver.encodeMarker and every consumer through this module — a
18
+ * drifting hand-copied char class is what let the suffix bug hide.
19
+ */
20
+ const MARKER_KEY = '[A-Za-z0-9_]+';
21
+ // Templates that embed a value into a regex literal run it through the escapeRegex
22
+ // Handlebars helper first (template-engine.ts), which escapes `$` — so a marker can
23
+ // arrive here spelled `__SUNGEN_TD\$key\$__`. `\\\\?` accepts that optional backslash
24
+ // before each delimiter; the replacement consumes it, so no escape leaks to output.
25
+ const DELIM = '\\\\?\\$';
26
+ /** Marker with no capture group — for "contains a marker" positions inside bigger regexes. */
27
+ const MARKER = `__SUNGEN_TD${DELIM}${MARKER_KEY}${DELIM}__`;
28
+ /** Marker capturing the encoded key. */
29
+ const MARKER_CAP = `__SUNGEN_TD${DELIM}(${MARKER_KEY})${DELIM}__`;
30
+
31
+ const MARKER_PATTERN = new RegExp(MARKER);
32
+ // Pass 0: full-line // comments carrying a marker (anchored — see pass comment).
33
+ const RE_COMMENT_LINE = new RegExp(`^(\\s*)\\/\\/(.*)${MARKER_CAP}(.*)$`, 'gm');
34
+ // Pass 1: a quoted string literal containing at least one marker. Covers ', " and
35
+ // backtick containers; `\\.` lets an ESCAPED quote (`'it\'s {{x}}'`) sit inside the
36
+ // body instead of terminating the literal early. The single-char branch excludes `\`
37
+ // so every backslash is consumed by exactly ONE branch — an ambiguous `(?!\1).`
38
+ // alternative enumerates 2^n segmentations when a marker-less escape-heavy literal
39
+ // (escapeQuotes output: multi-line Flutter content-desc) fails to match, hanging
40
+ // the generate run. `\n` exclusion just makes the dot's no-newline behavior explicit.
41
+ const RE_STRING_LITERAL = new RegExp('([\'"`])((?:\\\\.|(?!\\1)[^\\\\\\n])*?' + MARKER + '(?:\\\\.|(?!\\1)[^\\\\\\n])*)\\1', 'g');
42
+ // Pass 2: a single-line regex literal containing at least one marker.
43
+ const RE_REGEX_LITERAL = new RegExp(`\\/((?:[^/\\\\\\n]|\\\\.)*?${MARKER}(?:[^/\\\\\\n]|\\\\.)*)\\/([gimsuy]*)`, 'g');
44
+ // Whole-literal probe: the body is EXACTLY one marker (bare accessor call, no template).
45
+ const RE_WHOLE_MARKER = new RegExp(`^${MARKER_CAP}$`);
46
+ // Every marker occurrence, capturing the key (Pass 3 + interpolation).
47
+ const RE_MARKER_G = new RegExp(MARKER_CAP, 'g');
2
48
 
3
49
  /**
4
- * Replace __SUNGEN_TD_ markers with testData.get() calls in generated code.
5
- * Three passes: comments, string literals, then regex literals.
50
+ * Replace __SUNGEN_TD$…$__ markers with testData.get() calls in generated code.
51
+ * Four passes: comments, string literals, regex literals, then bare identifiers.
6
52
  */
7
53
  export function transformToRuntimeData(code: string, accessor: string = 'testData'): string {
8
54
  // Pass 0: Comments — replace markers in // comments with decoded key name.
@@ -14,7 +60,7 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
14
60
  // testData.get() (silent: `toHaveAttribute('href', 'official_site_url')`). Generated
15
61
  // comments are always full-line, so anchoring loses nothing.
16
62
  code = code.replace(
17
- /^(\s*)\/\/(.*)__SUNGEN_TD_([A-Za-z0-9_]+)__(.*)$/gm,
63
+ RE_COMMENT_LINE,
18
64
  (_, indent, before, enc, after) => `${indent}//${before}${decodeKey(enc)}${after}`
19
65
  );
20
66
 
@@ -30,22 +76,22 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
30
76
  // 'marker' → testData.get('key')
31
77
  // 'a__m1__b__m2__c' → `a${testData.get('k1')}b${testData.get('k2')}c`
32
78
  code = code.replace(
33
- /(['"])((?:(?!\1).)*?__SUNGEN_TD_[A-Za-z0-9_]+__(?:(?!\1).)*)\1/g,
79
+ RE_STRING_LITERAL,
34
80
  (_, _quote, body) => {
35
- const whole = /^__SUNGEN_TD_([A-Za-z0-9_]+)__$/.exec(body);
81
+ const whole = RE_WHOLE_MARKER.exec(body);
36
82
  if (whole) return `${accessor}.get('${decodeKey(whole[1])}')`;
37
83
  return `\`${interpolateMarkers(body, accessor)}\``;
38
84
  }
39
85
  );
40
86
 
41
- // Pass 2: Regex literal context — /…__marker__…/ → new RegExp(`…`). Same
87
+ // Pass 2: Regex literal context — /…$marker$…/ → new RegExp(`…`). Same
42
88
  // several-markers-per-literal rule as Pass 1 (/{{min}}-{{max}} items/).
43
89
  // Only matches within a single line (no newlines in the body).
44
90
  code = code.replace(
45
- /\/((?:[^/\\\n]|\\.)*?__SUNGEN_TD_[A-Za-z0-9_]+__(?:[^/\\\n]|\\.)*)\/([gimsuy]*)/g,
91
+ RE_REGEX_LITERAL,
46
92
  (_, body, flags) => {
47
93
  const flagStr = flags ? `, '${flags}'` : '';
48
- const whole = /^__SUNGEN_TD_([A-Za-z0-9_]+)__$/.exec(body);
94
+ const whole = RE_WHOLE_MARKER.exec(body);
49
95
  if (whole) return `new RegExp(${accessor}.get('${decodeKey(whole[1])}')${flagStr})`;
50
96
  return `new RegExp(\`${interpolateMarkers(body, accessor)}\`${flagStr})`;
51
97
  }
@@ -55,7 +101,7 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
55
101
  // sits as a bare token in a numeric position (e.g. `toHaveCount(__marker__)` from the
56
102
  // table/list count templates). testData.get() returns a string, so coerce with Number().
57
103
  code = code.replace(
58
- /__SUNGEN_TD_([A-Za-z0-9_]+)__/g,
104
+ RE_MARKER_G,
59
105
  (_, enc) => `Number(${accessor}.get('${decodeKey(enc)}'))`
60
106
  );
61
107
 
@@ -72,5 +118,5 @@ function decodeKey(encoded: string): string {
72
118
 
73
119
  /** Replace every marker in a literal's body with a `${accessor.get('key')}` interpolation. */
74
120
  function interpolateMarkers(body: string, accessor: string): string {
75
- return body.replace(/__SUNGEN_TD_([A-Za-z0-9_]+)__/g, (_, enc) => `\${${accessor}.get('${decodeKey(enc)}')}`);
121
+ return body.replace(RE_MARKER_G, (_, enc) => `\${${accessor}.get('${decodeKey(enc)}')}`);
76
122
  }