Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ apps/web/test-results/
.spec-workflow/
.omx/
optimus/
.superpowers/
.superpowers/
.claude
.playwright-mcp/
3 changes: 3 additions & 0 deletions apps/web/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
- mailbox 创建
- inbox 读取
- admin 基本操作
- `/design-system` 结构与视觉回归脚手架

## 📏 约定

- 端到端测试优先验证“用户路径”,不是内部实现细节
- 当前阶段允许通过 `page.route()` 做 API mock,先保证前端壳层和交互路径稳定
- 更接近真实后端联调的 e2e 可在后续独立 staging 环境中补充
- `design-system.spec.ts` 默认只跑结构断言;截图基线需在预览页示例稳定后,通过 `PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1` 显式开启
- `/design-system` 现在是公开页面;相关 e2e 不要求先登录或挂后台壳层
54 changes: 54 additions & 0 deletions apps/web/e2e/design-system.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test, type Page } from "@playwright/test";

const snapshotUpdatesEnabled = process.env.PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS === "1";

async function mockPublicSession(page: Page) {
await page.route("**/auth/session", async (route) => {
await route.fulfill({ status: 401, json: { error: "not authenticated" } });
});
}

async function visitDesignSystem(page: Page, theme: "light" | "dark") {
await mockPublicSession(page);
await page.addInitScript((nextTheme) => {
window.localStorage.setItem("wemail-workspace-theme", nextTheme);
document.documentElement.dataset.theme = nextTheme;
document.documentElement.style.colorScheme = nextTheme;
}, theme);

await page.goto("/design-system");
await expect(page.getByRole("navigation", { name: "首页导航" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Foundations" })).toBeVisible();
await expect(page.getByRole("navigation", { name: /design system sidebar/i })).toBeVisible();
await expect(page.getByTestId("design-system-page")).toBeVisible();
await expect
.poll(async () => page.evaluate(() => document.documentElement.dataset.theme), { timeout: 10_000 })
.toBe(theme);
}

test("shows the design system page as a sidebar-driven public docsite", async ({ page }) => {
await visitDesignSystem(page, "light");

const sidebarButtons = page.getByRole("navigation", { name: /design system sidebar/i }).getByRole("button");
await expect(sidebarButtons).toHaveCount(14);
await expect(page.getByText("WeMail Design System v1")).toBeVisible();
await expect(page.getByRole("button", { name: "打开对话框" })).toBeVisible();
await expect(page.getByRole("button", { name: "打开抽屉" })).toBeVisible();
});

test.describe("design system visual regression scaffold", () => {
test.skip(
!snapshotUpdatesEnabled,
"Enable PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1 when the preview examples and CI screenshot environment are ready."
);

for (const theme of ["light", "dark"] as const) {
test(`captures the ${theme} theme shell`, async ({ page }) => {
await visitDesignSystem(page, theme);

await expect(page.getByTestId("design-system-page")).toHaveScreenshot(`design-system-${theme}.png`, {
animations: "disabled"
});
});
}
});
11 changes: 11 additions & 0 deletions apps/web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, useLocation } from "react-router-dom";
import { AppLayout } from "./AppLayout";
import { AppRoutes } from "./AppRoutes";
import { AuthPage } from "../pages/AuthPage";
import { DesignSystemPage } from "../pages/DesignSystemPage";
import { WemailLoadingShell } from "../shared/WemailLoadingShell";
import { WemailToastViewport } from "../shared/WemailToastViewport";
import { useAppStore } from "./appStore";
Expand Down Expand Up @@ -59,6 +60,16 @@ function AppContent() {
}, [location.pathname, session]);

const toastViewport = <WemailToastViewport onDismissToast={dismissToast} toasts={toasts} />;
const isPublicDesignSystemPage = location.pathname === "/design-system";

if (isPublicDesignSystemPage) {
return (
<>
{toastViewport}
<DesignSystemPage />
</>
);
}

if (auth.loadingSession) {
return (
Expand Down
44 changes: 29 additions & 15 deletions apps/web/src/app/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import {
Bell,
ChevronDown,
Expand All @@ -18,7 +18,8 @@ import {

import type { SessionSummary } from "@wemail/shared";

import { Button, ButtonLink } from "../shared/button";
import { Button } from "../shared/button";
import { Tabs, TabsList, TabsPanel, TabsTrigger } from "../shared/tabs";
import { WemailBrandLockup } from "../shared/WemailBrandLockup";
import type { WorkspaceRailIcon, WorkspaceShellState } from "./workspaceShell";
import type { WorkspaceTheme } from "./useWorkspaceTheme";
Expand Down Expand Up @@ -81,6 +82,7 @@ export function AppLayout({
children
}: AppLayoutProps) {
const location = useLocation();
const navigate = useNavigate();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement | null>(null);
const railScrollRef = useRef<HTMLElement | null>(null);
Expand Down Expand Up @@ -108,6 +110,9 @@ export function AppLayout({
};
}, []);

const activeSecondaryRoute =
shell.secondaryNav.find((item) => item.to === location.pathname)?.to ?? shell.secondaryNav[0]?.to ?? "";

useEffect(() => {
const areas = [railScrollRef.current, mainScrollRef.current].filter(Boolean) as Array<HTMLElement>;
if (areas.length === 0) return;
Expand Down Expand Up @@ -162,19 +167,28 @@ export function AppLayout({

<div className="workspace-topbar-center">
{shell.secondaryNav.length > 0 ? (
<nav className="workspace-pill-nav workspace-secondary-nav" aria-label={`${shell.activePrimaryLabel} 二级菜单`}>
{shell.secondaryNav.map((item) => (
<ButtonLink
className="workspace-pill-link"
isActive={location.pathname === item.to}
key={item.to}
size="sm"
to={item.to}
variant="pill"
>
{item.label}
</ButtonLink>
))}
<nav aria-label={`${shell.activePrimaryLabel} 二级菜单`} className="workspace-secondary-tabs-nav">
<Tabs
activationMode="automatic"
className="workspace-secondary-tabs"
onValueChange={(nextValue) => {
if (!nextValue || nextValue === location.pathname) return;
void navigate(nextValue);
}}
value={activeSecondaryRoute}
variant="segmented"
>
<TabsList className="workspace-pill-nav workspace-secondary-nav">
{shell.secondaryNav.map((item) => (
<TabsTrigger className="workspace-pill-link" key={item.to} value={item.to}>
{item.label}
</TabsTrigger>
))}
</TabsList>
{shell.secondaryNav.map((item) => (
<TabsPanel className="sr-only" forceMount key={item.to} value={item.to} />
))}
</Tabs>
</nav>
) : (
<div aria-label="当前左侧菜单" className="workspace-pill-nav workspace-secondary-nav workspace-secondary-nav-single">
Expand Down
Loading
Loading