Upload files to "scripts"
This commit is contained in:
399
scripts/transform.js
Normal file
399
scripts/transform.js
Normal file
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user