test(e2e): 前后台联调 Playwright 与 admin 冒烟
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

新增 e2e-live 真 API 一致性全路径,并为 admin-h5 增加 mock 浏览器冒烟。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-06 22:41:43 +08:00
co-authored by Cursor
parent 879bf70cb7
commit 7e9023f0a8
12 changed files with 400 additions and 1 deletions
+2
View File
@@ -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 # Playwrightpreview + mock API,无需 Go
npm run test:e2e:live # 前后台联调(需 deps:up + API :8080;见 e2e-live/
```
---
+2
View File
@@ -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。**
+104
View File
@@ -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()
})
+3 -1
View File
@@ -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",
+31
View File
@@ -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,
},
})
+3
View File
@@ -0,0 +1,3 @@
test-results/
playwright-report/
blob-report/
+14
View File
@@ -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 → 量表 → 多页烟测。
+13
View File
@@ -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"
}
}
+43
View File
@@ -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,
},
],
})
@@ -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()
})
})
+13
View File
@@ -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
+3
View File
@@ -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"
}