import { describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { bundleExternalPlugin, buildSharedModuleSource } from "./bundle"; import { PLUGIN_HOST_GLOBAL } from "./host-contract"; /** * This is the seam that lets a plugin on disk run inside the desktop and * browser renderers, and the failure it prevents is nasty: if `react` or a * `gloomberb/*` module gets bundled into the plugin instead of shared with the * host, the plugin loads fine and then throws on its first hook, or silently * renders against a second copy of the theme. That is worth pinning down. */ function scratchPlugin(source: string): string { const dir = mkdtempSync(join(tmpdir(), "gloom-bundle-")); writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "scratch", main: "index.tsx" })); writeFileSync(join(dir, "index.tsx"), source); return dir; } const fakeExports = async (specifier: string) => ( specifier === "gloomberb/ui" ? ["Box", "Text"] : specifier === "react" ? ["useState"] : specifier === "gloomberb/broker" ? ["PRESERVED_PASSWORD_HINT", "getBrokerRemoteClient"] : specifier === "gloomberb/types/config" ? ["TICKER_RESEARCH_PANE_ID"] : [] ); describe("buildSharedModuleSource", () => { test("re-exports each name from the host registry", () => { const source = buildSharedModuleSource("gloomberb/ui", ["Box", "Text"]); expect(source).toContain(PLUGIN_HOST_GLOBAL); expect(source).toContain('export const Box = mod["Box"];'); expect(source).toContain('export const Text = mod["Text"];'); }); test("throws a directed error when the host registry is missing", () => { // A plugin bundle loaded before the host installs its modules should say so, // not fail later with an undefined property read deep in a render. const source = buildSharedModuleSource("gloomberb/ui", []); expect(source).toContain("was not installed before this plugin loaded"); }); test("skips a default export, which the module already provides", () => { expect(buildSharedModuleSource("react", ["default", "useState"])) .not.toContain("export const default"); }); }); describe("bundleExternalPlugin", () => { test("shares react and gloomberb modules instead of bundling them", async () => { const dir = scratchPlugin(` import { useState } from "react"; import { Box } from "gloomberb/ui"; export default { id: "scratch", name: "Scratch", version: "1.0.0", useState, Box }; `); const out = join(dir, "out"); try { const result = await bundleExternalPlugin(dir, out, { exportNamesFor: fakeExports }); const code = await Bun.file(result.outputPath).text(); expect(result.shared).toEqual(["gloomberb/ui", "react"]); expect(code).toContain(PLUGIN_HOST_GLOBAL); // The giveaway that React got inlined rather than shared. expect(code).not.toContain("react-dom/client"); expect(code).not.toContain("Invalid hook call"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("bundles a plugin's own dependencies rather than sharing them", async () => { const dir = scratchPlugin(` import { double } from "./helper"; export default { id: "scratch", name: "Scratch", version: "1.0.0", value: double(21) }; `); writeFileSync(join(dir, "helper.ts"), "export function double(n: number) { return n * 2; }"); const out = join(dir, "out"); try { const result = await bundleExternalPlugin(dir, out, { exportNamesFor: fakeExports }); const code = await Bun.file(result.outputPath).text(); expect(result.shared).toEqual([]); expect(code).toContain("* 2"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("reports a compile error against the plugin instead of throwing something opaque", async () => { const dir = scratchPlugin(`import { missing } from "./nope"; export default missing;`); try { await expect(bundleExternalPlugin(dir, join(dir, "out"), { exportNamesFor: fakeExports })) .rejects.toThrow(); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("shares gloomberb/broker rather than bundling the host's copy", async () => { // That module owns the remote broker client the desktop view installs at // startup. A plugin carrying its own copy reads an empty one and reports // that the broker host is unavailable, which looks like a broken broker // rather than a bundling mistake. const dir = scratchPlugin(` import { PRESERVED_PASSWORD_HINT } from "gloomberb/broker"; export default { id: "scratch", name: "Scratch", version: "1.0.0", hint: PRESERVED_PASSWORD_HINT }; `); try { const result = await bundleExternalPlugin(dir, join(dir, "out"), { exportNamesFor: fakeExports }); const code = await Bun.file(result.outputPath).text(); expect(result.shared).toEqual(["gloomberb/broker"]); expect(code).toContain(PLUGIN_HOST_GLOBAL); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("compiles the browser entry so a plugin with a native half still loads", async () => { // Bun's browser target rejects `node:*` imports even behind a dynamic // import, so without this a broker that opens a socket or resolves DNS // could not ship to the desktop view at all. const dir = mkdtempSync(join(tmpdir(), "gloom-bundle-browser-")); writeFileSync( join(dir, "package.json"), JSON.stringify({ name: "scratch", main: "index.ts", browser: "index.browser.ts" }), ); writeFileSync(join(dir, "native.ts"), ` import { Socket } from "node:net"; export const connect = () => new Socket(); `); writeFileSync(join(dir, "index.ts"), ` export default { id: "scratch", name: "Scratch", version: "1.0.0", load: () => import("./native") }; `); writeFileSync(join(dir, "index.browser.ts"), ` export default { id: "scratch", name: "Scratch", version: "1.0.0" }; `); const out = join(dir, "out"); try { const result = await bundleExternalPlugin(dir, out, { exportNamesFor: fakeExports }); const code = await Bun.file(result.outputPath).text(); expect(code).toContain("Scratch"); expect(code).not.toContain("node:net"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("shares the type modules a plugin reads runtime values from", async () => { // `gloomberb/types/config` used to be bundled in from the host's package // directory. The packaged desktop app has no such directory, so a plugin // that read `TICKER_RESEARCH_PANE_ID` from it failed to compile there. It // is served from the host registry now, like `gloomberb/ui`. const dir = scratchPlugin(` import { TICKER_RESEARCH_PANE_ID } from "gloomberb/types/config"; export default { id: "scratch", name: "Scratch", version: "1.0.0", pane: TICKER_RESEARCH_PANE_ID }; `); try { const result = await bundleExternalPlugin(dir, join(dir, "out"), { exportNamesFor: fakeExports }); const code = await Bun.file(result.outputPath).text(); expect(result.shared).toEqual(["gloomberb/types/config"]); expect(code).toContain(PLUGIN_HOST_GLOBAL); expect(code).not.toContain("ticker-research"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("resolves a host export that is not shared without a linked node_modules", async () => { // `gloomberb/package.json` is data, so it is bundled in rather than shared. // A plugin on disk finds it through the symlinked node_modules the // installer writes; one compiled out of this repo's node_modules has no // such link, and before the export map was read it failed to compile at all. const dir = scratchPlugin(` import pkg from "gloomberb/package.json"; export default { id: "scratch", name: "Scratch", version: "1.0.0", host: pkg.description }; `); try { const result = await bundleExternalPlugin(dir, join(dir, "out"), { exportNamesFor: fakeExports }); const code = await Bun.file(result.outputPath).text(); expect(result.shared).toEqual([]); expect(code).toContain("Market research and portfolio tracker for the terminal"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("rejects a directory with no entry file", async () => { const dir = mkdtempSync(join(tmpdir(), "gloom-bundle-empty-")); mkdirSync(join(dir, "src"), { recursive: true }); try { await expect(bundleExternalPlugin(dir, join(dir, "out"))).rejects.toThrow("No plugin entry file"); } finally { rmSync(dir, { recursive: true, force: true }); } }); });