diff --git a/server/src/__tests__/plugin-dev-watcher.test.ts b/server/src/__tests__/plugin-dev-watcher.test.ts index 75df3105535..c6ffa74257d 100644 --- a/server/src/__tests__/plugin-dev-watcher.test.ts +++ b/server/src/__tests__/plugin-dev-watcher.test.ts @@ -1,12 +1,28 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { resolvePluginWatchTargets } from "../services/plugin-dev-watcher.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const chokidarMock = vi.hoisted(() => ({ + watch: vi.fn(), +})); + +vi.mock("chokidar", () => ({ + default: chokidarMock, +})); + +import { createPluginDevWatcher, resolvePluginWatchTargets } from "../services/plugin-dev-watcher.js"; const tempDirs: string[] = []; +beforeEach(() => { + vi.useRealTimers(); + chokidarMock.watch.mockReset(); +}); + afterEach(() => { + vi.useRealTimers(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); @@ -19,25 +35,49 @@ function makeTempPluginDir(): string { return dir; } +function writePluginPackage(pluginDir: string): void { + mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true }); + writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@acme/example", + paperclipPlugin: { + manifest: "./dist/manifest.js", + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + }), + ); + writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n"); + writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n"); + writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n"); + writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n"); +} + +function createLifecycle() { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + restartWorker: vi.fn().mockResolvedValue(undefined), + }); +} + +function installMockFsWatcher() { + const handlers: Record void> = {}; + const fakeWatcher = { + close: vi.fn(), + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + handlers[event] = listener; + return fakeWatcher; + }), + }; + chokidarMock.watch.mockReturnValue(fakeWatcher); + return { fakeWatcher, handlers }; +} + describe("resolvePluginWatchTargets", () => { it("watches package metadata plus concrete declared runtime files", () => { const pluginDir = makeTempPluginDir(); - mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true }); - writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ - name: "@acme/example", - paperclipPlugin: { - manifest: "./dist/manifest.js", - worker: "./dist/worker.js", - ui: "./dist/ui", - }, - }), - ); - writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n"); - writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n"); - writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n"); - writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n"); + writePluginPackage(pluginDir); const targets = resolvePluginWatchTargets(pluginDir); @@ -66,3 +106,43 @@ describe("resolvePluginWatchTargets", () => { ]); }); }); + +describe("createPluginDevWatcher", () => { + it("starts watching local plugins announced by lifecycle events", async () => { + const pluginDir = makeTempPluginDir(); + writePluginPackage(pluginDir); + installMockFsWatcher(); + const lifecycle = createLifecycle(); + + const devWatcher = createPluginDevWatcher( + lifecycle as never, + async (pluginId) => (pluginId === "plugin-1" ? pluginDir : null), + ); + + lifecycle.emit("plugin.loaded", { pluginId: "plugin-1", pluginKey: "example" }); + + await vi.waitFor(() => expect(chokidarMock.watch).toHaveBeenCalledTimes(1)); + const [watchedPaths] = chokidarMock.watch.mock.calls[0] ?? []; + expect(watchedPaths).toContain(path.join(pluginDir, "dist", "worker.js")); + + devWatcher.close(); + }); + + it("debounces watched file changes and restarts the plugin worker", async () => { + vi.useFakeTimers(); + const pluginDir = makeTempPluginDir(); + writePluginPackage(pluginDir); + const { handlers } = installMockFsWatcher(); + const lifecycle = createLifecycle(); + + const devWatcher = createPluginDevWatcher(lifecycle as never); + devWatcher.watch("plugin-1", pluginDir); + + handlers.all?.("change", path.join(pluginDir, "dist", "worker.js")); + await vi.advanceTimersByTimeAsync(500); + + expect(lifecycle.restartWorker).toHaveBeenCalledWith("plugin-1"); + + devWatcher.close(); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index d94475afeab..d037718420f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -420,12 +420,10 @@ export async function createApp( void toolDispatcher.initialize().catch((err) => { logger.error({ err }, "Failed to initialize plugin tool dispatcher"); }); - const devWatcher = opts.uiMode === "vite-dev" - ? createPluginDevWatcher( - lifecycle, - async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null, - ) - : null; + const devWatcher = createPluginDevWatcher( + lifecycle, + async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null, + ); void loader.loadAll().then((result) => { if (!result) return; for (const loaded of result.results) { diff --git a/server/src/services/plugin-dev-watcher.ts b/server/src/services/plugin-dev-watcher.ts index 76b258c4218..ceba5d4e181 100644 --- a/server/src/services/plugin-dev-watcher.ts +++ b/server/src/services/plugin-dev-watcher.ts @@ -164,6 +164,10 @@ export function createPluginDevWatcher( const watchers = new Map(); const debounceTimers = new Map>(); const fileExists = fsDeps?.existsSync ?? existsSync; + log.info( + { resolvesInstalledPlugins: Boolean(resolvePluginPackagePath) }, + "plugin-dev-watcher: initialized", + ); function watchPlugin(pluginId: string, packagePath: string): void { // Don't double-watch @@ -293,11 +297,23 @@ export function createPluginDevWatcher( } async function watchLocalPluginById(pluginId: string): Promise { - if (!resolvePluginPackagePath) return; + if (!resolvePluginPackagePath) { + log.debug( + { pluginId }, + "plugin-dev-watcher: no package path resolver configured, skipping lifecycle event", + ); + return; + } try { const packagePath = await resolvePluginPackagePath(pluginId); - if (!packagePath) return; + if (!packagePath) { + log.debug( + { pluginId }, + "plugin-dev-watcher: plugin is not a local-path install, skipping watch", + ); + return; + } watchPlugin(pluginId, packagePath); } catch (err) { log.warn(