From b118e9e913faee8fbc8bc6451e6323be95f351f9 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 18 Jul 2026 05:27:53 +0100 Subject: [PATCH] fix: preserve generated asset mtimes --- .../scripts/build-copilot-runtime.d.mts | 20 +++++++++ .../browser/scripts/build-copilot-runtime.mjs | 43 +++++++++++++------ .../scripts/build-copilot-runtime.test.ts | 32 ++++++++++++++ scripts/build-diffs-viewer-runtime.mjs | 17 +------- scripts/build-discord-activity-sdk.mjs | 19 +------- scripts/lib/generated-text-asset.d.mts | 12 ++++++ scripts/lib/generated-text-asset.mjs | 25 +++++++++++ scripts/test-projects.test-support.mjs | 12 ++++++ 8 files changed, 136 insertions(+), 44 deletions(-) create mode 100644 extensions/browser/scripts/build-copilot-runtime.d.mts create mode 100644 extensions/browser/scripts/build-copilot-runtime.test.ts create mode 100644 scripts/lib/generated-text-asset.d.mts create mode 100644 scripts/lib/generated-text-asset.mjs diff --git a/extensions/browser/scripts/build-copilot-runtime.d.mts b/extensions/browser/scripts/build-copilot-runtime.d.mts new file mode 100644 index 000000000000..14f541a2dbf4 --- /dev/null +++ b/extensions/browser/scripts/build-copilot-runtime.d.mts @@ -0,0 +1,20 @@ +type CopilotRuntimeBuild = (options: { + bundle: boolean; + entryPoints: string[]; + format: string; + legalComments: string; + minify: boolean; + outfile: string; + platform: string; + target: string; + tsconfig: string; + write: false; +}) => Promise<{ + outputFiles?: Array<{ text: string }>; +}>; + +/** Builds the Browser copilot runtime and returns whether the generated asset changed. */ +export function buildCopilotRuntime(params?: { + build?: CopilotRuntimeBuild; + outputPath?: string; +}): Promise; diff --git a/extensions/browser/scripts/build-copilot-runtime.mjs b/extensions/browser/scripts/build-copilot-runtime.mjs index 31165e6a2b8e..fd6d2cf4e72e 100644 --- a/extensions/browser/scripts/build-copilot-runtime.mjs +++ b/extensions/browser/scripts/build-copilot-runtime.mjs @@ -2,19 +2,38 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { writeGeneratedTextAsset } from "../../../scripts/lib/generated-text-asset.mjs"; -const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const modulePath = fileURLToPath(import.meta.url); +const pluginDir = path.resolve(path.dirname(modulePath), ".."); const repoRoot = path.resolve(pluginDir, "../.."); const outfile = path.join(pluginDir, "chrome-extension", "modules", "copilot-runtime.js"); -await build({ - entryPoints: [path.join(pluginDir, "scripts", "copilot-runtime-entry.ts")], - outfile, - bundle: true, - format: "esm", - legalComments: "inline", - minify: true, - platform: "browser", - target: "chrome125", - tsconfig: path.join(repoRoot, "tsconfig.json"), -}); +/** Builds the copilot runtime without rewriting an identical generated asset. */ +export async function buildCopilotRuntime(params = {}) { + const buildImpl = params.build ?? build; + const outputPath = params.outputPath ?? outfile; + const result = await buildImpl({ + entryPoints: [path.join(pluginDir, "scripts", "copilot-runtime-entry.ts")], + outfile: outputPath, + bundle: true, + format: "esm", + legalComments: "inline", + minify: true, + platform: "browser", + target: "chrome125", + tsconfig: path.join(repoRoot, "tsconfig.json"), + write: false, + }); + + const outputFile = result.outputFiles?.[0]; + if (!outputFile) { + throw new Error("esbuild did not produce the Browser copilot runtime bundle"); + } + + return writeGeneratedTextAsset(outputPath, outputFile.text); +} + +if (process.argv[1] === modulePath) { + await buildCopilotRuntime(); +} diff --git a/extensions/browser/scripts/build-copilot-runtime.test.ts b/extensions/browser/scripts/build-copilot-runtime.test.ts new file mode 100644 index 000000000000..2aa4f79eef91 --- /dev/null +++ b/extensions/browser/scripts/build-copilot-runtime.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../test-support.js"; +import { buildCopilotRuntime } from "./build-copilot-runtime.mjs"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("scripts/build-copilot-runtime.mjs", () => { + it("creates a missing bundle without rewriting it when unchanged", async () => { + const rootDir = tempDirs.make("openclaw-browser-copilot-runtime-"); + const outputPath = path.join(rootDir, "copilot-runtime.js"); + const build = vi.fn(async () => ({ + outputFiles: [{ text: "export const copilotRuntime = true;\n" }], + })); + + await expect(buildCopilotRuntime({ build, outputPath })).resolves.toBe(true); + expect(fs.readFileSync(outputPath, "utf8")).toBe("export const copilotRuntime = true;\n"); + + const initialTime = new Date("2026-07-18T04:00:00.000Z"); + fs.utimesSync(outputPath, initialTime, initialTime); + + await expect(buildCopilotRuntime({ build, outputPath })).resolves.toBe(false); + expect(fs.statSync(outputPath).mtimeMs).toBe(initialTime.getTime()); + expect(build).toHaveBeenCalledWith( + expect.objectContaining({ + outfile: outputPath, + write: false, + }), + ); + }); +}); diff --git a/scripts/build-diffs-viewer-runtime.mjs b/scripts/build-diffs-viewer-runtime.mjs index 8597ddd65004..c02090c182d0 100644 --- a/scripts/build-diffs-viewer-runtime.mjs +++ b/scripts/build-diffs-viewer-runtime.mjs @@ -1,10 +1,10 @@ #!/usr/bin/env node // Builds browser runtime bundles for the diffs viewer assets. -import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { writeGeneratedTextAsset } from "./lib/generated-text-asset.mjs"; const modulePath = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(modulePath), ".."); @@ -73,8 +73,6 @@ async function buildDiffsViewerRuntime(targetName) { } const outputPath = path.join(repoRoot, target.output); - await fs.mkdir(path.dirname(outputPath), { recursive: true }); - const result = await build({ entryPoints: [path.join(repoRoot, target.entry)], bundle: true, @@ -112,18 +110,7 @@ async function buildDiffsViewerRuntime(targetName) { } const runtime = outputFile.text.replace(/[ \t]+$/gm, ""); - let previousRuntime = null; - try { - previousRuntime = await fs.readFile(outputPath, "utf8"); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - - if (previousRuntime !== runtime) { - await fs.writeFile(outputPath, runtime); - } + await writeGeneratedTextAsset(outputPath, runtime); } if (process.argv[1] === modulePath) { diff --git a/scripts/build-discord-activity-sdk.mjs b/scripts/build-discord-activity-sdk.mjs index 8f4088d41b95..0887a1c5485c 100644 --- a/scripts/build-discord-activity-sdk.mjs +++ b/scripts/build-discord-activity-sdk.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { writeGeneratedTextAsset } from "./lib/generated-text-asset.mjs"; const modulePath = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(modulePath), ".."); @@ -14,7 +14,6 @@ const outputPath = path.join(repoRoot, "extensions/discord/assets/embedded-app-s export async function buildDiscordActivitySdk(params = {}) { const buildImpl = params.build ?? build; const targetPath = params.outputPath ?? outputPath; - await fs.mkdir(path.dirname(targetPath), { recursive: true }); const result = await buildImpl({ entryPoints: ["@discord/embedded-app-sdk"], absWorkingDir: discordDir, @@ -33,21 +32,7 @@ export async function buildDiscordActivitySdk(params = {}) { throw new Error("esbuild did not produce the Discord Embedded App SDK bundle"); } - const nextBundle = outputFile.text; - let currentBundle = null; - try { - currentBundle = await fs.readFile(targetPath, "utf8"); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - - if (currentBundle === nextBundle) { - return false; - } - await fs.writeFile(targetPath, nextBundle); - return true; + return writeGeneratedTextAsset(targetPath, outputFile.text); } if (process.argv[1] === modulePath) { diff --git a/scripts/lib/generated-text-asset.d.mts b/scripts/lib/generated-text-asset.d.mts new file mode 100644 index 000000000000..4ae53d5ea925 --- /dev/null +++ b/scripts/lib/generated-text-asset.d.mts @@ -0,0 +1,12 @@ +interface GeneratedTextAssetFs { + readFile(path: string, encoding: "utf8"): Promise; + mkdir(path: string, options: { recursive: true }): Promise; + writeFile(path: string, contents: string, encoding: "utf8"): Promise; +} + +/** Writes a generated text asset and returns whether its contents changed. */ +export function writeGeneratedTextAsset( + filePath: string, + contents: string, + params?: { fs?: GeneratedTextAssetFs }, +): Promise; diff --git a/scripts/lib/generated-text-asset.mjs b/scripts/lib/generated-text-asset.mjs new file mode 100644 index 000000000000..0ba40a2f4927 --- /dev/null +++ b/scripts/lib/generated-text-asset.mjs @@ -0,0 +1,25 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +/** + * Writes a generated text asset only when its contents changed. + */ +export async function writeGeneratedTextAsset(filePath, contents, params = {}) { + const fsImpl = params.fs ?? fs; + let currentContents = null; + try { + currentContents = await fsImpl.readFile(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + if (currentContents === contents) { + return false; + } + + await fsImpl.mkdir(path.dirname(filePath), { recursive: true }); + await fsImpl.writeFile(filePath, contents, "utf8"); + return true; +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 467528c43c92..c47587c90a16 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1443,6 +1443,14 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/lib/plugin-npm-runtime-assets.mjs", ["test/scripts/plugin-npm-runtime-build-args.test.ts"], ], + [ + "scripts/lib/generated-text-asset.mjs", + [ + "extensions/browser/scripts/build-copilot-runtime.test.ts", + "test/scripts/build-diffs-viewer-runtime.test.ts", + "test/scripts/bundled-plugin-assets.test.ts", + ], + ], [ "scripts/lib/static-extension-assets.mjs", [ @@ -2134,6 +2142,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/build-discord-activity-sdk.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], ["scripts/build-diffs-viewer-runtime.mjs", ["test/scripts/build-diffs-viewer-runtime.test.ts"]], ["scripts/run-node-watch-paths.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], + [ + "extensions/browser/scripts/build-copilot-runtime.mjs", + ["extensions/browser/scripts/build-copilot-runtime.test.ts"], + ], ["extensions/canvas/scripts/bundle-a2ui.mjs", ["extensions/canvas/scripts/bundle-a2ui.test.ts"]], ["extensions/canvas/scripts/copy-a2ui.mjs", ["extensions/canvas/scripts/copy-a2ui.test.ts"]], ]);