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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "levante",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "levante": "./dist/cli.js",
7
+ "levante-mcp": "./dist/mcp.js"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./config": {
15
+ "types": "./dist/config/schema.d.ts",
16
+ "import": "./dist/config/schema.js"
17
+ }
18
+ },
19
+ "files": ["dist/", "agents/", "scripts/", "templates/"],
20
+ "scripts": {
21
+ "build": "bun build src/cli.ts src/index.ts src/config/schema.ts src/mcp.ts --outdir dist --target node --format esm --splitting",
22
+ "dev": "bun run src/cli.ts"
23
+ },
24
+ "dependencies": {
25
+ "@qai/types": "workspace:*",
26
+ "@inquirer/prompts": "^8.3.0",
27
+ "@modelcontextprotocol/sdk": "^1.27.1",
28
+ "commander": "^13.1.0",
29
+ "dotenv": "^17.2.0",
30
+ "glob": "^13.0.6",
31
+ "ora": "^9.3.0",
32
+ "picocolors": "^1.1.1",
33
+ "yaml": "^2.7.1",
34
+ "zod": "^4.0.5"
35
+ },
36
+ "peerDependencies": {
37
+ "@playwright/test": ">=1.40.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.0.14",
41
+ "typescript": "^5.8.3"
42
+ }
43
+ }
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TEMPLATE: Authenticate a user and save the storage state for reuse by codegen and trace replay.
4
+ * Uses the Playwright API directly to perform a username/password login flow.
5
+ *
6
+ * This is a reference implementation — adapt the authenticate() function below to match
7
+ * your application's login flow (e.g., different form fields, OAuth redirects, SSO).
8
+ *
9
+ * Usage:
10
+ * node scripts/auth/setup-auth.mjs # single resource (default)
11
+ * node scripts/auth/setup-auth.mjs --multi # multi resource
12
+ * node scripts/auth/setup-auth.mjs --output .auth/custom.json
13
+ *
14
+ * Credentials come from .env (USERNAME/PASSWORD or MULTI_RESOURCE_USERNAME/PASSWORD).
15
+ * Storage state is saved to .auth/codegen.json by default.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
19
+ import { dirname, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const root = process.env.E2E_AI_PROJECT_ROOT || resolve(__dirname, '..', '..');
24
+
25
+ function loadEnv() {
26
+ const envPath = resolve(root, '.env');
27
+ if (!existsSync(envPath)) return;
28
+ const content = readFileSync(envPath, 'utf-8');
29
+ for (const line of content.split('\n')) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith('#')) continue;
32
+ const eq = trimmed.indexOf('=');
33
+ if (eq <= 0) continue;
34
+ const key = trimmed.slice(0, eq).trim();
35
+ const value = trimmed
36
+ .slice(eq + 1)
37
+ .trim()
38
+ .replace(/^["']|["']$/g, '');
39
+ if (!process.env[key]) process.env[key] = value;
40
+ }
41
+ }
42
+
43
+ loadEnv();
44
+
45
+ const args = process.argv.slice(2);
46
+ const isMulti = args.includes('--multi');
47
+ const outputIdx = args.indexOf('--output');
48
+ const customOutput = outputIdx !== -1 ? args[outputIdx + 1] : null;
49
+
50
+ const username = isMulti
51
+ ? process.env.MULTI_RESOURCE_USERNAME
52
+ : process.env.SINGLE_RESOURCE_USERNAME;
53
+ const password = isMulti
54
+ ? process.env.MULTI_RESOURCE_PASSWORD
55
+ : process.env.SINGLE_RESOURCE_PASSWORD;
56
+ const baseUrl = process.env.BASE_URL;
57
+
58
+ if (!username || !password) {
59
+ const prefix = isMulti ? 'MULTI_RESOURCE' : 'SINGLE_RESOURCE';
60
+ console.error(`Missing ${prefix}_USERNAME or ${prefix}_PASSWORD in .env`);
61
+ process.exit(1);
62
+ }
63
+ if (!baseUrl) {
64
+ console.error('Missing BASE_URL in .env');
65
+ process.exit(1);
66
+ }
67
+
68
+ const storageStatePath = customOutput
69
+ ? resolve(root, customOutput)
70
+ : resolve(root, '.auth', 'codegen.json');
71
+
72
+ // Ensure .auth directory exists
73
+ const authDir = dirname(storageStatePath);
74
+ if (!existsSync(authDir)) {
75
+ mkdirSync(authDir, { recursive: true });
76
+ }
77
+
78
+ /**
79
+ * Authenticate and save storage state.
80
+ * Mirrors the login flow from e2e/config/auth.ts.
81
+ */
82
+ async function authenticate() {
83
+ const { chromium } = await import('playwright');
84
+
85
+ console.error(`Authenticating as ${username}...`);
86
+
87
+ const browser = await chromium.launch({ headless: true });
88
+ const context = await browser.newContext();
89
+ const page = await context.newPage();
90
+
91
+ try {
92
+ await page.goto(baseUrl);
93
+ await page.waitForEvent('load');
94
+
95
+ const usernameInput = page.getByRole('textbox', { name: 'username' });
96
+ await usernameInput.fill(username);
97
+ await usernameInput.press('Enter');
98
+
99
+ const passwordInput = page.getByRole('textbox', { name: 'password' });
100
+ await passwordInput.fill(password);
101
+ await passwordInput.press('Enter');
102
+
103
+ await page.waitForURL('**/dashboard**', { timeout: 30_000 });
104
+
105
+ await context.storageState({ path: storageStatePath });
106
+ console.error(`Storage state saved: ${storageStatePath}`);
107
+ } finally {
108
+ await browser.close();
109
+ }
110
+ }
111
+
112
+ // If the storage state already exists, skip authentication
113
+ if (existsSync(storageStatePath)) {
114
+ console.error(`Storage state already exists: ${storageStatePath}`);
115
+ console.error('Reusing cached auth (delete the file to force re-authentication).');
116
+ } else {
117
+ await authenticate();
118
+ }
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run Playwright codegen against the app URL from your environment.
4
+ * Accepts an issue key so the recording is saved under <workingDir>/<KEY>/.
5
+ * Loads .env from the project root and passes BASE_URL to codegen.
6
+ *
7
+ * Environment variables (set by the CLI or directly):
8
+ * E2E_AI_PROJECT_ROOT - Absolute path to the project root
9
+ * E2E_AI_WORKING_DIR - Relative or absolute path to working directory (default: .qai/levante)
10
+ * E2E_AI_KEY - Issue key (e.g., PROJ-101, LIN-42, or any identifier)
11
+ *
12
+ * Usage:
13
+ * node codegen-env.mjs PROJ-101
14
+ * node codegen-env.mjs PROJ-101 --no-voice --no-trace
15
+ * E2E_AI_KEY=PROJ-101 node codegen-env.mjs
16
+ */
17
+
18
+ import { spawn, execSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'node:fs';
20
+ import { dirname, resolve, relative, isAbsolute } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+
25
+ // Project root: prefer env var, then fall back to parent of scripts dir
26
+ const root = process.env.E2E_AI_PROJECT_ROOT || resolve(__dirname, '..');
27
+
28
+ function loadEnv() {
29
+ const envPath = resolve(root, '.env');
30
+ if (!existsSync(envPath)) return;
31
+ const content = readFileSync(envPath, 'utf-8');
32
+ for (const line of content.split('\n')) {
33
+ const trimmed = line.trim();
34
+ if (!trimmed || trimmed.startsWith('#')) continue;
35
+ const eq = trimmed.indexOf('=');
36
+ if (eq <= 0) continue;
37
+ const key = trimmed.slice(0, eq).trim();
38
+ const value = trimmed
39
+ .slice(eq + 1)
40
+ .trim()
41
+ .replace(/^["']|["']$/g, '');
42
+ if (!process.env[key]) process.env[key] = value;
43
+ }
44
+ }
45
+
46
+ /** Normalize an issue key to PROJECT-NNNN (uppercase project) or return as-is. */
47
+ function normalizeKey(key) {
48
+ const s = String(key || '').trim();
49
+ const match = s.match(/^([A-Za-z]+)-(\d+)$/i);
50
+ if (match) {
51
+ return `${match[1].toUpperCase()}-${match[2]}`;
52
+ }
53
+ return s || '';
54
+ }
55
+
56
+ /** Extract issue key from a URL (e.g. .../browse/PROJ-101) or plain string. */
57
+ function extractKey(input) {
58
+ const s = String(input || '').trim();
59
+ const browseMatch = s.match(/\/browse\/([A-Za-z]+-\d+)/i);
60
+ if (browseMatch) return normalizeKey(browseMatch[1]);
61
+ if (s.includes('-') && /^[A-Za-z]+-\d+$/i.test(s)) return normalizeKey(s);
62
+ return normalizeKey(s);
63
+ }
64
+
65
+ // --- ANSI helpers ---
66
+ function setTerminalTitle(title) {
67
+ process.stderr.write(`\x1b]0;${title}\x07`);
68
+ }
69
+
70
+ function printRecordingStatus(isRecording) {
71
+ if (isRecording) {
72
+ process.stderr.write('\x1b[97;41m \uD83C\uDFA4 Recording... \x1b[0m');
73
+ process.stderr.write(' Press \x1b[1mR\x1b[0m to pause\n');
74
+ setTerminalTitle('\uD83D\uDD34 Recording \u2014 codegen');
75
+ } else {
76
+ process.stderr.write('\x1b[97;43m \u23F8 Recording paused \x1b[0m');
77
+ process.stderr.write(' Press \x1b[1mR\x1b[0m to resume\n');
78
+ setTerminalTitle('\u23F8 Paused \u2014 codegen');
79
+ }
80
+ }
81
+
82
+ loadEnv();
83
+ const baseUrl = process.env.BASE_URL;
84
+ const url = baseUrl ? baseUrl.replace(/\/$/, '') : 'about:blank';
85
+
86
+ // Parse CLI args
87
+ const rawArgs = process.argv.slice(2);
88
+ const voiceEnabled = !rawArgs.includes('--no-voice');
89
+ const traceEnabled = !rawArgs.includes('--no-trace');
90
+ const positionalArgs = rawArgs.filter((a) => !a.startsWith('--no-'));
91
+
92
+ const keyInput = positionalArgs[0];
93
+ const customOut = positionalArgs[1];
94
+ const issueKey = extractKey(process.env.E2E_AI_KEY || keyInput);
95
+
96
+ if (!issueKey) {
97
+ console.error('Usage: node codegen-env.mjs <ISSUE_KEY_or_URL> [output-path]');
98
+ console.error('Example: node codegen-env.mjs PROJ-101');
99
+ console.error(' Or set E2E_AI_KEY=PROJ-101 and run: node codegen-env.mjs');
100
+ process.exit(1);
101
+ }
102
+
103
+ // Resolve working directory from env or default
104
+ const workingDirRel = process.env.E2E_AI_WORKING_DIR || '.qai/levante';
105
+ const workingDirAbs = isAbsolute(workingDirRel) ? workingDirRel : resolve(root, workingDirRel);
106
+ const issueDir = resolve(workingDirAbs, issueKey);
107
+ if (!existsSync(issueDir)) {
108
+ mkdirSync(issueDir, { recursive: true });
109
+ console.error(`Created: ${relative(root, issueDir)}/`);
110
+ }
111
+
112
+ const now = new Date();
113
+ const timestamp = [
114
+ now.getFullYear(),
115
+ String(now.getMonth() + 1).padStart(2, '0'),
116
+ String(now.getDate()).padStart(2, '0'),
117
+ '-',
118
+ String(now.getHours()).padStart(2, '0'),
119
+ String(now.getMinutes()).padStart(2, '0'),
120
+ String(now.getSeconds()).padStart(2, '0'),
121
+ ].join('');
122
+ const defaultOut = resolve(issueDir, `codegen-${timestamp}.ts`);
123
+ const outputPath = customOut ? resolve(root, customOut) : defaultOut;
124
+ const outputRelative = relative(root, outputPath);
125
+
126
+ console.error(`Issue key: ${issueKey}`);
127
+ console.error(`Recording will be saved to: ${outputRelative}`);
128
+ console.error(`Voice recording: ${voiceEnabled ? 'ENABLED' : 'disabled (--no-voice)'}`);
129
+ console.error(`Trace replay: ${traceEnabled ? 'ENABLED' : 'disabled (--no-trace)'}`);
130
+ console.error('(When you close the Playwright Inspector, the file is written there.)\n');
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
+
179
+ // --- Voice setup ---
180
+ let recording = false;
181
+ let currentRecProcess = null;
182
+ let segmentIndex = 0;
183
+ const segmentPaths = [];
184
+ let recordingsDir = null;
185
+
186
+ if (voiceEnabled) {
187
+ // Resolve scripts relative to this file's location
188
+ const { checkRecAvailable, startRecording } = await import(resolve(__dirname, 'voice', 'recorder.mjs'));
189
+ checkRecAvailable();
190
+
191
+ recordingsDir = resolve(issueDir, 'recordings');
192
+ if (!existsSync(recordingsDir)) {
193
+ mkdirSync(recordingsDir, { recursive: true });
194
+ }
195
+
196
+ const segPath = resolve(recordingsDir, `seg-${String(segmentIndex).padStart(3, '0')}.wav`);
197
+ segmentPaths.push(segPath);
198
+ const rec = startRecording(segPath);
199
+ currentRecProcess = rec.process;
200
+ recording = true;
201
+ segmentIndex++;
202
+
203
+ console.error(`Audio segments saved to: ${relative(root, recordingsDir)}/`);
204
+ printRecordingStatus(true);
205
+ }
206
+
207
+ // --- Auth setup: authenticate and cache storage state ---
208
+ const authDir = resolve(root, '.auth');
209
+ const storageStatePath = resolve(authDir, 'codegen.json');
210
+ if (!existsSync(authDir)) {
211
+ mkdirSync(authDir, { recursive: true });
212
+ }
213
+
214
+ if (!existsSync(storageStatePath)) {
215
+ console.error('Authenticating to cache storage state...');
216
+ try {
217
+ execSync(`node "${resolve(__dirname, 'auth', 'setup-auth.mjs')}"`, {
218
+ cwd: root,
219
+ stdio: 'inherit',
220
+ env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
221
+ });
222
+ } catch {
223
+ console.error('Auth setup failed \u2014 codegen will start without cached auth.');
224
+ }
225
+ }
226
+
227
+ // Spawn codegen with --load-storage (skip login) and --save-storage (update cache)
228
+ const harPath = traceEnabled ? resolve(issueDir, `har-${timestamp}.har`) : null;
229
+ const codegenArgs = ['playwright', 'codegen', '--output', outputPath];
230
+ if (traceEnabled && harPath) {
231
+ codegenArgs.push('--save-har', harPath);
232
+ }
233
+ if (existsSync(storageStatePath)) {
234
+ codegenArgs.push('--load-storage', storageStatePath);
235
+ codegenArgs.push('--save-storage', storageStatePath);
236
+ console.error('Using cached auth \u2014 codegen will start already logged in.');
237
+ }
238
+ codegenArgs.push(url);
239
+
240
+ const child = spawn('npx', codegenArgs, {
241
+ stdio: ['ignore', 'inherit', 'inherit'],
242
+ cwd: root,
243
+ shell: true,
244
+ });
245
+
246
+ // Start polling the output file for new actions to capture timestamps
247
+ startActionPolling();
248
+
249
+ // --- Keyboard listener for pause/resume ---
250
+ if (voiceEnabled && process.stdin.isTTY) {
251
+ process.stdin.setRawMode(true);
252
+ process.stdin.resume();
253
+ process.stdin.setEncoding('utf-8');
254
+
255
+ process.stdin.on('data', async (key) => {
256
+ if (key === '\x03') {
257
+ child.kill('SIGTERM');
258
+ return;
259
+ }
260
+
261
+ if (key === 'r' || key === 'R') {
262
+ const { startRecording, stopRecording } = await import(resolve(__dirname, 'voice', 'recorder.mjs'));
263
+
264
+ if (recording) {
265
+ await stopRecording(currentRecProcess);
266
+ currentRecProcess = null;
267
+ recording = false;
268
+ printRecordingStatus(false);
269
+ } else {
270
+ const segPath = resolve(recordingsDir, `seg-${String(segmentIndex).padStart(3, '0')}.wav`);
271
+ segmentPaths.push(segPath);
272
+ const rec = startRecording(segPath);
273
+ currentRecProcess = rec.process;
274
+ recording = true;
275
+ segmentIndex++;
276
+ printRecordingStatus(true);
277
+ }
278
+ }
279
+ });
280
+ }
281
+
282
+ function cleanupTerminal() {
283
+ setTerminalTitle('');
284
+ if (process.stdin.isTTY && voiceEnabled) {
285
+ process.stdin.setRawMode(false);
286
+ process.stdin.pause();
287
+ }
288
+ }
289
+
290
+ child.on('exit', async (code) => {
291
+ cleanupTerminal();
292
+ if (pollInterval) clearInterval(pollInterval);
293
+
294
+ if (code === 0) {
295
+ console.error(`\nSaved: ${outputRelative}`);
296
+ }
297
+
298
+ // --- Inject action timestamps into codegen output ---
299
+ if (existsSync(outputPath) && actionElapsedSec.length > 0) {
300
+ injectActionTimestamps(outputPath);
301
+ console.error(`Injected ${actionElapsedSec.length} action timestamp(s) into: ${outputRelative}`);
302
+ }
303
+
304
+ // --- Voice post-processing: merge WAV segments only (transcription deferred to transcribe command) ---
305
+ if (voiceEnabled && segmentPaths.length > 0) {
306
+ try {
307
+ if (recording && currentRecProcess) {
308
+ const { stopRecording } = await import(resolve(__dirname, 'voice', 'recorder.mjs'));
309
+ await stopRecording(currentRecProcess);
310
+ currentRecProcess = null;
311
+ recording = false;
312
+ }
313
+
314
+ const existingSegments = segmentPaths.filter((p) => existsSync(p));
315
+
316
+ if (existingSegments.length === 0) {
317
+ console.error('No audio segments recorded.');
318
+ } else {
319
+ const mergedWavPath = resolve(recordingsDir, `voice-${timestamp}.wav`);
320
+
321
+ if (existingSegments.length === 1) {
322
+ renameSync(existingSegments[0], mergedWavPath);
323
+ } else {
324
+ console.error(`Merging ${existingSegments.length} audio segments...`);
325
+ const args = ['--combine', 'concatenate', ...existingSegments, mergedWavPath];
326
+ execSync(`sox ${args.map((a) => `"${a}"`).join(' ')}`, { stdio: 'ignore' });
327
+
328
+ for (const seg of existingSegments) {
329
+ try { unlinkSync(seg); } catch {}
330
+ }
331
+ }
332
+
333
+ console.error(`\nVoice recording summary:`);
334
+ console.error(` Audio: ${relative(root, mergedWavPath)}`);
335
+ console.error(` Codegen: ${outputRelative}`);
336
+ console.error(` (Run 'transcribe' to process voice → merge into codegen)`);
337
+ }
338
+ } catch (err) {
339
+ console.error(`\nVoice processing error: ${err.message}`);
340
+ }
341
+ }
342
+
343
+ // --- Trace: inject test.use({ trace: 'on' }) and run replay to generate trace ---
344
+ if (existsSync(outputPath)) {
345
+ const codegenSrc = readFileSync(outputPath, 'utf-8');
346
+ if (!codegenSrc.includes("test.use({ trace: 'on' })")) {
347
+ const injected = codegenSrc.replace(
348
+ /^(import\s.*from\s+['"]@playwright\/test['"];?\s*\n)/m,
349
+ "$1\ntest.use({ trace: 'on' });\n",
350
+ );
351
+ if (injected !== codegenSrc) {
352
+ writeFileSync(outputPath, injected);
353
+ console.error("Injected test.use({ trace: 'on' }) into codegen output.");
354
+ }
355
+ }
356
+
357
+ if (traceEnabled) {
358
+ console.error('\nStarting trace replay...');
359
+ try {
360
+ const replayScript = resolve(__dirname, 'trace', 'replay-with-trace.mjs');
361
+ execSync(`node "${replayScript}" "${outputRelative}"`, {
362
+ cwd: root,
363
+ stdio: 'inherit',
364
+ env: { ...process.env, E2E_AI_PROJECT_ROOT: root },
365
+ });
366
+ } catch {
367
+ console.error('Trace replay failed (codegen file is still saved).');
368
+ }
369
+ }
370
+ }
371
+
372
+ if (harPath && existsSync(harPath)) {
373
+ console.error(`HAR saved: ${relative(root, harPath)}`);
374
+ }
375
+
376
+ process.exit(code ?? 0);
377
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Convert a Zephyr-format test case JSON into an XML file suitable
3
+ * for import via Zephyr's "Import from File" wizard.
4
+ *
5
+ * Can be used both as a CLI script and as an importable module.
6
+ */
7
+
8
+ export type ZephyrStep = { stepNumber: number; description: string; expectedResult: string };
9
+
10
+ export type ZephyrTestCaseFile = {
11
+ title: string;
12
+ precondition?: string;
13
+ steps: ZephyrStep[];
14
+ issueKey?: string;
15
+ issueContext?: { project?: string; summary?: string; parent?: string; labels?: unknown[]; [key: string]: unknown };
16
+ };
17
+
18
+ /** Escape content for CDATA: split ]]> so it doesn't close the section. */
19
+ function cdata(value: string): string {
20
+ const s = String(value ?? '');
21
+ return s.replace(/\]\]>/g, ']]]]><![CDATA[>');
22
+ }
23
+
24
+ function escapeXml(value: string): string {
25
+ return String(value)
26
+ .replace(/&/g, '&amp;')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;')
30
+ .replace(/'/g, '&apos;');
31
+ }
32
+
33
+ /**
34
+ * Derive feature/area from parent epic or first label, otherwise "General".
35
+ */
36
+ function getFeature(data: ZephyrTestCaseFile, titlePrefix?: string): string {
37
+ const parent = data.issueContext?.parent;
38
+ if (parent && typeof parent === 'string') {
39
+ const withoutKey = parent.replace(/^[A-Z][A-Z0-9]*-[0-9]+\s*/i, '').trim();
40
+ if (withoutKey) return withoutKey;
41
+ }
42
+ const labels = data.issueContext?.labels;
43
+ if (Array.isArray(labels) && labels.length > 0 && typeof labels[0] === 'string') {
44
+ return labels[0];
45
+ }
46
+ return 'General';
47
+ }
48
+
49
+ /**
50
+ * Format the full title with optional prefix.
51
+ * Default format: "<prefix> - <feature> - <test name>"
52
+ */
53
+ export function formatExportTitle(data: ZephyrTestCaseFile, titlePrefix?: string): string {
54
+ const prefix = titlePrefix ?? 'UI Automation';
55
+ const feature = getFeature(data, titlePrefix);
56
+ const testName = (data.title ?? '').trim() || 'Untitled';
57
+ return `${prefix} - ${feature} - ${testName}`;
58
+ }
59
+
60
+ /**
61
+ * Convert a ZephyrTestCaseFile to Zephyr-compatible import XML.
62
+ */
63
+ export function jsonToImportXml(data: ZephyrTestCaseFile, titlePrefix?: string): string {
64
+ const projectKey =
65
+ (data.issueContext?.project as string) || (data.issueKey?.split('-')[0]) || 'PROJECT';
66
+ const exportDate = new Date().toISOString().replace('T', ' ').replace(/\.[0-9]{3}Z$/, ' UTC');
67
+ const name = formatExportTitle(data, titlePrefix);
68
+ const precondition = data.precondition ?? '';
69
+ const steps = (data.steps ?? []).slice().sort((a, b) => a.stepNumber - b.stepNumber);
70
+
71
+ const stepLines = steps.map(
72
+ (step, i) =>
73
+ ` <step index="${i}">
74
+ <customFields/>
75
+ <description><![CDATA[${cdata(step.description ?? '')}]]></description>
76
+ <expectedResult><![CDATA[${cdata(step.expectedResult ?? '')}]]></expectedResult>
77
+ </step>`
78
+ );
79
+
80
+ const testCaseAttrs = data.issueKey ? ` key="${escapeXml(data.issueKey)}"` : '';
81
+ const summary =
82
+ (data.issueContext?.summary as string) || data.title || '';
83
+ const issuesBlock = data.issueKey
84
+ ? `<issues>
85
+ <issue>
86
+ <key>${escapeXml(data.issueKey)}</key>
87
+ <summary><![CDATA[${cdata(summary)}]]></summary>
88
+ </issue>
89
+ </issues>`
90
+ : '<issues/>';
91
+
92
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
93
+ <project>
94
+ <projectId>0</projectId>
95
+ <projectKey>${escapeXml(projectKey)}</projectKey>
96
+ <exportDate>${exportDate}</exportDate>
97
+ <testCases>
98
+ <testCase${testCaseAttrs}>
99
+ <attachments/>
100
+ <confluencePageLinks/>
101
+ <createdBy/>
102
+ <createdOn>${exportDate}</createdOn>
103
+ <customFields/>
104
+ <folder><![CDATA[]]></folder>
105
+ ${issuesBlock}
106
+ <labels/>
107
+ <name><![CDATA[${cdata(name)}]]></name>
108
+ <owner/>
109
+ <precondition><![CDATA[${cdata(precondition)}]]></precondition>
110
+ <priority><![CDATA[Normal]]></priority>
111
+ <status><![CDATA[Draft]]></status>
112
+ <parameters/>
113
+ <testDataWrapper/>
114
+ <testScript type="steps">
115
+ <steps>
116
+ ${stepLines.join('\n')}
117
+ </steps>
118
+ </testScript>
119
+ </testCase>
120
+ </testCases>
121
+ </project>
122
+ `;
123
+ }
124
+
125
+ // CLI entry point — only runs when this file is the direct entry point
126
+ const isDirectRun = process.argv[1]?.includes('zephyr-json-to-import-xml');
127
+ if (isDirectRun) {
128
+ const fs = await import('node:fs');
129
+ const path = await import('node:path');
130
+
131
+ const args = process.argv.slice(2).filter((a) => !a.startsWith('--'));
132
+ const outIdx = process.argv.indexOf('-o');
133
+ const outArg = outIdx >= 0 ? process.argv[outIdx + 1] : null;
134
+
135
+ const filePath = args[0];
136
+ if (!filePath) {
137
+ console.error('Usage: bun run zephyr-json-to-import-xml <test-case.json> [-o output.xml]');
138
+ process.exit(1);
139
+ }
140
+
141
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
142
+ if (!fs.existsSync(absPath)) {
143
+ console.error(`File not found: ${absPath}`);
144
+ process.exit(1);
145
+ }
146
+
147
+ const raw = JSON.parse(fs.readFileSync(absPath, 'utf-8')) as ZephyrTestCaseFile;
148
+ const xml = jsonToImportXml(raw);
149
+
150
+ const outPath = outArg
151
+ ? (path.isAbsolute(outArg) ? outArg : path.resolve(outArg))
152
+ : absPath.replace(/\.json$/i, '-import.xml');
153
+
154
+ fs.writeFileSync(outPath, xml, 'utf-8');
155
+ console.log(`Wrote ${outPath}`);
156
+ }