feat(e2e): 核心全链路用例与辅助模块

新增 smoke-http、core-full-chain(访客深链守卫、登录黄金路径、可选健康 URL);抽出 music-room-session 辅助;Playwright 忽略本地 admin_debug;CI 注入可选 MOICEN_HEALTHCHECK_URL。

Made-with: Cursor
This commit is contained in:
2026-04-28 11:53:48 +08:00
parent b98785cda5
commit d85027a933
8 changed files with 309 additions and 182 deletions
+3
View File
@@ -6,3 +6,6 @@ HUIKE_FRONT_BASE_URL=https://music-room.moicen.com
HUIKE_ADMIN_BASE_URL=https://admin.moicen.com
MOICEN_ADMIN_USER=
MOICEN_ADMIN_PASSWORD=
# 可选:任意 HTTP 200 健康检查 URLCI 可在仓库 Settings → Variables 配置 MOICEN_HEALTHCHECK_URL
MOICEN_HEALTHCHECK_URL=
@@ -50,4 +50,6 @@ jobs:
- name: Playwright
env:
MOICEN_E2E_UNIONID: ${{ secrets.MOICEN_E2E_UNIONID }}
# 可选:Repository variables,例如后端 health/ping;未配置时对应用例 skip
MOICEN_HEALTHCHECK_URL: ${{ vars.MOICEN_HEALTHCHECK_URL }}
run: npx playwright test
+4
View File
@@ -18,6 +18,8 @@ npx playwright test
| 文件 | 内容 |
|------|------|
| `tests/smoke-http.spec.ts` | **request**`GET /` 返回 HTML(网关 / TLS / 静态入口底线,不启浏览器) |
| `tests/core-full-chain.spec.ts` | **访客**:未登录访问 `/course` 被送回 `/` 且见访客占位;**已登录**:黄金路径(会话→深链→课程体系壳层→JWT 与机构闭环);**可选** `MOICEN_HEALTHCHECK_URL` |
| `tests/guest-onboarding.spec.ts` | 未登录:仅见「请返回微信小程序完成登录」,不出现已登录工作台 |
| `tests/home-shell.spec.ts` | `#app`、伪造 unionid 不白屏、`page_path` 净化 |
| `tests/logged-in-and-isolation.spec.ts` | 需 Secret:登录后非访客态;异主 unionid 剥离;多角色选身份后见「欢迎回来」(不校验姓名) |
@@ -31,6 +33,8 @@ npx playwright test
**Settings → Secrets → Actions** 配置 **`MOICEN_E2E_UNIONID`** 即可。未配 unionid 时仅跑访客与壳层用例。
可选:在 **Settings → Secrets and variables → Actions → Variables** 配置 **`MOICEN_HEALTHCHECK_URL`**(完整 URL,返回 2xx),用于可选后端健康检查用例;未配置时该条自动 skip。
`workflow_dispatch` 可改目标 `base_url`**默认定时:每天 06:30 UTC**(见 `.github/workflows/playwright-music-room.yml`)。
## 与 moicen 运维文档
+1
View File
@@ -6,6 +6,7 @@ loadEnv({ path: path.join(__dirname, '.env.e2e') });
export default defineConfig({
testDir: './tests',
testIgnore: ['**/admin_debug.spec.ts'],
timeout: 120_000,
workers: 1,
expect: { timeout: 30_000 },
+85
View File
@@ -0,0 +1,85 @@
import { expect, test } from './fixtures';
import {
decodeJwtPayload,
establishSession,
extractOrgIdFromTokenPayload,
resolveOrgContextForCoursePage,
waitForCourseRealmVisible,
} from './helpers/music-room-session';
const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim();
/** 可选:仓库 Variables `MOICEN_HEALTHCHECK_URL`(如 `https://api.example/health`),未配置则 skip */
const moicenHealthcheckUrl = process.env.MOICEN_HEALTHCHECK_URL?.trim();
test.describe('核心全链路(访客)', () => {
/**
* `main.ts`:未带 Registered 链时访问非 `/`、`/guest/*` 会 replace 到 `/`,最终应呈现访客占位。
*/
test('未登录深链 /course 最终回到访客首页', async ({ page }) => {
await page.goto('/course', {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 60_000 });
await page.waitForURL((u) => new URL(u).pathname === '/', {
timeout: 60_000,
});
await expect(
page.getByText('请返回微信小程序完成登录')
).toBeVisible({ timeout: 60_000 });
});
});
test.describe('核心全链路(已登录 → 教学资源)', () => {
test.describe.configure({ timeout: 180_000 });
test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONIDSecret 或 .env.e2e');
test('黄金路径:会话建立 → 深链进入课程体系壳层 → JWT 与 CurrentOrgId 闭环', async ({
page,
}) => {
await establishSession(page, moicenUnionid!);
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).not.toBeNull();
const jwtOrgId = extractOrgIdFromTokenPayload(payload);
expect(jwtOrgId).toBeTruthy();
if (storage.currentOrgId) {
expect(storage.currentOrgId).toBe(jwtOrgId);
}
await page.goto('/course', {
waitUntil: 'domcontentloaded',
timeout: 90_000,
});
await expect(page.locator('#app')).toBeVisible({ timeout: 90_000 });
await waitForCourseRealmVisible(page);
await expect(page.getByText('请求失败,点击重新加载')).toHaveCount(0, {
timeout: 45_000,
});
});
});
test('可选:MOICEN_HEALTHCHECK_URL 可达', async ({ request }) => {
test.skip(!moicenHealthcheckUrl, '未配置 MOICEN_HEALTHCHECK_URL(仓库 Variables');
const res = await request.get(moicenHealthcheckUrl!);
expect(
res.ok(),
`HTTP ${res.status()} ${res.statusText()} ${moicenHealthcheckUrl}`
).toBeTruthy();
});
+183
View File
@@ -0,0 +1,183 @@
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',
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 等)'
);
}
+11 -182
View File
@@ -1,193 +1,22 @@
import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
decodeJwtPayload,
detectOrgSelectState,
extractOrgIdFromTokenPayload,
resolveOrgContextForCoursePage,
establishSession,
waitForCourseRealmVisible,
} from './helpers/music-room-session';
const moicenUnionid = process.env.MOICEN_E2E_UNIONID?.trim();
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 内 */
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 信号轮询。
*/
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()}`);
}
type OrgSelectState = 'ready' | 'empty' | 'guest' | 'timeout';
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';
}
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')) {
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 等)'
);
}
test.describe('多机构数据隔离(会话与 token', () => {
test.describe.configure({ timeout: 180_000 });
test.skip(!moicenUnionid, '需要 MOICEN_E2E_UNIONIDSecret 或 .env.e2e');
test('本地 CurrentOrgId 与 JWT current_org_id 一致', async ({ page }) => {
await establishSession(page);
await establishSession(page, moicenUnionid!);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
@@ -214,7 +43,7 @@ test.describe('多机构数据隔离(会话与 token', () => {
test('机构选择页存在多个机构时,切换后 CurrentOrgId 变化', async ({
page,
}) => {
await establishSession(page);
await establishSession(page, moicenUnionid!);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
@@ -302,7 +131,7 @@ test.describe('多机构数据隔离(会话与 token', () => {
test('课程体系页加载成功且不进入列表错误态(机构维度接口可用)', async ({
page,
}) => {
await establishSession(page);
await establishSession(page, moicenUnionid!);
if (
await page.getByText('请返回微信小程序完成登录').isVisible().catch(() => false)
) {
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from './fixtures';
function frontBaseUrl(): string {
const raw =
process.env.HUIKE_FRONT_BASE_URL?.trim() ||
'https://music-room.moicen.com';
return raw.replace(/\/$/, '');
}
/**
* 不依赖浏览器:验证网关返回 H5 入口文档(部署/ DNS / TLS / Nginx 全链路底线)。
*/
test.describe('网关与 H5 文档(request', () => {
test('GET / 返回 HTML', async ({ request }) => {
const res = await request.get(`${frontBaseUrl()}/`);
expect(res.ok(), `HTTP ${res.status()} ${res.statusText()}`).toBeTruthy();
const ct = res.headers()['content-type'] ?? '';
expect(ct).toMatch(/text\/html/i);
});
});