2026-04-28 11:53:48 +08:00
|
|
|
import type { Page } from '@playwright/test';
|
|
|
|
|
import { expect, test } from '../fixtures';
|
|
|
|
|
|
|
|
|
|
export function decodeJwtPayload(token: string): Record<string, unknown> | 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<string, unknown>;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 与 `huike-front/src/main.ts` 中 `parseCurrentOrgIdFromToken` 一致:claims 可能在顶层或嵌在 `sub` JSON 内 */
|
|
|
|
|
export function extractOrgIdFromTokenPayload(
|
|
|
|
|
payload: Record<string, unknown> | 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<string, unknown>;
|
|
|
|
|
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<OrgSelectState> {
|
|
|
|
|
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',
|
2026-05-03 19:14:36 +08:00
|
|
|
timeout: 120_000,
|
2026-04-28 11:53:48 +08:00
|
|
|
});
|
|
|
|
|
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 等)'
|
|
|
|
|
);
|
|
|
|
|
}
|