@sun-asterisk/sungen 3.2.27 → 3.2.29

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 (237) hide show
  1. package/dist/capabilities/discover.d.ts +29 -0
  2. package/dist/capabilities/discover.d.ts.map +1 -1
  3. package/dist/capabilities/discover.js +144 -10
  4. package/dist/capabilities/discover.js.map +1 -1
  5. package/dist/capabilities/registry.d.ts +18 -0
  6. package/dist/capabilities/registry.d.ts.map +1 -1
  7. package/dist/capabilities/registry.js +25 -0
  8. package/dist/capabilities/registry.js.map +1 -1
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/audit.js +9 -0
  11. package/dist/cli/commands/audit.js.map +1 -1
  12. package/dist/cli/commands/capability.js +1 -1
  13. package/dist/cli/commands/capability.js.map +1 -1
  14. package/dist/dashboard/templates/index.html +1 -1
  15. package/dist/exporters/feature-parser.d.ts +1 -1
  16. package/dist/exporters/feature-parser.js +1 -1
  17. package/dist/exporters/matrix/build.d.ts.map +1 -1
  18. package/dist/exporters/matrix/build.js +14 -1
  19. package/dist/exporters/matrix/build.js.map +1 -1
  20. package/dist/exporters/matrix/item-labels.d.ts +35 -0
  21. package/dist/exporters/matrix/item-labels.d.ts.map +1 -0
  22. package/dist/exporters/matrix/item-labels.js +90 -0
  23. package/dist/exporters/matrix/item-labels.js.map +1 -0
  24. package/dist/exporters/matrix/render-coverage-sheet.d.ts +14 -0
  25. package/dist/exporters/matrix/render-coverage-sheet.d.ts.map +1 -0
  26. package/dist/exporters/matrix/render-coverage-sheet.js +160 -0
  27. package/dist/exporters/matrix/render-coverage-sheet.js.map +1 -0
  28. package/dist/exporters/matrix/render-overview-sheet.d.ts +20 -0
  29. package/dist/exporters/matrix/render-overview-sheet.d.ts.map +1 -0
  30. package/dist/exporters/matrix/render-overview-sheet.js +36 -0
  31. package/dist/exporters/matrix/render-overview-sheet.js.map +1 -0
  32. package/dist/exporters/matrix/render-testcase-sheet.d.ts +30 -0
  33. package/dist/exporters/matrix/render-testcase-sheet.d.ts.map +1 -0
  34. package/dist/exporters/matrix/render-testcase-sheet.js +156 -0
  35. package/dist/exporters/matrix/render-testcase-sheet.js.map +1 -0
  36. package/dist/exporters/matrix/render-xlsx.d.ts +41 -31
  37. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
  38. package/dist/exporters/matrix/render-xlsx.js +72 -425
  39. package/dist/exporters/matrix/render-xlsx.js.map +1 -1
  40. package/dist/exporters/matrix/testcase-summary-band.d.ts +30 -0
  41. package/dist/exporters/matrix/testcase-summary-band.d.ts.map +1 -0
  42. package/dist/exporters/matrix/testcase-summary-band.js +75 -0
  43. package/dist/exporters/matrix/testcase-summary-band.js.map +1 -0
  44. package/dist/exporters/matrix/wording.d.ts.map +1 -1
  45. package/dist/exporters/matrix/wording.js +13 -0
  46. package/dist/exporters/matrix/wording.js.map +1 -1
  47. package/dist/exporters/matrix/xlsx-cells.d.ts +38 -0
  48. package/dist/exporters/matrix/xlsx-cells.d.ts.map +1 -0
  49. package/dist/exporters/matrix/xlsx-cells.js +52 -0
  50. package/dist/exporters/matrix/xlsx-cells.js.map +1 -0
  51. package/dist/exporters/xlsx-report-builder.d.ts +12 -1
  52. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  53. package/dist/exporters/xlsx-report-builder.js +10 -2
  54. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  55. package/dist/generators/gherkin-parser/index.d.ts +7 -0
  56. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  57. package/dist/generators/gherkin-parser/index.js +3 -3
  58. package/dist/generators/gherkin-parser/index.js.map +1 -1
  59. package/dist/generators/test-generator/adapters/appium/os-support.d.ts +53 -0
  60. package/dist/generators/test-generator/adapters/appium/os-support.d.ts.map +1 -0
  61. package/dist/generators/test-generator/adapters/appium/os-support.js +200 -0
  62. package/dist/generators/test-generator/adapters/appium/os-support.js.map +1 -0
  63. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/alert-fill-action.hbs +9 -2
  64. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +24 -1
  65. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/hover-action.hbs +6 -2
  66. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +4 -3
  67. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/unknown-element-action.hbs +9 -0
  68. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/have-value-assertion.hbs +34 -1
  69. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +14 -6
  70. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/page-assertion.hbs +6 -0
  71. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/go-back-action.hbs +21 -0
  72. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/set-geolocation-action.hbs +16 -0
  73. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +8 -0
  74. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +4 -1
  75. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +4 -1
  76. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +2 -1
  77. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +7 -0
  78. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +7 -0
  79. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-empty.hbs +4 -1
  80. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +4 -1
  81. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-count.hbs +4 -1
  82. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +12 -4
  83. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-not-exists.hbs +14 -2
  84. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +8 -8
  85. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/id.hbs +1 -1
  86. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/testid.hbs +1 -1
  87. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +8 -8
  88. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +6 -2
  89. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  90. package/dist/generators/test-generator/code-generator.js +6 -2
  91. package/dist/generators/test-generator/code-generator.js.map +1 -1
  92. package/dist/generators/test-generator/diagnostics.d.ts +38 -2
  93. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  94. package/dist/generators/test-generator/diagnostics.js +37 -1
  95. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  96. package/dist/generators/test-generator/patterns/index.d.ts +33 -6
  97. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  98. package/dist/generators/test-generator/patterns/index.js +71 -7
  99. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  100. package/dist/generators/test-generator/patterns/types.d.ts +8 -0
  101. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  102. package/dist/generators/test-generator/step-mapper.d.ts +40 -1
  103. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  104. package/dist/generators/test-generator/step-mapper.js +104 -21
  105. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  106. package/dist/generators/test-generator/utils/selector-resolver.d.ts +24 -1
  107. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  108. package/dist/generators/test-generator/utils/selector-resolver.js +82 -12
  109. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  110. package/dist/harness/audit.d.ts +3 -1
  111. package/dist/harness/audit.d.ts.map +1 -1
  112. package/dist/harness/audit.js +52 -29
  113. package/dist/harness/audit.js.map +1 -1
  114. package/dist/harness/capability-plan.d.ts +4 -0
  115. package/dist/harness/capability-plan.d.ts.map +1 -1
  116. package/dist/harness/capability-plan.js +32 -2
  117. package/dist/harness/capability-plan.js.map +1 -1
  118. package/dist/harness/catalog/drivers.yaml +1 -1
  119. package/dist/harness/catalog/universal-viewpoints.yaml +145 -0
  120. package/dist/harness/next-step.d.ts.map +1 -1
  121. package/dist/harness/next-step.js +1 -0
  122. package/dist/harness/next-step.js.map +1 -1
  123. package/dist/harness/parse.d.ts +6 -0
  124. package/dist/harness/parse.d.ts.map +1 -1
  125. package/dist/harness/parse.js +1 -1
  126. package/dist/harness/parse.js.map +1 -1
  127. package/dist/harness/repair.d.ts.map +1 -1
  128. package/dist/harness/repair.js +8 -1
  129. package/dist/harness/repair.js.map +1 -1
  130. package/dist/harness/sensors.d.ts +49 -0
  131. package/dist/harness/sensors.d.ts.map +1 -1
  132. package/dist/harness/sensors.js +51 -1
  133. package/dist/harness/sensors.js.map +1 -1
  134. package/dist/harness/viewpoint-ledger.d.ts +10 -0
  135. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  136. package/dist/harness/viewpoint-ledger.js +23 -2
  137. package/dist/harness/viewpoint-ledger.js.map +1 -1
  138. package/dist/orchestrator/templates/ai-src/commands/delivery.md +4 -3
  139. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -5
  140. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +48 -1
  141. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +39 -6
  142. package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -0
  143. package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +16 -1
  144. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +11 -0
  145. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +14 -3
  146. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +89 -3
  147. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +58 -1
  148. package/dist/orchestrator/templates/ai-src/skills/sungen-test-design-techniques/SKILL.md +13 -0
  149. package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/SKILL.md +59 -10
  150. package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-d-display.md +1 -0
  151. package/dist/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-f-mobile-native.md +419 -0
  152. package/dist/orchestrator/templates/readme.md +1 -1
  153. package/dist/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  154. package/dist/orchestrator/templates/specs-url-assert.d.ts +31 -0
  155. package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -1
  156. package/dist/orchestrator/templates/specs-url-assert.js +43 -0
  157. package/dist/orchestrator/templates/specs-url-assert.js.map +1 -1
  158. package/dist/orchestrator/templates/specs-url-assert.ts +54 -0
  159. package/dist/orchestrator/templates/wdio.conf.ts +105 -1
  160. package/package.json +3 -3
  161. package/src/capabilities/discover.ts +146 -10
  162. package/src/capabilities/registry.ts +30 -0
  163. package/src/cli/commands/audit.ts +9 -0
  164. package/src/cli/commands/capability.ts +1 -1
  165. package/src/dashboard/templates/index.html +1 -1
  166. package/src/exporters/feature-parser.ts +1 -1
  167. package/src/exporters/matrix/build.ts +13 -1
  168. package/src/exporters/matrix/item-labels.ts +78 -0
  169. package/src/exporters/matrix/render-coverage-sheet.ts +168 -0
  170. package/src/exporters/matrix/render-overview-sheet.ts +56 -0
  171. package/src/exporters/matrix/render-testcase-sheet.ts +178 -0
  172. package/src/exporters/matrix/render-xlsx.ts +67 -448
  173. package/src/exporters/matrix/testcase-summary-band.ts +79 -0
  174. package/src/exporters/matrix/wording.ts +13 -0
  175. package/src/exporters/matrix/xlsx-cells.ts +56 -0
  176. package/src/exporters/xlsx-report-builder.ts +16 -2
  177. package/src/generators/gherkin-parser/index.ts +1 -1
  178. package/src/generators/test-generator/adapters/appium/os-support.ts +176 -0
  179. package/src/generators/test-generator/adapters/appium/templates/steps/actions/alert-fill-action.hbs +9 -2
  180. package/src/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +24 -1
  181. package/src/generators/test-generator/adapters/appium/templates/steps/actions/hover-action.hbs +6 -2
  182. package/src/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +4 -3
  183. package/src/generators/test-generator/adapters/appium/templates/steps/actions/unknown-element-action.hbs +9 -0
  184. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/have-value-assertion.hbs +34 -1
  185. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +14 -6
  186. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/page-assertion.hbs +6 -0
  187. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/go-back-action.hbs +21 -0
  188. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/set-geolocation-action.hbs +16 -0
  189. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +8 -0
  190. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +4 -1
  191. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row.hbs +4 -1
  192. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +2 -1
  193. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +7 -0
  194. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +7 -0
  195. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-empty.hbs +4 -1
  196. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +4 -1
  197. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-count.hbs +4 -1
  198. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +12 -4
  199. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-not-exists.hbs +14 -2
  200. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +8 -8
  201. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/id.hbs +1 -1
  202. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-strategies/testid.hbs +1 -1
  203. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +8 -8
  204. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +6 -2
  205. package/src/generators/test-generator/code-generator.ts +6 -2
  206. package/src/generators/test-generator/diagnostics.ts +39 -2
  207. package/src/generators/test-generator/patterns/index.ts +78 -8
  208. package/src/generators/test-generator/patterns/types.ts +8 -0
  209. package/src/generators/test-generator/step-mapper.ts +109 -19
  210. package/src/generators/test-generator/utils/selector-resolver.ts +98 -12
  211. package/src/harness/audit.ts +56 -31
  212. package/src/harness/capability-plan.ts +33 -1
  213. package/src/harness/catalog/drivers.yaml +1 -1
  214. package/src/harness/catalog/universal-viewpoints.yaml +145 -0
  215. package/src/harness/next-step.ts +1 -0
  216. package/src/harness/parse.ts +7 -1
  217. package/src/harness/repair.ts +8 -1
  218. package/src/harness/sensors.ts +100 -3
  219. package/src/harness/viewpoint-ledger.ts +26 -5
  220. package/src/orchestrator/templates/ai-src/commands/delivery.md +4 -3
  221. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -5
  222. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +48 -1
  223. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +39 -6
  224. package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -0
  225. package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +16 -1
  226. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +11 -0
  227. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +14 -3
  228. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +89 -3
  229. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +58 -1
  230. package/src/orchestrator/templates/ai-src/skills/sungen-test-design-techniques/SKILL.md +13 -0
  231. package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/SKILL.md +59 -10
  232. package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-d-display.md +1 -0
  233. package/src/orchestrator/templates/ai-src/skills/sungen-viewpoint/group-f-mobile-native.md +419 -0
  234. package/src/orchestrator/templates/readme.md +1 -1
  235. package/src/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  236. package/src/orchestrator/templates/specs-url-assert.ts +54 -0
  237. package/src/orchestrator/templates/wdio.conf.ts +105 -1
@@ -21,6 +21,8 @@
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.joinPath = joinPath;
23
23
  exports.urlMatches = urlMatches;
24
+ exports.expectPage = expectPage;
25
+ const test_1 = require("@playwright/test");
24
26
  /**
25
27
  * Join a page's URL with the `with {{v}}` value for a NAVIGATION step
26
28
  * (`Given User is on [X] page with {{v}}`).
@@ -118,4 +120,45 @@ function urlMatches({ pathname, declared, dataRef }) {
118
120
  return queryOk && hashOk;
119
121
  };
120
122
  }
123
+ /**
124
+ * `toHaveURL` is a polling assertion: it passes on the FIRST sample that matches. A client-side
125
+ * router pushes the destination URL before the route guard or the API has answered, so a user
126
+ * with no permission is "on" `/items/detail` for a few hundred milliseconds — long enough for
127
+ * the assertion to sample it, pass, and end the test green — and is then bounced to `/forbidden`
128
+ * with no assertion left to see it. A real project shipped a permission matrix on that oracle;
129
+ * the 403s were found by hand (#662).
130
+ *
131
+ * `expectPage` is the page oracle `see [X] page` compiles to now. Arriving is not enough: the URL
132
+ * has to be STILL matching once the page has settled.
133
+ * 1. arrive — the same predicate `toHaveURL` always used;
134
+ * 2. settle — wait for network to go quiet (bounded: an app that polls forever must not hang
135
+ * the test, so a timeout here is not a failure);
136
+ * 3. hold — sample the URL over a settle window; the moment it stops matching, fail and
137
+ * NAME where it went, because that destination is the finding;
138
+ * 4. re-assert — the arrival predicate, once more, on the settled page.
139
+ *
140
+ * `SUNGEN_URL_SETTLE_MS` (default 1000) is the hold window; `SUNGEN_URL_NETWORKIDLE_MS` (default
141
+ * 3000) bounds step 2. Both are runtime knobs, so a slow environment is a config change, not a
142
+ * regenerate.
143
+ */
144
+ async function expectPage(page, expectation, opts = {}) {
145
+ const settleMs = opts.settleMs ?? Number(process.env.SUNGEN_URL_SETTLE_MS ?? 1000);
146
+ const networkIdleMs = opts.networkIdleMs ?? Number(process.env.SUNGEN_URL_NETWORKIDLE_MS ?? 3000);
147
+ const matches = urlMatches(expectation);
148
+ const assertUrl = opts.assertUrl ?? (async (m) => { await (0, test_1.expect)(page).toHaveURL(m); });
149
+ await assertUrl(matches);
150
+ const arrivedAt = page.url();
151
+ await page.waitForLoadState('networkidle', { timeout: networkIdleMs }).catch(() => undefined);
152
+ const started = Date.now();
153
+ while (Date.now() - started < settleMs) {
154
+ const now = page.url();
155
+ if (!matches(new URL(now))) {
156
+ throw new Error(`page assertion: reached ${arrivedAt} and then left for ${now} after ${Date.now() - started}ms — `
157
+ + 'a route guard, an error boundary or a redirect moved the user away after the URL had already '
158
+ + 'matched. The page the test asserted is not the page the user ended on.');
159
+ }
160
+ await new Promise((r) => setTimeout(r, 50));
161
+ }
162
+ await assertUrl(matches);
163
+ }
121
164
  //# sourceMappingURL=specs-url-assert.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"specs-url-assert.js","sourceRoot":"","sources":["../../../src/orchestrator/templates/specs-url-assert.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"specs-url-assert.js","sourceRoot":"","sources":["../../../src/orchestrator/templates/specs-url-assert.ts"],"names":[],"mappings":";AAAA,oBAAoB;AACpB;;;;;;;;;;;;;;;;;GAiBG;;AAwCH,4BAYC;AA0BD,gCAuCC;AAuBD,gCA6BC;AAvKD,2CAAqD;AAmBrD;;;;;;;;;;;;;;;;;;GAkBG;AACH,SAAgB,QAAQ,CAAC,IAAY,EAAE,IAAY,EAAE,QAAwB;IAC3E,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,KAAK,EAAE;QAAE,OAAO,IAAI,GAAG,IAAI,CAAC;IACvF,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC/B,2FAA2F;IAC3F,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,GAAG,IAAI,GAAG,KAAK,CAAC;IAC/E,yEAAyE;IACzE,IAAI,eAAe,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAC9C,0FAA0F;IAC1F,qEAAqE;IACrE,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,GAAG,KAAK,CAAC;IAC/C,4DAA4D;IAC5D,OAAO,IAAI,GAAG,IAAI,GAAG,KAAK,CAAC;AAC7B,CAAC;AAED;;;;GAIG;AACH,SAAS,WAAW,CAAC,QAAgB;IACnC,IAAI,CAAC;QACH,OAAO,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACtC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,QAAQ,CAAC;IAClB,CAAC;AACH,CAAC;AAED;;;;;;;;;;GAUG;AACH,SAAgB,UAAU,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAkB;IACxE,OAAO,CAAC,CAAM,EAAW,EAAE;QACzB,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;QAExF,8FAA8F;QAC9F,sFAAsF;QACtF,+FAA+F;QAC/F,2FAA2F;QAC3F,mEAAmE;QACnE,IAAI,CAAC,OAAO;YAAE,OAAO,CAAC,CAAC,MAAM,KAAK,EAAE,CAAC;QAErC,8FAA8F;QAC9F,4FAA4F;QAC5F,6FAA6F;QAC7F,2FAA2F;QAC3F,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;YAChD,MAAM,IAAI,KAAK,CAAC,iCAAiC,OAAO,kCAAkC,CAAC,CAAC;QAC9F,CAAC;QACD,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CAAC,iCAAiC,OAAO,gCAAgC,CAAC,CAAC;QAC5F,CAAC;QAED,6FAA6F;QAC7F,6FAA6F;QAC7F,wFAAwF;QACxF,iFAAiF;QACjF,uFAAuF;QACvF,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,uBAAuB,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;QAE1E,4FAA4F;QAC5F,4FAA4F;QAC5F,0FAA0F;QAC1F,yDAAyD;QACzD,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;QAE5F,uDAAuD;QACvD,MAAM,MAAM,GAAG,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,IAAI,CAAC;QAC5C,OAAO,OAAO,IAAI,MAAM,CAAC;IAC3B,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACI,KAAK,UAAU,UAAU,CAC9B,IAA2G,EAC3G,WAA2B,EAC3B,OAAmH,EAAE;IAErH,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,oBAAoB,IAAI,IAAI,CAAC,CAAC;IACnF,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,yBAAyB,IAAI,IAAI,CAAC,CAAC;IAClG,MAAM,OAAO,GAAG,UAAU,CAAC,WAAW,CAAC,CAAC;IACxC,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,CAAC,KAAK,EAAE,CAAsB,EAAE,EAAE,GAAG,MAAM,IAAA,aAAM,EAAC,IAAuB,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAEhI,MAAM,SAAS,CAAC,OAAO,CAAC,CAAC;IACzB,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAE7B,MAAM,IAAI,CAAC,gBAAgB,CAAC,aAAa,EAAE,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IAE9F,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC3B,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,GAAG,QAAQ,EAAE,CAAC;QACvC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YAC3B,MAAM,IAAI,KAAK,CACb,2BAA2B,SAAS,sBAAsB,GAAG,UAAU,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,OAAO;kBAChG,+FAA+F;kBAC/F,wEAAwE,CAC3E,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;IAC9C,CAAC;IAED,MAAM,SAAS,CAAC,OAAO,CAAC,CAAC;AAC3B,CAAC"}
@@ -18,6 +18,8 @@
18
18
  * URL, and needs no regex escaping of runtime values, which is what forced the split.
19
19
  */
20
20
 
21
+ import { expect, type Page } from '@playwright/test';
22
+
21
23
  export interface UrlExpectation {
22
24
  /**
23
25
  * Anchored pathname pattern, compiled by the generator from the page selector's `value`
@@ -132,3 +134,55 @@ export function urlMatches({ pathname, declared, dataRef }: UrlExpectation): (u:
132
134
  return queryOk && hashOk;
133
135
  };
134
136
  }
137
+
138
+ /**
139
+ * `toHaveURL` is a polling assertion: it passes on the FIRST sample that matches. A client-side
140
+ * router pushes the destination URL before the route guard or the API has answered, so a user
141
+ * with no permission is "on" `/items/detail` for a few hundred milliseconds — long enough for
142
+ * the assertion to sample it, pass, and end the test green — and is then bounced to `/forbidden`
143
+ * with no assertion left to see it. A real project shipped a permission matrix on that oracle;
144
+ * the 403s were found by hand (#662).
145
+ *
146
+ * `expectPage` is the page oracle `see [X] page` compiles to now. Arriving is not enough: the URL
147
+ * has to be STILL matching once the page has settled.
148
+ * 1. arrive — the same predicate `toHaveURL` always used;
149
+ * 2. settle — wait for network to go quiet (bounded: an app that polls forever must not hang
150
+ * the test, so a timeout here is not a failure);
151
+ * 3. hold — sample the URL over a settle window; the moment it stops matching, fail and
152
+ * NAME where it went, because that destination is the finding;
153
+ * 4. re-assert — the arrival predicate, once more, on the settled page.
154
+ *
155
+ * `SUNGEN_URL_SETTLE_MS` (default 1000) is the hold window; `SUNGEN_URL_NETWORKIDLE_MS` (default
156
+ * 3000) bounds step 2. Both are runtime knobs, so a slow environment is a config change, not a
157
+ * regenerate.
158
+ */
159
+ export async function expectPage(
160
+ page: { url(): string; waitForLoadState(state: 'networkidle', opts?: { timeout?: number }): Promise<void> },
161
+ expectation: UrlExpectation,
162
+ opts: { settleMs?: number; networkIdleMs?: number; assertUrl?: (matches: (u: URL) => boolean) => Promise<void> } = {},
163
+ ): Promise<void> {
164
+ const settleMs = opts.settleMs ?? Number(process.env.SUNGEN_URL_SETTLE_MS ?? 1000);
165
+ const networkIdleMs = opts.networkIdleMs ?? Number(process.env.SUNGEN_URL_NETWORKIDLE_MS ?? 3000);
166
+ const matches = urlMatches(expectation);
167
+ const assertUrl = opts.assertUrl ?? (async (m: (u: URL) => boolean) => { await expect(page as unknown as Page).toHaveURL(m); });
168
+
169
+ await assertUrl(matches);
170
+ const arrivedAt = page.url();
171
+
172
+ await page.waitForLoadState('networkidle', { timeout: networkIdleMs }).catch(() => undefined);
173
+
174
+ const started = Date.now();
175
+ while (Date.now() - started < settleMs) {
176
+ const now = page.url();
177
+ if (!matches(new URL(now))) {
178
+ throw new Error(
179
+ `page assertion: reached ${arrivedAt} and then left for ${now} after ${Date.now() - started}ms — `
180
+ + 'a route guard, an error boundary or a redirect moved the user away after the URL had already '
181
+ + 'matched. The page the test asserted is not the page the user ended on.',
182
+ );
183
+ }
184
+ await new Promise((r) => setTimeout(r, 50));
185
+ }
186
+
187
+ await assertUrl(matches);
188
+ }
@@ -28,6 +28,26 @@ import * as path from 'node:path';
28
28
  import { execSync } from 'node:child_process';
29
29
  import PwShapeReporter from './specs/reporters/pw-shape-reporter';
30
30
 
31
+ /**
32
+ * Failure-evidence output. `test-results/` is already gitignored — screenshots and page sources
33
+ * of a real app routinely contain live data (a signed-in email, a token rendered on screen), so
34
+ * this directory must never become committable.
35
+ */
36
+ const EVIDENCE_ROOT = path.join('test-results', 'mobile');
37
+ /** A full logcat is unbounded; only the tail is ever diagnostic. */
38
+ const EVIDENCE_LOG_TAIL_LINES = 500;
39
+ /** ~2 MB of accessibility tree is already far more than anyone reads. */
40
+ const EVIDENCE_PAGE_SOURCE_MAX = 2_000_000;
41
+
42
+ /** Filesystem-safe path segment from a feature/test title. */
43
+ function evidenceSlug(s: string): string {
44
+ return String(s)
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9]+/g, '-')
47
+ .replace(/^-+|-+$/g, '')
48
+ .slice(0, 80) || 'unnamed';
49
+ }
50
+
31
51
  /**
32
52
  * Auto-select MOBILE specs and classify each by its target OS (dual-target routing).
33
53
  *
@@ -404,9 +424,93 @@ export const config: WebdriverIO.Config = {
404
424
  // Connectivity self-heal (#615): onPrepare heals a device wedged by a previous KILLED run
405
425
  // (its onComplete never fired) before the session starts; onComplete restores after this run,
406
426
  // pass or fail. Both run in the launcher process, so they fire even when the worker/session died.
407
- onPrepare: () => { restoreConnectivity('onPrepare'); setShowTouches('onPrepare'); },
427
+ onPrepare: () => { restoreConnectivity('onPrepare'); setShowTouches('onPrepare'); fs.mkdirSync(EVIDENCE_ROOT, { recursive: true }); },
408
428
  onComplete: () => { restoreConnectivity('onComplete'); setShowTouches('onComplete'); },
409
429
 
430
+ /**
431
+ * Failure evidence. A mobile failure used to reach the report as a single error string — no
432
+ * image, no tree, no device log — which makes triaging a native UI almost impossible.
433
+ *
434
+ * Only runs on failure, so a passing suite pays nothing. Each capture is wrapped SEPARATELY:
435
+ * one unavailable command (a Flutter page source that blows the buffer, a driver with no log
436
+ * endpoint) must not cost us the other two, and nothing in here may convert an already-failing
437
+ * test into a different error.
438
+ */
439
+ afterTest: async (
440
+ test: { title?: string; parent?: string },
441
+ _context: unknown,
442
+ result: { passed?: boolean },
443
+ ): Promise<void> => {
444
+ if (result?.passed) return;
445
+
446
+ // Opt-out for constrained runners (a device farm billing per artifact, a CI box with no disk).
447
+ // A feature tagged @screenshot:on-failure declares its evidence essential and overrides it.
448
+ const forced = (globalThis as Record<string, unknown>).__sungenForceEvidence === true;
449
+ if (!forced && process.env.SUNGEN_MOBILE_EVIDENCE === 'off') return;
450
+
451
+ const feature = test?.parent || 'Tests';
452
+ const title = test?.title || 'test';
453
+ const dir = path.join(EVIDENCE_ROOT, evidenceSlug(feature), evidenceSlug(title));
454
+ const files: Array<{ name: string; path: string; contentType: string }> = [];
455
+
456
+ try {
457
+ fs.mkdirSync(dir, { recursive: true });
458
+ } catch (e) {
459
+ console.warn(`[sungen] could not create evidence dir ${dir}: ${(e as Error).message}`);
460
+ return;
461
+ }
462
+
463
+ // 1. Screenshot — the single most useful artifact for a UI failure.
464
+ try {
465
+ const shot = path.join(dir, 'screenshot.png');
466
+ await driver.saveScreenshot(shot);
467
+ files.push({ name: 'screenshot', path: shot, contentType: 'image/png' });
468
+ } catch (e) {
469
+ console.warn(`[sungen] screenshot capture failed: ${(e as Error).message}`);
470
+ }
471
+
472
+ // 2. Page source — the accessibility tree, i.e. WHY a selector did not match. Capped: a
473
+ // Flutter/RecyclerView tree can run to tens of MB and there is no value in writing that.
474
+ try {
475
+ const src = await driver.getPageSource();
476
+ const capped = src.length > EVIDENCE_PAGE_SOURCE_MAX
477
+ ? `${src.slice(0, EVIDENCE_PAGE_SOURCE_MAX)}\n<!-- [sungen] truncated at ${EVIDENCE_PAGE_SOURCE_MAX} of ${src.length} chars -->`
478
+ : src;
479
+ if (src.length > EVIDENCE_PAGE_SOURCE_MAX) {
480
+ console.warn(`[sungen] page source ${src.length} chars — truncated to ${EVIDENCE_PAGE_SOURCE_MAX}`);
481
+ }
482
+ const out = path.join(dir, 'page-source.xml');
483
+ fs.writeFileSync(out, capped, 'utf-8');
484
+ files.push({ name: 'page-source', path: out, contentType: 'application/xml' });
485
+ } catch (e) {
486
+ console.warn(`[sungen] page source capture failed: ${(e as Error).message}`);
487
+ }
488
+
489
+ // 3. Device log — logcat on Android, syslog on iOS. Tail only: a full logcat is unbounded.
490
+ try {
491
+ const logType = driver.isIOS ? 'syslog' : 'logcat';
492
+ const entries = (await driver.getLogs(logType)) as Array<{ message?: string } | string>;
493
+ const lines = entries.map((l) => (typeof l === 'string' ? l : l?.message || '')).filter(Boolean);
494
+ const tail = lines.slice(-EVIDENCE_LOG_TAIL_LINES).join('\n');
495
+ const out = path.join(dir, 'device.log');
496
+ fs.writeFileSync(out, tail, 'utf-8');
497
+ files.push({ name: 'device-log', path: out, contentType: 'text/plain' });
498
+ } catch (e) {
499
+ console.warn(`[sungen] device log capture failed: ${(e as Error).message}`);
500
+ }
501
+
502
+ // Hand the paths to the reporter so they land in the result's `attachments`. Resolved off
503
+ // globalThis, not imported — see the note in specs/reporters/pw-shape-reporter.ts.
504
+ try {
505
+ const attach = (globalThis as Record<string, unknown>).__sungenAttachEvidence;
506
+ if (typeof attach === 'function') {
507
+ (attach as (f: string, t: string, files: typeof files) => void)(feature, title, files);
508
+ }
509
+ } catch (e) {
510
+ console.warn(`[sungen] could not register evidence with the reporter: ${(e as Error).message}`);
511
+ }
512
+ },
513
+
410
514
  // iOS + locale: XCUITest's `noReset:false` does NOT actually clear the app's data container
411
515
  // (verified live 2026-06-11) — a Flutter app that persisted its language on a previous launch keeps
412
516
  // it and ignores the new device locale, so the i18n run asserts the wrong language. Wipe the app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.27",
3
+ "version": "3.2.29",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -40,8 +40,8 @@
40
40
  "@babel/types": "^7.28.5",
41
41
  "@cucumber/gherkin": "^37.0.0",
42
42
  "@cucumber/messages": "^31.0.0",
43
- "@sungen/driver-data-factory": "3.2.27",
44
- "@sungen/driver-ui": "3.2.27",
43
+ "@sungen/driver-data-factory": "3.2.29",
44
+ "@sungen/driver-ui": "3.2.29",
45
45
  "chalk": "^5.6.2",
46
46
  "commander": "^14.0.2",
47
47
  "dotenv": "^17.2.3",
@@ -18,9 +18,12 @@
18
18
  * (`--version`, `capability remove`) even when a driver is missing/broken.
19
19
  */
20
20
  import { createRequire } from 'module';
21
+ import * as fs from 'fs';
21
22
  import * as path from 'path';
23
+ import { coreVersion } from '../orchestrator/lockstep-version';
22
24
  import { capabilityRegistry } from './registry';
23
25
  import { LOCAL_DRIVERS, registerCoreCapability } from './builtins';
26
+ import { loadDriverCatalog, readCapabilities } from '../harness/capability';
24
27
 
25
28
  /**
26
29
  * Driver packages relocated out of core, loaded before the in-core `LOCAL_DRIVERS`. As `db`/`api`
@@ -44,18 +47,116 @@ const EXTERNAL_DRIVERS = ['@sungen/driver-ui', '@sungen/driver-db', '@sungen/dri
44
47
  // test-data available out of the box (no `capability add` needed); inert until a field-map/recipe exists.
45
48
  const BUNDLED_DRIVERS = new Set(['@sungen/driver-ui', '@sungen/driver-data-factory']);
46
49
 
50
+ /** One installed copy of a driver package: where it was found from, and which version lives there. */
51
+ export interface DriverCopy {
52
+ /** The anchor the lookup was made from — the project's package.json, or core's own file. */
53
+ anchor: 'project' | 'core';
54
+ resolved: string;
55
+ version: string | undefined;
56
+ }
57
+
58
+ /** `3.2.28-beta.2` and `3.2.28` sit on the same lockstep line; `3.2.20` does not. */
59
+ export function sameVersionLine(a: string | undefined, b: string | undefined): boolean {
60
+ if (!a || !b || a === 'latest' || b === 'latest') return false;
61
+ const line = (v: string) => v.split('-')[0];
62
+ return line(a) === line(b);
63
+ }
64
+
65
+ /**
66
+ * Pick which installed copy of a driver to load.
67
+ *
68
+ * Project-first is right for an OPT-IN driver: `sungen capability add db` installs into the
69
+ * project's node_modules, and a globally-installed core would never see it from its own anchor.
70
+ *
71
+ * It was wrong for a BUNDLED driver. Core pins `@sungen/driver-ui` to its exact version, but the
72
+ * project may hold an older copy — a `^` range from an earlier scaffold, a lock file nobody
73
+ * refreshed — and that copy loaded first, unconditionally. Core 3.2.25 started gating every state
74
+ * clause on `pattern.states` (SG-E021); driver-ui 3.2.20 predates the field; so
75
+ * `see [Filter] button is hidden` threw on the very pattern (`is-hidden`) the message told the
76
+ * author to use. Every `is <state>` step in every project holding a stale driver, on Gherkin that
77
+ * was correct — and the message blamed the Gherkin.
78
+ *
79
+ * So for a bundled driver the copy on core's own version line wins wherever it lives. Only when no
80
+ * copy is on that line does the first one load — and the caller then warns, because that skew is
81
+ * exactly the breakage the user is about to see. An unknown core version (`latest`) keeps the old
82
+ * project-first order: there is no line to match against.
83
+ */
84
+ export function pickDriverCopy(copies: readonly DriverCopy[], core: string, bundled: boolean): DriverCopy | undefined {
85
+ if (!copies.length) return undefined;
86
+ if (bundled && core !== 'latest') {
87
+ const lockstep = copies.find((c) => sameVersionLine(c.version, core));
88
+ if (lockstep) return lockstep;
89
+ }
90
+ return copies[0];
91
+ }
92
+
93
+ function readDriverVersion(req: NodeRequire, name: string, resolved: string): string | undefined {
94
+ // `<pkg>/package.json` is not always exported; fall back to walking up from the entry file.
95
+ try { return req(`${name}/package.json`)?.version; } catch { /* try the walk */ }
96
+ let dir = path.dirname(resolved);
97
+ for (let i = 0; i < 6; i++) {
98
+ try {
99
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
100
+ if (pkg?.name === name) return pkg.version;
101
+ } catch { /* keep walking */ }
102
+ const parent = path.dirname(dir);
103
+ if (parent === dir) break;
104
+ dir = parent;
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** The lockstep contract, in the user's terms, with the one command that restores it. */
110
+ function versionSkewWarning(name: string, loaded: string | undefined, core: string, bypassed?: DriverCopy): string {
111
+ const fix = ` Fix: run \`sungen update\` in the project (or \`npm i ${name}@${core}\`) so every @sungen/driver-* matches sungen ${core}.`;
112
+ if (bypassed) {
113
+ return (
114
+ `⚠ sungen: the project holds "${name}" ${bypassed.version ?? '(unknown version)'} but sungen is ${core} — ` +
115
+ `loading core's own ${loaded ?? core} copy instead. The two are published in lockstep; a driver behind core misses ` +
116
+ `metadata core now requires (for the UI driver: every "is hidden"/"is disabled" clause fails SG-E021 on the pattern that should assert it).\n` + fix
117
+ );
118
+ }
119
+ return (
120
+ `⚠ sungen: "${name}" ${loaded ?? '(unknown version)'} is loaded against sungen ${core} — the two are published in lockstep, ` +
121
+ `and a driver on another version line misses metadata core requires (for the UI driver: every "is hidden"/"is disabled" ` +
122
+ `clause fails SG-E021 on the pattern that should assert it).\n` + fix
123
+ );
124
+ }
125
+
47
126
  function loadExternalDriver(name: string): void {
48
- // Resolve from the user's PROJECT first, then from core's own location. Opt-in drivers
49
- // (`sungen capability add db|api`) install into the PROJECT's node_modules — a globally-installed
50
- // core (`npm i -g`) wouldn't see them via a core-anchored `require`. The bundled @sungen/driver-ui
51
- // is a core dependency (co-located with core), so the second anchor finds it.
52
- for (const anchor of [path.join(process.cwd(), 'package.json'), __filename]) {
127
+ const bundled = BUNDLED_DRIVERS.has(name);
128
+ // Look from the user's PROJECT and from core's own location. Opt-in drivers (`sungen capability add
129
+ // db|api`) install into the PROJECT's node_modules — a globally-installed core (`npm i -g`) wouldn't
130
+ // see them via a core-anchored `require`. The bundled @sungen/driver-ui is a core dependency
131
+ // (co-located with core), so the second anchor always has a copy on core's version line.
132
+ const anchors: Array<[DriverCopy['anchor'], string]> = [
133
+ ['project', path.join(process.cwd(), 'package.json')],
134
+ ['core', __filename],
135
+ ];
136
+ const copies: DriverCopy[] = [];
137
+ const requires = new Map<string, NodeRequire>();
138
+ for (const [anchor, file] of anchors) {
53
139
  let req: NodeRequire;
54
- try { req = createRequire(anchor); } catch { continue; }
140
+ try { req = createRequire(file); } catch { continue; }
55
141
  let resolved: string;
56
142
  try { resolved = req.resolve(name); } catch { continue; } // not installed at this anchor → try next
143
+ if (copies.some((c) => c.resolved === resolved)) continue; // one install seen from both anchors
144
+ requires.set(resolved, req);
145
+ copies.push({ anchor, resolved, version: readDriverVersion(req, name, resolved) });
146
+ }
147
+
148
+ const core = coreVersion();
149
+ const chosen = pickDriverCopy(copies, core, bundled);
150
+ if (chosen) {
151
+ if (core !== 'latest' && chosen.version && !sameVersionLine(chosen.version, core)) {
152
+ console.warn(versionSkewWarning(name, chosen.version, core));
153
+ } else if (bundled && chosen.anchor === 'core' && copies[0] !== chosen) {
154
+ // A stale project copy was bypassed. Loading the right one already fixed the compile; the line
155
+ // is there so the stale dependency gets cleaned up rather than lingering under every future run.
156
+ console.warn(versionSkewWarning(name, chosen.version, core, copies[0]));
157
+ }
57
158
  try {
58
- const mod = req(resolved);
159
+ const mod = requires.get(chosen.resolved)!(chosen.resolved);
59
160
  const register: ((r: typeof capabilityRegistry) => void) | undefined = mod?.sungenDriver?.register ?? mod?.register;
60
161
  if (typeof register === 'function') register(capabilityRegistry);
61
162
  } catch (e) {
@@ -68,10 +169,10 @@ function loadExternalDriver(name: string): void {
68
169
  ` Fix: reinstall it (\`sungen capability add ${name.replace(/^@sungen\/driver-/, '')}\`) or remove it.`,
69
170
  );
70
171
  }
71
- return; // found at this anchor (loaded or warned) — don't fall through to other anchors
172
+ return;
72
173
  }
73
174
  // Not resolvable at ANY anchor.
74
- if (BUNDLED_DRIVERS.has(name)) {
175
+ if (bundled) {
75
176
  // A bundled default is missing → broken install, not a declined opt-in. Warn loudly so the user
76
177
  // sees the real cause instead of a wall of "Unrecognized step pattern".
77
178
  console.warn(
@@ -86,9 +187,44 @@ function loadExternalDriver(name: string): void {
86
187
  // Opt-in driver not installed — capabilities are opt-in, so skip silently.
87
188
  }
88
189
 
190
+ /**
191
+ * Should this driver package load for the project's declared platform?
192
+ *
193
+ * Only an **opt-in platform** driver is gated. Three exclusions, each load-bearing:
194
+ * - `kind: 'capability'` (api/db/mock/data-factory) is gated by its ANNOTATION TAG, not by
195
+ * platform. Gating it here would break `@query`/`@api` on a project that legitimately has both.
196
+ * - `bundled: true` (driver-ui) is the BASELINE vocabulary, not a platform choice. Mobile reuses
197
+ * the whole web pattern set — click, fill, visibility — on the appium adapter, so skipping
198
+ * driver-ui on a `platform: mobile` project would delete almost every step the suite uses.
199
+ * - No `platform:` in `qa/capabilities.yaml` → load everything, exactly as before. A project that
200
+ * predates the profile, and every `--framework`-override path, must keep working.
201
+ *
202
+ * A package the catalog does not know stays ungated: an unknown driver is not evidence of a
203
+ * platform mismatch, and silently dropping it would be the hardest failure here to diagnose.
204
+ */
205
+ function servesPlatform(pkg: string, platform: string | undefined): boolean {
206
+ if (!platform) return true;
207
+ const meta = Object.values(loadDriverCatalog()).find((m) => m.package === pkg);
208
+ if (!meta || meta.kind !== 'platform' || meta.bundled) return true;
209
+ if (meta.id === platform) return true;
210
+ // Record what the gate dropped. SG-W001 needs it: without the driver's patterns in the registry a
211
+ // mobile-only step reads as "sungen has no step form for it", which is untrue — the form exists in
212
+ // a vocabulary this project declined. Recording the SKIP is the honest half; naming the step would
213
+ // need a static list that rots on the next pattern added.
214
+ capabilityRegistry.noteGatedDriver(pkg, meta.id, platform);
215
+ return false;
216
+ }
217
+
89
218
  export function discoverAndRegisterCapabilities(): void {
90
219
  if (capabilityRegistry.isPopulated()) return;
91
- for (const name of EXTERNAL_DRIVERS) loadExternalDriver(name); // ui (external) — first, default capability
220
+ // CG-1b — discovery never read the project's platform, so on a machine holding both drivers the
221
+ // mobile vocabulary was live on web projects and vice versa. `platforms:` on the patterns is what
222
+ // makes a cross-platform step FAIL WELL; this is what keeps it from being reachable at all.
223
+ const { platform } = readCapabilities(process.cwd());
224
+ for (const name of EXTERNAL_DRIVERS) {
225
+ if (!servesPlatform(name, platform)) continue;
226
+ loadExternalDriver(name); // ui (external) — first, default capability
227
+ }
92
228
  for (const driver of LOCAL_DRIVERS) driver.register(capabilityRegistry); // db, api (in-core for now)
93
229
  registerCoreCapability(capabilityRegistry); // generic layer, always present, registered last
94
230
  }
@@ -76,6 +76,10 @@ export interface CapabilityDescriptor {
76
76
 
77
77
  export class CapabilityRegistry {
78
78
  private caps = new Map<string, CapabilityDescriptor>();
79
+ /** package → the platform it serves, for drivers the platform gate skipped. See `noteGatedDriver`. */
80
+ private gated = new Map<string, string>();
81
+ /** The project platform those skips were made FOR — the same value for every entry in `gated`. */
82
+ private gatedFor: string | undefined;
79
83
 
80
84
  /** Register (or replace, by id) a capability descriptor. Idempotent by id. */
81
85
  register(descriptor: CapabilityDescriptor): void {
@@ -116,9 +120,35 @@ export class CapabilityRegistry {
116
120
  return kind ? all.filter((s) => s.kind === kind) : all;
117
121
  }
118
122
 
123
+ /**
124
+ * Record a driver discovery SKIPPED because it does not serve the project's platform.
125
+ *
126
+ * Gating removes the driver's patterns from the registry, which is the point — but it also means
127
+ * a step belonging to that driver reaches SG-W001 ("sungen has no step form for it") instead of
128
+ * SG-W020 ("this step is mobile-only"). SG-W001's sentence is then false. This is the only fact
129
+ * available to correct it: patterns were never loaded, so what was skipped is knowable and which
130
+ * step it owned is not.
131
+ */
132
+ noteGatedDriver(pkg: string, serves: string, projectPlatform: string): void {
133
+ this.gated.set(pkg, serves);
134
+ this.gatedFor = projectPlatform;
135
+ }
136
+
137
+ /** Drivers skipped by the platform gate, as `[package, platform it serves]`. Empty when nothing was gated. */
138
+ gatedDrivers(): Array<[string, string]> {
139
+ return [...this.gated.entries()];
140
+ }
141
+
142
+ /** The project platform the gate applied, or undefined when nothing was gated. */
143
+ gatedForPlatform(): string | undefined {
144
+ return this.gatedFor;
145
+ }
146
+
119
147
  /** Test seam: drop all registrations. */
120
148
  _reset(): void {
121
149
  this.caps.clear();
150
+ this.gated.clear();
151
+ this.gatedFor = undefined;
122
152
  }
123
153
  }
124
154
 
@@ -92,6 +92,15 @@ function render(r: AuditReport): void {
92
92
  for (const c of r.duplicates.clusters.slice(0, 3)) {
93
93
  L(` ${c.sameDataLikely ? '✗ exact' : '○ EP/data family'} (${c.scenarios.length}): ${c.scenarios.slice(0, 3).join(' | ')}${c.scenarios.length > 3 ? ' …' : ''}`);
94
94
  }
95
+ // #694 — printed under the duplicate axis because that is where a reader looks for it, but it
96
+ // is a DIFFERENT signal: ⑤ clusters identical steps, this catches one id on distinct scenarios.
97
+ // Marked BLOCKING inline so a red build states its cause without scrolling to the findings.
98
+ if (r.reusedScenarioIds.length > 0) {
99
+ L(` ✗ BLOCKING — ${r.reusedScenarioIds.length} reused scenario id(s): a shared id ships as several deliverable rows under ONE testcase id`);
100
+ for (const d of r.reusedScenarioIds.slice(0, 3)) {
101
+ L(` ${d.id} ×${d.count}${d.file ? ` in ${d.file}` : ''}: ${d.titles.slice(0, 2).join(' | ')}${d.titles.length > 2 ? ' …' : ''}`);
102
+ }
103
+ }
95
104
  L('');
96
105
  L(` ⑥ Traceability — ${(r.trace.mappedRatio * 100).toFixed(0)}% scenarios linked to viewpoint-overview`);
97
106
  L(` ${r.trace.note}`);
@@ -98,7 +98,7 @@ export function registerCapabilityCommand(program: Command): void {
98
98
  // ── plan (Planner — recommend-only, no install) ──────────────────────────
99
99
  cap
100
100
  .command('plan')
101
- .description('Capability Planner: classify execution modes + @manual reasons (M1–M9) + recommend drivers (no install)')
101
+ .description('Capability Planner: classify execution modes + @manual reasons (M1–M11) + recommend drivers (no install)')
102
102
  .requiredOption('-s, --screen <name>', 'Screen or flow name')
103
103
  .option('--json', 'Output the raw plan JSON')
104
104
  .action((o: { screen: string; json?: boolean }) => {