@sun-asterisk/sungen 2.4.6 → 2.5.0

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 (92) hide show
  1. package/dist/cli/commands/generate.d.ts.map +1 -1
  2. package/dist/cli/commands/generate.js +2 -0
  3. package/dist/cli/commands/generate.js.map +1 -1
  4. package/dist/cli/index.js +1 -1
  5. package/dist/generators/gherkin-parser/index.d.ts +1 -0
  6. package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
  7. package/dist/generators/gherkin-parser/index.js +3 -0
  8. package/dist/generators/gherkin-parser/index.js.map +1 -1
  9. package/dist/generators/test-generator/adapters/adapter-interface.d.ts +29 -1
  10. package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
  11. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +21 -1
  12. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
  13. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js +11 -2
  14. package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
  15. package/dist/generators/test-generator/adapters/playwright/templates/after-all.hbs +8 -0
  16. package/dist/generators/test-generator/adapters/playwright/templates/after-each.hbs +8 -0
  17. package/dist/generators/test-generator/adapters/playwright/templates/before-all.hbs +8 -0
  18. package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  19. package/dist/generators/test-generator/adapters/playwright/templates/test-file.hbs +24 -0
  20. package/dist/generators/test-generator/code-generator.d.ts +2 -0
  21. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  22. package/dist/generators/test-generator/code-generator.js +109 -12
  23. package/dist/generators/test-generator/code-generator.js.map +1 -1
  24. package/dist/generators/test-generator/step-mapper.d.ts +1 -0
  25. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  26. package/dist/generators/test-generator/step-mapper.js +1 -1
  27. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  28. package/dist/generators/test-generator/template-engine.d.ts +29 -1
  29. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  30. package/dist/generators/test-generator/template-engine.js +11 -2
  31. package/dist/generators/test-generator/template-engine.js.map +1 -1
  32. package/dist/generators/test-generator/utils/data-resolver.d.ts +11 -2
  33. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  34. package/dist/generators/test-generator/utils/data-resolver.js +36 -25
  35. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  36. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +7 -0
  37. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -0
  38. package/dist/generators/test-generator/utils/runtime-data-transformer.js +42 -0
  39. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -0
  40. package/dist/generators/types.d.ts +1 -0
  41. package/dist/generators/types.d.ts.map +1 -1
  42. package/dist/generators/types.js.map +1 -1
  43. package/dist/orchestrator/project-initializer.d.ts +9 -0
  44. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  45. package/dist/orchestrator/project-initializer.js +74 -10
  46. package/dist/orchestrator/project-initializer.js.map +1 -1
  47. package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +1 -1
  48. package/dist/orchestrator/templates/ai-instructions/claude-config.md +11 -2
  49. package/dist/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +66 -13
  50. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +54 -3
  51. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +1 -1
  52. package/dist/orchestrator/templates/ai-instructions/copilot-config.md +11 -2
  53. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +86 -13
  54. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +54 -3
  55. package/dist/orchestrator/templates/specs-base.d.ts +12 -1
  56. package/dist/orchestrator/templates/specs-base.d.ts.map +1 -1
  57. package/dist/orchestrator/templates/specs-base.js +47 -5
  58. package/dist/orchestrator/templates/specs-base.js.map +1 -1
  59. package/dist/orchestrator/templates/specs-base.ts +65 -7
  60. package/dist/orchestrator/templates/specs-test-data.d.ts +14 -0
  61. package/dist/orchestrator/templates/specs-test-data.d.ts.map +1 -0
  62. package/dist/orchestrator/templates/specs-test-data.js +100 -0
  63. package/dist/orchestrator/templates/specs-test-data.js.map +1 -0
  64. package/dist/orchestrator/templates/specs-test-data.ts +66 -0
  65. package/package.json +1 -1
  66. package/src/cli/commands/generate.ts +2 -0
  67. package/src/cli/index.ts +1 -1
  68. package/src/generators/gherkin-parser/index.ts +4 -0
  69. package/src/generators/test-generator/adapters/adapter-interface.ts +12 -1
  70. package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +14 -2
  71. package/src/generators/test-generator/adapters/playwright/templates/after-all.hbs +8 -0
  72. package/src/generators/test-generator/adapters/playwright/templates/after-each.hbs +8 -0
  73. package/src/generators/test-generator/adapters/playwright/templates/before-all.hbs +8 -0
  74. package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
  75. package/src/generators/test-generator/adapters/playwright/templates/test-file.hbs +24 -0
  76. package/src/generators/test-generator/code-generator.ts +122 -13
  77. package/src/generators/test-generator/step-mapper.ts +2 -2
  78. package/src/generators/test-generator/template-engine.ts +28 -2
  79. package/src/generators/test-generator/utils/data-resolver.ts +45 -27
  80. package/src/generators/test-generator/utils/runtime-data-transformer.ts +51 -0
  81. package/src/generators/types.ts +1 -0
  82. package/src/orchestrator/project-initializer.ts +84 -10
  83. package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +1 -1
  84. package/src/orchestrator/templates/ai-instructions/claude-config.md +11 -2
  85. package/src/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +66 -13
  86. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +54 -3
  87. package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +1 -1
  88. package/src/orchestrator/templates/ai-instructions/copilot-config.md +11 -2
  89. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +86 -13
  90. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +54 -3
  91. package/src/orchestrator/templates/specs-base.ts +65 -7
  92. package/src/orchestrator/templates/specs-test-data.ts +66 -0
@@ -1,4 +1,8 @@
1
1
  {{imports}}
2
+ {{#if runtimeData}}
3
+
4
+ const testData = TestDataLoader.load('{{screenName}}', '{{featureFileName}}');
5
+ {{/if}}
2
6
 
3
7
  {{#if featureDescription}}
4
8
  /**
@@ -11,10 +15,30 @@ test.describe('{{featureName}}', () => {
11
15
  {{#if singleAuthRole}}
12
16
  test.use({ storageState: 'specs/.auth/{{singleAuthRole}}.json' });
13
17
 
18
+ {{/if}}
19
+ {{#if cleanupConfig}}
20
+ test.use({ autoCleanup: { {{cleanupConfig}} } });
21
+
22
+ {{/if}}
23
+ {{#if screenshotOnFailure}}
24
+ test.use({ screenshotOnFailure: true });
25
+
26
+ {{/if}}
27
+ {{#if beforeAll}}
28
+ {{beforeAll}}
29
+
14
30
  {{/if}}
15
31
  {{#if background}}
16
32
  {{background}}
17
33
 
34
+ {{/if}}
35
+ {{#if afterEach}}
36
+ {{afterEach}}
37
+
38
+ {{/if}}
39
+ {{#if afterAll}}
40
+ {{afterAll}}
41
+
18
42
  {{/if}}
19
43
  {{#if authGroups}}
20
44
  {{#each authGroups}}
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { ParsedFeature, ParsedScenario, ParsedStep } from '../gherkin-parser';
4
4
  import { StepMapper } from './step-mapper';
5
5
  import { TestGeneratorAdapter, adapterRegistry } from './adapters';
6
+ import { transformToRuntimeData } from './utils/runtime-data-transformer';
6
7
 
7
8
  /**
8
9
  * Filter base scenario steps for @extend: only keep Given→When steps.
@@ -27,6 +28,33 @@ function filterBaseStepsForExtend(steps: ParsedStep[]): ParsedStep[] {
27
28
  return result;
28
29
  }
29
30
 
31
+ /**
32
+ * Extract @cleanup:* tags into autoCleanup config string for test.use()
33
+ * @cleanup:overlay @cleanup:forms → 'overlay: true, forms: true'
34
+ */
35
+ function extractCleanupConfig(tags: string[]): string | undefined {
36
+ const validKeys = ['overlay', 'forms', 'scroll', 'storage'];
37
+ const entries = tags
38
+ .filter(t => t.startsWith('@cleanup:'))
39
+ .map(t => t.replace('@cleanup:', ''))
40
+ .filter(key => {
41
+ if (!validKeys.includes(key)) {
42
+ console.warn(`⚠ Unknown @cleanup:${key} — valid options: ${validKeys.join(', ')}`);
43
+ return false;
44
+ }
45
+ return true;
46
+ });
47
+ if (entries.length === 0) return undefined;
48
+ return entries.map(key => `${key}: true`).join(', ');
49
+ }
50
+
51
+ /**
52
+ * Check for @screenshot:on-failure tag
53
+ */
54
+ function hasScreenshotOnFailure(tags: string[]): boolean {
55
+ return tags.includes('@screenshot:on-failure');
56
+ }
57
+
30
58
  /**
31
59
  * Extract auth role from tags
32
60
  * @auth:admin → 'admin'
@@ -106,7 +134,7 @@ export class CodeGenerator {
106
134
  // Steps registry built per feature during generateTestCode(); used by countSteps()
107
135
  private stepsRegistry = new Map<string, ParsedScenario>();
108
136
 
109
- constructor(options: { useAI?: boolean; verbose?: boolean; framework?: string; baseURL?: string; screenName?: string } = {}) {
137
+ constructor(options: { useAI?: boolean; verbose?: boolean; framework?: string; baseURL?: string; screenName?: string; runtimeData?: boolean } = {}) {
110
138
  this.options = options;
111
139
  this.screenName = options.screenName;
112
140
  this.stepMapper = new StepMapper(options);
@@ -155,14 +183,19 @@ export class CodeGenerator {
155
183
  : path.join(outputDir, fileName);
156
184
 
157
185
  // Generate imports using adapter
158
- const imports = this.adapter.renderImports();
186
+ const imports = this.adapter.renderImports({ runtimeData: this.options.runtimeData });
159
187
 
160
188
  // Generate test code (async now to support AI mapping)
161
189
  const testCode = await this.generateTestCode(feature);
162
190
 
163
191
  // Combine and collapse any runs of 3+ newlines down to 2 (one blank line max)
164
192
  const raw = `${imports}\n\n${testCode}`;
165
- const code = raw.replace(/\n{3,}/g, '\n\n');
193
+ let code = raw.replace(/\n{3,}/g, '\n\n');
194
+
195
+ // Runtime data: replace __SUNGEN_TD_ markers with testData.get() calls
196
+ if (this.options.runtimeData) {
197
+ code = transformToRuntimeData(code);
198
+ }
166
199
 
167
200
  return {
168
201
  featureName: feature.name,
@@ -180,6 +213,7 @@ export class CodeGenerator {
180
213
  private countSteps(feature: ParsedFeature): number {
181
214
  let total = 0;
182
215
  for (const scenario of feature.scenarios) {
216
+ if (scenario.stepsName || scenario.hookType) continue;
183
217
  if (scenario.extendsName) {
184
218
  const base = this.stepsRegistry.get(scenario.extendsName);
185
219
  total += (base ? base.steps.length : 0) + scenario.steps.length;
@@ -206,17 +240,30 @@ export class CodeGenerator {
206
240
  * Ensure specs/base.ts exists in the output directory
207
241
  */
208
242
  ensureBaseFile(outputDir: string): void {
243
+ const templatesRoot = path.join(__dirname, '..', '..', '..', 'orchestrator', 'templates');
244
+
209
245
  const basePath = path.join(outputDir, 'base.ts');
210
- if (fs.existsSync(basePath)) return;
246
+ if (!fs.existsSync(basePath)) {
247
+ const templatePath = path.join(templatesRoot, 'specs-base.ts');
248
+ if (fs.existsSync(templatePath)) {
249
+ const baseDir = path.dirname(basePath);
250
+ if (!fs.existsSync(baseDir)) {
251
+ fs.mkdirSync(baseDir, { recursive: true });
252
+ }
253
+ fs.copyFileSync(templatePath, basePath);
254
+ console.log('✓ Created: specs/base.ts');
255
+ }
256
+ }
211
257
 
212
- const templatePath = path.join(__dirname, '..', '..', '..', 'orchestrator', 'templates', 'specs-base.ts');
213
- if (fs.existsSync(templatePath)) {
214
- const baseDir = path.dirname(basePath);
215
- if (!fs.existsSync(baseDir)) {
216
- fs.mkdirSync(baseDir, { recursive: true });
258
+ if (this.options.runtimeData) {
259
+ const testDataPath = path.join(outputDir, 'test-data.ts');
260
+ if (!fs.existsSync(testDataPath)) {
261
+ const templatePath = path.join(templatesRoot, 'specs-test-data.ts');
262
+ if (fs.existsSync(templatePath)) {
263
+ fs.copyFileSync(templatePath, testDataPath);
264
+ console.log('✓ Created: specs/test-data.ts');
265
+ }
217
266
  }
218
- fs.copyFileSync(templatePath, basePath);
219
- console.log('✓ Created: specs/base.ts');
220
267
  }
221
268
  }
222
269
 
@@ -233,12 +280,14 @@ export class CodeGenerator {
233
280
 
234
281
  // Derive screen name from source file path when not explicitly set
235
282
  // qa/screens/{screenName}/features/{featureName}.feature -> screenName
283
+ let effectiveScreenName = this.screenName;
236
284
  if (!this.screenName && feature.sourceFile) {
237
285
  const sourceDir = path.dirname(feature.sourceFile);
238
286
  const parts = sourceDir.split(path.sep);
239
287
  const screensIndex = parts.indexOf('screens');
240
288
  if (screensIndex >= 0 && screensIndex < parts.length - 2) {
241
- this.stepMapper.setScreenContext(parts[screensIndex + 1]);
289
+ effectiveScreenName = parts[screensIndex + 1];
290
+ this.stepMapper.setScreenContext(effectiveScreenName);
242
291
  }
243
292
  }
244
293
 
@@ -261,13 +310,35 @@ export class CodeGenerator {
261
310
  this.stepsRegistry.set(scenario.stepsName, scenario);
262
311
  }
263
312
  }
264
-
313
+
314
+ // Pre-pass: extract hook scenarios (@beforeAll, @afterEach, @afterAll)
315
+ const hookScenarios = new Map<string, ParsedScenario>();
316
+ for (const scenario of feature.scenarios) {
317
+ if (scenario.hookType) {
318
+ if (hookScenarios.has(scenario.hookType)) {
319
+ console.warn(`⚠ Duplicate @${scenario.hookType} hook — last definition wins`);
320
+ }
321
+ hookScenarios.set(scenario.hookType, scenario);
322
+ }
323
+ }
324
+
265
325
  // Generate background if exists
266
326
  let background: string | undefined;
267
327
  if (feature.background) {
268
328
  background = await this.generateBeforeEach(feature.background);
269
329
  }
270
330
 
331
+ // Generate hook blocks
332
+ const beforeAll = hookScenarios.has('beforeAll')
333
+ ? await this.generateHook(hookScenarios.get('beforeAll')!, 'beforeAll')
334
+ : undefined;
335
+ const afterEach = hookScenarios.has('afterEach')
336
+ ? await this.generateHook(hookScenarios.get('afterEach')!, 'afterEach')
337
+ : undefined;
338
+ const afterAll = hookScenarios.has('afterAll')
339
+ ? await this.generateHook(hookScenarios.get('afterAll')!, 'afterAll')
340
+ : undefined;
341
+
271
342
  // Generate all scenarios with feature tags for inheritance
272
343
  // Skip scenarios tagged with @manual
273
344
  // Track auth role per scenario for grouping
@@ -280,6 +351,11 @@ export class CodeGenerator {
280
351
  continue;
281
352
  }
282
353
 
354
+ // Skip hook scenarios — already generated as hook blocks above
355
+ if (scenario.hookType) {
356
+ continue;
357
+ }
358
+
283
359
  // Resolve auth tags for @extend scenarios (same logic as generateScenario)
284
360
  let authFeatureTags = feature.tags || [];
285
361
  if (scenario.extendsName) {
@@ -327,12 +403,24 @@ export class CodeGenerator {
327
403
  ? authGroups[0].authRole
328
404
  : undefined;
329
405
 
406
+ // Extract @cleanup:* tags for autoCleanup fixture config
407
+ const cleanupConfig = extractCleanupConfig(feature.tags || []);
408
+ const screenshotOnFailure = hasScreenshotOnFailure(feature.tags || []);
409
+
330
410
  // Use adapter to render the complete test file structure
331
411
  return this.adapter.renderTestFile({
332
412
  imports: '', // Not used in template as it's rendered separately
333
413
  featureName: feature.name,
334
414
  featureDescription: feature.description,
335
415
  background,
416
+ beforeAll,
417
+ afterEach,
418
+ afterAll,
419
+ cleanupConfig,
420
+ screenshotOnFailure,
421
+ runtimeData: this.options.runtimeData,
422
+ screenName: effectiveScreenName,
423
+ featureFileName: featureName,
336
424
  scenarios: needsGrouping ? [] : scenarios,
337
425
  authGroups: needsGrouping ? authGroups : undefined,
338
426
  singleAuthRole,
@@ -356,6 +444,27 @@ export class CodeGenerator {
356
444
  });
357
445
  }
358
446
 
447
+ private async generateHook(
448
+ scenario: ParsedScenario,
449
+ hookType: 'beforeAll' | 'afterEach' | 'afterAll'
450
+ ): Promise<string> {
451
+ const steps: Array<{ comment?: string; code: string }> = [];
452
+ for (const step of scenario.steps) {
453
+ const mapped = await Promise.resolve(this.stepMapper.mapStep(step));
454
+ steps.push({
455
+ comment: mapped.comment,
456
+ code: this.indentCode(mapped.code, 4),
457
+ });
458
+ }
459
+
460
+ const renderMap = {
461
+ beforeAll: () => this.adapter.renderBeforeAll({ steps }),
462
+ afterEach: () => this.adapter.renderAfterEach({ steps }),
463
+ afterAll: () => this.adapter.renderAfterAll({ steps }),
464
+ };
465
+ return renderMap[hookType]();
466
+ }
467
+
359
468
  private async generateScenario(
360
469
  scenario: ParsedScenario,
361
470
  hasBackground: boolean,
@@ -35,7 +35,7 @@ export class StepMapper {
35
35
  private inRowScope: boolean = false;
36
36
  private rowScopeTable: string = '';
37
37
 
38
- constructor(options: { verbose?: boolean; baseURL?: string; featureName?: string; screenName?: string; featurePath?: string } = {}) {
38
+ constructor(options: { verbose?: boolean; baseURL?: string; featureName?: string; screenName?: string; featurePath?: string; runtimeData?: boolean } = {}) {
39
39
  this.verbose = options.verbose ?? false;
40
40
  this.baseURL = options.baseURL || null; // null means path-only navigation
41
41
  this.featureName = options.featureName;
@@ -46,7 +46,7 @@ export class StepMapper {
46
46
  this.templateEngine = new TemplateEngine(playwrightTemplatesDir);
47
47
  this.patternRegistry = new PatternRegistry();
48
48
  this.selectorResolver = new SelectorResolver(undefined, options.screenName);
49
- this.dataResolver = new DataResolver(undefined, options.screenName);
49
+ this.dataResolver = new DataResolver(undefined, options.screenName, options.runtimeData);
50
50
 
51
51
  if (this.verbose) {
52
52
  console.log(` [StepMapper] ${this.patternRegistry.getPatternCount()} patterns loaded`);
@@ -229,8 +229,8 @@ export class TemplateEngine {
229
229
  this.baseContext = {};
230
230
  }
231
231
 
232
- renderImports(): string {
233
- return this.render('imports', {});
232
+ renderImports(options?: { runtimeData?: boolean }): string {
233
+ return this.render('imports', { runtimeData: options?.runtimeData });
234
234
  }
235
235
 
236
236
  renderTestFile(data: {
@@ -238,6 +238,14 @@ export class TemplateEngine {
238
238
  featureName: string;
239
239
  featureDescription?: string;
240
240
  background?: string;
241
+ beforeAll?: string;
242
+ afterEach?: string;
243
+ afterAll?: string;
244
+ cleanupConfig?: string;
245
+ screenshotOnFailure?: boolean;
246
+ runtimeData?: boolean;
247
+ screenName?: string;
248
+ featureFileName?: string;
241
249
  scenarios: string[];
242
250
  authGroups?: Array<{ authRole?: string; scenarios: string[] }>;
243
251
  singleAuthRole?: string;
@@ -251,6 +259,24 @@ export class TemplateEngine {
251
259
  return this.render('before-each', data);
252
260
  }
253
261
 
262
+ renderBeforeAll(data: {
263
+ steps: Array<{ comment?: string; code: string }>;
264
+ }): string {
265
+ return this.render('before-all', data);
266
+ }
267
+
268
+ renderAfterEach(data: {
269
+ steps: Array<{ comment?: string; code: string }>;
270
+ }): string {
271
+ return this.render('after-each', data);
272
+ }
273
+
274
+ renderAfterAll(data: {
275
+ steps: Array<{ comment?: string; code: string }>;
276
+ }): string {
277
+ return this.render('after-all', data);
278
+ }
279
+
254
280
  renderScenario(data: {
255
281
  scenarioName: string;
256
282
  steps: Array<{ comment?: string; code: string }>;
@@ -5,8 +5,11 @@ import { readYamlIfExists } from '../../../utils/yaml-io';
5
5
 
6
6
  /**
7
7
  * DataResolver - Resolves data references to actual values
8
- * at generation time (not runtime)
9
- *
8
+ *
9
+ * Two modes:
10
+ * - Compile-time (default): resolves YAML values and bakes them into generated code
11
+ * - Runtime (runtimeMode=true): returns markers that post-processor converts to testData.get() calls
12
+ *
10
13
  * Supports override files with priority:
11
14
  * 1. .override.yaml (highest - user customizations)
12
15
  * 2. -override.yaml (legacy - backward compat)
@@ -16,10 +19,12 @@ export class DataResolver {
16
19
  private dataCache = new Map<string, any>();
17
20
  private testDataDir: string;
18
21
  private screenName?: string;
22
+ private runtimeMode: boolean;
19
23
 
20
- constructor(testDataDir?: string, screenName?: string) {
24
+ constructor(testDataDir?: string, screenName?: string, runtimeMode: boolean = false) {
21
25
  this.testDataDir = testDataDir || path.join(process.cwd(), 'qa', 'test-data');
22
26
  this.screenName = screenName;
27
+ this.runtimeMode = runtimeMode;
23
28
  }
24
29
 
25
30
  /**
@@ -36,34 +41,18 @@ export class DataResolver {
36
41
  * @returns The resolved value
37
42
  */
38
43
  resolveData(dataRef: string, featureName?: string): string {
39
- // Split into parts: email.valid -> [email, valid]
40
- // Use only dot (.) as separator
41
- const parts = dataRef.split('.');
42
-
43
- if (parts.length < 1) {
44
- throw new Error(`Invalid data reference: ${dataRef}. Expected format: path.to.value`);
44
+ if (this.runtimeMode) {
45
+ this.validateDataRef(dataRef, featureName);
46
+ return DataResolver.encodeMarker(dataRef);
45
47
  }
46
48
 
47
- // If featureName provided, use it as the file name
48
- // Otherwise, use first part as filename (backward compatibility)
49
- let fileName: string;
50
- let valuePath: string[];
51
-
52
- if (featureName) {
53
- fileName = featureName;
54
- valuePath = parts;
55
- } else {
56
- if (parts.length < 2) {
57
- throw new Error(`Invalid data reference: ${dataRef}. Expected format: file.path.to.value or provide featureName`);
58
- }
59
- fileName = parts[0];
60
- valuePath = parts.slice(1);
61
- }
49
+ return this.resolveValue(dataRef, featureName);
50
+ }
62
51
 
63
- // Load data file
52
+ private resolveValue(dataRef: string, featureName?: string): string {
53
+ const { fileName, valuePath } = this.parseDataRef(dataRef, featureName);
64
54
  const data = this.loadDataFile(fileName);
65
55
 
66
- // Navigate to the value
67
56
  let current = data;
68
57
  for (const key of valuePath) {
69
58
  if (current && typeof current === 'object' && key in current) {
@@ -75,7 +64,6 @@ export class DataResolver {
75
64
  }
76
65
  }
77
66
 
78
- // Return as string
79
67
  if (typeof current === 'string') {
80
68
  return current;
81
69
  } else if (typeof current === 'number' || typeof current === 'boolean') {
@@ -87,6 +75,36 @@ export class DataResolver {
87
75
  }
88
76
  }
89
77
 
78
+ private validateDataRef(dataRef: string, featureName?: string): void {
79
+ // Same navigation as resolveValue — validates the key path exists at compile time
80
+ this.resolveValue(dataRef, featureName);
81
+ }
82
+
83
+ private parseDataRef(dataRef: string, featureName?: string): { fileName: string; valuePath: string[] } {
84
+ const parts = dataRef.split('.');
85
+
86
+ if (parts.length < 1) {
87
+ throw new Error(`Invalid data reference: ${dataRef}. Expected format: path.to.value`);
88
+ }
89
+
90
+ if (featureName) {
91
+ return { fileName: featureName, valuePath: parts };
92
+ }
93
+
94
+ if (parts.length < 2) {
95
+ throw new Error(`Invalid data reference: ${dataRef}. Expected format: file.path.to.value or provide featureName`);
96
+ }
97
+ return { fileName: parts[0], valuePath: parts.slice(1) };
98
+ }
99
+
100
+ static encodeMarker(ref: string): string {
101
+ return `__SUNGEN_TD_${ref.replace(/\./g, '_D_')}__`;
102
+ }
103
+
104
+ static decodeMarker(encoded: string): string {
105
+ return encoded.replace(/^__SUNGEN_TD_/, '').replace(/__$/, '').replace(/_D_/g, '.');
106
+ }
107
+
90
108
  /**
91
109
  * Load data file from disk (with caching)
92
110
  * Searches new screen-based directory structure only
@@ -0,0 +1,51 @@
1
+ const MARKER_PATTERN = /__SUNGEN_TD_([A-Za-z0-9_]+)__/;
2
+
3
+ /**
4
+ * Replace __SUNGEN_TD_ markers with testData.get() calls in generated code.
5
+ * Three passes: comments, string literals, then regex literals.
6
+ */
7
+ export function transformToRuntimeData(code: string): string {
8
+ // Pass 0: Comments — replace markers in // comments with decoded key name
9
+ // Prevents Pass 2 from misinterpreting // comment markers as regex delimiters
10
+ code = code.replace(
11
+ /\/\/(.*)__SUNGEN_TD_([A-Za-z0-9_]+)__(.*)/g,
12
+ (_, before, enc, after) => `//${before}${decodeKey(enc)}${after}`
13
+ );
14
+
15
+ // Pass 1: String literal context — handles both whole-string and embedded markers
16
+ // 'marker' → testData.get('key')
17
+ // 'prefix__marker__suffix' → `prefix${testData.get('key')}suffix`
18
+ code = code.replace(
19
+ /(['"])((?:(?!\1).)*?)__SUNGEN_TD_([A-Za-z0-9_]+)__((?:(?!\1).)*?)\1/g,
20
+ (_, _quote, prefix, enc, suffix) => {
21
+ const key = decodeKey(enc);
22
+ if (!prefix && !suffix) {
23
+ return `testData.get('${key}')`;
24
+ }
25
+ return `\`${prefix}\${testData.get('${key}')}${suffix}\``;
26
+ }
27
+ );
28
+
29
+ // Pass 2: Regex literal context — /prefix__marker__suffix/ → new RegExp(`...`)
30
+ // Only matches within a single line (no newlines in prefix/suffix)
31
+ code = code.replace(
32
+ /\/((?:[^/\\\n]|\\.)*?)__SUNGEN_TD_([A-Za-z0-9_]+)__((?:[^/\\\n]|\\.)*?)\/([gimsuy]*)/g,
33
+ (_, prefix, enc, suffix, flags) => {
34
+ const key = decodeKey(enc);
35
+ const ref = `testData.get('${key}')`;
36
+ const flagStr = flags ? `, '${flags}'` : '';
37
+ if (!prefix && !suffix) return `new RegExp(${ref}${flagStr})`;
38
+ return `new RegExp(\`${prefix}\${${ref}}${suffix}\`${flagStr})`;
39
+ }
40
+ );
41
+
42
+ return code;
43
+ }
44
+
45
+ export function hasRuntimeDataMarkers(code: string): boolean {
46
+ return MARKER_PATTERN.test(code);
47
+ }
48
+
49
+ function decodeKey(encoded: string): string {
50
+ return encoded.replace(/_D_/g, '.');
51
+ }
@@ -170,6 +170,7 @@ export interface GenerateOptions {
170
170
  all?: boolean; // Generate all screens
171
171
  force?: boolean; // Force re-generation
172
172
  skipCache?: boolean; // Skip cache lookup
173
+ runtimeData?: boolean; // Runtime data loading (default: true). False = compile-time hardcoding.
173
174
  }
174
175
 
175
176
  export interface ValidateOptions {
@@ -48,8 +48,9 @@ export class ProjectInitializer {
48
48
  // Create tsconfig.json if doesn't exist
49
49
  this.createTsConfig();
50
50
 
51
- // Create specs/base.ts for shared context
51
+ // Create specs/base.ts for shared context and specs/test-data.ts for runtime data
52
52
  this.createSpecsBase();
53
+ this.createSpecsTestData();
53
54
 
54
55
  // Create/update .gitignore
55
56
  this.updateGitignore();
@@ -383,6 +384,27 @@ export class ProjectInitializer {
383
384
  this.createdItems.push('specs/generated/base.ts');
384
385
  }
385
386
 
387
+ /**
388
+ * Create specs/test-data.ts for runtime YAML loading
389
+ */
390
+ private createSpecsTestData(): void {
391
+ const testDataPath = path.join(this.cwd, 'specs', 'generated', 'test-data.ts');
392
+
393
+ if (fs.existsSync(testDataPath)) {
394
+ this.skippedItems.push('specs/generated/test-data.ts');
395
+ return;
396
+ }
397
+
398
+ const baseDir = path.dirname(testDataPath);
399
+ if (!fs.existsSync(baseDir)) {
400
+ fs.mkdirSync(baseDir, { recursive: true });
401
+ }
402
+
403
+ const content = this.readTemplate('specs-test-data.ts');
404
+ fs.writeFileSync(testDataPath, content, 'utf-8');
405
+ this.createdItems.push('specs/generated/test-data.ts');
406
+ }
407
+
386
408
  /**
387
409
  * Read a template file from the templates directory
388
410
  */
@@ -405,26 +427,78 @@ export class ProjectInitializer {
405
427
  this.createdItems.push('package.json');
406
428
  }
407
429
 
408
- // Check if @playwright/test is already installed
430
+ // Ensure standard scripts exist in package.json
431
+ this.ensurePackageScripts(packageJsonPath);
432
+
433
+ // Check which dependencies are missing
434
+ const requiredDeps = ['@playwright/test', '@types/node', 'yaml'];
435
+ let missingDeps: string[] = requiredDeps;
409
436
  try {
410
437
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
411
438
  const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
412
- if (deps['@playwright/test']) {
413
- console.log('✓ @playwright/test already installed\n');
414
- return;
415
- }
439
+ missingDeps = requiredDeps.filter(d => !deps[d]);
416
440
  } catch {
417
- // package.json just created, proceed with install
441
+ // package.json just created, install all
442
+ }
443
+
444
+ if (missingDeps.length === 0) {
445
+ console.log('✓ All dependencies already installed\n');
446
+ return;
418
447
  }
419
448
 
420
- // Install Playwright and TypeScript types
421
- console.log('📦 Installing @playwright/test and @types/node...\n');
422
- execSync('npm install -D @playwright/test @types/node', execOpts);
449
+ console.log(`📦 Installing ${missingDeps.join(', ')}...\n`);
450
+ execSync(`npm install -D ${missingDeps.join(' ')}`, execOpts);
423
451
 
424
452
  console.log('\n🎭 Installing Playwright browsers...\n');
425
453
  execSync('npx playwright install', execOpts);
426
454
  }
427
455
 
456
+ /**
457
+ * Ensure package.json has standard Playwright + Sungen scripts.
458
+ * Only adds missing scripts — never overwrites user customizations.
459
+ */
460
+ private ensurePackageScripts(packageJsonPath: string): void {
461
+ let packageJson: Record<string, any>;
462
+ try {
463
+ packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
464
+ } catch {
465
+ return;
466
+ }
467
+
468
+ if (!packageJson.scripts || typeof packageJson.scripts !== 'object') {
469
+ packageJson.scripts = {};
470
+ }
471
+
472
+ const standardScripts: Record<string, string> = {
473
+ 'test': 'playwright test specs/generated/',
474
+ 'test:headed': 'playwright test specs/generated/ --headed',
475
+ 'test:debug': 'playwright test specs/generated/ --debug',
476
+ 'test:ui': 'playwright test specs/generated/ --ui',
477
+ 'report': 'playwright show-report',
478
+ 'generate': 'sungen generate --all',
479
+ 'install:browsers': 'npx playwright install chromium',
480
+ };
481
+
482
+ let added = 0;
483
+ for (const [name, command] of Object.entries(standardScripts)) {
484
+ // Skip if user already has this script (don't overwrite)
485
+ // Exception: overwrite the npm init default test script
486
+ const existing = packageJson.scripts[name];
487
+ if (existing && existing !== 'echo "Error: no test specified" && exit 1') {
488
+ continue;
489
+ }
490
+ packageJson.scripts[name] = command;
491
+ added++;
492
+ }
493
+
494
+ if (added > 0) {
495
+ // Also mark as private (test projects should not be published)
496
+ packageJson.private = true;
497
+ fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8');
498
+ console.log(`📝 Added ${added} standard script(s) to package.json\n`);
499
+ }
500
+ }
501
+
428
502
  /**
429
503
  * Get Playwright configuration template
430
504
  */
@@ -18,7 +18,7 @@ Parse **screen** from `$ARGUMENTS`. If missing, ask the user.
18
18
  1. Verify `qa/screens/<screen>/` has `.feature` + `test-data.yaml`.
19
19
  2. **Phase 0 — Selector Pre-gen**: if `selectors.yaml` is missing/empty or doesn't cover the feature file's `[Reference]`s, run Phase 0 from `sungen-selector-fix` — confirm with user, `browser_navigate` → one `browser_snapshot` → merge YAML entries.
20
20
  3. **Phase 0.5 — Auth Persistence**: if the feature has `@auth:<role>` tags and `specs/.auth/<role>.json` is missing/expired, run Phase 0.5 from `sungen-selector-fix` — user logs in manually in MCP browser → `browser_storage_state` → `specs/.auth/<role>.json`. Offer `sungen makeauth <role>` as CLI fallback only if `browser_storage_state` isn't available in this MCP version.
21
- 4. Compile: `sungen generate --screen <screen>`.
21
+ 4. Compile: `sungen generate --screen <screen>` (default: runtime data loading from YAML). Use `--inline-data` only if user requests compile-time hardcoded values.
22
22
 
23
23
  ## Run & Fix (phased — per `sungen-selector-fix` skill)
24
24