import type { Page } from '@playwright/test'; import { expect, test } from './fixtures'; const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim(); function decodeJwtPayload(token: string): Record | null { const raw = token.startsWith('Bearer ') ? token.slice(7).trim() : token; const parts = raw.split('.'); if (parts.length !== 3) return null; try { const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); const json = Buffer.from(padded, 'base64').toString('utf8'); return JSON.parse(json) as Record; } catch { return null; } } /** 与 `huike-front/src/main.ts` 中 `parseCurrentOrgIdFromToken` 一致:claims 可能在顶层或嵌在 `sub` JSON 内 */ function extractOrgIdFromTokenPayload( payload: Record | null ): string | undefined { if (!payload) return undefined; const top = payload.current_org_id; if (typeof top === 'string' && top.length > 0) return top; const sub = payload.sub; if (typeof sub !== 'string' || sub.length === 0) return undefined; try { const inner = JSON.parse(sub) as Record; const innerOrg = inner.current_org_id; if (typeof innerOrg === 'string' && innerOrg.length > 0) return innerOrg; } catch { return undefined; } return undefined; } /** * 教学资源相关任意壳层可见即可(`/course/summary` 入口链或课程体系列表)。 * CI 上 SPA 长连接会导致 networkidle 不可靠;此处只用 DOM 信号轮询。 */ async function waitForCourseRealmVisible(page: Page) { const deadline = Date.now() + 95_000; while (Date.now() < deadline) { const titleHit = await page.title().catch(() => ''); if (/教学资源库|课程体系/.test(titleHit)) { return; } if ( await page.getByRole('link', { name: '课程体系' }).isVisible().catch(() => false) ) { return; } for (const label of ['课程体系', '课程', '课节']) { if ( await page .locator('.van-cell') .filter({ hasText: new RegExp(label) }) .first() .isVisible() .catch(() => false) ) { return; } } if ( await page .getByPlaceholder('请输入课程体系名称搜索') .isVisible() .catch(() => false) ) { return; } if ( await page.locator('.van-search').first().isVisible().catch(() => false) ) { return; } await page.waitForTimeout(400); } throw new Error(`教学资源壳层未出现:${page.url()}`); } async function establishSession(page: Page) { const q = new URLSearchParams({ unionid: moicenUnionid!, status: '2', }); await page.goto(`/?${q.toString()}`, { waitUntil: 'domcontentloaded', timeout: 60_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 }); if ( await page.getByText('请选择您的登录身份').isVisible().catch(() => false) ) { await page.locator('.van-grid-item').first().click(); } await expect( page.getByText(/请选择您的登录身份|欢迎回来|进入工作台/) ).toBeVisible({ timeout: 120_000 }); } /** * 停留在 `/` 时 main.ts 不会把 JWT 中的机构写入 CurrentOrgId。 * 深链会触发守卫;多机构且无上下文时会先进入 `/org/select`,需点选机构后再继续。 * 交替尝试 summary 与课程索引:部分账号在 CI 上只对其中之一稳定渲染壳层。 */ async function resolveOrgContextForCoursePage(page: Page) { const deepPaths = ['/course/summary', '/course'] as const; for (let attempt = 0; attempt < 8; attempt++) { const targetPath = deepPaths[attempt % deepPaths.length]; await page.goto(targetPath, { waitUntil: 'domcontentloaded', timeout: 90_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 }); if (page.url().includes('/org/select')) { await expect(page.getByText(/请选择机构|选择机构/)).toBeVisible({ timeout: 60_000, }); await Promise.race([ page .locator('.main .van-cell-group .van-cell') .first() .waitFor({ state: 'visible', timeout: 60_000 }), page.getByText('暂无可用机构').waitFor({ state: 'visible', timeout: 60_000 }), ]); const emptyOrgs = await page .getByText('暂无可用机构') .isVisible() .catch(() => false); if (emptyOrgs) { test.skip(true, '账号无可用机构,跳过机构上下文用例'); } const pickCells = page.locator('.main .van-cell-group .van-cell'); const n = await pickCells.count(); expect(n, '机构选择页应有可选机构').toBeGreaterThan(0); await pickCells.first().click(); await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 }); continue; } try { const path = new URL(page.url()).pathname || ''; const onCourseRealm = path === '/course' || path.startsWith('/course/'); if (onCourseRealm) { await waitForCourseRealmVisible(page); return; } } catch { /* URL 解析失败则继续重试 */ } // 守卫可能暂时送回首页「进入工作台」等,下一循环再深链 } throw new Error( 'resolveOrgContextForCoursePage: 无法在合理步数内进入教学资源链(/course/summary 等)' ); } test.describe('多机构数据隔离(会话与 token)', () => { test.describe.configure({ timeout: 180_000 }); test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONID(Secret 或 .env.e2e)'); test('本地 CurrentOrgId 与 JWT current_org_id 一致', async ({ page }) => { await establishSession(page); if ( await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) ) { test.skip(true, '当前 unionid 会话未处于可用登录态'); } await resolveOrgContextForCoursePage(page); const storage = await page.evaluate(() => ({ currentOrgId: window.localStorage.getItem('CurrentOrgId'), authorization: window.localStorage.getItem('Authorization'), })); expect(storage.authorization, '登录后应有 Authorization').toBeTruthy(); const payload = decodeJwtPayload(storage.authorization!); expect(payload, 'Authorization 应为可解析的 JWT').not.toBeNull(); const jwtOrgId = extractOrgIdFromTokenPayload(payload); expect(jwtOrgId, 'JWT(含 sub 嵌套)应能解析出 current_org_id').toBeTruthy(); if (storage.currentOrgId) { expect(storage.currentOrgId).toBe(jwtOrgId); } }); test('机构选择页存在多个机构时,切换后 CurrentOrgId 变化', async ({ page, }) => { await establishSession(page); if ( await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) ) { test.skip(true, '当前 unionid 会话未处于可用登录态'); } await resolveOrgContextForCoursePage(page); await page.goto('/org/select', { waitUntil: 'load', timeout: 90_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 }); await page.waitForURL(/\/org\/select/, { timeout: 90_000 }); // 与 `org/select.vue` 根节点一致;避免 getByText 在 Vant/拆行下偶发匹配不到 await expect(page.locator('.main')).toBeVisible({ timeout: 90_000 }); if ( await page.getByText('暂无可用机构').isVisible().catch(() => false) ) { test.skip(true, '机构接口无数据,跳过多机构切换断言'); } await expect(page.locator('.main h4')).toContainText(/请选择机构/, { timeout: 90_000, }); const orgCells = page.locator('.main .van-cell-group .van-cell'); const count = await orgCells.count(); test.skip( count < 2, '账号仅绑定单一机构时跳过切换断言(仍可通过 JWT 对齐用例校验隔离键)' ); let beforeOrg: string | null = await page.evaluate(() => window.localStorage.getItem('CurrentOrgId') ); if (!beforeOrg) { const authSnap = await page.evaluate(() => window.localStorage.getItem('Authorization') ); beforeOrg = extractOrgIdFromTokenPayload( decodeJwtPayload(authSnap ?? '') ) ?? null; } expect(beforeOrg, '切换前应能从 localStorage 或 JWT 解析当前机构').toBeTruthy(); await orgCells.nth(1).click(); await page.waitForURL( (u) => { try { return new URL(u).pathname === '/' || new URL(u).pathname === ''; } catch { return false; } }, { timeout: 90_000 } ); await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 }); const afterOrg = await page.evaluate(() => window.localStorage.getItem('CurrentOrgId') ); expect(afterOrg).toBeTruthy(); expect(afterOrg).not.toBe(beforeOrg); const authAfter = await page.evaluate(() => window.localStorage.getItem('Authorization') ); const payloadAfter = decodeJwtPayload(authAfter ?? ''); const jwtOrgAfter = extractOrgIdFromTokenPayload(payloadAfter); expect(jwtOrgAfter, '切换后 JWT 应携带 current_org_id').toBeTruthy(); expect(jwtOrgAfter).toBe(afterOrg); }); test('课程体系页加载成功且不进入列表错误态(机构维度接口可用)', async ({ page, }) => { await establishSession(page); if ( await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false) ) { test.skip(true, '当前 unionid 会话未处于可用登录态'); } await resolveOrgContextForCoursePage(page); await page.goto('/course', { waitUntil: 'domcontentloaded', timeout: 90_000, }); await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 }); await waitForCourseRealmVisible(page); const listError = page.getByText('请求失败,点击重新加载'); await expect(listError).toHaveCount(0, { timeout: 45_000, }); }); });