Files
workspace/code/g3soft-erp/web/e2e/support/session.ts
T
2026-10-14 17:25:47 +08:00

139 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }