levante 0.1.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,119 @@
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
+ * 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
+
38
+ /**
39
+ * Merge codegen output with voice transcript segments.
40
+ *
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).
44
+ *
45
+ * @param {string} codegenContent - The original codegen .ts file content
46
+ * @param {Array<{ start: number, end: number, text: string }>} segments - Whisper transcript segments
47
+ * @param {number} durationSec - Total session duration in seconds (used only for linear fallback)
48
+ * @returns {string} Annotated codegen content
49
+ */
50
+ export function merge(codegenContent, segments, durationSec) {
51
+ if (!segments || segments.length === 0) return codegenContent;
52
+
53
+ const lines = codegenContent.split('\n');
54
+ const actionPattern = /^\s*(await\s+page\.|await\s+expect\()/;
55
+ const tsCommentPattern = /^\s*\/\/\s*@t:[\d.]+s\s*$/;
56
+
57
+ // Find indices of action lines (skip @t: comment lines)
58
+ const actionIndices = [];
59
+ for (let i = 0; i < lines.length; i++) {
60
+ if (actionPattern.test(lines[i])) {
61
+ actionIndices.push(i);
62
+ }
63
+ }
64
+
65
+ if (actionIndices.length === 0) return codegenContent;
66
+
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
+ });
75
+
76
+ // For each segment, find the nearest action by timestamp
77
+ /** @type {Map<number, Array<{ start: number, end: number, text: string }>>} */
78
+ const insertions = new Map();
79
+
80
+ for (const seg of segments) {
81
+ const segMid = (seg.start + seg.end) / 2;
82
+ let bestIdx = 0;
83
+ let bestDist = Math.abs(actionTimestamps[0] - segMid);
84
+
85
+ for (let i = 1; i < actionTimestamps.length; i++) {
86
+ const dist = Math.abs(actionTimestamps[i] - segMid);
87
+ if (dist < bestDist) {
88
+ bestDist = dist;
89
+ bestIdx = i;
90
+ }
91
+ }
92
+
93
+ const actionLineIdx = actionIndices[bestIdx];
94
+ if (!insertions.has(actionLineIdx)) {
95
+ insertions.set(actionLineIdx, []);
96
+ }
97
+ insertions.get(actionLineIdx).push(seg);
98
+ }
99
+
100
+ // Build result: strip old @t: comments and insert voice comments before action lines
101
+ const result = [];
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
+
106
+ const segs = insertions.get(i);
107
+ if (segs) {
108
+ const indent = lines[i].match(/^(\s*)/)[1];
109
+ for (const seg of segs) {
110
+ result.push(
111
+ `${indent}// [Voice ${formatTime(seg.start)} - ${formatTime(seg.end)}] "${seg.text}"`,
112
+ );
113
+ }
114
+ }
115
+ result.push(lines[i]);
116
+ }
117
+
118
+ return result.join('\n');
119
+ }
@@ -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