@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
602 lines (507 loc) • 18.8 kB
text/typescript
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();
});
});