u
This commit is contained in:
1 parent
15e0ae5b67
commit
e434374db5
71 files changed
+5726
-329
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { SESSION_STORAGE_KEY, loadSession, type PersistedSession } from './session'
|
||||
|
||||
export { expect }
|
||||
|
||||
/**
|
||||
* 免登录的 test:每个 page 在应用脚本执行**之前**就把 sessionStorage 种好,
|
||||
* 于是路由守卫(`isAuthenticated` 只看 token 非空)直接放行,用例一进站就是登录态。
|
||||
*
|
||||
* 为什么不是 `storageState`:token 存在 sessionStorage,而 Playwright 的 storageState
|
||||
* 只覆盖 cookie + localStorage,种不进去。
|
||||
*
|
||||
* 这里覆盖的是内置 `context` fixture —— 同名依赖拿到的是基类的原始实现(不是自己,
|
||||
* 否则就无限递归了)。放 `context` 而不是 `page`:addInitScript 挂在 context 上,
|
||||
* 用例里新开的 tab / iframe 也一并生效。
|
||||
*
|
||||
* 手写等价物:
|
||||
* sessionStorage.setItem('g3erp:login', JSON.stringify({ loginInfo: { token, user } }))
|
||||
*
|
||||
* ⚠️ 别在用例里真去提交登录表单:那会刷新服务端会话,把本轮其他并发用例一起踢下线。
|
||||
* 要测登录流程本身,得先用一个**专用账号**,并关掉 VITE_DEV_AUTO_LOGIN。
|
||||
*/
|
||||
export const test = base.extend({
|
||||
context: async ({ context }, use) => {
|
||||
const { token, user } = loadSession()
|
||||
const seed: PersistedSession = { loginInfo: { token, user } }
|
||||
|
||||
await context.addInitScript(
|
||||
(payload: { key: string; value: PersistedSession }) => {
|
||||
window.sessionStorage.setItem(payload.key, JSON.stringify(payload.value))
|
||||
},
|
||||
{ key: SESSION_STORAGE_KEY, value: seed },
|
||||
)
|
||||
|
||||
await use(context)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { apiTarget, devAccount, loginForE2E, saveSession } from './session'
|
||||
|
||||
/**
|
||||
* 整轮测试唯一的登录点。
|
||||
*
|
||||
* 在 globalSetup(主进程、worker 起来之前)登录一次,token 落 `e2e/.auth/session.json`,
|
||||
* worker 里的 fixtures 只读文件。**不要在 fixture / 用例里再登录**——
|
||||
* 同一账号重复登录会互踢(详见 session.ts 的说明),并发用例会互相踢下线。
|
||||
*/
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
const { orgId, account } = devAccount()
|
||||
const session = await loginForE2E()
|
||||
saveSession(session)
|
||||
console.log(
|
||||
`[e2e] 已登录 ${orgId}/${account}(后端 ${apiTarget()}),会话写入 e2e/.auth/session.json`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* E2E 会话:整轮测试**只登录一次**,token 落文件,各 worker 复用。
|
||||
*
|
||||
* 两条硬约束,决定了这里为什么不是「每个用例自己登录」:
|
||||
*
|
||||
* 1. **token 存在 sessionStorage**(key `g3erp:login`,见 `src/stores/user.ts` 的
|
||||
* persist 配置),而 Playwright 的 `storageState` 只序列化 cookie + localStorage,
|
||||
* **不含 sessionStorage**。所以 login 只负责把 token 取回来落盘,由
|
||||
* `support/fixtures.ts` 用 `addInitScript` 在应用脚本执行前把 sessionStorage 种好。
|
||||
*
|
||||
* 2. **同一账号重复登录会互踢**:后端 `ActiveSessionRegistry.activate` 按
|
||||
* (机构码, 账号) 只保留最新会话,上一个会话的下一次请求会被判 40101。
|
||||
* 并发 worker 各登录一次 → 互相把对方踢下线 → 用例随机失败。
|
||||
*
|
||||
* 命令行等价物:`node ../scripts/dev-login.mjs --token-only`(给人 / 外部脚本用)。
|
||||
*/
|
||||
|
||||
export interface SessionUser {
|
||||
id: string
|
||||
account: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
token: string
|
||||
orgId: string
|
||||
user: SessionUser
|
||||
}
|
||||
|
||||
/** 与 `src/stores/user.ts` 的 `persist.key` 对应,改那边要同步改这里。 */
|
||||
export const SESSION_STORAGE_KEY = 'g3erp:login'
|
||||
|
||||
/**
|
||||
* sessionStorage 里实际存的形状:pinia-plugin-persistedstate 是
|
||||
* `JSON.stringify(被 pick 出来的 state)`,user store 的 pick 是 `['loginInfo']`。
|
||||
* 即 `{"loginInfo":{"token":"...","user":{...}}}`。
|
||||
*/
|
||||
export interface PersistedSession {
|
||||
loginInfo: { token: string; user: SessionUser | null }
|
||||
}
|
||||
|
||||
const WEB_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const SESSION_FILE = path.join(WEB_ROOT, 'e2e', '.auth', 'session.json')
|
||||
|
||||
/** 与 Vite 同序读取:后面的文件覆盖前面的。 */
|
||||
const ENV_FILES = ['.env.development', '.env.development.local']
|
||||
|
||||
/** 读 `web/.env.*` 里的一个变量(不引 dotenv,只认 `KEY=value` 这种写法)。 */
|
||||
function readEnv(key: string): string {
|
||||
let value = ''
|
||||
for (const file of ENV_FILES) {
|
||||
const full = path.join(WEB_ROOT, file)
|
||||
if (!existsSync(full)) continue
|
||||
for (const line of readFileSync(full, 'utf8').split(/\r?\n/)) {
|
||||
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
|
||||
if (match?.[1] === key) {
|
||||
value = match[2].trim().replace(/^(['"])(.*)\1$/, '$2')
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** 直接打后端,不经过 Vite 代理(省掉对 dev server 的依赖)。 */
|
||||
export function apiTarget(): string {
|
||||
return (
|
||||
process.env.E2E_API_TARGET ||
|
||||
readEnv('VITE_PROXY_TARGET') ||
|
||||
'http://127.0.0.1:8088'
|
||||
).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** 账号来源与开发期自动登录同一份配置,避免两处各写一套。 */
|
||||
export function devAccount(): { orgId: string; account: string; password: string } {
|
||||
return {
|
||||
orgId: process.env.E2E_ORG || readEnv('VITE_DEV_ORG') || 'G3HD',
|
||||
account: process.env.E2E_ACCOUNT || readEnv('VITE_DEV_ACCOUNT') || 'demo_zhangwei',
|
||||
// ?? 而非 ||:空密码是合法值(超管在库里就是空串)
|
||||
password: process.env.E2E_PASSWORD ?? readEnv('VITE_DEV_PASSWORD'),
|
||||
}
|
||||
}
|
||||
|
||||
/** 调登录接口换 token。失败信息要足够定位问题,别只丢一个 HTTP 状态码。 */
|
||||
export async function loginForE2E(): Promise<Session> {
|
||||
const { orgId, account, password } = devAccount()
|
||||
const target = apiTarget()
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`${target}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orgid: orgId, userid: account, password }),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`连不上后端 ${target}:${(error as Error).message}\n` +
|
||||
'e2e 需要后端先起来,见 CODEBUDDY.md 的「启动」。',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
// 后端鉴权失败返回的是 HTTP 200 + body.code=401(不是 HTTP 401),两个都要判
|
||||
const body = (await response.json()) as {
|
||||
code?: number
|
||||
message?: string
|
||||
data?: Session
|
||||
}
|
||||
if (body.code !== 0 || !body.data?.token) {
|
||||
throw new Error(
|
||||
`E2E 登录失败(code=${body.code}):${body.message || '响应里没有 token'}\n` +
|
||||
`账号:${orgId}/${account}(密码${password ? '已填' : '为空'})\n` +
|
||||
'账号密码来自 web/.env.development 的 VITE_DEV_*,可用 E2E_ORG / E2E_ACCOUNT / E2E_PASSWORD 覆盖。',
|
||||
)
|
||||
}
|
||||
|
||||
return body.data
|
||||
}
|
||||
|
||||
export function saveSession(session: Session): void {
|
||||
mkdirSync(path.dirname(SESSION_FILE), { recursive: true })
|
||||
writeFileSync(SESSION_FILE, `${JSON.stringify(session, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function loadSession(): Session {
|
||||
if (!existsSync(SESSION_FILE)) {
|
||||
throw new Error(
|
||||
`找不到 ${SESSION_FILE}:globalSetup 没执行成功。重跑 pnpm test:e2e,先看它的报错。`,
|
||||
)
|
||||
}
|
||||
return JSON.parse(readFileSync(SESSION_FILE, 'utf8')) as Session
|
||||
}
|
||||
|
||||
export { SESSION_FILE }
|
||||
Reference in new issue
Block a user