diff --git a/Cargo.lock b/Cargo.lock index ba886089..d657c1f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2548,7 +2548,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "async-trait", @@ -2585,7 +2585,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "async-trait", @@ -2608,7 +2608,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "async-stream", @@ -2648,7 +2648,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "async-trait", @@ -2667,7 +2667,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "async-trait", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9d2bc929..983bcfb2 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -28,6 +28,7 @@ import { import { ThemeProvider } from '@/components/ThemeProvider'; import { OAuthConsentModal } from '@/components/OAuthConsentModal'; import { ServerInstallModal } from '@/components/ServerInstallModal'; +import { WelcomeGuide } from '@/components/WelcomeGuide'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; import { useDataSync } from '@/hooks/useDataSync'; import { useAppStore, useActiveSpace, useViewSpace, useTheme } from '@/stores'; @@ -245,6 +246,8 @@ function App() { return ( + {/* Welcome guide - shown on first launch */} + {/* OAuth consent modal - shown when MCP clients request authorization */} {/* Server install modal - shown when install deep link is received */} diff --git a/apps/desktop/src/components/WelcomeGuide.tsx b/apps/desktop/src/components/WelcomeGuide.tsx new file mode 100644 index 00000000..cd49998a --- /dev/null +++ b/apps/desktop/src/components/WelcomeGuide.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { + ChevronRight, + ChevronLeft, +} from 'lucide-react'; +import { Button, Card } from '@mcpmux/ui'; +import { useAppStore, useHasSeenWelcome } from '@/stores'; +import { WELCOME_STEPS } from './welcomeSteps'; + +export function WelcomeGuide() { + const hasSeenWelcome = useHasSeenWelcome(); + const setHasSeenWelcome = useAppStore((state) => state.setHasSeenWelcome); + const [currentStep, setCurrentStep] = useState(0); + + if (hasSeenWelcome) { + return null; + } + + const step = WELCOME_STEPS[currentStep]; + const isFirstStep = currentStep === 0; + const isLastStep = currentStep === WELCOME_STEPS.length - 1; + const totalSteps = WELCOME_STEPS.length; + + const handleNext = () => { + if (isLastStep) { + setHasSeenWelcome(true); + } else { + setCurrentStep((prev) => prev + 1); + } + }; + + const handleBack = () => { + setCurrentStep((prev) => prev - 1); + }; + + const handleSkip = () => { + setHasSeenWelcome(true); + }; + + return ( +
+ + {/* Step indicator */} +
+
+ {WELCOME_STEPS.map((_, index) => ( +
+ ))} +
+ + {currentStep + 1} / {totalSteps} + +
+ + {/* Icon */} +
+
+ {step.icon} +
+
+ + {/* Content */} +
+

+ {step.title} +

+

+ {step.description} +

+
+ + {/* Details */} +
    + {step.details.map((detail, index) => ( +
  • + + {detail} +
  • + ))} +
+ + {/* Tip */} + {step.tip && ( +
+ Tip: + {step.tip} +
+ )} + + {/* Navigation */} +
+
+ {isFirstStep ? ( + + ) : ( + + )} +
+ +
+ +
+ ); +} diff --git a/apps/desktop/src/components/welcomeSteps.tsx b/apps/desktop/src/components/welcomeSteps.tsx new file mode 100644 index 00000000..5d36e9e0 --- /dev/null +++ b/apps/desktop/src/components/welcomeSteps.tsx @@ -0,0 +1,84 @@ +import { + Globe, + Server, + Monitor, + Wrench, + Rocket, +} from 'lucide-react'; + +export interface WelcomeStep { + id: string; + icon: React.ReactNode; + title: string; + description: string; + details: string[]; + tip: string | null; +} + +export const WELCOME_STEPS: WelcomeStep[] = [ + { + id: 'welcome', + icon: , + title: 'Welcome to McpMux', + description: + 'Your centralized MCP server manager. Configure your MCP servers once, connect every AI client through a single gateway.', + details: [ + 'No more duplicating server configs across Cursor, Claude Desktop, VS Code, and others', + 'Credentials are encrypted in your OS keychain — not stored in plain-text JSON', + 'One local gateway endpoint for all your AI clients', + ], + tip: null, + }, + { + id: 'discover', + icon: , + title: 'Discover & Install Servers', + description: + 'Browse the curated registry and install MCP servers with one click. Search by category, auth type, or name.', + details: [ + 'Open the Discover page from the sidebar to browse available servers', + 'Click Install on any server to add it to your active space', + 'Servers requiring API keys or OAuth will prompt you for credentials', + ], + tip: 'Servers are cached locally so you can browse even when offline.', + }, + { + id: 'spaces', + icon: , + title: 'Organize with Spaces', + description: + 'Spaces are isolated workspaces with their own servers and credentials. Use them to separate work, personal, and project contexts.', + details: [ + 'Create spaces from the Spaces page — each gets its own server set', + 'Switch the active space from the sidebar dropdown', + 'Your AI clients automatically follow the active space', + ], + tip: 'A default space is created for you on first launch.', + }, + { + id: 'connect', + icon: , + title: 'Connect Your AI Clients', + description: + 'Add one config snippet to each AI client. All installed servers become available through the McpMux gateway.', + details: [ + 'Copy the gateway config from the Dashboard page', + 'Paste it into your client\'s MCP settings (inside mcpServers)', + 'Clients like Cursor, Claude Desktop, VS Code, and Windsurf are all supported', + ], + tip: 'The gateway runs locally — your data never leaves your machine.', + }, + { + id: 'featuresets', + icon: , + title: 'Control with FeatureSets', + description: + 'FeatureSets let you control which tools, prompts, and resources each client can access. Create permission bundles for fine-grained control.', + details: [ + 'Go to FeatureSets to create bundles like "Read Only" or "Dev Tools"', + 'Assign specific server features to each bundle', + 'Grant bundles to individual clients from the Clients page', + ], + tip: 'A "Default" FeatureSet is auto-created for each space.', + }, +]; diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index f3030da2..3dab0c45 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -9,6 +9,7 @@ const initialState: AppState = { viewSpaceId: null, sidebarCollapsed: false, theme: 'system', + hasSeenWelcome: false, loading: { spaces: false, servers: false, @@ -95,6 +96,11 @@ export const useAppStore = create()( state.theme = theme; }), + setHasSeenWelcome: (value) => + set((state) => { + state.hasSeenWelcome = value; + }), + // Loading setLoading: (key, value) => set((state) => { @@ -110,6 +116,7 @@ export const useAppStore = create()( activeSpaceId: state.activeSpaceId, sidebarCollapsed: state.sidebarCollapsed, theme: state.theme, + hasSeenWelcome: state.hasSeenWelcome, }), } ) diff --git a/apps/desktop/src/stores/selectors.ts b/apps/desktop/src/stores/selectors.ts index 77cefe3c..fa61843f 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -7,6 +7,7 @@ export const useActiveSpaceId = () => useAppStore((state) => state.activeSpaceId export const useViewSpaceId = () => useAppStore((state) => state.viewSpaceId); export const useTheme = () => useAppStore((state) => state.theme); export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCollapsed); +export const useHasSeenWelcome = () => useAppStore((state) => state.hasSeenWelcome); // Computed selectors export const useActiveSpace = (): Space | null => { diff --git a/apps/desktop/src/stores/types.ts b/apps/desktop/src/stores/types.ts index 99d4feab..d138e6ea 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -9,6 +9,7 @@ export interface AppState { // UI state sidebarCollapsed: boolean; theme: 'light' | 'dark' | 'system'; + hasSeenWelcome: boolean; // Loading states loading: { @@ -29,6 +30,7 @@ export interface AppActions { // UI toggleSidebar: () => void; setTheme: (theme: 'light' | 'dark' | 'system') => void; + setHasSeenWelcome: (value: boolean) => void; // Loading setLoading: (key: keyof AppState['loading'], value: boolean) => void; diff --git a/tests/e2e/pages/WelcomeGuidePage.ts b/tests/e2e/pages/WelcomeGuidePage.ts new file mode 100644 index 00000000..346a55ca --- /dev/null +++ b/tests/e2e/pages/WelcomeGuidePage.ts @@ -0,0 +1,58 @@ +import { Page, Locator } from '@playwright/test'; +import { BasePage } from './BasePage'; + +/** + * Welcome Guide overlay page object + */ +export class WelcomeGuidePage extends BasePage { + readonly overlay: Locator; + readonly card: Locator; + readonly title: Locator; + readonly description: Locator; + readonly stepCount: Locator; + readonly stepIndicator: Locator; + readonly details: Locator; + readonly tip: Locator; + readonly icon: Locator; + readonly nextButton: Locator; + readonly backButton: Locator; + readonly skipButton: Locator; + + constructor(page: Page) { + super(page); + this.overlay = page.getByTestId('welcome-guide-overlay'); + this.card = page.getByTestId('welcome-guide-card'); + this.title = page.getByTestId('welcome-guide-title'); + this.description = page.getByTestId('welcome-guide-description'); + this.stepCount = page.getByTestId('welcome-guide-step-count'); + this.stepIndicator = page.getByTestId('welcome-guide-step-indicator'); + this.details = page.getByTestId('welcome-guide-details'); + this.tip = page.getByTestId('welcome-guide-tip'); + this.icon = page.getByTestId('welcome-guide-icon'); + this.nextButton = page.getByTestId('welcome-guide-next-btn'); + this.backButton = page.getByTestId('welcome-guide-back-btn'); + this.skipButton = page.getByTestId('welcome-guide-skip-btn'); + } + + async navigate() { + await this.goto('/'); + await this.waitForLoad(); + } + + async goToStep(stepNumber: number) { + for (let i = 0; i < stepNumber - 1; i++) { + await this.nextButton.click(); + } + } + + async dismiss() { + await this.skipButton.click(); + } + + async completeAllSteps() { + // Navigate through all 5 steps and click Get Started + for (let i = 0; i < 5; i++) { + await this.nextButton.click(); + } + } +} diff --git a/tests/e2e/pages/index.ts b/tests/e2e/pages/index.ts index 1d76f53d..2d7922f9 100644 --- a/tests/e2e/pages/index.ts +++ b/tests/e2e/pages/index.ts @@ -7,3 +7,4 @@ export { ServersPage } from './ServersPage'; export { RegistryPage } from './RegistryPage'; export { FeatureSetsPage } from './FeatureSetsPage'; export { ClientsPage } from './ClientsPage'; +export { WelcomeGuidePage } from './WelcomeGuidePage'; diff --git a/tests/e2e/specs/welcome-guide.spec.ts b/tests/e2e/specs/welcome-guide.spec.ts new file mode 100644 index 00000000..a13d8cda --- /dev/null +++ b/tests/e2e/specs/welcome-guide.spec.ts @@ -0,0 +1,186 @@ +import { test, expect } from '@playwright/test'; +import { WelcomeGuidePage, DashboardPage } from '../pages'; + +test.describe('Welcome Guide', () => { + test.describe('first launch', () => { + test.beforeEach(async ({ page }) => { + // Clear localStorage to simulate first launch + await page.goto('/'); + await page.evaluate(() => { + const stored = localStorage.getItem('mcpmux-storage'); + if (stored) { + const parsed = JSON.parse(stored); + parsed.state = { ...parsed.state, hasSeenWelcome: false }; + localStorage.setItem('mcpmux-storage', JSON.stringify(parsed)); + } + }); + await page.reload(); + await page.waitForLoadState('networkidle'); + }); + + test('should display welcome guide overlay on first launch', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await expect(welcome.overlay).toBeVisible(); + await expect(welcome.card).toBeVisible(); + }); + + test('should show first step with Welcome to McpMux title', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await expect(welcome.title).toHaveText('Welcome to McpMux'); + await expect(welcome.stepCount).toHaveText('1 / 5'); + }); + + test('should show step description and details', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await expect(welcome.description).toBeVisible(); + await expect(welcome.details).toBeVisible(); + }); + + test('should show Skip and Next buttons on first step', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await expect(welcome.skipButton).toBeVisible(); + await expect(welcome.nextButton).toBeVisible(); + await expect(welcome.nextButton).toHaveText(/Next/); + }); + }); + + test.describe('navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => { + const stored = localStorage.getItem('mcpmux-storage'); + if (stored) { + const parsed = JSON.parse(stored); + parsed.state = { ...parsed.state, hasSeenWelcome: false }; + localStorage.setItem('mcpmux-storage', JSON.stringify(parsed)); + } + }); + await page.reload(); + await page.waitForLoadState('networkidle'); + }); + + test('should navigate to second step', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await welcome.nextButton.click(); + + await expect(welcome.title).toHaveText('Discover & Install Servers'); + await expect(welcome.stepCount).toHaveText('2 / 5'); + await expect(welcome.backButton).toBeVisible(); + }); + + test('should navigate back to first step', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await welcome.nextButton.click(); + await welcome.backButton.click(); + + await expect(welcome.title).toHaveText('Welcome to McpMux'); + await expect(welcome.stepCount).toHaveText('1 / 5'); + }); + + test('should show tip on steps that have one', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + // Step 2 has a tip + await welcome.nextButton.click(); + await expect(welcome.tip).toBeVisible(); + }); + + test('should navigate through all steps to the last', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + + // Navigate to step 5 + await welcome.goToStep(5); + + await expect(welcome.title).toHaveText('Control with FeatureSets'); + await expect(welcome.stepCount).toHaveText('5 / 5'); + await expect(welcome.nextButton).toHaveText('Get Started'); + }); + + test('should show correct titles for each step', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + + const expectedTitles = [ + 'Welcome to McpMux', + 'Discover & Install Servers', + 'Organize with Spaces', + 'Connect Your AI Clients', + 'Control with FeatureSets', + ]; + + for (let i = 0; i < expectedTitles.length; i++) { + await expect(welcome.title).toHaveText(expectedTitles[i]); + if (i < expectedTitles.length - 1) { + await welcome.nextButton.click(); + } + } + }); + }); + + test.describe('dismissal', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => { + const stored = localStorage.getItem('mcpmux-storage'); + if (stored) { + const parsed = JSON.parse(stored); + parsed.state = { ...parsed.state, hasSeenWelcome: false }; + localStorage.setItem('mcpmux-storage', JSON.stringify(parsed)); + } + }); + await page.reload(); + await page.waitForLoadState('networkidle'); + }); + + test('should dismiss when Skip is clicked', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await welcome.skipButton.click(); + + await expect(welcome.overlay).not.toBeVisible(); + }); + + test('should dismiss when Get Started is clicked on last step', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await welcome.completeAllSteps(); + + await expect(welcome.overlay).not.toBeVisible(); + }); + + test('should show dashboard after dismissal', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await welcome.skipButton.click(); + + const dashboard = new DashboardPage(page); + await expect(dashboard.heading).toBeVisible(); + }); + + test('should persist dismissal across page reloads', async ({ page }) => { + const welcome = new WelcomeGuidePage(page); + await welcome.skipButton.click(); + + // Reload the page + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Welcome guide should not appear again + await expect(welcome.overlay).not.toBeVisible(); + }); + }); + + test.describe('returning user', () => { + test('should not show welcome guide when hasSeenWelcome is true', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => { + const stored = localStorage.getItem('mcpmux-storage'); + if (stored) { + const parsed = JSON.parse(stored); + parsed.state = { ...parsed.state, hasSeenWelcome: true }; + localStorage.setItem('mcpmux-storage', JSON.stringify(parsed)); + } + }); + await page.reload(); + await page.waitForLoadState('networkidle'); + + const welcome = new WelcomeGuidePage(page); + await expect(welcome.overlay).not.toBeVisible(); + }); + }); +}); diff --git a/tests/e2e/specs/welcome-guide.wdio.ts b/tests/e2e/specs/welcome-guide.wdio.ts new file mode 100644 index 00000000..eda3af80 --- /dev/null +++ b/tests/e2e/specs/welcome-guide.wdio.ts @@ -0,0 +1,154 @@ +/** + * E2E Tests: Welcome Guide (Desktop) + * Tests the first-launch welcome onboarding flow in the Tauri desktop app. + * Uses data-testid only (ADR-003). + */ + +import { byTestId, TIMEOUT, safeClick } from '../helpers/selectors'; + +describe('Welcome Guide - First Launch', () => { + it('should display welcome guide overlay on first launch', async () => { + const overlay = await byTestId('welcome-guide-overlay'); + await overlay.waitForDisplayed({ timeout: TIMEOUT.medium }); + await expect(overlay).toBeDisplayed(); + }); + + it('should show first step with Welcome to McpMux title', async () => { + const title = await byTestId('welcome-guide-title'); + await expect(title).toBeDisplayed(); + const titleText = await title.getText(); + expect(titleText).toBe('Welcome to McpMux'); + }); + + it('should show step count as 1 / 5', async () => { + const stepCount = await byTestId('welcome-guide-step-count'); + const text = await stepCount.getText(); + expect(text).toBe('1 / 5'); + }); + + it('should show step description', async () => { + const description = await byTestId('welcome-guide-description'); + await expect(description).toBeDisplayed(); + }); + + it('should show step details list', async () => { + const details = await byTestId('welcome-guide-details'); + await expect(details).toBeDisplayed(); + }); + + it('should show icon', async () => { + const icon = await byTestId('welcome-guide-icon'); + await expect(icon).toBeDisplayed(); + }); + + it('should show Skip button on first step', async () => { + const skipBtn = await byTestId('welcome-guide-skip-btn'); + await expect(skipBtn).toBeDisplayed(); + }); + + it('should show Next button on first step', async () => { + const nextBtn = await byTestId('welcome-guide-next-btn'); + await expect(nextBtn).toBeDisplayed(); + const text = await nextBtn.getText(); + expect(text).toContain('Next'); + }); +}); + +describe('Welcome Guide - Navigation', () => { + it('should navigate to second step when Next is clicked', async () => { + const nextBtn = await byTestId('welcome-guide-next-btn'); + await safeClick(nextBtn); + await browser.pause(500); + + const title = await byTestId('welcome-guide-title'); + const titleText = await title.getText(); + expect(titleText).toBe('Discover & Install Servers'); + + const stepCount = await byTestId('welcome-guide-step-count'); + const countText = await stepCount.getText(); + expect(countText).toBe('2 / 5'); + }); + + it('should show Back button on second step', async () => { + const backBtn = await byTestId('welcome-guide-back-btn'); + await expect(backBtn).toBeDisplayed(); + }); + + it('should show tip on step with tip', async () => { + const tip = await byTestId('welcome-guide-tip'); + await expect(tip).toBeDisplayed(); + }); + + it('should navigate back to first step', async () => { + const backBtn = await byTestId('welcome-guide-back-btn'); + await safeClick(backBtn); + await browser.pause(500); + + const title = await byTestId('welcome-guide-title'); + const titleText = await title.getText(); + expect(titleText).toBe('Welcome to McpMux'); + + const stepCount = await byTestId('welcome-guide-step-count'); + const countText = await stepCount.getText(); + expect(countText).toBe('1 / 5'); + }); + + it('should navigate through all steps to the last', async () => { + const expectedTitles = [ + 'Welcome to McpMux', + 'Discover & Install Servers', + 'Organize with Spaces', + 'Connect Your AI Clients', + 'Control with FeatureSets', + ]; + + for (let i = 0; i < expectedTitles.length; i++) { + const title = await byTestId('welcome-guide-title'); + const titleText = await title.getText(); + expect(titleText).toBe(expectedTitles[i]); + + if (i < expectedTitles.length - 1) { + const nextBtn = await byTestId('welcome-guide-next-btn'); + await safeClick(nextBtn); + await browser.pause(300); + } + } + }); + + it('should show Get Started on last step', async () => { + const nextBtn = await byTestId('welcome-guide-next-btn'); + const text = await nextBtn.getText(); + expect(text).toContain('Get Started'); + }); + + it('should show step count as 5 / 5 on last step', async () => { + const stepCount = await byTestId('welcome-guide-step-count'); + const countText = await stepCount.getText(); + expect(countText).toBe('5 / 5'); + }); +}); + +describe('Welcome Guide - Dismissal', () => { + it('should dismiss when Get Started is clicked on last step', async () => { + // We should be on the last step from previous describe block + const nextBtn = await byTestId('welcome-guide-next-btn'); + await safeClick(nextBtn); + await browser.pause(500); + + // Overlay should be gone + const overlay = await byTestId('welcome-guide-overlay'); + const isDisplayed = await overlay.isDisplayed().catch(() => false); + expect(isDisplayed).toBe(false); + }); + + it('should show dashboard after dismissal', async () => { + const pageSource = await browser.getPageSource(); + expect(pageSource.includes('Dashboard')).toBe(true); + }); + + it('should not show welcome guide after dismissal', async () => { + const overlay = await byTestId('welcome-guide-overlay'); + const isDisplayed = await overlay.isDisplayed().catch(() => false); + expect(isDisplayed).toBe(false); + }); +}); diff --git a/tests/ts/components/WelcomeGuide.test.tsx b/tests/ts/components/WelcomeGuide.test.tsx new file mode 100644 index 00000000..86b35a3a --- /dev/null +++ b/tests/ts/components/WelcomeGuide.test.tsx @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { WelcomeGuide } from '../../../apps/desktop/src/components/WelcomeGuide'; +import { WELCOME_STEPS } from '../../../apps/desktop/src/components/welcomeSteps'; +import { useAppStore } from '../../../apps/desktop/src/stores/appStore'; + +describe('WelcomeGuide', () => { + beforeEach(() => { + useAppStore.setState({ + spaces: [], + activeSpaceId: null, + viewSpaceId: null, + sidebarCollapsed: false, + theme: 'system', + hasSeenWelcome: false, + loading: { spaces: false, servers: false }, + }); + }); + + describe('visibility', () => { + it('should render when hasSeenWelcome is false', () => { + render(); + expect(screen.getByTestId('welcome-guide-overlay')).toBeInTheDocument(); + }); + + it('should not render when hasSeenWelcome is true', () => { + useAppStore.setState({ hasSeenWelcome: true }); + render(); + expect(screen.queryByTestId('welcome-guide-overlay')).toBeNull(); + }); + }); + + describe('first step rendering', () => { + it('should show the first step title', () => { + render(); + expect(screen.getByTestId('welcome-guide-title')).toHaveTextContent(WELCOME_STEPS[0].title); + }); + + it('should show the first step description', () => { + render(); + expect(screen.getByTestId('welcome-guide-description')).toHaveTextContent(WELCOME_STEPS[0].description); + }); + + it('should display step count as 1 / 5', () => { + render(); + expect(screen.getByTestId('welcome-guide-step-count')).toHaveTextContent('1 / 5'); + }); + + it('should show step details', () => { + render(); + const details = screen.getByTestId('welcome-guide-details'); + const items = within(details).getAllByRole('listitem'); + expect(items).toHaveLength(WELCOME_STEPS[0].details.length); + }); + + it('should show Skip button on first step', () => { + render(); + expect(screen.getByTestId('welcome-guide-skip-btn')).toBeInTheDocument(); + }); + + it('should not show Back button on first step', () => { + render(); + expect(screen.queryByTestId('welcome-guide-back-btn')).toBeNull(); + }); + + it('should show Next button', () => { + render(); + expect(screen.getByTestId('welcome-guide-next-btn')).toHaveTextContent('Next'); + }); + + it('should not show tip when step has no tip', () => { + render(); + // First step has no tip + expect(screen.queryByTestId('welcome-guide-tip')).toBeNull(); + }); + }); + + describe('navigation', () => { + it('should advance to next step when Next is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('welcome-guide-next-btn')); + + expect(screen.getByTestId('welcome-guide-title')).toHaveTextContent(WELCOME_STEPS[1].title); + expect(screen.getByTestId('welcome-guide-step-count')).toHaveTextContent('2 / 5'); + }); + + it('should show Back button on second step', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('welcome-guide-next-btn')); + + expect(screen.getByTestId('welcome-guide-back-btn')).toBeInTheDocument(); + expect(screen.queryByTestId('welcome-guide-skip-btn')).toBeNull(); + }); + + it('should go back to previous step when Back is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('welcome-guide-next-btn')); + await user.click(screen.getByTestId('welcome-guide-back-btn')); + + expect(screen.getByTestId('welcome-guide-title')).toHaveTextContent(WELCOME_STEPS[0].title); + expect(screen.getByTestId('welcome-guide-step-count')).toHaveTextContent('1 / 5'); + }); + + it('should show tip on steps that have one', async () => { + const user = userEvent.setup(); + render(); + + // Navigate to step 2 (Discover), which has a tip + await user.click(screen.getByTestId('welcome-guide-next-btn')); + + expect(screen.getByTestId('welcome-guide-tip')).toBeInTheDocument(); + expect(screen.getByTestId('welcome-guide-tip')).toHaveTextContent(WELCOME_STEPS[1].tip!); + }); + + it('should navigate through all steps', async () => { + const user = userEvent.setup(); + render(); + + for (let i = 0; i < WELCOME_STEPS.length - 1; i++) { + expect(screen.getByTestId('welcome-guide-title')).toHaveTextContent(WELCOME_STEPS[i].title); + await user.click(screen.getByTestId('welcome-guide-next-btn')); + } + + // Should now be on the last step + expect(screen.getByTestId('welcome-guide-title')).toHaveTextContent( + WELCOME_STEPS[WELCOME_STEPS.length - 1].title + ); + }); + + it('should show Get Started on last step', async () => { + const user = userEvent.setup(); + render(); + + // Navigate to the last step + for (let i = 0; i < WELCOME_STEPS.length - 1; i++) { + await user.click(screen.getByTestId('welcome-guide-next-btn')); + } + + expect(screen.getByTestId('welcome-guide-next-btn')).toHaveTextContent('Get Started'); + }); + }); + + describe('dismissal', () => { + it('should set hasSeenWelcome to true when Skip is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('welcome-guide-skip-btn')); + + expect(useAppStore.getState().hasSeenWelcome).toBe(true); + }); + + it('should set hasSeenWelcome to true when Get Started is clicked on last step', async () => { + const user = userEvent.setup(); + render(); + + // Navigate to the last step + for (let i = 0; i < WELCOME_STEPS.length - 1; i++) { + await user.click(screen.getByTestId('welcome-guide-next-btn')); + } + + await user.click(screen.getByTestId('welcome-guide-next-btn')); + + expect(useAppStore.getState().hasSeenWelcome).toBe(true); + }); + + it('should hide the guide after Skip is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId('welcome-guide-skip-btn')); + + expect(screen.queryByTestId('welcome-guide-overlay')).toBeNull(); + }); + }); + + describe('step data integrity', () => { + it('should have exactly 5 steps', () => { + expect(WELCOME_STEPS).toHaveLength(5); + }); + + it('should have unique step IDs', () => { + const ids = WELCOME_STEPS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('each step should have title, description, and details', () => { + for (const step of WELCOME_STEPS) { + expect(step.title).toBeTruthy(); + expect(step.description).toBeTruthy(); + expect(step.details.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/tests/ts/stores/appStore.test.ts b/tests/ts/stores/appStore.test.ts index 9fb20e2f..f503a5a6 100644 --- a/tests/ts/stores/appStore.test.ts +++ b/tests/ts/stores/appStore.test.ts @@ -11,6 +11,7 @@ describe('appStore', () => { viewSpaceId: null, sidebarCollapsed: false, theme: 'system', + hasSeenWelcome: false, loading: { spaces: false, servers: false }, }); }); @@ -278,6 +279,23 @@ describe('appStore', () => { }); }); + describe('setHasSeenWelcome', () => { + it('should default to false', () => { + expect(useAppStore.getState().hasSeenWelcome).toBe(false); + }); + + it('should set hasSeenWelcome to true', () => { + useAppStore.getState().setHasSeenWelcome(true); + expect(useAppStore.getState().hasSeenWelcome).toBe(true); + }); + + it('should set hasSeenWelcome back to false', () => { + useAppStore.getState().setHasSeenWelcome(true); + useAppStore.getState().setHasSeenWelcome(false); + expect(useAppStore.getState().hasSeenWelcome).toBe(false); + }); + }); + describe('setLoading', () => { it('should set spaces loading state', () => { useAppStore.getState().setLoading('spaces', true);