Files
nexu-io--open-design/apps/web/tests/components/FileWorkspace.design-system.test.tsx
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

1068 lines
36 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { fireEvent, waitFor } from '@testing-library/react';
import { createRoot, type Root } from 'react-dom/client';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { FileWorkspace } from '../../src/components/FileWorkspace';
import type { AgentEvent, DesignSystemSummary, ProjectFile } from '../../src/types';
const registryMocks = vi.hoisted(() => ({
deleteProjectFile: vi.fn(),
fetchProjectFileText: vi.fn(),
fetchProjectFolders: vi.fn(async () => []),
updateDesignSystemDraft: vi.fn(),
writeProjectTextFile: vi.fn(),
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
deleteProjectFile: registryMocks.deleteProjectFile,
fetchProjectFileText: registryMocks.fetchProjectFileText,
fetchProjectFolders: registryMocks.fetchProjectFolders,
updateDesignSystemDraft: registryMocks.updateDesignSystemDraft,
writeProjectTextFile: registryMocks.writeProjectTextFile,
};
});
vi.mock('../../src/components/DesignBrowserPanel', () => ({
DesignBrowserPanel: () => <div data-testid="design-browser-panel" />,
labelFromUrl: (url: string) => url,
}));
vi.mock('../../src/components/workspace/TerminalViewer', () => ({
TerminalViewer: ({ terminalId }: { terminalId: string }) => (
<div data-testid="terminal-viewer">{terminalId}</div>
),
}));
let root: Root | null = null;
let host: HTMLDivElement | null = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeAll(() => {
globalThis.ResizeObserver = class {
observe() {}
disconnect() {}
unobserve() {}
};
});
afterEach(() => {
if (root) {
act(() => root?.unmount());
root = null;
}
host?.remove();
host = null;
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function workspaceFile(name: string): ProjectFile {
return {
name,
path: name,
type: 'file',
size: 100,
mtime: Date.parse('2026-05-14T00:00:00.000Z'),
kind: name.endsWith('.html') ? 'html' : name.endsWith('.svg') ? 'image' : 'text',
mime: name.endsWith('.html') ? 'text/html' : name.endsWith('.svg') ? 'image/svg+xml' : 'text/plain',
};
}
function designSystem(overrides: Partial<DesignSystemSummary> = {}): DesignSystemSummary {
return {
id: 'user:acme',
title: 'Acme Design System',
category: 'Custom',
summary: 'Context project for Acme.',
swatches: [],
surface: 'web',
source: 'user',
status: 'draft',
isEditable: true,
...overrides,
};
}
function renderWorkspace(element: React.ReactElement) {
host = document.createElement('div');
document.body.appendChild(host);
act(() => {
root = createRoot(host!);
root.render(element);
});
return host;
}
// The in-project Design System tab renders the brand.html-style kit, which loads
// brand.json / DESIGN.md asynchronously before the publish + warning scaffolding
// (and the manifest-error banner) mount. Flush a few microtask turns so the kit
// settles and that scaffolding is present.
async function flushKit(times = 3) {
for (let i = 0; i < times; i += 1) {
// eslint-disable-next-line no-await-in-loop
await act(async () => {
await Promise.resolve();
});
}
}
type ToolUseEvent = Extract<AgentEvent, { kind: 'tool_use' }>;
function todoWrite(
todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm?: string }>,
): ToolUseEvent {
return { kind: 'tool_use', id: 'todo-write', name: 'TodoWrite', input: { todos } };
}
describe('FileWorkspace design-system project surface', () => {
it('renders the brand.html-style kit modules from the project DESIGN.md', async () => {
registryMocks.fetchProjectFileText.mockImplementation((_projectId: string, name: string) => {
if (name === 'DESIGN.md') {
return Promise.resolve(
[
'# Acme',
'',
'## Color Palette',
'',
'| Role | Name | Hex | Usage |',
'| --- | --- | --- | --- |',
'| accent | Emerald | `#10B981` | links and CTAs |',
'| foreground | Ink | `#111827` | body text |',
].join('\n'),
);
}
return Promise.resolve(null);
});
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
/>,
);
await flushKit();
const kit = container.querySelector('[data-testid="design-system-project-kit"]');
expect(kit).toBeTruthy();
// The parsed palette renders as swatch chips (hex captions are normalized to
// lowercase by the parser; CSS upper-cases them only visually).
expect(container.textContent?.toLowerCase()).toContain('#10b981');
expect(container.textContent?.toLowerCase()).toContain('#111827');
// The DESIGN.md edit affordance lives in the sticky header's "More" menu.
const moreTrigger = container.querySelector<HTMLButtonElement>(
'[data-testid="design-kit-more-actions"]',
);
expect(moreTrigger).toBeTruthy();
await act(async () => {
moreTrigger?.click();
await Promise.resolve();
});
expect(container.textContent).toContain('Edit DESIGN.md');
expect(container.textContent).toContain('Refresh');
expect(container.textContent).toContain('Download');
expect(container.textContent).not.toContain('Reset');
});
it('shows the design-system tab while brand extraction is running but keeps kit edits locked', async () => {
registryMocks.fetchProjectFileText.mockImplementation((_projectId: string, name: string) => {
if (name === 'DESIGN.md') {
return Promise.resolve(
[
'# Acme',
'',
'## Color Palette',
'',
'| Role | Name | Hex | Usage |',
'| --- | --- | --- | --- |',
'| accent | Emerald | `#10B981` | links and CTAs |',
].join('\n'),
);
}
return Promise.resolve(null);
});
const container = renderWorkspace(
<FileWorkspace
projectId="brand-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('brand.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
designSystemBrandId="brand-acme"
designSystemEditable={false}
/>,
);
await flushKit();
expect(container.querySelector('[data-testid="design-system-project-tab"]')).toBeTruthy();
expect(container.textContent).toContain('Extracting design system');
expect(container.querySelector<HTMLButtonElement>('[data-testid="design-system-publish"]')?.disabled).toBe(true);
expect(container.querySelector('button[aria-label="Edit Emerald"]')).toBeNull();
const moreTrigger = container.querySelector<HTMLButtonElement>(
'[data-testid="design-kit-more-actions"]',
);
expect(moreTrigger).toBeTruthy();
await act(async () => {
moreTrigger?.click();
await Promise.resolve();
});
expect(container.textContent).not.toContain('Edit DESIGN.md');
});
it('edits and resets palette colors through the color editor dialog', async () => {
let designMdBody = [
'# Acme',
'',
'## Color Palette',
'',
'| Role | Name | Hex | Usage |',
'| --- | --- | --- | --- |',
'| accent | Emerald | `#10B981` | links and CTAs |',
'| foreground | Ink | `#111827` | body text |',
].join('\n');
registryMocks.fetchProjectFileText.mockImplementation((_projectId: string, name: string) => {
if (name === 'DESIGN.md') return Promise.resolve(designMdBody);
return Promise.resolve(null);
});
registryMocks.writeProjectTextFile.mockImplementation((_projectId: string, name: string, body: string) => {
if (name === 'DESIGN.md') designMdBody = body;
return Promise.resolve(workspaceFile(name));
});
registryMocks.updateDesignSystemDraft.mockResolvedValue(designSystem());
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
/>,
);
await flushKit();
const editEmerald = container.querySelector<HTMLButtonElement>('button[aria-label="Edit Emerald"]');
expect(editEmerald).toBeTruthy();
await act(async () => {
fireEvent.click(editEmerald!);
});
const dialog = document.body.querySelector<HTMLElement>('[data-testid="design-kit-color-editor"]');
expect(dialog).toBeTruthy();
const hexInput = dialog!.querySelector<HTMLInputElement>('input[aria-label="Hex value"]');
expect(hexInput).toBeTruthy();
await act(async () => {
fireEvent.change(hexInput!, { target: { value: '#FF6A3D' } });
});
const save = Array.from(dialog!.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.includes('Save color'));
expect(save).toBeTruthy();
await act(async () => {
fireEvent.click(save!);
await Promise.resolve();
});
await waitFor(() => expect(registryMocks.writeProjectTextFile).toHaveBeenLastCalledWith(
'ds-acme',
'DESIGN.md',
expect.stringContaining('`#FF6A3D`'),
));
await flushKit();
await waitFor(() => expect(document.body.querySelector('[data-testid="design-kit-color-editor"]')).toBeNull());
const editAgain = container.querySelector<HTMLButtonElement>('button[aria-label="Edit Emerald"]');
expect(editAgain).toBeTruthy();
await act(async () => {
fireEvent.click(editAgain!);
});
const resetDialog = document.body.querySelector<HTMLElement>('[data-testid="design-kit-color-editor"]');
const reset = Array.from(resetDialog!.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.includes('Reset'));
expect(reset).toBeTruthy();
await act(async () => {
fireEvent.click(reset!);
await Promise.resolve();
});
await waitFor(() => expect(registryMocks.writeProjectTextFile).toHaveBeenLastCalledWith(
'ds-acme',
'DESIGN.md',
expect.stringContaining('`#10B981`'),
));
});
it('finalizes brand-backed palette edits before refreshing project files and design-system lists', async () => {
let brandJson = JSON.stringify({
name: 'Acme',
sourceUrl: 'https://acme.test',
logo: { primary: null, alternates: [] },
colors: [{ role: 'accent', name: 'Emerald', hex: '#10B981', usage: 'links and CTAs' }],
typography: {},
imagery: { style: '', subjects: [], treatment: '', avoid: [], samples: [] },
voice: {
adjectives: [],
tone: '',
messagingPillars: [],
vocabulary: { use: [], avoid: [] },
},
layout: { radius: '', borderWeight: '', spacing: '', postureRules: [] },
});
registryMocks.fetchProjectFileText.mockImplementation((_projectId: string, name: string) => {
if (name === 'DESIGN.md') return Promise.resolve('# Acme');
if (name === 'brand.json') return Promise.resolve(brandJson);
return Promise.resolve(null);
});
registryMocks.writeProjectTextFile.mockImplementation((_projectId: string, name: string, body: string) => {
if (name === 'brand.json') brandJson = body;
return Promise.resolve(workspaceFile(name));
});
const events: string[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/plugins') {
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/raw/fonts/') || url.includes('/raw/system/tokens.')) {
return new Response(null, { status: 404 });
}
events.push(url);
return new Response(JSON.stringify({ id: 'brand-acme' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const onRefreshFiles = vi.fn(() => {
events.push('refresh-files');
});
const onDesignSystemsRefresh = vi.fn(() => {
events.push('refresh-design-systems');
});
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('brand.json')]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
designSystemBrandId="brand-acme"
onDesignSystemsRefresh={onDesignSystemsRefresh}
/>,
);
await flushKit();
const editEmerald = container.querySelector<HTMLButtonElement>('button[aria-label="Edit Emerald"]');
expect(editEmerald).toBeTruthy();
await act(async () => {
fireEvent.click(editEmerald!);
});
const dialog = document.body.querySelector<HTMLElement>('[data-testid="design-kit-color-editor"]');
const hexInput = dialog?.querySelector<HTMLInputElement>('input[aria-label="Hex value"]');
expect(hexInput).toBeTruthy();
await act(async () => {
fireEvent.change(hexInput!, { target: { value: '#FF6A3D' } });
});
const save = Array.from(dialog!.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.includes('Save color'));
expect(save).toBeTruthy();
await act(async () => {
fireEvent.click(save!);
await Promise.resolve();
});
await waitFor(() => expect(registryMocks.writeProjectTextFile).toHaveBeenCalledWith(
'ds-acme',
'brand.json',
expect.stringContaining('"hex": "#FF6A3D"'),
));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/brands/brand-acme/finalize',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ projectId: 'ds-acme' }),
}),
));
expect(events).toEqual([
'/api/brands/brand-acme/finalize',
'refresh-files',
'refresh-design-systems',
]);
});
it('deletes image module assets from the project and refreshes dependents', async () => {
registryMocks.fetchProjectFileText.mockImplementation((_projectId: string, name: string) => {
if (name === 'DESIGN.md') return Promise.resolve('# Acme');
if (name === 'brand.json') {
return Promise.resolve(JSON.stringify({
name: 'Acme',
sourceUrl: 'https://acme.test',
logo: { primary: null, alternates: [] },
colors: [],
typography: {},
imagery: {
style: 'Product imagery',
subjects: [],
treatment: '',
avoid: [],
samples: [{ file: 'imagery/hero.png', caption: 'Hero', kind: 'hero' }],
},
voice: {
adjectives: [],
tone: '',
messagingPillars: [],
vocabulary: { use: [], avoid: [] },
},
layout: { radius: '', borderWeight: '', spacing: '', postureRules: [] },
}));
}
return Promise.resolve(null);
});
registryMocks.writeProjectTextFile.mockResolvedValue(workspaceFile('brand.json'));
registryMocks.deleteProjectFile.mockResolvedValue(true);
const events: string[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/plugins') {
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/raw/fonts/') || url.includes('/raw/system/tokens.')) {
return new Response(null, { status: 404 });
}
events.push(url);
return new Response(JSON.stringify({ id: 'brand-acme' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const onRefreshFiles = vi.fn(() => {
events.push('refresh-files');
});
const onDesignSystemsRefresh = vi.fn(() => {
events.push('refresh-design-systems');
});
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('brand.json'), workspaceFile('imagery/hero.png')]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
designSystemBrandId="brand-acme"
onDesignSystemsRefresh={onDesignSystemsRefresh}
/>,
);
await flushKit();
const deleteImage = container.querySelector<HTMLButtonElement>('button[aria-label="Delete Hero"]');
expect(deleteImage).toBeTruthy();
await act(async () => {
fireEvent.click(deleteImage!);
await Promise.resolve();
});
await waitFor(() => expect(registryMocks.writeProjectTextFile).toHaveBeenCalledWith(
'ds-acme',
'brand.json',
expect.not.stringContaining('imagery/hero.png'),
));
expect(registryMocks.deleteProjectFile).toHaveBeenCalledWith('ds-acme', 'imagery/hero.png');
expect(fetchMock).toHaveBeenCalledWith(
'/api/brands/brand-acme/finalize',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ projectId: 'ds-acme' }),
}),
);
expect(events).toEqual([
'/api/brands/brand-acme/finalize',
'refresh-files',
'refresh-design-systems',
]);
});
it('refreshes before downloading the current project archive', async () => {
registryMocks.fetchProjectFileText.mockImplementation((_projectId: string, name: string) => {
if (name === 'DESIGN.md') return Promise.resolve('# Acme');
return Promise.resolve(null);
});
const events: string[] = [];
const onRefreshFiles = vi.fn(async () => {
events.push('refresh-files');
});
const onDesignSystemsRefresh = vi.fn(() => {
events.push('refresh-design-systems');
});
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/plugins') {
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/raw/fonts/') || url.includes('/raw/system/tokens.')) {
return new Response(null, { status: 404 });
}
events.push(url);
if (url === '/api/brands/brand-acme/finalize') {
return new Response(JSON.stringify({ id: 'brand-acme' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(new Blob(['zip']), {
status: 200,
headers: { 'Content-Disposition': 'attachment; filename="acme.zip"' },
});
});
vi.stubGlobal('fetch', fetchMock);
const NativeUrl = URL;
class MockUrl extends NativeUrl {
static createObjectURL = vi.fn(() => 'blob:archive');
static revokeObjectURL = vi.fn();
}
vi.stubGlobal('URL', MockUrl);
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md')]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
designSystemBrandId="brand-acme"
onDesignSystemsRefresh={onDesignSystemsRefresh}
/>,
);
await flushKit();
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="design-kit-more-actions"]')?.click();
await Promise.resolve();
});
const downloadItem = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'),
).find((button) => button.textContent?.includes('Download'));
expect(downloadItem).toBeTruthy();
await act(async () => {
fireEvent.click(downloadItem!);
await Promise.resolve();
});
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(events).toEqual([
'/api/brands/brand-acme/finalize',
'refresh-files',
'refresh-design-systems',
'/api/projects/ds-acme/archive',
]);
});
it('reports malformed design-system card manifests instead of silently falling back', async () => {
registryMocks.fetchProjectFileText.mockResolvedValue('{not json');
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('_ds_manifest.json')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
/>,
);
await flushKit();
const alert = container.querySelector<HTMLElement>('[data-testid="design-system-manifest-error"]');
expect(alert?.getAttribute('role')).toBe('alert');
expect(alert?.textContent).toContain('Invalid _ds_manifest.json');
});
it('reports semantically invalid design-system card entries instead of silently skipping them', async () => {
registryMocks.fetchProjectFileText.mockResolvedValue(
JSON.stringify({ cards: [{ path: 'preview/type-display.html', group: 123, name: 'Display' }] }),
);
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('_ds_manifest.json')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
/>,
);
await flushKit();
const alert = container.querySelector<HTMLElement>('[data-testid="design-system-manifest-error"]');
expect(alert?.getAttribute('role')).toBe('alert');
expect(alert?.textContent).toContain('cards[0].group must be a string');
});
it('shows the creating state while the initial design-system project is still source-only', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('context/source-context.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
streaming
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({ provenance: { companyBlurb: 'Acme analytics workspace' } })}
designSystemActivityEvents={[
todoWrite([
{ content: 'Create README.md with high-level company/product understanding', status: 'in_progress' },
{ content: 'Create colors_and_type.css with CSS variables', status: 'pending' },
]),
]}
/>,
);
expect(markup).toContain('Creating your design system...');
expect(markup).toContain('Keep this tab open. You can come back in a few minutes.');
expect(markup).toContain('role="progressbar"');
});
it('shows the completed extraction state once generation is no longer running', async () => {
registryMocks.fetchProjectFileText.mockResolvedValue('# Acme\n\n## Voice\nReady.');
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem()}
/>,
);
await flushKit();
expect(container.textContent).toContain('Extraction complete');
expect(container.textContent).toContain('Your design system is ready');
expect(container.textContent).not.toContain('Creating your design system...');
expect(container.textContent).not.toContain('Keep this tab open. You can come back in a few minutes.');
});
it('blocks publishing GitHub-backed design systems until connector evidence snapshots exist', async () => {
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[
workspaceFile('DESIGN.md'),
workspaceFile('context/source-context.md'),
workspaceFile('preview/colors.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({
provenance: {
companyBlurb: 'Acme analytics workspace',
githubUrls: ['https://github.com/acme/product'],
},
})}
/>,
);
await flushKit();
const publishButton = container.querySelector<HTMLButtonElement>(
'[data-testid="design-system-publish"]',
);
expect(container.textContent).toContain('Connect your repo to pull aspects of your design system');
expect(publishButton?.disabled).toBe(true);
await act(async () => {
publishButton?.click();
await Promise.resolve();
});
expect(registryMocks.updateDesignSystemDraft).not.toHaveBeenCalled();
});
it('keeps the disabled-publish guidance on a non-disabled wrapper so it stays reachable', async () => {
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[
workspaceFile('DESIGN.md'),
workspaceFile('context/source-context.md'),
workspaceFile('preview/colors.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({
provenance: {
companyBlurb: 'Acme analytics workspace',
githubUrls: ['https://github.com/acme/product'],
},
})}
/>,
);
await flushKit();
const publishButton = container.querySelector<HTMLButtonElement>(
'[data-testid="design-system-publish"]',
);
expect(publishButton?.disabled).toBe(true);
// A disabled button never fires hover/focus, so the guidance lives on a
// non-disabled wrapper that still contains the button.
const guidance = 'Finish importing your GitHub repo before you can publish.';
const carrier = container.querySelector<HTMLElement>(`[title="${guidance}"]`);
expect(carrier).toBeTruthy();
expect(carrier?.tagName).not.toBe('BUTTON');
expect(carrier?.contains(publishButton ?? null)).toBe(true);
});
it('publishes project-backed design systems and refreshes the registry state', async () => {
registryMocks.updateDesignSystemDraft.mockResolvedValue(designSystem({ status: 'published' }));
const onRefresh = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[
workspaceFile('DESIGN.md'),
workspaceFile('context/source-context.md'),
workspaceFile('context/github/acme-product.md'),
workspaceFile('context/github/acme-product/files/src/components/Button.tsx'),
workspaceFile('preview/colors.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({
provenance: {
companyBlurb: 'Acme analytics workspace',
githubUrls: ['https://github.com/acme/product'],
},
})}
onDesignSystemsRefresh={onRefresh}
/>,
);
await flushKit();
const publishButton = container.querySelector<HTMLButtonElement>(
'[data-testid="design-system-publish"]',
);
expect(publishButton?.disabled).toBe(false);
await act(async () => {
publishButton?.click();
await Promise.resolve();
});
expect(registryMocks.updateDesignSystemDraft).toHaveBeenCalledWith('user:acme', {
status: 'published',
});
expect(onRefresh).toHaveBeenCalledOnce();
});
it('routes Create new design to the selected design system', async () => {
const onUseDesignSystem = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('preview/colors.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({ status: 'published' })}
onUseDesignSystem={onUseDesignSystem}
/>,
);
await flushKit();
const newDesignButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Create new design'),
);
expect(newDesignButton).toBeTruthy();
await act(async () => {
newDesignButton?.click();
await Promise.resolve();
});
expect(onUseDesignSystem).toHaveBeenCalledWith('user:acme', 'Acme Design System');
});
it('offers a Connect GitHub action that routes to Connectors when repo evidence is missing', async () => {
const onConnectRepo = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('preview/colors.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({
provenance: {
companyBlurb: 'Acme analytics workspace',
githubUrls: ['https://github.com/acme/product'],
},
})}
onConnectRepo={onConnectRepo}
githubConnected={false}
/>,
);
await flushKit();
const connectButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Connect GitHub'),
);
expect(connectButton).toBeTruthy();
await act(async () => {
connectButton?.click();
await Promise.resolve();
});
expect(onConnectRepo).toHaveBeenCalledTimes(1);
});
it('keeps the Connect GitHub action when evidence notes exist but file snapshots are still missing', async () => {
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[
workspaceFile('DESIGN.md'),
workspaceFile('context/github/acme-product.md'),
workspaceFile('preview/colors.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({
provenance: {
companyBlurb: 'Acme analytics workspace',
githubUrls: ['https://github.com/acme/product'],
},
})}
onConnectRepo={vi.fn()}
githubConnected={false}
/>,
);
await flushKit();
expect(container.textContent).toContain('Connect your repo to pull aspects of your design system');
const connectButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Connect GitHub'),
);
expect(connectButton).toBeTruthy();
});
it('shows re-import guidance instead of Connect when GitHub is already connected', async () => {
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[
workspaceFile('DESIGN.md'),
workspaceFile('context/github/acme-product.md'),
workspaceFile('preview/colors.html'),
]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({
provenance: {
companyBlurb: 'Acme analytics workspace',
githubUrls: ['https://github.com/acme/product'],
},
})}
onConnectRepo={vi.fn()}
githubConnected
/>,
);
await flushKit();
expect(container.textContent).toContain('GitHub is connected');
expect(container.textContent).not.toContain('Connect your repo to pull aspects of your design system');
const importButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Import repo'),
);
expect(importButton).toBeTruthy();
});
it('routes the default button to the selected design system id', async () => {
const onSetDefault = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('preview/colors.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({ status: 'published' })}
defaultDesignSystemId="default"
onSetDefaultDesignSystem={onSetDefault}
/>,
);
await flushKit();
const moreTrigger = container.querySelector<HTMLButtonElement>(
'[data-testid="design-kit-more-actions"]',
);
expect(moreTrigger).toBeTruthy();
await act(async () => {
moreTrigger?.click();
await Promise.resolve();
});
const defaultItem = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="menuitemcheckbox"]'),
).find((button) => button.textContent?.includes('Default for new chats'));
expect(defaultItem).toBeTruthy();
expect(defaultItem?.getAttribute('aria-checked')).toBe('false');
await act(async () => {
defaultItem?.click();
await Promise.resolve();
});
expect(onSetDefault).toHaveBeenCalledWith('user:acme');
});
it('clears the default design system when the selected default button is pressed', async () => {
const onSetDefault = vi.fn();
const container = renderWorkspace(
<FileWorkspace
projectId="ds-acme"
projectKind="prototype"
files={[workspaceFile('DESIGN.md'), workspaceFile('preview/colors.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
designSystemProject={designSystem({ status: 'published' })}
defaultDesignSystemId="user:acme"
onSetDefaultDesignSystem={onSetDefault}
/>,
);
await flushKit();
const moreTrigger = container.querySelector<HTMLButtonElement>(
'[data-testid="design-kit-more-actions"]',
);
await act(async () => {
moreTrigger?.click();
await Promise.resolve();
});
const defaultItem = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="menuitemcheckbox"]'),
).find((button) => button.textContent?.includes('Chat default'));
expect(defaultItem?.getAttribute('aria-checked')).toBe('true');
await act(async () => {
defaultItem?.click();
await Promise.resolve();
});
expect(onSetDefault).toHaveBeenCalledWith(null);
});
});