mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
ci(test): run frontend checks on v2 pushes (#572)
This commit is contained in:
@@ -4,16 +4,20 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- v2
|
- v2
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- v2
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-tests-${{ github.event.pull_request.number }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
format:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -58,7 +62,7 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
|
||||||
unit-tests:
|
typecheck-and-coverage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ yarn build
|
|||||||
|
|
||||||
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
||||||
|
|
||||||
1. lint 与既有 `unit-tests` 使用不同 job,避免改变现有测试 check 的名称和职责。
|
1. lint 与 `typecheck-and-coverage` 使用不同 job,保持静态检查和测试覆盖率职责独立。
|
||||||
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
||||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
|
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
|
||||||
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
|
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
|
||||||
|
|||||||
+1
-1
@@ -92,4 +92,4 @@ yarn lint
|
|||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
Pull Request 工作流使用 Node 24 LTS 和 frozen lockfile。`unit-tests` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查,当前处于普通 check 观察阶段,不改变既有 required checks。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v2` 的 Pull Request 和推送到 `v2` 时运行。`typecheck-and-coverage` job 依次执行类型检查和覆盖率门禁;独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||||
|
|||||||
@@ -461,6 +461,55 @@ describe('MediaCard', () => {
|
|||||||
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
|
expect(dialogProps).toMatchObject({ subscribedSeasons: [2] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'structured TMDB identity',
|
||||||
|
createMediaInfo({
|
||||||
|
media_id: 'series-9554',
|
||||||
|
mediaid_prefix: undefined,
|
||||||
|
season: 2,
|
||||||
|
source: 'themoviedb',
|
||||||
|
tmdb_id: undefined,
|
||||||
|
type: '电视剧',
|
||||||
|
}),
|
||||||
|
'tmdb:series-9554',
|
||||||
|
[
|
||||||
|
{ id: 93, media_id: 'series-9554', media_source: 'themoviedb', season: 4, type: '电视剧' },
|
||||||
|
{ id: 94, media_id: 'other', media_source: 'themoviedb', season: 5, type: '电视剧' },
|
||||||
|
],
|
||||||
|
[4],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'legacy AniList identity',
|
||||||
|
createMediaInfo({ anilist_id: 154588, season: 2, source: 'anilist', tmdb_id: undefined, type: '电视剧' }),
|
||||||
|
'anilist:154588',
|
||||||
|
[
|
||||||
|
{ anilistid: 154588, id: 95, season: 1, type: '电视剧' },
|
||||||
|
{ anilistid: 154589, id: 96, season: 3, type: '电视剧' },
|
||||||
|
],
|
||||||
|
[1],
|
||||||
|
],
|
||||||
|
])('matches %s when collecting subscribed TV seasons', async (_label, media, mediaId, subscribes, expected) => {
|
||||||
|
server.use(
|
||||||
|
querySubscribeByMediaHandler(mediaId, { id: 93, season: 2 }),
|
||||||
|
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||||
|
subscribeListHandler(subscribes),
|
||||||
|
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||||
|
HttpResponse.json({ data: { value: {} }, success: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { container } = await renderCard(media)
|
||||||
|
getStatusObservers()[0]?.trigger()
|
||||||
|
await waitFor(() => expect(getActionButtons(container).at(-1)).toHaveClass('text-error'))
|
||||||
|
|
||||||
|
await fireEvent.mouseEnter(getHoverArea(container))
|
||||||
|
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
|
const [, dialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||||
|
expect(dialogProps).toMatchObject({ subscribedSeasons: expected })
|
||||||
|
})
|
||||||
|
|
||||||
it('updates image badges on load and falls back after an image error', async () => {
|
it('updates image badges on load and falls back after an image error', async () => {
|
||||||
const media = createMediaInfo({
|
const media = createMediaInfo({
|
||||||
poster_path: '/original/poster.jpg',
|
poster_path: '/original/poster.jpg',
|
||||||
|
|||||||
@@ -154,6 +154,18 @@ describe('SubscribeSeasonDialog', () => {
|
|||||||
},
|
},
|
||||||
'custom:custom-7305',
|
'custom:custom-7305',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'source-only TMDB',
|
||||||
|
{
|
||||||
|
bangumi_id: undefined,
|
||||||
|
douban_id: undefined,
|
||||||
|
media_id: 'source-7306',
|
||||||
|
mediaid_prefix: undefined,
|
||||||
|
source: 'themoviedb',
|
||||||
|
tmdb_id: undefined,
|
||||||
|
},
|
||||||
|
'tmdb:source-7306',
|
||||||
|
],
|
||||||
] as const)('uses the %s media identifier without requesting TMDB groups', async (_label, overrides, mediaId) => {
|
] as const)('uses the %s media identifier without requesting TMDB groups', async (_label, overrides, mediaId) => {
|
||||||
const media = createTvMedia(overrides)
|
const media = createTvMedia(overrides)
|
||||||
const requested = vi.fn()
|
const requested = vi.fn()
|
||||||
|
|||||||
@@ -892,6 +892,39 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
|
|||||||
expect(container.querySelector('.v-expansion-panel')).toHaveTextContent('第 2 季')
|
expect(container.querySelector('.v-expansion-panel')).toHaveTextContent('第 2 季')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('scrolls the episode-group rail in both directions', async () => {
|
||||||
|
const media = createSubscribeTv({
|
||||||
|
season_info: [createMediaSeason({ season_number: 1 })],
|
||||||
|
title: '剧集组滚动剧',
|
||||||
|
tmdb_id: 8718,
|
||||||
|
})
|
||||||
|
const groups = Array.from({ length: 5 }, (_, index) => ({
|
||||||
|
episode_count: 8 + index,
|
||||||
|
group_count: 1,
|
||||||
|
id: `group-${index}`,
|
||||||
|
name: `剧集组 ${index}`,
|
||||||
|
}))
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { container } = await renderDetail({ episodeGroups: groups, media, type: '电视剧' })
|
||||||
|
const rail = container.querySelector<HTMLElement>('.episode-group-rail')
|
||||||
|
expect(rail).not.toBeNull()
|
||||||
|
Object.defineProperties(rail, {
|
||||||
|
clientWidth: { configurable: true, value: 400 },
|
||||||
|
scrollLeft: { configurable: true, value: 0, writable: true },
|
||||||
|
scrollWidth: { configurable: true, value: 1000 },
|
||||||
|
})
|
||||||
|
rail!.scrollBy = vi.fn()
|
||||||
|
await fireEvent.scroll(rail as HTMLElement)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看更多剧集组' }))
|
||||||
|
expect(rail!.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: 288 })
|
||||||
|
|
||||||
|
rail!.scrollLeft = 300
|
||||||
|
await fireEvent.scroll(rail as HTMLElement)
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看上一组剧集组' }))
|
||||||
|
expect(rail!.scrollBy).toHaveBeenCalledWith({ behavior: 'smooth', left: -288 })
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores stale episode-group seasons after a newer group is selected', async () => {
|
it('ignores stale episode-group seasons after a newer group is selected', async () => {
|
||||||
const media = createSubscribeTv({
|
const media = createSubscribeTv({
|
||||||
season_info: [createMediaSeason({ season_number: 1 })],
|
season_info: [createMediaSeason({ season_number: 1 })],
|
||||||
|
|||||||
@@ -3,14 +3,22 @@ import { resolve } from 'node:path'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml')
|
const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml')
|
||||||
|
const testingGuidePath = resolve(process.cwd(), 'docs/testing.md')
|
||||||
|
const codeQualityGuidePath = resolve(process.cwd(), 'docs/code-quality.md')
|
||||||
|
|
||||||
describe('前端 Pull Request workflow', () => {
|
describe('前端测试 workflow', () => {
|
||||||
it('使用只读的变更文件格式检查,并显式比较事件 base/head SHA', () => {
|
it('在 PR 与 v2 push 上运行,并将变更文件格式检查限制为 PR', () => {
|
||||||
const workflow = readFileSync(workflowPath, 'utf8')
|
const workflow = readFileSync(workflowPath, 'utf8')
|
||||||
const formatJob = workflow.match(/\n {2}format:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
|
const formatJob = workflow.match(/\n {2}format:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
|
||||||
|
const qualityJob = workflow.match(/\n {2}typecheck-and-coverage:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups
|
||||||
|
?.job
|
||||||
|
|
||||||
expect(workflow).toContain('permissions:\n contents: read')
|
expect(workflow).toContain('permissions:\n contents: read')
|
||||||
|
expect(workflow).toContain('pull_request:\n branches:\n - v2')
|
||||||
|
expect(workflow).toContain('push:\n branches:\n - v2')
|
||||||
|
expect(workflow).toContain('group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}')
|
||||||
expect(formatJob).toBeDefined()
|
expect(formatJob).toBeDefined()
|
||||||
|
expect(formatJob).toContain("if: github.event_name == 'pull_request'")
|
||||||
expect(formatJob).toContain('fetch-depth: 0')
|
expect(formatJob).toContain('fetch-depth: 0')
|
||||||
expect(formatJob).toContain("node-version: '24'")
|
expect(formatJob).toContain("node-version: '24'")
|
||||||
expect(formatJob).toContain('run: yarn --frozen-lockfile')
|
expect(formatJob).toContain('run: yarn --frozen-lockfile')
|
||||||
@@ -18,5 +26,21 @@ describe('前端 Pull Request workflow', () => {
|
|||||||
expect(formatJob).toContain('HEAD_SHA: ${{ github.event.pull_request.head.sha }}')
|
expect(formatJob).toContain('HEAD_SHA: ${{ github.event.pull_request.head.sha }}')
|
||||||
expect(formatJob).toContain('run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"')
|
expect(formatJob).toContain('run: yarn format:check --base "$BASE_SHA" --head "$HEAD_SHA"')
|
||||||
expect(formatJob).not.toContain('--write')
|
expect(formatJob).not.toContain('--write')
|
||||||
|
expect(qualityJob).toBeDefined()
|
||||||
|
expect(qualityJob).toContain('run: yarn typecheck')
|
||||||
|
expect(qualityJob).toContain('run: yarn test:coverage')
|
||||||
|
expect(workflow).not.toContain('\n unit-tests:\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('文档使用当前测试 job 名称和触发范围', () => {
|
||||||
|
const testingGuide = readFileSync(testingGuidePath, 'utf8')
|
||||||
|
const codeQualityGuide = readFileSync(codeQualityGuidePath, 'utf8')
|
||||||
|
|
||||||
|
expect(testingGuide).toContain('`typecheck-and-coverage` job')
|
||||||
|
expect(testingGuide).toContain('推送到 `v2`')
|
||||||
|
expect(testingGuide).toContain('只在 Pull Request 事件运行')
|
||||||
|
expect(codeQualityGuide).toContain('lint 与 `typecheck-and-coverage` 使用不同 job')
|
||||||
|
expect(testingGuide).not.toContain('`unit-tests`')
|
||||||
|
expect(codeQualityGuide).not.toContain('`unit-tests`')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user