From e6f529f72d543161a384512b5d837830e1082b68 Mon Sep 17 00:00:00 2001 From: Deano Date: Fri, 30 Jan 2026 09:10:07 +0000 Subject: [PATCH] ci: add A2UI bundling script and CI updates for tests --- .github/workflows/ci.yml | 4 ++ scripts/bundle-a2ui.ts | 112 ++++++++++++++++++++++++++++++++++++++ scripts/test-parallel.mjs | 20 ++++++- 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 scripts/bundle-a2ui.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 885d87fcb..e00b659de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,10 @@ jobs: pnpm -v pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + - name: Prebuild A2UI bundle (bun tests) + if: ${{ matrix.runtime == 'bun' && matrix.task == 'test' }} + run: pnpm canvas:a2ui:bundle + - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) run: ${{ matrix.command }} diff --git a/scripts/bundle-a2ui.ts b/scripts/bundle-a2ui.ts new file mode 100644 index 000000000..91c1de395 --- /dev/null +++ b/scripts/bundle-a2ui.ts @@ -0,0 +1,112 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const HASH_FILE = path.join(repoRoot, "src", "canvas-host", "a2ui", ".bundle.hash"); +const OUTPUT_FILE = path.join(repoRoot, "src", "canvas-host", "a2ui", "a2ui.bundle.js"); + +const INPUT_PATHS = [ + path.join(repoRoot, "package.json"), + path.join(repoRoot, "pnpm-lock.yaml"), + path.join(repoRoot, "vendor", "a2ui", "renderers", "lit"), + path.join(repoRoot, "apps", "shared", "OpenClawKit", "Tools", "CanvasA2UI"), +]; + +async function exists(p: string) { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +async function listFiles(root: string): Promise { + const out: string[] = []; + async function walk(dir: string) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const ent of entries) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + await walk(full); + } else if (ent.isFile()) { + out.push(full); + } + } + } + await walk(root); + return out; +} + +async function collectInputFiles(): Promise { + const files: string[] = []; + for (const p of INPUT_PATHS) { + const st = await fs.stat(p); + if (st.isDirectory()) { + files.push(...(await listFiles(p))); + } else { + files.push(p); + } + } + files.sort((a, b) => a.localeCompare(b, "en")); + return files; +} + +async function sha256File(filePath: string): Promise { + const buf = await fs.readFile(filePath); + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +async function computeHash(): Promise { + const inputs = await collectInputFiles(); + const h = crypto.createHash("sha256"); + for (const p of inputs) { + // include relative path to avoid collisions and keep stable ordering + const rel = path.relative(repoRoot, p).replace(/\\/g, "/"); + h.update(rel); + h.update("\0"); + h.update(await fs.readFile(p)); + h.update("\0"); + } + return h.digest("hex"); +} + +function run(cmd: string, args: string[], label: string) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: "inherit", cwd: repoRoot, shell: process.platform === "win32" }); + child.on("exit", (code, signal) => { + if (code === 0) return resolve(); + reject(new Error(`${label} failed (code=${code ?? "?"}${signal ? ` signal=${signal}` : ""})`)); + }); + }); +} + +async function main() { + const currentHash = await computeHash(); + const previousHash = (await exists(HASH_FILE)) ? (await fs.readFile(HASH_FILE, "utf8")).trim() : null; + + if (previousHash && previousHash === currentHash && (await exists(OUTPUT_FILE))) { + console.log("A2UI bundle up to date; skipping."); + return; + } + + // Build vendor A2UI lit renderer TS output, then bundle our bootstrap. + await run("pnpm", ["-s", "exec", "tsc", "-p", "vendor/a2ui/renderers/lit/tsconfig.json"], "A2UI lit tsc"); + await run("pnpm", ["-s", "exec", "rolldown", "-c", "apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs"], "A2UI rolldown"); + + await fs.writeFile(HASH_FILE, `${currentHash}\n`, "utf8"); + + // sanity + const outHash = await sha256File(OUTPUT_FILE); + if (!outHash) throw new Error("A2UI bundle not generated"); +} + +main().catch((err) => { + console.error("A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle"); + console.error(String(err)); + process.exit(1); +}); diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 433b37376..2312fa4ea 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -3,6 +3,16 @@ import os from "node:os"; const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +function runOnce(args, label) { + return new Promise((resolve, reject) => { + const child = spawn(pnpm, args, { stdio: "inherit", shell: process.platform === "win32" }); + child.on("exit", (code, signal) => { + if (code === 0) return resolve(); + reject(new Error(`${label} failed (code=${code ?? "?"}${signal ? ` signal=${signal}` : ""})`)); + }); + }); +} + const runs = [ { name: "unit", @@ -44,7 +54,7 @@ const WARNING_SUPPRESSION_FLAGS = [ "--disable-warning=DEP0060", ]; -const runOnce = (entry, extraArgs = []) => +const runTestOnce = (entry, extraArgs = []) => new Promise((resolve) => { const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers), ...windowsCiArgs, ...extraArgs] @@ -67,10 +77,10 @@ const runOnce = (entry, extraArgs = []) => }); const run = async (entry) => { - if (shardCount <= 1) return runOnce(entry); + if (shardCount <= 1) return runTestOnce(entry); for (let shardIndex = 1; shardIndex <= shardCount; shardIndex += 1) { // eslint-disable-next-line no-await-in-loop - const code = await runOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); + const code = await runTestOnce(entry, ["--shard", `${shardIndex}/${shardCount}`]); if (code !== 0) return code; } return 0; @@ -85,6 +95,10 @@ const shutdown = (signal) => { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); +// Some unit tests expect the gateway-hosted A2UI scaffold + bundle to be present. +// The bundle is a generated artifact (gitignored), so ensure it's built before running Vitest. +await runOnce(["canvas:a2ui:bundle"], "A2UI bundle"); + const parallelCodes = await Promise.all(parallelRuns.map(run)); const failedParallel = parallelCodes.find((code) => code !== 0); if (failedParallel !== undefined) {