an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.
web/panels.ts appRegistryMetas — at least one panel
web/layout.ts defaultLayout — how they are arranged
both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout,
not a screen.
there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.
the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.
the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
4.8 KiB
TypeScript
101 lines
4.8 KiB
TypeScript
import { afterAll, describe, expect, it } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { discoverPlugins } from './discover';
|
|
|
|
// Discovery against a real tree, because every assertion here is about the FILESYSTEM being the
|
|
// declaration. Mocking `existsSync` would test the mock.
|
|
|
|
const root = mkdtempSync(join(tmpdir(), 'officer-plugins-'));
|
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
const MANIFEST = (over = '') => `export const manifest = {
|
|
publisher: 'officerdev', version: '1.0.0', platform: '>=1.0.0',
|
|
label: 'X', summary: 'x', icon: 'Network', color: '#fff',
|
|
permissions: [], ${over}
|
|
};`;
|
|
|
|
function plant(appName: string, files: Record<string, string>) {
|
|
const dir = join(root, appName);
|
|
for (const [rel, body] of Object.entries(files)) {
|
|
const full = join(dir, rel);
|
|
mkdirSync(join(full, '..'), { recursive: true });
|
|
writeFileSync(full, body);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
plant('full', {
|
|
'manifest.ts': MANIFEST(),
|
|
'api/router.ts': 'export const router = {};',
|
|
'db/schema.ts': 'export const t = {};',
|
|
'sidecar/index.ts': 'export {};',
|
|
'web/panels.ts': 'export const appRegistryMetas = [];',
|
|
'web/layout.ts': 'export const defaultLayout = {};',
|
|
});
|
|
plant('bare', { 'manifest.ts': MANIFEST() });
|
|
plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' });
|
|
plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' });
|
|
// A frontend that is not a workspace. The rule: every plugin route renders a Workspace with at least one
|
|
// panel, enforced here rather than by review — a `web/` directory without both files is refused.
|
|
plant('halfweb', { 'manifest.ts': MANIFEST(), 'web/panels.ts': 'export const appRegistryMetas = [];' });
|
|
plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
|
|
plant('_scratch', { 'manifest.ts': MANIFEST() });
|
|
|
|
describe('discoverPlugins', () => {
|
|
it('reads what the tree declares, and nothing more', async () => {
|
|
const { plugins } = await discoverPlugins(root);
|
|
const full = plugins.find((p) => p.appName === 'full')!;
|
|
expect(full.api).toContain('api/router.ts');
|
|
expect(full.schema).toContain('db/schema.ts');
|
|
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
|
|
expect(full.web?.panels).toContain('web/panels.ts');
|
|
expect(full.web?.layout).toContain('web/layout.ts');
|
|
});
|
|
|
|
it('a manifest alone is a valid plugin — every other part is optional', async () => {
|
|
const { plugins } = await discoverPlugins(root);
|
|
const bare = plugins.find((p) => p.appName === 'bare')!;
|
|
expect([bare.api, bare.schema, bare.sidecar, bare.web]).toEqual([null, null, null, null]);
|
|
});
|
|
|
|
// The runtime is the extension, not a field, so it cannot contradict the file it describes.
|
|
it('reads the runtime off the extension', async () => {
|
|
const { plugins } = await discoverPlugins(root);
|
|
expect(plugins.find((p) => p.appName === 'nodeish')!.sidecar?.runtime).toBe('node');
|
|
});
|
|
|
|
it('takes the app name from the directory, so it cannot disagree with where the code sits', async () => {
|
|
const { plugins } = await discoverPlugins(root);
|
|
expect(plugins.map((p) => p.appName)).toContain('full');
|
|
});
|
|
|
|
// The property that matters most: one bad plugin must not take the platform down, or hide the good
|
|
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
|
|
it('collects broken plugins instead of throwing', async () => {
|
|
const { plugins, broken } = await discoverPlugins(root);
|
|
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'halfweb', 'nomanifest']);
|
|
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
|
|
expect(plugins.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// The exclusionary rule, and the reason it is a shape rather than a policy: a plugin cannot ship a
|
|
// frontend that is not a workspace, because it never renders the screen.
|
|
it('refuses a web/ directory that is not panels AND a layout', async () => {
|
|
const { broken } = await discoverPlugins(root);
|
|
const halfweb = broken.find((b) => b.appName === 'halfweb')!;
|
|
expect(halfweb.error).toContain('web/layout.ts');
|
|
expect(halfweb.error).toContain('contribute panels and a layout, not a screen');
|
|
});
|
|
|
|
it('skips underscore and dot directories, which are scratch space', async () => {
|
|
const { plugins, broken } = await discoverPlugins(root);
|
|
expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch');
|
|
});
|
|
|
|
it('is empty, not an error, when there is no plugins directory at all', async () => {
|
|
expect(await discoverPlugins(join(root, 'does-not-exist'))).toEqual({ plugins: [], broken: [] });
|
|
});
|
|
});
|