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
377 lines
16 KiB
TypeScript
377 lines
16 KiB
TypeScript
// Plan §8 Definition of Done — daemon-side e2e suite.
|
|
//
|
|
// Anchors the four §8 e2e cases that don't require Playwright UI flow
|
|
// or the Phase 1.5 headless flag (e2e-3 stays out of scope until then).
|
|
// Each case runs against the real resolver / installer / snapshot
|
|
// modules so the public contract surface (HTTP body shape, SQLite
|
|
// columns) is what the test sees.
|
|
//
|
|
// e2e-2 pure apply across runs — two consecutive applies share digest
|
|
// and produce no FS mutation.
|
|
// e2e-4 replay invariance — replay reproduces the prompt block
|
|
// byte-for-byte after a plugin upgrade.
|
|
// e2e-6 connector trust gate — restricted plugin without
|
|
// connector:<id> fails apply with 409
|
|
// capabilities-required, the matching
|
|
// /api/tools/connectors/execute call
|
|
// is also rejected with 403.
|
|
// e2e-8 apply purity regression — 100 applies grow the snapshot count
|
|
// by 100, leave the project cwd byte
|
|
// size unchanged, and emit no
|
|
// .mcp.json.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { mkdtemp, rm, writeFile, mkdir, readdir, stat, readFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import url from 'node:url';
|
|
import Database from 'better-sqlite3';
|
|
import { migratePlugins } from '../src/plugins/persistence.js';
|
|
import { applyPlugin } from '../src/plugins/apply.js';
|
|
import { installFromLocalFolder } from '../src/plugins/installer.js';
|
|
import { getInstalledPlugin } from '../src/plugins/registry.js';
|
|
import { createSnapshot, getSnapshot } from '../src/plugins/snapshots.js';
|
|
import { resolvePluginSnapshot, capabilitiesRequiredError } from '../src/plugins/resolve-snapshot.js';
|
|
import { renderPluginBlock } from '@open-design/contracts';
|
|
import { checkConnectorAccess, ToolTokenRegistry } from '../src/tool-tokens.js';
|
|
import { FIRST_PARTY_ATOMS, type AtomCatalogEntry } from '../src/plugins/atoms.js';
|
|
|
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'plugin-fixtures', 'sample-plugin');
|
|
|
|
let db: Database.Database;
|
|
let tmpRoot: string;
|
|
let pluginsRoot: string;
|
|
let projectCwd: string;
|
|
|
|
async function freshFixture(targetPath: string, version = '1.0.0') {
|
|
await mkdir(targetPath, { recursive: true });
|
|
const manifest = {
|
|
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
|
name: 'sample-plugin',
|
|
title: 'Sample Plugin',
|
|
version,
|
|
description: 'Phase 1 fixture',
|
|
license: 'MIT',
|
|
od: {
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Make a {{topic}} brief.' },
|
|
inputs: [{ name: 'topic', type: 'string', required: true, label: 'Topic' }],
|
|
capabilities: ['prompt:inject'],
|
|
},
|
|
};
|
|
await writeFile(path.join(targetPath, 'open-design.json'), JSON.stringify(manifest, null, 2));
|
|
await writeFile(
|
|
path.join(targetPath, 'SKILL.md'),
|
|
'---\nname: sample-plugin\ndescription: fixture\n---\n# Sample\n',
|
|
);
|
|
}
|
|
|
|
const REGISTRY_VIEW = {
|
|
skills: [],
|
|
designSystems: [],
|
|
craft: [],
|
|
atoms: FIRST_PARTY_ATOMS.map((a: AtomCatalogEntry) => ({ id: a.id, label: a.label })),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-dod-'));
|
|
pluginsRoot = path.join(tmpRoot, 'plugins');
|
|
projectCwd = path.join(tmpRoot, 'project-cwd');
|
|
await mkdir(projectCwd, { recursive: true });
|
|
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);
|
|
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
db.close();
|
|
await rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function installLocal(source: string) {
|
|
let installed = false;
|
|
let error: string | undefined;
|
|
for await (const ev of installFromLocalFolder(db, {
|
|
source,
|
|
roots: { userPluginsRoot: pluginsRoot },
|
|
})) {
|
|
if (ev.kind === 'success') installed = true;
|
|
if (ev.kind === 'error') error = ev.message;
|
|
}
|
|
if (!installed) throw new Error(`install failed: ${error}`);
|
|
}
|
|
|
|
async function totalSize(dir: string): Promise<number> {
|
|
let total = 0;
|
|
for (const entry of await readdir(dir)) {
|
|
const stats = await stat(path.join(dir, entry));
|
|
if (stats.isFile()) total += stats.size;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
describe('Plan §8 e2e — daemon-side anchors', () => {
|
|
it('e2e-2 pure apply across runs: two applies share digest, no FS mutation', async () => {
|
|
await installLocal(FIXTURE_DIR);
|
|
const plugin = getInstalledPlugin(db, 'sample-plugin')!;
|
|
const beforeSize = await totalSize(projectCwd);
|
|
const a = applyPlugin({
|
|
plugin,
|
|
inputs: { topic: 'design' },
|
|
registry: REGISTRY_VIEW,
|
|
});
|
|
const b = applyPlugin({
|
|
plugin,
|
|
inputs: { topic: 'design' },
|
|
registry: REGISTRY_VIEW,
|
|
});
|
|
expect(a.manifestSourceDigest).toBe(b.manifestSourceDigest);
|
|
expect(a.manifestSourceDigest).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(await totalSize(projectCwd)).toBe(beforeSize);
|
|
// Snapshot rows are not written by applyPlugin itself (purity
|
|
// invariant I2): the resolver / route is the only writer.
|
|
const snapshotCount = (db.prepare(`SELECT COUNT(*) AS n FROM applied_plugin_snapshots`).get() as { n: number }).n;
|
|
expect(snapshotCount).toBe(0);
|
|
});
|
|
|
|
it('e2e-4 replay invariance: snapshot prompt block survives plugin upgrade', async () => {
|
|
// Install v1.0.0, persist a snapshot via the resolver.
|
|
await installLocal(FIXTURE_DIR);
|
|
const plugin = getInstalledPlugin(db, 'sample-plugin')!;
|
|
const a = applyPlugin({ plugin, inputs: { topic: 'design' }, registry: REGISTRY_VIEW });
|
|
const original = createSnapshot(db, {
|
|
projectId: 'project-1',
|
|
conversationId: null,
|
|
runId: null,
|
|
pluginId: a.result.appliedPlugin.pluginId,
|
|
pluginVersion: a.result.appliedPlugin.pluginVersion,
|
|
pluginTitle: a.result.appliedPlugin.pluginTitle,
|
|
pluginDescription: a.result.appliedPlugin.pluginDescription,
|
|
manifestSourceDigest: a.manifestSourceDigest,
|
|
taskKind: a.result.appliedPlugin.taskKind,
|
|
inputs: a.result.appliedPlugin.inputs,
|
|
resolvedContext: a.result.appliedPlugin.resolvedContext,
|
|
pipeline: a.result.appliedPlugin.pipeline,
|
|
genuiSurfaces: a.result.appliedPlugin.genuiSurfaces ?? [],
|
|
capabilitiesGranted: a.result.appliedPlugin.capabilitiesGranted,
|
|
capabilitiesRequired: a.result.appliedPlugin.capabilitiesRequired,
|
|
assetsStaged: a.result.appliedPlugin.assetsStaged,
|
|
connectorsRequired: a.result.appliedPlugin.connectorsRequired,
|
|
connectorsResolved: a.result.appliedPlugin.connectorsResolved,
|
|
mcpServers: a.result.appliedPlugin.mcpServers,
|
|
query: a.result.query,
|
|
});
|
|
const promptBlock1 = renderPluginBlock(original);
|
|
|
|
// Simulate a plugin upgrade by re-installing v2.0.0 with a different
|
|
// body. The plugin row gets the new version, but the persisted
|
|
// snapshot is immutable per spec §8.2.1.
|
|
const upgradedFolder = path.join(tmpRoot, 'sample-plugin-v2');
|
|
await freshFixture(upgradedFolder, '2.0.0');
|
|
// Replace SKILL.md with new copy so the digest would differ.
|
|
await writeFile(
|
|
path.join(upgradedFolder, 'SKILL.md'),
|
|
'---\nname: sample-plugin\ndescription: upgraded\n---\n# Sample v2\nnew body\n',
|
|
);
|
|
await installLocal(upgradedFolder);
|
|
const upgraded = getInstalledPlugin(db, 'sample-plugin')!;
|
|
expect(upgraded.version).toBe('2.0.0');
|
|
const b = applyPlugin({ plugin: upgraded, inputs: { topic: 'design' }, registry: REGISTRY_VIEW });
|
|
// Live digest changed under the upgrade.
|
|
expect(b.manifestSourceDigest).not.toBe(a.manifestSourceDigest);
|
|
|
|
// Replay reads the original snapshot and the prompt block is identical.
|
|
const replayed = getSnapshot(db, original.snapshotId)!;
|
|
const promptBlock2 = renderPluginBlock(replayed);
|
|
expect(promptBlock2).toBe(promptBlock1);
|
|
expect(replayed.manifestSourceDigest).toBe(a.manifestSourceDigest);
|
|
});
|
|
|
|
it('e2e-6 connector trust gate: restricted plugin without connector:<id> blocks apply + tool token', async () => {
|
|
// Build a fixture plugin that requires a slack connector but does
|
|
// not declare the matching capability.
|
|
const folder = path.join(tmpRoot, 'connector-plugin');
|
|
await mkdir(folder, { recursive: true });
|
|
const manifest = {
|
|
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
|
name: 'connector-plugin',
|
|
title: 'Connector Plugin',
|
|
version: '1.0.0',
|
|
description: 'requires slack',
|
|
license: 'MIT',
|
|
od: {
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'List slack channels.' },
|
|
connectors: { required: [{ id: 'slack', tools: ['channels.list'] }] },
|
|
capabilities: ['prompt:inject'],
|
|
},
|
|
};
|
|
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify(manifest));
|
|
await writeFile(
|
|
path.join(folder, 'SKILL.md'),
|
|
'---\nname: connector-plugin\ndescription: requires slack\n---\n# Connector\n',
|
|
);
|
|
// Install as restricted (the GitHub-style default for non-local
|
|
// sources). We simulate that by overriding sourceKind via the
|
|
// already-installed registry record.
|
|
await installLocal(folder);
|
|
db.prepare('UPDATE installed_plugins SET trust = ? WHERE id = ?')
|
|
.run('restricted', 'connector-plugin');
|
|
const plugin = getInstalledPlugin(db, 'connector-plugin')!;
|
|
|
|
// Apply via the resolver — restricted + missing capability →
|
|
// capabilities-required envelope.
|
|
const result = resolvePluginSnapshot({
|
|
db,
|
|
body: { pluginId: 'connector-plugin' },
|
|
projectId: 'project-1',
|
|
registry: REGISTRY_VIEW,
|
|
});
|
|
expect(result).toBeDefined();
|
|
expect(result?.ok).toBe(false);
|
|
if (result && !result.ok) {
|
|
expect(result.status).toBe(409);
|
|
expect(result.exitCode).toBe(66);
|
|
expect(result.body.error.code).toBe('capabilities-required');
|
|
const required = result.body.error.data?.required as string[];
|
|
expect(required).toContain('connector:slack');
|
|
}
|
|
|
|
// The token-issuance gate refuses the same call independently.
|
|
const registry = new ToolTokenRegistry();
|
|
const grant = registry.mint({
|
|
runId: 'run-1',
|
|
projectId: 'project-1',
|
|
pluginSnapshotId: 'fake-snap',
|
|
pluginTrust: 'restricted',
|
|
pluginCapabilitiesGranted: ['prompt:inject'],
|
|
});
|
|
const gateResult = checkConnectorAccess(grant, 'slack');
|
|
expect(gateResult.ok).toBe(false);
|
|
expect(plugin.trust).toBe('restricted');
|
|
});
|
|
|
|
it('e2e-8 apply purity regression: 100 applies → 0 FS mutation, snapshot count grows by 100', async () => {
|
|
await installLocal(FIXTURE_DIR);
|
|
const plugin = getInstalledPlugin(db, 'sample-plugin')!;
|
|
const beforeSize = await totalSize(projectCwd);
|
|
const before = (db.prepare(`SELECT COUNT(*) AS n FROM applied_plugin_snapshots`).get() as { n: number }).n;
|
|
for (let i = 0; i < 100; i++) {
|
|
const a = applyPlugin({
|
|
plugin,
|
|
inputs: { topic: `topic-${i}` },
|
|
registry: REGISTRY_VIEW,
|
|
});
|
|
// Persist via resolver.finalizeOk path (linkSnapshotToProject etc.)
|
|
// not exercised here — we just call createSnapshot to assert the
|
|
// count grows. apply itself is pure.
|
|
createSnapshot(db, {
|
|
projectId: 'project-1',
|
|
conversationId: null,
|
|
runId: null,
|
|
pluginId: a.result.appliedPlugin.pluginId,
|
|
pluginVersion: a.result.appliedPlugin.pluginVersion,
|
|
manifestSourceDigest: a.manifestSourceDigest,
|
|
taskKind: a.result.appliedPlugin.taskKind,
|
|
inputs: a.result.appliedPlugin.inputs,
|
|
resolvedContext: a.result.appliedPlugin.resolvedContext,
|
|
capabilitiesGranted: a.result.appliedPlugin.capabilitiesGranted,
|
|
capabilitiesRequired: a.result.appliedPlugin.capabilitiesRequired,
|
|
assetsStaged: a.result.appliedPlugin.assetsStaged,
|
|
connectorsRequired: a.result.appliedPlugin.connectorsRequired,
|
|
connectorsResolved: a.result.appliedPlugin.connectorsResolved,
|
|
mcpServers: a.result.appliedPlugin.mcpServers,
|
|
query: a.result.query,
|
|
genuiSurfaces: [],
|
|
});
|
|
}
|
|
const after = (db.prepare(`SELECT COUNT(*) AS n FROM applied_plugin_snapshots`).get() as { n: number }).n;
|
|
expect(after - before).toBe(100);
|
|
expect(await totalSize(projectCwd)).toBe(beforeSize);
|
|
// No .mcp.json was written into the cwd — apply is pure.
|
|
const cwdEntries = await readdir(projectCwd);
|
|
expect(cwdEntries.some((e) => e === '.mcp.json')).toBe(false);
|
|
});
|
|
|
|
it('reuses the project-pinned snapshot when /api/runs body has no pluginId or snapshotId', async () => {
|
|
// Setup: install sample-plugin, create a project row, run the
|
|
// resolver with a body that names pluginId+inputs (project-create
|
|
// path). The resolver pins the snapshot via linkSnapshotToProject.
|
|
await installLocal(FIXTURE_DIR);
|
|
db.prepare(`INSERT INTO projects (id, name) VALUES (?, ?)`).run(
|
|
'project-pinned',
|
|
'pinned project',
|
|
);
|
|
|
|
const created = resolvePluginSnapshot({
|
|
db,
|
|
body: { pluginId: 'sample-plugin', pluginInputs: { topic: 'demo' } },
|
|
projectId: 'project-pinned',
|
|
registry: REGISTRY_VIEW,
|
|
});
|
|
expect(created?.ok).toBe(true);
|
|
const pinnedSnapshotId = created!.ok ? created!.snapshotId : '';
|
|
expect(pinnedSnapshotId.length).toBeGreaterThan(0);
|
|
|
|
// The project row now carries the snapshot id.
|
|
const projectRow = db
|
|
.prepare(`SELECT applied_plugin_snapshot_id AS id FROM projects WHERE id = ?`)
|
|
.get('project-pinned') as { id: string };
|
|
expect(projectRow.id).toBe(pinnedSnapshotId);
|
|
|
|
// Now simulate the run-create call: body has only `projectId`. The
|
|
// resolver should fall back to the project-pinned snapshot id and
|
|
// return ok with the same snapshotId — no missing-input gate fires.
|
|
const reused = resolvePluginSnapshot({
|
|
db,
|
|
body: { projectId: 'project-pinned' },
|
|
projectId: 'project-pinned',
|
|
registry: REGISTRY_VIEW,
|
|
});
|
|
expect(reused?.ok).toBe(true);
|
|
if (reused?.ok) {
|
|
expect(reused.snapshotId).toBe(pinnedSnapshotId);
|
|
expect(reused.created).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('accepts legacy inputs alias when resolving plugin snapshots', async () => {
|
|
await installLocal(FIXTURE_DIR);
|
|
|
|
const result = resolvePluginSnapshot({
|
|
db,
|
|
body: { pluginId: 'sample-plugin', inputs: { topic: 'demo' } },
|
|
projectId: 'project-1',
|
|
registry: REGISTRY_VIEW,
|
|
});
|
|
|
|
expect(result?.ok).toBe(true);
|
|
if (result?.ok) {
|
|
expect(result.snapshot.inputs).toMatchObject({ topic: 'demo' });
|
|
}
|
|
});
|
|
|
|
it('capabilitiesRequiredError envelope shape stays stable for code agents', () => {
|
|
const err = capabilitiesRequiredError({
|
|
pluginId: 'sample',
|
|
pluginVersion: '1.0.0',
|
|
required: ['prompt:inject', 'connector:slack'],
|
|
granted: ['prompt:inject'],
|
|
missing: ['connector:slack'],
|
|
});
|
|
expect(err.status).toBe(409);
|
|
expect(err.exitCode).toBe(66);
|
|
expect(err.body.error.code).toBe('capabilities-required');
|
|
const remediation = err.body.error.data?.remediation as string[];
|
|
expect(remediation.join('\n')).toMatch(/connector:slack/);
|
|
void readFile; // silence import-not-used in some toolings
|
|
});
|
|
});
|