mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-08 23:59:36 +08:00
833 lines
24 KiB
TypeScript
833 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
|
|
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
|
|
import { projectSplitClassName } from '../../src/components/ProjectView';
|
|
import {
|
|
fetchProjectFileText,
|
|
uploadProjectFiles,
|
|
writeProjectTextFile,
|
|
} from '../../src/providers/registry';
|
|
import type { ProjectFile } from '../../src/types';
|
|
|
|
vi.mock('../../src/providers/registry', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
|
'../../src/providers/registry',
|
|
);
|
|
return {
|
|
...actual,
|
|
fetchProjectFileText: vi.fn(),
|
|
uploadProjectFiles: vi.fn(),
|
|
writeProjectTextFile: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
|
|
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
|
|
const mockedWriteProjectTextFile = vi.mocked(writeProjectTextFile);
|
|
|
|
let root: Root | null = null;
|
|
let host: HTMLDivElement | null = null;
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
// Needed else the ResizeObserver in SketchEditor crashes the test
|
|
beforeAll(() => {
|
|
globalThis.ResizeObserver = class {
|
|
observe() {}
|
|
disconnect() {}
|
|
unobserve() {}
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
if (root) {
|
|
act(() => root?.unmount());
|
|
root = null;
|
|
}
|
|
host?.remove();
|
|
host = null;
|
|
vi.clearAllMocks();
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function baseFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
|
|
return {
|
|
name: 'mock.png',
|
|
path: 'mock.png',
|
|
type: 'file',
|
|
size: 1024,
|
|
mtime: 1710000000,
|
|
kind: 'image',
|
|
mime: 'image/png',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function workspaceFile(name: string): ProjectFile {
|
|
return {
|
|
name,
|
|
path: name,
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: name.endsWith('.html') ? 'html' : 'text',
|
|
mime: name.endsWith('.html') ? 'text/html' : 'text/plain',
|
|
};
|
|
}
|
|
|
|
function renderWorkspace(element: React.ReactElement) {
|
|
host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
root = createRoot(host);
|
|
act(() => {
|
|
root?.render(element);
|
|
});
|
|
return host;
|
|
}
|
|
|
|
function getTabByName(container: HTMLElement, name: RegExp): HTMLElement {
|
|
const tabs = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]'));
|
|
const tab = tabs.find((node) => name.test(node.textContent ?? ''));
|
|
if (!tab) throw new Error(`Could not find tab matching ${name}`);
|
|
return tab;
|
|
}
|
|
|
|
function createDragDataTransfer() {
|
|
const store = new Map<string, string>();
|
|
return {
|
|
effectAllowed: 'move',
|
|
dropEffect: 'move',
|
|
getData: vi.fn((type: string) => store.get(type) ?? ''),
|
|
setData: vi.fn((type: string, value: string) => {
|
|
store.set(type, value);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function dispatchDragEvent(
|
|
target: HTMLElement,
|
|
type: string,
|
|
dataTransfer = createDragDataTransfer(),
|
|
clientX = 0,
|
|
relatedTarget: EventTarget | null = null,
|
|
) {
|
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
Object.defineProperties(event, {
|
|
clientX: { value: clientX },
|
|
dataTransfer: { value: dataTransfer },
|
|
relatedTarget: { value: relatedTarget },
|
|
});
|
|
target.dispatchEvent(event);
|
|
return dataTransfer;
|
|
}
|
|
|
|
function stubTabRect(tab: HTMLElement, left = 0, width = 100) {
|
|
tab.getBoundingClientRect = vi.fn(() => ({
|
|
x: left,
|
|
y: 0,
|
|
left,
|
|
top: 0,
|
|
right: left + width,
|
|
bottom: 20,
|
|
width,
|
|
height: 20,
|
|
toJSON: () => ({}),
|
|
}));
|
|
}
|
|
|
|
function changeInputValue(input: HTMLInputElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
setter?.call(input, value);
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
function renderDesignFilesPanel(overrides: Partial<React.ComponentProps<typeof DesignFilesPanel>> = {}) {
|
|
const props: React.ComponentProps<typeof DesignFilesPanel> = {
|
|
projectId: 'project-1',
|
|
files: [],
|
|
liveArtifacts: [],
|
|
onRefreshFiles: vi.fn(),
|
|
onOpenFile: vi.fn(),
|
|
onOpenLiveArtifact: vi.fn(),
|
|
onRenameFile: vi.fn(),
|
|
onDeleteFile: vi.fn(),
|
|
onDeleteFiles: vi.fn(),
|
|
onUpload: vi.fn(),
|
|
onUploadFiles: vi.fn(),
|
|
onPaste: vi.fn(),
|
|
onNewSketch: vi.fn(),
|
|
...overrides,
|
|
};
|
|
return render(<DesignFilesPanel {...props} />);
|
|
}
|
|
|
|
function unreadableDropDataTransfer(fallbackFiles: File[] = []) {
|
|
return {
|
|
files: fallbackFiles,
|
|
items: [
|
|
{
|
|
webkitGetAsEntry: () => ({
|
|
isFile: true,
|
|
isDirectory: false,
|
|
name: 'stale.png',
|
|
file: (_done: (file: File) => void, fail?: (error: DOMException) => void) => {
|
|
fail?.(new DOMException('missing', 'NotFoundError'));
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('FileWorkspace upload input', () => {
|
|
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
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('hides upload failure details during in-panel preview and restores them after closing preview', async () => {
|
|
mockedUploadProjectFiles.mockRejectedValueOnce(new Error('storage offline'));
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[baseFile()]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId('design-files-upload-input'), {
|
|
target: { files: [new File(['mock'], 'mock.png', { type: 'image/png' })] },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'storage offline',
|
|
);
|
|
});
|
|
|
|
const row = screen.getByTestId('design-file-row-mock.png');
|
|
const nameButton = row.querySelector<HTMLButtonElement>('.df-row-name-btn');
|
|
if (!nameButton) throw new Error('Could not find file name button');
|
|
fireEvent.click(nameButton);
|
|
|
|
expect(screen.getByTestId('design-file-preview')).toBeTruthy();
|
|
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close preview' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'storage offline',
|
|
);
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId('upload-error-dismiss'));
|
|
|
|
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
|
|
});
|
|
|
|
it('keeps partial upload failures visible after a successful file opens', async () => {
|
|
mockedUploadProjectFiles.mockResolvedValueOnce({
|
|
uploaded: [
|
|
{
|
|
path: 'uploaded.png',
|
|
name: 'uploaded.png',
|
|
kind: 'image',
|
|
size: 1024,
|
|
},
|
|
],
|
|
failed: [{ name: 'failed.png', error: 'permission denied' }],
|
|
error: 'permission denied',
|
|
});
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[baseFile({ name: 'uploaded.png', path: 'uploaded.png' })]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId('design-files-upload-input'), {
|
|
target: {
|
|
files: [
|
|
new File(['uploaded'], 'uploaded.png', { type: 'image/png' }),
|
|
new File(['failed'], 'failed.png', { type: 'image/png' }),
|
|
],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'Uploaded 1 file(s), but 1 failed (permission denied).',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('falls back to the browser file list when a dragged entry cannot be read', async () => {
|
|
const fallbackFile = new File(['mock'], 'fallback.png', { type: 'image/png' });
|
|
const onUploadFiles = vi.fn();
|
|
const { container } = renderDesignFilesPanel({ onUploadFiles });
|
|
|
|
fireEvent.drop(container.querySelector('.df-drop')!, {
|
|
dataTransfer: unreadableDropDataTransfer([fallbackFile]),
|
|
});
|
|
|
|
await waitFor(() => expect(onUploadFiles).toHaveBeenCalledWith([fallbackFile]));
|
|
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
|
|
});
|
|
|
|
it('shows a recoverable read error when a dragged entry disappears before import', async () => {
|
|
const onUploadFiles = vi.fn();
|
|
const { container } = renderDesignFilesPanel({ onUploadFiles });
|
|
|
|
fireEvent.drop(container.querySelector('.df-drop')!, {
|
|
dataTransfer: unreadableDropDataTransfer(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'Could not read one or more dropped files or folders',
|
|
);
|
|
});
|
|
expect(onUploadFiles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('hides the workspace focus control while the chat pane is open', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
focusMode={false}
|
|
onFocusModeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
// While chat is visible the collapse trigger lives in ChatPane.
|
|
// FileWorkspace only renders an expand control once chat is hidden.
|
|
expect(markup).not.toContain('data-testid="workspace-focus-toggle"');
|
|
});
|
|
|
|
it('renders the expand control on the LEFT of the tab bar while focused', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
focusMode
|
|
onFocusModeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(markup).toContain('class="ws-tabs-shell"');
|
|
expect(markup).toContain('data-testid="workspace-focus-toggle"');
|
|
// The expand control sits before the tabs bar (left side) so its
|
|
// direction matches where the chat pane re-emerges from.
|
|
expect(markup).toMatch(
|
|
/<div class="ws-tabs-shell">\s*<button[^>]*data-testid="workspace-focus-toggle"[\s\S]*?<\/button>\s*<div class="ws-tabs-bar"/,
|
|
);
|
|
});
|
|
|
|
it('labels the same workspace control as chat restore while focused', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
focusMode
|
|
onFocusModeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(markup).toContain('Show chat');
|
|
});
|
|
});
|
|
|
|
describe('DesignFilesPanel plugin folders', () => {
|
|
it('surfaces generated plugin folders with agent-routed CLI actions', async () => {
|
|
const onPluginFolderAgentAction = vi.fn();
|
|
const container = renderWorkspace(
|
|
<DesignFilesPanel
|
|
projectId="project-1"
|
|
files={[
|
|
workspaceFile('generated-plugin/open-design.json'),
|
|
workspaceFile('generated-plugin/SKILL.md'),
|
|
workspaceFile('generated-plugin/examples/demo.md'),
|
|
]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
onOpenFile={vi.fn()}
|
|
onOpenLiveArtifact={vi.fn()}
|
|
onDeleteFile={vi.fn()}
|
|
onDeleteFiles={vi.fn()}
|
|
onRenameFile={vi.fn()}
|
|
onUpload={vi.fn()}
|
|
onUploadFiles={vi.fn()}
|
|
onPaste={vi.fn()}
|
|
onNewSketch={vi.fn()}
|
|
onPluginFolderAgentAction={onPluginFolderAgentAction}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('[data-testid="design-plugin-folder-generated-plugin"]')).toBeTruthy();
|
|
const install = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="design-plugin-folder-install-generated-plugin"]',
|
|
);
|
|
expect(install).toBeTruthy();
|
|
await act(async () => {
|
|
install?.click();
|
|
});
|
|
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'install');
|
|
|
|
const publish = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="design-plugin-folder-publish-generated-plugin"]',
|
|
);
|
|
const contribute = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="design-plugin-folder-contribute-generated-plugin"]',
|
|
);
|
|
expect(publish).toBeTruthy();
|
|
expect(contribute).toBeTruthy();
|
|
await act(async () => {
|
|
publish?.click();
|
|
});
|
|
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'publish');
|
|
await act(async () => {
|
|
contribute?.click();
|
|
});
|
|
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'contribute');
|
|
expect(container.textContent).not.toContain(
|
|
'Sent to the agent. The CLI run will continue in chat.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('FileWorkspace tab reordering', () => {
|
|
it('persists a dragged file tab before the tab it is dropped on', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[
|
|
workspaceFile('analysis.html'),
|
|
workspaceFile('notes.md'),
|
|
workspaceFile('summary.html'),
|
|
]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md', 'summary.html'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const source = getTabByName(container, /summary\.html/i);
|
|
const target = getTabByName(container, /analysis\.html/i);
|
|
stubTabRect(target);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
|
|
act(() => dispatchDragEvent(target, 'drop', dataTransfer));
|
|
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['summary.html', 'analysis.html', 'notes.md'],
|
|
active: null,
|
|
});
|
|
});
|
|
|
|
it('persists a dragged file tab after the tab when dropped on its right side', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[
|
|
workspaceFile('analysis.html'),
|
|
workspaceFile('notes.md'),
|
|
workspaceFile('summary.html'),
|
|
]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md', 'summary.html'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const source = getTabByName(container, /analysis\.html/i);
|
|
const target = getTabByName(container, /summary\.html/i);
|
|
stubTabRect(target);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(target, 'drop', dataTransfer, 75));
|
|
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['notes.md', 'summary.html', 'analysis.html'],
|
|
active: null,
|
|
});
|
|
});
|
|
|
|
it('does not persist when a tab is dropped on itself', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const tab = getTabByName(container, /analysis\.html/i);
|
|
stubTabRect(tab);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(tab, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(tab, 'drop', dataTransfer));
|
|
|
|
expect(onTabsStateChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears the drop indicator when the drag leaves the tab bar', () => {
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const source = getTabByName(container, /analysis\.html/i);
|
|
const target = getTabByName(container, /notes\.md/i);
|
|
const tabBar = container.querySelector<HTMLElement>('.ws-tabs-bar');
|
|
if (!tabBar) throw new Error('Could not find tabs bar');
|
|
stubTabRect(target);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
|
|
|
|
expect(target.className).toContain('drag-over-before');
|
|
|
|
act(() => dispatchDragEvent(tabBar, 'dragleave', dataTransfer, 0, document.body));
|
|
|
|
expect(target.className).not.toContain('drag-over-before');
|
|
expect(target.className).not.toContain('drag-over-after');
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('FileWorkspace sketch save', () => {
|
|
it('keeps saving state visible for at least 500ms', async () => {
|
|
// Simulate user doing some edits in the workspace
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
version: 1,
|
|
items: [
|
|
{ kind: 'pen', points: [{ x: 10, y: 20 }], color: '#000', size: 2 },
|
|
],
|
|
}),
|
|
);
|
|
mockedWriteProjectTextFile.mockResolvedValue(file);
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector('canvas')).not.toBeNull();
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
|
|
const btn = screen.getByText('Save') as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(btn);
|
|
});
|
|
|
|
expect(btn.textContent).toBe('Saving…');
|
|
expect(btn.disabled).toBe(true);
|
|
|
|
// Before the 500ms floor is reached, still saving
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(400);
|
|
});
|
|
expect(btn.textContent).toBe('Saving…');
|
|
expect(btn.disabled).toBe(true);
|
|
|
|
// After 500ms total, saving should end and the checkmark should appear
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(100);
|
|
});
|
|
expect(btn.textContent).not.toBe('Saving…');
|
|
expect(btn.querySelector('svg')).not.toBeNull();
|
|
});
|
|
});
|