@sun-asterisk/sungen 3.2.14 → 3.2.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 +29 -50
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +3 -46
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/exporters/locale-variants.d.ts +38 -0
- package/dist/exporters/locale-variants.d.ts.map +1 -0
- package/dist/exporters/locale-variants.js +159 -0
- package/dist/exporters/locale-variants.js.map +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +3 -11
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +11 -1
- package/dist/generators/test-generator/code-generator.js +1 -1
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +4 -1
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +3 -0
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts +6 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +41 -2
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/harness/annotation-overrides.d.ts +4 -1
- package/dist/harness/annotation-overrides.d.ts.map +1 -1
- package/dist/harness/annotation-overrides.js +5 -2
- package/dist/harness/annotation-overrides.js.map +1 -1
- package/dist/harness/quality-gates.d.ts.map +1 -1
- package/dist/harness/quality-gates.js +5 -0
- package/dist/harness/quality-gates.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +7 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +1 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +8 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +8 -0
- package/dist/orchestrator/templates/specs-test-data.ts +13 -4
- package/package.json +4 -4
- package/src/cli/commands/delivery.ts +32 -70
- package/src/dashboard/snapshot-builder.ts +11 -61
- package/src/exporters/locale-variants.ts +157 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +3 -11
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +11 -1
- package/src/generators/test-generator/code-generator.ts +1 -1
- package/src/generators/test-generator/diagnostics.ts +4 -1
- package/src/generators/test-generator/step-mapper.ts +43 -2
- package/src/harness/annotation-overrides.ts +5 -2
- package/src/harness/quality-gates.ts +4 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +7 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +1 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +8 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +8 -0
- package/src/orchestrator/templates/specs-test-data.ts +13 -4
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale/env classification for `<feature>-test-result.<suffix>.json` files —
|
|
3
|
+
* SHARED by the delivery CLI and the dashboard snapshot builder (previously two
|
|
4
|
+
* hand-mirrored copies that had already drifted into the fake-locale bug).
|
|
5
|
+
*
|
|
6
|
+
* Field report (3.2.14): every result-file suffix was treated as a LOCALE. A
|
|
7
|
+
* team running `SUNGEN_ENV=dev npx playwright test` (the standard .env.qa.dev
|
|
8
|
+
* convention) then `sungen delivery` without an env got a fabricated two-locale
|
|
9
|
+
* deliverable: a "VI Auto" sheet (hardcoded base label, all Pending, pointing
|
|
10
|
+
* at a result file that doesn't exist) next to a "DEV Auto" sheet.
|
|
11
|
+
*
|
|
12
|
+
* The file mechanism cannot distinguish an env overlay (`login.dev.yaml`,
|
|
13
|
+
* per-env credentials) from a locale overlay (`login.ja.yaml`, translated
|
|
14
|
+
* text) — they are deliberately the same mechanism keyed by SUNGEN_ENV. So
|
|
15
|
+
* locales are DECLARED, not guessed:
|
|
16
|
+
*
|
|
17
|
+
* # qa/capabilities.yaml
|
|
18
|
+
* locales: [ja, en] # non-base locales (written by /sungen:locale)
|
|
19
|
+
* base_locale: en # language of the base test-data (default: en)
|
|
20
|
+
*
|
|
21
|
+
* Suffix classification (first match wins), with E = the delivery env
|
|
22
|
+
* (SUNGEN_ENV / --env, possibly empty):
|
|
23
|
+
* 1. ^(android|ios)(.|$) → OS run (mobile per-OS branch consumes)
|
|
24
|
+
* 2. suffix === E → the base variant's own file (not a variant)
|
|
25
|
+
* 3. E === '' && suffix ∈ locales → locale variant on the base env
|
|
26
|
+
* 4. E !== '' && suffix === `${E}-<L>`, L ∈ locales → locale variant on env E
|
|
27
|
+
* 5. anything else → env run — EXCLUDED from variants and
|
|
28
|
+
* reported in `ignored` so the CLI can hint "run with --env <suffix>".
|
|
29
|
+
*
|
|
30
|
+
* Env pairing (rules 2–4) means a delivery only ever mixes results from ONE
|
|
31
|
+
* environment. Running with `--env ja` (an env named exactly like a declared
|
|
32
|
+
* locale) is interpreted as "deliver that locale's run" — the documented
|
|
33
|
+
* SUNGEN_ENV=<locale> convention, not a collision.
|
|
34
|
+
*/
|
|
35
|
+
import * as fs from 'fs';
|
|
36
|
+
import * as path from 'path';
|
|
37
|
+
import { parse as parseYaml } from 'yaml';
|
|
38
|
+
|
|
39
|
+
export interface LocaleVariant {
|
|
40
|
+
/** Result-file suffix as found on disk ('' = base). */
|
|
41
|
+
locale: string;
|
|
42
|
+
/** Sheet-name label (used only in multi-locale mode). */
|
|
43
|
+
displayCode: string;
|
|
44
|
+
resultsPath: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Test-data overlay code to merge for this variant's Test Data column —
|
|
47
|
+
* mirrors what the RUNTIME merged (TestDataLoader uses SUNGEN_ENV): the full
|
|
48
|
+
* suffix on web, the after-OS tail on mobile (`android.ja` ran with
|
|
49
|
+
* SUNGEN_ENV=ja). `null` = base data only. loadTestData no-ops on a missing
|
|
50
|
+
* overlay file, so passing an env code whose overlay doesn't exist is safe.
|
|
51
|
+
*/
|
|
52
|
+
overlay: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LocaleDiscovery {
|
|
56
|
+
variants: LocaleVariant[];
|
|
57
|
+
/** Env-run suffixes with result files, excluded from this delivery (hint material). */
|
|
58
|
+
ignored: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LocaleConfig {
|
|
62
|
+
baseLocale: string;
|
|
63
|
+
locales: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const DEFAULT_BASE_LOCALE = 'en';
|
|
67
|
+
|
|
68
|
+
/** Read the i18n declaration from qa/capabilities.yaml (absent → no locales, base 'en'). */
|
|
69
|
+
export function readLocaleConfig(cwd: string): LocaleConfig {
|
|
70
|
+
try {
|
|
71
|
+
const p = path.join(cwd, 'qa', 'capabilities.yaml');
|
|
72
|
+
if (fs.existsSync(p)) {
|
|
73
|
+
const parsed = parseYaml(fs.readFileSync(p, 'utf-8')) as
|
|
74
|
+
{ locales?: unknown; base_locale?: unknown } | null;
|
|
75
|
+
const locales = Array.isArray(parsed?.locales)
|
|
76
|
+
? parsed!.locales!.filter((l): l is string => typeof l === 'string' && l.length > 0)
|
|
77
|
+
: [];
|
|
78
|
+
const baseLocale = typeof parsed?.base_locale === 'string' && parsed.base_locale
|
|
79
|
+
? parsed.base_locale
|
|
80
|
+
: DEFAULT_BASE_LOCALE;
|
|
81
|
+
return { baseLocale, locales };
|
|
82
|
+
}
|
|
83
|
+
} catch { /* malformed yaml → behave as undeclared */ }
|
|
84
|
+
return { baseLocale: DEFAULT_BASE_LOCALE, locales: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const OS_SUFFIX = /^(android|ios)(\.|$)/;
|
|
88
|
+
|
|
89
|
+
/** The after-OS tail of a mobile suffix: 'android.ja' → 'ja', 'ios' → ''. */
|
|
90
|
+
export function osSuffixTail(suffix: string): string {
|
|
91
|
+
return suffix.replace(OS_SUFFIX, '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Discover the variants of a feature for ONE delivery run.
|
|
96
|
+
*
|
|
97
|
+
* `resolveBaseResultsPath` supplies the base variant's results (delivery and
|
|
98
|
+
* the dashboard share the same env-aware candidate chain via
|
|
99
|
+
* playwright-report-parser.resolveResultsPath — passed in to avoid a cycle).
|
|
100
|
+
*/
|
|
101
|
+
export function discoverLocaleVariants(
|
|
102
|
+
cwd: string,
|
|
103
|
+
genDir: string,
|
|
104
|
+
featureBaseName: string,
|
|
105
|
+
resolveBaseResultsPath: () => string | null,
|
|
106
|
+
): LocaleDiscovery {
|
|
107
|
+
const cfg = readLocaleConfig(cwd);
|
|
108
|
+
const env = process.env.SUNGEN_ENV || '';
|
|
109
|
+
const prefix = `${featureBaseName}-test-result`;
|
|
110
|
+
|
|
111
|
+
const variants: LocaleVariant[] = [{
|
|
112
|
+
locale: '',
|
|
113
|
+
// With --env <locale> (the SUNGEN_ENV=<locale> convention) the base IS that
|
|
114
|
+
// locale's run; otherwise the base carries the declared base language.
|
|
115
|
+
displayCode: (cfg.locales.includes(env) ? env : cfg.baseLocale).toUpperCase(),
|
|
116
|
+
resultsPath: resolveBaseResultsPath(),
|
|
117
|
+
overlay: env || null,
|
|
118
|
+
}];
|
|
119
|
+
const ignored: string[] = [];
|
|
120
|
+
|
|
121
|
+
if (fs.existsSync(genDir)) {
|
|
122
|
+
const suffixes = fs.readdirSync(genDir)
|
|
123
|
+
.filter((f) => f.startsWith(`${prefix}.`) && f.endsWith('.json') && f !== `${prefix}.json`)
|
|
124
|
+
.map((f) => f.slice(prefix.length + 1, -'.json'.length))
|
|
125
|
+
.filter((s) => s.length > 0)
|
|
126
|
+
.sort();
|
|
127
|
+
for (const suffix of suffixes) {
|
|
128
|
+
if (OS_SUFFIX.test(suffix)) {
|
|
129
|
+
// Mobile per-OS run — kept unconditionally; the mobile branch consumes these.
|
|
130
|
+
// Overlay = the tail (what SUNGEN_ENV was during that run), never the OS itself.
|
|
131
|
+
variants.push({
|
|
132
|
+
locale: suffix,
|
|
133
|
+
displayCode: suffix.toUpperCase(),
|
|
134
|
+
resultsPath: path.join(genDir, `${prefix}.${suffix}.json`),
|
|
135
|
+
overlay: osSuffixTail(suffix) || null,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (suffix === env) continue; // the base variant's own file
|
|
140
|
+
const isLocaleOfThisEnv = env === ''
|
|
141
|
+
? cfg.locales.includes(suffix)
|
|
142
|
+
: suffix.startsWith(`${env}-`) && cfg.locales.includes(suffix.slice(env.length + 1));
|
|
143
|
+
if (isLocaleOfThisEnv) {
|
|
144
|
+
variants.push({
|
|
145
|
+
locale: suffix,
|
|
146
|
+
displayCode: suffix.toUpperCase(),
|
|
147
|
+
resultsPath: path.join(genDir, `${prefix}.${suffix}.json`),
|
|
148
|
+
overlay: suffix,
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
ignored.push(suffix);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { variants, ignored };
|
|
157
|
+
}
|
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
{{~#if isGiven~}}
|
|
2
1
|
{
|
|
3
|
-
const
|
|
2
|
+
const table = {{> locator}};
|
|
3
|
+
const rows = table.locator('tbody').getByRole('row', { includeHidden: true }).filter({ visible: true });
|
|
4
4
|
{{#each assertions}}
|
|
5
|
-
{{this}}
|
|
5
|
+
{{{this}}}
|
|
6
6
|
{{/each~}}
|
|
7
7
|
}
|
|
8
|
-
{{~else~}}
|
|
9
|
-
{
|
|
10
|
-
const rows = {{> locator}}.locator('tbody').getByRole('row', { includeHidden: true }).filter({ visible: true });
|
|
11
|
-
{{#each assertions}}
|
|
12
|
-
{{this}}
|
|
13
|
-
{{/each~}}
|
|
14
|
-
}
|
|
15
|
-
{{~/if}}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
{{~#if rowSelector}}
|
|
2
|
+
{{~#if isGiven}}
|
|
3
|
+
const tableRow = {{> locator}}.locator('{{rowSelector}}');
|
|
4
|
+
await tableRow.filter({ hasText: '{{escapeQuotes filterValue}}' }).waitFor();
|
|
5
|
+
{{~else}}
|
|
6
|
+
const tableRow = {{> locator}}.locator('{{rowSelector}}');
|
|
7
|
+
await expect(tableRow).toContainText('{{escapeQuotes filterValue}}');
|
|
8
|
+
{{~/if}}
|
|
9
|
+
{{~else}}
|
|
1
10
|
{{~#if isGiven}}
|
|
2
11
|
const tableRow = {{> locator}}.getByRole('row', { includeHidden: true }).filter({ hasText: '{{escapeQuotes filterValue}}' }).filter({ visible: true });
|
|
3
12
|
await tableRow.waitFor();
|
|
4
13
|
{{~else}}
|
|
5
14
|
const tableRow = {{> locator}}.getByRole('row', { includeHidden: true }).filter({ hasText: '{{escapeQuotes filterValue}}' }).filter({ visible: true });
|
|
6
15
|
await expect(tableRow).toBeVisible();
|
|
7
|
-
{{~/if}}
|
|
16
|
+
{{~/if}}
|
|
17
|
+
{{~/if}}
|
|
@@ -908,7 +908,7 @@ export class CodeGenerator {
|
|
|
908
908
|
// them. Rewrite those to the per-row `rowData` view — each row then fires its own `@api` call
|
|
909
909
|
// with that row's input and asserts that row's expected status/body (the success/failure matrix).
|
|
910
910
|
// `testData.cases()/withRow()` (the global loader, no `.get/.bind/.set`) is intentionally left alone.
|
|
911
|
-
return transformToRuntimeData(rendered, 'rowData').replace(/\btestData\.(get|bind|set)\(/g, 'rowData.$1(');
|
|
911
|
+
return transformToRuntimeData(rendered, 'rowData').replace(/\btestData\.(get|bind|set|raw)\(/g, 'rowData.$1(');
|
|
912
912
|
}
|
|
913
913
|
return rendered;
|
|
914
914
|
}
|
|
@@ -17,12 +17,15 @@
|
|
|
17
17
|
* action line + ≥1 labeled Observable:/Oracle:/Expected: line
|
|
18
18
|
* SG-W009 — test-data references a `${QA_*}` secret that is declared in neither .env.qa
|
|
19
19
|
* nor .env.qa.example — the test will throw "not set" the moment the key is used
|
|
20
|
+
* SG-W010 — a row step's [Ref] looks POSITIONAL (First/Last/Top/…) but has no selectors.yaml
|
|
21
|
+
* entry: the label is display-only, so the step checks existence anywhere in the
|
|
22
|
+
* table — an ordering rule would pass even when the row moved
|
|
20
23
|
*
|
|
21
24
|
* Diagnostics never block generation by themselves; `sungen generate --strict`
|
|
22
25
|
* turns any collected diagnostic into a non-zero exit.
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
|
-
export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009';
|
|
28
|
+
export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010';
|
|
26
29
|
|
|
27
30
|
export interface Diagnostic {
|
|
28
31
|
code: DiagnosticCode;
|
|
@@ -37,6 +37,10 @@ export class StepMapper {
|
|
|
37
37
|
/** Row scope: when active, column assertions are scoped to this row */
|
|
38
38
|
private inRowScope: boolean = false;
|
|
39
39
|
private rowScopeTable: string = '';
|
|
40
|
+
/** Count of row-scope-entering steps in the current scenario + whether the shared
|
|
41
|
+
* `tableRow` binding has been emitted (let-once + reassign when there are ≥2). */
|
|
42
|
+
private tableRowScopeSteps: number = 0;
|
|
43
|
+
private tableRowDeclared: boolean = false;
|
|
40
44
|
/** SG-W007: an action step already ran in this scenario (alert handlers registered after it are too late) */
|
|
41
45
|
private sawActionStep: boolean = false;
|
|
42
46
|
/** Compile-time warning channel (SG-W001/W002); optional — absent means silent legacy behavior */
|
|
@@ -109,6 +113,12 @@ export class StepMapper {
|
|
|
109
113
|
/**
|
|
110
114
|
* Set scenario context for path variable resolution
|
|
111
115
|
*/
|
|
116
|
+
/** A `see [Ref] row in [Table] table with {{v}}` step — declares `tableRow` and enters row scope. */
|
|
117
|
+
private isRowScopeEntryStep(step: ParsedStep): boolean {
|
|
118
|
+
return step.elementType === 'row' && /\bin\b.*\btable\b/i.test(step.text) &&
|
|
119
|
+
/\bwith\b/i.test(step.text) && !/\bis\s+hidden\b/i.test(step.text);
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
setScenarioContext(steps: ParsedStep[]): void {
|
|
113
123
|
this.currentScenarioSteps = steps;
|
|
114
124
|
// Reset dialog and frame scope at the start of each new scenario
|
|
@@ -116,6 +126,16 @@ export class StepMapper {
|
|
|
116
126
|
this.inFrameScope = false;
|
|
117
127
|
this.inRowScope = false;
|
|
118
128
|
this.rowScopeTable = '';
|
|
129
|
+
// Row-scope steps declare `const tableRow` at FUNCTION scope on purpose — the
|
|
130
|
+
// following `[Col] column with {{v}}` steps reference it, so the declaration
|
|
131
|
+
// can't be block-wrapped like the self-contained row ACTIONS are. But two or
|
|
132
|
+
// more row steps in one scenario then redeclare the same const → the whole
|
|
133
|
+
// spec fails to parse ("Identifier 'tableRow' has already been declared").
|
|
134
|
+
// Count them up front: with ≥2 the first becomes `let` and the rest plain
|
|
135
|
+
// reassignments (which also correctly REBINDS the row scope); a single
|
|
136
|
+
// occurrence keeps `const` so existing single-row specs stay byte-identical.
|
|
137
|
+
this.tableRowScopeSteps = steps.filter((s) => this.isRowScopeEntryStep(s)).length;
|
|
138
|
+
this.tableRowDeclared = false;
|
|
119
139
|
this.sawActionStep = false;
|
|
120
140
|
this.lastPrimaryKeyword = 'Given';
|
|
121
141
|
this.dataResolver.clearCaptured(); // captured vars are scenario-scoped
|
|
@@ -245,13 +265,34 @@ export class StepMapper {
|
|
|
245
265
|
|
|
246
266
|
if (mappedStep) {
|
|
247
267
|
// Enter row scope when a row-in-table step is matched
|
|
248
|
-
if (
|
|
249
|
-
/\bwith\b/i.test(step.text) && !/\bis\s+hidden\b/i.test(step.text)) {
|
|
268
|
+
if (this.isRowScopeEntryStep(step)) {
|
|
250
269
|
const brackets = step.text.match(/\[([^\]]+)\]/g) || [];
|
|
251
270
|
this.inRowScope = true;
|
|
252
271
|
this.rowScopeTable = brackets.length >= 2
|
|
253
272
|
? brackets[1].replace(/[\[\]]/g, '')
|
|
254
273
|
: '';
|
|
274
|
+
// ≥2 row steps in this scenario: turn the duplicate `const tableRow`
|
|
275
|
+
// declarations into one `let` + reassignments (see setScenarioContext).
|
|
276
|
+
if (this.tableRowScopeSteps > 1 && mappedStep.code && mappedStep.code.includes('const tableRow')) {
|
|
277
|
+
if (this.tableRowDeclared) {
|
|
278
|
+
mappedStep.code = mappedStep.code.replace('const tableRow', 'tableRow');
|
|
279
|
+
} else {
|
|
280
|
+
mappedStep.code = mappedStep.code.replace('const tableRow', 'let tableRow');
|
|
281
|
+
this.tableRowDeclared = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// SG-W010 — a POSITIONAL-looking row label ([First X Row], [Last …]) without a
|
|
285
|
+
// selectors.yaml entry is display-only: the step degrades to an anywhere-in-the-
|
|
286
|
+
// table existence check and an ordering rule silently never fails. A resolved
|
|
287
|
+
// entry compiles to a positioned toContainText — its absence is detectable here.
|
|
288
|
+
const rowLabel = (brackets[0] || '').replace(/[\[\]]/g, '');
|
|
289
|
+
if (this.diagnostics && rowLabel &&
|
|
290
|
+
/\b(first|last|top|bottom|nth|\d+(st|nd|rd|th)?)\b/i.test(rowLabel) &&
|
|
291
|
+
mappedStep.code && !mappedStep.code.includes('toContainText')) {
|
|
292
|
+
this.diagnostics.add('SG-W010',
|
|
293
|
+
`row label "[${rowLabel}]" looks positional but has no selectors.yaml entry — it is a display label only, so this step checks existence ANYWHERE in the table (an ordering rule would pass even when the row moved). Declare it to position the row, e.g. "${rowLabel.toLowerCase()}:\\n type: locator\\n value: 'tbody tr:first-child'"`,
|
|
294
|
+
{ feature: this.featureName, step: step.text });
|
|
295
|
+
}
|
|
255
296
|
}
|
|
256
297
|
|
|
257
298
|
if (this.verbose) {
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Shared annotation-override grammar for precondition annotations (`@query`/`@api`).
|
|
3
3
|
*
|
|
4
4
|
* Parses `name(a={{x}},b="lit",c=3)` overrides into a map of JS expressions, e.g.
|
|
5
|
-
* `{ a: "testData.
|
|
5
|
+
* `{ a: "testData.raw('x')", b: "\"lit\"", c: "3" }`. Used by the DB and API capability drivers'
|
|
6
6
|
* precondition codegen; lives in core so both drivers can share it. Gherkin tags carry no whitespace,
|
|
7
7
|
* so values are single tokens — flows thread a prior response via a whole-value ref, e.g.
|
|
8
8
|
* `@api:get_profile(token={{login.body.token}})`, with the auth scheme declared in the catalog header.
|
|
9
|
+
* A `{{ref}}` override binds into a typed sink (DB query param, JSON request body), so it resolves
|
|
10
|
+
* via `testData.raw()` (native type, still interpolates embedded refs) rather than `get()`
|
|
11
|
+
* (Gherkin-text stringify) — a numeric override otherwise reaches the DB/API as a JSON string.
|
|
9
12
|
*/
|
|
10
13
|
export function parseQueryOverrides(raw?: string): Record<string, string> {
|
|
11
14
|
const out: Record<string, string> = {};
|
|
@@ -18,7 +21,7 @@ export function parseQueryOverrides(raw?: string): Record<string, string> {
|
|
|
18
21
|
if (!key) continue;
|
|
19
22
|
const v = val.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
|
|
20
23
|
const q = val.match(/^["'](.*)["']$/);
|
|
21
|
-
if (v) out[key] = `testData.
|
|
24
|
+
if (v) out[key] = `testData.raw(${JSON.stringify(v[1])})`;
|
|
22
25
|
else if (q) out[key] = JSON.stringify(q[1]);
|
|
23
26
|
else if (/^-?\d+(?:\.\d+)?$/.test(val)) out[key] = val;
|
|
24
27
|
else out[key] = JSON.stringify(val);
|
|
@@ -174,6 +174,10 @@ export interface OwnershipResult { duplicates: { scenario: string; flow: string
|
|
|
174
174
|
|
|
175
175
|
/** Scenarios whose step-skeleton also appears in a sibling flow feature → duplicate ownership. */
|
|
176
176
|
export function crossArtifactOwnership(screenDir: string, scenarios: ScenarioInfo[]): OwnershipResult {
|
|
177
|
+
// A behavior's ownership is judged from the screen side; flow/api units are the owners, so the
|
|
178
|
+
// gate does not apply to them. Without this guard, auditing qa/flows/<x>, qa/api/<x>, or
|
|
179
|
+
// qa/api/flows/<x> resolves flowsRoot to the unit's own parent and every scenario self-matches.
|
|
180
|
+
if (path.basename(path.dirname(screenDir)) !== 'screens') return { duplicates: [] };
|
|
177
181
|
const duplicates: { scenario: string; flow: string }[] = [];
|
|
178
182
|
// screenDir = <root>/qa/screens/<name>; flows live at <root>/qa/flows/*/features/*.feature
|
|
179
183
|
const flowsRoot = path.resolve(screenDir, '..', '..', 'flows');
|
|
@@ -85,7 +85,13 @@ The `.xlsx` leads with an **`Overview`** roll-up sheet, then splits the test cas
|
|
|
85
85
|
- **`Auto`** — automatable test cases (`Auto` + `Not compiled`).
|
|
86
86
|
- **`Manual`** — `@manual` test cases (always present, header-only when there are none).
|
|
87
87
|
|
|
88
|
-
Multi-locale
|
|
88
|
+
Multi-locale: `Overview` + one **`<LOCALE> Auto`** sheet per locale + a single shared **`Manual`** sheet (manual TCs are locale-invariant). The **CSV stays one file with every row** — the `Testcase type` column distinguishes Auto vs Manual. The Overview lists every detail sheet, so each locale's counts appear on its own row.
|
|
89
|
+
|
|
90
|
+
**Which result files become locale sheets — locales are DECLARED, never guessed.** A result-file suffix (`<name>-test-result.<X>.json`) is a locale ONLY when `X` is listed in `qa/capabilities.yaml` `locales:` (written by `/sungen:locale`), or — when delivering with `--env <E>` / `SUNGEN_ENV=<E>` — when `X = <E>-<locale>`. Every delivery mixes results of ONE environment:
|
|
91
|
+
- `sungen delivery login` → base results + bare declared locales (`ja`); `dev`/`stg` suffixes are IGNORED with a hint (`→ sungen delivery login --env dev`)
|
|
92
|
+
- `sungen delivery login --env dev` → `dev` results as base + `dev-ja` sheets; everything else ignored
|
|
93
|
+
- `--env ja` (SUNGEN_ENV=<locale> convention) → that locale's run IS the base, labeled JA
|
|
94
|
+
The base sheet label comes from `base_locale:` (default **EN**). Undeclared env suffixes NEVER fabricate locale sheets — a project without `locales:` always gets one clean `Auto` sheet. Mobile per-OS suffixes (`android`, `ios.ja`, …) are exempt and keep the per-OS files; their Test Data column merges the after-OS tail overlay (`ios.ja` → `<name>.ja.yaml`), matching what the runtime merged.
|
|
89
95
|
|
|
90
96
|
---
|
|
91
97
|
|
|
@@ -94,6 +94,7 @@ needs any of these, it is a **finding for QA** — surface it in the run summary
|
|
|
94
94
|
| `SG-W007` | Browser-alert handler step written AFTER an action — listener registers too late, the dialog auto-dismisses silently | Use the compound form `click [X] button and accept [OK] alert` (preferred), or move the alert step before the trigger |
|
|
95
95
|
| `SG-W008` | `@manual` scenario incomplete in either direction: (a) HAS Gherkin steps but NO `Then` — a dangling `When` is invalid Gherkin, comments can NOT substitute; (b) documentation-style (NO steps) but its comments lack numbered action lines and/or a labeled oracle line — prose paragraphs / rationale-only comments export EMPTY Steps + Expected cells | Two valid shapes only: complete the Gherkin with a `Then`, or write the FULL procedure in the `# Tester verifies:` block as NUMBERED lines — action steps first (`1. <action>`), then labeled `Observable:`/`Oracle:`/`Expected:` lines (the number is what fills Steps, the label is what fills Expected results) |
|
|
96
96
|
| `SG-W009` | test-data references `${QA_<NAME>}` (a SECRET env ref) but the name is declared in neither `.env.qa` nor `.env.qa.example` — the test throws "environment variable not set" the moment the key is used. (Only the `${QA_*}` shape is checked; any other `${...}` is literal data and never flagged.) | Add `QA_<NAME>=<value>` to `.env.qa` (gitignored) and list the NAME in `.env.qa.example` (the committed checklist). See `qa/TEST-DATA-GUIDE.md` |
|
|
97
|
+
| `SG-W010` | A row step's `[Ref]` looks POSITIONAL (`First…`/`Last…`/`Top…`/`nth`) but has no selectors.yaml entry — the label is display-only, so the step checks existence ANYWHERE in the table; an ordering/sort-stability rule would pass even when the row moved | Declare the row in selectors.yaml (a `type: locator` value RELATIVE to the table, e.g. `first contact row:` → `value: 'tbody tr:first-child'`) — the step then asserts `toContainText` on that exact row. This is a selectors.yaml-only fix — allowed inside the run-test auto-fix loop |
|
|
97
98
|
|
|
98
99
|
### Runtime error → `Test data "<key>" references ${QA_*} but the environment variable is not set`
|
|
99
100
|
|
|
@@ -127,6 +127,14 @@ User see [Table] table match data:
|
|
|
127
127
|
|
|
128
128
|
Row scope: `see [Ref] row in [Table] table with {{v}}` enters scope. Subsequent `see [Col] column with {{v}}` checks cell in that row. Use `table match data:` for multi-row verification.
|
|
129
129
|
|
|
130
|
+
**[Ref] of a row step is a display label UNLESS declared in selectors.yaml.** Without an entry the step checks the value exists in ANY row — a name like `[First Contact Row]` does NOT position the row, and an ordering rule would pass even when the row moved (SG-W010 warns on positional-looking undeclared labels). To assert a POSITION, declare the row (a `type: locator` value RELATIVE to the table):
|
|
131
|
+
```yaml
|
|
132
|
+
first contact row:
|
|
133
|
+
type: locator
|
|
134
|
+
value: 'tbody tr:first-child'
|
|
135
|
+
```
|
|
136
|
+
→ compiles to `expect(table.locator('tbody tr:first-child')).toContainText(v)` — the exact row must hold the value — and still enters row scope for `[Col] column` checks.
|
|
137
|
+
|
|
130
138
|
### Database verification (optional Data Driver)
|
|
131
139
|
|
|
132
140
|
Read-only DB-state checks. **Prefer named queries** — SQL lives in `qa/screens/<screen>/database/queries.yaml` (reviewed once, parameterized). Invoke with the `@query:<name>` annotation; it binds the result rows to `{{name}}`, then assert with `expect`:
|
|
@@ -34,6 +34,14 @@ Take a screen/flow whose `selectors/*.yaml` and `.feature` files were authored a
|
|
|
34
34
|
1. `selectors/<feature>.yaml` — hardcoded `name`/`value` replaced with `{{var}}`
|
|
35
35
|
2. `test-data/<feature>.yaml` — base locale, complete with all new keys
|
|
36
36
|
3. `test-data/<feature>.<locale>.yaml` — overlay with only the keys that change
|
|
37
|
+
4. **`qa/capabilities.yaml` — DECLARE the locale** (required since 3.2.15): append the code to `locales:` and set `base_locale:` (language of the base test-data; tool default is `en`). Delivery/dashboard treat ONLY declared codes as locales — undeclared result-file suffixes are assumed to be ENVIRONMENTS (dev/staging) and excluded from locale sheets:
|
|
38
|
+
```yaml
|
|
39
|
+
platform: web
|
|
40
|
+
enabled: [web]
|
|
41
|
+
base_locale: en # or vi/ja — whatever the base test-data is written in
|
|
42
|
+
locales: [ja] # append every bootstrapped locale here
|
|
43
|
+
```
|
|
44
|
+
Env × locale combos use the compound `SUNGEN_ENV=<env>-<locale>` (e.g. `staging-ja`, overlay `<feature>.staging-ja.yaml`) — the locale segment LAST; delivery recognizes it when the tail matches a declared locale.
|
|
37
45
|
4. (Optional) `selectors/<feature>.yaml` Pages block updated when locale uses URL prefix or query param
|
|
38
46
|
|
|
39
47
|
After this skill finishes, `sungen run-test <name> --env <locale>` Just Works.
|
|
@@ -62,16 +62,25 @@ export class TestDataLoader {
|
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* Resolve a key to its RAW, uncoerced value (array/object/number kept as-is) — for binding
|
|
65
|
-
* DB query
|
|
66
|
-
* native type. get() stringifies for Gherkin text; raw()
|
|
67
|
-
*
|
|
65
|
+
* DB query / API request params, where type-strict sinks (MySQL numeric columns, MongoDB,
|
|
66
|
+
* a JSON request body) require the native type. get() stringifies for Gherkin text; raw()
|
|
67
|
+
* must not. A string value still has its embedded `{{ref}}` cross-references resolved (same
|
|
68
|
+
* as get()), so an override like `token={{login.body.token}}` or `id={{prefix}}-001` still
|
|
69
|
+
* interpolates — only the final scalar/array/object type is preserved uncoerced. Arrays and
|
|
70
|
+
* objects are returned as-is; refs embedded inside their elements are not interpolated (unlike
|
|
71
|
+
* get()'s JSON-text pass), since raw() callers bind structured values directly, not text.
|
|
72
|
+
* Throws on missing, mirroring get()'s guard.
|
|
68
73
|
*/
|
|
69
74
|
raw(key: string): any {
|
|
70
75
|
const value = this.resolve(key);
|
|
71
76
|
if (value === undefined || value === null) {
|
|
72
77
|
throw new Error(`Test data key not found: ${key}`);
|
|
73
78
|
}
|
|
74
|
-
if (typeof value === 'string')
|
|
79
|
+
if (typeof value === 'string') {
|
|
80
|
+
const result = this.interpolate(value);
|
|
81
|
+
assertNoUnresolvedEnvRef(key, result);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
75
84
|
return value;
|
|
76
85
|
}
|
|
77
86
|
|