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
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:
21
apps/web/tests/components/AgentIcon.test.tsx
Normal file
21
apps/web/tests/components/AgentIcon.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
66
apps/web/tests/components/AssistantMessage.test.ts
Normal file
66
apps/web/tests/components/AssistantMessage.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
20
apps/web/tests/components/DesignsTab.test.ts
Normal file
20
apps/web/tests/components/DesignsTab.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
95
apps/web/tests/components/EntryView.test.ts
Normal file
95
apps/web/tests/components/EntryView.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
278
apps/web/tests/components/FileViewer.test.tsx
Normal file
278
apps/web/tests/components/FileViewer.test.tsx
Normal 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&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&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('<svg onload="alert(1)">');
|
||||
expect(markup).toContain('<script>alert(2)</script>');
|
||||
expect(markup).toContain('<![CDATA[<script>alert(3)</script>]]>');
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
246
apps/web/tests/components/FileWorkspace.test.tsx
Normal file
246
apps/web/tests/components/FileWorkspace.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
154
apps/web/tests/components/ManualEditPanel.test.tsx
Normal file
154
apps/web/tests/components/ManualEditPanel.test.tsx
Normal 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 }));
|
||||
});
|
||||
}
|
||||
});
|
||||
12
apps/web/tests/components/NewProjectPanel.test.ts
Normal file
12
apps/web/tests/components/NewProjectPanel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
apps/web/tests/components/PreviewModal.test.tsx
Normal file
49
apps/web/tests/components/PreviewModal.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
143
apps/web/tests/components/QuickSwitcher.test.tsx
Normal file
143
apps/web/tests/components/QuickSwitcher.test.tsx
Normal 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=');
|
||||
});
|
||||
});
|
||||
225
apps/web/tests/components/SettingsDialog.test.ts
Normal file
225
apps/web/tests/components/SettingsDialog.test.ts
Normal 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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
101
apps/web/tests/components/auto-open-file.test.ts
Normal file
101
apps/web/tests/components/auto-open-file.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
79
apps/web/tests/components/conversation-timestamps.test.tsx
Normal file
79
apps/web/tests/components/conversation-timestamps.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
136
apps/web/tests/components/file-viewer-markdown-copy.test.tsx
Normal file
136
apps/web/tests/components/file-viewer-markdown-copy.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
73
apps/web/tests/components/file-viewer-render-mode.test.ts
Normal file
73
apps/web/tests/components/file-viewer-render-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
111
apps/web/tests/components/preview-modal-fullscreen.test.tsx
Normal file
111
apps/web/tests/components/preview-modal-fullscreen.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user