diff --git a/extensions/diffs/src/config.test.ts b/extensions/diffs/src/config.test.ts index 8796ec5b061..cd621cad740 100644 --- a/extensions/diffs/src/config.test.ts +++ b/extensions/diffs/src/config.test.ts @@ -6,7 +6,7 @@ import { ResolvingThemes, } from "@pierre/diffs"; import AjvPkg from "ajv"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_DIFFS_PLUGIN_SECURITY, DEFAULT_DIFFS_TOOL_DEFAULTS, @@ -17,7 +17,12 @@ import { } from "./config.js"; import { renderDiffDocument } from "./render.js"; import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js"; -import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js"; +import { + getServedViewerAsset, + resolveViewerRuntimeFileUrl, + VIEWER_LOADER_PATH, + VIEWER_RUNTIME_PATH, +} from "./viewer-assets.js"; import { parseViewerPayloadJson } from "./viewer-payload.js"; const FULL_DEFAULTS = { @@ -540,6 +545,47 @@ describe("renderDiffDocument", () => { }); describe("viewer assets", () => { + it("prefers the built plugin asset layout when present", async () => { + const stat = vi.fn(async (path: string) => { + if (path === "/repo/dist/extensions/diffs/assets/viewer-runtime.js") { + return { mtimeMs: 1 }; + } + const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" }); + throw error; + }); + + await expect( + resolveViewerRuntimeFileUrl({ + baseUrl: "file:///repo/dist/extensions/diffs/index.js", + stat, + }), + ).resolves.toMatchObject({ + pathname: "/repo/dist/extensions/diffs/assets/viewer-runtime.js", + }); + expect(stat).toHaveBeenCalledTimes(1); + }); + + it("falls back to the source asset layout when the built artifact is absent", async () => { + const stat = vi.fn(async (path: string) => { + if (path === "/repo/extensions/diffs/assets/viewer-runtime.js") { + return { mtimeMs: 1 }; + } + const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" }); + throw error; + }); + + await expect( + resolveViewerRuntimeFileUrl({ + baseUrl: "file:///repo/extensions/diffs/src/viewer-assets.js", + stat, + }), + ).resolves.toMatchObject({ + pathname: "/repo/extensions/diffs/assets/viewer-runtime.js", + }); + expect(stat).toHaveBeenNthCalledWith(1, "/repo/extensions/diffs/src/assets/viewer-runtime.js"); + expect(stat).toHaveBeenNthCalledWith(2, "/repo/extensions/diffs/assets/viewer-runtime.js"); + }); + it("serves a stable loader that points at the current runtime bundle", async () => { const loader = await getServedViewerAsset(VIEWER_LOADER_PATH); diff --git a/extensions/diffs/src/viewer-assets.ts b/extensions/diffs/src/viewer-assets.ts index 83f1c0dc4af..7a7fc94f7d1 100644 --- a/extensions/diffs/src/viewer-assets.ts +++ b/extensions/diffs/src/viewer-assets.ts @@ -6,8 +6,10 @@ export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/"; export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`; export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`; const VIEWER_RUNTIME_RELATIVE_IMPORT_PATH = "./viewer-runtime.js"; - -const VIEWER_RUNTIME_FILE_URL = new URL("../assets/viewer-runtime.js", import.meta.url); +const VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS = [ + "./assets/viewer-runtime.js", + "../assets/viewer-runtime.js", +] as const; export type ServedViewerAsset = { body: string | Buffer; @@ -22,6 +24,43 @@ type RuntimeAssetCache = { let runtimeAssetCache: RuntimeAssetCache | null = null; +type ViewerRuntimeFileUrlParams = { + baseUrl?: string | URL; + stat?: (path: string) => Promise; +}; + +function isMissingFileError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +export async function resolveViewerRuntimeFileUrl( + params: ViewerRuntimeFileUrlParams = {}, +): Promise { + const baseUrl = params.baseUrl ?? import.meta.url; + const stat = params.stat ?? ((path: string) => fs.stat(path)); + let missingFileError: NodeJS.ErrnoException | null = null; + + for (const relativePath of VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS) { + const candidateUrl = new URL(relativePath, baseUrl); + try { + await stat(fileURLToPath(candidateUrl)); + return candidateUrl; + } catch (error) { + if (isMissingFileError(error)) { + missingFileError = error; + continue; + } + throw error; + } + } + + if (missingFileError) { + throw missingFileError; + } + + throw new Error("viewer runtime asset candidates were not checked"); +} + export async function getServedViewerAsset(pathname: string): Promise { if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) { return null; @@ -46,7 +85,8 @@ export async function getServedViewerAsset(pathname: string): Promise { - const runtimePath = fileURLToPath(VIEWER_RUNTIME_FILE_URL); + const runtimeUrl = await resolveViewerRuntimeFileUrl(); + const runtimePath = fileURLToPath(runtimeUrl); const runtimeStat = await fs.stat(runtimePath); if (runtimeAssetCache && runtimeAssetCache.mtimeMs === runtimeStat.mtimeMs) { return runtimeAssetCache; diff --git a/scripts/release-check.ts b/scripts/release-check.ts index a370068e610..9ae6e063ab0 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -11,6 +11,7 @@ import { } from "./lib/bundled-extension-manifest.ts"; import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs"; import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs"; +import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs"; import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts"; export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts"; @@ -23,6 +24,7 @@ const requiredPathGroups = [ ["dist/entry.js", "dist/entry.mjs"], ...listPluginSdkDistArtifacts(), ...listBundledPluginPackArtifacts(), + ...listStaticExtensionAssetOutputs(), "scripts/npm-runner.mjs", "scripts/postinstall-bundled-plugins.mjs", "dist/plugin-sdk/compat.js", @@ -169,7 +171,7 @@ export function collectMissingPackPaths(paths: Iterable): string[] { } return available.has(group) ? [] : [group]; }) - .toSorted(); + .toSorted((left, right) => left.localeCompare(right)); } export function collectForbiddenPackPaths(paths: Iterable): string[] { diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 6c50667a00a..094e4165b35 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -25,8 +25,21 @@ export const STATIC_EXTENSION_ASSETS = [ src: "extensions/acpx/src/runtime-internals/mcp-proxy.mjs", dest: "dist/extensions/acpx/mcp-proxy.mjs", }, + // diffs viewer runtime bundle — co-deployed inside the plugin package so the + // built bundle can resolve `./assets/viewer-runtime.js` from dist. + { + src: "extensions/diffs/assets/viewer-runtime.js", + dest: "dist/extensions/diffs/assets/viewer-runtime.js", + }, ]; +export function listStaticExtensionAssetOutputs(params = {}) { + const assets = params.assets ?? STATIC_EXTENSION_ASSETS; + return assets + .map(({ dest }) => dest.replace(/\\/g, "/")) + .toSorted((left, right) => left.localeCompare(right)); +} + export function copyStaticExtensionAssets(params = {}) { const rootDir = params.rootDir ?? ROOT; const assets = params.assets ?? STATIC_EXTENSION_ASSETS; diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 90ec2b79b03..912ec135c7a 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -263,6 +263,7 @@ describe("collectMissingPackPaths", () => { "dist/control-ui/index.html", "scripts/npm-runner.mjs", "scripts/postinstall-bundled-plugins.mjs", + bundledDistPluginFile("diffs", "assets/viewer-runtime.js"), bundledDistPluginFile("matrix", "helper-api.js"), bundledDistPluginFile("matrix", "runtime-api.js"), bundledDistPluginFile("matrix", "thread-bindings-runtime.js"), @@ -282,6 +283,8 @@ describe("collectMissingPackPaths", () => { "dist/index.js", "dist/entry.js", "dist/control-ui/index.html", + "dist/extensions/acpx/mcp-proxy.mjs", + bundledDistPluginFile("diffs", "assets/viewer-runtime.js"), ...requiredBundledPluginPackPaths, ...requiredPluginSdkPackPaths, "scripts/npm-runner.mjs", diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 81d3a325580..bab69773634 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { copyStaticExtensionAssets, + listStaticExtensionAssetOutputs, writeStableRootRuntimeAliases, } from "../../scripts/runtime-postbuild.mjs"; @@ -22,6 +23,12 @@ async function createTempRoot() { } describe("runtime postbuild static assets", () => { + it("tracks plugin-owned static assets that release packaging must ship", () => { + expect(listStaticExtensionAssetOutputs()).toContain( + "dist/extensions/diffs/assets/viewer-runtime.js", + ); + }); + it("copies declared static assets into dist", async () => { const rootDir = await createTempRoot(); const src = "extensions/acpx/src/runtime-internals/mcp-proxy.mjs";