@yasserkhanorg/e2e-agents 0.5.16 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/pipeline.d.ts +1 -1
- package/dist/agent/pipeline.d.ts.map +1 -1
- package/dist/agent/plan.d.ts +2 -13
- package/dist/agent/plan.d.ts.map +1 -1
- package/dist/agent/plan.js +0 -365
- package/dist/agent/types.d.ts +42 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +4 -0
- package/dist/api.d.ts +14 -14
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +67 -59
- package/dist/cli.js +86 -176
- package/dist/engine/ai_enrichment.d.ts +43 -0
- package/dist/engine/ai_enrichment.d.ts.map +1 -0
- package/dist/engine/ai_enrichment.js +235 -0
- package/dist/engine/diff_loader.d.ts +11 -0
- package/dist/engine/diff_loader.d.ts.map +1 -0
- package/dist/engine/diff_loader.js +74 -0
- package/dist/engine/impact_engine.d.ts +36 -0
- package/dist/engine/impact_engine.d.ts.map +1 -0
- package/dist/engine/impact_engine.js +196 -0
- package/dist/engine/plan_builder.d.ts +10 -0
- package/dist/engine/plan_builder.d.ts.map +1 -0
- package/dist/engine/plan_builder.js +374 -0
- package/dist/esm/agent/plan.js +1 -360
- package/dist/esm/agent/types.js +3 -0
- package/dist/esm/api.js +62 -54
- package/dist/esm/cli.js +87 -177
- package/dist/esm/engine/ai_enrichment.js +232 -0
- package/dist/esm/engine/diff_loader.js +70 -0
- package/dist/esm/engine/impact_engine.js +191 -0
- package/dist/esm/engine/plan_builder.js +368 -0
- package/dist/esm/index.js +6 -3
- package/dist/esm/knowledge/route_families.js +59 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -5
- package/dist/knowledge/route_families.d.ts +19 -0
- package/dist/knowledge/route_families.d.ts.map +1 -1
- package/dist/knowledge/route_families.js +62 -1
- package/package.json +1 -1
- package/dist/agent/ai_flow_analysis.d.ts +0 -13
- package/dist/agent/ai_flow_analysis.d.ts.map +0 -1
- package/dist/agent/ai_flow_analysis.js +0 -334
- package/dist/agent/ai_mapping.d.ts +0 -14
- package/dist/agent/ai_mapping.d.ts.map +0 -1
- package/dist/agent/ai_mapping.js +0 -560
- package/dist/agent/analysis.d.ts +0 -64
- package/dist/agent/analysis.d.ts.map +0 -1
- package/dist/agent/analysis.js +0 -292
- package/dist/agent/blast_radius.d.ts +0 -4
- package/dist/agent/blast_radius.d.ts.map +0 -1
- package/dist/agent/blast_radius.js +0 -37
- package/dist/agent/dependency_graph.d.ts +0 -14
- package/dist/agent/dependency_graph.d.ts.map +0 -1
- package/dist/agent/dependency_graph.js +0 -227
- package/dist/agent/flags.d.ts +0 -23
- package/dist/agent/flags.d.ts.map +0 -1
- package/dist/agent/flags.js +0 -171
- package/dist/agent/flow_catalog.d.ts +0 -25
- package/dist/agent/flow_catalog.d.ts.map +0 -1
- package/dist/agent/flow_catalog.js +0 -115
- package/dist/agent/flow_mapping.d.ts +0 -10
- package/dist/agent/flow_mapping.d.ts.map +0 -1
- package/dist/agent/flow_mapping.js +0 -84
- package/dist/agent/framework.d.ts +0 -13
- package/dist/agent/framework.d.ts.map +0 -1
- package/dist/agent/framework.js +0 -149
- package/dist/agent/gap_suggestions.d.ts +0 -14
- package/dist/agent/gap_suggestions.d.ts.map +0 -1
- package/dist/agent/gap_suggestions.js +0 -101
- package/dist/agent/generator.d.ts +0 -10
- package/dist/agent/generator.d.ts.map +0 -1
- package/dist/agent/generator.js +0 -115
- package/dist/agent/operational_insights.d.ts +0 -41
- package/dist/agent/operational_insights.d.ts.map +0 -1
- package/dist/agent/operational_insights.js +0 -127
- package/dist/agent/report.d.ts +0 -97
- package/dist/agent/report.d.ts.map +0 -1
- package/dist/agent/report.js +0 -159
- package/dist/agent/runner.d.ts +0 -7
- package/dist/agent/runner.d.ts.map +0 -1
- package/dist/agent/runner.js +0 -898
- package/dist/agent/selectors.d.ts +0 -10
- package/dist/agent/selectors.d.ts.map +0 -1
- package/dist/agent/selectors.js +0 -75
- package/dist/agent/subsystem_risk.d.ts +0 -23
- package/dist/agent/subsystem_risk.d.ts.map +0 -1
- package/dist/agent/subsystem_risk.js +0 -207
- package/dist/agent/tests.d.ts +0 -19
- package/dist/agent/tests.d.ts.map +0 -1
- package/dist/agent/tests.js +0 -116
- package/dist/agent/traceability.d.ts +0 -22
- package/dist/agent/traceability.d.ts.map +0 -1
- package/dist/agent/traceability.js +0 -183
- package/dist/esm/agent/ai_flow_analysis.js +0 -331
- package/dist/esm/agent/ai_mapping.js +0 -557
- package/dist/esm/agent/analysis.js +0 -287
- package/dist/esm/agent/blast_radius.js +0 -34
- package/dist/esm/agent/dependency_graph.js +0 -224
- package/dist/esm/agent/flags.js +0 -160
- package/dist/esm/agent/flow_catalog.js +0 -112
- package/dist/esm/agent/flow_mapping.js +0 -81
- package/dist/esm/agent/framework.js +0 -145
- package/dist/esm/agent/gap_suggestions.js +0 -98
- package/dist/esm/agent/generator.js +0 -112
- package/dist/esm/agent/operational_insights.js +0 -124
- package/dist/esm/agent/report.js +0 -156
- package/dist/esm/agent/runner.js +0 -894
- package/dist/esm/agent/selectors.js +0 -71
- package/dist/esm/agent/subsystem_risk.js +0 -204
- package/dist/esm/agent/tests.js +0 -111
- package/dist/esm/agent/traceability.js +0 -180
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { existsSync, readdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { loadRouteFamilyManifest, bindFilesToFamilies, getSpecDirsForBinding, getCypressSpecDirsForBinding, getPriorityForBinding, getUserFlowsForBinding, } from '../knowledge/route_families.js';
|
|
6
|
+
function scanDirForSpecs(baseDir, specDir, extension) {
|
|
7
|
+
const fullDir = join(baseDir, specDir);
|
|
8
|
+
if (!existsSync(fullDir)) {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const specs = [];
|
|
12
|
+
try {
|
|
13
|
+
const items = readdirSync(fullDir, { withFileTypes: true });
|
|
14
|
+
for (const item of items) {
|
|
15
|
+
const itemPath = join(fullDir, item.name);
|
|
16
|
+
if (item.isDirectory()) {
|
|
17
|
+
specs.push(...scanDirForSpecsRecursive(itemPath, extension));
|
|
18
|
+
}
|
|
19
|
+
else if (item.name.endsWith(extension)) {
|
|
20
|
+
specs.push(join(specDir, item.name));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Directory not readable
|
|
26
|
+
}
|
|
27
|
+
return specs;
|
|
28
|
+
}
|
|
29
|
+
function scanDirForSpecsRecursive(dir, extension) {
|
|
30
|
+
const specs = [];
|
|
31
|
+
try {
|
|
32
|
+
const items = readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const fullPath = join(dir, item.name);
|
|
35
|
+
if (item.isDirectory()) {
|
|
36
|
+
specs.push(...scanDirForSpecsRecursive(fullPath, extension));
|
|
37
|
+
}
|
|
38
|
+
else if (item.name.endsWith(extension)) {
|
|
39
|
+
specs.push(fullPath);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Directory not readable
|
|
45
|
+
}
|
|
46
|
+
return specs;
|
|
47
|
+
}
|
|
48
|
+
function resolvePlaywrightSpecs(testsRoot, specDirs) {
|
|
49
|
+
const specs = [];
|
|
50
|
+
for (const dir of specDirs) {
|
|
51
|
+
specs.push(...scanDirForSpecs(testsRoot, dir, '.spec.ts'));
|
|
52
|
+
}
|
|
53
|
+
return specs;
|
|
54
|
+
}
|
|
55
|
+
function resolveCypressSpecs(cypressRoot, specDirs) {
|
|
56
|
+
const specs = [];
|
|
57
|
+
for (const dir of specDirs) {
|
|
58
|
+
// cypressSpecDirs are relative to testsRoot (e.g. ../cypress/tests/integration/channels/search/)
|
|
59
|
+
// Resolve them relative to the cypress root
|
|
60
|
+
const resolvedDir = join(cypressRoot, dir.replace(/^\.\.\/cypress\//, ''));
|
|
61
|
+
if (!existsSync(resolvedDir)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const found = scanDirForSpecsRecursive(resolvedDir, '.js');
|
|
65
|
+
const tsFound = scanDirForSpecsRecursive(resolvedDir, '.ts');
|
|
66
|
+
specs.push(...found, ...tsFound);
|
|
67
|
+
}
|
|
68
|
+
return specs;
|
|
69
|
+
}
|
|
70
|
+
function computeCoverageStatus(pwSpecs, cySpecs) {
|
|
71
|
+
const hasPw = pwSpecs.length > 0;
|
|
72
|
+
const hasCy = cySpecs.length > 0;
|
|
73
|
+
if (hasPw && hasCy) {
|
|
74
|
+
return 'covered';
|
|
75
|
+
}
|
|
76
|
+
if (hasPw || hasCy) {
|
|
77
|
+
return 'partial';
|
|
78
|
+
}
|
|
79
|
+
return 'uncovered';
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Group file bindings into a deduplicated map of family/feature → changed files.
|
|
83
|
+
*/
|
|
84
|
+
function groupBindings(fileBindings) {
|
|
85
|
+
const groups = new Map();
|
|
86
|
+
for (const fb of fileBindings) {
|
|
87
|
+
for (const binding of fb.bindings) {
|
|
88
|
+
const key = binding.feature || binding.family;
|
|
89
|
+
const existing = groups.get(key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
if (!existing.files.includes(fb.file)) {
|
|
92
|
+
existing.files.push(fb.file);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
groups.set(key, {
|
|
97
|
+
familyId: binding.family,
|
|
98
|
+
featureId: binding.feature,
|
|
99
|
+
files: [fb.file],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return groups;
|
|
105
|
+
}
|
|
106
|
+
export function analyzeImpact(changedFiles, options) {
|
|
107
|
+
const { testsRoot, routeFamilies } = options;
|
|
108
|
+
const warnings = [];
|
|
109
|
+
// Load manifest
|
|
110
|
+
const manifest = loadRouteFamilyManifest(testsRoot, routeFamilies);
|
|
111
|
+
if (!manifest) {
|
|
112
|
+
return {
|
|
113
|
+
changedFiles,
|
|
114
|
+
expandedFiles: options.expandedFiles || [],
|
|
115
|
+
impactedFeatures: [],
|
|
116
|
+
unboundFiles: [...changedFiles],
|
|
117
|
+
warnings: ['Route family manifest not found. All files are unbound.'],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Combine original + expanded files
|
|
121
|
+
const allFiles = [...new Set([...changedFiles, ...(options.expandedFiles || [])])];
|
|
122
|
+
// Bind files to families
|
|
123
|
+
const fileBindings = bindFilesToFamilies(allFiles, manifest);
|
|
124
|
+
// Find unbound files
|
|
125
|
+
const unboundFiles = fileBindings
|
|
126
|
+
.filter((fb) => fb.bindings.length === 0)
|
|
127
|
+
.map((fb) => fb.file);
|
|
128
|
+
// Group bindings into features
|
|
129
|
+
const groups = groupBindings(fileBindings.filter((fb) => fb.bindings.length > 0));
|
|
130
|
+
// Determine cypress root
|
|
131
|
+
const cypressRoot = options.cypressRoot || inferCypressRoot(testsRoot);
|
|
132
|
+
// Resolve specs and compute coverage for each feature
|
|
133
|
+
const impactedFeatures = [];
|
|
134
|
+
for (const group of groups.values()) {
|
|
135
|
+
const binding = { family: group.familyId, feature: group.featureId };
|
|
136
|
+
const specDirs = getSpecDirsForBinding(manifest, binding);
|
|
137
|
+
const cypressSpecDirs = getCypressSpecDirsForBinding(manifest, binding);
|
|
138
|
+
const priority = getPriorityForBinding(manifest, binding);
|
|
139
|
+
const userFlows = getUserFlowsForBinding(manifest, binding);
|
|
140
|
+
const playwrightSpecs = resolvePlaywrightSpecs(testsRoot, specDirs);
|
|
141
|
+
const cypressSpecs = cypressRoot ? resolveCypressSpecs(cypressRoot, cypressSpecDirs) : [];
|
|
142
|
+
const coverageStatus = computeCoverageStatus(playwrightSpecs, cypressSpecs);
|
|
143
|
+
impactedFeatures.push({
|
|
144
|
+
familyId: group.familyId,
|
|
145
|
+
featureId: group.featureId,
|
|
146
|
+
priority,
|
|
147
|
+
changedFiles: group.files,
|
|
148
|
+
playwrightSpecs,
|
|
149
|
+
cypressSpecs,
|
|
150
|
+
userFlows,
|
|
151
|
+
coverageStatus,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Sort by priority (P0 first, then P1, then P2)
|
|
155
|
+
const priorityOrder = { P0: 0, P1: 1, P2: 2 };
|
|
156
|
+
impactedFeatures.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
|
157
|
+
if (unboundFiles.length > 0 && unboundFiles.length <= 5) {
|
|
158
|
+
warnings.push(`${unboundFiles.length} file(s) not mapped to any route family: ${unboundFiles.join(', ')}`);
|
|
159
|
+
}
|
|
160
|
+
else if (unboundFiles.length > 5) {
|
|
161
|
+
warnings.push(`${unboundFiles.length} file(s) not mapped to any route family`);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
changedFiles,
|
|
165
|
+
expandedFiles: options.expandedFiles || [],
|
|
166
|
+
impactedFeatures,
|
|
167
|
+
unboundFiles,
|
|
168
|
+
warnings,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function inferCypressRoot(testsRoot) {
|
|
172
|
+
// testsRoot is typically the Playwright tests directory
|
|
173
|
+
// Cypress tests are at a sibling path: e2e-tests/cypress/tests/integration/channels/
|
|
174
|
+
const candidate = join(testsRoot, '..', 'cypress');
|
|
175
|
+
if (existsSync(candidate)) {
|
|
176
|
+
return candidate;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Get gaps: P0/P1 features with 'uncovered' status.
|
|
182
|
+
*/
|
|
183
|
+
export function getGaps(result) {
|
|
184
|
+
return result.impactedFeatures.filter((f) => (f.priority === 'P0' || f.priority === 'P1') && f.coverageStatus === 'uncovered');
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Get partial gaps: P0/P1 features with 'partial' status (advisory).
|
|
188
|
+
*/
|
|
189
|
+
export function getPartialGaps(result) {
|
|
190
|
+
return result.impactedFeatures.filter((f) => (f.priority === 'P0' || f.priority === 'P1') && f.coverageStatus === 'partial');
|
|
191
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
|
+
// See LICENSE.txt for license information.
|
|
3
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { minimatch } from 'minimatch';
|
|
6
|
+
import { getGaps, getPartialGaps } from './impact_engine.js';
|
|
7
|
+
const DEFAULT_POLICY = {
|
|
8
|
+
minConfidenceForTargeted: 60,
|
|
9
|
+
safeMergeMinConfidence: 85,
|
|
10
|
+
forceFullOnWarningsAtOrAbove: 2,
|
|
11
|
+
forceFullOnP0WithGaps: true,
|
|
12
|
+
forceFullOnRiskyFiles: true,
|
|
13
|
+
riskyFilePatterns: [
|
|
14
|
+
'**/auth/**',
|
|
15
|
+
'**/login/**',
|
|
16
|
+
'**/permissions/**',
|
|
17
|
+
'**/admin/**',
|
|
18
|
+
'**/security/**',
|
|
19
|
+
'**/migrations/**',
|
|
20
|
+
'**/schema/**',
|
|
21
|
+
'**/*.sql',
|
|
22
|
+
'**/webhook/**',
|
|
23
|
+
],
|
|
24
|
+
enforcementMode: 'advisory',
|
|
25
|
+
blockOnActions: ['must-add-tests'],
|
|
26
|
+
};
|
|
27
|
+
function featureLabel(f) {
|
|
28
|
+
return f.featureId || f.familyId;
|
|
29
|
+
}
|
|
30
|
+
function computeConfidence(impact) {
|
|
31
|
+
const gaps = getGaps(impact);
|
|
32
|
+
const totalFeatures = impact.impactedFeatures.length;
|
|
33
|
+
const boundRatio = totalFeatures > 0
|
|
34
|
+
? (totalFeatures / (totalFeatures + impact.unboundFiles.length))
|
|
35
|
+
: 1;
|
|
36
|
+
// Graph-resolved bindings start at 100
|
|
37
|
+
let confidence = 100;
|
|
38
|
+
// Deduct for unbound files (not mapped to any family)
|
|
39
|
+
if (impact.unboundFiles.length > 0) {
|
|
40
|
+
const unboundPenalty = Math.min(30, impact.unboundFiles.length * 3);
|
|
41
|
+
confidence -= unboundPenalty;
|
|
42
|
+
}
|
|
43
|
+
// Deduct for gaps
|
|
44
|
+
confidence -= Math.min(20, gaps.length * 5);
|
|
45
|
+
// Deduct for warnings
|
|
46
|
+
confidence -= Math.min(15, impact.warnings.length * 5);
|
|
47
|
+
// Bonus for high bound ratio
|
|
48
|
+
if (boundRatio >= 0.9) {
|
|
49
|
+
confidence = Math.min(100, confidence + 5);
|
|
50
|
+
}
|
|
51
|
+
return Math.max(0, Math.min(100, confidence));
|
|
52
|
+
}
|
|
53
|
+
function findRiskyFiles(changedFiles, patterns) {
|
|
54
|
+
return [...new Set(changedFiles.filter((file) => patterns.some((pattern) => minimatch(file, pattern, { matchBase: true }))))];
|
|
55
|
+
}
|
|
56
|
+
function pickRunSet(impact, confidence, policy) {
|
|
57
|
+
const gaps = getGaps(impact);
|
|
58
|
+
const reasons = [];
|
|
59
|
+
const triggeredRules = [];
|
|
60
|
+
const riskyFiles = findRiskyFiles(impact.changedFiles, policy.riskyFilePatterns);
|
|
61
|
+
const hasP0 = impact.impactedFeatures.some((f) => f.priority === 'P0');
|
|
62
|
+
if (gaps.length > 0) {
|
|
63
|
+
reasons.push(`${gaps.length} uncovered P0/P1 feature(s) detected.`);
|
|
64
|
+
}
|
|
65
|
+
if (hasP0) {
|
|
66
|
+
reasons.push('P0 features are impacted by this change set.');
|
|
67
|
+
}
|
|
68
|
+
if (policy.forceFullOnRiskyFiles && riskyFiles.length > 0) {
|
|
69
|
+
triggeredRules.push('risky-files');
|
|
70
|
+
reasons.push(`Risky file patterns matched: ${riskyFiles.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
if (confidence < policy.minConfidenceForTargeted) {
|
|
73
|
+
triggeredRules.push('low-confidence');
|
|
74
|
+
}
|
|
75
|
+
if (impact.warnings.length >= policy.forceFullOnWarningsAtOrAbove) {
|
|
76
|
+
triggeredRules.push('warning-threshold');
|
|
77
|
+
reasons.push('Warning threshold exceeded.');
|
|
78
|
+
}
|
|
79
|
+
if (policy.forceFullOnP0WithGaps && hasP0 && gaps.length > 0) {
|
|
80
|
+
triggeredRules.push('p0-with-gaps');
|
|
81
|
+
}
|
|
82
|
+
if (triggeredRules.length > 0) {
|
|
83
|
+
return {
|
|
84
|
+
runSet: 'full',
|
|
85
|
+
reasons: reasons.length > 0 ? reasons : ['Policy rules triggered full suite.'],
|
|
86
|
+
triggeredRules,
|
|
87
|
+
riskyFiles,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// If we have impacted features with specs, recommend targeted
|
|
91
|
+
const coveredFeatures = impact.impactedFeatures.filter((f) => f.coverageStatus !== 'uncovered');
|
|
92
|
+
if (coveredFeatures.length > 0) {
|
|
93
|
+
return {
|
|
94
|
+
runSet: 'targeted',
|
|
95
|
+
reasons: reasons.length > 0 ? reasons : ['Impacted features have test coverage.'],
|
|
96
|
+
triggeredRules,
|
|
97
|
+
riskyFiles,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
runSet: 'smoke',
|
|
102
|
+
reasons: reasons.length > 0 ? reasons : ['No targeted tests mapped from impacted features.'],
|
|
103
|
+
triggeredRules,
|
|
104
|
+
riskyFiles,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function buildDecision(impact, runSet, confidence, policy) {
|
|
108
|
+
const gaps = getGaps(impact);
|
|
109
|
+
if (gaps.length > 0) {
|
|
110
|
+
return {
|
|
111
|
+
action: 'must-add-tests',
|
|
112
|
+
title: 'Must add tests',
|
|
113
|
+
summary: `Detected ${gaps.length} uncovered P0/P1 feature(s). Add or update tests before merge.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (impact.changedFiles.length === 0 && impact.impactedFeatures.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
action: 'safe-to-merge',
|
|
119
|
+
title: 'Safe to merge',
|
|
120
|
+
summary: 'No app file changes detected — no E2E coverage required for this change set.',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (runSet === 'smoke' && confidence >= policy.safeMergeMinConfidence && impact.warnings.length === 0) {
|
|
124
|
+
return {
|
|
125
|
+
action: 'safe-to-merge',
|
|
126
|
+
title: 'Safe to merge',
|
|
127
|
+
summary: 'No critical coverage gaps were detected and confidence is high.',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const coveredCount = impact.impactedFeatures.filter((f) => f.coverageStatus !== 'uncovered').length;
|
|
131
|
+
const coveredSuffix = coveredCount > 0
|
|
132
|
+
? ` All ${coveredCount} impacted feature(s) have test coverage.`
|
|
133
|
+
: '';
|
|
134
|
+
return {
|
|
135
|
+
action: 'run-now',
|
|
136
|
+
title: 'Run now',
|
|
137
|
+
summary: `Impacted features are covered by existing tests.${coveredSuffix} Verify with the E2E suite before merge.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function evaluateEnforcement(decision, policy) {
|
|
141
|
+
const blockOnActions = (policy.blockOnActions && policy.blockOnActions.length > 0)
|
|
142
|
+
? [...policy.blockOnActions]
|
|
143
|
+
: ['must-add-tests'];
|
|
144
|
+
const matchedAction = blockOnActions.includes(decision.action);
|
|
145
|
+
if (policy.enforcementMode === 'block' && matchedAction) {
|
|
146
|
+
return {
|
|
147
|
+
mode: policy.enforcementMode,
|
|
148
|
+
blockOnActions,
|
|
149
|
+
matchedAction,
|
|
150
|
+
shouldFail: true,
|
|
151
|
+
summary: `Blocking mode active: decision "${decision.action}" is configured to fail CI.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (policy.enforcementMode === 'warn' && matchedAction) {
|
|
155
|
+
return {
|
|
156
|
+
mode: policy.enforcementMode,
|
|
157
|
+
blockOnActions,
|
|
158
|
+
matchedAction,
|
|
159
|
+
shouldFail: false,
|
|
160
|
+
summary: `Warning mode active: decision "${decision.action}" is advisory-only for CI.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (policy.enforcementMode === 'block') {
|
|
164
|
+
return {
|
|
165
|
+
mode: policy.enforcementMode,
|
|
166
|
+
blockOnActions,
|
|
167
|
+
matchedAction,
|
|
168
|
+
shouldFail: false,
|
|
169
|
+
summary: `Blocking mode active, but decision "${decision.action}" is not configured for CI failure.`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
mode: policy.enforcementMode,
|
|
174
|
+
blockOnActions,
|
|
175
|
+
matchedAction,
|
|
176
|
+
shouldFail: false,
|
|
177
|
+
summary: 'Advisory mode active: recommendations do not fail CI by default.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Build recommended test list from impacted features' Playwright specs.
|
|
182
|
+
*/
|
|
183
|
+
function buildRecommendedTests(impact) {
|
|
184
|
+
const tests = [];
|
|
185
|
+
for (const feature of impact.impactedFeatures) {
|
|
186
|
+
if (feature.coverageStatus !== 'uncovered') {
|
|
187
|
+
for (const spec of feature.playwrightSpecs) {
|
|
188
|
+
if (!tests.includes(spec)) {
|
|
189
|
+
tests.push(spec);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return tests;
|
|
195
|
+
}
|
|
196
|
+
export function buildPlanFromImpact(impact, policyOverride, aiEnrichment) {
|
|
197
|
+
const policy = { ...DEFAULT_POLICY, ...(policyOverride || {}) };
|
|
198
|
+
const confidence = computeConfidence(impact);
|
|
199
|
+
const runSetResult = pickRunSet(impact, confidence, policy);
|
|
200
|
+
const decision = buildDecision(impact, runSetResult.runSet, confidence, policy);
|
|
201
|
+
const enforcement = evaluateEnforcement(decision, policy);
|
|
202
|
+
const gaps = getGaps(impact);
|
|
203
|
+
const partialGaps = getPartialGaps(impact);
|
|
204
|
+
// Build two separate lookup maps from aiEnrichment: one by featureId, one by familyId.
|
|
205
|
+
// The familyId map stores only the FIRST feature encountered to avoid last-write-wins collisions.
|
|
206
|
+
const aiFeatureByFeatureId = new Map();
|
|
207
|
+
const aiFeatureByFamilyId = new Map();
|
|
208
|
+
if (aiEnrichment) {
|
|
209
|
+
for (const ef of aiEnrichment.enrichedFeatures) {
|
|
210
|
+
if (ef.featureId) {
|
|
211
|
+
aiFeatureByFeatureId.set(ef.featureId, ef);
|
|
212
|
+
}
|
|
213
|
+
if (ef.familyId && !aiFeatureByFamilyId.has(ef.familyId)) {
|
|
214
|
+
aiFeatureByFamilyId.set(ef.familyId, ef);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const gapDetails = gaps.map((f) => {
|
|
219
|
+
const label = featureLabel(f);
|
|
220
|
+
const aiFeature = f.featureId
|
|
221
|
+
? (aiFeatureByFeatureId.get(f.featureId) ?? aiFeatureByFamilyId.get(f.familyId))
|
|
222
|
+
: aiFeatureByFamilyId.get(f.familyId);
|
|
223
|
+
const baseReasons = [`No Playwright or Cypress tests found for ${label}`];
|
|
224
|
+
const reasons = aiFeature && aiFeature.aiReasons.length > 0
|
|
225
|
+
? [...baseReasons, ...aiFeature.aiReasons]
|
|
226
|
+
: baseReasons;
|
|
227
|
+
const missingScenarios = aiFeature && aiFeature.aiMissingScenarios.length > 0
|
|
228
|
+
? aiFeature.aiMissingScenarios
|
|
229
|
+
: (f.userFlows.length > 0 ? f.userFlows.slice(0, 5) : undefined);
|
|
230
|
+
return {
|
|
231
|
+
id: label,
|
|
232
|
+
name: label,
|
|
233
|
+
priority: f.priority,
|
|
234
|
+
reasons,
|
|
235
|
+
files: f.changedFiles.slice(0, 6),
|
|
236
|
+
missingScenarios,
|
|
237
|
+
source: aiFeature ? 'ai+deterministic' : 'deterministic',
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
// Add partial gaps as advisory info
|
|
241
|
+
for (const f of partialGaps) {
|
|
242
|
+
const coverageType = f.playwrightSpecs.length > 0 ? 'Cypress' : 'Playwright';
|
|
243
|
+
const hasOpposite = f.playwrightSpecs.length > 0 ? 'Playwright' : 'Cypress';
|
|
244
|
+
const label = featureLabel(f);
|
|
245
|
+
const aiFeature = f.featureId
|
|
246
|
+
? (aiFeatureByFeatureId.get(f.featureId) ?? aiFeatureByFamilyId.get(f.familyId))
|
|
247
|
+
: aiFeatureByFamilyId.get(f.familyId);
|
|
248
|
+
const baseReasons = [`Missing ${coverageType} tests for ${label} (has ${hasOpposite} only)`];
|
|
249
|
+
const reasons = aiFeature && aiFeature.aiReasons.length > 0
|
|
250
|
+
? [...baseReasons, ...aiFeature.aiReasons]
|
|
251
|
+
: baseReasons;
|
|
252
|
+
gapDetails.push({
|
|
253
|
+
id: label,
|
|
254
|
+
name: `${label} (partial)`,
|
|
255
|
+
priority: f.priority,
|
|
256
|
+
reasons,
|
|
257
|
+
files: f.changedFiles.slice(0, 6),
|
|
258
|
+
source: aiFeature ? 'ai+deterministic' : 'deterministic',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const coveredFlows = impact.impactedFeatures
|
|
262
|
+
.filter((f) => f.coverageStatus === 'covered')
|
|
263
|
+
.map((f) => ({
|
|
264
|
+
id: featureLabel(f),
|
|
265
|
+
name: featureLabel(f),
|
|
266
|
+
priority: f.priority,
|
|
267
|
+
coveredBy: [
|
|
268
|
+
...(f.playwrightSpecs.length > 0 ? [`${f.playwrightSpecs.length} Playwright spec(s)`] : []),
|
|
269
|
+
...(f.cypressSpecs.length > 0 ? [`${f.cypressSpecs.length} Cypress spec(s)`] : []),
|
|
270
|
+
].slice(0, 3),
|
|
271
|
+
}));
|
|
272
|
+
const recommendedTests = buildRecommendedTests(impact);
|
|
273
|
+
const requiredNewTests = gaps.map((f) => `${featureLabel(f)}: Add E2E tests`);
|
|
274
|
+
const p0 = impact.impactedFeatures.filter((f) => f.priority === 'P0').length;
|
|
275
|
+
const p1 = impact.impactedFeatures.filter((f) => f.priority === 'P1').length;
|
|
276
|
+
const p2 = impact.impactedFeatures.filter((f) => f.priority === 'P2').length;
|
|
277
|
+
const runId = `plan-${Date.now().toString(36)}`;
|
|
278
|
+
const planSource = aiEnrichment ? 'ai+deterministic' : 'impact';
|
|
279
|
+
return {
|
|
280
|
+
schemaVersion: '1.0.0',
|
|
281
|
+
runId,
|
|
282
|
+
generatedAt: new Date().toISOString(),
|
|
283
|
+
source: planSource,
|
|
284
|
+
runSet: runSetResult.runSet,
|
|
285
|
+
confidence,
|
|
286
|
+
reasons: runSetResult.reasons,
|
|
287
|
+
recommendedTests,
|
|
288
|
+
requiredNewTests,
|
|
289
|
+
gapDetails,
|
|
290
|
+
coveredFlows,
|
|
291
|
+
policy: {
|
|
292
|
+
riskyFiles: runSetResult.riskyFiles,
|
|
293
|
+
triggeredRules: runSetResult.triggeredRules,
|
|
294
|
+
applied: policy,
|
|
295
|
+
},
|
|
296
|
+
decision,
|
|
297
|
+
enforcement,
|
|
298
|
+
metrics: {
|
|
299
|
+
changedFiles: impact.changedFiles.length,
|
|
300
|
+
impactedFlows: impact.impactedFeatures.length,
|
|
301
|
+
p0Flows: p0,
|
|
302
|
+
p1Flows: p1,
|
|
303
|
+
p2Flows: p2,
|
|
304
|
+
uncoveredP0P1Flows: gaps.length,
|
|
305
|
+
warnings: impact.warnings.length,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
export function writePlanReport(appRoot, plan) {
|
|
310
|
+
const baseDir = join(appRoot, '.e2e-ai-agents');
|
|
311
|
+
mkdirSync(baseDir, { recursive: true });
|
|
312
|
+
const planPath = join(baseDir, 'plan.json');
|
|
313
|
+
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8');
|
|
314
|
+
return planPath;
|
|
315
|
+
}
|
|
316
|
+
export function renderCiSummaryMarkdown(plan) {
|
|
317
|
+
const lines = [];
|
|
318
|
+
const { p0Flows, p1Flows, uncoveredP0P1Flows, changedFiles, impactedFlows } = plan.metrics;
|
|
319
|
+
const mustAddTests = plan.decision.action === 'must-add-tests';
|
|
320
|
+
const statusEmoji = mustAddTests ? '🔴' : plan.decision.action === 'safe-to-merge' ? '🟢' : '🟡';
|
|
321
|
+
lines.push(`## ${statusEmoji} E2E Coverage: ${plan.decision.title}`);
|
|
322
|
+
lines.push('');
|
|
323
|
+
lines.push(`${plan.decision.summary}`);
|
|
324
|
+
lines.push('');
|
|
325
|
+
lines.push(`**${changedFiles}** files changed → **${impactedFlows}** features impacted` +
|
|
326
|
+
(p0Flows > 0 || p1Flows > 0 ? ` (P0: ${p0Flows}, P1: ${p1Flows})` : ''));
|
|
327
|
+
if (mustAddTests && plan.requiredNewTests.length > 0) {
|
|
328
|
+
lines.push('');
|
|
329
|
+
lines.push('### ⚠️ Add E2E tests for these uncovered P0/P1 features');
|
|
330
|
+
lines.push('');
|
|
331
|
+
lines.push(`The following ${uncoveredP0P1Flows} feature(s) have no test coverage and must be covered before merge:`);
|
|
332
|
+
lines.push('');
|
|
333
|
+
for (const gap of plan.gapDetails.filter((g) => !g.name.includes('(partial)'))) {
|
|
334
|
+
const aiLabel = gap.source === 'ai+deterministic' ? ' ✦ AI-enriched' : '';
|
|
335
|
+
lines.push(`- **${gap.name}** [${gap.priority}]${aiLabel}`);
|
|
336
|
+
if (gap.missingScenarios && gap.missingScenarios.length > 0) {
|
|
337
|
+
for (const scenario of gap.missingScenarios) {
|
|
338
|
+
lines.push(` - ${scenario}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Show AI-provided reasons (skip the first deterministic reason which is always included)
|
|
342
|
+
const aiReasons = gap.reasons.slice(1);
|
|
343
|
+
if (aiReasons.length > 0) {
|
|
344
|
+
lines.push(` - *AI insight*: ${aiReasons.join('; ')}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (plan.coveredFlows.length > 0) {
|
|
349
|
+
lines.push('');
|
|
350
|
+
lines.push('### ✅ Covered features');
|
|
351
|
+
lines.push('');
|
|
352
|
+
for (const flow of plan.coveredFlows) {
|
|
353
|
+
lines.push(`- **${flow.name}** [${flow.priority}] — ${flow.coveredBy.join(', ')}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (plan.confidence < 100) {
|
|
357
|
+
lines.push('');
|
|
358
|
+
lines.push(`**Confidence**: ${plan.confidence}%`);
|
|
359
|
+
}
|
|
360
|
+
return lines.join('\n');
|
|
361
|
+
}
|
|
362
|
+
export function writeCiSummary(appRoot, markdown, relativePath = '.e2e-ai-agents/ci-summary.md') {
|
|
363
|
+
const fullPath = join(appRoot, relativePath);
|
|
364
|
+
const dir = dirname(fullPath);
|
|
365
|
+
mkdirSync(dir, { recursive: true });
|
|
366
|
+
writeFileSync(fullPath, markdown, 'utf-8');
|
|
367
|
+
return fullPath;
|
|
368
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -8,8 +8,11 @@ export { OpenAIProvider, checkOpenAISetup } from './openai_provider.js';
|
|
|
8
8
|
export { CustomProvider } from './custom_provider.js';
|
|
9
9
|
// Factory
|
|
10
10
|
export { LLMProviderFactory, validateProviderSetup } from './provider_factory.js';
|
|
11
|
-
// Agent API (impact
|
|
12
|
-
export {
|
|
11
|
+
// Agent API (deterministic impact + plan, traceability)
|
|
12
|
+
export { analyzeImpactDeterministic, recommendTestsDeterministic, handoffGeneratedTests, ingestTraceability, captureTraceability } from './api.js';
|
|
13
|
+
// V2 Engine (deterministic impact + plan)
|
|
14
|
+
export { analyzeImpact as analyzeImpactV2, getGaps, getPartialGaps } from './engine/impact_engine.js';
|
|
15
|
+
export { buildPlanFromImpact } from './engine/plan_builder.js';
|
|
13
16
|
export { appendFeedbackAndRecompute, readCalibration } from './agent/feedback.js';
|
|
14
17
|
export { finalizeGeneratedTests } from './agent/handoff.js';
|
|
15
18
|
export { ingestTraceabilityInput } from './agent/traceability_ingest.js';
|
|
@@ -21,6 +24,6 @@ export { buildGenerationPrompt, parseGenerationResponse, detectHallucinatedMetho
|
|
|
21
24
|
export { runHealStage, healFromReport, resolveHealTargets, renderHealMarkdown } from './pipeline/stage4_heal.js';
|
|
22
25
|
export { buildHealPrompt, buildQualityFixPrompt } from './prompts/heal.js';
|
|
23
26
|
// Knowledge modules
|
|
24
|
-
export { loadRouteFamilyManifest, bindFilesToFamilies } from './knowledge/route_families.js';
|
|
27
|
+
export { loadRouteFamilyManifest, bindFilesToFamilies, getCypressSpecDirsForBinding, getPriorityForBinding, getUserFlowsForBinding } from './knowledge/route_families.js';
|
|
25
28
|
export { buildApiSurface, loadOrBuildApiSurface } from './knowledge/api_surface.js';
|
|
26
29
|
export { buildSpecIndex, getSpecsForFamily } from './knowledge/spec_index.js';
|
|
@@ -63,9 +63,18 @@ function validateFamily(family) {
|
|
|
63
63
|
if (Array.isArray(obj.specDirs)) {
|
|
64
64
|
result.specDirs = obj.specDirs.filter((v) => typeof v === 'string');
|
|
65
65
|
}
|
|
66
|
+
if (Array.isArray(obj.cypressSpecDirs)) {
|
|
67
|
+
result.cypressSpecDirs = obj.cypressSpecDirs.filter((v) => typeof v === 'string');
|
|
68
|
+
}
|
|
66
69
|
if (Array.isArray(obj.tags)) {
|
|
67
70
|
result.tags = obj.tags.filter((v) => typeof v === 'string');
|
|
68
71
|
}
|
|
72
|
+
if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') {
|
|
73
|
+
result.priority = obj.priority;
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(obj.userFlows)) {
|
|
76
|
+
result.userFlows = obj.userFlows.filter((v) => typeof v === 'string');
|
|
77
|
+
}
|
|
69
78
|
if (Array.isArray(obj.features)) {
|
|
70
79
|
result.features = obj.features
|
|
71
80
|
.map((f) => validateFeature(f))
|
|
@@ -94,9 +103,18 @@ function validateFeature(feature) {
|
|
|
94
103
|
if (Array.isArray(obj.specDirs)) {
|
|
95
104
|
result.specDirs = obj.specDirs.filter((v) => typeof v === 'string');
|
|
96
105
|
}
|
|
106
|
+
if (Array.isArray(obj.cypressSpecDirs)) {
|
|
107
|
+
result.cypressSpecDirs = obj.cypressSpecDirs.filter((v) => typeof v === 'string');
|
|
108
|
+
}
|
|
97
109
|
if (Array.isArray(obj.tags)) {
|
|
98
110
|
result.tags = obj.tags.filter((v) => typeof v === 'string');
|
|
99
111
|
}
|
|
112
|
+
if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') {
|
|
113
|
+
result.priority = obj.priority;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(obj.userFlows)) {
|
|
116
|
+
result.userFlows = obj.userFlows.filter((v) => typeof v === 'string');
|
|
117
|
+
}
|
|
100
118
|
return result;
|
|
101
119
|
}
|
|
102
120
|
export function loadRouteFamilyManifest(testsRoot, config) {
|
|
@@ -136,7 +154,8 @@ export function loadRouteFamilyManifest(testsRoot, config) {
|
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
156
|
if (config?.strict) {
|
|
139
|
-
|
|
157
|
+
// eslint-disable-next-line no-console
|
|
158
|
+
console.warn('[e2e-agents] Route family manifest not found. The manifest is optional context for AI enrichment — create .e2e-ai-agents/route-families.json to enable family-level routing hints.');
|
|
140
159
|
}
|
|
141
160
|
return null;
|
|
142
161
|
}
|
|
@@ -193,6 +212,45 @@ export function getSpecDirsForBinding(manifest, binding) {
|
|
|
193
212
|
}
|
|
194
213
|
return family.specDirs || [];
|
|
195
214
|
}
|
|
215
|
+
export function getCypressSpecDirsForBinding(manifest, binding) {
|
|
216
|
+
const family = getFamilyById(manifest, binding.family);
|
|
217
|
+
if (!family) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
if (binding.feature) {
|
|
221
|
+
const feature = getFeatureById(family, binding.feature);
|
|
222
|
+
if (feature?.cypressSpecDirs && feature.cypressSpecDirs.length > 0) {
|
|
223
|
+
return feature.cypressSpecDirs;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return family.cypressSpecDirs || [];
|
|
227
|
+
}
|
|
228
|
+
export function getPriorityForBinding(manifest, binding) {
|
|
229
|
+
const family = getFamilyById(manifest, binding.family);
|
|
230
|
+
if (!family) {
|
|
231
|
+
return 'P2';
|
|
232
|
+
}
|
|
233
|
+
if (binding.feature) {
|
|
234
|
+
const feature = getFeatureById(family, binding.feature);
|
|
235
|
+
if (feature?.priority) {
|
|
236
|
+
return feature.priority;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return family.priority || 'P2';
|
|
240
|
+
}
|
|
241
|
+
export function getUserFlowsForBinding(manifest, binding) {
|
|
242
|
+
const family = getFamilyById(manifest, binding.family);
|
|
243
|
+
if (!family) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
if (binding.feature) {
|
|
247
|
+
const feature = getFeatureById(family, binding.feature);
|
|
248
|
+
if (feature?.userFlows && feature.userFlows.length > 0) {
|
|
249
|
+
return feature.userFlows;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return family.userFlows || [];
|
|
253
|
+
}
|
|
196
254
|
export function getRoutesForBinding(manifest, binding) {
|
|
197
255
|
const family = getFamilyById(manifest, binding.family);
|
|
198
256
|
if (!family) {
|