Refactor auth service login and logout logic
- Fix iframe handling in login process - Use getByRole for more reliable element selection - Add forced login confirmation dialog handling - Refactor logout to support intelligent Frame detection - Replace || with ?? for proper null coalescing - Update tests to avoid global ENV mutations Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,19 +29,17 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async login(options: AuthOptions = {}): Promise<AuthResult> {
|
public async login(options: AuthOptions = {}): Promise<AuthResult> {
|
||||||
// 优雅降级:如果没有传入参数,则默认使用环境变量的配置
|
const username = options.username ?? ENV.ERP_USERNAME;
|
||||||
const username = options.username || ENV.ERP_USERNAME;
|
const password = options.password ?? ENV.ERP_PASSWORD;
|
||||||
const password = options.password || ENV.ERP_PASSWORD;
|
const url = options.url ?? ENV.ERP_URL ?? 'https://68.11.34.30:8082/yonbip/resources/uap/rbac/login/main/index.html';
|
||||||
const url = options.url || ENV.ERP_URL;
|
|
||||||
const ignoreHttpsErrors = options.ignoreHttpsErrors ?? ENV.ERP_IGNORE_HTTPS_ERRORS;
|
const ignoreHttpsErrors = options.ignoreHttpsErrors ?? ENV.ERP_IGNORE_HTTPS_ERRORS;
|
||||||
const timeout = options.timeout || 60000;
|
const timeout = options.timeout ?? 60000;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
throw new Error('登录失败: 必须提供用户名和密码,或在 .env 中配置');
|
throw new Error('登录失败: 必须提供用户名和密码,或在 .env 中配置');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
// 优先使用传入的参数 -> 其次使用环境变量 -> 最后根据开发环境推断
|
|
||||||
const isHeadless = options.headless ?? (ENV.ERP_HEADLESS ?? !isDev);
|
const isHeadless = options.headless ?? (ENV.ERP_HEADLESS ?? !isDev);
|
||||||
const slowMo = isHeadless ? 0 : 50;
|
const slowMo = isHeadless ? 0 : 50;
|
||||||
|
|
||||||
@@ -65,31 +63,44 @@ export class AuthService {
|
|||||||
console.log(`[AuthService] 正在访问登录页: ${url}`);
|
console.log(`[AuthService] 正在访问登录页: ${url}`);
|
||||||
await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
|
await page.goto(url, { timeout, waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// 1. 核心修正:登录框在 iframe 里!必须先获取 iframe
|
||||||
|
console.log(`[AuthService] 提取主 iframe (forwardFrame)...`);
|
||||||
|
const frameElement = await page.waitForSelector('#forwardFrame', {
|
||||||
|
state: 'attached',
|
||||||
|
timeout
|
||||||
|
});
|
||||||
|
const mainFrame = await frameElement.contentFrame();
|
||||||
|
|
||||||
|
if (!mainFrame) {
|
||||||
|
throw new Error('获取主 Iframe 失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 在 iframe 内部定位并填写账号密码
|
||||||
console.log(`[AuthService] 正在填写账号密码...`);
|
console.log(`[AuthService] 正在填写账号密码...`);
|
||||||
// TODO: 请将下面的 '.u-input' 替换为你原项目中真实的 DOM 选择器
|
const usernameInput = mainFrame.getByRole('textbox', { name: '用户名' });
|
||||||
const usernameInput = page.locator('.u-input').first();
|
const passwordInput = mainFrame.getByRole('textbox', { name: '密码' });
|
||||||
const passwordInput = page.locator('.u-input').nth(1);
|
|
||||||
|
|
||||||
await usernameInput.waitFor({ state: 'visible', timeout: 15000 });
|
await usernameInput.waitFor({ state: 'visible', timeout: 15000 });
|
||||||
await usernameInput.fill(username);
|
await usernameInput.fill(username);
|
||||||
await passwordInput.fill(password);
|
await passwordInput.fill(password);
|
||||||
|
|
||||||
|
// 3. 点击登录按钮
|
||||||
console.log(`[AuthService] 点击登录按钮...`);
|
console.log(`[AuthService] 点击登录按钮...`);
|
||||||
// TODO: 替换真实的登录按钮选择器
|
await mainFrame.getByRole('button', { name: '登录' }).click();
|
||||||
await page.locator('.btn-submit').click();
|
|
||||||
|
|
||||||
console.log(`[AuthService] 等待主工作台加载...`);
|
// 4. 处理强制登录弹窗
|
||||||
const frameElement = await page.waitForSelector('#forwardFrame', {
|
const confirmBtn = mainFrame.getByRole('button', { name: '确定', exact: true });
|
||||||
state: 'attached',
|
try {
|
||||||
timeout
|
// 由于弹窗是网络请求后弹出的,留 2 秒缓冲时间判断是否出现
|
||||||
});
|
await confirmBtn.waitFor({ state: 'visible', timeout: 2000 });
|
||||||
|
if (await confirmBtn.count() > 0) {
|
||||||
const mainFrame = await frameElement.contentFrame();
|
console.log(`[AuthService] 检测到强制登录弹窗,点击确定...`);
|
||||||
if (!mainFrame) {
|
await confirmBtn.click();
|
||||||
throw new Error('获取主 Iframe 失败');
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[AuthService] 正常登录 (无强制登录弹窗)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[AuthService] 登录成功!`);
|
|
||||||
return { browser, context, page, mainFrame };
|
return { browser, context, page, mainFrame };
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -99,23 +110,68 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async logout(frame: Frame, page?: Page): Promise<void> {
|
/**
|
||||||
|
* 执行登出操作(支持智能侦测 Frame 是否失效)
|
||||||
|
* @param target 可以是当前的 Page,或者是之前提取的 Frame
|
||||||
|
* @param fallbackPage 可选的 Page 对象(兼容旧版本和测试代码传参)
|
||||||
|
*/
|
||||||
|
public async logout(target: Frame | Page, fallbackPage?: Page): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(`[AuthService] 开始执行登出流程...`);
|
console.log(`[AuthService] 开始执行登出流程...`);
|
||||||
const avatar = frame.locator('.diwork-avatar-default');
|
|
||||||
await avatar.waitFor({ state: 'visible', timeout: 10000 });
|
// 统一获取 Page 对象,优先使用传入的 fallbackPage
|
||||||
await avatar.click();
|
const currentPage = fallbackPage || ('page' in target && typeof target.page === 'function'
|
||||||
|
? (target as Frame).page()
|
||||||
const logoutBtn = frame.getByText('退出登录');
|
: target as Page);
|
||||||
await logoutBtn.waitFor({ state: 'visible', timeout: 5000 });
|
|
||||||
await logoutBtn.click();
|
// 智能侦测:如果是 Frame 且未被销毁(detached),则直接使用
|
||||||
|
let activeFrame: Frame | null = null;
|
||||||
if (page) {
|
if ('isDetached' in target) {
|
||||||
await page.waitForURL(/.*login.*/, { timeout: 15000 }).catch(() => {
|
const frame = target as Frame;
|
||||||
console.warn('[AuthService] 登出后未按预期跳转回 login 页面');
|
if (!frame.isDetached()) {
|
||||||
});
|
activeFrame = frame;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果 Frame 已失效(发生过页面跳转),或者传入的直接是 Page,则重新定位工作台主 Iframe
|
||||||
|
if (!activeFrame) {
|
||||||
|
console.log(`[AuthService] 重新定位主工作台 Iframe...`);
|
||||||
|
const frameElement = await currentPage.waitForSelector('#forwardFrame', { state: 'attached', timeout: 10000 });
|
||||||
|
activeFrame = await frameElement.contentFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeFrame) {
|
||||||
|
throw new Error('未找到有效的登出 Iframe');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AuthService] 点击账号菜单按钮(logo)...`);
|
||||||
|
const logoImg = activeFrame.getByRole('img', { name: 'logo' });
|
||||||
|
await logoImg.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
await logoImg.click();
|
||||||
|
|
||||||
|
// 等待菜单出现
|
||||||
|
await currentPage.waitForTimeout(1000);
|
||||||
|
|
||||||
|
console.log(`[AuthService] 点击退出登录按钮...`);
|
||||||
|
await activeFrame.getByText('退出登录').click();
|
||||||
|
|
||||||
|
// 等待确认框出现
|
||||||
|
await currentPage.waitForTimeout(1000);
|
||||||
|
|
||||||
|
console.log(`[AuthService] 等待退出登录确认框...`);
|
||||||
|
try {
|
||||||
|
const confirmBtn = activeFrame.getByRole('button', { name: '确定(Y)' });
|
||||||
|
await confirmBtn.waitFor({ state: 'visible', timeout: 3000 });
|
||||||
|
console.log(`[AuthService] 找到确认框,点击确定按钮`);
|
||||||
|
await confirmBtn.click();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[AuthService] 未找到确认框,可能已自动退出`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待注销完成
|
||||||
|
await currentPage.waitForTimeout(2000);
|
||||||
console.log(`[AuthService] 登出完毕。`);
|
console.log(`[AuthService] 登出完毕。`);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[AuthService] 登出异常: ${error.message}`);
|
console.error(`[AuthService] 登出异常: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { AuthService } from '../../src/main/services/authService';
|
import { AuthService } from '../../src/main/services/authService';
|
||||||
import { chromium } from 'playwright-core';
|
import { chromium } from 'playwright-core';
|
||||||
import { ENV } from '../../src/main/config/env'; // <-- 引入 ENV 对象
|
|
||||||
|
|
||||||
// 深度 Mock playwright-core,显式声明返回 any 绕过 TS 严格模式检查
|
// 深度 Mock playwright-core
|
||||||
vi.mock('playwright-core', (): any => {
|
vi.mock('playwright-core', (): any => {
|
||||||
|
const mockPage = {
|
||||||
|
goto: vi.fn().mockResolvedValue(true),
|
||||||
|
waitForSelector: vi.fn().mockReturnThis(),
|
||||||
|
waitForTimeout: vi.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
const mockFrame = {
|
const mockFrame = {
|
||||||
locator: vi.fn().mockReturnThis(),
|
locator: vi.fn().mockReturnThis(),
|
||||||
getByText: vi.fn().mockReturnThis(),
|
getByText: vi.fn().mockReturnThis(),
|
||||||
|
getByRole: vi.fn().mockReturnThis(),
|
||||||
waitFor: vi.fn().mockResolvedValue(true),
|
waitFor: vi.fn().mockResolvedValue(true),
|
||||||
click: vi.fn().mockResolvedValue(true),
|
click: vi.fn().mockResolvedValue(true),
|
||||||
};
|
|
||||||
|
|
||||||
const mockFrameElement = {
|
|
||||||
contentFrame: vi.fn().mockResolvedValue(mockFrame),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockPage = {
|
|
||||||
goto: vi.fn().mockResolvedValue(true),
|
|
||||||
locator: vi.fn().mockReturnThis(),
|
|
||||||
first: vi.fn().mockReturnThis(),
|
|
||||||
nth: vi.fn().mockReturnThis(),
|
|
||||||
fill: vi.fn().mockResolvedValue(true),
|
fill: vi.fn().mockResolvedValue(true),
|
||||||
click: vi.fn().mockResolvedValue(true),
|
count: vi.fn().mockResolvedValue(0),
|
||||||
waitForSelector: vi.fn().mockResolvedValue(mockFrameElement),
|
page: vi.fn().mockReturnValue(mockPage),
|
||||||
waitFor: vi.fn().mockResolvedValue(true),
|
isDetached: vi.fn().mockReturnValue(false),
|
||||||
waitForURL: vi.fn().mockResolvedValue(true),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mockPage.waitForSelector = vi.fn().mockResolvedValue({
|
||||||
|
contentFrame: vi.fn().mockResolvedValue(mockFrame)
|
||||||
|
});
|
||||||
|
|
||||||
const mockContext = {
|
const mockContext = {
|
||||||
newPage: vi.fn().mockResolvedValue(mockPage),
|
newPage: vi.fn().mockResolvedValue(mockPage),
|
||||||
};
|
};
|
||||||
@@ -50,31 +48,22 @@ describe('AuthService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authService = new AuthService();
|
authService = new AuthService();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// 强制设置测试环境变量
|
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该抛出错误,如果未提供且环境变量中也没有用户名或密码', async () => {
|
it('应该抛出错误,如果未提供且环境变量中也没有用户名或密码', async () => {
|
||||||
// 临时清空 ENV 对象中缓存的配置
|
// 终极修复:直接传入明确的空字符串。
|
||||||
const originalUsername = ENV.ERP_USERNAME;
|
// 因为 authService 使用了 ?? 操作符,它会直接使用传入的空字符串,
|
||||||
const originalPassword = ENV.ERP_PASSWORD;
|
// 而不会退回去读取 ENV,从而完美触发异常。
|
||||||
ENV.ERP_USERNAME = '';
|
// 这样写彻底避免了修改全局 ENV 导致影响后续测试用例的“状态泄漏”问题。
|
||||||
ENV.ERP_PASSWORD = '';
|
|
||||||
|
|
||||||
// 此时 ENV 为空,options 也为空,必定会触发报错
|
|
||||||
await expect(authService.login({ username: '', password: '' }))
|
await expect(authService.login({ username: '', password: '' }))
|
||||||
.rejects
|
.rejects
|
||||||
.toThrow('登录失败: 必须提供用户名和密码,或在 .env 中配置');
|
.toThrow('登录失败: 必须提供用户名和密码,或在 .env 中配置');
|
||||||
|
|
||||||
// 恢复全局配置,以免影响后面的测试用例
|
|
||||||
ENV.ERP_USERNAME = originalUsername;
|
|
||||||
ENV.ERP_PASSWORD = originalPassword;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该能读取环境变量并成功执行登录流程', async () => {
|
it('应该能读取环境变量并成功执行登录流程', async () => {
|
||||||
// 这里的 login 不传参数,将自动从 ENV 对象(也就是你的 .env 文件)中读取账号密码
|
|
||||||
const result = await authService.login({
|
const result = await authService.login({
|
||||||
headless: true, // 强制测试静默模式
|
headless: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(chromium.launch).toHaveBeenCalled();
|
expect(chromium.launch).toHaveBeenCalled();
|
||||||
@@ -84,17 +73,18 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('登出操作应该按顺序点击头像和退出按钮', async () => {
|
it('登出操作应该按顺序点击头像和退出按钮', async () => {
|
||||||
// 拿到 Mock 的 frame 对象
|
|
||||||
const mockBrowser = await chromium.launch();
|
const mockBrowser = await chromium.launch();
|
||||||
const mockContext = await mockBrowser.newContext();
|
const mockContext = await mockBrowser.newContext();
|
||||||
const mockPage = await mockContext.newPage();
|
const mockPage = await mockContext.newPage();
|
||||||
|
|
||||||
|
// 直接传入 Page 对象,触发内部的重新寻找 iframe 逻辑
|
||||||
|
await authService.logout(mockPage as any);
|
||||||
|
|
||||||
|
// 验证对应的选择器是否被正确调用
|
||||||
const mockFrameEl = await mockPage.waitForSelector('#forwardFrame');
|
const mockFrameEl = await mockPage.waitForSelector('#forwardFrame');
|
||||||
const mockFrame = await mockFrameEl.contentFrame();
|
const mockFrame = await mockFrameEl.contentFrame();
|
||||||
|
|
||||||
await authService.logout(mockFrame!, mockPage);
|
expect(mockFrame!.getByRole).toHaveBeenCalledWith('img', { name: 'logo' });
|
||||||
|
|
||||||
// 验证对应的选择器是否被正确调用
|
|
||||||
expect(mockFrame!.locator).toHaveBeenCalledWith('.diwork-avatar-default');
|
|
||||||
expect(mockFrame!.getByText).toHaveBeenCalledWith('退出登录');
|
expect(mockFrame!.getByText).toHaveBeenCalledWith('退出登录');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user