import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { createFrontend, registrationFiles } from './frontend.mjs'; import { isModuleId, repoRoot, ScaffoldError, substitutionsFor } from './scaffold.mjs'; const NAME = 'my-feature'; const PACKAGE = '@n8n/frontend-module-my-feature'; /** * The fixture holds a copy of the four real files, not a hand-written imitation of them. Each * registration finds its place with a regex, so a fixture that only looks like the real file lets * the anchor rot: the file moves or the anchor line changes shape, the test stays green and the * scaffolder writes a module that the shell cannot see. This copy makes that drift a test failure. */ const makeFixture = () => { const root = mkdtempSync(join(tmpdir(), 'module-cli-')); const real = registrationFiles(repoRoot); for (const [key, file] of Object.entries(registrationFiles(root))) { mkdirSync(dirname(file), { recursive: true }); cpSync(real[key], file); } return root; }; const scaffold = (root, name = NAME) => createFrontend({ name, packageDir: join(root, 'packages', 'modules', name, 'frontend'), substitutions: substitutionsFor(name), root, }); const read = (root, key) => readFileSync(registrationFiles(root)[key], 'utf8'); const readAll = (root) => Object.fromEntries(Object.keys(registrationFiles(root)).map((key) => [key, read(root, key)])); const occurrences = (text, needle) => text.split(needle).length - 1; /** Deletes the line an anchor matches, to make one registration fail. */ const dropLine = (root, key, pattern) => { const file = registrationFiles(root)[key]; const lines = readFileSync(file, 'utf8').split('\n'); const dropped = lines.filter((line) => !pattern.test(line)); writeFileSync(file, dropped.join('\n')); return lines.find((line) => pattern.test(line)); }; const restoreLine = (root, key, pattern, line) => { const file = registrationFiles(root)[key]; const lines = readFileSync(file, 'utf8').split('\n'); lines.splice(lines.findIndex((candidate) => pattern.test(candidate)) + 1, 0, line); writeFileSync(file, lines.join('\n')); }; const SDK_PATHS = /^\s*"@n8n\/frontend-module-sdk":/; /** * The body of `plugins: [...]` in a generated vite config. A plugin is wired by its call inside * this array, not by its import, so the two are asserted apart. */ const pluginArray = (viteConfig) => { const [, body] = /\n\t\tplugins: \[\n([\s\S]*?)\n\t\t\],\n/.exec(viteConfig) ?? []; // A shape change here would silently make every assertion below it vacuous. if (body === undefined) throw new Error('no `plugins: [` array in the generated vite.config.ts'); return body; }; /** The two icons the generated test renders, one per plugin. See that template for why. */ const ICON_FROM_UNPLUGIN = 'box'; const ICON_FROM_SVG_LOADER = 'webhook'; /** `InstanceAi` gives `instance-ai`, the id a descriptor of that name comes from. */ const toKebab = (pascalName) => pascalName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); describe('createFrontend', () => { let root; beforeEach(() => { root = makeFixture(); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); describe('fresh registration', () => { it('writes the package from the templates', () => { const { packageName } = scaffold(root); const packageDir = join(root, 'packages', 'modules', NAME, 'frontend'); expect(packageName).toBe(PACKAGE); const written = [ 'package.json', 'tsconfig.json', 'vite.config.ts', 'eslint.config.mjs', 'stylelint.config.mjs', 'biome.jsonc', 'README.md', 'src/index.ts', `src/${NAME}.module.ts`, `src/${NAME}.store.ts`, `src/${NAME}.store.test.ts`, 'src/__tests__/setup.ts', 'src/__tests__/design-system-icons.test.ts', ]; for (const file of written) { const body = readFileSync(join(packageDir, file), 'utf8'); // An unreplaced placeholder ships a module that does not compile. expect({ file, placeholders: body.match(/\{\{\w+\}\}/g) }).toEqual({ file, placeholders: null, }); } expect(readFileSync(join(packageDir, 'package.json'), 'utf8')).toContain(`"${PACKAGE}"`); expect(readFileSync(join(packageDir, 'src/index.ts'), 'utf8')).toContain('MyFeatureModule'); expect(readFileSync(join(packageDir, `src/${NAME}.store.ts`), 'utf8')).toContain( "defineStore('myFeature'", ); // `defineFrontendModule()` is the canonical descriptor form: it is the SDK's // one seam for validation and dev-mode checks. A scaffold that emits a bare // annotated object starts every new module outside that seam. const descriptor = readFileSync(join(packageDir, `src/${NAME}.module.ts`), 'utf8'); expect(descriptor).toContain( "import { defineFrontendModule } from '@n8n/frontend-module-sdk';", ); expect(descriptor).toContain('export const MyFeatureModule = defineFrontendModule({'); }); it('registers the plugins a design-system consumer needs, and declares them', () => { // design-system is consumed from source, so its `~icons/lucide/*` virtual modules and its // `custom/*.svg` components are this package's problem. Without `svgLoader` an `.svg` // import is a data-URI string, which Vue renders as a tag name. // // The call inside `plugins: [...]` is what wires a plugin into Vite, so that is what this // asserts. An earlier version matched the import specifiers instead, and stayed green // when both calls were deleted from the array — an import alone configures nothing. scaffold(root); const packageDir = join(root, 'packages', 'modules', NAME, 'frontend'); const viteConfig = readFileSync(join(packageDir, 'vite.config.ts'), 'utf8'); const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); const registered = pluginArray(viteConfig); for (const call of ['vue(', 'icons(', 'svgLoader(']) { expect({ call, inPluginsArray: registered.includes(call) }).toEqual({ call, inPluginsArray: true, }); } // A call needs its import, and the import needs its package: three ways to lose the same // plugin, so all three are checked. for (const specifier of ['unplugin-icons/vite', 'vite-svg-loader']) { expect(viteConfig).toMatch(new RegExp(`^import .* from '${specifier}';$`, 'm')); } for (const dependency of ['unplugin-icons', 'vite-svg-loader', '@iconify/json']) { expect(Object.keys(manifest.devDependencies)).toContain(dependency); } // `autoInstall: false` keeps a test run off the network, so the collection has to be the // `@iconify/json` dependency asserted above. expect(registered).toContain('autoInstall: false'); // The generated test is the behavioural half of this guarantee: it renders one icon of // each kind in the module's own suite, which is the only place a real Vite graph exists. // Assert it still names both mechanisms, so it cannot degrade into a trivial test. const iconTest = readFileSync( join(packageDir, 'src/__tests__/design-system-icons.test.ts'), 'utf8', ); expect(iconTest).toContain('N8nIcon'); for (const icon of [ICON_FROM_UNPLUGIN, ICON_FROM_SVG_LOADER]) { expect(iconTest).toContain(`'${icon}'`); } }); it('lints SCSS from the first run, before any SCSS exists', () => { // A generated package has no `.scss` and no `