@sun-asterisk/sungen 2.6.12 → 2.6.15

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 (107) 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/orchestrator/ai-rules-updater.d.ts.map +1 -1
  41. package/dist/orchestrator/ai-rules-updater.js +4 -0
  42. package/dist/orchestrator/ai-rules-updater.js.map +1 -1
  43. package/dist/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +48 -14
  44. package/dist/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +4 -1
  45. package/dist/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +22 -11
  46. package/dist/orchestrator/templates/ai-instructions/claude-cmd-locale.md +71 -0
  47. package/dist/orchestrator/templates/ai-instructions/claude-cmd-review.md +23 -8
  48. package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +45 -6
  49. package/dist/orchestrator/templates/ai-instructions/claude-config.md +4 -1
  50. package/dist/orchestrator/templates/ai-instructions/claude-skill-locale.md +316 -0
  51. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -13
  52. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +4 -1
  53. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +20 -9
  54. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +70 -0
  55. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-review.md +23 -8
  56. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +44 -6
  57. package/dist/orchestrator/templates/ai-instructions/copilot-config.md +4 -1
  58. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +291 -0
  59. package/dist/orchestrator/templates/playwright.config.ts +25 -8
  60. package/dist/orchestrator/templates/specs-base.ts +9 -0
  61. package/dist/orchestrator/templates/specs-locale-fixture.ts +105 -0
  62. package/package.json +1 -1
  63. package/src/cli/commands/delivery.ts +256 -65
  64. package/src/cli/index.ts +1 -1
  65. package/src/dashboard/snapshot-builder.ts +207 -32
  66. package/src/dashboard/templates/index.html +84 -84
  67. package/src/dashboard/types.ts +40 -3
  68. package/src/exporters/csv-exporter.ts +36 -7
  69. package/src/exporters/json-exporter.ts +22 -2
  70. package/src/exporters/playwright-report-parser.ts +20 -0
  71. package/src/exporters/selector-key-resolver.ts +190 -0
  72. package/src/exporters/test-data-resolver.ts +65 -7
  73. package/src/exporters/types.ts +1 -0
  74. package/src/exporters/xlsx-exporter.ts +61 -7
  75. package/src/generators/test-generator/code-generator.ts +14 -0
  76. package/src/orchestrator/ai-rules-updater.ts +4 -0
  77. package/src/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +48 -14
  78. package/src/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +4 -1
  79. package/src/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +22 -11
  80. package/src/orchestrator/templates/ai-instructions/claude-cmd-locale.md +71 -0
  81. package/src/orchestrator/templates/ai-instructions/claude-cmd-review.md +23 -8
  82. package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +45 -6
  83. package/src/orchestrator/templates/ai-instructions/claude-config.md +4 -1
  84. package/src/orchestrator/templates/ai-instructions/claude-skill-locale.md +316 -0
  85. package/src/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -13
  86. package/src/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +4 -1
  87. package/src/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +20 -9
  88. package/src/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +70 -0
  89. package/src/orchestrator/templates/ai-instructions/copilot-cmd-review.md +23 -8
  90. package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +44 -6
  91. package/src/orchestrator/templates/ai-instructions/copilot-config.md +4 -1
  92. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +291 -0
  93. package/src/orchestrator/templates/playwright.config.ts +25 -8
  94. package/src/orchestrator/templates/specs-base.ts +9 -0
  95. package/src/orchestrator/templates/specs-locale-fixture.ts +105 -0
  96. package/dist/orchestrator/templates/playwright.config.d.ts +0 -10
  97. package/dist/orchestrator/templates/playwright.config.d.ts.map +0 -1
  98. package/dist/orchestrator/templates/playwright.config.js +0 -104
  99. package/dist/orchestrator/templates/playwright.config.js.map +0 -1
  100. package/dist/orchestrator/templates/specs-base.d.ts +0 -14
  101. package/dist/orchestrator/templates/specs-base.d.ts.map +0 -1
  102. package/dist/orchestrator/templates/specs-base.js +0 -77
  103. package/dist/orchestrator/templates/specs-base.js.map +0 -1
  104. package/dist/orchestrator/templates/specs-test-data.d.ts +0 -16
  105. package/dist/orchestrator/templates/specs-test-data.d.ts.map +0 -1
  106. package/dist/orchestrator/templates/specs-test-data.js +0 -151
  107. package/dist/orchestrator/templates/specs-test-data.js.map +0 -1
@@ -39,11 +39,47 @@ export interface AggregateSummary {
39
39
  }
40
40
 
41
41
  export interface ScreenSnapshot {
42
- name: string; // "kudos" or "flow/checkout"
42
+ name: string; // "kudos" or "flow/checkout" — the screen DIRECTORY name
43
43
  isFlow: boolean;
44
- featureName: string;
45
- featurePath?: string;
44
+ featureName: string; // name of the primary (first) feature; kept for backwards compat
45
+ featurePath?: string; // entry-point path of the primary feature
46
46
  specLink?: string; // relative path to requirements/spec.md
47
+ summary: ScreenSummaryStats; // rollup across all features
48
+ scenarios: ScenarioSnapshot[]; // flat list across all features (each carries its `featureBaseName`)
49
+ features?: FeatureSnapshot[]; // present when a screen has 1+ .feature files; UI groups by this
50
+ }
51
+
52
+ /**
53
+ * One `.feature` file inside a screen/flow. A screen can host multiple
54
+ * features (e.g. `home.feature` + `home-modal.feature`), which the dashboard
55
+ * surfaces as sub-groups under the screen entry.
56
+ *
57
+ * When a feature has been i18n-bootstrapped (locale overlay test-data file +
58
+ * matching `<feature>-test-result.<locale>.json` exists), the snapshot
59
+ * builder packs each locale variant into the `locales` array. UI consumers
60
+ * can render a locale switcher; legacy consumers fall back to `scenarios`
61
+ * (which always reflects the base locale).
62
+ */
63
+ export interface FeatureSnapshot {
64
+ featureBaseName: string; // file basename without extension — "home" or "home-modal"
65
+ featureName: string; // human title from the .feature file ("Home Notifications Panel")
66
+ featurePath?: string; // entry-point path declared in the .feature
67
+ specLink?: string; // relative path to requirements/spec.md, when present
68
+ summary: ScreenSummaryStats; // BASE locale rollup — back-compat for non-locale consumers
69
+ scenarios: ScenarioSnapshot[]; // BASE locale scenarios
70
+ locales?: LocaleSnapshot[]; // present when 2+ locales detected (base + at least one overlay)
71
+ }
72
+
73
+ /**
74
+ * One locale variant of a feature's scenarios. The structure (categories,
75
+ * viewpoints, TC IDs) matches the base locale — only test-data values,
76
+ * results, and `testEnvironment` differ. `locale === ''` denotes the base
77
+ * locale; non-empty values come from `test-data/<feature>.<locale>.yaml`
78
+ * overlays plus matching `<feature>-test-result.<locale>.json` reports.
79
+ */
80
+ export interface LocaleSnapshot {
81
+ locale: string; // '' for base, 'en' / 'ja' / 'staging-ja' otherwise
82
+ displayCode: string; // 'VI' / 'EN' / 'JA' — used in sheet names + UI tabs
47
83
  summary: ScreenSummaryStats;
48
84
  scenarios: ScenarioSnapshot[];
49
85
  }
@@ -64,6 +100,7 @@ export interface ScenarioSnapshot {
64
100
  category2: string; // Accessing | GUI | Function
65
101
  category3: string; // feature name
66
102
  category4: string; // screen name
103
+ featureBaseName?: string; // .feature file basename — lets flat consumers group by feature
67
104
  vpId?: string; // VP-UI-001 if present
68
105
  priority: 'High' | 'Normal' | 'Low';
69
106
  type: 'Auto' | 'Manual' | 'Not compiled';
@@ -23,6 +23,7 @@ import {
23
23
  statusToTestResult,
24
24
  } from './playwright-report-parser';
25
25
  import { EnvironmentInfo, PlaywrightResult, ScreenSummary, TestCaseRow } from './types';
26
+ import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
26
27
 
27
28
  export interface BuildCsvInput {
28
29
  screen: string;
@@ -35,6 +36,13 @@ export interface BuildCsvInput {
35
36
  testData: Record<string, string>;
36
37
  results: Map<string, PlaywrightResult> | null;
37
38
  env: EnvironmentInfo;
39
+ /**
40
+ * Optional selector-key → var-name map. When supplied, step / expected
41
+ * cells get `[selectorKey]` references swapped for the locale-specific
42
+ * `[testData[var]]` value — so EN sheets read "Click [ABOUT KUDOS] link"
43
+ * instead of the VI selector key "Click [VỀ KUDOS] link".
44
+ */
45
+ selectorKeyMap?: SelectorKeyMap;
38
46
  }
39
47
 
40
48
  /**
@@ -57,8 +65,12 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
57
65
  // Pre-condition is auth-only. Steps section = Background + @extend base
58
66
  // (entire) + own pre-Then. Expected = own's Then-onwards only.
59
67
  const precondition = formatPrecondition(authRole);
68
+ const localize = (text: string): string =>
69
+ input.selectorKeyMap
70
+ ? substituteSelectorKeysInStep(text, input.selectorKeyMap, input.testData)
71
+ : text;
60
72
  const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
61
- s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text);
73
+ localize(s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text));
62
74
  const stepLines = m.resolvedSteps.map(formatStep).filter(Boolean);
63
75
  const expectedLines = m.resolvedExpected.map(formatStep).filter(Boolean);
64
76
  const steps =
@@ -101,13 +113,13 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
101
113
  testResult = statusToTestResult(result.status);
102
114
  executedDate = formatExecutedDate(result.startTime);
103
115
  note = formatNote(result);
104
- environment = formatEnv(input.env.baseURL, input.featurePath, input.env.projectName);
116
+ environment = formatEnv(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
105
117
  executor = input.env.executor;
106
118
  }
107
119
 
108
120
  // If we have environment info and the test actually ran, populate env + executor
109
121
  if (testResult !== 'Pending' && testResult !== 'N/A') {
110
- if (!environment) environment = formatEnv(input.env.baseURL, input.featurePath, input.env.projectName);
122
+ if (!environment) environment = formatEnv(input.env.baseURL, input.featurePath, result?.projectName || input.env.projectName);
111
123
  if (!executor) executor = input.env.executor;
112
124
  }
113
125
 
@@ -306,16 +318,33 @@ export function renderCsv(summary: ScreenSummary, rows: TestCaseRow[], specLink:
306
318
  }
307
319
 
308
320
  /**
309
- * Write the CSV to disk at qa/deliverables/<screen>-testcases.csv.
310
- * Creates directory if needed.
321
+ * Write the CSV to disk at `qa/deliverables/<screen>-testcases[.env].csv`.
322
+ * When `SUNGEN_ENV` is set, the env name is appended so locale exports
323
+ * don't overwrite each other. Creates directory if needed.
311
324
  */
312
- export function writeCsv(cwd: string, screen: string, csvContent: string): string {
325
+ /**
326
+ * Optional `locale` overrides `process.env.SUNGEN_ENV` — used by the
327
+ * multi-locale delivery aggregator. Pass an empty string to force the
328
+ * base (no suffix) variant.
329
+ */
330
+ export function writeCsv(cwd: string, screen: string, csvContent: string, locale?: string | null): string {
313
331
  const outDir = path.join(cwd, 'qa', 'deliverables');
314
332
  if (!fs.existsSync(outDir)) {
315
333
  fs.mkdirSync(outDir, { recursive: true });
316
334
  }
317
- const outPath = path.join(outDir, `${screen}-testcases.csv`);
335
+ const outPath = path.join(outDir, `${deliverableBasename(screen, locale)}.csv`);
318
336
  fs.writeFileSync(outPath, csvContent, 'utf-8');
319
337
  return outPath;
320
338
  }
321
339
 
340
+ /**
341
+ * Build the env-aware basename used by both CSV and XLSX writers.
342
+ * `<screen>-testcases` becomes `<screen>-testcases.<env>` when a non-empty
343
+ * locale is given (or `SUNGEN_ENV` is set and no explicit override). Pass
344
+ * `null` / empty string to force the base variant.
345
+ */
346
+ export function deliverableBasename(screen: string, locale?: string | null): string {
347
+ const effective = locale !== undefined ? locale : process.env.SUNGEN_ENV;
348
+ return effective ? `${screen}-testcases.${effective}` : `${screen}-testcases`;
349
+ }
350
+
@@ -21,6 +21,7 @@ import {
21
21
  statusToTestResult,
22
22
  } from './playwright-report-parser';
23
23
  import { EnvironmentInfo, PlaywrightResult } from './types';
24
+ import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
24
25
  import {
25
26
  ScenarioSnapshot,
26
27
  ScreenSnapshot,
@@ -37,6 +38,20 @@ export interface BuildScreenSnapshotInput {
37
38
  testData: Record<string, string>;
38
39
  results: Map<string, PlaywrightResult> | null;
39
40
  env: EnvironmentInfo;
41
+ /**
42
+ * The `.feature` file basename (without extension). Stamped onto each
43
+ * generated ScenarioSnapshot so flat consumers (dashboard Suites view) can
44
+ * group scenarios by their originating feature within a screen.
45
+ * Defaults to `screen` when omitted (legacy single-feature path).
46
+ */
47
+ featureBaseName?: string;
48
+ /**
49
+ * Optional selector-key → var-name map. When supplied, `steps` and
50
+ * `expectedResults` get `[selectorKey]` references swapped for the
51
+ * locale-specific value so dashboard EN tabs read "Click [ABOUT KUDOS]
52
+ * link" instead of the VI key "Click [VỀ KUDOS] link".
53
+ */
54
+ selectorKeyMap?: SelectorKeyMap;
40
55
  }
41
56
 
42
57
  /**
@@ -60,8 +75,12 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
60
75
  // (all of it, including its Then) + own pre-Then. Expected = own's
61
76
  // Then-onwards only. Given → keepActor (state), When/Then → stripActor.
62
77
  const precondition = formatPrecondition(authRole);
78
+ const localize = (text: string): string =>
79
+ input.selectorKeyMap
80
+ ? substituteSelectorKeysInStep(text, input.selectorKeyMap, input.testData)
81
+ : text;
63
82
  const formatStep = (s: { text: string; bucket: 'given' | 'when' | 'then' }) =>
64
- s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text);
83
+ localize(s.bucket === 'given' ? keepActor(s.text) : stripActor(s.text));
65
84
  const steps = m.resolvedSteps.map(formatStep).filter(Boolean);
66
85
  const expectedResults = m.resolvedExpected.map(formatStep).filter(Boolean);
67
86
  // Dashboard modal uses whitespace-pre-wrap — pass '\n' to render one
@@ -97,7 +116,7 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
97
116
  errorMessage = String(result.error).split('\n')[0].trim().replace(/^Error:\s*/i, '');
98
117
  }
99
118
  if (result.tracePath) tracePath = result.tracePath;
100
- testEnvironment = formatTestEnvironment(input.env.baseURL, input.featurePath, input.env.projectName);
119
+ testEnvironment = formatTestEnvironment(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
101
120
  testExecutor = input.env.executor;
102
121
  }
103
122
  }
@@ -108,6 +127,7 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
108
127
  category2,
109
128
  category3: input.featureName,
110
129
  category4: input.screen,
130
+ featureBaseName: input.featureBaseName ?? input.screen,
111
131
  vpId,
112
132
  priority,
113
133
  type,
@@ -23,6 +23,8 @@ interface PlaywrightSuite {
23
23
  interface PlaywrightSpec {
24
24
  title?: string; // scenario title
25
25
  tests?: Array<{
26
+ projectName?: string; // which project ran this test (e.g. 'chromium', 'firefox')
27
+ projectId?: string;
26
28
  results?: Array<{
27
29
  status?: string; // 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'
28
30
  startTime?: string;
@@ -94,11 +96,29 @@ export function loadPlaywrightReport(reportPath: string): Map<string, Playwright
94
96
  startTime: res?.startTime,
95
97
  error: errorMsg,
96
98
  tracePath: trace,
99
+ projectName: test?.projectName,
97
100
  });
98
101
  }
99
102
  return result;
100
103
  }
101
104
 
105
+ /**
106
+ * Read the default project name from a Playwright JSON report.
107
+ * Used as a fallback when a per-test `projectName` isn't available
108
+ * (e.g. for `Pending` rows where no Playwright result exists yet).
109
+ * Returns null if the file is missing/invalid or no projects are listed.
110
+ */
111
+ export function loadReportProjectName(reportPath: string): string | null {
112
+ if (!fs.existsSync(reportPath)) return null;
113
+ try {
114
+ const raw: PlaywrightJsonReport = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
115
+ const name = raw.config?.projects?.[0]?.name;
116
+ return typeof name === 'string' && name ? name : null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
102
122
  /**
103
123
  * Strip ANSI escape sequences (terminal color/style codes) that Playwright
104
124
  * embeds in error messages. The dashboard and CSV render plain text, so
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Map selector keys (Gherkin `[Reference]` names) to the display text they
3
+ * resolve to. Used by row renderers to swap `[selectorKey]` in step text
4
+ * with the actual user-visible label so QA can read the step like a human
5
+ * reader: `Click [VỀ KUDOS]` (VI) / `Click [ABOUT KUDOS]` (EN) instead of
6
+ * the opaque key `Click [cta_to_kudos]`.
7
+ *
8
+ * Resolution rules (first match wins per selector entry):
9
+ * 1. `name` field present → use it. If it embeds exactly one `{{var}}`,
10
+ * look up the locale-specific value at substitution time. Otherwise
11
+ * use the literal `name` (brand names like "Sun* Kudos").
12
+ * 2. `value` field present AND `type` is `text` or `locator` → same logic.
13
+ * For `locator` we extract the var from inside `:has-text("{{x}}")`
14
+ * and similar composite templates because that var is the displayed text.
15
+ *
16
+ * Skipped entirely:
17
+ * • `type: 'page'` (URL paths, not display text)
18
+ * • `value` for `type: 'role'` (role name like 'button', not display)
19
+ * • Entries with no `name` and no usable `value`
20
+ * • Composite templates with 2+ vars (ambiguous which to use)
21
+ */
22
+
23
+ import * as fs from 'fs';
24
+ import { parse as parseYaml } from 'yaml';
25
+
26
+ /**
27
+ * How to derive the display string for a selector key:
28
+ * - `varName` set → look up at substitution time in the locale-specific test-data
29
+ * - `literal` set → use the YAML value as-is (hardcoded text, brand names)
30
+ */
31
+ export interface SelectorResolution {
32
+ varName?: string;
33
+ literal?: string;
34
+ }
35
+
36
+ export type SelectorKeyMap = Map<string, SelectorResolution>;
37
+
38
+ /**
39
+ * Build a fresh `/g` regex each call to avoid `lastIndex` state pollution
40
+ * across selectors / features when matchAll / test are used on a module-
41
+ * scoped instance. The cost is negligible (regex compile is fast); the
42
+ * payoff is that processing N features sequentially can't leak state from
43
+ * one feature's substitution into another's selector audit.
44
+ */
45
+ function varPattern(): RegExp {
46
+ return /\{\{\s*([a-zA-Z_][\w.]*)\s*\}\}/g;
47
+ }
48
+
49
+ function hasAnyVar(s: string): boolean {
50
+ return varPattern().test(s);
51
+ }
52
+
53
+ /**
54
+ * Extract every `{{var}}` reference from a string. Returns the list of var
55
+ * names (unique, in appearance order).
56
+ */
57
+ function extractVars(s: string): string[] {
58
+ const out: string[] = [];
59
+ const seen = new Set<string>();
60
+ for (const m of s.matchAll(varPattern())) {
61
+ const v = m[1];
62
+ if (!seen.has(v)) {
63
+ seen.add(v);
64
+ out.push(v);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Decide how to resolve a field. Returns null when the field can't drive a
72
+ * useful display string for QA.
73
+ *
74
+ * `isLocator` true means the value is a Playwright locator selector string
75
+ * (CSS / XPath / attribute query). For these, hardcoded values without an
76
+ * embedded `{{var}}` are skipped because the literal `a[href="/x"]` is
77
+ * opaque garbage for QA — the original `[selectorKey]` is more informative.
78
+ * Composite locators that embed `{{var}}` (e.g. `:has-text("{{x}}")`) still
79
+ * resolve via the embedded var.
80
+ *
81
+ * For non-locator fields (`name`, or `value` with type=text), hardcoded
82
+ * literals ARE substituted so brand names and fixed copy ("Sun* Kudos",
83
+ * "Notifications") show in step text instead of opaque keys ("nav kudos").
84
+ */
85
+ function resolveField(value: string, isLocator: boolean): SelectorResolution | null {
86
+ if (!value) return null;
87
+ const vars = extractVars(value);
88
+ if (vars.length === 0) {
89
+ return isLocator ? null : { literal: value };
90
+ }
91
+ if (vars.length === 1) {
92
+ // Sole `{{var}}` or composite like `has-text("{{var}}")`. Both resolve
93
+ // to the same var's locale-specific display text.
94
+ return { varName: vars[0] };
95
+ }
96
+ // 2+ vars in one template — ambiguous which to use; skip rather than guess.
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Build the SelectorKey → resolution map from a selectors YAML file.
102
+ * Returns an empty map when the file is missing or malformed.
103
+ */
104
+ export function loadSelectorKeyMap(selectorsFilePath: string): SelectorKeyMap {
105
+ const out: SelectorKeyMap = new Map();
106
+ if (!fs.existsSync(selectorsFilePath)) return out;
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = parseYaml(fs.readFileSync(selectorsFilePath, 'utf-8'));
110
+ } catch {
111
+ return out;
112
+ }
113
+ if (!parsed || typeof parsed !== 'object') return out;
114
+
115
+ for (const [key, raw] of Object.entries(parsed as Record<string, unknown>)) {
116
+ if (!raw || typeof raw !== 'object') continue;
117
+ const entry = raw as Record<string, unknown>;
118
+ const type = typeof entry.type === 'string' ? entry.type : '';
119
+
120
+ // Prefer `name` first — it's the canonical display text for role-based
121
+ // selectors. Skip when type is 'page' (URL paths) since pages never
122
+ // appear as bracket refs in step text in a display-meaningful way.
123
+ if (type === 'page') continue;
124
+
125
+ const nameField = entry.name;
126
+ if (typeof nameField === 'string') {
127
+ // `name` is always display text — never a locator string.
128
+ const resolution = resolveField(nameField, false);
129
+ if (resolution) {
130
+ out.set(key, resolution);
131
+ continue;
132
+ }
133
+ }
134
+
135
+ // For `text` and `locator` types the `value` field is the display text
136
+ // (text) or embeds it (locator with `:has-text("{{x}}")`). For role,
137
+ // `value` is the role name ('button', 'link') — not useful here.
138
+ if (type === 'text' || type === 'locator') {
139
+ const valueField = entry.value;
140
+ if (typeof valueField === 'string') {
141
+ const resolution = resolveField(valueField, type === 'locator');
142
+ if (resolution) {
143
+ out.set(key, resolution);
144
+ continue;
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Replace every `[selectorKey]` reference in a step (or expected) line with
155
+ * its resolved display text. Falls back to the original `[key]` when the
156
+ * key isn't in the map or its resolution doesn't yield a non-empty value.
157
+ *
158
+ * Sungen convention puts selector refs in square brackets, so any `[…]`
159
+ * substring is a candidate. Non-selector brackets (literal `[1]` etc.)
160
+ * safely fall through because they're never in the selector map.
161
+ *
162
+ * IMPORTANT: when the step already contains a `{{var}}` template, the
163
+ * substitution is SKIPPED for the whole step. Steps of the shape
164
+ * `See [X] header with {{x}}` already convey the locale-specific value
165
+ * via the `{{x}}` placeholder; replacing `[X]` with the same value would
166
+ * produce noisy duplication like `See [Au Co Arts Centre, Hanoi] header
167
+ * with {{event_location}}`. Keeping the selector key matches the source
168
+ * `.feature` shape and stays readable for QA reviewers.
169
+ */
170
+ export function substituteSelectorKeysInStep(
171
+ stepText: string,
172
+ selectorKeyMap: SelectorKeyMap,
173
+ testData: Record<string, string>,
174
+ ): string {
175
+ // Fresh regex per call — no state pollution across step / feature boundaries.
176
+ if (hasAnyVar(stepText)) return stepText;
177
+
178
+ return stepText.replace(/\[([^\]]+)\]/g, (match, key: string) => {
179
+ // Sungen convention lowercases the bracketed Reference when computing
180
+ // the YAML key (`sungen-selector-keys` skill), so the step might say
181
+ // `[Kudos:Quick Action]` while the selector entry is `kudos:quick action`.
182
+ // Normalize before lookup to match both styles.
183
+ const trimmedKey = key.trim();
184
+ const res = selectorKeyMap.get(trimmedKey) ?? selectorKeyMap.get(trimmedKey.toLowerCase());
185
+ if (!res) return match;
186
+ const value = res.varName ? testData[res.varName] : res.literal;
187
+ if (value === undefined || value === null || value === '') return match;
188
+ return `[${value}]`;
189
+ });
190
+ }
@@ -12,19 +12,77 @@ import { parse as parseYaml } from 'yaml';
12
12
  * kudos:
13
13
  * recipient: "An"
14
14
  * become { "kudos.recipient": "An" }, matching {{kudos.recipient}} refs.
15
- * Arrays are JSON-stringified. Returns empty map if file missing.
15
+ * Arrays are JSON-stringified. Returns empty map if both files are missing.
16
+ *
17
+ * When `SUNGEN_ENV` is set, also merges the matching overlay file alongside
18
+ * the base (e.g. `login.yaml` + `login.vi.yaml`). Shallow root-level merge —
19
+ * keys present in the overlay replace base. Matches the compile-time runtime
20
+ * resolver behaviour in src/generators/test-generator/utils/data-resolver.ts
21
+ * so delivery CSV/XLSX values line up with what tests actually ran with.
22
+ */
23
+ /**
24
+ * Optional `locale` overrides `process.env.SUNGEN_ENV` — used by the
25
+ * multi-locale delivery aggregator so each iteration of the loop can load a
26
+ * specific overlay without mutating the global env. Pass `null` to force
27
+ * base-only (no overlay merge); leave `undefined` to keep the env-driven
28
+ * behaviour (default).
16
29
  */
17
- export function loadTestData(testDataFilePath: string): Record<string, string> {
18
- if (!fs.existsSync(testDataFilePath)) return {};
19
- const content = fs.readFileSync(testDataFilePath, 'utf-8');
20
- const parsed = parseYaml(content);
21
- if (!parsed || typeof parsed !== 'object') return {};
30
+ export function loadTestData(testDataFilePath: string, locale?: string | null): Record<string, string> {
31
+ const base = readYamlObject(testDataFilePath);
32
+ const effectiveLocale = locale !== undefined ? locale : process.env.SUNGEN_ENV;
33
+ const overlay = effectiveLocale ? readYamlObject(envOverlayPath(testDataFilePath, effectiveLocale)) : null;
34
+ if (!base && !overlay) return {};
35
+ // Deep merge so nested namespaces (flow conventions like `kudos.recipient`
36
+ // / `profile.sent_tab_label`) don't get wiped when the overlay touches the
37
+ // top-level `kudos:` key. Shallow spread previously replaced the whole
38
+ // sub-tree, dropping every base key the overlay didn't redeclare.
39
+ const merged = deepMerge(base ?? {}, overlay ?? {});
22
40
 
23
41
  const result: Record<string, string> = {};
24
- flatten(parsed as Record<string, unknown>, '', result);
42
+ flatten(merged, '', result);
25
43
  return result;
26
44
  }
27
45
 
46
+ /**
47
+ * Two-input plain-object deep merge. Overlay wins at every level:
48
+ * - Scalar / array / mismatched type → overlay replaces base.
49
+ * - Both plain objects → recursive merge.
50
+ * Pure (no input mutation) so callers can reuse the inputs.
51
+ */
52
+ function deepMerge(
53
+ base: Record<string, unknown>,
54
+ overlay: Record<string, unknown>,
55
+ ): Record<string, unknown> {
56
+ const result: Record<string, unknown> = { ...base };
57
+ for (const [key, overlayValue] of Object.entries(overlay)) {
58
+ const baseValue = result[key];
59
+ if (isPlainObject(baseValue) && isPlainObject(overlayValue)) {
60
+ result[key] = deepMerge(baseValue as Record<string, unknown>, overlayValue as Record<string, unknown>);
61
+ } else {
62
+ result[key] = overlayValue;
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function isPlainObject(value: unknown): boolean {
69
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
70
+ }
71
+
72
+ function readYamlObject(filePath: string): Record<string, unknown> | null {
73
+ if (!fs.existsSync(filePath)) return null;
74
+ const parsed = parseYaml(fs.readFileSync(filePath, 'utf-8'));
75
+ if (!parsed || typeof parsed !== 'object') return null;
76
+ return parsed as Record<string, unknown>;
77
+ }
78
+
79
+ function envOverlayPath(basePath: string, env: string): string {
80
+ const ext = path.extname(basePath);
81
+ const dir = path.dirname(basePath);
82
+ const stem = path.basename(basePath, ext);
83
+ return path.join(dir, `${stem}.${env}${ext}`);
84
+ }
85
+
28
86
  function flatten(obj: Record<string, unknown>, prefix: string, out: Record<string, string>): void {
29
87
  for (const [key, value] of Object.entries(obj)) {
30
88
  if (value === null || value === undefined) continue;
@@ -117,6 +117,7 @@ export interface PlaywrightResult {
117
117
  startTime?: string;
118
118
  error?: string;
119
119
  tracePath?: string;
120
+ projectName?: string;
120
121
  }
121
122
 
122
123
  /**
@@ -16,6 +16,7 @@ import JSZip from 'jszip';
16
16
  import { ScreenSummary, TestCaseRow } from './types';
17
17
  import { getPackageVersion } from './package-info';
18
18
  import { SUN_LOGO_PNG_BASE64 } from './sun-logo';
19
+ import { deliverableBasename } from './csv-exporter';
19
20
 
20
21
  const COL_COUNT = 16;
21
22
  const HEADER_FILL = 'FFD9D2E9'; // lavender — matches the summary-header band on row 6
@@ -44,7 +45,55 @@ export function renderXlsx(
44
45
  const wb = new ExcelJS.Workbook();
45
46
  wb.creator = 'sungen delivery';
46
47
  wb.created = new Date();
47
- const ws = wb.addWorksheet('Testcases', {
48
+ addTestcaseSheet(wb, 'Testcases', summary, rows, specLink);
49
+ return wb;
50
+ }
51
+
52
+ /**
53
+ * Render multiple locales into a single workbook with one sheet per locale.
54
+ *
55
+ * Triggered automatically by `sungen delivery <screen>` when SUNGEN_ENV is
56
+ * unset and 2+ `*-test-result.<locale>.json` files are detected for the same
57
+ * feature basename. Each sheet uses the canonical BM-2-901-13 layout; sheet
58
+ * names follow `<feature>-<LOCALE>` (uppercased).
59
+ *
60
+ * Pass-through: `renderXlsx(summary, rows, specLink)` remains the single-sheet
61
+ * shortcut used when only one locale exists or when `SUNGEN_ENV` filters to
62
+ * one.
63
+ */
64
+ export interface LocaleSheet {
65
+ sheetName: string; // e.g. "home-VI" or "home-EN"
66
+ summary: ScreenSummary;
67
+ rows: TestCaseRow[];
68
+ specLink: string;
69
+ }
70
+
71
+ export function renderXlsxMultiSheet(sheets: LocaleSheet[]): ExcelJS.Workbook {
72
+ if (sheets.length === 0) {
73
+ throw new Error('renderXlsxMultiSheet requires at least one locale sheet');
74
+ }
75
+ const wb = new ExcelJS.Workbook();
76
+ wb.creator = 'sungen delivery';
77
+ wb.created = new Date();
78
+ for (const s of sheets) {
79
+ addTestcaseSheet(wb, s.sheetName, s.summary, s.rows, s.specLink);
80
+ }
81
+ return wb;
82
+ }
83
+
84
+ /**
85
+ * Append one BM-2-901-13 testcase sheet to the given workbook. Sheet content
86
+ * (layout, summary band, data rows, auto-filter, protection) is identical to
87
+ * the single-sheet `renderXlsx` output — only the sheet name differs.
88
+ */
89
+ function addTestcaseSheet(
90
+ wb: ExcelJS.Workbook,
91
+ sheetName: string,
92
+ summary: ScreenSummary,
93
+ rows: TestCaseRow[],
94
+ specLink: string,
95
+ ): void {
96
+ const ws = wb.addWorksheet(sheetName, {
48
97
  views: [{ state: 'frozen', ySplit: 0, xSplit: 0 }],
49
98
  });
50
99
 
@@ -359,22 +408,27 @@ export function renderXlsx(
359
408
  objects: false,
360
409
  scenarios: false,
361
410
  };
362
-
363
- return wb;
364
411
  }
365
412
 
366
413
  /**
367
- * Write the workbook to `qa/deliverables/<screen>-testcases.xlsx`.
368
- * Directory is created on demand. Returns the absolute output path.
414
+ * Write the workbook to `qa/deliverables/<screen>-testcases[.env].xlsx`.
415
+ * When `SUNGEN_ENV` is set, the env name is appended so locale exports don't
416
+ * overwrite each other. Directory is created on demand.
417
+ */
418
+ /**
419
+ * Optional `locale` overrides `process.env.SUNGEN_ENV` for filename suffix —
420
+ * used by the multi-locale delivery aggregator. Pass `null` / empty string
421
+ * to force the base (no suffix) variant.
369
422
  */
370
423
  export async function writeXlsx(
371
424
  cwd: string,
372
425
  screen: string,
373
- wb: ExcelJS.Workbook
426
+ wb: ExcelJS.Workbook,
427
+ locale?: string | null,
374
428
  ): Promise<string> {
375
429
  const outDir = path.join(cwd, 'qa', 'deliverables');
376
430
  if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
377
- const outPath = path.join(outDir, `${screen}-testcases.xlsx`);
431
+ const outPath = path.join(outDir, `${deliverableBasename(screen, locale)}.xlsx`);
378
432
  const buffer = await wb.xlsx.writeBuffer();
379
433
  const locked = await lockEmbeddedImages(Buffer.from(buffer as ArrayBuffer));
380
434
  fs.writeFileSync(outPath, locked);
@@ -305,6 +305,20 @@ export class CodeGenerator {
305
305
  }
306
306
  }
307
307
 
308
+ // base.ts now depends on locale-fixture.ts — keep them paired.
309
+ const localeFixturePath = path.join(outputDir, 'locale-fixture.ts');
310
+ if (!fs.existsSync(localeFixturePath)) {
311
+ const templatePath = path.join(templatesRoot, 'specs-locale-fixture.ts');
312
+ if (fs.existsSync(templatePath)) {
313
+ const baseDir = path.dirname(localeFixturePath);
314
+ if (!fs.existsSync(baseDir)) {
315
+ fs.mkdirSync(baseDir, { recursive: true });
316
+ }
317
+ fs.copyFileSync(templatePath, localeFixturePath);
318
+ console.log('✓ Created: specs/locale-fixture.ts');
319
+ }
320
+ }
321
+
308
322
  if (this.options.runtimeData) {
309
323
  const testDataPath = path.join(outputDir, 'test-data.ts');
310
324
  if (!fs.existsSync(testDataPath)) {