/** * scripts/transform.js * QKF: Gherkin (.feature) -> Mocha + Allure spec generator * * CHANGE: Generates ONE spec file per .feature file (aggregating all Scenario Outlines inside). * * Run: * node scripts/transform.js --features features --verbose * node scripts/transform.js --in features/Login1.feature --verbose */ const fs = require("fs"); const path = require("path"); /* --------------------------- CLI helpers ---------------------------- */ function getArg(name, def = null) { const idx = process.argv.indexOf(name); if (idx === -1) return def; const val = process.argv[idx + 1]; if (!val || val.startsWith("--")) return def; return val; } function hasFlag(name) { return process.argv.includes(name); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function readText(filePath) { let t = fs.readFileSync(filePath, "utf-8"); if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); // strip BOM return t.normalize("NFC"); } function writeText(filePath, text) { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, text, "utf-8"); } function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } } function walkFiles(dirPath, predicate) { const out = []; if (!dirPath) return out; const abs = path.resolve(dirPath); if (!fs.existsSync(abs)) return out; const stack = [abs]; while (stack.length) { const curr = stack.pop(); const entries = fs.readdirSync(curr, { withFileTypes: true }); for (const e of entries) { const full = path.join(curr, e.name); if (e.isDirectory()) stack.push(full); else if (e.isFile() && (!predicate || predicate(full))) out.push(full); } } return out; } function sanitizeFilePart(s) { return String(s) .trim() .replace(/[\/\\:*?"<>|]+/g, "_") .replace(/\s+/g, "_") .replace(/_+/g, "_"); } /* --------------------------- Inputs ---------------------------- */ const inFile = getArg("--in", null); const featuresDir = getArg("--features", path.resolve(process.cwd(), "features")); const outDir = getArg("--outDir", path.resolve(process.cwd(), "test", "generated")); const scenarioFilter = getArg("--scenario", null); // optional filter across outlines const printOnly = hasFlag("--print"); const dryRun = hasFlag("--dry-run"); const verbose = hasFlag("--verbose"); /* --------------------------- Main ---------------------------- */ (function main() { const featureFiles = resolveFeatureFiles({ inFile, featuresDir }); if (!featureFiles.length) { console.error( `[transform] No .feature files found. (--in ${inFile || "N/A"}) (--features ${featuresDir})` ); process.exitCode = 2; return; } if (verbose) { console.log(`[transform] Found ${featureFiles.length} feature file(s):`); featureFiles.forEach((f) => console.log(` - ${f}`)); } const results = []; for (const filePath of featureFiles) { const featureText = readText(filePath); const featureName = extractFeatureName(featureText) || path.basename(filePath, path.extname(filePath)); let outlines = extractAllScenarioOutlines(featureText); // optional scenario filter if (scenarioFilter) { outlines = outlines.filter( (o) => String(o.scenarioName).toLowerCase() === String(scenarioFilter).toLowerCase() ); } if (verbose) { console.log( `[transform] ${path.basename(filePath)} outlines detected: ${outlines.length}` ); outlines.forEach((o) => console.log(` • ${o.scenarioName} (steps=${o.steps.length}, examples=${o.examples.length})`) ); } // Keep only outlines with examples const runnable = outlines.filter((o) => Array.isArray(o.examples) && o.examples.length > 0); if (!runnable.length) { console.warn(`[transform] No runnable Scenario Outline (with Examples) in ${filePath}`); continue; } // Generate ONE spec for the feature file const jsSpec = generateMochaAllureSpecForFeature({ featureName, outlines: runnable, }); const baseName = sanitizeFilePart(path.basename(filePath, path.extname(filePath))); const outName = `${baseName}.spec.js`; const outPath = path.resolve(outDir, outName); results.push({ filePath, outPath, outlines: runnable.length, totalExamples: runnable.reduce((a, o) => a + o.examples.length, 0), }); if (printOnly) { process.stdout.write(jsSpec); } else if (!dryRun) { writeText(outPath, jsSpec); } } if (!printOnly) { if (dryRun) { console.log( `[transform] Dry run. Would generate ${results.length} spec file(s) into: ${path.resolve( outDir )}` ); } else { console.log( `[transform] Generated ${results.length} spec file(s) into: ${path.resolve(outDir)}` ); } if (verbose && results.length) { for (const r of results) { console.log( ` - ${r.outPath} (outlines=${r.outlines}, totalExamples=${r.totalExamples})` ); } } } })(); function resolveFeatureFiles({ inFile, featuresDir }) { if (inFile) { const abs = path.resolve(inFile); if (!isFile(abs)) return []; return [abs]; } const absDir = path.resolve(featuresDir); return walkFiles(absDir, (p) => p.toLowerCase().endsWith(".feature")).sort(); } /* --------------------------- Code generation (ONE spec per feature file) ---------------------------- */ function generateMochaAllureSpecForFeature({ featureName, outlines }) { // IMPORTANT: generated spec is in test/generated => ../../qkf/runtime const runtimeRequire = `require("../../qkf/runtime")`; const allIts = outlines .map((outline) => generateItsForOutline(outline, featureName)) .join("\n\n"); return `/* eslint-disable no-unused-vars */ /** * AUTO-GENERATED by scripts/transform.js * Feature -> Mocha (BDD) + Allure steps * * One spec per .feature file. * Includes screenshot after EACH step. */ const { it } = require("mocha"); const { label, step, attachment } = require("allure-js-commons"); const { createRuntime } = ${runtimeRequire}; const runtime = createRuntime({ rootDir: process.cwd() }); const { registry, qkf } = runtime; function applyVariables(template, vars) { return String(template).replace(/<([^<>]+)>/g, (_, key) => { const k = String(key).trim(); return Object.prototype.hasOwnProperty.call(vars, k) ? String(vars[k]) : \`<\${k}>\`; }); } async function attachStepScreenshot(name) { // qkf.screenshotBase64() returns base64 PNG (no data URI prefix) const pngBase64 = await qkf.screenshotBase64(); const buf = Buffer.from(pngBase64, "base64"); await attachment(name, buf, "image/png"); } ${allIts} `; } function generateItsForOutline(outline, featureName) { const safeFeature = String(featureName); const safeScenario = String(outline.scenarioName); return outline.examples .map((row, exampleIdx) => { const titleParts = Object.entries(row).map(([k, v]) => `${k}=${v}`); const testTitle = `${outline.scenarioName} [${exampleIdx + 1}] (${titleParts.join(", ")})`; const rowJson = JSON.stringify(row, null, 2); const stepsCode = outline.steps .map((s, stepIdx) => { const allureTitle = `${s.keyword} ${s.text}`.trim(); const execText = String(s.text || "").trim(); // no keyword for matching step defs const shotName = `Screenshot - Step ${stepIdx + 1}`; return ` await step(applyVariables(${JSON.stringify(allureTitle)}, vars), async () => { await registry.run(applyVariables(${JSON.stringify(execText)}, vars)); await attachStepScreenshot(${JSON.stringify(shotName)}); });`; }) .join("\n\n"); return `it(${JSON.stringify(testTitle)}, async function () { const vars = ${rowJson}; this.timeout(120000); await runtime.beforeEachTest(); try { await label("feature", ${JSON.stringify(safeFeature)}); await label("scenario", ${JSON.stringify(safeScenario)}); await label("language", "es"); await attachment("Example variables (JSON)", JSON.stringify(vars, null, 2), "application/json"); ${stepsCode} } finally { await runtime.afterEachTest({ test: this.currentTest, allureAttachment: attachment }); } });`; }) .join("\n\n"); } /* --------------------------- Parsing helpers (tolerant) ---------------------------- */ function extractFeatureName(featureText) { const lines = String(featureText).split(/\r?\n/); for (const line of lines) { const m = line.match(/^\s*(Característica|Caracteristica|Feature)\s*:\s*(.+)\s*$/i); if (m) return m[2].trim(); } return null; } function extractAllScenarioOutlines(featureText) { const lines = String(featureText).split(/\r?\n/); const OUTLINE_RE = /^\s*(Esquema\s+(?:del\s+)?escenario|Esquema\s+de\s+escenario|Escenario\s+Esquematizado|Scenario\s+Outline)\s*:\s*/i; const OUTLINE_TITLE_RE = /^\s*(?:Esquema\s+(?:del\s+)?escenario|Esquema\s+de\s+escenario|Escenario\s+Esquematizado|Scenario\s+Outline)\s*:\s*(.+)\s*$/i; const outlines = []; for (let i = 0; i < lines.length; i++) { if (!OUTLINE_RE.test(lines[i])) continue; const mTitle = lines[i].match(OUTLINE_TITLE_RE); const scenarioName = (mTitle ? mTitle[1] : `ScenarioOutline_${outlines.length + 1}`).trim(); const blockLines = []; for (let j = i + 1; j < lines.length; j++) { const t = lines[j]; if (/^\s*(Característica|Caracteristica|Feature)\s*:/i.test(t)) break; if (/^\s*(Escenario|Scenario)\s*:/i.test(t)) break; if (OUTLINE_RE.test(t)) break; blockLines.push(t); } const steps = extractStepsFromBlock(blockLines); const examples = extractExamplesFromBlock(blockLines); outlines.push({ scenarioName, steps, examples }); } return outlines; } function extractStepsFromBlock(blockLines) { const isExamplesLine = (s) => /^\s*(Ejemplos|Examples)\s*:/i.test(s); const isStepLine = (s) => /^\s*(Dado|Dada|Dados|Dadas|Cuando|Y|E|Entonces|Pero|Given|When|Then|And|But)\b/i.test(s); const parseStep = (line) => { const m = String(line).match( /^\s*(Dado|Dada|Dados|Dadas|Cuando|Y|E|Entonces|Pero|Given|When|Then|And|But)\b\s*(.*)\s*$/i ); if (!m) return null; return { keyword: m[1], text: m[2] }; }; const steps = []; for (let i = 0; i < blockLines.length; i++) { const line = blockLines[i]; if (isExamplesLine(line)) break; if (!isStepLine(line)) continue; const s = parseStep(line); if (s) steps.push(s); } return steps; } function extractExamplesFromBlock(blockLines) { let startIdx = -1; for (let i = 0; i < blockLines.length; i++) { if (/^\s*(Ejemplos|Examples)\s*:/i.test(blockLines[i])) { startIdx = i + 1; break; } } if (startIdx === -1) return []; const tableLines = []; for (let i = startIdx; i < blockLines.length; i++) { const t = String(blockLines[i]).trim(); if (t.startsWith("|")) tableLines.push(t); else if (tableLines.length > 0) break; } if (tableLines.length < 2) return []; const parseRow = (rowLine) => { const raw = String(rowLine).split("|").map((s) => s.trim()); return raw.filter((_, idx) => idx !== 0 && idx !== raw.length - 1); }; const headers = parseRow(tableLines[0]); const rows = tableLines.slice(1).map(parseRow); return rows .filter((r) => r.some((cell) => cell !== "")) .map((r) => { const obj = {}; headers.forEach((h, idx) => (obj[h] = (r[idx] ?? "").trim())); return obj; }); }