|
@@ -0,0 +1,300 @@
|
|
|
|
|
+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}/talent-mini`;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Token 存储键名(人才小程序专用)
|
|
|
|
|
+ */
|
|
|
|
|
+const TOKEN_KEY = 'talent_token';
|
|
|
|
|
+const _USER_KEY = 'talent_user';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 人才小程序 Page Object
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用于人才小程序 E2E 测试
|
|
|
|
|
+ * H5 页面路径: /talent-mini
|
|
|
|
|
+ *
|
|
|
|
|
+ * 主要功能:
|
|
|
|
|
+ * - 小程序登录(手机号/身份证号/残疾证号 + 密码)
|
|
|
|
|
+ * - Token 管理
|
|
|
|
|
+ * - 页面导航和验证
|
|
|
|
|
+ *
|
|
|
|
|
+ * @example
|
|
|
|
|
+ * ```typescript
|
|
|
|
|
+ * const talentMiniPage = new TalentMiniPage(page);
|
|
|
|
|
+ * await talentMiniPage.goto();
|
|
|
|
|
+ * await talentMiniPage.login('13800138000', 'password123');
|
|
|
|
|
+ * await talentMiniPage.expectLoginSuccess();
|
|
|
|
|
+ * ```
|
|
|
|
|
+ */
|
|
|
|
|
+export class TalentMiniPage {
|
|
|
|
|
+ readonly page: Page;
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 页面级选择器 =====
|
|
|
|
|
+ /** 登录页面容器 */
|
|
|
|
|
+ readonly loginPage: Locator;
|
|
|
|
|
+ /** 页面标题 */
|
|
|
|
|
+ readonly pageTitle: Locator;
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 登录表单选择器 =====
|
|
|
|
|
+ /** 身份标识输入框(手机号/身份证号/残疾证号) */
|
|
|
|
|
+ readonly identifierInput: Locator;
|
|
|
|
|
+ /** 密码输入框 */
|
|
|
|
|
+ readonly passwordInput: Locator;
|
|
|
|
|
+ /** 登录按钮 */
|
|
|
|
|
+ readonly loginButton: Locator;
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 主页选择器(登录后,待主页实现后添加) =====
|
|
|
|
|
+ /** 用户信息显示区域 */
|
|
|
|
|
+ readonly userInfo: Locator;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(page: Page) {
|
|
|
|
|
+ this.page = page;
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化登录页面选择器
|
|
|
|
|
+ // 使用 data-testid(任务 8 已添加)
|
|
|
|
|
+ this.loginPage = page.getByTestId('talent-login-page');
|
|
|
|
|
+ this.pageTitle = page.getByTestId('talent-page-title');
|
|
|
|
|
+
|
|
|
|
|
+ // 登录表单选择器 - 使用 data-testid
|
|
|
|
|
+ this.identifierInput = page.getByTestId('talent-identifier-input');
|
|
|
|
|
+ this.passwordInput = page.getByTestId('talent-password-input');
|
|
|
|
|
+ this.loginButton = page.getByTestId('talent-login-button');
|
|
|
|
|
+
|
|
|
|
|
+ // 主页选择器(登录后可用,待主页实现后添加对应的 testid)
|
|
|
|
|
+ this.userInfo = page.getByTestId('talent-user-info');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 导航和基础验证 =====
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 移除开发服务器的覆盖层 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.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
|
|
|
|
|
+ // 验证登录表单元素可见
|
|
|
|
|
+ await expect(this.identifierInput).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
|
|
|
|
|
+ await expect(this.passwordInput).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
|
|
|
|
|
+ await expect(this.loginButton).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 登录功能方法 =====
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 填写身份标识(手机号/身份证号/残疾证号)
|
|
|
|
|
+ * @param identifier 身份标识(11位手机号或身份证号或残疾证号)
|
|
|
|
|
+ */
|
|
|
|
|
+ async fillIdentifier(identifier: string): Promise<void> {
|
|
|
|
|
+ // Taro 的 Input 组件使用自定义元素,使用 JavaScript 直接设置值
|
|
|
|
|
+ // 不使用 click() 避免被开发服务器的覆盖层阻止
|
|
|
|
|
+ await this.identifierInput.evaluate((el: HTMLInputElement, value) => {
|
|
|
|
|
+ el.value = value;
|
|
|
|
|
+ el.focus();
|
|
|
|
|
+ el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
+ el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
+ el.blur();
|
|
|
|
|
+ }, identifier);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 填写密码
|
|
|
|
|
+ * @param password 密码(6-20位)
|
|
|
|
|
+ */
|
|
|
|
|
+ async fillPassword(password: string): Promise<void> {
|
|
|
|
|
+ // Taro 的 Input 组件使用自定义元素,使用 JavaScript 直接设置值
|
|
|
|
|
+ // 不使用 click() 避免被开发服务器的覆盖层阻止
|
|
|
|
|
+ await this.passwordInput.evaluate((el: HTMLInputElement, value) => {
|
|
|
|
|
+ el.value = value;
|
|
|
|
|
+ el.focus();
|
|
|
|
|
+ el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
+ el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
|
+ el.blur();
|
|
|
|
|
+ }, password);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 点击登录按钮
|
|
|
|
|
+ */
|
|
|
|
|
+ async clickLoginButton(): Promise<void> {
|
|
|
|
|
+ // 使用 force: true 避免被开发服务器的覆盖层阻止
|
|
|
|
|
+ await this.loginButton.click({ force: true });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 执行登录操作(完整流程)
|
|
|
|
|
+ * @param identifier 身份标识(手机号/身份证号/残疾证号)
|
|
|
|
|
+ * @param password 密码
|
|
|
|
|
+ */
|
|
|
|
|
+ async login(identifier: string, password: string): Promise<void> {
|
|
|
|
|
+ await this.fillIdentifier(identifier);
|
|
|
|
|
+ await this.fillPassword(password);
|
|
|
|
|
+ await this.clickLoginButton();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 验证登录成功
|
|
|
|
|
+ *
|
|
|
|
|
+ * 登录成功后应该跳转到主页或显示用户信息
|
|
|
|
|
+ */
|
|
|
|
|
+ async expectLoginSuccess(): Promise<void> {
|
|
|
|
|
+ // 使用 auto-waiting 机制,等待 URL 变化或用户信息显示
|
|
|
|
|
+ // 小程序登录成功后会跳转到首页
|
|
|
|
|
+
|
|
|
|
|
+ // 等待 URL 变化,使用 Promise.race 实现超时
|
|
|
|
|
+ await this.page.waitForURL(
|
|
|
|
|
+ url => url.pathname.includes('/pages/index/index') || url.pathname.includes('/talent-mini'),
|
|
|
|
|
+ { timeout: TIMEOUTS.PAGE_LOAD }
|
|
|
|
|
+ ).catch(() => {
|
|
|
|
|
+ // 如果没有跳转,检查是否显示用户信息
|
|
|
|
|
+ // 注意:此验证将在 Story 12.7 E2E 测试中完全实现
|
|
|
|
|
+ // 当前仅提供基础结构
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 验证登录失败(错误提示显示)
|
|
|
|
|
+ * @param expectedErrorMessage 预期的错误消息(可选)
|
|
|
|
|
+ */
|
|
|
|
|
+ async expectLoginError(expectedErrorMessage?: string): Promise<void> {
|
|
|
|
|
+ // 等待一下,让后端响应或前端验证生效
|
|
|
|
|
+ await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
|
|
|
|
|
+
|
|
|
|
|
+ // 验证仍然在登录页面(未跳转)
|
|
|
|
|
+ const currentUrl = this.page.url();
|
|
|
|
|
+ expect(currentUrl).toContain('/talent-mini');
|
|
|
|
|
+
|
|
|
|
|
+ // 验证登录页面元素仍然可见
|
|
|
|
|
+ await expect(this.identifierInput).toBeVisible();
|
|
|
|
|
+ await expect(this.passwordInput).toBeVisible();
|
|
|
|
|
+
|
|
|
|
|
+ // 如果提供了预期的错误消息,尝试验证
|
|
|
|
|
+ if (expectedErrorMessage) {
|
|
|
|
|
+ // 尝试查找错误消息(可能在 Toast、Modal 或表单验证中)
|
|
|
|
|
+ const errorElement = this.page.getByText(expectedErrorMessage, { exact: false }).first();
|
|
|
|
|
+ await errorElement.isVisible().catch(() => false);
|
|
|
|
|
+ // 不强制要求错误消息可见,因为后端可能不会返回错误
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== Token 管理方法 =====
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取当前存储的 token
|
|
|
|
|
+ * @returns token 字符串,如果不存在则返回 null
|
|
|
|
|
+ */
|
|
|
|
|
+ async getToken(): Promise<string | null> {
|
|
|
|
|
+ const result = await this.page.evaluate(() => {
|
|
|
|
|
+ // 从 talent_token 获取(人才小程序专用)
|
|
|
|
|
+ const talentToken = localStorage.getItem('talent_token');
|
|
|
|
|
+ if (talentToken) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(talentToken);
|
|
|
|
|
+ return parsed.data || talentToken;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // 如果解析失败,直接返回原字符串
|
|
|
|
|
+ return talentToken;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试其他常见 token 键
|
|
|
|
|
+ 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<void> {
|
|
|
|
|
+ await this.page.evaluate((t) => {
|
|
|
|
|
+ localStorage.setItem(TOKEN_KEY, t);
|
|
|
|
|
+ localStorage.setItem('token', t);
|
|
|
|
|
+ localStorage.setItem('auth_token', t);
|
|
|
|
|
+ }, token);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 清除所有认证相关的存储
|
|
|
|
|
+ */
|
|
|
|
|
+ async clearAuth(): Promise<void> {
|
|
|
|
|
+ await this.page.evaluate(() => {
|
|
|
|
|
+ // 清除人才小程序相关的认证数据
|
|
|
|
|
+ localStorage.removeItem('talent_token');
|
|
|
|
|
+ localStorage.removeItem('talent_user');
|
|
|
|
|
+
|
|
|
|
|
+ // 清除其他常见 token 键
|
|
|
|
|
+ localStorage.removeItem('token');
|
|
|
|
|
+ localStorage.removeItem('auth_token');
|
|
|
|
|
+ sessionStorage.removeItem('token');
|
|
|
|
|
+ sessionStorage.removeItem('auth_token');
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ===== 主页元素验证方法 =====
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 验证主页元素可见(登录后)
|
|
|
|
|
+ * 根据实际小程序主页结构调整
|
|
|
|
|
+ */
|
|
|
|
|
+ async expectHomePageVisible(): Promise<void> {
|
|
|
|
|
+ // 使用 auto-waiting 机制,等待主页元素可见
|
|
|
|
|
+ // 注意:此方法将在 Story 12.7 E2E 测试中使用,当前仅提供基础结构
|
|
|
|
|
+ // 根据实际小程序主页的 data-testid 调整
|
|
|
|
|
+ const dashboard = this.page.getByTestId('talent-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();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|