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)}
Programs${demoManifest.program_count}
Runtime${demoManifest.runtime_programs}
Static${demoManifest.static_programs}
Post${demoManifest.post_programs}
Fail${demoManifest.summary.fail}
Program Evidence
| Program | Layer | Status | Spec Sections | Artifact |
${rows}
`;
}
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;
}