@sun-asterisk/sungen 3.1.0 → 3.1.2

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 (109) hide show
  1. package/dist/cli/commands/challenge.d.ts.map +1 -1
  2. package/dist/cli/commands/challenge.js +9 -2
  3. package/dist/cli/commands/challenge.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts.map +1 -1
  5. package/dist/cli/commands/delivery.js +3 -2
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/cli/commands/generate.d.ts.map +1 -1
  8. package/dist/cli/commands/generate.js +8 -0
  9. package/dist/cli/commands/generate.js.map +1 -1
  10. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  11. package/dist/exporters/csv-exporter.js +92 -76
  12. package/dist/exporters/csv-exporter.js.map +1 -1
  13. package/dist/exporters/spec-parser.d.ts.map +1 -1
  14. package/dist/exporters/spec-parser.js +6 -1
  15. package/dist/exporters/spec-parser.js.map +1 -1
  16. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +1 -0
  17. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  18. package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +19 -1
  19. package/dist/generators/test-generator/code-generator.d.ts +16 -1
  20. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  21. package/dist/generators/test-generator/code-generator.js +133 -49
  22. package/dist/generators/test-generator/code-generator.js.map +1 -1
  23. package/dist/generators/test-generator/patterns/database-patterns.d.ts +2 -1
  24. package/dist/generators/test-generator/patterns/database-patterns.d.ts.map +1 -1
  25. package/dist/generators/test-generator/patterns/database-patterns.js +2 -1
  26. package/dist/generators/test-generator/patterns/database-patterns.js.map +1 -1
  27. package/dist/generators/test-generator/patterns/expect-patterns.d.ts +3 -0
  28. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -0
  29. package/dist/generators/test-generator/patterns/expect-patterns.js +54 -0
  30. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -0
  31. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  32. package/dist/generators/test-generator/patterns/index.js +2 -0
  33. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  34. package/dist/generators/test-generator/step-mapper.d.ts +6 -0
  35. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  36. package/dist/generators/test-generator/step-mapper.js +8 -0
  37. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  38. package/dist/generators/test-generator/template-engine.d.ts +3 -0
  39. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  40. package/dist/generators/test-generator/template-engine.js.map +1 -1
  41. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +1 -1
  42. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  43. package/dist/generators/test-generator/utils/runtime-data-transformer.js +5 -5
  44. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  45. package/dist/harness/challenge.d.ts +1 -0
  46. package/dist/harness/challenge.d.ts.map +1 -1
  47. package/dist/harness/challenge.js +49 -2
  48. package/dist/harness/challenge.js.map +1 -1
  49. package/dist/harness/data-driven-lint.d.ts +7 -0
  50. package/dist/harness/data-driven-lint.d.ts.map +1 -0
  51. package/dist/harness/data-driven-lint.js +153 -0
  52. package/dist/harness/data-driven-lint.js.map +1 -0
  53. package/dist/harness/parse.d.ts +2 -0
  54. package/dist/harness/parse.d.ts.map +1 -1
  55. package/dist/harness/parse.js +16 -0
  56. package/dist/harness/parse.js.map +1 -1
  57. package/dist/harness/query-catalog.d.ts +48 -0
  58. package/dist/harness/query-catalog.d.ts.map +1 -0
  59. package/dist/harness/query-catalog.js +0 -0
  60. package/dist/harness/query-catalog.js.map +1 -0
  61. package/dist/harness/script-check.d.ts.map +1 -1
  62. package/dist/harness/script-check.js +7 -4
  63. package/dist/harness/script-check.js.map +1 -1
  64. package/dist/orchestrator/templates/ai-instructions/claude-agent-challenge.md +3 -2
  65. package/dist/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +40 -0
  66. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +19 -0
  67. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +1 -0
  68. package/dist/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +6 -0
  69. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +40 -0
  70. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +19 -0
  71. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +1 -0
  72. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +6 -0
  73. package/dist/orchestrator/templates/specs-db.d.ts +8 -0
  74. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
  75. package/dist/orchestrator/templates/specs-db.js +22 -0
  76. package/dist/orchestrator/templates/specs-db.js.map +1 -1
  77. package/dist/orchestrator/templates/specs-db.ts +22 -0
  78. package/dist/orchestrator/templates/specs-test-data.ts +76 -15
  79. package/package.json +2 -2
  80. package/src/cli/commands/challenge.ts +6 -2
  81. package/src/cli/commands/delivery.ts +3 -2
  82. package/src/cli/commands/generate.ts +8 -0
  83. package/src/exporters/csv-exporter.ts +22 -6
  84. package/src/exporters/spec-parser.ts +6 -1
  85. package/src/generators/test-generator/adapters/adapter-interface.ts +1 -0
  86. package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +19 -1
  87. package/src/generators/test-generator/code-generator.ts +128 -50
  88. package/src/generators/test-generator/patterns/database-patterns.ts +2 -1
  89. package/src/generators/test-generator/patterns/expect-patterns.ts +49 -0
  90. package/src/generators/test-generator/patterns/index.ts +2 -0
  91. package/src/generators/test-generator/step-mapper.ts +9 -0
  92. package/src/generators/test-generator/template-engine.ts +3 -0
  93. package/src/generators/test-generator/utils/runtime-data-transformer.ts +5 -5
  94. package/src/harness/challenge.ts +47 -2
  95. package/src/harness/data-driven-lint.ts +119 -0
  96. package/src/harness/parse.ts +12 -0
  97. package/src/harness/query-catalog.ts +0 -0
  98. package/src/harness/script-check.ts +8 -5
  99. package/src/orchestrator/templates/ai-instructions/claude-agent-challenge.md +3 -2
  100. package/src/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +40 -0
  101. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +19 -0
  102. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +1 -0
  103. package/src/orchestrator/templates/ai-instructions/claude-skill-test-design-techniques.md +6 -0
  104. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +40 -0
  105. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +19 -0
  106. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +1 -0
  107. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-test-design-techniques.md +6 -0
  108. package/src/orchestrator/templates/specs-db.ts +22 -0
  109. package/src/orchestrator/templates/specs-test-data.ts +76 -15
@@ -4,6 +4,7 @@ import * as fs from 'fs';
4
4
  import { CodeGenerator } from '../../generators/test-generator/code-generator';
5
5
  import { adapterRegistry } from '../../generators/test-generator/adapters';
6
6
  import { scanTestDataSecrets } from '../../harness/secret-scan';
7
+ import { lintDataDriven } from '../../harness/data-driven-lint';
7
8
  import { readCapabilities, writeCapabilities, driverMeta, loadDriverCatalog } from '../../harness/capability';
8
9
 
9
10
  /**
@@ -182,6 +183,13 @@ export function registerGenerateCommand(program: Command): void {
182
183
  console.log(` Move real secrets to an env overlay / CI secret; keep test-data placeholders only.`);
183
184
  }
184
185
 
186
+ // Data-driven lint (@cases / @query) — advisory, never blocks generation.
187
+ const ddWarnings = scanDirs.flatMap((d) => lintDataDriven(d, cwd));
188
+ if (ddWarnings.length) {
189
+ console.log(`\n⚠️ Data-driven lint (@cases / @query) — review:`);
190
+ for (const w of ddWarnings.slice(0, 20)) console.log(` ${w.scenario ? w.scenario + ': ' : ''}${w.message}`);
191
+ }
192
+
185
193
  console.log(`Next step: npx playwright test --ui\n`);
186
194
  } catch (error) {
187
195
  console.error('Error:', error instanceof Error ? error.message : error);
@@ -53,7 +53,25 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
53
53
  let fallbackIndex = 1;
54
54
 
55
55
  for (const m of input.merged) {
56
- const { vpId, category1 } = splitVpAndName(m.feature.name);
56
+ // Data-driven (@cases): one scenario ran once per input row — emit one CSV row per
57
+ // executed input. The Playwright results are titled "<scenario> — <label>"; expand
58
+ // by matching that prefix. With no results yet, fall back to a single (Pending) row.
59
+ const isCases = m.feature.tags.some((t) => t.startsWith('@cases:'));
60
+ let variants: Array<{ nameSuffix: string; result?: PlaywrightResult }> = [{ nameSuffix: '' }];
61
+ if (isCases && input.results) {
62
+ // Result titles are "<describe> > <scenario> — <label>"; match the scenario+label marker.
63
+ const marker = `${m.feature.name} — `;
64
+ const rowResults = [...input.results.entries()].filter(([t]) => t.includes(marker));
65
+ if (rowResults.length) {
66
+ variants = rowResults.map(([t, r]) => ({ nameSuffix: ` — ${t.slice(t.indexOf(marker) + marker.length)}`, result: r }));
67
+ }
68
+ } else if (input.results && m.spec) {
69
+ variants = [{ nameSuffix: '', result: input.results.get(m.spec.testTitle) }];
70
+ }
71
+
72
+ for (const variant of variants) {
73
+ const displayName = `${m.feature.name}${variant.nameSuffix}`;
74
+ const { vpId, category1 } = splitVpAndName(displayName);
57
75
  const tcId = generateTcId(input.screen, vpId, fallbackIndex);
58
76
  if (!vpId) fallbackIndex++;
59
77
 
@@ -87,11 +105,8 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
87
105
  // automatically) and render natively as multi-line in XLSX.
88
106
  const testData = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
89
107
 
90
- // Match Playwright result by test title (from .spec.ts) OR by scenarioName
91
- let result: PlaywrightResult | undefined;
92
- if (input.results && m.spec) {
93
- result = input.results.get(m.spec.testTitle);
94
- }
108
+ // Status for this (possibly per-input) row resolved into `variant` above.
109
+ const result: PlaywrightResult | undefined = variant.result;
95
110
 
96
111
  // Determine Test Result
97
112
  let testResult: string;
@@ -141,6 +156,7 @@ export function buildTestCaseRows(input: BuildCsvInput): TestCaseRow[] {
141
156
  testEnvironment: environment,
142
157
  note,
143
158
  });
159
+ }
144
160
  }
145
161
 
146
162
  return rows;
@@ -20,7 +20,12 @@ function extractTestBlock(content: string, startIdx: number): {
20
20
  // test('title', { tag: [...] }, async ({ page }) => {
21
21
  // Backreference \1 lets the inner title contain the opposite quote type
22
22
  // (e.g. test('Footer "X" link', ...) — common when scenarios cite UI labels).
23
- const testRegex = /test\s*\(\s*(['"])((?:(?!\1).)+)\1\s*,\s*(?:\{[^}]*\}\s*,\s*)?async\s*\([^)]*\)\s*=>\s*\{/g;
23
+ // Quote char may be ' " or ` — the backtick form is a data-driven (@cases) title
24
+ // like `VP-… — ${__row.__label}`.
25
+ // The options object uses `\{.*?\}` (non-greedy, not `[^}]*`) so a tag value containing a
26
+ // `}` — e.g. `@query:q(p={{var}})` — doesn't truncate the match (issue #271). The header is
27
+ // single-line, so `.*?` anchored on `}, async` is safe.
28
+ const testRegex = /test\s*\(\s*(['"`])((?:(?!\1).)+)\1\s*,\s*(?:\{.*?\}\s*,\s*)?async\s*\([^)]*\)\s*=>\s*\{/g;
24
29
  testRegex.lastIndex = startIdx;
25
30
  const match = testRegex.exec(content);
26
31
  if (!match) return null;
@@ -36,6 +36,7 @@ export interface ScenarioData {
36
36
  authRole?: string; // Auth role for storage state
37
37
  isParallel?: boolean; // @parallel: use fresh page from fixture
38
38
  tags?: string; // Pass-through tags for Playwright { tag: [...] }, e.g. "'@smoke', '@high'"
39
+ casesDataset?: string; // @cases:<dataset> — emit one test() per row of the runtime dataset
39
40
  }
40
41
 
41
42
  export interface StepTemplateData {
@@ -1,3 +1,20 @@
1
+ {{#if casesDataset}}
2
+ for (const __row of testData.cases('{{casesDataset}}')) {
3
+ {{#if tags}}
4
+ test(`{{scenarioName}} — ${__row.__label}`, { tag: [{{{tags}}}] }, async ({{#if isParallel}}{ page }{{/if}}) => {
5
+ {{else}}
6
+ test(`{{scenarioName}} — ${__row.__label}`, async ({{#if isParallel}}{ page }{{/if}}) => {
7
+ {{/if}}
8
+ const rowData = testData.withRow(__row);
9
+ {{#each steps}}
10
+ {{#if comment}}
11
+ // {{comment}}
12
+ {{/if}}
13
+ {{code}}
14
+ {{/each}}
15
+ });
16
+ }
17
+ {{else}}
1
18
  {{#if isParallel}}
2
19
  {{#if tags}}
3
20
  test('{{scenarioName}}', { tag: [{{{tags}}}] }, async ({ page }) => {
@@ -24,4 +41,5 @@
24
41
  {{code}}
25
42
  {{/each}}
26
43
  });
27
- {{/if}}
44
+ {{/if}}
45
+ {{/if}}
@@ -5,6 +5,7 @@ import { StepMapper } from './step-mapper';
5
5
  import { TestGeneratorAdapter, adapterRegistry } from './adapters';
6
6
  import { transformToRuntimeData } from './utils/runtime-data-transformer';
7
7
  import { isDbStep } from './patterns/database-patterns';
8
+ import { resolveQuery, compileQuery } from '../../harness/query-catalog';
8
9
 
9
10
  /**
10
11
  * Filter base scenario steps for @extend: only keep Given→When steps.
@@ -74,7 +75,7 @@ function extractCleanupFlags(tags: string[]): { overlay?: boolean; forms?: boole
74
75
  const FUNCTIONAL_TAG_PREFIXES = [
75
76
  '@parallel', '@cleanup:', '@auth:', '@manual', '@no-auth',
76
77
  '@steps:', '@extend:', '@screenshot:', '@beforeAll', '@afterEach', '@afterAll',
77
- '@flow',
78
+ '@flow', '@cases:',
78
79
  ];
79
80
 
80
81
  function extractPassThroughTags(scenarioTags: string[], featureTags: string[]): string | undefined {
@@ -170,6 +171,8 @@ export class CodeGenerator {
170
171
  private adapter: TestGeneratorAdapter;
171
172
  private screenName?: string;
172
173
  private options: any;
174
+ // Screen/flow name for the CURRENT feature, in catalog-resolution form (`flows/<x>` for flows).
175
+ private queryScreenName: string = '';
173
176
  // Steps registry built per feature during generateTestCode(); used by countSteps()
174
177
  private stepsRegistry = new Map<string, ParsedScenario>();
175
178
 
@@ -302,62 +305,90 @@ export class CodeGenerator {
302
305
  const steps: ParsedStep[] = [];
303
306
  if (feature.background?.steps) steps.push(...feature.background.steps);
304
307
  for (const sc of feature.scenarios || []) if (sc.steps) steps.push(...sc.steps);
305
- return steps.some((s) => s && typeof s.text === 'string' && isDbStep(s.text));
308
+ if (steps.some((s) => s && typeof s.text === 'string' && isDbStep(s.text))) return true;
309
+ // A scenario may carry only a @query:<name> post-condition tag (no DB step in its body).
310
+ return (feature.scenarios || []).some((sc) => (sc.tags || []).some((t) => t.startsWith('@query:')));
306
311
  }
307
312
 
308
- /** Copy the Data Driver runtime helper into specs/db.ts (idempotent). */
309
- ensureDbFile(outputDir: string): void {
310
- const templatesRoot = path.join(__dirname, '..', '..', 'orchestrator', 'templates');
311
- const dbPath = path.join(outputDir, 'db.ts');
312
- if (!fs.existsSync(dbPath)) {
313
- const templatePath = path.join(templatesRoot, 'specs-db.ts');
314
- if (fs.existsSync(templatePath)) {
315
- if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
316
- fs.copyFileSync(templatePath, dbPath);
317
- console.log('✓ Created: specs/db.ts');
318
- }
313
+ /**
314
+ * Build the precondition bind steps for a scenario's `@query:<name>[(p={{v}},…)]` tags.
315
+ * Each runs the catalog query and binds the result array to a `{{name}}` variable.
316
+ */
317
+ private buildQueryBinds(scenario: ParsedScenario): Array<{ comment?: string; code: string }> {
318
+ const out: Array<{ comment?: string; code: string }> = [];
319
+ const TAG = /^@query:([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$/;
320
+ for (const tag of scenario.tags || []) {
321
+ const m = tag.match(TAG);
322
+ if (!m) continue;
323
+ const name = m[1];
324
+ const overrides = this.parseQueryOverrides(m[2]);
325
+ const entry = resolveQuery(name, this.queryScreenName); // throws (fail-fast) if missing/ambiguous
326
+ const { sql, paramNames } = compileQuery(entry);
327
+ const paramExprs = paramNames.map((p) =>
328
+ p in overrides ? overrides[p] : `testData.get(${JSON.stringify(p)})`,
329
+ );
330
+ const label = JSON.stringify(entry.description ? `query "${name}" — ${entry.description}` : `query "${name}"`);
331
+ const ds = entry.datasource ? JSON.stringify(entry.datasource) : 'undefined';
332
+ out.push({
333
+ comment: `@query:${name} → bind {{${name}}} from ${entry.datasource || 'default datasource'}`,
334
+ code: this.indentCode(
335
+ `testData.bind(${JSON.stringify(name)}, await db.fetchQuery(${label}, ${JSON.stringify(sql)}, [${paramExprs.join(', ')}], ${ds}));`,
336
+ 4,
337
+ ),
338
+ });
319
339
  }
340
+ return out;
320
341
  }
321
342
 
322
- ensureBaseFile(outputDir: string): void {
323
- const templatesRoot = path.join(__dirname, '..', '..', 'orchestrator', 'templates');
324
-
325
- const basePath = path.join(outputDir, 'base.ts');
326
- if (!fs.existsSync(basePath)) {
327
- const templatePath = path.join(templatesRoot, 'specs-base.ts');
328
- if (fs.existsSync(templatePath)) {
329
- const baseDir = path.dirname(basePath);
330
- if (!fs.existsSync(baseDir)) {
331
- fs.mkdirSync(baseDir, { recursive: true });
332
- }
333
- fs.copyFileSync(templatePath, basePath);
334
- console.log('✓ Created: specs/base.ts');
335
- }
336
- }
343
+ /** Parse `@query:name(a={{x}},b="lit",c=3)` overrides → { a: "testData.get('x')", } JS exprs. */
344
+ private parseQueryOverrides(raw?: string): Record<string, string> {
345
+ const out: Record<string, string> = {};
346
+ if (!raw) return out;
347
+ for (const part of raw.split(',')) {
348
+ const eq = part.indexOf('=');
349
+ if (eq < 0) continue;
350
+ const key = part.slice(0, eq).trim();
351
+ const val = part.slice(eq + 1).trim();
352
+ if (!key) continue;
353
+ const v = val.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
354
+ const q = val.match(/^["'](.*)["']$/);
355
+ if (v) out[key] = `testData.get(${JSON.stringify(v[1])})`;
356
+ else if (q) out[key] = JSON.stringify(q[1]);
357
+ else if (/^-?\d+(?:\.\d+)?$/.test(val)) out[key] = val;
358
+ else out[key] = JSON.stringify(val);
359
+ }
360
+ return out;
361
+ }
337
362
 
338
- // base.ts now depends on locale-fixture.ts — keep them paired.
339
- const localeFixturePath = path.join(outputDir, 'locale-fixture.ts');
340
- if (!fs.existsSync(localeFixturePath)) {
341
- const templatePath = path.join(templatesRoot, 'specs-locale-fixture.ts');
342
- if (fs.existsSync(templatePath)) {
343
- const baseDir = path.dirname(localeFixturePath);
344
- if (!fs.existsSync(baseDir)) {
345
- fs.mkdirSync(baseDir, { recursive: true });
346
- }
347
- fs.copyFileSync(templatePath, localeFixturePath);
348
- console.log('✓ Created: specs/locale-fixture.ts');
349
- }
350
- }
363
+ /**
364
+ * Copy an auto-generated runtime helper into specs/, **refreshing it when the template changed**.
365
+ * These files carry a `DO NOT EDIT — regenerated` header, so a stale copy left by an older sungen
366
+ * (e.g. a `specs/db.ts` from before a feature was added) is replaced instead of kept (issue #270).
367
+ * No-ops when the on-disk content already matches the template, so it stays quiet + idempotent.
368
+ */
369
+ private syncGeneratedHelper(outputDir: string, fileName: string, templateName: string): void {
370
+ const templatePath = path.join(__dirname, '..', '..', 'orchestrator', 'templates', templateName);
371
+ if (!fs.existsSync(templatePath)) return;
372
+ const targetPath = path.join(outputDir, fileName);
373
+ const next = fs.readFileSync(templatePath, 'utf8');
374
+ const exists = fs.existsSync(targetPath);
375
+ if (exists && fs.readFileSync(targetPath, 'utf8') === next) return; // already current
376
+ if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
377
+ fs.writeFileSync(targetPath, next);
378
+ console.log(`✓ ${exists ? 'Updated' : 'Created'}: specs/${fileName}`);
379
+ }
380
+
381
+ /** Ensure specs/db.ts is present and current (Data Driver runtime helper). */
382
+ ensureDbFile(outputDir: string): void {
383
+ this.syncGeneratedHelper(outputDir, 'db.ts', 'specs-db.ts');
384
+ }
351
385
 
386
+ ensureBaseFile(outputDir: string): void {
387
+ this.syncGeneratedHelper(outputDir, 'base.ts', 'specs-base.ts');
388
+ // base.ts depends on locale-fixture.ts — keep them paired.
389
+ this.syncGeneratedHelper(outputDir, 'locale-fixture.ts', 'specs-locale-fixture.ts');
352
390
  if (this.options.runtimeData) {
353
- const testDataPath = path.join(outputDir, 'test-data.ts');
354
- if (!fs.existsSync(testDataPath)) {
355
- const templatePath = path.join(templatesRoot, 'specs-test-data.ts');
356
- if (fs.existsSync(templatePath)) {
357
- fs.copyFileSync(templatePath, testDataPath);
358
- console.log('✓ Created: specs/test-data.ts');
359
- }
360
- }
391
+ this.syncGeneratedHelper(outputDir, 'test-data.ts', 'specs-test-data.ts');
361
392
  }
362
393
  }
363
394
 
@@ -391,6 +422,8 @@ export class CodeGenerator {
391
422
  }
392
423
  this.stepMapper.setScreenContext(effectiveScreenName);
393
424
  }
425
+ // Catalog-resolution screen name for @query binds (flows are prefixed `flows/`).
426
+ this.queryScreenName = isFlowFeature ? `flows/${effectiveScreenName}` : (effectiveScreenName || '');
394
427
 
395
428
  // Reset flow mode per feature to prevent state leak in --all mode
396
429
  this.stepMapper.setFlowMode(isFlowFeature);
@@ -637,6 +670,35 @@ export class CodeGenerator {
637
670
  // Set scenario context for path variable resolution (full merged list)
638
671
  this.stepMapper.setScenarioContext(stepsToMap);
639
672
 
673
+ // Data-driven (@cases): the scenario's {{col}} refs are dataset *row columns* that
674
+ // exist only at runtime — register them as captured so they resolve to a runtime
675
+ // get() (→ rowData.get) instead of compile-time YAML lookup.
676
+ const casesTag = scenario.tags.find((t) => t.startsWith('@cases:'));
677
+ const casesDataset = casesTag ? casesTag.slice('@cases:'.length).trim() : undefined;
678
+ if (casesDataset) {
679
+ const refs = new Set<string>();
680
+ for (const st of stepsToMap) {
681
+ for (const m of (st.text || '').matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) refs.add(m[1]);
682
+ }
683
+ for (const r of refs) this.stepMapper.registerCaptured(r);
684
+ }
685
+
686
+ // @query-bound vars: `{{name.col}}` / `{{name[2].col}}` / `{{name.count}}` exist only at
687
+ // runtime (the bind fetches them) — register as captured so they resolve to a runtime get()
688
+ // instead of a compile-time YAML lookup that would fail.
689
+ const queryNames = (scenario.tags || [])
690
+ .map((t) => t.match(/^@query:([A-Za-z_][A-Za-z0-9_]*)/))
691
+ .filter((m): m is RegExpMatchArray => !!m)
692
+ .map((m) => m[1]);
693
+ if (queryNames.length) {
694
+ for (const st of stepsToMap) {
695
+ for (const mt of (st.text || '').matchAll(/\{\{\s*([^}]+?)\s*\}\}/g)) {
696
+ const head = mt[1].split(/[.[]/)[0];
697
+ if (queryNames.includes(head)) this.stepMapper.registerCaptured(mt[1]);
698
+ }
699
+ }
700
+ }
701
+
640
702
  const steps: Array<{ comment?: string; code: string }> = [];
641
703
 
642
704
  if (scenario.extendsName && this.stepsRegistry.has(scenario.extendsName)) {
@@ -666,17 +728,33 @@ export class CodeGenerator {
666
728
  }
667
729
  }
668
730
 
731
+ // @query:<name>[(p={{v}},…)] tags → run the catalog query as a PRECONDITION and bind its
732
+ // result to a `{{name}}` variable (scenario asserts on it with `expect …` + path access).
733
+ // Prepended so the binds run before the scenario's own steps.
734
+ const queryBinds = this.buildQueryBinds(scenario);
735
+ if (queryBinds.length) steps.unshift(...queryBinds);
736
+
669
737
  // Extract pass-through tags (feature + scenario, excluding functional tags)
670
738
  const tags = extractPassThroughTags(scenario.tags, featureTags);
671
739
 
672
740
  // Use adapter to render scenario
673
- return this.adapter.renderScenario({
741
+ const rendered = this.adapter.renderScenario({
674
742
  scenarioName: scenario.name,
675
743
  steps,
676
744
  authRole,
677
745
  isParallel,
678
746
  tags,
747
+ casesDataset,
679
748
  });
749
+
750
+ // Data-driven (@cases): the per-row test() binds a row-scoped view (`rowData`).
751
+ // Pre-transform THIS scenario's runtime-data markers to read from `rowData`, so the
752
+ // global `testData` transform that runs next on the rest of the file leaves it alone.
753
+ // The loop header's `testData.cases()/withRow()` are literal code (no markers) → untouched.
754
+ if (casesDataset && this.options.runtimeData) {
755
+ return transformToRuntimeData(rendered, 'rowData');
756
+ }
757
+ return rendered;
680
758
  }
681
759
 
682
760
  /**
@@ -21,7 +21,8 @@ const reRow = new RegExp(`see\\s+${TABLE}\\s+row\\s+where\\b`, 'i');
21
21
  const reNoRow = new RegExp(`see\\s+${TABLE}\\s+no\\s+row\\s+where\\b`, 'i');
22
22
  const reCount = new RegExp(`see\\s+${TABLE}.*\\bcount\\s+is\\b`, 'i');
23
23
 
24
- /** True when a step is a DB-verification step (used to wire the `db` import). */
24
+ /** True when a step is a declarative DB-verification step (used to wire the `db` import).
25
+ * Named queries are invoked via the `@query:` annotation, not a step — see code-generator. */
25
26
  export function isDbStep(text: string): boolean {
26
27
  return reNoRow.test(text) || reRow.test(text) || reCount.test(text);
27
28
  }
@@ -0,0 +1,49 @@
1
+ import { ParsedStep } from '../../gherkin-parser';
2
+ import { StepPattern, PatternContext } from './types';
3
+ import { MappedStep } from '../step-mapper';
4
+
5
+ /**
6
+ * Data-vs-data assertions — compare two runtime values directly (no UI element).
7
+ * The primary use is asserting on an `@query`-bound result via path access, e.g.
8
+ *
9
+ * Then expect {{products.count}} is {{expected}}
10
+ * Then expect {{products.count}} is at least {{one}}
11
+ * Then expect {{active_user.status}} is "active"
12
+ * Then expect {{order.total}} is not {{zero}}
13
+ *
14
+ * Both sides are `{{var|path}}` | "literal" | 'literal' | number. Generic — works for any
15
+ * test-data values, not only DB results.
16
+ */
17
+ const VALUE = String.raw`\{\{[^}]+\}\}|"[^"]*"|'[^']*'|-?\d+(?:\.\d+)?`;
18
+ const reExpect = new RegExp(`^\\s*(?:User\\s+)?expect\\s+(${VALUE})\\s+is\\s+(not\\s+|at\\s+least\\s+|at\\s+most\\s+)?(${VALUE})\\s*$`, 'i');
19
+
20
+ /** Render a value token (`{{var}}` | "literal" | 'literal' | number) as a JS expression. */
21
+ function valueExpr(token: string): string {
22
+ const t = token.trim();
23
+ const v = t.match(/^\{\{\s*([^}]+?)\s*\}\}$/);
24
+ if (v) return `testData.get(${JSON.stringify(v[1])})`;
25
+ const q = t.match(/^["'](.*)["']$/);
26
+ if (q) return JSON.stringify(q[1]);
27
+ if (/^-?\d+(?:\.\d+)?$/.test(t)) return t;
28
+ return JSON.stringify(t);
29
+ }
30
+
31
+ export const expectPatterns: StepPattern[] = [
32
+ {
33
+ name: 'expect-data',
34
+ priority: 62, // above generic see-assertions; sibling of the DB assertions
35
+ matcher: (step: ParsedStep) => reExpect.test(step.text),
36
+ generator: (step: ParsedStep, _ctx: PatternContext): MappedStep => {
37
+ const m = step.text.match(reExpect)!;
38
+ const a = valueExpr(m[1]);
39
+ const op = (m[2] || '').trim().toLowerCase();
40
+ const b = valueExpr(m[3]);
41
+ let code: string;
42
+ if (op === 'at least') code = `expect(Number(${a})).toBeGreaterThanOrEqual(Number(${b}));`;
43
+ else if (op === 'at most') code = `expect(Number(${a})).toBeLessThanOrEqual(Number(${b}));`;
44
+ else if (op === 'not') code = `expect(String(${a})).not.toBe(String(${b}));`;
45
+ else code = `expect(String(${a})).toBe(String(${b}));`;
46
+ return { code, comment: `Expect ${m[1]} ${op ? op + ' ' : 'is '}${m[3]}` };
47
+ },
48
+ },
49
+ ];
@@ -12,6 +12,7 @@ import { scopePatterns } from './scope-patterns';
12
12
  import { tablePatterns } from './table-patterns';
13
13
  import { capturePatterns } from './capture-patterns';
14
14
  import { databasePatterns } from './database-patterns';
15
+ import { expectPatterns } from './expect-patterns';
15
16
 
16
17
  /**
17
18
  * Pattern Registry - manages all step patterns
@@ -38,6 +39,7 @@ export class PatternRegistry {
38
39
  this.patterns.push(...tablePatterns);
39
40
  this.patterns.push(...capturePatterns);
40
41
  this.patterns.push(...databasePatterns);
42
+ this.patterns.push(...expectPatterns);
41
43
 
42
44
  // Sort by priority (higher first)
43
45
  this.patterns.sort((a, b) => (b.priority || 0) - (a.priority || 0));
@@ -94,6 +94,15 @@ export class StepMapper {
94
94
  this.templateEngine.resetBaseContext();
95
95
  }
96
96
 
97
+ /**
98
+ * Register a runtime data variable so `{{name}}` resolves to `testData.get('name')`
99
+ * (skipping compile-time YAML validation). Used for @cases row columns, which exist
100
+ * only at runtime in the dataset rows. Scenario-scoped (cleared by setScenarioContext).
101
+ */
102
+ registerCaptured(name: string): void {
103
+ this.dataResolver.registerCaptured(name);
104
+ }
105
+
97
106
  /**
98
107
  * Map a Gherkin step to Playwright code
99
108
  * Uses pattern registry first, falls back to AI if enabled
@@ -284,6 +284,9 @@ export class TemplateEngine {
284
284
  scenarioName: string;
285
285
  steps: Array<{ comment?: string; code: string }>;
286
286
  authRole?: string;
287
+ isParallel?: boolean;
288
+ tags?: string;
289
+ casesDataset?: string;
287
290
  }): string {
288
291
  return this.render('scenario', data);
289
292
  }
@@ -4,7 +4,7 @@ const MARKER_PATTERN = /__SUNGEN_TD_([A-Za-z0-9_]+)__/;
4
4
  * Replace __SUNGEN_TD_ markers with testData.get() calls in generated code.
5
5
  * Three passes: comments, string literals, then regex literals.
6
6
  */
7
- export function transformToRuntimeData(code: string): string {
7
+ export function transformToRuntimeData(code: string, accessor: string = 'testData'): string {
8
8
  // Pass 0: Comments — replace markers in // comments with decoded key name
9
9
  // Prevents Pass 2 from misinterpreting // comment markers as regex delimiters
10
10
  code = code.replace(
@@ -20,9 +20,9 @@ export function transformToRuntimeData(code: string): string {
20
20
  (_, _quote, prefix, enc, suffix) => {
21
21
  const key = decodeKey(enc);
22
22
  if (!prefix && !suffix) {
23
- return `testData.get('${key}')`;
23
+ return `${accessor}.get('${key}')`;
24
24
  }
25
- return `\`${prefix}\${testData.get('${key}')}${suffix}\``;
25
+ return `\`${prefix}\${${accessor}.get('${key}')}${suffix}\``;
26
26
  }
27
27
  );
28
28
 
@@ -32,7 +32,7 @@ export function transformToRuntimeData(code: string): string {
32
32
  /\/((?:[^/\\\n]|\\.)*?)__SUNGEN_TD_([A-Za-z0-9_]+)__((?:[^/\\\n]|\\.)*?)\/([gimsuy]*)/g,
33
33
  (_, prefix, enc, suffix, flags) => {
34
34
  const key = decodeKey(enc);
35
- const ref = `testData.get('${key}')`;
35
+ const ref = `${accessor}.get('${key}')`;
36
36
  const flagStr = flags ? `, '${flags}'` : '';
37
37
  if (!prefix && !suffix) return `new RegExp(${ref}${flagStr})`;
38
38
  return `new RegExp(\`${prefix}\${${ref}}${suffix}\`${flagStr})`;
@@ -44,7 +44,7 @@ export function transformToRuntimeData(code: string): string {
44
44
  // table/list count templates). testData.get() returns a string, so coerce with Number().
45
45
  code = code.replace(
46
46
  /__SUNGEN_TD_([A-Za-z0-9_]+)__/g,
47
- (_, enc) => `Number(testData.get('${decodeKey(enc)}'))`
47
+ (_, enc) => `Number(${accessor}.get('${decodeKey(enc)}'))`
48
48
  );
49
49
 
50
50
  return code;
@@ -34,6 +34,8 @@ export interface ChallengeReport {
34
34
  shallowThemes: string[];
35
35
  // Depth critic
36
36
  collectionClaimSingular: ChallengeFinding[];
37
+ // Data-driven critic — @cases-worthy gaps (spec-independent)
38
+ dataDriven: ChallengeFinding[];
37
39
  // Novelty critic (deterministic prompts → AI agent fills candidates)
38
40
  noveltyPrompts: string[];
39
41
  // Roll-up
@@ -47,6 +49,10 @@ const PLURAL_NOUN = /\b(cards|items|products|rows|results|prices|entries|records
47
49
  const QUANTIFIER = /\b(all|every|each)\b/i;
48
50
  const DISPLAY_VERB = /\b(displays?|shows?|lists?|grid|contains?)\b/i;
49
51
 
52
+ // Title lexicon implying a CLASS of inputs (→ a data-driven @cases candidate). Used by
53
+ // Detector A. Spec-independent — reads the title, not the spec.
54
+ const INPUT_CLASS_LEXICON = /\b(invalid|formats?|boundary|range|min|max|too (?:long|short)|special char|each|various|classes)\b/i;
55
+
50
56
  /** Risk lenses the Novelty critic prompts the AI to explore (beyond the catalog). */
51
57
  const NOVELTY_LENSES = [
52
58
  'double-submit / rapid repeat of the primary action (duplicate side-effects?)',
@@ -88,17 +94,49 @@ export function buildChallenge(screenDir: string, screenName: string): Challenge
88
94
  }
89
95
  }
90
96
 
91
- // 3. Novelty critic — deterministic prompts; the AI agent expands these into candidates.
97
+ // 3. Data-driven critic — surface @cases-worthy gaps (spec-independent).
98
+ const dataDriven: ChallengeFinding[] = [];
99
+ const clustered = new Set<string>();
100
+ // Detector B (high precision): ≥2 non-@cases scenarios sharing the SAME step skeleton
101
+ // (stepSkeleton normalizes {{vars}}/quoted values) → they differ only by data → collapse.
102
+ const bySkeleton = new Map<string, ScenarioInfo[]>();
103
+ for (const s of scenarios) {
104
+ if (s.manual || s.casesDataset || !s.stepSkeleton) continue;
105
+ (bySkeleton.get(s.stepSkeleton) ?? bySkeleton.set(s.stepSkeleton, []).get(s.stepSkeleton)!).push(s);
106
+ }
107
+ for (const group of bySkeleton.values()) {
108
+ if (group.length < 2) continue;
109
+ group.forEach((s) => clustered.add(s.name));
110
+ dataDriven.push({
111
+ scenario: group.map((s) => s.name).join(' | '),
112
+ issue: `${group.length} scenarios share the same steps and differ only by input value (data-variants).`,
113
+ suggestion: `Collapse into ONE \`@cases:<dataset>\` — ${group.length} rows in test-data, each \`{{col}}\` a column. See Gherkin → Advanced → Data-driven.`,
114
+ });
115
+ }
116
+ // Detector A (advisory): a lone scenario whose title implies a class of inputs → suggest @cases.
117
+ for (const s of scenarios) {
118
+ if (s.manual || s.casesDataset || clustered.has(s.name)) continue;
119
+ if (INPUT_CLASS_LEXICON.test(s.name)) {
120
+ dataDriven.push({
121
+ scenario: s.name,
122
+ issue: 'Title reads like a CLASS of inputs (validation/boundary/error) but tests a single value.',
123
+ suggestion: 'Consider `@cases` to cover the EP/boundary classes (one row per valid/invalid class), not just one value.',
124
+ });
125
+ }
126
+ }
127
+
128
+ // 4. Novelty critic — deterministic prompts; the AI agent expands these into candidates.
92
129
  const noveltyPrompts = NOVELTY_LENSES.map((l) => `Find 1 non-obvious, valuable scenario via: ${l}`);
93
130
 
94
131
  // Roll-up — exploration readiness signals (not a fake score).
95
132
  const explorationReadiness: string[] = [];
133
+ if (dataDriven.length) explorationReadiness.push(`${dataDriven.length} data-driven gap(s) — scenarios that should be one \`@cases\` (collapse data-variants / cover EP-boundary classes).`);
96
134
  if (collectionClaimSingular.length) explorationReadiness.push(`${collectionClaimSingular.length} title↔assertion gap(s) — deterministic depth critic flagged these; an AI Business-Depth critic should confirm + fix.`);
97
135
  if (overCovered.length) explorationReadiness.push(`${overCovered.length} possibly over-covered area(s) — rebalance toward correctness.`);
98
136
  if (shallowThemes.length) explorationReadiness.push(`Shallow themes: ${shallowThemes.join(', ')}.`);
99
137
  explorationReadiness.push('Novelty candidates are NOT generated deterministically — run the `sungen-challenge` agent (Claude) or its inline criteria (Copilot) to propose them, then QA accept/reject (≤20% of official, no auto-merge).');
100
138
 
101
- return { screen: screenName, overCovered, shallowThemes, collectionClaimSingular, noveltyPrompts, explorationReadiness };
139
+ return { screen: screenName, overCovered, shallowThemes, collectionClaimSingular, dataDriven, noveltyPrompts, explorationReadiness };
102
140
  }
103
141
 
104
142
  /** Render the Challenge Report as Markdown (advisory — not part of the official suite). */
@@ -114,6 +152,13 @@ export function renderChallengeMarkdown(r: ChallengeReport): string {
114
152
  } else lines.push('_none_');
115
153
  lines.push('');
116
154
 
155
+ lines.push('## Data-driven — scenarios that should be `@cases` (one test case, many inputs)');
156
+ if (r.dataDriven.length) {
157
+ lines.push('| Scenario(s) | Issue | Suggested |', '|---|---|---|');
158
+ for (const f of r.dataDriven) lines.push(`| ${f.scenario} | ${f.issue} | ${f.suggestion} |`);
159
+ } else lines.push('_none_');
160
+ lines.push('');
161
+
117
162
  lines.push('## Coverage — possibly over-covered / shallow');
118
163
  if (r.overCovered.length) for (const o of r.overCovered) lines.push(`- **${o.bucket}** — ${o.note}`);
119
164
  if (r.shallowThemes.length) lines.push(`- Shallow themes: ${r.shallowThemes.join(', ')}`);