From 09bd8bbce2b1f30253dcea091151e82b653b67e6 Mon Sep 17 00:00:00 2001 From: Jordan Paulino Date: Thu, 30 Apr 2026 19:26:17 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=95=20Increase=20Vite=20pipeline=20tes?= =?UTF-8?q?t=20coverage=20with=20focused=20unit=20tests.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add targeted tests for Vite generators, scripts orchestration/watch behavior, Vue2 plugin transforms, and browser-compat resolver modes to improve confidence and raise coverage without touching legacy Browserify/pack paths. Made-with: Cursor --- lib/cmd/vite/generators.test.js | 192 ++++++ lib/cmd/vite/plugins/browser-compat.test.js | 68 +++ lib/cmd/vite/plugins/vue2.test.js | 122 ++++ lib/cmd/vite/scripts.test.js | 636 ++++++++++++++++++++ 4 files changed, 1018 insertions(+) create mode 100644 lib/cmd/vite/generators.test.js create mode 100644 lib/cmd/vite/plugins/vue2.test.js create mode 100644 lib/cmd/vite/scripts.test.js diff --git a/lib/cmd/vite/generators.test.js b/lib/cmd/vite/generators.test.js new file mode 100644 index 0000000..b99a20e --- /dev/null +++ b/lib/cmd/vite/generators.test.js @@ -0,0 +1,192 @@ +/* eslint-env jest */ +'use strict'; + +const fs = require('fs-extra'); +const os = require('os'); +const path = require('path'); + +let cwdSpy, tmpDir; + +async function setupTmpDir(prefix) { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tmpDir); + jest.resetModules(); +} + +async function cleanupTmpDir() { + if (cwdSpy) cwdSpy.mockRestore(); + if (tmpDir) await fs.remove(tmpDir); + cwdSpy = null; + tmpDir = null; + jest.resetModules(); +} + +describe('generate-vite env/bootstrap/kiln generators', () => { + afterEach(async () => { + await cleanupTmpDir(); + jest.dontMock('./generate-env-init'); + jest.dontMock('../../config-file-helpers'); + }); + + describe('generate-env-init', () => { + it('writes .clay/_env-init.js with hydration runtime', async () => { + await setupTmpDir('claycli-vite-env-'); + + const { generateViteEnvInit, ENV_INIT_FILE } = require('./generate-env-init'); + const writtenPath = await generateViteEnvInit(); + const content = await fs.readFile(ENV_INIT_FILE, 'utf8'); + + expect(writtenPath).toBe(ENV_INIT_FILE); + expect(await fs.pathExists(ENV_INIT_FILE)).toBe(true); + expect(content).toContain('AUTO-GENERATED'); + expect(content).toContain('window.kiln.preloadData._envVars'); + expect(content).toContain('window.process.env = Object.assign'); + }); + }); + + describe('generate-globals-init', () => { + it('returns null when global/js has no non-test files', async () => { + await setupTmpDir('claycli-vite-globals-empty-'); + + await fs.ensureDir(path.join(tmpDir, 'global', 'js')); + await fs.writeFile(path.join(tmpDir, 'global', 'js', 'foo.test.js'), 'module.exports = {};'); + + const { generateViteGlobalsInit, GLOBALS_INIT_FILE } = require('./generate-globals-init'); + const writtenPath = await generateViteGlobalsInit(); + + expect(writtenPath).toBeNull(); + expect(await fs.pathExists(GLOBALS_INIT_FILE)).toBe(false); + }); + + it('writes _globals-init.js with imports for every non-test global script', async () => { + await setupTmpDir('claycli-vite-globals-'); + + await fs.ensureDir(path.join(tmpDir, 'global', 'js')); + await fs.writeFile(path.join(tmpDir, 'global', 'js', 'a.js'), 'window.a = true;'); + await fs.writeFile(path.join(tmpDir, 'global', 'js', 'b.js'), 'window.b = true;'); + await fs.writeFile(path.join(tmpDir, 'global', 'js', 'b.test.js'), 'window.bt = true;'); + + const { generateViteGlobalsInit, GLOBALS_INIT_FILE } = require('./generate-globals-init'); + const writtenPath = await generateViteGlobalsInit(); + const content = await fs.readFile(GLOBALS_INIT_FILE, 'utf8'); + + expect(writtenPath).toBe(GLOBALS_INIT_FILE); + expect(content).toContain("import './../global/js/a.js';"); + expect(content).toContain("import './../global/js/b.js';"); + expect(content).not.toContain('b.test.js'); + }); + }); + + describe('generate-bootstrap', () => { + it('builds bootstrap with env/globals imports, sticky shim and module map', async () => { + await setupTmpDir('claycli-vite-bootstrap-'); + + await fs.ensureDir(path.join(tmpDir, '.clay')); + await fs.writeFile(path.join(tmpDir, '.clay', '_globals-init.js'), '// globals'); + await fs.ensureDir(path.join(tmpDir, 'components', 'article')); + await fs.writeFile(path.join(tmpDir, 'components', 'article', 'client.js'), 'module.exports = function() {};'); + await fs.ensureDir(path.join(tmpDir, 'layouts', 'homepage')); + await fs.writeFile(path.join(tmpDir, 'layouts', 'homepage', 'client.js'), 'module.exports = function() {};'); + + const generateEnvInitMock = jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', '_env-init.js')); + + jest.doMock('./generate-env-init', () => ({ + generateViteEnvInit: generateEnvInitMock, + })); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockReturnValue(['auth:init']), + })); + + const { generateViteBootstrap, VITE_BOOTSTRAP_FILE, VITE_BOOTSTRAP_KEY } = require('./generate-bootstrap'); + const writtenPath = await generateViteBootstrap(); + const content = await fs.readFile(VITE_BOOTSTRAP_FILE, 'utf8'); + + expect(generateEnvInitMock).toHaveBeenCalledTimes(1); + expect(writtenPath).toBe(VITE_BOOTSTRAP_FILE); + expect(VITE_BOOTSTRAP_KEY).toBe('.clay/vite-bootstrap'); + expect(content).toContain("import './_env-init.js';"); + expect(content).toContain("import './_globals-init.js';"); + expect(content).toContain('window.modules = window.modules || {};'); + expect(content).toContain('"components/article/client.js": () => import("../components/article/client.js")'); + expect(content).toContain('"layouts/homepage/client.js": () => import("../layouts/homepage/client.js")'); + expect(content).toContain('fired["auth:init"] = ev.detail;'); + expect(content).toContain('mountComponentModules().catch(console.error);'); + }); + + it('omits sticky shim and globals import when not configured', async () => { + await setupTmpDir('claycli-vite-bootstrap-min-'); + await fs.ensureDir(path.join(tmpDir, 'components', 'article')); + await fs.writeFile(path.join(tmpDir, 'components', 'article', 'client.js'), 'module.exports = function() {};'); + + jest.doMock('./generate-env-init', () => ({ + generateViteEnvInit: jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', '_env-init.js')), + })); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockReturnValue(undefined), + })); + + const { generateViteBootstrap, VITE_BOOTSTRAP_FILE } = require('./generate-bootstrap'); + + await generateViteBootstrap(); + const content = await fs.readFile(VITE_BOOTSTRAP_FILE, 'utf8'); + + expect(content).toContain('// no global/js — skipping _globals-init'); + expect(content).not.toContain('clayViteStickyEvents'); + }); + }); + + describe('generate-kiln-edit', () => { + it('builds kiln edit entry with models, kilnjs, and optional kiln plugin', async () => { + await setupTmpDir('claycli-vite-kiln-'); + + await fs.ensureDir(path.join(tmpDir, 'components', 'article')); + await fs.writeFile(path.join(tmpDir, 'components', 'article', 'model.js'), 'module.exports = {};'); + await fs.writeFile(path.join(tmpDir, 'components', 'article', 'kiln.js'), 'module.exports = function() {};'); + await fs.ensureDir(path.join(tmpDir, 'layouts', 'homepage')); + await fs.writeFile(path.join(tmpDir, 'layouts', 'homepage', 'model.js'), 'module.exports = {};'); + await fs.ensureDir(path.join(tmpDir, 'services', 'kiln')); + await fs.writeFile(path.join(tmpDir, 'services', 'kiln', 'index.js'), 'module.exports = function() {};'); + + const generateEnvInitMock = jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', '_env-init.js')); + + jest.doMock('./generate-env-init', () => ({ + generateViteEnvInit: generateEnvInitMock, + })); + + const { generateViteKilnEditEntry, KILN_EDIT_ENTRY_FILE, KILN_EDIT_ENTRY_KEY } = require('./generate-kiln-edit'); + const writtenPath = await generateViteKilnEditEntry(); + const content = await fs.readFile(KILN_EDIT_ENTRY_FILE, 'utf8'); + + expect(generateEnvInitMock).toHaveBeenCalledTimes(1); + expect(writtenPath).toBe(KILN_EDIT_ENTRY_FILE); + expect(KILN_EDIT_ENTRY_KEY).toBe('.clay/vite-kiln-edit-init'); + expect(content).toContain('import \'./_env-init.js\';'); + expect(content).toContain('import * as _m0 from "../components/article/model.js";'); + expect(content).toContain('import * as _m1 from "../layouts/homepage/model.js";'); + expect(content).toContain('import * as _k0 from "../components/article/kiln.js";'); + expect(content).toContain('import * as _kilnPluginNs from "../services/kiln/index.js";'); + expect(content).toContain('window.kiln.componentModels["article"] = _resolveDefault(_m0);'); + expect(content).toContain('window.kiln.componentKilnjs["article"] = _resolveDefault(_k0);'); + expect(content).toContain('if (typeof _initKilnPlugins === "function") _initKilnPlugins();'); + }); + + it('does not import kiln plugin when services/kiln/index.js is missing', async () => { + await setupTmpDir('claycli-vite-kiln-noplugin-'); + + await fs.ensureDir(path.join(tmpDir, 'components', 'article')); + await fs.writeFile(path.join(tmpDir, 'components', 'article', 'model.js'), 'module.exports = {};'); + + jest.doMock('./generate-env-init', () => ({ + generateViteEnvInit: jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', '_env-init.js')), + })); + + const { generateViteKilnEditEntry, KILN_EDIT_ENTRY_FILE } = require('./generate-kiln-edit'); + + await generateViteKilnEditEntry(); + const content = await fs.readFile(KILN_EDIT_ENTRY_FILE, 'utf8'); + + expect(content).not.toContain('_kilnPluginNs'); + expect(content).not.toContain('_initKilnPlugins'); + }); + }); +}); diff --git a/lib/cmd/vite/plugins/browser-compat.test.js b/lib/cmd/vite/plugins/browser-compat.test.js index 0354d50..c78b1f8 100644 --- a/lib/cmd/vite/plugins/browser-compat.test.js +++ b/lib/cmd/vite/plugins/browser-compat.test.js @@ -2,6 +2,9 @@ 'use strict'; +const fs = require('fs-extra'); +const os = require('os'); +const path = require('path'); const viteBrowserCompatPlugin = require('./browser-compat'); // ── helpers ─────────────────────────────────────────────────────────────────── @@ -196,6 +199,71 @@ describe('viteBrowserCompatPlugin', () => { }); }); + describe('browser field false mappings', () => { + let tmpDir; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clay-browser-field-')); + }); + + afterEach(async () => { + if (tmpDir) await fs.remove(tmpDir); + tmpDir = null; + }); + + it('stubs bare imports mapped to false in package.json browser field', async () => { + const pkgDir = path.join(tmpDir, 'pkg'); + const importer = path.join(pkgDir, 'lib', 'index.js'); + + await fs.ensureDir(path.dirname(importer)); + await fs.writeFile(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: 'pkg', + browser: { fs: false }, + })); + await fs.writeFile(importer, '// importer'); + + const plugin = viteBrowserCompatPlugin(); + const resolved = plugin.resolveId('fs', importer); + + expect(resolved).toContain('simple:fs'); + expect(plugin.load(resolved)).toBe('export default {}; export {};'); + }); + + it('stubs relative imports mapped to false in package.json browser field', async () => { + const pkgDir = path.join(tmpDir, 'pkg'); + const importer = path.join(pkgDir, 'lib', 'css-syntax-error.js'); + + await fs.ensureDir(path.join(pkgDir, 'lib')); + await fs.writeFile(path.join(pkgDir, 'package.json'), JSON.stringify({ + name: 'pkg', + browser: { './lib/terminal-highlight': false }, + })); + await fs.writeFile(importer, '// importer'); + + const plugin = viteBrowserCompatPlugin(); + const resolved = plugin.resolveId('./terminal-highlight', importer); + + expect(resolved).toContain('simple:./terminal-highlight'); + expect(plugin.load(resolved)).toBe('export default {}; export {};'); + }); + }); + + describe('lenient externalize mode', () => { + it('replaces Vite browser-external proxy ids with empty module when enabled', () => { + const plugin = viteBrowserCompatPlugin({}, { lenientExternalize: true }); + + expect(plugin.load('__vite-browser-external')).toBe('export default {}; export {};'); + expect(plugin.load('__vite-browser-external:fs')).toBe('export default {}; export {};'); + }); + + it('does not intercept Vite browser-external ids when disabled', () => { + const plugin = viteBrowserCompatPlugin({}, { lenientExternalize: false }); + + expect(plugin.load('__vite-browser-external')).toBeNull(); + expect(plugin.load('__vite-browser-external:fs')).toBeNull(); + }); + }); + describe('plugin metadata', () => { it('has the correct plugin name', () => { const plugin = viteBrowserCompatPlugin(); diff --git a/lib/cmd/vite/plugins/vue2.test.js b/lib/cmd/vite/plugins/vue2.test.js new file mode 100644 index 0000000..d6bc5f7 --- /dev/null +++ b/lib/cmd/vite/plugins/vue2.test.js @@ -0,0 +1,122 @@ +/* eslint-env jest */ +'use strict'; + +const fs = require('fs-extra'); +const os = require('os'); +const path = require('path'); + +let tmpDir, cwdSpy; + +async function setupTmp(prefix) { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tmpDir); + jest.resetModules(); +} + +async function cleanupTmp() { + if (cwdSpy) cwdSpy.mockRestore(); + if (tmpDir) await fs.remove(tmpDir); + cwdSpy = null; + tmpDir = null; + jest.resetModules(); +} + +describe('vite vue2 plugin', () => { + afterEach(async () => { + await cleanupTmp(); + jest.dontMock('@vue/component-compiler-utils'); + jest.dontMock('vue-template-compiler'); + }); + + it('returns null for non-vue files', async () => { + await setupTmp('claycli-vue2-nonvue-'); + + const pluginFactory = require('./vue2'); + const plugin = pluginFactory(); + const result = await plugin.transform.call({ warn: jest.fn(), error: jest.fn() }, 'const x = 1;', '/tmp/a.js'); + + expect(result).toBeNull(); + }); + + it('transforms vue SFC and writes kiln css on closeBundle', async () => { + await setupTmp('claycli-vue2-transform-'); + + const vuePath = path.join(tmpDir, 'components', 'foo', 'client.vue'); + + await fs.ensureDir(path.dirname(vuePath)); + await fs.writeFile(vuePath, [ + '', + '', + '', + ].join('\n')); + + const pluginFactory = require('./vue2'); + const plugin = pluginFactory(); + const ctx = { + warn: jest.fn(), + error: jest.fn((msg) => { + throw new Error(msg); + }), + }; + + const out = await plugin.transform.call(ctx, '', vuePath); + + expect(out).toBeTruthy(); + expect(out.code).toContain('const __sfc__ ='); + expect(out.code).toContain('__sfc__.render = render;'); + expect(out.code).toContain('__sfc__._scopeId'); + expect(out.code).toContain('document.createElement("style")'); + expect(out.code).toContain('export default __sfc__;'); + + await plugin.closeBundle(); + + const kilnCssPath = path.join(tmpDir, 'public', 'css', '_kiln-plugins.css'); + const kilnCss = await fs.readFile(kilnCssPath, 'utf8'); + + expect(kilnCss).toContain('.foo'); + }); + + it('warns and returns null if vue file cannot be read', async () => { + await setupTmp('claycli-vue2-missing-file-'); + + const pluginFactory = require('./vue2'); + const plugin = pluginFactory(); + const ctx = { + warn: jest.fn(), + error: jest.fn((msg) => { + throw new Error(msg); + }), + }; + + const result = await plugin.transform.call(ctx, '', path.join(tmpDir, 'missing.vue')); + + expect(result).toBeNull(); + expect(ctx.warn).toHaveBeenCalled(); + }); + + it('errors with install guidance when vue compilers are unavailable', async () => { + await setupTmp('claycli-vue2-missing-compiler-'); + + await fs.ensureDir(path.join(tmpDir, 'components', 'foo')); + const vuePath = path.join(tmpDir, 'components', 'foo', 'client.vue'); + + await fs.writeFile(vuePath, ''); + + jest.doMock('@vue/component-compiler-utils', () => { + throw new Error('module missing'); + }); + + const pluginFactory = require('./vue2'); + const plugin = pluginFactory(); + const ctx = { + warn: jest.fn(), + error: jest.fn((msg) => { + throw new Error(msg); + }), + }; + + await expect(plugin.transform.call(ctx, '', vuePath)).rejects.toThrow( + /Vue 2 SFC support requires @vue\/component-compiler-utils and vue-template-compiler/ + ); + }); +}); diff --git a/lib/cmd/vite/scripts.test.js b/lib/cmd/vite/scripts.test.js new file mode 100644 index 0000000..21a1eec --- /dev/null +++ b/lib/cmd/vite/scripts.test.js @@ -0,0 +1,636 @@ +/* eslint-env jest */ +'use strict'; + +const fs = require('fs-extra'); +const os = require('os'); +const path = require('path'); + +let tmpDir, cwdSpy; + +async function setupTmp(prefix) { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tmpDir); + jest.resetModules(); +} + +async function cleanupTmp() { + if (cwdSpy) cwdSpy.mockRestore(); + if (tmpDir) await fs.remove(tmpDir); + cwdSpy = null; + tmpDir = null; + jest.resetModules(); +} + +function setStdoutTTY(value) { + const previous = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value, + }); + + return () => { + if (previous) { + Object.defineProperty(process.stdout, 'isTTY', previous); + } else { + delete process.stdout.isTTY; + } + }; +} + +describe('vite scripts', () => { + afterEach(async () => { + jest.useRealTimers(); + await cleanupTmp(); + jest.dontMock('vite'); + jest.dontMock('./generate-bootstrap'); + jest.dontMock('./generate-kiln-edit'); + jest.dontMock('./generate-globals-init'); + jest.dontMock('./plugins/client-env'); + jest.dontMock('./plugins/browser-compat'); + jest.dontMock('./plugins/service-rewrite'); + jest.dontMock('./plugins/missing-module'); + jest.dontMock('./plugins/vue2'); + jest.dontMock('./plugins/manual-chunks'); + jest.dontMock('./styles'); + jest.dontMock('./fonts'); + jest.dontMock('./templates'); + jest.dontMock('./vendor'); + jest.dontMock('./media'); + jest.dontMock('chokidar'); + jest.dontMock('../../config-file-helpers'); + }); + + it('getViteConfig merges bundlerConfig customizer output', async () => { + await setupTmp('claycli-vite-scripts-config-'); + + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockImplementation(key => { + if (key !== 'bundlerConfig') return undefined; + return (cfg) => ({ ...cfg, minify: true, kilnSplit: true, extraEntries: ['x.js'] }); + }), + })); + + const { getViteConfig } = require('./scripts'); + const cfg = getViteConfig({ minify: false }); + + expect(cfg.minify).toBe(true); + expect(cfg.kilnSplit).toBe(true); + expect(cfg.extraEntries).toEqual(['x.js']); + expect(cfg.sourcemap).toBe(true); + }); + + it('buildJS runs split builds, writes manifest and client-env', async () => { + await setupTmp('claycli-vite-scripts-build-'); + + const clayDir = path.join(tmpDir, '.clay'); + const destDir = path.join(tmpDir, 'public', 'js'); + const bootstrapFile = path.join(clayDir, 'vite-bootstrap.js'); + const kilnFile = path.join(clayDir, 'vite-kiln-edit-init.js'); + const envFile = path.join(tmpDir, 'client-env.json'); + + const viewResult = { + output: [ + { + type: 'chunk', + isEntry: true, + facadeModuleId: bootstrapFile, + fileName: '.clay/vite-bootstrap-a1b2c3d4.js', + imports: ['chunks/shared-111.js'], + }, + ], + }; + const kilnResult = { + output: [ + { + type: 'chunk', + isEntry: true, + facadeModuleId: kilnFile, + fileName: '.clay/vite-kiln-edit-init-z9y8x7w6.js', + imports: [], + }, + ], + }; + + const viteBuild = jest.fn() + .mockResolvedValueOnce(viewResult) + .mockResolvedValueOnce(kilnResult); + const envCollector = { + plugin: jest.fn().mockReturnValue({ name: 'env-collector' }), + write: jest.fn().mockResolvedValue(undefined), + }; + + jest.doMock('vite', () => ({ build: viteBuild })); + jest.doMock('./plugins/client-env', () => ({ + createClientEnvCollector: jest.fn().mockReturnValue(envCollector), + })); + jest.doMock('./generate-globals-init', () => ({ + generateViteGlobalsInit: jest.fn().mockResolvedValue(null), + })); + jest.doMock('./generate-kiln-edit', () => ({ + generateViteKilnEditEntry: jest.fn().mockResolvedValue(kilnFile), + KILN_EDIT_ENTRY_FILE: kilnFile, + KILN_EDIT_ENTRY_KEY: '.clay/vite-kiln-edit-init', + })); + jest.doMock('./generate-bootstrap', () => ({ + generateViteBootstrap: jest.fn().mockImplementation(async () => { + await fs.ensureDir(clayDir); + await fs.writeFile(bootstrapFile, '// bootstrap'); + return bootstrapFile; + }), + VITE_BOOTSTRAP_FILE: bootstrapFile, + VITE_BOOTSTRAP_KEY: '.clay/vite-bootstrap', + })); + jest.doMock('./plugins/browser-compat', () => jest.fn(() => ({ name: 'browser-compat' }))); + jest.doMock('./plugins/service-rewrite', () => jest.fn(() => ({ name: 'service-rewrite' }))); + jest.doMock('./plugins/missing-module', () => jest.fn(() => ({ name: 'missing-module' }))); + jest.doMock('./plugins/vue2', () => jest.fn(() => ({ name: 'vue2' }))); + jest.doMock('./plugins/manual-chunks', () => jest.fn(() => 'manual-chunks')); + jest.doMock('./styles', () => ({ buildStyles: jest.fn(), SRC_GLOBS: [] })); + jest.doMock('./fonts', () => ({ buildFonts: jest.fn(), FONTS_SRC_GLOB: '' })); + jest.doMock('./templates', () => ({ buildTemplates: jest.fn(), TEMPLATE_GLOB_PATTERN: '*.hbs' })); + jest.doMock('./vendor', () => ({ copyVendor: jest.fn() })); + jest.doMock('./media', () => ({ copyMedia: jest.fn() })); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockReturnValue(undefined), + })); + + const { buildJS } = require('./scripts'); + + await buildJS(); + + const manifestPath = path.join(destDir, '_manifest.json'); + const manifest = await fs.readJson(manifestPath); + + expect(viteBuild).toHaveBeenCalledTimes(2); + expect(envCollector.plugin).toHaveBeenCalledTimes(1); + expect(envCollector.write).toHaveBeenCalledTimes(1); + expect(path.dirname(envFile)).toBe(tmpDir); + expect(manifest['.clay/vite-bootstrap'].file).toBe('/js/.clay/vite-bootstrap-a1b2c3d4.js'); + expect(manifest['.clay/vite-bootstrap'].imports).toEqual(['/js/chunks/shared-111.js']); + expect(manifest['.clay/vite-kiln-edit-init'].file).toBe('/js/.clay/vite-kiln-edit-init-z9y8x7w6.js'); + }); + + it('buildAll runs media first then parallel asset steps', async () => { + await setupTmp('claycli-vite-scripts-buildall-'); + + const order = []; + + jest.doMock('./media', () => ({ + copyMedia: jest.fn().mockImplementation(async () => { + order.push('media'); + }), + })); + jest.doMock('./styles', () => ({ + buildStyles: jest.fn().mockImplementation(async () => { + order.push('styles'); + }), + SRC_GLOBS: [], + })); + jest.doMock('./fonts', () => ({ + buildFonts: jest.fn().mockImplementation(async () => { + order.push('fonts'); + }), + FONTS_SRC_GLOB: '', + })); + jest.doMock('./templates', () => ({ + buildTemplates: jest.fn().mockImplementation(async () => { + order.push('templates'); + }), + TEMPLATE_GLOB_PATTERN: '*.hbs', + })); + jest.doMock('./vendor', () => ({ + copyVendor: jest.fn().mockImplementation(async () => { + order.push('vendor'); + }), + })); + jest.doMock('vite', () => ({ + build: jest.fn().mockResolvedValue({ output: [] }), + })); + jest.doMock('./plugins/client-env', () => ({ + createClientEnvCollector: jest.fn().mockReturnValue({ + plugin: jest.fn().mockReturnValue({ name: 'env-collector' }), + write: jest.fn().mockResolvedValue(undefined), + }), + })); + jest.doMock('./generate-globals-init', () => ({ + generateViteGlobalsInit: jest.fn().mockResolvedValue(null), + })); + jest.doMock('./generate-kiln-edit', () => ({ + generateViteKilnEditEntry: jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', 'vite-kiln-edit-init.js')), + KILN_EDIT_ENTRY_FILE: path.join(tmpDir, '.clay', 'vite-kiln-edit-init.js'), + KILN_EDIT_ENTRY_KEY: '.clay/vite-kiln-edit-init', + })); + jest.doMock('./generate-bootstrap', () => ({ + generateViteBootstrap: jest.fn().mockImplementation(async () => { + const file = path.join(tmpDir, '.clay', 'vite-bootstrap.js'); + + await fs.ensureDir(path.dirname(file)); + await fs.writeFile(file, '// bootstrap'); + return file; + }), + VITE_BOOTSTRAP_FILE: path.join(tmpDir, '.clay', 'vite-bootstrap.js'), + VITE_BOOTSTRAP_KEY: '.clay/vite-bootstrap', + })); + jest.doMock('./plugins/browser-compat', () => jest.fn(() => ({ name: 'browser-compat' }))); + jest.doMock('./plugins/service-rewrite', () => jest.fn(() => ({ name: 'service-rewrite' }))); + jest.doMock('./plugins/missing-module', () => jest.fn(() => ({ name: 'missing-module' }))); + jest.doMock('./plugins/vue2', () => jest.fn(() => ({ name: 'vue2' }))); + jest.doMock('./plugins/manual-chunks', () => jest.fn(() => 'manual-chunks')); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockReturnValue(undefined), + })); + + const restoreTTY = setStdoutTTY(false); + const { buildAll } = require('./scripts'); + + await buildAll(); + + restoreTTY(); + expect(order[0]).toBe('media'); + expect(order).toEqual(expect.arrayContaining(['styles', 'fonts', 'templates', 'vendor'])); + }); + + it('buildJS supports kilnSplit single-pass mode with extra entries', async () => { + await setupTmp('claycli-vite-scripts-kilnsplit-'); + + const clayDir = path.join(tmpDir, '.clay'); + const destDir = path.join(tmpDir, 'public', 'js'); + const bootstrapFile = path.join(clayDir, 'vite-bootstrap.js'); + const kilnFile = path.join(clayDir, 'vite-kiln-edit-init.js'); + const extraFile = path.join(tmpDir, 'components', 'foo', 'client.js'); + + await fs.ensureDir(path.dirname(extraFile)); + await fs.writeFile(extraFile, 'module.exports = function() {};'); + + const viewResult = { + output: [ + { + type: 'chunk', + isEntry: true, + facadeModuleId: bootstrapFile, + fileName: '.clay/vite-bootstrap-aa.js', + imports: [], + }, + { + type: 'chunk', + isEntry: true, + facadeModuleId: kilnFile, + fileName: '.clay/vite-kiln-edit-init-bb.js', + imports: [], + }, + { + type: 'chunk', + isEntry: true, + facadeModuleId: extraFile, + fileName: 'components/foo/client-cc.js', + imports: [], + }, + ], + }; + + const viteBuild = jest.fn().mockResolvedValue(viewResult); + const envCollector = { + plugin: jest.fn().mockReturnValue({ name: 'env-collector' }), + write: jest.fn().mockResolvedValue(undefined), + }; + + jest.doMock('vite', () => ({ build: viteBuild })); + jest.doMock('./plugins/client-env', () => ({ + createClientEnvCollector: jest.fn().mockReturnValue(envCollector), + })); + jest.doMock('./generate-globals-init', () => ({ + generateViteGlobalsInit: jest.fn().mockResolvedValue(null), + })); + jest.doMock('./generate-kiln-edit', () => ({ + generateViteKilnEditEntry: jest.fn().mockResolvedValue(kilnFile), + KILN_EDIT_ENTRY_FILE: kilnFile, + KILN_EDIT_ENTRY_KEY: '.clay/vite-kiln-edit-init', + })); + jest.doMock('./generate-bootstrap', () => ({ + generateViteBootstrap: jest.fn().mockImplementation(async () => { + await fs.ensureDir(clayDir); + await fs.writeFile(bootstrapFile, '// bootstrap'); + return bootstrapFile; + }), + VITE_BOOTSTRAP_FILE: bootstrapFile, + VITE_BOOTSTRAP_KEY: '.clay/vite-bootstrap', + })); + jest.doMock('./plugins/browser-compat', () => jest.fn(() => ({ name: 'browser-compat' }))); + jest.doMock('./plugins/service-rewrite', () => jest.fn(() => ({ name: 'service-rewrite' }))); + jest.doMock('./plugins/missing-module', () => jest.fn(() => ({ name: 'missing-module' }))); + jest.doMock('./plugins/vue2', () => jest.fn(() => ({ name: 'vue2' }))); + jest.doMock('./plugins/manual-chunks', () => jest.fn(() => 'manual-chunks')); + jest.doMock('./styles', () => ({ buildStyles: jest.fn(), SRC_GLOBS: [] })); + jest.doMock('./fonts', () => ({ buildFonts: jest.fn(), FONTS_SRC_GLOB: '' })); + jest.doMock('./templates', () => ({ buildTemplates: jest.fn(), TEMPLATE_GLOB_PATTERN: '*.hbs' })); + jest.doMock('./vendor', () => ({ copyVendor: jest.fn() })); + jest.doMock('./media', () => ({ copyMedia: jest.fn() })); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockImplementation(key => { + if (key !== 'bundlerConfig') return undefined; + return cfg => ({ ...cfg, kilnSplit: true, extraEntries: [extraFile] }); + }), + })); + + const { buildJS } = require('./scripts'); + + await buildJS(); + + const manifest = await fs.readJson(path.join(destDir, '_manifest.json')); + + expect(viteBuild).toHaveBeenCalledTimes(1); + expect(manifest['.clay/vite-bootstrap'].file).toBe('/js/.clay/vite-bootstrap-aa.js'); + expect(manifest['.clay/vite-kiln-edit-init'].file).toBe('/js/.clay/vite-kiln-edit-init-bb.js'); + expect(manifest['components/foo/client'].file).toBe('/js/components/foo/client-cc.js'); + }); + + it('buildJS fails clearly when bootstrap file was not generated', async () => { + await setupTmp('claycli-vite-scripts-missing-bootstrap-'); + + const clayDir = path.join(tmpDir, '.clay'); + const bootstrapFile = path.join(clayDir, 'vite-bootstrap.js'); + const kilnFile = path.join(clayDir, 'vite-kiln-edit-init.js'); + + jest.doMock('vite', () => ({ build: jest.fn() })); + jest.doMock('./plugins/client-env', () => ({ + createClientEnvCollector: jest.fn().mockReturnValue({ + plugin: jest.fn().mockReturnValue({ name: 'env-collector' }), + write: jest.fn().mockResolvedValue(undefined), + }), + })); + jest.doMock('./generate-globals-init', () => ({ + generateViteGlobalsInit: jest.fn().mockResolvedValue(null), + })); + jest.doMock('./generate-kiln-edit', () => ({ + generateViteKilnEditEntry: jest.fn().mockResolvedValue(kilnFile), + KILN_EDIT_ENTRY_FILE: kilnFile, + KILN_EDIT_ENTRY_KEY: '.clay/vite-kiln-edit-init', + })); + jest.doMock('./generate-bootstrap', () => ({ + generateViteBootstrap: jest.fn().mockResolvedValue(bootstrapFile), + VITE_BOOTSTRAP_FILE: bootstrapFile, + VITE_BOOTSTRAP_KEY: '.clay/vite-bootstrap', + })); + jest.doMock('./plugins/browser-compat', () => jest.fn(() => ({ name: 'browser-compat' }))); + jest.doMock('./plugins/service-rewrite', () => jest.fn(() => ({ name: 'service-rewrite' }))); + jest.doMock('./plugins/missing-module', () => jest.fn(() => ({ name: 'missing-module' }))); + jest.doMock('./plugins/vue2', () => jest.fn(() => ({ name: 'vue2' }))); + jest.doMock('./plugins/manual-chunks', () => jest.fn(() => 'manual-chunks')); + jest.doMock('./styles', () => ({ buildStyles: jest.fn(), SRC_GLOBS: [] })); + jest.doMock('./fonts', () => ({ buildFonts: jest.fn(), FONTS_SRC_GLOB: '' })); + jest.doMock('./templates', () => ({ buildTemplates: jest.fn(), TEMPLATE_GLOB_PATTERN: '*.hbs' })); + jest.doMock('./vendor', () => ({ copyVendor: jest.fn() })); + jest.doMock('./media', () => ({ copyMedia: jest.fn() })); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockReturnValue(undefined), + })); + + const { buildJS } = require('./scripts'); + + await expect(buildJS()).rejects.toThrow('clay vite: missing .clay/vite-bootstrap.js after prepare.'); + }); + + it('buildAll reports failed steps with aggregate error', async () => { + await setupTmp('claycli-vite-scripts-buildall-fail-'); + + jest.doMock('./media', () => ({ copyMedia: jest.fn().mockResolvedValue(undefined) })); + jest.doMock('./styles', () => ({ + buildStyles: jest.fn().mockRejectedValue(new Error('styles failed')), + SRC_GLOBS: [], + })); + jest.doMock('./fonts', () => ({ + buildFonts: jest.fn().mockResolvedValue(undefined), + FONTS_SRC_GLOB: '', + })); + jest.doMock('./templates', () => ({ + buildTemplates: jest.fn().mockResolvedValue(undefined), + TEMPLATE_GLOB_PATTERN: '*.hbs', + })); + jest.doMock('./vendor', () => ({ copyVendor: jest.fn().mockResolvedValue(undefined) })); + jest.doMock('vite', () => ({ + build: jest.fn().mockResolvedValue({ output: [] }), + })); + jest.doMock('./plugins/client-env', () => ({ + createClientEnvCollector: jest.fn().mockReturnValue({ + plugin: jest.fn().mockReturnValue({ name: 'env-collector' }), + write: jest.fn().mockResolvedValue(undefined), + }), + })); + jest.doMock('./generate-globals-init', () => ({ + generateViteGlobalsInit: jest.fn().mockResolvedValue(null), + })); + jest.doMock('./generate-kiln-edit', () => ({ + generateViteKilnEditEntry: jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', 'vite-kiln-edit-init.js')), + KILN_EDIT_ENTRY_FILE: path.join(tmpDir, '.clay', 'vite-kiln-edit-init.js'), + KILN_EDIT_ENTRY_KEY: '.clay/vite-kiln-edit-init', + })); + jest.doMock('./generate-bootstrap', () => ({ + generateViteBootstrap: jest.fn().mockImplementation(async () => { + const file = path.join(tmpDir, '.clay', 'vite-bootstrap.js'); + + await fs.ensureDir(path.dirname(file)); + await fs.writeFile(file, '// bootstrap'); + return file; + }), + VITE_BOOTSTRAP_FILE: path.join(tmpDir, '.clay', 'vite-bootstrap.js'), + VITE_BOOTSTRAP_KEY: '.clay/vite-bootstrap', + })); + jest.doMock('./plugins/browser-compat', () => jest.fn(() => ({ name: 'browser-compat' }))); + jest.doMock('./plugins/service-rewrite', () => jest.fn(() => ({ name: 'service-rewrite' }))); + jest.doMock('./plugins/missing-module', () => jest.fn(() => ({ name: 'missing-module' }))); + jest.doMock('./plugins/vue2', () => jest.fn(() => ({ name: 'vue2' }))); + jest.doMock('./plugins/manual-chunks', () => jest.fn(() => 'manual-chunks')); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockReturnValue(undefined), + })); + + const restoreTTY = setStdoutTTY(false); + const { buildAll } = require('./scripts'); + + await expect(buildAll()).rejects.toThrow(/Build failed: 1 step\(s\) failed — styles failed/); + restoreTTY(); + }); + + it('watch wires rollup/chokidar and dispose closes all watchers', async () => { + await setupTmp('claycli-vite-scripts-watch-'); + + const jsRollupHandlers = {}; + const kilnRollupHandlers = {}; + const chokidarWatchers = []; + const rollupWatchers = []; + + function createRollupWatcher(handlerBag) { + const watcher = { + on: jest.fn((evt, cb) => { + handlerBag[evt] = cb; + return watcher; + }), + close: jest.fn(), + }; + + return watcher; + } + + function createChokidarWatcher() { + const handlers = {}; + const watcher = { + on: jest.fn((evt, cb) => { + handlers[evt] = cb; + return watcher; + }), + once: jest.fn((evt, cb) => { + if (evt === 'ready') setImmediate(cb); + return watcher; + }), + close: jest.fn().mockResolvedValue(undefined), + }; + + watcher.__handlers = handlers; + chokidarWatchers.push(watcher); + return watcher; + } + + const viteBuild = jest.fn().mockImplementation(async () => { + const watcher = rollupWatchers.length === 0 + ? createRollupWatcher(kilnRollupHandlers) + : createRollupWatcher(jsRollupHandlers); + + rollupWatchers.push(watcher); + return watcher; + }); + + jest.doMock('vite', () => ({ build: viteBuild })); + jest.doMock('chokidar', () => ({ + watch: jest.fn(() => createChokidarWatcher()), + })); + jest.doMock('./plugins/client-env', () => ({ + createClientEnvCollector: jest.fn().mockReturnValue({ + plugin: jest.fn().mockReturnValue({ name: 'env-collector' }), + write: jest.fn().mockResolvedValue(undefined), + }), + })); + jest.doMock('./generate-globals-init', () => ({ + generateViteGlobalsInit: jest.fn().mockResolvedValue(null), + })); + jest.doMock('./generate-kiln-edit', () => ({ + generateViteKilnEditEntry: jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', 'vite-kiln-edit-init.js')), + KILN_EDIT_ENTRY_FILE: path.join(tmpDir, '.clay', 'vite-kiln-edit-init.js'), + KILN_EDIT_ENTRY_KEY: '.clay/vite-kiln-edit-init', + })); + jest.doMock('./generate-bootstrap', () => ({ + generateViteBootstrap: jest.fn().mockImplementation(async () => { + const file = path.join(tmpDir, '.clay', 'vite-bootstrap.js'); + + await fs.ensureDir(path.dirname(file)); + await fs.writeFile(file, '// bootstrap'); + return file; + }), + VITE_BOOTSTRAP_FILE: path.join(tmpDir, '.clay', 'vite-bootstrap.js'), + VITE_BOOTSTRAP_KEY: '.clay/vite-bootstrap', + })); + jest.doMock('./plugins/browser-compat', () => jest.fn(() => ({ name: 'browser-compat' }))); + jest.doMock('./plugins/service-rewrite', () => jest.fn(() => ({ name: 'service-rewrite' }))); + jest.doMock('./plugins/missing-module', () => jest.fn(() => ({ name: 'missing-module' }))); + jest.doMock('./plugins/vue2', () => jest.fn(() => ({ name: 'vue2' }))); + jest.doMock('./plugins/manual-chunks', () => jest.fn(() => 'manual-chunks')); + const buildStylesMock = jest.fn().mockResolvedValue(undefined); + const buildFontsMock = jest.fn().mockResolvedValue(undefined); + const buildTemplatesMock = jest.fn().mockResolvedValue(undefined); + + jest.doMock('./styles', () => ({ buildStyles: buildStylesMock, SRC_GLOBS: [] })); + jest.doMock('./fonts', () => ({ buildFonts: buildFontsMock, FONTS_SRC_GLOB: '' })); + jest.doMock('./templates', () => ({ buildTemplates: buildTemplatesMock, TEMPLATE_GLOB_PATTERN: '*.hbs' })); + jest.doMock('./vendor', () => ({ copyVendor: jest.fn().mockResolvedValue(undefined) })); + jest.doMock('./media', () => ({ copyMedia: jest.fn().mockResolvedValue(undefined) })); + jest.doMock('../../config-file-helpers', () => ({ + getConfigValue: jest.fn().mockReturnValue(undefined), + })); + + const restoreTTY = setStdoutTTY(false); + const onRebuild = jest.fn(); + const onReady = jest.fn(); + const { watch } = require('./scripts'); + const session = await watch({ onRebuild, onReady }); + + expect(viteBuild).toHaveBeenCalledTimes(2); + expect(jsRollupHandlers.event).toBeDefined(); + expect(kilnRollupHandlers.event).toBeDefined(); + + const kilnCfg = viteBuild.mock.calls[0][0]; + const jsCfg = viteBuild.mock.calls[1][0]; + const kilnCapture = kilnCfg.plugins.find(p => p.name === 'clay-capture-kiln-output'); + const jsCapture = jsCfg.plugins.find(p => p.name === 'clay-capture-watch-output'); + const jsResult = { close: jest.fn() }; + const kilnResult = { close: jest.fn() }; + + expect(kilnCapture).toBeTruthy(); + expect(jsCapture).toBeTruthy(); + + jsRollupHandlers.event({ code: 'BUNDLE_START' }); + jsCapture.writeBundle(null, { + '.clay/vite-bootstrap-a.js': { + type: 'chunk', + isEntry: true, + facadeModuleId: path.join(tmpDir, '.clay', 'vite-bootstrap.js'), + fileName: '.clay/vite-bootstrap-a.js', + imports: [], + }, + }); + await jsRollupHandlers.event({ code: 'BUNDLE_END', duration: 12, result: jsResult }); + + kilnRollupHandlers.event({ code: 'BUNDLE_START' }); + kilnCapture.writeBundle(null, { + '.clay/vite-kiln-edit-init-a.js': { + type: 'chunk', + isEntry: true, + facadeModuleId: path.join(tmpDir, '.clay', 'vite-kiln-edit-init.js'), + fileName: '.clay/vite-kiln-edit-init-a.js', + imports: [], + }, + }); + await kilnRollupHandlers.event({ code: 'BUNDLE_END', duration: 9, result: kilnResult }); + + // Trigger chokidar add/change paths to exercise debounced rebuild handlers. + const jsWatcher = chokidarWatchers[0]; + const cssWatcher = chokidarWatchers[1]; + const fontWatcher = chokidarWatchers[2]; + const templateWatcher = chokidarWatchers[3]; + + jsWatcher.__handlers.add(path.join(tmpDir, 'components', 'foo', 'client.js')); + jsWatcher.__handlers.add(path.join(tmpDir, 'global', 'js', 'site.js')); + jsWatcher.__handlers.add(path.join(tmpDir, 'components', 'foo', 'model.js')); + jsWatcher.__handlers.unlink(path.join(tmpDir, 'components', 'foo', 'client.js')); + jsWatcher.__handlers.change(path.join(tmpDir, 'components', 'foo', 'client.js')); + cssWatcher.__handlers.change(path.join(tmpDir, 'styleguides', 'sg', 'components', 'nav.css')); + fontWatcher.__handlers.change(path.join(tmpDir, 'fonts', 'a.woff')); + templateWatcher.__handlers.change(path.join(tmpDir, 'components', 'foo', 'template.hbs')); + await new Promise(resolve => setTimeout(resolve, 350)); + + buildStylesMock.mockRejectedValueOnce(new Error('style watch fail')); + buildFontsMock.mockRejectedValueOnce(new Error('font watch fail')); + buildTemplatesMock.mockRejectedValueOnce(new Error('template watch fail')); + cssWatcher.__handlers.change(path.join(tmpDir, 'styleguides', 'sg', 'components', 'nav.css')); + fontWatcher.__handlers.change(path.join(tmpDir, 'fonts', 'a.woff')); + templateWatcher.__handlers.change(path.join(tmpDir, 'components', 'foo', 'template.hbs')); + await new Promise(resolve => setTimeout(resolve, 350)); + + expect(onRebuild).toHaveBeenCalled(); + expect(onReady).toHaveBeenCalled(); + expect(jsResult.close).toHaveBeenCalled(); + expect(kilnResult.close).toHaveBeenCalled(); + expect(buildStylesMock).toHaveBeenCalled(); + expect(buildFontsMock).toHaveBeenCalled(); + expect(buildTemplatesMock).toHaveBeenCalled(); + + await session.dispose(); + restoreTTY(); + + expect(rollupWatchers[0].close).toHaveBeenCalled(); + expect(rollupWatchers[1].close).toHaveBeenCalled(); + expect(chokidarWatchers).toHaveLength(4); + chokidarWatchers.forEach((w) => { + expect(w.close).toHaveBeenCalled(); + }); + }); +});