Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s

This repository contains the open-design daemon CLI source code, built
and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use
by the HelixMind /design slash command.

Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
marco
2026-05-06 20:50:24 +02:00
commit 5dd70b5016
1336 changed files with 287186 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { AgentIcon } from '../../src/components/AgentIcon';
describe('AgentIcon', () => {
it('renders Qoder with a dedicated supplied-mark visual', () => {
const markup = renderToStaticMarkup(<AgentIcon id="qoder" size={24} />);
expect(markup).toContain('background:#111113');
expect(markup).toContain('fill="#2ADB5C"');
expect(markup).toContain('fill="#FFFFFF"');
});
it('keeps unknown agents on the generic fallback visual', () => {
const markup = renderToStaticMarkup(<AgentIcon id="unknown-agent" size={24} />);
expect(markup).toContain('linear-gradient(135deg, #6b7280 0%, #4b5563 100%)');
expect(markup).not.toContain('fill="#2ADB5C"');
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { assistantRoleLabel } from '../../src/components/AssistantMessage';
import type { ChatMessage } from '../../src/types';
const t = () => 'Assistant';
describe('assistantRoleLabel', () => {
it('prefers the persisted assistant display name over the protocol id', () => {
const message: ChatMessage = {
id: 'message-1',
role: 'assistant',
content: '',
agentId: 'openai-api',
agentName: 'OpenAI API · google/gemma-4-e4b',
};
expect(assistantRoleLabel(message, t)).toBe('OpenAI API · google/gemma-4-e4b');
});
it('maps API protocol ids to readable labels when no display name is saved', () => {
const message: ChatMessage = {
id: 'message-2',
role: 'assistant',
content: '',
agentId: 'openai-api',
};
expect(assistantRoleLabel(message, t)).toBe('OpenAI API');
});
it('normalizes saved API protocol ids used as display names', () => {
const message: ChatMessage = {
id: 'message-3',
role: 'assistant',
content: '',
agentName: 'openai-api',
};
expect(assistantRoleLabel(message, t)).toBe('OpenAI API');
});
it('preserves an explicit local agent model in the display name', () => {
const message: ChatMessage = {
id: 'message-4',
role: 'assistant',
content: '',
agentId: 'claude',
agentName: 'Claude · claude-sonnet-4-6',
};
expect(assistantRoleLabel(message, t)).toBe('Claude · claude-sonnet-4-6');
});
it('adds the model reported by a local CLI initializing event', () => {
const message: ChatMessage = {
id: 'message-5',
role: 'assistant',
content: '',
agentId: 'claude',
agentName: 'Claude',
events: [{ kind: 'status', label: 'initializing', detail: 'claude-sonnet-4-6' }],
};
expect(assistantRoleLabel(message, t)).toBe('Claude · claude-sonnet-4-6');
});
});

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { STATUS_LABEL_KEYS, STATUS_ORDER } from '../../src/components/DesignsTab';
describe('DesignsTab status metadata', () => {
it('places awaiting_input between running and succeeded', () => {
expect(STATUS_ORDER).toEqual([
'not_started',
'running',
'awaiting_input',
'succeeded',
'failed',
'canceled',
]);
});
it('maps awaiting_input to the i18n label key', () => {
expect(STATUS_LABEL_KEYS.awaiting_input).toBe('designs.status.awaitingInput');
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
isTrustedConnectorCallbackOrigin,
sortConnectorsForDisplay,
sortConnectorsForSearch,
} from '../../src/components/EntryView';
describe('connector OAuth callback origin', () => {
it('accepts the app origin', () => {
expect(isTrustedConnectorCallbackOrigin('http://127.0.0.1:60809', 'http://127.0.0.1:60809')).toBe(true);
});
it('accepts loopback daemon origins on a different port', () => {
expect(isTrustedConnectorCallbackOrigin('http://127.0.0.1:60807', 'http://127.0.0.1:60809')).toBe(true);
expect(isTrustedConnectorCallbackOrigin('http://localhost:60807', 'http://127.0.0.1:60809')).toBe(true);
});
it('rejects non-loopback origins', () => {
expect(isTrustedConnectorCallbackOrigin('https://example.com', 'http://127.0.0.1:60809')).toBe(false);
expect(isTrustedConnectorCallbackOrigin('file://callback', 'http://127.0.0.1:60809')).toBe(false);
});
});
describe('connector display sorting', () => {
it('places connected connectors first and sorts the rest alphabetically', () => {
const sorted = sortConnectorsForDisplay([
{ id: 'zapi', name: 'Zapier', provider: 'Composio', category: 'Automation', status: 'available', tools: [] },
{ id: 'gmail', name: 'Gmail', provider: 'Composio', category: 'Email', status: 'connected', tools: [] },
{ id: 'airtable', name: 'Airtable', provider: 'Composio', category: 'Data', status: 'available', tools: [] },
{ id: 'github', name: 'GitHub', provider: 'Composio', category: 'Code', status: 'connected', tools: [] },
{ id: 'calendar', name: 'Calendar', provider: 'Composio', category: 'Calendar', status: 'available', tools: [] },
]);
expect(sorted.map((connector) => connector.id)).toEqual([
'github',
'gmail',
'airtable',
'calendar',
'zapi',
]);
});
it('ranks exact and prefix name/provider matches above description matches', () => {
const sorted = sortConnectorsForSearch([
{
id: 'linear',
name: 'Linear',
provider: 'Composio',
category: 'Project management',
status: 'connected',
description: 'Sync issues from GitHub repositories.',
tools: [],
},
{
id: 'github-enterprise',
name: 'GitHub Enterprise',
provider: 'Composio',
category: 'Code',
status: 'available',
tools: [],
},
{
id: 'github',
name: 'GitHub',
provider: 'Composio',
category: 'Code',
status: 'available',
tools: [],
},
{
id: 'slack',
name: 'Slack',
provider: 'Composio',
category: 'Communication',
status: 'connected',
tools: [
{
title: 'Post GitHub release',
name: 'post_github_release',
safety: { sideEffect: 'write', approval: 'confirm', reason: 'Posts a message.' },
refreshEligible: false,
},
],
},
], 'github');
expect(sorted.map((connector) => connector.id)).toEqual([
'github',
'github-enterprise',
'slack',
'linear',
]);
});
});

View File

@@ -0,0 +1,278 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import {
FileViewer,
LiveArtifactRefreshHistoryPanel,
SvgViewer,
} from '../../src/components/FileViewer';
import type { LiveArtifact, ProjectFile } from '../../src/types';
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
return {
name: 'asset.png',
path: 'asset.png',
type: 'file',
size: 1024,
mtime: 1710000000,
kind: 'image',
mime: 'image/png',
...overrides,
};
}
describe('FileViewer SVG artifacts', () => {
it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => {
const file = baseFile({
name: 'diagram.svg',
path: 'diagram.svg',
mime: 'image/svg+xml',
artifactManifest: {
version: 1,
kind: 'svg',
title: 'Diagram',
entry: 'diagram.svg',
renderer: 'svg',
exports: ['svg'],
},
});
const markup = renderToStaticMarkup(<FileViewer projectId="project-1" file={file} />);
expect(markup).toContain('class="viewer svg-viewer"');
expect(markup).not.toContain('class="viewer image-viewer"');
expect(markup).toContain('Preview');
expect(markup).toContain('Source');
expect(markup).toContain('src="/api/projects/project-1/raw/diagram.svg?v=1710000000&amp;r=0"');
});
it('keeps normal image artifacts on the existing image viewer path', () => {
const file = baseFile({ name: 'photo.png', path: 'photo.png' });
const markup = renderToStaticMarkup(<FileViewer projectId="project-1" file={file} />);
expect(markup).toContain('class="viewer image-viewer"');
expect(markup).not.toContain('class="viewer svg-viewer"');
expect(markup).not.toContain('class="viewer-tabs"');
});
it('marks preview and source modes through the SVG viewer toggle controls', () => {
const file = baseFile({ name: 'diagram.svg', path: 'diagram.svg', mime: 'image/svg+xml' });
const previewMarkup = renderToStaticMarkup(
<SvgViewer projectId="project-1" file={file} initialMode="preview" />,
);
const sourceMarkup = renderToStaticMarkup(
<SvgViewer
projectId="project-1"
file={file}
initialMode="source"
initialSource="<svg><title>Diagram</title></svg>"
/>,
);
expect(previewMarkup).toContain('class="viewer-tab active" aria-pressed="true">Preview</button>');
expect(previewMarkup).toContain('aria-pressed="false">Source</button>');
expect(previewMarkup).toContain('<img');
expect(sourceMarkup).toContain('aria-pressed="false">Preview</button>');
expect(sourceMarkup).toContain('class="viewer-tab active" aria-pressed="true">Source</button>');
expect(sourceMarkup).toContain('class="viewer-source"');
expect(sourceMarkup).not.toContain('<img');
});
it('URL-loads a plain HTML preview iframe instead of inlining via srcDoc', () => {
const file = baseFile({
name: 'page.html',
path: 'page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Page',
entry: 'page.html',
renderer: 'html',
exports: ['html'],
},
});
const markup = renderToStaticMarkup(
<FileViewer projectId="project-1" file={file} liveHtml="<html><body>hi</body></html>" />,
);
expect(markup).toContain('data-testid="artifact-preview-frame"');
expect(markup).toContain('data-od-render-mode="url-load"');
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0"');
expect(markup).not.toContain('data-od-render-mode="srcdoc"');
});
it('keeps decks on the srcDoc path so the deck postMessage bridge can run', () => {
const file = baseFile({
name: 'deck.html',
path: 'deck.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'deck',
title: 'Deck',
entry: 'deck.html',
renderer: 'deck-html',
exports: ['html'],
},
});
const markup = renderToStaticMarkup(
<FileViewer
projectId="project-1"
file={file}
isDeck
liveHtml={'<html><body><section class="slide">one</section></body></html>'}
/>,
);
expect(markup).toContain('data-testid="artifact-preview-frame"');
expect(markup).toContain('data-od-render-mode="srcdoc"');
expect(markup).not.toContain('data-od-render-mode="url-load"');
});
it('falls back to srcDoc when the HTML body looks deck-shaped even without an isDeck hint', () => {
const file = baseFile({
name: 'inferred.html',
path: 'inferred.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Inferred',
entry: 'inferred.html',
renderer: 'html',
exports: ['html'],
},
});
const markup = renderToStaticMarkup(
<FileViewer
projectId="project-1"
file={file}
liveHtml={'<html><body><section class="slide">one</section><section class="slide">two</section></body></html>'}
/>,
);
expect(markup).toContain('data-od-render-mode="srcdoc"');
expect(markup).not.toContain('data-od-render-mode="url-load"');
});
it('renders unsafe SVG source as escaped text instead of executable markup', () => {
const file = baseFile({ name: 'unsafe.svg', path: 'unsafe.svg', mime: 'image/svg+xml' });
const unsafeSource = [
'<svg onload="alert(1)"><script>alert(2)</script><text>Logo</text></svg>',
'<svg><![CDATA[<script>alert(3)</script>]]></svg>',
].join('\n');
const markup = renderToStaticMarkup(
<SvgViewer
projectId="project-1"
file={file}
initialMode="source"
initialSource={unsafeSource}
/>,
);
expect(markup).toContain('&lt;svg onload=&quot;alert(1)&quot;&gt;');
expect(markup).toContain('&lt;script&gt;alert(2)&lt;/script&gt;');
expect(markup).toContain('&lt;![CDATA[&lt;script&gt;alert(3)&lt;/script&gt;]]&gt;');
expect(markup).not.toContain('<svg onload');
expect(markup).not.toContain('<script>');
expect(markup).not.toContain('<![CDATA[');
expect(markup).not.toContain('dangerouslySetInnerHTML');
});
});
function baseLiveArtifact(overrides: Partial<LiveArtifact> = {}): LiveArtifact {
const artifact: LiveArtifact = {
schemaVersion: 1,
id: 'la_1',
projectId: 'proj_1',
title: 'Launch Metrics',
slug: 'launch-metrics',
status: 'active',
pinned: false,
preview: { type: 'html', entry: 'index.html' },
refreshStatus: 'idle',
createdAt: '2026-04-29T12:00:00.000Z',
updatedAt: '2026-04-29T12:00:00.000Z',
document: {
format: 'html_template_v1',
templatePath: 'template.html',
generatedPreviewPath: 'index.html',
dataPath: 'data.json',
dataJson: { title: 'Launch Metrics' },
},
};
return { ...artifact, ...overrides, document: overrides.document ?? artifact.document };
}
describe('LiveArtifactRefreshHistoryPanel', () => {
it('renders a human-readable status instead of raw JSON when no history exists', () => {
const markup = renderToStaticMarkup(
<LiveArtifactRefreshHistoryPanel
liveArtifact={baseLiveArtifact({ refreshStatus: 'never' })}
fallbackRefreshStatus="never"
isRunning={false}
sessionEvents={[]}
/>,
);
// Status badge with tone, not JSON
expect(markup).toContain('live-artifact-refresh-panel');
expect(markup).toContain('data-testid="live-artifact-refresh-status-badge"');
expect(markup).toContain('Not refreshable');
expect(markup).toContain('Last refreshed');
expect(markup).toContain('Never');
expect(markup).toContain('No refresh activity yet in this session');
// Raw JSON is available but tucked inside a collapsed <details>, not exposed as the primary view.
expect(markup).toContain('<details');
expect(markup).toContain('Advanced debug metadata');
const detailsIndex = markup.indexOf('<details');
const rawJsonIndex = markup.search(/<pre class="viewer-source">\s*\{/);
expect(detailsIndex).toBeGreaterThanOrEqual(0);
expect(rawJsonIndex).toBeGreaterThan(detailsIndex);
});
it('surfaces running state and a session timeline with duration + source counts', () => {
const now = Date.now();
const markup = renderToStaticMarkup(
<LiveArtifactRefreshHistoryPanel
liveArtifact={baseLiveArtifact({
refreshStatus: 'succeeded',
lastRefreshedAt: new Date(now - 45_000).toISOString(),
})}
fallbackRefreshStatus="succeeded"
isRunning
sessionEvents={[
{ id: 1, phase: 'started', at: now - 5_000 },
{
id: 2,
phase: 'succeeded',
at: now - 1_200,
durationMs: 3_800,
refreshedSourceCount: 2,
},
]}
/>,
);
// isRunning wins over persisted `succeeded`
expect(markup).toContain('Refreshing');
// Both timeline rows are present
expect(markup).toContain('Started');
expect(markup).toContain('Succeeded');
// Source count + duration are humanized (3.8s), not raw ms
expect(markup).toContain('2 sources updated');
expect(markup).toContain('3.8s');
});
});

View File

@@ -0,0 +1,246 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
import { projectSplitClassName } from '../../src/components/ProjectView';
describe('FileWorkspace upload input', () => {
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
expect(markup).toContain('data-testid="design-files-upload-input"');
expect(markup).not.toContain('accept=');
});
it('keeps focus mode controls in the workspace tab bar', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
focusMode={false}
onFocusModeChange={vi.fn()}
/>,
);
expect(markup).toContain('data-testid="workspace-focus-toggle"');
expect(markup).toContain('Focus workspace');
});
it('keeps the focus mode action outside the horizontally scrollable tablist', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
focusMode={false}
onFocusModeChange={vi.fn()}
/>,
);
expect(markup).toContain('class="ws-tabs-shell"');
expect(markup).toContain('class="ws-tabs-actions"');
expect(markup).toMatch(
/<div class="ws-tabs-bar" role="tablist"[^>]*>[\s\S]*?<\/div><div class="ws-tabs-actions">/,
);
});
it('labels the same workspace control as chat restore while focused', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
focusMode
onFocusModeChange={vi.fn()}
/>,
);
expect(markup).toContain('Show chat');
});
});
describe('projectSplitClassName', () => {
it('marks the project split as focused so the chat pane can collapse globally', () => {
expect(projectSplitClassName(false)).toBe('split');
expect(projectSplitClassName(true)).toBe('split split-focus');
});
});
describe('scrollWorkspaceTabsWithWheel', () => {
function makeTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
return { scrollLeft, scrollWidth, clientWidth } as HTMLDivElement;
}
function makeClampedTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
let value = scrollLeft;
return {
scrollWidth,
clientWidth,
get scrollLeft() {
return value;
},
set scrollLeft(next: number) {
value = Math.min(Math.max(next, 0), scrollWidth - clientWidth);
},
} as HTMLDivElement;
}
it('maps vertical mouse wheel movement to horizontal tab scrolling', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(52);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('supports reverse vertical wheel movement', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(52);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: -40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('normalizes line-based wheel deltas to useful pixel movement', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: false,
deltaMode: 1,
deltaX: 0,
deltaY: 3,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(60);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('normalizes page-based wheel deltas to useful pixel movement', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12, 600, 200);
const event = {
ctrlKey: false,
deltaMode: 2,
deltaX: 0,
deltaY: 1,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(172);
expect(preventDefault).toHaveBeenCalledTimes(1);
});
it('leaves native horizontal wheel gestures alone', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 50,
deltaY: 10,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).not.toHaveBeenCalled();
});
it('leaves ctrl-wheel zoom gestures alone', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12);
const event = {
ctrlKey: true,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).not.toHaveBeenCalled();
});
it('does not intercept vertical wheel movement when tabs do not overflow', () => {
const preventDefault = vi.fn();
const currentTarget = makeTabBar(12, 200, 200);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(12);
expect(preventDefault).not.toHaveBeenCalled();
});
it('lets page scrolling continue when the tab bar is already at the wheel boundary', () => {
const preventDefault = vi.fn();
const currentTarget = makeClampedTabBar(200, 400, 200);
const event = {
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 40,
preventDefault,
} as unknown as WheelEvent;
scrollWorkspaceTabsWithWheel(currentTarget, event);
expect(currentTarget.scrollLeft).toBe(200);
expect(preventDefault).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary } from '../../src/components/ManualEditPanel';
import type { ManualEditTarget } from '../../src/edit-mode/types';
const target: ManualEditTarget = {
id: 'hero-title',
kind: 'text',
label: 'Hero Title',
tagName: 'h1',
className: 'hero',
text: 'Original',
rect: { x: 0, y: 0, width: 120, height: 40 },
fields: { text: 'Original' },
attributes: { 'data-od-id': 'hero-title' },
styles: {
color: '',
backgroundColor: '',
fontSize: '',
fontWeight: '',
textAlign: '',
padding: '',
margin: '',
borderRadius: '',
border: '',
width: '',
minHeight: '',
},
outerHtml: '<h1 data-od-id="hero-title">Original</h1>',
};
describe('ManualEditPanel', () => {
let dom: JSDOM;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>');
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = dom.window.document.querySelector('#root') as HTMLDivElement;
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
dom.window.close();
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
Reflect.deleteProperty(globalThis, 'HTMLElement');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
it('opens with target metadata and calls selection from the layers rail', () => {
const onSelectTarget = vi.fn();
renderPanel({ onSelectTarget });
expect(host.textContent).toContain('Hero Title');
expect(host.textContent).toContain('hero-title');
click(buttonByText('Hero Title'));
expect(onSelectTarget).toHaveBeenCalledWith(target);
});
it('builds content patches from the active target', () => {
const onApplyPatch = vi.fn();
renderPanel({ onApplyPatch });
click(buttonByText('Apply Content'));
expect(onApplyPatch).toHaveBeenCalledWith(
{ id: 'hero-title', kind: 'set-text', value: 'Updated copy' },
'Content: Hero Title',
);
});
it('shows invalid attribute JSON without applying a write patch', () => {
const onApplyPatch = vi.fn();
const onError = vi.fn();
renderPanel({ onApplyPatch, onError, attributesText: '{bad' });
click(buttonByText('Attributes'));
click(buttonByText('Apply Attributes'));
expect(onError).toHaveBeenCalled();
expect(onApplyPatch).not.toHaveBeenCalled();
});
it('summarizes full-source history entries without rendering the full file', () => {
const source = '<html><body>' + 'x'.repeat(10_000) + '</body></html>';
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).toBe(
JSON.stringify({ kind: 'set-full-source', bytes: source.length }),
);
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).not.toContain('x'.repeat(100));
});
function renderPanel({
onSelectTarget = vi.fn(),
onApplyPatch = vi.fn(),
onError = vi.fn(),
attributesText = '{}',
}: {
onSelectTarget?: ReturnType<typeof vi.fn>;
onApplyPatch?: ReturnType<typeof vi.fn>;
onError?: ReturnType<typeof vi.fn>;
attributesText?: string;
}) {
const draft = {
...emptyManualEditDraft('<html></html>'),
text: 'Updated copy',
attributesText,
outerHtml: target.outerHtml,
};
act(() => {
root.render(
<ManualEditPanel
targets={[target]}
selectedTarget={target}
draft={draft}
history={[]}
error={null}
canUndo={false}
canRedo={false}
onSelectTarget={onSelectTarget}
onDraftChange={vi.fn()}
onApplyPatch={onApplyPatch}
onError={onError}
onCancelDraft={vi.fn()}
onUndo={vi.fn()}
onRedo={vi.fn()}
/>,
);
});
}
function buttonByText(text: string): HTMLButtonElement {
const buttons = Array.from(host.querySelectorAll('button'));
const button = buttons.find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button as HTMLButtonElement;
}
function click(button: HTMLButtonElement): void {
act(() => {
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
});
}
});

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { supportedModels } from '../../src/components/NewProjectPanel';
import { IMAGE_MODELS } from '../../src/media/models';
describe('NewProjectPanel image provider visibility', () => {
it('shows Nano Banana in supported image models', () => {
const models = supportedModels('image', IMAGE_MODELS);
expect(models.some((model) => model.provider === 'nanobanana')).toBe(true);
expect(models.some((model) => model.id === 'gemini-3.1-flash-image-preview')).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { PreviewModal } from '../../src/components/PreviewModal';
describe('PreviewModal sandbox isolation', () => {
it('renders generated previews without same-origin sandbox access', () => {
const markup = renderToStaticMarkup(
<PreviewModal
title="Unsafe preview"
views={[
{
id: 'preview',
label: 'Preview',
html: '<script>window.parent.document.body.innerHTML="owned"</script>',
},
]}
exportTitleFor={() => 'unsafe-preview'}
onClose={() => {}}
/>,
);
expect(markup).toContain('sandbox="allow-scripts"');
expect(markup).not.toContain('allow-same-origin');
expect(markup).toContain('srcDoc=');
});
it('keeps deck srcdoc handling for deck preview views', () => {
const markup = renderToStaticMarkup(
<PreviewModal
title="Deck preview"
views={[
{
id: 'deck',
label: 'Deck',
html: '<section class="slide">one</section><section class="slide">two</section>',
deck: true,
},
]}
exportTitleFor={() => 'deck-preview'}
onClose={() => {}}
/>,
);
expect(markup).toContain('sandbox="allow-scripts"');
expect(markup).not.toContain('allow-same-origin');
expect(markup).toContain('od:slide');
});
});

View File

@@ -0,0 +1,143 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextCursor, QuickSwitcher, scoreMatch } from '../../src/components/QuickSwitcher';
import type { ProjectFile } from '../../src/types';
// QuickSwitcher reads recents from localStorage during render. The default
// vitest env is node, so stub a minimal Storage to keep the component
// happy and the assertions deterministic.
function createStorageStub(): Storage {
const store = new Map<string, string>();
return {
getItem: (k) => (store.has(k) ? store.get(k)! : null),
setItem: (k, v) => { store.set(k, v); },
removeItem: (k) => { store.delete(k); },
clear: () => { store.clear(); },
key: (i) => Array.from(store.keys())[i] ?? null,
get length() { return store.size; },
} satisfies Storage;
}
beforeEach(() => {
vi.stubGlobal('localStorage', createStorageStub());
});
afterEach(() => {
vi.unstubAllGlobals();
});
function file(overrides: Partial<ProjectFile>): ProjectFile {
return {
name: 'index.html',
path: 'index.html',
type: 'file',
size: 1024,
mtime: 1700000000,
kind: 'html',
mime: 'text/html',
...overrides,
};
}
describe('scoreMatch — fuzzy ranking tiers', () => {
it('exact basename match scores highest', () => {
expect(scoreMatch(file({ name: 'app.tsx' }), 'app.tsx')).toBe(1000);
});
it('prefix-on-basename outranks substring-on-basename', () => {
const prefix = scoreMatch(file({ name: 'header.tsx' }), 'head');
const substring = scoreMatch(file({ name: 'page-header.tsx' }), 'head');
expect(prefix).toBeGreaterThan(substring);
});
it('substring-on-basename outranks substring-on-path-only', () => {
const inBase = scoreMatch(file({ name: 'utils/helper.ts' }), 'help');
const onlyInPath = scoreMatch(file({ name: 'helpers/main.ts' }), 'help');
// 'help' is in the basename of utils/helper.ts ('helper.ts')
// 'help' is only in the dir of helpers/main.ts ('helpers')
expect(inBase).toBeGreaterThan(onlyInPath);
});
it('returns 0 when the query matches neither basename nor path', () => {
expect(scoreMatch(file({ name: 'app.tsx' }), 'xyz')).toBe(0);
});
it('matching is case-insensitive (queries normalized to lowercase by caller)', () => {
// The component lowercases the query before calling scoreMatch, so
// scoreMatch itself can rely on the contract that q is already lower.
expect(scoreMatch(file({ name: 'Hero.tsx' }), 'hero')).toBeGreaterThan(0);
});
});
describe('nextCursor — arrow-key wrap behavior', () => {
it('moves forward through the list without wrapping in the middle', () => {
expect(nextCursor(0, 5, 1)).toBe(1);
expect(nextCursor(2, 5, 1)).toBe(3);
});
it('moves backward through the list without wrapping in the middle', () => {
expect(nextCursor(3, 5, -1)).toBe(2);
expect(nextCursor(1, 5, -1)).toBe(0);
});
it('wraps from the last row to the first when pressing ↓', () => {
// Row 4 (last of 5) → 0 (first). Documented behavior in the PR test plan.
expect(nextCursor(4, 5, 1)).toBe(0);
});
it('wraps from the first row to the last when pressing ↑', () => {
expect(nextCursor(0, 5, -1)).toBe(4);
});
it('returns 0 when the list is empty (no division-by-zero, no NaN)', () => {
expect(nextCursor(0, 0, 1)).toBe(0);
expect(nextCursor(0, 0, -1)).toBe(0);
});
it('stays put on a single-item list (wrap is a no-op)', () => {
expect(nextCursor(0, 1, 1)).toBe(0);
expect(nextCursor(0, 1, -1)).toBe(0);
});
});
describe('QuickSwitcher render', () => {
it('renders the empty state when the project has no files', () => {
const markup = renderToStaticMarkup(
<QuickSwitcher projectId="p1" files={[]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
);
// Empty-state copy comes from i18n; the rendered class is stable.
expect(markup).toContain('class="qs-empty"');
expect(markup).not.toContain('class="qs-row');
});
it('renders a row per file when no query is set', () => {
const files = [
file({ name: 'a.html', mtime: 3 }),
file({ name: 'b.html', mtime: 2 }),
file({ name: 'c.html', mtime: 1 }),
];
const markup = renderToStaticMarkup(
<QuickSwitcher projectId="p1" files={files} onOpenFile={vi.fn()} onClose={vi.fn()} />,
);
const rowCount = (markup.match(/class="qs-row /g) ?? []).length;
expect(rowCount).toBe(3);
});
it('exposes the keyboard hints in the footer', () => {
const markup = renderToStaticMarkup(
<QuickSwitcher projectId="p1" files={[file({})]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
);
// Three <kbd> hints (↑↓ / ↵ / esc).
const kbdCount = (markup.match(/<kbd>/g) ?? []).length;
expect(kbdCount).toBeGreaterThanOrEqual(3);
});
it('renders the input placeholder so users discover the palette is searchable', () => {
const markup = renderToStaticMarkup(
<QuickSwitcher projectId="p1" files={[]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
);
expect(markup).toContain('class="qs-input"');
expect(markup).toContain('placeholder=');
});
});

View File

@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest';
import {
agentRefreshOptionsForConfig,
isValidApiBaseUrl,
switchApiProtocolConfig,
updateAgentCliEnvValue,
updateCurrentApiProtocolConfig,
} from '../../src/components/SettingsDialog';
import type { AppConfig } from '../../src/types';
const baseConfig: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'anthropic',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: null,
skillId: null,
designSystemId: null,
};
describe('SettingsDialog API protocol switching', () => {
it('stores the current custom protocol config while preserving custom endpoint details', () => {
const config: AppConfig = {
...baseConfig,
apiKey: 'anthropic-key',
apiProviderBaseUrl: null,
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
apiProviderBaseUrl: null,
});
expect(next.apiProtocolConfigs?.anthropic).toMatchObject({
apiKey: 'anthropic-key',
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
apiProviderBaseUrl: null,
});
});
it('restores each protocol draft instead of leaking shared field values', () => {
const openai = switchApiProtocolConfig(baseConfig, 'openai');
const openaiEdited = updateCurrentApiProtocolConfig(openai, {
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
const google = switchApiProtocolConfig(openaiEdited, 'google');
const googleEdited = updateCurrentApiProtocolConfig(google, {
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
const restoredOpenai = switchApiProtocolConfig(googleEdited, 'openai');
expect(restoredOpenai).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
expect(restoredOpenai.apiProtocolConfigs?.google).toMatchObject({
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
});
it('loads the new protocol default on first visit', () => {
expect(switchApiProtocolConfig(baseConfig, 'openai')).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
});
});
it('auto-fills Google defaults when switching from a selected known provider', () => {
expect(switchApiProtocolConfig(baseConfig, 'google')).toMatchObject({
mode: 'api',
apiProtocol: 'google',
apiKey: '',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.0-flash',
apiProviderBaseUrl: 'https://generativelanguage.googleapis.com',
});
});
it('keeps Azure API version in the Azure draft only', () => {
const config: AppConfig = {
...baseConfig,
apiProtocol: 'azure',
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
apiProtocol: 'openai',
apiKey: '',
apiVersion: '',
});
expect(next.apiProtocolConfigs?.azure).toMatchObject({
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
});
});
});
describe('SettingsDialog API Base URL validation', () => {
it('accepts public http/https URLs and loopback local providers', () => {
expect(isValidApiBaseUrl('https://api.openai.com/v1')).toBe(true);
expect(isValidApiBaseUrl('http://localhost:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://127.0.0.1:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://[::1]:11434/v1')).toBe(true);
expect(isValidApiBaseUrl(' https://resource.openai.azure.com ')).toBe(true);
expect(isValidApiBaseUrl('ddddd')).toBe(false);
expect(isValidApiBaseUrl('api.openai.com/v1')).toBe(false);
expect(isValidApiBaseUrl('ftp://api.example.com')).toBe(false);
expect(isValidApiBaseUrl('http:api.example.com')).toBe(false);
expect(isValidApiBaseUrl('https://')).toBe(false);
expect(isValidApiBaseUrl('http://10.0.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://169.254.1.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://172.16.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://192.168.1.5:11434/v1')).toBe(false);
});
});
describe('SettingsDialog agent CLI env settings', () => {
it('updates supported per-agent CLI env values without dropping sibling agents', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'claude',
'CLAUDE_CONFIG_DIR',
' ~/.claude-2 ',
);
expect(next.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
codex: { CODEX_HOME: '~/.codex-alt' },
});
});
it('removes empty per-agent CLI env entries', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'claude',
'CLAUDE_CONFIG_DIR',
'',
);
expect(next.agentCliEnv).toEqual({
codex: { CODEX_HOME: '~/.codex-alt' },
});
});
it('passes pending CLI env prefs through agent rescan options', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
},
};
expect(agentRefreshOptionsForConfig(config)).toEqual({
throwOnError: true,
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
},
});
});
it('passes an empty CLI env object through agent rescan after fields are cleared', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {},
};
expect(agentRefreshOptionsForConfig(config)).toEqual({
throwOnError: true,
agentCliEnv: {},
});
});
});

View File

@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssistantMessage } from '../../src/components/AssistantMessage';
import type { AgentEvent, ChatMessage } from '../../src/types';
function messageWithEvents(events: AgentEvent[]): ChatMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: '',
events,
startedAt: 1_000,
endedAt: 3_000,
};
}
describe('AssistantMessage unfinished todo state', () => {
afterEach(() => cleanup());
it('keeps Done for a completed latest TodoWrite fixture', () => {
render(
<AssistantMessage
message={messageWithEvents([
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Ship layout', status: 'completed' }] },
},
])}
streaming={false}
projectId="project-1"
isLast
/>,
);
expect(screen.getByText('Done')).toBeTruthy();
expect(screen.queryByText('Stopped with unfinished work')).toBeNull();
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
});
it('shows unfinished state and passes unfinished todos to the continue callback', () => {
const onContinue = vi.fn();
render(
<AssistantMessage
message={messageWithEvents([
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: {
todos: [
{ content: 'Draft layout', status: 'completed' },
{
content: 'Build components',
status: 'in_progress',
activeForm: 'Building components',
},
{ content: 'Run QA', status: 'pending' },
],
},
},
])}
streaming={false}
projectId="project-1"
isLast
onContinueRemainingTasks={onContinue}
/>,
);
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
expect(screen.getByText('2 task(s) remain')).toBeTruthy();
const remainingList = screen.getByText('2 task(s) remain').closest('.unfinished-todos');
expect(remainingList).not.toBeNull();
expect(within(remainingList as HTMLElement).getByText('Building components')).toBeTruthy();
expect(within(remainingList as HTMLElement).getByText('Run QA')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Continue remaining tasks' }));
expect(onContinue).toHaveBeenCalledWith([
{
content: 'Build components',
status: 'in_progress',
activeForm: 'Building components',
},
{ content: 'Run QA', status: 'pending', activeForm: undefined },
]);
});
it('hides the continue button on older assistant turns', () => {
render(
<AssistantMessage
message={messageWithEvents([
{
kind: 'tool_use',
id: 'todo-1',
name: 'TodoWrite',
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
},
])}
streaming={false}
projectId="project-1"
isLast={false}
onContinueRemainingTasks={vi.fn()}
/>,
);
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
expect(screen.getByText('1 task(s) remain')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
});
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { decideAutoOpenAfterWrite } from '../../src/components/auto-open-file';
describe('decideAutoOpenAfterWrite', () => {
it('returns shouldOpen=false when filePath is empty', () => {
const result = decideAutoOpenAfterWrite('', [{ name: 'index.html' }]);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('returns shouldOpen=true when filePath equals a project file path', () => {
const result = decideAutoOpenAfterWrite('index.html', [
{ name: 'index.html', path: 'index.html' },
{ name: 'styles.css', path: 'styles.css' },
]);
expect(result).toEqual({ shouldOpen: true, fileName: 'index.html' });
});
it('returns shouldOpen=false when filePath has slashes but matches no project path', () => {
// Regression: this is the "rogue empty tab" case — the agent edited a
// file outside the project (e.g. an upstream repo's source file) and
// we must NOT open a placeholder tab for it. filePath has a slash, so
// the basename fallback is intentionally skipped.
const result = decideAutoOpenAfterWrite(
'/home/bryan/projects/open-design/apps/daemon/src/project-watchers.ts',
[
{ name: 'index.html', path: 'index.html' },
{ name: 'App.jsx', path: 'App.jsx' },
],
);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('falls back to basename match when filePath is just a basename', () => {
const result = decideAutoOpenAfterWrite('App.jsx', [
{ name: 'index.html', path: 'index.html' },
{ name: 'App.jsx', path: 'App.jsx' },
{ name: 'styles.css', path: 'styles.css' },
{ name: 'README.md', path: 'README.md' },
]);
expect(result).toEqual({ shouldOpen: true, fileName: 'App.jsx' });
});
it('matches an absolute filePath via path-suffix against a nested project file', () => {
// Real-world case: the agent passes an absolute file_path; the project
// file lives at "prototype/App.jsx". The decision must still resolve
// unambiguously, returning the project-relative file name.
const result = decideAutoOpenAfterWrite(
'/home/bryan/projects/open-design/.od/projects/abc/prototype/App.jsx',
[
{ name: 'index.html', path: 'index.html' },
{ name: 'prototype/App.jsx', path: 'prototype/App.jsx' },
],
);
expect(result).toEqual({ shouldOpen: true, fileName: 'prototype/App.jsx' });
});
it('declines when an absolute filePath could match multiple nested project files (ambiguous)', () => {
// Two project files share the basename "App.jsx" but live in different
// subdirs. The agent's filePath ends with "/App.jsx" only, with no
// disambiguating subdirectory match — refuse rather than open the wrong file.
const result = decideAutoOpenAfterWrite(
'/some/external/path/App.jsx',
[
{ name: 'src/App.jsx', path: 'src/App.jsx' },
{ name: 'lib/App.jsx', path: 'lib/App.jsx' },
],
);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('declines when filePath has a slash and no project path is a suffix match', () => {
// Agent edited /upstream/repo/App.jsx; project also has prototype/App.jsx.
// The previous (basename-only) implementation would have opened the
// wrong file; the path-suffix check leaves zero matches and the
// basename fallback is intentionally skipped because filePath has a slash.
const result = decideAutoOpenAfterWrite('/upstream/repo/App.jsx', [
{ name: 'prototype/App.jsx', path: 'prototype/App.jsx' },
]);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
it('still works when ProjectFile entries omit the optional path field', () => {
// Defensive: ProjectFile.path is optional in the API contract. Fall
// back to using `name` (which the daemon populates with the full
// project-relative path) when path is missing.
const result = decideAutoOpenAfterWrite('index.html', [
{ name: 'index.html' },
{ name: 'styles.css' },
]);
expect(result).toEqual({ shouldOpen: true, fileName: 'index.html' });
});
it('declines a basename fallback when multiple project files share the basename', () => {
const result = decideAutoOpenAfterWrite('App.jsx', [
{ name: 'src/App.jsx', path: 'src/App.jsx' },
{ name: 'lib/App.jsx', path: 'lib/App.jsx' },
]);
expect(result).toEqual({ shouldOpen: false, fileName: null });
});
});

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChatPane } from '../../src/components/ChatPane';
import type { ChatMessage } from '../../src/types';
function renderChatPane(messages: ChatMessage[]) {
return render(
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => 'project-1'}
onSend={() => {}}
onStop={() => {}}
conversations={[]}
activeConversationId={null}
onSelectConversation={() => {}}
onDeleteConversation={() => {}}
/>,
);
}
describe('conversation timestamps', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('shows inline relative message times with exact hover text', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
renderChatPane([
{
id: 'user-1',
role: 'user',
content: 'Create a landing page',
createdAt: Date.parse('2025-01-15T12:00:00Z'),
},
{
id: 'assistant-1',
role: 'assistant',
content: 'Done',
createdAt: Date.parse('2025-01-15T12:01:00Z'),
},
]);
const firstTime = screen.getByText('2h ago');
expect(firstTime.tagName).toBe('TIME');
expect(firstTime.getAttribute('title')).toContain('2025');
expect(screen.getByText('1h ago').tagName).toBe('TIME');
});
it('adds day separators when a conversation crosses days', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-16T14:00:00Z'));
renderChatPane([
{
id: 'user-1',
role: 'user',
content: 'First request',
createdAt: Date.parse('2025-01-15T12:00:00Z'),
},
{
id: 'user-2',
role: 'user',
content: 'Follow-up',
createdAt: Date.parse('2025-01-16T12:00:00Z'),
},
]);
expect(screen.getAllByRole('separator')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,136 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileViewer } from '../../src/components/FileViewer';
import type { ProjectFile } from '../../src/types';
import { fetchProjectFileText } from '../../src/providers/registry';
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
fetchProjectFileText: vi.fn(),
};
});
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
let writeTextMock: ReturnType<typeof vi.fn>;
let originalClipboard: PropertyDescriptor | undefined;
let originalExecCommand: PropertyDescriptor | undefined;
function baseFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
return {
name: 'notes.md',
path: 'notes.md',
type: 'file',
size: 256,
mtime: 1710000000,
kind: 'text',
mime: 'text/markdown',
artifactManifest: {
version: 1,
kind: 'markdown-document',
title: 'Notes',
entry: 'notes.md',
renderer: 'markdown',
exports: ['md'],
},
...overrides,
};
}
describe('FileViewer markdown code block copy', () => {
beforeEach(() => {
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand');
mockedFetchProjectFileText.mockResolvedValue('```ts\nconsole.log("copied")\n```');
writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: writeTextMock,
},
});
});
afterEach(() => {
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
delete (navigator as { clipboard?: Clipboard }).clipboard;
}
if (originalExecCommand) {
Object.defineProperty(document, 'execCommand', originalExecCommand);
} else {
delete (document as { execCommand?: typeof document.execCommand }).execCommand;
}
cleanup();
vi.clearAllMocks();
});
it('copies fenced code blocks from the markdown preview', async () => {
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
await waitFor(() => {
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
});
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
expect(copyButton.tagName).toBe('BUTTON');
copyButton.focus();
expect(copyButton).toBe(document.activeElement);
fireEvent.click(copyButton);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith('console.log("copied")');
});
expect(copyButton).toBe(document.activeElement);
await waitFor(() => {
expect(copyButton.getAttribute('aria-label')).toBe('Copied!');
});
expect(screen.getByRole('status').textContent).toBe('Copied!');
});
it('copies empty fenced code blocks instead of treating the button as broken', async () => {
mockedFetchProjectFileText.mockResolvedValue('```ts\n```');
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
await waitFor(() => {
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
});
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
fireEvent.click(copyButton);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith('');
});
});
it('restores focus when the Clipboard API fails and the execCommand fallback succeeds', async () => {
writeTextMock.mockRejectedValueOnce(new Error('clipboard unavailable'));
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn().mockReturnValue(true),
});
const execCommandSpy = vi.mocked(document.execCommand);
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
await waitFor(() => {
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
});
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
copyButton.focus();
expect(copyButton).toBe(document.activeElement);
fireEvent.click(copyButton);
await waitFor(() => {
expect(execCommandSpy).toHaveBeenCalledWith('copy');
});
expect(copyButton).toBe(document.activeElement);
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { parseForceInline, shouldUrlLoadHtmlPreview } from '../../src/components/file-viewer-render-mode';
describe('shouldUrlLoadHtmlPreview', () => {
const base = { mode: 'preview' as const, isDeck: false, commentMode: false, forceInline: false };
it('URL-loads a plain HTML preview by default', () => {
expect(shouldUrlLoadHtmlPreview(base)).toBe(true);
});
it('falls back to srcDoc when the file is a deck (deck bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true })).toBe(false);
});
it('falls back to srcDoc when comment mode is active (comment bridge required)', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true })).toBe(false);
});
it('falls back to srcDoc when the user opts in via forceInline', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, forceInline: true })).toBe(false);
});
it('does not URL-load while the source-code tab is active', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, mode: 'source' })).toBe(false);
});
it('treats any disqualifying flag as sufficient on its own', () => {
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, commentMode: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, forceInline: true })).toBe(false);
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, forceInline: true })).toBe(false);
});
});
describe('parseForceInline', () => {
it('returns false when the parameter is absent', () => {
expect(parseForceInline('')).toBe(false);
expect(parseForceInline('?other=1')).toBe(false);
expect(parseForceInline(null)).toBe(false);
expect(parseForceInline(undefined)).toBe(false);
});
it('returns true for the documented opt-in values', () => {
expect(parseForceInline('?forceInline=1')).toBe(true);
expect(parseForceInline('?forceInline=true')).toBe(true);
expect(parseForceInline('?forceInline=TRUE')).toBe(true);
expect(parseForceInline('?forceInline=yes')).toBe(true);
expect(parseForceInline('?forceInline=on')).toBe(true);
});
it('returns false for explicit opt-out values and unrelated strings', () => {
expect(parseForceInline('?forceInline=0')).toBe(false);
expect(parseForceInline('?forceInline=false')).toBe(false);
expect(parseForceInline('?forceInline=no')).toBe(false);
expect(parseForceInline('?forceInline=off')).toBe(false);
expect(parseForceInline('?forceInline=banana')).toBe(false);
});
it('treats an empty value as absent (defensive: ?forceInline= shows up as "")', () => {
expect(parseForceInline('?forceInline=')).toBe(false);
});
it('accepts a pre-built URLSearchParams', () => {
const params = new URLSearchParams('forceInline=1&other=foo');
expect(parseForceInline(params)).toBe(true);
});
it('survives surrounding whitespace in the value', () => {
const params = new URLSearchParams();
params.set('forceInline', ' 1 ');
expect(parseForceInline(params)).toBe(true);
});
});

View File

@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewModal } from '../../src/components/PreviewModal';
// Regression coverage for nexu-io/open-design#141: pressing Esc in fullscreen
// used to require two presses because the browser exits its native fullscreen
// element on the first press without delivering a keydown to JS, leaving the
// React `fullscreen` state stuck on. The fix listens to fullscreenchange and
// mirrors the native state into React.
const baseProps = {
title: 'Sample',
views: [{ id: 'main', label: 'Main', html: '<p>hi</p>' }],
exportTitleFor: (id: string) => id,
};
function dispatchFullscreenChange() {
act(() => {
document.dispatchEvent(new Event('fullscreenchange'));
});
}
function setNativeFullscreenElement(el: Element | null) {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => el,
});
}
describe('PreviewModal fullscreen exit', () => {
afterEach(() => {
cleanup();
setNativeFullscreenElement(null);
});
it('drops the fullscreen overlay when the browser exits native fullscreen', () => {
const onClose = vi.fn();
const { container } = render(
<PreviewModal {...baseProps} onClose={onClose} />,
);
// Click the Fullscreen button. jsdom does not implement requestFullscreen
// on plain elements, so PreviewModal's fallback path runs and just sets
// the React state — exactly matching what happens after a successful
// browser fullscreen request.
const fsButton = container.querySelector(
'button[title="Fullscreen"]',
) as HTMLButtonElement;
expect(fsButton).toBeTruthy();
fireEvent.click(fsButton);
const stage = container.querySelector('.ds-modal') as HTMLElement;
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
// Simulate the user pressing Esc in browser fullscreen: the browser
// exits its native fullscreen element and fires fullscreenchange, but
// (in browsers like Firefox) does not deliver the keydown to JS.
setNativeFullscreenElement(null);
dispatchFullscreenChange();
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
it('keeps the modal mounted on Esc while fullscreen, and closes only on a second Esc', () => {
const onClose = vi.fn();
const { container } = render(
<PreviewModal {...baseProps} onClose={onClose} />,
);
const fsButton = container.querySelector(
'button[title="Fullscreen"]',
) as HTMLButtonElement;
fireEvent.click(fsButton);
const stage = container.querySelector('.ds-modal') as HTMLElement;
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
// First Esc — drops fullscreen, must not close the modal.
fireEvent.keyDown(document, { key: 'Escape' });
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
expect(onClose).not.toHaveBeenCalled();
// Second Esc — closes the modal.
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('ignores fullscreenchange when another element is still fullscreen', () => {
const onClose = vi.fn();
const { container } = render(
<PreviewModal {...baseProps} onClose={onClose} />,
);
const fsButton = container.querySelector(
'button[title="Fullscreen"]',
) as HTMLButtonElement;
fireEvent.click(fsButton);
const stage = container.querySelector('.ds-modal') as HTMLElement;
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
// Some other element is the active fullscreen target — our overlay must
// not collapse to non-fullscreen on transitions that leave a different
// element fullscreen.
const other = document.createElement('div');
document.body.appendChild(other);
setNativeFullscreenElement(other);
dispatchFullscreenChange();
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
document.body.removeChild(other);
});
});