UNPKG

@embeddable.com/sdk-core

Version:

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

1,487 lines (1,264 loc) 96.4 kB
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from "vitest"; import * as http from "node:http"; import axios from "axios"; import provideConfig from "./provideConfig"; import buildGlobalHooks from "./buildGlobalHooks"; import { getToken } from "./login"; import * as chokidar from "chokidar"; import generate, { generateDTS } from "./generate"; import buildTypes from "./buildTypes"; import validate, { embeddableValidation } from "./validate"; import { findFiles } from "@embeddable.com/sdk-utils"; import dev, { configureWatcher, buildWebComponent, globalHookWatcher, openDevWorkspacePage, sendBuildChanges, sendEmbeddableChanges, onWebComponentBuildFinish, waitForStableHmrFiles, resetStateForTesting, } from "./dev"; import login from "./login"; import { checkNodeVersion } from "./utils"; import { createManifest } from "./cleanup"; import prepare from "./prepare"; import { WebSocketServer } from "ws"; import * as open from "open"; import { logError } from "./logger"; import { ResolvedEmbeddableConfig } from "./defineConfig"; import { RollupWatcher } from "rollup"; import ora from "ora"; import { archive, EMBEDDABLE_FILES, sendBuild } from "./push"; import fg from "fast-glob"; import { selectWorkspace } from "./workspaceUtils"; import serveStatic from "serve-static"; import { createNodeSys } from "@stencil/core/sys/node"; import * as fs from "node:fs/promises"; import { IncomingMessage, ServerResponse } from "http"; // Mock dependencies vi.mock("./buildTypes", () => ({ default: vi.fn(), EMB_OPTIONS_FILE_REGEX: /\.emb\.ts$/, EMB_TYPE_FILE_REGEX: /\.type\.emb\.ts$/, })); vi.mock("./buildGlobalHooks", () => ({ default: vi.fn() })); vi.mock("./prepare", () => ({ default: vi.fn(), removeIfExists: vi.fn() })); vi.mock("./generate", () => ({ default: vi.fn(), generateDTS: vi.fn(), triggerWebComponentRebuild: vi.fn(), })); vi.mock("./provideConfig", () => ({ default: vi.fn() })); vi.mock("@stencil/core/sys/node", () => ({ createNodeLogger: vi.fn(), createNodeSys: vi.fn(), })); vi.mock("open", () => ({ default: vi.fn() })); vi.mock("ws", () => ({ WebSocketServer: class WebSocketServer { constructor() {} } })); vi.mock("chokidar", () => ({ watch: vi.fn((_) => ({ on: vi.fn() })) })); vi.mock("./login", () => ({ getToken: vi.fn(), default: vi.fn() })); vi.mock("axios", () => ({ default: { get: vi.fn(), post: vi.fn() } })); vi.mock("@embeddable.com/sdk-utils", () => ({ findFiles: vi.fn() })); vi.mock("./validate", () => ({ default: vi.fn(), embeddableValidation: vi.fn().mockResolvedValue([]), formatIssue: (issue: { filePath: string; message: string }) => `${issue.filePath}: ${issue.message}`, })); vi.mock("./utils", () => ({ checkNodeVersion: vi.fn(), shouldSkipModelCheck: vi.fn().mockReturnValue(false) })); vi.mock("./cleanup", () => ({ createManifest: vi.fn() })); vi.mock("node:http", () => ({ createServer: vi.fn(() => ({ listen: vi.fn(), close: vi.fn() })), })); vi.mock("node:fs/promises", () => ({ readFile: vi.fn(), appendFile: vi.fn(), })); vi.mock("./logger", () => ({ initLogger: vi.fn(), logError: vi.fn(), })); vi.mock("./devLogger", () => ({ default: { init: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), marker: vi.fn(), issue: vi.fn(), startCycle: vi.fn(() => 1), endCycle: vi.fn(), }, })); vi.mock("./push", async (importOriginal) => { const actual = await importOriginal<any>(); return { ...actual, archive: vi.fn(), sendBuild: vi.fn(), }; }); vi.mock("ora", () => ({ default: vi.fn(() => ({ info: vi.fn(), start: vi.fn(), succeed: vi.fn(), fail: vi.fn(), stop: vi.fn(), })), })); vi.mock("./workspaceUtils", () => ({ selectWorkspace: vi.fn(), })); vi.mock("serve-static", () => ({ default: vi.fn(() => vi.fn()), })); vi.mock("fast-glob", () => ({ default: vi.fn() })); vi.mock("./dev", async (importOriginal) => { const actual = await importOriginal<typeof dev>(); return { ...actual, buildWebComponent: vi.fn(), }; }); vi.mock("./utils/dev.utils", () => ({ createWatcherLock: vi.fn(() => ({ lock: vi.fn(), unlock: vi.fn(), waitUntilFree: vi.fn().mockResolvedValue(undefined), })), delay: vi.fn().mockResolvedValue(undefined), preventContentLength: vi.fn(), waitUntilFileStable: vi.fn().mockResolvedValue(undefined), })); const mockConfig = { client: { rootDir: "/mock/root", buildDir: "/mock/root/.embeddable-dev-build", componentDir: "/mock/root/.embeddable-dev-build/component", stencilBuild: "/mock/root/.embeddable-dev-build/dist/embeddable-wrapper", tmpDir: "/mock/root/.embeddable-dev-tmp", customCanvasCss: "/mock/root/custom-canvas.css", modelsSrc: "/mock/root/models", presetsSrc: "/mock/root/presets", componentLibraries: [], }, plugins: [], previewBaseUrl: "http://preview.example.com", pushBaseUrl: "http://push.example.com", pushComponents: true, pushModels: true, }; describe("dev command", () => { let listenMock: Mock; let wsMock: any; let oraMock: any; let mockServer: any; beforeEach(async () => { // Reset module-level state between tests resetStateForTesting(); listenMock = vi.fn(); wsMock = { send: vi.fn(), }; oraMock = { info: vi.fn(), start: vi.fn(() => oraMock), succeed: vi.fn(), fail: vi.fn(), stop: vi.fn(), }; mockServer = { listen: listenMock, close: vi.fn(), on: vi.fn(), }; vi.mocked(http.createServer).mockImplementation(() => mockServer as any); // Mock WebSocketServer constructor to return our mock instance const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return { clients: [wsMock], on: vi.fn(), } as any; } as any); vi.mocked(ora).mockImplementation(() => oraMock); // Mock process.on to avoid actually setting up process listeners vi.spyOn(process, "on").mockImplementation(() => process); vi.spyOn(process, "exit").mockImplementation(() => undefined as never); vi.mocked(provideConfig).mockResolvedValue( mockConfig as unknown as ResolvedEmbeddableConfig, ); vi.mocked(getToken).mockResolvedValue("mock-token"); vi.mocked(axios.get).mockResolvedValue({ data: [{ workspaceId: "mock-workspace" }], }); vi.mocked(axios.post).mockResolvedValue({ data: "mock-workspace", }); // @ts-ignore const watcherMock: RollupWatcher = { on: vi.fn(), close: vi.fn() }; vi.mocked(buildGlobalHooks).mockResolvedValue({ themeWatcher: watcherMock, lifecycleWatcher: watcherMock, }); // Return a proper stencil watcher mock so buildWebComponent does not crash // when it tries to call .on() and .start() on the result of generate() const stencilWatcherMock = { on: vi.fn(), start: vi.fn(), close: vi.fn() }; vi.mocked(generate).mockResolvedValue(stencilWatcherMock as any); vi.mocked(buildWebComponent).mockImplementation(() => Promise.resolve()); vi.mocked(validate).mockImplementation(() => Promise.resolve(true)); vi.mocked(embeddableValidation).mockResolvedValue([]); vi.mocked(selectWorkspace).mockResolvedValue({ workspaceId: "mock-workspace" }); vi.mocked(findFiles).mockResolvedValue([ ["mock-model.json", "/mock/root/models/mock-model.json"], ]); vi.mocked(fg).mockResolvedValue([]); // Mock fs functions vi.mocked(fs.readFile).mockResolvedValue("default content"); vi.mocked(fs.appendFile).mockResolvedValue(undefined); }); afterEach(() => { vi.restoreAllMocks(); }); it("should set up the development with pushComponents false", async () => { vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushComponents: false, pushModels: true, } as unknown as ResolvedEmbeddableConfig); // Run the dev command await dev(); // Verify that the necessary functions were called expect(checkNodeVersion).toHaveBeenCalled(); expect(prepare).toHaveBeenCalled(); expect(http.createServer).toHaveBeenCalled(); expect(WebSocketServer).toHaveBeenCalled(); // Verify that the server was set up to listen on the correct port expect(listenMock).toHaveBeenCalledWith(8926, expect.any(Function)); // Call the listen callback to simulate the server being set up listenMock.mock.calls[0][1](); expect(createManifest).toHaveBeenCalled(); await expect.poll(() => chokidar.watch).toBeCalledTimes(1); }); it("should set up the development environment with pushComponents true", async () => { vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushComponents: true, pushModels: true, } as unknown as ResolvedEmbeddableConfig); // Run the dev command await dev(); // Verify that the necessary functions were called expect(checkNodeVersion).toHaveBeenCalled(); expect(prepare).toHaveBeenCalled(); expect(http.createServer).toHaveBeenCalled(); expect(WebSocketServer).toHaveBeenCalled(); // Verify that the server was set up to listen on the correct port expect(listenMock).toHaveBeenCalledWith(8926, expect.any(Function)); // Call the listen callback to simulate the server being set up listenMock.mock.calls[0][1](); await expect.poll(() => createManifest).toHaveBeenCalled(); await expect.poll(() => chokidar.watch).toBeCalledTimes(2); }); it("should log errors and exit on failure", async () => { const originalConsoleLog = console.log; console.log = vi.fn(); const error = new Error("Test error"); const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); }); vi.mocked(checkNodeVersion).mockImplementation(() => { throw error; }); await expect(dev()).rejects.toThrow("process.exit"); expect(console.log).toHaveBeenCalledWith(error); expect(logError).toHaveBeenCalledWith({ command: "dev", breadcrumbs: ["run dev"], error, }); expect(exitSpy).toHaveBeenCalledWith(1); console.log = originalConsoleLog; }); describe("configureWatcher", () => { it("should configure the watcher", () => { const watcher = { on: vi.fn() } as unknown as RollupWatcher; configureWatcher( watcher, mockConfig as unknown as ResolvedEmbeddableConfig, ); expect(watcher.on).toHaveBeenCalledWith("change", expect.any(Function)); }); it("should call generate on BUNDLE_END", async () => { let onHandler: ((event: any) => void) | undefined; const watcher = { on: (event: string, handler: (event: any) => void) => { onHandler = handler; }, } as unknown as RollupWatcher; configureWatcher( watcher, mockConfig as unknown as ResolvedEmbeddableConfig, ); await onHandler?.({ code: "BUNDLE_END", } as any); expect(generate).toHaveBeenCalledWith(mockConfig, "sdk-react"); }); it("should call buildTypes on BUNDLE_START", async () => { let onHandler: ((event: any) => void) | undefined; const watcher = { on: (event: string, handler: (event: any) => void) => { onHandler = handler; }, } as unknown as RollupWatcher; configureWatcher( watcher, mockConfig as unknown as ResolvedEmbeddableConfig, ); await onHandler?.({ code: "BUNDLE_START", } as any); expect(buildTypes).toHaveBeenCalledWith(mockConfig); }); }); describe("globalHookWatcher", () => { it("should call watcher.on", async () => { const watcher = { on: vi.fn() } as unknown as RollupWatcher; globalHookWatcher(watcher, "theme"); await expect .poll(() => watcher.on) .toBeCalledWith("change", expect.any(Function)); expect(watcher.on).toHaveBeenCalledWith("event", expect.any(Function)); }); }); describe("openDevWorkspacePage", () => { it("should open the dev workspace page", async () => { await openDevWorkspacePage( "http://preview.example.com", "mock-workspace", ); expect(open.default).toHaveBeenCalledWith( "http://preview.example.com/workspace/mock-workspace", ); expect(ora).toHaveBeenCalledWith( "Preview workspace is available at http://preview.example.com/workspace/mock-workspace", ); }); }); describe("sendBuildChanges", () => { it("should send the build changes", async () => { await sendBuildChanges(mockConfig as unknown as ResolvedEmbeddableConfig); const filesList = [ ...Array.from({ length: 1 }, () => [ "mock-model.json", "/mock/root/models/mock-model.json", ]), [ "embeddable-manifest.json", process.platform === "win32" ? "D:\\mock\\root\\.embeddable-dev-build\\embeddable-manifest.json" : "/mock/root/.embeddable-dev-build/embeddable-manifest.json", ], ...Array.from({ length: 2 }, () => [ "mock-model.json", "/mock/root/models/mock-model.json", ]), ]; expect(getToken).toHaveBeenCalled(); expect(archive).toHaveBeenCalledWith({ ctx: mockConfig, filesList, isDev: true, }); }); it("should call sendBuild with pushEmbeddables: false", async () => { await sendBuildChanges(mockConfig as unknown as ResolvedEmbeddableConfig); expect(vi.mocked(sendBuild)).toHaveBeenCalledWith( expect.objectContaining({ pushEmbeddables: false }), expect.any(Object), ); }); it("should not include embeddable files in sendBuildChanges archive", async () => { const embeddableConfig = { ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig; vi.mocked(findFiles).mockClear(); await sendBuildChanges(embeddableConfig); // sendBuildChanges no longer handles embeddable files const embeddableFilesCall = vi.mocked(findFiles).mock.calls.find( (call) => call[1] === EMBEDDABLE_FILES, ); expect(embeddableFilesCall).toBeUndefined(); }); }); describe("sendBuildChanges error handling", () => { it("should handle errors during build sending", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(validate).mockResolvedValue(true); const error = new Error("Archive failed"); vi.mocked(archive).mockRejectedValue(error); await dev(); // To initialize wss await sendBuildChanges(mockConfig as unknown as ResolvedEmbeddableConfig); expect(ora().fail).toHaveBeenCalledWith( `Data models and/or security context synchronization failed with error: ${error.message}`, ); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "dataModelsAndOrSecurityContextUpdateError", error: error.message }), ); }); }); describe("sendEmbeddableChanges", () => { it("should send embeddable files and emit embeddablesUpdateSuccess", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(embeddableValidation).mockResolvedValue([]); const embeddableConfig = { ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig; vi.mocked(findFiles).mockImplementation(async (_dir, pattern) => { if (pattern === EMBEDDABLE_FILES) { return [["dashboard.embeddable.yaml", "/mock/src/dashboard.embeddable.yaml"]]; } return []; }); await dev(); await sendEmbeddableChanges(embeddableConfig); expect(findFiles).toHaveBeenCalledWith("/mock/src", EMBEDDABLE_FILES); expect(archive).toHaveBeenCalledWith( expect.objectContaining({ filesList: [ ["dashboard.embeddable.yaml", "/mock/src/dashboard.embeddable.yaml"], ], }), ); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "embeddablesUpdateSuccess" }), ); expect(vi.mocked(sendBuild)).toHaveBeenCalledWith( expect.objectContaining({ pushComponents: false, pushModels: false }), expect.any(Object), ); }); it("should emit embeddablesUpdateError when validation fails", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); const validationIssues = [ { filePath: "/mock/src/file.embeddable.yml", message: "invalid valueType", }, ]; vi.mocked(embeddableValidation).mockResolvedValue(validationIssues); const formatted = validationIssues.map( (i) => `${i.filePath}: ${i.message}`, ); await dev(); await sendEmbeddableChanges(mockConfig as unknown as ResolvedEmbeddableConfig); expect(ora().fail).toHaveBeenCalledWith( "One or more embeddable.yml files are invalid:", ); expect(ora().info).toHaveBeenCalledWith(formatted[0]); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "embeddablesUpdateError", error: formatted.join("; "), }), ); // Reset for other tests vi.mocked(embeddableValidation).mockResolvedValue([]); }); it("should emit embeddablesUpdateError when archive fails", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(embeddableValidation).mockResolvedValue([]); const error = new Error("Upload failed"); vi.mocked(archive).mockRejectedValue(error); const embeddableConfig = { ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig; await dev(); await sendEmbeddableChanges(embeddableConfig); expect(ora().fail).toHaveBeenCalledWith( `Embeddables synchronization failed: ${error.message}`, ); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "embeddablesUpdateError", error: error.message }), ); }); it("should include errorMetadata.errors detail lines in the spinner failure message", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(embeddableValidation).mockResolvedValue([]); const apiError = Object.assign(new Error("Uploaded bundle is invalid"), { response: { data: { errorMessage: "Uploaded bundle is invalid", errorMetadata: { errors: { "test.embeddable.yml:errorMessage": 'test.embeddable.yml: embeddable "My App" widget "BarChart" input "xAxis" of type "measure" must reference a dataset input via "config.dataset"', }, }, }, }, }); vi.mocked(archive).mockRejectedValue(apiError); const embeddableConfig = { ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig; await dev(); await sendEmbeddableChanges(embeddableConfig); const expectedError = [ "Uploaded bundle is invalid", ' • test.embeddable.yml: embeddable "My App" widget "BarChart" input "xAxis" of type "measure" must reference a dataset input via "config.dataset"', ].join("\n"); expect(ora().fail).toHaveBeenCalledWith( `Embeddables synchronization failed: ${expectedError}`, ); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "embeddablesUpdateError", error: expectedError }), ); }); it("should include errorMetadata.message detail line in spinner failure and wire message", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(embeddableValidation).mockResolvedValue([]); const apiError = Object.assign(new Error("Embeddable config is invalid"), { response: { data: { errorMessage: "Embeddable config is invalid", errorMetadata: { message: "filter in embeddable 'my-embeddable' has unknown operator 'foo'", }, }, }, }); vi.mocked(archive).mockRejectedValue(apiError); const embeddableConfig = { ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig; await dev(); await sendEmbeddableChanges(embeddableConfig); const expectedError = [ "Embeddable config is invalid", " • filter in embeddable 'my-embeddable' has unknown operator 'foo'", ].join("\n"); expect(ora().fail).toHaveBeenCalledWith( `Embeddables synchronization failed: ${expectedError}`, ); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "embeddablesUpdateError", error: expectedError }), ); }); it("should include both errorMetadata.message and errorMetadata.errors when both are present", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(embeddableValidation).mockResolvedValue([]); const apiError = Object.assign(new Error("Embeddable config is invalid"), { response: { data: { errorMessage: "Embeddable config is invalid", errorMetadata: { message: "filter in embeddable 'my-embeddable' has unknown operator 'foo'", errors: { "field1": "field1 is required", "field2": "field2 must be a string", }, }, }, }, }); vi.mocked(archive).mockRejectedValue(apiError); const embeddableConfig = { ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig; await dev(); await sendEmbeddableChanges(embeddableConfig); const expectedError = [ "Embeddable config is invalid", " • filter in embeddable 'my-embeddable' has unknown operator 'foo'", " • field1 is required", " • field2 must be a string", ].join("\n"); expect(ora().fail).toHaveBeenCalledWith( `Embeddables synchronization failed: ${expectedError}`, ); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "embeddablesUpdateError", error: expectedError }), ); }); it("emits validate_start/validate_end markers around a successful cycle", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function () { return mockWss as any; } as any); vi.mocked(embeddableValidation).mockResolvedValue([]); vi.mocked(archive).mockResolvedValue(undefined as any); vi.mocked(sendBuild).mockResolvedValue(undefined as any); const { default: devLog } = await import("./devLogger"); vi.mocked(devLog.startCycle).mockReturnValue(7); await dev(); await sendEmbeddableChanges(mockConfig as unknown as ResolvedEmbeddableConfig); expect(devLog.startCycle).toHaveBeenCalledWith( "embeddable", expect.objectContaining({ files: expect.any(Array) }), ); expect(devLog.endCycle).toHaveBeenCalledWith(7, "embeddable", "ok"); expect(devLog.issue).not.toHaveBeenCalled(); }); it("emits issue events and validate_end stage=validate when SDK validation fails", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function () { return mockWss as any; } as any); const issues = [ { filePath: "/mock/src/foo.embeddable.yml", message: "Required", line: 12, column: 3, path: "embeddables[0].name", }, ]; vi.mocked(embeddableValidation).mockResolvedValue(issues); const { default: devLog } = await import("./devLogger"); vi.mocked(devLog.startCycle).mockReturnValue(11); await dev(); await sendEmbeddableChanges(mockConfig as unknown as ResolvedEmbeddableConfig); expect(devLog.issue).toHaveBeenCalledWith( expect.objectContaining({ scope: "embeddable", stage: "validate", filePath: issues[0].filePath, message: issues[0].message, line: 12, column: 3, path: "embeddables[0].name", }), ); expect(devLog.endCycle).toHaveBeenCalledWith( 11, "embeddable", "error", expect.objectContaining({ stage: "validate", errorCount: 1 }), ); }); it("emits stage=sync issue and validate_end when sync to BE fails", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function () { return mockWss as any; } as any); vi.mocked(embeddableValidation).mockResolvedValue([]); vi.mocked(archive).mockRejectedValue(new Error("BE rejected build")); const { default: devLog } = await import("./devLogger"); vi.mocked(devLog.startCycle).mockReturnValue(13); await dev(); await sendEmbeddableChanges(mockConfig as unknown as ResolvedEmbeddableConfig); expect(devLog.issue).toHaveBeenCalledWith( expect.objectContaining({ scope: "embeddable", stage: "sync", message: "BE rejected build", }), ); expect(devLog.endCycle).toHaveBeenCalledWith( 13, "embeddable", "error", expect.objectContaining({ stage: "sync" }), ); }); it("should do nothing on the initial sync when no embeddable files are found", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(findFiles).mockResolvedValue([]); await dev(); await sendEmbeddableChanges( mockConfig as unknown as ResolvedEmbeddableConfig, { isInitialSync: true }, ); expect(archive).not.toHaveBeenCalled(); expect(mockWss.clients[0].send).not.toHaveBeenCalledWith( expect.stringContaining("embeddablesUpdate"), ); }); it("should sync the empty set when the last embeddable file is removed at runtime", async () => { const mockWss = { clients: [{ send: vi.fn() }], on: vi.fn(), close: vi.fn(), }; const wsModule = await import("ws"); vi.spyOn(wsModule, "WebSocketServer").mockImplementation(function() { return mockWss as any; } as any); vi.mocked(findFiles).mockResolvedValue([]); vi.mocked(embeddableValidation).mockResolvedValue([]); vi.mocked(archive).mockResolvedValue(undefined as any); vi.mocked(sendBuild).mockResolvedValue(undefined as any); const embeddableConfig = { ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig; await dev(); await sendEmbeddableChanges(embeddableConfig); expect(archive).toHaveBeenCalledWith( expect.objectContaining({ filesList: [], isDev: true }), ); expect(mockWss.clients[0].send).toHaveBeenCalledWith( JSON.stringify({ type: "embeddablesUpdateSuccess" }), ); }); }); describe("embeddableWatcher", () => { it("should resolve embeddable files via fast-glob and pass them to chokidar.watch when pushEmbeddables is true", async () => { const mockEmbeddableFiles = ["/mock/src/dashboard.embeddable.yml"]; vi.mocked(fg).mockResolvedValue(mockEmbeddableFiles); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); await dev(); await listenMock.mock.calls[0][1](); expect(vi.mocked(fg)).toHaveBeenCalledWith( "**/*.embeddable.{yaml,yml}", expect.objectContaining({ cwd: "/mock/src", absolute: true }), ); expect(chokidar.watch).toHaveBeenCalledWith( expect.arrayContaining(mockEmbeddableFiles), expect.anything(), ); }); it("should set up a directory watcher for new embeddable files when pushEmbeddables is true", async () => { vi.mocked(fg).mockResolvedValue([]); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); await dev(); await listenMock.mock.calls[0][1](); // Should also watch the srcDir to detect newly created .embeddable.yml files. expect(chokidar.watch).toHaveBeenCalledWith( "/mock/src", expect.objectContaining({ ignoreInitial: true }), ); }); it("should add new .embeddable.yml files to fsWatcher and trigger sendEmbeddableChanges", async () => { const fsWatcherAddMock = vi.fn(); const dirWatcherCallbacks: Record<string, Function> = {}; vi.mocked(chokidar.watch).mockImplementation((watched: any) => { if (watched === "/mock/src") { // dirWatcher — capture callbacks return { on: vi.fn((event: string, cb: Function) => { dirWatcherCallbacks[event] = cb; }), } as any; } // fsWatcher or other watchers return { on: vi.fn(), add: fsWatcherAddMock } as any; }); vi.mocked(fg).mockResolvedValue([]); vi.mocked(validate).mockResolvedValue(true); vi.mocked(findFiles).mockResolvedValue([]); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); await dev(); await listenMock.mock.calls[0][1](); // Simulate a new .embeddable.yml file being created expect(dirWatcherCallbacks["add"]).toBeDefined(); dirWatcherCallbacks["add"]("/mock/src/new-dashboard.embeddable.yml"); expect(fsWatcherAddMock).toHaveBeenCalledWith( "/mock/src/new-dashboard.embeddable.yml", ); }); it("emits change_detected on fsWatcher events with chokidar event type and path", async () => { const fsWatcherCallbacks: Record<string, Function> = {}; vi.mocked(chokidar.watch).mockImplementation((watched: any) => { if (watched === "/mock/src") { return { on: vi.fn() } as any; } return { on: vi.fn((event: string, cb: Function) => { fsWatcherCallbacks[event] = cb; }), add: vi.fn(), } as any; }); vi.mocked(fg).mockResolvedValue(["/mock/src/foo.embeddable.yml"]); vi.mocked(findFiles).mockResolvedValue([]); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); const { default: devLog } = await import("./devLogger"); vi.mocked(devLog.marker).mockClear(); await dev(); await listenMock.mock.calls[0][1](); expect(fsWatcherCallbacks["all"]).toBeDefined(); fsWatcherCallbacks["all"]("change", "/mock/src/foo.embeddable.yml"); expect(devLog.marker).toHaveBeenCalledWith("change_detected", { scope: "embeddable", change: "change", file: "/mock/src/foo.embeddable.yml", }); }); it("emits change_detected when dirWatcher discovers a brand-new embeddable file", async () => { const dirWatcherCallbacks: Record<string, Function> = {}; vi.mocked(chokidar.watch).mockImplementation((watched: any) => { if (watched === "/mock/src") { return { on: vi.fn((event: string, cb: Function) => { dirWatcherCallbacks[event] = cb; }), } as any; } return { on: vi.fn(), add: vi.fn() } as any; }); vi.mocked(fg).mockResolvedValue([]); vi.mocked(findFiles).mockResolvedValue([]); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); const { default: devLog } = await import("./devLogger"); vi.mocked(devLog.marker).mockClear(); await dev(); await listenMock.mock.calls[0][1](); dirWatcherCallbacks["add"]("/mock/src/new.embeddable.yml"); expect(devLog.marker).toHaveBeenCalledWith("change_detected", { scope: "embeddable", change: "add", file: "/mock/src/new.embeddable.yml", }); }); it("re-registers a deleted embeddable file when a file with the same name is recreated", async () => { const fsWatcherAddMock = vi.fn(); const fsWatcherUnwatchMock = vi.fn(); const dirWatcherCallbacks: Record<string, Function> = {}; const fsWatcherCallbacks: Record<string, Function> = {}; vi.mocked(chokidar.watch).mockImplementation((watched: any) => { if (watched === "/mock/src") { // dirWatcher return { on: vi.fn((event: string, cb: Function) => { dirWatcherCallbacks[event] = cb; }), } as any; } if ( Array.isArray(watched) && watched.some( (f) => typeof f === "string" && f.includes(".embeddable."), ) ) { // embeddable fsWatcher return { on: vi.fn((event: string, cb: Function) => { fsWatcherCallbacks[event] = cb; }), add: fsWatcherAddMock, unwatch: fsWatcherUnwatchMock, } as any; } return { on: vi.fn(), add: vi.fn(), unwatch: vi.fn() } as any; }); vi.mocked(fg).mockResolvedValue(["/mock/src/foo.embeddable.yml"]); vi.mocked(findFiles).mockResolvedValue([]); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); await dev(); await listenMock.mock.calls[0][1](); // A still-known file recreated is ignored (already watched). dirWatcherCallbacks["add"]("/mock/src/foo.embeddable.yml"); expect(fsWatcherAddMock).not.toHaveBeenCalled(); // Deleting it forgets and unwatches the path. expect(fsWatcherCallbacks["unlink"]).toBeDefined(); fsWatcherCallbacks["unlink"]("/mock/src/foo.embeddable.yml"); expect(fsWatcherUnwatchMock).toHaveBeenCalledWith( "/mock/src/foo.embeddable.yml", ); // Recreating a file with the same name now re-registers it. dirWatcherCallbacks["add"]("/mock/src/foo.embeddable.yml"); expect(fsWatcherAddMock).toHaveBeenCalledWith( "/mock/src/foo.embeddable.yml", ); }); it("ignores unlink events for non-embeddable files", async () => { const fsWatcherUnwatchMock = vi.fn(); const fsWatcherCallbacks: Record<string, Function> = {}; vi.mocked(chokidar.watch).mockImplementation((watched: any) => { if (watched === "/mock/src") { return { on: vi.fn() } as any; } if ( Array.isArray(watched) && watched.some( (f) => typeof f === "string" && f.includes(".embeddable."), ) ) { return { on: vi.fn((event: string, cb: Function) => { fsWatcherCallbacks[event] = cb; }), add: vi.fn(), unwatch: fsWatcherUnwatchMock, } as any; } return { on: vi.fn(), add: vi.fn(), unwatch: vi.fn() } as any; }); vi.mocked(fg).mockResolvedValue(["/mock/src/foo.embeddable.yml"]); vi.mocked(findFiles).mockResolvedValue([]); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); await dev(); await listenMock.mock.calls[0][1](); fsWatcherCallbacks["unlink"]("/mock/src/component.emb.ts"); expect(fsWatcherUnwatchMock).not.toHaveBeenCalled(); }); it("should ignore non-embeddable files in dirWatcher add callback", async () => { const fsWatcherAddMock = vi.fn(); const dirWatcherCallbacks: Record<string, Function> = {}; vi.mocked(chokidar.watch).mockImplementation((watched: any) => { if (watched === "/mock/src") { return { on: vi.fn((event: string, cb: Function) => { dirWatcherCallbacks[event] = cb; }), } as any; } return { on: vi.fn(), add: fsWatcherAddMock } as any; }); vi.mocked(fg).mockResolvedValue([]); vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: true, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); await dev(); await listenMock.mock.calls[0][1](); // Simulate a non-embeddable file being created dirWatcherCallbacks["add"]("/mock/src/component.emb.ts"); expect(fsWatcherAddMock).not.toHaveBeenCalled(); }); it("should not include embeddable files when pushEmbeddables is false", async () => { vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushEmbeddables: false, client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); vi.mocked(fg).mockClear(); await dev(); await listenMock.mock.calls[0][1](); const embeddableCall = vi.mocked(fg).mock.calls.find( (call) => call[0] === "**/*.embeddable.{yaml,yml}", ); expect(embeddableCall).toBeUndefined(); }); it("should not call fg with embeddable pattern when pushEmbeddables is undefined", async () => { vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, // pushEmbeddables not set → undefined → falsy client: { ...mockConfig.client, srcDir: "/mock/src" }, } as unknown as ResolvedEmbeddableConfig); vi.mocked(fg).mockClear(); await dev(); await listenMock.mock.calls[0][1](); const embeddableCall = vi.mocked(fg).mock.calls.find( (call) => call[0] === "**/*.embeddable.{yaml,yml}", ); expect(embeddableCall).toBeUndefined(); }); }); describe("Plugin build coordination", () => { it("should handle configs with no plugins when pushComponents is true", async () => { vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushComponents: true, plugins: [], // Empty plugins array } as unknown as ResolvedEmbeddableConfig); await dev(); // Test should pass without calling any plugin methods expect(provideConfig).toHaveBeenCalled(); }); it("should handle configs when pushComponents is false", async () => { vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushComponents: false, plugins: [], } as unknown as ResolvedEmbeddableConfig); await dev(); // Test should pass and skip plugin builds expect(provideConfig).toHaveBeenCalled(); }); it("should handle plugin execution path when plugins are present", async () => { // This test verifies the code path is exercised when plugins exist // The actual plugin coordination logic is tested in executePluginBuilds.test.ts vi.mocked(provideConfig).mockResolvedValue({ ...mockConfig, pushComponents: true, plugins: [() => ({ validate: vi.fn(), build: vi.fn() })], } as unknown as ResolvedEmbeddableConfig); vi.mocked(buildWebComponent).mockResolvedValue(undefined); // This should execute the plugin coordination code path await dev(); expect(provideConfig).toHaveBeenCalled(); }); }); describe("Plugin build coordination (PR #765 new functionality)", () => { it("should handle plugin build coordination with queue processing", async () => { const mockPlugin1 = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const mockPlugin2 = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const configWithPlugins = { ...mockConfig, pushComponents: true, plugins: [() => mockPlugin1, () => mockPlugin2], } as unknown as ResolvedEmbeddableConfig; vi.mocked(provideConfig).mockResolvedValue(configWithPlugins); await dev(); // Call the listen callback to trigger plugin coordination await listenMock.mock.calls[0][1](); // Verify both plugins were processed through coordination expect(mockPlugin1.validate).toHaveBeenCalled(); expect(mockPlugin1.build).toHaveBeenCalled(); expect(mockPlugin2.validate).toHaveBeenCalled(); expect(mockPlugin2.build).toHaveBeenCalled(); }); it("should test pendingPluginBuilds queue mechanism when builds are in progress", async () => { // This test targets the specific coordination logic added in PR #765 const mockPlugin1 = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const mockPlugin2 = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const configWithPlugins = { ...mockConfig, pushComponents: true, plugins: [() => mockPlugin1, () => mockPlugin2], } as unknown as ResolvedEmbeddableConfig; vi.mocked(provideConfig).mockResolvedValue(configWithPlugins); await dev(); // Start the plugin coordination process that should handle both plugins await listenMock.mock.calls[0][1](); // Verify the coordination handled both plugins through the queue mechanism expect(mockPlugin1.validate).toHaveBeenCalled(); expect(mockPlugin1.build).toHaveBeenCalled(); expect(mockPlugin2.validate).toHaveBeenCalled(); expect(mockPlugin2.build).toHaveBeenCalled(); }); it("should handle pluginBuildInProgress flag correctly", async () => { // Test the specific pluginBuildInProgress variable added in PR #765 const mockPlugin = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const configWithPlugin = { ...mockConfig, pushComponents: true, plugins: [() => mockPlugin], } as unknown as ResolvedEmbeddableConfig; vi.mocked(provideConfig).mockResolvedValue(configWithPlugin); await dev(); // Trigger the coordination logic that uses pluginBuildInProgress await listenMock.mock.calls[0][1](); expect(mockPlugin.validate).toHaveBeenCalled(); expect(mockPlugin.build).toHaveBeenCalled(); }); it("should execute doPluginBuilds with proper error handling", async () => { // Test the doPluginBuilds function added in PR #765 with error scenarios const mockPlugin = { validate: vi.fn().mockRejectedValue(new Error("Validation failed")), build: vi.fn(), cleanup: vi.fn(), }; const configWithPlugin = { ...mockConfig, pushComponents: true, plugins: [() => mockPlugin], } as unknown as ResolvedEmbeddableConfig; vi.mocked(provideConfig).mockResolvedValue(configWithPlugin); // Mock console.log to prevent error output during test const originalLog = console.log; console.log = vi.fn(); try { await dev(); // This should trigger the plugin coordination await expect(listenMock.mock.calls[0][1]()).rejects.toThrow(); } catch (error) { // Expected to fail due to validation error } finally { console.log = originalLog; } expect(mockPlugin.validate).toHaveBeenCalled(); }); it("should process pendingPluginBuilds queue after current build completes", async () => { // Test the pendingPluginBuilds.shift() logic added in PR #765 const mockPlugin1 = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const mockPlugin2 = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const configWithMultiplePlugins = { ...mockConfig, pushComponents: true, plugins: [() => mockPlugin1, () => mockPlugin2], } as unknown as ResolvedEmbeddableConfig; vi.mocked(provideConfig).mockResolvedValue(configWithMultiplePlugins); await dev(); // Execute the coordination that should process the queue await listenMock.mock.calls[0][1](); // Both plugins should be validated and built through the coordination system expect(mockPlugin1.validate).toHaveBeenCalled(); expect(mockPlugin1.build).toHaveBeenCalled(); expect(mockPlugin2.validate).toHaveBeenCalled(); expect(mockPlugin2.build).toHaveBeenCalled(); }); it("should call executePluginBuilds instead of direct plugin loop", async () => { // Test that the new executePluginBuilds call (line 268 in diff) is working const mockPlugin = { validate: vi.fn().mockResolvedValue(undefined), build: vi.fn().mockResolvedValue({ on: vi.fn(), close: vi.fn() }), cleanup: vi.fn(), }; const configWithPlugin = { ...mockConfig, pushComponents: true, plugins: [() => mockPlugin], } as unknown as ResolvedEmbeddableConfig; vi.mocked(provideConfig).mockResolvedValue(configWithPlugin); await dev(); // This should call executePluginBuilds which is the new coordination logic await listenMock.mock.calls[0][1](); // Verify the plugin was processed through the new coordination system expect(mockPlugin.validate).toHaveBeenCalled(); expect(mockPlugin.build).toHaveBeenCalled(); }); }); describe("Ultra-targeted tests for PR #765 new coverage", () => { it("should test executePluginBuilds queueing when pluginBuildInProgress is true", async () => { // Test the exact queueing logic from the NEW executePluginBuilds function const mockPlugin1 = { validate: