open-design/apps/daemon/tests/plugins-search.test.ts
Cursor Agent fc46774080
feat(plugins): od plugin search + filters on od plugin list (Phase 4)
Plan Y1 / spec §13 / §16 Phase 4.

apps/daemon/src/plugins/search.ts ships a pure-helper
searchInstalledPlugins({ plugins, query?, taskKind?, mode?, tag?,
trust?, bundled? }) that ranks + filters an in-memory list of
InstalledPluginRecord. Match strategy:

  rank 0  id exact
  rank 1  title exact
  rank 2  tag exact
  rank 3  id substring
  rank 4  title substring
  rank 5  description substring
  rank 6  tag substring
  rank 9  no query (structural-filter-only mode)

Ties on rank break alphabetically by id. Structural filters AND
together: --task-kind=code-migration + --tag=phase-7 returns only
plugins matching both. Bundled filter accepts true (only bundled),
false (excludes bundled), or absent (no filter).

CLI:

  od plugin list [--task-kind <kind>] [--mode <mode>] [--tag <tag>] \
                 [--trust <tier>] [--bundled | --no-bundled] [--json]
  od plugin search <query> [--task-kind <kind>] [--mode <mode>] \
                           [--tag <tag>] [--trust <tier>] \
                           [--bundled | --no-bundled] [--json]

Search results show 'matched=[...]' tail per row so the user knows
which field caught the hit. printPluginHelp() updated with the new
verb shape.

Daemon tests: 1683 \u2192 1699 (+16 cases on plugins-search: free-text
match ranking across id / title / tag / description, exact-vs-
substring precedence, structural filters per axis (taskKind, mode,
tag, trust, bundled true/false, AND-combine), query + filter AND,
total + matched output shape, rank-tie alphabetical break).

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

157 lines
5.9 KiB
TypeScript

// Phase 4 / plan §3.Y1 — searchInstalledPlugins().
import { describe, expect, it } from 'vitest';
import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts';
import { searchInstalledPlugins } from '../src/plugins/search.js';
const make = (id: string, over: Partial<{ title: string; description: string; tags: string[]; taskKind: string; mode: string; trust: 'trusted' | 'restricted' | 'bundled'; sourceKind: 'bundled' | 'local' | 'github' | 'url'; }>): InstalledPluginRecord => ({
id,
title: over.title ?? `Title for ${id}`,
version: '0.1.0',
sourceKind: over.sourceKind ?? 'local',
source: '/tmp/' + id,
fsPath: '/tmp/' + id,
trust: over.trust ?? 'trusted',
capabilitiesGranted: [],
installedAt: 1,
updatedAt: 1,
manifest: {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
name: id,
version: '0.1.0',
title: over.title ?? `Title for ${id}`,
...(over.description ? { description: over.description } : {}),
...(over.tags ? { tags: over.tags } : {}),
...(over.taskKind || over.mode
? { od: {
...(over.taskKind ? { taskKind: over.taskKind } : {}),
...(over.mode ? { mode: over.mode } : {}),
} }
: {}),
} as PluginManifest,
});
describe('searchInstalledPlugins — free-text query', () => {
const plugins = [
make('exact-id', { title: 'Generic title', tags: ['utility'] }),
make('beta', { title: 'Awesome card builder', tags: ['ui'] }),
make('alpha', { title: 'Description matcher', description: 'helps with cards' }),
make('gamma', { title: 'Card power up', tags: ['cards'] }),
];
it('exact id match wins (rank=0)', () => {
const r = searchInstalledPlugins({ plugins, query: 'exact-id' });
expect(r.entries[0]?.plugin.id).toBe('exact-id');
expect(r.entries[0]?.rank).toBe(0);
});
it('exact title match (rank=1) ranks ahead of substring on id', () => {
const r = searchInstalledPlugins({ plugins, query: 'awesome card builder' });
expect(r.entries[0]?.plugin.id).toBe('beta');
expect(r.entries[0]?.rank).toBe(1);
});
it('tag exact match (rank=2)', () => {
const r = searchInstalledPlugins({ plugins, query: 'cards' });
// gamma has tag 'cards' (exact); alpha/beta only contain
// 'cards' as substring on title/description.
expect(r.entries[0]?.plugin.id).toBe('gamma');
expect(r.entries[0]?.rank).toBe(2);
});
it('substring matches across id / title / description / tags', () => {
const r = searchInstalledPlugins({ plugins, query: 'card' });
const ids = r.entries.map((e) => e.plugin.id);
expect(ids).toEqual(expect.arrayContaining(['gamma', 'beta', 'alpha']));
});
it('returns empty entries when query matches nothing', () => {
const r = searchInstalledPlugins({ plugins, query: 'no-such-thing' });
expect(r.entries).toEqual([]);
});
});
describe('searchInstalledPlugins — structural filters', () => {
const plugins = [
make('a', { taskKind: 'figma-migration', mode: 'extract' }),
make('b', { taskKind: 'code-migration', mode: 'extract' }),
make('c', { taskKind: 'code-migration', mode: 'edit', tags: ['phase-7'] }),
make('d', { trust: 'restricted' }),
make('e', { sourceKind: 'bundled', trust: 'bundled' }),
];
it('filters by taskKind', () => {
const r = searchInstalledPlugins({ plugins, taskKind: 'code-migration' });
expect(r.entries.map((e) => e.plugin.id).sort()).toEqual(['b', 'c']);
});
it('filters by mode', () => {
const r = searchInstalledPlugins({ plugins, mode: 'edit' });
expect(r.entries.map((e) => e.plugin.id)).toEqual(['c']);
});
it('filters by tag', () => {
const r = searchInstalledPlugins({ plugins, tag: 'phase-7' });
expect(r.entries.map((e) => e.plugin.id)).toEqual(['c']);
});
it('filters by trust tier', () => {
const r = searchInstalledPlugins({ plugins, trust: 'restricted' });
expect(r.entries.map((e) => e.plugin.id)).toEqual(['d']);
});
it("filters by 'bundled': true keeps only bundled plugins", () => {
const r = searchInstalledPlugins({ plugins, bundled: true });
expect(r.entries.map((e) => e.plugin.id)).toEqual(['e']);
});
it("filters by 'bundled': false excludes bundled plugins", () => {
const r = searchInstalledPlugins({ plugins, bundled: false });
expect(r.entries.map((e) => e.plugin.id).sort()).toEqual(['a', 'b', 'c', 'd']);
});
it('combines multiple filters AND', () => {
const r = searchInstalledPlugins({ plugins, taskKind: 'code-migration', mode: 'extract' });
expect(r.entries.map((e) => e.plugin.id)).toEqual(['b']);
});
});
describe('searchInstalledPlugins — query + filters together', () => {
const plugins = [
make('alpha', { taskKind: 'code-migration', tags: ['ui'] }),
make('beta', { taskKind: 'figma-migration', tags: ['ui'] }),
make('gamma', { taskKind: 'code-migration', tags: ['build'] }),
];
it('AND-combines free-text + structural filters', () => {
const r = searchInstalledPlugins({ plugins, query: 'alp', taskKind: 'code-migration' });
expect(r.entries.map((e) => e.plugin.id)).toEqual(['alpha']);
});
it('returns empty when filter excludes the query target', () => {
const r = searchInstalledPlugins({ plugins, query: 'beta', taskKind: 'code-migration' });
expect(r.entries).toEqual([]);
});
});
describe('searchInstalledPlugins — output shape', () => {
it('reports total + matched fields per entry', () => {
const r = searchInstalledPlugins({
plugins: [make('beta', { title: 'Card builder' })],
query: 'card',
});
expect(r.total).toBe(1);
expect(r.entries[0]?.matched).toEqual(['title']);
});
it('breaks rank ties alphabetically by id', () => {
const r = searchInstalledPlugins({
plugins: [
make('zeta', { title: 'Card library' }),
make('alpha', { title: 'Card library' }),
],
query: 'card library',
});
expect(r.entries.map((e) => e.plugin.id)).toEqual(['alpha', 'zeta']);
});
});