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
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
120 lines
4.4 KiB
TypeScript
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();
|
|
},
|
|
);
|
|
});
|