test: 用户管理全生命周期测试(42项)覆盖异常case
覆盖场景: - 创建用户异常:重复用户名、密码太短、空字段 - 权限边界:USER 不能创建/查看/删除用户 - 角色变更:USER↔TENANT_ADMIN 切换后权限实时生效 - 删除异常:删自己、删 admin、删不存在用户 - UI 验证:角色列、编辑弹窗、权限管理页、权限矩阵 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* 用户管理全生命周期测试
|
||||||
|
*
|
||||||
|
* 覆盖场景:
|
||||||
|
* - 三种角色(SUPER_ADMIN / TENANT_ADMIN / USER)的 CRUD 权限
|
||||||
|
* - 创建用户的各种异常 case(重复用户名、密码太短、空字段)
|
||||||
|
* - 编辑用户(改名、改角色)
|
||||||
|
* - 删除用户(删自己、删 admin、删不存在的人)
|
||||||
|
* - 角色变更后权限实时生效
|
||||||
|
* - UI 交互验证(Playwright)
|
||||||
|
*/
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const API = 'http://localhost:3001';
|
||||||
|
const BASE = 'http://localhost:13001';
|
||||||
|
const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234';
|
||||||
|
|
||||||
|
// ── 工具函数 ──
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
|
||||||
|
function assert(label, ok, detail = '') {
|
||||||
|
if (ok) {
|
||||||
|
console.log(` ✅ ${label}`);
|
||||||
|
pass++;
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${label}${detail ? ' — ' + detail : ''}`);
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginApi(username, password) {
|
||||||
|
const r = await fetch(`${API}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const data = await r.json();
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(token, method, path, body = null) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(`${API}/api${path}`, opts);
|
||||||
|
const status = r.status;
|
||||||
|
let data = null;
|
||||||
|
try { data = await r.json(); } catch { data = null; }
|
||||||
|
return { status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过 Playwright 登录并获取 apiKey */
|
||||||
|
async function getApiKey(page, username, password) {
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await page.locator('input[type="text"]').first().fill(username);
|
||||||
|
await page.locator('input[type="password"]').first().fill(password);
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
await page.waitForURL('**/');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
return await page.evaluate(() => localStorage.getItem('kb_api_key') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 主测试 ──
|
||||||
|
async function run() {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(70));
|
||||||
|
console.log('🧪 用户管理全生命周期测试');
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// Phase 1: Admin 登录 + 创建测试用户
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n📦 Phase 1: 环境准备');
|
||||||
|
const adminToken = await loginApi('admin', 'admin123');
|
||||||
|
assert('admin 登录', !!adminToken);
|
||||||
|
|
||||||
|
// 创建 test-user-a(正常用户)
|
||||||
|
const r1 = await api(adminToken, 'POST', '/users', {
|
||||||
|
username: 'e2e-user-a', password: 'pass123', displayName: '测试用户A',
|
||||||
|
});
|
||||||
|
const userAId = r1.data?.user?.id || r1.data?.id;
|
||||||
|
assert('创建 userA', r1.status === 201 || r1.status === 200, `status=${r1.status}`);
|
||||||
|
assert(' userA 有 ID', !!userAId);
|
||||||
|
|
||||||
|
// 加到租户
|
||||||
|
const r1m = await api(adminToken, 'POST', `/v1/tenants/${TENANT_ID}/members`, {
|
||||||
|
userId: userAId, role: 'USER',
|
||||||
|
});
|
||||||
|
assert(' userA 加入租户', r1m.status === 201 || r1m.status === 200, `status=${r1m.status}`);
|
||||||
|
|
||||||
|
// 创建 test-user-b(后来会升为 TENANT_ADMIN)
|
||||||
|
const r2 = await api(adminToken, 'POST', '/users', {
|
||||||
|
username: 'e2e-user-b', password: 'pass456', displayName: '测试用户B',
|
||||||
|
});
|
||||||
|
const userBId = r2.data?.user?.id || r2.data?.id;
|
||||||
|
assert('创建 userB', !!userBId);
|
||||||
|
|
||||||
|
const r2m = await api(adminToken, 'POST', `/v1/tenants/${TENANT_ID}/members`, {
|
||||||
|
userId: userBId, role: 'USER',
|
||||||
|
});
|
||||||
|
assert(' userB 加入租户', r2m.status === 201 || r2m.status === 200);
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// Phase 2: 创建用户的异常情况
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n📦 Phase 2: 创建用户 — 异常 case');
|
||||||
|
|
||||||
|
// 2a. 重复用户名
|
||||||
|
const rDup = await api(adminToken, 'POST', '/users', {
|
||||||
|
username: 'e2e-user-a', password: 'pass123', displayName: '重复用户',
|
||||||
|
});
|
||||||
|
assert(' 重复用户名拒绝', rDup.status >= 400, `status=${rDup.status}`);
|
||||||
|
|
||||||
|
// 2b. 密码太短
|
||||||
|
const rShort = await api(adminToken, 'POST', '/users', {
|
||||||
|
username: 'e2e-user-short', password: '12', displayName: '短密码',
|
||||||
|
});
|
||||||
|
assert(' 密码太短拒绝', rShort.status >= 400, `status=${rShort.status}`);
|
||||||
|
|
||||||
|
// 2c. 空用户名
|
||||||
|
const rEmpty = await api(adminToken, 'POST', '/users', {
|
||||||
|
username: '', password: 'pass123', displayName: '空用户名',
|
||||||
|
});
|
||||||
|
assert(' 空用户名拒绝', rEmpty.status >= 400, `status=${rEmpty.status}`);
|
||||||
|
|
||||||
|
// 2d. 不传密码
|
||||||
|
const rNoPass = await api(adminToken, 'POST', '/users', {
|
||||||
|
username: 'e2e-user-nopass', displayName: '无密码',
|
||||||
|
});
|
||||||
|
assert(' 无密码拒绝', rNoPass.status >= 400, `status=${rNoPass.status}`);
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// Phase 3: USER 角色不能创建用户
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n📦 Phase 3: 权限边界 — USER 不能创建/删除用户');
|
||||||
|
|
||||||
|
const userAToken = await loginApi('e2e-user-a', 'pass123');
|
||||||
|
assert(' userA 登录', !!userAToken);
|
||||||
|
|
||||||
|
const rForbidCreate = await api(userAToken, 'POST', '/users', {
|
||||||
|
username: 'e2e-user-forbid', password: 'pass123',
|
||||||
|
});
|
||||||
|
assert(' USER 创建用户被拒', rForbidCreate.status === 403, `got ${rForbidCreate.status}`);
|
||||||
|
|
||||||
|
const rForbidList = await api(userAToken, 'GET', '/users');
|
||||||
|
assert(' USER 查看用户列表被拒', rForbidList.status === 403, `got ${rForbidList.status}`);
|
||||||
|
|
||||||
|
const rForbidDelete = await api(userAToken, 'DELETE', `/users/${userBId}`);
|
||||||
|
assert(' USER 删除用户被拒', rForbidDelete.status === 403, `got ${rForbidDelete.status}`);
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// Phase 4: 编辑用户 + 角色变更
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n📦 Phase 4: 编辑用户 & 角色变更');
|
||||||
|
|
||||||
|
// 4a. 改名
|
||||||
|
const rRename = await api(adminToken, 'PUT', `/users/${userAId}`, {
|
||||||
|
displayName: '用户A已改名',
|
||||||
|
});
|
||||||
|
assert(' 编辑用户名', rRename.status === 200, `got ${rRename.status}`);
|
||||||
|
|
||||||
|
// 4b. 提升为 TENANT_ADMIN
|
||||||
|
const rPromote = await api(adminToken, 'PATCH', `/v1/tenants/${TENANT_ID}/members/${userAId}`, {
|
||||||
|
role: 'TENANT_ADMIN',
|
||||||
|
});
|
||||||
|
assert(' 提升 userA 为管理员', rPromote.status === 200, `got ${rPromote.status}`);
|
||||||
|
|
||||||
|
// 4c. 验证权限实时生效
|
||||||
|
const userA2Token = await loginApi('e2e-user-a', 'pass123');
|
||||||
|
assert(' userA 重新登录', !!userA2Token);
|
||||||
|
|
||||||
|
const rCheckPerm = await fetch(`${API}/api/permissions/mine`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${userA2Token}` },
|
||||||
|
});
|
||||||
|
const permData = await rCheckPerm.json();
|
||||||
|
const permCount = (permData.permissions || []).length;
|
||||||
|
assert(` userA 权限从 5→${permCount}`, permCount >= 20, `实际=${permCount}`);
|
||||||
|
|
||||||
|
// 4d. 验证现在可以查看用户列表了
|
||||||
|
const rCanList = await api(userA2Token, 'GET', '/users');
|
||||||
|
assert(' userA(TENANT_ADMIN) 能查看用户列表', rCanList.status === 200);
|
||||||
|
|
||||||
|
// 4e. 降回 USER
|
||||||
|
const rDemote = await api(adminToken, 'PATCH', `/v1/tenants/${TENANT_ID}/members/${userAId}`, {
|
||||||
|
role: 'USER',
|
||||||
|
});
|
||||||
|
assert(' 降回 userA 为 USER', rDemote.status === 200);
|
||||||
|
|
||||||
|
const userA3Token = await loginApi('e2e-user-a', 'pass123');
|
||||||
|
const rCheckPerm2 = await fetch(`${API}/api/permissions/mine`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${userA3Token}` },
|
||||||
|
});
|
||||||
|
const permData2 = await rCheckPerm2.json();
|
||||||
|
const permCount2 = (permData2.permissions || []).length;
|
||||||
|
assert(` userA 权限从 ${permCount}→${permCount2}`, permCount2 <= 5, `实际=${permCount2}`);
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// Phase 5: 删除用户的异常情况
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n📦 Phase 5: 删除用户 — 异常 case');
|
||||||
|
|
||||||
|
// 5a. 删自己
|
||||||
|
// 先获取 admin 自己的 ID
|
||||||
|
const adminProfile = await api(adminToken, 'GET', '/users/me');
|
||||||
|
const adminId = adminProfile.data?.id;
|
||||||
|
assert(' admin profile 有 ID', !!adminId, `data=${JSON.stringify(adminProfile.data)}`);
|
||||||
|
|
||||||
|
if (adminId) {
|
||||||
|
const rSelf = await api(adminToken, 'DELETE', `/users/${adminId}`);
|
||||||
|
assert(' 不能删自己', rSelf.status >= 400, `got ${rSelf.status} msg=${JSON.stringify(rSelf.data)}`);
|
||||||
|
|
||||||
|
// 验证 admin 还在——通过 users 列表
|
||||||
|
const rCheckList = await api(adminToken, 'GET', '/users');
|
||||||
|
const allUsersAfter = Array.isArray(rCheckList.data) ? rCheckList.data : (rCheckList.data?.data || []);
|
||||||
|
const adminStillThere = allUsersAfter.some(u => u.id === adminId);
|
||||||
|
assert(' admin 还在', adminStillThere, `列表中无 admin ID`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5b. 删不存在的用户
|
||||||
|
const rNonExist = await api(adminToken, 'DELETE', '/users/non-existent-id');
|
||||||
|
assert(' 删不存在用户返回 404', rNonExist.status === 404, `got ${rNonExist.status}`);
|
||||||
|
|
||||||
|
// 5c. 删 admin 账户
|
||||||
|
// 先查 admin 的 ID
|
||||||
|
const usersList = await api(adminToken, 'GET', '/users');
|
||||||
|
const allUsers = Array.isArray(usersList.data) ? usersList.data : (usersList.data?.data || []);
|
||||||
|
const realAdmin = allUsers.find(u => u.username === 'admin');
|
||||||
|
if (realAdmin) {
|
||||||
|
const rDelAdmin = await api(adminToken, 'DELETE', `/users/${realAdmin.id}`);
|
||||||
|
assert(' 不能删 admin 账号', rDelAdmin.status >= 400, `got ${rDelAdmin.status} msg=${JSON.stringify(rDelAdmin.data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5d. TENANT_ADMIN 删其他租户的用户(如果有的话)
|
||||||
|
// 创建另一个租户的用户
|
||||||
|
const rOtherTenant = await api(adminToken, 'POST', '/v1/tenants', { name: 'temp-other-tenant' });
|
||||||
|
const otherTenantId = rOtherTenant.data?.id;
|
||||||
|
if (otherTenantId) {
|
||||||
|
// 删除临时租户
|
||||||
|
await api(adminToken, 'DELETE', `/v1/tenants/${otherTenantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// Phase 6: 正常删除用户
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n📦 Phase 6: 正常删除用户(清理测试数据)');
|
||||||
|
|
||||||
|
const rDelA = await api(adminToken, 'DELETE', `/users/${userAId}`);
|
||||||
|
assert(' 删除 userA', rDelA.status === 200, `got ${rDelA.status}`);
|
||||||
|
|
||||||
|
const rCheckA = await api(adminToken, 'GET', `/users/${userAId}`);
|
||||||
|
assert(' userA 已不存在', rCheckA.status === 404, `got ${rCheckA.status}`);
|
||||||
|
|
||||||
|
const rDelB = await api(adminToken, 'DELETE', `/users/${userBId}`);
|
||||||
|
assert(' 删除 userB', rDelB.status === 200, `got ${rDelB.status}`);
|
||||||
|
|
||||||
|
// 验证删除后登录失败
|
||||||
|
const rLoginDel = await loginApi('e2e-user-a', 'pass123');
|
||||||
|
assert(' 删除后 userA 无法登录', !rLoginDel, `token=${!!rLoginDel}`);
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// Phase 7: UI 验证
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n📦 Phase 7: UI 交互验证');
|
||||||
|
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||||
|
|
||||||
|
// 7a. 登录 admin
|
||||||
|
const apiKey = await getApiKey(page, 'admin', 'admin123');
|
||||||
|
assert(' UI 登录成功', !!apiKey);
|
||||||
|
|
||||||
|
// 7b. 进入设置 → 点击「用户管理」侧栏按钮
|
||||||
|
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const sidebarBtns = await page.evaluate(() => {
|
||||||
|
// 侧栏在 class 包含 w-64 和 bg-slate-50 的 div 里
|
||||||
|
const aside = document.querySelector('[class*="w-64"]') || document.querySelector('aside');
|
||||||
|
if (!aside) return [];
|
||||||
|
return Array.from(aside.querySelectorAll('button')).map(b => (b.textContent || '').trim()).filter(Boolean);
|
||||||
|
});
|
||||||
|
assert(' 设置页侧栏有按钮', sidebarBtns.length > 0, `按钮: ${sidebarBtns.slice(0,5).join(', ')}`);
|
||||||
|
|
||||||
|
// 点击用户管理
|
||||||
|
const userMgmtBtn = page.locator('button:has-text("用户管理")');
|
||||||
|
if (await userMgmtBtn.isVisible().catch(() => false)) {
|
||||||
|
await userMgmtBtn.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
assert(' 用户管理 Tab 可点击', true);
|
||||||
|
} else {
|
||||||
|
assert(' 用户管理按钮可见', false, '侧栏无"用户管理"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7c. 检查用户表
|
||||||
|
const tables = await page.evaluate(() => document.querySelectorAll('table').length);
|
||||||
|
assert(' 用户表存在', tables > 0, `找到 ${tables} 个 table`);
|
||||||
|
|
||||||
|
const headers = await page.evaluate(() => {
|
||||||
|
return Array.from(document.querySelectorAll('th')).map(th => th.textContent?.trim());
|
||||||
|
});
|
||||||
|
assert(' 用户表有角色列', headers.some(h => h?.includes('角色')), `列: ${headers.join(', ')}`);
|
||||||
|
|
||||||
|
const rowCount = await page.evaluate(() => document.querySelectorAll('tbody tr').length);
|
||||||
|
assert(' 用户表有数据行', rowCount > 0, `${rowCount} 行`);
|
||||||
|
|
||||||
|
// 7d. 编辑用户弹窗 — 打开(找任意行的第一个操作按钮)
|
||||||
|
// 操作栏中编辑按钮在第1个
|
||||||
|
const firstActionBtn = page.locator('tbody tr button').first();
|
||||||
|
if (await firstActionBtn.isVisible().catch(() => false)) {
|
||||||
|
await firstActionBtn.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// 检查弹窗中是否有角色选择按钮
|
||||||
|
const roleBtns = await page.evaluate(() => {
|
||||||
|
return Array.from(document.querySelectorAll('.fixed button, [class*="fixed"] button, [class*="inset-0"] button'))
|
||||||
|
.map(b => b.textContent?.trim())
|
||||||
|
.filter(t => t === '用户' || t === '管理员' || t === '超级管理员');
|
||||||
|
});
|
||||||
|
assert(' 编辑弹窗有角色选项', roleBtns.length >= 2, `找到 ${roleBtns.length} 个`);
|
||||||
|
|
||||||
|
// 检查是否有权限预览
|
||||||
|
const permPreview = await page.evaluate(() => {
|
||||||
|
return document.body.textContent?.includes('该角色的权限');
|
||||||
|
});
|
||||||
|
assert(' 编辑弹窗有权限预览', !!permPreview, '未找到"该角色的权限"');
|
||||||
|
|
||||||
|
// 关闭弹窗——点右上角 X 或点取消
|
||||||
|
const cancelBtn = page.locator('button:has-text("取消")').last();
|
||||||
|
if (await cancelBtn.isVisible().catch(() => false)) {
|
||||||
|
await cancelBtn.click();
|
||||||
|
}
|
||||||
|
// 等弹窗完全消失
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.waitForFunction(() => !document.querySelector('[class*="inset-0"][class*="z-\\[1000\\]"]'), { timeout: 5000 }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
assert(' 操作按钮可见', false, '未找到任何行内操作按钮');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7e. 权限管理 Tab
|
||||||
|
// 先确保没有弹窗遮挡
|
||||||
|
await page.waitForFunction(() => !document.querySelector('.fixed.inset-0'), { timeout: 5000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// 用 evaluate 直接点击,绕过任何 DOM 遮挡
|
||||||
|
const clicked = await page.evaluate(() => {
|
||||||
|
const btns = Array.from(document.querySelectorAll('button'));
|
||||||
|
const permBtn = btns.find(b => (b.textContent || '').includes('权限管理'));
|
||||||
|
if (permBtn) { permBtn.scrollIntoView({ block: 'center' }); permBtn.click(); return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assert(' 权限管理按钮可点击', clicked);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 检查是否三个系统角色渲染
|
||||||
|
const hasRoles = await page.evaluate(() => {
|
||||||
|
const body = document.body.textContent || '';
|
||||||
|
return body.includes('SUPER_ADMIN') && body.includes('TENANT_ADMIN') && body.includes('USER');
|
||||||
|
});
|
||||||
|
assert(' 权限管理页显示三个系统角色', !!hasRoles);
|
||||||
|
|
||||||
|
// 点击 SUPER_ADMIN 角色查看权限
|
||||||
|
const superClicked = await page.evaluate(() => {
|
||||||
|
const btns = Array.from(document.querySelectorAll('button'));
|
||||||
|
const superBtn = btns.find(b => (b.textContent || '').includes('SUPER_ADMIN'));
|
||||||
|
if (superBtn) { superBtn.scrollIntoView({ block: 'center' }); superBtn.click(); return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
assert(' SUPER_ADMIN 角色可点击', superClicked);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const permMatrix = await page.evaluate(() => {
|
||||||
|
const body = document.body.textContent || '';
|
||||||
|
return body.includes('用户管理') && body.includes('知识库');
|
||||||
|
});
|
||||||
|
assert(' 权限矩阵渲染', !!permMatrix);
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
|
||||||
|
// ──────────────────────────────────
|
||||||
|
// 汇总
|
||||||
|
// ──────────────────────────────────
|
||||||
|
console.log('\n' + '='.repeat(70));
|
||||||
|
console.log(`📊 测试汇总: ${pass} ✅ | ${fail} ❌ | 共 ${pass+fail} 项`);
|
||||||
|
console.log('='.repeat(70));
|
||||||
|
|
||||||
|
if (fail > 0) {
|
||||||
|
console.log('\n⚠️ 部分测试未通过,请检查以上 ❌ 项');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('\n🎉 所有测试通过!用户管理功能闭环正常。');
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(e => { console.error('\n💥 测试异常:', e.message); process.exit(1); });
|
||||||
Reference in New Issue
Block a user