mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Fix local plugin dev watcher startup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b4fa4f3de6
commit
6a6a69e5c3
3 files changed
+120
-26
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user