Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 285 additions & 0 deletions src/__tests__/e2e/specs/workspace/workspace-switching.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { test } from '@/__tests__/e2e/support/fixtures/test-hooks';
import { expect } from '@playwright/test';
import { AuthPage, DashboardPage } from '@/__tests__/e2e/support/page-objects';
import { createTestWorkspaceScenario, createTestWorkspace } from '@/__tests__/e2e/support/fixtures/database';
import { selectors, dynamicSelectors } from '@/__tests__/e2e/support/fixtures/selectors';

/**
* Workspace Switching E2E Tests
*
* Tests the ability for users to switch between workspaces using the
* workspace switcher in the top left of the dashboard.
*/
test.describe('Workspace Switching', () => {
let authPage: AuthPage;
let dashboardPage: DashboardPage;
let firstWorkspaceSlug: string;
let secondWorkspaceSlug: string;
let firstWorkspaceName: string;
let secondWorkspaceName: string;

test.beforeEach(async ({ page }) => {
// Create first workspace scenario
const firstScenario = await createTestWorkspaceScenario({
owner: {
name: "E2E Test Owner",
email: "e2e-owner@example.com",
withGitHubAuth: true,
githubUsername: "e2e-test-owner",
},
workspace: {
name: "First Workspace",
slug: `e2e-workspace-1-${Date.now()}`,
description: "First workspace for switching test",
},
});

// Create second workspace for the same user
const secondWorkspace = await createTestWorkspace({
ownerId: firstScenario.owner.id,
name: "Second Workspace",
slug: `e2e-workspace-2-${Date.now()}`,
description: "Second workspace for switching test",
});

firstWorkspaceSlug = firstScenario.workspace.slug;
secondWorkspaceSlug = secondWorkspace.slug;
firstWorkspaceName = firstScenario.workspace.name;
secondWorkspaceName = secondWorkspace.name;

authPage = new AuthPage(page);
dashboardPage = new DashboardPage(page);

// Sign in and navigate to first workspace
await authPage.goto();
await authPage.signInWithMock();
await page.waitForURL(/\/w\/.*/, { timeout: 10000 });
});

test('should display workspace switcher with current workspace', async ({ page }) => {
await test.step('verify workspace switcher is visible', async () => {
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await expect(switcherTrigger).toBeVisible();
await expect(switcherTrigger).toContainText(firstWorkspaceName);
});
});

test('should switch to another workspace from dashboard', async ({ page }) => {
await test.step('navigate to first workspace dashboard', async () => {
await dashboardPage.goto(firstWorkspaceSlug);
await dashboardPage.waitForLoad();
expect(page.url()).toContain(`/w/${firstWorkspaceSlug}`);
});

await test.step('open workspace switcher dropdown', async () => {
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await switcherTrigger.click();

// Wait for dropdown to be visible
const dropdown = page.locator(selectors.workspaceSwitcher.dropdown);
await expect(dropdown).toBeVisible({ timeout: 5000 });
});

await test.step('select second workspace from dropdown', async () => {
// Click on the second workspace option using slug selector
const secondWorkspaceOption = page.locator(
dynamicSelectors.workspaceSwitcherOptionBySlug(secondWorkspaceSlug)
);

await expect(secondWorkspaceOption).toBeVisible();
await secondWorkspaceOption.click();
});

await test.step('verify navigation to second workspace', async () => {
// Wait for URL to change to second workspace
await page.waitForURL(new RegExp(`/w/${secondWorkspaceSlug}`), { timeout: 10000 });

// Verify we're on the dashboard of the second workspace
expect(page.url()).toContain(`/w/${secondWorkspaceSlug}`);
await expect(page.locator(selectors.pageTitle.dashboard)).toBeVisible();
});

await test.step('verify workspace switcher shows new workspace', async () => {
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await expect(switcherTrigger).toBeVisible();
await expect(switcherTrigger).toContainText(secondWorkspaceName);
});
});

test('should persist workspace context after reload', async ({ page }) => {
await test.step('switch to second workspace', async () => {
await dashboardPage.goto(firstWorkspaceSlug);
await dashboardPage.waitForLoad();

// Open dropdown and switch
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await switcherTrigger.click();

const dropdown = page.locator(selectors.workspaceSwitcher.dropdown);
await expect(dropdown).toBeVisible();

const secondWorkspaceOption = page.locator(
dynamicSelectors.workspaceSwitcherOptionBySlug(secondWorkspaceSlug)
);
await secondWorkspaceOption.click();

await page.waitForURL(new RegExp(`/w/${secondWorkspaceSlug}`), { timeout: 10000 });
});

await test.step('reload page and verify workspace persists', async () => {
await page.reload();
await dashboardPage.waitForLoad();

// Verify we're still on the second workspace
expect(page.url()).toContain(`/w/${secondWorkspaceSlug}`);

const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await expect(switcherTrigger).toContainText(secondWorkspaceName);
});
});

test('should switch workspaces and navigate to different pages', async ({ page }) => {
await test.step('start on first workspace dashboard', async () => {
await dashboardPage.goto(firstWorkspaceSlug);
await dashboardPage.waitForLoad();
expect(page.url()).toContain(`/w/${firstWorkspaceSlug}`);
});

await test.step('navigate to tasks page on first workspace', async () => {
await page.locator(selectors.navigation.tasksLink).first().click();
await page.waitForURL(new RegExp(`/w/${firstWorkspaceSlug}/tasks`), { timeout: 10000 });
expect(page.url()).toContain(`/w/${firstWorkspaceSlug}/tasks`);
});

await test.step('switch to second workspace from tasks page', async () => {
// Open workspace switcher
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await switcherTrigger.click();

const dropdown = page.locator(selectors.workspaceSwitcher.dropdown);
await expect(dropdown).toBeVisible();

// Select second workspace
const secondWorkspaceOption = page.locator(
dynamicSelectors.workspaceSwitcherOptionBySlug(secondWorkspaceSlug)
);
await secondWorkspaceOption.click();

// Should navigate to dashboard of second workspace
await page.waitForURL(new RegExp(`/w/${secondWorkspaceSlug}`), { timeout: 10000 });
});

await test.step('verify we are on second workspace dashboard', async () => {
expect(page.url()).toContain(`/w/${secondWorkspaceSlug}`);
await expect(page.locator(selectors.pageTitle.dashboard)).toBeVisible();

const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await expect(switcherTrigger).toContainText(secondWorkspaceName);
});
});

test('should show both workspaces in dropdown menu', async ({ page }) => {
await test.step('navigate to first workspace', async () => {
await dashboardPage.goto(firstWorkspaceSlug);
await dashboardPage.waitForLoad();
});

await test.step('open workspace switcher and verify both workspaces listed', async () => {
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await switcherTrigger.click();

const dropdown = page.locator(selectors.workspaceSwitcher.dropdown);
await expect(dropdown).toBeVisible();

// Verify current workspace is shown
const currentWorkspace = page.locator(selectors.workspaceSwitcher.currentWorkspace);
await expect(currentWorkspace).toBeVisible();
await expect(currentWorkspace).toContainText(firstWorkspaceName);

// Verify second workspace is shown in the list
const secondWorkspaceOption = page.locator(
dynamicSelectors.workspaceSwitcherOptionBySlug(secondWorkspaceSlug)
);
await expect(secondWorkspaceOption).toBeVisible();
await expect(secondWorkspaceOption).toContainText(secondWorkspaceName);
});
});

test('should maintain workspace context when navigating between pages', async ({ page }) => {
await test.step('switch to second workspace', async () => {
await dashboardPage.goto(firstWorkspaceSlug);
await dashboardPage.waitForLoad();

const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await switcherTrigger.click();

const dropdown = page.locator(selectors.workspaceSwitcher.dropdown);
await expect(dropdown).toBeVisible();

const secondWorkspaceOption = page.locator(
dynamicSelectors.workspaceSwitcherOptionBySlug(secondWorkspaceSlug)
);
await secondWorkspaceOption.click();

await page.waitForURL(new RegExp(`/w/${secondWorkspaceSlug}`), { timeout: 10000 });
});

await test.step('navigate to tasks page', async () => {
await page.locator(selectors.navigation.tasksLink).first().click();
await page.waitForURL(new RegExp(`/w/${secondWorkspaceSlug}/tasks`), { timeout: 10000 });
expect(page.url()).toContain(`/w/${secondWorkspaceSlug}/tasks`);
});

await test.step('navigate back to dashboard', async () => {
await page.locator(selectors.navigation.dashboardLink).first().click();
await page.waitForURL(new RegExp(`/w/${secondWorkspaceSlug}$`), { timeout: 10000 });
expect(page.url()).toMatch(new RegExp(`/w/${secondWorkspaceSlug}(/)?$`));
});

await test.step('verify still on second workspace', async () => {
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await expect(switcherTrigger).toContainText(secondWorkspaceName);
});
});

test('should display create workspace option in dropdown', async ({ page }) => {
await test.step('navigate to workspace dashboard', async () => {
await dashboardPage.goto(firstWorkspaceSlug);
await dashboardPage.waitForLoad();
});

await test.step('open workspace switcher and verify create option', async () => {
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await switcherTrigger.click();

const dropdown = page.locator(selectors.workspaceSwitcher.dropdown);
await expect(dropdown).toBeVisible();

// Verify create workspace button is visible
const createButton = page.locator(selectors.workspaceSwitcher.createButton);
await expect(createButton).toBeVisible();
await expect(createButton).toContainText(/Create new workspace/i);
});
});

test('should close dropdown when clicking outside', async ({ page }) => {
await test.step('navigate to workspace dashboard', async () => {
await dashboardPage.goto(firstWorkspaceSlug);
await dashboardPage.waitForLoad();
});

await test.step('open and close workspace switcher dropdown', async () => {
const switcherTrigger = page.locator(selectors.workspaceSwitcher.trigger);
await switcherTrigger.click();

const dropdown = page.locator(selectors.workspaceSwitcher.dropdown);
await expect(dropdown).toBeVisible();

// Click outside the dropdown
await page.locator(selectors.pageTitle.dashboard).click();

// Dropdown should be hidden
await expect(dropdown).not.toBeVisible({ timeout: 2000 });
});
});
});
22 changes: 22 additions & 0 deletions src/__tests__/e2e/support/fixtures/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ export const selectors = {
createButton: 'button:has-text("Create")',
},

// Workspace Switcher
workspaceSwitcher: {
container: '[data-testid="workspace-switcher-container"]',
trigger: '[data-testid="workspace-switcher-trigger"]',
dropdown: '[data-testid="workspace-switcher-dropdown"]',
currentWorkspace: '[data-testid="workspace-switcher-current"]',
option: '[data-testid="workspace-switcher-option"]',
createButton: '[data-testid="workspace-switcher-create"]',
},

workspaceMembers: {
card: '[data-testid="workspace-members-card"]',
addButton: '[data-testid="add-member-button"]',
Expand Down Expand Up @@ -175,4 +185,16 @@ export const dynamicSelectors = {

workspaceMemberRoleBadgeByUsername: (username: string) =>
`[data-testid="workspace-member-row"][data-member-username="${username}"] [data-testid="member-role-badge"]`,

/**
* Select workspace switcher option by workspace slug
*/
workspaceSwitcherOptionBySlug: (slug: string) =>
`[data-testid="workspace-switcher-option"][data-workspace-slug="${slug}"]`,

/**
* Select workspace switcher option by workspace ID
*/
workspaceSwitcherOptionById: (id: string) =>
`[data-testid="workspace-switcher-option"][data-workspace-id="${id}"]`,
};
15 changes: 13 additions & 2 deletions src/components/WorkspaceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,11 @@ export function WorkspaceSwitcher({
}

return (
<div className="p-4 border-b">
<div className="p-4 border-b" data-testid="workspace-switcher-container">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="workspace-switcher-trigger"
variant="outline"
className="w-full justify-between h-auto p-3 hover:bg-accent transition-colors"
disabled={loading}
Expand Down Expand Up @@ -134,13 +135,19 @@ export function WorkspaceSwitcher({
side="bottom"
sideOffset={8}
forceMount
data-testid="workspace-switcher-dropdown"
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Workspaces ({workspaces.length})
</DropdownMenuLabel>

{/* Current Workspace */}
<DropdownMenuItem className="flex items-center gap-2 p-2 bg-accent/50">
<DropdownMenuItem
className="flex items-center gap-2 p-2 bg-accent/50"
data-testid="workspace-switcher-current"
data-workspace-id={activeWorkspace.id}
data-workspace-slug={activeWorkspace.slug}
>
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-primary text-primary-foreground">
<Building2 className="w-3.5 h-3.5" />
</div>
Expand All @@ -162,6 +169,9 @@ export function WorkspaceSwitcher({
key={workspace.id}
onClick={() => handleWorkspaceSelect(workspace)}
className="flex items-center gap-2 p-2"
data-testid="workspace-switcher-option"
data-workspace-id={workspace.id}
data-workspace-slug={workspace.slug}
>
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-muted">
<Building2 className="w-3.5 h-3.5" />
Expand All @@ -185,6 +195,7 @@ export function WorkspaceSwitcher({
className={`flex items-center gap-2 p-2 ${
isAtLimit ? 'opacity-60 cursor-not-allowed' : ''
}`}
data-testid="workspace-switcher-create"
>
<div className={`flex items-center justify-center w-6 h-6 rounded-md ${
isAtLimit ? 'border border-muted bg-muted' : 'border border-dashed'
Expand Down