@yasserkhanorg/e2e-agents 1.5.0 → 1.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.
@@ -48,6 +48,7 @@ function logLevelToString(level) {
48
48
  export class Logger {
49
49
  constructor(minLevel) {
50
50
  this.level = minLevel ?? getLogLevelFromEnv();
51
+ this.jsonMode = process.env.LOG_FORMAT?.toLowerCase() === 'json';
51
52
  }
52
53
  error(message, context) {
53
54
  if (this.level >= LogLevel.ERROR) {
@@ -72,11 +73,37 @@ export class Logger {
72
73
  setLevel(level) {
73
74
  this.level = level;
74
75
  }
76
+ setJsonMode(enabled) {
77
+ this.jsonMode = enabled;
78
+ }
79
+ /**
80
+ * Start a timer for measuring duration of an operation.
81
+ * Returns an object with `end()` that logs at DEBUG level and returns elapsed ms.
82
+ */
83
+ timer(label) {
84
+ const start = performance.now();
85
+ return {
86
+ end: () => {
87
+ const elapsed = Math.round(performance.now() - start);
88
+ this.debug(`${label} completed`, { durationMs: elapsed });
89
+ return elapsed;
90
+ },
91
+ };
92
+ }
75
93
  log(level, message, context) {
76
94
  const timestamp = new Date().toISOString();
77
95
  const levelStr = logLevelToString(level);
78
- const contextStr = context ? ` ${JSON.stringify(context)}` : '';
79
- const output = `[${timestamp}] [${levelStr}] ${message}${contextStr}`;
96
+ let output;
97
+ if (this.jsonMode) {
98
+ const entry = { ts: timestamp, level: levelStr, msg: message };
99
+ if (context)
100
+ entry.ctx = context;
101
+ output = JSON.stringify(entry);
102
+ }
103
+ else {
104
+ const contextStr = context ? ` ${JSON.stringify(context)}` : '';
105
+ output = `[${timestamp}] [${levelStr}] ${message}${contextStr}`;
106
+ }
80
107
  if (level <= LogLevel.WARN) {
81
108
  console.error(output);
82
109
  }
@@ -3,6 +3,7 @@
3
3
  import { existsSync, mkdirSync, writeFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { getChangedFiles } from '../agent/git.js';
6
+ import { logger } from '../logger.js';
6
7
  import { preprocess } from './stage0_preprocess.js';
7
8
  import { runImpactStage } from './stage1_impact.js';
8
9
  import { runCoverageStage } from './stage2_coverage.js';
@@ -58,20 +59,25 @@ export async function runPipeline(config) {
58
59
  const reportPath = writeReport(config.testsRoot, emptyReport);
59
60
  return { report: emptyReport, reportPath, warnings: allWarnings };
60
61
  }
62
+ const timings = {};
61
63
  // Step 2: Preprocess — deterministic file classification + route family binding
64
+ const preprocessTimer = logger.timer('preprocess');
62
65
  const preprocessResult = preprocess(changedFiles, {
63
66
  appPath: config.appPath,
64
67
  testsRoot: config.testsRoot,
65
68
  routeFamilies: config.routeFamilies,
66
69
  apiSurface: config.apiSurface,
67
70
  });
71
+ timings.preprocess = preprocessTimer.end();
68
72
  allWarnings.push(...preprocessResult.warnings);
69
73
  let decisions = [];
70
74
  // Step 3: Impact stage — AI-powered flow identification per family
71
75
  if (stages.includes('impact')) {
76
+ const impactTimer = logger.timer('impact');
72
77
  const impactResult = await runImpactStage(preprocessResult.familyGroups, preprocessResult.manifest, preprocessResult.specIndex, preprocessResult.apiSurface, preprocessResult.context, config.impact || {});
73
78
  decisions = impactResult.decisions;
74
79
  allWarnings.push(...impactResult.warnings);
80
+ timings.impact = impactTimer.end();
75
81
  // Check cannot_determine ratio
76
82
  const cannotDetermineRatio = computeCannotDetermineRatio(decisions);
77
83
  if (cannotDetermineRatio > 0.3) {
@@ -80,18 +86,23 @@ export async function runPipeline(config) {
80
86
  }
81
87
  // Step 4: Coverage stage — AI-powered spec coverage evaluation
82
88
  if (stages.includes('coverage') && decisions.length > 0) {
89
+ const coverageTimer = logger.timer('coverage');
83
90
  const coverageResult = await runCoverageStage(decisions, preprocessResult.specIndex, preprocessResult.context, config.testsRoot, config.coverage || {});
84
91
  decisions = coverageResult.decisions;
92
+ timings.coverage = coverageTimer.end();
85
93
  allWarnings.push(...coverageResult.warnings);
86
94
  }
87
95
  // Step 5: Generation stage — AI-powered spec generation for create_spec / add_scenarios
88
96
  if (stages.includes('generation') && decisions.length > 0) {
97
+ const generationTimer = logger.timer('generation');
89
98
  const generationResult = await runGenerationStage(decisions, preprocessResult.apiSurface, config.testsRoot, config.generation || {});
90
99
  generatedSpecs = generationResult.generated;
100
+ timings.generation = generationTimer.end();
91
101
  allWarnings.push(...generationResult.warnings);
92
102
  }
93
103
  // Step 6: Heal stage — MCP-backed playwright-test-healer for failing/flaky specs
94
104
  if (stages.includes('heal')) {
105
+ const healTimer = logger.timer('heal');
95
106
  const healTargets = resolveHealTargets(config.testsRoot, {
96
107
  playwrightReportPath: config.playwrightReportPath,
97
108
  generatedSpecs,
@@ -103,6 +114,7 @@ export async function runPipeline(config) {
103
114
  else {
104
115
  allWarnings.push('Heal stage: no targets found (no failing specs in report, no generated specs).');
105
116
  }
117
+ timings.heal = healTimer.end();
106
118
  }
107
119
  // Build report
108
120
  const report = {
@@ -118,16 +130,18 @@ export async function runPipeline(config) {
118
130
  generationAgent: stages.includes('generation') ? (config.generation?.provider || 'auto') : undefined,
119
131
  },
120
132
  };
121
- const reportPath = writeReport(config.testsRoot, report, healResult);
133
+ const reportPath = writeReport(config.testsRoot, report, healResult, timings);
122
134
  return { report, reportPath, warnings: allWarnings, generated: generatedSpecs, healResult };
123
135
  }
124
- function writeReport(testsRoot, report, healResult) {
136
+ function writeReport(testsRoot, report, healResult, timings) {
125
137
  const outputDir = join(testsRoot, '.e2e-ai-agents');
126
138
  if (!existsSync(outputDir)) {
127
139
  mkdirSync(outputDir, { recursive: true });
128
140
  }
141
+ // Include timings in the JSON report if available
142
+ const reportWithTimings = timings ? { ...report, timings } : report;
129
143
  const jsonPath = join(outputDir, 'pipeline-report.json');
130
- writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf-8');
144
+ writeFileSync(jsonPath, JSON.stringify(reportWithTimings, null, 2), 'utf-8');
131
145
  const mdPath = join(outputDir, 'pipeline-report.md');
132
146
  writeFileSync(mdPath, renderMarkdown(report, healResult), 'utf-8');
133
147
  return jsonPath;
@@ -59,9 +59,47 @@ function sampleFiles(dir, maxFiles) {
59
59
  walk(dir);
60
60
  return files;
61
61
  }
62
- function buildEnrichPrompt(families, projectRoot) {
62
+ /**
63
+ * Build a shallow directory listing of the source tree (depth 2-3) so the LLM
64
+ * can suggest accurate webappPaths / serverPaths for test-derived families.
65
+ */
66
+ function getSourceTreeListing(projectRoot, maxDepth = 3) {
67
+ const lines = [];
68
+ function walk(dir, depth, prefix) {
69
+ if (depth > maxDepth || lines.length > 200)
70
+ return;
71
+ let entries;
72
+ try {
73
+ entries = readdirSync(dir).sort();
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ const dirs = entries.filter((e) => {
79
+ if (e.startsWith('.') || SKIP_DIRS.has(e))
80
+ return false;
81
+ try {
82
+ const stat = lstatSync(join(dir, e));
83
+ return !stat.isSymbolicLink() && stat.isDirectory();
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ });
89
+ for (const d of dirs) {
90
+ lines.push(`${prefix}${d}/`);
91
+ walk(join(dir, d), depth + 1, prefix + ' ');
92
+ }
93
+ }
94
+ walk(resolve(projectRoot), 0, '');
95
+ return lines.join('\n');
96
+ }
97
+ function buildEnrichPrompt(families, projectRoot, testsRoot) {
63
98
  const sections = [];
99
+ const hasTestOnlyFamilies = families.some((f) => f.webappPaths.length === 0 && f.serverPaths.length === 0);
100
+ const resolvedTestsRoot = testsRoot ? resolve(testsRoot) : resolve(projectRoot);
64
101
  for (const family of families) {
102
+ const isTestOnly = family.webappPaths.length === 0 && family.serverPaths.length === 0;
65
103
  const allDirs = [
66
104
  ...family.webappPaths.map((p) => p.replace(/\/?\*.*$/, '')),
67
105
  ...family.serverPaths.map((p) => p.replace(/\/?\*.*$/, '')),
@@ -75,10 +113,19 @@ function buildEnrichPrompt(families, projectRoot) {
75
113
  if (samples.length >= MAX_FILES_PER_FAMILY)
76
114
  break;
77
115
  }
116
+ // For test-only families, sample the test files themselves for richer context
117
+ if (isTestOnly) {
118
+ for (const specDir of family.specDirs) {
119
+ if (samples.length >= MAX_FILES_PER_FAMILY)
120
+ break;
121
+ const fullDir = join(resolvedTestsRoot, specDir);
122
+ samples.push(...sampleFiles(fullDir, MAX_FILES_PER_FAMILY - samples.length));
123
+ }
124
+ }
78
125
  // Sample spec descriptions
79
126
  const specSamples = [];
80
127
  for (const specDir of family.specDirs) {
81
- const fullDir = join(resolve(projectRoot), specDir);
128
+ const fullDir = join(resolvedTestsRoot, specDir);
82
129
  const specFiles = sampleFiles(fullDir, 5);
83
130
  for (const sf of specFiles) {
84
131
  const matches = sf.content.match(/(?:test|it|describe)\s*\(\s*['"`]([^'"`]+)/g);
@@ -87,7 +134,7 @@ function buildEnrichPrompt(families, projectRoot) {
87
134
  }
88
135
  }
89
136
  }
90
- sections.push(`## Family: ${family.id}
137
+ sections.push(`## Family: ${family.id}${isTestOnly ? ' [TEST-ONLY — needs webappPaths/serverPaths]' : ''}
91
138
  Routes (guessed): ${JSON.stringify(family.routes)}
92
139
  Webapp paths: ${JSON.stringify(family.webappPaths)}
93
140
  Server paths: ${JSON.stringify(family.serverPaths)}
@@ -102,6 +149,10 @@ Test descriptions:
102
149
  ${specSamples.length > 0 ? specSamples.map((d) => `- ${d}`).join('\n') : '(none found)'}
103
150
  `);
104
151
  }
152
+ // Include source tree listing when we have test-only families
153
+ const sourceTreeSection = hasTestOnlyFamilies
154
+ ? `\n## Source Directory Structure\nUse this to suggest accurate webappPaths and serverPaths for test-only families:\n\`\`\`\n${getSourceTreeListing(projectRoot)}\n\`\`\`\n`
155
+ : '';
105
156
  return `You are analyzing a codebase to enrich route-family definitions for an E2E test impact analysis tool.
106
157
 
107
158
  For each family below, provide:
@@ -110,6 +161,8 @@ For each family below, provide:
110
161
  3. **routes**: Improved URL patterns (e.g., "/{team}/channels/{channel}" instead of "/channels")
111
162
  4. **pageObjects**: Array of page object class names found in the code
112
163
  5. **components**: Array of UI component names relevant to this family
164
+ 6. **webappPaths**: Array of glob patterns for frontend source directories (e.g., "src/components/drafts/**"). REQUIRED for families marked [TEST-ONLY].
165
+ 7. **serverPaths**: Array of glob patterns for backend source directories. REQUIRED for families marked [TEST-ONLY].
113
166
 
114
167
  Respond in JSON format:
115
168
  \`\`\`json
@@ -120,11 +173,13 @@ Respond in JSON format:
120
173
  "userFlows": ["Flow name 1", "Flow name 2"],
121
174
  "routes": ["/improved/route/{param}"],
122
175
  "pageObjects": ["PageName"],
123
- "components": ["ComponentName"]
176
+ "components": ["ComponentName"],
177
+ "webappPaths": ["src/components/feature_name/**"],
178
+ "serverPaths": ["server/channels/api4/feature.go"]
124
179
  }
125
180
  ]
126
181
  \`\`\`
127
-
182
+ ${sourceTreeSection}
128
183
  ${sections.join('\n---\n')}`;
129
184
  }
130
185
  export function validateEntries(parsed) {
@@ -143,6 +198,8 @@ export function validateEntries(parsed) {
143
198
  userFlows: filterStrings(entry.userFlows, 500),
144
199
  pageObjects: filterStrings(entry.pageObjects, 200),
145
200
  components: filterStrings(entry.components, 200),
201
+ webappPaths: filterStrings(entry.webappPaths, 300),
202
+ serverPaths: filterStrings(entry.serverPaths, 300),
146
203
  }));
147
204
  }
148
205
  export function parseEnrichResponse(response) {
@@ -192,13 +249,22 @@ function applyEnrichment(family, enriched) {
192
249
  if (enriched.components && (!family.components || family.components.length === 0)) {
193
250
  result.components = enriched.components;
194
251
  }
252
+ // Only fill source paths when the family has none (test-derived families)
253
+ if (enriched.webappPaths && (!family.webappPaths || family.webappPaths.length === 0)) {
254
+ result.webappPaths = enriched.webappPaths;
255
+ }
256
+ if (enriched.serverPaths && (!family.serverPaths || family.serverPaths.length === 0)) {
257
+ result.serverPaths = enriched.serverPaths;
258
+ }
195
259
  return result;
196
260
  }
197
- export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD) {
261
+ export async function enrichFamilies(families, scanned, projectRoot, provider, budgetUSD, testsRoot) {
198
262
  const scannedMap = new Map(scanned.map((s) => [s.id, s]));
199
263
  const enriched = [];
200
264
  let totalTokens = 0;
201
265
  let totalCost = 0;
266
+ let requestCount = 0;
267
+ let totalResponseMs = 0;
202
268
  const skipped = [];
203
269
  // Process in chunks of 4 families
204
270
  const chunkSize = 4;
@@ -218,7 +284,7 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
218
284
  enriched.push(...chunk);
219
285
  continue;
220
286
  }
221
- let prompt = buildEnrichPrompt(scannedChunk, projectRoot);
287
+ let prompt = buildEnrichPrompt(scannedChunk, projectRoot, testsRoot);
222
288
  if (prompt.length > MAX_PROMPT_CHARS) {
223
289
  // Truncate at the last complete section boundary to avoid malformed input
224
290
  const lastSectionEnd = prompt.lastIndexOf('\n---\n', MAX_PROMPT_CHARS);
@@ -231,15 +297,18 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
231
297
  prompt = prompt.slice(0, MAX_PROMPT_CHARS);
232
298
  }
233
299
  }
234
- let timer;
300
+ let timeoutTimer;
235
301
  try {
236
302
  const timeoutPromise = new Promise((_, reject) => {
237
- timer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
303
+ timeoutTimer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
238
304
  });
305
+ const reqStart = performance.now();
239
306
  const response = await Promise.race([
240
307
  provider.generateText(prompt, { maxTokens: 4096, temperature: 0.3 }),
241
308
  timeoutPromise,
242
309
  ]);
310
+ totalResponseMs += performance.now() - reqStart;
311
+ requestCount++;
243
312
  totalTokens += (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
244
313
  totalCost += response.cost ?? 0;
245
314
  const entries = parseEnrichResponse(response.text);
@@ -260,8 +329,8 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
260
329
  enriched.push(...chunk);
261
330
  }
262
331
  finally {
263
- if (timer)
264
- clearTimeout(timer);
332
+ if (timeoutTimer)
333
+ clearTimeout(timeoutTimer);
265
334
  }
266
335
  }
267
336
  return {
@@ -269,5 +338,7 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
269
338
  tokensUsed: totalTokens,
270
339
  costUSD: Math.round(totalCost * 100) / 100,
271
340
  skippedFamilies: skipped,
341
+ requestCount,
342
+ avgResponseMs: requestCount > 0 ? Math.round(totalResponseMs / requestCount) : 0,
272
343
  };
273
344
  }
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2
2
  // See LICENSE.txt for license information.
3
+ import { execFileSync } from 'child_process';
3
4
  import { existsSync } from 'fs';
4
5
  import { join, resolve } from 'path';
5
6
  import { isGuessedRoute } from './types.js';
@@ -67,6 +68,21 @@ function scannedToRouteFamily(scanned) {
67
68
  }
68
69
  return family;
69
70
  }
71
+ /**
72
+ * Try to find a matching family ID with singular/plural normalization.
73
+ * "team" matches "teams", "emoji" matches "emoji", etc.
74
+ */
75
+ function findFuzzyMatch(id, idMap) {
76
+ if (idMap.has(id))
77
+ return id;
78
+ // Try adding 's'
79
+ if (!id.endsWith('s') && idMap.has(id + 's'))
80
+ return id + 's';
81
+ // Try removing 's'
82
+ if (id.endsWith('s') && idMap.has(id.slice(0, -1)))
83
+ return id.slice(0, -1);
84
+ return undefined;
85
+ }
70
86
  export function mergeFamilies(existing, scanned) {
71
87
  const existingFamilies = existing?.families || [];
72
88
  const existingMap = new Map(existingFamilies.map((f) => [f.id, f]));
@@ -74,9 +90,15 @@ export function mergeFamilies(existing, scanned) {
74
90
  const newFamilies = [];
75
91
  const updatedFamilies = [];
76
92
  const mergedFamilies = [];
77
- // Process existing families
93
+ // Process existing families — match scanned by exact or fuzzy ID
78
94
  for (const ef of existingFamilies) {
79
- const sf = scannedMap.get(ef.id);
95
+ let sf = scannedMap.get(ef.id);
96
+ // Try singular/plural match if exact match failed
97
+ if (!sf) {
98
+ const fuzzyId = findFuzzyMatch(ef.id, scannedMap);
99
+ if (fuzzyId)
100
+ sf = scannedMap.get(fuzzyId);
101
+ }
80
102
  if (sf) {
81
103
  mergedFamilies.push(mergeFamily(ef, sf));
82
104
  updatedFamilies.push(ef.id);
@@ -86,9 +108,10 @@ export function mergeFamilies(existing, scanned) {
86
108
  mergedFamilies.push({ ...ef });
87
109
  }
88
110
  }
89
- // Add new families from scanner
111
+ // Add new families from scanner (if no existing family matched)
90
112
  for (const sf of scanned) {
91
- if (!existingMap.has(sf.id)) {
113
+ const matchedExisting = findFuzzyMatch(sf.id, existingMap);
114
+ if (!matchedExisting) {
92
115
  mergedFamilies.push(scannedToRouteFamily(sf));
93
116
  newFamilies.push(sf.id);
94
117
  }
@@ -108,8 +131,33 @@ export function mergeFamilies(existing, scanned) {
108
131
  summary: parts.join(', '),
109
132
  };
110
133
  }
111
- export function detectStaleFamilies(manifest, projectRoot) {
112
- const resolved = resolve(projectRoot);
134
+ /**
135
+ * Detect families whose paths no longer exist on disk.
136
+ *
137
+ * Paths in the manifest may be relative to different roots:
138
+ * - webappPaths / serverPaths are typically relative to the repo root
139
+ * - specDirs may be relative to the tests root
140
+ *
141
+ * We try each pattern against all provided roots (and the git repo root
142
+ * if discoverable) to avoid false positives from path-prefix mismatches.
143
+ */
144
+ export function detectStaleFamilies(manifest, projectRoot, testsRoot) {
145
+ const roots = new Set([resolve(projectRoot)]);
146
+ if (testsRoot)
147
+ roots.add(resolve(testsRoot));
148
+ // Also try to discover the git repo root — manifest paths may be repo-relative
149
+ try {
150
+ const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
151
+ cwd: projectRoot,
152
+ encoding: 'utf-8',
153
+ stdio: ['pipe', 'pipe', 'pipe'],
154
+ }).trim();
155
+ if (gitRoot)
156
+ roots.add(resolve(gitRoot));
157
+ }
158
+ catch {
159
+ // Not a git repo or git not available — that's fine
160
+ }
113
161
  const stale = [];
114
162
  for (const family of manifest.families) {
115
163
  const allPatterns = [
@@ -119,15 +167,34 @@ export function detectStaleFamilies(manifest, projectRoot) {
119
167
  ];
120
168
  if (allPatterns.length === 0)
121
169
  continue;
122
- // Check if any pattern resolves to existing files/dirs
170
+ // Check if any pattern resolves to existing files/dirs in any root
123
171
  let hasAny = false;
124
172
  for (const pattern of allPatterns) {
125
173
  // Strip trailing glob (* or **) to get the directory
126
174
  const dirPart = pattern.replace(/\/?\*.*$/, '');
127
- if (dirPart && existsSync(join(resolved, dirPart))) {
128
- hasAny = true;
129
- break;
175
+ if (!dirPart)
176
+ continue;
177
+ // For file-level patterns like "server/channels/api4/draft*.go",
178
+ // dirPart is "server/channels/api4/draft" — check the parent dir instead
179
+ const isFileGlob = /\.\w+$/.test(pattern);
180
+ const pathsToCheck = [dirPart];
181
+ if (isFileGlob) {
182
+ const parentDir = dirPart.split('/').slice(0, -1).join('/');
183
+ if (parentDir)
184
+ pathsToCheck.push(parentDir);
185
+ }
186
+ for (const checkPath of pathsToCheck) {
187
+ for (const root of roots) {
188
+ if (existsSync(join(root, checkPath))) {
189
+ hasAny = true;
190
+ break;
191
+ }
192
+ }
193
+ if (hasAny)
194
+ break;
130
195
  }
196
+ if (hasAny)
197
+ break;
131
198
  }
132
199
  if (!hasAny) {
133
200
  stale.push(family.id);