UNPKG

@embeddable.com/sdk-core

Version:

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

564 lines (484 loc) 16.3 kB
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)); }); }); });