import { TIMEOUTS } from '../../utils/timeouts'; import { Page, Locator, expect } from '@playwright/test'; /** * 企业小程序 H5 URL */ const MINI_BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8080'; const MINI_LOGIN_URL = `${MINI_BASE_URL}/mini`; /** * 企业小程序 Page Object * * 用于企业小程序 E2E 测试 * H5 页面路径: /mini * * 主要功能: * - 小程序登录(手机号 + 密码) * - Token 管理 * - 页面导航和验证 * * @example * ```typescript * const miniPage = new EnterpriseMiniPage(page); * await miniPage.goto(); * await miniPage.login('13800138000', 'password123'); * await miniPage.expectLoginSuccess(); * ``` */ export class EnterpriseMiniPage { readonly page: Page; // ===== 页面级选择器 ===== /** 登录页面容器 */ readonly loginPage: Locator; /** 页面标题 */ readonly pageTitle: Locator; // ===== 登录表单选择器 ===== /** 手机号输入框 */ readonly phoneInput: Locator; /** 密码输入框 */ readonly passwordInput: Locator; /** 登录按钮 */ readonly loginButton: Locator; // ===== 主页选择器(登录后) ===== /** 用户信息显示区域 */ readonly userInfo: Locator; /** 退出登录按钮 */ readonly logoutButton: Locator; constructor(page: Page) { this.page = page; // 初始化登录页面选择器 // Taro 组件在 H5 渲染时会传递 data-testid 到 DOM (使用 taro-view-core 等组件) this.loginPage = page.getByTestId('mini-login-page'); this.pageTitle = page.getByTestId('mini-page-title'); // 登录表单选择器 - 使用 data-testid this.phoneInput = page.getByTestId('mini-phone-input'); this.passwordInput = page.getByTestId('mini-password-input'); this.loginButton = page.getByTestId('mini-login-button'); // 主页选择器(登录后可用) this.userInfo = page.getByTestId('mini-user-info'); // 退出登录按钮 - 使用 getByText 而非 getByRole this.logoutButton = page.getByText('退出登录'); } // ===== 导航和基础验证 ===== /** * 移除开发服务器的覆盖层 iframe(防止干扰测试) */ private async removeDevOverlays(): Promise { await this.page.evaluate(() => { // 移除 react-refresh-overlay 和 webpack-dev-server-client-overlay const overlays = document.querySelectorAll('#react-refresh-overlay, #webpack-dev-server-client-overlay'); overlays.forEach(overlay => overlay.remove()); // 移除 vConsole 开发者工具覆盖层 const vConsole = document.querySelector('#__vconsole'); if (vConsole) { vConsole.remove(); } }); } /** * 导航到企业小程序 H5 登录页面 */ async goto(): Promise { await this.page.goto(MINI_LOGIN_URL); // 移除开发服务器的覆盖层 await this.removeDevOverlays(); // 使用 auto-waiting 机制,等待页面容器可见 await this.expectToBeVisible(); } /** * 验证登录页面关键元素可见 */ async expectToBeVisible(): Promise { // 等待页面容器可见 await this.loginPage.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD }); // 验证页面标题 await this.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT }); } // ===== 登录功能方法 ===== /** * 填写手机号 * @param phone 手机号(11位数字) * * 注意:使用 click + type 方法触发自然的用户输入事件 * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态 */ async fillPhone(phone: string): Promise { // 先移除覆盖层,确保输入可操作 await this.removeDevOverlays(); // 点击聚焦,然后清空(使用 Ctrl+A + Backspace 模拟用户操作) await this.phoneInput.click(); // 等待元素聚焦 await this.page.waitForTimeout(100); // 使用 type 方法输入,会自动覆盖现有内容 await this.phoneInput.type(phone, { delay: 50 }); // 等待表单验证更新 await this.page.waitForTimeout(200); } /** * 填写密码 * @param password 密码(6-20位) * * 注意:使用 click + type 方法触发自然的用户输入事件 * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态 */ async fillPassword(password: string): Promise { // 先移除覆盖层,确保输入可操作 await this.removeDevOverlays(); // 点击聚焦 await this.passwordInput.click(); // 等待元素聚焦 await this.page.waitForTimeout(100); // 使用 type 方法输入 await this.passwordInput.type(password, { delay: 50 }); // 等待表单验证更新 await this.page.waitForTimeout(200); } /** * 点击登录按钮 */ async clickLoginButton(): Promise { // 使用 force: true 避免被开发服务器的覆盖层阻止 await this.loginButton.click({ force: true }); } /** * 执行登录操作(完整流程) * @param phone 手机号 * @param password 密码 */ async login(phone: string, password: string): Promise { await this.fillPhone(phone); await this.fillPassword(password); await this.clickLoginButton(); } /** * 验证登录成功 * * 登录成功后应该跳转到主页或显示用户信息 */ async expectLoginSuccess(): Promise { // 使用 auto-waiting 机制,等待 URL 变化或用户信息显示 // 小程序登录成功后会跳转到 dashboard 页面 // 等待 URL 变化,使用 Promise.race 实现超时 await this.page.waitForURL( url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages/yongren/dashboard'), { timeout: TIMEOUTS.PAGE_LOAD } ).catch(() => { // 如果没有跳转,检查是否显示用户信息 // 注意:此验证将在 Story 12.5 E2E 测试中完全实现 // 当前仅提供基础结构 }); } /** * 验证登录失败(错误提示显示) * @param expectedErrorMessage 预期的错误消息(可选) */ async expectLoginError(expectedErrorMessage?: string): Promise { // 等待一下,让后端响应或前端验证生效 await this.page.waitForTimeout(1000); // 验证仍然在登录页面(未跳转) const currentUrl = this.page.url(); expect(currentUrl).toContain('/mini'); // 验证登录页面容器仍然可见 await expect(this.loginPage).toBeVisible(); // 如果提供了预期的错误消息,尝试验证 if (expectedErrorMessage) { // 尝试查找错误消息(可能在 Toast、Modal 或表单验证中) const errorElement = this.page.getByText(expectedErrorMessage, { exact: false }).first(); await errorElement.isVisible().catch(() => false); // 不强制要求错误消息可见,因为后端可能不会返回错误 } } // ===== Token 管理方法 ===== /** * 获取当前存储的 token * @returns token 字符串,如果不存在则返回 null * * 注意:Taro.getStorageSync 在 H5 环境下映射到 localStorage * token 直接存储为字符串,不是 JSON 格式 * * Taro H5 可能使用以下键名格式: * - 直接键名: 'enterprise_token' * - 带前缀: 'taro_app_storage_key' * - 或者其他变体 */ async getToken(): Promise { const result = await this.page.evaluate(() => { // 获取所有 localStorage 键 const keys = Object.keys(localStorage); const storage: Record = {}; keys.forEach(k => storage[k] = localStorage.getItem(k) || ''); // 尝试各种可能的键名 // 1. 直接键名 const token = localStorage.getItem('enterprise_token'); if (token) return token; // 2. 带前缀的键名(Taro 可能使用前缀) const prefixedKeys = keys.filter(k => k.includes('token') || k.includes('auth')); for (const key of prefixedKeys) { const value = localStorage.getItem(key); if (value && value.length > 20) { // JWT token 通常很长 return value; } } // 3. 其他常见键名 return ( localStorage.getItem('token') || localStorage.getItem('auth_token') || sessionStorage.getItem('token') || sessionStorage.getItem('auth_token') || null ); }); return result; } /** * 设置 token(用于测试前置条件) * @param token token 字符串 */ async setToken(token: string): Promise { await this.page.evaluate((t) => { localStorage.setItem('token', t); localStorage.setItem('auth_token', t); }, token); } /** * 清除所有认证相关的存储 */ async clearAuth(): Promise { await this.page.evaluate(() => { // 清除企业小程序相关的认证数据 localStorage.removeItem('enterprise_token'); localStorage.removeItem('enterpriseUserInfo'); // 清除其他常见 token 键 localStorage.removeItem('token'); localStorage.removeItem('auth_token'); sessionStorage.removeItem('token'); sessionStorage.removeItem('auth_token'); }); } // ===== 主页元素验证方法 ===== /** * 验证主页元素可见(登录后) * 根据实际小程序主页结构调整 */ async expectHomePageVisible(): Promise { // 使用 auto-waiting 机制,等待主页元素可见 // 注意:此方法将在 Story 12.5 E2E 测试中使用,当前仅提供基础结构 // 根据实际小程序主页的 data-testid 调整 const dashboard = this.page.getByTestId('mini-dashboard'); await dashboard.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD }); } /** * 获取用户信息显示的文本 * @returns 用户信息文本 */ async getUserInfoText(): Promise { const userInfo = this.userInfo; const count = await userInfo.count(); if (count === 0) { return null; } return await userInfo.textContent(); } // ===== 退出登录方法 ===== /** * 退出登录 */ async logout(): Promise { // 点击退出登录按钮 await this.logoutButton.click(); // 等待页面加载完成 await this.page.waitForLoadState('domcontentloaded'); } /** * 验证已退出登录(返回登录页面) */ async expectLoggedOut(): Promise { // 验证返回到登录页面 await this.loginPage.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD }); } }