From aeae7b582835a12ee7cb30027b68e7aac5db690f Mon Sep 17 00:00:00 2001 From: "eric.pereyra" Date: Sun, 8 Feb 2026 20:37:09 -0600 Subject: [PATCH] Upload files to "scripts" --- scripts/transform.js | 399 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 scripts/transform.js diff --git a/scripts/transform.js b/scripts/transform.js new file mode 100644 index 0000000..f322c95 --- /dev/null +++ b/scripts/transform.js @@ -0,0 +1,399 @@ +/** + * 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; + }); +}