open-design/apps/web/tests/state/projects.test.ts
吴杨帆 6155ad8cbe
fix(web): surface Claude Design zip import failures (#1862) (#3047)
Show a toast when the daemon rejects a ZIP import instead of silently
closing the file picker with no feedback.
2026-05-27 06:24:38 +00:00

372 lines
12 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
applyPlugin,
contributeGeneratedPluginToOpenDesign,
createPluginShareProject,
importClaudeDesignZip,
importFolderProject,
installGeneratedPluginFolder,
listPlugins,
publishGeneratedPluginToGitHub,
} from '../../src/state/projects';
describe('applyPlugin', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('passes the current locale to the daemon apply endpoint', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
query: '生成一份简报。',
contextItems: [],
inputs: [],
assets: [],
mcpServers: [],
projectMetadata: {},
trust: 'trusted',
capabilitiesGranted: [],
capabilitiesRequired: [],
appliedPlugin: {
snapshotId: 'snap-1',
pluginId: 'sample-plugin',
pluginVersion: '1.0.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: [],
capabilitiesRequired: [],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 0,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
await applyPlugin('sample-plugin', { locale: 'zh-CN' });
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(String(init?.body))).toMatchObject({
inputs: {},
grantCaps: [],
locale: 'zh-CN',
});
});
});
describe('listPlugins', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('hides plugins marked od.hidden from UI-facing lists', async () => {
const visible = {
id: 'od-new-generation',
title: 'New generation',
manifest: { od: { kind: 'scenario' } },
};
const hidden = {
id: 'od-default',
title: 'Default design router',
manifest: { od: { kind: 'scenario', hidden: true } },
};
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ plugins: [hidden, visible] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
)));
const rows = await listPlugins();
expect(rows.map((row) => row.id)).toEqual(['od-new-generation']);
});
it('can include hidden plugins for installed-entry matching', async () => {
const visible = {
id: 'od-new-generation',
title: 'New generation',
manifest: { od: { kind: 'scenario' } },
};
const hidden = {
id: 'od-default',
title: 'Default design router',
manifest: { od: { kind: 'scenario', hidden: true } },
};
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ plugins: [hidden, visible] }),
{ status: 200, headers: { 'content-type': 'application/json' } },
)));
const rows = await listPlugins({ includeHidden: true });
expect(rows.map((row) => row.id)).toEqual(['od-default', 'od-new-generation']);
});
});
describe('installGeneratedPluginFolder', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('installs a project-relative generated plugin folder', async () => {
const dispatchEvent = vi.fn();
vi.stubGlobal('window', { dispatchEvent });
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
ok: true,
plugin: { id: 'generated-plugin', title: 'Generated Plugin' },
warnings: [],
message: 'Installed Generated Plugin.',
log: [],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
const outcome = await installGeneratedPluginFolder('project-1', 'generated-plugin');
expect(outcome.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects/project-1/plugins/install-folder',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ path: 'generated-plugin' }),
}),
);
expect(dispatchEvent).toHaveBeenCalled();
});
it('preserves install diagnostics from non-2xx project folder responses', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
ok: false,
warnings: ['Missing open-design.json'],
message: 'Plugin validation failed.',
log: ['Validating generated-plugin'],
}),
{ status: 400, headers: { 'content-type': 'application/json' }, statusText: 'Bad Request' },
));
vi.stubGlobal('fetch', fetchMock);
const outcome = await installGeneratedPluginFolder('project-1', 'generated-plugin');
expect(outcome).toMatchObject({
ok: false,
warnings: ['Missing open-design.json'],
message: 'Plugin validation failed.',
log: ['Validating generated-plugin'],
});
});
});
describe('importClaudeDesignZip', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('preserves daemon import errors from non-2xx responses', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ error: 'Unable to unpack Claude export.' }),
{ status: 422, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
const file = new File(['zip-bytes'], 'claude-design.zip', {
type: 'application/zip',
});
await expect(importClaudeDesignZip(file)).rejects.toThrow(
'Unable to unpack Claude export.',
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/import/claude-design',
expect.objectContaining({
method: 'POST',
body: expect.any(FormData),
}),
);
});
});
describe('generated plugin share actions', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('posts publish and contribute actions for project-relative plugin folders', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
ok: true,
message: 'Ready',
url: 'https://github.com/example/generated-plugin',
log: ['ok'],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
const publish = await publishGeneratedPluginToGitHub('project-1', 'generated-plugin');
const contribute = await contributeGeneratedPluginToOpenDesign('project-1', 'generated-plugin');
expect(publish).toMatchObject({ ok: true, message: 'Ready' });
expect(contribute).toMatchObject({ ok: true, message: 'Ready' });
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/projects/project-1/plugins/publish-github',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ path: 'generated-plugin' }),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/projects/project-1/plugins/contribute-open-design',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ path: 'generated-plugin' }),
}),
);
});
});
describe('createPluginShareProject', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('creates an agent-backed share project for an installed plugin', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
ok: true,
project: {
id: 'project-1',
name: 'Publish to GitHub: Sample Plugin',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
pendingPrompt: 'Publish it',
metadata: { kind: 'prototype' },
},
conversationId: 'conversation-1',
appliedPluginSnapshotId: 'snapshot-1',
actionPluginId: 'od-plugin-publish-github',
sourcePluginId: 'sample-plugin',
stagedPath: 'plugin-source/sample-plugin',
prompt: 'Publish it',
message: 'Created a Publish to GitHub task.',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
const outcome = await createPluginShareProject(
'sample-plugin',
'publish-github',
'zh-CN',
);
expect(outcome).toMatchObject({
ok: true,
project: { id: 'project-1' },
appliedPluginSnapshotId: 'snapshot-1',
stagedPath: 'plugin-source/sample-plugin',
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/sample-plugin/share-project',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'publish-github', locale: 'zh-CN' }),
}),
);
});
it('surfaces share project errors from the daemon', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({
ok: false,
code: 'share-action-plugin-missing',
message: 'Restart the daemon.',
}),
{ status: 409, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
const outcome = await createPluginShareProject(
'sample-plugin',
'contribute-open-design',
);
expect(outcome).toEqual({
ok: false,
code: 'share-action-plugin-missing',
message: 'Restart the daemon.',
});
});
});
describe('importFolderProject', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns the project on success', async () => {
const response = {
project: { id: 'p-1', name: 'My Folder' },
conversationId: 'conv-1',
entryFile: 'index.html',
};
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
JSON.stringify(response),
{ status: 200, headers: { 'content-type': 'application/json' } },
)));
const result = await importFolderProject({ baseDir: '/home/user/project' });
expect(result).toMatchObject({ project: { id: 'p-1' }, entryFile: 'index.html' });
});
it('throws with daemon error message for filesystem root', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ error: { code: 'BAD_REQUEST', message: 'cannot import the filesystem root' } }),
{ status: 400, headers: { 'content-type': 'application/json' } },
)));
await expect(importFolderProject({ baseDir: '/' }))
.rejects.toThrow('cannot import the filesystem root');
});
it('throws with daemon error message for non-existent folder', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ error: { code: 'BAD_REQUEST', message: 'folder not found' } }),
{ status: 400, headers: { 'content-type': 'application/json' } },
)));
await expect(importFolderProject({ baseDir: '/abc/xyz/notexist' }))
.rejects.toThrow('folder not found');
});
it('throws with daemon error message for file path', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ error: { code: 'BAD_REQUEST', message: 'path must be a directory' } }),
{ status: 400, headers: { 'content-type': 'application/json' } },
)));
await expect(importFolderProject({ baseDir: '/etc/hosts' }))
.rejects.toThrow('path must be a directory');
});
it('throws a fallback message when response body has no error detail', async () => {
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response(
'Internal Server Error',
{ status: 500 },
)));
await expect(importFolderProject({ baseDir: '/some/path' }))
.rejects.toThrow('Failed to import folder');
});
});