import { createHash } from "node:crypto"; import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(fileURLToPath(new URL("../../..", import.meta.url))); const webRoot = join(root, "kdl-wasm", "web"); const deployRoot = join(webRoot, "test-results", "deploy"); const appRoot = join(webRoot, "app"); const specRoot = join(webRoot, "tests", "fixtures", "abb120", "spec-programs"); const resultRoot = join(webRoot, "test-results", "abb120-spec"); const uiResultRoot = join(webRoot, "test-results", "virtual-controller"); const working2Root = join(root, "working2"); const docsRoot = join(root, "work", "doc"); const jobId = getArg("--job") ?? latestJobId(); const generatedAt = new Date(); const releaseId = getArg("--release") ?? `${formatShanghaiId(generatedAt)}-${jobId}`; const packageName = `kdl-olp-demo-${releaseId}`; const packageDir = join(deployRoot, packageName); const zipPath = join(deployRoot, `${packageName}.zip`); const jobDir = join(resultRoot, jobId); const manifest = readJson(join(specRoot, "manifest.json")); const job = readJson(join(jobDir, "job.json")); const report = readJson(join(jobDir, "report.json")); const uiEvidence = readJson(join(uiResultRoot, "evidence.json")); assertReportIsDeployable(report); assertJobMatchesInputs(job, report, manifest); assertSafeDeployPath(packageDir); mkdirSync(deployRoot, { recursive: true }); rmSync(packageDir, { recursive: true, force: true }); mkdirSync(packageDir, { recursive: true }); copyTree(appRoot, join(packageDir, "app")); copyTree(specRoot, join(packageDir, "spec-programs")); copyTree(jobDir, join(packageDir, "test-results", "abb120-spec", jobId)); copyTree(uiResultRoot, join(packageDir, "test-results", "virtual-controller")); copyDocs(join(packageDir, "docs")); copyWorking2Docs(join(packageDir, "docs", "working2")); const demoManifest = buildDemoManifest(); writeJson(join(packageDir, "demo-manifest.json"), demoManifest); writeFileSync(join(packageDir, "index.html"), indexHtml(demoManifest), "utf8"); writeFileSync(join(packageDir, "README.md"), packageReadme(demoManifest), "utf8"); zipDirectory(packageDir, zipPath); const zip = { path: slash(relative(root, zipPath)), bytes: statSync(zipPath).size, sha256: sha256File(zipPath) }; const summary = { release_id: releaseId, generated_at: generatedAt.toISOString(), job_id: jobId, package_dir: slash(relative(root, packageDir)), zip, program_count: demoManifest.program_count, report_fail: report.summary.fail }; writeJson(join(packageDir, "demo-build-summary.json"), summary); writeJson(join(deployRoot, `${packageName}.summary.json`), summary); console.log(JSON.stringify(summary, null, 2)); function buildDemoManifest() { const levelCounts = countLevels(manifest.programs); const programs = manifest.programs.map((entry) => { const jobProgram = job.programs.find((program) => program.program_id === entry.program_id); return { program_id: entry.program_id, file: entry.file, source: `spec-programs/${entry.file}`, coverage_level: entry.coverage_level, spec_sections: entry.spec_sections, expected_status: entry.expected_status, status: jobProgram?.status ?? "missing", diagnostics: jobProgram?.diagnostics?.map((diagnostic) => diagnostic.code).filter(Boolean) ?? [], artifacts: artifactLinks(entry.program_id) }; }); return { release_id: releaseId, generated_at: generatedAt.toISOString(), suite_id: manifest.suite_id, job_id: job.job_id, robot_id: manifest.robot_id, spec_source: manifest.spec_source, program_count: manifest.programs.length, runtime_programs: levelCounts.Runtime, static_programs: levelCounts.Static, post_programs: levelCounts.Post, entry: "app/virtual-controller.html", virtual_controller_entry: "app/virtual-controller.html", report_json: `test-results/abb120-spec/${job.job_id}/report.json`, report_html: `test-results/abb120-spec/${job.job_id}/report.html`, job_json: `test-results/abb120-spec/${job.job_id}/job.json`, program_manifest: "spec-programs/manifest.json", screenshots: { desktop: "test-results/virtual-controller/virtual-controller-desktop.png", mobile: "test-results/virtual-controller/virtual-controller-mobile.png", evidence: "test-results/virtual-controller/evidence.json" }, docs: [ { title: "GRL user manual", href: "docs/grl-user-manual.docx", kind: "docx" }, { title: "OLP test document", href: "docs/olp-test-document.docx", kind: "docx" }, { title: "Working2 acceptance evidence", href: "docs/working2/acceptance-evidence.md", kind: "markdown" } ], coverage: report.coverage, summary: report.summary, programs }; } function artifactLinks(programId) { const base = `test-results/abb120-spec/${job.job_id}/programs/${programId}`; return { compile: `${base}/compile.json`, controller: `${base}/controller.json`, motion_queue: `${base}/motion-queue.json`, trace: `${base}/trace.json`, io: `${base}/io.json`, trajectory: `${base}/trajectory.json`, post_report: `${base}/post-report.json`, roundtrip: `${base}/roundtrip.json` }; } function copyDocs(targetDir) { mkdirSync(targetDir, { recursive: true }); const docs = [ ["GRL功能语法逻辑与程序创建使用手册.docx", "grl-user-manual.docx"], ["通用机器人离线编程系统测试文档.docx", "olp-test-document.docx"] ]; for (const [doc, alias] of docs) { const source = join(docsRoot, doc); if (!existsSync(source)) { throw new Error(`Required document is missing: ${source}`); } copyFileSync(source, join(targetDir, doc)); copyFileSync(source, join(targetDir, alias)); } } function copyWorking2Docs(targetDir) { mkdirSync(targetDir, { recursive: true }); for (const entry of readdirSync(working2Root, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith(".md")) { copyFileSync(join(working2Root, entry.name), join(targetDir, entry.name)); } } const aliases = { "01-项目功能内容.md": "project-scope.md", "02-项目程序开发详细步骤.md": "development-steps.md", "03-推进台账.md": "progress-log.md", "04-任务矩阵.md": "task-matrix.md", "05-验收证据.md": "acceptance-evidence.md", "06-决策记录.md": "decisions.md", "07-服务器发布与演示包实施方案.md": "server-deploy-plan.md" }; for (const [sourceName, aliasName] of Object.entries(aliases)) { const source = join(working2Root, sourceName); if (existsSync(source)) { copyFileSync(source, join(targetDir, aliasName)); } } const manifestDir = join(working2Root, "programs"); if (existsSync(manifestDir)) { copyTree(manifestDir, join(targetDir, "programs")); const manifestSource = join(manifestDir, "manifest.md"); if (existsSync(manifestSource)) { copyFileSync(manifestSource, join(targetDir, "program-manifest.md")); } } } function indexHtml(demoManifest) { const rows = demoManifest.programs.map((program) => ` ${escapeHtml(program.program_id)} ${escapeHtml(program.coverage_level)} ${escapeHtml(program.status)} ${escapeHtml(program.spec_sections.join(", "))} compile `).join(""); return ` KDL OLP Demo ${escapeHtml(demoManifest.release_id)}

KDL OLP ABB120 Working2 Demo

Release ${escapeHtml(demoManifest.release_id)} / Job ${escapeHtml(demoManifest.job_id)} / Suite ${escapeHtml(demoManifest.suite_id)}

Programs${demoManifest.program_count}
Runtime${demoManifest.runtime_programs}
Static${demoManifest.static_programs}
Post${demoManifest.post_programs}
Fail${demoManifest.summary.fail}

Program Evidence

${rows}
ProgramLayerStatusSpec SectionsArtifact

Screenshots

Virtual controller desktop screenshot Virtual controller mobile screenshot

Documents

${demoManifest.docs.map((doc) => ``).join("\n ")}
${escapeHtml(doc.title)}${escapeHtml(doc.href)}
`; } function packageReadme(demoManifest) { return [ `# KDL OLP demo ${demoManifest.release_id}`, "", `Job: ${demoManifest.job_id}`, `Suite: ${demoManifest.suite_id}`, `Programs: ${demoManifest.program_count}`, `Report: ${demoManifest.report_html}`, `Virtual controller: ${demoManifest.virtual_controller_entry}`, "", "Serve this directory as the HTTPS document root for the demo port." ].join("\n"); } function latestJobId() { if (!existsSync(resultRoot)) { throw new Error(`No ABB120 spec result directory found: ${resultRoot}`); } const jobs = readdirSync(resultRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && entry.name.startsWith("W2-JOB-")) .map((entry) => ({ name: entry.name, mtimeMs: statSync(join(resultRoot, entry.name)).mtimeMs })) .sort((left, right) => right.mtimeMs - left.mtimeMs || right.name.localeCompare(left.name)); if (jobs.length === 0) { throw new Error(`No W2-JOB-* result directories found in ${resultRoot}`); } return jobs[0].name; } function assertReportIsDeployable(report) { if (report.summary?.programs !== 19) { throw new Error(`Expected report.summary.programs=19, got ${report.summary?.programs}`); } if (report.summary?.fail !== 0) { throw new Error(`Expected report.summary.fail=0, got ${report.summary?.fail}`); } if (report.coverage?.missingSections?.length !== 0) { throw new Error(`Expected no missing sections, got ${report.coverage.missingSections.join(",")}`); } } function assertJobMatchesInputs(job, report, manifest) { if (job.job_id !== jobId || report.job_id !== jobId) { throw new Error(`Job id mismatch: requested ${jobId}, job=${job.job_id}, report=${report.job_id}`); } if (job.suite_id !== manifest.suite_id || report.suite_id !== manifest.suite_id) { throw new Error("Suite id mismatch between manifest, job, and report"); } if (manifest.programs.length !== 19 || job.programs.length !== 19) { throw new Error(`Expected 19 programs, manifest=${manifest.programs.length}, job=${job.programs.length}`); } } function countLevels(programs) { return programs.reduce((counts, program) => { counts[program.coverage_level] = (counts[program.coverage_level] ?? 0) + 1; return counts; }, { Runtime: 0, Static: 0, Post: 0 }); } function copyTree(source, target) { if (!existsSync(source)) { throw new Error(`Required path is missing: ${source}`); } mkdirSync(dirname(target), { recursive: true }); cpSync(source, target, { recursive: true, force: true }); } function readJson(file) { return JSON.parse(readFileSync(file, "utf8")); } function writeJson(file, value) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function zipDirectory(sourceDir, destinationFile) { const files = walkFiles(sourceDir).sort((left, right) => left.localeCompare(right)); const chunks = []; const central = []; let offset = 0; for (const file of files) { const data = readFileSync(file); const name = slash(relative(sourceDir, file)); const nameBuffer = Buffer.from(name, "utf8"); const stat = statSync(file); const { time, date } = dosDateTime(stat.mtime); const crc = crc32(data); const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(0, 8); local.writeUInt16LE(time, 10); local.writeUInt16LE(date, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(nameBuffer.length, 26); local.writeUInt16LE(0, 28); chunks.push(local, nameBuffer, data); const header = Buffer.alloc(46); header.writeUInt32LE(0x02014b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(20, 6); header.writeUInt16LE(0x0800, 8); header.writeUInt16LE(0, 10); header.writeUInt16LE(time, 12); header.writeUInt16LE(date, 14); header.writeUInt32LE(crc, 16); header.writeUInt32LE(data.length, 20); header.writeUInt32LE(data.length, 24); header.writeUInt16LE(nameBuffer.length, 28); header.writeUInt16LE(0, 30); header.writeUInt16LE(0, 32); header.writeUInt16LE(0, 34); header.writeUInt16LE(0, 36); header.writeUInt32LE(0, 38); header.writeUInt32LE(offset, 42); central.push(header, nameBuffer); offset += local.length + nameBuffer.length + data.length; } const centralOffset = offset; const centralSize = central.reduce((total, chunk) => total + chunk.length, 0); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10); end.writeUInt32LE(centralSize, 12); end.writeUInt32LE(centralOffset, 16); end.writeUInt16LE(0, 20); rmSync(destinationFile, { force: true }); writeFileSync(destinationFile, Buffer.concat([...chunks, ...central, end])); } function sha256File(file) { return createHash("sha256").update(readFileSync(file)).digest("hex"); } function formatShanghaiId(date) { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).formatToParts(date); const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${value.year}${value.month}${value.day}-${value.hour}${value.minute}${value.second}`; } function getArg(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; } function slash(value) { return value.split(sep).join("/"); } function psSingleQuoted(value) { return `'${String(value).replaceAll("'", "''")}'`; } function assertSafeDeployPath(path) { const allowedRoot = resolve(deployRoot) + sep; const resolved = resolve(path); if (!resolved.startsWith(allowedRoot)) { throw new Error(`Refusing to modify path outside deploy root: ${resolved}`); } } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function escapeAttr(value) { return escapeHtml(value); } function walkFiles(dir) { const files = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { files.push(...walkFiles(fullPath)); } else if (entry.isFile()) { files.push(fullPath); } } return files; } function dosDateTime(date) { const year = Math.max(1980, date.getFullYear()); const month = date.getMonth() + 1; const day = date.getDate(); const hours = date.getHours(); const minutes = date.getMinutes(); const seconds = Math.floor(date.getSeconds() / 2); return { time: (hours << 11) | (minutes << 5) | seconds, date: ((year - 1980) << 9) | (month << 5) | day }; } var crcTable; function crc32(buffer) { crcTable ??= createCrcTable(); let crc = 0xffffffff; for (const byte of buffer) { crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } function createCrcTable() { const table = new Uint32Array(256); for (let index = 0; index < 256; index += 1) { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1); } table[index] = value >>> 0; } return table; }