139 lines
5.0 KiB
TypeScript
139 lines
5.0 KiB
TypeScript
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 }
|