diff --git a/.gitignore b/.gitignore index 8b56f7f..0db2bd1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ .DS_Store *.log .env +test-results/ +playwright-report/ diff --git a/e2e/auth.spec.js b/e2e/auth.spec.js new file mode 100644 index 0000000..74250a2 --- /dev/null +++ b/e2e/auth.spec.js @@ -0,0 +1,160 @@ +const { test, expect } = require('@playwright/test'); +const testData = require('./fixtures/test-data'); +const { cleanDatabase, seedExistingUser, disconnect } = require('./fixtures/database'); + +test.describe('认证流程', () => { + test.beforeAll(async () => { + await cleanDatabase(); + await seedExistingUser(); + }); + + test.afterAll(async () => { + await cleanDatabase(); + await disconnect(); + }); + + test('注册新用户成功', async ({ page }) => { + await page.goto('/'); + // 点击「我的 XXX」卡片进入 auth 视图(未登录时) + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + + // 切换到注册 tab + await page.locator('.auth-tabs [data-tab="register"]').click(); + // 等待注册表单的 fadeIn 动画完成 + await expect(page.locator('[data-form="register"]')).toBeVisible(); + await page.waitForTimeout(400); + + // 填写注册表单 + await page.fill('[data-form="register"] [name="account"]', testData.users.newUser.account); + await page.fill('[data-form="register"] [name="password"]', testData.users.newUser.password); + await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.newUser.password); + + // 提交 + await page.locator('[data-form="register"] button[type="submit"]').click(); + + // 注册成功后自动成为创作者,应跳转到创作者中心 + await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 }); + }); + + test('重复注册同一账号失败', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + await page.locator('.auth-tabs [data-tab="register"]').click(); + // 等待注册表单的 fadeIn 动画完成 + await expect(page.locator('[data-form="register"]')).toBeVisible(); + await page.waitForTimeout(400); + + await page.fill('[data-form="register"] [name="account"]', testData.users.existing.account); + await page.fill('[data-form="register"] [name="password"]', testData.users.existing.password); + await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.existing.password); + + // 监听 dialog(错误信息通过 alert 弹出) + const dialogPromise = page.waitForEvent('dialog'); + await page.locator('[data-form="register"] button[type="submit"]').click(); + const dialog = await dialogPromise; + expect(dialog.message()).toContain('已注册'); + await dialog.accept(); + + // 应仍停留在 auth 视图 + await expect(page.locator('#auth')).toBeVisible(); + }); + + test('登录已有用户成功', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + + // 默认在登录 tab + await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account); + await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password); + await page.locator('[data-form="login"] button[type="submit"]').click(); + + // 登录成功后(existing 是创作者)应跳转到创作者中心 + await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 }); + }); + + test('错误密码登录失败', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + + await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account); + await page.fill('[data-form="login"] [name="password"]', 'wrongpassword'); + + const dialogPromise = page.waitForEvent('dialog'); + await page.locator('[data-form="login"] button[type="submit"]').click(); + const dialog = await dialogPromise; + expect(dialog.message()).toContain('账号或密码错误'); + await dialog.accept(); + + // 应仍停留在 auth 视图 + await expect(page.locator('#auth')).toBeVisible(); + }); + + test('注册时密码不一致提示', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + await page.locator('.auth-tabs [data-tab="register"]').click(); + // 等待注册表单的 fadeIn 动画完成 + await expect(page.locator('[data-form="register"]')).toBeVisible(); + await page.waitForTimeout(400); + + await page.fill('[data-form="register"] [name="account"]', 'mismatch_user'); + await page.fill('[data-form="register"] [name="password"]', 'password1'); + await page.fill('[data-form="register"] [name="confirmPassword"]', 'password2'); + + // 密码不一致通过原生 setCustomValidity + reportValidity 提示(非 alert) + await page.locator('[data-form="register"] button[type="submit"]').click(); + + // 验证 confirmPassword 字段的 validationMessage 包含「不一致」 + const validationMessage = await page + .locator('[data-form="register"] [name="confirmPassword"]') + .evaluate((el) => el.validationMessage); + expect(validationMessage).toContain('不一致'); + + // 应仍停留在 auth 视图 + await expect(page.locator('#auth')).toBeVisible(); + }); + + test('登出后回到首页', async ({ page }) => { + // 先登录 + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account); + await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password); + await page.locator('[data-form="login"] button[type="submit"]').click(); + await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 }); + + // 切换到设置 tab(登出按钮在设置面板) + await page.locator('[data-center-tab="settings"]').click(); + await expect(page.locator('#center-settings')).toBeVisible(); + + // 点击登出 + await page.locator('[data-action="logout"]').click(); + + // 应回到首页 + await expect(page.locator('#landing')).toBeVisible(); + }); + + test('登录态持久化(刷新页面后仍登录)', async ({ page }) => { + // 先登录 + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account); + await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password); + await page.locator('[data-form="login"] button[type="submit"]').click(); + await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 }); + + // 刷新页面 + await page.reload(); + + // 首页应可见,且卡片按钮文案应变化(不再是「登录 / 注册」) + await expect(page.locator('#landing')).toBeVisible(); + await expect(page.locator('#characters-btn')).not.toHaveText(/登录/); + }); +}); diff --git a/e2e/creator.spec.js b/e2e/creator.spec.js new file mode 100644 index 0000000..9095424 --- /dev/null +++ b/e2e/creator.spec.js @@ -0,0 +1,240 @@ +const { test, expect } = require('@playwright/test'); +const testData = require('./fixtures/test-data'); +const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database'); + +test.describe('创作者中心与角色发布/编辑', () => { + let existingUser; + let existingRole; + + test.beforeAll(async () => { + await cleanDatabase(); + await seedExistingUser(); + existingUser = await prisma.user.findUnique({ + where: { account: testData.users.existing.account }, + }); + + existingRole = await prisma.role.create({ + data: { + creatorId: existingUser.id, + displayName: '已有角色', + gender: 'female', + personality: '温柔体贴', + background: '来自南方', + speechStyle: '轻声细语', + greeting: '你好~', + desc: '温柔体贴的角色', + price: 19.9, + status: 'running', + temperature: 0.8, + maxTokens: 2048, + enableMemory: true, + enableTools: false, + model: 'gpt-4o', + }, + }); + }); + + test.afterAll(async () => { + await cleanDatabase(); + await disconnect(); + }); + + async function loginAsExisting(page) { + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account); + await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password); + await page.locator('[data-form="login"] button[type="submit"]').click(); + await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 }); + } + + test('创作者中心显示三个 tab', async ({ page }) => { + await loginAsExisting(page); + await expect(page.locator('[data-center-tab="roles"]')).toBeVisible(); + await expect(page.locator('[data-center-tab="income"]')).toBeVisible(); + await expect(page.locator('[data-center-tab="settings"]')).toBeVisible(); + }); + + test('角色 tab 显示已有角色', async ({ page }) => { + await loginAsExisting(page); + // 默认在 roles tab + await expect(page.locator('#creator-role-list .role-card')).toHaveCount(1, { timeout: 5000 }); + await expect(page.locator('#creator-role-list .role-card__name')).toHaveText('已有角色'); + }); + + test('收入 tab 显示余额和流水', async ({ page }) => { + await loginAsExisting(page); + await page.locator('[data-center-tab="income"]').click(); + // income 面板可见(#center-income 带 active class) + await expect(page.locator('#center-income')).toBeVisible(); + // 余额元素可见 + await expect(page.locator('#income-balance')).toBeVisible({ timeout: 5000 }); + // 流水列表容器存在 + await expect(page.locator('#income-list')).toBeVisible(); + }); + + test('设置 tab 可保存昵称和库名', async ({ page }) => { + await loginAsExisting(page); + await page.locator('[data-center-tab="settings"]').click(); + await expect(page.locator('#center-settings')).toBeVisible(); + + // 修改设置 + await page.fill('#settings-form [name="creatorName"]', '新昵称'); + await page.fill('#settings-form [name="libraryName"]', '新库名'); + + // 保存成功会弹 alert「设置已保存」 + const dialogPromise = page.waitForEvent('dialog'); + await page.locator('#settings-form button[type="submit"]').click(); + const dialog = await dialogPromise; + expect(dialog.message()).toContain('保存'); + await dialog.accept(); + }); + + test('新建角色完整流程(4步表单)', async ({ page }) => { + await loginAsExisting(page); + + // 点击新建角色 + await page.locator('[data-action="new-role"]').click(); + await expect(page.locator('#creator')).toBeVisible(); + + // Step 1: 基础身份(agentId 必填,pattern [a-zA-Z0-9_]+;displayName 必填) + await page.fill('#character-form [name="agentId"]', 'e2e_test_role'); + await page.fill('#character-form [name="displayName"]', testData.role.displayName); + await page.selectOption('#character-form [name="gender"]', testData.role.gender); + await page.fill('#character-form [name="age"]', testData.role.age); + await page.locator('.form-step.active [data-action="next"]').click(); + + // Step 2: 灵魂设定(background / personality / speechStyle 必填) + await page.fill('#character-form [name="background"]', testData.role.background); + await page.fill('#character-form [name="personality"]', testData.role.personality); + await page.fill('#character-form [name="speechStyle"]', testData.role.speechStyle); + await page.fill('#character-form [name="likes"]', testData.role.likes); + await page.fill('#character-form [name="dislikes"]', testData.role.dislikes); + await page.locator('.form-step.active [data-action="next"]').click(); + + // Step 3: 关系与记忆(greeting 必填) + await page.fill('#character-form [name="relationship"]', testData.role.relationship); + await page.fill('#character-form [name="memories"]', testData.role.memories); + await page.fill('#character-form [name="secrets"]', testData.role.secrets); + await page.fill('#character-form [name="greeting"]', testData.role.greeting); + await page.locator('.form-step.active [data-action="next"]').click(); + + // Step 4: 运行配置(model/temperature/maxTokens 有默认值;无 price 字段) + // 捕获可能的错误弹窗 + let dialogMessage = ''; + page.on('dialog', async (dialog) => { + dialogMessage = dialog.message(); + await dialog.accept(); + }); + await page.locator('.form-step.active [data-action="publish"]').click(); + + // 应显示生成结果面板 + await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 }); + + // 验证 Soul.md 内容(单个 #preview-code 元素,默认显示 soul) + await expect(page.locator('#preview-code')).toBeVisible(); + const soulContent = await page.locator('#preview-code').textContent(); + expect(soulContent).toContain(testData.role.displayName); + // generateSoulMd 会把 personality 按 [,,] 拆分成 " | " 连接的标签 + const personalityTag = testData.role.personality.split(/[,,]/)[0].trim(); + expect(soulContent).toContain(personalityTag); + + // 切换到 config.yaml 预览(preview-tabs 内的 [data-tab="config"]) + await page.locator('.preview-tabs [data-tab="config"]').click(); + const configContent = await page.locator('#preview-code').textContent(); + expect(configContent).toContain('model:'); + expect(configContent).toContain('temperature:'); + }); + + test('新建角色后数据库有记录', async ({ page }) => { + await loginAsExisting(page); + await page.locator('[data-action="new-role"]').click(); + await expect(page.locator('#creator')).toBeVisible(); + + // Step 1: 填写最小必填字段 + await page.fill('#character-form [name="agentId"]', 'e2e_db_role'); + await page.fill('#character-form [name="displayName"]', 'DB验证角色'); + await page.selectOption('#character-form [name="gender"]', 'female'); + await page.locator('.form-step.active [data-action="next"]').click(); + + // Step 2 + await page.fill('#character-form [name="personality"]', '测试性格'); + await page.fill('#character-form [name="background"]', '测试背景'); + await page.fill('#character-form [name="speechStyle"]', '测试风格'); + await page.locator('.form-step.active [data-action="next"]').click(); + + // Step 3 + await page.fill('#character-form [name="greeting"]', '测试问候'); + await page.locator('.form-step.active [data-action="next"]').click(); + + // Step 4: 直接发布(无 price 字段,使用默认值) + let dialogMessage = ''; + page.on('dialog', async (dialog) => { + dialogMessage = dialog.message(); + await dialog.accept(); + }); + await page.locator('.form-step.active [data-action="publish"]').click(); + await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 }); + + // 验证数据库 + const dbRole = await prisma.role.findFirst({ + where: { displayName: 'DB验证角色' }, + }); + expect(dbRole).not.toBeNull(); + expect(dbRole.personality).toBe('测试性格'); + expect(dbRole.greeting).toBe('测试问候'); + expect(dbRole.soulMd).not.toBeNull(); + expect(dbRole.configYaml).not.toBeNull(); + }); + + test('编辑角色加载已有数据', async ({ page }) => { + await loginAsExisting(page); + + // 等待角色列表加载,找到已 seeded 的角色(其他测试可能已创建额外角色) + await expect(page.locator('#creator-role-list .role-card')).not.toHaveCount(0, { timeout: 5000 }); + // 定位到"已有角色"那张卡片的编辑按钮 + const seededCard = page.locator('#creator-role-list .role-card', { hasText: '已有角色' }).first(); + await expect(seededCard).toBeVisible({ timeout: 5000 }); + await seededCard.locator('[data-action="edit-role"]').click(); + await expect(page.locator('#creator')).toBeVisible(); + + // 验证表单已预填(loadRoleForEdit 会拉取 /roles/:id/full 填充表单) + await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('已有角色', { timeout: 5000 }); + await expect(page.locator('#character-form [name="personality"]')).toHaveValue('温柔体贴'); + }); + + test('表单验证 - 必填字段为空时阻止提交', async ({ page }) => { + await loginAsExisting(page); + await page.locator('[data-action="new-role"]').click(); + await expect(page.locator('#creator')).toBeVisible(); + + // 不填任何字段直接点下一步 + await page.locator('.form-step.active [data-action="next"]').click(); + + // 应仍停留在 step 0(agentId / displayName 必填,验证失败) + // step 0 仍 active => agentId 字段仍可见 + await expect(page.locator('#character-form [name="agentId"]')).toBeVisible(); + await expect(page.locator('#character-form [name="displayName"]')).toBeVisible(); + }); + + test('步骤导航 - 上一步按钮', async ({ page }) => { + await loginAsExisting(page); + await page.locator('[data-action="new-role"]').click(); + await expect(page.locator('#creator')).toBeVisible(); + + // 填写 step 1 并前进 + await page.fill('#character-form [name="agentId"]', 'e2e_nav_test'); + await page.fill('#character-form [name="displayName"]', '导航测试'); + await page.selectOption('#character-form [name="gender"]', 'female'); + await page.locator('.form-step.active [data-action="next"]').click(); + + // 进入 step 2 后点上一步(data-action="prev",非 prev-step) + await expect(page.locator('#character-form [name="background"]')).toBeVisible(); + await page.locator('.form-step.active [data-action="prev"]').click(); + + // 应回到 step 1,且数据保留 + await expect(page.locator('#character-form [name="displayName"]')).toBeVisible(); + await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('导航测试'); + }); +}); diff --git a/e2e/fixtures/database.js b/e2e/fixtures/database.js new file mode 100644 index 0000000..c44549f --- /dev/null +++ b/e2e/fixtures/database.js @@ -0,0 +1,30 @@ +// 数据库清理 helper +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +async function cleanDatabase() { + await prisma.order.deleteMany(); + await prisma.role.deleteMany(); + await prisma.user.deleteMany(); +} + +async function seedExistingUser() { + const bcrypt = require('bcryptjs'); + const testData = require('./test-data'); + await prisma.user.create({ + data: { + account: testData.users.existing.account, + password: bcrypt.hashSync(testData.users.existing.password, 10), + isCreator: true, + creatorName: testData.users.existing.account, + libraryName: 'E2E测试库', + }, + }); +} + +async function disconnect() { + await prisma.$disconnect(); +} + +module.exports = { cleanDatabase, seedExistingUser, disconnect, prisma }; diff --git a/e2e/fixtures/test-data.js b/e2e/fixtures/test-data.js new file mode 100644 index 0000000..a37c71c --- /dev/null +++ b/e2e/fixtures/test-data.js @@ -0,0 +1,33 @@ +// 测试数据 fixtures +const testData = { + users: { + newUser: { + account: `e2e_${Date.now()}`, + password: 'Test123456', + }, + existing: { + account: 'e2e_existing', + password: 'Test123456', + }, + }, + role: { + displayName: 'E2E测试角色', + gender: 'female', + age: '25', + relationship: '女友', + personality: '温柔,体贴,善解人意', + background: '来自江南水乡的女孩,喜欢读书和绘画', + speechStyle: '轻声细语,偶尔带点小俏皮', + likes: '读书,绘画,猫咪', + dislikes: '吵闹,谎言', + memories: '一起看过的樱花,第一次约会', + secrets: '其实很怕打雷', + greeting: '你回来啦~今天过得怎么样?', + model: 'gpt-4o', + temperature: '0.8', + maxTokens: '2048', + price: '29.9', + }, +}; + +module.exports = testData; diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js new file mode 100644 index 0000000..8bebc47 --- /dev/null +++ b/e2e/navigation.spec.js @@ -0,0 +1,214 @@ +const { test, expect } = require('@playwright/test'); +const bcrypt = require('bcryptjs'); +const { cleanDatabase, disconnect, prisma } = require('./fixtures/database'); + +test.describe('导航与可访问性', () => { + test.beforeAll(async () => { + await cleanDatabase(); + }); + + test.afterAll(async () => { + await cleanDatabase(); + await disconnect(); + }); + + test('首页显示两张卡片', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#landing')).toBeVisible(); + // 两张卡片:open-characters 和 open-distill + const cards = page.locator('[data-action="open-characters"], [data-action="open-distill"]'); + await expect(cards).toHaveCount(2, { timeout: 5000 }); + }); + + test('底部 tabBar 三个 tab 可见', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.tab-bar')).toBeVisible(); + await expect(page.locator('[data-tab-action="tab-home"]')).toBeVisible(); + await expect(page.locator('[data-tab-action="tab-distill"]')).toBeVisible(); + await expect(page.locator('[data-tab-action="tab-mine"]')).toBeVisible(); + }); + + test('tabBar 切换视图', async ({ page }) => { + await page.goto('/'); + + // 点击蒸馏前任 tab + await page.locator('[data-tab-action="tab-distill"]').click(); + await expect(page.locator('#distill')).toBeVisible(); + + // 点击首页 tab + await page.locator('[data-tab-action="tab-home"]').click(); + await expect(page.locator('#landing')).toBeVisible(); + + // 点击我的 tab(未登录应跳转到 auth) + await page.locator('[data-tab-action="tab-mine"]').click(); + await expect(page.locator('#auth')).toBeVisible(); + }); + + test('首页卡片点击进入对应视图', async ({ page }) => { + await page.goto('/'); + + // 点击蒸馏前任卡片 + await page.locator('[data-action="open-distill"]').first().click(); + await expect(page.locator('#distill')).toBeVisible(); + + // 返回首页 + await page.locator('[data-tab-action="tab-home"]').click(); + await expect(page.locator('#landing')).toBeVisible(); + + // 点击「我的 XXX」卡片(未登录进入 auth) + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + }); + + test('关于页面可访问', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-about"]').first().click(); + await expect(page.locator('#about')).toBeVisible(); + }); + + test('创作者入驻页面可访问', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-onboarding"]').first().click(); + await expect(page.locator('#onboarding')).toBeVisible(); + }); + + test('FAQ 折叠展开', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-about"]').first().click(); + await expect(page.locator('#about')).toBeVisible(); + + // 点击第一个 FAQ 按钮(.faq-q,非 .faq-item__question) + const faqButton = page.locator('.faq-q').first(); + await faqButton.click(); + + // 验证 aria-expanded 变为 true + await expect(faqButton).toHaveAttribute('aria-expanded', 'true'); + + // 再次点击折叠 + await faqButton.click(); + await expect(faqButton).toHaveAttribute('aria-expanded', 'false'); + }); + + test('返回按钮基于历史记录导航', async ({ page }) => { + await page.goto('/'); + + // 导航路径: landing → about → 返回 + await page.locator('[data-action="open-about"]').first().click(); + await expect(page.locator('#about')).toBeVisible(); + + // about 视图使用 data-action="back" + await page.locator('#about [data-action="back"]').click(); + await expect(page.locator('#landing')).toBeVisible(); + }); + + test('跳过链接(a11y)存在且可用', async ({ page }) => { + await page.goto('/'); + const skipLink = page.locator('.skip-link'); + // 跳过链接应存在 + await expect(skipLink).toHaveCount(1); + }); + + test('tabBar 有正确的 ARIA 角色', async ({ page }) => { + await page.goto('/'); + const tabbar = page.locator('.tab-bar'); + await expect(tabbar).toHaveAttribute('role', 'tablist'); + + // tab-bar 内的按钮应有 role="tab" + const tabs = page.locator('.tab-bar [data-tab-action]'); + const count = await tabs.count(); + expect(count).toBe(3); + for (let i = 0; i < count; i++) { + await expect(tabs.nth(i)).toHaveAttribute('role', 'tab'); + } + }); + + test('视图切换时 aria-live 播报', async ({ page }) => { + await page.goto('/'); + // #sr-announce 是 aria-live 区域,role="status" aria-live="polite" + const liveRegion = page.locator('#sr-announce'); + await expect(liveRegion).toHaveAttribute('aria-live', 'polite'); + await expect(liveRegion).toHaveAttribute('role', 'status'); + + // 切换视图后 live region 应有内容 + await page.locator('[data-action="open-about"]').first().click(); + await expect(liveRegion).not.toHaveText(''); + const liveText = await liveRegion.textContent(); + expect(liveText.length).toBeGreaterThan(0); + }); + + test('角色卡片支持键盘操作', async ({ page }) => { + // 创建非创作者用户 + 角色 + const bcrypt = require('bcryptjs'); + const user = await prisma.user.create({ + data: { + account: 'e2e_keyboard_user', + password: bcrypt.hashSync('Test123456', 10), + isCreator: false, + libraryName: '键盘测试库', + }, + }); + await prisma.role.create({ + data: { + creatorId: user.id, + displayName: '键盘测试角色', + gender: 'female', + personality: '测试', + background: '测试', + speechStyle: '测试', + greeting: '测试', + desc: '键盘测试', + price: 9.9, + status: 'running', + }, + }); + + // 登录非创作者(登录后自动进入角色库) + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + await page.fill('[data-form="login"] [name="account"]', 'e2e_keyboard_user'); + await page.fill('[data-form="login"] [name="password"]', 'Test123456'); + await page.locator('[data-form="login"] button[type="submit"]').click(); + await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 }); + + // 聚焦角色卡片(role-card 有 tabindex="0") + const roleCard = page.locator('#role-list .role-card').first(); + await roleCard.focus(); + + // 按 Enter 应进入详情 + await page.keyboard.press('Enter'); + await expect(page.locator('#role-detail')).toBeVisible({ timeout: 5000 }); + + // 清理 + await prisma.role.deleteMany(); + await prisma.user.deleteMany(); + }); + + test('表单 label 与 input 关联', async ({ page }) => { + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + + // 检查登录表单的 input 存在 + const accountInput = page.locator('[data-form="login"] [name="account"]'); + await expect(accountInput).toBeVisible(); + + // 检查是否有对应的 label(包裹式 label 包含账号相关文案) + const labelText = await page.locator('label').filter({ hasText: /账号|用户名|Account/i }).count(); + expect(labelText).toBeGreaterThan(0); + }); + + test('focus-visible 样式存在', async ({ page }) => { + await page.goto('/'); + // Tab 到第一个可聚焦元素 + await page.keyboard.press('Tab'); + // 聚焦元素应有可见的焦点样式(:focus-visible 使用 outline) + const focused = await page.evaluate(() => { + const el = document.activeElement; + if (!el) return false; + const styles = window.getComputedStyle(el); + return styles.outlineStyle !== 'none' || styles.boxShadow !== 'none'; + }); + expect(focused).toBe(true); + }); +}); diff --git a/e2e/roles.spec.js b/e2e/roles.spec.js new file mode 100644 index 0000000..57dd7ed --- /dev/null +++ b/e2e/roles.spec.js @@ -0,0 +1,148 @@ +const { test, expect } = require('@playwright/test'); +const bcrypt = require('bcryptjs'); +const testData = require('./fixtures/test-data'); +const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database'); + +// 非创作者用户(用于角色库测试) +const NON_CREATOR = { + account: 'e2e_noncreator', + password: 'Test123456', +}; + +test.describe('角色库与角色详情', () => { + let existingUser; + let testRole; + + test.beforeAll(async () => { + await cleanDatabase(); + await seedExistingUser(); + existingUser = await prisma.user.findUnique({ + where: { account: testData.users.existing.account }, + }); + + // 创建非创作者用户(open-characters 对非创作者进入 role-library) + await prisma.user.create({ + data: { + account: NON_CREATOR.account, + password: bcrypt.hashSync(NON_CREATOR.password, 10), + isCreator: false, + libraryName: 'E2E测试库', + }, + }); + + // 创建一个测试角色(status=running 才会在角色库展示) + testRole = await prisma.role.create({ + data: { + creatorId: existingUser.id, + displayName: '测试角色A', + gender: 'female', + age: '24', + relationship: '女友', + personality: '温柔可爱,善解人意', + background: '来自海边的小镇女孩', + speechStyle: '轻柔细腻', + greeting: '你好呀~', + desc: '温柔可爱的女友角色', + price: 29.9, + status: 'running', + avatar: 'https://example.com/avatar.png', + }, + }); + }); + + test.afterAll(async () => { + await cleanDatabase(); + await disconnect(); + }); + + // 以非创作者身份登录,登录后自动进入角色库 + async function loginAsNonCreator(page) { + await page.goto('/'); + await page.locator('[data-action="open-characters"]').first().click(); + await expect(page.locator('#auth')).toBeVisible(); + // 确保登录 tab 激活 + await page.locator('.auth-tabs [data-tab="login"]').click(); + await page.locator('[data-form="login"]').waitFor(); + await page.fill('[data-form="login"] [name="account"]', NON_CREATOR.account); + await page.fill('[data-form="login"] [name="password"]', NON_CREATOR.password); + await page.locator('[data-form="login"] button[type="submit"]').click(); + await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 }); + } + + test('角色库显示已上架角色', async ({ page }) => { + await loginAsNonCreator(page); + + // 应显示测试角色 + await expect(page.locator('#role-list .role-card')).toHaveCount(1, { timeout: 5000 }); + await expect(page.locator('#role-list .role-card__name')).toHaveText('测试角色A'); + }); + + test('空角色库显示空状态', async ({ page }) => { + // 清空角色 + await prisma.role.deleteMany(); + + await loginAsNonCreator(page); + + // 应显示空状态 + await expect(page.locator('#library-empty')).toBeVisible({ timeout: 5000 }); + }); + + test('点击角色卡片进入详情页', async ({ page }) => { + // 重新创建角色 + testRole = await prisma.role.create({ + data: { + creatorId: existingUser.id, + displayName: '详情测试角色', + gender: 'male', + personality: '阳光开朗', + background: '运动少年', + speechStyle: '热情直接', + greeting: '嘿!你好!', + desc: '阳光开朗的男孩', + price: 19.9, + status: 'running', + }, + }); + + await loginAsNonCreator(page); + + // 点击角色卡片 + await page.locator('#role-list .role-card').first().click(); + await expect(page.locator('#role-detail')).toBeVisible(); + + // 验证详情内容 + await expect(page.locator('#detail-name')).toHaveText('详情测试角色'); + await expect(page.locator('#detail-role-name')).toHaveText('详情测试角色'); + await expect(page.locator('#detail-price')).toContainText('19.9'); + }); + + test('角色详情页返回按钮', async ({ page }) => { + await loginAsNonCreator(page); + + await page.locator('#role-list .role-card').first().click(); + await expect(page.locator('#role-detail')).toBeVisible(); + + // 点击返回(role-detail 使用 back-to-library) + await page.locator('[data-action="back-to-library"]').click(); + await expect(page.locator('#role-library')).toBeVisible(); + }); + + test('角色详情付款流程', async ({ page }) => { + await loginAsNonCreator(page); + + await page.locator('#role-list .role-card').first().click(); + await expect(page.locator('#role-detail')).toBeVisible(); + + // 付款前:pre-pay 按钮区可见,paid 区隐藏 + await expect(page.locator('#detail-actions-pre')).toBeVisible(); + await expect(page.locator('#detail-paid')).toBeHidden(); + + // 点击付款按钮(data-action="pay",非 pay-role) + await page.locator('[data-action="pay"]').click(); + + // 应显示已付款区域(含二维码 / 头像下载) + await expect(page.locator('#detail-paid')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('#detail-actions-pre')).toBeHidden(); + await expect(page.locator('[data-action="download-avatar"]')).toBeVisible(); + }); +}); diff --git a/index.html b/index.html index d58a31d..cf44bad 100644 --- a/index.html +++ b/index.html @@ -509,7 +509,7 @@