@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
text/typescript
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: