mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-12 23:39:36 +08:00
318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { builtinModules } from 'node:module';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import { collectRuntimeSpecifiers } from '../../scripts/check-runtime-deps.mjs';
|
|
|
|
const root = path.resolve(import.meta.dirname, '../../packages/templates');
|
|
const templates = ['default', 'examples'].map((kind) => {
|
|
const directory = path.join(root, `app-template-${kind}`);
|
|
return {
|
|
kind,
|
|
directory,
|
|
manifest: JSON.parse(
|
|
readFileSync(path.join(directory, 'package.json'), 'utf8'),
|
|
),
|
|
};
|
|
});
|
|
const [baseline] = templates;
|
|
const runtimeExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs'];
|
|
const builtins = new Set(
|
|
builtinModules.flatMap((name) => [name, name.replace(/^node:/u, '')]),
|
|
);
|
|
|
|
function filesIn(directory, prefix = '') {
|
|
return readdirSync(directory, { withFileTypes: true })
|
|
.flatMap((entry) => {
|
|
const relative = path.join(prefix, entry.name);
|
|
return entry.isDirectory()
|
|
? filesIn(path.join(directory, entry.name), relative)
|
|
: [relative];
|
|
})
|
|
.sort();
|
|
}
|
|
|
|
function runtimeFilesIn(directory) {
|
|
if (!existsSync(directory)) return [];
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
if (
|
|
['dev-commands', 'dist', 'node_modules', 'tests'].includes(entry.name)
|
|
) {
|
|
return [];
|
|
}
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) return runtimeFilesIn(target);
|
|
return runtimeExtensions.includes(path.extname(entry.name)) ? [target] : [];
|
|
});
|
|
}
|
|
|
|
function resolveRuntimeRelative(from, specifier) {
|
|
const unresolved = path.resolve(path.dirname(from), specifier);
|
|
const base = unresolved.replace(/\.(?:c|m)?js$/u, '');
|
|
const candidates = [
|
|
unresolved,
|
|
...runtimeExtensions.map((extension) => `${base}${extension}`),
|
|
...runtimeExtensions.map((extension) =>
|
|
path.join(base, `index${extension}`),
|
|
),
|
|
];
|
|
return candidates.find(
|
|
(candidate) => existsSync(candidate) && statSync(candidate).isFile(),
|
|
);
|
|
}
|
|
|
|
function runtimePackageName(specifier) {
|
|
if (
|
|
specifier.startsWith('.') ||
|
|
specifier.startsWith('/') ||
|
|
specifier.startsWith('#') ||
|
|
specifier.startsWith('@/') ||
|
|
specifier.startsWith('node:')
|
|
) {
|
|
return undefined;
|
|
}
|
|
const segments = specifier.split('/');
|
|
return specifier.startsWith('@')
|
|
? segments.slice(0, 2).join('/')
|
|
: segments[0];
|
|
}
|
|
|
|
function runtimeDependencies(template) {
|
|
const pending = ['server', 'database', 'cli'].flatMap((directory) =>
|
|
runtimeFilesIn(path.join(template.directory, directory)),
|
|
);
|
|
const visited = new Set();
|
|
const imported = new Map();
|
|
while (pending.length > 0) {
|
|
const file = pending.pop();
|
|
if (!file || visited.has(file)) continue;
|
|
visited.add(file);
|
|
for (const specifier of collectRuntimeSpecifiers(
|
|
readFileSync(file, 'utf8'),
|
|
file,
|
|
)) {
|
|
if (specifier.startsWith('.')) {
|
|
const resolved = resolveRuntimeRelative(file, specifier);
|
|
assert.ok(
|
|
resolved,
|
|
`${template.kind}: unresolved ${specifier} from ${file}`,
|
|
);
|
|
if (
|
|
!resolved.includes(`${path.sep}cli${path.sep}dev-commands${path.sep}`)
|
|
) {
|
|
pending.push(resolved);
|
|
}
|
|
continue;
|
|
}
|
|
const dependency = runtimePackageName(specifier);
|
|
if (
|
|
dependency &&
|
|
dependency !== template.manifest.name &&
|
|
!builtins.has(dependency)
|
|
) {
|
|
imported.set(dependency, path.relative(template.directory, file));
|
|
}
|
|
}
|
|
}
|
|
return imported;
|
|
}
|
|
|
|
function sharedFrameworkSource(template, file) {
|
|
let source = readFileSync(path.join(template.directory, file), 'utf8');
|
|
|
|
// The image recipe is shared. Only the template's own directory, named in the usage comment, differs; it is
|
|
// normalized to Default's before comparing.
|
|
if (file === 'Dockerfile') {
|
|
source = source.replaceAll(
|
|
`packages/templates/app-template-${template.kind}`,
|
|
'packages/templates/app-template-default',
|
|
);
|
|
}
|
|
|
|
// Keep product identity local while comparing the shared layout.
|
|
if (file === 'client/layouts/components/sidebar-footer.tsx') {
|
|
source = source.replace("'Examples Template'", "'Default Template'");
|
|
}
|
|
return source;
|
|
}
|
|
|
|
// These are shared framework mechanisms, not product pages or plugin composition.
|
|
// Compare both directions so adding a build helper in only one template also fails.
|
|
for (const template of templates) {
|
|
test(`${template.kind} keeps the shared build and CLI framework aligned with Default`, () => {
|
|
for (const directory of [
|
|
'client/routing',
|
|
'client/layouts',
|
|
'client/theme',
|
|
]) {
|
|
const expected = filesIn(path.join(baseline.directory, directory));
|
|
assert.deepEqual(
|
|
filesIn(path.join(template.directory, directory)),
|
|
expected,
|
|
);
|
|
for (const file of expected) {
|
|
const relative = path.join(directory, file);
|
|
assert.equal(
|
|
sharedFrameworkSource(template, relative),
|
|
sharedFrameworkSource(baseline, relative),
|
|
`${template.kind}: ${relative}`,
|
|
);
|
|
}
|
|
}
|
|
for (const file of [
|
|
'tsconfig.json',
|
|
'tsconfig.server.json',
|
|
'eslint.config.js',
|
|
'vitest.config.ts',
|
|
'vite.config.ts',
|
|
'ecosystem.config.js',
|
|
'Dockerfile',
|
|
'Dockerfile.dockerignore',
|
|
'server/app.ts',
|
|
'server/embedded.ts',
|
|
'server/standalone.ts',
|
|
// What every plugin's toasts pass through; a template that drifts here presents them differently.
|
|
'client/lib/toaster.ts',
|
|
]) {
|
|
assert.equal(
|
|
sharedFrameworkSource(template, file),
|
|
sharedFrameworkSource(baseline, file),
|
|
`${template.kind}: ${file}`,
|
|
);
|
|
}
|
|
// The scripts are the shared command contract; compare them in both directions.
|
|
assert.deepEqual(
|
|
template.manifest.scripts,
|
|
baseline.manifest.scripts,
|
|
`${template.kind}: shared scripts`,
|
|
);
|
|
});
|
|
|
|
test(`${template.kind} carries no Hub publishing`, () => {
|
|
// NocoBase Hub is discontinued: neither @nocobase/hub-cli nor the publishing flag it replaced may come back.
|
|
assert.equal(
|
|
template.manifest.devDependencies?.['@nocobase/hub-cli'],
|
|
undefined,
|
|
);
|
|
assert.equal(
|
|
template.manifest.dependencies?.['@nocobase/hub-cli'],
|
|
undefined,
|
|
);
|
|
assert.equal(template.manifest.nocobase?.cli?.publishing, undefined);
|
|
});
|
|
|
|
test(`${template.kind} publishes its Dockerfile to generated applications`, () => {
|
|
// BuildKit reads `Dockerfile.dockerignore` only beside the Dockerfile it belongs to. Shipping one without the
|
|
// other builds an image from a context that includes config.yml, .env, and storage/.
|
|
for (const entry of ['Dockerfile', 'Dockerfile.dockerignore']) {
|
|
assert.ok(
|
|
template.manifest.files.includes(entry),
|
|
`${template.kind}: files must list ${entry}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test(`${template.kind} publishes the shadcn MCP configuration for each editor`, () => {
|
|
// Claude Code, Cursor and VS Code read these from the project; each runs the application's own shadcn CLI.
|
|
for (const entry of ['.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json']) {
|
|
assert.ok(
|
|
template.manifest.files.includes(entry),
|
|
`${template.kind}: files must list ${entry}`,
|
|
);
|
|
const config = readFileSync(path.join(template.directory, entry), 'utf8');
|
|
assert.equal(
|
|
config,
|
|
readFileSync(path.join(baseline.directory, entry), 'utf8'),
|
|
);
|
|
const servers =
|
|
JSON.parse(config)[
|
|
entry === '.vscode/mcp.json' ? 'servers' : 'mcpServers'
|
|
];
|
|
assert.deepEqual(servers.shadcn, {
|
|
command: 'pnpm',
|
|
args: ['exec', 'shadcn', 'mcp'],
|
|
});
|
|
}
|
|
});
|
|
|
|
test(`${template.kind} publishes the database directory by part, not whole`, () => {
|
|
// `files` is a whitelist npm applies ahead of every ignore file, so a bare `database` entry publishes
|
|
// whatever `collections generate` happens to have written locally: a snapshot of one developer's database,
|
|
// in whatever dialect they run it against, shipped to every application scaffolded from the template.
|
|
// Neither .gitignore nor .npmignore can take it back out. Name the parts that are source instead.
|
|
const { files } = template.manifest;
|
|
assert.equal(files.includes('database'), false);
|
|
for (const entry of [
|
|
'database/tsconfig.json',
|
|
'database/*/migrations/**',
|
|
'database/*/seeds/**',
|
|
]) {
|
|
assert.ok(
|
|
files.includes(entry),
|
|
`${template.kind}: files must list ${entry}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test(`${template.kind} declares server runtime packages in dependencies only`, () => {
|
|
const { dependencies, devDependencies } = template.manifest;
|
|
const duplicates = Object.keys(dependencies).filter(
|
|
(name) => name in devDependencies,
|
|
);
|
|
assert.deepEqual(duplicates, []);
|
|
assert.ok(dependencies['@nocobase/db']);
|
|
// The dialect `server/config/database.ts` defaults to. Creation no longer chooses a database, so a template that
|
|
// did not depend on its own default would scaffold an application unable to start until a driver was installed
|
|
// by hand. Switching databases means adding another driver and, if nothing else uses SQLite, removing this one.
|
|
assert.ok(
|
|
dependencies['@nocobase/db-sqlite'],
|
|
`${template.kind}: the driver for the default dialect must be a dependency`,
|
|
);
|
|
assert.equal(
|
|
devDependencies['@nocobase/db-sqlite'],
|
|
undefined,
|
|
`${template.kind}: the SQLite driver is a runtime dependency, not a development one`,
|
|
);
|
|
// And no other. `config init` picks the dialect from the installed drivers when it is not told one, and with
|
|
// several it refuses rather than guess — which is right for an application that added a driver, and wrong for
|
|
// one that has just been created. A template shipping extra drivers makes the documented `pnpm nocobase config init`
|
|
// fail in every non-interactive run, and puts drivers nothing uses into every deployment.
|
|
const drivers = Object.keys(dependencies).filter(
|
|
(name) =>
|
|
/^@nocobase\/db-/u.test(name) && name !== '@nocobase/db-testkit',
|
|
);
|
|
assert.deepEqual(
|
|
drivers,
|
|
['@nocobase/db-sqlite'],
|
|
`${template.kind}: declare only the driver for the default dialect`,
|
|
);
|
|
assert.equal(dependencies.hono, 'catalog:');
|
|
assert.equal(devDependencies.hono, undefined);
|
|
for (const [name, file] of runtimeDependencies(template)) {
|
|
assert.ok(
|
|
dependencies[name],
|
|
`${template.kind}: ${name} is imported at runtime by ${file} but is not in dependencies`,
|
|
);
|
|
}
|
|
for (const field of [
|
|
'engines',
|
|
'packageManager',
|
|
'type',
|
|
'prettier',
|
|
'browserslist',
|
|
]) {
|
|
assert.deepEqual(
|
|
template.manifest[field],
|
|
baseline.manifest[field],
|
|
field,
|
|
);
|
|
}
|
|
for (const [name, range] of Object.entries(devDependencies)) {
|
|
const baselineRange = baseline.manifest.devDependencies[name];
|
|
if (baselineRange) assert.equal(range, baselineRange, name);
|
|
}
|
|
});
|
|
}
|