mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* docs: add live artifacts implementation spec * docs: align live artifacts implementation plan * Ralph iteration 1: work in progress * Ralph iteration 2: work in progress * Ralph iteration 3: work in progress * Ralph iteration 4: work in progress * Ralph iteration 5: work in progress * Ralph iteration 6: work in progress * Ralph iteration 7: work in progress * Ralph iteration 8: work in progress * Ralph iteration 9: work in progress * Ralph iteration 10: work in progress * Ralph iteration 11: work in progress * Ralph iteration 12: work in progress * Ralph iteration 13: work in progress * Ralph iteration 14: work in progress * Ralph iteration 15: work in progress * Ralph iteration 16: work in progress * Ralph iteration 17: work in progress * Ralph iteration 18: work in progress * Ralph iteration 19: work in progress * Ralph iteration 20: work in progress * Ralph iteration 21: work in progress * Ralph iteration 22: work in progress * Ralph iteration 23: work in progress * Ralph iteration 24: work in progress * Ralph iteration 25: work in progress * Ralph iteration 26: work in progress * Ralph iteration 27: work in progress * Ralph iteration 28: work in progress * Ralph iteration 29: work in progress * Ralph iteration 30: work in progress * Ralph iteration 31: work in progress * Ralph iteration 32: work in progress * Ralph iteration 33: work in progress * Ralph iteration 34: work in progress * Ralph iteration 35: work in progress * Ralph iteration 36: work in progress * Ralph iteration 37: work in progress * Ralph iteration 38: work in progress * Ralph iteration 39: work in progress * Ralph iteration 40: work in progress * Ralph iteration 41: work in progress * Ralph iteration 42: work in progress * Ralph iteration 43: work in progress * Ralph iteration 44: work in progress * Ralph iteration 45: work in progress * Ralph iteration 46: work in progress * Ralph iteration 47: work in progress * Ralph iteration 48: work in progress * Ralph iteration 49: work in progress * Ralph iteration 50: work in progress * Ralph iteration 51: work in progress * Ralph iteration 52: work in progress * Ralph iteration 53: work in progress * Ralph iteration 54: work in progress * Ralph iteration 55: work in progress * Ralph iteration 56: work in progress * Ralph iteration 57: work in progress * Ralph iteration 58: work in progress * Ralph iteration 59: work in progress * Ralph iteration 60: work in progress * Ralph iteration 61: work in progress * Ralph iteration 62: work in progress * Ralph iteration 63: work in progress * Ralph iteration 64: work in progress * Ralph iteration 65: work in progress * Ralph iteration 1: work in progress * Ralph iteration 2: work in progress * Ralph iteration 3: work in progress * Ralph iteration 4: work in progress * Ralph iteration 5: work in progress * Ralph iteration 6: work in progress * Ralph iteration 8: work in progress * Ralph iteration 9: work in progress * Ralph iteration 17: work in progress * Add Composio-backed connectors * Add Composio-backed connector catalog * Fix connector callback flow * Update live artifact connector refresh * Fix live artifact refresh updates * Improve live artifact viewer toolbar * Refine live artifact source tabs * Expand Composio connector catalog * Improve Composio connector browsing * Fix artifact refresh source safety checks Generated-By: looper 0.4.1 (runner=fixer, agent=opencode) * Fix live artifacts PR feedback Generated-By: looper 0.5.0 (runner=fixer, agent=opencode) * Fix live artifact preview CORS validation Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * Fix connector OAuth IPv6 loopback hosts Allow bracketed IPv6 loopback Host headers when deriving connector OAuth callback URLs so IPv6-bound daemons can complete connection flow. Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * Preserve live artifact refresh permissions Respect explicit refresh permission choices during live artifact create and update flows so revoked connector sources remain gated. Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * Fix live artifact preview cache freshness Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * Fix live artifact refresh validation Guard manual refreshes with local daemon checks and reject daemon_tool sources without a toolName before refresh execution. Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * Fix Composio credential invalidation Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * Fix live artifact CORS methods Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode) * Fix workspace validation Restore media config test isolation under Vitest setup data-dir overrides and add the missing French live artifact display copy so the workspace test suite stays aligned.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode) * Fix connector safety filtering Keep agent-preview connector listings aligned with execution safety policy and prune stale Composio OAuth state records before they accumulate. Generated-By: looper 0.5.2 (runner=fixer, agent=opencode) * Fix agent runtime cleanup Generated-By: looper 0.5.2 (runner=fixer, agent=opencode) * Fix live artifact daemon access Validate local-only live artifact routes against the peer socket address and pass daemon-resolved CLI paths to ACP MCP descriptors.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode) * Fix connector run limit pruning Evict stale connector rate-limit buckets so long-lived daemon processes do not retain per-run entries indefinitely.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode) * Fix connector compact schemas Generated-By: looper 0.5.2 (runner=fixer, agent=opencode) * Improve connector connection feedback * Adjust connector gate positioning * Fix live artifact refresh commits Avoid marking refresh candidates failed after snapshot or state persistence errors by deferring live artifact mutations until the durable refresh metadata is written. Also align connector OAuth callback host validation with daemon loopback handling.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode) * Improve connector search relevance * fix(daemon): harden connector connection state Require loopback daemon validation before connector connect side effects and only clear provider-owned connector statuses during credential reset. Generated-By: looper 0.5.4 (runner=fixer, agent=opencode) * fix(daemon): guard connector disconnect route Require local daemon request validation before connector disconnect side effects. Generated-By: looper 0.5.4 (runner=fixer, agent=opencode) * fix(daemon): guard composio config updates Generated-By: looper 0.5.4 (runner=fixer, agent=opencode) * fix(daemon): dispatch live artifacts mcp first Route the live-artifacts MCP server before the generic MCP CLI so od mcp live-artifacts starts the dedicated server instead of failing generic argument parsing.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode) * fix(daemon): handle integer connector schemas Allow JSON Schema integer connector inputs while preserving fractional-value validation so generated connector tool schemas accept valid page sizes and limits. Generated-By: looper 0.5.4 (runner=fixer, agent=opencode) * fix: align live artifact refresh error codes Generated-By: looper 0.5.4 (runner=fixer, agent=opencode) * Fix live artifact connector refresh flow * Update live artifact design cards * Add beta badge to live artifact form * Remove live artifact tile model * Fix live artifact refresh sync * Fix live artifact MCP refresh durability Generated-By: looper 0.5.4 (runner=fixer, agent=opencode) * Fix live artifact refresh safety Enforce persisted refresh opt-out and connector auto-read gating before refresh sources execute. Generated-By: looper 0.5.5 (runner=fixer, agent=opencode)
276 lines
9.4 KiB
TypeScript
276 lines
9.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
import { runLiveArtifactsToolCli } from '../src/tools-live-artifacts-cli.js';
|
|
|
|
const ORIGINAL_ENV = { ...process.env };
|
|
|
|
describe('live artifact tool CLI environment', () => {
|
|
let stdoutWrite: { mockRestore: () => void };
|
|
let stderrWrite: { mockRestore: () => void };
|
|
let stdoutOutput: string[];
|
|
let stderrOutput: string[];
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
const tempRoots: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...ORIGINAL_ENV };
|
|
stdoutOutput = [];
|
|
stderrOutput = [];
|
|
stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
stdoutOutput.push(String(chunk));
|
|
return true;
|
|
});
|
|
stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
|
|
stderrOutput.push(String(chunk));
|
|
return true;
|
|
});
|
|
fetchMock = vi.fn(async () =>
|
|
new Response(JSON.stringify({ artifacts: [] }), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
status: 200,
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
stdoutWrite.mockRestore();
|
|
stderrWrite.mockRestore();
|
|
process.env = ORIGINAL_ENV;
|
|
return Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))).then(() => undefined);
|
|
});
|
|
|
|
async function makeArtifactInputFiles() {
|
|
const root = await mkdtemp(path.join(tmpdir(), 'od-live-artifact-cli-'));
|
|
tempRoots.push(root);
|
|
const artifactPath = path.join(root, 'artifact.json');
|
|
await writeFile(artifactPath, JSON.stringify({
|
|
title: 'Data backed artifact',
|
|
preview: { type: 'html', entry: 'index.html' },
|
|
document: {
|
|
format: 'html_template_v1',
|
|
templatePath: 'template.html',
|
|
generatedPreviewPath: 'index.html',
|
|
dataPath: 'data.json',
|
|
dataJson: {},
|
|
},
|
|
}));
|
|
await writeFile(path.join(root, 'data.json'), JSON.stringify({ title: 'Injected title', metrics: { count: 3 } }));
|
|
await writeFile(path.join(root, 'template.html'), '<h1>{{data.title}}</h1>');
|
|
await writeFile(path.join(root, 'provenance.json'), JSON.stringify({ generatedAt: '2026-05-05T00:00:00.000Z', generatedBy: 'agent', sources: [] }));
|
|
return artifactPath;
|
|
}
|
|
|
|
it('reads OD_DAEMON_URL and OD_TOOL_TOKEN from the injected environment', async () => {
|
|
process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456/base/';
|
|
process.env.OD_TOOL_TOKEN = 'agent-run-token';
|
|
|
|
const result = await runLiveArtifactsToolCli(['list']);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:7456/base/api/tools/live-artifacts/list',
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer agent-run-token',
|
|
Accept: 'application/json',
|
|
}),
|
|
}),
|
|
);
|
|
expect(JSON.parse(stdoutOutput.join(''))).toEqual({ ok: true, artifacts: [] });
|
|
});
|
|
|
|
it('prints compact success JSON for list results', async () => {
|
|
process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456';
|
|
process.env.OD_TOOL_TOKEN = 'agent-run-token';
|
|
fetchMock.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
artifacts: [
|
|
{
|
|
id: 'live_1',
|
|
title: 'Launch Metrics',
|
|
status: 'active',
|
|
refreshStatus: 'idle',
|
|
preview: { type: 'html', entry: 'index.html' },
|
|
updatedAt: '2026-04-30T12:00:00.000Z',
|
|
dataJson: { large: 'omitted from compact output' },
|
|
},
|
|
],
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
|
|
),
|
|
);
|
|
|
|
const result = await runLiveArtifactsToolCli(['list']);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(JSON.parse(stdoutOutput.join(''))).toEqual({
|
|
ok: true,
|
|
artifacts: [
|
|
{
|
|
id: 'live_1',
|
|
title: 'Launch Metrics',
|
|
status: 'active',
|
|
refreshStatus: 'idle',
|
|
preview: { type: 'html', entry: 'index.html' },
|
|
updatedAt: '2026-04-30T12:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
expect(stderrOutput.join('')).toBe('');
|
|
});
|
|
|
|
it('injects sibling data.json into document dataJson when creating artifacts', async () => {
|
|
process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456/base/';
|
|
process.env.OD_TOOL_TOKEN = 'agent-run-token';
|
|
const artifactPath = await makeArtifactInputFiles();
|
|
fetchMock.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
artifact: {
|
|
id: 'live_1',
|
|
title: 'Data backed artifact',
|
|
status: 'active',
|
|
refreshStatus: 'idle',
|
|
preview: { type: 'html', entry: 'index.html' },
|
|
updatedAt: '2026-05-05T00:00:00.000Z',
|
|
},
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
|
|
),
|
|
);
|
|
|
|
const result = await runLiveArtifactsToolCli(['create', '--input', artifactPath]);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
const requestBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
|
|
expect(requestBody.input.document.dataJson).toEqual({ title: 'Injected title', metrics: { count: 3 } });
|
|
expect(requestBody.templateHtml).toBe('<h1>{{data.title}}</h1>');
|
|
expect(requestBody.provenanceJson).toMatchObject({ generatedBy: 'agent' });
|
|
});
|
|
|
|
it('calls the refresh tool endpoint with the artifact id', async () => {
|
|
process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456/base/';
|
|
process.env.OD_TOOL_TOKEN = 'agent-run-token';
|
|
fetchMock.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
artifact: {
|
|
id: 'live_1',
|
|
title: 'Launch Metrics',
|
|
status: 'active',
|
|
refreshStatus: 'succeeded',
|
|
preview: { type: 'html', entry: 'index.html' },
|
|
updatedAt: '2026-04-30T12:00:00.000Z',
|
|
},
|
|
refresh: { id: 'refresh-000001', status: 'succeeded', refreshedSourceCount: 1 },
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
|
|
),
|
|
);
|
|
|
|
const result = await runLiveArtifactsToolCli(['refresh', '--artifact-id', 'live_1']);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://127.0.0.1:7456/base/api/tools/live-artifacts/refresh',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ artifactId: 'live_1' }),
|
|
headers: expect.objectContaining({ Authorization: 'Bearer agent-run-token' }),
|
|
}),
|
|
);
|
|
expect(JSON.parse(stdoutOutput.join(''))).toEqual({
|
|
ok: true,
|
|
artifact: {
|
|
id: 'live_1',
|
|
title: 'Launch Metrics',
|
|
status: 'active',
|
|
refreshStatus: 'succeeded',
|
|
preview: { type: 'html', entry: 'index.html' },
|
|
updatedAt: '2026-04-30T12:00:00.000Z',
|
|
},
|
|
refresh: { id: 'refresh-000001', status: 'succeeded', refreshedSourceCount: 1 },
|
|
});
|
|
});
|
|
|
|
it('prints compact validation errors and exits non-zero on API failure', async () => {
|
|
process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456';
|
|
process.env.OD_TOOL_TOKEN = 'agent-run-token';
|
|
fetchMock.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
code: 'LIVE_ARTIFACT_INVALID',
|
|
message: 'Live artifact validation failed',
|
|
details: {
|
|
kind: 'validation',
|
|
issues: [
|
|
{
|
|
path: 'sourceJson.token',
|
|
message: 'credential-like fields are not allowed',
|
|
code: 'FORBIDDEN_KEY',
|
|
received: 'secret value that must not be echoed',
|
|
},
|
|
],
|
|
},
|
|
retryable: false,
|
|
},
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' }, status: 400 },
|
|
),
|
|
);
|
|
|
|
const result = await runLiveArtifactsToolCli(['list']);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(stdoutOutput.join('')).toBe('');
|
|
expect(JSON.parse(stderrOutput.join(''))).toEqual({
|
|
ok: false,
|
|
status: 400,
|
|
error: {
|
|
code: 'LIVE_ARTIFACT_INVALID',
|
|
message: 'Live artifact validation failed',
|
|
details: {
|
|
kind: 'validation',
|
|
issues: [
|
|
{
|
|
path: 'sourceJson.token',
|
|
message: 'credential-like fields are not allowed',
|
|
code: 'FORBIDDEN_KEY',
|
|
},
|
|
],
|
|
},
|
|
retryable: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('fails before making a request when the injected environment is missing', async () => {
|
|
delete process.env.OD_DAEMON_URL;
|
|
delete process.env.OD_TOOL_TOKEN;
|
|
|
|
const result = await runLiveArtifactsToolCli(['list']);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(stderrOutput.join('')).toContain('OD_DAEMON_URL is required');
|
|
});
|
|
|
|
it('requires OD_TOOL_TOKEN from the injected environment', async () => {
|
|
process.env.OD_DAEMON_URL = 'http://127.0.0.1:7456';
|
|
delete process.env.OD_TOOL_TOKEN;
|
|
|
|
const result = await runLiveArtifactsToolCli(['list']);
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(stderrOutput.join('')).toContain('OD_TOOL_TOKEN is required');
|
|
});
|
|
});
|