@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.
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +215 -65
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +173 -32
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/dashboard/templates/index.html +84 -84
- package/dist/dashboard/types.d.ts +35 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.d.ts +24 -3
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +28 -7
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/json-exporter.d.ts +15 -0
- package/dist/exporters/json-exporter.d.ts.map +1 -1
- package/dist/exporters/json-exporter.js +7 -2
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts +7 -0
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +20 -0
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/selector-key-resolver.d.ts +55 -0
- package/dist/exporters/selector-key-resolver.d.ts.map +1 -0
- package/dist/exporters/selector-key-resolver.js +208 -0
- package/dist/exporters/selector-key-resolver.js.map +1 -0
- package/dist/exporters/test-data-resolver.d.ts +15 -2
- package/dist/exporters/test-data-resolver.d.ts.map +1 -1
- package/dist/exporters/test-data-resolver.js +61 -8
- package/dist/exporters/test-data-resolver.js.map +1 -1
- package/dist/exporters/types.d.ts +1 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.d.ts +28 -3
- package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.js +34 -6
- package/dist/exporters/xlsx-exporter.js.map +1 -1
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +13 -0
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
- package/dist/orchestrator/ai-rules-updater.js +4 -0
- package/dist/orchestrator/ai-rules-updater.js.map +1 -1
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +48 -14
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +4 -1
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +22 -11
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-locale.md +71 -0
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-review.md +23 -8
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +45 -6
- package/dist/orchestrator/templates/ai-instructions/claude-config.md +4 -1
- package/dist/orchestrator/templates/ai-instructions/claude-skill-locale.md +316 -0
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -13
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +4 -1
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +20 -9
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +70 -0
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-review.md +23 -8
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +44 -6
- package/dist/orchestrator/templates/ai-instructions/copilot-config.md +4 -1
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +291 -0
- package/dist/orchestrator/templates/playwright.config.ts +25 -8
- package/dist/orchestrator/templates/specs-base.ts +9 -0
- package/dist/orchestrator/templates/specs-locale-fixture.ts +105 -0
- package/package.json +1 -1
- package/src/cli/commands/delivery.ts +256 -65
- package/src/cli/index.ts +1 -1
- package/src/dashboard/snapshot-builder.ts +207 -32
- package/src/dashboard/templates/index.html +84 -84
- package/src/dashboard/types.ts +40 -3
- package/src/exporters/csv-exporter.ts +36 -7
- package/src/exporters/json-exporter.ts +22 -2
- package/src/exporters/playwright-report-parser.ts +20 -0
- package/src/exporters/selector-key-resolver.ts +190 -0
- package/src/exporters/test-data-resolver.ts +65 -7
- package/src/exporters/types.ts +1 -0
- package/src/exporters/xlsx-exporter.ts +61 -7
- package/src/generators/test-generator/code-generator.ts +14 -0
- package/src/orchestrator/ai-rules-updater.ts +4 -0
- package/src/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +48 -14
- package/src/orchestrator/templates/ai-instructions/claude-cmd-dashboard.md +4 -1
- package/src/orchestrator/templates/ai-instructions/claude-cmd-delivery.md +22 -11
- package/src/orchestrator/templates/ai-instructions/claude-cmd-locale.md +71 -0
- package/src/orchestrator/templates/ai-instructions/claude-cmd-review.md +23 -8
- package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +45 -6
- package/src/orchestrator/templates/ai-instructions/claude-config.md +4 -1
- package/src/orchestrator/templates/ai-instructions/claude-skill-locale.md +316 -0
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +50 -13
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-dashboard.md +4 -1
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +20 -9
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-locale.md +70 -0
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-review.md +23 -8
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +44 -6
- package/src/orchestrator/templates/ai-instructions/copilot-config.md +4 -1
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-locale.md +291 -0
- package/src/orchestrator/templates/playwright.config.ts +25 -8
- package/src/orchestrator/templates/specs-base.ts +9 -0
- package/src/orchestrator/templates/specs-locale-fixture.ts +105 -0
- package/dist/orchestrator/templates/playwright.config.d.ts +0 -10
- package/dist/orchestrator/templates/playwright.config.d.ts.map +0 -1
- package/dist/orchestrator/templates/playwright.config.js +0 -104
- package/dist/orchestrator/templates/playwright.config.js.map +0 -1
- package/dist/orchestrator/templates/specs-base.d.ts +0 -14
- package/dist/orchestrator/templates/specs-base.d.ts.map +0 -1
- package/dist/orchestrator/templates/specs-base.js +0 -77
- package/dist/orchestrator/templates/specs-base.js.map +0 -1
- package/dist/orchestrator/templates/specs-test-data.d.ts +0 -16
- package/dist/orchestrator/templates/specs-test-data.d.ts.map +0 -1
- package/dist/orchestrator/templates/specs-test-data.js +0 -151
- package/dist/orchestrator/templates/specs-test-data.js.map +0 -1
package/src/dashboard/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
if (!
|
|
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(
|
|
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;
|
package/src/exporters/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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}
|
|
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)) {
|