Files
JS-MOCHA-SE-AL/scripts/transform.js

400 lines
13 KiB
JavaScript

/**
* 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;
});
}