import type { Page } from '@playwright/test'; import { expect, test } from '../fixtures'; export 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 内 */ export 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 信号轮询。 */ export 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()}`); } export type OrgSelectState = 'ready' | 'empty' | 'guest' | 'timeout'; export async function detectOrgSelectState( page: Page, timeoutMs = 90_000 ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const state = await page.evaluate(() => { const appRoot = document.querySelector('#app'); if (!appRoot) return 'pending'; const rootText = appRoot.textContent ?? ''; if (rootText.includes('请返回微信小程序完成登录')) return 'guest'; if (rootText.includes('暂无可用机构')) return 'empty'; if (rootText.includes('请选择机构')) return 'ready'; if (appRoot.querySelector('.van-cell-group .van-cell')) return 'ready'; return 'pending'; }); if (state === 'guest' || state === 'empty' || state === 'ready') { return state; } await page.waitForTimeout(500); } return 'timeout'; } export async function establishSession(page: Page, unionid: string) { const q = new URLSearchParams({ unionid, 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 上只对其中之一稳定渲染壳层。 */ export 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')) { const orgSelectState = await detectOrgSelectState(page, 60_000); if (orgSelectState === 'guest') { test.skip(true, '深链进入机构页时会话退回访客态'); } if (orgSelectState === 'empty') { test.skip(true, '账号无可用机构,跳过机构上下文用例'); } if (orgSelectState === 'timeout') { throw new Error(`机构页未渲染就绪信号:${page.url()}`); } const pickCells = page.locator('#app .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 等)' ); }