mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-16 15:07:08 +08:00
* feat: add agent-ready v3 plugin development * docs: align plugin guides with capability-driven creation * test: cover plugin capability generation matrix * test: verify plugin capability publishing boundaries * test: cover plugin registration lifecycle * feat: add stable JSON errors for plugin tooling * feat: complete agent-ready plugin lifecycle tooling * docs: audit server plugin agent observability * feat: add agent-friendly server inspection * refactor: narrow server plugin inspection * fix: align agent plugin development loop * feat: add app-facing plugin skill example * fix: keep synchronized agent skills local * feat: complete agent-ready route development * refactor plugin server route ownership * docs: complete agent-ready route guidance * docs: add agent-ready plugin module guides * refactor client runtime composition * docs: add plugin development table of contents * feat: make plugin locale capabilities explicit * feat: refine plugin inspection diagnostics * docs: consolidate plugin development guides * docs: streamline plugin development guidance for agents * fix: provide dev config to plugin loop fixtures * fix: address plugin lifecycle review findings * fix(template): initialize installed app test databases
343 lines
10 KiB
JavaScript
343 lines
10 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { execFile } from 'node:child_process';
|
|
import { writeFileSync } from 'node:fs';
|
|
import {
|
|
access,
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
parseRemovePluginArgs,
|
|
removePlugin,
|
|
} from '../../scripts/remove-plugin.mjs';
|
|
|
|
const run = promisify(execFile);
|
|
const removeScriptUrl = new URL(
|
|
'../../scripts/remove-plugin.mjs',
|
|
import.meta.url,
|
|
).href;
|
|
|
|
test('parses removal options', () => {
|
|
assert.deepEqual(
|
|
parseRemovePluginArgs([
|
|
'@nocobase/app-plugin-audit-log',
|
|
'--dry-run',
|
|
'--no-install',
|
|
]),
|
|
{
|
|
dryRun: true,
|
|
help: false,
|
|
install: false,
|
|
json: false,
|
|
name: '@nocobase/app-plugin-audit-log',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('parses JSON removal output mode', () => {
|
|
assert.deepEqual(parseRemovePluginArgs(['audit-log', '--json']), {
|
|
dryRun: false,
|
|
help: false,
|
|
install: true,
|
|
json: true,
|
|
name: 'audit-log',
|
|
});
|
|
});
|
|
|
|
test('prints one JSON success document to stdout for a dry run', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
await createPluginPackage(repoRoot, 'audit-log');
|
|
const source = `import { main } from ${JSON.stringify(removeScriptUrl)}; await main(['audit-log', '--dry-run', '--json'], ${JSON.stringify(repoRoot)});`;
|
|
|
|
const result = await run(process.execPath, [
|
|
'--input-type=module',
|
|
'--eval',
|
|
source,
|
|
]);
|
|
assert.equal(result.stderr, '');
|
|
assert.deepEqual(JSON.parse(result.stdout), {
|
|
schemaVersion: 1,
|
|
ok: true,
|
|
operation: 'plugin:remove',
|
|
status: 'success',
|
|
result: {
|
|
mode: 'dry-run',
|
|
directoryName: 'app-plugin-audit-log',
|
|
packageName: '@nocobase/app-plugin-audit-log',
|
|
shortName: 'audit-log',
|
|
targetDirectory: path.join(repoRoot, 'packages/app-plugin-audit-log'),
|
|
commands: ['CI=true pnpm install --no-frozen-lockfile'],
|
|
},
|
|
});
|
|
});
|
|
|
|
test('prints one structured reference error to stderr and exits non-zero', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
await createPluginPackage(repoRoot, 'audit-log');
|
|
const appDirectory = path.join(repoRoot, 'packages/app');
|
|
await mkdir(appDirectory);
|
|
await writeJson(path.join(appDirectory, 'package.json'), {
|
|
name: '@nocobase/app',
|
|
dependencies: { '@nocobase/app-plugin-audit-log': 'workspace:^' },
|
|
});
|
|
const source = `import { main } from ${JSON.stringify(removeScriptUrl)}; await main(['audit-log', '--dry-run', '--json'], ${JSON.stringify(repoRoot)});`;
|
|
|
|
await assert.rejects(
|
|
run(process.execPath, ['--input-type=module', '--eval', source]),
|
|
(error) => {
|
|
assert.equal(error.stdout, '');
|
|
const response = JSON.parse(error.stderr);
|
|
assert.equal(response.error.code, 'PLUGIN_STILL_REFERENCED');
|
|
assert.equal(response.error.details.references.length, 1);
|
|
assert.deepEqual(response.error.suggestions, [
|
|
{
|
|
command: 'pnpm',
|
|
args: ['plugin:unregister', 'audit-log', '--app', 'app'],
|
|
},
|
|
]);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('removes an unreferenced plugin without changing the lockfile', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
|
|
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml');
|
|
await writeFile(lockfilePath, 'lockfileVersion: 9.0\n');
|
|
|
|
const result = await removePlugin({
|
|
install: false,
|
|
name: 'audit-log',
|
|
repoRoot,
|
|
});
|
|
|
|
assert.equal(result.packageName, '@nocobase/app-plugin-audit-log');
|
|
await assert.rejects(access(targetDirectory), { code: 'ENOENT' });
|
|
assert.equal(await readFile(lockfilePath, 'utf8'), 'lockfileVersion: 9.0\n');
|
|
});
|
|
|
|
test('dry-run validates but preserves the plugin', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
|
|
|
|
await removePlugin({
|
|
dryRun: true,
|
|
install: false,
|
|
name: 'app-plugin-audit-log',
|
|
repoRoot,
|
|
});
|
|
|
|
await access(targetDirectory);
|
|
});
|
|
|
|
test('refuses removal while a workspace package references the plugin', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
|
|
const appDirectory = path.join(repoRoot, 'packages/app');
|
|
await mkdir(appDirectory);
|
|
await writeJson(path.join(appDirectory, 'package.json'), {
|
|
name: '@nocobase/app',
|
|
dependencies: {
|
|
'@nocobase/app-plugin-audit-log': 'workspace:^',
|
|
},
|
|
nocobase: {
|
|
plugins: {
|
|
'@nocobase/app-plugin-audit-log': { enabled: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
removePlugin({ install: false, name: 'audit-log', repoRoot }),
|
|
(error) => {
|
|
assert.match(error.message, /packages\/app\/package\.json/u);
|
|
assert.match(error.message, /dependencies/u);
|
|
assert.match(error.message, /nocobase\.plugins/u);
|
|
assert.match(
|
|
error.message,
|
|
/pnpm plugin:unregister audit-log --app app/u,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
await access(targetDirectory);
|
|
});
|
|
|
|
test('refuses a directory whose package name does not match', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = path.join(repoRoot, 'packages/app-plugin-audit-log');
|
|
await mkdir(targetDirectory);
|
|
await writeJson(path.join(targetDirectory, 'package.json'), {
|
|
name: '@nocobase/app-plugin-something-else',
|
|
});
|
|
|
|
await assert.rejects(
|
|
removePlugin({ install: false, name: 'audit-log', repoRoot }),
|
|
/expected package name/u,
|
|
);
|
|
await access(targetDirectory);
|
|
});
|
|
|
|
test('refuses removal while server/plugins.ts imports the plugin', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
|
|
const appDirectory = path.join(repoRoot, 'packages/app');
|
|
await mkdir(path.join(appDirectory, 'server'), { recursive: true });
|
|
await writeJson(path.join(appDirectory, 'package.json'), {
|
|
name: '@nocobase/app',
|
|
});
|
|
await writeFile(
|
|
path.join(appDirectory, 'server/plugins.ts'),
|
|
`import auditLog from '@nocobase/app-plugin-audit-log/server';
|
|
import { defineServerPlugins } from '@nocobase/app-server-kit/plugins';
|
|
|
|
export default defineServerPlugins([auditLog]);
|
|
`,
|
|
);
|
|
|
|
await assert.rejects(
|
|
removePlugin({ install: false, name: 'audit-log', repoRoot }),
|
|
(error) => {
|
|
assert.match(error.message, /server\/plugins\.ts/u);
|
|
assert.match(
|
|
error.message,
|
|
/pnpm plugin:unregister audit-log --app app/u,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
await access(targetDirectory);
|
|
});
|
|
|
|
test('refuses removal while client/plugins.ts imports the plugin', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
|
|
const appDirectory = path.join(repoRoot, 'packages/app');
|
|
await mkdir(path.join(appDirectory, 'client'), { recursive: true });
|
|
await writeJson(path.join(appDirectory, 'package.json'), {
|
|
name: '@nocobase/app',
|
|
});
|
|
await writeFile(
|
|
path.join(appDirectory, 'client/plugins.ts'),
|
|
`import auditLog from '@nocobase/app-plugin-audit-log/client';
|
|
import { defineClientPlugins } from '@nocobase/app-client/plugins';
|
|
|
|
export default defineClientPlugins([auditLog()]);
|
|
`,
|
|
);
|
|
|
|
await assert.rejects(
|
|
removePlugin({ install: false, name: 'audit-log', repoRoot }),
|
|
(error) => {
|
|
assert.match(error.message, /client\/plugins\.ts/u);
|
|
assert.match(
|
|
error.message,
|
|
/pnpm plugin:unregister audit-log --app app/u,
|
|
);
|
|
return true;
|
|
},
|
|
);
|
|
await access(targetDirectory);
|
|
});
|
|
|
|
test('removes a plugin after every application reference is gone', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
|
|
const appDirectory = path.join(repoRoot, 'packages/app');
|
|
await mkdir(path.join(appDirectory, 'client'), { recursive: true });
|
|
await mkdir(path.join(appDirectory, 'server'), { recursive: true });
|
|
await writeJson(path.join(appDirectory, 'package.json'), {
|
|
name: '@nocobase/app',
|
|
devDependencies: {},
|
|
nocobase: { plugins: {} },
|
|
});
|
|
await writeFile(
|
|
path.join(appDirectory, 'client/plugins.ts'),
|
|
`import { defineClientPlugins } from '@nocobase/app-client/plugins';
|
|
|
|
export default defineClientPlugins([]);
|
|
`,
|
|
);
|
|
await writeFile(
|
|
path.join(appDirectory, 'server/plugins.ts'),
|
|
`import { defineServerPlugins } from '@nocobase/app-server-kit/plugins';
|
|
|
|
export default defineServerPlugins([]);
|
|
`,
|
|
);
|
|
|
|
await removePlugin({ install: false, name: 'audit-log', repoRoot });
|
|
|
|
await assert.rejects(access(targetDirectory), { code: 'ENOENT' });
|
|
});
|
|
|
|
test('restores the plugin and lockfile when synchronization fails', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
|
|
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml');
|
|
await writeFile(lockfilePath, 'lockfileVersion: 9.0\noriginal: true\n');
|
|
|
|
await assert.rejects(
|
|
removePlugin({
|
|
name: 'audit-log',
|
|
repoRoot,
|
|
synchronize() {
|
|
writeFileSync(lockfilePath, 'lockfileVersion: 9.0\nmodified: true\n');
|
|
throw new Error('simulated install failure');
|
|
},
|
|
}),
|
|
/was restored: simulated install failure/u,
|
|
);
|
|
|
|
await access(targetDirectory);
|
|
assert.equal(
|
|
await readFile(lockfilePath, 'utf8'),
|
|
'lockfileVersion: 9.0\noriginal: true\n',
|
|
);
|
|
assert.deepEqual(
|
|
(await readdir(repoRoot)).filter((entry) =>
|
|
entry.startsWith('.plugin-remove-'),
|
|
),
|
|
[],
|
|
);
|
|
});
|
|
|
|
async function createTestRepo(t) {
|
|
const repoRoot = await mkdtemp(
|
|
path.join(tmpdir(), 'nocobase-remove-plugin-'),
|
|
);
|
|
await mkdir(path.join(repoRoot, 'packages'));
|
|
await writeJson(path.join(repoRoot, 'package.json'), {
|
|
name: 'test-workspace',
|
|
private: true,
|
|
});
|
|
t.after(() => rm(repoRoot, { force: true, recursive: true }));
|
|
return repoRoot;
|
|
}
|
|
|
|
async function createPluginPackage(repoRoot, shortName) {
|
|
const targetDirectory = path.join(
|
|
repoRoot,
|
|
`packages/app-plugin-${shortName}`,
|
|
);
|
|
await mkdir(targetDirectory);
|
|
await writeJson(path.join(targetDirectory, 'package.json'), {
|
|
name: `@nocobase/app-plugin-${shortName}`,
|
|
});
|
|
return targetDirectory;
|
|
}
|
|
|
|
async function writeJson(file, value) {
|
|
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|