@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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability Planner (Phase 2b) — deterministic, recommend-only.
|
|
3
|
+
*
|
|
4
|
+
* Classifies each scenario's execution mode + each @manual case by reason code
|
|
5
|
+
* (M1–M9), maps capability-reasons to drivers, and emits the manual-reason KPI.
|
|
6
|
+
* Never installs anything (that's `sungen capability add`). See
|
|
7
|
+
* reports/sungen_phase2b_spec.md.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { loadDriverCatalog } from './capability';
|
|
12
|
+
|
|
13
|
+
export type ReasonClass = 'capability' | 'keep' | 'flow';
|
|
14
|
+
export interface ReasonDef { code: string; label: string; cls: ReasonClass; drivers: string[] }
|
|
15
|
+
|
|
16
|
+
export const MANUAL_REASONS: Record<string, ReasonDef> = {
|
|
17
|
+
M1: { code: 'M1', label: 'Missing data setup', cls: 'capability', drivers: ['data-factory'] },
|
|
18
|
+
M2: { code: 'M2', label: 'Missing API/DB assertion', cls: 'capability', drivers: ['api', 'db'] },
|
|
19
|
+
M3: { code: 'M3', label: 'Missing mock/network control', cls: 'capability', drivers: ['mock'] },
|
|
20
|
+
M4: { code: 'M4', label: 'Missing stable selector/test-id', cls: 'capability', drivers: [] }, // locator contract, no driver
|
|
21
|
+
M5: { code: 'M5', label: 'External dependency', cls: 'capability', drivers: ['mail-file', 'contract'] },
|
|
22
|
+
M6: { code: 'M6', label: 'Visual / UX judgment', cls: 'keep', drivers: ['specialized'] },
|
|
23
|
+
M7: { code: 'M7', label: 'Environment limitation', cls: 'capability', drivers: [] }, // testability, no driver
|
|
24
|
+
M8: { code: 'M8', label: 'Not worth automating', cls: 'keep', drivers: [] },
|
|
25
|
+
M9: { code: 'M9', label: 'True human judgment', cls: 'keep', drivers: [] },
|
|
26
|
+
// Not a single-screen driver gap: automatable cross-screen via a flow (/sungen:add-flow).
|
|
27
|
+
XS: { code: 'XS', label: 'Cross-screen (automate via flow)', cls: 'flow', drivers: [] },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Keyword inference (best-effort retrofit). Order matters — first match wins.
|
|
31
|
+
// Cross-screen "deferred to a flow" is checked FIRST: it is not a driver gap on
|
|
32
|
+
// this screen, it is handled by a flow — so it must not recommend a driver.
|
|
33
|
+
const INFER: { code: string; re: RegExp }[] = [
|
|
34
|
+
{ code: 'XS', re: /\b(deferred to a flow|cross[-\s]?screen|in a flow|via a flow)\b/i },
|
|
35
|
+
{ code: 'M4', re: /\b(selector|test[-\s]?id|locator|data-testid)\b/i },
|
|
36
|
+
{ code: 'M3', re: /\b(mock|stub|network|offline|slow network|intercept)\b/i },
|
|
37
|
+
{ code: 'M2', re: /\b(api|endpoint|backend|db|database|server[-\s]?side|via api)\b/i },
|
|
38
|
+
{ code: 'M1', re: /\b(data setup|dataset|seed|test data|empty (category|product|dataset|state)|zero products|forcing an empty|backend\/test data)\b/i },
|
|
39
|
+
{ code: 'M5', re: /\b(external|third[-\s]?party|sandbox|email|mail|payment gateway|invoice|download)\b/i },
|
|
40
|
+
{ code: 'M6', re: /\b(visual|responsive|layout|accessibilit|a11y|keyboard|screen reader|ux|breakpoint)\b/i },
|
|
41
|
+
{ code: 'M7', re: /\b(environment|staging[-\s]?only|infra|env limitation)\b/i },
|
|
42
|
+
{ code: 'M8', re: /\b(not worth|exploratory|one[-\s]?off)\b/i },
|
|
43
|
+
{ code: 'M9', re: /\b(judgment|human|subjective|manual review)\b/i },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
interface ParsedScenario { name: string; tags: string[]; manual: boolean; reason: string }
|
|
47
|
+
|
|
48
|
+
/** Parse scenarios with their tags + the reason comment line above (for @manual). */
|
|
49
|
+
export function parseScenarios(featurePath: string): ParsedScenario[] {
|
|
50
|
+
if (!fs.existsSync(featurePath)) return [];
|
|
51
|
+
const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
|
|
52
|
+
const out: ParsedScenario[] = [];
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const m = lines[i].match(/^\s*Scenario:\s*(.+)$/);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
const tags: string[] = [];
|
|
57
|
+
let reason = '';
|
|
58
|
+
// Scan UP over the ADJACENT block (tag line + a directly-above reason comment).
|
|
59
|
+
// Stop at a blank line so section-divider comments further up aren't captured.
|
|
60
|
+
for (let j = i - 1; j >= 0 && j >= i - 6; j--) {
|
|
61
|
+
const l = lines[j].trim();
|
|
62
|
+
if (l === '') break;
|
|
63
|
+
if (/^@/.test(l)) tags.unshift(...l.split(/\s+/).filter((t) => t.startsWith('@')));
|
|
64
|
+
else if (/^#/.test(l)) { if (!reason) reason = l.replace(/^#+\s*/, ''); }
|
|
65
|
+
else break;
|
|
66
|
+
}
|
|
67
|
+
// Scan DOWN for the reason comment placed as the first line inside the body.
|
|
68
|
+
for (let k = i + 1; k < lines.length && k <= i + 4; k++) {
|
|
69
|
+
const l = lines[k].trim();
|
|
70
|
+
if (/^#/.test(l)) { if (!reason) reason = l.replace(/^#+\s*/, ''); break; }
|
|
71
|
+
else if (l === '') continue;
|
|
72
|
+
else break; // a real step → stop
|
|
73
|
+
}
|
|
74
|
+
out.push({ name: m[1].trim(), tags, manual: tags.some((t) => /^@manual\b/i.test(t)), reason });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function explicitCode(tags: string[]): string | undefined {
|
|
80
|
+
for (const t of tags) {
|
|
81
|
+
const m = t.match(/^@(?:manual|reason):(M[1-9])$/i);
|
|
82
|
+
if (m) return m[1].toUpperCase();
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function inferReasonCode(tags: string[], reason: string): { code: string; explicit: boolean; unclassified: boolean } {
|
|
88
|
+
const ex = explicitCode(tags);
|
|
89
|
+
if (ex) return { code: ex, explicit: true, unclassified: false };
|
|
90
|
+
for (const r of INFER) if (r.re.test(reason)) return { code: r.code, explicit: false, unclassified: false };
|
|
91
|
+
return { code: 'M9', explicit: false, unclassified: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function classifyMode(tags: string[]): string {
|
|
95
|
+
const has = (re: RegExp) => tags.some((t) => re.test(t));
|
|
96
|
+
if (has(/^@manual\b/i)) return 'manual';
|
|
97
|
+
if (has(/^@hybrid$/i)) return 'hybrid';
|
|
98
|
+
if (has(/^@(api|apiassert)$/i)) return 'api';
|
|
99
|
+
if (has(/^@(mock|network)$/i)) return 'mock';
|
|
100
|
+
if (has(/^@dbassert$/i)) return 'dbAssert';
|
|
101
|
+
return 'ui';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface CapabilityPlan {
|
|
105
|
+
screen: string;
|
|
106
|
+
total: number;
|
|
107
|
+
modes: Record<string, number>;
|
|
108
|
+
byReason: Record<string, number>;
|
|
109
|
+
unclassified: { name: string; reason: string }[];
|
|
110
|
+
capabilityManual: number;
|
|
111
|
+
judgmentManual: number;
|
|
112
|
+
crossScreen: number; // automatable via a flow — not a single-screen driver gap
|
|
113
|
+
capabilityManualPct: number;
|
|
114
|
+
recommendations: { driver: string; pkg: string; reason: string; count: number; scenarios: string[] }[];
|
|
115
|
+
keep: { code: string; count: number }[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildPlan(screenDir: string, screenName: string): CapabilityPlan {
|
|
119
|
+
const featurePath = path.join(screenDir, 'features', `${screenName}.feature`);
|
|
120
|
+
const scenarios = parseScenarios(featurePath);
|
|
121
|
+
const catalog = loadDriverCatalog();
|
|
122
|
+
|
|
123
|
+
const modes: Record<string, number> = {};
|
|
124
|
+
const byReason: Record<string, number> = {};
|
|
125
|
+
const unclassified: CapabilityPlan['unclassified'] = [];
|
|
126
|
+
// driver → {reasonCode, scenarios}
|
|
127
|
+
const recByDriver = new Map<string, { reason: string; scenarios: string[] }>();
|
|
128
|
+
const keepCount: Record<string, number> = {};
|
|
129
|
+
let capabilityManual = 0, judgmentManual = 0, crossScreen = 0;
|
|
130
|
+
|
|
131
|
+
for (const s of scenarios) {
|
|
132
|
+
const mode = classifyMode(s.tags);
|
|
133
|
+
modes[mode] = (modes[mode] || 0) + 1;
|
|
134
|
+
if (mode !== 'manual') continue;
|
|
135
|
+
|
|
136
|
+
const { code, unclassified: unc } = inferReasonCode(s.tags, s.reason);
|
|
137
|
+
byReason[code] = (byReason[code] || 0) + 1;
|
|
138
|
+
if (unc) unclassified.push({ name: s.name, reason: s.reason || '(no reason comment)' });
|
|
139
|
+
|
|
140
|
+
const def = MANUAL_REASONS[code];
|
|
141
|
+
if (def.cls === 'flow') {
|
|
142
|
+
crossScreen++;
|
|
143
|
+
} else if (def.cls === 'capability') {
|
|
144
|
+
capabilityManual++;
|
|
145
|
+
for (const d of def.drivers) {
|
|
146
|
+
const cur = recByDriver.get(d) || { reason: code, scenarios: [] };
|
|
147
|
+
cur.scenarios.push(s.name);
|
|
148
|
+
recByDriver.set(d, cur);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
judgmentManual++;
|
|
152
|
+
keepCount[code] = (keepCount[code] || 0) + 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const recommendations = [...recByDriver.entries()]
|
|
157
|
+
.map(([driver, v]) => ({
|
|
158
|
+
driver,
|
|
159
|
+
pkg: catalog[driver]?.package || `@sungen/driver-${driver}`,
|
|
160
|
+
reason: v.reason,
|
|
161
|
+
count: v.scenarios.length,
|
|
162
|
+
scenarios: v.scenarios,
|
|
163
|
+
}))
|
|
164
|
+
.sort((a, b) => b.count - a.count);
|
|
165
|
+
|
|
166
|
+
const manualTotal = modes['manual'] || 0;
|
|
167
|
+
return {
|
|
168
|
+
screen: screenName,
|
|
169
|
+
total: scenarios.length,
|
|
170
|
+
modes,
|
|
171
|
+
byReason,
|
|
172
|
+
unclassified,
|
|
173
|
+
capabilityManual,
|
|
174
|
+
judgmentManual,
|
|
175
|
+
crossScreen,
|
|
176
|
+
capabilityManualPct: manualTotal ? Math.round((capabilityManual / manualTotal) * 100) : 0,
|
|
177
|
+
recommendations,
|
|
178
|
+
keep: Object.entries(keepCount).map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability profile + driver catalog (harness-roadmap Phase 2a).
|
|
3
|
+
*
|
|
4
|
+
* Sungen core is runtime-agnostic: a project declares its PLATFORM driver (web →
|
|
5
|
+
* Playwright, mobile → Appium) + any capability drivers in qa/capabilities.yaml.
|
|
6
|
+
* There is no default runtime — but for back-compat, an ABSENT profile is treated
|
|
7
|
+
* as `web` (the bundled adapter) with a one-time notice + scaffold.
|
|
8
|
+
*
|
|
9
|
+
* This module is metadata + profile I/O only. It never installs or imports a driver.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
14
|
+
|
|
15
|
+
export interface CapabilityProfile {
|
|
16
|
+
platform?: string; // web | mobile | … (the runtime adapter)
|
|
17
|
+
enabled: string[]; // driver ids enabled for this project
|
|
18
|
+
source: 'capabilities.yaml' | 'absent';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DriverMeta {
|
|
22
|
+
id: string;
|
|
23
|
+
kind: 'platform' | 'capability';
|
|
24
|
+
package: string;
|
|
25
|
+
runtime?: string;
|
|
26
|
+
adapter?: string; // registry adapter name (defaults to id)
|
|
27
|
+
capabilities: string[];
|
|
28
|
+
unblocks?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function capabilitiesPath(cwd: string): string {
|
|
32
|
+
return path.join(cwd, 'qa', 'capabilities.yaml');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readCapabilities(cwd: string): CapabilityProfile {
|
|
36
|
+
const p = capabilitiesPath(cwd);
|
|
37
|
+
if (!fs.existsSync(p)) return { platform: undefined, enabled: [], source: 'absent' };
|
|
38
|
+
try {
|
|
39
|
+
const y = parseYaml(fs.readFileSync(p, 'utf-8')) || {};
|
|
40
|
+
return {
|
|
41
|
+
platform: typeof y.platform === 'string' ? y.platform : undefined,
|
|
42
|
+
enabled: Array.isArray(y.enabled) ? y.enabled.map(String) : [],
|
|
43
|
+
source: 'capabilities.yaml',
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return { platform: undefined, enabled: [], source: 'absent' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function writeCapabilities(cwd: string, profile: { platform?: string; enabled: string[] }): string {
|
|
51
|
+
const p = capabilitiesPath(cwd);
|
|
52
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
53
|
+
const body =
|
|
54
|
+
'# Sungen capability profile — which runtime/drivers this project uses.\n' +
|
|
55
|
+
'# platform: the runtime adapter (web → Playwright, mobile → Appium). No default.\n' +
|
|
56
|
+
'# enabled: drivers turned on (added via `sungen capability add <driver>`).\n\n' +
|
|
57
|
+
stringifyYaml({ platform: profile.platform, enabled: profile.enabled });
|
|
58
|
+
fs.writeFileSync(p, body, 'utf-8');
|
|
59
|
+
return p;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let _catalog: Record<string, DriverMeta> | null = null;
|
|
63
|
+
export function loadDriverCatalog(): Record<string, DriverMeta> {
|
|
64
|
+
if (_catalog) return _catalog;
|
|
65
|
+
const p = path.join(__dirname, 'catalog', 'drivers.yaml');
|
|
66
|
+
const y = parseYaml(fs.readFileSync(p, 'utf-8')) as { drivers: Record<string, Omit<DriverMeta, 'id'>> };
|
|
67
|
+
const out: Record<string, DriverMeta> = {};
|
|
68
|
+
for (const [id, meta] of Object.entries(y.drivers)) out[id] = { id, ...meta };
|
|
69
|
+
_catalog = out;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function driverMeta(id: string): DriverMeta | undefined {
|
|
74
|
+
return loadDriverCatalog()[id];
|
|
75
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Driver Catalog (metadata only — NO driver code is bundled here).
|
|
2
|
+
# Lets Sungen RECOMMEND/RESOLVE a driver that may not be installed yet, and tells
|
|
3
|
+
# `sungen capability add` which package to install. See reports/sungen_phase2a_spec.md.
|
|
4
|
+
#
|
|
5
|
+
# kind: platform → the runtime/codegen adapter for a target (pick ONE per project)
|
|
6
|
+
# kind: capability → an extra ability added on top of a platform (Phase 3)
|
|
7
|
+
# unblocks: manual-reason codes (M1–M9) this driver can resolve (Phase 2b taxonomy)
|
|
8
|
+
|
|
9
|
+
drivers:
|
|
10
|
+
web:
|
|
11
|
+
kind: platform
|
|
12
|
+
package: "@sungen/driver-web" # Phase 2a: bundled Playwright adapter serves this (back-compat)
|
|
13
|
+
runtime: playwright
|
|
14
|
+
adapter: web # registry adapter name
|
|
15
|
+
capabilities: ["@ui"]
|
|
16
|
+
mobile:
|
|
17
|
+
kind: platform
|
|
18
|
+
package: "@sungen/driver-mobile"
|
|
19
|
+
runtime: appium
|
|
20
|
+
adapter: mobile
|
|
21
|
+
capabilities: ["@ui"]
|
|
22
|
+
|
|
23
|
+
api:
|
|
24
|
+
kind: capability
|
|
25
|
+
package: "@sungen/driver-api"
|
|
26
|
+
capabilities: ["@api", "@apiAssert", "@hybrid"]
|
|
27
|
+
unblocks: [M2]
|
|
28
|
+
data-factory:
|
|
29
|
+
kind: capability
|
|
30
|
+
package: "@sungen/driver-data-factory"
|
|
31
|
+
capabilities: ["@dataFactory"]
|
|
32
|
+
unblocks: [M1]
|
|
33
|
+
db:
|
|
34
|
+
kind: capability
|
|
35
|
+
package: "@sungen/driver-db"
|
|
36
|
+
capabilities: ["@dbAssert"]
|
|
37
|
+
unblocks: [M2]
|
|
38
|
+
mock:
|
|
39
|
+
kind: capability
|
|
40
|
+
package: "@sungen/driver-mock"
|
|
41
|
+
capabilities: ["@mock", "@network"]
|
|
42
|
+
unblocks: [M3]
|
|
43
|
+
mail-file:
|
|
44
|
+
kind: capability
|
|
45
|
+
package: "@sungen/driver-mail-file"
|
|
46
|
+
capabilities: ["@mail", "@file"]
|
|
47
|
+
unblocks: [M5]
|
|
48
|
+
contract:
|
|
49
|
+
kind: capability
|
|
50
|
+
package: "@sungen/driver-contract"
|
|
51
|
+
capabilities: ["@contract"]
|
|
52
|
+
unblocks: [M5]
|
|
53
|
+
specialized:
|
|
54
|
+
kind: capability
|
|
55
|
+
package: "@sungen/driver-specialized"
|
|
56
|
+
capabilities: ["@specialized"]
|
|
57
|
+
unblocks: [M6]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow first-class checks (flow_firstclass_spec).
|
|
3
|
+
*
|
|
4
|
+
* A. Deferral integrity — every screen `@manual` "deferred to a flow (X -> Y)" must
|
|
5
|
+
* be backed by a real flow scenario that covers Y (deeply). Otherwise the
|
|
6
|
+
* cross-screen (XS) manual is a hidden gap, not "flow-covered".
|
|
7
|
+
* B. Run-test contract — surface the fragile cross-screen patterns (same-card
|
|
8
|
+
* identity scope, shared-state isolation under @parallel) as explicit checks.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic + read-only. No test execution, no selector resolution.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { loadScenarios } from './parse';
|
|
15
|
+
import { parseScenarios } from './capability-plan';
|
|
16
|
+
|
|
17
|
+
const KNOWN_TARGETS = ['cart', 'category', 'brand', 'detail', 'checkout', 'wishlist', 'order', 'payment', 'login'];
|
|
18
|
+
|
|
19
|
+
export interface Deferral { screen: string; scenario: string; hint: string; targets: string[]; verdict: 'covered' | 'shallow' | 'missing'; via?: string }
|
|
20
|
+
export interface Contract { flow: string; kind: 'SAME-CARD' | 'ISOLATION'; scenario?: string; message: string }
|
|
21
|
+
export interface FlowCheckReport {
|
|
22
|
+
screens: string[]; flows: string[];
|
|
23
|
+
deferrals: Deferral[];
|
|
24
|
+
contracts: Contract[];
|
|
25
|
+
missing: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function listDirs(p: string): string[] {
|
|
29
|
+
return fs.existsSync(p) ? fs.readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) : [];
|
|
30
|
+
}
|
|
31
|
+
function featurePath(base: string, kind: 'screens' | 'flows', name: string): string {
|
|
32
|
+
return path.join(base, 'qa', kind, name, 'features', `${name}.feature`);
|
|
33
|
+
}
|
|
34
|
+
function featureTags(fp: string): string[] {
|
|
35
|
+
if (!fs.existsSync(fp)) return [];
|
|
36
|
+
for (const line of fs.readFileSync(fp, 'utf-8').split('\n')) {
|
|
37
|
+
const t = line.trim();
|
|
38
|
+
if (t.startsWith('Feature:')) break;
|
|
39
|
+
if (t.startsWith('@')) return t.split(/\s+/).filter((x) => x.startsWith('@'));
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
function targetsFromHint(hint: string): string[] {
|
|
44
|
+
const h = hint.toLowerCase();
|
|
45
|
+
const hits = KNOWN_TARGETS.filter((k) => h.includes(k));
|
|
46
|
+
if (hits.length) return hits;
|
|
47
|
+
// fallback: significant words after "->"
|
|
48
|
+
const after = h.split('->').pop() || h;
|
|
49
|
+
return after.split(/[^a-z]+/).filter((w) => w.length > 3 && !['home', 'page', 'flow', 'products', 'product', 'result'].includes(w));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildFlowCheck(cwd: string, onlyFlow?: string): FlowCheckReport {
|
|
53
|
+
const screens = listDirs(path.join(cwd, 'qa', 'screens'));
|
|
54
|
+
const flows = (onlyFlow ? [onlyFlow] : listDirs(path.join(cwd, 'qa', 'flows')));
|
|
55
|
+
|
|
56
|
+
// Index flow scenarios (name + haystack + depth).
|
|
57
|
+
const flowScenarios: { flow: string; name: string; haystack: string; deep: boolean }[] = [];
|
|
58
|
+
for (const f of listDirs(path.join(cwd, 'qa', 'flows'))) {
|
|
59
|
+
for (const s of loadScenarios(featurePath(cwd, 'flows', f))) {
|
|
60
|
+
flowScenarios.push({ flow: f, name: s.name, haystack: s.haystack, deep: s.hasDataAssertion });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A. Deferral integrity (screens).
|
|
65
|
+
const deferrals: Deferral[] = [];
|
|
66
|
+
for (const sc of screens) {
|
|
67
|
+
for (const s of parseScenarios(featurePath(cwd, 'screens', sc))) {
|
|
68
|
+
if (!s.manual || !/deferred to a flow/i.test(s.reason)) continue;
|
|
69
|
+
const targets = targetsFromHint(s.reason);
|
|
70
|
+
const matches = flowScenarios.filter((fs2) => targets.some((t) => fs2.haystack.includes(t)));
|
|
71
|
+
let verdict: Deferral['verdict'] = 'missing';
|
|
72
|
+
let via: string | undefined;
|
|
73
|
+
if (matches.some((m) => m.deep)) { verdict = 'covered'; via = matches.find((m) => m.deep)!.flow; }
|
|
74
|
+
else if (matches.length) { verdict = 'shallow'; via = matches[0].flow; }
|
|
75
|
+
deferrals.push({ screen: sc, scenario: s.name, hint: s.reason, targets, verdict, via });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// B. Run-test contract (per flow).
|
|
80
|
+
const contracts: Contract[] = [];
|
|
81
|
+
for (const f of flows) {
|
|
82
|
+
const fp = featurePath(cwd, 'flows', f);
|
|
83
|
+
const parallel = featureTags(fp).some((t) => /^@parallel$/i.test(t));
|
|
84
|
+
const scs = loadScenarios(fp);
|
|
85
|
+
let cartStateCount = 0;
|
|
86
|
+
for (const s of scs) {
|
|
87
|
+
const h = s.haystack;
|
|
88
|
+
if (/\bremember\b/.test(h) && /(add to cart|view product)/.test(h)) {
|
|
89
|
+
contracts.push({ flow: f, kind: 'SAME-CARD', scenario: s.name, message: 'remember + Add To Cart/View Product → scope to ONE card at run-test, else the identity proof is hollow.' });
|
|
90
|
+
}
|
|
91
|
+
if (/(add to cart|remove|\bcart\b|quantity)/.test(h)) cartStateCount++;
|
|
92
|
+
}
|
|
93
|
+
if (parallel && cartStateCount >= 2) {
|
|
94
|
+
contracts.push({ flow: f, kind: 'ISOLATION', message: `@parallel + ${cartStateCount} cart/state scenarios → give each a fresh browser context, else count/quantity asserts are flaky.` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { screens, flows, deferrals, contracts, missing: deferrals.filter((d) => d.verdict === 'missing').length };
|
|
99
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow Capability Planner (Phase 2c) — flow-aware, deterministic, recommend-only.
|
|
3
|
+
*
|
|
4
|
+
* Decomposes a flow into per-screen LEGS (from [Screen:Element] refs), reports each
|
|
5
|
+
* leg's SELECTOR READINESS + capability, folds in the manual-reason taxonomy
|
|
6
|
+
* (capability-plan) and the run-test contract (flow-check), and emits a run-test
|
|
7
|
+
* PLAN. Automates the manual diagnosis done while healing cart-and-filter.
|
|
8
|
+
* See reports/sungen_phase2c_spec.md.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import { parse as parseYaml } from 'yaml';
|
|
13
|
+
import { MANUAL_REASONS, inferReasonCode } from './capability-plan';
|
|
14
|
+
import { buildFlowCheck, Contract } from './flow-check';
|
|
15
|
+
|
|
16
|
+
const REF_RE = /\[([A-Za-z][\w ]*?):([\w &.-]+?)\]/g;
|
|
17
|
+
|
|
18
|
+
interface FlowScenario { name: string; manual: boolean; reason: string; tags: string[]; refs: { screen: string; element: string }[] }
|
|
19
|
+
|
|
20
|
+
function parseFlowScenarios(featurePath: string): FlowScenario[] {
|
|
21
|
+
if (!fs.existsSync(featurePath)) return [];
|
|
22
|
+
const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
|
|
23
|
+
const out: FlowScenario[] = [];
|
|
24
|
+
const idx: number[] = [];
|
|
25
|
+
lines.forEach((l, i) => { if (/^\s*Scenario:/.test(l)) idx.push(i); });
|
|
26
|
+
for (let s = 0; s < idx.length; s++) {
|
|
27
|
+
const i = idx[s];
|
|
28
|
+
const end = s + 1 < idx.length ? idx[s + 1] : lines.length;
|
|
29
|
+
const name = lines[i].replace(/^\s*Scenario:\s*/, '').trim();
|
|
30
|
+
// tags + reason from the lines above + first comment in body
|
|
31
|
+
const tags: string[] = []; let reason = '';
|
|
32
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
33
|
+
const t = lines[j].trim();
|
|
34
|
+
if (t === '') break;
|
|
35
|
+
if (/^@/.test(t)) tags.unshift(...t.split(/\s+/).filter((x) => x.startsWith('@')));
|
|
36
|
+
else if (/^#/.test(t)) { if (!reason) reason = t.replace(/^#+\s*/, ''); }
|
|
37
|
+
else break;
|
|
38
|
+
}
|
|
39
|
+
const body = lines.slice(i, end).join('\n');
|
|
40
|
+
if (!reason) { const m = body.match(/^\s*#\s*(.+)$/m); if (m) reason = m[1].trim(); }
|
|
41
|
+
const refs: FlowScenario['refs'] = [];
|
|
42
|
+
let mm: RegExpExecArray | null;
|
|
43
|
+
REF_RE.lastIndex = 0;
|
|
44
|
+
while ((mm = REF_RE.exec(body))) refs.push({ screen: mm[1].trim(), element: mm[2].trim() });
|
|
45
|
+
out.push({ name, manual: tags.some((t) => /^@manual\b/i.test(t)), reason, tags, refs });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectorKeys(selectorsPath: string): Set<string> {
|
|
51
|
+
if (!fs.existsSync(selectorsPath)) return new Set();
|
|
52
|
+
try {
|
|
53
|
+
const y = parseYaml(fs.readFileSync(selectorsPath, 'utf-8')) || {};
|
|
54
|
+
return new Set(Object.keys(y).map((k) => k.toLowerCase()));
|
|
55
|
+
} catch { return new Set(); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LegPlan {
|
|
59
|
+
screen: string;
|
|
60
|
+
scenarios: number;
|
|
61
|
+
refs: string[];
|
|
62
|
+
haveKeys: number;
|
|
63
|
+
readiness: 'ready' | 'partial' | 'missing';
|
|
64
|
+
touchedByAutomated: boolean;
|
|
65
|
+
}
|
|
66
|
+
export interface FlowPlan {
|
|
67
|
+
flow: string;
|
|
68
|
+
total: number;
|
|
69
|
+
legs: LegPlan[];
|
|
70
|
+
byReason: Record<string, number>;
|
|
71
|
+
capabilityManual: number;
|
|
72
|
+
judgmentManual: number;
|
|
73
|
+
contracts: Contract[];
|
|
74
|
+
readiness: 'ready' | 'not-ready';
|
|
75
|
+
missingLegs: string[];
|
|
76
|
+
plan: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildFlowPlan(cwd: string, flow: string): FlowPlan {
|
|
80
|
+
const featurePath = path.join(cwd, 'qa', 'flows', flow, 'features', `${flow}.feature`);
|
|
81
|
+
const selectorsPath = path.join(cwd, 'qa', 'flows', flow, 'selectors', `${flow}.yaml`);
|
|
82
|
+
const scenarios = parseFlowScenarios(featurePath);
|
|
83
|
+
const keys = selectorKeys(selectorsPath);
|
|
84
|
+
|
|
85
|
+
// Legs = distinct screen namespaces.
|
|
86
|
+
const legMap = new Map<string, { scenarios: Set<string>; refs: Set<string>; automated: boolean }>();
|
|
87
|
+
const byReason: Record<string, number> = {};
|
|
88
|
+
let capabilityManual = 0, judgmentManual = 0;
|
|
89
|
+
|
|
90
|
+
for (const sc of scenarios) {
|
|
91
|
+
if (sc.manual) {
|
|
92
|
+
const { code } = inferReasonCode(sc.tags, sc.reason);
|
|
93
|
+
byReason[code] = (byReason[code] || 0) + 1;
|
|
94
|
+
const cls = MANUAL_REASONS[code]?.cls;
|
|
95
|
+
if (cls === 'capability') capabilityManual++; else if (cls === 'keep') judgmentManual++;
|
|
96
|
+
}
|
|
97
|
+
for (const r of sc.refs) {
|
|
98
|
+
const leg = r.screen.toLowerCase();
|
|
99
|
+
const entry = legMap.get(leg) || { scenarios: new Set(), refs: new Set(), automated: false };
|
|
100
|
+
entry.scenarios.add(sc.name);
|
|
101
|
+
entry.refs.add(`${leg}:${r.element.toLowerCase()}`);
|
|
102
|
+
if (!sc.manual) entry.automated = true;
|
|
103
|
+
legMap.set(leg, entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const legs: LegPlan[] = [...legMap.entries()].map(([screen, e]) => {
|
|
108
|
+
const refs = [...e.refs];
|
|
109
|
+
const haveKeys = refs.filter((rk) => keys.has(rk)).length;
|
|
110
|
+
const readiness: LegPlan['readiness'] = haveKeys === refs.length ? 'ready' : haveKeys === 0 ? 'missing' : 'partial';
|
|
111
|
+
return { screen, scenarios: e.scenarios.size, refs, haveKeys, readiness, touchedByAutomated: e.automated };
|
|
112
|
+
}).sort((a, b) => b.scenarios - a.scenarios);
|
|
113
|
+
|
|
114
|
+
const missingLegs = legs.filter((l) => l.touchedByAutomated && l.readiness !== 'ready').map((l) => l.screen);
|
|
115
|
+
const fc = buildFlowCheck(cwd, flow);
|
|
116
|
+
const contracts = fc.contracts.filter((c) => c.flow === flow);
|
|
117
|
+
|
|
118
|
+
const readiness: FlowPlan['readiness'] = missingLegs.length ? 'not-ready' : 'ready';
|
|
119
|
+
const plan: string[] = [];
|
|
120
|
+
if (missingLegs.length) {
|
|
121
|
+
plan.push(`Run \`/sungen:run-test ${flow}\` → Phase 0 generates selectors for: ${missingLegs.join(', ')} (from the live pages).`);
|
|
122
|
+
}
|
|
123
|
+
if (contracts.some((c) => c.kind === 'SAME-CARD')) plan.push('Honour SAME-CARD: scope remember + Add To Cart/View Product to ONE card at run-test.');
|
|
124
|
+
if (contracts.some((c) => c.kind === 'ISOLATION')) plan.push('Honour ISOLATION: fresh browser context per cart/state scenario (@parallel).');
|
|
125
|
+
const driverReasons = Object.keys(byReason).filter((c) => MANUAL_REASONS[c]?.cls === 'capability');
|
|
126
|
+
if (driverReasons.length) {
|
|
127
|
+
const drivers = [...new Set(driverReasons.flatMap((c) => MANUAL_REASONS[c].drivers))];
|
|
128
|
+
if (drivers.length) plan.push(`Consider drivers (recommend-only): ${drivers.join(', ')} — for capability-manual (${driverReasons.join('/')}).`);
|
|
129
|
+
} else {
|
|
130
|
+
plan.push('No capability driver needed — all automated legs are @ui.');
|
|
131
|
+
}
|
|
132
|
+
if (readiness === 'ready') plan.unshift('Selectors present for every automated leg — ready to compile + run.');
|
|
133
|
+
|
|
134
|
+
return { flow, total: scenarios.length, legs, byReason, capabilityManual, judgmentManual, contracts, readiness, missingLegs, plan };
|
|
135
|
+
}
|