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.
- package/agents/0.init-agent.md +83 -0
- package/agents/1_1.transcript-agent.md +107 -0
- package/agents/1_2.scenario-agent.md +90 -0
- package/agents/2.playwright-generator-agent.md +96 -0
- package/agents/3.refactor-agent.md +51 -0
- package/agents/4.self-healing-agent.md +90 -0
- package/agents/5.qa-testcase-agent.md +77 -0
- package/dist/cli-039axkk7.js +251 -0
- package/dist/cli-0kkk77d6.js +251 -0
- package/dist/cli-69cp23fq.js +76 -0
- package/dist/cli-6wrk5ptg.js +250 -0
- package/dist/cli-akwt7nqw.js +67 -0
- package/dist/cli-hdefnftg.js +13610 -0
- package/dist/cli-w0v11cvq.js +13610 -0
- package/dist/cli-wckvcay0.js +48 -0
- package/dist/cli.js +9214 -0
- package/dist/config/schema.js +9 -0
- package/dist/index-2qecm8mk.js +7073 -0
- package/dist/index.js +15 -0
- package/dist/mcp.js +19647 -0
- package/package.json +43 -0
- package/scripts/auth/setup-auth.mjs +118 -0
- package/scripts/codegen-env.mjs +377 -0
- package/scripts/exporters/zephyr-json-to-import-xml.ts +156 -0
- package/scripts/trace/replay-with-trace.mjs +95 -0
- package/scripts/trace/replay.config.ts +37 -0
- package/scripts/voice/merger.mjs +119 -0
- package/scripts/voice/recorder.mjs +54 -0
- package/scripts/voice/transcriber.mjs +52 -0
- package/templates/e2e-ai.context.example.md +93 -0
- package/templates/workflow.md +289 -0
|
@@ -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
|