@sun-asterisk/sungen 3.2.32 → 3.2.33

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 (243) hide show
  1. package/dist/cli/commands/audit.d.ts +1 -0
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +10 -1
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/capability.d.ts.map +1 -1
  6. package/dist/cli/commands/capability.js +35 -17
  7. package/dist/cli/commands/capability.js.map +1 -1
  8. package/dist/cli/commands/delivery.d.ts.map +1 -1
  9. package/dist/cli/commands/delivery.js +39 -18
  10. package/dist/cli/commands/delivery.js.map +1 -1
  11. package/dist/cli/commands/generate.d.ts.map +1 -1
  12. package/dist/cli/commands/generate.js +14 -7
  13. package/dist/cli/commands/generate.js.map +1 -1
  14. package/dist/cli/commands/results-merge.d.ts.map +1 -1
  15. package/dist/cli/commands/results-merge.js +2 -12
  16. package/dist/cli/commands/results-merge.js.map +1 -1
  17. package/dist/cli/commands/results-sanitize.d.ts.map +1 -1
  18. package/dist/cli/commands/results-sanitize.js +2 -13
  19. package/dist/cli/commands/results-sanitize.js.map +1 -1
  20. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  21. package/dist/dashboard/snapshot-builder.js +6 -10
  22. package/dist/dashboard/snapshot-builder.js.map +1 -1
  23. package/dist/exporters/feature-parser.d.ts +21 -0
  24. package/dist/exporters/feature-parser.d.ts.map +1 -1
  25. package/dist/exporters/feature-parser.js +32 -1
  26. package/dist/exporters/feature-parser.js.map +1 -1
  27. package/dist/exporters/locale-variants.d.ts +7 -1
  28. package/dist/exporters/locale-variants.d.ts.map +1 -1
  29. package/dist/exporters/locale-variants.js +23 -16
  30. package/dist/exporters/locale-variants.js.map +1 -1
  31. package/dist/exporters/matrix/build.d.ts.map +1 -1
  32. package/dist/exporters/matrix/build.js +13 -7
  33. package/dist/exporters/matrix/build.js.map +1 -1
  34. package/dist/exporters/matrix/carry-manual-results.d.ts +70 -0
  35. package/dist/exporters/matrix/carry-manual-results.d.ts.map +1 -0
  36. package/dist/exporters/matrix/carry-manual-results.js +232 -0
  37. package/dist/exporters/matrix/carry-manual-results.js.map +1 -0
  38. package/dist/exporters/matrix/export.d.ts +2 -0
  39. package/dist/exporters/matrix/export.d.ts.map +1 -1
  40. package/dist/exporters/matrix/export.js +7 -1
  41. package/dist/exporters/matrix/export.js.map +1 -1
  42. package/dist/exporters/matrix/wording.d.ts +7 -0
  43. package/dist/exporters/matrix/wording.d.ts.map +1 -1
  44. package/dist/exporters/matrix/wording.js +27 -1
  45. package/dist/exporters/matrix/wording.js.map +1 -1
  46. package/dist/exporters/xlsx-exporter.d.ts +2 -0
  47. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  48. package/dist/exporters/xlsx-exporter.js +7 -2
  49. package/dist/exporters/xlsx-exporter.js.map +1 -1
  50. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +2 -2
  51. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +8 -2
  52. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +7 -3
  53. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +68 -4
  54. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +7 -1
  55. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +8 -4
  56. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +5 -1
  57. package/dist/generators/test-generator/code-generator.d.ts +2 -1
  58. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  59. package/dist/generators/test-generator/code-generator.js +67 -3
  60. package/dist/generators/test-generator/code-generator.js.map +1 -1
  61. package/dist/generators/test-generator/diagnostics.d.ts +33 -4
  62. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  63. package/dist/generators/test-generator/diagnostics.js +32 -3
  64. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  65. package/dist/generators/test-generator/patterns/index.d.ts +12 -2
  66. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  67. package/dist/generators/test-generator/patterns/index.js +15 -9
  68. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  69. package/dist/generators/test-generator/patterns/types.d.ts +15 -0
  70. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  71. package/dist/generators/test-generator/patterns/with-parent-scope.d.ts +5 -0
  72. package/dist/generators/test-generator/patterns/with-parent-scope.d.ts.map +1 -1
  73. package/dist/generators/test-generator/patterns/with-parent-scope.js +6 -0
  74. package/dist/generators/test-generator/patterns/with-parent-scope.js.map +1 -1
  75. package/dist/generators/test-generator/step-mapper.d.ts +46 -0
  76. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  77. package/dist/generators/test-generator/step-mapper.js +84 -9
  78. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  79. package/dist/generators/test-generator/utils/data-resolver.d.ts +25 -0
  80. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  81. package/dist/generators/test-generator/utils/data-resolver.js +39 -8
  82. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  83. package/dist/generators/test-generator/utils/selector-entry-fields.d.ts.map +1 -1
  84. package/dist/generators/test-generator/utils/selector-entry-fields.js +1 -0
  85. package/dist/generators/test-generator/utils/selector-entry-fields.js.map +1 -1
  86. package/dist/generators/test-generator/utils/selector-resolver.d.ts +37 -4
  87. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  88. package/dist/generators/test-generator/utils/selector-resolver.js +212 -79
  89. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  90. package/dist/harness/audit.d.ts.map +1 -1
  91. package/dist/harness/audit.js +12 -4
  92. package/dist/harness/audit.js.map +1 -1
  93. package/dist/harness/capability.d.ts +7 -1
  94. package/dist/harness/capability.d.ts.map +1 -1
  95. package/dist/harness/capability.js +15 -12
  96. package/dist/harness/capability.js.map +1 -1
  97. package/dist/harness/catalog/universal-viewpoints.yaml +5 -5
  98. package/dist/harness/config-file.d.ts +17 -0
  99. package/dist/harness/config-file.d.ts.map +1 -0
  100. package/dist/harness/config-file.js +100 -0
  101. package/dist/harness/config-file.js.map +1 -0
  102. package/dist/harness/depth-lint.d.ts.map +1 -1
  103. package/dist/harness/depth-lint.js +2 -1
  104. package/dist/harness/depth-lint.js.map +1 -1
  105. package/dist/harness/flow-check.d.ts.map +1 -1
  106. package/dist/harness/flow-check.js +6 -2
  107. package/dist/harness/flow-check.js.map +1 -1
  108. package/dist/harness/keyword-match.d.ts +59 -0
  109. package/dist/harness/keyword-match.d.ts.map +1 -0
  110. package/dist/harness/keyword-match.js +86 -0
  111. package/dist/harness/keyword-match.js.map +1 -0
  112. package/dist/harness/perf.d.ts +7 -1
  113. package/dist/harness/perf.d.ts.map +1 -1
  114. package/dist/harness/perf.js +13 -10
  115. package/dist/harness/perf.js.map +1 -1
  116. package/dist/harness/quality-gates.d.ts +9 -2
  117. package/dist/harness/quality-gates.d.ts.map +1 -1
  118. package/dist/harness/quality-gates.js +16 -4
  119. package/dist/harness/quality-gates.js.map +1 -1
  120. package/dist/harness/query-catalog.js +0 -0
  121. package/dist/harness/query-catalog.js.map +1 -1
  122. package/dist/harness/read-text.d.ts +6 -0
  123. package/dist/harness/read-text.d.ts.map +1 -1
  124. package/dist/harness/read-text.js +7 -1
  125. package/dist/harness/read-text.js.map +1 -1
  126. package/dist/harness/secret-scan.d.ts +27 -1
  127. package/dist/harness/secret-scan.d.ts.map +1 -1
  128. package/dist/harness/secret-scan.js +57 -4
  129. package/dist/harness/secret-scan.js.map +1 -1
  130. package/dist/harness/sensors.d.ts +12 -0
  131. package/dist/harness/sensors.d.ts.map +1 -1
  132. package/dist/harness/sensors.js +41 -18
  133. package/dist/harness/sensors.js.map +1 -1
  134. package/dist/harness/spec-branches.d.ts +0 -18
  135. package/dist/harness/spec-branches.d.ts.map +1 -1
  136. package/dist/harness/spec-branches.js +23 -2
  137. package/dist/harness/spec-branches.js.map +1 -1
  138. package/dist/harness/spec-coverage.d.ts.map +1 -1
  139. package/dist/harness/spec-coverage.js +14 -4
  140. package/dist/harness/spec-coverage.js.map +1 -1
  141. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  142. package/dist/harness/viewpoint-ledger.js +2 -1
  143. package/dist/harness/viewpoint-ledger.js.map +1 -1
  144. package/dist/harness/write-atomic.d.ts +2 -0
  145. package/dist/harness/write-atomic.d.ts.map +1 -0
  146. package/dist/harness/write-atomic.js +62 -0
  147. package/dist/harness/write-atomic.js.map +1 -0
  148. package/dist/orchestrator/ai-skills/writer.d.ts.map +1 -1
  149. package/dist/orchestrator/ai-skills/writer.js +15 -7
  150. package/dist/orchestrator/ai-skills/writer.js.map +1 -1
  151. package/dist/orchestrator/assets-drift.d.ts.map +1 -1
  152. package/dist/orchestrator/assets-drift.js +11 -6
  153. package/dist/orchestrator/assets-drift.js.map +1 -1
  154. package/dist/orchestrator/project-deps-sync.d.ts.map +1 -1
  155. package/dist/orchestrator/project-deps-sync.js +22 -1
  156. package/dist/orchestrator/project-deps-sync.js.map +1 -1
  157. package/dist/orchestrator/project-initializer.d.ts +8 -0
  158. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  159. package/dist/orchestrator/project-initializer.js +49 -7
  160. package/dist/orchestrator/project-initializer.js.map +1 -1
  161. package/dist/orchestrator/security-overrides-catalog.d.ts +39 -0
  162. package/dist/orchestrator/security-overrides-catalog.d.ts.map +1 -0
  163. package/dist/orchestrator/security-overrides-catalog.js +63 -0
  164. package/dist/orchestrator/security-overrides-catalog.js.map +1 -0
  165. package/dist/orchestrator/security-overrides.d.ts +51 -0
  166. package/dist/orchestrator/security-overrides.d.ts.map +1 -0
  167. package/dist/orchestrator/security-overrides.js +165 -0
  168. package/dist/orchestrator/security-overrides.js.map +1 -0
  169. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -0
  170. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +3 -1
  171. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +9 -0
  172. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +13 -1
  173. package/dist/orchestrator/templates/specs-locale-fixture.ts +21 -9
  174. package/dist/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  175. package/dist/orchestrator/templates/specs-url-assert.d.ts +17 -1
  176. package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -1
  177. package/dist/orchestrator/templates/specs-url-assert.js +14 -2
  178. package/dist/orchestrator/templates/specs-url-assert.js.map +1 -1
  179. package/dist/orchestrator/templates/specs-url-assert.ts +24 -2
  180. package/dist/orchestrator/templates/wdio.conf.ts +24 -1
  181. package/package.json +3 -3
  182. package/src/cli/commands/audit.ts +10 -2
  183. package/src/cli/commands/capability.ts +51 -18
  184. package/src/cli/commands/delivery.ts +38 -16
  185. package/src/cli/commands/generate.ts +15 -7
  186. package/src/cli/commands/results-merge.ts +2 -8
  187. package/src/cli/commands/results-sanitize.ts +2 -9
  188. package/src/dashboard/snapshot-builder.ts +6 -8
  189. package/src/exporters/feature-parser.ts +32 -2
  190. package/src/exporters/locale-variants.ts +23 -17
  191. package/src/exporters/matrix/build.ts +14 -8
  192. package/src/exporters/matrix/carry-manual-results.ts +229 -0
  193. package/src/exporters/matrix/export.ts +10 -4
  194. package/src/exporters/matrix/wording.ts +26 -1
  195. package/src/exporters/xlsx-exporter.ts +7 -2
  196. package/src/generators/test-generator/adapters/adapter-interface.ts +2 -2
  197. package/src/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +2 -2
  198. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +8 -2
  199. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +7 -3
  200. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +68 -4
  201. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +7 -1
  202. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +8 -4
  203. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +5 -1
  204. package/src/generators/test-generator/code-generator.ts +54 -3
  205. package/src/generators/test-generator/diagnostics.ts +33 -4
  206. package/src/generators/test-generator/patterns/index.ts +15 -8
  207. package/src/generators/test-generator/patterns/types.ts +15 -0
  208. package/src/generators/test-generator/patterns/with-parent-scope.ts +6 -0
  209. package/src/generators/test-generator/step-mapper.ts +90 -8
  210. package/src/generators/test-generator/utils/data-resolver.ts +41 -12
  211. package/src/generators/test-generator/utils/selector-entry-fields.ts +1 -0
  212. package/src/generators/test-generator/utils/selector-resolver.ts +238 -82
  213. package/src/harness/audit.ts +17 -7
  214. package/src/harness/capability.ts +23 -12
  215. package/src/harness/catalog/universal-viewpoints.yaml +5 -5
  216. package/src/harness/config-file.ts +66 -0
  217. package/src/harness/depth-lint.ts +2 -1
  218. package/src/harness/flow-check.ts +6 -2
  219. package/src/harness/keyword-match.ts +85 -0
  220. package/src/harness/perf.ts +15 -7
  221. package/src/harness/quality-gates.ts +16 -4
  222. package/src/harness/query-catalog.ts +0 -0
  223. package/src/harness/read-text.ts +7 -1
  224. package/src/harness/secret-scan.ts +60 -4
  225. package/src/harness/sensors.ts +50 -19
  226. package/src/harness/spec-branches.ts +5 -2
  227. package/src/harness/spec-coverage.ts +14 -4
  228. package/src/harness/viewpoint-ledger.ts +2 -1
  229. package/src/harness/write-atomic.ts +22 -0
  230. package/src/orchestrator/ai-skills/writer.ts +18 -3
  231. package/src/orchestrator/assets-drift.ts +10 -5
  232. package/src/orchestrator/project-deps-sync.ts +23 -1
  233. package/src/orchestrator/project-initializer.ts +54 -6
  234. package/src/orchestrator/security-overrides-catalog.ts +69 -0
  235. package/src/orchestrator/security-overrides.ts +149 -0
  236. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -0
  237. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +3 -1
  238. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +9 -0
  239. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +13 -1
  240. package/src/orchestrator/templates/specs-locale-fixture.ts +21 -9
  241. package/src/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  242. package/src/orchestrator/templates/specs-url-assert.ts +24 -2
  243. package/src/orchestrator/templates/wdio.conf.ts +24 -1
@@ -34,7 +34,7 @@
34
34
  */
35
35
  import * as fs from 'fs';
36
36
  import * as path from 'path';
37
- import { parse as parseYaml } from 'yaml';
37
+ import { readYamlConfig, warnUnreadableConfig } from '../harness/config-file';
38
38
 
39
39
  export interface LocaleVariant {
40
40
  /** Result-file suffix as found on disk ('' = base). */
@@ -65,23 +65,29 @@ export interface LocaleConfig {
65
65
 
66
66
  export const DEFAULT_BASE_LOCALE = 'en';
67
67
 
68
- /** Read the i18n declaration from qa/capabilities.yaml (absent → no locales, base 'en'). */
68
+ /**
69
+ * Read the i18n declaration from qa/capabilities.yaml (absent → no locales, base 'en').
70
+ *
71
+ * An unreadable file previously took the same path as an absent one — the `existsSync` check sat
72
+ * INSIDE the try, so a parse error fell through to the same "undeclared" return. Every declared
73
+ * locale then dropped out of the deliverable with no line anywhere saying why.
74
+ */
69
75
  export function readLocaleConfig(cwd: string): LocaleConfig {
70
- try {
71
- const p = path.join(cwd, 'qa', 'capabilities.yaml');
72
- if (fs.existsSync(p)) {
73
- const parsed = parseYaml(fs.readFileSync(p, 'utf-8')) as
74
- { locales?: unknown; base_locale?: unknown } | null;
75
- const locales = Array.isArray(parsed?.locales)
76
- ? parsed!.locales!.filter((l): l is string => typeof l === 'string' && l.length > 0)
77
- : [];
78
- const baseLocale = typeof parsed?.base_locale === 'string' && parsed.base_locale
79
- ? parsed.base_locale
80
- : DEFAULT_BASE_LOCALE;
81
- return { baseLocale, locales };
82
- }
83
- } catch { /* malformed yaml → behave as undeclared */ }
84
- return { baseLocale: DEFAULT_BASE_LOCALE, locales: [] };
76
+ const p = path.join(cwd, 'qa', 'capabilities.yaml');
77
+ const read = readYamlConfig<{ locales?: unknown; base_locale?: unknown } | null>(p);
78
+ if (read.state === 'absent') return { baseLocale: DEFAULT_BASE_LOCALE, locales: [] };
79
+ if (read.state === 'unreadable') {
80
+ warnUnreadableConfig(p, read.error, 'no locale variants are discovered — the deliverable covers the base locale only');
81
+ return { baseLocale: DEFAULT_BASE_LOCALE, locales: [] };
82
+ }
83
+ const parsed = read.value;
84
+ const locales = Array.isArray(parsed?.locales)
85
+ ? parsed!.locales!.filter((l): l is string => typeof l === 'string' && l.length > 0)
86
+ : [];
87
+ const baseLocale = typeof parsed?.base_locale === 'string' && parsed.base_locale
88
+ ? parsed.base_locale
89
+ : DEFAULT_BASE_LOCALE;
90
+ return { baseLocale, locales };
85
91
  }
86
92
 
87
93
  const OS_SUFFIX = /^(android|ios)(\.|$)/;
@@ -19,7 +19,7 @@ import {
19
19
  import { getCasesDatasetRows, resolveResultVariants } from '../result-variants';
20
20
  import { renderDynamicTokens } from '../test-data-resolver';
21
21
  import { maskCredentialValue } from '../sensitive-key';
22
- import { classifyManualComments, renderAction, renderExpected, renderPrecondition, renderSetupInstruction, runtimeVarsOf } from './wording';
22
+ import { afterSubstitution, classifyManualComments, renderAction, renderExpected, renderPrecondition, renderSetupInstruction, runtimeVarsOf } from './wording';
23
23
  import { scenarioFingerprint, combinedFingerprint, mapContentFingerprint } from './fingerprint';
24
24
  import {
25
25
  CoverageVariant,
@@ -251,10 +251,16 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
251
251
  verification: manual?.verification.map((t) => sentenceOf(t)) ?? [],
252
252
  };
253
253
 
254
- /** Render the display cells for one data context (vars resolved first). */
254
+ /**
255
+ * Render the display cells for one data context. Steps are worded FIRST and values
256
+ * substituted AFTER (#772): wording strips the DSL's `[Ref]` brackets, and a value
257
+ * such as `^[A-Za-z]{3}` or `[E2E-01] title` must never be mistaken for one.
258
+ * Preconditions (rendered at build time above) already went in this order.
259
+ */
255
260
  const renderCells = (vars: Record<string, string>): { trigger: string[]; oracle: string[]; precondition: string[]; verification: string[] } => {
256
261
  const sub = (s: string): string => substituteDisplayVars(s, vars, runtimeVars);
257
- const preconditionOut = precondition.map(sub);
262
+ const fill = (rendered: string): string => afterSubstitution(sub(rendered));
263
+ const preconditionOut = precondition.map(fill);
258
264
  const verificationOut = base.verification.map(sub);
259
265
  if (procedureProfile === 'sequence' && !manual) {
260
266
  // Ordered flow (review B-03): keep the event order — actions stay numbered in
@@ -266,9 +272,9 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
266
272
  const trigger: string[] = [];
267
273
  const oracle: string[] = [];
268
274
  steps.forEach((s, i) => {
269
- if (i > lastActionIdx) oracle.push(renderExpected(sub(s.text))); // trailing Then block
270
- else if (s.bucket === 'then') trigger.push(`Verify: ${renderExpected(sub(s.text))}`);
271
- else if (s.bucket === 'when') trigger.push(renderAction(sub(s.text)));
275
+ if (i > lastActionIdx) oracle.push(fill(renderExpected(s.text))); // trailing Then block
276
+ else if (s.bucket === 'then') trigger.push(`Verify: ${fill(renderExpected(s.text))}`);
277
+ else if (s.bucket === 'when') trigger.push(fill(renderAction(s.text)));
272
278
  // leading given steps already live in the precondition
273
279
  });
274
280
  return { trigger, oracle, precondition: preconditionOut, verification: verificationOut };
@@ -277,8 +283,8 @@ export function deriveVariants(inputs: Pick<BuildInputs, 'feature' | 'merged' |
277
283
  // Method/API/Header/Body lines are already reader-facing, not step DSL).
278
284
  const trigger = apiCallsAreTrigger
279
285
  ? apiRequestLines.map(sub)
280
- : rawTrigger.map((s) => renderAction(sub(s)));
281
- const oracle = rawOracle.map((s) => renderExpected(sub(s)));
286
+ : rawTrigger.map((s) => fill(renderAction(s)));
287
+ const oracle = rawOracle.map((s) => fill(renderExpected(s)));
282
288
  return {
283
289
  // A pure-render check (Given + Then only) is checked on page load — say so
284
290
  // in words instead of a placeholder token (review §10.3).
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Carry a tester's hand-entered Manual results across a `sungen delivery` re-run (#756).
3
+ *
4
+ * The matrix workbook is rebuilt from scratch on every run, and a manual variant has no
5
+ * run-report entry, so its Result cell renders `Pending` and M–P render blank. Without
6
+ * this step the re-run overwrote the file and the tester's record was gone.
7
+ *
8
+ * Before the new workbook is written, the existing file's Manual sheet is read, and each
9
+ * variant sub-row's L–P cells (Test Result, Executed Date, Test Executor, Test Environment,
10
+ * Note) are carried into the new workbook by `v.ref` — column A.
11
+ *
12
+ * A result is only true of the procedure it was executed against, so every write stamps
13
+ * `ref → fingerprint` into a veryHidden sheet. The next run carries a result only while the
14
+ * stamp still matches; a changed procedure resets it to `Pending` and keeps the previous
15
+ * values in Note. Spec: docs/spec/delivery-coverage-matrix-spec.md §8.4.
16
+ *
17
+ * Manual sheet only: on Auto the run report is the source of truth for Result.
18
+ */
19
+
20
+ import * as fs from 'fs';
21
+ import * as ExcelJS from 'exceljs';
22
+ import { MatrixModel } from './types';
23
+ import { HEADER_ROW, RESULT_COL } from './render-testcase-sheet';
24
+
25
+ const MANUAL_SHEET = 'Manual';
26
+ /** veryHidden: Excel's UI cannot unhide it, so a tester cannot edit the guard by accident. */
27
+ export const STAMP_SHEET = '_sungen_manual';
28
+ /** L..P — the tester-owned columns of a variant sub-row. */
29
+ const CARRIED_COLS = [RESULT_COL, RESULT_COL + 1, RESULT_COL + 2, RESULT_COL + 3, RESULT_COL + 4];
30
+ const NOTE_INDEX = 4;
31
+ const RESET_MARKER = '[sungen] Procedure changed since this result was recorded';
32
+
33
+ interface CarriedCell { value: ExcelJS.CellValue; numFmt?: string }
34
+
35
+ interface PriorRow { cells: CarriedCell[] }
36
+
37
+ export interface PriorManualResults {
38
+ rows: Map<string, PriorRow>;
39
+ /** Later copies of a ref already read — only the first is carried, so these are reported. */
40
+ duplicates: { ref: string; result: string }[];
41
+ /** `ref → fingerprint` from the stamp sheet; null when the file predates #756. */
42
+ stamps: Map<string, string> | null;
43
+ }
44
+
45
+ export interface ManualCarryReport {
46
+ /** Carried with a matching fingerprint. */
47
+ carried: number;
48
+ /** Carried from a file with no stamp — no drift check was possible. */
49
+ unchecked: number;
50
+ /** Result reset to Pending because the procedure changed. */
51
+ reset: string[];
52
+ /** Prior rows with an entry that no longer match any manual variant. */
53
+ notPlaced: { ref: string; result: string }[];
54
+ /** Prior rows with an entry that repeat a ref above them — not carried. */
55
+ duplicates: { ref: string; result: string }[];
56
+ }
57
+
58
+ function isFormula(v: ExcelJS.CellValue): boolean {
59
+ return typeof v === 'object' && v !== null && ('formula' in v || 'sharedFormula' in v);
60
+ }
61
+
62
+ function cellText(v: ExcelJS.CellValue): string {
63
+ if (v === null || v === undefined) return '';
64
+ if (v instanceof Date) return isNaN(v.getTime()) ? '' : v.toISOString().slice(0, 10);
65
+ if (typeof v === 'object') {
66
+ if ('richText' in v) return v.richText.map((r) => r.text).join('').trim();
67
+ if ('text' in v) return String(v.text).trim();
68
+ if ('result' in v) return cellText(v.result as ExcelJS.CellValue);
69
+ }
70
+ return String(v).trim();
71
+ }
72
+
73
+ /**
74
+ * ExcelJS reads a formula's text result under a date format as an Invalid Date. Drop that
75
+ * cached result — Excel recomputes it on open — instead of writing NaN back into the file.
76
+ */
77
+ function readable(v: ExcelJS.CellValue): ExcelJS.CellValue {
78
+ if (isFormula(v)) {
79
+ const { result, ...formula } = v as ExcelJS.CellFormulaValue;
80
+ if (result instanceof Date && isNaN(result.getTime())) return formula as ExcelJS.CellValue;
81
+ }
82
+ return v;
83
+ }
84
+
85
+ /** A row still at the rendered default carries nothing. */
86
+ function isUntouched(cells: CarriedCell[]): boolean {
87
+ const [result, ...rest] = cells.map((c) => cellText(c.value));
88
+ return (result === '' || result === 'Pending') && rest.every((t) => t === '');
89
+ }
90
+
91
+ /**
92
+ * Variant sub-rows of a Manual sheet, in row order: `[ref, row]`. Either signal is enough:
93
+ * the renderer puts variants on outline level 1, and a parent's Result is a roll-up formula.
94
+ * Outline levels are lost by some spreadsheet tools on save, and a tester can paste a
95
+ * formula into a variant's Result, so relying on one alone would hide the row silently.
96
+ */
97
+ function variantRows(ws: ExcelJS.Worksheet): [string, ExcelJS.Row][] {
98
+ const out: [string, ExcelJS.Row][] = [];
99
+ for (let r = HEADER_ROW + 1; r <= ws.rowCount; r++) {
100
+ const row = ws.getRow(r);
101
+ const ref = cellText(row.getCell(1).value);
102
+ if (ref && (row.outlineLevel === 1 || !isFormula(row.getCell(RESULT_COL).value))) out.push([ref, row]);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Read the Manual results of the workbook about to be overwritten. Returns null when there is
109
+ * no such file. A file that exists but cannot be read THROWS: overwriting it would destroy the
110
+ * very record this step exists to keep.
111
+ */
112
+ export async function readPriorManualResults(file: string): Promise<PriorManualResults | null> {
113
+ if (!fs.existsSync(file)) return null;
114
+ const wb = new ExcelJS.Workbook();
115
+ try {
116
+ await wb.xlsx.readFile(file);
117
+ } catch (e) {
118
+ throw new Error(
119
+ `cannot read ${file} to carry its Manual results (${(e as Error).message}). ` +
120
+ 'Close it in Excel, or move it aside to start a fresh workbook, then re-run.',
121
+ );
122
+ }
123
+
124
+ // Every workbook sungen writes has a Manual sheet, even an empty one. Without it the
125
+ // tester's results cannot be located — carrying nothing would look like a clean run.
126
+ const ws = wb.getWorksheet(MANUAL_SHEET);
127
+ if (!ws) {
128
+ throw new Error(
129
+ `${file} has no "${MANUAL_SHEET}" sheet, so its Manual results cannot be carried. ` +
130
+ `Rename the sheet back to "${MANUAL_SHEET}", or move the file aside to start a fresh workbook, then re-run.`,
131
+ );
132
+ }
133
+
134
+ const rows = new Map<string, PriorRow>();
135
+ const duplicates: PriorManualResults['duplicates'] = [];
136
+ for (const [ref, row] of variantRows(ws)) {
137
+ const cells = CARRIED_COLS.map((c) => {
138
+ const cell = row.getCell(c);
139
+ return { value: readable(cell.value), ...(cell.numFmt ? { numFmt: cell.numFmt } : {}) };
140
+ });
141
+ if (!rows.has(ref)) rows.set(ref, { cells });
142
+ else if (!isUntouched(cells)) duplicates.push({ ref, result: cellText(cells[0].value) || '(no result)' });
143
+ }
144
+
145
+ const stampWs = wb.getWorksheet(STAMP_SHEET);
146
+ let stamps: Map<string, string> | null = null;
147
+ if (stampWs) {
148
+ stamps = new Map();
149
+ stampWs.eachRow((row, n) => {
150
+ if (n > 1) stamps!.set(cellText(row.getCell(1).value), cellText(row.getCell(2).value));
151
+ });
152
+ }
153
+ return { rows, duplicates, stamps };
154
+ }
155
+
156
+ /**
157
+ * Note for a reset row: what was invalidated, then whatever the tester had written. A note
158
+ * that is a hyperlink (evidence, a defect ticket) stays one — only its text gains the marker.
159
+ */
160
+ function resetNote(prior: CarriedCell[]): ExcelJS.CellValue {
161
+ const [result, date, executor] = prior.map((c) => cellText(c.value));
162
+ const was = [result, date, executor].filter(Boolean).join(' · ');
163
+ const header = `${RESET_MARKER} — previous: ${was}. Re-run required.`;
164
+ const note = prior[NOTE_INDEX].value;
165
+ const own = cellText(note);
166
+ if (typeof note === 'object' && note !== null && 'hyperlink' in note) {
167
+ return { ...note, text: `${header}\n${own}` };
168
+ }
169
+ return own ? `${header}\n${own}` : header;
170
+ }
171
+
172
+ /**
173
+ * Carry `prior` into the freshly rendered `wb`, then stamp every manual variant's fingerprint.
174
+ * Only the Manual sheet's variant sub-rows are touched.
175
+ */
176
+ export function carryManualResults(
177
+ wb: ExcelJS.Workbook,
178
+ model: MatrixModel,
179
+ prior: PriorManualResults | null,
180
+ ): ManualCarryReport {
181
+ const report: ManualCarryReport = { carried: 0, unchecked: 0, reset: [], notPlaced: [], duplicates: prior?.duplicates ?? [] };
182
+ const fingerprints = new Map(
183
+ model.items.flatMap((i) => i.variants).filter((v) => v.mode === 'manual').map((v) => [v.ref, v.fingerprint]),
184
+ );
185
+
186
+ const ws = wb.getWorksheet(MANUAL_SHEET);
187
+ const placed = new Set<string>();
188
+ if (ws && prior) {
189
+ for (const [ref, row] of variantRows(ws)) {
190
+ const before = prior.rows.get(ref);
191
+ if (!before) continue;
192
+ placed.add(ref);
193
+ if (isUntouched(before.cells)) continue;
194
+
195
+ const stamped = prior.stamps?.get(ref);
196
+ // Only a recorded result is a claim about the procedure. A row with no result (a
197
+ // blocker note, or one already reset) is carried as-is, so a second procedure
198
+ // change does not stack a second marker on it.
199
+ const hasResult = !['', 'Pending'].includes(cellText(before.cells[0].value));
200
+ if (stamped !== undefined && stamped !== fingerprints.get(ref) && hasResult) {
201
+ row.getCell(RESULT_COL).value = 'Pending';
202
+ row.getCell(CARRIED_COLS[NOTE_INDEX]).value = resetNote(before.cells);
203
+ report.reset.push(ref);
204
+ continue;
205
+ }
206
+
207
+ CARRIED_COLS.forEach((c, i) => {
208
+ const cell = row.getCell(c);
209
+ cell.value = before.cells[i].value;
210
+ if (before.cells[i].numFmt) cell.numFmt = before.cells[i].numFmt!;
211
+ });
212
+ if (stamped === undefined) report.unchecked++;
213
+ else report.carried++;
214
+ }
215
+ }
216
+
217
+ if (prior) {
218
+ for (const [ref, before] of prior.rows) {
219
+ if (!placed.has(ref) && !isUntouched(before.cells)) {
220
+ report.notPlaced.push({ ref, result: cellText(before.cells[0].value) || '(no result)' });
221
+ }
222
+ }
223
+ }
224
+
225
+ const stampWs = wb.addWorksheet(STAMP_SHEET, { state: 'veryHidden' });
226
+ stampWs.addRow(['ref', 'fingerprint']);
227
+ for (const [ref, fp] of fingerprints) stampWs.addRow([ref, fp]);
228
+ return report;
229
+ }
@@ -16,7 +16,8 @@ import { loadPlaywrightReport } from '../playwright-report-parser';
16
16
  import { mergeFeatureAndSpec } from '../scenario-merger';
17
17
  import { getPackageVersion } from '../package-info';
18
18
  import { writeCsv } from '../csv-exporter';
19
- import { writeXlsx } from '../xlsx-exporter';
19
+ import { deliverableXlsxPath, writeXlsx } from '../xlsx-exporter';
20
+ import { carryManualResults, ManualCarryReport, readPriorManualResults } from './carry-manual-results';
20
21
  import { loadPerfConfig, perfVerdict } from '../../harness/perf';
21
22
  import { loadDeliveryMap, writeDeliveryMap } from './map-loader';
22
23
  import { mapContentFingerprint } from './fingerprint';
@@ -163,13 +164,18 @@ export async function writeMatrixDeliverables(
163
164
  paths: MatrixTargetPaths,
164
165
  model: MatrixModel,
165
166
  format: MatrixFormat = 'xlsx',
166
- ): Promise<{ csvPath?: string; xlsxPath?: string }> {
167
- const out: { csvPath?: string; xlsxPath?: string } = {};
167
+ ): Promise<{ csvPath?: string; xlsxPath?: string; manualCarry?: ManualCarryReport }> {
168
+ const out: { csvPath?: string; xlsxPath?: string; manualCarry?: ManualCarryReport } = {};
169
+ const xlsx = format === 'xlsx' || format === 'both';
170
+ // Read the workbook about to be overwritten BEFORE writing anything, so the tester's Manual
171
+ // results survive (#756) and a refusal leaves no half-written target behind.
172
+ const prior = xlsx ? await readPriorManualResults(deliverableXlsxPath(paths.cwd, paths.unit)) : null;
168
173
  if (format === 'csv' || format === 'both') {
169
174
  out.csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
170
175
  }
171
- if (format === 'xlsx' || format === 'both') {
176
+ if (xlsx) {
172
177
  const wb = renderMatrixXlsx(model, getPackageVersion());
178
+ out.manualCarry = carryManualResults(wb, model, prior);
173
179
  out.xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
174
180
  }
175
181
  return out;
@@ -40,8 +40,25 @@ export function runtimeVarsOf(stepTexts: string[], tags: string[]): Set<string>
40
40
  }
41
41
 
42
42
  // `[Email] field` → `Email field` (the visible label + its element type).
43
+ // Only the DSL's own refs: a `"quoted literal"` and a `{{token}}` are data, so their
44
+ // brackets stay (#772) — `"[DRAFT] report"`, `{{q.rows[0].id}}`. Callers render BEFORE
45
+ // substituting values, so a value's brackets (`^[A-Za-z]{3}`) never reach this.
46
+ // A literal opens only where a word does not end: in free text (manual procedures) `5"`
47
+ // is an inch mark, and two of them must not shield the `[Ref]` between them.
43
48
  function deRef(text: string): string {
44
- return text.replace(/\[([^\]]+)\]/g, '$1');
49
+ return text.replace(/(?<![\p{L}\p{N}])"[^"]*"|\{\{[^}]*\}\}|\[([^\]]+)\]/gu, (m, ref?: string) => ref ?? m);
50
+ }
51
+
52
+ /**
53
+ * Finish a cell rendered with `{{tokens}}` still in it, once the values are substituted
54
+ * (#772: render first, substitute after, so deRef never sees a value). Keeps what
55
+ * `sentence()` did when values went in first: a leading value is capitalised, and a
56
+ * value that already ends a sentence does not gain a second mark (`value..`).
57
+ */
58
+ export function afterSubstitution(text: string): string {
59
+ if (!text) return text;
60
+ const s = text.charAt(0).toUpperCase() + text.slice(1);
61
+ return s.replace(/([.!?…"])\.$/, '$1');
45
62
  }
46
63
 
47
64
  function sentence(text: string): string {
@@ -115,7 +132,15 @@ export function renderExpected(raw: string): string {
115
132
  if (RUNTIME_CAPTURE.test(raw) && /\bas\s+\{\{/.test(raw)) return renderAction(raw);
116
133
  let s = raw.trim().replace(/^(User|The user)\s+/i, '');
117
134
 
135
+ // `table match data:` rows (folded in by feature-parser, #771) are the EXPECTED VALUES —
136
+ // `[E2E-01] Q1` is data, not a ref — so only the table's own ref is de-ref'd (#772).
137
+ const md = s.match(/^sees? \[([^\]]+)\]\s*table match(?:es)? data: (.+)$/i);
138
+ if (md) return sentence(`The ${md[1]} table shows these rows: ${md[2]}`);
139
+
118
140
  const rules: Array<[RegExp, (m: RegExpMatchArray) => string]> = [
141
+ // tab order: (#771) row 1 is the focused origin, each later row is one Tab press —
142
+ // its cells ARE refs, so they are de-ref'd like any step's.
143
+ [/^sees? tab order: (.+)$/i, (m) => `Pressing Tab moves focus in this order: ${m[1]}`],
119
144
  // expect A is (not) B — comparing two captured values (order matters: "is not" first)
120
145
  [/^expects? (.+?) is not (.+)$/i, (m) => `${m[1]} differs from ${m[2]}`],
121
146
  [/^expects? (.+?) is (.+)$/i, (m) => `${m[1]} equals ${m[2]}`],
@@ -113,15 +113,20 @@ export function buildApiDetailRows(
113
113
  * used by the multi-locale delivery aggregator. Pass `null` / empty string
114
114
  * to force the base (no suffix) variant.
115
115
  */
116
+ /** Where `writeXlsx` puts a unit's workbook — shared so a reader targets the identical file. */
117
+ export function deliverableXlsxPath(cwd: string, screen: string, locale?: string | null): string {
118
+ return path.join(cwd, 'qa', 'deliverables', `${deliverableBasename(screen, locale)}.xlsx`);
119
+ }
120
+
116
121
  export async function writeXlsx(
117
122
  cwd: string,
118
123
  screen: string,
119
124
  wb: ExcelJS.Workbook,
120
125
  locale?: string | null,
121
126
  ): Promise<string> {
122
- const outDir = path.join(cwd, 'qa', 'deliverables');
127
+ const outPath = deliverableXlsxPath(cwd, screen, locale);
128
+ const outDir = path.dirname(outPath);
123
129
  if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
124
- const outPath = path.join(outDir, `${deliverableBasename(screen, locale)}.xlsx`);
125
130
  const buffer = await wb.xlsx.writeBuffer();
126
131
  const locked = await lockEmbeddedImages(Buffer.from(buffer as ArrayBuffer));
127
132
  fs.writeFileSync(outPath, locked);
@@ -42,7 +42,7 @@ export interface TestFileData {
42
42
  runtimeData?: boolean; // --runtime-data flag: testData.get() instead of hardcoded values
43
43
  screenName?: string; // Screen name for TestDataLoader.load()
44
44
  featureFileName?: string; // Feature file name for TestDataLoader.load()
45
- isParallel?: boolean; // @parallel tag: fresh page per test (opt-out from serial default)
45
+ isParallel?: boolean; // !@serial: fresh page per test. Parallel is the default; @parallel only names it
46
46
  flowMode?: boolean; // flow: cross-screen → longer per-test timeout (bug #243)
47
47
  platform?: string; // mobile @platform:* value (mobile|android|ios) → `// sungen:platform=` marker for wdio per-OS routing
48
48
  cleanup?: { overlay?: boolean; forms?: boolean; scroll?: boolean; storage?: boolean };
@@ -65,7 +65,7 @@ export interface ScenarioData {
65
65
  scenarioName: string;
66
66
  steps: Array<{ comment?: string; code: string }>;
67
67
  authRole?: string; // Auth role for storage state
68
- isParallel?: boolean; // @parallel: use fresh page from fixture
68
+ isParallel?: boolean; // !@serial: use fresh page from fixture
69
69
  tags?: string; // Pass-through tags for Playwright { tag: [...] }, e.g. "'@smoke', '@high'"
70
70
  casesDataset?: string; // @cases:<dataset> — emit one test() per row of the runtime dataset
71
71
  resetTier?: string; // Mobile (appium): scenario-level `@reset:` override; raises (never lowers) the feature tier for this scenario only
@@ -2,8 +2,8 @@
2
2
  // Persistence oracles (dismissals, pins) hinge on what survives a reinstall.
3
3
  await (async () => {
4
4
  const __pkg = __resolveAppId();
5
- const __caps = (driver.requestedCapabilities || {}) as Record<string, string>;
6
- const __appPath = __caps['appium:app'] || __caps['app'];
5
+ // Same reader the reset tiers use — one place decides what "the session has a build file" means.
6
+ const __appPath = __appBuildPath();
7
7
  if (!__appPath) {
8
8
  throw new Error('reinstall the app: the session must install from a build file — set APP_APK (Android) / IOS_APP (iOS) so the appium:app capability is present.');
9
9
  }
@@ -9,12 +9,18 @@
9
9
  Flutter screen; UiSelector has no OR, hence the xpath. iOS scatters it across
10
10
  label/value, which the predicate already ORs.
11
11
 
12
+ SCOPE (#747): an `in [Parent] <type>` clause chains this under the parent element. The android
13
+ xpath must go RELATIVE (`.//`) to do so — a child xpath beginning with `//` is ABSOLUTE in
14
+ Appium even when chained, so keeping it would emit code that LOOKS scoped and searches the whole
15
+ screen, which is the same silent widening the scope clause exists to prevent. The iOS predicate
16
+ carries no such trap and chains as written.
17
+
12
18
  The expected value must stay a standalone quoted literal so the runtime-data transform
13
19
  can swap it for testData.get(...). --}}
14
20
  {
15
21
  const __expected = String('{{escapeQuotes dataValue}}').trim();
16
22
  const __sel = driver.isIOS
17
23
  ? `-ios predicate string:label == "${__expected}" OR value == "${__expected}"`
18
- : `//*[@text="${__expected}" or @content-desc="${__expected}"]`;
19
- await expect($(__sel)).toBeDisplayed();
24
+ : `{{#if parentLocator}}.{{/if}}//*[@text="${__expected}" or @content-desc="${__expected}"]`;
25
+ await expect({{#if parentLocator}}{{parentLocator}}.$(__sel){{else}}$(__sel){{/if}}).toBeDisplayed();
20
26
  }
@@ -1,13 +1,17 @@
1
1
  {{!-- Role variable, visible (mobile best-effort): ARIA role has no native-mobile analog — reuse
2
2
  the role→accessibility-id mapping in appium-selector.hbs (`~name`) when a static name
3
3
  exists; the resolver already folds dataValue into name when there is none, so this is only
4
- the fallback for a caller that omits both. --}}
4
+ the fallback for a caller that omits both.
5
+
6
+ SCOPE (#747): an `in [Parent] <type>` clause chains this under the parent element. Both
7
+ branches locate by accessibility id / predicate, neither of which carries the absolute-xpath
8
+ trap the label-value twin has to work around. --}}
5
9
  {{#if name}}
6
- await expect($('~{{escapeQuotes name}}')).toBeDisplayed();
10
+ await expect({{#if parentLocator}}{{parentLocator}}.$('~{{escapeQuotes name}}'){{else}}$('~{{escapeQuotes name}}'){{/if}}).toBeDisplayed();
7
11
  {{else}}
8
12
  {
9
13
  const __expected = String('{{escapeQuotes dataValue}}').trim();
10
14
  const __sel = driver.isIOS ? `-ios predicate string:label == "${__expected}" OR value == "${__expected}"` : `android=new UiSelector().text("${__expected}")`;
11
- await expect($(__sel)).toBeDisplayed();
15
+ await expect({{#if parentLocator}}{{parentLocator}}.$(__sel){{else}}$(__sel){{/if}}).toBeDisplayed();
12
16
  }
13
17
  {{/if}}
@@ -502,9 +502,36 @@ describe('{{featureName}}', () => {
502
502
  // lose the escalation between them — SG-W024 already reports that shape when tiers are involved.)
503
503
  let __escalateAfter: string | null = null;
504
504
 
505
+ // `@known-defect` marks a scenario that is EXPECTED to be red — it asserts a live defect. The
506
+ // scaffolded /sungen:run-test command tells authors exactly that ("expected-red scenarios stay red;
507
+ // never 'fix' them"), so the ladder honours it here: such a failure is the suite working as
508
+ // designed, not evidence of leftover state. Arming escalation off it would make the NEXT scenario
509
+ // pay a harder reset for nothing — on every run, forever, because the defect is known.
510
+ //
511
+ // Read off the TITLE because `@known-defect` is a pass-through tag: appium appends it there as the
512
+ // `[@a @b]` suffix (which is what keeps it greppable via `--grep`), and that is how the field
513
+ // report shows it. Anchored to that trailing group, so a scenario merely NAMED `… known-defect …`
514
+ // cannot exempt itself — the same reason __tierFor refuses to strip a trailing `[Selector]` ref.
515
+ //
516
+ // Matched as a whole TAG TOKEN, never as a substring. `@known-defect-list` is a DIFFERENT tag, and
517
+ // reading it as this one would silently stop the next scenario's real dirty-state failure from
518
+ // getting the escalated reset it needs — with no log line at all, because "never armed" is not the
519
+ // "capped" path and prints nothing. The `:<ref>` parameter form is accepted because that is what
520
+ // the rest of the vocabulary does (`@manual:M10`, `@reset:data`).
521
+ //
522
+ // Caveat, inherited rather than introduced: a @cases scenario builds its title at runtime as
523
+ // `name — <row label>`, so a row LABEL that itself ends in `[@…]` would be read as the tag group.
524
+ // Same trailing-bracket ambiguity __tierFor accepts for a `[Selector]` ref.
525
+ function __isExpectedFail(title: string): boolean {
526
+ const tagGroup = title.match(/ \[@[^\]]*\]$/);
527
+ if (!tagGroup) return false;
528
+ return tagGroup[0].slice(2, -1).split(/\s+/)
529
+ .some((t) => t === '@known-defect' || t.startsWith('@known-defect:'));
530
+ }
531
+
505
532
  // Registered BEFORE any user @afterEach block so a throwing user hook cannot skip the bookkeeping.
506
533
  afterEach(async function () {
507
- __escalateAfter = this.currentTest?.state === 'failed' ? (this.currentTest?.title ?? '') : null;
534
+ __escalateAfter = this.currentTest?.state === 'failed' && !__isExpectedFail(this.currentTest?.title ?? '') ? (this.currentTest?.title ?? '') : null;
508
535
  });
509
536
 
510
537
  {{#if scenarioResetTiers}}
@@ -583,9 +610,31 @@ describe('{{featureName}}', () => {
583
610
  await driver.execute('mobile: clearApp', { appId: pkg });
584
611
  }
585
612
 
586
- async function __reinstallApp(pkg: string): Promise<void> {
613
+ /** The app's build file, when the session was given one. Absent when the app was attached BY PACKAGE. */
614
+ function __appBuildPath(): string | undefined {
587
615
  const caps = (driver.requestedCapabilities || {}) as Record<string, string>;
588
- const appPath = caps['appium:app'] || caps['app'];
616
+ return caps['appium:app'] || caps['app'];
617
+ }
618
+
619
+ /**
620
+ * The strongest tier THIS SESSION can actually carry out.
621
+ *
622
+ * Reinstalling needs the app's build file, and `data` on iOS IS a reinstall (no `mobile: clearApp`
623
+ * outside UiAutomator2). Attaching the app by package — the common local setup — leaves no build
624
+ * file, so those tiers cannot run at all.
625
+ *
626
+ * This exists so ESCALATION can cap itself. Escalation is a recovery the runner CHOSE; nothing
627
+ * asked for it. Escalating into a tier that throws turns one failed scenario into a dead spec file,
628
+ * because the throw lands in `beforeEach` and mocha then skips every remaining test in the file.
629
+ * A DECLARED tier is never capped — that is an unmet contract and must still throw by name.
630
+ */
631
+ function __performableTier(): string {
632
+ if (__appBuildPath()) return 'full';
633
+ return driver.isIOS ? 'soft' : 'data';
634
+ }
635
+
636
+ async function __reinstallApp(pkg: string): Promise<void> {
637
+ const appPath = __appBuildPath();
589
638
  if (!appPath) {
590
639
  throw new Error(
591
640
  `reset tier needs the app's build file to reinstall — set APP_APK (Android) / IOS_APP (iOS) so the appium:app capability is present, or drop to @reset:soft.`,
@@ -637,9 +686,24 @@ describe('{{featureName}}', () => {
637
686
  // Same title ⟹ this is a retry of the scenario that just failed: keep its declared tier so a real
638
687
  // failure fails both attempts (see __escalateAfter above).
639
688
  const __escalate = __escalateAfter !== null && __escalateAfter !== (this.currentTest?.title ?? '');
640
- const __tier = __escalate ? __ESCALATE[__base] : __base;
689
+ // Escalation caps at what this session can perform, with the DECLARED tier as the FLOOR.
690
+ // The cap stops a tier NOBODY ASKED FOR from throwing in this hook and taking the rest of the
691
+ // spec file with it. The floor stops the cap from silently WEAKENING what the feature declared:
692
+ // a declared tier this session cannot perform still reaches __resetApp and still throws by name,
693
+ // because that one IS an unmet contract.
694
+ const __want = __escalate ? __ESCALATE[__base] : __base;
695
+ const __tier = __escalate
696
+ ? __TIER_ORDER[Math.max(
697
+ __TIER_ORDER.indexOf(__base),
698
+ Math.min(__TIER_ORDER.indexOf(__want), __TIER_ORDER.indexOf(__performableTier())),
699
+ )]
700
+ : __want;
641
701
  if (__tier !== __base) {
642
702
  console.log(` \u2191 reset escalated ${__base} \u2192 ${__tier} (previous scenario failed: "${__escalateAfter}")`);
703
+ } else if (__want !== __base) {
704
+ // Said per occurrence, not once per run: this names the scenario that did NOT get the stronger
705
+ // reset it was owed, which is a fact about THIS scenario, not just about the session.
706
+ console.warn(` ! reset could not escalate ${__base} \u2192 ${__want} — this session has no app build file to reinstall from (the app was attached by package). Staying at ${__base}; set APP_APK (Android) / IOS_APP (iOS) to enable the stronger reset.`);
643
707
  }
644
708
  await __resetApp(__tier);
645
709
  {{#if backgroundSteps}}
@@ -1 +1,7 @@
1
- await expect(page.getByText(/{{#if label}}{{escapeRegex label}}\s*{{else}}(?:^|\s){{/if}}{{escapeRegex dataValue}}$/)).toBeVisible();
1
+ {{!-- Caption+value read: the ref names on-screen TEXT ("学科 -"), not an element, so the locator is
2
+ built inline from the caption rather than through the `locator` partial. That is why the scope
3
+ clause needs its own slot here — the partial's `parentLocator` handling never runs (#747).
4
+ Unscoped, `… in [Education Card 6] card` compiled away to nothing: on a screen repeating ten
5
+ 学歴・職歴 cards the assertion strict-mode-failed where the caption repeats, and where it
6
+ happened to be unique elsewhere it passed having read a DIFFERENT card than the sentence names. --}}
7
+ await expect({{#if parentLocator}}{{parentLocator}}{{else}}page{{/if}}.getByText(/{{#if label}}{{escapeRegex label}}\s*{{else}}(?:^|\s){{/if}}{{escapeRegex dataValue}}$/)).toBeVisible();
@@ -1,9 +1,13 @@
1
+ {{!-- `getByRole` is built inline from role/name rather than through the `locator` partial, so the
2
+ scope clause needs its own slot here exactly as in label-value-assertion — the label branch's
3
+ twin (#747). `see [Logo] image with {{alt}} in [Product Card 3] card` searched every card on
4
+ the page before this. --}}
1
5
  {{#if name}}
2
6
  {{#if dataValue}}
3
- await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }).filter({ hasText: /^{{escapeRegex dataValue}}$/ }){{> locator-nth}}).toBeVisible();
7
+ await expect({{#if parentLocator}}{{parentLocator}}{{else}}page{{/if}}.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }).filter({ hasText: /^{{escapeRegex dataValue}}$/ }){{> locator-nth}}).toBeVisible();
4
8
  {{else}}
5
- await expect(page.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }){{> locator-nth}}).toBeVisible();
9
+ await expect({{#if parentLocator}}{{parentLocator}}{{else}}page{{/if}}.getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }){{> locator-nth}}).toBeVisible();
6
10
  {{/if}}
7
11
  {{else}}
8
- await expect(page.getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}).filter({ hasText: /^{{escapeRegex dataValue}}$/ }){{> locator-nth}}).toBeVisible();
9
- {{/if}}
12
+ await expect({{#if parentLocator}}{{parentLocator}}{{else}}page{{/if}}.getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}).filter({ hasText: /^{{escapeRegex dataValue}}$/ }){{> locator-nth}}).toBeVisible();
13
+ {{/if}}