From f0167c26b310a64c24b232d13ed346a58dc03b1b Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 12:38:16 +0800 Subject: [PATCH 1/5] test(routes): provide guest auth sessions Route-level tests now render an app shell that consumes the authentication context. A shared guest wrapper supplies the same provider boundary used by production composition. Projects and playtest coverage remains focused on page behavior without duplicating auth setup. --- frontend/src/pages/playtest/index.test.tsx | 9 +++++--- frontend/src/pages/projects/index.test.tsx | 25 ++++++++++++++-------- frontend/src/test/auth-session.tsx | 21 ++++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 frontend/src/test/auth-session.tsx diff --git a/frontend/src/pages/playtest/index.test.tsx b/frontend/src/pages/playtest/index.test.tsx index 148dc8e6..4b056935 100644 --- a/frontend/src/pages/playtest/index.test.tsx +++ b/frontend/src/pages/playtest/index.test.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { AppRoutes } from '@/app' +import { GuestAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { @@ -26,9 +27,11 @@ function renderPlaytest(path: string, fetchFn?: typeof globalThis.fetch) { vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', fetchFn ?? createProjectAssetsBackend().fetch) return render( - - - , + + + + + , ) } diff --git a/frontend/src/pages/projects/index.test.tsx b/frontend/src/pages/projects/index.test.tsx index c9fd6388..4a2f59d0 100644 --- a/frontend/src/pages/projects/index.test.tsx +++ b/frontend/src/pages/projects/index.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router' import { AppRoutes } from '@/app' +import { GuestAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' afterEach(() => { @@ -23,9 +24,11 @@ describe('ProjectsPage', () => { it('renders backend Projects as the first browsing level', async () => { installBackend() const { container } = render( - - - , + + + + + , ) expect(await screen.findByRole('heading', { name: '项目中心' })).toBeTruthy() @@ -41,9 +44,11 @@ describe('ProjectsPage', () => { it('sends creation to the project create page and deletes through the Project API', async () => { const backend = installBackend() render( - - - , + + + + + , ) expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2) @@ -70,9 +75,11 @@ describe('ProjectsPage', () => { vi.stubEnv('VITE_API_BASE_URL', 'https://api.windup.test') vi.stubGlobal('fetch', backend.fetch) render( - - - , + + + + + , ) expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(12) diff --git a/frontend/src/test/auth-session.tsx b/frontend/src/test/auth-session.tsx new file mode 100644 index 00000000..55469c5e --- /dev/null +++ b/frontend/src/test/auth-session.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' + +import type { UserApis } from '@/entities' +import { AuthSessionProvider } from '@/features/auth-session' + +const guestApis: UserApis = { + sendCode: async () => undefined, + register: async () => Promise.reject(new Error('guest test session does not register')), + login: async () => Promise.reject(new Error('guest test session does not log in')), + loginByCode: async () => Promise.reject(new Error('guest test session does not log in')), + refresh: async () => Promise.reject(new Error('guest test session has no refresh token')), + logout: async () => undefined, + me: async () => Promise.reject(new Error('guest test session has no current user')), + changePassword: async () => + Promise.reject(new Error('guest test session cannot change password')), +} + +/** 为直接渲染 AppRoutes 的页面测试补齐生产组合根中的访客会话。 */ +export function GuestAuthSession({ children }: { children: ReactNode }) { + return {children} +} From 4274ee576d217678db9eb7182ea894139e1bd72e Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 12:38:56 +0800 Subject: [PATCH 2/5] feat(header): add account access The account dialog had no discoverable entry in the product shell. The header now reflects session state, preserves safe return paths, and exposes local logout. Responsive sizing keeps navigation and account controls usable without horizontal overflow. --- frontend/src/app/layout/app-header.tsx | 115 ++++++++++++++++++------- 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 4ad4db7b..2ec75c12 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -1,4 +1,6 @@ -import { Link, useLocation } from 'react-router' +import { Link, useLocation, useNavigate } from 'react-router' + +import { useAuthSession } from '@/features/auth-session' interface ProductNavigationItem { to: string @@ -53,8 +55,19 @@ function getWorkspaceLabel(pathname: string): { title: string; detail: string } * 悬浮不占布局高度,页面顶部留白由页面或 PageContainer 自己让出。 */ export function AppHeader() { - const { pathname } = useLocation() + const { pathname, search, hash } = useLocation() + const navigate = useNavigate() + const session = useAuthSession() const workspace = getWorkspaceLabel(pathname) + const accountEntry = `/?${new URLSearchParams({ + account: 'login', + returnTo: `${pathname}${search}${hash}`, + })}` + + function signOut() { + const returnHome = () => navigate('/', { replace: true }) + void session.logout().then(returnHome, returnHome) + } return (
@@ -62,10 +75,10 @@ export function AppHeader() { - Windup + Windup @@ -74,38 +87,76 @@ export function AppHeader() { - + ) : ( + <> + + {session.state.user.nickname || session.state.user.email} + + + + )} + +
) } From 0c21a08681db8cd4ed04471705e985e8a4d9af74 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 12:39:13 +0800 Subject: [PATCH 3/5] test(header): cover account states Header behavior now depends on bootstrapped guest and authenticated session states. The tests exercise safe login return paths, identity display, logout, and route navigation. A real session provider keeps the assertions aligned with production composition. --- frontend/src/app/layout/app-header.test.tsx | 101 +++++++++++++++++--- 1 file changed, 87 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index 33c363bc..4f920c0f 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -1,19 +1,75 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { MemoryRouter } from 'react-router' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import type { AuthTokens, UserApis } from '@/entities' +import { AuthSessionProvider } from '@/features/auth-session' import { AppHeader } from './app-header' -afterEach(cleanup) +const user = { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 0, +} + +function tokens(): AuthTokens { + return { accessToken: 'access-token', refreshToken: 'rotated-refresh-token', user } +} + +function createApis(): UserApis & Record> { + return { + sendCode: vi.fn(async () => undefined), + register: vi.fn(async () => tokens()), + login: vi.fn(async () => tokens()), + loginByCode: vi.fn(async () => tokens()), + refresh: vi.fn(async () => tokens()), + logout: vi.fn(async () => undefined), + me: vi.fn(async () => user), + changePassword: vi.fn(async () => undefined), + } +} + +function LocationProbe() { + const location = useLocation() + return ( + {`${location.pathname}${location.search}${location.hash}`} + ) +} + +function renderHeader(entry = '/', apis = createApis()) { + return { + apis, + ...render( + + + + + + + + } + /> + + + , + ), + } +} + +afterEach(() => { + cleanup() + window.localStorage.clear() +}) describe('AppHeader', () => { it('保留三个产品入口,并将工作流路由归入创作', () => { - render( - - - , - ) + renderHeader('/workflow-editor/run-1') expect(screen.getByRole('link', { name: '返回 Windup 首页' }).getAttribute('href')).toBe('/') expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('href')).toBe('/projects') @@ -22,14 +78,31 @@ describe('AppHeader', () => { }) it('在首页只高亮首页一项', () => { - render( - - - , - ) + renderHeader() expect(screen.getByRole('link', { name: '首页' }).getAttribute('aria-current')).toBe('page') expect(screen.getByRole('link', { name: '项目资产' }).getAttribute('aria-current')).toBeNull() expect(screen.getByRole('link', { name: '创作' }).getAttribute('aria-current')).toBeNull() }) + + it('为访客提供可发现的登录入口并保留完整站内回跳地址', async () => { + renderHeader('/quick-start?mode=fast#brief') + + const entry = await screen.findByRole('link', { name: '登录 / 注册' }) + expect(entry.getAttribute('href')).toBe( + '/?account=login&returnTo=%2Fquick-start%3Fmode%3Dfast%23brief', + ) + }) + + it('显示登录用户并在登出后回到首页访客态', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + const { apis } = renderHeader('/projects') + + expect(await screen.findByText('Reader')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '退出登录' })) + + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) + expect(await screen.findByRole('link', { name: '登录 / 注册' })).toBeTruthy() + expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token') + }) }) From 713d05aebd6beb9c43526f59a31e186219a3c368 Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 16:49:23 +0800 Subject: [PATCH 4/5] test(auth): align route tests with session provider The rebased header now consumes the shared auth session in every AppShell render. Add authenticated and guest test wrappers and isolate the closed account panel test from AppShell. Restore the rebased frontend suite without changing production behavior. --- .../src/features/account-panel/index.test.tsx | 5 +- .../src/pages/project-create/index.test.tsx | 59 ++++++++----------- frontend/src/test/auth-session.tsx | 49 ++++++++++++++- 3 files changed, 73 insertions(+), 40 deletions(-) diff --git a/frontend/src/features/account-panel/index.test.tsx b/frontend/src/features/account-panel/index.test.tsx index 8a9bfed7..eedfcb8c 100644 --- a/frontend/src/features/account-panel/index.test.tsx +++ b/frontend/src/features/account-panel/index.test.tsx @@ -275,13 +275,10 @@ describe('AppShell account panel host', () => { it('does not require an auth context while the panel is closed', () => { render( - -

当前页面

-
+
, ) - expect(screen.getByText('当前页面')).toBeTruthy() expect(screen.queryByRole('dialog')).toBeNull() }) diff --git a/frontend/src/pages/project-create/index.test.tsx b/frontend/src/pages/project-create/index.test.tsx index b9b05f11..a32b9ed7 100644 --- a/frontend/src/pages/project-create/index.test.tsx +++ b/frontend/src/pages/project-create/index.test.tsx @@ -4,14 +4,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter } from 'react-router' import { AppRoutes } from '@/app' -import { registerApiAccessTokenProvider } from '@/shared/api' +import { AuthenticatedAuthSession, GuestAuthSession } from '@/test/auth-session' import { createProjectAssetsBackend } from '@/test/project-assets-backend' -const revokeProviders: Array<() => void> = [] - afterEach(() => { cleanup() - while (revokeProviders.length) revokeProviders.pop()?.() + window.localStorage.clear() vi.unstubAllEnvs() vi.unstubAllGlobals() }) @@ -23,17 +21,17 @@ function installBackend() { return backend } -/** 登录模块尚未落地,测试里用同一个 provider 边界冒充已签发的 access token。 */ -function signIn(accessToken = 'access-token-for-test') { - revokeProviders.push(registerApiAccessTokenProvider(() => accessToken)) -} - -function renderProjectCreate() { - return render( - - - , +async function renderProjectCreate(authenticated = true) { + const Session = authenticated ? AuthenticatedAuthSession : GuestAuthSession + const result = render( + + + + + , ) + if (authenticated) await screen.findByText('Reader') + return result } function creationRequests(backend: ReturnType) { @@ -45,8 +43,7 @@ function creationRequests(backend: ReturnType describe('ProjectCreatePage', () => { it('按项目契约提交表单并进入创建出的项目', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.change(screen.getByLabelText('游戏视角'), { target: { value: 'top-down' } }) @@ -73,18 +70,19 @@ describe('ProjectCreatePage', () => { it('没有登录凭证时禁用创建并说明原因', async () => { const backend = installBackend() - renderProjectCreate() + await renderProjectCreate(false) const submit = await screen.findByRole('button', { name: '创建项目' }) expect(submit.hasAttribute('disabled')).toBe(true) - expect(screen.getByText(/登录/)).toBeTruthy() + expect( + screen.getByText('创建项目需要先登录。登录模块尚未接入,创建入口暂时保持关闭。'), + ).toBeTruthy() expect(creationRequests(backend)).toHaveLength(0) }) it('名称超过 20 字时在提交前拦下', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾'.repeat(21) } }) fireEvent.click(screen.getByRole('button', { name: '创建项目' })) @@ -95,8 +93,7 @@ describe('ProjectCreatePage', () => { it('名称重复时保留已填内容并显示后端给出的原因', async () => { installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '点灯人 · MVP' } }) fireEvent.change(screen.getByLabelText('画风约束'), { target: { value: '低饱和像素绘本' } }) @@ -108,8 +105,7 @@ describe('ProjectCreatePage', () => { it('连续点击创建只发出一次请求', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) const submit = screen.getByRole('button', { name: '创建项目' }) @@ -121,8 +117,7 @@ describe('ProjectCreatePage', () => { }) it('名称留空时在提交前拦下', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.click(screen.getByRole('button', { name: '创建项目' })) @@ -132,8 +127,7 @@ describe('ProjectCreatePage', () => { it('精灵宽高越界时在提交前拦下', async () => { const backend = installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.change(screen.getByLabelText('宽度(像素)'), { target: { value: '16' } }) @@ -148,8 +142,7 @@ describe('ProjectCreatePage', () => { it('传输失败时收敛成一句统一文案', async () => { installBackend() vi.stubGlobal('fetch', () => Promise.reject(new TypeError('offline'))) - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.click(screen.getByRole('button', { name: '创建项目' })) @@ -159,8 +152,7 @@ describe('ProjectCreatePage', () => { it('改动任一字段后撤掉上一次的错误', async () => { installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.click(screen.getByRole('button', { name: '创建项目' })) expect(await screen.findByRole('alert')).toBeTruthy() @@ -171,8 +163,7 @@ describe('ProjectCreatePage', () => { }) it('点尺寸预设后撤掉上一次的错误', async () => { installBackend() - signIn() - renderProjectCreate() + await renderProjectCreate() fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '雾港来信' } }) fireEvent.change(screen.getByLabelText('宽度(像素)'), { target: { value: '16' } }) diff --git a/frontend/src/test/auth-session.tsx b/frontend/src/test/auth-session.tsx index 55469c5e..ca4cb20d 100644 --- a/frontend/src/test/auth-session.tsx +++ b/frontend/src/test/auth-session.tsx @@ -1,7 +1,37 @@ +/* oxlint-disable react/only-export-components -- 测试会话组件与配套 fixture 共用一个入口。 */ import type { ReactNode } from 'react' -import type { UserApis } from '@/entities' -import { AuthSessionProvider } from '@/features/auth-session' +import type { AuthTokens, User, UserApis } from '@/entities' +import { AuthSessionProvider, useAuthSession } from '@/features/auth-session' + +export const testUser: User = { + id: '7', + email: 'reader@example.com', + nickname: 'Reader', + emailVerifiedAt: '2026-08-07T01:02:03Z', + statusCode: 0, +} + +function tokens(): AuthTokens { + return { + accessToken: 'access-token-for-test', + refreshToken: 'rotated-refresh-token', + user: testUser, + } +} + +export function createAuthenticatedTestApis(): UserApis { + return { + sendCode: async () => undefined, + register: async () => tokens(), + login: async () => tokens(), + loginByCode: async () => tokens(), + refresh: async () => tokens(), + logout: async () => undefined, + me: async () => testUser, + changePassword: async () => undefined, + } +} const guestApis: UserApis = { sendCode: async () => undefined, @@ -19,3 +49,18 @@ const guestApis: UserApis = { export function GuestAuthSession({ children }: { children: ReactNode }) { return {children} } + +export function AuthenticatedAuthSession({ children }: { children: ReactNode }) { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + return ( + + {children} + + ) +} + +/** 业务页面只在 access token 已恢复后挂载,避免把启动中的空 token 固化进首次 render。 */ +function AuthenticatedChildren({ children }: { children: ReactNode }) { + const session = useAuthSession() + return session.state.status === 'authenticated' ? children : null +} From 197f2e7fd7041afedb530cbaef7f5411f9556bcd Mon Sep 17 00:00:00 2001 From: huyan Date: Fri, 7 Aug 2026 17:27:17 +0800 Subject: [PATCH 5/5] style(header): refine account controls The account entry felt visually heavier than the surrounding navigation. Use a quiet outlined surface and group authenticated controls into one compact unit. The header now keeps account actions clear without competing with page content. --- frontend/src/app/layout/app-header.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 2ec75c12..ce068398 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -132,16 +132,16 @@ export function AppHeader() { 登录 / 注册 登录 ) : ( - <> +
{session.state.user.nickname || session.state.user.email} @@ -149,11 +149,11 @@ export function AppHeader() { type="button" onClick={signOut} aria-label="退出登录" - className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-[0.5625rem] px-2 text-xs font-semibold text-[#68736a] transition-colors hover:bg-[#e7eee8] hover:text-[#26372c] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#284331]" + className="inline-flex min-h-11 min-w-11 items-center justify-center border-l border-[#2d3b31]/12 px-2.5 text-xs font-semibold text-[#68736a] transition-colors hover:bg-[#dce9df] hover:text-[#26372c] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#284331]" > 退出 - +
)}