From 107820b2bd4ed93de9c8fe5594a71fb1c8bbb0be Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 16 Jul 2026 22:17:28 +0100 Subject: [PATCH] fix: prevent generated asset watch loops --- scripts/build-discord-activity-sdk.mjs | 55 ++++++++++++++----- scripts/run-node-watch-paths.mjs | 12 ++++- scripts/test-projects.test-support.mjs | 2 + test/scripts/bundled-plugin-assets.test.ts | 61 +++++++++++++++++++--- 4 files changed, 109 insertions(+), 21 deletions(-) diff --git a/scripts/build-discord-activity-sdk.mjs b/scripts/build-discord-activity-sdk.mjs index 9b5371847f0e..792dc4934c1c 100644 --- a/scripts/build-discord-activity-sdk.mjs +++ b/scripts/build-discord-activity-sdk.mjs @@ -10,15 +10,46 @@ const repoRoot = path.resolve(path.dirname(modulePath), ".."); const discordDir = path.join(repoRoot, "extensions/discord"); const outputPath = path.join(repoRoot, "extensions/discord/assets/embedded-app-sdk.mjs"); -await fs.mkdir(path.dirname(outputPath), { recursive: true }); -await build({ - entryPoints: ["@discord/embedded-app-sdk"], - absWorkingDir: discordDir, - bundle: true, - platform: "browser", - target: "es2020", - format: "esm", - minify: true, - legalComments: "none", - outfile: outputPath, -}); +/** Builds the browser SDK bundle without rewriting an identical tracked asset. */ +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, + bundle: true, + platform: "browser", + target: "es2020", + format: "esm", + minify: true, + legalComments: "none", + outfile: targetPath, + write: false, + }); + + const outputFile = result.outputFiles?.[0]; + if (!outputFile) { + 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; +} + +if (process.argv[1] === modulePath) { + await buildDiscordActivitySdk(); +} diff --git a/scripts/run-node-watch-paths.mjs b/scripts/run-node-watch-paths.mjs index 6f493c1a60bf..b2aad34aaecb 100644 --- a/scripts/run-node-watch-paths.mjs +++ b/scripts/run-node-watch-paths.mjs @@ -40,6 +40,13 @@ const ignoredRunNodeRepoPathPatterns = [ /^extensions\/[^/]+\/src\/host\/.+\/\.bundle\.hash$/u, /^extensions\/[^/]+\/src\/host\/.+\/[^/]+\.bundle\.js$/u, ]; +// Asset build hooks write these tracked runtime bundles. They are outputs, not +// source inputs; watching them makes the dev build react to its own writes. +const generatedPluginAssetPaths = new Set([ + "extensions/diffs-language-pack/assets/viewer-runtime.js", + "extensions/diffs/assets/viewer-runtime.js", + "extensions/discord/assets/embedded-app-sdk.mjs", +]); const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/; /** Normalizes watch paths to repository-style POSIX separators. */ @@ -69,7 +76,10 @@ const isRestartRelevantExtensionPath = (relativePath) => { const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { const normalizedPath = normalizeRunNodePath(repoPath).replace(/^\.\/+/, ""); - if (ignoredRunNodeRepoPathPatterns.some((pattern) => pattern.test(normalizedPath))) { + if ( + generatedPluginAssetPaths.has(normalizedPath) || + ignoredRunNodeRepoPathPatterns.some((pattern) => pattern.test(normalizedPath)) + ) { return false; } if (runNodeConfigFiles.includes(normalizedPath)) { diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index aa672358cd98..ba058954428c 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -2092,7 +2092,9 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/e2e/cron-mcp-cleanup-seed.ts", ["test/scripts/docker-e2e-seeds.test.ts"]], ["scripts/bundled-plugin-assets.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], ["scripts/bundle-a2ui.mjs", ["test/scripts/bundled-plugin-assets.test.ts"]], + ["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/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"]], ]); diff --git a/test/scripts/bundled-plugin-assets.test.ts b/test/scripts/bundled-plugin-assets.test.ts index 8eee9f63d221..25e274f44271 100644 --- a/test/scripts/bundled-plugin-assets.test.ts +++ b/test/scripts/bundled-plugin-assets.test.ts @@ -2,11 +2,16 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { buildDiscordActivitySdk } from "../../scripts/build-discord-activity-sdk.mjs"; import { parseBundledPluginAssetArgs, readBundledPluginAssetHooks, } from "../../scripts/bundled-plugin-assets.mjs"; +import { + isBuildRelevantRunNodePath, + isRestartRelevantRunNodePath, +} from "../../scripts/run-node-watch-paths.mjs"; async function withPluginAssetFixture(run: (rootDir: string) => Promise) { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-assets-")); @@ -39,14 +44,30 @@ async function withPluginAssetFixture(run: (rootDir: string) => Promise) { } describe("bundled plugin assets", () => { - it("resolves the Discord SDK entry from the package that declares it", () => { - const script = fs.readFileSync( - path.join(process.cwd(), "scripts/build-discord-activity-sdk.mjs"), - "utf8", - ); + it("does not rewrite an unchanged Discord SDK bundle", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-discord-sdk-")); + const outputPath = path.join(rootDir, "embedded-app-sdk.mjs"); + const build = vi.fn(async () => ({ + outputFiles: [{ text: "export const sdk = true;\n" }], + })); - expect(script).toContain('const discordDir = path.join(repoRoot, "extensions/discord")'); - expect(script).toContain("absWorkingDir: discordDir"); + try { + fs.writeFileSync(outputPath, "export const sdk = true;\n"); + const initialTime = new Date("2026-07-16T12:00:00.000Z"); + fs.utimesSync(outputPath, initialTime, initialTime); + + await expect(buildDiscordActivitySdk({ build, outputPath })).resolves.toBe(false); + expect(fs.statSync(outputPath).mtimeMs).toBe(initialTime.getTime()); + expect(build).toHaveBeenCalledWith( + expect.objectContaining({ + absWorkingDir: path.join(process.cwd(), "extensions/discord"), + outfile: outputPath, + write: false, + }), + ); + } finally { + fs.rmSync(rootDir, { force: true, recursive: true }); + } }); it("discovers the Discord Embedded App SDK build hook", async () => { @@ -66,6 +87,30 @@ describe("bundled plugin assets", () => { ]); }); + it("keeps build-generated static assets out of the source watcher", async () => { + const rootDir = process.cwd(); + const hooks = await readBundledPluginAssetHooks({ phase: "build", rootDir }); + const generatedAssetSources = hooks.flatMap((hook) => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(hook.pluginDir, "package.json"), "utf8"), + ) as { + openclaw?: { build?: { staticAssets?: Array<{ source?: string }> } }; + }; + const pluginPath = path.relative(rootDir, hook.pluginDir).replaceAll(path.sep, "/"); + return (packageJson.openclaw?.build?.staticAssets ?? []).flatMap((asset) => { + const source = asset.source?.replace(/^\.\/+/u, ""); + return source ? [path.posix.join(pluginPath, source)] : []; + }); + }); + + expect(generatedAssetSources).toContain("extensions/discord/assets/embedded-app-sdk.mjs"); + for (const source of generatedAssetSources) { + expect(isBuildRelevantRunNodePath(source), source).toBe(false); + expect(isRestartRelevantRunNodePath(source), source).toBe(false); + } + expect(isRestartRelevantRunNodePath("extensions/discord/src/activities/http.ts")).toBe(true); + }); + it("discovers plugin-owned asset scripts by manifest id", async () => { await withPluginAssetFixture(async (rootDir) => { const hooks = await readBundledPluginAssetHooks({