UNPKG

specpress

Version:

Export PDF and/or DOCX files from a subset of Markdown, ASN.1 and JSON files

320 lines (282 loc) 11.2 kB
#!/usr/bin/env node /** * Regression test for HTML and DOCX export. * * Clones a spec repository, exports its content to HTML and DOCX, * and either stores the result as baseline ("generate") or compares * against a previously stored baseline ("validate"). * * Usage: * node scripts/regression-test.js generate * node scripts/regression-test.js validate * * Configuration (edit the constants below or override via env vars): * SPEC_REPO — git URL of the specification repository * SPEC_SUBDIR — subfolder inside the repo that contains the spec files */ const fs = require('fs') const path = require('path') const os = require('os') const { execSync } = require('child_process') const { Md2Html } = require('../lib/md2html/md2html') const { collectFiles, concatenateFiles, formatExportMessage } = require('../lib/common/specProcessor') const { MarkdownToDocxConverter } = require('../lib/md2docx/md2docx') // ── Configuration ────────────────────────────────────────────────── const SPEC_REPO = process.env.SPEC_REPO || 'https://forge.3gpp.org/rep/fs_6gspecs_new/ericsson_multifiletypes_onem2m_example.git' const SPEC_SUBDIR = process.env.SPEC_SUBDIR || 'specification' // ─────────────────────────────────────────────────────────────────── const ROOT = path.join(os.tmpdir(), 'specpress-regression') const REPO_DIR = path.join(ROOT, 'repo') const BASELINE_DIR = path.join(ROOT, 'baseline') const CURRENT_DIR = path.join(ROOT, 'current') const mode = process.argv[2] if (mode !== 'generate' && mode !== 'validate') { console.error('Usage: node scripts/regression-test.js <generate|validate>') process.exit(1) } function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) } function rmDir(dir) { if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }) } function cloneRepo() { if (fs.existsSync(REPO_DIR)) { console.log('Repo already cloned, pulling latest...') execSync('git pull', { cwd: REPO_DIR, stdio: 'inherit' }) } else { ensureDir(ROOT) console.log(`Cloning ${SPEC_REPO}...`) execSync(`git clone --depth 1 "${SPEC_REPO}" "${REPO_DIR}"`, { stdio: 'inherit' }) } } function loadDefaults() { const libDir = path.join(__dirname, '..', 'lib') const cssPath = path.join(libDir, 'css', '3gpp.css') const mermaidPath = path.join(libDir, 'css', 'mermaid-config.json') return { css: fs.existsSync(cssPath) ? fs.readFileSync(cssPath, 'utf8') : '', mermaidConfig: fs.existsSync(mermaidPath) ? fs.readFileSync(mermaidPath, 'utf8') : '{}' } } function exportHtml(inputDir, outputDir) { const { css, mermaidConfig } = loadDefaults() const processor = new Md2Html({ css, mermaidConfig }) return processor.exportHtmlFromDirectory(inputDir, outputDir) } async function exportDocx(inputDir, outputDir) { const { mermaidConfig } = loadDefaults() const files = collectFiles([inputDir]) if (files.length === 0) throw new Error(`No spec files found in ${inputDir}`) const content = concatenateFiles(files) const tempMd = path.join(outputDir, '.~regression.md') const docxPath = path.join(outputDir, 'export.docx') fs.writeFileSync(tempMd, content) let mermaidConfigPath = null if (mermaidConfig !== '{}') { mermaidConfigPath = path.join(outputDir, '.~mermaid.json') fs.writeFileSync(mermaidConfigPath, mermaidConfig) } try { const converter = new MarkdownToDocxConverter(mermaidConfigPath) await converter.convert(tempMd, docxPath, path.dirname(files[0])) return { fileCount: files.length, imageCount: converter.imageCount, message: formatExportMessage('DOCX', files.length, converter.imageCount) } } finally { if (fs.existsSync(tempMd)) fs.unlinkSync(tempMd) if (mermaidConfigPath && fs.existsSync(mermaidConfigPath)) fs.unlinkSync(mermaidConfigPath) } } async function runExport(outputDir) { rmDir(outputDir) ensureDir(outputDir) const inputDir = path.join(REPO_DIR, SPEC_SUBDIR) if (!fs.existsSync(inputDir)) { console.error(`Spec directory not found: ${inputDir}`) process.exit(1) } const htmlDir = path.join(outputDir, 'html') ensureDir(htmlDir) console.log('Exporting HTML...') const htmlResult = exportHtml(inputDir, htmlDir) console.log(` ${htmlResult.message}`) const docxDir = path.join(outputDir, 'docx') ensureDir(docxDir) console.log('Exporting DOCX...') const docxResult = await exportDocx(inputDir, docxDir) console.log(` ${docxResult.message}`) } // ── Comparison helpers ───────────────────────────────────────────── function compareFiles(fileA, fileB) { const a = fs.readFileSync(fileA) const b = fs.readFileSync(fileB) return { sizeA: a.length, sizeB: b.length, identical: a.equals(b) } } function listFilesRecursive(dir, base) { base = base || dir let results = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name) if (entry.isDirectory()) results.push(...listFilesRecursive(full, base)) else results.push(path.relative(base, full)) } return results.sort() } function unzipDocx(docxPath, destDir) { ensureDir(destDir) const zipCopy = docxPath + '.zip' fs.copyFileSync(docxPath, zipCopy) try { if (process.platform === 'win32') { execSync(`powershell -Command "Expand-Archive -LiteralPath '${zipCopy}' -DestinationPath '${destDir}' -Force"`, { stdio: 'pipe' }) } else { execSync(`unzip -o "${zipCopy}" -d "${destDir}"`, { stdio: 'pipe' }) } } finally { fs.unlinkSync(zipCopy) } } /** * Normalizes XML content to remove non-deterministic parts: * - Timestamps in core.xml * - Mermaid SVG hashes in .rels files (mermaid rendering is non-deterministic) */ function normalizeXml(content, relPath) { if (relPath.endsWith('core.xml')) { content = content.replace(/<dcterms:created[^>]*>.*?<\/dcterms:created>/g, '<dcterms:created/>') content = content.replace(/<dcterms:modified[^>]*>.*?<\/dcterms:modified>/g, '<dcterms:modified/>') } content = content.replace(/media\/[0-9a-f]{40}\.svg/g, 'media/MERMAID.svg') return content } function compareXmlFiles(dirA, dirB) { const filesA = listFilesRecursive(dirA).filter(f => f.endsWith('.xml') || f.endsWith('.rels')) const filesB = listFilesRecursive(dirB).filter(f => f.endsWith('.xml') || f.endsWith('.rels')) const allFiles = [...new Set([...filesA, ...filesB])].sort() const results = [] for (const rel of allFiles) { const pathA = path.join(dirA, rel) const pathB = path.join(dirB, rel) if (!fs.existsSync(pathA)) { results.push({ file: rel, status: 'removed' }) } else if (!fs.existsSync(pathB)) { results.push({ file: rel, status: 'added' }) } else { const a = normalizeXml(fs.readFileSync(pathA, 'utf8'), rel) const b = normalizeXml(fs.readFileSync(pathB, 'utf8'), rel) if (a === b) { results.push({ file: rel, status: 'identical' }) } else { results.push({ file: rel, status: 'changed', sizeA: a.length, sizeB: b.length }) } } } return results } function validate() { if (!fs.existsSync(BASELINE_DIR)) { console.error('No baseline found. Run "generate" first.') process.exit(1) } if (!fs.existsSync(CURRENT_DIR)) { console.error('No current export found. Something went wrong during export.') process.exit(1) } let allPassed = true // ── HTML comparison ── console.log('\n═══ HTML Comparison ═══') const baseHtml = path.join(BASELINE_DIR, 'html') const currHtml = path.join(CURRENT_DIR, 'html') const baseHtmlFiles = listFilesRecursive(baseHtml) const currHtmlFiles = listFilesRecursive(currHtml) const allHtmlFiles = [...new Set([...baseHtmlFiles, ...currHtmlFiles])].sort() for (const rel of allHtmlFiles) { const bPath = path.join(baseHtml, rel) const cPath = path.join(currHtml, rel) if (!fs.existsSync(bPath)) { console.log(` + ${rel} (added)`) allPassed = false } else if (!fs.existsSync(cPath)) { console.log(` - ${rel} (removed)`) allPassed = false } else { const cmp = compareFiles(bPath, cPath) if (cmp.identical) { console.log(` ✓ ${rel}`) } else { console.log(` ✗ ${rel} (size: ${cmp.sizeA} → ${cmp.sizeB})`) allPassed = false } } } // ── DOCX comparison ── console.log('\n═══ DOCX Comparison ═══') const baseDocx = path.join(BASELINE_DIR, 'docx', 'export.docx') const currDocx = path.join(CURRENT_DIR, 'docx', 'export.docx') console.log('\n── Binary (informational) ──') const binCmp = compareFiles(baseDocx, currDocx) if (binCmp.identical) { console.log(' ✓ Files are identical') } else { console.log(` ~ Files differ (size: ${binCmp.sizeA} → ${binCmp.sizeB}) — expected due to timestamps/mermaid`) } console.log('\n── XML content ──') const baseUnzip = path.join(BASELINE_DIR, 'docx', '_extracted') const currUnzip = path.join(CURRENT_DIR, 'docx', '_extracted') rmDir(baseUnzip) rmDir(currUnzip) try { unzipDocx(baseDocx, baseUnzip) unzipDocx(currDocx, currUnzip) const xmlResults = compareXmlFiles(baseUnzip, currUnzip) let xmlChanged = 0 for (const r of xmlResults) { if (r.status === 'identical') { console.log(` ✓ ${r.file}`) } else if (r.status === 'changed') { console.log(` ✗ ${r.file} (size: ${r.sizeA} → ${r.sizeB})`) xmlChanged++ } else { console.log(` ${r.status === 'added' ? '+' : '-'} ${r.file} (${r.status})`) xmlChanged++ } } if (xmlChanged > 0) allPassed = false } catch (e) { console.log(` ⚠ Could not extract DOCX for XML comparison: ${e.message}`) allPassed = false } // ── Summary ── console.log('\n═══ Result ═══') if (allPassed) { console.log('✓ All outputs match the baseline.') } else { console.log('✗ Differences detected — see above.') process.exit(1) } } // ── Main ─────────────────────────────────────────────────────────── async function main() { if (mode === 'generate') { cloneRepo() console.log('\nGenerating baseline...') await runExport(BASELINE_DIR) console.log(`\nBaseline stored in ${BASELINE_DIR}`) console.log('Run "validate" after refactoring to compare.') } else { if (!fs.existsSync(REPO_DIR)) { console.error('Spec repo not found. Run "generate" first.') process.exit(1) } console.log('\nGenerating current output...') await runExport(CURRENT_DIR) validate() } } main().catch(e => { console.error(e.message) process.exit(1) })