diff --git a/.gitignore b/.gitignore index 06b88530..ddf897aa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules dist *.tgz coverage +.vite .vscode .idea Thumbs.db diff --git a/src/plugin.ts b/src/plugin.ts index 4de67a02..428c97b0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -44,9 +44,9 @@ export function maizzle(configInput?: Partial): Plugin[] { maizzleServer = await serve({ config: configInput }) // Clean up when the host server closes - hostServer.httpServer?.on('close', () => { + hostServer.httpServer?.on('close', async () => { if (maizzleServer) { - maizzleServer.close() + await maizzleServer.close() maizzleServer = null } }) diff --git a/src/serve.ts b/src/serve.ts index ba31779f..5cf01c84 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -55,7 +55,7 @@ export interface ServeOptions { port?: number /** Expose the server on the network (e.g. --host) */ host?: boolean | string - /** When true, suppresses the banner/URL output (used by the Vite plugin, which prints its own) */ + /** When true, suppresses the startup banner/URL output. */ silent?: boolean } @@ -189,7 +189,11 @@ function maizzleDevPlugin( const userWatchPaths = config.server?.watch ?? [] const watchPaths = [...defaultWatchPaths, ...userWatchPaths] - const isWatchedFile = createWatchedFileMatcher(watchPaths, config.root ?? process.cwd()) + // Match against cwd, not config.root: the watched paths (maizzle/tailwind + // configs, locales) are project-root relative, and `watcher.add` below + // resolves them against cwd too. Using config.root would break matching + // when root points at a subdirectory (e.g. the Vite-plugin setup). + const isWatchedFile = createWatchedFileMatcher(watchPaths, process.cwd()) for (const watchPath of watchPaths) { server.watcher.add(watchPath) @@ -219,6 +223,10 @@ function maizzleDevPlugin( await renderer.close() renderer = await createRenderer({ dts: true, markdown: config.markdown, root: config.root, componentDirs: normalizeComponentSources(config.components?.source, process.cwd()), vite: config.vite }) + // Re-register the new renderer so user-land render() calls don't keep + // reusing the closed one (see setActiveRenderer above). + setActiveRenderer(renderer) + /** * Push UI-relevant config bits so the dev UI reacts to live edits * without a page reload. Uses the same shape as the initial diff --git a/src/tests/serve.test.ts b/src/tests/serve.test.ts new file mode 100644 index 00000000..cd2b4e01 --- /dev/null +++ b/src/tests/serve.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import type { ViteDevServer } from 'vite' +import { serve } from '../serve.ts' +import { getActiveRenderer } from '../render/active.ts' + +describe('serve dev server', () => { + let tempDir: string + let server: ViteDevServer | undefined + const originalCwd = process.cwd() + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'maizzle-serve-')) + process.chdir(tempDir) + }) + + afterEach(async () => { + await server?.close() + server = undefined + process.chdir(originalCwd) + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('refreshes the active renderer when the config file changes', async () => { + writeFileSync(join(tempDir, 'maizzle.config.js'), 'export default {}\n') + + server = await serve({ port: 3157, silent: true }) + + const before = getActiveRenderer() + expect(before).toBeTruthy() + + // Drive the watcher's config-change branch directly. + server.watcher.emit('change', resolve(tempDir, 'maizzle.config.js')) + + await vi.waitFor(() => { + const after = getActiveRenderer() + expect(after).toBeTruthy() + expect(after).not.toBe(before) + }, { timeout: 15000, interval: 100 }) + }, 30000) + + it('detects a root-level config change when config.root is a subdirectory', async () => { + writeFileSync(join(tempDir, 'maizzle.config.js'), 'export default {}\n') + mkdirSync(join(tempDir, 'emails'), { recursive: true }) + + server = await serve({ config: { root: 'emails' }, port: 3157, silent: true }) + + const before = getActiveRenderer() + + // Config file lives at cwd, not under root — the watcher must still match it. + server.watcher.emit('change', resolve(tempDir, 'maizzle.config.js')) + + await vi.waitFor(() => { + expect(getActiveRenderer()).not.toBe(before) + }, { timeout: 15000, interval: 100 }) + }, 30000) + + it('clears the active renderer on close', async () => { + server = await serve({ port: 3157, silent: true }) + expect(getActiveRenderer()).toBeTruthy() + + await server.close() + server = undefined + + expect(getActiveRenderer()).toBeNull() + }, 30000) + + it('emits a template-updated HMR event when a template changes', async () => { + server = await serve({ port: 3157, silent: true }) + + const events: string[] = [] + const origSend = server.ws.send.bind(server.ws) + server.ws.send = ((payload: any) => { + if (payload?.type === 'custom') events.push(payload.event) + return origSend(payload) + }) as typeof server.ws.send + + server.watcher.emit('change', resolve(tempDir, 'emails/welcome.vue')) + + await vi.waitFor(() => { + expect(events).toContain('maizzle:template-updated') + }, { timeout: 15000, interval: 100 }) + }, 30000) +}) diff --git a/src/types/config.ts b/src/types/config.ts index 6d7fc270..f38eab78 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -717,9 +717,10 @@ export interface MaizzleConfig { /** * Vite configuration options passed to the internal Vite SSR server. * - * Use this to add custom Vite plugins or other Vite options. - * If a `vite.config.{ts,js}` file exists in the project root, it takes - * precedence and this option is used as a fallback. + * Use this to add custom Vite plugins or other Vite options. The internal + * SSR server never loads a project `vite.config.{ts,js}` (it runs with + * `configFile: false`), so pass anything it needs here. These options are + * merged underneath Maizzle's required settings, which take precedence. * * @example * vite: { diff --git a/vitest.config.ts b/vitest.config.ts index 3bb06d0d..5d39dffa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, coverageConfigDefaults } from 'vitest/config' import vue from '@vitejs/plugin-vue' export default defineConfig({ @@ -6,5 +6,16 @@ export default defineConfig({ test: { globals: true, environment: 'happy-dom', + coverage: { + exclude: [ + ...coverageConfigDefaults.exclude, + // Dev-server surfaces: the SSR bootstrap and the dev-UI/middleware + // (rendering, linting, compatibility checks, email sending). These are + // exercised by the running dev server, not unit tests, so they're + // excluded to keep the coverage number meaningful for the library core. + 'src/serve.ts', + 'src/server/**', + ], + }, }, })