open-design/apps/web/tests/components/WorkspaceTabsBar.test.tsx

244 lines
8.2 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.navAutomations': 'Automations',
'entry.navPlugins': 'Plugins',
'entry.navIntegrations': 'Integrations',
'entry.metaProject': 'Project',
'entry.metaPlugins': 'Plugins',
'entry.metaWorkspace': 'Workspace',
'entry.metaStartProject': 'Start a new project',
'entry.noTabsFound': 'No tabs found',
'entry.pluginDetailsTitle': 'Plugin details',
'entry.marketplaceTitle': 'Marketplace',
'entry.workspaceTabsAria': 'Workspace tabs',
'entry.openWorkspacesAria': 'Open workspaces',
'entry.showHiddenTabs': 'Show hidden tabs',
'entry.moreTabs': '{count} more',
'entry.newTab': 'New tab',
'entry.searchTabs': 'Search tabs',
'entry.openTabs': 'Open tabs',
};
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,
};
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('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('appends and activates a new Home tab when Home is closed and user navigates 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]} />,
);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
expect(labels).toHaveLength(1);
expect(labels[0]).toContain('Project Alpha');
});
// Navigate to Home
rerender(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
await waitFor(() => {
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
// It should append a new Home tab, resulting in 2 tabs total (Project Alpha and Home)
expect(labels).toHaveLength(2);
expect(labels.filter((label) => label.includes('Home'))).toHaveLength(1);
expect(labels.filter((label) => label.includes('Project Alpha'))).toHaveLength(1);
});
});
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('creates a replacement Home tab when the last tab is closed', async () => {
render(<WorkspaceTabsBar route={{ kind: 'home', view: 'home' }} projects={[project]} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
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('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();
});
});
});