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
875 lines
34 KiB
TypeScript
875 lines
34 KiB
TypeScript
// Plan §3.F4 / spec §8 e2e-3 anchor.
|
|
//
|
|
// Verifies the headless `od plugin install → project create → run start`
|
|
// loop end-to-end at the HTTP layer (the same paths the CLI subcommands
|
|
// from §3.F1 / §3.F2 hit). Without an actual agent backend we can't
|
|
// assert "first ND-JSON event has kind='pipeline_stage_started'" — that
|
|
// requires the run-time pipeline runner being wired into the live agent
|
|
// loop. What we can lock today:
|
|
//
|
|
// 1. POST /api/plugins/install (local fixture) succeeds.
|
|
// 2. POST /api/projects { pluginId, pluginInputs } → 200 +
|
|
// appliedPluginSnapshotId pinned to the new project.
|
|
// 3. POST /api/runs { projectId, pluginId, pluginInputs } → 202 +
|
|
// runId.
|
|
// 4. GET /api/runs/:id surfaces appliedPluginSnapshotId on the run
|
|
// status body so a code agent that polled status (rather than
|
|
// streaming events) can still reach the snapshot id.
|
|
// 5. POST /api/applied-plugins/:id is fetchable and returns the same
|
|
// snapshot a replay would re-launch against.
|
|
//
|
|
// Once the pipeline runner is wired into startChatRun (deferred to the
|
|
// Phase 1 follow-up that lands a fully-driven agent loop), this test
|
|
// gets extended to assert the first SSE event is `pipeline_stage_started`.
|
|
|
|
import type http from 'node:http';
|
|
import { execFile } from 'node:child_process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import path from 'node:path';
|
|
import url from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
import { startServer } from '../src/server.js';
|
|
|
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '../../..');
|
|
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'plugin-fixtures', 'sample-plugin');
|
|
const CLI_SRC = path.join(__dirname, '../src/cli.ts');
|
|
const TSX_CLI = path.join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
const execFileP = promisify(execFile);
|
|
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
let shutdown: (() => Promise<void> | void) | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
|
url: string;
|
|
server: http.Server;
|
|
shutdown?: () => Promise<void> | void;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
shutdown = started.shutdown;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await Promise.resolve(shutdown?.());
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
async function withFakeAgent<T>(
|
|
binName: string,
|
|
script: string,
|
|
run: () => Promise<T>,
|
|
): Promise<T> {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'od-headless-agent-bin-'));
|
|
const oldPath = process.env.PATH;
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
const runner = path.join(dir, `${binName}-runner.cjs`);
|
|
await writeFile(runner, script);
|
|
await writeFile(
|
|
path.join(dir, `${binName}.cmd`),
|
|
`@echo off\r\nnode "${runner}" %*\r\n`,
|
|
);
|
|
} else {
|
|
const bin = path.join(dir, binName);
|
|
await writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
|
await chmod(bin, 0o755);
|
|
}
|
|
process.env.PATH = `${dir}${path.delimiter}${oldPath ?? ''}`;
|
|
return await run();
|
|
} finally {
|
|
if (oldPath === undefined) {
|
|
delete process.env.PATH;
|
|
} else {
|
|
process.env.PATH = oldPath;
|
|
}
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function runCli(
|
|
args: string[],
|
|
options: { timeout?: number } = {},
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
OD_DAEMON_URL: baseUrl,
|
|
};
|
|
delete env.NODE_OPTIONS;
|
|
return await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env,
|
|
timeout: options.timeout ?? 20_000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
}) as { stdout: string; stderr: string };
|
|
}
|
|
|
|
async function runCliResult(
|
|
args: string[],
|
|
options: { timeout?: number } = {},
|
|
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
try {
|
|
const { stdout, stderr } = await runCli(args, options);
|
|
return { stdout, stderr, code: 0 };
|
|
} catch (err) {
|
|
const failed = err as { stdout?: string; stderr?: string; code?: number | null };
|
|
return {
|
|
stdout: failed.stdout ?? '',
|
|
stderr: failed.stderr ?? '',
|
|
code: failed.code ?? 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function readSseUntilSuccess(resp: Response) {
|
|
if (!resp.body) throw new Error('install: no body');
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const blocks = buffer.split('\n\n');
|
|
buffer = blocks.pop() ?? '';
|
|
for (const block of blocks) {
|
|
const eventLine = block.split('\n').find((l) => l.startsWith('event: '));
|
|
const dataLine = block.split('\n').find((l) => l.startsWith('data: '));
|
|
const event = eventLine ? eventLine.slice('event: '.length) : '';
|
|
const data = dataLine ? JSON.parse(dataLine.slice('data: '.length)) : null;
|
|
if (event === 'success') return data;
|
|
if (event === 'error') throw new Error(data?.message ?? 'install failed');
|
|
}
|
|
}
|
|
throw new Error('install stream ended without success');
|
|
}
|
|
|
|
describe('Plan §8 e2e-3 (entry slice) — headless install → project → run', () => {
|
|
it('duplicates a bundled HTML example into a project without starting a run', async () => {
|
|
const listResp = await fetch(`${baseUrl}/api/plugins`);
|
|
expect(listResp.status).toBe(200);
|
|
const listBody = (await listResp.json()) as {
|
|
plugins?: Array<{
|
|
id: string;
|
|
title?: string;
|
|
manifest?: { name?: string; od?: { preview?: { entry?: string } } };
|
|
}>;
|
|
};
|
|
const plugin = (listBody.plugins ?? []).find((record) =>
|
|
record.id === 'example-mobile-app' ||
|
|
record.manifest?.name === 'example-mobile-app' ||
|
|
record.title === 'Mobile App',
|
|
);
|
|
expect(plugin).toBeTruthy();
|
|
|
|
const duplicateResp = await fetch(
|
|
`${baseUrl}/api/plugins/${encodeURIComponent(plugin!.id)}/duplicate-project`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Duplicated mobile app' }),
|
|
},
|
|
);
|
|
expect(duplicateResp.status).toBe(201);
|
|
const duplicateBody = (await duplicateResp.json()) as {
|
|
ok: true;
|
|
projectId: string;
|
|
conversationId: string;
|
|
relPath: string;
|
|
sourcePluginId: string;
|
|
sourceEntry: string;
|
|
copiedFiles: number;
|
|
project: {
|
|
id: string;
|
|
name: string;
|
|
pendingPrompt?: string | null;
|
|
metadata?: {
|
|
kind?: string;
|
|
duplicatedFromPluginId?: string;
|
|
duplicatedFromPluginEntry?: string;
|
|
entryFile?: string;
|
|
};
|
|
};
|
|
};
|
|
expect(duplicateBody.ok).toBe(true);
|
|
expect(duplicateBody.projectId).toBeTruthy();
|
|
expect(duplicateBody.conversationId).toBeTruthy();
|
|
expect(duplicateBody.relPath).toBe('index.html');
|
|
expect(duplicateBody.sourcePluginId).toBe(plugin!.id);
|
|
expect(duplicateBody.sourceEntry).toMatch(/example\.html$/);
|
|
expect(duplicateBody.copiedFiles).toBeGreaterThan(0);
|
|
expect(duplicateBody.project.name).toBe('Duplicated mobile app');
|
|
expect(duplicateBody.project.pendingPrompt ?? null).toBeNull();
|
|
expect(duplicateBody.project.metadata).toMatchObject({
|
|
kind: 'prototype',
|
|
duplicatedFromPluginId: plugin!.id,
|
|
duplicatedFromPluginEntry: duplicateBody.sourceEntry,
|
|
entryFile: 'index.html',
|
|
});
|
|
|
|
const filesResp = await fetch(
|
|
`${baseUrl}/api/projects/${encodeURIComponent(duplicateBody.projectId)}/files`,
|
|
);
|
|
expect(filesResp.status).toBe(200);
|
|
const filesBody = (await filesResp.json()) as { files: Array<{ name: string }> };
|
|
const fileNames = filesBody.files.map((file) => file.name).sort();
|
|
expect(fileNames).toContain('index.html');
|
|
expect(fileNames).toContain('assets/template.html');
|
|
|
|
const runsResp = await fetch(
|
|
`${baseUrl}/api/runs?projectId=${encodeURIComponent(duplicateBody.projectId)}`,
|
|
);
|
|
expect(runsResp.status).toBe(200);
|
|
const runsBody = (await runsResp.json()) as { runs?: unknown[] };
|
|
expect(runsBody.runs ?? []).toHaveLength(0);
|
|
|
|
await fetch(`${baseUrl}/api/projects/${encodeURIComponent(duplicateBody.projectId)}`, {
|
|
method: 'DELETE',
|
|
}).catch(() => {});
|
|
});
|
|
|
|
it('rejects bundled examples that reference local files outside the duplicated directory', async () => {
|
|
const listResp = await fetch(`${baseUrl}/api/plugins`);
|
|
expect(listResp.status).toBe(200);
|
|
const listBody = (await listResp.json()) as {
|
|
plugins?: Array<{
|
|
id: string;
|
|
manifest?: { name?: string };
|
|
}>;
|
|
};
|
|
const plugin = (listBody.plugins ?? []).find((record) =>
|
|
record.id === 'example-open-design-landing-deck' ||
|
|
record.manifest?.name === 'example-open-design-landing-deck',
|
|
);
|
|
expect(plugin).toBeTruthy();
|
|
|
|
const duplicateResp = await fetch(
|
|
`${baseUrl}/api/plugins/${encodeURIComponent(plugin!.id)}/duplicate-project`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Broken duplicate should fail' }),
|
|
},
|
|
);
|
|
|
|
expect(duplicateResp.status).toBe(422);
|
|
const body = (await duplicateResp.json()) as {
|
|
error?: { code?: string; message?: string };
|
|
};
|
|
expect(body.error).toMatchObject({
|
|
code: 'UNSUPPORTED_DUPLICATE_DEPENDENCIES',
|
|
});
|
|
expect(body.error?.message).toContain('../open-design-landing/assets/hero.png');
|
|
});
|
|
|
|
it('surfaces duplicate daemon errors through CLI structured stderr', async () => {
|
|
const result = await runCliResult([
|
|
'plugin',
|
|
'duplicate',
|
|
'example-open-design-landing-deck',
|
|
'--json',
|
|
]);
|
|
|
|
expect(result.code).toBe(1);
|
|
expect(result.stdout).toBe('');
|
|
const body = JSON.parse(result.stderr.trim()) as {
|
|
error?: { code?: string; message?: string };
|
|
};
|
|
expect(body.error?.code).toBe('UNSUPPORTED_DUPLICATE_DEPENDENCIES');
|
|
expect(body.error?.message).toContain('../open-design-landing/assets/hero.png');
|
|
});
|
|
|
|
it('walks install → project create → run start → status with snapshot pinned', async () => {
|
|
// 1. Install a local fixture plugin via the SSE install endpoint.
|
|
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: FIXTURE_DIR }),
|
|
});
|
|
expect(installResp.status).toBe(200);
|
|
const installSuccess = await readSseUntilSuccess(installResp);
|
|
expect(installSuccess?.plugin?.id).toBe('sample-plugin');
|
|
|
|
// 2. Create a project bound to the plugin.
|
|
const projectId = `headless-${Date.now()}`;
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Headless e2e-3',
|
|
pluginId: 'sample-plugin',
|
|
pluginInputs: { topic: 'agentic design' },
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
const createBody = (await createResp.json()) as {
|
|
project: { id: string };
|
|
conversationId: string;
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(createBody.project.id).toBe(projectId);
|
|
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
// 3. Start a run that re-uses the same applied snapshot id.
|
|
const runResp = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
projectId,
|
|
pluginId: 'sample-plugin',
|
|
appliedPluginSnapshotId: createBody.appliedPluginSnapshotId,
|
|
pluginInputs: { topic: 'agentic design' },
|
|
}),
|
|
});
|
|
expect(runResp.status).toBe(202);
|
|
const runBody = (await runResp.json()) as {
|
|
runId: string;
|
|
pluginId?: string;
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(runBody.runId).toBeTruthy();
|
|
expect(runBody.pluginId).toBe('sample-plugin');
|
|
expect(runBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId);
|
|
|
|
// 4. The run status surfaces the snapshot id so a polling client
|
|
// can reach replay without parsing the SSE stream.
|
|
const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`);
|
|
expect(statusResp.status).toBe(200);
|
|
const statusBody = (await statusResp.json()) as {
|
|
id: string;
|
|
projectId: string;
|
|
pluginId: string | null;
|
|
appliedPluginSnapshotId: string | null;
|
|
};
|
|
expect(statusBody.pluginId).toBe('sample-plugin');
|
|
expect(statusBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId);
|
|
|
|
// 5. Replay reads the same snapshot row.
|
|
const snapResp = await fetch(`${baseUrl}/api/applied-plugins/${encodeURIComponent(createBody.appliedPluginSnapshotId!)}`);
|
|
expect(snapResp.status).toBe(200);
|
|
const snap = (await snapResp.json()) as {
|
|
snapshotId: string;
|
|
pluginId: string;
|
|
query?: string;
|
|
inputs?: Record<string, string | number | boolean>;
|
|
};
|
|
expect(snap.snapshotId).toBe(createBody.appliedPluginSnapshotId);
|
|
expect(snap.pluginId).toBe('sample-plugin');
|
|
expect(snap.query).toBe('Generate a {{topic}} brief for {{audience}}.');
|
|
expect(snap.inputs).toEqual({ audience: 'general', topic: 'agentic design' });
|
|
|
|
// Cancel the run so the test cleans up the in-memory child path.
|
|
await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' });
|
|
});
|
|
|
|
it('creates share projects for publishing and contributing a user plugin', async () => {
|
|
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: FIXTURE_DIR }),
|
|
});
|
|
expect(installResp.status).toBe(200);
|
|
const installSuccess = await readSseUntilSuccess(installResp);
|
|
expect(installSuccess?.plugin?.id).toBe('sample-plugin');
|
|
|
|
const shareResp = await fetch(`${baseUrl}/api/plugins/sample-plugin/share-project`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ action: 'publish-github', locale: 'en' }),
|
|
});
|
|
expect(shareResp.status).toBe(200);
|
|
const shareBody = (await shareResp.json()) as {
|
|
ok: boolean;
|
|
project: { id: string; pendingPrompt?: string };
|
|
conversationId: string;
|
|
appliedPluginSnapshotId?: string;
|
|
actionPluginId: string;
|
|
sourcePluginId: string;
|
|
stagedPath: string;
|
|
prompt: string;
|
|
};
|
|
expect(shareBody.ok).toBe(true);
|
|
expect(shareBody.actionPluginId).toBe('od-plugin-publish-github');
|
|
expect(shareBody.sourcePluginId).toBe('sample-plugin');
|
|
expect(shareBody.appliedPluginSnapshotId).toBeTruthy();
|
|
expect(shareBody.stagedPath).toBe('plugin-source/sample-plugin');
|
|
expect(shareBody.prompt).toContain('Publish the local Open Design plugin');
|
|
expect(shareBody.prompt).toContain('/api/projects/$OD_PROJECT_ID/plugins/publish-github');
|
|
expect(shareBody.prompt).toContain('plugin-source/sample-plugin');
|
|
expect(shareBody.project.pendingPrompt).toBe(shareBody.prompt);
|
|
|
|
const filesResp = await fetch(
|
|
`${baseUrl}/api/projects/${encodeURIComponent(shareBody.project.id)}/files`,
|
|
);
|
|
expect(filesResp.status).toBe(200);
|
|
const filesBody = (await filesResp.json()) as { files: Array<{ name: string }> };
|
|
const fileNames = filesBody.files.map((file) => file.name).sort();
|
|
expect(fileNames).toContain('plugin-source/sample-plugin/open-design.json');
|
|
expect(fileNames).toContain('plugin-source/sample-plugin/SKILL.md');
|
|
|
|
const snapshotResp = await fetch(
|
|
`${baseUrl}/api/applied-plugins/${encodeURIComponent(shareBody.appliedPluginSnapshotId!)}`,
|
|
);
|
|
expect(snapshotResp.status).toBe(200);
|
|
const snapshot = (await snapshotResp.json()) as {
|
|
pluginId: string;
|
|
inputs?: Record<string, string | number | boolean>;
|
|
};
|
|
expect(snapshot.pluginId).toBe('od-plugin-publish-github');
|
|
expect(snapshot.inputs).toMatchObject({
|
|
source_plugin_id: 'sample-plugin',
|
|
plugin_context_path: 'plugin-source/sample-plugin',
|
|
});
|
|
|
|
const contributeResp = await fetch(`${baseUrl}/api/plugins/sample-plugin/share-project`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ action: 'contribute-open-design', locale: 'en' }),
|
|
});
|
|
expect(contributeResp.status).toBe(200);
|
|
const contributeBody = (await contributeResp.json()) as {
|
|
ok: boolean;
|
|
project: { id: string };
|
|
appliedPluginSnapshotId?: string;
|
|
actionPluginId: string;
|
|
sourcePluginId: string;
|
|
stagedPath: string;
|
|
prompt: string;
|
|
};
|
|
expect(contributeBody.ok).toBe(true);
|
|
expect(contributeBody.actionPluginId).toBe('od-plugin-contribute-open-design');
|
|
expect(contributeBody.sourcePluginId).toBe('sample-plugin');
|
|
expect(contributeBody.appliedPluginSnapshotId).toBeTruthy();
|
|
expect(contributeBody.stagedPath).toBe('plugin-source/sample-plugin');
|
|
expect(contributeBody.prompt).toContain('/api/projects/$OD_PROJECT_ID/plugins/contribute-open-design');
|
|
|
|
const locator = process.platform === 'win32' ? 'where' : 'which';
|
|
const realGit = ((await execFileP(locator, ['git'])).stdout as string)
|
|
.split(/\r?\n/)
|
|
.find(Boolean)
|
|
?.trim();
|
|
expect(realGit).toBeTruthy();
|
|
const previousRealGit = process.env.OD_REAL_GIT;
|
|
const previousGitAuthorName = process.env.GIT_AUTHOR_NAME;
|
|
const previousGitAuthorEmail = process.env.GIT_AUTHOR_EMAIL;
|
|
const previousGitCommitterName = process.env.GIT_COMMITTER_NAME;
|
|
const previousGitCommitterEmail = process.env.GIT_COMMITTER_EMAIL;
|
|
process.env.OD_REAL_GIT = realGit;
|
|
process.env.GIT_AUTHOR_NAME = 'Open Design Test';
|
|
process.env.GIT_AUTHOR_EMAIL = 'open-design-test@example.com';
|
|
process.env.GIT_COMMITTER_NAME = 'Open Design Test';
|
|
process.env.GIT_COMMITTER_EMAIL = 'open-design-test@example.com';
|
|
try {
|
|
await withFakeAgent(
|
|
'gh',
|
|
`
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const args = process.argv.slice(2);
|
|
function ok(text) {
|
|
if (text) process.stdout.write(text + '\\n');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === '--version') ok('gh version 2.0.0');
|
|
if (args[0] === 'auth' && args[1] === 'status') ok('Logged in to github.com account test-user');
|
|
if (args[0] === 'api' && args[1] === 'user') ok('test-user');
|
|
if (args[0] === 'repo' && args[1] === 'create') ok('https://github.com/test-user/' + args[2]);
|
|
if (args[0] === 'repo' && args[1] === 'view') {
|
|
const repo = args[2] || '';
|
|
if (!args.includes('--json') && (repo === 'test-user/sample-plugin' || repo.endsWith('/sample-plugin'))) {
|
|
console.error('GraphQL: Could not resolve to a Repository with the name "' + repo + '".');
|
|
process.exit(1);
|
|
}
|
|
if (args.includes('--json')) {
|
|
const fullName = repo || 'test-user/sample-plugin';
|
|
ok(JSON.stringify({ nameWithOwner: fullName, url: 'https://github.com/' + fullName }));
|
|
}
|
|
ok('https://github.com/test-user/' + path.basename(process.cwd()));
|
|
}
|
|
if (args[0] === 'repo' && args[1] === 'fork') ok('forked nexu-io/open-design');
|
|
if (args[0] === 'repo' && args[1] === 'clone') {
|
|
const dest = args[3] || path.basename(args[2]);
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const init = spawnSync(process.env.OD_REAL_GIT, ['init'], { cwd: dest, stdio: 'inherit' });
|
|
process.exit(init.status ?? 0);
|
|
}
|
|
if (args[0] === 'pr' && args[1] === 'create') ok('https://github.com/nexu-io/open-design/pull/123');
|
|
console.error('unexpected gh command: ' + args.join(' '));
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
await withFakeAgent(
|
|
'git',
|
|
`
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === 'push') {
|
|
console.log('pushed');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'clone') {
|
|
const dest = args[args.length - 1];
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const init = spawnSync(process.env.OD_REAL_GIT, ['init', '-b', 'main'], { cwd: dest, encoding: 'utf8' });
|
|
if (init.status !== 0) {
|
|
if (init.stderr) process.stderr.write(init.stderr);
|
|
process.exit(init.status ?? 1);
|
|
}
|
|
const remote = args.find((arg) => String(arg).startsWith('https://')) || 'https://github.com/test-user/open-design.git';
|
|
const remoteAdd = spawnSync(process.env.OD_REAL_GIT, ['remote', 'add', 'origin', remote], { cwd: dest, encoding: 'utf8' });
|
|
if (remoteAdd.status !== 0) {
|
|
if (remoteAdd.stderr) process.stderr.write(remoteAdd.stderr);
|
|
process.exit(remoteAdd.status ?? 1);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
const result = spawnSync(process.env.OD_REAL_GIT, args, {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
encoding: 'utf8',
|
|
});
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
process.exit(result.status ?? 0);
|
|
`,
|
|
async () => {
|
|
const publishEndpointResp = await fetch(
|
|
`${baseUrl}/api/projects/${encodeURIComponent(shareBody.project.id)}/plugins/publish-github`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ path: shareBody.stagedPath }),
|
|
},
|
|
);
|
|
expect(publishEndpointResp.status).toBe(200);
|
|
const publishEndpointBody = (await publishEndpointResp.json()) as {
|
|
ok: boolean;
|
|
url?: string;
|
|
};
|
|
expect(publishEndpointBody.ok).toBe(true);
|
|
expect(publishEndpointBody.url).toBe('https://github.com/test-user/sample-plugin');
|
|
|
|
const contributeEndpointResp = await fetch(
|
|
`${baseUrl}/api/projects/${encodeURIComponent(contributeBody.project.id)}/plugins/contribute-open-design`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ path: contributeBody.stagedPath }),
|
|
},
|
|
);
|
|
expect(contributeEndpointResp.status).toBe(200);
|
|
const contributeEndpointBody = (await contributeEndpointResp.json()) as {
|
|
ok: boolean;
|
|
url?: string;
|
|
};
|
|
expect(contributeEndpointBody.ok).toBe(true);
|
|
expect(contributeEndpointBody.url).toBe('https://github.com/nexu-io/open-design/pull/123');
|
|
},
|
|
);
|
|
},
|
|
);
|
|
} finally {
|
|
if (previousRealGit === undefined) {
|
|
delete process.env.OD_REAL_GIT;
|
|
} else {
|
|
process.env.OD_REAL_GIT = previousRealGit;
|
|
}
|
|
if (previousGitAuthorName === undefined) {
|
|
delete process.env.GIT_AUTHOR_NAME;
|
|
} else {
|
|
process.env.GIT_AUTHOR_NAME = previousGitAuthorName;
|
|
}
|
|
if (previousGitAuthorEmail === undefined) {
|
|
delete process.env.GIT_AUTHOR_EMAIL;
|
|
} else {
|
|
process.env.GIT_AUTHOR_EMAIL = previousGitAuthorEmail;
|
|
}
|
|
if (previousGitCommitterName === undefined) {
|
|
delete process.env.GIT_COMMITTER_NAME;
|
|
} else {
|
|
process.env.GIT_COMMITTER_NAME = previousGitCommitterName;
|
|
}
|
|
if (previousGitCommitterEmail === undefined) {
|
|
delete process.env.GIT_COMMITTER_EMAIL;
|
|
} else {
|
|
process.env.GIT_COMMITTER_EMAIL = previousGitCommitterEmail;
|
|
}
|
|
}
|
|
}, 120_000);
|
|
|
|
it('runs the CLI install → project create → plugin run path with query and local SKILL.md in the agent prompt', async () => {
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'od-headless-cli-plugin-'));
|
|
const pluginId = `headless-cli-plugin-${randomUUID().slice(0, 8)}`;
|
|
const fixture = path.join(pluginRoot, pluginId);
|
|
await mkdir(fixture, { recursive: true });
|
|
await writeFile(
|
|
path.join(fixture, 'open-design.json'),
|
|
JSON.stringify({
|
|
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
|
name: pluginId,
|
|
title: 'Headless CLI Plugin',
|
|
version: '1.0.0',
|
|
description: 'Fixture that binds a local SKILL.md for headless CLI tests.',
|
|
license: 'MIT',
|
|
od: {
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Generate a {{topic}} brief for {{audience}}.' },
|
|
context: {
|
|
skills: [{ path: './SKILL.md' }],
|
|
atoms: ['todo-write', 'discovery-question-form'],
|
|
},
|
|
inputs: [
|
|
{ name: 'topic', type: 'string', required: true, label: 'Topic' },
|
|
{ name: 'audience', type: 'string', default: 'general', label: 'Audience' },
|
|
],
|
|
capabilities: ['prompt:inject'],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
await writeFile(
|
|
path.join(fixture, 'SKILL.md'),
|
|
[
|
|
'---',
|
|
`name: ${pluginId}`,
|
|
'description: Local skill loaded by the headless CLI e2e test.',
|
|
'---',
|
|
'# Headless Local Skill',
|
|
'',
|
|
'Follow this local skill during headless runs.',
|
|
].join('\n'),
|
|
);
|
|
|
|
try {
|
|
const install = await runCli(['plugin', 'install', fixture]);
|
|
expect(install.stdout).toContain('[install] ok');
|
|
|
|
const topic = `headless cli ${randomUUID().slice(0, 8)}`;
|
|
const created = await runCli([
|
|
'project',
|
|
'create',
|
|
'--name',
|
|
'CLI headless plugin run',
|
|
'--plugin',
|
|
pluginId,
|
|
'--inputs',
|
|
JSON.stringify({ topic }),
|
|
'--json',
|
|
]);
|
|
const createBody = JSON.parse(created.stdout) as {
|
|
project: { id: string };
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
const captureRoot = await mkdtemp(path.join(tmpdir(), 'od-headless-cli-capture-'));
|
|
const capturePath = path.join(captureRoot, 'prompt.txt');
|
|
const previousCapture = process.env.OD_PROMPT_CAPTURE;
|
|
process.env.OD_PROMPT_CAPTURE = capturePath;
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
let input = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
process.stdin.on('end', () => {
|
|
const capturePath = process.env.OD_PROMPT_CAPTURE;
|
|
fs.appendFileSync(capturePath + '.all', '--- prompt ---\\n' + input + '\\n');
|
|
if (input.includes('# Headless Local Skill') || input.includes('## Active plugin')) {
|
|
fs.writeFileSync(capturePath, input);
|
|
}
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'headless-ok' } }));
|
|
});
|
|
`,
|
|
async () => {
|
|
const run = await runCli([
|
|
'plugin',
|
|
'run',
|
|
pluginId,
|
|
'--project',
|
|
createBody.project.id,
|
|
'--inputs',
|
|
JSON.stringify({ topic }),
|
|
'--agent',
|
|
'opencode',
|
|
'--follow',
|
|
], { timeout: 60_000 });
|
|
expect(run.stdout).toContain('[run] started run');
|
|
expect(run.stdout).toContain('"event":"agent"');
|
|
expect(run.stdout).toContain('headless-ok');
|
|
expect(run.stdout).toContain('"event":"end"');
|
|
expect(run.stdout).toContain('"status":"succeeded"');
|
|
},
|
|
);
|
|
|
|
const prompt = await readFile(capturePath, 'utf8');
|
|
expect(prompt).toContain('# Headless Local Skill');
|
|
expect(prompt).toContain('Follow this local skill during headless runs.');
|
|
expect(prompt).toContain('## Active plugin');
|
|
expect(prompt).toContain('The plugin\'s example brief is: _Generate a {{topic}} brief for {{audience}}._');
|
|
expect(prompt).toContain(`- **topic**: ${topic}`);
|
|
expect(prompt).toContain('- **audience**: general');
|
|
expect(prompt).toContain(`# User request\n\nGenerate a ${topic} brief for general.`);
|
|
} finally {
|
|
if (previousCapture === undefined) {
|
|
delete process.env.OD_PROMPT_CAPTURE;
|
|
} else {
|
|
process.env.OD_PROMPT_CAPTURE = previousCapture;
|
|
}
|
|
await rm(captureRoot, { recursive: true, force: true });
|
|
}
|
|
} finally {
|
|
await rm(pluginRoot, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
// Full §8 e2e-3 contract — once the pipeline runner fires on a run
|
|
// with a declared pipeline, the first ND-JSON event should be
|
|
// `pipeline_stage_started`. Plan §3.I1 wires firePipelineForRun into
|
|
// POST /api/runs so any plugin run with `od.pipeline.stages[*]`
|
|
// emits the stage timeline before the agent's message_chunk stream.
|
|
it('first SSE event on a plugin run with od.pipeline is pipeline_stage_started', async () => {
|
|
// Install a fixture plugin with a 2-stage pipeline. We use a
|
|
// disposable manifest rather than the on-disk fixture so the
|
|
// pipeline shape is locked here.
|
|
const fs = await import('node:fs/promises');
|
|
const os = await import('node:os');
|
|
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'od-headless-pipeline-'));
|
|
const fixture = path.join(tmpRoot, 'pipeline-plugin');
|
|
await fs.mkdir(fixture, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(fixture, 'open-design.json'),
|
|
JSON.stringify({
|
|
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
|
name: 'pipeline-plugin',
|
|
title: 'Pipeline Plugin',
|
|
version: '1.0.0',
|
|
description: 'fixture with a declared pipeline',
|
|
license: 'MIT',
|
|
od: {
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Make a {{topic}} brief.' },
|
|
inputs: [{ name: 'topic', type: 'string', required: true, label: 'Topic' }],
|
|
pipeline: {
|
|
stages: [
|
|
{ id: 'discovery', atoms: ['discovery-question-form'] },
|
|
{ id: 'plan', atoms: ['todo-write'] },
|
|
],
|
|
},
|
|
capabilities: ['prompt:inject'],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(fixture, 'SKILL.md'),
|
|
'---\nname: pipeline-plugin\ndescription: fixture with pipeline\n---\n# Pipeline\n',
|
|
);
|
|
|
|
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: fixture }),
|
|
});
|
|
await readSseUntilSuccess(installResp);
|
|
|
|
const projectId = `pipeline-${Date.now()}`;
|
|
// The fixture declares od.pipeline.stages and is installed under
|
|
// sourceKind='local', which is trusted by default (trust.ts
|
|
// defaultTrustForRecord). The trusted default grant therefore already
|
|
// includes pipeline:*, so the snapshot is created without any ephemeral
|
|
// grantCaps — exercising the stored/default grant path directly.
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Pipeline e2e-3',
|
|
pluginId: 'pipeline-plugin',
|
|
pluginInputs: { topic: 'agentic design' },
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
const createBody = (await createResp.json()) as {
|
|
project: { id: string };
|
|
conversationId: string;
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
const runResp = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
projectId,
|
|
pluginId: 'pipeline-plugin',
|
|
appliedPluginSnapshotId: createBody.appliedPluginSnapshotId,
|
|
}),
|
|
});
|
|
expect(runResp.status).toBe(202);
|
|
const runBody = (await runResp.json()) as { runId: string };
|
|
|
|
// The pipeline emits its first event synchronously inside POST
|
|
// /api/runs (firePipelineForRun runs before design.runs.start
|
|
// schedules the agent), so by the time we GET /api/runs/:id/events
|
|
// the run buffer already contains pipeline_stage_started.
|
|
// Wait briefly for the async tail (devloop iteration log) to settle.
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
|
|
const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`);
|
|
const statusBody = (await statusResp.json()) as { id: string };
|
|
expect(statusBody.id).toBe(runBody.runId);
|
|
|
|
// Read the run's event buffer through the SSE stream — the
|
|
// server pipes every record through res.write, so reading the
|
|
// body until 'end' or pipeline_stage_completed surfaces the
|
|
// first events. We don't actually wait for end (the run is
|
|
// long-running); we just look for the stage-start anchor.
|
|
const eventsResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/events`, {
|
|
headers: { accept: 'text/event-stream' },
|
|
});
|
|
expect(eventsResp.body).toBeTruthy();
|
|
const reader = eventsResp.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let firstStageEvent: string | null = null;
|
|
let messageChunkSeen = false;
|
|
const start = Date.now();
|
|
while (Date.now() - start < 1500) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const blocks = buffer.split('\n\n');
|
|
buffer = blocks.pop() ?? '';
|
|
for (const block of blocks) {
|
|
const eventLine = block.split('\n').find((l) => l.startsWith('event: '));
|
|
if (!eventLine) continue;
|
|
const event = eventLine.slice('event: '.length);
|
|
if (event === 'pipeline_stage_started' && !firstStageEvent && !messageChunkSeen) {
|
|
firstStageEvent = event;
|
|
}
|
|
if (event === 'message_chunk') messageChunkSeen = true;
|
|
if (firstStageEvent || event === 'end') break;
|
|
}
|
|
if (firstStageEvent) break;
|
|
}
|
|
void reader.cancel().catch(() => undefined);
|
|
|
|
expect(firstStageEvent).toBe('pipeline_stage_started');
|
|
|
|
await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' });
|
|
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
});
|