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 { 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 }