test(e2e): 前后台联调 Playwright 与 admin 冒烟
新增 e2e-live 真 API 一致性全路径,并为 admin-h5 增加 mock 浏览器冒烟。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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。**
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
@@ -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 → 量表 → 多页烟测。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Generated
+13
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user