dorfteich/packages/plugin-sdk/src/manifest.test.ts
Claude Opus 4.8 ec6ca80c4d
All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 3m18s
CI / Auth e2e pack (push) Successful in 4m3s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Has been skipped
Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70)
The SDK is the contract every other M7 story builds on (ADR 0008,
plugin-architecture.md). New package `@dorfteich/plugin-sdk`, standalone
(only depends on zod) so a plugin author needs nothing else.

- Zod manifest schema (`validateManifest`/`parseManifest`) with actionable
  `{ path, message }` issues and cross-field rules (extension-point/kind
  match, unique ids, section_style declares no permissions). Fixtures:
  3 valid + 14 invalid variants, asserted individually.
- `checkApiVersion` compatibility helper against the host's supported range.
- Capability names + method→capability map as the single source of truth
  for the permission gate.
- Transport-agnostic postMessage RPC engine (`createRpcEndpoint`) with
  request/response ids, per-request timeouts, unknown-method and
  endpoint-disposed handling, plus a `windowTransport` adapter.
- Host side (`createHostBridge`): routes plugin capability calls through
  the manifest permission gate; drives plugin lifecycle (render/edit/destroy).
- Plugin side (`createPlugin`): answers lifecycle calls, exposes a typed
  `host` proxy. RPC roundtrip verified in a jsdom MessageChannel test
  (roundtrip, args, timeout, unknown method, undeclared capability, dispose).
- README documents the protocol with a mermaid sequence diagram.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 16:12:00 +02:00

120 lines
4.4 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parseManifest, validateManifest } from './manifest';
const fixturesDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'fixtures',
'manifests',
);
function loadFixture(kind: 'valid' | 'invalid', name: string): unknown {
return JSON.parse(readFileSync(path.join(fixturesDir, kind, name), 'utf8'));
}
function listFixtures(kind: 'valid' | 'invalid'): string[] {
return readdirSync(path.join(fixturesDir, kind))
.filter((f) => f.endsWith('.json'))
.sort();
}
describe('manifest validation — valid fixtures', () => {
const valid = listFixtures('valid');
it('ships at least the reference-plugin manifests', () => {
expect(valid).toEqual(
expect.arrayContaining(['mermaid.json', 'section-styles-basic.json', 'toc.json']),
);
});
it.each(valid)('%s validates cleanly', (name) => {
const result = validateManifest(loadFixture('valid', name));
expect(result.issues).toEqual([]);
expect(result.success).toBe(true);
expect(result.manifest?.id).toBeTruthy();
});
it('parseManifest returns a typed manifest', () => {
const manifest = parseManifest(loadFixture('valid', 'toc.json'));
expect(manifest.kind).toBe('code');
expect(manifest.extensionPoints[0]?.title.de).toBe('Inhaltsverzeichnis');
// permissions default is applied even though every fixture sets it.
expect(manifest.permissions).toContain('readCurrentPage');
});
it('defaults permissions to an empty array when omitted', () => {
const manifest = parseManifest(loadFixture('valid', 'section-styles-basic.json'));
expect(manifest.permissions).toEqual([]);
});
});
describe('manifest validation — invalid fixtures', () => {
const invalid = listFixtures('invalid');
it('provides at least ten invalid variants', () => {
expect(invalid.length).toBeGreaterThanOrEqual(10);
});
it.each(invalid)('%s is rejected with issues', (name) => {
const result = validateManifest(loadFixture('invalid', name));
expect(result.success).toBe(false);
expect(result.manifest).toBeUndefined();
expect(result.issues.length).toBeGreaterThan(0);
for (const issue of result.issues) {
expect(issue.path).toBeTruthy();
expect(issue.message).toBeTruthy();
}
});
// Spot-check that the messages are actionable and point at the right field.
const expectations: Record<string, { path: string | RegExp; message: RegExp }> = {
'01-missing-id.json': { path: 'id', message: /required/i },
'02-bad-id.json': { path: 'id', message: /lowercase slug/i },
'03-missing-name.json': { path: 'name', message: /required/i },
'04-bad-version.json': { path: 'version', message: /semantic version/i },
'05-bad-api-version.json': { path: 'apiVersion', message: /major version/i },
'06-bad-kind.json': { path: 'kind', message: /.+/ },
'07-empty-extension-points.json': { path: 'extensionPoints', message: /.+/ },
'08-kind-mismatch.json': {
path: 'extensionPoints.0.type',
message: /allows only .* extension points/i,
},
'09-duplicate-extension-point-id.json': {
path: 'extensionPoints.1.id',
message: /duplicate extension point id/i,
},
'10-title-missing-de.json': { path: 'extensionPoints.0.title.de', message: /required/i },
'11-unknown-permission.json': { path: 'permissions.1', message: /.+/ },
'12-bad-fallback.json': { path: 'fallback.type', message: /discriminator/i },
'13-section-style-with-permissions.json': {
path: 'permissions',
message: /must not declare permissions/i,
},
'14-unknown-field.json': { path: /.+/, message: /unrecognized|extraField/i },
};
it.each(Object.entries(expectations))(
'%s yields an actionable message',
(name, { path: expectedPath, message }) => {
const result = validateManifest(loadFixture('invalid', name));
const matching = result.issues.find(
(issue) =>
(expectedPath instanceof RegExp
? expectedPath.test(issue.path)
: issue.path === expectedPath) && message.test(issue.message),
);
expect(
matching,
`expected an issue at "${String(expectedPath)}" matching ${message} but got ${JSON.stringify(
result.issues,
)}`,
).toBeDefined();
},
);
});