Fix local plugin dev watcher startup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-12 12:03:15 -05:00
1 parent b4fa4f3de6
commit 6a6a69e5c3
3 files changed
+120 -26

No files matched your search

+98 -18
View File
@@ -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<string, (...args: unknown[]) => 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();
});
});
+4 -6
View File
@@ -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) {
+18 -2
View File
@@ -164,6 +164,10 @@ export function createPluginDevWatcher(
const watchers = new Map<string, FSWatcher>();
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
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<void> {
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(