Files
nocobase/tests/scripts/remove-plugin.test.mjs
T
chenos 78cf0a2092 feat: add agent-ready v3 plugin development (#80)
* 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
2026-08-31 07:23:16 +08:00

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`);
}