Ver código fonte

fix(story-12.5): 解决 page 实例共享问题并实现部分跳过的测试

- 添加 browser fixture 支持独立 context 创建测试用户
- 使用 browser.newContext() 避免 adminLoginPage 和 enterpriseMiniPage 冲突
- 实现 AC1 基本登录成功测试 (2/3 通过)
- 新增 createTestUser() 辅助函数串行创建测试用户
- 修复 Taro Input 组件值设置(使用 JS 直接赋值)
- 添加 logout() 和 expectLoggedOut() 方法

剩余问题:
- Token 存储测试 (2 个): Token 为 null,需检查小程序实际存储方式
- 退出登录测试 (1 个): 找不到退出登录按钮,需调整选择器

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 4 dias atrás
pai
commit
ec5e3a5253

+ 1 - 1
_bmad-output/implementation-artifacts/12-4-enterprise-mini-page-object.md

@@ -1,6 +1,6 @@
 # Story 12.4: 企业小程序 Page Object
 
-Status: review
+Status: done
 
 <!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
 

+ 57 - 11
web/tests/e2e/pages/mini/enterprise-mini.page.ts

@@ -46,6 +46,8 @@ export class EnterpriseMiniPage {
   // ===== 主页选择器(登录后) =====
   /** 用户信息显示区域 */
   readonly userInfo: Locator;
+  /** 退出登录按钮 */
+  readonly logoutButton: Locator;
 
   constructor(page: Page) {
     this.page = page;
@@ -62,15 +64,30 @@ export class EnterpriseMiniPage {
 
     // 主页选择器(登录后可用)
     this.userInfo = page.getByTestId('mini-user-info');
+    // 退出登录按钮
+    this.logoutButton = page.getByRole('button', { name: /退出|登出|Logout|Sign out/i });
   }
 
   // ===== 导航和基础验证 =====
 
+  /**
+   * 移除开发服务器的覆盖层 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());
+    });
+  }
+
   /**
    * 导航到企业小程序 H5 登录页面
    */
   async goto(): Promise<void> {
     await this.page.goto(MINI_LOGIN_URL);
+    // 移除开发服务器的覆盖层
+    await this.removeDevOverlays();
     // 使用 auto-waiting 机制,等待页面容器可见
     await this.expectToBeVisible();
   }
@@ -92,11 +109,15 @@ export class EnterpriseMiniPage {
    * @param phone 手机号(11位数字)
    */
   async fillPhone(phone: string): Promise<void> {
-    // Taro 的 Input 组件使用自定义元素,使用 type() 代替 fill()
-    await this.phoneInput.click();
-    // 先全选已有内容(如果有),然后输入新内容
-    await this.page.keyboard.press('Control+A');
-    await this.phoneInput.type(phone, { delay: 10 });
+    // 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);
   }
 
   /**
@@ -104,18 +125,23 @@ export class EnterpriseMiniPage {
    * @param password 密码(6-20位)
    */
   async fillPassword(password: string): Promise<void> {
-    // Taro 的 Input 组件使用自定义元素,使用 type() 代替 fill()
-    await this.passwordInput.click();
-    // 先全选已有内容(如果有),然后输入新内容
-    await this.page.keyboard.press('Control+A');
-    await this.passwordInput.type(password, { delay: 10 });
+    // 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> {
-    await this.loginButton.click();
+    // 使用 force: true 避免被开发服务器的覆盖层阻止
+    await this.loginButton.click({ force: true });
   }
 
   /**
@@ -241,4 +267,24 @@ export class EnterpriseMiniPage {
     }
     return await userInfo.textContent();
   }
+
+  // ===== 退出登录方法 =====
+
+  /**
+   * 退出登录
+   */
+  async logout(): Promise<void> {
+    // 点击退出登录按钮
+    await this.logoutButton.click();
+    // 等待页面加载完成
+    await this.page.waitForLoadState('domcontentloaded');
+  }
+
+  /**
+   * 验证已退出登录(返回登录页面)
+   */
+  async expectLoggedOut(): Promise<void> {
+    // 验证返回到登录页面
+    await this.loginPage.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
+  }
 }

+ 293 - 30
web/tests/e2e/specs/mini/enterprise-mini-login.spec.ts

@@ -1,4 +1,7 @@
 import { test, expect } from '../../utils/test-setup';
+import { AdminLoginPage } from '../../pages/admin/login.page';
+import { UserManagementPage } from '../../pages/admin/user-management.page';
+import { UserType } from '@d8d/shared-types';
 
 /**
  * 企业小程序登录 E2E 测试
@@ -6,14 +9,70 @@ import { test, expect } from '../../utils/test-setup';
  * 测试企业用户通过小程序登录的功能,验证:
  * - 登录失败场景
  * - 表单验证
- *
- * 注意:
- * - 登录成功、Token 持久性、退出登录等测试需要预创建测试用户
- * - 这些测试需要在独立测试套件中运行,使用专用的 page context
- * - 当前测试文件专注于不需要用户创建的测试场景
+ * - 登录成功场景(使用独立 context 创建测试用户)
+ * - Token 持久性
+ * - 退出登录
  *
  * @see {@link ../pages/mini/enterprise-mini.page.ts} EnterpriseMiniPage
  */
+
+/**
+ * 创建测试用户的辅助函数
+ *
+ * 在独立的 browser context 中通过管理后台创建测试用户,
+ * 避免与小程序登录测试共享 page 实例导致的冲突。
+ *
+ * @param browser Playwright browser 实例
+ * @param userData 用户数据
+ * @returns 创建的用户名和密码
+ */
+async function createTestUser(browser: typeof test['fixtures']['browser'], userData: {
+  username: string;
+  password: string;
+  nickname?: string;
+  phone?: string;
+  email?: string;
+  userType?: UserType;
+  companyName?: string;
+}): Promise<{ username: string; password: string }> {
+  // 创建独立的 browser context
+  const adminContext = await browser.newContext();
+
+  try {
+    // 在独立 context 中创建 page
+    const adminPage = await adminContext.newPage();
+
+    // 创建管理后台 Page Objects
+    const adminLoginPage = new AdminLoginPage(adminPage);
+    const userManagementPage = new UserManagementPage(adminPage);
+
+    // 登录管理后台
+    await adminLoginPage.goto();
+    await adminLoginPage.login('admin', 'admin123');
+
+    // 导航到用户管理页面
+    await userManagementPage.goto();
+
+    // 创建测试用户(使用 EMPLOYER 类型,企业用户)
+    const result = await userManagementPage.createUser({
+      username: userData.username,
+      password: userData.password,
+      nickname: userData.nickname || '测试企业用户',
+      phone: userData.phone,
+      email: userData.email,
+      userType: userData.userType || UserType.EMPLOYER,
+    }, userData.companyName || '默认公司');
+
+    // 验证创建成功
+    expect(result.success).toBe(true);
+
+    return { username: userData.username, password: userData.password };
+  } finally {
+    // 清理:关闭独立 context
+    await adminContext.close();
+  }
+}
+
 test.describe('企业小程序登录功能', () => {
   test.describe('表单验证测试 (AC3)', () => {
     test('手机号为空时应该显示错误提示', async ({ enterpriseMiniPage }) => {
@@ -108,48 +167,252 @@ test.describe('企业小程序登录功能', () => {
     });
   });
 
-  test.describe.skip('基本登录成功测试 (AC1) - 需要预创建测试用户', () => {
-    // 这些测试需要预创建的测试用户
-    // 应该在独立的测试套件中运行,使用专用的 page context
-    test('应该成功登录企业小程序', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
-      // 1. 在独立测试套件中创建测试用户
+  test.describe.serial('基本登录成功测试 (AC1)', () => {
+    test('应该成功登录企业小程序', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户(使用独立 context)
+      const testUsername = `mini_test_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `138${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序测试用户',
+        userType: UserType.EMPLOYER,
+      });
+
       // 2. 使用该用户登录小程序
-      // 3. 验证登录成功
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+
+      // 3. 验证登录成功(URL 跳转到 dashboard 或显示用户信息)
+      await enterpriseMiniPage.expectLoginSuccess();
     });
 
-    test('登录成功后应该显示用户信息', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
+    test('登录成功后应该显示用户信息', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户
+      const testUsername = `mini_info_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `139${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序信息测试',
+        userType: UserType.EMPLOYER,
+      });
+
+      // 2. 登录小程序
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+
+      // 3. 验证登录成功
+      await enterpriseMiniPage.expectLoginSuccess();
+
+      // 4. 验证用户信息显示(检查 userInfo 元素或 dashboard 可见)
+      const currentUrl = enterpriseMiniPage.page.url();
+      expect(currentUrl).toMatch(/dashboard|pages/);
     });
 
-    test('登录成功后 token 应该正确存储', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
+    test('登录成功后 token 应该正确存储', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户
+      const testUsername = `mini_token_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `137${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序 Token 测试',
+        userType: UserType.EMPLOYER,
+      });
+
+      // 2. 登录小程序
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+
+      // 3. 验证登录成功
+      await enterpriseMiniPage.expectLoginSuccess();
+
+      // 4. 验证 token 被正确存储
+      const token = await enterpriseMiniPage.getToken();
+      expect(token).not.toBeNull();
+      expect(token?.length).toBeGreaterThan(0);
     });
   });
 
-  test.describe.skip('Token 持久性测试 (AC4) - 需要预创建测试用户', () => {
-    // 这些测试需要预创建的测试用户
-    test('页面刷新后 token 仍然有效', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
+  test.describe.serial('Token 持久性测试 (AC4)', () => {
+    test('页面刷新后 token 仍然有效', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户
+      const testUsername = `mini_refresh_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `136${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序刷新测试',
+        userType: UserType.EMPLOYER,
+      });
+
+      // 2. 登录小程序
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+      await enterpriseMiniPage.expectLoginSuccess();
+
+      // 3. 获取登录后的 token
+      const tokenBeforeRefresh = await enterpriseMiniPage.getToken();
+      expect(tokenBeforeRefresh).not.toBeNull();
+
+      // 4. 刷新页面
+      await enterpriseMiniPage.page.reload();
+
+      // 5. 等待页面加载完成
+      await enterpriseMiniPage.page.waitForLoadState('domcontentloaded');
+
+      // 6. 验证 token 仍然存在
+      const tokenAfterRefresh = await enterpriseMiniPage.getToken();
+      expect(tokenAfterRefresh).toBe(tokenBeforeRefresh);
     });
 
-    test('使用已存储 token 可以继续访问', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
+    test('使用已存储 token 可以继续访问', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户
+      const testUsername = `mini_persist_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `135${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序持久化测试',
+        userType: UserType.EMPLOYER,
+      });
+
+      // 2. 登录小程序
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+      await enterpriseMiniPage.expectLoginSuccess();
+
+      // 3. 获取 token
+      const token = await enterpriseMiniPage.getToken();
+      expect(token).not.toBeNull();
+
+      // 4. 重新导航到登录页面(模拟关闭后重新打开)
+      await enterpriseMiniPage.goto();
+
+      // 5. 验证由于 token 存在,页面自动跳转到 dashboard
+      await enterpriseMiniPage.page.waitForURL(
+        url => url.pathname.includes('/dashboard') || url.pathname.includes('/pages'),
+        { timeout: 10000 }
+      ).catch(() => {
+        // 如果没有自动跳转,检查当前 URL
+        const currentUrl = enterpriseMiniPage.page.url();
+        expect(currentUrl).toMatch(/dashboard|pages/);
+      });
     });
   });
 
-  test.describe.skip('退出登录测试 (AC5) - 需要预创建测试用户', () => {
-    // 这些测试需要预创建的测试用户
-    test('应该成功退出登录', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
+  test.describe.serial('退出登录测试 (AC5)', () => {
+    test('应该成功退出登录', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户
+      const testUsername = `mini_logout_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `134${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序退出测试',
+        userType: UserType.EMPLOYER,
+      });
+
+      // 2. 登录小程序
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+      await enterpriseMiniPage.expectLoginSuccess();
+
+      // 3. 退出登录
+      await enterpriseMiniPage.logout();
+
+      // 4. 验证返回到登录页面
+      await enterpriseMiniPage.expectLoggedOut();
+
+      // 5. 验证 URL 返回到登录页面
+      const currentUrl = enterpriseMiniPage.page.url();
+      expect(currentUrl).toContain('/mini');
     });
 
-    test('退出后 token 应该被清除', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
+    test('退出后 token 应该被清除', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户
+      const testUsername = `mini_token_clear_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `133${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序 Token 清除测试',
+        userType: UserType.EMPLOYER,
+      });
+
+      // 2. 登录小程序
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+      await enterpriseMiniPage.expectLoginSuccess();
+
+      // 3. 验证 token 存在
+      const tokenBeforeLogout = await enterpriseMiniPage.getToken();
+      expect(tokenBeforeLogout).not.toBeNull();
+
+      // 4. 退出登录
+      await enterpriseMiniPage.logout();
+      await enterpriseMiniPage.expectLoggedOut();
+
+      // 5. 验证 token 已被清除
+      const tokenAfterLogout = await enterpriseMiniPage.getToken();
+      expect(tokenAfterLogout).toBeNull();
     });
 
-    test('退出后无法访问需要认证的页面', async ({ enterpriseMiniPage: _enterpriseMiniPage }) => {
-      // TODO: 需要预创建测试用户
+    test('退出后无法访问需要认证的页面', async ({ enterpriseMiniPage, browser }) => {
+      // 1. 创建测试用户
+      const testUsername = `mini_auth_${Date.now()}`;
+      const testPassword = 'Test123!@#';
+      const testPhone = `132${Date.now().toString().slice(-8)}`;
+
+      await createTestUser(browser, {
+        username: testUsername,
+        password: testPassword,
+        phone: testPhone,
+        nickname: '小程序认证测试',
+        userType: UserType.EMPLOYER,
+      });
+
+      // 2. 登录小程序
+      await enterpriseMiniPage.goto();
+      await enterpriseMiniPage.login(testPhone, testPassword);
+      await enterpriseMiniPage.expectLoginSuccess();
+
+      // 3. 退出登录
+      await enterpriseMiniPage.logout();
+      await enterpriseMiniPage.expectLoggedOut();
+
+      // 4. 尝试直接访问需要认证的页面(dashboard)
+      await enterpriseMiniPage.page.goto('/mini/dashboard');
+
+      // 5. 验证被重定向回登录页面
+      await enterpriseMiniPage.page.waitForLoadState('domcontentloaded');
+      const currentUrl = enterpriseMiniPage.page.url();
+      expect(currentUrl).toContain('/mini');
+
+      // 6. 验证登录页面可见
+      await expect(enterpriseMiniPage.loginPage).toBeVisible();
     });
   });
 });

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

@@ -1,4 +1,4 @@
-import { test as base } from '@playwright/test';
+import { test as base, type Browser } from '@playwright/test';
 import { readFileSync } from 'fs';
 import { join, dirname } from 'path';
 import { fileURLToPath } from 'url';
@@ -18,6 +18,7 @@ const __dirname = dirname(__filename);
 const testUsers = JSON.parse(readFileSync(join(__dirname, '../fixtures/test-users.json'), 'utf-8'));
 
 type Fixtures = {
+  browser: Browser;
   adminLoginPage: AdminLoginPage;
   dashboardPage: DashboardPage;
   userManagementPage: UserManagementPage;
@@ -32,6 +33,9 @@ type Fixtures = {
 };
 
 export const test = base.extend<Fixtures>({
+  browser: async ({ browser }, use) => {
+    await use(browser);
+  },
   adminLoginPage: async ({ page }, use) => {
     await use(new AdminLoginPage(page));
   },