@yasserkhanorg/e2e-agents 1.6.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.
@@ -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;
@@ -263,6 +263,8 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
263
263
  const enriched = [];
264
264
  let totalTokens = 0;
265
265
  let totalCost = 0;
266
+ let requestCount = 0;
267
+ let totalResponseMs = 0;
266
268
  const skipped = [];
267
269
  // Process in chunks of 4 families
268
270
  const chunkSize = 4;
@@ -295,15 +297,18 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
295
297
  prompt = prompt.slice(0, MAX_PROMPT_CHARS);
296
298
  }
297
299
  }
298
- let timer;
300
+ let timeoutTimer;
299
301
  try {
300
302
  const timeoutPromise = new Promise((_, reject) => {
301
- 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);
302
304
  });
305
+ const reqStart = performance.now();
303
306
  const response = await Promise.race([
304
307
  provider.generateText(prompt, { maxTokens: 4096, temperature: 0.3 }),
305
308
  timeoutPromise,
306
309
  ]);
310
+ totalResponseMs += performance.now() - reqStart;
311
+ requestCount++;
307
312
  totalTokens += (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
308
313
  totalCost += response.cost ?? 0;
309
314
  const entries = parseEnrichResponse(response.text);
@@ -324,8 +329,8 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
324
329
  enriched.push(...chunk);
325
330
  }
326
331
  finally {
327
- if (timer)
328
- clearTimeout(timer);
332
+ if (timeoutTimer)
333
+ clearTimeout(timeoutTimer);
329
334
  }
330
335
  }
331
336
  return {
@@ -333,5 +338,7 @@ export async function enrichFamilies(families, scanned, projectRoot, provider, b
333
338
  tokensUsed: totalTokens,
334
339
  costUSD: Math.round(totalCost * 100) / 100,
335
340
  skippedFamilies: skipped,
341
+ requestCount,
342
+ avgResponseMs: requestCount > 0 ? Math.round(totalResponseMs / requestCount) : 0,
336
343
  };
337
344
  }
@@ -513,15 +513,15 @@ export function discoverServerDerivedFamilies(serverRoot) {
513
513
  }
514
514
  }
515
515
  // Build families from grouped domains.
516
- // Only include server-only families that span 2 tiers (architecturally significant).
517
- const families = [];
516
+ // Multi-tier families (≥2 tiers) can be new families.
517
+ // Single-tier families can only merge into existing families.
518
+ const multiTierFamilies = [];
519
+ const singleTierFamilies = [];
518
520
  for (const [domain, paths] of familyPaths) {
519
521
  if (paths.size === 0)
520
522
  continue;
521
523
  const tierCount = familyTiers.get(domain)?.size ?? 0;
522
- if (tierCount < 2)
523
- continue; // Skip single-tier domains (likely infrastructure)
524
- families.push({
524
+ const family = {
525
525
  id: domain,
526
526
  routes: [`/${domain.replace(/_/g, '-')}`],
527
527
  webappPaths: [],
@@ -531,9 +531,15 @@ export function discoverServerDerivedFamilies(serverRoot) {
531
531
  tags: [],
532
532
  features: [],
533
533
  routesGuessed: true,
534
- });
534
+ };
535
+ if (tierCount >= 2) {
536
+ multiTierFamilies.push(family);
537
+ }
538
+ else {
539
+ singleTierFamilies.push(family);
540
+ }
535
541
  }
536
- return families;
542
+ return { multiTierFamilies, singleTierFamilies };
537
543
  }
538
544
  export function discoverTestDerivedFamilies(testsRoot) {
539
545
  const resolved = resolve(testsRoot);
@@ -620,7 +626,113 @@ export function discoverTestDerivedFamilies(testsRoot) {
620
626
  }
621
627
  return Array.from(familyMap.values());
622
628
  }
623
- export function scanProject(projectRoot, testsRoot, serverRoot) {
629
+ /**
630
+ * Discover test library paths (page objects, helpers) organized by feature.
631
+ * Walks well-known test lib directories and maps subdirectories to family IDs.
632
+ */
633
+ export function discoverTestLibPaths(testsRoot) {
634
+ const resolved = resolve(testsRoot);
635
+ const result = new Map();
636
+ const libDirs = [
637
+ 'lib/src/ui/components',
638
+ 'lib/src/ui/pages',
639
+ 'lib/src/server',
640
+ ];
641
+ for (const libDir of libDirs) {
642
+ const fullDir = join(resolved, libDir);
643
+ if (!existsSync(fullDir))
644
+ continue;
645
+ let entries;
646
+ try {
647
+ entries = readdirSync(fullDir);
648
+ }
649
+ catch {
650
+ continue;
651
+ }
652
+ for (const entry of entries) {
653
+ if (isSkipped(entry))
654
+ continue;
655
+ const fullPath = join(fullDir, entry);
656
+ try {
657
+ const stat = lstatSync(fullPath);
658
+ if (stat.isSymbolicLink() || !stat.isDirectory())
659
+ continue;
660
+ }
661
+ catch {
662
+ continue;
663
+ }
664
+ const familyId = normalizeId(entry);
665
+ const relPath = relative(resolved, fullPath).replace(/\\/g, '/');
666
+ const pattern = `${relPath}/*`;
667
+ if (!result.has(familyId))
668
+ result.set(familyId, []);
669
+ result.get(familyId).push(pattern);
670
+ }
671
+ }
672
+ return result;
673
+ }
674
+ /**
675
+ * Discover files in well-known directories (types, utils) whose basename
676
+ * maps directly to a family ID.
677
+ */
678
+ export function discoverNameMatchedPaths(appPath, gitRepoRoot) {
679
+ const result = new Map();
680
+ const resolvedApp = resolve(appPath);
681
+ const scanRoots = [
682
+ { root: join(resolvedApp, 'src/utils'), base: resolvedApp },
683
+ { root: join(resolvedApp, 'src/types'), base: resolvedApp },
684
+ ];
685
+ // Monorepo-aware: scan platform types directory
686
+ if (gitRepoRoot) {
687
+ const resolvedGitRoot = resolve(gitRepoRoot);
688
+ const platformTypes = join(resolvedGitRoot, 'webapp/platform/types/src');
689
+ if (existsSync(platformTypes)) {
690
+ scanRoots.push({ root: platformTypes, base: resolvedGitRoot });
691
+ }
692
+ const platformClient = join(resolvedGitRoot, 'webapp/platform/client/src');
693
+ if (existsSync(platformClient)) {
694
+ scanRoots.push({ root: platformClient, base: resolvedGitRoot });
695
+ }
696
+ }
697
+ for (const { root, base } of scanRoots) {
698
+ if (!existsSync(root))
699
+ continue;
700
+ let entries;
701
+ try {
702
+ entries = readdirSync(root);
703
+ }
704
+ catch {
705
+ continue;
706
+ }
707
+ for (const entry of entries) {
708
+ if (entry.startsWith('.'))
709
+ continue;
710
+ const ext = entry.slice(entry.lastIndexOf('.'));
711
+ if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext))
712
+ continue;
713
+ const fullPath = join(root, entry);
714
+ try {
715
+ const stat = lstatSync(fullPath);
716
+ if (!stat.isFile() || stat.isSymbolicLink())
717
+ continue;
718
+ }
719
+ catch {
720
+ continue;
721
+ }
722
+ // Strip extension and normalize
723
+ const baseName = entry.slice(0, entry.lastIndexOf('.'));
724
+ const familyId = normalizeId(baseName);
725
+ if (familyId.length < 3)
726
+ continue;
727
+ const relPath = relative(base, fullPath).replace(/\\/g, '/');
728
+ if (!result.has(familyId))
729
+ result.set(familyId, []);
730
+ result.get(familyId).push(relPath);
731
+ }
732
+ }
733
+ return result;
734
+ }
735
+ export function scanProject(projectRoot, testsRoot, serverRoot, gitRepoRoot) {
624
736
  const resolved = resolve(projectRoot);
625
737
  const resolvedTestsRoot = testsRoot ? resolve(testsRoot) : resolved;
626
738
  const sourceDirs = discoverSourceDirs(resolved);
@@ -683,9 +795,12 @@ export function scanProject(projectRoot, testsRoot, serverRoot) {
683
795
  // When a separate serverRoot is provided, discover families from Go source
684
796
  // filenames across the three-tier backend (api4, app, store).
685
797
  if (serverRoot) {
686
- const serverFamilies = discoverServerDerivedFamilies(resolve(serverRoot));
798
+ const { multiTierFamilies: serverMulti, singleTierFamilies: serverSingle } = discoverServerDerivedFamilies(resolve(serverRoot));
687
799
  const existingIds = new Set(families.map((f) => f.id));
688
- for (const sf of serverFamilies) {
800
+ // Merge ALL server families (multi + single tier) into existing families,
801
+ // but only add NEW families if they span ≥2 tiers.
802
+ const allServerFamilies = [...serverMulti, ...serverSingle];
803
+ for (const sf of allServerFamilies) {
689
804
  // Try exact match, then singular/plural variants
690
805
  let target = families.find((f) => f.id === sf.id);
691
806
  if (!target && !sf.id.endsWith('s')) {
@@ -702,13 +817,53 @@ export function scanProject(projectRoot, testsRoot, serverRoot) {
702
817
  }
703
818
  }
704
819
  }
705
- else {
706
- // New server-only family
820
+ else if (serverMulti.includes(sf)) {
821
+ // Only add new families if they span ≥2 tiers
707
822
  families.push(sf);
708
823
  existingIds.add(sf.id);
709
824
  }
710
825
  }
711
826
  }
827
+ // Merge test library paths (page objects, helpers) into existing families
828
+ if (testsRoot) {
829
+ const testLibPaths = discoverTestLibPaths(resolvedTestsRoot);
830
+ for (const [libFamilyId, patterns] of testLibPaths) {
831
+ let target = families.find((f) => f.id === libFamilyId);
832
+ if (!target && !libFamilyId.endsWith('s')) {
833
+ target = families.find((f) => f.id === libFamilyId + 's');
834
+ }
835
+ if (!target && libFamilyId.endsWith('s')) {
836
+ target = families.find((f) => f.id === libFamilyId.slice(0, -1));
837
+ }
838
+ if (target) {
839
+ for (const p of patterns) {
840
+ if (!target.webappPaths.includes(p)) {
841
+ target.webappPaths.push(p);
842
+ }
843
+ }
844
+ }
845
+ }
846
+ }
847
+ // Merge name-matched type/util files into existing families
848
+ {
849
+ const nameMatchedPaths = discoverNameMatchedPaths(resolved, gitRepoRoot);
850
+ for (const [nmFamilyId, paths] of nameMatchedPaths) {
851
+ let target = families.find((f) => f.id === nmFamilyId);
852
+ if (!target && !nmFamilyId.endsWith('s')) {
853
+ target = families.find((f) => f.id === nmFamilyId + 's');
854
+ }
855
+ if (!target && nmFamilyId.endsWith('s')) {
856
+ target = families.find((f) => f.id === nmFamilyId.slice(0, -1));
857
+ }
858
+ if (target) {
859
+ for (const p of paths) {
860
+ if (!target.webappPaths.includes(p)) {
861
+ target.webappPaths.push(p);
862
+ }
863
+ }
864
+ }
865
+ }
866
+ }
712
867
  const familyIds = new Set(families.map((f) => f.id));
713
868
  const unmatchedSourceDirs = sourceDirs.filter((d) => !familyIds.has(normalizeId(d.familyHint)));
714
869
  const unmatchedTestDirs = testDirs.filter((d) => !familyIds.has(normalizeId(d.familyHint)));
@@ -3,6 +3,62 @@
3
3
  import { execFileSync } from 'child_process';
4
4
  import { resolve } from 'path';
5
5
  import { bindFilesToFamilies } from '../knowledge/route_families.js';
6
+ /**
7
+ * Glob-style patterns for infrastructure / cross-cutting files that will never
8
+ * belong to a single route family. Excluded from coverage calculations.
9
+ */
10
+ const INFRA_GLOBS = [
11
+ 'Makefile', 'go.mod', 'go.sum',
12
+ '*.lock',
13
+ '**/mocks/*', '**/storetest/*', '**/testlib/*',
14
+ '**/i18n/*',
15
+ '**/.github/*', '**/scripts/*',
16
+ '**/docker-compose*',
17
+ '**/__fixtures__/*', '**/test_templates/*',
18
+ ];
19
+ /**
20
+ * Check if a file path matches any infrastructure glob pattern.
21
+ * Uses simple string matching — no external glob library needed.
22
+ */
23
+ export function isInfraFile(filePath) {
24
+ const normalized = filePath.replace(/\\/g, '/');
25
+ for (const pattern of INFRA_GLOBS) {
26
+ if (pattern.startsWith('**/')) {
27
+ // Match anywhere in the path
28
+ const suffix = pattern.slice(3);
29
+ if (suffix.endsWith('/*')) {
30
+ // Directory match: **/mocks/* → any segment named "mocks" with a child
31
+ const dirName = suffix.slice(0, -2);
32
+ if (normalized.includes(`/${dirName}/`) || normalized.startsWith(`${dirName}/`))
33
+ return true;
34
+ }
35
+ else if (suffix.endsWith('*')) {
36
+ // Prefix match: **/docker-compose* → file starting with docker-compose
37
+ const prefix = suffix.slice(0, -1);
38
+ const base = normalized.split('/').pop() || '';
39
+ if (base.startsWith(prefix))
40
+ return true;
41
+ }
42
+ else {
43
+ if (normalized.endsWith(`/${suffix}`) || normalized === suffix)
44
+ return true;
45
+ }
46
+ }
47
+ else if (pattern.startsWith('*.')) {
48
+ // Extension match: *.lock
49
+ const ext = pattern.slice(1);
50
+ if (normalized.endsWith(ext))
51
+ return true;
52
+ }
53
+ else {
54
+ // Exact basename match: Makefile, go.mod, go.sum
55
+ const base = normalized.split('/').pop() || '';
56
+ if (base === pattern)
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
6
62
  export function parseGitLog(log) {
7
63
  const commits = [];
8
64
  let current = null;
@@ -49,10 +105,10 @@ export function getCommitFiles(projectRoot, since) {
49
105
  return parseGitLog(log);
50
106
  }
51
107
  export function validateCommit(manifest, files, hash, message) {
52
- // Filter out non-source files
108
+ // Filter out non-source files and infrastructure files
53
109
  const sourceFiles = files.filter((f) => {
54
110
  return !f.endsWith('.md') && !f.endsWith('.json') && !f.endsWith('.yml') && !f.endsWith('.yaml') &&
55
- !f.startsWith('.') && !f.includes('node_modules/');
111
+ !f.startsWith('.') && !f.includes('node_modules/') && !isInfraFile(f);
56
112
  });
57
113
  if (sourceFiles.length === 0) {
58
114
  return { hash, message, changedFiles: [], boundFiles: 0, unboundFiles: [], familiesHit: [] };
package/dist/logger.d.ts CHANGED
@@ -11,12 +11,21 @@ export declare enum LogLevel {
11
11
  }
12
12
  export declare class Logger {
13
13
  private level;
14
+ private jsonMode;
14
15
  constructor(minLevel?: LogLevel);
15
16
  error(message: string, context?: Record<string, unknown>): void;
16
17
  warn(message: string, context?: Record<string, unknown>): void;
17
18
  info(message: string, context?: Record<string, unknown>): void;
18
19
  debug(message: string, context?: Record<string, unknown>): void;
19
20
  setLevel(level: LogLevel): void;
21
+ setJsonMode(enabled: boolean): void;
22
+ /**
23
+ * Start a timer for measuring duration of an operation.
24
+ * Returns an object with `end()` that logs at DEBUG level and returns elapsed ms.
25
+ */
26
+ timer(label: string): {
27
+ end: () => number;
28
+ };
20
29
  private log;
21
30
  }
22
31
  export declare const logger: Logger;
@@ -1 +1 @@
1
- {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,oBAAY,QAAQ;IAChB,KAAK,IAAI;IACT,IAAI,IAAI;IACR,IAAI,IAAI;IACR,KAAK,IAAI;CACZ;AAqCD,qBAAa,MAAM;IACf,OAAO,CAAC,KAAK,CAAW;gBAEZ,QAAQ,CAAC,EAAE,QAAQ;IAI/B,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM/D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM9D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM/D,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI;IAI/B,OAAO,CAAC,GAAG;CAYd;AAGD,eAAO,MAAM,MAAM,QAAe,CAAC"}
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,oBAAY,QAAQ;IAChB,KAAK,IAAI;IACT,IAAI,IAAI;IACR,IAAI,IAAI;IACR,KAAK,IAAI;CACZ;AAqCD,qBAAa,MAAM;IACf,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,QAAQ,CAAU;gBAEd,QAAQ,CAAC,EAAE,QAAQ;IAK/B,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM/D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM9D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAM/D,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,IAAI;IAI/B,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAInC;;;OAGG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG;QAAC,GAAG,EAAE,MAAM,MAAM,CAAA;KAAC;IAWzC,OAAO,CAAC,GAAG;CAoBd;AAGD,eAAO,MAAM,MAAM,QAAe,CAAC"}
package/dist/logger.js CHANGED
@@ -51,6 +51,7 @@ function logLevelToString(level) {
51
51
  class Logger {
52
52
  constructor(minLevel) {
53
53
  this.level = minLevel ?? getLogLevelFromEnv();
54
+ this.jsonMode = process.env.LOG_FORMAT?.toLowerCase() === 'json';
54
55
  }
55
56
  error(message, context) {
56
57
  if (this.level >= LogLevel.ERROR) {
@@ -75,11 +76,37 @@ class Logger {
75
76
  setLevel(level) {
76
77
  this.level = level;
77
78
  }
79
+ setJsonMode(enabled) {
80
+ this.jsonMode = enabled;
81
+ }
82
+ /**
83
+ * Start a timer for measuring duration of an operation.
84
+ * Returns an object with `end()` that logs at DEBUG level and returns elapsed ms.
85
+ */
86
+ timer(label) {
87
+ const start = performance.now();
88
+ return {
89
+ end: () => {
90
+ const elapsed = Math.round(performance.now() - start);
91
+ this.debug(`${label} completed`, { durationMs: elapsed });
92
+ return elapsed;
93
+ },
94
+ };
95
+ }
78
96
  log(level, message, context) {
79
97
  const timestamp = new Date().toISOString();
80
98
  const levelStr = logLevelToString(level);
81
- const contextStr = context ? ` ${JSON.stringify(context)}` : '';
82
- const output = `[${timestamp}] [${levelStr}] ${message}${contextStr}`;
99
+ let output;
100
+ if (this.jsonMode) {
101
+ const entry = { ts: timestamp, level: levelStr, msg: message };
102
+ if (context)
103
+ entry.ctx = context;
104
+ output = JSON.stringify(entry);
105
+ }
106
+ else {
107
+ const contextStr = context ? ` ${JSON.stringify(context)}` : '';
108
+ output = `[${timestamp}] [${levelStr}] ${message}${contextStr}`;
109
+ }
83
110
  if (level <= LogLevel.WARN) {
84
111
  console.error(output);
85
112
  }
@@ -1 +1 @@
1
- {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../src/pipeline/orchestrator.ts"],"names":[],"mappings":"AAQA,OAAO,EAAiB,KAAK,YAAY,EAAC,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAmB,KAAK,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAqB,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAC,MAAM,wBAAwB,CAAC;AACrG,OAAO,EAAuD,KAAK,UAAU,EAAE,KAAK,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACxH,OAAO,EAAe,KAAK,kBAAkB,EAAoB,MAAM,gCAAgC,CAAC;AAExG,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,gCAAgC,CAAC;AACtE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,6BAA6B,CAAC;AAElE,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,MAAM,CAAC,EAAE,KAAK,CAAC,YAAY,GAAG,QAAQ,GAAG,UAAU,GAAG,YAAY,GAAG,MAAM,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,cAAc;IAC3B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAqBD,wBAAsB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAgIjF"}
1
+ {"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../src/pipeline/orchestrator.ts"],"names":[],"mappings":"AAQA,OAAO,EAAiB,KAAK,YAAY,EAAC,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAmB,KAAK,cAAc,EAAC,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAqB,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAC,MAAM,wBAAwB,CAAC;AACrG,OAAO,EAAuD,KAAK,UAAU,EAAE,KAAK,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACxH,OAAO,EAAe,KAAK,kBAAkB,EAAoB,MAAM,gCAAgC,CAAC;AAExG,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,gCAAgC,CAAC;AACtE,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,6BAA6B,CAAC;AAElE,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,MAAM,CAAC,EAAE,KAAK,CAAC,YAAY,GAAG,QAAQ,GAAG,UAAU,GAAG,YAAY,GAAG,MAAM,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,cAAc;IAC3B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAqBD,wBAAsB,WAAW,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CA6IjF"}
@@ -6,6 +6,7 @@ exports.runPipeline = runPipeline;
6
6
  const fs_1 = require("fs");
7
7
  const path_1 = require("path");
8
8
  const git_js_1 = require("../agent/git.js");
9
+ const logger_js_1 = require("../logger.js");
9
10
  const stage0_preprocess_js_1 = require("./stage0_preprocess.js");
10
11
  const stage1_impact_js_1 = require("./stage1_impact.js");
11
12
  const stage2_coverage_js_1 = require("./stage2_coverage.js");
@@ -61,20 +62,25 @@ async function runPipeline(config) {
61
62
  const reportPath = writeReport(config.testsRoot, emptyReport);
62
63
  return { report: emptyReport, reportPath, warnings: allWarnings };
63
64
  }
65
+ const timings = {};
64
66
  // Step 2: Preprocess — deterministic file classification + route family binding
67
+ const preprocessTimer = logger_js_1.logger.timer('preprocess');
65
68
  const preprocessResult = (0, stage0_preprocess_js_1.preprocess)(changedFiles, {
66
69
  appPath: config.appPath,
67
70
  testsRoot: config.testsRoot,
68
71
  routeFamilies: config.routeFamilies,
69
72
  apiSurface: config.apiSurface,
70
73
  });
74
+ timings.preprocess = preprocessTimer.end();
71
75
  allWarnings.push(...preprocessResult.warnings);
72
76
  let decisions = [];
73
77
  // Step 3: Impact stage — AI-powered flow identification per family
74
78
  if (stages.includes('impact')) {
79
+ const impactTimer = logger_js_1.logger.timer('impact');
75
80
  const impactResult = await (0, stage1_impact_js_1.runImpactStage)(preprocessResult.familyGroups, preprocessResult.manifest, preprocessResult.specIndex, preprocessResult.apiSurface, preprocessResult.context, config.impact || {});
76
81
  decisions = impactResult.decisions;
77
82
  allWarnings.push(...impactResult.warnings);
83
+ timings.impact = impactTimer.end();
78
84
  // Check cannot_determine ratio
79
85
  const cannotDetermineRatio = (0, guardrails_js_1.computeCannotDetermineRatio)(decisions);
80
86
  if (cannotDetermineRatio > 0.3) {
@@ -83,18 +89,23 @@ async function runPipeline(config) {
83
89
  }
84
90
  // Step 4: Coverage stage — AI-powered spec coverage evaluation
85
91
  if (stages.includes('coverage') && decisions.length > 0) {
92
+ const coverageTimer = logger_js_1.logger.timer('coverage');
86
93
  const coverageResult = await (0, stage2_coverage_js_1.runCoverageStage)(decisions, preprocessResult.specIndex, preprocessResult.context, config.testsRoot, config.coverage || {});
87
94
  decisions = coverageResult.decisions;
95
+ timings.coverage = coverageTimer.end();
88
96
  allWarnings.push(...coverageResult.warnings);
89
97
  }
90
98
  // Step 5: Generation stage — AI-powered spec generation for create_spec / add_scenarios
91
99
  if (stages.includes('generation') && decisions.length > 0) {
100
+ const generationTimer = logger_js_1.logger.timer('generation');
92
101
  const generationResult = await (0, stage3_generation_js_1.runGenerationStage)(decisions, preprocessResult.apiSurface, config.testsRoot, config.generation || {});
93
102
  generatedSpecs = generationResult.generated;
103
+ timings.generation = generationTimer.end();
94
104
  allWarnings.push(...generationResult.warnings);
95
105
  }
96
106
  // Step 6: Heal stage — MCP-backed playwright-test-healer for failing/flaky specs
97
107
  if (stages.includes('heal')) {
108
+ const healTimer = logger_js_1.logger.timer('heal');
98
109
  const healTargets = (0, stage4_heal_js_1.resolveHealTargets)(config.testsRoot, {
99
110
  playwrightReportPath: config.playwrightReportPath,
100
111
  generatedSpecs,
@@ -106,6 +117,7 @@ async function runPipeline(config) {
106
117
  else {
107
118
  allWarnings.push('Heal stage: no targets found (no failing specs in report, no generated specs).');
108
119
  }
120
+ timings.heal = healTimer.end();
109
121
  }
110
122
  // Build report
111
123
  const report = {
@@ -121,16 +133,18 @@ async function runPipeline(config) {
121
133
  generationAgent: stages.includes('generation') ? (config.generation?.provider || 'auto') : undefined,
122
134
  },
123
135
  };
124
- const reportPath = writeReport(config.testsRoot, report, healResult);
136
+ const reportPath = writeReport(config.testsRoot, report, healResult, timings);
125
137
  return { report, reportPath, warnings: allWarnings, generated: generatedSpecs, healResult };
126
138
  }
127
- function writeReport(testsRoot, report, healResult) {
139
+ function writeReport(testsRoot, report, healResult, timings) {
128
140
  const outputDir = (0, path_1.join)(testsRoot, '.e2e-ai-agents');
129
141
  if (!(0, fs_1.existsSync)(outputDir)) {
130
142
  (0, fs_1.mkdirSync)(outputDir, { recursive: true });
131
143
  }
144
+ // Include timings in the JSON report if available
145
+ const reportWithTimings = timings ? { ...report, timings } : report;
132
146
  const jsonPath = (0, path_1.join)(outputDir, 'pipeline-report.json');
133
- (0, fs_1.writeFileSync)(jsonPath, JSON.stringify(report, null, 2), 'utf-8');
147
+ (0, fs_1.writeFileSync)(jsonPath, JSON.stringify(reportWithTimings, null, 2), 'utf-8');
134
148
  const mdPath = (0, path_1.join)(outputDir, 'pipeline-report.md');
135
149
  (0, fs_1.writeFileSync)(mdPath, renderMarkdown(report, healResult), 'utf-8');
136
150
  return jsonPath;
@@ -1 +1 @@
1
- {"version":3,"file":"enricher.d.ts","sourceRoot":"","sources":["../../src/training/enricher.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAC1D,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gCAAgC,CAAC;AAGhE,OAAO,KAAK,EAAC,gBAAgB,EAAE,aAAa,EAAC,MAAM,YAAY,CAAC;AAkLhE,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,aAAa,EAAE,CAmBlE;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,EAAE,CAwBrE;AAkCD,wBAAsB,cAAc,CAChC,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,EAAE,aAAa,EAAE,EACxB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,WAAW,EACrB,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,gBAAgB,CAAC,CAgF3B"}
1
+ {"version":3,"file":"enricher.d.ts","sourceRoot":"","sources":["../../src/training/enricher.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,0BAA0B,CAAC;AAC1D,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,gCAAgC,CAAC;AAGhE,OAAO,KAAK,EAAC,gBAAgB,EAAE,aAAa,EAAC,MAAM,YAAY,CAAC;AAkLhE,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,aAAa,EAAE,CAmBlE;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,EAAE,CAwBrE;AAkCD,wBAAsB,cAAc,CAChC,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,EAAE,aAAa,EAAE,EACxB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,WAAW,EACrB,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,gBAAgB,CAAC,CAuF3B"}
@@ -268,6 +268,8 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
268
268
  const enriched = [];
269
269
  let totalTokens = 0;
270
270
  let totalCost = 0;
271
+ let requestCount = 0;
272
+ let totalResponseMs = 0;
271
273
  const skipped = [];
272
274
  // Process in chunks of 4 families
273
275
  const chunkSize = 4;
@@ -300,15 +302,18 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
300
302
  prompt = prompt.slice(0, MAX_PROMPT_CHARS);
301
303
  }
302
304
  }
303
- let timer;
305
+ let timeoutTimer;
304
306
  try {
305
307
  const timeoutPromise = new Promise((_, reject) => {
306
- timer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
308
+ timeoutTimer = setTimeout(() => reject(new Error('LLM request timed out')), LLM_TIMEOUT_MS);
307
309
  });
310
+ const reqStart = performance.now();
308
311
  const response = await Promise.race([
309
312
  provider.generateText(prompt, { maxTokens: 4096, temperature: 0.3 }),
310
313
  timeoutPromise,
311
314
  ]);
315
+ totalResponseMs += performance.now() - reqStart;
316
+ requestCount++;
312
317
  totalTokens += (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
313
318
  totalCost += response.cost ?? 0;
314
319
  const entries = parseEnrichResponse(response.text);
@@ -329,8 +334,8 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
329
334
  enriched.push(...chunk);
330
335
  }
331
336
  finally {
332
- if (timer)
333
- clearTimeout(timer);
337
+ if (timeoutTimer)
338
+ clearTimeout(timeoutTimer);
334
339
  }
335
340
  }
336
341
  return {
@@ -338,5 +343,7 @@ async function enrichFamilies(families, scanned, projectRoot, provider, budgetUS
338
343
  tokensUsed: totalTokens,
339
344
  costUSD: Math.round(totalCost * 100) / 100,
340
345
  skippedFamilies: skipped,
346
+ requestCount,
347
+ avgResponseMs: requestCount > 0 ? Math.round(totalResponseMs / requestCount) : 0,
341
348
  };
342
349
  }