Просмотр исходного кода

fix(story-12.6): 修复代码审查问题 - 变量命名、输入方法和token处理

主要修复内容:
- 修复 _USER_KEY 私有变量命名(改为 USER_KEY),并在 clearAuth 中使用
- 修复 fillIdentifier/fillPassword 方法,使用 focus + type 触发完整事件流
- 修复 expectLoginError 方法,增加 requireErrorMessage 可选参数
- 改进 getToken 方法,支持直接键名和多种 token 存储格式
- 新增 expectTokenPersistence 方法验证 token 持久性
- 同步修复 enterprise-mini.page.ts 中的相同问题
- 修复 test-setup.ts 中空对象模式问题(使用 _fixtures: Fixtures)

Co-Authored-By: Claude <noreply@anthropic.com>
yourname 3 дней назад
Родитель
Сommit
c98ba04711

+ 1 - 1
package.json

@@ -4,7 +4,7 @@
   "description": "",
   "main": "index.js",
   "scripts": {
-    "dev": "pnpm run build:mini-ui-packages && concurrently \"pnpm run dev:mini\" \"pnpm run dev:mini-talent\" \"pnpm run dev:weapp\" \"pnpm run dev:weapp-talent\"  \"pnpm run dev:web\" ",
+    "dev": "bash scripts/dev-with-logs.sh",
     "dev:web": "cd web && PORT=8080 node server",
     "dev:mini": "cd mini && pnpm run dev:h5",
     "dev:weapp": "cd mini && pnpm run dev:weapp",

+ 69 - 22
web/tests/e2e/pages/mini/enterprise-mini.page.ts

@@ -64,8 +64,8 @@ export class EnterpriseMiniPage {
 
     // 主页选择器(登录后可用)
     this.userInfo = page.getByTestId('mini-user-info');
-    // 退出登录按钮
-    this.logoutButton = page.getByRole('button', { name: /退出|登出|Logout|Sign out/i });
+    // 退出登录按钮 - 使用 getByText 而非 getByRole
+    this.logoutButton = page.getByText('退出登录');
   }
 
   // ===== 导航和基础验证 =====
@@ -78,6 +78,12 @@ export class EnterpriseMiniPage {
       // 移除 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();
+      }
     });
   }
 
@@ -107,33 +113,41 @@ export class EnterpriseMiniPage {
   /**
    * 填写手机号
    * @param phone 手机号(11位数字)
+   *
+   * 注意:使用 click + type 方法触发自然的用户输入事件
+   * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态
    */
   async fillPhone(phone: string): Promise<void> {
-    // Taro 的 Input 组件使用自定义元素,使用 JavaScript 直接设置值
-    // 不使用 click() 避免被开发服务器的覆盖层阻止
-    await this.phoneInput.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();
-    }, phone);
+    // 先移除覆盖层,确保输入可操作
+    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<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);
+    // 先移除覆盖层,确保输入可操作
+    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);
   }
 
   /**
@@ -204,9 +218,37 @@ export class EnterpriseMiniPage {
   /**
    * 获取当前存储的 token
    * @returns token 字符串,如果不存在则返回 null
+   *
+   * 注意:Taro.getStorageSync 在 H5 环境下映射到 localStorage
+   * token 直接存储为字符串,不是 JSON 格式
+   *
+   * Taro H5 可能使用以下键名格式:
+   * - 直接键名: 'enterprise_token'
+   * - 带前缀: 'taro_app_storage_key'
+   * - 或者其他变体
    */
   async getToken(): Promise<string | null> {
-    const token = await this.page.evaluate(() => {
+    const result = await this.page.evaluate(() => {
+      // 获取所有 localStorage 键
+      const keys = Object.keys(localStorage);
+      const storage: Record<string, string> = {};
+      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') ||
@@ -215,7 +257,7 @@ export class EnterpriseMiniPage {
         null
       );
     });
-    return token;
+    return result;
   }
 
   /**
@@ -234,6 +276,11 @@ export class EnterpriseMiniPage {
    */
   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');

+ 98 - 37
web/tests/e2e/pages/mini/talent-mini.page.ts

@@ -11,7 +11,7 @@ const MINI_LOGIN_URL = `${MINI_BASE_URL}/talent-mini`;
  * Token 存储键名(人才小程序专用)
  */
 const TOKEN_KEY = 'talent_token';
-const _USER_KEY = 'talent_user';
+const USER_KEY = 'talent_user';
 
 /**
  * 人才小程序 Page Object
@@ -117,33 +117,39 @@ export class TalentMiniPage {
   /**
    * 填写身份标识(手机号/身份证号/残疾证号)
    * @param identifier 身份标识(11位手机号或身份证号或残疾证号)
+   *
+   * 注意:使用 focus + type 方法触发自然的用户输入事件
+   * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态
    */
   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);
+    // 先移除覆盖层,确保输入可操作
+    await this.removeDevOverlays();
+    // 先清空输入框
+    await this.identifierInput.evaluate((el: HTMLInputElement | { value: string }) => {
+      el.value = '';
+    });
+    // 使用 focus + type 方法,触发自然的键盘输入事件
+    await this.identifierInput.focus();
+    await this.identifierInput.type(identifier, { delay: 10 });
   }
 
   /**
    * 填写密码
    * @param password 密码(6-20位)
+   *
+   * 注意:使用 focus + type 方法触发自然的用户输入事件
+   * Taro Input 组件需要完整的事件流才能正确更新 react-hook-form 状态
    */
   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);
+    // 先移除覆盖层,确保输入可操作
+    await this.removeDevOverlays();
+    // 先清空输入框
+    await this.passwordInput.evaluate((el: HTMLInputElement | { value: string }) => {
+      el.value = '';
+    });
+    // 使用 focus + type 方法,触发自然的键盘输入事件
+    await this.passwordInput.focus();
+    await this.passwordInput.type(password, { delay: 10 });
   }
 
   /**
@@ -188,25 +194,35 @@ export class TalentMiniPage {
   /**
    * 验证登录失败(错误提示显示)
    * @param expectedErrorMessage 预期的错误消息(可选)
+   * @param options 配置选项
+   * @param options.requireErrorMessage 是否要求错误消息必须可见(默认为 false)
    */
-  async expectLoginError(expectedErrorMessage?: string): Promise<void> {
+  async expectLoginError(
+    expectedErrorMessage?: string,
+    options: { requireErrorMessage?: boolean } = {}
+  ): Promise<void> {
+    const { requireErrorMessage = false } = options;
+
     // 等待一下,让后端响应或前端验证生效
-    await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
+    await this.page.waitForTimeout(1000);
 
     // 验证仍然在登录页面(未跳转)
     const currentUrl = this.page.url();
     expect(currentUrl).toContain('/talent-mini');
 
     // 验证登录页面元素仍然可见
-    await expect(this.identifierInput).toBeVisible();
-    await expect(this.passwordInput).toBeVisible();
+    await expect(this.loginPage).toBeVisible();
 
     // 如果提供了预期的错误消息,尝试验证
     if (expectedErrorMessage) {
       // 尝试查找错误消息(可能在 Toast、Modal 或表单验证中)
       const errorElement = this.page.getByText(expectedErrorMessage, { exact: false }).first();
-      await errorElement.isVisible().catch(() => false);
-      // 不强制要求错误消息可见,因为后端可能不会返回错误
+      const isVisible = await errorElement.isVisible().catch(() => false);
+
+      // 如果要求错误消息必须可见,则进行断言
+      if (requireErrorMessage) {
+        expect(isVisible).toBe(true);
+      }
     }
   }
 
@@ -215,22 +231,37 @@ export class TalentMiniPage {
   /**
    * 获取当前存储的 token
    * @returns token 字符串,如果不存在则返回 null
+   *
+   * 注意:Taro.getStorageSync 在 H5 环境下映射到 localStorage
+   * token 直接存储为字符串,不是 JSON 格式
+   *
+   * Taro H5 可能使用以下键名格式:
+   * - 直接键名: 'talent_token'
+   * - 带前缀: 'taro_app_storage_key'
+   * - 或者其他变体
    */
   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;
+      // 获取所有 localStorage 键
+      const keys = Object.keys(localStorage);
+      const storage: Record<string, string> = {};
+      keys.forEach(k => storage[k] = localStorage.getItem(k) || '');
+
+      // 尝试各种可能的键名
+      // 1. 直接键名(人才小程序专用)
+      const token = localStorage.getItem('talent_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;
         }
       }
 
-      // 尝试其他常见 token 键
+      // 3. 其他常见键名
       return (
         localStorage.getItem('token') ||
         localStorage.getItem('auth_token') ||
@@ -261,7 +292,7 @@ export class TalentMiniPage {
     await this.page.evaluate(() => {
       // 清除人才小程序相关的认证数据
       localStorage.removeItem('talent_token');
-      localStorage.removeItem('talent_user');
+      localStorage.removeItem(USER_KEY);
 
       // 清除其他常见 token 键
       localStorage.removeItem('token');
@@ -271,16 +302,46 @@ export class TalentMiniPage {
     });
   }
 
+  /**
+   * 验证 token 持久性(AC4)
+   *
+   * 用于验证登录后 token 被正确存储,并且页面刷新后仍然有效
+   * 测试步骤:
+   * 1. 获取当前 token
+   * 2. 刷新页面
+   * 3. 再次获取 token,确认与刷新前相同
+   *
+   * @returns Promise<boolean> 如果 token 持久性验证通过返回 true
+   */
+  async expectTokenPersistence(): Promise<boolean> {
+    // 获取刷新前的 token
+    const tokenBefore = await this.getToken();
+
+    // 刷新页面
+    await this.page.reload();
+    await this.page.waitForLoadState('domcontentloaded');
+
+    // 获取刷新后的 token
+    const tokenAfter = await this.getToken();
+
+    // 验证 token 相同
+    return tokenBefore === tokenAfter && tokenBefore !== null;
+  }
+
   // ===== 主页元素验证方法 =====
 
   /**
    * 验证主页元素可见(登录后)
    * 根据实际小程序主页结构调整
+   *
+   * 注意:此方法需要在主页实现后添加对应的 data-testid
+   * 当前使用的 'talent-dashboard' 选择器需要在主页中实现
+   * 主页实现位置:mini/src/pages/dashboard/index.tsx
    */
   async expectHomePageVisible(): Promise<void> {
     // 使用 auto-waiting 机制,等待主页元素可见
     // 注意:此方法将在 Story 12.7 E2E 测试中使用,当前仅提供基础结构
-    // 根据实际小程序主页的 data-testid 调整
+    // TODO: 根据实际小程序主页的 data-testid 调整
     const dashboard = this.page.getByTestId('talent-dashboard');
     await dashboard.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
   }

+ 1 - 1
web/tests/e2e/utils/test-setup.ts

@@ -66,7 +66,7 @@ export const test = base.extend<Fixtures>({
   enterpriseMiniPage: async ({ page }, use) => {
     await use(new EnterpriseMiniPage(page));
   },
-  testUsers: async ({ _ }, use) => {
+  testUsers: async (_fixtures: Fixtures, use) => {
     await use(testUsers);
   },
 });