This commit is contained in:
oneao committed 2026-10-14 17:25:47 +08:00
1 parent 15e0ae5b67
commit e434374db5
71 files changed
+5726 -329

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from './support/fixtures'
import { loadSession } from './support/session'
/**
* 冒烟用例:证明「免登录」这条基础设施真的通了。
*
* 落点选 `/approval/todo` 而不是首页:
* - `/dashboard` **没有任何权限点**(`sql/20_registration/14_dashboard_menu.sql` 写明
* 「不建权限点」,只有 `g3soft` 提权可见);
* - e2e 默认账号 `demo_zhangwei` 只被授了 `menu.approval%`
* (`sql/90_mock/02_fms_workflow_runtime_mock.sql:186`)。
* 于是非超管进 `/dashboard` 会被守卫送到 `/403`,而 `/approval/todo` 是演示账号有授权的页面,
* 换成 `g3soft`(超管)也照样通过 —— 两边都不挑账号。
*/
test('种了会话就能直接进受保护页面,不跳登录页也不跳 403', async ({ page }) => {
await page.goto('/approval/todo')
// 断言最终 URL 就是目标页,同时覆盖两种失败:跳 /login(会话没种上)、
// 跳 /403(种上了但没授权)。只写 not.toHaveURL('/login') 会漏掉后者,而且
// goto 之后 URL 立刻就是目标值,not 断言会在重定向发生前就通过 —— 断言不到东西。
await expect(page).toHaveURL(/\/approval\/todo$/)
})
test('顶栏显示的是会话里的那个用户', async ({ page }) => {
const user = loadSession().user
const expected = user?.name || user?.account || ''
expect(expected, '会话里没有用户名,登录响应结构可能变了').not.toBe('')
await page.goto('/approval/todo')
await expect(page.locator('.layout-header__user')).toContainText(expected)
})
test('刷新后仍是登录态(会话读自 sessionStorage,不靠内存)', async ({ page }) => {
await page.goto('/approval/todo')
await expect(page).toHaveURL(/\/approval\/todo$/)
await page.reload()
await expect(page).toHaveURL(/\/approval\/todo$/)
})
@@ -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`,
)
}
+138
View File
@@ -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 }