@sun-asterisk/sungen 3.2.9 → 3.2.10-beta.1

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 (147) hide show
  1. package/dist/capabilities/discover.js +1 -1
  2. package/dist/capabilities/discover.js.map +1 -1
  3. package/dist/capabilities/registry.d.ts +9 -0
  4. package/dist/capabilities/registry.d.ts.map +1 -1
  5. package/dist/capabilities/registry.js +4 -0
  6. package/dist/capabilities/registry.js.map +1 -1
  7. package/dist/cli/commands/delivery.d.ts.map +1 -1
  8. package/dist/cli/commands/delivery.js +15 -5
  9. package/dist/cli/commands/delivery.js.map +1 -1
  10. package/dist/cli/commands/generate.d.ts.map +1 -1
  11. package/dist/cli/commands/generate.js +16 -0
  12. package/dist/cli/commands/generate.js.map +1 -1
  13. package/dist/exporters/spec-parser.d.ts.map +1 -1
  14. package/dist/exporters/spec-parser.js +16 -5
  15. package/dist/exporters/spec-parser.js.map +1 -1
  16. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/alert-accept-action.hbs +1 -1
  17. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/alert-dismiss-action.hbs +1 -1
  18. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/alert-fill-action.hbs +1 -1
  19. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/capture-row-column.hbs +2 -0
  20. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/click-element-with-text.hbs +1 -1
  21. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/click-with-alert-action.hbs +2 -0
  22. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/recipe-sequence.hbs +3 -0
  23. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
  24. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/unknown-element-action.hbs +1 -1
  25. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +3 -0
  26. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element.hbs +1 -1
  27. package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-table-refresh.hbs +3 -0
  28. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/has-text-value.hbs +1 -0
  29. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +1 -1
  30. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +1 -1
  31. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-blur.hbs +1 -0
  32. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click-text.hbs +3 -0
  33. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click.hbs +1 -0
  34. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -0
  35. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-value.hbs +1 -0
  36. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-for-each.hbs +5 -0
  37. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-hover.hbs +1 -0
  38. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-press.hbs +1 -0
  39. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-scroll-into-view.hbs +1 -0
  40. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-type.hbs +1 -0
  41. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-wait-for.hbs +1 -0
  42. package/dist/generators/test-generator/app-profile.d.ts +89 -0
  43. package/dist/generators/test-generator/app-profile.d.ts.map +1 -0
  44. package/dist/generators/test-generator/app-profile.js +157 -0
  45. package/dist/generators/test-generator/app-profile.js.map +1 -0
  46. package/dist/generators/test-generator/code-generator.d.ts +4 -0
  47. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  48. package/dist/generators/test-generator/code-generator.js +7 -0
  49. package/dist/generators/test-generator/code-generator.js.map +1 -1
  50. package/dist/generators/test-generator/diagnostics.d.ts +36 -0
  51. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -0
  52. package/dist/generators/test-generator/diagnostics.js +44 -0
  53. package/dist/generators/test-generator/diagnostics.js.map +1 -0
  54. package/dist/generators/test-generator/patterns/types.d.ts +12 -0
  55. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  56. package/dist/generators/test-generator/recipe-expander.d.ts +32 -0
  57. package/dist/generators/test-generator/recipe-expander.d.ts.map +1 -0
  58. package/dist/generators/test-generator/recipe-expander.js +118 -0
  59. package/dist/generators/test-generator/recipe-expander.js.map +1 -0
  60. package/dist/generators/test-generator/step-mapper.d.ts +17 -0
  61. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  62. package/dist/generators/test-generator/step-mapper.js +70 -0
  63. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  64. package/dist/generators/test-generator/template-engine.d.ts +5 -1
  65. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  66. package/dist/generators/test-generator/template-engine.js +27 -3
  67. package/dist/generators/test-generator/template-engine.js.map +1 -1
  68. package/dist/generators/test-generator/utils/selector-resolver.d.ts +10 -1
  69. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  70. package/dist/generators/test-generator/utils/selector-resolver.js +32 -0
  71. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  72. package/dist/harness/catalog/drivers.yaml +1 -1
  73. package/dist/harness/query-catalog.d.ts +32 -2
  74. package/dist/harness/query-catalog.d.ts.map +1 -1
  75. package/dist/harness/query-catalog.js +0 -0
  76. package/dist/harness/query-catalog.js.map +1 -1
  77. package/dist/index.d.ts +5 -2
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +5 -1
  80. package/dist/index.js.map +1 -1
  81. package/dist/orchestrator/templates/ai-src/commands/create-data-test.md +83 -0
  82. package/dist/orchestrator/templates/ai-src/config/claude.md +3 -1
  83. package/dist/orchestrator/templates/ai-src/config/copilot.md +3 -1
  84. package/dist/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +93 -0
  85. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +12 -1
  86. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +43 -8
  87. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +22 -0
  88. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +14 -1
  89. package/dist/orchestrator/templates/specs-db.d.ts +201 -11
  90. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
  91. package/dist/orchestrator/templates/specs-db.js +440 -91
  92. package/dist/orchestrator/templates/specs-db.js.map +1 -1
  93. package/dist/orchestrator/templates/specs-db.ts +463 -79
  94. package/dist/orchestrator/templates/specs-test-data.ts +39 -3
  95. package/package.json +7 -4
  96. package/src/capabilities/discover.ts +1 -1
  97. package/src/capabilities/registry.ts +13 -0
  98. package/src/cli/commands/delivery.ts +13 -5
  99. package/src/cli/commands/generate.ts +15 -0
  100. package/src/exporters/spec-parser.ts +17 -5
  101. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/alert-accept-action.hbs +1 -1
  102. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/alert-dismiss-action.hbs +1 -1
  103. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/alert-fill-action.hbs +1 -1
  104. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/capture-row-column.hbs +2 -0
  105. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/click-element-with-text.hbs +1 -1
  106. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/click-with-alert-action.hbs +2 -0
  107. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/recipe-sequence.hbs +3 -0
  108. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
  109. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/unknown-element-action.hbs +1 -1
  110. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +3 -0
  111. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element.hbs +1 -1
  112. package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-table-refresh.hbs +3 -0
  113. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/has-text-value.hbs +1 -0
  114. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +1 -1
  115. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +1 -1
  116. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-blur.hbs +1 -0
  117. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click-text.hbs +3 -0
  118. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click.hbs +1 -0
  119. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -0
  120. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-value.hbs +1 -0
  121. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-for-each.hbs +5 -0
  122. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-hover.hbs +1 -0
  123. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-press.hbs +1 -0
  124. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-scroll-into-view.hbs +1 -0
  125. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-type.hbs +1 -0
  126. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-wait-for.hbs +1 -0
  127. package/src/generators/test-generator/app-profile.ts +185 -0
  128. package/src/generators/test-generator/code-generator.ts +9 -0
  129. package/src/generators/test-generator/diagnostics.ts +51 -0
  130. package/src/generators/test-generator/patterns/types.ts +12 -0
  131. package/src/generators/test-generator/recipe-expander.ts +129 -0
  132. package/src/generators/test-generator/step-mapper.ts +73 -0
  133. package/src/generators/test-generator/template-engine.ts +29 -3
  134. package/src/generators/test-generator/utils/selector-resolver.ts +49 -2
  135. package/src/harness/catalog/drivers.yaml +1 -1
  136. package/src/harness/query-catalog.ts +0 -0
  137. package/src/index.ts +5 -2
  138. package/src/orchestrator/templates/ai-src/commands/create-data-test.md +83 -0
  139. package/src/orchestrator/templates/ai-src/config/claude.md +3 -1
  140. package/src/orchestrator/templates/ai-src/config/copilot.md +3 -1
  141. package/src/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +93 -0
  142. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +12 -1
  143. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +43 -8
  144. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +22 -0
  145. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +14 -1
  146. package/src/orchestrator/templates/specs-db.ts +463 -79
  147. package/src/orchestrator/templates/specs-test-data.ts +39 -3
@@ -48,9 +48,27 @@ export class TestDataLoader {
48
48
  if (value === undefined || value === null) {
49
49
  throw new Error(`Test data key not found: ${key}`);
50
50
  }
51
+ // A projected array (e.g. `{{q.rows[*].name}}`) serializes as a JSON list so the full
52
+ // ordered set is preserved for the assert-compare — String([...]) would flatten it to a
53
+ // lossy comma-join that can't be distinguished from a scalar containing commas.
54
+ if (Array.isArray(value)) return this.interpolate(JSON.stringify(value));
51
55
  return this.interpolate(String(value));
52
56
  }
53
57
 
58
+ /**
59
+ * Resolve a key to its RAW, uncoerced value (array/object/number kept as-is) — for binding
60
+ * DB query parameters, where type-strict stores (MySQL numeric columns, MongoDB) require the
61
+ * native type. get() stringifies for Gherkin text; raw() must not. Throws on missing, mirroring
62
+ * get()'s guard.
63
+ */
64
+ raw(key: string): any {
65
+ const value = this.resolve(key);
66
+ if (value === undefined || value === null) {
67
+ throw new Error(`Test data key not found: ${key}`);
68
+ }
69
+ return value;
70
+ }
71
+
54
72
  /**
55
73
  * Substitute embedded `{{ref}}` cross-references inside a resolved string value.
56
74
  * Each ref is looked up via resolve() (flat key, @cases row column, or dotted path),
@@ -78,6 +96,7 @@ export class TestDataLoader {
78
96
  * `q.count` / `q.length` → number of rows
79
97
  * `q.first.col` / `q.last.col` / `q[2].col` → a specific row's column
80
98
  * `q.col` → shorthand for the first row's column
99
+ * `q.rows[*].col` → the `col` of EVERY row, projected to an ordered array
81
100
  */
82
101
  private resolve(key: string): any {
83
102
  // 1. Exact flat key — captured vars (set()) live under a literal, possibly dotted, key.
@@ -88,9 +107,26 @@ export class TestDataLoader {
88
107
  return this.data[key];
89
108
  }
90
109
  // 2. Structured path: head from the row (cases) or shared data, then walk segments.
91
- const tokens = String(key).replace(/\[(\d+)\]/g, '.$1').split('.');
92
- let cur: any = (this.row && tokens[0] in this.row) ? this.row[tokens[0]] : this.data[tokens[0]];
93
- for (let i = 1; i < tokens.length && cur != null; i++) cur = TestDataLoader.step(cur, tokens[i]);
110
+ // `[n]` `.n` (numeric index); `[*]` → `.*` (project the rest of the path over each element).
111
+ const tokens = String(key).replace(/\[(\d+)\]/g, '.$1').replace(/\[\*\]/g, '.*').split('.');
112
+ const cur: any = (this.row && tokens[0] in this.row) ? this.row[tokens[0]] : this.data[tokens[0]];
113
+ return TestDataLoader.walk(cur, tokens, 1);
114
+ }
115
+
116
+ /**
117
+ * Walk the remaining path tokens from `i`. A `*` token on an array projects: the rest of the
118
+ * path is mapped over every element and the collected array returned (recurses for nested `[*]`).
119
+ * Non-`*` tokens use the linear single-step navigation, so numeric/first/last/object access is
120
+ * unchanged.
121
+ */
122
+ private static walk(cur: any, tokens: string[], i: number): any {
123
+ for (; i < tokens.length && cur != null; i++) {
124
+ if (tokens[i] === '*' && Array.isArray(cur)) {
125
+ const rest = tokens.slice(i + 1);
126
+ return cur.map((el) => TestDataLoader.walk(el, rest, 0));
127
+ }
128
+ cur = TestDataLoader.step(cur, tokens[i]);
129
+ }
94
130
  return cur;
95
131
  }
96
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.9",
3
+ "version": "3.2.10-beta.1",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -12,7 +12,7 @@
12
12
  "copy-templates": "mkdir -p dist/generators/test-generator/adapters/playwright/templates/steps && mkdir -p dist/generators/test-generator/templates && mkdir -p dist/orchestrator/templates && mkdir -p dist/dashboard/templates && cp -r src/generators/test-generator/adapters/playwright/templates/*.hbs dist/generators/test-generator/adapters/playwright/templates/ 2>/dev/null || true && cp -r src/generators/test-generator/adapters/playwright/templates/steps dist/generators/test-generator/adapters/playwright/templates/ && mkdir -p dist/generators/test-generator/adapters/appium/templates/steps && cp -r src/generators/test-generator/adapters/appium/templates/*.hbs dist/generators/test-generator/adapters/appium/templates/ 2>/dev/null || true && cp -r src/generators/test-generator/adapters/appium/templates/steps dist/generators/test-generator/adapters/appium/templates/ && cp src/generators/test-generator/templates/*.hbs dist/generators/test-generator/templates/ 2>/dev/null || true && cp -r src/orchestrator/templates/* dist/orchestrator/templates/ && cp src/dashboard/templates/index.html dist/dashboard/templates/index.html && mkdir -p dist/harness/catalog && cp src/harness/catalog/*.yaml dist/harness/catalog/",
13
13
  "build:dashboard": "cd ../../dashboard && npm install --silent && npm run build && cd - && cp ../../dashboard/dist/index.html src/dashboard/templates/index.html",
14
14
  "dev": "tsx src/cli/index.ts",
15
- "test": "tsx tests/golden/run.ts && tsx tests/audit/run.ts && tsx tests/ingest/run.ts && tsx tests/eval/run.ts && tsx tests/exporter/run.ts && tsx tests/exporter/feature-parser-category.run.ts && tsx tests/exporter/api-detail-sheet.run.ts && tsx tests/exporter/overview-sheet.run.ts && tsx tests/exporter/delivery-divider-tcid.run.ts && tsx tests/exporter/note-expected-actual.run.ts && tsx tests/exporter/test-data-vars.run.ts && tsx tests/exporter/mobile-app-id.run.ts && tsx tests/exporter/api-testcase-cells.run.ts && tsx tests/exporter/delivery-cases-result.run.ts && tsx tests/exporter/precondition-auth-role.run.ts && tsx tests/exporter/report-builder-parity.run.ts && tsx tests/exporter/delivery-preflight-testdata.run.ts && tsx tests/dashboard/api-flows-discovery.run.ts && tsx tests/dashboard/status-results-fallback.run.ts && tsx tests/codegen/verb-ref-masking.run.ts && tsx tests/codegen/table-aria-hidden-locators.run.ts && tsx tests/api-runtime/base-path-url-join.run.ts && tsx tests/api-runtime/file-upload-multipart.run.ts && tsx tests/api-runtime/schema-assertion.run.ts && tsx tests/runtime/test-data-cross-ref.run.ts && tsx tests/capabilities/run.ts && tsx tests/openapi/run.ts && tsx tests/api-field-coverage/run.ts && tsx tests/packaging/run.ts && tsx tests/generate-hint/run.ts && tsx tests/template-assertion/run.ts && tsx tests/journey/run.ts && tsx tests/harness/serial-cascade.run.ts && tsx tests/ai-skills/cross-assistant-orphan.run.ts && tsx tests/init/codex-mcp-config.run.ts && tsx src/orchestrator/ai-skills/golden-skills.test.ts && tsx tests/db-runtime/sql-placeholder-rewrite.run.ts && tsx tests/db-runtime/fallback-flow-codified.run.ts && tsx tests/db-runtime/mysql-integration.opt-in.run.ts",
15
+ "test": "tsx tests/golden/run.ts && tsx tests/audit/run.ts && tsx tests/ingest/run.ts && tsx tests/eval/run.ts && tsx tests/exporter/run.ts && tsx tests/exporter/feature-parser-category.run.ts && tsx tests/exporter/api-detail-sheet.run.ts && tsx tests/exporter/overview-sheet.run.ts && tsx tests/exporter/delivery-divider-tcid.run.ts && tsx tests/exporter/note-expected-actual.run.ts && tsx tests/exporter/test-data-vars.run.ts && tsx tests/exporter/mobile-app-id.run.ts && tsx tests/exporter/api-testcase-cells.run.ts && tsx tests/exporter/delivery-cases-result.run.ts && tsx tests/exporter/precondition-auth-role.run.ts && tsx tests/exporter/report-builder-parity.run.ts && tsx tests/exporter/delivery-preflight-testdata.run.ts && tsx tests/exporter/delivery-preflight-selectors.run.ts && tsx tests/dashboard/api-flows-discovery.run.ts && tsx tests/dashboard/status-results-fallback.run.ts && tsx tests/codegen/verb-ref-masking.run.ts && tsx tests/codegen/table-aria-hidden-locators.run.ts && tsx tests/api-runtime/base-path-url-join.run.ts && tsx tests/api-runtime/file-upload-multipart.run.ts && tsx tests/api-runtime/schema-assertion.run.ts && tsx tests/runtime/test-data-cross-ref.run.ts && tsx tests/runtime/test-data-array-projection.run.ts &&tsx tests/capabilities/run.ts && tsx tests/openapi/run.ts && tsx tests/api-field-coverage/run.ts && tsx tests/packaging/run.ts && tsx tests/generate-hint/run.ts && tsx tests/template-assertion/run.ts && tsx tests/journey/run.ts && tsx tests/harness/serial-cascade.run.ts && tsx tests/ai-skills/cross-assistant-orphan.run.ts && tsx tests/init/codex-mcp-config.run.ts && tsx src/orchestrator/ai-skills/golden-skills.test.ts && tsx tests/db-runtime/sql-placeholder-rewrite.run.ts && tsx tests/db-runtime/fallback-flow-codified.run.ts && tsx tests/db-runtime/cosmos-engine.run.ts && tsx tests/db-runtime/mongodb-engine.run.ts && tsx tests/db-runtime/dynamodb-engine.run.ts && tsx tests/db-runtime/mysql-integration.opt-in.run.ts && tsx tests/db-runtime/mongodb-integration.opt-in.run.ts && tsx tests/db-runtime/dynamodb-integration.opt-in.run.ts",
16
16
  "test:update": "tsx tests/golden/run.ts --update && tsx tests/audit/run.ts --update && tsx tests/ingest/run.ts --update",
17
17
  "prepublishOnly": "npm run build:dashboard && npm run build"
18
18
  },
@@ -39,7 +39,7 @@
39
39
  "@babel/types": "^7.28.5",
40
40
  "@cucumber/gherkin": "^37.0.0",
41
41
  "@cucumber/messages": "^31.0.0",
42
- "@sungen/driver-ui": "3.2.9",
42
+ "@sungen/driver-ui": "3.2.10-beta.1",
43
43
  "chalk": "^5.6.2",
44
44
  "commander": "^14.0.2",
45
45
  "dotenv": "^17.2.3",
@@ -65,6 +65,9 @@
65
65
  "LICENSE"
66
66
  ],
67
67
  "devDependencies": {
68
- "mysql2": "^3"
68
+ "mysql2": "^3",
69
+ "mongodb": "^7",
70
+ "@aws-sdk/client-dynamodb": "^3",
71
+ "@aws-sdk/lib-dynamodb": "^3"
69
72
  }
70
73
  }
@@ -27,7 +27,7 @@ import { LOCAL_DRIVERS, registerCoreCapability } from './builtins';
27
27
  * move to their packages (R5.5/R5.6) they join this list and leave `LOCAL_DRIVERS`; eventually this
28
28
  * becomes a scan of installed `@sungen/driver-*` packages (R5.7).
29
29
  */
30
- const EXTERNAL_DRIVERS = ['@sungen/driver-ui', '@sungen/driver-db', '@sungen/driver-api', '@sungen/driver-mobile'];
30
+ const EXTERNAL_DRIVERS = ['@sungen/driver-ui', '@sungen/driver-db', '@sungen/driver-api', '@sungen/driver-mobile', '@sungen/driver-data-factory'];
31
31
 
32
32
  /**
33
33
  * Bundled (non-opt-in) drivers: shipped as a core dependency (`drivers.yaml` `bundled: true`), so the
@@ -62,6 +62,14 @@ export interface CapabilityDescriptor {
62
62
  */
63
63
  cliCommands?: Array<(program: unknown) => void>;
64
64
 
65
+ /**
66
+ * App-profile presets this capability ships (spec: sungen_app_profile_spec.md §3).
67
+ * Keyed by short name (`radix`, `antd`, `native`); each value is an AppProfile-shaped
68
+ * plain object that `qa/app.yaml` can pull in via `extends:`. UI knowledge stays in
69
+ * the UI driver — core only merges data.
70
+ */
71
+ appProfilePresets?: Record<string, unknown>;
72
+
65
73
  // --- Declared for later R-steps; not consumed yet (kept here so the SPI shape is visible). ---
66
74
  // adapter?: string; // codegen adapter id (existing adapterRegistry)
67
75
  }
@@ -97,6 +105,11 @@ export class CapabilityRegistry {
97
105
  return this.all().flatMap((c) => c.patterns ?? []);
98
106
  }
99
107
 
108
+ /** All app-profile presets contributed by registered capabilities (later registration wins on key collision). */
109
+ appProfilePresets(): Record<string, unknown> {
110
+ return Object.assign({}, ...this.all().map((c) => c.appProfilePresets ?? {}));
111
+ }
112
+
100
113
  /** All registered sensors, optionally filtered by kind ('advisory' | 'gate'). */
101
114
  sensors(kind?: Sensor['kind']): Sensor[] {
102
115
  const all = this.all().flatMap((c) => c.sensors ?? []);
@@ -355,11 +355,19 @@ function featureReferencesTestData(featureFile: string): boolean {
355
355
 
356
356
  function checkSelectorsHasEntries(selectorsFile: string, screen: string): boolean {
357
357
  if (!fs.existsSync(selectorsFile)) return false;
358
- const content = fs.readFileSync(selectorsFile, 'utf-8');
359
- // Very small check: YAML has more than just a single `<screen>: page` entry
360
- const topLevelKeys = (content.match(/^[a-zA-Z_][^\s:]*:\s*$/gm) || []).length +
361
- (content.match(/^[a-zA-Z_][^\s:]*:\s/gm) || []).length;
362
- return topLevelKeys > 1;
358
+ // Parse the YAML and count real top-level keys. The old regex heuristic required
359
+ // key names without whitespace, but sungen's own selector-key convention produces
360
+ // multi-word keys ("email address:", "contact points table:") — a file whose keys
361
+ // are ALL multi-word counted as 0 entries and falsely blocked the export.
362
+ try {
363
+ const parsed = parseYaml(fs.readFileSync(selectorsFile, 'utf-8'));
364
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
365
+ // More than just the single `<screen>: page` entry.
366
+ return Object.keys(parsed as Record<string, unknown>).length > 1;
367
+ } catch {
368
+ // Malformed YAML — surface as "missing entries" so preflight points at the file.
369
+ return false;
370
+ }
363
371
  }
364
372
 
365
373
  export function hasBlockingMissing(p: PreflightCheck): boolean {
@@ -99,6 +99,7 @@ export function registerGenerateCommand(program: Command): void {
99
99
  .option('--all', 'Generate tests for all screens, flows, and API areas')
100
100
  .option('--framework <name>', 'Override the platform driver (else read from qa/capabilities.yaml)')
101
101
  .option('--inline-data', 'Hardcode test data at compile time instead of runtime loading')
102
+ .option('--strict', 'Exit non-zero when compile diagnostics are found (unmatched steps, dropped {{values}})')
102
103
  .action(async (options) => {
103
104
  try {
104
105
  const screenName = options.screen;
@@ -192,6 +193,20 @@ export function registerGenerateCommand(program: Command): void {
192
193
 
193
194
  console.log(`\n${results.length} test file(s) generated.`);
194
195
 
196
+ // Compile diagnostics (SG-W001 unmatched step, SG-W002 dropped {{value}}, ...).
197
+ // Warning-only by default; --strict turns any diagnostic into exit 1.
198
+ const diagnostics = generator.getDiagnostics();
199
+ if (diagnostics.count > 0) {
200
+ console.log(`\n⚠️ Compile diagnostics (${diagnostics.count}):`);
201
+ for (const line of diagnostics.formatLines().slice(0, 20)) console.log(` ${line}`);
202
+ if (diagnostics.count > 20) console.log(` … and ${diagnostics.count - 20} more`);
203
+ console.log(` Unmatched steps compile to no-op // TODO comments — the test runs weaker than it reads.`);
204
+ if (options.strict) {
205
+ console.error(`\n--strict: failing generation on ${diagnostics.count} diagnostic(s).`);
206
+ process.exit(1);
207
+ }
208
+ }
209
+
195
210
  // Security S0 — warn (never block) if test-data looks to hold a real secret.
196
211
  const scanDirs: string[] = [];
197
212
  if (screenName) scanDirs.push(path.join(process.cwd(), 'qa', 'screens', screenName));
@@ -20,6 +20,10 @@ function extractTestBlock(content: string, startIdx: number): {
20
20
  // test('title', { tag: [...] }, async ({ page }) => {
21
21
  // Backreference \1 lets the inner title contain the opposite quote type
22
22
  // (e.g. test('Footer "X" link', ...) — common when scenarios cite UI labels).
23
+ // `\\.` consumes any backslash-escaped char FIRST, so the wrapping quote may also appear
24
+ // ESCAPED inside the title (codegen emits `row\'s` for an apostrophe in a scenario name).
25
+ // Without it the title capture stopped at `\'` and the whole test() silently vanished from
26
+ // the parse — the scenario was then mislabelled "Not compiled"/N/A although it ran and passed.
23
27
  // Quote char may be ' " or ` — the backtick form is a data-driven (@cases) title
24
28
  // like `VP-… — ${__row.__label}`.
25
29
  // The options object uses `\{.*?\}` (non-greedy, not `[^}]*`) so a tag value containing a
@@ -27,13 +31,14 @@ function extractTestBlock(content: string, startIdx: number): {
27
31
  // single-line, so `.*?` anchored on `}, async` is safe.
28
32
  // Accept both Playwright `test(...)` and mobile/Appium mocha `it(...)` (issue #391). The leading
29
33
  // `\b` prevents `it(` from matching the tail of a larger word like `submit(` / `edit(`.
30
- const testRegex = /\b(?:test|it)\s*\(\s*(['"`])((?:(?!\1).)+)\1\s*,\s*(?:\{.*?\}\s*,\s*)?async\s*\([^)]*\)\s*=>\s*\{/g;
34
+ const testRegex = /\b(?:test|it)\s*\(\s*(['"`])((?:\\.|(?!\1).)+)\1\s*,\s*(?:\{.*?\}\s*,\s*)?async\s*\([^)]*\)\s*=>\s*\{/g;
31
35
  testRegex.lastIndex = startIdx;
32
36
  const match = testRegex.exec(content);
33
37
  if (!match) return null;
34
38
 
35
- // [1] = outer quote char, [2] = title
36
- const title = match[2];
39
+ // [1] = outer quote char, [2] = title. Unescape `\'`/`\"`/backtick/backslash: the results.json
40
+ // title is the RUNTIME string (`row's`), so the escaped source form would never match it.
41
+ const title = unescapeQuoted(match[2]);
37
42
  const bodyStart = match.index + match[0].length;
38
43
 
39
44
  // Find matching closing brace (accounting for nested braces)
@@ -54,6 +59,11 @@ function extractTestBlock(content: string, startIdx: number): {
54
59
  return { title, body, endIdx };
55
60
  }
56
61
 
62
+ /** Unescape backslash-escaped quote chars (and backslashes) captured from a quoted JS string. */
63
+ function unescapeQuoted(s: string): string {
64
+ return s.replace(/\\(['"`\\])/g, '$1');
65
+ }
66
+
57
67
  /**
58
68
  * Find describe('name', ...) blocks and return their names + body ranges.
59
69
  */
@@ -61,10 +71,12 @@ function extractDescribeBlocks(content: string): Array<{ name: string; bodyStart
61
71
  const result: Array<{ name: string; bodyStart: number; bodyEnd: number }> = [];
62
72
  // Accept Playwright `test.describe(...)` / `test.describe.serial(...)` AND mobile/Appium mocha
63
73
  // `describe(...)` (issue #391). The optional `test.` prefix keeps web specs matching unchanged.
64
- const regex = /(?:test\.)?describe(?:\.serial)?\s*\(\s*['"]([^'"]+)['"]\s*,\s*\(\s*\)\s*=>\s*\{/g;
74
+ // Same escaped-quote handling as the test-title regex: a feature name with an apostrophe
75
+ // (describe('Manager\'s dashboard')) must not drop the whole describe block from the parse.
76
+ const regex = /(?:test\.)?describe(?:\.serial)?\s*\(\s*(['"])((?:\\.|(?!\1).)+)\1\s*,\s*\(\s*\)\s*=>\s*\{/g;
65
77
  let match: RegExpExecArray | null;
66
78
  while ((match = regex.exec(content)) !== null) {
67
- const name = match[1];
79
+ const name = unescapeQuoted(match[2]);
68
80
  const bodyStart = match.index + match[0].length;
69
81
  let depth = 1;
70
82
  let i = bodyStart;
@@ -1 +1 @@
1
- page.on('dialog', dialog => dialog.accept());
1
+ page.once('dialog', dialog => dialog.accept());
@@ -1 +1 @@
1
- page.on('dialog', dialog => dialog.dismiss());
1
+ page.once('dialog', dialog => dialog.dismiss());
@@ -1 +1 @@
1
- page.on('dialog', dialog => dialog.accept('{{escapeQuotes fillValue}}'));
1
+ page.once('dialog', dialog => dialog.accept('{{escapeQuotes fillValue}}'));
@@ -0,0 +1,2 @@
1
+ { const tableRow = {{> locator}}.getByRole('row').nth({{{rowIndexExpr}}});
2
+ testData.set('{{varName}}', ((await {{{cellExpr}}}.textContent()) ?? '').trim()); }
@@ -1 +1 @@
1
- await {{> locator-base}}.filter({ hasText: {{#if (and (eq strategy "text") (eq value ""))}}/.*{{escapeRegex dataValue}}$/{{else}}'{{escapeQuotes dataValue}}'{{/if}} }){{> locator-nth}}.click();
1
+ await {{> locator-base}}.filter({ hasText: {{> has-text-value dataValue=dataValue exact=exact legacyTail=(and (eq strategy "text") (eq value ""))}} }){{> locator-nth}}.click();
@@ -0,0 +1,2 @@
1
+ page.once('dialog', dialog => dialog.{{dialogAction}}());
2
+ await {{> locator}}.click();
@@ -0,0 +1,3 @@
1
+ {{#each steps}}
2
+ {{> (lookup this 'partial') this}}
3
+ {{/each}}
@@ -0,0 +1,2 @@
1
+ { const tableRow = {{> locator}}.getByRole('row').nth({{{rowIndexExpr}}});
2
+ await {{{actionLocator}}}.{{action}}(); }
@@ -1 +1 @@
1
- await page.getByText('{{escapeQuotes selectorValue}}').filter({ hasText: {{#if (eq selectorValue "")}}/.*{{escapeRegex dataValue}}$/{{else}}'{{escapeQuotes dataValue}}'{{/if}} }){{#if (gte nth 0)}}.nth({{nth}}){{/if}}.{{action}}();
1
+ await page.getByText('{{escapeQuotes selectorValue}}').filter({ hasText: {{> has-text-value dataValue=dataValue exact=exact legacyTail=(eq selectorValue "")}} }){{#if (gte nth 0)}}.nth({{nth}}){{/if}}.{{action}}();
@@ -1 +1,4 @@
1
1
  await expect(page).toHaveURL(/{{escapeRegex path}}/);
2
+ {{#if query}}
3
+ expect(page.url()).toContain('{{escapeQuotes query}}');
4
+ {{/if}}
@@ -1 +1 @@
1
- await {{> locator}}.waitFor({ state: '{{state}}' });
1
+ {{#if (eq state "enabled")}}await expect({{> locator}}).toBeEnabled();{{else if (eq state "disabled")}}await expect({{> locator}}).toBeDisabled();{{else}}await {{> locator}}.waitFor({ state: '{{state}}' });{{/if}}
@@ -0,0 +1,3 @@
1
+ { const refreshIndicator = page.locator('{{escapeQuotes indicator}}');
2
+ await refreshIndicator.first().waitFor({ state: 'visible', timeout: 1500 }).catch(() => {});
3
+ await refreshIndicator.first().waitFor({ state: 'hidden' }); }
@@ -0,0 +1 @@
1
+ {{#if exact}}/^{{escapeRegex dataValue}}$/{{else if legacyTail}}/.*{{escapeRegex dataValue}}$/{{else}}'{{escapeQuotes dataValue}}'{{/if}}
@@ -8,7 +8,7 @@
8
8
  {{~#case 'id'}}locator('{{value}}'){{/case~}}
9
9
  {{~#default}}locator('{{value}}'){{/default~}}
10
10
  {{~/switch}}{{else~}}
11
- {{#if scope}}{{pageRoot}}.getByLabel('{{escapeQuotes scope}}').{{#switch strategy~}}
11
+ {{#if scope}}{{#if (eq scope "dialog")}}{{pageRoot}}.{{else}}{{pageRoot}}.getByLabel('{{escapeQuotes scope}}').{{/if}}{{#switch strategy~}}
12
12
  {{~#case 'testid'}}getByTestId('{{value}}'){{/case~}}
13
13
  {{~#case 'role'}}{{#if name}}getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }){{else}}getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}){{/if}}{{/case~}}
14
14
  {{~#case 'placeholder'}}getByPlaceholder('{{escapeQuotes value}}'{{#if exact}}, { exact: true }{{/if}}){{/case~}}
@@ -8,7 +8,7 @@
8
8
  {{~#case 'id'}}locator('{{value}}'){{/case~}}
9
9
  {{~#default}}locator('{{value}}'){{/default~}}
10
10
  {{~/switch}}{{> locator-nth}}{{else~}}
11
- {{#if scope}}{{pageRoot}}.getByLabel('{{escapeQuotes scope}}').{{#switch strategy~}}
11
+ {{#if scope}}{{#if (eq scope "dialog")}}{{pageRoot}}.{{else}}{{pageRoot}}.getByLabel('{{escapeQuotes scope}}').{{/if}}{{#switch strategy~}}
12
12
  {{~#case 'testid'}}getByTestId('{{value}}'){{/case~}}
13
13
  {{~#case 'role'}}{{#if name}}getByRole('{{role}}', { name: '{{escapeQuotes name}}'{{#if exact}}, exact: true{{/if}} }){{else}}getByRole('{{role}}'{{#if exact}}, { exact: true }{{/if}}){{/if}}{{/case~}}
14
14
  {{~#case 'placeholder'}}getByPlaceholder('{{escapeQuotes value}}'{{#if exact}}, { exact: true }{{/if}}){{/case~}}
@@ -0,0 +1 @@
1
+ await {{{targetExpr}}}.evaluate((el) => (el instanceof HTMLElement) && el.blur());
@@ -0,0 +1,3 @@
1
+ { const __opt = {{{targetExpr}}};
2
+ await __opt.scrollIntoViewIfNeeded().catch(() => {});
3
+ await __opt.click(); }
@@ -0,0 +1 @@
1
+ await expect({{{targetExpr}}}).toContainText({{{valueExpr}}});
@@ -0,0 +1 @@
1
+ await expect({{{targetExpr}}}).toHaveValue({{{valueExpr}}});
@@ -0,0 +1,5 @@
1
+ for (const item of String({{{sourceExpr}}}).split('{{split}}').map((s) => s.trim())) {
2
+ {{#each body}}
3
+ {{> (lookup this 'partial') this}}
4
+ {{/each}}
5
+ }
@@ -0,0 +1 @@
1
+ await page.keyboard.press('{{key}}');
@@ -0,0 +1 @@
1
+ await {{{targetExpr}}}.scrollIntoViewIfNeeded();
@@ -0,0 +1 @@
1
+ await page.keyboard.type({{{valueExpr}}});
@@ -0,0 +1 @@
1
+ await {{{targetExpr}}}.first().waitFor({ state: '{{state}}' });
@@ -0,0 +1,185 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import yaml from 'yaml';
4
+
5
+ /**
6
+ * App Profile (`qa/app.yaml`) — the per-project BEHAVIOR layer (spec:
7
+ * docs/spec/sungen_app_profile_spec.md). Three kinds of knowledge live in three files:
8
+ * INTENT → .feature + test-data (portable across projects)
9
+ * IDENTITY → selectors.yaml (per-screen: WHERE an element is)
10
+ * BEHAVIOR → qa/app.yaml (per-project: HOW this app's UI behaves)
11
+ *
12
+ * The file describes the app under test, never the tool (no timeouts/retries/sleeps —
13
+ * those belong to sungen.config.yaml). One file per project; presets merge in via
14
+ * `extends:`; per-element `variant:`/`scope:` in selectors.yaml wins over the profile.
15
+ * No file → the built-in defaults below → pre-profile behavior stays intact.
16
+ */
17
+
18
+ export interface AppProfile {
19
+ version?: number;
20
+ extends?: string | string[];
21
+ app?: { ui_library?: string; framework?: string; locales?: string[]; base_locale?: string };
22
+ widgets?: Record<string, string | { recipe?: string; variant?: string }>;
23
+ recipes?: Record<string, unknown>;
24
+ layers?: { portal_roles?: string[] };
25
+ feedback?: {
26
+ toast?: { role?: string; named_by?: 'content' | 'label'; stacking?: string };
27
+ loading?: { indicator?: string };
28
+ field_error?: { locate?: string; indicator?: string };
29
+ confirm?: string;
30
+ };
31
+ conventions?: Record<string, unknown>;
32
+ detection?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface ProfileWarning {
36
+ code: 'SG-W005' | 'SG-W006';
37
+ message: string;
38
+ }
39
+
40
+ export interface ResolvedAppProfile {
41
+ /** ARIA roles rendered via portal — never wrapped in dialog/landmark scope (issue #465). */
42
+ portalRoles: string[];
43
+ /** Roles whose accessible name derives from CONTENT — defaulting name from the Gherkin
44
+ * reference can never match (issue #467). */
45
+ contentNamedRoles: string[];
46
+ /** widget kind → variant/recipe name, normalized (`select: radix-select`). Per-element
47
+ * `variant:` in selectors.yaml overrides this. */
48
+ widgetVariants: Record<string, string>;
49
+ /** recipe name → { intent → primitive list } — built-in variant recipes merged under
50
+ * project-defined ones (project wins). */
51
+ recipes: Record<string, Record<string, unknown[]>>;
52
+ /** Refresh/loading signal for `wait for [T] table to refresh` (feedback.loading.indicator). */
53
+ loadingIndicator: string;
54
+ /** Positional-row conventions (`row {{n}}` steps): first row number + whether the header
55
+ * counts as a row in getByRole('row') indexing. */
56
+ tableRowBase: number;
57
+ tableHeaderIsRow: boolean;
58
+ /** The merged profile (presets ← project), or null when qa/app.yaml does not exist. */
59
+ raw: AppProfile | null;
60
+ warnings: ProfileWarning[];
61
+ }
62
+
63
+ /** Portal-rendered option layers (Radix/MUI/Headless UI/antd portal to document.body). */
64
+ export const DEFAULT_PORTAL_ROLES = ['option', 'listbox', 'menu', 'menuitem', 'tooltip'];
65
+
66
+ /** ARIA name-from-content roles (per spec): a `{ name }` filter invented from the Gherkin
67
+ * reference text can never match these. */
68
+ export const DEFAULT_CONTENT_NAMED_ROLES = ['status', 'alert', 'log', 'marquee', 'timer'];
69
+
70
+ /** Default refresh signal — apps that set aria-busy while loading need zero config. */
71
+ export const DEFAULT_LOADING_INDICATOR = '[aria-busy="true"]';
72
+
73
+ const KNOWN_TOP_KEYS = ['version', 'extends', 'app', 'widgets', 'recipes', 'layers', 'feedback', 'conventions', 'detection'];
74
+
75
+ /** Reserved preset key: always merged as the base layer (built-in widget recipes ship here). */
76
+ const BUILTIN_PRESET_KEY = 'builtin';
77
+
78
+ export function defaultAppProfile(presets: Record<string, unknown> = {}): ResolvedAppProfile {
79
+ return finalizeProfile((presets[BUILTIN_PRESET_KEY] as AppProfile) ?? {}, null, []);
80
+ }
81
+
82
+ /** Derive the compiler-consumed slices from a merged profile document. */
83
+ function finalizeProfile(merged: AppProfile, raw: AppProfile | null, warnings: ProfileWarning[]): ResolvedAppProfile {
84
+ const portalRoles = merged.layers?.portal_roles ?? DEFAULT_PORTAL_ROLES;
85
+ const contentNamed = new Set(DEFAULT_CONTENT_NAMED_ROLES);
86
+ if (merged.feedback?.toast?.named_by === 'content' && merged.feedback.toast.role) {
87
+ contentNamed.add(merged.feedback.toast.role);
88
+ }
89
+ const widgetVariants: Record<string, string> = {};
90
+ for (const [kind, v] of Object.entries(merged.widgets ?? {})) {
91
+ const name = typeof v === 'string' ? v : (v?.recipe ?? v?.variant);
92
+ if (name) widgetVariants[kind] = name;
93
+ }
94
+ const conventions = merged.conventions as { table?: { row_base?: number; header_is_row?: boolean } } | undefined;
95
+ return {
96
+ portalRoles,
97
+ contentNamedRoles: [...contentNamed],
98
+ widgetVariants,
99
+ recipes: (merged.recipes ?? {}) as Record<string, Record<string, unknown[]>>,
100
+ loadingIndicator: merged.feedback?.loading?.indicator ?? DEFAULT_LOADING_INDICATOR,
101
+ tableRowBase: conventions?.table?.row_base ?? 1,
102
+ tableHeaderIsRow: conventions?.table?.header_is_row ?? false,
103
+ raw,
104
+ warnings,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Load and resolve `qa/app.yaml`: merge the `extends` preset chain (project wins),
110
+ * validate top-level keys, and derive the compiler-consumed slices. Never throws on
111
+ * a bad profile — problems surface as SG-W005/W006 diagnostics, and each slice falls
112
+ * back to its built-in default.
113
+ */
114
+ export function loadAppProfile(cwd: string, presets: Record<string, unknown> = {}): ResolvedAppProfile {
115
+ const warnings: ProfileWarning[] = [];
116
+ const qaDir = path.join(cwd, 'qa');
117
+
118
+ // Per-environment profiles are unsupported BY DESIGN — widget behavior must not differ
119
+ // between environments (test-data/locale overlays already cover what legitimately does).
120
+ if (fs.existsSync(qaDir)) {
121
+ for (const name of fs.readdirSync(qaDir)) {
122
+ if (/^app\.[^.]+\.ya?ml$/.test(name)) {
123
+ warnings.push({
124
+ code: 'SG-W006',
125
+ message: `qa/${name}: per-environment app profiles are unsupported by design — describe the app once in qa/app.yaml`,
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ const file = path.join(qaDir, 'app.yaml');
132
+ if (!fs.existsSync(file)) {
133
+ return { ...defaultAppProfile(presets), warnings };
134
+ }
135
+
136
+ let doc: AppProfile;
137
+ try {
138
+ doc = (yaml.parse(fs.readFileSync(file, 'utf-8')) ?? {}) as AppProfile;
139
+ } catch (e) {
140
+ warnings.push({ code: 'SG-W005', message: `qa/app.yaml: unparseable YAML (${e instanceof Error ? e.message : e}) — using built-in defaults` });
141
+ return { ...defaultAppProfile(presets), warnings };
142
+ }
143
+
144
+ for (const key of Object.keys(doc)) {
145
+ if (!KNOWN_TOP_KEYS.includes(key)) {
146
+ warnings.push({ code: 'SG-W005', message: `qa/app.yaml: unknown key "${key}" (known: ${KNOWN_TOP_KEYS.join(', ')})` });
147
+ }
148
+ }
149
+
150
+ // extends: preset chain, merged in order, project file last (narrow beats broad).
151
+ // The reserved `builtin` preset (built-in widget recipes from driver-ui) is always
152
+ // the base layer, so per-element `variant: radix-select` works without any extends.
153
+ const extendNames = doc.extends ? (Array.isArray(doc.extends) ? doc.extends : [doc.extends]) : [];
154
+ const availableNames = Object.keys(presets).filter((k) => k !== BUILTIN_PRESET_KEY);
155
+ const chain: AppProfile[] = presets[BUILTIN_PRESET_KEY] ? [presets[BUILTIN_PRESET_KEY] as AppProfile] : [];
156
+ for (const name of extendNames) {
157
+ const key = String(name).replace(/^@sungen\/app-/, '');
158
+ const preset = presets[key] as AppProfile | undefined;
159
+ if (!preset || key === BUILTIN_PRESET_KEY) {
160
+ warnings.push({
161
+ code: 'SG-W005',
162
+ message: `qa/app.yaml: unknown preset "${name}" in extends (available: ${availableNames.join(', ') || 'none — is the driver that ships it installed?'})`,
163
+ });
164
+ } else {
165
+ chain.push(preset);
166
+ }
167
+ }
168
+ const merged = [...chain, doc].reduce<AppProfile>((acc, layer) => deepMerge(acc, layer) as AppProfile, {});
169
+
170
+ return finalizeProfile(merged, merged, warnings);
171
+ }
172
+
173
+ /** Plain-object deep merge; arrays and scalars replace (a preset's list is a whole answer). */
174
+ function deepMerge(base: unknown, layer: unknown): unknown {
175
+ if (!isPlainObject(base) || !isPlainObject(layer)) return layer === undefined ? base : layer;
176
+ const out: Record<string, unknown> = { ...base };
177
+ for (const [k, v] of Object.entries(layer)) {
178
+ out[k] = k in out ? deepMerge(out[k], v) : v;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
184
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
185
+ }
@@ -4,6 +4,7 @@ import { ParsedFeature, ParsedScenario, ParsedStep } from '../gherkin-parser';
4
4
  import { StepMapper } from './step-mapper';
5
5
  import { TestGeneratorAdapter, adapterRegistry } from './adapters';
6
6
  import { transformToRuntimeData } from './utils/runtime-data-transformer';
7
+ import { DiagnosticsCollector } from './diagnostics';
7
8
  import { capabilityRegistry } from '../../capabilities/registry';
8
9
  import { discoverAndRegisterCapabilities } from '../../capabilities/discover';
9
10
  import { readCapabilities } from '../../harness/capability';
@@ -227,10 +228,13 @@ export class CodeGenerator {
227
228
  // Steps registry built per feature during generateTestCode(); used by countSteps()
228
229
  private stepsRegistry = new Map<string, ParsedScenario>();
229
230
 
231
+ private diagnostics = new DiagnosticsCollector();
232
+
230
233
  constructor(options: { useAI?: boolean; verbose?: boolean; framework?: string; baseURL?: string; screenName?: string; runtimeData?: boolean; flowMode?: boolean } = {}) {
231
234
  this.options = options;
232
235
  this.screenName = options.screenName;
233
236
  this.stepMapper = new StepMapper(options);
237
+ this.stepMapper.setDiagnostics(this.diagnostics);
234
238
  // Get adapter from registry. Platform comes from the caller (resolved from
235
239
  // qa/capabilities.yaml); default `web` = the bundled Playwright adapter
236
240
  // (back-compat baseline). External platforms (e.g. mobile) are loaded by the
@@ -243,6 +247,11 @@ export class CodeGenerator {
243
247
  }
244
248
  }
245
249
 
250
+ /** Compile diagnostics collected across all generateTest calls (SG-W001/W002...) */
251
+ getDiagnostics(): DiagnosticsCollector {
252
+ return this.diagnostics;
253
+ }
254
+
246
255
  /**
247
256
  * Generate test file from parsed feature
248
257
  */