@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
345 lines (305 loc) • 11.5 kB
text/typescript
// buildGlobalHooks.unit.test.ts
import { describe, it, expect, beforeEach, vi, Mock } from "vitest";
import * as fs from "node:fs/promises";
import * as fsSync from "node:fs";
import * as vite from "vite";
import {
findFiles,
getContentHash,
getGlobalHooksMeta,
getComponentLibraryConfig,
} from "@embeddable.com/sdk-utils";
import buildGlobalHooks from "../src/buildGlobalHooks";
import { ResolvedEmbeddableConfig } from "./defineConfig";
import path from "node:path";
// Mock implementations
vi.mock("node:fs/promises");
vi.mock("node:fs");
vi.mock("vite");
vi.mock("@embeddable.com/sdk-utils", async () => {
const actual = await vi.importActual<
typeof import("@embeddable.com/sdk-utils")
>("@embeddable.com/sdk-utils");
return {
...actual,
findFiles: vi.fn(),
getContentHash: vi.fn(),
getGlobalHooksMeta: vi.fn(),
getComponentLibraryConfig: vi.fn(),
};
});
const lifecyclePath = path.resolve(
process.cwd(),
"fake",
"root",
"embeddable.lifecycle.ts",
);
const themePath = path.resolve(
process.cwd(),
"fake",
"root",
"embeddable.theme.ts",
);
describe("buildGlobalHooks (Unit Tests)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should skip lifecycle building if file doesn't exist", async () => {
vi.spyOn(fsSync, "existsSync").mockImplementation((p: any) => {
// We want the code to see that /fake/root/embeddable.lifecycle.ts does NOT exist
if (p === lifecyclePath) {
return false;
}
// Otherwise, default to false
return false;
});
// Possibly not used, but let's mock anyway
(findFiles as Mock).mockResolvedValue([]);
// The aggregator template might be read if there's some library with a theme
(fs.readFile as Mock).mockResolvedValue("some template content");
(getContentHash as Mock).mockReturnValue("abc123");
const ctx: ResolvedEmbeddableConfig = {
client: {
srcDir: path.resolve(process.cwd(), "fake", "src"),
buildDir: path.resolve(process.cwd(), "fake", "build"),
tmpDir: path.resolve(process.cwd(), "fake", "tmp"),
rootDir: path.resolve(process.cwd(), "fake", "root"),
lifecycleHooksFile: lifecyclePath,
componentLibraries: [],
customizationFile: themePath,
},
core: {
templatesDir: "/fake/templates",
},
} as any;
await buildGlobalHooks(ctx);
// Because we skip building the repo lifecycle, no call to vite.build with that entry
expect(vite.build).not.toHaveBeenCalledWith(
expect.objectContaining({
build: expect.objectContaining({
lib: expect.objectContaining({
entry: lifecyclePath,
}),
}),
}),
);
});
it("should build lifecycle file if it exists", async () => {
// Now we say "repo lifecycle does exist"
vi.spyOn(fsSync, "existsSync").mockImplementation((p: any) => {
// If path is exactly the lifecycle file => true
return p === lifecyclePath;
});
// Just in case, but not strictly used here
(findFiles as Mock).mockResolvedValue([]);
// The aggregator template might or might not be read
(fs.readFile as Mock).mockResolvedValue("some file content");
(getContentHash as Mock).mockReturnValue("abc123");
(fs.rename as Mock).mockResolvedValue(undefined);
(vite.build as Mock).mockResolvedValue(undefined);
const ctx: ResolvedEmbeddableConfig = {
client: {
srcDir: path.resolve(process.cwd(), "fake", "src"),
buildDir: path.resolve(process.cwd(), "fake", "build"),
tmpDir: path.resolve(process.cwd(), "fake", "tmp"),
rootDir: path.resolve(process.cwd(), "fake", "root"),
lifecycleHooksFile: lifecyclePath,
customizationFile: themePath,
componentLibraries: [],
},
core: {
templatesDir: "/fake/templates",
},
} as any;
await buildGlobalHooks(ctx);
// We expect a call to build the lifecycle
expect(vite.build).toHaveBeenCalledWith(
expect.objectContaining({
build: expect.objectContaining({
lib: expect.objectContaining({
entry: lifecyclePath,
fileName: "embeddable-lifecycle",
}),
}),
}),
);
});
it("should build theme aggregator if libraries exist with themeProvider references", async () => {
// aggregator template read is guaranteed
(fs.readFile as Mock).mockResolvedValue("template content");
(getContentHash as Mock).mockReturnValue("xyz777");
(fs.rename as Mock).mockResolvedValue(undefined);
(vite.build as Mock).mockResolvedValue(undefined);
// Suppose we skip the lifecycle, but aggregator is still built
vi.spyOn(fsSync, "existsSync").mockImplementation((p: any) => {
// lifecycle => false
if (p === lifecyclePath) return false;
// local theme => true
if (p === themePath) return true;
return false;
});
(getComponentLibraryConfig as Mock).mockImplementation((cfg: any) => ({
libraryName: cfg.name,
}));
// Each library call to getGlobalHooksMeta occurs twice:
// 1) buildThemeHook -> aggregator
// 2) buildLifecycleHooks -> but we skip it if lifecycle doesn't exist
// The code still calls getGlobalHooksMeta for each library in buildLifecycleHooks
// So we must provide 4 total mock results for 2 libraries => aggregator + lifecycle each.
(getGlobalHooksMeta as Mock)
// aggregator call #1: libA
.mockResolvedValueOnce({
themeProvider: "libA-theme.js",
lifecycleHooks: [],
})
// aggregator call #2: libB
.mockResolvedValueOnce({
themeProvider: "libB-theme.js",
lifecycleHooks: [],
})
// lifecycle call #1: libA
.mockResolvedValueOnce({
themeProvider: "libA-theme.js",
lifecycleHooks: [],
})
// lifecycle call #2: libB
.mockResolvedValueOnce({
themeProvider: "libB-theme.js",
lifecycleHooks: [],
});
const ctx: ResolvedEmbeddableConfig = {
client: {
srcDir: path.resolve(process.cwd(), "fake", "src"),
buildDir: path.resolve(process.cwd(), "fake", "build"),
tmpDir: path.resolve(process.cwd(), "fake", "tmp"),
rootDir: path.resolve(process.cwd(), "fake", "root"),
lifecycleHooksFile: lifecyclePath,
customizationFile: themePath,
componentLibraries: [{ name: "libA" }, { name: "libB" }],
},
core: {
templatesDir: "/fake/templates",
},
} as any;
await buildGlobalHooks(ctx);
// aggregator => build with entry = /fake/build/embeddableThemeHook.js
expect(vite.build).toHaveBeenCalledWith(
expect.objectContaining({
build: expect.objectContaining({
lib: expect.objectContaining({
entry: expect.stringContaining("embeddableThemeHook.js"),
fileName: `embeddable-theme-xyz777`, // from getContentHash
}),
}),
}),
);
});
it("should skip aggregator if no library has themeProvider and no local theme", async () => {
(fs.readFile as Mock).mockResolvedValue("template content");
(getContentHash as Mock).mockReturnValue("someHash");
(fs.rename as Mock).mockResolvedValue(undefined);
(vite.build as Mock).mockResolvedValue(undefined);
vi.spyOn(fsSync, "existsSync").mockImplementation((p: any) => {
// Suppose no local theme => false
if (p === themePath) return false;
if (p === lifecyclePath) return false;
return false;
});
// Each library is missing a themeProvider
(getComponentLibraryConfig as Mock).mockImplementation((cfg: any) => ({
libraryName: cfg.name,
}));
// no theme, so aggregator is skipped
(getGlobalHooksMeta as Mock).mockResolvedValue({
themeProvider: null,
lifecycleHooks: [],
});
const ctx: ResolvedEmbeddableConfig = {
client: {
srcDir: path.resolve(process.cwd(), "fake", "src"),
buildDir: path.resolve(process.cwd(), "fake", "build"),
tmpDir: path.resolve(process.cwd(), "fake", "tmp"),
rootDir: path.resolve(process.cwd(), "fake", "root"),
lifecycleHooksFile: lifecyclePath,
customizationFile: themePath,
componentLibraries: [
{ name: "libA" }, // no theme
],
},
core: {
templatesDir: "/fake/templates",
},
} as any;
await buildGlobalHooks(ctx);
// aggregator not built
// We do an exact check: "not toHaveBeenCalledWith"
// But the code might still do a build for the lifecycle if it existed.
// We said the lifecycle is false => so no build for aggregator
expect(vite.build).not.toHaveBeenCalledWith(
expect.objectContaining({
build: expect.objectContaining({
lib: expect.objectContaining({
entry: expect.stringContaining("embeddableThemeHook.js"),
}),
}),
}),
);
});
it("should normalize Windows paths in theme import statements", async () => {
// Mock template content that has the placeholder for local theme import
const templateContent = `{{LIBRARY_THEME_IMPORTS}}
{{LOCAL_THEME_IMPORT}}
// rest of template`;
(fs.readFile as Mock)
.mockResolvedValueOnce(templateContent) // Template read
.mockResolvedValueOnce("file content"); // Temp file read for hash
(getContentHash as Mock).mockReturnValue("hash123");
(vite.build as Mock).mockResolvedValue(undefined);
(fs.writeFile as Mock).mockResolvedValue(undefined);
(fs.rm as Mock).mockResolvedValue(undefined);
// Simulate Windows-style path with backslashes
const windowsThemePath =
"C:\\work\\code\\embeddable-boilerplate\\embeddable.theme.ts";
vi.spyOn(fsSync, "existsSync").mockImplementation((p: any) => {
if (p === windowsThemePath) return true; // Theme file exists
if (p === lifecyclePath) return false; // No lifecycle
return false;
});
(getComponentLibraryConfig as Mock).mockReturnValue({
libraryName: "testLib",
});
(getGlobalHooksMeta as Mock).mockResolvedValue({
themeProvider: null, // No library theme
lifecycleHooks: [],
});
const ctx: ResolvedEmbeddableConfig = {
client: {
srcDir: path.resolve(process.cwd(), "fake", "src"),
buildDir: path.resolve(process.cwd(), "fake", "build"),
tmpDir: path.resolve(process.cwd(), "fake", "tmp"),
rootDir: path.resolve(process.cwd(), "fake", "root"),
lifecycleHooksFile: lifecyclePath,
customizationFile: windowsThemePath, // Windows path with backslashes
componentLibraries: [],
},
core: {
templatesDir: "/fake/templates",
},
} as any;
await buildGlobalHooks(ctx);
// Verify that the temporary file was written with normalized path
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("embeddableThemeHook.js"),
expect.stringContaining(
"C:/work/code/embeddable-boilerplate/embeddable.theme.ts",
), // Forward slashes
"utf8",
);
// Verify that the content does NOT contain backslashes (which would break imports)
const writeFileCall = (fs.writeFile as Mock).mock.calls.find((call) =>
call[0].includes("embeddableThemeHook.js"),
);
expect(writeFileCall).toBeDefined();
expect(writeFileCall![1]).not.toContain("C:\\work\\code"); // Should not have backslashes
});
});