Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

905 lines
29 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
openWorkspaceTab,
WorkspaceTabsBar,
} from '../../src/components/WorkspaceTabsBar';
import { navigate, type Route } from '../../src/router';
import type { Project } from '../../src/types';
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => {
const labels: Record<string, string> = {
'app.brand': 'Open Design',
'common.close': 'Close',
'common.untitled': 'Untitled',
'entry.navDesignSystems': 'Design systems',
'entry.navHome': 'Home',
'entry.navProjects': 'Projects',
'entry.navTasks': 'Automations',
'entry.navPlugins': 'Plugins',
'entry.navIntegrations': 'Integrations',
'settings.welcomeTitle': 'Welcome',
};
return labels[key] ?? key;
},
}));
vi.mock('../../src/router', async () => {
const actual = await vi.importActual<typeof import('../../src/router')>(
'../../src/router',
);
return {
...actual,
navigate: vi.fn(),
};
});
const homeRoute: Route = { kind: 'home', view: 'home' };
const projectRoute: Route = {
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
};
const project: Project = {
id: 'project-alpha',
name: 'Project Alpha',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
};
const projectBeta: Project = {
id: 'project-beta',
name: 'Project Beta',
skillId: null,
designSystemId: null,
createdAt: 2,
updatedAt: 2,
};
function createDataTransfer(): DataTransfer {
const store = new Map<string, string>();
return {
dropEffect: 'move',
effectAllowed: 'move',
getData: vi.fn((key: string) => store.get(key) ?? ''),
setData: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
} as unknown as DataTransfer;
}
function mockTabRect(element: HTMLElement, left: number, width = 100) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () =>
({
x: left,
y: 0,
left,
right: left + width,
top: 0,
bottom: 32,
width,
height: 32,
toJSON: () => ({}),
}) as DOMRect,
});
}
function dispatchDragEvent(
element: HTMLElement,
type: 'dragover' | 'drop',
dataTransfer: DataTransfer,
clientX: number,
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { configurable: true, value: dataTransfer });
Object.defineProperty(event, 'clientX', { configurable: true, value: clientX });
fireEvent(element, event);
}
describe('WorkspaceTabsBar navigation semantics', () => {
beforeEach(() => {
window.localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
document.querySelector('[data-testid="blank-workspace-area"]')?.remove();
});
it('keeps Home tab as a singleton and avoids duplication', async () => {
const { rerender } = render(
<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />,
);
expect(screen.getAllByRole('tab')).toHaveLength(1);
// Clicking 'New tab' when a Home tab already exists should activate the existing Home tab
fireEvent.click(screen.getByRole('button', { name: 'New tab' }));
fireEvent.click(screen.getByRole('button', { name: 'New tab' }));
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels.filter((label) => label.includes('Home'))).toHaveLength(1);
});
// Navigate to projectRoute using rerender with a fresh object reference
rerender(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(2);
expect(labels.some((label) => label.includes('Home'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
// Return to Home by navigating back with a fresh route object reference
rerender(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
await waitFor(() => {
const tabs = screen.getAllByRole('tab');
const labels = tabs.map((tab) => tab.textContent ?? '');
// Expect that we still have 2 tabs (Home and Project Alpha)
expect(tabs).toHaveLength(2);
expect(labels.filter((label) => label.includes('Home'))).toHaveLength(1);
expect(labels.filter((label) => label.includes('Project Alpha'))).toHaveLength(1);
});
});
it('auto-closes the Welcome tab once onboarding completes, even when a project opens', async () => {
const { rerender } = render(
<WorkspaceTabsBar
route={{ kind: 'home', view: 'onboarding' }}
projects={[project]}
onboardingCompleted={false}
/>,
);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels.some((label) => label.includes('Welcome'))).toBe(true);
});
// Completing onboarding via the design-system path navigates to a fresh
// project while the entry tab is still parked on the Welcome view.
rerender(
<WorkspaceTabsBar
route={{ ...projectRoute }}
projects={[project]}
onboardingCompleted={true}
/>,
);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels.some((label) => label.includes('Welcome'))).toBe(false);
expect(labels.some((label) => label.includes('Home'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
});
it('resets the entry tab to Home after onboarding opens a design-system extraction project', async () => {
const { rerender } = render(
<WorkspaceTabsBar
route={{ kind: 'home', view: 'onboarding' }}
projects={[project]}
onboardingCompleted={false}
/>,
);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels.some((label) => label.includes('Welcome'))).toBe(true);
});
rerender(
<WorkspaceTabsBar
route={{ kind: 'design-system-create' }}
projects={[project]}
onboardingCompleted={true}
/>,
);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels.some((label) => label.includes('Design systems'))).toBe(true);
});
rerender(
<WorkspaceTabsBar
route={{ ...projectRoute }}
projects={[project]}
onboardingCompleted={true}
/>,
);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels.some((label) => label.includes('Design systems'))).toBe(false);
expect(labels.some((label) => label.includes('Home'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
});
it('closes the Search tabs popover when the route flips to onboarding', async () => {
const { rerender } = render(
<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />,
);
// Open the Search-tabs popover from the (non-onboarding) home view.
fireEvent.click(screen.getByRole('button', { name: 'Search tabs' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Search tabs' })).toBeTruthy();
});
// Onboarding hides the trigger button; the already-open popover must not
// survive the route transition (e.g. browser back/forward into
// /onboarding), or it floats over the first-run flow with no visible
// control to dismiss it.
rerender(
<WorkspaceTabsBar route={{ kind: 'home', view: 'onboarding' }} projects={[project]} />,
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Search tabs' })).toBeNull();
});
expect(screen.queryByRole('button', { name: 'Search tabs' })).toBeNull();
});
it('collapses every entry section into the single leftmost tab (no new tab per section)', async () => {
const { rerender } = render(
<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />,
);
expect(screen.getAllByRole('tab')).toHaveLength(1);
const sections: Array<{ view: 'projects' | 'tasks' | 'design-systems' | 'plugins' | 'integrations'; label: string }> = [
{ view: 'projects', label: 'Projects' },
{ view: 'tasks', label: 'Automations' },
{ view: 'design-systems', label: 'Design systems' },
{ view: 'plugins', label: 'Plugins' },
{ view: 'integrations', label: 'Integrations' },
];
for (const section of sections) {
rerender(<WorkspaceTabsBar route={{ kind: 'home', view: section.view }} projects={[project]} />);
await waitFor(() => {
const tabs = screen.getAllByRole('tab');
// Exactly one tab the whole time — the section just switches the view.
expect(tabs).toHaveLength(1);
expect(tabs[0]?.textContent ?? '').toContain(section.label);
});
}
// The single entry tab in a non-home view is still permanent (no close btn).
expect(screen.queryByRole('button', { name: 'Close tab' })).toBeNull();
});
it('keeps the entry tab when opening a project from a non-home entry view', async () => {
const { rerender } = render(
<WorkspaceTabsBar route={{ kind: 'home', view: 'design-systems' }} projects={[project]} />,
);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(1);
expect(labels[0]).toContain('Design systems');
});
// Opening a project from the design-systems view must APPEND a project tab,
// not replace the entry tab.
rerender(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(2);
expect(labels.some((label) => label.includes('Design systems'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
// Switching to another section keeps the SAME entry tab and the project tab.
rerender(<WorkspaceTabsBar route={{ kind: 'home', view: 'tasks' }} projects={[project]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(2);
expect(labels.some((label) => label.includes('Automations'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
});
it('collapses a restored two-entry-tab workspace into a single entry tab', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'entry:projects:1',
tabs: [
{ id: 'entry:home:1', kind: 'entry', view: 'home', createdAt: 1, lastActiveAt: 1 },
{ id: 'entry:projects:1', kind: 'entry', view: 'projects', createdAt: 2, lastActiveAt: 2 },
],
}),
);
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'projects' }} projects={[project]} />);
await waitFor(() => {
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(1);
expect(tabs[0]?.textContent ?? '').toContain('Projects');
});
});
it('can append and focus a project tab for create-project flows', async () => {
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
openWorkspaceTab({ ...projectRoute });
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(2);
expect(labels.some((label) => label.includes('Home'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
});
it('keeps a singleton Home tab when restoring a Home-less workspace and navigating back to Home', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'project:project-alpha',
tabs: [
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
createdAt: 1,
lastActiveAt: 1,
},
],
}),
);
const { rerender } = render(
<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project]} />,
);
// Restoring a Home-less saved workspace immediately mints the permanent
// Home tab pinned leftmost — Project Alpha sits to its right.
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Alpha'),
]);
});
// Navigating to Home must not duplicate it.
rerender(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(2);
expect(labels.filter((label) => label.includes('Home'))).toHaveLength(1);
expect(labels.filter((label) => label.includes('Project Alpha'))).toHaveLength(1);
});
});
it('creates a pinned Home tab when restoring saved tabs that have no Home entry', async () => {
// Users who closed/replaced Home before the permanent-Home feature shipped
// can have a saved `[project, ...]` workspace with no Home entry. Normalizing
// that state must mint a Home tab and pin it leftmost, not leave the workspace
// Home-less until the user manually navigates home.
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'project:project-alpha',
tabs: [
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
createdAt: 1,
lastActiveAt: 1,
},
{
id: 'project:project-beta',
kind: 'project',
projectId: 'project-beta',
conversationId: null,
fileName: null,
createdAt: 2,
lastActiveAt: 2,
},
],
}),
);
render(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project, projectBeta]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Alpha'),
expect.stringContaining('Project Beta'),
]);
});
});
it('deduplicates and cleans up restored Home tabs from old sessions', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'entry:home:old-two',
tabs: [
{
id: 'entry:home:old-one',
kind: 'entry',
view: 'home',
createdAt: 1,
lastActiveAt: 1,
},
{
id: 'entry:home:old-two',
kind: 'entry',
view: 'home',
createdAt: 2,
lastActiveAt: 2,
},
],
}),
);
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
// Expect that the duplicate Home tabs are deduplicated to exactly one Home tab
expect(labels.filter((label) => label.includes('Home'))).toHaveLength(1);
});
});
it('keeps the pinned Home tab permanent and non-closable', async () => {
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
// The Home tab is pinned leftmost and has no close affordance, so there is
// no way to remove the last remaining tab.
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull();
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(1);
expect(labels[0]).toContain('Home');
});
it('maps the browser new-tab shortcut to the workspace new-tab action', async () => {
render(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project]} />);
const allowedDefault = fireEvent.keyDown(document, {
key: 't',
metaKey: true,
});
expect(allowedDefault).toBe(false);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(2);
expect(labels.some((label) => label.includes('Home'))).toBe(true);
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
});
expect(navigate).toHaveBeenCalledWith(homeRoute);
});
it('defers browser tab shortcuts when the project file workspace is mounted', () => {
render(
<>
<div data-testid="file-workspace" />
<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project]} />
</>,
);
const allowedDefault = fireEvent.keyDown(document, {
key: 't',
metaKey: true,
});
expect(allowedDefault).toBe(true);
// Home is always pinned leftmost, so the project route renders Home + the
// project tab. The deferred shortcut must not add or change tabs.
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Alpha'),
]);
expect(navigate).not.toHaveBeenCalled();
});
it('maps the browser close-tab shortcut to the active workspace tab', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'project:project-alpha',
tabs: [
{
id: 'entry:home:seed',
kind: 'entry',
view: 'home',
createdAt: 1,
lastActiveAt: 1,
},
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
createdAt: 2,
lastActiveAt: 2,
},
],
}),
);
render(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project]} />);
const allowedDefault = fireEvent.keyDown(document, {
key: 'w',
ctrlKey: true,
});
expect(allowedDefault).toBe(false);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(1);
expect(labels[0]).toContain('Home');
});
expect(navigate).toHaveBeenCalledWith(homeRoute);
});
it('switches tabs with browser-style next and previous tab shortcuts', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'project:project-alpha',
tabs: [
{
id: 'entry:home:seed',
kind: 'entry',
view: 'home',
createdAt: 1,
lastActiveAt: 1,
},
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
createdAt: 2,
lastActiveAt: 2,
},
{
id: 'project:project-beta',
kind: 'project',
projectId: 'project-beta',
conversationId: null,
fileName: null,
createdAt: 3,
lastActiveAt: 3,
},
],
}),
);
render(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project, projectBeta]} />);
const nextAllowedDefault = fireEvent.keyDown(document, {
key: 'Tab',
ctrlKey: true,
});
expect(nextAllowedDefault).toBe(false);
await waitFor(() => {
expect(navigate).toHaveBeenLastCalledWith({
kind: 'project',
projectId: 'project-beta',
conversationId: null,
fileName: null,
});
});
const previousAllowedDefault = fireEvent.keyDown(document, {
key: 'Tab',
ctrlKey: true,
shiftKey: true,
});
expect(previousAllowedDefault).toBe(false);
await waitFor(() => {
expect(navigate).toHaveBeenLastCalledWith(projectRoute);
});
});
it('dismisses tab search when a blank page area handles the mouse down', async () => {
const outsideArea = document.createElement('div');
outsideArea.setAttribute('data-testid', 'blank-workspace-area');
outsideArea.addEventListener('mousedown', (event) => event.stopPropagation());
document.body.append(outsideArea);
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
fireEvent.click(screen.getByRole('button', { name: 'Search tabs' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'Search tabs' })).toBeTruthy();
});
fireEvent.mouseDown(outsideArea);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'Search tabs' })).toBeNull();
});
});
it('clamps the hover preview to 220px for narrow tabs', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'entry:home:seed',
tabs: [
{
id: 'entry:home:seed',
kind: 'entry',
view: 'home',
createdAt: 1,
lastActiveAt: 2,
},
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
createdAt: 2,
lastActiveAt: 1,
},
],
}),
);
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
await waitFor(() => {
expect(screen.getAllByRole('tab')).toHaveLength(2);
});
const projectTab = screen.getAllByRole('tab').find((tab) =>
(tab.textContent ?? '').includes('Project Alpha'),
) as HTMLElement;
mockTabRect(projectTab, 32, 148);
fireEvent.mouseEnter(projectTab);
await new Promise((resolve) => window.setTimeout(resolve, 430));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip.style.width).toBe('220px');
expect(tooltip.style.left).toBe('32px');
});
it('matches anchor width for tabs wider than the 220px floor', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'entry:home:seed',
tabs: [
{
id: 'entry:home:seed',
kind: 'entry',
view: 'home',
createdAt: 1,
lastActiveAt: 2,
},
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
createdAt: 2,
lastActiveAt: 1,
},
],
}),
);
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
await waitFor(() => {
expect(screen.getAllByRole('tab')).toHaveLength(2);
});
const projectTab = screen.getAllByRole('tab').find((tab) =>
(tab.textContent ?? '').includes('Project Alpha'),
) as HTMLElement;
mockTabRect(projectTab, 50, 300);
fireEvent.mouseEnter(projectTab);
await new Promise((resolve) => window.setTimeout(resolve, 430));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip.style.width).toBe('300px');
expect(tooltip.style.left).toBe('50px');
});
it('keeps the Home tab pinned leftmost when a tab is dropped onto its left edge', async () => {
const vibrate = vi.fn();
Object.defineProperty(window.navigator, 'vibrate', {
configurable: true,
value: vibrate,
});
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'project:project-alpha',
tabs: [
{
id: 'entry:home:seed',
kind: 'entry',
view: 'home',
createdAt: 1,
lastActiveAt: 1,
},
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
createdAt: 2,
lastActiveAt: 2,
},
{
id: 'project:project-beta',
kind: 'project',
projectId: 'project-beta',
conversationId: null,
fileName: null,
createdAt: 3,
lastActiveAt: 3,
},
],
}),
);
render(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project, projectBeta]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Alpha'),
expect.stringContaining('Project Beta'),
]);
});
// Dragging a project tab onto Home's left edge must not place anything
// before Home. Home is the permanent, pinned-leftmost tab; the drop should
// resolve to "after Home" so Home stays first.
const [homeTab, , betaTab] = screen.getAllByRole('tab');
mockTabRect(homeTab! as HTMLElement, 0);
const dataTransfer = createDataTransfer();
fireEvent.dragStart(betaTab!, { dataTransfer });
// clientX 10 lands in the left half of Home's rect (left=0, width=100).
dispatchDragEvent(homeTab! as HTMLElement, 'dragover', dataTransfer, 10);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Beta'),
expect.stringContaining('Project Alpha'),
]);
});
dispatchDragEvent(homeTab! as HTMLElement, 'drop', dataTransfer, 10);
fireEvent.dragEnd(betaTab!, { dataTransfer });
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Beta'),
expect.stringContaining('Project Alpha'),
]);
});
expect(navigate).not.toHaveBeenCalled();
expect(vibrate).toHaveBeenCalledWith(8);
expect(vibrate).toHaveBeenCalledWith(12);
const stored = JSON.parse(window.localStorage.getItem('open-design:workspace-tabs:v1') ?? '{}') as {
activeTabId?: string;
tabs?: Array<{ id?: string }>;
};
expect(stored.activeTabId).toBe('project:project-alpha');
expect(stored.tabs?.map((tab) => tab.id)).toEqual([
'entry:home:seed',
'project:project-beta',
'project:project-alpha',
]);
});
it('reorders tabs live from right to left while dragging', async () => {
window.localStorage.setItem(
'open-design:workspace-tabs:v1',
JSON.stringify({
activeTabId: 'project:project-alpha',
tabs: [
{
id: 'entry:home:seed',
kind: 'entry',
view: 'home',
createdAt: 1,
lastActiveAt: 1,
},
{
id: 'project:project-alpha',
kind: 'project',
projectId: 'project-alpha',
conversationId: null,
fileName: null,
createdAt: 2,
lastActiveAt: 2,
},
{
id: 'project:project-beta',
kind: 'project',
projectId: 'project-beta',
conversationId: null,
fileName: null,
createdAt: 3,
lastActiveAt: 3,
},
],
}),
);
render(<WorkspaceTabsBar route={{ ...projectRoute }} projects={[project, projectBeta]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Alpha'),
expect.stringContaining('Project Beta'),
]);
});
const [, alphaTab, betaTab] = screen.getAllByRole('tab');
mockTabRect(alphaTab! as HTMLElement, 100);
const dataTransfer = createDataTransfer();
fireEvent.dragStart(betaTab!, { dataTransfer });
dispatchDragEvent(alphaTab! as HTMLElement, 'dragover', dataTransfer, 110);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toEqual([
expect.stringContaining('Home'),
expect.stringContaining('Project Beta'),
expect.stringContaining('Project Alpha'),
]);
});
});
});