e2e-ai 1.0.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.
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Replay a Playwright codegen recording with tracing enabled.
4
+ * Captures a trace.zip with screenshots, DOM snapshots, and network activity.
5
+ *
6
+ * Usage:
7
+ * node replay-with-trace.mjs <codegen-file.ts>
8
+ *
9
+ * The trace is saved alongside the codegen file as codegen-<timestamp>-trace.zip.
10
+ */
11
+
12
+ import { execSync } from 'node:child_process';
13
+ import { existsSync, readdirSync, renameSync, rmSync } from 'node:fs';
14
+ import { dirname, resolve, basename, relative } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const root = process.env.E2E_AI_PROJECT_ROOT || resolve(__dirname, '..', '..');
19
+
20
+ const codegenFile = process.argv[2];
21
+ if (!codegenFile) {
22
+ console.error('Usage: node replay-with-trace.mjs <codegen-file.ts>');
23
+ process.exit(1);
24
+ }
25
+
26
+ const codegenPath = resolve(root, codegenFile);
27
+ if (!existsSync(codegenPath)) {
28
+ console.error(`File not found: ${codegenFile}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const codegenDir = dirname(codegenPath);
33
+ const codegenBase = basename(codegenFile, '.ts');
34
+ const traceOutputDir = resolve(codegenDir, 'trace-results');
35
+ const configPath = resolve(__dirname, 'replay.config.ts');
36
+ const traceZipDest = resolve(codegenDir, `${codegenBase}-trace.zip`);
37
+
38
+ // Ensure auth storage state exists for replay
39
+ const storageStatePath = resolve(root, '.auth', 'codegen.json');
40
+ if (!existsSync(storageStatePath)) {
41
+ console.error('No cached auth found \u2014 running auth setup...');
42
+ try {
43
+ execSync(`node "${resolve(__dirname, '..', 'auth', 'setup-auth.mjs')}"`, {
44
+ cwd: root,
45
+ stdio: 'inherit',
46
+ env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
47
+ });
48
+ } catch {
49
+ console.error('Auth setup failed \u2014 replay will proceed without cached auth.');
50
+ }
51
+ }
52
+
53
+ console.error(`Replaying with trace: ${codegenFile}`);
54
+ console.error(`Trace will be saved to: ${relative(root, traceZipDest)}`);
55
+
56
+ let replayFailed = false;
57
+ try {
58
+ execSync(
59
+ `npx playwright test "${codegenPath}" --config "${configPath}" --project chromium`,
60
+ {
61
+ cwd: root,
62
+ stdio: 'inherit',
63
+ env: {
64
+ ...process.env,
65
+ TRACE_OUTPUT_DIR: traceOutputDir,
66
+ },
67
+ },
68
+ );
69
+ } catch {
70
+ replayFailed = true;
71
+ console.error('Replay finished with errors (trace may still be partial).');
72
+ }
73
+
74
+ if (existsSync(traceOutputDir)) {
75
+ const dirs = readdirSync(traceOutputDir, { withFileTypes: true }).filter((d) => d.isDirectory());
76
+
77
+ for (const dir of dirs) {
78
+ const traceZip = resolve(traceOutputDir, dir.name, 'trace.zip');
79
+ if (existsSync(traceZip)) {
80
+ renameSync(traceZip, traceZipDest);
81
+ break;
82
+ }
83
+ }
84
+
85
+ rmSync(traceOutputDir, { recursive: true, force: true });
86
+ }
87
+
88
+ if (existsSync(traceZipDest)) {
89
+ console.error(`\nTrace saved: ${relative(root, traceZipDest)}`);
90
+ console.error(`Open with: npx playwright show-trace "${relative(root, traceZipDest)}"`);
91
+ process.exit(replayFailed ? 1 : 0);
92
+ } else {
93
+ console.error('\nWarning: No trace file was captured.');
94
+ process.exit(1);
95
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { defineConfig, devices } from '@playwright/test';
4
+
5
+ /**
6
+ * Minimal Playwright config for replaying codegen recordings with tracing.
7
+ * Used by scripts/trace/replay-with-trace.mjs.
8
+ *
9
+ * Trace output dir is controlled via TRACE_OUTPUT_DIR env variable.
10
+ * Project root is controlled via E2E_AI_PROJECT_ROOT env variable.
11
+ * Reuses cached auth from .auth/codegen.json when available.
12
+ */
13
+ const projectRoot = process.env.E2E_AI_PROJECT_ROOT || resolve(__dirname, '..', '..');
14
+ const storageStatePath = resolve(projectRoot, '.auth', 'codegen.json');
15
+ const storageState = existsSync(storageStatePath) ? storageStatePath : undefined;
16
+
17
+ export default defineConfig({
18
+ testDir: projectRoot,
19
+ testMatch: '**/codegen-*.ts',
20
+ timeout: 120_000,
21
+ retries: 0,
22
+ workers: 1,
23
+ reporter: 'list',
24
+ outputDir: process.env.TRACE_OUTPUT_DIR || resolve(projectRoot, 'test-results-trace'),
25
+ use: {
26
+ trace: 'on',
27
+ screenshot: 'on',
28
+ actionTimeout: 30_000,
29
+ storageState,
30
+ },
31
+ projects: [
32
+ {
33
+ name: 'chromium',
34
+ use: { ...devices['Desktop Chrome'] },
35
+ },
36
+ ],
37
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Format seconds as MM:SS.
3
+ * @param {number} sec
4
+ * @returns {string}
5
+ */
6
+ function formatTime(sec) {
7
+ const m = Math.floor(sec / 60);
8
+ const s = Math.floor(sec % 60);
9
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
10
+ }
11
+
12
+ /**
13
+ * Merge codegen output with voice transcript segments.
14
+ *
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.
18
+ *
19
+ * @param {string} codegenContent - The original codegen .ts file content
20
+ * @param {Array<{ start: number, end: number, text: string }>} segments - Whisper transcript segments
21
+ * @param {number} durationSec - Total session duration in seconds
22
+ * @returns {string} Annotated codegen content
23
+ */
24
+ export function merge(codegenContent, segments, durationSec) {
25
+ if (!segments || segments.length === 0) return codegenContent;
26
+
27
+ const lines = codegenContent.split('\n');
28
+ const actionPattern = /^\s*(await\s+page\.|await\s+expect\()/;
29
+
30
+ // Find indices of action lines
31
+ const actionIndices = [];
32
+ for (let i = 0; i < lines.length; i++) {
33
+ if (actionPattern.test(lines[i])) {
34
+ actionIndices.push(i);
35
+ }
36
+ }
37
+
38
+ if (actionIndices.length === 0) return codegenContent;
39
+
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
+ });
45
+
46
+ // For each segment, find the nearest action by timestamp
47
+ // Map: actionIndex → list of segments to insert before it
48
+ /** @type {Map<number, Array<{ start: number, end: number, text: string }>>} */
49
+ const insertions = new Map();
50
+
51
+ for (const seg of segments) {
52
+ const segMid = (seg.start + seg.end) / 2;
53
+ let bestIdx = 0;
54
+ let bestDist = Math.abs(actionTimestamps[0] - segMid);
55
+
56
+ for (let i = 1; i < actionTimestamps.length; i++) {
57
+ const dist = Math.abs(actionTimestamps[i] - segMid);
58
+ if (dist < bestDist) {
59
+ bestDist = dist;
60
+ bestIdx = i;
61
+ }
62
+ }
63
+
64
+ const actionLineIdx = actionIndices[bestIdx];
65
+ if (!insertions.has(actionLineIdx)) {
66
+ insertions.set(actionLineIdx, []);
67
+ }
68
+ insertions.get(actionLineIdx).push(seg);
69
+ }
70
+
71
+ // Build result: insert comment lines before action lines
72
+ const result = [];
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const segs = insertions.get(i);
75
+ if (segs) {
76
+ // Detect indentation of the action line
77
+ const indent = lines[i].match(/^(\s*)/)[1];
78
+ for (const seg of segs) {
79
+ result.push(
80
+ `${indent}// [Voice ${formatTime(seg.start)} - ${formatTime(seg.end)}] "${seg.text}"`,
81
+ );
82
+ }
83
+ }
84
+ result.push(lines[i]);
85
+ }
86
+
87
+ return result.join('\n');
88
+ }
@@ -0,0 +1,54 @@
1
+ import { spawn, execSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Check that the `rec` command (from sox) is available.
5
+ * Throws a clear error if not installed.
6
+ */
7
+ export function checkRecAvailable() {
8
+ try {
9
+ execSync('which rec', { stdio: 'ignore' });
10
+ } catch {
11
+ throw new Error(
12
+ 'The "rec" command is not available.\n' +
13
+ 'Install sox to enable voice recording:\n' +
14
+ ' brew install sox # macOS\n' +
15
+ ' sudo apt install sox # Debian/Ubuntu\n',
16
+ );
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Start recording audio from the microphone to a WAV file.
22
+ * @param {string} wavPath - Absolute path to the output .wav file
23
+ * @returns {{ process: import('node:child_process').ChildProcess, startTime: number }}
24
+ */
25
+ export function startRecording(wavPath) {
26
+ checkRecAvailable();
27
+
28
+ const recProcess = spawn('rec', ['-q', '-r', '16000', '-c', '1', '-b', '16', wavPath], {
29
+ stdio: 'ignore',
30
+ });
31
+
32
+ recProcess.on('error', (err) => {
33
+ console.error(`Recording process error: ${err.message}`);
34
+ });
35
+
36
+ return { process: recProcess, startTime: Date.now() };
37
+ }
38
+
39
+ /**
40
+ * Stop recording by sending SIGTERM and waiting for the process to close.
41
+ * @param {import('node:child_process').ChildProcess} recProcess
42
+ * @returns {Promise<void>}
43
+ */
44
+ export function stopRecording(recProcess) {
45
+ return new Promise((resolve, reject) => {
46
+ if (!recProcess || recProcess.killed) {
47
+ resolve();
48
+ return;
49
+ }
50
+ recProcess.on('close', () => resolve());
51
+ recProcess.on('error', reject);
52
+ recProcess.kill('SIGTERM');
53
+ });
54
+ }
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Transcribe a WAV file using OpenAI Whisper API.
5
+ * Returns an array of segments with start/end timestamps (seconds) and text.
6
+ *
7
+ * Requires OPENAI_API_KEY in the environment.
8
+ * If the key is missing, logs a warning and returns an empty array.
9
+ *
10
+ * @param {string} wavPath - Absolute path to the .wav file
11
+ * @returns {Promise<Array<{ start: number, end: number, text: string }>>}
12
+ */
13
+ export async function transcribe(wavPath) {
14
+ const apiKey = process.env.OPENAI_API_KEY;
15
+ if (!apiKey) {
16
+ console.error(
17
+ 'Warning: OPENAI_API_KEY not set — skipping voice transcription.\n' +
18
+ 'Set it in .env to enable automatic transcription.',
19
+ );
20
+ return [];
21
+ }
22
+
23
+ const fileBuffer = readFileSync(wavPath);
24
+ const blob = new Blob([fileBuffer], { type: 'audio/wav' });
25
+
26
+ const formData = new FormData();
27
+ formData.append('file', blob, 'recording.wav');
28
+ formData.append('model', 'whisper-1');
29
+ formData.append('response_format', 'verbose_json');
30
+ formData.append('timestamp_granularities[]', 'segment');
31
+
32
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
33
+ method: 'POST',
34
+ headers: {
35
+ Authorization: `Bearer ${apiKey}`,
36
+ },
37
+ body: formData,
38
+ });
39
+
40
+ if (!response.ok) {
41
+ const errorText = await response.text();
42
+ throw new Error(`Whisper API error (${response.status}): ${errorText}`);
43
+ }
44
+
45
+ const result = await response.json();
46
+
47
+ return (result.segments || []).map((seg) => ({
48
+ start: seg.start,
49
+ end: seg.end,
50
+ text: seg.text.trim(),
51
+ }));
52
+ }
@@ -0,0 +1,93 @@
1
+ # Project Context for e2e-ai
2
+
3
+ ## Application
4
+
5
+ - **Name**: My Application
6
+ - **Description**: A web application for managing resources
7
+ - **Tech Stack**: React, TypeScript, Material UI
8
+ - **Base URL**: https://app.example.com
9
+
10
+ ## Test Infrastructure
11
+
12
+ ### Fixtures
13
+ - `singleResourcePage` — Pre-authenticated page for single-resource user
14
+ - `multiResourcePage` — Pre-authenticated page for multi-resource user
15
+
16
+ ### Helpers
17
+ - `createStepCounter()` — Returns a `nextStep(label)` function for sequential step naming
18
+ - `test` — Custom test object from `@config` with fixtures pre-registered
19
+
20
+ ### Auth Pattern
21
+ - Login is handled by fixtures — test step 1 ("Log in") is a no-op
22
+ - Storage state cached in `.auth/` directory
23
+
24
+ ## Feature Methods
25
+
26
+ ### useFeatures(page)
27
+ Returns `{ appNav, settingsApp, dashboardApp }`
28
+
29
+ - `appNav.navigateTo(section)` — Navigate to a named section
30
+ - `appNav.waitForNavigation()` — Wait for navigation to complete
31
+ - `dashboardApp.waitForDashboard()` — Wait for dashboard to load
32
+
33
+ ### useItemFeatures(page, appNav)
34
+ Returns `{ itemList, itemCreation, itemDetail }`
35
+
36
+ - `itemList.clickListButton()` — Switch to list view
37
+ - `itemList.clickGridButton()` — Switch to grid view
38
+ - `itemCreation.openCreateDialog()` — Open the create item modal
39
+ - `itemDetail.waitForDetail()` — Wait for detail panel to load
40
+
41
+ ## Import Conventions
42
+
43
+ ```typescript
44
+ import { createStepCounter, test } from '@config';
45
+ import { useFeatures } from '@features';
46
+ import { useItemFeatures } from '@features/items';
47
+ import { expect } from 'playwright/test';
48
+ ```
49
+
50
+ Path aliases (from tsconfig.json):
51
+ - `@config` → `e2e/config`
52
+ - `@features` → `e2e/features`
53
+ - `@features/*` → `e2e/features/*`
54
+
55
+ ## Selector Conventions
56
+
57
+ 1. Prefer `getByRole`, `getByText`, `getByLabel` over CSS selectors
58
+ 2. Use `[id^="item-"]` pattern for dynamically generated IDs
59
+ 3. Use `data-testid` when semantic selectors are ambiguous
60
+ 4. Never rely on generated CSS class names (e.g., `.css-xxxxx`)
61
+
62
+ ## Test Structure Template
63
+
64
+ ```typescript
65
+ import { createStepCounter, test } from '@config';
66
+ import { useFeatures } from '@features';
67
+ import { expect } from 'playwright/test';
68
+
69
+ test.describe('ISSUE-KEY - Test title', () => {
70
+ test('Test title', async ({ singleResourcePage }) => {
71
+ const { appNav } = useFeatures(singleResourcePage);
72
+ const nextStep = createStepCounter();
73
+
74
+ await test.step(nextStep('Log in with valid credentials'), async () => {
75
+ // Expected: User is logged in and on the dashboard
76
+ // Login handled by singleResourcePage fixture
77
+ });
78
+
79
+ await test.step(nextStep('Navigate to Items section'), async () => {
80
+ // Expected: Items list is displayed
81
+ await appNav.navigateTo('Items');
82
+ });
83
+ });
84
+ });
85
+ ```
86
+
87
+ ## Utility Patterns
88
+
89
+ - `{ timeout: 10000 }` for visibility assertions
90
+ - `{ timeout: 15000 }` for navigation waits
91
+ - `{ timeout: 500 }` for short animation waits
92
+ - Use `page.waitForLoadState('networkidle')` before asserting loaded data
93
+ - Wrap data-dependent assertions in try/catch with `// TODO: data-dependent` comment