import fs from "node:fs"; import fsPromises from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { cleanTsdownOutputRoots, createTsdownOutputScanner, pruneSourceCheckoutBundledPluginNodeModules, pruneStaleRootChunkFiles, resolveTsdownBuildInvocation, runTsdownBuildInvocation, } from "../../scripts/tsdown-build.mjs"; import { createScriptTestHarness } from "./test-helpers.js"; const { createTempDir } = createScriptTestHarness(); describe("resolveTsdownBuildInvocation", () => { it("routes Windows tsdown builds through the pnpm runner instead of shell=true", () => { const result = resolveTsdownBuildInvocation({ platform: "win32", nodeExecPath: "C:\\Program Files\\nodejs\\node.exe", npmExecPath: "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", env: {}, }); expect(result).toEqual({ command: "C:\\Program Files\\nodejs\\node.exe", args: [ "C:/Users/test/AppData/Local/pnpm/10.32.1/bin/pnpm.cjs", "exec", "tsdown", "--config-loader", "unrun", "--logLevel", "warn", "--no-clean", ], options: { stdio: ["ignore", "pipe", "pipe"], shell: false, windowsVerbatimArguments: undefined, env: {}, }, }); }); it("keeps source-checkout prune best-effort", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const rmSync = vi.spyOn(fs, "rmSync"); rmSync.mockImplementation(() => { throw new Error("locked"); }); expect(() => pruneSourceCheckoutBundledPluginNodeModules({ cwd: process.cwd(), }), ).not.toThrow(); expect(warn).toHaveBeenCalledWith( "tsdown: could not prune bundled plugin source node_modules: Error: locked", ); warn.mockRestore(); rmSync.mockRestore(); }); it("prunes stale hashed root chunk files but keeps stable aliases and nested assets", async () => { const rootDir = createTempDir("openclaw-tsdown-build-"); const distDir = path.join(rootDir, "dist"); const distRuntimeDir = path.join(rootDir, "dist-runtime"); await fsPromises.mkdir(path.join(distDir, "control-ui"), { recursive: true }); await fsPromises.mkdir(distRuntimeDir, { recursive: true }); await fsPromises.writeFile(path.join(distDir, "delegate-BPjCe4gC.js"), "old delegate\n"); await fsPromises.writeFile(path.join(distDir, "compact.runtime-2DiEmVcA.js"), "old runtime\n"); await fsPromises.writeFile(path.join(distDir, "compact.runtime.js"), "stable alias\n"); await fsPromises.writeFile(path.join(distDir, "entry.js"), "entry\n"); await fsPromises.writeFile(path.join(distDir, "control-ui", "index.html"), "asset\n"); await fsPromises.writeFile( path.join(distRuntimeDir, "heartbeat-runner.runtime-fspOEj_1.js"), "old runtime\n", ); await fsPromises.writeFile(path.join(distRuntimeDir, "heartbeat-runner.runtime.js"), "alias\n"); pruneStaleRootChunkFiles({ cwd: rootDir }); await expect( fsPromises.readFile(path.join(distDir, "compact.runtime.js"), "utf8"), ).resolves.toBe("stable alias\n"); await expect(fsPromises.readFile(path.join(distDir, "entry.js"), "utf8")).resolves.toBe( "entry\n", ); await expect( fsPromises.readFile(path.join(distDir, "control-ui", "index.html"), "utf8"), ).resolves.toBe("asset\n"); await expect( fsPromises.readFile(path.join(distRuntimeDir, "heartbeat-runner.runtime.js"), "utf8"), ).resolves.toBe("alias\n"); await expect(fsPromises.stat(path.join(distDir, "delegate-BPjCe4gC.js"))).rejects.toThrow(); await expect( fsPromises.stat(path.join(distDir, "compact.runtime-2DiEmVcA.js")), ).rejects.toThrow(); await expect( fsPromises.stat(path.join(distRuntimeDir, "heartbeat-runner.runtime-fspOEj_1.js")), ).rejects.toThrow(); }); it("cleans tsdown output roots before using tsdown --no-clean", async () => { const rootDir = createTempDir("openclaw-tsdown-clean-"); const distFile = path.join(rootDir, "dist", "stale.js"); const pluginGeneratedFile = path.join(rootDir, "dist", "extensions", "telegram", "index.js"); const distRuntimeFile = path.join(rootDir, "dist-runtime", "stale.js"); const unrelatedFile = path.join(rootDir, "tmp", "keep.js"); await fsPromises.mkdir(path.dirname(distFile), { recursive: true }); await fsPromises.mkdir(path.dirname(pluginGeneratedFile), { recursive: true }); await fsPromises.mkdir(path.dirname(distRuntimeFile), { recursive: true }); await fsPromises.mkdir(path.dirname(unrelatedFile), { recursive: true }); await fsPromises.writeFile(distFile, "stale\n"); await fsPromises.writeFile(pluginGeneratedFile, "generated\n"); await fsPromises.writeFile(distRuntimeFile, "stale\n"); await fsPromises.writeFile(unrelatedFile, "keep\n"); cleanTsdownOutputRoots({ cwd: rootDir }); await expect(fsPromises.stat(distFile)).rejects.toThrow(); await expect(fsPromises.stat(pluginGeneratedFile)).rejects.toThrow(); await expect(fsPromises.stat(path.join(rootDir, "dist-runtime"))).rejects.toThrow(); await expect(fsPromises.readFile(unrelatedFile, "utf8")).resolves.toBe("keep\n"); }); }); describe("createTsdownOutputScanner", () => { it("tracks fatal build diagnostics while bounding captured output", () => { const scanner = createTsdownOutputScanner({ maxCaptureBytes: 20 }); scanner.append("prefix that should be trimmed\n"); scanner.append("[INEFFECTIVE_DYNAMIC_IMPORT]\n"); scanner.append("[UNRESOLVED_IMPORT] src/index.ts\n"); const result = scanner.finish(); expect(result.hasIneffectiveDynamicImport).toBe(true); expect(result.fatalUnresolvedImport).toContain("[UNRESOLVED_IMPORT] src/index.ts"); expect(result.captured.length).toBeLessThanOrEqual(20); }); it("ignores unresolved imports from bundled plugin and dependency paths", () => { const scanner = createTsdownOutputScanner(); scanner.append("[UNRESOLVED_IMPORT] extensions/telegram/src/index.ts\n"); scanner.append("[UNRESOLVED_IMPORT] node_modules/example/index.js\n"); expect(scanner.finish().fatalUnresolvedImport).toBeNull(); }); }); describe("runTsdownBuildInvocation", () => { function createWriteSink() { const chunks: string[] = []; return { sink: { write(chunk: unknown) { chunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk)); return true; }, }, chunks, }; } it("streams child output while preserving diagnostics for post-run checks", async () => { const output = createWriteSink(); const result = await runTsdownBuildInvocation( { command: process.execPath, args: [ "-e", "process.stdout.write('stdout-ok\\n'); process.stderr.write('[INEFFECTIVE_DYNAMIC_IMPORT]\\n')", ], options: { stdio: ["ignore", "pipe", "pipe"], shell: false, env: process.env, }, }, { stdout: output.sink, stderr: output.sink, env: { ...process.env, OPENCLAW_TSDOWN_HEARTBEAT_MS: "0" }, }, ); expect(result.status).toBe(0); expect(result.hasIneffectiveDynamicImport).toBe(true); expect(output.chunks.join("")).toContain("stdout-ok"); }); it("terminates the child when OPENCLAW_TSDOWN_TIMEOUT_MS elapses", async () => { const output = createWriteSink(); const result = await runTsdownBuildInvocation( { command: process.execPath, args: ["-e", "setTimeout(() => {}, 10000)"], options: { stdio: ["ignore", "pipe", "pipe"], shell: false, env: process.env, }, }, { stdout: output.sink, stderr: output.sink, env: { ...process.env, OPENCLAW_TSDOWN_HEARTBEAT_MS: "0", OPENCLAW_TSDOWN_TIMEOUT_MS: "50", }, }, ); expect(result.timedOut).toBe(true); expect(result.status).toBeNull(); expect(result.signal).toBe("SIGTERM"); expect(output.chunks.join("")).toContain("timeout after 50ms"); }); });