mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
perf: 并行化前端单测并缩短 CI 关键路径 (#690)
This commit is contained in:
@@ -62,9 +62,19 @@ jobs:
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
|
||||
typecheck-and-tests:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit tests (${{ matrix.shard }})
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- 1/2
|
||||
- 2/2
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
@@ -79,8 +89,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: yarn test:run
|
||||
run: yarn test:run --shard=${{ matrix.shard }} --silent=passed-only
|
||||
|
||||
@@ -40,7 +40,8 @@ yarn format # 格式化当前分支、暂存区、工作区和未跟
|
||||
yarn format:check # 检查相同变更文件,不修改文件
|
||||
yarn format:all:check # 只读测量全仓格式收敛情况
|
||||
yarn typecheck
|
||||
yarn test:run
|
||||
yarn test:run # 本地默认并行执行两个 Vitest shard
|
||||
yarn test:run --serial
|
||||
yarn test:coverage
|
||||
yarn build
|
||||
```
|
||||
@@ -80,9 +81,9 @@ yarn build
|
||||
|
||||
第二阶段在 Pull Request workflow 中增加独立的全仓 `yarn lint` job:
|
||||
|
||||
1. lint 与 `typecheck-and-tests` 使用不同 job,保持静态检查和单元测试职责独立。
|
||||
1. `lint` job 集中执行 ESLint 与 typecheck,单元测试使用独立的两分片 job,使静态检查与测试关键路径并行;本地默认并行执行两个 Vitest shard,并与 CI 共用 `yarn test:run` 入口。
|
||||
2. 初始阶段作为普通 check 运行,不立即配置 required check。
|
||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint`,不执行自动修复或更新 baseline。
|
||||
3. workflow 使用 Node 24、frozen lockfile 和只读 `yarn lint` / `yarn typecheck`,不执行自动修复或更新 baseline。
|
||||
4. 观察 fork PR、依赖缓存、执行时间、误报和路径范围。
|
||||
5. 连续多个 PR 稳定通过后,再由维护者决定是否设为 required。
|
||||
|
||||
|
||||
+5
-2
@@ -84,7 +84,8 @@ Vitest 收集 `src/**/__tests__/**/*.spec.ts` 和 `tests/config/**/*.spec.ts`。
|
||||
|
||||
```sh
|
||||
yarn test # watch 模式
|
||||
yarn test:run # 单次运行
|
||||
yarn test:run # 默认并行两个 shard 单次运行
|
||||
yarn test:run --serial # 单进程运行,适合排查顺序或共享状态问题
|
||||
yarn test:coverage # 单次运行并检查覆盖率
|
||||
yarn test:lint-config # 聚焦执行 ESLint 配置契约测试
|
||||
yarn typecheck
|
||||
@@ -92,4 +93,6 @@ yarn lint
|
||||
yarn build
|
||||
```
|
||||
|
||||
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`typecheck-and-tests` job 执行类型检查和单元测试(`yarn test:run`),不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`。独立的 `lint` job 执行全仓只读 ESLint 检查。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
`yarn test:run` 默认并行执行两个 Vitest shard,本地全量测试和 CI 使用同一个入口。传入 `--shard=1/2` 之类的显式分片参数时只执行对应 shard;`yarn test:run --serial` 强制单进程运行。传入测试文件或名称过滤条件时自动使用单进程,避免聚焦测试因文件数不足而无法分片。
|
||||
|
||||
`Frontend Tests` 工作流使用 Node 24 LTS 和 frozen lockfile,在面向 `v3` 的 Pull Request 和推送到 `v3` 时运行。`lint` job 依次执行全仓只读 ESLint 和类型检查;单元测试 matrix 通过统一入口分别执行两个 Vitest shard,成功用例的标准输出默认静默,失败详情仍保留。CI 不要求覆盖率达标;覆盖率可按需在本地运行 `yarn test:coverage`。变更文件格式检查依赖 Pull Request 的 base/head SHA,因此只在 Pull Request 事件运行。Prettier 和 Node 兼容范围按[前端代码质量工具链演进](code-quality.md)继续渐进接入,新增测试代码不得引入新的 lint 或格式问题。
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:run": "node scripts/run-tests.mjs",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:lint-config": "vitest run tests/config/eslint-config.spec.ts",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const defaultShards = ['1/2', '2/2']
|
||||
const serialFlag = '--serial'
|
||||
const vitestCliPath = fileURLToPath(import.meta.resolve('vitest/vitest.mjs'))
|
||||
|
||||
/**
|
||||
* 生成一次 test:run 所需的 Vitest 参数组。全量测试默认并行;显式分片、串行模式和聚焦过滤保持单进程。
|
||||
*
|
||||
* @param {string[]} args test:run 接收的命令行参数
|
||||
* @returns {string[][]} 每个 Vitest 进程接收的一组参数
|
||||
*/
|
||||
export function planTestRuns(args) {
|
||||
const vitestArgs = args.filter(arg => arg !== serialFlag)
|
||||
const hasExplicitShard = vitestArgs.some(arg => arg === '--shard' || arg.startsWith('--shard='))
|
||||
const hasPositionalFilter = vitestArgs.some(arg => !arg.startsWith('-'))
|
||||
|
||||
if (args.includes(serialFlag) || hasExplicitShard || hasPositionalFilter) return [vitestArgs]
|
||||
|
||||
return defaultShards.map(shard => [...vitestArgs, `--shard=${shard}`])
|
||||
}
|
||||
|
||||
function runVitest(args) {
|
||||
return new Promise(resolveRun => {
|
||||
const child = spawn(process.execPath, [vitestCliPath, 'run', ...args], {
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('error', error => {
|
||||
console.error(`无法启动 Vitest: ${error.message}`)
|
||||
resolveRun(1)
|
||||
})
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`Vitest 被信号 ${signal} 终止`)
|
||||
resolveRun(1)
|
||||
return
|
||||
}
|
||||
|
||||
resolveRun(code ?? 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runs = planTestRuns(process.argv.slice(2))
|
||||
const exitCodes = await Promise.all(runs.map(runVitest))
|
||||
|
||||
process.exitCode = exitCodes.find(code => code !== 0) ?? 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await main()
|
||||
}
|
||||
@@ -11,7 +11,8 @@ describe('前端测试 workflow', () => {
|
||||
it('在 PR 与 v3 push 上运行,并将变更文件格式检查限制为 PR', () => {
|
||||
const workflow = readFileSync(workflowPath, 'utf8')
|
||||
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-tests:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
|
||||
const lintJob = workflow.match(/\n {2}lint:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
|
||||
const testJob = workflow.match(/\n {2}typecheck-and-tests:\n(?<job>[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job
|
||||
|
||||
expect(workflow).toContain('permissions:\n contents: read')
|
||||
expect(workflow).toContain('pull_request:\n branches:\n - v3')
|
||||
@@ -26,10 +27,16 @@ describe('前端测试 workflow', () => {
|
||||
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).not.toContain('--write')
|
||||
expect(qualityJob).toBeDefined()
|
||||
expect(qualityJob).toContain('run: yarn typecheck')
|
||||
expect(qualityJob).toContain('run: yarn test:run')
|
||||
expect(qualityJob).not.toContain('test:coverage')
|
||||
expect(lintJob).toBeDefined()
|
||||
expect(lintJob).toContain('run: yarn lint')
|
||||
expect(lintJob).toContain('run: yarn typecheck')
|
||||
expect(testJob).toBeDefined()
|
||||
expect(testJob).toContain('shard:')
|
||||
expect(testJob).toContain('- 1/2')
|
||||
expect(testJob).toContain('- 2/2')
|
||||
expect(testJob).toContain('run: yarn test:run --shard=${{ matrix.shard }} --silent=passed-only')
|
||||
expect(testJob).not.toContain('run: yarn typecheck')
|
||||
expect(testJob).not.toContain('test:coverage')
|
||||
expect(workflow).not.toContain('\n unit-tests:\n')
|
||||
})
|
||||
|
||||
@@ -37,10 +44,13 @@ describe('前端测试 workflow', () => {
|
||||
const testingGuide = readFileSync(testingGuidePath, 'utf8')
|
||||
const codeQualityGuide = readFileSync(codeQualityGuidePath, 'utf8')
|
||||
|
||||
expect(testingGuide).toContain('`typecheck-and-tests` job')
|
||||
expect(testingGuide).toContain('`yarn test:run` 默认并行执行两个 Vitest shard')
|
||||
expect(testingGuide).toContain('`yarn test:run --serial`')
|
||||
expect(testingGuide).toContain('传入测试文件或名称过滤条件时自动使用单进程')
|
||||
expect(testingGuide).toContain('推送到 `v3`')
|
||||
expect(testingGuide).toContain('只在 Pull Request 事件运行')
|
||||
expect(codeQualityGuide).toContain('lint 与 `typecheck-and-tests` 使用不同 job')
|
||||
expect(codeQualityGuide).toContain('`lint` job 集中执行 ESLint 与 typecheck')
|
||||
expect(codeQualityGuide).toContain('本地默认并行执行两个 Vitest shard')
|
||||
expect(testingGuide).not.toContain('`unit-tests`')
|
||||
expect(codeQualityGuide).not.toContain('`unit-tests`')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const packageJsonPath = resolve(process.cwd(), 'package.json')
|
||||
|
||||
function planTestRuns(args: string[]): string[][] {
|
||||
const script = [
|
||||
"import { planTestRuns } from './scripts/run-tests.mjs'",
|
||||
`process.stdout.write(JSON.stringify(planTestRuns(${JSON.stringify(args)})))`,
|
||||
].join(';')
|
||||
|
||||
return JSON.parse(
|
||||
execFileSync(process.execPath, ['--input-type=module', '--eval', script], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('前端测试统一入口', () => {
|
||||
it('由项目脚本接管 test:run', () => {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
||||
|
||||
expect(packageJson.scripts['test:run']).toBe('node scripts/run-tests.mjs')
|
||||
})
|
||||
|
||||
it('全量测试默认拆成两个并行 shard', () => {
|
||||
expect(planTestRuns([])).toEqual([['--shard=1/2'], ['--shard=2/2']])
|
||||
expect(planTestRuns(['--silent=passed-only'])).toEqual([
|
||||
['--silent=passed-only', '--shard=1/2'],
|
||||
['--silent=passed-only', '--shard=2/2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('显式 shard、串行模式和聚焦过滤只启动一个 Vitest 进程', () => {
|
||||
expect(planTestRuns(['--shard=2/2', '--silent=passed-only'])).toEqual([['--shard=2/2', '--silent=passed-only']])
|
||||
expect(planTestRuns(['--serial', '--silent=passed-only'])).toEqual([['--silent=passed-only']])
|
||||
expect(planTestRuns(['tests/config/frontend-workflow.spec.ts'])).toEqual([
|
||||
['tests/config/frontend-workflow.spec.ts'],
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user