@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
564 lines (484 loc) • 16.3 kB
text/typescript
import push, { buildArchive } from "./push";
import provideConfig from "./provideConfig";
import { fileFromPath } from "formdata-node/file-from-path";
import archiver from "archiver";
import * as fs from "node:fs/promises";
import * as fsSync from "node:fs";
import { findFiles } from "@embeddable.com/sdk-utils";
import { ResolvedEmbeddableConfig } from "./defineConfig";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { getArgumentByKey } from "./utils";
// @ts-ignore
import reportErrorToRollbar from "./rollbar.mjs";
import { checkBuildSuccess, checkNodeVersion } from "./utils";
import { server } from "../../../mocks/server";
import { http, HttpResponse } from "msw";
const infoMock = {
info: vi.fn(),
succeed: vi.fn(),
fail: vi.fn(),
};
const startMock = {
succeed: vi.fn(),
info: () => infoMock,
fail: vi.fn(),
};
vi.mock("ora", () => ({
default: () => ({
start: vi.fn().mockReturnValue(startMock),
info: vi.fn(),
}),
}));
vi.mock("./utils", () => ({
checkNodeVersion: vi.fn(),
checkBuildSuccess: vi.fn(),
getArgumentByKey: vi.fn(),
getSDKVersions: vi.fn(),
}));
vi.mock("node:fs/promises", () => ({
writeFile: vi.fn(),
readFile: vi.fn(),
access: vi.fn(),
rm: vi.fn(),
mkdir: vi.fn(),
stat: vi.fn(),
appendFile: vi.fn(),
}));
vi.mock("node:fs", () => ({
createWriteStream: vi.fn(),
}));
vi.mock("./provideConfig", () => ({
default: vi.fn().mockResolvedValue({
client: {
rootDir: "rootDir",
buildDir: "buildDir",
archiveFile: "embeddable-build.zip",
},
}),
}));
vi.mock("./rollbar.mjs", () => ({
default: vi.fn(),
}));
vi.mock("@embeddable.com/sdk-utils", () => ({
findFiles: vi.fn(),
}));
vi.mock("archiver", () => ({
default: {
create: vi.fn(),
},
}));
vi.mock("formdata-node/file-from-path", () => ({
fileFromPath: vi.fn().mockReturnValue(new Blob([new ArrayBuffer(8)])),
}));
const config = {
client: {
rootDir: "rootDir",
buildDir: "buildDir",
archiveFile: "embeddable-build.zip",
customCanvasCss: "src/custom-canvas.css",
},
pushBaseUrl: "http://localhost:3000",
previewBaseUrl: "http://localhost:3000",
pushComponents: true,
};
describe("push", () => {
const archiveMock = {
finalize: vi.fn(),
pipe: vi.fn(),
directory: vi.fn(),
file: vi.fn(),
};
beforeEach(() => {
vi.mocked(checkNodeVersion).mockResolvedValue(true);
vi.mocked(checkBuildSuccess).mockResolvedValue(true);
vi.mocked(getArgumentByKey).mockReturnValue(undefined);
vi.mocked(provideConfig).mockResolvedValue(
config as ResolvedEmbeddableConfig
);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockImplementation(async () =>
Buffer.from(`{"access_token":"mocked-token"}`)
);
vi.mocked(fs.stat).mockResolvedValue({
size: 100,
} as any);
vi.mocked(findFiles).mockResolvedValue([["fileName", "filePath"]]);
vi.mocked(fileFromPath).mockReturnValue(
new Blob([new ArrayBuffer(8)]) as any
);
vi.mocked(archiver.create).mockReturnValue(archiveMock as any);
vi.mocked(fsSync.createWriteStream).mockReturnValue({
on: (event: string, cb: () => void) => {
cb();
},
end: vi.fn(),
} as any);
vi.spyOn(process, "exit").mockImplementation(() => null as never);
});
it("should push the build", async () => {
await push();
expect(provideConfig).toHaveBeenCalled();
expect(checkNodeVersion).toHaveBeenCalled();
expect(checkBuildSuccess).toHaveBeenCalled();
expect(fs.access).toHaveBeenCalledWith(config.client.buildDir);
expect(archiver.create).toHaveBeenCalledWith("zip", {
zlib: { level: 9 },
});
expect(fsSync.createWriteStream).toHaveBeenCalledWith(
config.client.archiveFile
);
expect(archiveMock.pipe).toHaveBeenCalled();
expect(archiveMock.file).toHaveBeenCalledWith("src/custom-canvas.css", {
name: "global.css",
});
expect(archiveMock.directory).toHaveBeenCalledWith("buildDir", false);
expect(archiveMock.finalize).toHaveBeenCalled();
// after publishing the file gets removed
expect(fs.rm).toHaveBeenCalledWith(config.client.archiveFile);
expect(infoMock.info).toHaveBeenCalledWith(
"Publishing to mocked-workspace-name using http://localhost:3000/workspace/mocked-workspace-id..."
);
expect(infoMock.succeed).toHaveBeenCalledWith(
"Published to mocked-workspace-name using http://localhost:3000/workspace/mocked-workspace-id"
);
});
it("should fail if there are no workspaces", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
server.use(
http.get("**/workspace", () => {
return HttpResponse.json([]);
})
);
await push();
expect(startMock.fail).toHaveBeenCalledWith("No workspaces found");
expect(process.exit).toHaveBeenCalledWith(1);
expect(reportErrorToRollbar).toHaveBeenCalled();
});
it("should fail if the build is not successful", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.mocked(checkBuildSuccess).mockResolvedValue(false);
await push();
expect(process.exit).toHaveBeenCalledWith(1);
expect(console.error).toHaveBeenCalledWith(
"Build failed or not completed. Please run `embeddable:build` first."
);
});
it("should push by api key provided in the arguments", async () => {
vi.mocked(getArgumentByKey).mockReturnValue("mocked-api-key");
Object.defineProperties(process, {
argv: {
value: [
"--api-key",
"mocked-api-key",
"--email",
"mocked-email@valid.com",
],
},
});
await push();
expect(startMock.succeed).toHaveBeenCalledWith("Published using API key");
});
describe("push configuration", () => {
it("should fail if both pushModels and pushComponents are disabled", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.mocked(provideConfig).mockResolvedValue({
...config,
pushModels: false,
pushComponents: false,
} as ResolvedEmbeddableConfig);
await push();
expect(startMock.fail).toHaveBeenCalledWith(
"Cannot push: both pushModels and pushComponents are disabled"
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it("should only include component files when pushModels is false", async () => {
const mockArchiver = {
finalize: vi.fn(),
pipe: vi.fn(),
directory: vi.fn(),
file: vi.fn(),
};
vi.mocked(archiver.create).mockReturnValue(mockArchiver as any);
vi.mocked(provideConfig).mockResolvedValue({
...config,
pushModels: false,
pushComponents: true,
} as ResolvedEmbeddableConfig);
await push();
// Should include component build directory
expect(mockArchiver.directory).toHaveBeenCalled();
// Should not include model files (except global.css which is part of components)
expect(mockArchiver.file).toHaveBeenCalledTimes(2);
expect(mockArchiver.file).toHaveBeenCalledWith(expect.anything(), {
name: "global.css",
});
});
});
describe("API key validation", () => {
it("should fail if API key is not provided", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
Object.defineProperties(process, {
argv: {
value: ["--api-key"],
},
});
vi.mocked(getArgumentByKey).mockReturnValue(undefined);
await push();
expect(startMock.fail).toHaveBeenCalledWith("No API key provided");
expect(process.exit).toHaveBeenCalledWith(1);
});
it("should fail if email is not provided with API key", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
Object.defineProperties(process, {
argv: {
value: ["--api-key", "some-key"],
},
});
vi.mocked(getArgumentByKey)
.mockReturnValueOnce("some-key") // API key
.mockReturnValueOnce(undefined); // Email
await push();
expect(startMock.fail).toHaveBeenCalledWith(
"Invalid email provided. Please provide a valid email using --email (-e) flag"
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it("should fail if email is invalid", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
Object.defineProperties(process, {
argv: {
value: ["--api-key", "some-key", "--email", "invalid-email"],
},
});
vi.mocked(getArgumentByKey)
.mockReturnValueOnce("some-key") // API key
.mockReturnValueOnce("invalid-email"); // Invalid email
await push();
expect(startMock.fail).toHaveBeenCalledWith(
"Invalid email provided. Please provide a valid email using --email (-e) flag"
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it("should accept optional message parameter", async () => {
Object.defineProperties(process, {
argv: {
value: [
"--api-key",
"some-key",
"--email",
"valid@email.com",
"--message",
"test message",
"--cube-version",
"v1.34",
],
},
});
vi.mocked(getArgumentByKey).mockImplementation((keysArg) => {
const key = Array.isArray(keysArg) ? keysArg[0] : keysArg;
if (key === "--api-key") return "some-key";
if (key === "--email") return "valid@email.com";
if (key === "--message") return "test message";
if (key === "--cube-version") return "v1.34";
return undefined;
});
await push();
expect(startMock.succeed).toHaveBeenCalledWith("Published using API key");
});
});
describe("error handling", () => {
beforeEach(() => {
// Reset all mocks to their default state
vi.mocked(getArgumentByKey).mockReturnValue(undefined);
Object.defineProperties(process, {
argv: {
value: [],
},
});
});
it("should fail if build directory does not exist", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.mocked(fs.access).mockRejectedValue(new Error("No such directory"));
vi.mocked(provideConfig).mockResolvedValue(
config as ResolvedEmbeddableConfig
);
await push();
expect(console.error).toHaveBeenCalledWith(
"No embeddable build was produced."
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it("should fail if token is not available", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("{}"));
vi.mocked(provideConfig).mockResolvedValue(
config as ResolvedEmbeddableConfig
);
await push();
expect(console.error).toHaveBeenCalledWith(
"Expired token. Please login again."
);
expect(process.exit).toHaveBeenCalledWith(1);
});
it("should handle and report errors during push", async () => {
const testError = new Error("Test error");
vi.mocked(provideConfig).mockRejectedValue(testError);
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockImplementation(async () =>
Buffer.from(`{"access_token":"mocked-token"}`)
);
await push();
expect(reportErrorToRollbar).toHaveBeenCalledWith(testError);
expect(process.exit).toHaveBeenCalledWith(1);
});
});
describe("buildArchive", () => {
type MockArchiver = {
finalize: ReturnType<typeof vi.fn>;
pipe: ReturnType<typeof vi.fn>;
directory: ReturnType<typeof vi.fn>;
file: ReturnType<typeof vi.fn>;
};
let mockArchiver: MockArchiver;
let mockOra: {
start: ReturnType<typeof vi.fn>;
succeed: ReturnType<typeof vi.fn>;
fail: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockArchiver = {
finalize: vi.fn(),
pipe: vi.fn(),
directory: vi.fn(),
file: vi.fn(),
};
vi.mocked(archiver.create).mockReturnValue(mockArchiver as any);
vi.mocked(findFiles).mockResolvedValue([]);
});
it("should include all file types when both flags are true", async () => {
vi.mocked(findFiles)
.mockResolvedValueOnce([
["model1.cube.yml", "/path/to/model1.cube.yml"],
["model2.cube.yaml", "/path/to/model2.cube.yaml"],
])
.mockResolvedValueOnce([
["context1.sc.yml", "/path/to/context1.sc.yml"],
["context2.cc.yml", "/path/to/context2.cc.yml"],
]);
const testConfig = {
...config,
pushModels: true,
pushComponents: true,
client: {
...config.client,
srcDir: "/src",
},
} as ResolvedEmbeddableConfig;
await buildArchive(testConfig);
// Should include component build directory
expect(mockArchiver.directory).toHaveBeenCalledWith(
testConfig.client.buildDir,
false
);
// Should include global.css
expect(mockArchiver.file).toHaveBeenCalledWith(
testConfig.client.customCanvasCss,
{
name: "global.css",
}
);
// Should include all model files
expect(mockArchiver.file).toHaveBeenCalledWith(
"/path/to/model1.cube.yml",
{
name: "model1.cube.yml",
}
);
expect(mockArchiver.file).toHaveBeenCalledWith(
"/path/to/model2.cube.yaml",
{
name: "model2.cube.yaml",
}
);
// Should include all preset files
expect(mockArchiver.file).toHaveBeenCalledWith(
"/path/to/context1.sc.yml",
{
name: "context1.sc.yml",
}
);
expect(mockArchiver.file).toHaveBeenCalledWith(
"/path/to/context2.cc.yml",
{
name: "context2.cc.yml",
}
);
});
it("should only include component files when pushModels is false", async () => {
const testConfig = {
...config,
pushModels: false,
pushComponents: true,
client: {
...config.client,
srcDir: "/src",
},
} as ResolvedEmbeddableConfig;
await buildArchive(testConfig);
// Should include component build directory
expect(mockArchiver.directory).toHaveBeenCalledWith(
testConfig.client.buildDir,
false
);
// Should include global.css
expect(mockArchiver.file).toHaveBeenCalledWith(
testConfig.client.customCanvasCss,
{
name: "global.css",
}
);
// Should only find client context files
expect(findFiles).toHaveBeenCalledOnce();
});
it("should search in custom directories for model files", async () => {
const testConfig = {
...config,
pushModels: true,
pushComponents: true,
client: {
...config.client,
srcDir: "/src",
modelsSrc: "/custom/models/path",
presetsSrc: "/custom/presets/path",
},
} as ResolvedEmbeddableConfig;
await buildArchive(testConfig);
expect(findFiles).toHaveBeenCalledWith(
"/custom/models/path",
expect.any(RegExp)
);
expect(findFiles).toHaveBeenCalledWith(
"/custom/presets/path",
expect.any(RegExp)
);
});
it("should use srcDir as fallback when modelsSrc/presetsSrc are not defined", async () => {
const testConfig = {
...config,
pushModels: true,
pushComponents: true,
client: {
...config.client,
srcDir: "/src",
modelsSrc: undefined,
presetsSrc: undefined,
},
} as unknown as ResolvedEmbeddableConfig;
await buildArchive(testConfig);
expect(findFiles).toHaveBeenCalledWith("/src", expect.any(RegExp));
expect(findFiles).toHaveBeenCalledWith("/src", expect.any(RegExp));
});
});
});