From d85027a933f8b7d37683457604039c4826b826d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E7=94=B7?= Date: Tue, 28 Apr 2026 11:53:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(e2e):=20=E6=A0=B8=E5=BF=83=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E7=94=A8=E4=BE=8B=E4=B8=8E=E8=BE=85=E5=8A=A9?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 smoke-http、core-full-chain(访客深链守卫、登录黄金路径、可选健康 URL);抽出 music-room-session 辅助;Playwright 忽略本地 admin_debug;CI 注入可选 MOICEN_HEALTHCHECK_URL。 Made-with: Cursor --- .env.e2e.example | 3 + .github/workflows/playwright-music-room.yml | 2 + README.md | 4 + playwright.config.ts | 1 + tests/core-full-chain.spec.ts | 85 +++++++++ tests/helpers/music-room-session.ts | 183 +++++++++++++++++++ tests/org-data-isolation.spec.ts | 193 ++------------------ tests/smoke-http.spec.ts | 20 ++ 8 files changed, 309 insertions(+), 182 deletions(-) create mode 100644 tests/core-full-chain.spec.ts create mode 100644 tests/helpers/music-room-session.ts create mode 100644 tests/smoke-http.spec.ts diff --git a/.env.e2e.example b/.env.e2e.example index 4e8ffaf..6fedd65 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -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 健康检查 URL(CI 可在仓库 Settings → Variables 配置 MOICEN_HEALTHCHECK_URL) +MOICEN_HEALTHCHECK_URL= diff --git a/.github/workflows/playwright-music-room.yml b/.github/workflows/playwright-music-room.yml index abcc1e1..610428e 100644 --- a/.github/workflows/playwright-music-room.yml +++ b/.github/workflows/playwright-music-room.yml @@ -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 diff --git a/README.md b/README.md index ee0363b..b20b960 100644 --- a/README.md +++ b/README.md @@ -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 运维文档 diff --git a/playwright.config.ts b/playwright.config.ts index 7ce4db6..9b06f24 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 }, diff --git a/tests/core-full-chain.spec.ts b/tests/core-full-chain.spec.ts new file mode 100644 index 0000000..2081757 --- /dev/null +++ b/tests/core-full-chain.spec.ts @@ -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_UNIONID(Secret 或 .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(); +}); diff --git a/tests/helpers/music-room-session.ts b/tests/helpers/music-room-session.ts new file mode 100644 index 0000000..3563d30 --- /dev/null +++ b/tests/helpers/music-room-session.ts @@ -0,0 +1,183 @@ +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 等)' + ); +} diff --git a/tests/org-data-isolation.spec.ts b/tests/org-data-isolation.spec.ts index 48fbac9..65b83b9 100644 --- a/tests/org-data-isolation.spec.ts +++ b/tests/org-data-isolation.spec.ts @@ -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 | 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()}`); -} - -type OrgSelectState = 'ready' | 'empty' | 'guest' | 'timeout'; - -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'; -} - -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_UNIONID(Secret 或 .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) ) { diff --git a/tests/smoke-http.spec.ts b/tests/smoke-http.spec.ts new file mode 100644 index 0000000..0ae227c --- /dev/null +++ b/tests/smoke-http.spec.ts @@ -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); + }); +});