@sun-asterisk/sungen 3.0.0-beta.71 → 3.0.0-beta.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/capability.d.ts +3 -0
- package/dist/cli/commands/capability.d.ts.map +1 -0
- package/dist/cli/commands/capability.js +196 -0
- package/dist/cli/commands/capability.js.map +1 -0
- package/dist/cli/commands/flow-check.d.ts +3 -0
- package/dist/cli/commands/flow-check.d.ts.map +1 -0
- package/dist/cli/commands/flow-check.js +136 -0
- package/dist/cli/commands/flow-check.js.map +1 -0
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +28 -2
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/script-check.js +1 -1
- package/dist/cli/commands/script-check.js.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +1 -0
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-registry.d.ts +13 -0
- package/dist/generators/test-generator/adapters/adapter-registry.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-registry.js +73 -1
- package/dist/generators/test-generator/adapters/adapter-registry.js.map +1 -1
- package/dist/generators/test-generator/adapters/index.d.ts +1 -1
- package/dist/generators/test-generator/adapters/index.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/index.js +5 -1
- package/dist/generators/test-generator/adapters/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/test-file.hbs +6 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +6 -2
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/patterns/form-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/form-patterns.js +3 -1
- package/dist/generators/test-generator/patterns/form-patterns.js.map +1 -1
- package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/runtime-data-transformer.js +4 -0
- package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +12 -6
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/harness/capability-plan.d.ts +49 -0
- package/dist/harness/capability-plan.d.ts.map +1 -0
- package/dist/harness/capability-plan.js +215 -0
- package/dist/harness/capability-plan.js.map +1 -0
- package/dist/harness/capability.d.ts +23 -0
- package/dist/harness/capability.d.ts.map +1 -0
- package/dist/harness/capability.js +98 -0
- package/dist/harness/capability.js.map +1 -0
- package/dist/harness/catalog/drivers.yaml +57 -0
- package/dist/harness/flow-check.d.ts +23 -0
- package/dist/harness/flow-check.d.ts.map +1 -0
- package/dist/harness/flow-check.js +132 -0
- package/dist/harness/flow-check.js.map +1 -0
- package/dist/harness/flow-plan.d.ts +23 -0
- package/dist/harness/flow-plan.d.ts.map +1 -0
- package/dist/harness/flow-plan.js +166 -0
- package/dist/harness/flow-plan.js.map +1 -0
- package/dist/harness/script-check.d.ts +23 -0
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +88 -6
- package/dist/harness/script-check.js.map +1 -1
- package/dist/orchestrator/project-initializer.d.ts +5 -0
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +20 -0
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/specs-test-data.ts +11 -6
- package/package.json +3 -2
- package/src/cli/commands/capability.ts +160 -0
- package/src/cli/commands/flow-check.ts +97 -0
- package/src/cli/commands/generate.ts +28 -2
- package/src/cli/commands/script-check.ts +1 -1
- package/src/cli/index.ts +4 -0
- package/src/generators/test-generator/adapters/adapter-interface.ts +1 -0
- package/src/generators/test-generator/adapters/adapter-registry.ts +37 -0
- package/src/generators/test-generator/adapters/index.ts +4 -1
- package/src/generators/test-generator/adapters/playwright/templates/test-file.hbs +6 -0
- package/src/generators/test-generator/code-generator.ts +6 -2
- package/src/generators/test-generator/patterns/form-patterns.ts +3 -1
- package/src/generators/test-generator/utils/runtime-data-transformer.ts +8 -0
- package/src/generators/test-generator/utils/selector-resolver.ts +13 -6
- package/src/harness/capability-plan.ts +180 -0
- package/src/harness/capability.ts +75 -0
- package/src/harness/catalog/drivers.yaml +57 -0
- package/src/harness/flow-check.ts +99 -0
- package/src/harness/flow-plan.ts +135 -0
- package/src/harness/script-check.ts +79 -6
- package/src/orchestrator/project-initializer.ts +23 -0
- 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>', '
|
|
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:
|
|
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 (
|
|
180
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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]],
|
|
367
|
+
return this.resolveFromEntry(selectorFile[suffixedKeys[0]], displayLabel);
|
|
361
368
|
}
|
|
362
369
|
|
|
363
370
|
const tried: string[] = [];
|