e2e-ai 1.2.0 → 1.4.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  E2eAiConfigSchema,
3
3
  defineConfig
4
- } from "../cli-ph82pe4b.js";
4
+ } from "../cli-fgp618yt.js";
5
5
  import"../cli-wckvcay0.js";
6
6
  export {
7
7
  defineConfig,
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@ import {
2
2
  getPackageRoot,
3
3
  getProjectRoot,
4
4
  loadConfig
5
- } from "./cli-ba9d3pdp.js";
5
+ } from "./cli-kx32qnf3.js";
6
6
  import {
7
7
  defineConfig
8
- } from "./cli-ph82pe4b.js";
8
+ } from "./cli-fgp618yt.js";
9
9
  import"./cli-wckvcay0.js";
10
10
  export {
11
11
  loadConfig,
package/dist/mcp.js CHANGED
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- loadAgent,
4
- scanCodebase
5
- } from "./cli-g7cc13w2.js";
3
+ loadAgent
4
+ } from "./cli-98db6h2q.js";
6
5
  import {
7
6
  getPackageRoot
8
- } from "./cli-ba9d3pdp.js";
7
+ } from "./cli-kx32qnf3.js";
9
8
  import {
10
9
  $ZodObject,
11
10
  $ZodType,
@@ -38,7 +37,7 @@ import {
38
37
  toJSONSchema,
39
38
  union,
40
39
  unknown
41
- } from "./cli-ph82pe4b.js";
40
+ } from "./cli-fgp618yt.js";
42
41
  import {
43
42
  __commonJS,
44
43
  __toESM
@@ -14857,8 +14856,72 @@ class StdioServerTransport {
14857
14856
  }
14858
14857
 
14859
14858
  // src/mcp.ts
14860
- import { readFileSync } from "node:fs";
14861
- import { join } from "node:path";
14859
+ import { readFileSync as readFileSync2 } from "node:fs";
14860
+ import { join as join2 } from "node:path";
14861
+
14862
+ // src/utils/scan.ts
14863
+ import { readdirSync, existsSync, readFileSync } from "node:fs";
14864
+ import { join, relative } from "node:path";
14865
+ async function scanCodebase(root) {
14866
+ const scan = {
14867
+ testFiles: [],
14868
+ configFiles: [],
14869
+ fixtureFiles: [],
14870
+ featureFiles: [],
14871
+ tsconfigPaths: {},
14872
+ playwrightConfig: null,
14873
+ sampleTestContent: null
14874
+ };
14875
+ function walk(dir, depth = 0) {
14876
+ if (depth > 5)
14877
+ return [];
14878
+ const files = [];
14879
+ try {
14880
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
14881
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist")
14882
+ continue;
14883
+ const full = join(dir, entry.name);
14884
+ if (entry.isDirectory()) {
14885
+ files.push(...walk(full, depth + 1));
14886
+ } else {
14887
+ files.push(full);
14888
+ }
14889
+ }
14890
+ } catch {}
14891
+ return files;
14892
+ }
14893
+ const allFiles = walk(root);
14894
+ for (const file of allFiles) {
14895
+ const rel = relative(root, file);
14896
+ if (rel.endsWith(".test.ts") || rel.endsWith(".spec.ts")) {
14897
+ scan.testFiles.push(rel);
14898
+ if (!scan.sampleTestContent && scan.testFiles.length <= 3) {
14899
+ try {
14900
+ scan.sampleTestContent = readFileSync(file, "utf-8").slice(0, 3000);
14901
+ } catch {}
14902
+ }
14903
+ }
14904
+ if (rel.endsWith(".feature.ts"))
14905
+ scan.featureFiles.push(rel);
14906
+ if (rel.includes("fixture") && rel.endsWith(".ts"))
14907
+ scan.fixtureFiles.push(rel);
14908
+ if (rel === "playwright.config.ts" || rel === "playwright.config.js")
14909
+ scan.playwrightConfig = rel;
14910
+ if (rel === "tsconfig.json" || rel.endsWith("/tsconfig.json")) {
14911
+ try {
14912
+ const tsconfig = JSON.parse(readFileSync(file, "utf-8"));
14913
+ if (tsconfig.compilerOptions?.paths) {
14914
+ scan.tsconfigPaths = { ...scan.tsconfigPaths, ...tsconfig.compilerOptions.paths };
14915
+ }
14916
+ } catch {}
14917
+ }
14918
+ }
14919
+ for (const name of ["playwright.config.ts", "vitest.config.ts", "jest.config.ts", "tsconfig.json", "package.json"]) {
14920
+ if (existsSync(join(root, name)))
14921
+ scan.configFiles.push(name);
14922
+ }
14923
+ return scan;
14924
+ }
14862
14925
 
14863
14926
  // src/utils/validateContext.ts
14864
14927
  var REQUIRED_SECTIONS = [
@@ -14955,8 +15018,8 @@ server.registerTool("e2e_ai_get_example", {
14955
15018
  inputSchema: exports_external.object({})
14956
15019
  }, async () => {
14957
15020
  try {
14958
- const examplePath = join(getPackageRoot(), "templates", "e2e-ai.context.example.md");
14959
- const content = readFileSync(examplePath, "utf-8");
15021
+ const examplePath = join2(getPackageRoot(), "templates", "e2e-ai.context.example.md");
15022
+ const content = readFileSync2(examplePath, "utf-8");
14960
15023
  return {
14961
15024
  content: [{ type: "text", text: content }]
14962
15025
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "e2e-ai",
3
3
  "description": "AI-powered test automation pipeline — record, transcribe, generate, heal and ship Playwright tests from a single CLI",
4
- "version": "1.2.0",
4
+ "version": "1.4.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "@modelcontextprotocol/sdk": "^1.27.1",
35
35
  "commander": "^13.1.0",
36
36
  "dotenv": "^17.2.0",
37
+ "glob": "^13.0.6",
37
38
  "ora": "^9.3.0",
38
39
  "picocolors": "^1.1.1",
39
40
  "yaml": "^2.7.1",
@@ -129,8 +129,54 @@ console.error(`Voice recording: ${voiceEnabled ? 'ENABLED' : 'disabled (--no-voi
129
129
  console.error(`Trace replay: ${traceEnabled ? 'ENABLED' : 'disabled (--no-trace)'}`);
130
130
  console.error('(When you close the Playwright Inspector, the file is written there.)\n');
131
131
 
132
+ // --- Session timing (always track, used for action timestamps) ---
133
+ const sessionStartTime = Date.now();
134
+
135
+ // --- Action timestamp tracking via file polling ---
136
+ const actionPattern = /^\s*(await\s+page\.|await\s+expect\()/;
137
+ let prevActionCount = 0;
138
+ const actionElapsedSec = []; // elapsed seconds for each action, in order
139
+ let pollInterval = null;
140
+
141
+ function startActionPolling() {
142
+ pollInterval = setInterval(() => {
143
+ if (!existsSync(outputPath)) return;
144
+ try {
145
+ const content = readFileSync(outputPath, 'utf-8');
146
+ const actionLines = content.split('\n').filter((l) => actionPattern.test(l));
147
+ const newCount = actionLines.length;
148
+ if (newCount > prevActionCount) {
149
+ const elapsed = (Date.now() - sessionStartTime) / 1000;
150
+ for (let i = prevActionCount; i < newCount; i++) {
151
+ actionElapsedSec.push(elapsed);
152
+ }
153
+ prevActionCount = newCount;
154
+ }
155
+ } catch {
156
+ // File might be mid-write — ignore
157
+ }
158
+ }, 500);
159
+ }
160
+
161
+ /** Inject // @t:<seconds>s comments above each action line in the codegen file. */
162
+ function injectActionTimestamps(filePath) {
163
+ if (actionElapsedSec.length === 0) return;
164
+ const content = readFileSync(filePath, 'utf-8');
165
+ const lines = content.split('\n');
166
+ const result = [];
167
+ let idx = 0;
168
+ for (const line of lines) {
169
+ if (actionPattern.test(line) && idx < actionElapsedSec.length) {
170
+ const indent = line.match(/^(\s*)/)[1];
171
+ result.push(`${indent}// @t:${actionElapsedSec[idx].toFixed(1)}s`);
172
+ idx++;
173
+ }
174
+ result.push(line);
175
+ }
176
+ writeFileSync(filePath, result.join('\n'));
177
+ }
178
+
132
179
  // --- Voice setup ---
133
- let sessionStartTime = null;
134
180
  let recording = false;
135
181
  let currentRecProcess = null;
136
182
  let segmentIndex = 0;
@@ -147,8 +193,6 @@ if (voiceEnabled) {
147
193
  mkdirSync(recordingsDir, { recursive: true });
148
194
  }
149
195
 
150
- sessionStartTime = Date.now();
151
-
152
196
  const segPath = resolve(recordingsDir, `seg-${String(segmentIndex).padStart(3, '0')}.wav`);
153
197
  segmentPaths.push(segPath);
154
198
  const rec = startRecording(segPath);
@@ -195,6 +239,9 @@ const child = spawn('npx', codegenArgs, {
195
239
  shell: true,
196
240
  });
197
241
 
242
+ // Start polling the output file for new actions to capture timestamps
243
+ startActionPolling();
244
+
198
245
  // --- Keyboard listener for pause/resume ---
199
246
  if (voiceEnabled && process.stdin.isTTY) {
200
247
  process.stdin.setRawMode(true);
@@ -238,16 +285,20 @@ function cleanupTerminal() {
238
285
 
239
286
  child.on('exit', async (code) => {
240
287
  cleanupTerminal();
288
+ if (pollInterval) clearInterval(pollInterval);
241
289
 
242
290
  if (code === 0) {
243
291
  console.error(`\nSaved: ${outputRelative}`);
244
292
  }
245
293
 
246
- // --- Voice post-processing ---
247
- if (voiceEnabled && segmentPaths.length > 0) {
248
- const sessionEndTime = Date.now();
249
- const durationSec = (sessionEndTime - sessionStartTime) / 1000;
294
+ // --- Inject action timestamps into codegen output ---
295
+ if (existsSync(outputPath) && actionElapsedSec.length > 0) {
296
+ injectActionTimestamps(outputPath);
297
+ console.error(`Injected ${actionElapsedSec.length} action timestamp(s) into: ${outputRelative}`);
298
+ }
250
299
 
300
+ // --- Voice post-processing: merge WAV segments only (transcription deferred to transcribe command) ---
301
+ if (voiceEnabled && segmentPaths.length > 0) {
251
302
  try {
252
303
  if (recording && currentRecProcess) {
253
304
  const { stopRecording } = await import(resolve(__dirname, 'voice', 'recorder.mjs'));
@@ -260,45 +311,26 @@ child.on('exit', async (code) => {
260
311
 
261
312
  if (existingSegments.length === 0) {
262
313
  console.error('No audio segments recorded.');
263
- process.exit(code ?? 0);
264
- return;
265
- }
266
-
267
- const mergedWavPath = resolve(recordingsDir, `voice-${timestamp}.wav`);
268
-
269
- if (existingSegments.length === 1) {
270
- renameSync(existingSegments[0], mergedWavPath);
271
314
  } else {
272
- console.error(`Merging ${existingSegments.length} audio segments...`);
273
- const args = ['--combine', 'concatenate', ...existingSegments, mergedWavPath];
274
- execSync(`sox ${args.map((a) => `"${a}"`).join(' ')}`, { stdio: 'ignore' });
275
-
276
- for (const seg of existingSegments) {
277
- try { unlinkSync(seg); } catch {}
315
+ const mergedWavPath = resolve(recordingsDir, `voice-${timestamp}.wav`);
316
+
317
+ if (existingSegments.length === 1) {
318
+ renameSync(existingSegments[0], mergedWavPath);
319
+ } else {
320
+ console.error(`Merging ${existingSegments.length} audio segments...`);
321
+ const args = ['--combine', 'concatenate', ...existingSegments, mergedWavPath];
322
+ execSync(`sox ${args.map((a) => `"${a}"`).join(' ')}`, { stdio: 'ignore' });
323
+
324
+ for (const seg of existingSegments) {
325
+ try { unlinkSync(seg); } catch {}
326
+ }
278
327
  }
279
- }
280
-
281
- console.error(`Audio saved: ${relative(root, mergedWavPath)}`);
282
328
 
283
- const { transcribe } = await import(resolve(__dirname, 'voice', 'transcriber.mjs'));
284
- const segments = await transcribe(mergedWavPath);
285
-
286
- const transcriptPath = resolve(recordingsDir, `voice-${timestamp}.json`);
287
- writeFileSync(transcriptPath, JSON.stringify(segments, null, 2));
288
- console.error(`Transcript saved: ${relative(root, transcriptPath)}`);
289
-
290
- if (segments.length > 0 && existsSync(outputPath)) {
291
- const { merge } = await import(resolve(__dirname, 'voice', 'merger.mjs'));
292
- const codegenContent = readFileSync(outputPath, 'utf-8');
293
- const annotated = merge(codegenContent, segments, durationSec);
294
- writeFileSync(outputPath, annotated);
295
- console.error(`Merged ${segments.length} voice segment(s) into: ${outputRelative}`);
329
+ console.error(`\nVoice recording summary:`);
330
+ console.error(` Audio: ${relative(root, mergedWavPath)}`);
331
+ console.error(` Codegen: ${outputRelative}`);
332
+ console.error(` (Run 'transcribe' to process voice → merge into codegen)`);
296
333
  }
297
-
298
- console.error('\nVoice recording summary:');
299
- console.error(` Audio: ${relative(root, mergedWavPath)}`);
300
- console.error(` Transcript: ${relative(root, transcriptPath)}`);
301
- console.error(` Codegen: ${outputRelative}`);
302
334
  } catch (err) {
303
335
  console.error(`\nVoice processing error: ${err.message}`);
304
336
  }
@@ -9,16 +9,42 @@ function formatTime(sec) {
9
9
  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
10
10
  }
11
11
 
12
+ /**
13
+ * Extract real action timestamps from // @t:<seconds>s comments injected during codegen.
14
+ * Returns an array of { lineIndex, elapsed } or null if no timestamps found.
15
+ *
16
+ * @param {string[]} lines
17
+ * @param {number[]} actionIndices - line indices of action lines
18
+ * @returns {number[] | null} elapsed seconds per action, or null
19
+ */
20
+ function extractEmbeddedTimestamps(lines, actionIndices) {
21
+ const tsPattern = /^\s*\/\/\s*@t:([\d.]+)s\s*$/;
22
+ const timestamps = [];
23
+
24
+ for (const actionIdx of actionIndices) {
25
+ // Look at the line immediately before the action for a @t: comment
26
+ if (actionIdx > 0 && tsPattern.test(lines[actionIdx - 1])) {
27
+ const match = lines[actionIdx - 1].match(tsPattern);
28
+ timestamps.push(parseFloat(match[1]));
29
+ } else {
30
+ // Missing timestamp for this action — can't use embedded timestamps
31
+ return null;
32
+ }
33
+ }
34
+
35
+ return timestamps;
36
+ }
37
+
12
38
  /**
13
39
  * Merge codegen output with voice transcript segments.
14
40
  *
15
- * Action lines (await page.* / await expect(*) are identified and distributed
16
- * linearly across the session duration. Each speech segment is inserted as a
17
- * comment before the nearest action line.
41
+ * If the codegen contains // @t:<seconds>s comments (injected during recording),
42
+ * those real timestamps are used for alignment. Otherwise, action timestamps are
43
+ * distributed linearly across the session duration (fallback).
18
44
  *
19
45
  * @param {string} codegenContent - The original codegen .ts file content
20
46
  * @param {Array<{ start: number, end: number, text: string }>} segments - Whisper transcript segments
21
- * @param {number} durationSec - Total session duration in seconds
47
+ * @param {number} durationSec - Total session duration in seconds (used only for linear fallback)
22
48
  * @returns {string} Annotated codegen content
23
49
  */
24
50
  export function merge(codegenContent, segments, durationSec) {
@@ -26,8 +52,9 @@ export function merge(codegenContent, segments, durationSec) {
26
52
 
27
53
  const lines = codegenContent.split('\n');
28
54
  const actionPattern = /^\s*(await\s+page\.|await\s+expect\()/;
55
+ const tsCommentPattern = /^\s*\/\/\s*@t:[\d.]+s\s*$/;
29
56
 
30
- // Find indices of action lines
57
+ // Find indices of action lines (skip @t: comment lines)
31
58
  const actionIndices = [];
32
59
  for (let i = 0; i < lines.length; i++) {
33
60
  if (actionPattern.test(lines[i])) {
@@ -37,14 +64,16 @@ export function merge(codegenContent, segments, durationSec) {
37
64
 
38
65
  if (actionIndices.length === 0) return codegenContent;
39
66
 
40
- // Estimate timestamp for each action: distribute linearly across the session
41
- const actionTimestamps = actionIndices.map((_, idx) => {
42
- if (actionIndices.length === 1) return durationSec / 2;
43
- return (idx / (actionIndices.length - 1)) * durationSec;
44
- });
67
+ // Try to use embedded timestamps, fall back to linear distribution
68
+ const embeddedTs = extractEmbeddedTimestamps(lines, actionIndices);
69
+ const actionTimestamps = embeddedTs
70
+ ? embeddedTs
71
+ : actionIndices.map((_, idx) => {
72
+ if (actionIndices.length === 1) return durationSec / 2;
73
+ return (idx / (actionIndices.length - 1)) * durationSec;
74
+ });
45
75
 
46
76
  // For each segment, find the nearest action by timestamp
47
- // Map: actionIndex → list of segments to insert before it
48
77
  /** @type {Map<number, Array<{ start: number, end: number, text: string }>>} */
49
78
  const insertions = new Map();
50
79
 
@@ -68,12 +97,14 @@ export function merge(codegenContent, segments, durationSec) {
68
97
  insertions.get(actionLineIdx).push(seg);
69
98
  }
70
99
 
71
- // Build result: insert comment lines before action lines
100
+ // Build result: strip old @t: comments and insert voice comments before action lines
72
101
  const result = [];
73
102
  for (let i = 0; i < lines.length; i++) {
103
+ // Skip @t: timestamp comments — they're consumed, not preserved
104
+ if (tsCommentPattern.test(lines[i])) continue;
105
+
74
106
  const segs = insertions.get(i);
75
107
  if (segs) {
76
- // Detect indentation of the action line
77
108
  const indent = lines[i].match(/^(\s*)/)[1];
78
109
  for (const seg of segs) {
79
110
  result.push(