n8n/packages/@n8n/module-cli/src/frontend.test.mjs
Alex Grozav 9da3b7479f
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.18.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (26.5.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Verify single-instance deps (npm-install) (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
refactor(editor): Add defineFrontendModule() and make it the canonical descriptor form (no-changelog) (#38464)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-12 16:34:08 +00:00

419 lines
16 KiB
JavaScript

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 `<style>` block yet, and stylelint exits 1 on
// an empty match. Without `--allow-empty-input` the module is red on the day it is made.
scaffold(root);
const packageDir = join(root, 'packages', 'modules', NAME, 'frontend');
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
expect(manifest.scripts['lint:styles']).toContain('--allow-empty-input');
expect(manifest.scripts['lint:styles:fix']).toContain('--allow-empty-input');
expect(Object.keys(manifest.devDependencies)).toContain('@n8n/stylelint-config');
expect(Object.keys(manifest.devDependencies)).toContain('stylelint');
expect(readFileSync(join(packageDir, 'stylelint.config.mjs'), 'utf8')).toContain(
'@n8n/stylelint-config/base',
);
});
it('makes all four registrations', () => {
const { edits } = scaffold(root);
expect(edits.map((edit) => edit.note)).toEqual([
'@n8n/frontend-vite-config/index.ts (Vite alias)',
'editor-ui/package.json (devDependency)',
'editor-ui/tsconfig.json (paths)',
'editor-ui/src/app/modules.manifest.ts (registration)',
]);
expect(read(root, 'viteConfig')).toContain(
`{ name: '${PACKAGE}', dir: 'modules/${NAME}/frontend' },`,
);
expect(read(root, 'editorUiPackage')).toContain(`"${PACKAGE}": "workspace:*",`);
expect(read(root, 'editorUiTsconfig')).toContain(
`"${PACKAGE}": ["../../modules/${NAME}/frontend/src/index.ts"],`,
);
expect(read(root, 'editorUiTsconfig')).toContain(
`"${PACKAGE}/*": ["../../modules/${NAME}/frontend/src/*"],`,
);
expect(read(root, 'manifest')).toContain(`import { MyFeatureModule } from '${PACKAGE}';`);
expect(read(root, 'manifest')).toMatch(/\tMyFeatureModule,\n\];/);
});
it('puts the entry inside the modulePackages array while the array is empty', () => {
// The array is `= [];` on one line while it holds no module. An entry written after that
// line is a statement, not an element, and the file then has a syntax error.
const file = registrationFiles(root).viteConfig;
writeFileSync(
file,
readFileSync(file, 'utf8').replace(
/^(export const modulePackages.*= \[)[\s\S]*?^\];$/m,
'$1];',
),
);
scaffold(root);
const lines = read(root, 'viteConfig').split('\n');
const opens = lines.findIndex((line) => line.startsWith('export const modulePackages'));
expect(lines[opens].endsWith('= [')).toBe(true);
expect(lines[opens + 1]).toContain(`name: '${PACKAGE}'`);
expect(lines[opens + 2]).toBe('];');
});
it('inserts the dependency between its sorted neighbours', () => {
// Only the neighbours are asserted. The list as a whole is not in `String` order: pnpm
// writes `zod` before `z-vue-scan`, and `pnpm install` must leave the new entry alone.
scaffold(root, 'zzz-feature');
scaffold(root, 'aaa-feature');
const lines = read(root, 'editorUiPackage').split('\n');
const opens = lines.findIndex((line) => /^\s*"devDependencies": \{/.test(line));
const names = [];
for (let at = opens + 1; !/^\s*\},?\s*$/.test(lines[at]); at++) {
const [, dependency] = /^\s*"([^"]+)":/.exec(lines[at]) ?? [];
if (dependency) names.push(dependency);
}
for (const name of ['@n8n/frontend-module-aaa-feature', '@n8n/frontend-module-zzz-feature']) {
const at = names.indexOf(name);
expect({ name, at: at > 0 }).toEqual({ name, at: true });
expect(names[at - 1] < name).toBe(true);
expect(names[at + 1] > name).toBe(true);
}
});
});
describe('rerun', () => {
it('adds nothing the second time', () => {
scaffold(root);
const afterFirst = readAll(root);
const { edits } = scaffold(root);
expect(edits).toEqual([]);
expect(readAll(root)).toEqual(afterFirst);
for (const body of Object.values(afterFirst)) {
expect(occurrences(body, PACKAGE)).toBeLessThanOrEqual(2);
}
});
it('adds no second module to the manifest', () => {
scaffold(root);
scaffold(root);
const manifest = read(root, 'manifest');
expect(occurrences(manifest, 'MyFeatureModule')).toBe(2);
expect(occurrences(read(root, 'viteConfig'), PACKAGE)).toBe(1);
expect(occurrences(read(root, 'editorUiPackage'), PACKAGE)).toBe(1);
expect(occurrences(read(root, 'editorUiTsconfig'), PACKAGE)).toBe(2);
});
});
describe('partial failure', () => {
it('completes the missing registrations on the next run', () => {
const anchor = dropLine(root, 'editorUiTsconfig', SDK_PATHS);
expect(() => scaffold(root)).toThrow(ScaffoldError);
// Registrations 1 and 2 ran, 3 failed, 4 never started.
expect(read(root, 'viteConfig')).toContain(PACKAGE);
expect(read(root, 'editorUiPackage')).toContain(PACKAGE);
expect(read(root, 'manifest')).not.toContain(PACKAGE);
restoreLine(root, 'editorUiTsconfig', /^\s*"@n8n\/frontend-constants\*":/, anchor);
const { edits } = scaffold(root);
expect(edits.map((edit) => edit.note)).toEqual([
'editor-ui/tsconfig.json (paths)',
'editor-ui/src/app/modules.manifest.ts (registration)',
]);
expect(occurrences(read(root, 'viteConfig'), PACKAGE)).toBe(1);
expect(occurrences(read(root, 'editorUiPackage'), PACKAGE)).toBe(1);
expect(occurrences(read(root, 'editorUiTsconfig'), PACKAGE)).toBe(2);
expect(occurrences(read(root, 'manifest'), PACKAGE)).toBe(1);
});
it.each([
['viteConfig', /^export const modulePackages/, 'frontend-vite-config/index.ts'],
['editorUiPackage', /^\s*"devDependencies": \{/, 'editor-ui/package.json'],
['editorUiTsconfig', SDK_PATHS, 'editor-ui/tsconfig.json'],
['manifest', /^\];/, 'modules.manifest.ts'],
])('names %s when its anchor is gone', (key, pattern, file) => {
dropLine(root, key, pattern);
expect(() => scaffold(root)).toThrow(new RegExp(`${file}\\. Register the module by hand\\.`));
});
it('leaves the module directory in place for the rerun', () => {
dropLine(root, 'manifest', /^\];/);
expect(() => scaffold(root)).toThrow(ScaffoldError);
expect(existsSync(join(root, 'packages', 'modules', NAME, 'frontend', 'package.json'))).toBe(
true,
);
});
});
describe('descriptor name collision', () => {
// The manifest binds one name per module, and the name comes from the id. Two imports of one
// name do not compile, so the id has to give a name that the manifest does not hold yet.
// `data-table` and `agents` are the two the roadmap extracts in wave 2.
it.each(['data-table', 'agents'])('refuses %s, which the shell already binds', (name) => {
expect(() => scaffold(root, name)).toThrow(
/modules\.manifest\.ts already imports \w+Module from '@\/features\//,
);
});
it('refuses every in-shell feature an id can name', () => {
// The list comes from the manifest, not from a copy of it here: a new in-shell feature must
// not become a collision that no test covers. A binding no id can give is skipped, because
// no id gives it — `MCPModule` needs the id `mcp`, and `mcp` gives `McpModule`.
const bindings = [...read(root, 'manifest').matchAll(/import \{ (\w+)Module \} from '@\//g)];
const reachable = bindings
.map(([, binding]) => ({ binding, id: toKebab(binding) }))
.filter(({ binding, id }) => isModuleId(id) && substitutionsFor(id).PascalName === binding)
.map(({ id }) => id);
// Only a vacuous-loop guard. A fixed floor would be wrong here: this counts the features
// still in the shell, and the migration exists to drive that count down. It was 7 until
// otel graduated in #36679, which turned a passing test red for doing its job.
expect(reachable.length).toBeGreaterThan(0);
for (const id of reachable) {
expect(() => scaffold(root, id)).toThrow(ScaffoldError);
}
});
it('writes nothing when it refuses', () => {
const boundElsewhere = /import \{ (\w+)Module \} from '@\/features\//.exec(
read(root, 'manifest'),
)[1];
const name = toKebab(boundElsewhere);
const before = readAll(root);
expect(() => scaffold(root, name)).toThrow(ScaffoldError);
expect(existsSync(join(root, 'packages', 'modules', name))).toBe(false);
expect(readAll(root)).toEqual(before);
});
it('accepts a module the shell binds from this package, so a rerun still works', () => {
scaffold(root);
const { edits } = scaffold(root);
expect(edits).toEqual([]);
});
it('rejects an id whose name a sibling id would share', () => {
// `mcp-2` and `mcp2` both give `Mcp2`, for the descriptor and for the id of the store.
expect(isModuleId('mcp-2')).toBe(false);
expect(isModuleId('mcp2')).toBe(true);
expect(substitutionsFor('mcp2').PascalName).toBe('Mcp2');
});
it('gives a distinct descriptor name to every id it accepts', () => {
const ids = ['a', 'ab', 'a-b', 'mcp2', 'mcp2-x', 'data-table', 'datatable', 'my-feature'];
const descriptors = ids.map((id) => `${substitutionsFor(id).PascalName}Module`);
expect(ids.every((id) => isModuleId(id))).toBe(true);
expect(new Set(descriptors).size).toBe(ids.length);
});
});
});