@sun-asterisk/sungen 3.2.12-beta.1 → 3.2.12-beta.3

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.
@@ -25,7 +25,7 @@ import {
25
25
  import { ApiCatalogEntry, EnvironmentInfo, PlaywrightResult, ScreenSummary, TestCaseRow } from './types';
26
26
  import { collectSecretValues, formatApiExpected, formatApiRequest, hasApiCalls, redactSecrets } from './api-testcase-formatter';
27
27
  import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
28
- import { resolveResultVariants } from './result-variants';
28
+ import { formatVariantRow, resolveResultVariants } from './result-variants';
29
29
 
30
30
  export interface BuildCsvInput {
31
31
  screen: string;
@@ -70,7 +70,7 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
70
70
  // Data-driven (@cases): expand to one CSV row per executed dataset input (Playwright titles
71
71
  // "<scenario> — <label>"); plain scenarios resolve their single result. Shared with the
72
72
  // dashboard/JSON exporter via resolveResultVariants so the two can't drift on @cases handling.
73
- const variants = resolveResultVariants(m, input.results);
73
+ const variants = resolveResultVariants(m, input.results, input.testData);
74
74
 
75
75
  for (const variant of variants) {
76
76
  const displayName = `${m.feature.name}${variant.nameSuffix}`;
@@ -132,7 +132,11 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
132
132
  // Multi-line bullet format keeps delivery CSV/XLSX in sync with the
133
133
  // dashboard's Test data section. Multi-line CSV cells are valid (quoted
134
134
  // automatically) and render natively as multi-line in XLSX.
135
- const testData = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
135
+ let testData = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
136
+ // @cases: each report line shows ITS OWN dataset row (executed auto rows and manual
137
+ // split rows both carry variant.row) — one line per case, one dataset per line.
138
+ const variantRow = formatVariantRow(variant, '\n');
139
+ if (variantRow) testData = testData ? `${testData}\n${variantRow}` : variantRow;
136
140
 
137
141
  // Status for this (possibly per-input) row — resolved into `variant` above.
138
142
  const result: PlaywrightResult | undefined = variant.result;
@@ -9,7 +9,7 @@ import { FeatureMetadata, OrderedStep, ScenarioMetadata } from './types';
9
9
  /**
10
10
  * Variables referenced in a scenario: find all {{var_name}} in step text.
11
11
  */
12
- function extractReferencedVars(scenario: ParsedScenario): string[] {
12
+ function extractReferencedVars(scenario: ParsedScenario, includeComments = false): string[] {
13
13
  const vars = new Set<string>();
14
14
  for (const step of scenario.steps) {
15
15
  const matches = step.text.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_.-]*)\}\}/g);
@@ -28,6 +28,19 @@ function extractReferencedVars(scenario: ParsedScenario): string[] {
28
28
  }
29
29
  }
30
30
  }
31
+ // Manual scenarios carry their real tester procedure in `# Tester verifies:` comment
32
+ // blocks — the {{var}} refs there are the data the tester needs, so include them
33
+ // (issue: manual test cases exported with an empty Test Data cell). Automated
34
+ // scenarios keep step-only scanning: their comments are notes (SPEC-GAP etc.) and
35
+ // would only add noise vars to the delivery report.
36
+ if (includeComments) {
37
+ for (const line of scenario.comments ?? []) {
38
+ const matches = line.matchAll(/\{\{([a-zA-Z_][a-zA-Z0-9_.-]*)\}\}/g);
39
+ for (const match of matches) {
40
+ vars.add(match[1]);
41
+ }
42
+ }
43
+ }
31
44
  return Array.from(vars);
32
45
  }
33
46
 
@@ -85,7 +98,7 @@ export function parseFeatureMetadata(featureFilePath: string): FeatureMetadata {
85
98
  tags: [...parsed.tags, ...sc.tags],
86
99
  stepsName: sc.stepsName,
87
100
  extendsName: sc.extendsName,
88
- referencedVars: extractReferencedVars(sc),
101
+ referencedVars: extractReferencedVars(sc, extractTestcaseType([...parsed.tags, ...sc.tags]) === 'Manual'),
89
102
  rawGivenSteps: given,
90
103
  rawWhenSteps: when,
91
104
  rawThenSteps: then,
@@ -23,7 +23,7 @@ import {
23
23
  } from './playwright-report-parser';
24
24
  import { ApiCatalogEntry, EnvironmentInfo, PlaywrightResult } from './types';
25
25
  import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
26
- import { resolveResultVariants } from './result-variants';
26
+ import { formatVariantRow, resolveResultVariants } from './result-variants';
27
27
  import {
28
28
  ScenarioSnapshot,
29
29
  ScreenSnapshot,
@@ -123,13 +123,17 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
123
123
  }
124
124
  // Dashboard modal uses whitespace-pre-wrap — pass '\n' to render one
125
125
  // key/value per line instead of the CSV-friendly "; " join.
126
- const testDataStr = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
126
+ const baseTestDataStr = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
127
127
 
128
- // @cases scenarios expand to one snapshot entry per executed dataset row (shared with the CSV
129
- // exporter via resolveResultVariants), so their per-row pass/fail is counted not collapsed
130
- // into one Pending/N/A entry as before.
131
- const variants = resolveResultVariants(m, input.results ?? null);
128
+ // @cases scenarios expand to one snapshot entry per executed dataset row and manual
129
+ // @cases split per DATASET row (shared with the CSV exporter via resolveResultVariants),
130
+ // so their per-row pass/fail is counted — not collapsed into one Pending/N/A entry.
131
+ const variants = resolveResultVariants(m, input.results ?? null, input.testData);
132
132
  for (const variant of variants) {
133
+ // Per-line Test Data: shared vars + THIS variant's dataset row (parity with csv-exporter).
134
+ let testDataStr = baseTestDataStr;
135
+ const variantRow = formatVariantRow(variant, '\n');
136
+ if (variantRow) testDataStr = testDataStr ? `${testDataStr}\n${variantRow}` : variantRow;
133
137
  const displayName = `${m.feature.name}${variant.nameSuffix}`;
134
138
  const { vpId, category1 } = splitVpAndName(displayName);
135
139
  const tcId = generateTcId(input.screen, vpId, fallbackIndex);
@@ -5,12 +5,42 @@
5
5
  */
6
6
  import { MergedScenario } from './scenario-merger';
7
7
  import { PlaywrightResult } from './types';
8
+ import { extractTestcaseType } from './feature-parser';
8
9
 
9
10
  export interface ResultVariant {
10
- /** Appended to the scenario display name: `''` for a plain scenario, ` — <label>` per executed
11
+ /** Appended to the scenario display name: `''` for a plain scenario, ` — <label>` per
11
12
  * @cases dataset row (so each row gets a distinct category1). */
12
13
  nameSuffix: string;
13
14
  result?: PlaywrightResult;
15
+ /** The @cases dataset row backing this variant — lets the exporters render the row's own
16
+ * columns into the Test Data cell (`__`-prefixed bookkeeping keys excluded). Present on
17
+ * executed auto rows (matched by label) and on manual split rows. */
18
+ row?: Record<string, unknown>;
19
+ }
20
+
21
+ /**
22
+ * Read the `@cases:<name>` dataset rows for a scenario from the (flattened) test-data map.
23
+ * `loadTestData()` flattens arrays to JSON strings — accept that shape and raw arrays.
24
+ * Scalar rows normalize to `{ value }`, mirroring the runtime TestDataLoader.cases().
25
+ */
26
+ export function getCasesDatasetRows(
27
+ m: MergedScenario,
28
+ testData: Record<string, string> | null | undefined,
29
+ ): Array<Record<string, unknown>> | null {
30
+ const tag = m.feature.tags.find((t) => t.startsWith('@cases:'));
31
+ if (!tag || !testData) return null;
32
+ let list: unknown = testData[tag.slice('@cases:'.length)];
33
+ if (typeof list === 'string') {
34
+ try { list = JSON.parse(list); } catch { return null; }
35
+ }
36
+ if (!Array.isArray(list) || list.length === 0) return null;
37
+ return list.map((row) =>
38
+ row && typeof row === 'object' && !Array.isArray(row) ? (row as Record<string, unknown>) : { value: row });
39
+ }
40
+
41
+ /** Display label of a dataset row — mirrors the runtime TestDataLoader.cases() rule. */
42
+ function rowLabel(row: Record<string, unknown>, i: number): string {
43
+ return String(row.case ?? row.name ?? row.label ?? `row ${i + 1}`);
14
44
  }
15
45
 
16
46
  /**
@@ -18,9 +48,12 @@ export interface ResultVariant {
18
48
  *
19
49
  * A `@cases:<dataset>` scenario compiles to one `test()` per dataset row, whose Playwright title is
20
50
  * `<scenario> — <label>` — expand into one variant per EXECUTED row by matching that prefix (so an
21
- * already-run row carries its real pass/fail). A plain scenario resolves its single result by the
22
- * spec test title. With no results — or a @cases scenario with no matching result yet — a lone
23
- * variant with no result is returned, so the caller marks it Pending / N/A.
51
+ * already-run row carries its real pass/fail), attaching the backing dataset row by label.
52
+ *
53
+ * A MANUAL `@cases` scenario never executes, so that expansion can never fire — split it from the
54
+ * DATASET instead: one variant per row (` — <label>`, no result → Pending), each carrying its row
55
+ * so the manual tester gets one report line + its own data per case to walk and record. An auto
56
+ * @cases scenario that has not run yet stays a single line (its expansion belongs to execution).
24
57
  *
25
58
  * NOTE: this deliberately does NOT gate on `m.spec`. For @cases the compiled test title carries a
26
59
  * `— ${__row.__label}` suffix, so `findMatchingSpecTest` (base-name match) often leaves `m.spec`
@@ -30,13 +63,26 @@ export interface ResultVariant {
30
63
  export function resolveResultVariants(
31
64
  m: MergedScenario,
32
65
  results: Map<string, PlaywrightResult> | null,
66
+ testData?: Record<string, string> | null,
33
67
  ): ResultVariant[] {
34
68
  const isCases = m.feature.tags.some((t) => t.startsWith('@cases:'));
35
- if (isCases && results) {
36
- const marker = `${m.feature.name} — `;
37
- const rows = [...results.entries()].filter(([t]) => t.includes(marker));
38
- if (rows.length) {
39
- return rows.map(([t, r]) => ({ nameSuffix: ` — ${t.slice(t.indexOf(marker) + marker.length)}`, result: r }));
69
+ if (isCases) {
70
+ const datasetRows = getCasesDatasetRows(m, testData);
71
+ const byLabel = new Map<string, Record<string, unknown>>();
72
+ (datasetRows ?? []).forEach((row, i) => byLabel.set(rowLabel(row, i), row));
73
+
74
+ if (results) {
75
+ const marker = `${m.feature.name} — `;
76
+ const ran = [...results.entries()].filter(([t]) => t.includes(marker));
77
+ if (ran.length) {
78
+ return ran.map(([t, r]) => {
79
+ const label = t.slice(t.indexOf(marker) + marker.length);
80
+ return { nameSuffix: ` — ${label}`, result: r, row: byLabel.get(label) };
81
+ });
82
+ }
83
+ }
84
+ if (datasetRows && extractTestcaseType(m.feature.tags) === 'Manual') {
85
+ return datasetRows.map((row, i) => ({ nameSuffix: ` — ${rowLabel(row, i)}`, row }));
40
86
  }
41
87
  return [{ nameSuffix: '' }];
42
88
  }
@@ -45,3 +91,16 @@ export function resolveResultVariants(
45
91
  }
46
92
  return [{ nameSuffix: '' }];
47
93
  }
94
+
95
+ /**
96
+ * Render a variant's dataset row for the Test Data cell: one `col: value` line per column,
97
+ * `__`-prefixed bookkeeping keys excluded. Returns '' when the variant carries no row.
98
+ */
99
+ export function formatVariantRow(variant: ResultVariant, separator: string = '\n'): string {
100
+ if (!variant.row) return '';
101
+ const useBullet = separator.includes('\n');
102
+ return Object.entries(variant.row)
103
+ .filter(([k]) => !k.startsWith('__'))
104
+ .map(([k, v]) => `${useBullet ? '• ' : ''}${k}: ${v}`)
105
+ .join(separator);
106
+ }
@@ -133,6 +133,7 @@ function truncate(s: string, max: number): string {
133
133
  return s.substring(0, max - 1) + '…';
134
134
  }
135
135
 
136
+
136
137
  /**
137
138
  * Resolve `{{var}}` test-data placeholders in a step / expected-result line to their concrete values,
138
139
  * so the delivery report shows what QA actually verifies (e.g. `text contains {{app_version}}` →
@@ -90,6 +90,10 @@ Run (local-first): `[ -x ./bin/sungen.js ] && ./bin/sungen.js data lint --screen
90
90
 
91
91
  Run (local-first): `[ -x ./bin/sungen.js ] && ./bin/sungen.js data gen --screen <name> || npx sungen data gen --screen <name>`. If `test-data/<name>.yaml` already exists it **merges** the standardized field data in — it **replaces only the field keys it owns and preserves every other key** (create-test's data, flow-namespaced keys, `@cases` datasets, overrides). It asks before merging (in ALL mode after they confirmed, add `--yes`; `--force` skips the prompt). The output is standardized `valid/boundary/invalid` values with `CHK-*` traceability.
92
92
 
93
+ ### 5.5 Validate semantic correctness
94
+
95
+ Run (local-first): `[ -x ./bin/sungen.js ] && ./bin/sungen.js data validate --screen <name> || npx sungen data validate --screen <name>`. This is stronger than lint (coverage): it checks every generated value is **semantically** correct — each `valid`/`boundary` value actually satisfies the constraints, and each synthesized `invalid` value actually violates them (a value in the `valid` set that breaks `maxLength`, or a "below min" case that landed inside the range, is a `VALUE_NOT_VALID` error). Fix any `error`; `INVALID_LOOKS_VALID` warns are a prompt to confirm the value is invalid for a non-length reason (charset/format). Each generated value also carries an `expected: valid|invalid` field in the test-data for traceability.
96
+
93
97
  ### 6. Weave into scenarios (if create-test already ran)
94
98
 
95
99
  If the `.feature` exists, turn the generated invalid/boundary sets into data-driven `Scenario Outline` + `Examples` (`@cases`) referencing the values — following `sungen-gherkin-syntax`. Otherwise leave the standardized `test-data/<name>.yaml` for `/sungen:run-test` to consume.
@@ -109,6 +109,14 @@ Then `sungen data gen` writes the standardized `test-data/<name>.yaml`. Do not h
109
109
  generated values — change the field-map or `.overwrite` and re-run (determinism: same input → same
110
110
  data).
111
111
 
112
+ Then run **`sungen data validate`** — the semantic check beyond coverage. It verifies each generated
113
+ value is *correct*, not just *present*: every `valid`/`boundary` value actually satisfies the
114
+ constraints and every synthesized `invalid` value actually violates them. A boundary is expanded to
115
+ **one case per point** (`min / min+1` → two cases; `< min` → min-1, below the minimum), each stamped
116
+ `expected: valid|invalid` in the output. Any `VALUE_NOT_VALID` error means a value labeled valid breaks
117
+ a constraint — fix the field-map/constraints and re-run. This is what stops the "structurally right but
118
+ semantically wrong" data (e.g. a "below minimum length" case that is actually long enough to pass).
119
+
112
120
  ## Weaving into scenarios
113
121
 
114
122
  When a `.feature` exists, express invalid/boundary sets as data-driven `Scenario Outline` + `Examples`