From 3d1361281d69576e6c06796a90c32874e321aa51 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:02:24 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=B9=B6=E8=A1=8C=E5=8C=96=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=8D=95=E6=B5=8B=E5=B9=B6=E7=BC=A9=E7=9F=AD=20CI=20?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E8=B7=AF=E5=BE=84=20(#690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 15 +++++-- docs/code-quality.md | 7 ++-- docs/testing.md | 7 +++- package.json | 2 +- scripts/run-tests.mjs | 56 ++++++++++++++++++++++++++ tests/config/frontend-workflow.spec.ts | 24 +++++++---- tests/config/test-runner.spec.ts | 44 ++++++++++++++++++++ 7 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 scripts/run-tests.mjs create mode 100644 tests/config/test-runner.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa2a32e0..06c11e9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/docs/code-quality.md b/docs/code-quality.md index 2f128673..5d2571fa 100644 --- a/docs/code-quality.md +++ b/docs/code-quality.md @@ -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。 diff --git a/docs/testing.md b/docs/testing.md index 11e0c2e8..7e00561c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 或格式问题。 diff --git a/package.json b/package.json index 91bc2390..8ace7e22 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 00000000..9580a108 --- /dev/null +++ b/scripts/run-tests.mjs @@ -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() +} diff --git a/tests/config/frontend-workflow.spec.ts b/tests/config/frontend-workflow.spec.ts index 9edf1102..f04c466a 100644 --- a/tests/config/frontend-workflow.spec.ts +++ b/tests/config/frontend-workflow.spec.ts @@ -11,7 +11,8 @@ describe('前端测试 workflow', () => { it('在 PR 与 v3 push 上运行,并将变更文件格式检查限制为 PR', () => { const workflow = readFileSync(workflowPath, 'utf8') const formatJob = workflow.match(/\n {2}format:\n(?[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job - const qualityJob = workflow.match(/\n {2}typecheck-and-tests:\n(?[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job + const lintJob = workflow.match(/\n {2}lint:\n(?[\s\S]*?)(?=\n {2}[\w-]+:\n|$)/)?.groups?.job + const testJob = workflow.match(/\n {2}typecheck-and-tests:\n(?[\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`') }) diff --git a/tests/config/test-runner.spec.ts b/tests/config/test-runner.spec.ts new file mode 100644 index 00000000..b98533aa --- /dev/null +++ b/tests/config/test-runner.spec.ts @@ -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'], + ]) + }) +})