// @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, describe, expect, it, vi } from 'vitest'; import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace'; import { projectSplitClassName } from '../../src/components/ProjectView'; import { uploadProjectFiles } from '../../src/providers/registry'; import type { ProjectFile } from '../../src/types'; vi.mock('../../src/providers/registry', async () => { const actual = await vi.importActual( '../../src/providers/registry', ); return { ...actual, uploadProjectFiles: vi.fn(), }; }); const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles); let root: Root | null = null; let host: HTMLDivElement | null = null; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; 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 { 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('[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(); 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 })); } describe('FileWorkspace upload input', () => { it('keeps the Design Files picker aligned with drag-and-drop file support', () => { const markup = renderToStaticMarkup( , ); 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( , ); 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('.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( , ); 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('hides the workspace focus control while the chat pane is open', () => { const markup = renderToStaticMarkup( , ); // 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( , ); 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( /
\s*]*data-testid="workspace-focus-toggle"[\s\S]*?<\/button>\s*
{ const markup = renderToStaticMarkup( , ); expect(markup).toContain('Show chat'); }); }); describe('FileWorkspace design file rename', () => { it('renames from the Design Files row menu and replaces persisted tabs', async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith('/api/projects/project-1/files/rename') && init?.method === 'POST') { return new Response( JSON.stringify({ file: workspaceFile('resume-notes.txt'), oldName: 'paste-1.txt', newName: 'resume-notes.txt', }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); } return new Response('', { status: 200 }); }); vi.stubGlobal('fetch', fetchMock); const onTabsStateChange = vi.fn(); const onRefreshFiles = vi.fn(); const container = renderWorkspace( , ); const designFilesTab = container.querySelector('[data-testid="design-files-tab"]'); if (!designFilesTab) throw new Error('Could not find design files tab'); act(() => designFilesTab.click()); const menuButton = container.querySelector('[data-testid="design-file-menu-paste-1.txt"]'); if (!menuButton) throw new Error('Could not find design file menu'); act(() => menuButton.click()); const renameButton = Array.from(container.querySelectorAll('button')) .find((button) => button.textContent === 'Rename'); if (!renameButton) throw new Error('Could not find rename command'); act(() => renameButton.click()); const input = container.querySelector('.df-rename-input'); if (!input) throw new Error('Could not find rename input'); act(() => { changeInputValue(input, 'resume-notes.txt'); }); await act(async () => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); expect(fetchMock).toHaveBeenCalledWith( '/api/projects/project-1/files/rename', expect.objectContaining({ method: 'POST', body: JSON.stringify({ from: 'paste-1.txt', to: 'resume-notes.txt' }), }), ); expect(onTabsStateChange).toHaveBeenLastCalledWith({ tabs: ['resume-notes.txt', 'index.html'], active: 'resume-notes.txt', }); expect(onRefreshFiles).toHaveBeenCalledTimes(1); }); it('rejects renaming a persisted file over an open pending sketch tab', async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response('', { status: 200 }), ); const alertMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('alert', alertMock); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); const onTabsStateChange = vi.fn(); const container = renderWorkspace( , ); const designFilesTab = container.querySelector('[data-testid="design-files-tab"]'); if (!designFilesTab) throw new Error('Could not find design files tab'); act(() => designFilesTab.click()); const newSketchButton = Array.from(container.querySelectorAll('button')) .find((button) => button.textContent === 'New sketch'); if (!newSketchButton) throw new Error('Could not find new sketch command'); act(() => newSketchButton.click()); const pendingSketchTab = Array.from(container.querySelectorAll('[role="tab"]')).find((tab) => tab.textContent?.includes('.sketch.json'), ); if (!pendingSketchTab) throw new Error('Could not find pending sketch tab'); const pendingSketchName = pendingSketchTab.textContent!.replace(' •', ''); act(() => designFilesTab.click()); const menuButton = container.querySelector('[data-testid="design-file-menu-paste-1.txt"]'); if (!menuButton) throw new Error('Could not find file menu'); act(() => menuButton.click()); const renameButton = Array.from(container.querySelectorAll('button')) .find((button) => button.textContent === 'Rename'); if (!renameButton) throw new Error('Could not find rename command'); act(() => renameButton.click()); const input = container.querySelector('.df-rename-input'); if (!input) throw new Error('Could not find rename input'); act(() => { changeInputValue(input, pendingSketchName); }); await act(async () => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); expect(alertMock).toHaveBeenCalledWith( `A pending sketch named "${pendingSketchName}" is already open. Save or close it before renaming.`, ); const renameCalls = fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/projects/project-1/files/rename'), ); expect(renameCalls).toHaveLength(0); expect(onTabsStateChange).not.toHaveBeenCalled(); expect(pendingSketchTab.textContent).toContain(pendingSketchName); }); }); describe('FileWorkspace tab reordering', () => { it('persists a dragged file tab before the tab it is dropped on', () => { const onTabsStateChange = vi.fn(); const container = renderWorkspace( , ); 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( , ); 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( , ); 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( , ); const source = getTabByName(container, /analysis\.html/i); const target = getTabByName(container, /notes\.md/i); const tabBar = container.querySelector('.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(); }); });