| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- 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');
- // 设置按钮
- this.settingsButton = page.getByText('设置').nth(1);
- // 退出登录按钮 - 使用 getByText 而非 getByRole
- this.logoutButton = page.getByText('退出登录');
- }
- // ===== 导航和基础验证 =====
- /**
- * 移除开发服务器的覆盖层 iframe(防止干扰测试)
- */
- private async removeDevOverlays(): Promise<void> {
- 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<void> {
- await this.page.goto(MINI_LOGIN_URL);
- // 移除开发服务器的覆盖层
- await this.removeDevOverlays();
- // 使用 auto-waiting 机制,等待页面容器可见
- await this.expectToBeVisible();
- }
- /**
- * 验证登录页面关键元素可见
- */
- async expectToBeVisible(): Promise<void> {
- // 等待页面容器可见
- await this.loginPage.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
- // 验证页面标题
- await this.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
- }
- // ===== 登录功能方法 =====
- /**
- * 填写手机号
- * @param phone 手机号(11位数字)
- *
- * 注意:使用 fill() 方法并添加验证步骤确保密码输入完整
- * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态
- */
- async fillPhone(phone: string): Promise<void> {
- // 先移除覆盖层,确保输入可操作
- 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位)
- *
- * 注意:taro-input-core 是 Taro 框架的自定义组件,不是标准 HTML 元素
- * 需要使用 evaluate() 直接操作 DOM 元素来设置值和触发事件
- */
- async fillPassword(password: string): Promise<void> {
- await this.removeDevOverlays();
- await this.passwordInput.click();
- await this.page.waitForTimeout(100);
-
- // taro-input-core 不是标准 input 元素,使用 JS 直接设置值并触发事件
- await this.passwordInput.evaluate((el, val) => {
- // 尝试找到内部的真实 input 元素
- const nativeInput = el.querySelector('input') || el;
- if (nativeInput instanceof HTMLInputElement) {
- nativeInput.value = val;
- nativeInput.dispatchEvent(new Event('input', { bubbles: true }));
- nativeInput.dispatchEvent(new Event('change', { bubbles: true }));
- } else {
- // 如果找不到 input 元素,设置 value 属性
- (el as any).value = val;
- el.dispatchEvent(new Event('input', { bubbles: true }));
- el.dispatchEvent(new Event('change', { bubbles: true }));
- }
- }, password);
-
- await this.page.waitForTimeout(300);
- }
- /**
- * 点击登录按钮
- */
- async clickLoginButton(): Promise<void> {
- // 使用 force: true 避免被开发服务器的覆盖层阻止
- await this.loginButton.click({ force: true });
- }
- /**
- * 执行登录操作(完整流程)
- * @param phone 手机号
- * @param password 密码
- */
- async login(phone: string, password: string): Promise<void> {
- await this.fillPhone(phone);
- await this.fillPassword(password);
- await this.clickLoginButton();
- }
- /**
- * 验证登录成功
- *
- * 登录成功后应该跳转到主页或显示用户信息
- */
- async expectLoginSuccess(): Promise<void> {
- // 使用 auto-waiting 机制,等待 URL 变化或用户信息显示
- // 小程序登录成功后会跳转到 dashboard 页面
- // 等待 URL 变化,使用 Promise.race 实现超时
- const urlChanged = await this.page.waitForURL(
- url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages/yongren/dashboard'),
- { timeout: TIMEOUTS.PAGE_LOAD }
- ).then(() => true).catch(() => false);
- // 如果 URL 没有变化,检查 token 是否被存储
- if (!urlChanged) {
- const token = await this.getToken();
- if (!token) {
- throw new Error('登录失败:URL 未跳转且 token 未存储');
- }
- }
- }
- /**
- * 验证登录失败(错误提示显示)
- * @param expectedErrorMessage 预期的错误消息(可选)
- */
- async expectLoginError(expectedErrorMessage?: string): Promise<void> {
- // 等待一下,让后端响应或前端验证生效
- 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
- * Taro.setStorageSync 会将数据包装为 JSON 格式:{"data":"VALUE"}
- * 因此需要解析 JSON 并提取 data 字段
- *
- * Taro H5 可能使用以下键名格式:
- * - 直接键名: 'enterprise_token'
- * - 带前缀: 'taro_app_storage_key'
- * - 或者其他变体
- */
- async getToken(): Promise<string | null> {
- const result = await this.page.evaluate(() => {
- // 尝试各种可能的键名
- // 1. 直接键名 - Taro 的 setStorageSync 将数据包装为 {"data":"VALUE"}
- const token = localStorage.getItem('enterprise_token');
- if (token) {
- try {
- // Taro 格式: {"data":"JWT_TOKEN"}
- const parsed = JSON.parse(token);
- if (parsed.data) {
- return parsed.data;
- }
- return token;
- } catch {
- return token;
- }
- }
- // 2. 获取所有 localStorage 键,查找可能的 token
- const keys = Object.keys(localStorage);
- const prefixedKeys = keys.filter(k => k.includes('token') || k.includes('auth'));
- for (const key of prefixedKeys) {
- const value = localStorage.getItem(key);
- if (value) {
- try {
- // 尝试解析 Taro 格式
- const parsed = JSON.parse(value);
- if (parsed.data && parsed.data.length > 20) { // JWT token 通常很长
- return parsed.data;
- }
- } catch {
- // 不是 JSON 格式,直接使用
- if (value.length > 20) {
- return value;
- }
- }
- }
- }
- // 3. 其他常见键名
- const otherTokens = [
- localStorage.getItem('token'),
- localStorage.getItem('auth_token'),
- sessionStorage.getItem('token'),
- sessionStorage.getItem('auth_token')
- ].filter(Boolean);
- for (const t of otherTokens) {
- if (t) {
- try {
- const parsed = JSON.parse(t);
- if (parsed.data) return parsed.data;
- } catch {
- if (t.length > 20) return t;
- }
- }
- }
- return null;
- });
- return result;
- }
- /**
- * 设置 token(用于测试前置条件)
- * @param token token 字符串
- */
- async setToken(token: string): Promise<void> {
- await this.page.evaluate((t) => {
- localStorage.setItem('token', t);
- localStorage.setItem('auth_token', t);
- }, token);
- }
- /**
- * 清除所有认证相关的存储
- */
- async clearAuth(): Promise<void> {
- 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<void> {
- // 使用 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<string | null> {
- const userInfo = this.userInfo;
- const count = await userInfo.count();
- if (count === 0) {
- return null;
- }
- return await userInfo.textContent();
- }
- // ===== 退出登录方法 =====
- /**
- * 退出登录
- *
- * 注意:企业小程序的退出登录按钮在设置页面中,需要先点击设置按钮
- */
- async logout(): Promise<void> {
- // 先点击设置按钮进入设置页面
- await this.settingsButton.click();
- await this.page.waitForTimeout(500);
-
- // 滚动到页面底部,确保退出登录按钮可见
- await this.page.evaluate(() => {
- window.scrollTo(0, document.body.scrollHeight);
- });
- await this.page.waitForTimeout(300);
-
- // 点击退出登录按钮(使用 JS 直接点击来绕过 Taro 组件的事件处理)
- await this.logoutButton.evaluate((el) => {
- // 查找包含该文本的可点击元素
- const button = el.closest('button') || el.closest('[role="button"]') || el;
- (button as HTMLElement).click();
- });
-
- // 等待确认对话框出现
- await this.page.waitForTimeout(1500);
-
- // 处理确认对话框 - Taro.showModal 会显示一个确认对话框
- // 使用更具体的选择器,因为对话框有两个按钮(取消/确定)
- const _confirmButton = this.page.locator('.taro-modal__footer').getByText('确定').first();
- // 尝试使用 JS 直接点击确定按钮
- const dialogClicked = await this.page.evaluate(() => {
- // 查找所有"确定"文本的元素
- const buttons = Array.from(document.querySelectorAll('*'));
- const confirmBtn = buttons.find(el => el.textContent === '确定' && el.textContent?.trim() === '确定');
- if (confirmBtn) {
- (confirmBtn as HTMLElement).click();
- return true;
- }
- return false;
- });
-
- if (!dialogClicked) {
- // 如果 JS 点击失败,尝试使用 Playwright 点击
- await this.page.getByText('确定').click({ force: true });
- }
-
- // 等待退出登录完成并跳转到登录页面
- await this.page.waitForTimeout(3000);
- }
- /**
- * 验证已退出登录(返回登录页面)
- */
- async expectLoggedOut(): Promise<void> {
- // 验证返回到登录页面
- await this.loginPage.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
- }
- }
|