Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

305 lines
14 KiB
TypeScript

// Phase 4 / spec §23.3.5 — bundled plugin boot walker.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migratePlugins } from '../src/plugins/persistence.js';
import { listInstalledPlugins, upsertInstalledPlugin } from '../src/plugins/registry.js';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { registerBundledPlugins } from '../src/plugins/bundled.js';
let db: Database.Database;
let tmpRoot: string;
const SAMPLE_MANIFEST = (id: string) =>
JSON.stringify({
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
name: id,
title: id,
version: '0.1.0',
description: `${id} bundled fixture`,
license: 'MIT',
od: { kind: 'atom', capabilities: ['prompt:inject'] },
});
const SAMPLE_SKILL = (id: string) => `---\nname: ${id}\ndescription: bundled fixture\n---\n# ${id}\n`;
beforeEach(async () => {
tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-bundled-'));
db = new Database(':memory:');
db.exec(`
CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT);
CREATE TABLE conversations (id TEXT PRIMARY KEY, project_id TEXT, title TEXT);
`);
migratePlugins(db);
});
afterEach(async () => {
db.close();
await rm(tmpRoot, { recursive: true, force: true });
});
describe('registerBundledPlugins', () => {
it('registers every <bundledRoot>/<tier>/<id>/ folder under source_kind=bundled', async () => {
// Build a layout with one atom + one scenario:
// <bundledRoot>/atoms/discovery-question-form/{open-design.json,SKILL.md}
// <bundledRoot>/scenarios/od-new-generation/{open-design.json,SKILL.md}
const atomDir = path.join(tmpRoot, 'atoms', 'discovery-question-form');
const sceneDir = path.join(tmpRoot, 'scenarios', 'od-new-generation');
await mkdir(atomDir, { recursive: true });
await mkdir(sceneDir, { recursive: true });
await writeFile(path.join(atomDir, 'open-design.json'), SAMPLE_MANIFEST('discovery-question-form'));
await writeFile(path.join(atomDir, 'SKILL.md'), SAMPLE_SKILL('discovery-question-form'));
await writeFile(path.join(sceneDir, 'open-design.json'), SAMPLE_MANIFEST('od-new-generation'));
await writeFile(path.join(sceneDir, 'SKILL.md'), SAMPLE_SKILL('od-new-generation'));
const result = await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(result.registered.map((r) => r.id).sort()).toEqual(['discovery-question-form', 'od-new-generation']);
const installed = listInstalledPlugins(db);
expect(installed.length).toBe(2);
for (const row of installed) {
expect(row.sourceKind).toBe('bundled');
expect(row.trust).toBe('bundled');
}
});
it('can stamp official registry provenance on bundled preinstalls', async () => {
const folder = path.join(tmpRoot, 'scenarios', 'starter');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('starter'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('starter'));
const result = await registerBundledPlugins({
db,
bundledRoot: tmpRoot,
marketplaceProvenance: {
sourceMarketplaceId: 'official',
marketplaceTrust: 'official',
entryNamePrefix: 'open-design',
},
});
expect(result.registered[0]?.sourceKind).toBe('bundled');
expect(result.registered[0]?.sourceMarketplaceId).toBe('official');
expect(result.registered[0]?.sourceMarketplaceEntryName).toBe('open-design/starter');
expect(result.registered[0]?.sourceMarketplaceEntryVersion).toBe('0.1.0');
expect(result.registered[0]?.marketplaceTrust).toBe('official');
expect(result.registered[0]?.resolvedSource).toBe(folder);
const [row] = listInstalledPlugins(db);
expect(row?.sourceMarketplaceId).toBe('official');
expect(row?.sourceMarketplaceEntryName).toBe('open-design/starter');
});
it('also registers a direct <bundledRoot>/<plugin-id>/ folder', async () => {
// Direct layout (no tier): <bundledRoot>/sample-plugin/...
const folder = path.join(tmpRoot, 'sample-plugin');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('sample-plugin'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('sample-plugin'));
const result = await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(result.registered.map((r) => r.id)).toEqual(['sample-plugin']);
});
it('is idempotent — re-running upserts the same row', async () => {
const folder = path.join(tmpRoot, 'atoms', 'sample');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('sample'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('sample'));
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(listInstalledPlugins(db).length).toBe(1);
});
// Regression for: this walker re-runs every daemon boot, and each run
// stamps a fresh `now` onto every record it builds. A re-registration that
// changes nothing must not bump `installedAt`/`updatedAt` — otherwise every
// restart resets the whole bundled catalog's freshness to the same instant,
// which is exactly what broke the Community gallery's "Newest" sort (it
// orders by `updatedAt`).
it('preserves installedAt/updatedAt across a no-op re-registration', async () => {
const folder = path.join(tmpRoot, 'atoms', 'sample');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('sample'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('sample'));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValue(1_000);
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
const [first] = listInstalledPlugins(db);
expect(first?.installedAt).toBe(1_000);
expect(first?.updatedAt).toBe(1_000);
// Simulate a later boot with nothing on disk changed.
nowSpy.mockReturnValue(2_000);
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
const [second] = listInstalledPlugins(db);
expect(second?.installedAt).toBe(1_000);
expect(second?.updatedAt).toBe(1_000);
// A genuine content change (version bump) at a later boot DOES refresh
// updatedAt — the guard only skips no-op re-registrations.
await writeFile(
path.join(folder, 'open-design.json'),
SAMPLE_MANIFEST('sample').replace('"0.1.0"', '"0.2.0"'),
);
nowSpy.mockReturnValue(3_000);
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
const [third] = listInstalledPlugins(db);
expect(third?.installedAt).toBe(1_000);
expect(third?.updatedAt).toBe(3_000);
expect(third?.version).toBe('0.2.0');
nowSpy.mockRestore();
});
// Regression for review feedback on #5362: SKILL.md's markdown body never
// makes it into the parsed manifest (adaptAgentSkill keeps only frontmatter
// fields), so a body-only edit with no frontmatter/version bump must still
// be treated as a real change — otherwise updatedAt stays frozen forever
// for a plugin whose SKILL.md instructions keep getting updated.
it('advances updatedAt when only SKILL.md changes, with no version bump', async () => {
const folder = path.join(tmpRoot, 'atoms', 'sample');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('sample'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('sample'));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValue(1_000);
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
const [first] = listInstalledPlugins(db);
expect(first?.updatedAt).toBe(1_000);
// Edit only SKILL.md's body — same frontmatter, same version, same
// open-design.json — and confirm the parsed manifest is unaffected before
// asserting on updatedAt (otherwise this test wouldn't actually probe the
// gap: a manifest-derived field changing would trivially pass too).
const editedSkill = `${SAMPLE_SKILL('sample')}\nRewritten instructions body.\n`;
await writeFile(path.join(folder, 'SKILL.md'), editedSkill);
nowSpy.mockReturnValue(2_000);
const result = await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(JSON.stringify(result.registered[0]?.manifest)).toBe(
JSON.stringify(first?.manifest),
);
const [second] = listInstalledPlugins(db);
expect(second?.installedAt).toBe(1_000);
expect(second?.updatedAt).toBe(2_000);
nowSpy.mockRestore();
});
// Regression for further review feedback on #5362: a bundled plugin's
// runtime behavior is served from far more than open-design.json/SKILL.md
// (see discoverPluginHtmlAssets in routes/plugins/assets.ts, and manifest
// preview-media paths like od.preview.poster/video/gif). Editing a preview
// asset with no manifest/SKILL.md change at all must still advance
// updatedAt, or "Newest" stays wrong for asset-only bundled updates.
it('advances updatedAt when only a preview asset changes, with no manifest or SKILL.md edit', async () => {
const folder = path.join(tmpRoot, 'atoms', 'sample');
await mkdir(path.join(folder, 'preview'), { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('sample'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('sample'));
await writeFile(path.join(folder, 'preview', 'index.html'), '<p>v1 preview</p>');
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValue(1_000);
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
const [first] = listInstalledPlugins(db);
expect(first?.updatedAt).toBe(1_000);
// Edit only the preview asset — open-design.json and SKILL.md untouched.
await writeFile(path.join(folder, 'preview', 'index.html'), '<p>v2 preview, redesigned</p>');
nowSpy.mockReturnValue(2_000);
const result = await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(JSON.stringify(result.registered[0]?.manifest)).toBe(
JSON.stringify(first?.manifest),
);
const [second] = listInstalledPlugins(db);
expect(second?.installedAt).toBe(1_000);
expect(second?.updatedAt).toBe(2_000);
nowSpy.mockRestore();
});
it('returns empty result when bundledRoot does not exist', async () => {
const result = await registerBundledPlugins({
db,
bundledRoot: path.join(tmpRoot, 'does-not-exist'),
});
expect(result.registered).toEqual([]);
expect(result.warnings).toEqual([]);
});
it('skips folders without open-design.json without warning', async () => {
const folder = path.join(tmpRoot, 'atoms', 'no-manifest');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'README.md'), '# nothing\n');
const result = await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(result.registered).toEqual([]);
});
it('prunes persisted bundled rows whose bundled folder was removed', async () => {
// Upgrade path: a previous daemon image shipped `stale`, the user's DB
// registered it, and this image no longer carries the folder. The row
// must disappear too — otherwise /api/plugins keeps serving a record
// whose backing files are gone.
const keepDir = path.join(tmpRoot, 'atoms', 'keep');
const staleDir = path.join(tmpRoot, 'atoms', 'stale');
for (const [dir, id] of [[keepDir, 'keep'], [staleDir, 'stale']] as const) {
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'open-design.json'), SAMPLE_MANIFEST(id));
await writeFile(path.join(dir, 'SKILL.md'), SAMPLE_SKILL(id));
}
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(listInstalledPlugins(db).length).toBe(2);
// A user-installed plugin must never be touched by bundled pruning.
upsertInstalledPlugin(db, {
id: 'user-plugin',
title: 'User Plugin',
version: '0.1.0',
sourceKind: 'project',
source: '/tmp/user-plugin',
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
manifest: JSON.parse(SAMPLE_MANIFEST('user-plugin')),
fsPath: '/tmp/user-plugin',
installedAt: 0,
updatedAt: 0,
} as InstalledPluginRecord);
await rm(staleDir, { recursive: true, force: true });
const result = await registerBundledPlugins({ db, bundledRoot: tmpRoot });
expect(result.pruned).toEqual(['stale']);
expect(listInstalledPlugins(db).map((r) => r.id).sort()).toEqual([
'keep',
'user-plugin',
]);
});
it('does not prune bundled rows when the bundled root itself is missing', async () => {
// A missing bundledRoot means a mispackaged image, not "every bundled
// plugin was deliberately removed" — wiping the registry would turn a
// packaging bug into data loss.
const folder = path.join(tmpRoot, 'atoms', 'sample');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('sample'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('sample'));
await registerBundledPlugins({ db, bundledRoot: tmpRoot });
const result = await registerBundledPlugins({
db,
bundledRoot: path.join(tmpRoot, 'does-not-exist'),
});
expect(result.pruned).toEqual([]);
expect(listInstalledPlugins(db).map((r) => r.id)).toEqual(['sample']);
});
});