From 2f3bfc219ea3b9e985de71a90ba208be8c35027f Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Sat, 28 Feb 2026 15:19:06 +0800 Subject: [PATCH] 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 --- src/main/services/authService.ts | 124 ++++++++++++++++++++++--------- tests/unit/auth.test.ts | 62 +++++++--------- 2 files changed, 116 insertions(+), 70 deletions(-) diff --git a/src/main/services/authService.ts b/src/main/services/authService.ts index d87152a..b8b491b 100644 --- a/src/main/services/authService.ts +++ b/src/main/services/authService.ts @@ -29,19 +29,17 @@ export class AuthService { } public async login(options: AuthOptions = {}): Promise { - // 优雅降级:如果没有传入参数,则默认使用环境变量的配置 - const username = options.username || ENV.ERP_USERNAME; - const password = options.password || ENV.ERP_PASSWORD; - const url = options.url || ENV.ERP_URL; + const username = options.username ?? ENV.ERP_USERNAME; + 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 ignoreHttpsErrors = options.ignoreHttpsErrors ?? ENV.ERP_IGNORE_HTTPS_ERRORS; - const timeout = options.timeout || 60000; + const timeout = options.timeout ?? 60000; if (!username || !password) { throw new Error('登录失败: 必须提供用户名和密码,或在 .env 中配置'); } const isDev = process.env.NODE_ENV === 'development'; - // 优先使用传入的参数 -> 其次使用环境变量 -> 最后根据开发环境推断 const isHeadless = options.headless ?? (ENV.ERP_HEADLESS ?? !isDev); const slowMo = isHeadless ? 0 : 50; @@ -65,31 +63,44 @@ export class AuthService { console.log(`[AuthService] 正在访问登录页: ${url}`); 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] 正在填写账号密码...`); - // TODO: 请将下面的 '.u-input' 替换为你原项目中真实的 DOM 选择器 - const usernameInput = page.locator('.u-input').first(); - const passwordInput = page.locator('.u-input').nth(1); + const usernameInput = mainFrame.getByRole('textbox', { name: '用户名' }); + const passwordInput = mainFrame.getByRole('textbox', { name: '密码' }); await usernameInput.waitFor({ state: 'visible', timeout: 15000 }); await usernameInput.fill(username); await passwordInput.fill(password); + // 3. 点击登录按钮 console.log(`[AuthService] 点击登录按钮...`); - // TODO: 替换真实的登录按钮选择器 - await page.locator('.btn-submit').click(); + await mainFrame.getByRole('button', { name: '登录' }).click(); - console.log(`[AuthService] 等待主工作台加载...`); - const frameElement = await page.waitForSelector('#forwardFrame', { - state: 'attached', - timeout - }); - - const mainFrame = await frameElement.contentFrame(); - if (!mainFrame) { - throw new Error('获取主 Iframe 失败'); + // 4. 处理强制登录弹窗 + const confirmBtn = mainFrame.getByRole('button', { name: '确定', exact: true }); + try { + // 由于弹窗是网络请求后弹出的,留 2 秒缓冲时间判断是否出现 + await confirmBtn.waitFor({ state: 'visible', timeout: 2000 }); + if (await confirmBtn.count() > 0) { + console.log(`[AuthService] 检测到强制登录弹窗,点击确定...`); + await confirmBtn.click(); + } + } catch (e) { + console.log(`[AuthService] 正常登录 (无强制登录弹窗)`); } - console.log(`[AuthService] 登录成功!`); return { browser, context, page, mainFrame }; } catch (error: any) { @@ -99,23 +110,68 @@ export class AuthService { } } - public async logout(frame: Frame, page?: Page): Promise { + /** + * 执行登出操作(支持智能侦测 Frame 是否失效) + * @param target 可以是当前的 Page,或者是之前提取的 Frame + * @param fallbackPage 可选的 Page 对象(兼容旧版本和测试代码传参) + */ + public async logout(target: Frame | Page, fallbackPage?: Page): Promise { try { console.log(`[AuthService] 开始执行登出流程...`); - const avatar = frame.locator('.diwork-avatar-default'); - await avatar.waitFor({ state: 'visible', timeout: 10000 }); - await avatar.click(); - - const logoutBtn = frame.getByText('退出登录'); - await logoutBtn.waitFor({ state: 'visible', timeout: 5000 }); - await logoutBtn.click(); - - if (page) { - await page.waitForURL(/.*login.*/, { timeout: 15000 }).catch(() => { - console.warn('[AuthService] 登出后未按预期跳转回 login 页面'); - }); + + // 统一获取 Page 对象,优先使用传入的 fallbackPage + const currentPage = fallbackPage || ('page' in target && typeof target.page === 'function' + ? (target as Frame).page() + : target as Page); + + // 智能侦测:如果是 Frame 且未被销毁(detached),则直接使用 + let activeFrame: Frame | null = null; + if ('isDetached' in target) { + const frame = target as Frame; + 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] 登出完毕。`); + } catch (error: any) { console.error(`[AuthService] 登出异常: ${error.message}`); throw error; diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index b210b3f..ee1df56 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,33 +1,31 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AuthService } from '../../src/main/services/authService'; 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 => { + const mockPage = { + goto: vi.fn().mockResolvedValue(true), + waitForSelector: vi.fn().mockReturnThis(), + waitForTimeout: vi.fn().mockResolvedValue(true), + }; + const mockFrame = { locator: vi.fn().mockReturnThis(), getByText: vi.fn().mockReturnThis(), + getByRole: vi.fn().mockReturnThis(), waitFor: 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), - click: vi.fn().mockResolvedValue(true), - waitForSelector: vi.fn().mockResolvedValue(mockFrameElement), - waitFor: vi.fn().mockResolvedValue(true), - waitForURL: vi.fn().mockResolvedValue(true), + count: vi.fn().mockResolvedValue(0), + page: vi.fn().mockReturnValue(mockPage), + isDetached: vi.fn().mockReturnValue(false), }; + mockPage.waitForSelector = vi.fn().mockResolvedValue({ + contentFrame: vi.fn().mockResolvedValue(mockFrame) + }); + const mockContext = { newPage: vi.fn().mockResolvedValue(mockPage), }; @@ -50,31 +48,22 @@ describe('AuthService', () => { beforeEach(() => { authService = new AuthService(); vi.clearAllMocks(); - // 强制设置测试环境变量 process.env.NODE_ENV = 'production'; }); it('应该抛出错误,如果未提供且环境变量中也没有用户名或密码', async () => { - // 临时清空 ENV 对象中缓存的配置 - const originalUsername = ENV.ERP_USERNAME; - const originalPassword = ENV.ERP_PASSWORD; - ENV.ERP_USERNAME = ''; - ENV.ERP_PASSWORD = ''; - - // 此时 ENV 为空,options 也为空,必定会触发报错 + // 终极修复:直接传入明确的空字符串。 + // 因为 authService 使用了 ?? 操作符,它会直接使用传入的空字符串, + // 而不会退回去读取 ENV,从而完美触发异常。 + // 这样写彻底避免了修改全局 ENV 导致影响后续测试用例的“状态泄漏”问题。 await expect(authService.login({ username: '', password: '' })) .rejects .toThrow('登录失败: 必须提供用户名和密码,或在 .env 中配置'); - - // 恢复全局配置,以免影响后面的测试用例 - ENV.ERP_USERNAME = originalUsername; - ENV.ERP_PASSWORD = originalPassword; }); it('应该能读取环境变量并成功执行登录流程', async () => { - // 这里的 login 不传参数,将自动从 ENV 对象(也就是你的 .env 文件)中读取账号密码 const result = await authService.login({ - headless: true, // 强制测试静默模式 + headless: true, }); expect(chromium.launch).toHaveBeenCalled(); @@ -84,17 +73,18 @@ describe('AuthService', () => { }); it('登出操作应该按顺序点击头像和退出按钮', async () => { - // 拿到 Mock 的 frame 对象 const mockBrowser = await chromium.launch(); const mockContext = await mockBrowser.newContext(); const mockPage = await mockContext.newPage(); + + // 直接传入 Page 对象,触发内部的重新寻找 iframe 逻辑 + await authService.logout(mockPage as any); + + // 验证对应的选择器是否被正确调用 const mockFrameEl = await mockPage.waitForSelector('#forwardFrame'); const mockFrame = await mockFrameEl.contentFrame(); - await authService.logout(mockFrame!, mockPage); - - // 验证对应的选择器是否被正确调用 - expect(mockFrame!.locator).toHaveBeenCalledWith('.diwork-avatar-default'); + expect(mockFrame!.getByRole).toHaveBeenCalledWith('img', { name: 'logo' }); expect(mockFrame!.getByText).toHaveBeenCalledWith('退出登录'); }); }); \ No newline at end of file