Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions src/plugins/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,31 +89,35 @@ export interface Options extends Omit<GenerateOptions, 'output'> {
* @ignore
*/
export function icpBindgen(options: Options): Plugin {
let cleanupWatcher: (() => void) | undefined;

return {
name: VITE_PLUGIN_NAME,
async buildStart() {
await run(options);
},
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));

Expand Down
99 changes: 99 additions & 0 deletions tests/vite-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof icpBindgen>,
server: ReturnType<typeof createMockServer>,
) {
// 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();
});
});
});
Loading