open-design/apps/daemon/tests/plugins-diff.test.ts
Cursor Agent dd5f08fb35
feat(plugins): od plugin diff <a> <b> (Phase 4 author tooling)
Plan AA1.

apps/daemon/src/plugins/diff.ts ships a pure helper:

  diffPlugins({ a, b }) \u2192 PluginDiffReport
    { pluginId?, entries: PluginDiffEntry[], added, removed, changed }

Each entry carries field path + kind ('added' | 'removed' |
'changed') + before / after / optional summary. Entries sort by
field path so the report is byte-deterministic across re-runs
(modulo timestamps, which the helper avoids comparing).

Compared fields:

  Top-level:   id, title, version, sourceKind, source, trust,
               capabilitiesGranted
  Manifest:    title, version, description, license, tags
  od.*:        kind, taskKind, mode, capabilities, inputs[]
               (by name), context.skills (by ref / path),
               context.craft, context.assets, connectors.required
               (by id), genui.surfaces (by id)
  Pipeline:    stages roster + per-stage atoms[] + until + repeat

Collection diffs collapse to '<n> added, <m> removed' summaries
so the CLI renders one line per field instead of dragging in a
generic deep-diff library.

CLI: `od plugin diff <id-a> <id-b> [--json]`. Renders +/-/~
glyphs per kind. printPluginHelp() updated.

Daemon tests: 1705 \u2192 1716 (+11 cases on plugins-diff:
equivalence, top-level version + trust changes, capabilitiesGranted
add / remove, id rename surfaces (no shared pluginId), taskKind
change, inputs[] roster diff, pipeline added / removed / per-
stage atoms churn, connectors.required churn, lexicographic
sort, aggregate count parity).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:48:58 +00:00

151 lines
6 KiB
TypeScript

// Plan §3.AA1 — diffPlugins() pure helper.
import { describe, expect, it } from 'vitest';
import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts';
import { diffPlugins } from '../src/plugins/diff.js';
const make = (
id: string,
manifest: NonNullable<PluginManifest['od']> | undefined = undefined,
over: Partial<InstalledPluginRecord> = {},
): InstalledPluginRecord => ({
id,
title: `Title for ${id}`,
version: '0.1.0',
sourceKind: 'local',
source: '/tmp/' + id,
fsPath: '/tmp/' + id,
trust: 'trusted',
capabilitiesGranted: [],
installedAt: 1,
updatedAt: 1,
manifest: {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
name: id,
version: '0.1.0',
title: `Title for ${id}`,
...(manifest ? { od: manifest } : {}),
} as PluginManifest,
...over,
});
describe('diffPlugins — equivalence', () => {
it('returns an empty entries list for byte-equal records', () => {
const a = make('p', { taskKind: 'new-generation' });
const b = make('p', { taskKind: 'new-generation' });
const r = diffPlugins({ a, b });
expect(r.entries).toEqual([]);
expect(r.added).toBe(0);
expect(r.removed).toBe(0);
expect(r.changed).toBe(0);
expect(r.pluginId).toBe('p');
});
});
describe('diffPlugins — top-level record', () => {
it('detects version + trust changes', () => {
const a = make('p');
const b = make('p', undefined, { version: '0.2.0', trust: 'restricted' });
const r = diffPlugins({ a, b });
const fields = r.entries.map((e) => e.field).sort();
expect(fields).toEqual(expect.arrayContaining(['trust', 'version']));
const versionEntry = r.entries.find((e) => e.field === 'version');
expect(versionEntry?.kind).toBe('changed');
expect(versionEntry?.before).toBe('0.1.0');
expect(versionEntry?.after).toBe('0.2.0');
});
it('detects added / removed entries on capabilitiesGranted', () => {
const a = make('p', undefined, { capabilitiesGranted: ['fs:read'] });
const b = make('p', undefined, { capabilitiesGranted: ['fs:read', 'connector:slack'] });
const r = diffPlugins({ a, b });
const cap = r.entries.find((e) => e.field === 'capabilitiesGranted');
expect(cap?.kind).toBe('changed');
expect(cap?.summary).toMatch(/1 added/);
expect(cap?.after).toContain('connector:slack');
});
it('renames surface as id changed (no shared pluginId on the report)', () => {
const r = diffPlugins({ a: make('alpha'), b: make('beta') });
expect(r.pluginId).toBeUndefined();
expect(r.entries.find((e) => e.field === 'id')?.kind).toBe('changed');
});
});
describe('diffPlugins — manifest body', () => {
it('detects od.taskKind changes', () => {
const a = make('p', { taskKind: 'new-generation' });
const b = make('p', { taskKind: 'code-migration' });
const r = diffPlugins({ a, b });
const e = r.entries.find((x) => x.field === 'od.taskKind');
expect(e?.kind).toBe('changed');
expect(e?.before).toBe('new-generation');
expect(e?.after).toBe('code-migration');
});
it('detects added / removed inputs[] by name', () => {
const a = make('p', { taskKind: 'new-generation', inputs: [{ name: 'topic', type: 'string' }] });
const b = make('p', { taskKind: 'new-generation', inputs: [{ name: 'topic', type: 'string' }, { name: 'tone', type: 'string' }] });
const r = diffPlugins({ a, b });
const e = r.entries.find((x) => x.field === 'od.inputs[]');
expect(e?.summary).toMatch(/1 added/);
expect(e?.after).toContain('tone');
});
it('detects pipeline added / removed / per-stage atoms changes', () => {
const a = make('p', {
pipeline: { stages: [
{ id: 'plan', atoms: ['todo-write'] },
{ id: 'do', atoms: ['file-write'] },
] },
});
const b = make('p', {
pipeline: { stages: [
{ id: 'plan', atoms: ['todo-write', 'direction-picker'] },
{ id: 'critique', atoms: ['critique-theater'] },
] },
});
const r = diffPlugins({ a, b });
// Stage-id roster differs.
expect(r.entries.find((e) => e.field === 'od.pipeline.stages')?.kind).toBe('changed');
// Per-stage atoms diff for the surviving stage 'plan'.
const planAtoms = r.entries.find((e) => e.field === 'od.pipeline.stages[plan].atoms');
expect(planAtoms?.summary).toMatch(/1 added/);
});
it('emits od.pipeline=added when only the rhs has a pipeline', () => {
const a = make('p', { taskKind: 'new-generation' });
const b = make('p', { taskKind: 'new-generation', pipeline: { stages: [{ id: 'x', atoms: ['todo-write'] }] } });
const r = diffPlugins({ a, b });
const e = r.entries.find((x) => x.field === 'od.pipeline');
expect(e?.kind).toBe('added');
expect(e?.after).toContain('x');
});
it('detects connectors required-list churn', () => {
const a = make('p', { taskKind: 'figma-migration', connectors: { required: [{ id: 'figma', tools: ['files.get'] }] } });
const b = make('p', { taskKind: 'figma-migration', connectors: { required: [{ id: 'figma', tools: ['files.get'] }, { id: 'slack', tools: [] }] } });
const r = diffPlugins({ a, b });
const e = r.entries.find((x) => x.field === 'od.connectors.required');
expect(e?.summary).toMatch(/1 added/);
});
});
describe('diffPlugins — output shape', () => {
it('sorts entries by field path lexicographically (deterministic output)', () => {
const a = make('p');
const b = make('p', undefined, { version: '0.2.0', trust: 'restricted', source: '/tmp/new' });
const r = diffPlugins({ a, b });
const fields = r.entries.map((e) => e.field);
expect(fields).toEqual([...fields].sort());
});
it("aggregate counts (added/removed/changed) match entries' kind tally", () => {
const a = make('p', { taskKind: 'new-generation' });
const b = make('p', { taskKind: 'code-migration', mode: 'edit' });
const r = diffPlugins({ a, b });
const counted = { added: 0, removed: 0, changed: 0 };
for (const e of r.entries) counted[e.kind]++;
expect({ added: r.added, removed: r.removed, changed: r.changed }).toEqual(counted);
});
});