@sun-asterisk/sungen 2.6.11 → 2.6.14

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 (128) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +215 -65
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/cli/index.js +1 -1
  5. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  6. package/dist/dashboard/snapshot-builder.js +173 -32
  7. package/dist/dashboard/snapshot-builder.js.map +1 -1
  8. package/dist/dashboard/templates/index.html +84 -84
  9. package/dist/dashboard/types.d.ts +35 -0
  10. package/dist/dashboard/types.d.ts.map +1 -1
  11. package/dist/exporters/csv-exporter.d.ts +24 -3
  12. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  13. package/dist/exporters/csv-exporter.js +28 -7
  14. package/dist/exporters/csv-exporter.js.map +1 -1
  15. package/dist/exporters/json-exporter.d.ts +15 -0
  16. package/dist/exporters/json-exporter.d.ts.map +1 -1
  17. package/dist/exporters/json-exporter.js +7 -2
  18. package/dist/exporters/json-exporter.js.map +1 -1
  19. package/dist/exporters/playwright-report-parser.d.ts +7 -0
  20. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  21. package/dist/exporters/playwright-report-parser.js +20 -0
  22. package/dist/exporters/playwright-report-parser.js.map +1 -1
  23. package/dist/exporters/selector-key-resolver.d.ts +55 -0
  24. package/dist/exporters/selector-key-resolver.d.ts.map +1 -0
  25. package/dist/exporters/selector-key-resolver.js +208 -0
  26. package/dist/exporters/selector-key-resolver.js.map +1 -0
  27. package/dist/exporters/test-data-resolver.d.ts +15 -2
  28. package/dist/exporters/test-data-resolver.d.ts.map +1 -1
  29. package/dist/exporters/test-data-resolver.js +61 -8
  30. package/dist/exporters/test-data-resolver.js.map +1 -1
  31. package/dist/exporters/types.d.ts +1 -0
  32. package/dist/exporters/types.d.ts.map +1 -1
  33. package/dist/exporters/xlsx-exporter.d.ts +28 -3
  34. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  35. package/dist/exporters/xlsx-exporter.js +34 -6
  36. package/dist/exporters/xlsx-exporter.js.map +1 -1
  37. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  38. package/dist/generators/test-generator/code-generator.js +13 -0
  39. package/dist/generators/test-generator/code-generator.js.map +1 -1
  40. package/dist/generators/test-generator/utils/selector-resolver.d.ts +9 -0
  41. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  42. package/dist/generators/test-generator/utils/selector-resolver.js +18 -2
  43. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  44. package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
  45. package/dist/orchestrator/ai-rules-updater.js +4 -0
  46. package/dist/orchestrator/ai-rules-updater.js.map +1 -1
  47. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  48. package/dist/orchestrator/project-initializer.js +1 -2
  49. package/dist/orchestrator/project-initializer.js.map +1 -1
  50. package/dist/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +48 -14
  51. package/dist/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +4 -1
  52. package/dist/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +22 -11
  53. package/dist/orchestrator/templates/ai-instructions/claude-cmd-locale.md +71 -0
  54. package/dist/orchestrator/templates/ai-instructions/claude-cmd-review.md +23 -8
  55. package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +45 -6
  56. package/dist/orchestrator/templates/ai-instructions/claude-config.md +6 -1
  57. package/dist/orchestrator/templates/ai-instructions/claude-skill-locale.md +316 -0
  58. package/dist/orchestrator/templates/ai-instructions/claude-skill-selector-fix.md +1 -0
  59. package/dist/orchestrator/templates/ai-instructions/claude-skill-selector-keys.md +38 -0
  60. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +2 -0
  61. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -13
  62. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +4 -1
  63. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +20 -9
  64. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +70 -0
  65. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-review.md +23 -8
  66. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +44 -6
  67. package/dist/orchestrator/templates/ai-instructions/copilot-config.md +6 -1
  68. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +291 -0
  69. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-selector-fix.md +1 -0
  70. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-selector-keys.md +38 -0
  71. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +2 -0
  72. package/dist/orchestrator/templates/playwright.config.ts +25 -8
  73. package/dist/orchestrator/templates/specs-base.ts +9 -0
  74. package/dist/orchestrator/templates/specs-locale-fixture.ts +105 -0
  75. package/package.json +1 -1
  76. package/src/cli/commands/delivery.ts +256 -65
  77. package/src/cli/index.ts +1 -1
  78. package/src/dashboard/snapshot-builder.ts +207 -32
  79. package/src/dashboard/templates/index.html +84 -84
  80. package/src/dashboard/types.ts +40 -3
  81. package/src/exporters/csv-exporter.ts +36 -7
  82. package/src/exporters/json-exporter.ts +22 -2
  83. package/src/exporters/playwright-report-parser.ts +20 -0
  84. package/src/exporters/selector-key-resolver.ts +190 -0
  85. package/src/exporters/test-data-resolver.ts +65 -7
  86. package/src/exporters/types.ts +1 -0
  87. package/src/exporters/xlsx-exporter.ts +61 -7
  88. package/src/generators/test-generator/code-generator.ts +14 -0
  89. package/src/generators/test-generator/utils/selector-resolver.ts +19 -2
  90. package/src/orchestrator/ai-rules-updater.ts +4 -0
  91. package/src/orchestrator/project-initializer.ts +1 -2
  92. package/src/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +48 -14
  93. package/src/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +4 -1
  94. package/src/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +22 -11
  95. package/src/orchestrator/templates/ai-instructions/claude-cmd-locale.md +71 -0
  96. package/src/orchestrator/templates/ai-instructions/claude-cmd-review.md +23 -8
  97. package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +45 -6
  98. package/src/orchestrator/templates/ai-instructions/claude-config.md +6 -1
  99. package/src/orchestrator/templates/ai-instructions/claude-skill-locale.md +316 -0
  100. package/src/orchestrator/templates/ai-instructions/claude-skill-selector-fix.md +1 -0
  101. package/src/orchestrator/templates/ai-instructions/claude-skill-selector-keys.md +38 -0
  102. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +2 -0
  103. package/src/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -13
  104. package/src/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +4 -1
  105. package/src/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +20 -9
  106. package/src/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +70 -0
  107. package/src/orchestrator/templates/ai-instructions/copilot-cmd-review.md +23 -8
  108. package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +44 -6
  109. package/src/orchestrator/templates/ai-instructions/copilot-config.md +6 -1
  110. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +291 -0
  111. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-selector-fix.md +1 -0
  112. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-selector-keys.md +38 -0
  113. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +2 -0
  114. package/src/orchestrator/templates/playwright.config.ts +25 -8
  115. package/src/orchestrator/templates/specs-base.ts +9 -0
  116. package/src/orchestrator/templates/specs-locale-fixture.ts +105 -0
  117. package/dist/orchestrator/templates/playwright.config.d.ts +0 -10
  118. package/dist/orchestrator/templates/playwright.config.d.ts.map +0 -1
  119. package/dist/orchestrator/templates/playwright.config.js +0 -104
  120. package/dist/orchestrator/templates/playwright.config.js.map +0 -1
  121. package/dist/orchestrator/templates/specs-base.d.ts +0 -14
  122. package/dist/orchestrator/templates/specs-base.d.ts.map +0 -1
  123. package/dist/orchestrator/templates/specs-base.js +0 -77
  124. package/dist/orchestrator/templates/specs-base.js.map +0 -1
  125. package/dist/orchestrator/templates/specs-test-data.d.ts +0 -16
  126. package/dist/orchestrator/templates/specs-test-data.d.ts.map +0 -1
  127. package/dist/orchestrator/templates/specs-test-data.js +0 -151
  128. package/dist/orchestrator/templates/specs-test-data.js.map +0 -1
@@ -11,6 +11,7 @@ import { parse as parseYaml } from 'yaml';
11
11
  import { parseFeatureMetadata } from '../../exporters/feature-parser';
12
12
  import { parseSpecFile } from '../../exporters/spec-parser';
13
13
  import { loadTestData } from '../../exporters/test-data-resolver';
14
+ import { loadSelectorKeyMap } from '../../exporters/selector-key-resolver';
14
15
  import { loadPlaywrightReport } from '../../exporters/playwright-report-parser';
15
16
  import { mergeFeatureAndSpec } from '../../exporters/scenario-merger';
16
17
  import {
@@ -19,8 +20,8 @@ import {
19
20
  renderCsv,
20
21
  writeCsv,
21
22
  } from '../../exporters/csv-exporter';
22
- import { renderXlsx, writeXlsx } from '../../exporters/xlsx-exporter';
23
- import { EnvironmentInfo, PreflightCheck, ScreenSummary } from '../../exporters/types';
23
+ import { renderXlsx, renderXlsxMultiSheet, writeXlsx } from '../../exporters/xlsx-exporter';
24
+ import { EnvironmentInfo, PreflightCheck, ScreenSummary, TestCaseRow } from '../../exporters/types';
24
25
 
25
26
  const COLOR = {
26
27
  reset: '\x1b[0m',
@@ -40,46 +41,95 @@ function log(msg: string): void {
40
41
  // Discovery
41
42
  // ----------------------------------------------------------------------------
42
43
 
44
+ /**
45
+ * A single feature file inside a screen/flow. One DeliveryTarget = one CSV/XLSX.
46
+ *
47
+ * `screen` is the parent directory name under qa/screens or qa/flows.
48
+ * `featureBaseName` is the .feature file basename without extension — typically
49
+ * either `<screen>` (the main feature) or `<screen>-<sub>` (sub-features like
50
+ * `home-modal`). All co-located YAML/spec/result files share this basename.
51
+ *
52
+ * `name` is kept as an alias of `featureBaseName` so existing callers/labels
53
+ * (preflight table, summary) read naturally — every visible row is per-feature.
54
+ */
43
55
  interface DeliveryTarget {
56
+ screen: string;
57
+ featureBaseName: string;
58
+ /** Alias of `featureBaseName` — preserves the old `target.name` call sites. */
44
59
  name: string;
45
60
  isFlow: boolean;
46
61
  }
47
62
 
63
+ function makeTarget(screen: string, featureBaseName: string, isFlow: boolean): DeliveryTarget {
64
+ return { screen, featureBaseName, name: featureBaseName, isFlow };
65
+ }
66
+
67
+ /**
68
+ * List all `.feature` files inside a screen/flow as separate targets.
69
+ * Returns empty array when the directory has no features yet.
70
+ */
71
+ function listFeatureTargets(cwd: string, screen: string, isFlow: boolean): DeliveryTarget[] {
72
+ const featuresDir = path.join(cwd, 'qa', isFlow ? 'flows' : 'screens', screen, 'features');
73
+ if (!fs.existsSync(featuresDir)) return [];
74
+ return fs.readdirSync(featuresDir)
75
+ .filter((f) => f.endsWith('.feature'))
76
+ .map((f) => makeTarget(screen, f.slice(0, -'.feature'.length), isFlow))
77
+ .sort((a, b) => a.featureBaseName.localeCompare(b.featureBaseName));
78
+ }
79
+
48
80
  function listAllTargets(cwd: string): DeliveryTarget[] {
49
81
  const targets: DeliveryTarget[] = [];
50
82
 
51
83
  const screensDir = path.join(cwd, 'qa', 'screens');
52
84
  if (fs.existsSync(screensDir)) {
53
85
  for (const d of fs.readdirSync(screensDir, { withFileTypes: true })) {
54
- if (d.isDirectory()) targets.push({ name: d.name, isFlow: false });
86
+ if (d.isDirectory()) targets.push(...listFeatureTargets(cwd, d.name, false));
55
87
  }
56
88
  }
57
89
 
58
90
  const flowsDir = path.join(cwd, 'qa', 'flows');
59
91
  if (fs.existsSync(flowsDir)) {
60
92
  for (const d of fs.readdirSync(flowsDir, { withFileTypes: true })) {
61
- if (d.isDirectory()) targets.push({ name: d.name, isFlow: true });
93
+ if (d.isDirectory()) targets.push(...listFeatureTargets(cwd, d.name, true));
62
94
  }
63
95
  }
64
96
 
65
- return targets.sort((a, b) => a.name.localeCompare(b.name));
97
+ return targets.sort((a, b) => a.featureBaseName.localeCompare(b.featureBaseName));
66
98
  }
67
99
 
68
- function resolveTargetType(cwd: string, name: string): DeliveryTarget {
100
+ /**
101
+ * Resolve a positional CLI argument to one or more delivery targets.
102
+ *
103
+ * The argument can be EITHER:
104
+ * - A screen / flow name (e.g. `home`) → all features inside that screen
105
+ * - A specific feature basename (`home-modal`) → just that feature
106
+ *
107
+ * We detect by checking for a directory match first; if none, we search for a
108
+ * feature file with the basename across all screens & flows.
109
+ */
110
+ function resolveTargetsFromArg(cwd: string, name: string): DeliveryTarget[] {
69
111
  if (fs.existsSync(path.join(cwd, 'qa', 'flows', name))) {
70
- return { name, isFlow: true };
112
+ return listFeatureTargets(cwd, name, true);
71
113
  }
72
- return { name, isFlow: false };
114
+ if (fs.existsSync(path.join(cwd, 'qa', 'screens', name))) {
115
+ return listFeatureTargets(cwd, name, false);
116
+ }
117
+ // Treat as feature basename — find the parent screen/flow that hosts it.
118
+ const candidates = listAllTargets(cwd).filter((t) => t.featureBaseName === name);
119
+ if (candidates.length > 0) return candidates;
120
+ // Fallback: treat as screen name even if directory missing (lets preflight
121
+ // surface the "feature file missing" error with the right path).
122
+ return [makeTarget(name, name, false)];
73
123
  }
74
124
 
75
125
  function qaDir(cwd: string, target: DeliveryTarget): string {
76
- return path.join(cwd, 'qa', target.isFlow ? 'flows' : 'screens', target.name);
126
+ return path.join(cwd, 'qa', target.isFlow ? 'flows' : 'screens', target.screen);
77
127
  }
78
128
 
79
129
  function generatedDir(cwd: string, target: DeliveryTarget): string {
80
130
  return target.isFlow
81
- ? path.join(cwd, 'specs', 'generated', 'flows', target.name)
82
- : path.join(cwd, 'specs', 'generated', target.name);
131
+ ? path.join(cwd, 'specs', 'generated', 'flows', target.screen)
132
+ : path.join(cwd, 'specs', 'generated', target.screen);
83
133
  }
84
134
 
85
135
  // ----------------------------------------------------------------------------
@@ -88,19 +138,96 @@ function generatedDir(cwd: string, target: DeliveryTarget): string {
88
138
 
89
139
  /**
90
140
  * Resolve the results file path for a target.
91
- * Prefer the per-target co-located file, fall back to the global `test-results/results.json`.
141
+ *
142
+ * Lookup order (first existing wins):
143
+ * 1. Per-target env-specific: `<gen>/<name>-test-result.<SUNGEN_ENV>.json`
144
+ * 2. Per-target base: `<gen>/<name>-test-result.json`
145
+ * 3. Global env-specific: `test-results/results.<SUNGEN_ENV>.json`
146
+ * 4. Global base: `test-results/results.json`
147
+ *
148
+ * The env-suffixed names mirror what `playwright.config.ts` writes when
149
+ * `SUNGEN_ENV` is set, so each locale run keeps its own results file.
92
150
  */
93
151
  function resolveResultsPath(cwd: string, target: DeliveryTarget): string | null {
94
152
  const genDir = generatedDir(cwd, target);
95
- const perTarget = path.join(genDir, `${target.name}-test-result.json`);
96
- if (fs.existsSync(perTarget)) return perTarget;
97
- const global = path.join(cwd, 'test-results', 'results.json');
98
- if (fs.existsSync(global)) return global;
153
+ const env = process.env.SUNGEN_ENV;
154
+ const candidates: string[] = [];
155
+ if (env) candidates.push(path.join(genDir, `${target.featureBaseName}-test-result.${env}.json`));
156
+ candidates.push(path.join(genDir, `${target.featureBaseName}-test-result.json`));
157
+ if (env) candidates.push(path.join(cwd, 'test-results', `results.${env}.json`));
158
+ candidates.push(path.join(cwd, 'test-results', 'results.json'));
159
+ for (const c of candidates) {
160
+ if (fs.existsSync(c)) return c;
161
+ }
99
162
  return null;
100
163
  }
101
164
 
165
+ /**
166
+ * A single locale variant of a delivery target.
167
+ *
168
+ * `locale === ''` denotes the base locale (file `<name>-test-result.json`,
169
+ * test-data without overlay). Non-empty values come from
170
+ * `<name>-test-result.<locale>.json` files found in the generated directory.
171
+ *
172
+ * `displayCode` is what we show in sheet names — `BASE_LOCALE.toUpperCase()`
173
+ * for base, the locale code uppercased otherwise (e.g. `EN`, `JA`, `EN-US`).
174
+ */
175
+ interface LocaleVariant {
176
+ locale: string; // '' for base, 'en' / 'ja' / 'staging-ja' for variants
177
+ displayCode: string; // 'VI' / 'EN' / 'JA' — used in sheet name
178
+ resultsPath: string | null;
179
+ }
180
+
181
+ /**
182
+ * Base locale assumed when no `SUNGEN_ENV` and no explicit override.
183
+ * Configurable later via `qa/.sungen-config.yaml` or similar — currently
184
+ * hardcoded since every existing sungen project uses Vietnamese as the
185
+ * source-of-truth `test-data/<feature>.yaml`.
186
+ */
187
+ const DEFAULT_BASE_LOCALE = 'vi';
188
+
189
+ /**
190
+ * Discover every locale variant available for a feature by scanning the
191
+ * generated directory for `<featureBaseName>-test-result*.json` files.
192
+ *
193
+ * Result always includes the base (locale = '') as the first entry — even if
194
+ * `<name>-test-result.json` doesn't exist, callers still need somewhere to
195
+ * fall back to (Pending status). Variants follow alphabetically by locale
196
+ * code, so the sheet order is deterministic across runs.
197
+ */
198
+ function discoverLocaleVariants(cwd: string, target: DeliveryTarget): LocaleVariant[] {
199
+ const genDir = generatedDir(cwd, target);
200
+ const prefix = `${target.featureBaseName}-test-result`;
201
+ const variants: LocaleVariant[] = [];
202
+
203
+ const basePath = path.join(genDir, `${prefix}.json`);
204
+ variants.push({
205
+ locale: '',
206
+ displayCode: DEFAULT_BASE_LOCALE.toUpperCase(),
207
+ resultsPath: fs.existsSync(basePath) ? basePath : null,
208
+ });
209
+
210
+ if (fs.existsSync(genDir)) {
211
+ const localeFiles = fs.readdirSync(genDir)
212
+ .filter((f) => f.startsWith(`${prefix}.`) && f.endsWith('.json') && f !== `${prefix}.json`)
213
+ .sort();
214
+ for (const f of localeFiles) {
215
+ // Strip prefix + '.' on the left, '.json' on the right → locale code.
216
+ const locale = f.slice(prefix.length + 1, -'.json'.length);
217
+ if (!locale) continue;
218
+ variants.push({
219
+ locale,
220
+ displayCode: locale.toUpperCase(),
221
+ resultsPath: path.join(genDir, f),
222
+ });
223
+ }
224
+ }
225
+
226
+ return variants;
227
+ }
228
+
102
229
  function resolveTestDataPathForTarget(cwd: string, target: DeliveryTarget): string {
103
- return path.join(qaDir(cwd, target), 'test-data', `${target.name}.yaml`);
230
+ return path.join(qaDir(cwd, target), 'test-data', `${target.featureBaseName}.yaml`);
104
231
  }
105
232
 
106
233
  /**
@@ -126,42 +253,45 @@ function readFlowPagePath(selectorsFile: string): string | undefined {
126
253
  function runPreflight(cwd: string, target: DeliveryTarget): PreflightCheck {
127
254
  const base = qaDir(cwd, target);
128
255
  const genBase = generatedDir(cwd, target);
129
- const featureFile = path.join(base, 'features', `${target.name}.feature`);
256
+ const featureFile = path.join(base, 'features', `${target.featureBaseName}.feature`);
130
257
  const testDataFile = resolveTestDataPathForTarget(cwd, target);
131
- const selectorsFile = path.join(base, 'selectors', `${target.name}.yaml`);
132
- const specFile = path.join(genBase, `${target.name}.spec.ts`);
258
+ const selectorsFile = path.join(base, 'selectors', `${target.featureBaseName}.yaml`);
259
+ const specFile = path.join(genBase, `${target.featureBaseName}.spec.ts`);
133
260
  const resultsFile = resolveResultsPath(cwd, target);
134
261
 
135
262
  const featureOk = checkFeatureReal(featureFile);
136
263
  const testDataOk = checkTestDataHasVars(testDataFile);
137
- const selectorsOk = checkSelectorsHasEntries(selectorsFile, target.name);
264
+ const selectorsOk = checkSelectorsHasEntries(selectorsFile, target.featureBaseName);
138
265
  const specOk = fs.existsSync(specFile);
139
266
  const resultsOk = resultsFile !== null;
140
267
 
141
- const label = target.isFlow ? `flow/${target.name}` : target.name;
268
+ const label = target.isFlow ? `flow/${target.featureBaseName}` : target.featureBaseName;
142
269
  const missing: string[] = [];
143
270
  const suggestions: string[] = [];
144
271
 
145
272
  if (!featureOk) {
146
273
  missing.push(`feature file missing/empty: ${path.relative(cwd, featureFile)}`);
147
- suggestions.push(`/sungen:create-test ${target.name}`);
274
+ suggestions.push(`/sungen:create-test ${target.screen}`);
148
275
  }
149
276
  if (!testDataOk) {
150
277
  missing.push(`test-data.yaml has no variables: ${path.relative(cwd, testDataFile)}`);
151
- suggestions.push(`/sungen:create-test ${target.name}`);
278
+ suggestions.push(`/sungen:create-test ${target.screen}`);
152
279
  }
153
280
  if (!selectorsOk) {
154
281
  missing.push(`selectors.yaml missing entries: ${path.relative(cwd, selectorsFile)}`);
155
- suggestions.push(`/sungen:run-test ${target.name}`);
282
+ suggestions.push(`/sungen:run-test ${target.screen}`);
156
283
  }
157
284
  if (!specOk) {
158
285
  missing.push(`compiled .spec.ts missing: ${path.relative(cwd, specFile)}`);
159
- suggestions.push(target.isFlow ? `sungen generate --flow ${target.name}` : `sungen generate --screen ${target.name}`);
286
+ suggestions.push(target.isFlow ? `sungen generate --flow ${target.screen}` : `sungen generate --screen ${target.screen}`);
160
287
  }
161
288
  if (!resultsOk) {
162
- missing.push(`test-result.json missing (optional)`);
289
+ const env = process.env.SUNGEN_ENV;
290
+ const envSuffix = env ? `.${env}` : '';
291
+ missing.push(`test-result${envSuffix}.json missing (optional)`);
163
292
  const genRel = path.relative(cwd, genBase);
164
- suggestions.push(`PLAYWRIGHT_JSON_OUTPUT_NAME=${genRel}/${target.name}-test-result.json npx playwright test ${genRel}/${target.name}.spec.ts`);
293
+ const envPrefix = env ? `SUNGEN_ENV=${env} ` : '';
294
+ suggestions.push(`${envPrefix}PLAYWRIGHT_JSON_OUTPUT_NAME=${genRel}/${target.featureBaseName}-test-result.json npx playwright test ${genRel}/${target.featureBaseName}.spec.ts`);
165
295
  }
166
296
 
167
297
  return {
@@ -247,45 +377,102 @@ async function exportTarget(
247
377
  ): Promise<ScreenSummary | null> {
248
378
  const base = qaDir(cwd, target);
249
379
  const genBase = generatedDir(cwd, target);
250
- const featureFile = path.join(base, 'features', `${target.name}.feature`);
380
+ const featureFile = path.join(base, 'features', `${target.featureBaseName}.feature`);
251
381
  const testDataFile = resolveTestDataPathForTarget(cwd, target);
252
- const specFile = path.join(genBase, `${target.name}.spec.ts`);
253
- const resultsFile = resolveResultsPath(cwd, target);
382
+ const specFile = path.join(genBase, `${target.featureBaseName}.spec.ts`);
254
383
  const specMdFile = path.join(base, 'requirements', 'spec.md');
255
- const label = target.isFlow ? `flow/${target.name}` : target.name;
384
+ const label = target.isFlow ? `flow/${target.featureBaseName}` : target.featureBaseName;
256
385
 
257
386
  try {
258
387
  const feature = parseFeatureMetadata(featureFile);
259
388
  const spec = parseSpecFile(specFile);
260
- const testData = loadTestData(testDataFile);
261
- const results = resultsFile ? loadPlaywrightReport(resultsFile) : null;
262
389
 
263
390
  const merged = mergeFeatureAndSpec(feature, spec);
264
391
  // Screens get their URL path from the .feature file directly. Flows record
265
- // it in selectors/<name>.yaml under the page-selector block — fall back to
266
- // that so the Environment column shows a full URL for flows too.
392
+ // it in selectors/<feature>.yaml under the page-selector block — fall back
393
+ // to that so the Environment column shows a full URL for flows too.
267
394
  let featurePath = feature.featurePath;
395
+ const selectorsFile = path.join(base, 'selectors', `${target.featureBaseName}.yaml`);
268
396
  if (!featurePath && target.isFlow) {
269
- const selectorsFile = path.join(base, 'selectors', `${target.name}.yaml`);
270
397
  featurePath = readFlowPagePath(selectorsFile);
271
398
  }
272
- const rows = buildTestCaseRows({
273
- screen: label,
274
- featureName: feature.featureName,
275
- featurePath,
276
- merged,
277
- testData,
278
- results,
279
- env,
280
- });
399
+ const selectorKeyMap = loadSelectorKeyMap(selectorsFile);
281
400
 
282
401
  const specLink = fs.existsSync(specMdFile) ? path.relative(cwd, specMdFile) : '';
283
- const tempSummary = buildSummary(label, rows, '');
284
- const csv = renderCsv(tempSummary, rows, specLink);
285
- const csvPath = writeCsv(cwd, target.name, csv);
286
- const wb = renderXlsx(tempSummary, rows, specLink);
287
- await writeXlsx(cwd, target.name, wb);
288
- return buildSummary(label, rows, path.relative(cwd, csvPath));
402
+ const explicitEnv = process.env.SUNGEN_ENV;
403
+
404
+ // Decide between single-locale and aggregated multi-locale flows.
405
+ // • SUNGEN_ENV set → single locale (existing behaviour, no change)
406
+ // • SUNGEN_ENV unset → discover every *-test-result*.json variant.
407
+ // 2+ variants XLSX with one sheet per locale (`<feature>-<LOCALE>`),
408
+ // one CSV per locale (CSV has no sheet concept).
409
+ // <2 variants → single-sheet XLSX with the canonical 'Testcases' sheet.
410
+ if (explicitEnv) {
411
+ const rows = buildTestCaseRows({
412
+ screen: label,
413
+ featureName: feature.featureName,
414
+ featurePath,
415
+ merged,
416
+ testData: loadTestData(testDataFile),
417
+ results: resolveResultsPath(cwd, target) ? loadPlaywrightReport(resolveResultsPath(cwd, target)!) : null,
418
+ env,
419
+ selectorKeyMap,
420
+ });
421
+ const tempSummary = buildSummary(label, rows, '');
422
+ const csv = renderCsv(tempSummary, rows, specLink);
423
+ const csvPath = writeCsv(cwd, target.featureBaseName, csv);
424
+ const wb = renderXlsx(tempSummary, rows, specLink);
425
+ await writeXlsx(cwd, target.featureBaseName, wb);
426
+ return buildSummary(label, rows, path.relative(cwd, csvPath));
427
+ }
428
+
429
+ const variants = discoverLocaleVariants(cwd, target);
430
+ let primarySummary: ScreenSummary | null = null;
431
+ let primaryCsvPath = '';
432
+ const sheets: { sheetName: string; summary: ScreenSummary; rows: TestCaseRow[]; specLink: string }[] = [];
433
+
434
+ for (const variant of variants) {
435
+ // For the base variant the overlay merge is skipped (`locale: null`);
436
+ // for locale variants the matching `<feature>.<locale>.yaml` is merged.
437
+ const variantTestData = loadTestData(testDataFile, variant.locale || null);
438
+ const variantResults = variant.resultsPath ? loadPlaywrightReport(variant.resultsPath) : null;
439
+ const variantRows = buildTestCaseRows({
440
+ screen: label,
441
+ featureName: feature.featureName,
442
+ featurePath,
443
+ merged,
444
+ testData: variantTestData,
445
+ results: variantResults,
446
+ env,
447
+ selectorKeyMap,
448
+ });
449
+ const variantSummary = buildSummary(label, variantRows, '');
450
+
451
+ // CSV: always one file per locale (CSV has no sheet concept).
452
+ const csvLocale = variant.locale || null; // '' or 'en' → '' / 'en'
453
+ const csv = renderCsv(variantSummary, variantRows, specLink);
454
+ const csvPath = writeCsv(cwd, target.featureBaseName, csv, csvLocale);
455
+
456
+ sheets.push({
457
+ sheetName: `${target.featureBaseName}-${variant.displayCode}`,
458
+ summary: variantSummary,
459
+ rows: variantRows,
460
+ specLink,
461
+ });
462
+
463
+ // Use the base variant's summary as the "primary" return value so the
464
+ // top-level reporter rolls up base-locale numbers.
465
+ if (variant.locale === '') {
466
+ primarySummary = buildSummary(label, variantRows, path.relative(cwd, csvPath));
467
+ primaryCsvPath = csvPath;
468
+ }
469
+ }
470
+
471
+ // XLSX: single-sheet when only base, multi-sheet when 2+ locales found.
472
+ const wb = sheets.length >= 2 ? renderXlsxMultiSheet(sheets) : renderXlsx(sheets[0].summary, sheets[0].rows, specLink);
473
+ await writeXlsx(cwd, target.featureBaseName, wb);
474
+
475
+ return primarySummary ?? buildSummary(label, sheets[0].rows, primaryCsvPath);
289
476
  } catch (err) {
290
477
  console.error(`${COLOR.red}Error exporting ${label}:${COLOR.reset} ${err instanceof Error ? err.message : err}`);
291
478
  return null;
@@ -301,10 +488,11 @@ function printPreflightTable(checks: PreflightCheck[]): void {
301
488
  log(`${COLOR.gray}Columns: feature | test-data | selectors | .spec.ts | test-result.json${COLOR.reset}\n`);
302
489
 
303
490
  const ok = (b: boolean) => (b ? `${COLOR.green}✓${COLOR.reset}` : `${COLOR.red}✗${COLOR.reset}`);
491
+ const colWidth = Math.max(20, ...checks.map((c) => c.screen.length)) + 2;
304
492
 
305
493
  for (const c of checks) {
306
494
  log(
307
- ` ${c.screen.padEnd(20)} ${ok(c.featureOk)} ${ok(c.testDataOk)} ${ok(c.selectorsOk)} ${ok(c.specOk)} ${ok(c.resultsOk)}`
495
+ ` ${c.screen.padEnd(colWidth)}${ok(c.featureOk)} ${ok(c.testDataOk)} ${ok(c.selectorsOk)} ${ok(c.specOk)} ${ok(c.resultsOk)}`
308
496
  );
309
497
  }
310
498
  log('');
@@ -335,11 +523,12 @@ function printPreflightTable(checks: PreflightCheck[]): void {
335
523
  function printSummaryTable(summaries: ScreenSummary[]): void {
336
524
  log(`\n${COLOR.bold}Delivery export complete${COLOR.reset}`);
337
525
  log('');
338
- log(' Screen TCs Passed Failed Pending N/A File');
339
- log(' ' + '-'.repeat(80));
526
+ const colWidth = Math.max(20, ...summaries.map((s) => s.screen.length)) + 1;
527
+ log(` ${'Feature'.padEnd(colWidth)}TCs Passed Failed Pending N/A File`);
528
+ log(' ' + '-'.repeat(colWidth + 60));
340
529
  for (const s of summaries) {
341
530
  log(
342
- ` ${s.screen.padEnd(20)} ${String(s.total).padStart(3)} ${String(s.passed).padStart(6)} ${String(s.failed).padStart(6)} ${String(s.pending).padStart(7)} ${String(s.na).padStart(3)} ${s.outputFile}`
531
+ ` ${s.screen.padEnd(colWidth)}${String(s.total).padStart(3)} ${String(s.passed).padStart(6)} ${String(s.failed).padStart(6)} ${String(s.pending).padStart(7)} ${String(s.na).padStart(3)} ${s.outputFile}`
343
532
  );
344
533
  }
345
534
  log('');
@@ -365,20 +554,22 @@ export function registerDeliveryCommand(program: Command): void {
365
554
  try {
366
555
  const cwd = process.cwd();
367
556
 
368
- // 1. Scope detection
557
+ // 1. Scope detection — each positional name expands into one target
558
+ // per `.feature` file inside that screen/flow. Passing a specific
559
+ // feature basename (e.g. `home-modal`) narrows to that one file.
369
560
  let targets: DeliveryTarget[];
370
561
  if (names && names.length > 0) {
371
- targets = names.map((n) => resolveTargetType(cwd, n));
562
+ targets = names.flatMap((n) => resolveTargetsFromArg(cwd, n));
372
563
  } else {
373
564
  targets = listAllTargets(cwd);
374
- if (targets.length === 0) {
375
- console.error(`${COLOR.red}No screens or flows found in qa/screens/ or qa/flows/${COLOR.reset}`);
376
- process.exit(1);
377
- }
565
+ }
566
+ if (targets.length === 0) {
567
+ console.error(`${COLOR.red}No .feature files found in qa/screens/ or qa/flows/${COLOR.reset}`);
568
+ process.exit(1);
378
569
  }
379
570
 
380
- const labels = targets.map((t) => t.isFlow ? `flow/${t.name}` : t.name);
381
- log(`${COLOR.bold}sungen delivery${COLOR.reset} — exporting ${targets.length} target(s): ${labels.join(', ')}\n`);
571
+ const labels = targets.map((t) => t.isFlow ? `flow/${t.featureBaseName}` : t.featureBaseName);
572
+ log(`${COLOR.bold}sungen delivery${COLOR.reset} — exporting ${targets.length} feature(s): ${labels.join(', ')}\n`);
382
573
 
383
574
  // 2. Pre-flight
384
575
  let toExport: DeliveryTarget[];
@@ -395,7 +586,7 @@ export function registerDeliveryCommand(program: Command): void {
395
586
  checks.filter((c) => !hasBlockingMissing(c)).map((c) => c.screen)
396
587
  );
397
588
  toExport = targets.filter((t) => {
398
- const label = t.isFlow ? `flow/${t.name}` : t.name;
589
+ const label = t.isFlow ? `flow/${t.featureBaseName}` : t.featureBaseName;
399
590
  return passedScreens.has(label);
400
591
  });
401
592
  log(`${COLOR.yellow}Continuing with ${toExport.length} ready target(s).${COLOR.reset}\n`);
package/src/cli/index.ts CHANGED
@@ -21,7 +21,7 @@ async function main() {
21
21
  program
22
22
  .name('sungen')
23
23
  .description('Deterministic E2E Test Compiler — Gherkin + Selectors → Playwright')
24
- .version('2.6.11');
24
+ .version('2.6.14');
25
25
 
26
26
  // Global options
27
27
  program