/**
* Generate test cases from doc (txt/md/PDF) + rules.
* - DOC_PATH: path to document (default: samples/doc-example.md). Supports .pdf.
* - RULES_PATH: path to rules file (default: samples/rules-example.txt).
* - OUTPUT_FORMAT: "prompt" | "csv" | "md" | "both" (default: prompt). "both" = write .md and .csv from one LLM call.
* - OUTPUT_FILE: path for single output (csv default: samples/generated-testcases.csv).
* - OUTPUT_MD: path for markdown output (default: samples/generated-testcases.md).
* - OUTPUT_CSV: path for CSV output (default: samples/generated-testcases.csv).
* - OPENAI_API_KEY: if set, calls OpenAI to generate test cases; then output is the generated content.
*/
require("dotenv").config();
const fs = require("fs");
const path = require("path");
const DEFAULT_SYSTEM_PROMPT = `You are a QA expert. Your task: generate a set of manual test cases based on the provided documentation and rules.
- Output: a clear list of test cases, each with: ID, Description, Preconditions, Steps, Expected result, Priority (if applicable).
- Format: Markdown or table, easy to read.
- Follow the given rules strictly.`;
const CSV_SYSTEM_PROMPT = `You are a QA expert. Generate manual test cases from the documentation and rules.
Output ONLY valid CSV with this exact header (no extra text, no markdown):
模块,标题,前置条件,步骤描述,预期结果,test1测试人员,test1测试结果,buglink,PRE测试人员,PRE测试结果,buglink
- Use comma as separator. If a cell contains comma or newline or double quote, wrap the cell in double quotes and escape " as "".
- 模块: Chinese module path (e.g. Login/Login).
- 标题, 前置条件, 步骤描述, 预期结果: English.
- Leave test1测试人员, test1测试结果, buglink, PRE测试人员, PRE测试结果, buglink empty.`;
function buildPrompt(documentContent, rules, outputCsv) {
const formatNote = outputCsv
? "\n\nOutput ONLY the CSV content (header + data rows), no other text or markdown."
: "";
return `## Reference documentation
${documentContent}
---
## Rules to follow
${rules}
---
Generate a set of manual test cases that reasonably cover the requirements in the documentation and follow the rules above. Output in the same language as the documentation.${formatNote}`;
}
async function readDocumentContent(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".pdf") {
const PDFParse = (await import("pdf-parse")).PDFParse;
const data = await fs.promises.readFile(filePath);
const parser = new PDFParse({ data });
try {
const result = await parser.getText();
return result.text || "";
} finally {
await parser.destroy();
}
}
return fs.promises.readFile(filePath, "utf-8");
}
async function generateWithOpenAI(systemPrompt, userPrompt, outputCsv) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return null;
const model = process.env.OPENAI_MODEL || "gpt-4o-mini";
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
max_tokens: 4096,
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenAI API error ${res.status}: ${err}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
return content != null ? String(content).trim() : null;
}
function escapeCsvCell(value) {
if (value == null) return "";
const s = String(value);
if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
}
/** Parse markdown table into array of row arrays (cells). */
function parseMarkdownTable(text) {
const lines = text.split(/\r?\n/).filter((l) => l.trim().includes("|"));
const rows = [];
for (const line of lines) {
const cells = line
.split("|")
.map((c) => c.trim())
.filter((_, i, arr) => i > 0 && i < arr.length - 1);
if (cells.length === 0) continue;
const isSeparator = cells.every((c) => /^[-:\s]+$/.test(c));
if (isSeparator) continue;
rows.push(cells);
}
return rows;
}
/** Convert markdown table to CSV string. */
function markdownTableToCsv(text) {
const rows = parseMarkdownTable(text);
if (rows.length === 0) return "";
return rows.map((row) => row.map(escapeCsvCell).join(",")).join("\n");
}
const projectRoot = path.resolve(__dirname, "..");
const defaultDocPath = path.join(projectRoot, "samples", "doc-example.md");
const defaultRulesPath = path.join(projectRoot, "samples", "rules-example.txt");
const defaultCsvPath = path.join(projectRoot, "samples", "generated-testcases.csv");
const defaultMdPath = path.join(projectRoot, "samples", "generated-testcases.md");
const MAX_DOC_CHARS = 120000;
async function main() {
const docPath = process.env.DOC_PATH
? path.resolve(projectRoot, process.env.DOC_PATH)
: defaultDocPath;
const rulesPath = process.env.RULES_PATH
? path.resolve(projectRoot, process.env.RULES_PATH)
: defaultRulesPath;
const outputFormat = (process.env.OUTPUT_FORMAT || "prompt").toLowerCase();
const outputCsv = outputFormat === "csv";
const outputMd = outputFormat === "md" || outputFormat === "both";
const outputBoth = outputFormat === "both";
const outputFile = process.env.OUTPUT_FILE
? path.resolve(projectRoot, process.env.OUTPUT_FILE)
: defaultCsvPath;
const mdPath = process.env.OUTPUT_MD
? path.resolve(projectRoot, process.env.OUTPUT_MD)
: defaultMdPath;
const csvPath = process.env.OUTPUT_CSV
? path.resolve(projectRoot, process.env.OUTPUT_CSV)
: defaultCsvPath;
let documentContent;
try {
documentContent = await readDocumentContent(docPath);
} catch (e) {
console.error("Error reading document:", e.message);
process.exit(1);
}
if (!documentContent.trim()) {
console.error("Document is empty.");
process.exit(1);
}
// If doc is a file and has sibling "images" or "Images" folder, append image list to context
const docDir = path.dirname(docPath);
const imagesDir = fs.existsSync(path.join(docDir, "images"))
? path.join(docDir, "images")
: fs.existsSync(path.join(docDir, "Images"))
? path.join(docDir, "Images")
: null;
if (imagesDir) {
try {
const names = fs.readdirSync(imagesDir).filter((n) => /\.(png|jpg|jpeg|gif|webp)$/i.test(n));
if (names.length) {
const relDir = path.relative(projectRoot, imagesDir) || path.basename(imagesDir);
const imageRefs = names.map((n) => relDir + "/" + n).join(", ");
documentContent += "\n\n[原型图所在目录: " + relDir + "]\n可参考的图片: " + imageRefs;
}
} catch (_) {}
}
if (documentContent.length > MAX_DOC_CHARS) {
console.error("Document truncated to", MAX_DOC_CHARS, "chars for API.");
documentContent = documentContent.slice(0, MAX_DOC_CHARS) + "\n\n[... truncated ...]";
}
let rules;
try {
rules = await fs.promises.readFile(rulesPath, "utf-8");
} catch (e) {
console.error("Error reading rules:", e.message);
process.exit(1);
}
const wantCsvOnly = outputCsv && !outputBoth;
const userPrompt = buildPrompt(documentContent, rules, wantCsvOnly);
const systemPrompt = wantCsvOnly ? CSV_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT;
if (process.env.OPENAI_API_KEY) {
try {
console.error("Calling LLM to generate test cases...");
const text = await generateWithOpenAI(systemPrompt, userPrompt, wantCsvOnly);
if (text) {
if (outputBoth) {
const mdContent = text.replace(/^```[\w]*\n?|```$/g, "").trim();
await fs.promises.writeFile(mdPath, mdContent, "utf-8");
console.error("Wrote MD to:", mdPath);
const csv = markdownTableToCsv(mdContent);
if (csv) {
await fs.promises.writeFile(csvPath, csv, "utf-8");
console.error("Wrote CSV to:", csvPath);
}
console.log(mdContent);
return;
}
if (outputCsv) {
const csv = text.replace(/^```[\w]*\n?|```$/g, "").trim();
await fs.promises.writeFile(csvPath, csv, "utf-8");
console.error("Wrote CSV to:", csvPath);
console.log(csv);
return;
}
if (outputMd) {
const mdContent = text.replace(/^```[\w]*\n?|```$/g, "").trim();
await fs.promises.writeFile(mdPath, mdContent, "utf-8");
console.error("Wrote MD to:", mdPath);
console.log("# Generated test cases\n\n");
console.log(mdContent);
return;
}
console.log("# Generated test cases\n\n");
console.log(text);
return;
}
} catch (e) {
console.error("LLM error:", e.message);
if (outputCsv || outputBoth) process.exit(1);
console.error("\nFalling back to formatted prompt.\n");
}
} else if (outputCsv || outputBoth) {
const header =
"模块,标题,前置条件,步骤描述,预期结果,test1测试人员,test1测试结果,buglink,PRE测试人员,PRE测试结果,buglink";
const outCsv = outputBoth ? csvPath : outputFile;
await fs.promises.writeFile(outCsv, header + "\n", "utf-8");
console.error("OPENAI_API_KEY not set. Wrote CSV header only to:", outCsv);
if (outputBoth) {
await fs.promises.writeFile(mdPath, "# Generated test cases\n\nSet OPENAI_API_KEY and run again for content.\n", "utf-8");
console.error("Wrote placeholder MD to:", mdPath);
}
console.log(header);
return;
} else {
console.error("OPENAI_API_KEY not set. Set it to generate test cases with LLM.\n");
}
console.log("## Formatted prompt (for use with LLM)\n");
console.log("### System prompt (suggested)\n");
console.log(systemPrompt);
console.log("\n### User prompt\n");
console.log(userPrompt);
}
main();