UNPKG

@embeddable.com/sdk-core

Version:

Core Embeddable SDK module responsible for web-components bundling and publishing.

602 lines (507 loc) 18.8 kB
import generate, { resetForTesting, TRIGGER_BUILD_ITERATION_LIMIT, triggerWebComponentRebuild, generateDTS, injectBundleRender, injectCSS, } from "./generate"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as sorcery from "sorcery"; import { checkNodeVersion } from "./utils"; import { loadConfig, createCompiler } from "@stencil/core/compiler"; import { findFiles, getContentHash } from "@embeddable.com/sdk-utils"; import { ResolvedEmbeddableConfig } from "./defineConfig"; const config = { client: { rootDir: "rootDir", srcDir: "srcDir", buildDir: "buildDir", tmpDir: "tmpDir", stencilBuild: "stencilBuild", componentDir: "componentDir", webComponentRoot: "webComponentRoot", bundleHash: "hash", componentLibraries: [], }, core: { rootDir: "rootDir", templatesDir: "templatesDir", configsDir: "configsDir", }, "sdk-react": { rootDir: "rootDir", templatesDir: "templatesDir", configsDir: "configsDir", outputOptions: { buildName: "buildName", }, }, }; vi.mock("@embeddable.com/sdk-utils", () => ({ getContentHash: vi.fn(), findFiles: vi.fn(), })); vi.mock("./utils", () => ({ checkNodeVersion: vi.fn(), })); vi.mock("./provideConfig", () => ({ provideConfig: vi.fn().mockResolvedValue(config), })); vi.mock("node:fs/promises", () => ({ writeFile: vi.fn(), readdir: vi.fn(), readFile: vi.fn(), rename: vi.fn(), cp: vi.fn(), rm: vi.fn(), copyFile: vi.fn(), stat: vi.fn(), truncate: vi.fn(), appendFile: vi.fn(), })); vi.mock("node:path", async () => { const actual = await vi.importActual("node:path"); return { ...actual, resolve: vi.fn(), join: vi.fn(), relative: vi.fn(), }; }); vi.mock("@stencil/core/compiler", () => ({ createCompiler: vi.fn(), loadConfig: vi.fn(), })); vi.mock("sorcery", () => ({ load: vi.fn(), })); describe("generate", () => { const watcherMock = vi.fn().mockResolvedValue({ hasError: false, on: vi.fn(), }); beforeEach(() => { vi.mocked(checkNodeVersion).mockResolvedValue(true); vi.mocked(fs.readdir).mockResolvedValue([ "embeddable-wrapper.esm.js", "embeddable-wrapper.esm.js.map", "styles.css", ] as any); vi.mocked(path.resolve).mockImplementation((...args) => args.join("/")); vi.mocked(path.relative).mockReturnValue("../buildDir/buildName"); vi.mocked(fs.readFile).mockResolvedValue(""); vi.mocked(loadConfig).mockResolvedValue({ config: {}, } as any); vi.mocked(createCompiler).mockResolvedValue({ build: vi.fn().mockResolvedValue({ hasError: false, }), destroy: vi.fn(), createWatcher: watcherMock, } as any); vi.mocked(getContentHash).mockReturnValue("hash"); vi.mocked(findFiles).mockResolvedValue([["", ""]]); Object.defineProperties(process, { chdir: { value: vi.fn(), }, }); }); it("should generate bundle", async () => { await generate(config as unknown as ResolvedEmbeddableConfig, "sdk-react"); // should inject css expect(fs.writeFile).toHaveBeenCalledWith("componentDir/style.css", ""); // should inject bundle renderer expect(fs.writeFile).toHaveBeenCalledWith("componentDir/component.tsx", ""); expect(loadConfig).toHaveBeenCalled(); expect(createCompiler).toHaveBeenCalledWith({}); // check if the file is renamed expect(fs.rename).toHaveBeenCalledWith( "stencilBuild/embeddable-wrapper.esm.js", "stencilBuild/embeddable-wrapper.esm-hash.js", ); }); it("should generate bundle in dev mode", async () => { const ctx = { ...config, dev: { watch: true, logger: vi.fn(), sys: vi.mocked({ onProcessInterrupt: vi.fn(), }), }, }; vi.mocked(fs.readFile).mockResolvedValue( "replace-this-with-component-name", ); await generate(ctx as unknown as ResolvedEmbeddableConfig, "sdk-react"); expect(createCompiler).toHaveBeenCalled(); expect(watcherMock).toHaveBeenCalled(); expect(fs.writeFile).toHaveBeenCalledWith( "componentDir/component.tsx", expect.stringContaining("embeddable-component"), ); expect(loadConfig).toHaveBeenCalledWith({ config: { configPath: "webComponentRoot/stencil.config.ts", devMode: true, watchIgnoredRegex: [/\.css$/, /\.d\.ts$/, /\.js$/], maxConcurrentWorkers: process.platform === "win32" ? 0 : 8, minifyCss: false, minifyJs: false, namespace: "embeddable-wrapper", outputTargets: [ { type: "dist", buildDir: "buildDir/dist", }, ], rootDir: "webComponentRoot", sourceMap: true, srcDir: "componentDir", tsconfig: "webComponentRoot/tsconfig.json", }, initTsConfig: true, logger: expect.any(Function), sys: { onProcessInterrupt: expect.any(Function), }, }); }); }); describe("triggerWebComponentRebuild", () => { beforeEach(() => { vi.clearAllMocks(); resetForTesting(); }); it("should store original file stats on first call and append file", async () => { const mockStats = { size: 123 }; vi.mocked(fs.stat).mockResolvedValue(mockStats as any); await triggerWebComponentRebuild( config as unknown as ResolvedEmbeddableConfig, ); const filePath = path.resolve(config.client.componentDir, "component.tsx"); expect(fs.stat).toHaveBeenCalledWith(filePath); expect(fs.appendFile).toHaveBeenCalledWith(filePath, " "); }); it("should append file and not call stat after first build", async () => { const mockStats = { size: 123 }; vi.mocked(fs.stat).mockResolvedValue(mockStats as any); for (let i = 0; i < 3; i++) { await triggerWebComponentRebuild( config as unknown as ResolvedEmbeddableConfig, ); } expect(fs.stat).toHaveBeenCalledTimes(1); // only once expect(fs.appendFile).toHaveBeenCalledTimes(3); expect(fs.truncate).not.toHaveBeenCalled(); }); it("should reset file using truncate on the 6th call and reset count", async () => { const mockStats = { size: 321 }; vi.mocked(fs.stat).mockResolvedValue(mockStats as any); vi.mocked(path.resolve).mockReturnValue("componentDir/component.tsx"); for (let i = 0; i < TRIGGER_BUILD_ITERATION_LIMIT; i++) { await triggerWebComponentRebuild( config as unknown as ResolvedEmbeddableConfig, ); } expect(fs.truncate).not.toHaveBeenCalled(); vi.mocked(fs.appendFile).mockClear(); vi.mocked(fs.truncate).mockClear(); // now truncate should be called await triggerWebComponentRebuild( config as unknown as ResolvedEmbeddableConfig, ); const filePath = path.resolve(config.client.componentDir, "component.tsx"); expect(fs.truncate).toHaveBeenCalledWith(filePath, mockStats.size); expect(fs.appendFile).not.toHaveBeenCalledWith(filePath, " "); }); }); describe("generateDTS", () => { beforeEach(() => { vi.mocked(fs.readdir).mockResolvedValue([ "embeddable-wrapper.esm.js", ] as any); vi.mocked(path.resolve).mockImplementation((...args) => args.join("/")); // Template contains all tokens so we can verify replacement vi.mocked(fs.readFile).mockResolvedValue( "replace-this-with-component-name {{RENDER_IMPORT}} {{PLUGIN_FLAGS}}", ); vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any); vi.mocked(createCompiler).mockResolvedValue({ build: vi.fn().mockResolvedValue({ hasError: false }), destroy: vi.fn(), createWatcher: vi.fn(), } as any); vi.mocked(findFiles).mockResolvedValue([["", ""]]); Object.defineProperties(process, { chdir: { value: vi.fn() } }); }); it("should write an empty style.css", async () => { await generateDTS(config as unknown as ResolvedEmbeddableConfig); expect(fs.writeFile).toHaveBeenCalledWith( "componentDir/style.css", "", ); }); it("should write component.tsx with stub render and embeddable-component tag", async () => { await generateDTS(config as unknown as ResolvedEmbeddableConfig); expect(fs.writeFile).toHaveBeenCalledWith( "componentDir/component.tsx", expect.stringContaining("embeddable-component"), ); expect(fs.writeFile).toHaveBeenCalledWith( "componentDir/component.tsx", expect.stringContaining("const render = (..._args: any[]) => {};"), ); }); it("should replace {{PLUGIN_FLAGS}} token with empty pluginFlags", async () => { await generateDTS(config as unknown as ResolvedEmbeddableConfig); expect(fs.writeFile).toHaveBeenCalledWith( "componentDir/component.tsx", expect.stringContaining("const pluginFlags: Partial<PluginFlags> = {}"), ); expect(fs.writeFile).toHaveBeenCalledWith( "componentDir/component.tsx", expect.not.stringContaining("{{PLUGIN_FLAGS}}"), ); }); it("should call loadConfig with devMode=false and sourceMap=false", async () => { await generateDTS(config as unknown as ResolvedEmbeddableConfig); expect(loadConfig).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ devMode: false, sourceMap: false, minifyJs: false, minifyCss: false, }), }), ); }); it("should not create a watcher (not watch mode)", async () => { const createWatcherMock = vi.fn(); vi.mocked(createCompiler).mockResolvedValue({ build: vi.fn().mockResolvedValue({ hasError: false }), destroy: vi.fn(), createWatcher: createWatcherMock, } as any); await generateDTS(config as unknown as ResolvedEmbeddableConfig); expect(createWatcherMock).not.toHaveBeenCalled(); }); }); describe("injectBundleRender cross-platform paths", () => { const ctxWithFileName = { ...config, "sdk-react": { ...config["sdk-react"], outputOptions: { buildName: "buildName", fileName: "render.js", }, }, }; beforeEach(() => { vi.mocked(path.resolve).mockImplementation((...args) => args.join("/")); vi.mocked(fs.readFile).mockResolvedValue("{{RENDER_IMPORT}}\n{{PLUGIN_FLAGS}}"); }); it("should use forward slashes in import when path.relative returns unix path", async () => { vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName"); await injectBundleRender( ctxWithFileName as unknown as ResolvedEmbeddableConfig, "sdk-react", ); expect(fs.writeFile).toHaveBeenCalledWith( expect.any(String), expect.stringContaining("import render from '../../buildDir/buildName/render.js'"), ); }); it("should replace backslashes with forward slashes when path.relative returns windows path", async () => { vi.mocked(path.relative).mockReturnValue("..\\..\\buildDir\\buildName"); await injectBundleRender( ctxWithFileName as unknown as ResolvedEmbeddableConfig, "sdk-react", ); expect(fs.writeFile).toHaveBeenCalledWith( expect.any(String), expect.stringContaining("import render from '../../buildDir/buildName/render.js'"), ); }); it("should inject pluginFlags from config into component.tsx", async () => { vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName"); const ctxWithPluginFlags = { ...ctxWithFileName, "sdk-react": { ...ctxWithFileName["sdk-react"], pluginFlags: { supportsOnComponentReadyHook: true }, }, }; await injectBundleRender( ctxWithPluginFlags as unknown as ResolvedEmbeddableConfig, "sdk-react", ); expect(fs.writeFile).toHaveBeenCalledWith( expect.any(String), expect.stringContaining('const pluginFlags: Partial<PluginFlags> = {"supportsOnComponentReadyHook":true}'), ); }); it("should inject empty pluginFlags when not present in config", async () => { vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName"); await injectBundleRender( ctxWithFileName as unknown as ResolvedEmbeddableConfig, "sdk-react", ); expect(fs.writeFile).toHaveBeenCalledWith( expect.any(String), expect.stringContaining("const pluginFlags: Partial<PluginFlags> = {}"), ); }); it("should not leave {{PLUGIN_FLAGS}} token in output", async () => { vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName"); await injectBundleRender( ctxWithFileName as unknown as ResolvedEmbeddableConfig, "sdk-react", ); expect(fs.writeFile).toHaveBeenCalledWith( expect.any(String), expect.not.stringContaining("{{PLUGIN_FLAGS}}"), ); }); }); describe("injectCSS cross-platform paths", () => { beforeEach(() => { vi.mocked(path.resolve).mockImplementation((...args) => args.join("/")); vi.mocked(fs.readFile).mockResolvedValue("{{STYLES_IMPORT}}"); vi.mocked(fs.readdir).mockResolvedValue(["main.css"] as any); }); it("should use forward slashes in @import when path.relative returns unix path", async () => { vi.mocked(path.relative).mockReturnValue("../../buildDir/buildName"); await injectCSS( config as unknown as ResolvedEmbeddableConfig, "sdk-react", ); expect(fs.writeFile).toHaveBeenCalledWith( expect.any(String), expect.stringContaining("@import '../../buildDir/buildName/main.css'"), ); }); it("should replace backslashes with forward slashes when path.relative returns windows path", async () => { vi.mocked(path.relative).mockReturnValue("..\\..\\buildDir\\buildName"); await injectCSS( config as unknown as ResolvedEmbeddableConfig, "sdk-react", ); expect(fs.writeFile).toHaveBeenCalledWith( expect.any(String), expect.stringContaining("@import '../../buildDir/buildName/main.css'"), ); }); }); describe("generate stencil build error", () => { beforeEach(() => { vi.mocked(path.resolve).mockImplementation((...args) => args.join("/")); vi.mocked(path.relative).mockReturnValue("../buildDir/buildName"); vi.mocked(fs.readFile).mockResolvedValue(""); vi.mocked(fs.readdir).mockResolvedValue([] as any); vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any); vi.mocked(findFiles).mockResolvedValue([["", ""]]); }); it("should throw when Stencil build has errors", async () => { vi.mocked(createCompiler).mockResolvedValue({ build: vi.fn().mockResolvedValue({ hasError: true, diagnostics: [{ messageText: "type error" }], }), destroy: vi.fn(), createWatcher: vi.fn(), } as any); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); await expect( generate(config as unknown as ResolvedEmbeddableConfig, "sdk-react"), ).rejects.toThrow("Stencil build error"); expect(consoleSpy).toHaveBeenCalledWith( "Stencil build error:", expect.anything(), ); consoleSpy.mockRestore(); }); }); describe("generate - dev mode source map generation", () => { let buildFinishCallback: (() => void) | undefined; const devCtx = { ...config, dev: { watch: true, logger: vi.fn(), sys: { onProcessInterrupt: vi.fn(), }, }, }; // Flush all pending microtasks so async work scheduled via promise chains completes // before assertions run. const flushPromises = () => new Promise<void>((resolve) => setTimeout(resolve, 0)); beforeEach(() => { buildFinishCallback = undefined; vi.mocked(path.resolve).mockImplementation((...args) => args.join("/")); vi.mocked(path.relative).mockReturnValue("../buildDir/buildName"); vi.mocked(fs.readFile).mockResolvedValue("{{RENDER_IMPORT}} {{PLUGIN_FLAGS}}"); vi.mocked(fs.cp).mockResolvedValue(undefined); vi.mocked(fs.rm).mockResolvedValue(undefined); vi.mocked(loadConfig).mockResolvedValue({ config: {} } as any); vi.mocked(createCompiler).mockResolvedValue({ build: vi.fn().mockResolvedValue({ hasError: false }), destroy: vi.fn(), createWatcher: vi.fn().mockResolvedValue({ on: vi.fn().mockImplementation((event: string, cb: () => void) => { if (event === "buildFinish") { buildFinishCallback = cb; } }), }), } as any); vi.mocked(findFiles).mockResolvedValue([["", ""]]); vi.mocked(sorcery.load).mockResolvedValue({ write: vi.fn() } as any); Object.defineProperties(process, { chdir: { value: vi.fn() } }); }); it("calls process.chdir with client.rootDir before source map generation on buildFinish", async () => { vi.mocked(fs.readdir).mockResolvedValue([] as any); await generate(devCtx as unknown as ResolvedEmbeddableConfig, "sdk-react"); expect(buildFinishCallback).toBeDefined(); buildFinishCallback!(); await flushPromises(); expect(process.chdir).toHaveBeenCalledWith(devCtx.client.rootDir); }); it("skips files larger than 500 KB in dev mode source map generation", async () => { vi.mocked(fs.readdir).mockResolvedValue(["large-bundle.js"] as any); vi.mocked(fs.stat).mockResolvedValue({ size: 600 * 1024 } as any); // 600 KB > threshold await generate(devCtx as unknown as ResolvedEmbeddableConfig, "sdk-react"); buildFinishCallback!(); await flushPromises(); expect(sorcery.load).not.toHaveBeenCalled(); }); it("processes files smaller than 500 KB in dev mode source map generation", async () => { vi.mocked(fs.readdir).mockResolvedValue(["component.js"] as any); vi.mocked(fs.stat).mockResolvedValue({ size: 40 * 1024 } as any); // 40 KB < threshold const writeMock = vi.fn(); vi.mocked(sorcery.load).mockResolvedValue({ write: writeMock } as any); await generate(devCtx as unknown as ResolvedEmbeddableConfig, "sdk-react"); buildFinishCallback!(); await flushPromises(); expect(sorcery.load).toHaveBeenCalled(); expect(writeMock).toHaveBeenCalled(); }); it("does not check file size in non-dev (prod) build", async () => { vi.mocked(fs.readdir).mockResolvedValue(["bundle.js"] as any); vi.mocked(createCompiler).mockResolvedValue({ build: vi.fn().mockResolvedValue({ hasError: false }), destroy: vi.fn(), createWatcher: vi.fn(), } as any); await generate(config as unknown as ResolvedEmbeddableConfig, "sdk-react"); // fs.stat is not called for the size check in non-dev (prod) mode expect(fs.stat).not.toHaveBeenCalled(); // sorcery.load is still called — the size check is bypassed entirely in prod expect(sorcery.load).toHaveBeenCalled(); }); });