Files
KDL_WORK/kdl-wasm/web/scripts/build-demo-package.mjs
2026-06-28 20:58:18 +08:00

549 lines
19 KiB
JavaScript

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) => `
<tr>
<td><a href="${escapeAttr(program.source)}">${escapeHtml(program.program_id)}</a></td>
<td>${escapeHtml(program.coverage_level)}</td>
<td>${escapeHtml(program.status)}</td>
<td>${escapeHtml(program.spec_sections.join(", "))}</td>
<td><a href="${escapeAttr(program.artifacts.compile)}">compile</a></td>
</tr>`).join("");
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KDL OLP Demo ${escapeHtml(demoManifest.release_id)}</title>
<style>
:root {
color-scheme: light;
--ink: #172026;
--muted: #5e6a71;
--line: #cfd7dc;
--panel: #f6f8f9;
--accent: #1f7a8c;
--accent-2: #7b6d3a;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; color: var(--ink); background: #ffffff; }
header { padding: 28px 32px 18px; border-bottom: 1px solid var(--line); background: var(--panel); }
h1 { margin: 0 0 10px; font-size: 28px; line-height: 1.2; letter-spacing: 0; }
p { margin: 0; color: var(--muted); line-height: 1.55; }
main { max-width: 1180px; margin: 0 auto; padding: 24px 24px 40px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0 24px; }
.actions a { color: #fff; background: var(--accent); text-decoration: none; padding: 10px 13px; border-radius: 6px; font-weight: 700; }
.actions a.secondary { background: var(--accent-2); }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 24px; }
.summary div { border: 1px solid var(--line); border-radius: 6px; padding: 12px; background: #fff; }
.summary strong { display: block; font-size: 22px; }
section { margin-top: 28px; }
h2 { font-size: 18px; margin: 0 0 12px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { border: 1px solid var(--line); padding: 9px 10px; text-align: left; vertical-align: top; }
th { background: var(--panel); }
img { max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 6px; }
.screens { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); gap: 16px; align-items: start; }
@media (max-width: 760px) {
header { padding: 22px 18px 14px; }
main { padding: 18px 14px 32px; }
h1 { font-size: 23px; }
.screens { grid-template-columns: 1fr; }
table { font-size: 13px; }
th, td { padding: 7px; }
}
</style>
</head>
<body>
<header>
<h1>KDL OLP ABB120 Working2 Demo</h1>
<p>Release ${escapeHtml(demoManifest.release_id)} / Job ${escapeHtml(demoManifest.job_id)} / Suite ${escapeHtml(demoManifest.suite_id)}</p>
</header>
<main>
<nav class="actions" aria-label="demo links">
<a href="${escapeAttr(demoManifest.virtual_controller_entry)}">Open virtual controller</a>
<a href="${escapeAttr(demoManifest.report_html)}" class="secondary">Open report</a>
<a href="${escapeAttr(demoManifest.program_manifest)}" class="secondary">Program manifest</a>
<a href="demo-manifest.json" class="secondary">Demo manifest</a>
</nav>
<div class="summary">
<div><span>Programs</span><strong>${demoManifest.program_count}</strong></div>
<div><span>Runtime</span><strong>${demoManifest.runtime_programs}</strong></div>
<div><span>Static</span><strong>${demoManifest.static_programs}</strong></div>
<div><span>Post</span><strong>${demoManifest.post_programs}</strong></div>
<div><span>Fail</span><strong>${demoManifest.summary.fail}</strong></div>
</div>
<section>
<h2>Program Evidence</h2>
<table>
<thead><tr><th>Program</th><th>Layer</th><th>Status</th><th>Spec Sections</th><th>Artifact</th></tr></thead>
<tbody>${rows}
</tbody>
</table>
</section>
<section>
<h2>Screenshots</h2>
<div class="screens">
<a href="${escapeAttr(demoManifest.screenshots.desktop)}"><img src="${escapeAttr(demoManifest.screenshots.desktop)}" alt="Virtual controller desktop screenshot"></a>
<a href="${escapeAttr(demoManifest.screenshots.mobile)}"><img src="${escapeAttr(demoManifest.screenshots.mobile)}" alt="Virtual controller mobile screenshot"></a>
</div>
</section>
<section>
<h2>Documents</h2>
<table>
<tbody>
${demoManifest.docs.map((doc) => `<tr><td>${escapeHtml(doc.title)}</td><td><a href="${escapeAttr(doc.href)}">${escapeHtml(doc.href)}</a></td></tr>`).join("\n ")}
</tbody>
</table>
</section>
</main>
</body>
</html>
`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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;
}