@yasserkhanorg/e2e-agents 0.5.15 → 0.6.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 +0 -12
- 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 +10 -14
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +29 -59
- package/dist/cli.js +41 -174
- 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 +9 -0
- package/dist/engine/plan_builder.d.ts.map +1 -0
- package/dist/engine/plan_builder.js +329 -0
- package/dist/esm/agent/plan.js +1 -360
- package/dist/esm/agent/types.js +3 -0
- package/dist/esm/api.js +27 -56
- package/dist/esm/cli.js +40 -173
- package/dist/esm/engine/impact_engine.js +191 -0
- package/dist/esm/engine/plan_builder.js +323 -0
- package/dist/esm/index.js +6 -3
- package/dist/esm/knowledge/route_families.js +57 -0
- 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 +60 -0
- 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 -559
- 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 -556
- 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,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
3
|
+
// See LICENSE.txt for license information.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.buildPlanFromImpact = buildPlanFromImpact;
|
|
6
|
+
exports.writePlanReport = writePlanReport;
|
|
7
|
+
exports.renderCiSummaryMarkdown = renderCiSummaryMarkdown;
|
|
8
|
+
exports.writeCiSummary = writeCiSummary;
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const path_1 = require("path");
|
|
11
|
+
const minimatch_1 = require("minimatch");
|
|
12
|
+
const impact_engine_js_1 = require("./impact_engine.js");
|
|
13
|
+
const DEFAULT_POLICY = {
|
|
14
|
+
minConfidenceForTargeted: 60,
|
|
15
|
+
safeMergeMinConfidence: 85,
|
|
16
|
+
forceFullOnWarningsAtOrAbove: 2,
|
|
17
|
+
forceFullOnP0WithGaps: true,
|
|
18
|
+
forceFullOnRiskyFiles: true,
|
|
19
|
+
riskyFilePatterns: [
|
|
20
|
+
'**/auth/**',
|
|
21
|
+
'**/login/**',
|
|
22
|
+
'**/permissions/**',
|
|
23
|
+
'**/admin/**',
|
|
24
|
+
'**/security/**',
|
|
25
|
+
'**/migrations/**',
|
|
26
|
+
'**/schema/**',
|
|
27
|
+
'**/*.sql',
|
|
28
|
+
'**/webhook/**',
|
|
29
|
+
],
|
|
30
|
+
enforcementMode: 'advisory',
|
|
31
|
+
blockOnActions: ['must-add-tests'],
|
|
32
|
+
};
|
|
33
|
+
function featureLabel(f) {
|
|
34
|
+
return f.featureId || f.familyId;
|
|
35
|
+
}
|
|
36
|
+
function computeConfidence(impact) {
|
|
37
|
+
const gaps = (0, impact_engine_js_1.getGaps)(impact);
|
|
38
|
+
const totalFeatures = impact.impactedFeatures.length;
|
|
39
|
+
const boundRatio = totalFeatures > 0
|
|
40
|
+
? (totalFeatures / (totalFeatures + impact.unboundFiles.length))
|
|
41
|
+
: 1;
|
|
42
|
+
// Graph-resolved bindings start at 100
|
|
43
|
+
let confidence = 100;
|
|
44
|
+
// Deduct for unbound files (not mapped to any family)
|
|
45
|
+
if (impact.unboundFiles.length > 0) {
|
|
46
|
+
const unboundPenalty = Math.min(30, impact.unboundFiles.length * 3);
|
|
47
|
+
confidence -= unboundPenalty;
|
|
48
|
+
}
|
|
49
|
+
// Deduct for gaps
|
|
50
|
+
confidence -= Math.min(20, gaps.length * 5);
|
|
51
|
+
// Deduct for warnings
|
|
52
|
+
confidence -= Math.min(15, impact.warnings.length * 5);
|
|
53
|
+
// Bonus for high bound ratio
|
|
54
|
+
if (boundRatio >= 0.9) {
|
|
55
|
+
confidence = Math.min(100, confidence + 5);
|
|
56
|
+
}
|
|
57
|
+
return Math.max(0, Math.min(100, confidence));
|
|
58
|
+
}
|
|
59
|
+
function findRiskyFiles(changedFiles, patterns) {
|
|
60
|
+
return [...new Set(changedFiles.filter((file) => patterns.some((pattern) => (0, minimatch_1.minimatch)(file, pattern, { matchBase: true }))))];
|
|
61
|
+
}
|
|
62
|
+
function pickRunSet(impact, confidence, policy) {
|
|
63
|
+
const gaps = (0, impact_engine_js_1.getGaps)(impact);
|
|
64
|
+
const reasons = [];
|
|
65
|
+
const triggeredRules = [];
|
|
66
|
+
const riskyFiles = findRiskyFiles(impact.changedFiles, policy.riskyFilePatterns);
|
|
67
|
+
const hasP0 = impact.impactedFeatures.some((f) => f.priority === 'P0');
|
|
68
|
+
if (gaps.length > 0) {
|
|
69
|
+
reasons.push(`${gaps.length} uncovered P0/P1 feature(s) detected.`);
|
|
70
|
+
}
|
|
71
|
+
if (hasP0) {
|
|
72
|
+
reasons.push('P0 features are impacted by this change set.');
|
|
73
|
+
}
|
|
74
|
+
if (policy.forceFullOnRiskyFiles && riskyFiles.length > 0) {
|
|
75
|
+
triggeredRules.push('risky-files');
|
|
76
|
+
reasons.push(`Risky file patterns matched: ${riskyFiles.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
if (confidence < policy.minConfidenceForTargeted) {
|
|
79
|
+
triggeredRules.push('low-confidence');
|
|
80
|
+
}
|
|
81
|
+
if (impact.warnings.length >= policy.forceFullOnWarningsAtOrAbove) {
|
|
82
|
+
triggeredRules.push('warning-threshold');
|
|
83
|
+
reasons.push('Warning threshold exceeded.');
|
|
84
|
+
}
|
|
85
|
+
if (policy.forceFullOnP0WithGaps && hasP0 && gaps.length > 0) {
|
|
86
|
+
triggeredRules.push('p0-with-gaps');
|
|
87
|
+
}
|
|
88
|
+
if (triggeredRules.length > 0) {
|
|
89
|
+
return {
|
|
90
|
+
runSet: 'full',
|
|
91
|
+
reasons: reasons.length > 0 ? reasons : ['Policy rules triggered full suite.'],
|
|
92
|
+
triggeredRules,
|
|
93
|
+
riskyFiles,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// If we have impacted features with specs, recommend targeted
|
|
97
|
+
const coveredFeatures = impact.impactedFeatures.filter((f) => f.coverageStatus !== 'uncovered');
|
|
98
|
+
if (coveredFeatures.length > 0) {
|
|
99
|
+
return {
|
|
100
|
+
runSet: 'targeted',
|
|
101
|
+
reasons: reasons.length > 0 ? reasons : ['Impacted features have test coverage.'],
|
|
102
|
+
triggeredRules,
|
|
103
|
+
riskyFiles,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
runSet: 'smoke',
|
|
108
|
+
reasons: reasons.length > 0 ? reasons : ['No targeted tests mapped from impacted features.'],
|
|
109
|
+
triggeredRules,
|
|
110
|
+
riskyFiles,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function buildDecision(impact, runSet, confidence, policy) {
|
|
114
|
+
const gaps = (0, impact_engine_js_1.getGaps)(impact);
|
|
115
|
+
if (gaps.length > 0) {
|
|
116
|
+
return {
|
|
117
|
+
action: 'must-add-tests',
|
|
118
|
+
title: 'Must add tests',
|
|
119
|
+
summary: `Detected ${gaps.length} uncovered P0/P1 feature(s). Add or update tests before merge.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (impact.changedFiles.length === 0 && impact.impactedFeatures.length === 0) {
|
|
123
|
+
return {
|
|
124
|
+
action: 'safe-to-merge',
|
|
125
|
+
title: 'Safe to merge',
|
|
126
|
+
summary: 'No app file changes detected — no E2E coverage required for this change set.',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (runSet === 'smoke' && confidence >= policy.safeMergeMinConfidence && impact.warnings.length === 0) {
|
|
130
|
+
return {
|
|
131
|
+
action: 'safe-to-merge',
|
|
132
|
+
title: 'Safe to merge',
|
|
133
|
+
summary: 'No critical coverage gaps were detected and confidence is high.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const coveredCount = impact.impactedFeatures.filter((f) => f.coverageStatus !== 'uncovered').length;
|
|
137
|
+
const coveredSuffix = coveredCount > 0
|
|
138
|
+
? ` All ${coveredCount} impacted feature(s) have test coverage.`
|
|
139
|
+
: '';
|
|
140
|
+
return {
|
|
141
|
+
action: 'run-now',
|
|
142
|
+
title: 'Run now',
|
|
143
|
+
summary: `Impacted features are covered by existing tests.${coveredSuffix} Verify with the E2E suite before merge.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function evaluateEnforcement(decision, policy) {
|
|
147
|
+
const blockOnActions = (policy.blockOnActions && policy.blockOnActions.length > 0)
|
|
148
|
+
? [...policy.blockOnActions]
|
|
149
|
+
: ['must-add-tests'];
|
|
150
|
+
const matchedAction = blockOnActions.includes(decision.action);
|
|
151
|
+
if (policy.enforcementMode === 'block' && matchedAction) {
|
|
152
|
+
return {
|
|
153
|
+
mode: policy.enforcementMode,
|
|
154
|
+
blockOnActions,
|
|
155
|
+
matchedAction,
|
|
156
|
+
shouldFail: true,
|
|
157
|
+
summary: `Blocking mode active: decision "${decision.action}" is configured to fail CI.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (policy.enforcementMode === 'warn' && matchedAction) {
|
|
161
|
+
return {
|
|
162
|
+
mode: policy.enforcementMode,
|
|
163
|
+
blockOnActions,
|
|
164
|
+
matchedAction,
|
|
165
|
+
shouldFail: false,
|
|
166
|
+
summary: `Warning mode active: decision "${decision.action}" is advisory-only for CI.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (policy.enforcementMode === 'block') {
|
|
170
|
+
return {
|
|
171
|
+
mode: policy.enforcementMode,
|
|
172
|
+
blockOnActions,
|
|
173
|
+
matchedAction,
|
|
174
|
+
shouldFail: false,
|
|
175
|
+
summary: `Blocking mode active, but decision "${decision.action}" is not configured for CI failure.`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
mode: policy.enforcementMode,
|
|
180
|
+
blockOnActions,
|
|
181
|
+
matchedAction,
|
|
182
|
+
shouldFail: false,
|
|
183
|
+
summary: 'Advisory mode active: recommendations do not fail CI by default.',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Build recommended test list from impacted features' Playwright specs.
|
|
188
|
+
*/
|
|
189
|
+
function buildRecommendedTests(impact) {
|
|
190
|
+
const tests = [];
|
|
191
|
+
for (const feature of impact.impactedFeatures) {
|
|
192
|
+
if (feature.coverageStatus !== 'uncovered') {
|
|
193
|
+
for (const spec of feature.playwrightSpecs) {
|
|
194
|
+
if (!tests.includes(spec)) {
|
|
195
|
+
tests.push(spec);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return tests;
|
|
201
|
+
}
|
|
202
|
+
function buildPlanFromImpact(impact, policyOverride) {
|
|
203
|
+
const policy = { ...DEFAULT_POLICY, ...(policyOverride || {}) };
|
|
204
|
+
const confidence = computeConfidence(impact);
|
|
205
|
+
const runSetResult = pickRunSet(impact, confidence, policy);
|
|
206
|
+
const decision = buildDecision(impact, runSetResult.runSet, confidence, policy);
|
|
207
|
+
const enforcement = evaluateEnforcement(decision, policy);
|
|
208
|
+
const gaps = (0, impact_engine_js_1.getGaps)(impact);
|
|
209
|
+
const partialGaps = (0, impact_engine_js_1.getPartialGaps)(impact);
|
|
210
|
+
const gapDetails = gaps.map((f) => ({
|
|
211
|
+
id: featureLabel(f),
|
|
212
|
+
name: featureLabel(f),
|
|
213
|
+
priority: f.priority,
|
|
214
|
+
reasons: [`No Playwright or Cypress tests found for ${featureLabel(f)}`],
|
|
215
|
+
files: f.changedFiles.slice(0, 6),
|
|
216
|
+
missingScenarios: f.userFlows.length > 0 ? f.userFlows.slice(0, 5) : undefined,
|
|
217
|
+
}));
|
|
218
|
+
// Add partial gaps as advisory info
|
|
219
|
+
for (const f of partialGaps) {
|
|
220
|
+
const source = f.playwrightSpecs.length > 0 ? 'Cypress' : 'Playwright';
|
|
221
|
+
gapDetails.push({
|
|
222
|
+
id: featureLabel(f),
|
|
223
|
+
name: `${featureLabel(f)} (partial)`,
|
|
224
|
+
priority: f.priority,
|
|
225
|
+
reasons: [`Missing ${source} tests for ${featureLabel(f)} (has ${f.playwrightSpecs.length > 0 ? 'Playwright' : 'Cypress'} only)`],
|
|
226
|
+
files: f.changedFiles.slice(0, 6),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const coveredFlows = impact.impactedFeatures
|
|
230
|
+
.filter((f) => f.coverageStatus === 'covered')
|
|
231
|
+
.map((f) => ({
|
|
232
|
+
id: featureLabel(f),
|
|
233
|
+
name: featureLabel(f),
|
|
234
|
+
priority: f.priority,
|
|
235
|
+
coveredBy: [
|
|
236
|
+
...(f.playwrightSpecs.length > 0 ? [`${f.playwrightSpecs.length} Playwright spec(s)`] : []),
|
|
237
|
+
...(f.cypressSpecs.length > 0 ? [`${f.cypressSpecs.length} Cypress spec(s)`] : []),
|
|
238
|
+
].slice(0, 3),
|
|
239
|
+
}));
|
|
240
|
+
const recommendedTests = buildRecommendedTests(impact);
|
|
241
|
+
const requiredNewTests = gaps.map((f) => `${featureLabel(f)}: Add E2E tests`);
|
|
242
|
+
const p0 = impact.impactedFeatures.filter((f) => f.priority === 'P0').length;
|
|
243
|
+
const p1 = impact.impactedFeatures.filter((f) => f.priority === 'P1').length;
|
|
244
|
+
const p2 = impact.impactedFeatures.filter((f) => f.priority === 'P2').length;
|
|
245
|
+
const runId = `plan-${Date.now().toString(36)}`;
|
|
246
|
+
return {
|
|
247
|
+
schemaVersion: '1.0.0',
|
|
248
|
+
runId,
|
|
249
|
+
generatedAt: new Date().toISOString(),
|
|
250
|
+
source: 'impact',
|
|
251
|
+
runSet: runSetResult.runSet,
|
|
252
|
+
confidence,
|
|
253
|
+
reasons: runSetResult.reasons,
|
|
254
|
+
recommendedTests,
|
|
255
|
+
requiredNewTests,
|
|
256
|
+
gapDetails,
|
|
257
|
+
coveredFlows,
|
|
258
|
+
policy: {
|
|
259
|
+
riskyFiles: runSetResult.riskyFiles,
|
|
260
|
+
triggeredRules: runSetResult.triggeredRules,
|
|
261
|
+
applied: policy,
|
|
262
|
+
},
|
|
263
|
+
decision,
|
|
264
|
+
enforcement,
|
|
265
|
+
metrics: {
|
|
266
|
+
changedFiles: impact.changedFiles.length,
|
|
267
|
+
impactedFlows: impact.impactedFeatures.length,
|
|
268
|
+
p0Flows: p0,
|
|
269
|
+
p1Flows: p1,
|
|
270
|
+
p2Flows: p2,
|
|
271
|
+
uncoveredP0P1Flows: gaps.length,
|
|
272
|
+
warnings: impact.warnings.length,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function writePlanReport(appRoot, plan) {
|
|
277
|
+
const baseDir = (0, path_1.join)(appRoot, '.e2e-ai-agents');
|
|
278
|
+
(0, fs_1.mkdirSync)(baseDir, { recursive: true });
|
|
279
|
+
const planPath = (0, path_1.join)(baseDir, 'plan.json');
|
|
280
|
+
(0, fs_1.writeFileSync)(planPath, JSON.stringify(plan, null, 2), 'utf-8');
|
|
281
|
+
return planPath;
|
|
282
|
+
}
|
|
283
|
+
function renderCiSummaryMarkdown(plan) {
|
|
284
|
+
const lines = [];
|
|
285
|
+
const { p0Flows, p1Flows, uncoveredP0P1Flows, changedFiles, impactedFlows } = plan.metrics;
|
|
286
|
+
const mustAddTests = plan.decision.action === 'must-add-tests';
|
|
287
|
+
const statusEmoji = mustAddTests ? '🔴' : plan.decision.action === 'safe-to-merge' ? '🟢' : '🟡';
|
|
288
|
+
lines.push(`## ${statusEmoji} E2E Coverage: ${plan.decision.title}`);
|
|
289
|
+
lines.push('');
|
|
290
|
+
lines.push(`${plan.decision.summary}`);
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push(`**${changedFiles}** files changed → **${impactedFlows}** features impacted` +
|
|
293
|
+
(p0Flows > 0 || p1Flows > 0 ? ` (P0: ${p0Flows}, P1: ${p1Flows})` : ''));
|
|
294
|
+
if (mustAddTests && plan.requiredNewTests.length > 0) {
|
|
295
|
+
lines.push('');
|
|
296
|
+
lines.push('### ⚠️ Add E2E tests for these uncovered P0/P1 features');
|
|
297
|
+
lines.push('');
|
|
298
|
+
lines.push(`The following ${uncoveredP0P1Flows} feature(s) have no test coverage and must be covered before merge:`);
|
|
299
|
+
lines.push('');
|
|
300
|
+
for (const gap of plan.gapDetails.filter((g) => !g.name.includes('(partial)'))) {
|
|
301
|
+
lines.push(`- **${gap.name}** [${gap.priority}]`);
|
|
302
|
+
if (gap.missingScenarios && gap.missingScenarios.length > 0) {
|
|
303
|
+
for (const scenario of gap.missingScenarios) {
|
|
304
|
+
lines.push(` - ${scenario}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (plan.coveredFlows.length > 0) {
|
|
310
|
+
lines.push('');
|
|
311
|
+
lines.push('### ✅ Covered features');
|
|
312
|
+
lines.push('');
|
|
313
|
+
for (const flow of plan.coveredFlows) {
|
|
314
|
+
lines.push(`- **${flow.name}** [${flow.priority}] — ${flow.coveredBy.join(', ')}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (plan.confidence < 100) {
|
|
318
|
+
lines.push('');
|
|
319
|
+
lines.push(`**Confidence**: ${plan.confidence}%`);
|
|
320
|
+
}
|
|
321
|
+
return lines.join('\n');
|
|
322
|
+
}
|
|
323
|
+
function writeCiSummary(appRoot, markdown, relativePath = '.e2e-ai-agents/ci-summary.md') {
|
|
324
|
+
const fullPath = (0, path_1.join)(appRoot, relativePath);
|
|
325
|
+
const dir = (0, path_1.dirname)(fullPath);
|
|
326
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
327
|
+
(0, fs_1.writeFileSync)(fullPath, markdown, 'utf-8');
|
|
328
|
+
return fullPath;
|
|
329
|
+
}
|
package/dist/esm/agent/plan.js
CHANGED
|
@@ -1,359 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
2
2
|
// See LICENSE.txt for license information.
|
|
3
3
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import {
|
|
5
|
-
import { minimatch } from 'minimatch';
|
|
6
|
-
const DEFAULT_POLICY = {
|
|
7
|
-
minConfidenceForTargeted: 60,
|
|
8
|
-
safeMergeMinConfidence: 85,
|
|
9
|
-
forceFullOnWarningsAtOrAbove: 2,
|
|
10
|
-
forceFullOnP0WithGaps: true,
|
|
11
|
-
forceFullOnRiskyFiles: true,
|
|
12
|
-
riskyFilePatterns: [
|
|
13
|
-
'**/auth/**',
|
|
14
|
-
'**/login/**',
|
|
15
|
-
'**/permissions/**',
|
|
16
|
-
'**/admin/**',
|
|
17
|
-
'**/security/**',
|
|
18
|
-
'**/migrations/**',
|
|
19
|
-
'**/schema/**',
|
|
20
|
-
'**/*.sql',
|
|
21
|
-
'**/webhook/**',
|
|
22
|
-
],
|
|
23
|
-
enforcementMode: 'advisory',
|
|
24
|
-
blockOnActions: ['must-add-tests'],
|
|
25
|
-
};
|
|
26
|
-
function countPriority(flows) {
|
|
27
|
-
const counts = { p0: 0, p1: 0, p2: 0 };
|
|
28
|
-
for (const flow of flows) {
|
|
29
|
-
if (flow.priority === 'P0')
|
|
30
|
-
counts.p0 += 1;
|
|
31
|
-
else if (flow.priority === 'P1')
|
|
32
|
-
counts.p1 += 1;
|
|
33
|
-
else
|
|
34
|
-
counts.p2 += 1;
|
|
35
|
-
}
|
|
36
|
-
return counts;
|
|
37
|
-
}
|
|
38
|
-
function computeConfidence(impact, p0, p1) {
|
|
39
|
-
let confidence = 85;
|
|
40
|
-
confidence -= Math.min(25, impact.warnings.length * 8);
|
|
41
|
-
confidence -= Math.min(20, impact.gaps.length * 5);
|
|
42
|
-
if (p0 > 0) {
|
|
43
|
-
confidence -= Math.min(10, p0 * 3);
|
|
44
|
-
}
|
|
45
|
-
else if (p1 > 0) {
|
|
46
|
-
confidence -= Math.min(6, p1 * 2);
|
|
47
|
-
}
|
|
48
|
-
if (impact.impactModel) {
|
|
49
|
-
if (impact.impactModel.flowMapping === 'catalog') {
|
|
50
|
-
confidence += 4;
|
|
51
|
-
}
|
|
52
|
-
else if (impact.impactModel.flowMapping === 'ai') {
|
|
53
|
-
confidence += 4;
|
|
54
|
-
}
|
|
55
|
-
if (impact.impactModel.testMapping === 'catalog') {
|
|
56
|
-
confidence += 4;
|
|
57
|
-
}
|
|
58
|
-
else if (impact.impactModel.testMapping === 'traceability') {
|
|
59
|
-
confidence += 6;
|
|
60
|
-
}
|
|
61
|
-
else if (impact.impactModel.testMapping === 'ai') {
|
|
62
|
-
confidence += 4;
|
|
63
|
-
}
|
|
64
|
-
if (impact.impactModel.confidenceClass === 'medium') {
|
|
65
|
-
confidence -= 4;
|
|
66
|
-
}
|
|
67
|
-
else if (impact.impactModel.confidenceClass === 'low') {
|
|
68
|
-
confidence -= 12;
|
|
69
|
-
}
|
|
70
|
-
if (impact.impactModel.traceability) {
|
|
71
|
-
if (!impact.impactModel.traceability.manifestFound) {
|
|
72
|
-
confidence -= 6;
|
|
73
|
-
}
|
|
74
|
-
else if (impact.impactModel.traceability.coverageRatio >= 0.8) {
|
|
75
|
-
confidence += 2;
|
|
76
|
-
}
|
|
77
|
-
else if (impact.impactModel.traceability.coverageRatio < 0.5) {
|
|
78
|
-
confidence -= 4;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
if (impact.impactModel.dependencyGraph?.truncated) {
|
|
82
|
-
confidence -= 6;
|
|
83
|
-
}
|
|
84
|
-
if (impact.impactModel.dependencyGraph && impact.impactModel.dependencyGraph.expandedFiles > 0) {
|
|
85
|
-
confidence += 2;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return Math.max(0, Math.min(100, confidence));
|
|
89
|
-
}
|
|
90
|
-
function findRiskyFiles(changedFiles, patterns) {
|
|
91
|
-
const risky = changedFiles.filter((file) => patterns.some((pattern) => minimatch(file, pattern, { matchBase: true })));
|
|
92
|
-
return [...new Set(risky)];
|
|
93
|
-
}
|
|
94
|
-
function pickRunSet(impact, p0, confidence, policy) {
|
|
95
|
-
const reasons = [];
|
|
96
|
-
const triggeredRules = [];
|
|
97
|
-
const riskyFiles = findRiskyFiles(impact.changedFiles, policy.riskyFilePatterns);
|
|
98
|
-
if (impact.warnings.length > 0) {
|
|
99
|
-
reasons.push('Impact analysis emitted warnings; broader safety coverage is recommended.');
|
|
100
|
-
}
|
|
101
|
-
if (impact.gaps.length > 0) {
|
|
102
|
-
reasons.push('Uncovered P0/P1 flows were detected.');
|
|
103
|
-
}
|
|
104
|
-
if (p0 > 0) {
|
|
105
|
-
reasons.push('P0 flows are impacted by this change set.');
|
|
106
|
-
}
|
|
107
|
-
if (policy.forceFullOnRiskyFiles && riskyFiles.length > 0) {
|
|
108
|
-
triggeredRules.push('risky-files');
|
|
109
|
-
reasons.push(`Risky file patterns matched: ${riskyFiles.join(', ')}`);
|
|
110
|
-
}
|
|
111
|
-
if (impact.impactModel?.confidenceClass === 'low') {
|
|
112
|
-
triggeredRules.push('low-traceability');
|
|
113
|
-
reasons.push('Impact mapping confidence is low.');
|
|
114
|
-
}
|
|
115
|
-
if (impact.impactModel?.traceability?.manifestFound && impact.impactModel.traceability.coverageRatio < 0.4) {
|
|
116
|
-
triggeredRules.push('traceability-low-coverage');
|
|
117
|
-
reasons.push('Traceability manifest coverage is low for impacted flows; broader safety run is recommended.');
|
|
118
|
-
}
|
|
119
|
-
if (impact.impactModel?.dependencyGraph?.truncated) {
|
|
120
|
-
triggeredRules.push('dependency-graph-truncated');
|
|
121
|
-
reasons.push('Dependency graph expansion was truncated; broader safety run is recommended.');
|
|
122
|
-
}
|
|
123
|
-
if (confidence < policy.minConfidenceForTargeted) {
|
|
124
|
-
triggeredRules.push('low-confidence');
|
|
125
|
-
}
|
|
126
|
-
if (impact.warnings.length >= policy.forceFullOnWarningsAtOrAbove) {
|
|
127
|
-
triggeredRules.push('warning-threshold');
|
|
128
|
-
}
|
|
129
|
-
if (policy.forceFullOnP0WithGaps && p0 > 0 && impact.gaps.length > 0) {
|
|
130
|
-
triggeredRules.push('p0-with-gaps');
|
|
131
|
-
}
|
|
132
|
-
if (triggeredRules.length > 0) {
|
|
133
|
-
return {
|
|
134
|
-
runSet: 'full',
|
|
135
|
-
reasons: reasons.length > 0 ? reasons : ['Low confidence in targeted recommendation.'],
|
|
136
|
-
triggeredRules,
|
|
137
|
-
riskyFiles,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
if (impact.recommendedTests && impact.recommendedTests.length > 0) {
|
|
141
|
-
return {
|
|
142
|
-
runSet: 'targeted',
|
|
143
|
-
reasons: reasons.length > 0 ? reasons : ['Sufficient confidence for targeted run list.'],
|
|
144
|
-
triggeredRules,
|
|
145
|
-
riskyFiles,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
runSet: 'smoke',
|
|
150
|
-
reasons: reasons.length > 0 ? reasons : ['No targeted tests were mapped from the impacted flows.'],
|
|
151
|
-
triggeredRules,
|
|
152
|
-
riskyFiles,
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
function buildDecision(runSet, confidence, impact, policy) {
|
|
156
|
-
if (impact.gaps.length > 0) {
|
|
157
|
-
return {
|
|
158
|
-
action: 'must-add-tests',
|
|
159
|
-
title: 'Must add tests',
|
|
160
|
-
summary: `Detected ${impact.gaps.length} uncovered P0/P1 flow(s). Add or update tests before merge.`,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
if (impact.changedFiles.length === 0 && impact.flows.length === 0) {
|
|
164
|
-
return {
|
|
165
|
-
action: 'safe-to-merge',
|
|
166
|
-
title: 'Safe to merge',
|
|
167
|
-
summary: 'No app file changes detected — no E2E coverage required for this change set.',
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
if (runSet === 'smoke' && confidence >= policy.safeMergeMinConfidence && impact.warnings.length === 0) {
|
|
171
|
-
return {
|
|
172
|
-
action: 'safe-to-merge',
|
|
173
|
-
title: 'Safe to merge',
|
|
174
|
-
summary: 'No critical coverage gaps were detected and policy confidence is high.',
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
const coveredCount = impact.coverage.filter((c) => c.coveredBy.length > 0).length;
|
|
178
|
-
const coverageSuffix = coveredCount > 0
|
|
179
|
-
? ` All ${coveredCount} impacted flow(s) have test coverage.`
|
|
180
|
-
: '';
|
|
181
|
-
return {
|
|
182
|
-
action: 'run-now',
|
|
183
|
-
title: 'Run now',
|
|
184
|
-
summary: `Impacted flows are covered by existing tests.${coverageSuffix} Verify with the E2E suite before merge.`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
function evaluateEnforcement(decision, policy) {
|
|
188
|
-
const blockOnActions = (policy.blockOnActions && policy.blockOnActions.length > 0)
|
|
189
|
-
? [...policy.blockOnActions]
|
|
190
|
-
: ['must-add-tests'];
|
|
191
|
-
const matchedAction = blockOnActions.includes(decision.action);
|
|
192
|
-
if (policy.enforcementMode === 'block' && matchedAction) {
|
|
193
|
-
return {
|
|
194
|
-
mode: policy.enforcementMode,
|
|
195
|
-
blockOnActions,
|
|
196
|
-
matchedAction,
|
|
197
|
-
shouldFail: true,
|
|
198
|
-
summary: `Blocking mode active: decision "${decision.action}" is configured to fail CI.`,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
if (policy.enforcementMode === 'warn' && matchedAction) {
|
|
202
|
-
return {
|
|
203
|
-
mode: policy.enforcementMode,
|
|
204
|
-
blockOnActions,
|
|
205
|
-
matchedAction,
|
|
206
|
-
shouldFail: false,
|
|
207
|
-
summary: `Warning mode active: decision "${decision.action}" is advisory-only for CI.`,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
if (policy.enforcementMode === 'block') {
|
|
211
|
-
return {
|
|
212
|
-
mode: policy.enforcementMode,
|
|
213
|
-
blockOnActions,
|
|
214
|
-
matchedAction,
|
|
215
|
-
shouldFail: false,
|
|
216
|
-
summary: `Blocking mode active, but decision "${decision.action}" is not configured for CI failure.`,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
if (policy.enforcementMode === 'warn') {
|
|
220
|
-
return {
|
|
221
|
-
mode: policy.enforcementMode,
|
|
222
|
-
blockOnActions,
|
|
223
|
-
matchedAction,
|
|
224
|
-
shouldFail: false,
|
|
225
|
-
summary: `Warning mode active, but decision "${decision.action}" is not configured for warning.`,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
return {
|
|
229
|
-
mode: policy.enforcementMode,
|
|
230
|
-
blockOnActions,
|
|
231
|
-
matchedAction,
|
|
232
|
-
shouldFail: false,
|
|
233
|
-
summary: 'Advisory mode active: recommendations do not fail CI by default.',
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
export function refreshPlanEnforcement(plan) {
|
|
237
|
-
return {
|
|
238
|
-
...plan,
|
|
239
|
-
enforcement: evaluateEnforcement(plan.decision, plan.policy.applied),
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
export function buildPlanFromImpactReport(impact, policyOverride) {
|
|
243
|
-
if (impact.mode !== 'impact') {
|
|
244
|
-
throw new Error(`Plan generation requires impact report data, received mode=${impact.mode}`);
|
|
245
|
-
}
|
|
246
|
-
const policy = { ...DEFAULT_POLICY, ...(policyOverride || {}) };
|
|
247
|
-
const { p0, p1, p2 } = countPriority(impact.flows);
|
|
248
|
-
const confidence = computeConfidence(impact, p0, p1);
|
|
249
|
-
const runSet = pickRunSet(impact, p0, confidence, policy);
|
|
250
|
-
const decision = buildDecision(runSet.runSet, confidence, impact, policy);
|
|
251
|
-
const enforcement = evaluateEnforcement(decision, policy);
|
|
252
|
-
const requiredNewTests = impact.gaps.map((flow) => `${flow.id}: ${flow.name}`);
|
|
253
|
-
const gapDetails = impact.gaps.map((flow) => ({
|
|
254
|
-
id: flow.id,
|
|
255
|
-
name: flow.name,
|
|
256
|
-
priority: flow.priority,
|
|
257
|
-
reasons: (flow.reasons || []).slice(0, 5),
|
|
258
|
-
files: (flow.files || []).slice(0, 6),
|
|
259
|
-
existingTests: flow.existingTests && flow.existingTests.length > 0 ? flow.existingTests.slice(0, 3) : undefined,
|
|
260
|
-
missingScenarios: flow.missingScenarios && flow.missingScenarios.length > 0 ? flow.missingScenarios.slice(0, 5) : undefined,
|
|
261
|
-
}));
|
|
262
|
-
const coveredFlowIds = new Set(impact.gaps.map((g) => g.id));
|
|
263
|
-
const coveredFlows = impact.coverage
|
|
264
|
-
.filter((c) => c.coveredBy.length > 0 && !coveredFlowIds.has(c.flowId))
|
|
265
|
-
.map((c) => ({
|
|
266
|
-
id: c.flowId,
|
|
267
|
-
name: c.flowName,
|
|
268
|
-
priority: c.priority,
|
|
269
|
-
coveredBy: c.coveredBy.slice(0, 3),
|
|
270
|
-
}));
|
|
271
|
-
const sourceRunId = impact.runMetadata?.runId;
|
|
272
|
-
const runId = `plan-${sourceRunId || Date.now().toString(36)}`;
|
|
273
|
-
return {
|
|
274
|
-
schemaVersion: '1.0.0',
|
|
275
|
-
runId,
|
|
276
|
-
sourceRunId,
|
|
277
|
-
generatedAt: new Date().toISOString(),
|
|
278
|
-
source: 'impact',
|
|
279
|
-
runSet: runSet.runSet,
|
|
280
|
-
confidence,
|
|
281
|
-
reasons: runSet.reasons,
|
|
282
|
-
recommendedTests: impact.recommendedTests || [],
|
|
283
|
-
requiredNewTests,
|
|
284
|
-
gapDetails,
|
|
285
|
-
coveredFlows,
|
|
286
|
-
policy: {
|
|
287
|
-
riskyFiles: runSet.riskyFiles,
|
|
288
|
-
triggeredRules: runSet.triggeredRules,
|
|
289
|
-
applied: policy,
|
|
290
|
-
},
|
|
291
|
-
decision,
|
|
292
|
-
enforcement,
|
|
293
|
-
metrics: {
|
|
294
|
-
changedFiles: impact.changedFiles.length,
|
|
295
|
-
impactedFlows: impact.flows.length,
|
|
296
|
-
p0Flows: p0,
|
|
297
|
-
p1Flows: p1,
|
|
298
|
-
p2Flows: p2,
|
|
299
|
-
uncoveredP0P1Flows: impact.gaps.length,
|
|
300
|
-
warnings: impact.warnings.length,
|
|
301
|
-
},
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
export function attachDeveloperActions(plan, context) {
|
|
305
|
-
const safeSince = context.sinceRef ? ` --since "${context.sinceRef}"` : '';
|
|
306
|
-
const generateBaseCommand = context.configPath
|
|
307
|
-
? `npx e2e-ai-agents approve-and-generate --config "${context.configPath}" --pipeline --pipeline-mcp --pipeline-mcp-only${safeSince}`
|
|
308
|
-
: `npx e2e-ai-agents approve-and-generate --path "${context.appPath}" --tests-root "${context.testsRoot}" --pipeline --pipeline-mcp --pipeline-mcp-only${safeSince}`;
|
|
309
|
-
const runRecommendedTests = plan.recommendedTests.length > 0
|
|
310
|
-
? `node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync('${context.testsRoot}/.e2e-ai-agents/plan.json','utf8')); const tests=p.recommendedTests.map((t)=>t.replace(/ \\(flags:.*\\)$/,'')); console.log(tests.join(' '));" | xargs npx playwright test`
|
|
311
|
-
: undefined;
|
|
312
|
-
return {
|
|
313
|
-
...plan,
|
|
314
|
-
nextActions: {
|
|
315
|
-
requiresUserApprovalForGeneration: true,
|
|
316
|
-
runRecommendedTests,
|
|
317
|
-
runSmokeSuite: 'npx playwright test --grep @smoke --project=chrome',
|
|
318
|
-
runFullSuite: 'npx playwright test --project=chrome',
|
|
319
|
-
approveAndGenerate: generateBaseCommand,
|
|
320
|
-
generateMissingTests: generateBaseCommand,
|
|
321
|
-
healGeneratedTests: generateBaseCommand,
|
|
322
|
-
commitGeneratedTests: `npx e2e-ai-agents finalize-generated-tests --path "${context.appPath}" --tests-root "${context.testsRoot}" --commit-message "test(e2e): add generated coverage and healed specs"`,
|
|
323
|
-
openPullRequest: `npx e2e-ai-agents finalize-generated-tests --path "${context.appPath}" --tests-root "${context.testsRoot}" --create-pr`,
|
|
324
|
-
},
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
export function writePlanReport(appRoot, plan) {
|
|
328
|
-
const baseDir = join(appRoot, '.e2e-ai-agents');
|
|
329
|
-
mkdirSync(baseDir, { recursive: true });
|
|
330
|
-
const planPath = join(baseDir, 'plan.json');
|
|
331
|
-
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8');
|
|
332
|
-
return planPath;
|
|
333
|
-
}
|
|
334
|
-
export function renderCiSummaryMarkdown(plan) {
|
|
335
|
-
const lines = [];
|
|
336
|
-
const { p0Flows, p1Flows, uncoveredP0P1Flows, changedFiles, impactedFlows } = plan.metrics;
|
|
337
|
-
const mustAddTests = plan.decision.action === 'must-add-tests';
|
|
338
|
-
const statusEmoji = mustAddTests ? '🔴' : plan.decision.action === 'safe-to-merge' ? '🟢' : '🟡';
|
|
339
|
-
lines.push(`## ${statusEmoji} E2E Coverage: ${plan.decision.title}`);
|
|
340
|
-
lines.push('');
|
|
341
|
-
lines.push(`${plan.decision.summary}`);
|
|
342
|
-
lines.push('');
|
|
343
|
-
lines.push(`**${changedFiles}** files changed → **${impactedFlows}** flows impacted` +
|
|
344
|
-
(p0Flows > 0 || p1Flows > 0 ? ` (P0: ${p0Flows}, P1: ${p1Flows})` : ''));
|
|
345
|
-
if (mustAddTests && plan.requiredNewTests.length > 0) {
|
|
346
|
-
lines.push('');
|
|
347
|
-
lines.push('### ⚠️ Add E2E tests for these uncovered P0/P1 flows');
|
|
348
|
-
lines.push('');
|
|
349
|
-
lines.push(`The following ${uncoveredP0P1Flows} flow(s) have no test coverage and must be covered before merge:`);
|
|
350
|
-
lines.push('');
|
|
351
|
-
for (const gap of plan.requiredNewTests) {
|
|
352
|
-
lines.push(`- ${gap}`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
return lines.join('\n');
|
|
356
|
-
}
|
|
4
|
+
import { join } from 'path';
|
|
357
5
|
const PLAN_METRICS_EVENTS_PATH = '.e2e-ai-agents/metrics.jsonl';
|
|
358
6
|
const PLAN_METRICS_SUMMARY_PATH = '.e2e-ai-agents/metrics-summary.json';
|
|
359
7
|
function parsePlanMetricLine(line) {
|
|
@@ -433,10 +81,3 @@ export function appendPlanMetrics(appRoot, plan) {
|
|
|
433
81
|
writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf-8');
|
|
434
82
|
return { eventsPath, summaryPath };
|
|
435
83
|
}
|
|
436
|
-
export function writeCiSummary(appRoot, markdown, relativePath = '.e2e-ai-agents/ci-summary.md') {
|
|
437
|
-
const fullPath = join(appRoot, relativePath);
|
|
438
|
-
const dir = dirname(fullPath);
|
|
439
|
-
mkdirSync(dir, { recursive: true });
|
|
440
|
-
writeFileSync(fullPath, markdown, 'utf-8');
|
|
441
|
-
return fullPath;
|
|
442
|
-
}
|