@sun-asterisk/sungen 3.0.0-beta.71 → 3.0.0-beta.72

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 (86) hide show
  1. package/dist/cli/commands/capability.d.ts +3 -0
  2. package/dist/cli/commands/capability.d.ts.map +1 -0
  3. package/dist/cli/commands/capability.js +196 -0
  4. package/dist/cli/commands/capability.js.map +1 -0
  5. package/dist/cli/commands/flow-check.d.ts +3 -0
  6. package/dist/cli/commands/flow-check.d.ts.map +1 -0
  7. package/dist/cli/commands/flow-check.js +136 -0
  8. package/dist/cli/commands/flow-check.js.map +1 -0
  9. package/dist/cli/commands/generate.d.ts.map +1 -1
  10. package/dist/cli/commands/generate.js +28 -2
  11. package/dist/cli/commands/generate.js.map +1 -1
  12. package/dist/cli/commands/script-check.js +1 -1
  13. package/dist/cli/commands/script-check.js.map +1 -1
  14. package/dist/cli/index.js +4 -0
  15. package/dist/cli/index.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/adapter-registry.d.ts +13 -0
  19. package/dist/generators/test-generator/adapters/adapter-registry.d.ts.map +1 -1
  20. package/dist/generators/test-generator/adapters/adapter-registry.js +73 -1
  21. package/dist/generators/test-generator/adapters/adapter-registry.js.map +1 -1
  22. package/dist/generators/test-generator/adapters/index.d.ts +1 -1
  23. package/dist/generators/test-generator/adapters/index.d.ts.map +1 -1
  24. package/dist/generators/test-generator/adapters/index.js +5 -1
  25. package/dist/generators/test-generator/adapters/index.js.map +1 -1
  26. package/dist/generators/test-generator/adapters/playwright/templates/test-file.hbs +6 -0
  27. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  28. package/dist/generators/test-generator/code-generator.js +6 -2
  29. package/dist/generators/test-generator/code-generator.js.map +1 -1
  30. package/dist/generators/test-generator/patterns/form-patterns.d.ts.map +1 -1
  31. package/dist/generators/test-generator/patterns/form-patterns.js +3 -1
  32. package/dist/generators/test-generator/patterns/form-patterns.js.map +1 -1
  33. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  34. package/dist/generators/test-generator/utils/runtime-data-transformer.js +4 -0
  35. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  36. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  37. package/dist/generators/test-generator/utils/selector-resolver.js +12 -6
  38. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  39. package/dist/harness/capability-plan.d.ts +49 -0
  40. package/dist/harness/capability-plan.d.ts.map +1 -0
  41. package/dist/harness/capability-plan.js +215 -0
  42. package/dist/harness/capability-plan.js.map +1 -0
  43. package/dist/harness/capability.d.ts +23 -0
  44. package/dist/harness/capability.d.ts.map +1 -0
  45. package/dist/harness/capability.js +98 -0
  46. package/dist/harness/capability.js.map +1 -0
  47. package/dist/harness/catalog/drivers.yaml +57 -0
  48. package/dist/harness/flow-check.d.ts +23 -0
  49. package/dist/harness/flow-check.d.ts.map +1 -0
  50. package/dist/harness/flow-check.js +132 -0
  51. package/dist/harness/flow-check.js.map +1 -0
  52. package/dist/harness/flow-plan.d.ts +23 -0
  53. package/dist/harness/flow-plan.d.ts.map +1 -0
  54. package/dist/harness/flow-plan.js +166 -0
  55. package/dist/harness/flow-plan.js.map +1 -0
  56. package/dist/harness/script-check.d.ts +23 -0
  57. package/dist/harness/script-check.d.ts.map +1 -1
  58. package/dist/harness/script-check.js +88 -6
  59. package/dist/harness/script-check.js.map +1 -1
  60. package/dist/orchestrator/project-initializer.d.ts +5 -0
  61. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  62. package/dist/orchestrator/project-initializer.js +20 -0
  63. package/dist/orchestrator/project-initializer.js.map +1 -1
  64. package/dist/orchestrator/templates/specs-test-data.ts +11 -6
  65. package/package.json +3 -2
  66. package/src/cli/commands/capability.ts +160 -0
  67. package/src/cli/commands/flow-check.ts +97 -0
  68. package/src/cli/commands/generate.ts +28 -2
  69. package/src/cli/commands/script-check.ts +1 -1
  70. package/src/cli/index.ts +4 -0
  71. package/src/generators/test-generator/adapters/adapter-interface.ts +1 -0
  72. package/src/generators/test-generator/adapters/adapter-registry.ts +37 -0
  73. package/src/generators/test-generator/adapters/index.ts +4 -1
  74. package/src/generators/test-generator/adapters/playwright/templates/test-file.hbs +6 -0
  75. package/src/generators/test-generator/code-generator.ts +6 -2
  76. package/src/generators/test-generator/patterns/form-patterns.ts +3 -1
  77. package/src/generators/test-generator/utils/runtime-data-transformer.ts +8 -0
  78. package/src/generators/test-generator/utils/selector-resolver.ts +13 -6
  79. package/src/harness/capability-plan.ts +180 -0
  80. package/src/harness/capability.ts +75 -0
  81. package/src/harness/catalog/drivers.yaml +57 -0
  82. package/src/harness/flow-check.ts +99 -0
  83. package/src/harness/flow-plan.ts +135 -0
  84. package/src/harness/script-check.ts +79 -6
  85. package/src/orchestrator/project-initializer.ts +23 -0
  86. package/src/orchestrator/templates/specs-test-data.ts +11 -6
@@ -0,0 +1,160 @@
1
+ import { Command } from 'commander';
2
+ import { spawnSync } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import {
6
+ readCapabilities, writeCapabilities, loadDriverCatalog, driverMeta,
7
+ } from '../../harness/capability';
8
+ import { buildPlan, MANUAL_REASONS } from '../../harness/capability-plan';
9
+ import { adapterRegistry } from '../../generators/test-generator/adapters';
10
+
11
+ function findScreenDir(name: string): string | null {
12
+ const s = path.join(process.cwd(), 'qa', 'screens', name);
13
+ if (fs.existsSync(s)) return s;
14
+ const f = path.join(process.cwd(), 'qa', 'flows', name);
15
+ if (fs.existsSync(f)) return f;
16
+ return null;
17
+ }
18
+
19
+ export function registerCapabilityCommand(program: Command): void {
20
+ const cap = program
21
+ .command('capability')
22
+ .description('Manage execution drivers (platform + capability) — opt-in, installed only on consent');
23
+
24
+ // ── list ────────────────────────────────────────────────────────────────
25
+ cap
26
+ .command('list')
27
+ .description('Show the project platform + enabled drivers + the available driver catalog')
28
+ .action(() => {
29
+ try {
30
+ const cwd = process.cwd();
31
+ const profile = readCapabilities(cwd);
32
+ const catalog = loadDriverCatalog();
33
+ console.log('\n━━━ Capabilities ━━━');
34
+ console.log(` platform: ${profile.platform ?? '(none — run `sungen capability add web|mobile`)'}`);
35
+ console.log(` enabled: ${profile.enabled.length ? profile.enabled.join(', ') : '(none)'} [${profile.source}]`);
36
+ console.log('\n Available drivers (install with `sungen capability add <id>`):');
37
+ for (const d of Object.values(catalog)) {
38
+ const on = profile.enabled.includes(d.id) ? '●' : '○';
39
+ const unblocks = d.unblocks?.length ? ` unblocks ${d.unblocks.join('/')}` : '';
40
+ console.log(` ${on} ${d.id.padEnd(13)} [${d.kind}] ${d.package}${unblocks}`);
41
+ }
42
+ console.log('');
43
+ } catch (e) {
44
+ console.error('Error:', e instanceof Error ? e.message : e);
45
+ process.exit(1);
46
+ }
47
+ });
48
+
49
+ // ── plan (Planner — recommend-only, no install) ──────────────────────────
50
+ cap
51
+ .command('plan')
52
+ .description('Capability Planner: classify execution modes + @manual reasons (M1–M9) + recommend drivers (no install)')
53
+ .requiredOption('-s, --screen <name>', 'Screen or flow name')
54
+ .option('--json', 'Output the raw plan JSON')
55
+ .action((o: { screen: string; json?: boolean }) => {
56
+ try {
57
+ const dir = findScreenDir(o.screen);
58
+ if (!dir) throw new Error(`Screen/flow not found: qa/screens/${o.screen} or qa/flows/${o.screen}`);
59
+ const plan = buildPlan(dir, o.screen);
60
+
61
+ const outDir = path.join(process.cwd(), '.sungen', 'reports');
62
+ fs.mkdirSync(outDir, { recursive: true });
63
+ const outPath = path.join(outDir, `${o.screen}-plan.json`);
64
+ fs.writeFileSync(outPath, JSON.stringify(plan, null, 2), 'utf-8');
65
+ if (o.json) { console.log(JSON.stringify(plan, null, 2)); return; }
66
+
67
+ const L = console.log;
68
+ L(`\n━━━ Capability Plan: ${plan.screen} (${plan.total} scenarios) ━━━`);
69
+ L(` Execution modes: ${Object.entries(plan.modes).map(([m, n]) => `${m} ${n}`).join(' · ')}`);
70
+ const reasons = Object.entries(plan.byReason).sort().map(([c, n]) => `${c}×${n}`).join(' ') || '(no manual)';
71
+ L(` Manual by reason: ${reasons}`);
72
+ if (plan.modes['manual']) {
73
+ L(` capability-manual: ${plan.capabilityManual} (${plan.capabilityManualPct}%) judgment-manual: ${plan.judgmentManual} cross-screen→flow: ${plan.crossScreen}`);
74
+ }
75
+ if (plan.crossScreen) {
76
+ L(` Cross-screen (XS×${plan.crossScreen}): automate via a flow — \`/sungen:add-flow\` (not a single-screen driver gap).`);
77
+ }
78
+ if (plan.recommendations.length) {
79
+ L(' Recommendations (recommend-only — install with `sungen capability add`):');
80
+ for (const r of plan.recommendations) {
81
+ L(` • ${r.count} @manual:${r.reason} → enable \`${r.driver}\` (${r.pkg}) → would unblock ${r.count}`);
82
+ }
83
+ }
84
+ if (plan.keep.length) {
85
+ L(` Keep manual (judgment floor — do NOT automate): ${plan.keep.map((k) => `${k.code}×${k.count} (${MANUAL_REASONS[k.code].label})`).join(', ')}`);
86
+ }
87
+ if (plan.unclassified.length) {
88
+ L(` ⚠ Unclassified (add @manual:Mx or a clearer reason): ${plan.unclassified.length}`);
89
+ for (const u of plan.unclassified.slice(0, 8)) L(` - ${u.name}`);
90
+ }
91
+ L(`\n Report: ${path.relative(process.cwd(), outPath)}\n`);
92
+ } catch (e) {
93
+ console.error('Error:', e instanceof Error ? e.message : e);
94
+ process.exit(1);
95
+ }
96
+ });
97
+
98
+ // ── add (the consent action) ─────────────────────────────────────────────
99
+ cap
100
+ .command('add <driver>')
101
+ .description('Enable a driver: install its package (if external) + record it in qa/capabilities.yaml')
102
+ .option('--skip-install', 'Only record in the profile; do not run npm install')
103
+ .action((driver: string, o: { skipInstall?: boolean }) => {
104
+ try {
105
+ const cwd = process.cwd();
106
+ const meta = driverMeta(driver);
107
+ if (!meta) {
108
+ throw new Error(`Unknown driver "${driver}". Known: ${Object.keys(loadDriverCatalog()).join(', ')}`);
109
+ }
110
+ const adapterName = meta.adapter || meta.id;
111
+ const bundled = adapterRegistry.hasAdapter(adapterName); // e.g. `web` is bundled in Phase 2a
112
+
113
+ if (!bundled && !o.skipInstall) {
114
+ console.log(`📦 Installing ${meta.package} (dev dependency)...`);
115
+ const r = spawnSync('npm', ['install', '-D', meta.package], { stdio: 'inherit', shell: true });
116
+ if (r.status !== 0) throw new Error(`npm install -D ${meta.package} failed.`);
117
+ } else if (bundled) {
118
+ console.log(`✓ ${driver} is built-in (no install needed).`);
119
+ }
120
+
121
+ // Verify it can load (skip for bundled — already registered).
122
+ if (!bundled) {
123
+ adapterRegistry.loadDriver(adapterName, meta.package, cwd); // throws if not loadable
124
+ }
125
+
126
+ const profile = readCapabilities(cwd);
127
+ const enabled = Array.from(new Set([...profile.enabled, driver]));
128
+ const platform = meta.kind === 'platform' ? driver : profile.platform;
129
+ writeCapabilities(cwd, { platform, enabled });
130
+ console.log(`✓ capability "${driver}" enabled${meta.kind === 'platform' ? ` (platform=${driver})` : ''}.`);
131
+ console.log('');
132
+ } catch (e) {
133
+ console.error('Error:', e instanceof Error ? e.message : e);
134
+ process.exit(1);
135
+ }
136
+ });
137
+
138
+ // ── remove ─────────────────────────────────────────────────────────────
139
+ cap
140
+ .command('remove <driver>')
141
+ .description('Disable a driver in qa/capabilities.yaml (optionally uninstall the package)')
142
+ .option('--uninstall', 'Also run npm uninstall for the driver package')
143
+ .action((driver: string, o: { uninstall?: boolean }) => {
144
+ try {
145
+ const cwd = process.cwd();
146
+ const profile = readCapabilities(cwd);
147
+ const enabled = profile.enabled.filter((d) => d !== driver);
148
+ const platform = profile.platform === driver ? undefined : profile.platform;
149
+ writeCapabilities(cwd, { platform, enabled });
150
+ const meta = driverMeta(driver);
151
+ if (o.uninstall && meta && !adapterRegistry.hasAdapter(meta.adapter || meta.id)) {
152
+ spawnSync('npm', ['uninstall', meta.package], { stdio: 'inherit', shell: true });
153
+ }
154
+ console.log(`✓ capability "${driver}" disabled.`);
155
+ } catch (e) {
156
+ console.error('Error:', e instanceof Error ? e.message : e);
157
+ process.exit(1);
158
+ }
159
+ });
160
+ }
@@ -0,0 +1,97 @@
1
+ import { Command } from 'commander';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { buildFlowCheck } from '../../harness/flow-check';
5
+ import { buildFlowPlan } from '../../harness/flow-plan';
6
+
7
+ export function registerFlowCheckCommand(program: Command): void {
8
+ registerFlowPlan(program);
9
+ program
10
+ .command('flow-check')
11
+ .description('Flow first-class: verify screen→flow deferrals are really covered + surface the run-test contract')
12
+ .option('--flow <name>', 'Limit the run-test contract checks to one flow')
13
+ .option('--json', 'Output the raw JSON report')
14
+ .action((o: { flow?: string; json?: boolean }) => {
15
+ try {
16
+ const cwd = process.cwd();
17
+ const r = buildFlowCheck(cwd, o.flow);
18
+
19
+ const outDir = path.join(cwd, '.sungen', 'reports');
20
+ fs.mkdirSync(outDir, { recursive: true });
21
+ fs.writeFileSync(path.join(outDir, 'flow-check.json'), JSON.stringify(r, null, 2), 'utf-8');
22
+ if (o.json) { console.log(JSON.stringify(r, null, 2)); return; }
23
+
24
+ const L = console.log;
25
+ L('\n━━━ Flow Check ━━━');
26
+ L(` screens: ${r.screens.join(', ') || '(none)'} flows: ${r.flows.join(', ') || '(none)'}`);
27
+
28
+ L('\n ① Deferral integrity (screen → flow)');
29
+ if (!r.deferrals.length) L(' (no "deferred to a flow" @manual scenarios)');
30
+ for (const d of r.deferrals) {
31
+ const icon = d.verdict === 'covered' ? '✓' : d.verdict === 'shallow' ? '◐' : '✗';
32
+ const note = d.verdict === 'missing'
33
+ ? `NO flow covers "${d.targets.join('/')}" → false deferral (real manual gap)`
34
+ : `${d.verdict} by ${d.via}`;
35
+ L(` ${icon} ${d.screen} · ${d.scenario.slice(0, 60)} → ${note}`);
36
+ }
37
+
38
+ L('\n ② Run-test contract (cross-screen proofs)');
39
+ if (!r.contracts.length) L(' (no contract risks detected)');
40
+ for (const c of r.contracts) {
41
+ L(` ⚠ ${c.kind}${c.scenario ? ` · ${c.scenario.slice(0, 55)}` : ` · ${c.flow}`}\n ${c.message}`);
42
+ }
43
+
44
+ L(`\n Report: ${path.relative(cwd, path.join(outDir, 'flow-check.json'))}`);
45
+ if (r.missing > 0) {
46
+ L(`\n ✗ ${r.missing} false deferral(s) — these XS scenarios are NOT actually flow-covered. Add a flow or re-classify.\n`);
47
+ process.exit(2);
48
+ }
49
+ L('');
50
+ } catch (e) {
51
+ console.error('Error:', e instanceof Error ? e.message : e);
52
+ process.exit(1);
53
+ }
54
+ });
55
+ }
56
+
57
+ function registerFlowPlan(program: Command): void {
58
+ program
59
+ .command('flow-plan')
60
+ .description('Flow Capability Planner: decompose a flow into legs + selector readiness + contract + run-test plan')
61
+ .requiredOption('--flow <name>', 'Flow name')
62
+ .option('--json', 'Output the raw JSON plan')
63
+ .action((o: { flow: string; json?: boolean }) => {
64
+ try {
65
+ const cwd = process.cwd();
66
+ if (!fs.existsSync(path.join(cwd, 'qa', 'flows', o.flow))) throw new Error(`Flow not found: qa/flows/${o.flow}`);
67
+ const p = buildFlowPlan(cwd, o.flow);
68
+
69
+ const outDir = path.join(cwd, '.sungen', 'reports');
70
+ fs.mkdirSync(outDir, { recursive: true });
71
+ const outPath = path.join(outDir, `${o.flow}-flow-plan.json`);
72
+ fs.writeFileSync(outPath, JSON.stringify(p, null, 2), 'utf-8');
73
+ if (o.json) { console.log(JSON.stringify(p, null, 2)); return; }
74
+
75
+ const L = console.log;
76
+ L(`\n━━━ Flow Plan: ${p.flow} (${p.total} scenarios) ━━━`);
77
+ L(' Legs (screen → selector readiness):');
78
+ for (const leg of p.legs) {
79
+ const icon = leg.readiness === 'ready' ? '✓' : leg.readiness === 'partial' ? '◐' : '✗';
80
+ L(` ${icon} ${leg.screen.padEnd(10)} ${leg.scenarios} scenarios · ${leg.refs.length} refs · selectors ${leg.readiness.toUpperCase()} (${leg.haveKeys}/${leg.refs.length})`);
81
+ }
82
+ const reasons = Object.entries(p.byReason).sort().map(([c, n]) => `${c}×${n}`).join(' ') || '(none)';
83
+ L(` Manual by reason: ${reasons} capability-manual ${p.capabilityManual} judgment ${p.judgmentManual}`);
84
+ if (p.contracts.length) {
85
+ L(' Run-test contract:');
86
+ for (const c of p.contracts) L(` ⚠ ${c.kind}${c.scenario ? ` · ${c.scenario.slice(0, 50)}` : ''}`);
87
+ }
88
+ L(` Readiness: ${p.readiness === 'ready' ? '✓ READY' : '✗ NOT READY' + (p.missingLegs.length ? ` — missing: ${p.missingLegs.join(', ')}` : '')}`);
89
+ L(' Plan:');
90
+ p.plan.forEach((s, i) => L(` ${i + 1}. ${s}`));
91
+ L(`\n Report: ${path.relative(cwd, outPath)}\n`);
92
+ } catch (e) {
93
+ console.error('Error:', e instanceof Error ? e.message : e);
94
+ process.exit(1);
95
+ }
96
+ });
97
+ }
@@ -2,7 +2,9 @@ import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { CodeGenerator } from '../../generators/test-generator/code-generator';
5
+ import { adapterRegistry } from '../../generators/test-generator/adapters';
5
6
  import { scanTestDataSecrets } from '../../harness/secret-scan';
7
+ import { readCapabilities, writeCapabilities, driverMeta, loadDriverCatalog } from '../../harness/capability';
6
8
 
7
9
  /**
8
10
  * Find .feature files recursively in a directory
@@ -79,7 +81,7 @@ export function registerGenerateCommand(program: Command): void {
79
81
  .option('-s, --screen <name>', 'Generate tests for a specific screen')
80
82
  .option('--flow <name>', 'Generate tests for a specific flow')
81
83
  .option('--all', 'Generate tests for all screens and flows')
82
- .option('--framework <name>', 'Test framework (default: playwright)', 'playwright')
84
+ .option('--framework <name>', 'Override the platform driver (else read from qa/capabilities.yaml)')
83
85
  .option('--inline-data', 'Hardcode test data at compile time instead of runtime loading')
84
86
  .action(async (options) => {
85
87
  try {
@@ -122,9 +124,33 @@ export function registerGenerateCommand(program: Command): void {
122
124
  // Output directory
123
125
  const outputDir = path.join(process.cwd(), 'specs', 'generated');
124
126
 
127
+ // Phase 2a — resolve the PLATFORM driver (no silent default to a runtime).
128
+ // Order: --framework → qa/capabilities.yaml → back-compat web (scaffold + notice).
129
+ const cwd = process.cwd();
130
+ let platform = options.framework as string | undefined;
131
+ if (!platform) {
132
+ const cap = readCapabilities(cwd);
133
+ if (cap.platform) {
134
+ platform = cap.platform;
135
+ } else {
136
+ platform = 'web';
137
+ writeCapabilities(cwd, { platform: 'web', enabled: ['web'] });
138
+ console.log('ℹ️ No qa/capabilities.yaml — using platform "web" (Playwright) and scaffolding it.');
139
+ console.log(' Mobile/API projects: see `sungen capability list`.\n');
140
+ }
141
+ }
142
+ // Ensure the platform adapter is available; load an external driver if needed.
143
+ if (!adapterRegistry.hasAdapter(platform)) {
144
+ const meta = driverMeta(platform);
145
+ if (!meta) {
146
+ throw new Error(`Unknown platform "${platform}". Known: ${Object.keys(loadDriverCatalog()).join(', ')}`);
147
+ }
148
+ adapterRegistry.loadDriver(meta.adapter || platform, meta.package, cwd); // throws DriverNotInstalledError if missing
149
+ }
150
+
125
151
  // Create generator and compile
126
152
  const generator = new CodeGenerator({
127
- framework: options.framework || 'playwright',
153
+ framework: platform,
128
154
  screenName: screenName || flowName,
129
155
  verbose: program.opts().verbose,
130
156
  runtimeData: !options.inlineData,
@@ -31,7 +31,7 @@ export function registerScriptCheckCommand(program: Command): void {
31
31
  L('');
32
32
  L(`━━━ Script-check: ${name} — Gherkin ↔ Playwright 1:1 ━━━`);
33
33
  L('');
34
- L(` status: ${r.status === 'OK' ? '✓ IN SYNC' : '✗ ' + (r.drift === 'drift' ? 'DRIFT' : 'MISMATCH')}`);
34
+ L(` status: ${r.status === 'OK' ? '✓ IN SYNC' : '✗ ' + (r.bypass ? 'BYPASS' : r.drift === 'drift' ? 'DRIFT' : 'MISMATCH')}`);
35
35
  L(` scenarios: ${r.automatedScenarios} automated (+${r.manualScenarios} @manual) spec test() blocks: ${r.specTestBlocks} count-match: ${r.countMatch ? '✓' : '✗'}`);
36
36
  L(` drift: ${r.drift}`);
37
37
  if (r.driftHunks.length) { L(' differing lines (committed vs fresh regenerate):'); for (const h of r.driftHunks) L(h); }
package/src/cli/index.ts CHANGED
@@ -22,6 +22,8 @@ import { registerScriptCheckCommand } from './commands/script-check';
22
22
  import { registerTraceCommand } from './commands/trace';
23
23
  import { registerChallengeCommand } from './commands/challenge';
24
24
  import { registerBlindspotCommand } from './commands/blindspot';
25
+ import { registerCapabilityCommand } from './commands/capability';
26
+ import { registerFlowCheckCommand } from './commands/flow-check';
25
27
 
26
28
  // Read version from package.json so `--version` never drifts from the released version.
27
29
  const { version } = require('../../package.json') as { version: string };
@@ -56,6 +58,8 @@ async function main() {
56
58
  registerTraceCommand(program);
57
59
  registerChallengeCommand(program);
58
60
  registerBlindspotCommand(program);
61
+ registerCapabilityCommand(program);
62
+ registerFlowCheckCommand(program);
59
63
 
60
64
  await program.parseAsync(process.argv);
61
65
  }
@@ -22,6 +22,7 @@ export interface TestFileData {
22
22
  screenName?: string; // Screen name for TestDataLoader.load()
23
23
  featureFileName?: string; // Feature file name for TestDataLoader.load()
24
24
  isParallel?: boolean; // @parallel tag: fresh page per test (opt-out from serial default)
25
+ flowMode?: boolean; // flow: cross-screen → longer per-test timeout (bug #243)
25
26
  cleanup?: { overlay?: boolean; forms?: boolean; scroll?: boolean; storage?: boolean };
26
27
  backgroundSteps?: Array<{ comment?: string; code: string }>; // Raw background steps for serial beforeAll
27
28
  scenarios: string[];
@@ -1,5 +1,19 @@
1
+ import { createRequire } from 'module';
2
+ import * as path from 'path';
1
3
  import { TestGeneratorAdapter } from './adapter-interface';
2
4
 
5
+ /** Thrown when a platform/capability driver is selected but its package isn't installed. */
6
+ export class DriverNotInstalledError extends Error {
7
+ constructor(public adapterName: string, public packageName: string) {
8
+ super(
9
+ `No driver for "${adapterName}". Install it for this project:\n` +
10
+ ` sungen capability add ${adapterName}\n` +
11
+ ` (installs ${packageName}). Or keep affected scenarios @manual.`,
12
+ );
13
+ this.name = 'DriverNotInstalledError';
14
+ }
15
+ }
16
+
3
17
  /**
4
18
  * Adapter Registry
5
19
  * Manages available test framework adapters
@@ -50,6 +64,29 @@ export class AdapterRegistry {
50
64
  getAvailableAdapters(): string[] {
51
65
  return Array.from(this.adapters.keys());
52
66
  }
67
+
68
+ /**
69
+ * Load an EXTERNAL driver package (resolved from the PROJECT's node_modules) and
70
+ * let it register its adapter. Idempotent — a no-op if already registered.
71
+ * The driver package should export `activate(registry)`; some self-register on import.
72
+ * @throws DriverNotInstalledError when the package isn't installed in the project.
73
+ */
74
+ loadDriver(adapterName: string, packageName: string, cwd: string = process.cwd()): void {
75
+ const name = adapterName.toLowerCase();
76
+ if (this.adapters.has(name)) return;
77
+ const projectRequire = createRequire(path.join(cwd, 'package.json'));
78
+ let mod: any;
79
+ try {
80
+ mod = projectRequire(packageName);
81
+ } catch {
82
+ throw new DriverNotInstalledError(adapterName, packageName);
83
+ }
84
+ if (typeof mod?.activate === 'function') mod.activate(this);
85
+ if (!this.adapters.has(name)) {
86
+ // Package present but didn't register the expected adapter.
87
+ throw new DriverNotInstalledError(adapterName, packageName);
88
+ }
89
+ }
53
90
  }
54
91
 
55
92
  // Singleton instance
@@ -1,5 +1,5 @@
1
1
  export { TestGeneratorAdapter, TestFileData, ScenarioData, StepTemplateData, LocatorExpression } from './adapter-interface';
2
- export { AdapterRegistry, adapterRegistry } from './adapter-registry';
2
+ export { AdapterRegistry, adapterRegistry, DriverNotInstalledError } from './adapter-registry';
3
3
  export { PlaywrightAdapter } from './playwright/playwright-adapter';
4
4
 
5
5
  // Auto-register built-in adapters
@@ -7,3 +7,6 @@ import { adapterRegistry } from './adapter-registry';
7
7
  import { PlaywrightAdapter } from './playwright/playwright-adapter';
8
8
 
9
9
  adapterRegistry.register('playwright', () => new PlaywrightAdapter());
10
+ // Phase 2a: platform alias. `web` is the bundled Playwright adapter (back-compat
11
+ // baseline) until it is externalized to @sungen/driver-web in a later cut.
12
+ adapterRegistry.register('web', () => new PlaywrightAdapter());
@@ -13,6 +13,9 @@ const testData = TestDataLoader.load('{{screenName}}', '{{featureFileName}}');
13
13
 
14
14
  {{#if isParallel}}
15
15
  test.describe('{{featureName}}', () => {
16
+ {{#if flowMode}}
17
+ test.describe.configure({ timeout: 90_000 }); // cross-screen flow — allow for multi-page navigation
18
+ {{/if}}
16
19
  {{#if singleAuthRole}}
17
20
  test.use({ storageState: 'specs/.auth/{{singleAuthRole}}.json' });
18
21
 
@@ -65,6 +68,9 @@ test.describe('{{featureName}}', () => {
65
68
  });
66
69
  {{else}}
67
70
  test.describe.serial('{{featureName}}', () => {
71
+ {{#if flowMode}}
72
+ test.describe.configure({ timeout: 90_000 }); // cross-screen flow — allow for multi-page navigation
73
+ {{/if}}
68
74
  let page: Page;
69
75
  let context: BrowserContext;
70
76
 
@@ -176,8 +176,11 @@ export class CodeGenerator {
176
176
  this.options = options;
177
177
  this.screenName = options.screenName;
178
178
  this.stepMapper = new StepMapper(options);
179
- // Get adapter from registry (default: playwright)
180
- const frameworkName = options.framework || 'playwright';
179
+ // Get adapter from registry. Platform comes from the caller (resolved from
180
+ // qa/capabilities.yaml); default `web` = the bundled Playwright adapter
181
+ // (back-compat baseline). External platforms (e.g. mobile) are loaded by the
182
+ // command layer before construction; here we only resolve a registered adapter.
183
+ const frameworkName = options.framework || 'web';
181
184
  this.adapter = adapterRegistry.getAdapter(frameworkName);
182
185
 
183
186
  if (options.verbose) {
@@ -514,6 +517,7 @@ export class CodeGenerator {
514
517
  screenName: isFlowFeature ? `flows/${effectiveScreenName}` : effectiveScreenName,
515
518
  featureFileName: featureName,
516
519
  isParallel,
520
+ flowMode: isFlowFeature,
517
521
  cleanup,
518
522
  backgroundSteps,
519
523
  scenarios: needsGrouping ? [] : scenarios,
@@ -129,7 +129,9 @@ export const formPatterns: StepPattern[] = [
129
129
  data = { ...resolved, selectorRef: step.selectorRef };
130
130
  } else {
131
131
  const nativeSelectRoles = ['combobox', 'listbox', 'select'];
132
- const isNativeSelect = nativeSelectRoles.includes(resolved.role);
132
+ // A native <select> is detected by its ARIA role, or by an explicit `variant: native`
133
+ // on the selector entry (lets a CSS/#id-located select opt into .selectOption()).
134
+ const isNativeSelect = nativeSelectRoles.includes(resolved.role) || resolved.variant === 'native';
133
135
 
134
136
  if (isNativeSelect) {
135
137
  templateName = 'select-action';
@@ -39,6 +39,14 @@ export function transformToRuntimeData(code: string): string {
39
39
  }
40
40
  );
41
41
 
42
+ // Pass 3: Bare-identifier context — any marker left after the string/regex passes
43
+ // sits as a bare token in a numeric position (e.g. `toHaveCount(__marker__)` from the
44
+ // table/list count templates). testData.get() returns a string, so coerce with Number().
45
+ code = code.replace(
46
+ /__SUNGEN_TD_([A-Za-z0-9_]+)__/g,
47
+ (_, enc) => `Number(testData.get('${decodeKey(enc)}'))`
48
+ );
49
+
42
50
  return code;
43
51
  }
44
52
 
@@ -312,12 +312,19 @@ export class SelectorResolver {
312
312
  const key = SelectorResolver.generateKey(label);
313
313
  const selectorFile = this.loadNewSelectorFile(featureName);
314
314
 
315
+ // Flow refs are namespaced "Screen:Element". The namespace is ONLY for selector-key
316
+ // lookup (key keeps it) — it is NOT part of the DOM accessible name. Strip it for any
317
+ // name/label fallback (auto-infer or entry without an explicit `name`). Bug #243.
318
+ const displayLabel = this.flowMode && label.includes(':')
319
+ ? label.slice(label.indexOf(':') + 1).trim()
320
+ : label;
321
+
315
322
  // Try nth-suffixed key first: hay.gui.loi.cam.on--3
316
323
  if (nth && nth > 0) {
317
324
  const nthKey = `${key}--${nth}`;
318
325
  const nthEntry = selectorFile[nthKey];
319
326
  if (nthEntry) {
320
- return this.resolveFromEntry(nthEntry, label);
327
+ return this.resolveFromEntry(nthEntry, displayLabel);
321
328
  }
322
329
  }
323
330
 
@@ -327,7 +334,7 @@ export class SelectorResolver {
327
334
  const typedKey = `${key}--${normalizedType}`;
328
335
  const typedEntry = selectorFile[typedKey];
329
336
  if (typedEntry) {
330
- return this.resolveFromEntry(typedEntry, label);
337
+ return this.resolveFromEntry(typedEntry, displayLabel);
331
338
  }
332
339
 
333
340
  // If normalized differs from original, also try original as-is
@@ -335,7 +342,7 @@ export class SelectorResolver {
335
342
  const originalKey = `${key}--${elementType}`;
336
343
  const originalEntry = selectorFile[originalKey];
337
344
  if (originalEntry) {
338
- return this.resolveFromEntry(originalEntry, label);
345
+ return this.resolveFromEntry(originalEntry, displayLabel);
339
346
  }
340
347
  }
341
348
  }
@@ -343,12 +350,12 @@ export class SelectorResolver {
343
350
  // Fallback to base key
344
351
  const entry = selectorFile[key];
345
352
  if (entry) {
346
- return this.resolveFromEntry(entry, label);
353
+ return this.resolveFromEntry(entry, displayLabel);
347
354
  }
348
355
 
349
356
  // Auto-infer locator from elementType when no exact YAML entry exists
350
357
  if (elementType) {
351
- const inferred = SelectorResolver.inferFromElementType(elementType, label);
358
+ const inferred = SelectorResolver.inferFromElementType(elementType, displayLabel);
352
359
  if (inferred) {
353
360
  return inferred;
354
361
  }
@@ -357,7 +364,7 @@ export class SelectorResolver {
357
364
  // Last resort: find any suffixed key matching key--*
358
365
  const suffixedKeys = Object.keys(selectorFile).filter(k => k.startsWith(`${key}--`));
359
366
  if (suffixedKeys.length > 0) {
360
- return this.resolveFromEntry(selectorFile[suffixedKeys[0]], label);
367
+ return this.resolveFromEntry(selectorFile[suffixedKeys[0]], displayLabel);
361
368
  }
362
369
 
363
370
  const tried: string[] = [];