diff --git a/src/plugins/vite.ts b/src/plugins/vite.ts index c6f8823..f87606b 100644 --- a/src/plugins/vite.ts +++ b/src/plugins/vite.ts @@ -33,7 +33,7 @@ */ import { resolve } from 'node:path'; -import type { Plugin, ViteDevServer } from 'vite'; +import type { Plugin } from 'vite'; import { type GenerateOptions, type GenerateOutputOptions, @@ -89,6 +89,8 @@ export interface Options extends Omit { * @ignore */ export function icpBindgen(options: Options): Plugin { + let cleanupWatcher: (() => void) | undefined; + return { name: VITE_PLUGIN_NAME, async buildStart() { @@ -96,24 +98,26 @@ export function icpBindgen(options: Options): Plugin { }, configureServer(server) { if (!options.disableWatch) { - watchDidFileChanges(server, options); + // Remove previous listener to prevent accumulation on server restart. + cleanupWatcher?.(); + + const didFilePath = resolve(options.didFile); + server.watcher.add(didFilePath); + + const onChange = async (changedPath: string) => { + if (resolve(changedPath) === resolve(didFilePath)) { + await run(options); + } + }; + + server.watcher.on('change', onChange); + cleanupWatcher = () => server.watcher.off('change', onChange); } }, sharedDuringBuild: true, }; } -function watchDidFileChanges(server: ViteDevServer, options: Options) { - const didFilePath = resolve(options.didFile); - - server.watcher.add(didFilePath); - server.watcher.on('change', async (changedPath) => { - if (resolve(changedPath) === resolve(didFilePath)) { - await run(options); - } - }); -} - async function run(options: Options) { console.log(cyan(`[${VITE_PLUGIN_NAME}] Generating bindings from`), green(options.didFile)); diff --git a/tests/vite-plugin.test.ts b/tests/vite-plugin.test.ts new file mode 100644 index 0000000..e2c8e78 --- /dev/null +++ b/tests/vite-plugin.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for the `icpBindgen` Vite plugin's `configureServer` hook, which + * watches a `.did` file for changes and re-runs code generation. + * + * The `generate` function is mocked so these tests focus purely on watcher + * lifecycle behavior: listener registration, cleanup on server restart, file + * filtering, and the `disableWatch` opt-out. + */ + +import { EventEmitter } from 'node:events'; +import { resolve } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { icpBindgen } from '../src/plugins/vite.ts'; + +vi.mock('../src/core/generate/index.ts', () => ({ + generate: vi.fn().mockResolvedValue(undefined), +})); + +function createMockServer() { + const watcher = new EventEmitter(); + return { + watcher: Object.assign(watcher, { + add: vi.fn(), + off: watcher.removeListener.bind(watcher), + }), + }; +} + +const pluginOptions = { + didFile: './test.did', + outDir: './out', +}; + +// Our mock server only implements the subset of ViteDevServer used by the +// plugin (watcher.add/on/off), so a single cast is needed here. +function configureServer( + plugin: ReturnType, + server: ReturnType, +) { + // biome-ignore lint/suspicious/noExplicitAny: partial mock of ViteDevServer + (plugin.configureServer as any)(server); +} + +describe('icpBindgen vite plugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should register a change listener on configureServer', () => { + const plugin = icpBindgen(pluginOptions); + const server = createMockServer(); + + configureServer(plugin, server); + + expect(server.watcher.listenerCount('change')).toBe(1); + }); + + it('should not register a listener when disableWatch is true', () => { + const plugin = icpBindgen({ ...pluginOptions, disableWatch: true }); + const server = createMockServer(); + + configureServer(plugin, server); + + expect(server.watcher.listenerCount('change')).toBe(0); + }); + + it('should clean up previous listener when configureServer is called again', () => { + const plugin = icpBindgen(pluginOptions); + const server = createMockServer(); + + // Simulate multiple configureServer calls (e.g. Vite server restarts + // reusing the same watcher). + for (let i = 0; i < 10; i++) { + configureServer(plugin, server); + } + + expect(server.watcher.listenerCount('change')).toBe(1); + }); + + it('should trigger generate only for the watched did file', async () => { + const { generate } = await import('../src/core/generate/index.ts'); + + const plugin = icpBindgen(pluginOptions); + const server = createMockServer(); + + configureServer(plugin, server); + + // Emit a change for an unrelated file — should not trigger generate. + server.watcher.emit('change', '/some/other/file.ts'); + await vi.waitFor(() => {}); + expect(generate).not.toHaveBeenCalled(); + + // Emit a change for the watched did file — should trigger generate. + server.watcher.emit('change', resolve(pluginOptions.didFile)); + await vi.waitFor(() => { + expect(generate).toHaveBeenCalledOnce(); + }); + }); +});