import { describe, expect, it } from "vitest"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { bgm } from "./vite.js"; const fixtureRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "__fixtures__", ); const gamesRoot = path.join(fixtureRoot, "harbor"); /** * Vite 8 types plugin hooks as `ObjectHook`, which may be a plain function or * a `{ handler, order }` object. Our plugin uses plain functions, so cast the * hook to a callable for direct invocation in tests. */ type Callable = T extends (...args: infer A) => infer R ? (...args: A) => R : never; function resolveId(plugin: ReturnType, id: string): unknown { return (plugin.resolveId as Callable)( id, undefined, { isEntry: false, }, ); } function load(plugin: ReturnType, id: string): unknown { return (plugin.load as Callable)(id, { ssr: false }); } /** Parse the JSON payload out of an emitted `export default ` module. */ function parseModule(code: unknown): unknown { expect(String(code).startsWith("export default ")).toBe(true); return JSON.parse(String(code).slice("export default ".length)); } describe("bgm vite plugin", () => { it("resolves bgm imports to the virtual module", () => { const plugin = bgm({ root: gamesRoot }); expect(resolveId(plugin, "virtual:bgm/packages")).toBe( "\0bgm:virtual:bgm/packages", ); expect(resolveId(plugin, "virtual:bgm/package/harbor")).toBe( "\0bgm:virtual:bgm/package/harbor", ); expect(resolveId(plugin, "virtual:bgm/package/nope")).toBe( "\0bgm:virtual:bgm/package/nope", ); expect(resolveId(plugin, "other")).toBeUndefined(); }); it("loads every package as a JSON module", () => { const plugin = bgm({ root: gamesRoot }); const packages = parseModule( load(plugin, "\0bgm:virtual:bgm/packages"), ) as Array>; expect(packages).toHaveLength(1); expect(packages[0].meta.id).toBe("harbor"); expect(packages[0].parts).toHaveProperty("token#wood"); }); it("loads a package as a JSON module", () => { const plugin = bgm({ root: gamesRoot }); const pkg = parseModule( load(plugin, "\0bgm:virtual:bgm/package/harbor"), ) as Record; expect(pkg.meta.id).toBe("harbor"); expect(pkg.parts).toHaveProperty("token#wood"); expect(pkg.surfaces).toHaveProperty("board#harbor"); }); it("serializes maps as plain objects, not Map instances", () => { const plugin = bgm({ root: gamesRoot }); const pkg = parseModule( load(plugin, "\0bgm:virtual:bgm/package/harbor"), ) as Record; // The emitted shape must be JSON-serializable: plain objects keyed by // `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`). for (const key of ["parts", "surfaces", "setups", "dialogs"] as const) { expect(pkg[key]).not.toBeInstanceOf(Map); expect(pkg[key]).toEqual(expect.any(Object)); } // Consumers read the collections with Object.values / Object.keys. expect( Object.values(pkg.parts) .map((p: any) => p.id) .sort(), ).toEqual(["grain", "wood"]); expect(Object.keys(pkg.surfaces)).toEqual(["board#harbor", "board#player"]); expect(Object.keys(pkg.setups)).toEqual(["game#main"]); }); it("errors on an unknown package", () => { const plugin = bgm({ root: gamesRoot }); expect(() => load(plugin, "\0bgm:virtual:bgm/package/unknown")).toThrow( /not found/, ); }); it("watches every source file for reloads", () => { const plugin = bgm({ root: gamesRoot }); const watched: string[] = []; const context = { addWatchFile: (file: string) => watched.push(file) }; // Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores // the options, so pass a placeholder. (plugin.buildStart as Callable).call( context, {} as never, ); // Every def file (real + virtual code blocks) is watched so edits // trigger a re-collect. The real markdown source must be watched too: // `defMap.files` only lists virtual code-block files, so without watching // the on-disk `.md` file the dev server would never notice an edit. expect(watched.length).toBeGreaterThan(0); expect(watched.some((f) => f.endsWith(".yaml"))).toBe(true); expect(watched.some((f) => f.endsWith(".csv"))).toBe(true); expect(watched.some((f) => f.endsWith(".md"))).toBe(true); expect(watched.every((f) => path.isAbsolute(f))).toBe(true); }); });