From 7e9023f0a87bd9eafb7b80be3281ef596578b58d Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Thu, 6 Aug 2026 22:41:43 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20=E5=89=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=81=94=E8=B0=83=20Playwright=20=E4=B8=8E=20admin=20=E5=86=92?= =?UTF-8?q?=E7=83=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 e2e-live 真 API 一致性全路径,并为 admin-h5 增加 mock 浏览器冒烟。 Co-authored-by: Cursor --- .ai/commands.md | 2 + .ai/testing.md | 2 + apps/admin-h5/e2e/admin-ops-smoke.spec.ts | 104 ++++++++++++ apps/admin-h5/package.json | 4 +- apps/admin-h5/playwright.config.ts | 31 ++++ e2e-live/.gitignore | 3 + e2e-live/README.md | 14 ++ e2e-live/package.json | 13 ++ e2e-live/playwright.config.ts | 43 +++++ e2e-live/tests/fullstack-consistency.spec.ts | 169 +++++++++++++++++++ package-lock.json | 13 ++ package.json | 3 + 12 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 apps/admin-h5/e2e/admin-ops-smoke.spec.ts create mode 100644 apps/admin-h5/playwright.config.ts create mode 100644 e2e-live/.gitignore create mode 100644 e2e-live/README.md create mode 100644 e2e-live/package.json create mode 100644 e2e-live/playwright.config.ts create mode 100644 e2e-live/tests/fullstack-consistency.spec.ts diff --git a/.ai/commands.md b/.ai/commands.md index 41b3130..52ae5c9 100644 --- a/.ai/commands.md +++ b/.ai/commands.md @@ -87,6 +87,8 @@ npm run test:e2e # Playwright(先 build:h5;用系统 Chrome) # config.local.yaml 需 admin.bootstrap_*(见 config.example.yaml);仅空库种子 npm run dev:admin # http://127.0.0.1:5174/ npm run build:admin +npm run test:e2e:admin # Playwright(preview + mock API,无需 Go) +npm run test:e2e:live # 前后台联调(需 deps:up + API :8080;见 e2e-live/) ``` --- diff --git a/.ai/testing.md b/.ai/testing.md index 74179e3..50b05a7 100644 --- a/.ai/testing.md +++ b/.ai/testing.md @@ -14,6 +14,7 @@ | L1 Unit | Go engine `_test.go`;H5 Vitest | 核心逻辑必有 | | L2 Integration | `npm run test:api:integration`(需 Postgres) | P1 关键流必有 | | L3 E2E | `npm run test:e2e`(Playwright,可用系统 Chrome) | P1 收口至少 1 条 | +| L3 Live | `npm run test:e2e:live`(真 API + 用户 H5 + 运营后台) | 前后台数据一致性 | ## Go @@ -46,6 +47,7 @@ npm run build:h5 && npm run test:e2e # Playwright(默认 channel=chrome) - 关键页:`PortraitPage` / `RelationPage` / `MembershipPage` 有 Vitest。 - Playwright 画像主路径使用 network mock,不依赖 Go。 +- Admin:`npm run test:e2e:admin`(登录→用户→授予→审计,network mock)。 - 若无系统 Chrome:`npx playwright install chromium` 后设 `PW_CHANNEL=` 空或删 channel。 - **仅 build 通过 ≠ Frontend Done。** diff --git a/apps/admin-h5/e2e/admin-ops-smoke.spec.ts b/apps/admin-h5/e2e/admin-ops-smoke.spec.ts new file mode 100644 index 0000000..4bed755 --- /dev/null +++ b/apps/admin-h5/e2e/admin-ops-smoke.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '@playwright/test' + +/** Admin Phase A: login → users → detail grant → audit (API mocked). */ +test('admin login users grant and audit with mocked API', async ({ page }) => { + const userId = '11111111-1111-1111-1111-111111111111' + let granted = false + + await page.route('**/api/v1/admin/**', async (route) => { + const req = route.request() + const url = req.url() + const method = req.method() + + const ok = (data: unknown) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, message: 'success', data }), + }) + + if (url.includes('/auth/login') && method === 'POST') { + await ok({ + token: 'adm_e2e_token', + expires_at: new Date(Date.now() + 3600_000).toISOString(), + admin: { id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' }, + }) + return + } + if (url.includes('/auth/logout') && method === 'POST') { + await ok({ ok: true }) + return + } + if (url.endsWith('/me') || url.includes('/me?')) { + await ok({ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', username: 'admin' }) + return + } + if (url.includes(`/users/${userId}/membership/grant`) && method === 'POST') { + granted = true + await ok({ ok: true }) + return + } + if (url.includes(`/users/${userId}`) && method === 'GET') { + await ok({ + id: userId, + status: 'active', + created_at: '2026-08-01T00:00:00Z', + profiles: [{ id: 'p1', relation: 'self', display_name: '我' }], + membership: { + active: granted, + plan: granted ? 'month' : undefined, + status: granted ? 'active' : 'none', + expires_at: granted ? '2026-09-01T00:00:00Z' : undefined, + }, + recent_orders: [], + }) + return + } + if (url.includes('/users') && method === 'GET') { + await ok({ + items: [{ id: userId, status: 'active', created_at: '2026-08-01T00:00:00Z' }], + }) + return + } + if (url.includes('/orders')) { + await ok({ items: [] }) + return + } + if (url.includes('/audit-logs')) { + await ok({ + items: granted + ? [ + { + id: 'aud1', + admin_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + action: 'membership.grant', + target_type: 'user', + target_id: userId, + meta: { plan: 'month' }, + created_at: '2026-08-06T00:00:00Z', + }, + ] + : [], + }) + return + } + await ok({}) + }) + + await page.goto('/login') + await expect(page.getByRole('heading', { name: '运营后台' })).toBeVisible() + await page.locator('input[type="password"]').fill('change-me') + await page.getByRole('button', { name: '登录' }).click() + + await expect(page.getByRole('heading', { name: '用户' })).toBeVisible() + await expect(page.getByText(userId)).toBeVisible() + await page.getByRole('link', { name: userId }).click() + + await expect(page.getByRole('heading', { name: '用户详情' })).toBeVisible() + await page.getByRole('button', { name: '授予 / 延长' }).click() + await expect(page.getByText('已授予')).toBeVisible() + + await page.getByRole('link', { name: '审计' }).click() + await expect(page.getByRole('heading', { name: '审计' })).toBeVisible() + await expect(page.getByText('membership.grant')).toBeVisible() +}) diff --git a/apps/admin-h5/package.json b/apps/admin-h5/package.json index 3460eab..d43fb66 100644 --- a/apps/admin-h5/package.json +++ b/apps/admin-h5/package.json @@ -8,7 +8,8 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "typecheck": "vue-tsc --noEmit", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test" }, "dependencies": { "pinia": "^2.3.0", @@ -16,6 +17,7 @@ "vue-router": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@vitejs/plugin-vue": "^5.2.1", "typescript": "~5.7.2", "vite": "^6.0.7", diff --git a/apps/admin-h5/playwright.config.ts b/apps/admin-h5/playwright.config.ts new file mode 100644 index 0000000..b9c2d0d --- /dev/null +++ b/apps/admin-h5/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Ops admin L3 smoke: Vite preview + mocked /api/v1/admin (no Go required). + * Run: npm run test:e2e -w @yuxingu/admin-h5 + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: 'http://127.0.0.1:4174', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + ...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}), + }, + }, + ], + webServer: { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4174', + url: 'http://127.0.0.1:4174', + reuseExistingServer: false, + timeout: 120_000, + }, +}) diff --git a/e2e-live/.gitignore b/e2e-live/.gitignore new file mode 100644 index 0000000..845a8d8 --- /dev/null +++ b/e2e-live/.gitignore @@ -0,0 +1,3 @@ +test-results/ +playwright-report/ +blob-report/ diff --git a/e2e-live/README.md b/e2e-live/README.md new file mode 100644 index 0000000..480c8a5 --- /dev/null +++ b/e2e-live/README.md @@ -0,0 +1,14 @@ +# e2e-live — 前后台联调(真 API) + +需要: + +1. `npm run deps:up` +2. API:`cd apps/api && go run ./cmd/server`(`:8080`,含 admin bootstrap) +3. 本套件会自动拉起 user-h5 `:5173` 与 admin-h5 `:5174`(已占用则复用) + +```bash +npm install +npm run test:e2e:live +``` + +覆盖:画像 → 后台用户一致 → 前台开通会员 ↔ 后台订单/会员 → 后台授年卡 ↔ 前台年卡+审计 → 关系 → Ask → 量表 → 多页烟测。 diff --git a/e2e-live/package.json b/e2e-live/package.json new file mode 100644 index 0000000..bdc2952 --- /dev/null +++ b/e2e-live/package.json @@ -0,0 +1,13 @@ +{ + "name": "@yuxingu/e2e-live", + "version": "0.1.0", + "private": true, + "description": "前后台联调 Playwright(真 API)", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "^1.50.1" + } +} diff --git a/e2e-live/playwright.config.ts b/e2e-live/playwright.config.ts new file mode 100644 index 0000000..c61c1f0 --- /dev/null +++ b/e2e-live/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Live full-stack e2e: real Go API + user-h5 + admin-h5. + * Prerequisites: `npm run deps:up` and API on :8080 (or started via webServer). + */ +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + workers: 1, + timeout: 180_000, + expect: { timeout: 20_000 }, + forbidOnly: !!process.env.CI, + retries: 0, + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + ...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}), + }, + }, + ], + webServer: [ + { + command: 'npm run dev -w @yuxingu/user-h5 -- --host 127.0.0.1 --port 5173', + url: 'http://127.0.0.1:5173/psy/', + reuseExistingServer: true, + timeout: 120_000, + }, + { + command: 'npm run dev -w @yuxingu/admin-h5 -- --host 127.0.0.1 --port 5174', + url: 'http://127.0.0.1:5174/', + reuseExistingServer: true, + timeout: 120_000, + }, + ], +}) diff --git a/e2e-live/tests/fullstack-consistency.spec.ts b/e2e-live/tests/fullstack-consistency.spec.ts new file mode 100644 index 0000000..133e822 --- /dev/null +++ b/e2e-live/tests/fullstack-consistency.spec.ts @@ -0,0 +1,169 @@ +import { test, expect, type Page, type BrowserContext } from '@playwright/test' + +const USER = 'http://127.0.0.1:5173' +const ADMIN = 'http://127.0.0.1:5174' +const API = 'http://127.0.0.1:8080' + +test.describe.configure({ mode: 'serial' }) + +test.describe('前后台联调 · 真 API 全路径', () => { + let userCtx: BrowserContext + let userPage: Page + let adminPage: Page + let userId = '' + let marker = '' + + test.beforeAll(async ({ browser }) => { + const health = await fetch(`${API}/api/v1/healthz`) + if (!health.ok) { + throw new Error('API :8080 不可用。先 npm run deps:up && npm run dev:api') + } + marker = `L${Date.now().toString(36)}` + userCtx = await browser.newContext() + userPage = await userCtx.newPage() + adminPage = await browser.newPage() + }) + + test.afterAll(async () => { + await userCtx?.close() + await adminPage?.context().close() + }) + + test('0 · 运营后台可登录', async () => { + await adminPage.goto(`${ADMIN}/login`) + await adminPage.locator('input[type="password"]').fill('change-me') + await adminPage.getByRole('button', { name: '登录' }).click() + await expect(adminPage.getByRole('heading', { name: '用户' })).toBeVisible() + }) + + test('1 · 用户侧生成个人画像', async () => { + const portraitResp = userPage.waitForResponse( + (r) => r.url().includes('/reports/portrait') && r.request().method() === 'POST' && r.ok(), + ) + await userPage.goto(`${USER}/psy/portrait`) + await expect(userPage.getByRole('heading', { name: '愈心解码' }).first()).toBeVisible() + + const inputs = userPage.locator('.decode-inputs input') + await inputs.nth(0).fill('1992') + await inputs.nth(1).fill('7') + await inputs.nth(2).fill('18') + await userPage.getByRole('button', { name: '开始解码' }).click() + + const resp = await portraitResp + const body = await resp.json() + userId = body?.data?.user_id || '' + expect(userId, 'portrait 响应应含 user_id').toMatch(/^[0-9a-f-]{36}$/i) + + await expect(userPage.getByRole('link', { name: /在报告页打开/ })).toBeVisible({ timeout: 60_000 }) + await expect(userPage.getByRole('button', { name: /解锁完整分析/ }).first()).toBeVisible() + }) + + test('2 · 后台能看到该用户,并与前台档案一致', async () => { + expect(userId).toBeTruthy() + await adminPage.goto(`${ADMIN}/users/${userId}`) + await expect(adminPage.getByRole('heading', { name: '用户详情' })).toBeVisible() + await expect(adminPage.getByText(userId).first()).toBeVisible() + await expect(adminPage.getByText('self').first()).toBeVisible() + }) + + test('3 · 前台开通会员 → 后台订单与会员状态一致', async () => { + await userPage.goto(`${USER}/psy/membership`) + await expect(userPage.getByRole('heading', { name: '成长会员' })).toBeVisible() + await userPage.getByRole('button', { name: /月卡/ }).click() + await expect(userPage.getByText('会员有效')).toBeVisible({ timeout: 30_000 }) + + await adminPage.goto(`${ADMIN}/users/${userId}`) + await expect(adminPage.getByText('有效').first()).toBeVisible({ timeout: 15_000 }) + await expect(adminPage.getByText(/month|月/i).first()).toBeVisible() + + await adminPage.goto(`${ADMIN}/orders`) + await expect(adminPage.getByRole('heading', { name: '订单' })).toBeVisible() + await expect(adminPage.getByText(userId).first()).toBeVisible() + await expect(adminPage.getByText('membership').first()).toBeVisible() + await expect(adminPage.getByText('paid').first()).toBeVisible() + }) + + test('4 · 后台授予年卡 → 前台会员与审计一致', async () => { + await adminPage.goto(`${ADMIN}/users/${userId}`) + await adminPage.locator('select').selectOption('year') + await adminPage.getByRole('button', { name: '授予 / 延长' }).click() + await expect(adminPage.getByText('已授予')).toBeVisible() + + await adminPage.goto(`${ADMIN}/audit`) + await expect(adminPage.getByText('membership.grant').first()).toBeVisible() + await expect(adminPage.getByText(userId).first()).toBeVisible() + + await userPage.goto(`${USER}/psy/membership`) + await expect(userPage.getByText('会员有效 · 年卡')).toBeVisible({ timeout: 15_000 }) + }) + + test('5 · 成长报告列表可打开(会员后)', async () => { + await userPage.goto(`${USER}/psy/reports`) + await userPage.waitForLoadState('networkidle') + await expect(userPage.locator('.who-static')).toContainText('成长报告', { timeout: 20_000 }) + await expect(userPage.getByText('愈心解码').first()).toBeVisible() + await userPage.getByText('查看报告').first().click() + await expect(userPage).toHaveURL(/\/reports\//) + }) + + test('6 · 关系理解', async () => { + await userPage.goto(`${USER}/psy/relation`) + await expect(userPage.getByRole('heading', { name: '看见彼此的相处模样' })).toBeVisible() + await userPage.getByPlaceholder('例如:伴侣').fill(marker.slice(0, 6)) + const nums = userPage.locator('input[type="number"]') + await nums.nth(0).fill('1991') + await nums.nth(1).fill('3') + await nums.nth(2).fill('9') + await userPage.getByRole('button', { name: '去匹配' }).click() + await expect(userPage.getByRole('button', { name: '生成中…' })).toBeHidden({ timeout: 60_000 }) + await expect(userPage.getByText(/默契|相处|沟通|匹配|性格/).first()).toBeVisible({ timeout: 60_000 }) + }) + + test('7 · AI 问答', async () => { + await userPage.goto(`${USER}/psy/ask`) + const box = userPage.getByPlaceholder('让我来解答你的问题吧') + await expect(box).toBeVisible({ timeout: 30_000 }) + const q = `联调提问${marker}` + await box.fill(q) + await userPage.getByRole('button', { name: '发送' }).click() + await expect(userPage.getByText(q)).toBeVisible() + await expect(userPage.locator('body')).toContainText(/你|建议|可以|了解|次数|会员/, { timeout: 60_000 }) + }) + + test('8 · 探索测试完整提交', async () => { + await userPage.goto(`${USER}/psy/scales/communication-style`) + await expect(userPage.getByRole('button', { name: '开始' })).toBeVisible({ timeout: 20_000 }) + await userPage.getByRole('button', { name: '开始' }).click() + for (let i = 0; i < 3; i++) { + await userPage.locator('label.opt').first().click() + const nextLabel = i === 2 ? '查看探索结果' : '下一题' + await userPage.getByRole('button', { name: nextLabel }).click() + } + await expect(userPage.getByText(/沟通|探索|结果|偏好/).first()).toBeVisible({ timeout: 30_000 }) + }) + + test('9 · 其余核心页可打开(烟测)', async () => { + for (const path of [ + '/psy/', + '/psy/explore', + '/psy/companion', + '/psy/mine', + '/psy/reports', + '/psy/profile', + '/psy/star', + '/psy/rhythm', + '/psy/cards', + '/psy/synastry', + ]) { + await userPage.goto(`${USER}${path}`) + await expect(userPage.locator('#app')).toBeVisible() + await expect(userPage.getByText(/失败|unavailable|Internal Server/i)).toHaveCount(0) + } + }) + + test('10 · 后台用户详情仍可看到档案与近订单', async () => { + await adminPage.goto(`${ADMIN}/users/${userId}`) + await expect(adminPage.getByText('self').first()).toBeVisible() + await expect(adminPage.getByText('有效').first()).toBeVisible() + }) +}) diff --git a/package-lock.json b/package-lock.json index f7f6720..715420c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "workspaces": [ "apps/user-h5", "apps/admin-h5", + "e2e-live", "packages/*" ] }, @@ -20,6 +21,7 @@ "vue-router": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@vitejs/plugin-vue": "^5.2.1", "typescript": "~5.7.2", "vite": "^6.0.7", @@ -50,6 +52,13 @@ "vue-tsc": "^2.2.0" } }, + "e2e-live": { + "name": "@yuxingu/e2e-live", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.50.1" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1450,6 +1459,10 @@ "resolved": "apps/admin-h5", "link": true }, + "node_modules/@yuxingu/e2e-live": { + "resolved": "e2e-live", + "link": true + }, "node_modules/@yuxingu/sdk": { "resolved": "packages/sdk", "link": true diff --git a/package.json b/package.json index 1396df6..7cb681a 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "workspaces": [ "apps/user-h5", "apps/admin-h5", + "e2e-live", "packages/*" ], "scripts": { @@ -17,6 +18,8 @@ "deps:ps": "docker compose -f docker-compose.dev.yml ps", "test:h5": "npm run test -w @yuxingu/user-h5", "test:e2e": "npm run test:e2e -w @yuxingu/user-h5", + "test:e2e:admin": "npm run test:e2e -w @yuxingu/admin-h5", + "test:e2e:live": "npm run test -w @yuxingu/e2e-live", "test:api": "cd apps/api && go test ./...", "test:api:integration": "cd apps/api && go test ./internal/integration/ -count=1" }