diff --git a/README.md b/README.md index 9ff183b0..45da3a48 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ pnpm test pnpm run build:all ``` +`pnpm test` 会运行所有声明了常规测试的 workspace;`pnpm test:all` 是兼容别名,`pnpm test:core` 只运行 Core 测试。原生模块测试需单独运行 `pnpm test:native`。`pnpm coverage` 会生成各常规测试 workspace 的覆盖率报告,但不设置覆盖率阻断阈值。 + - 先读 [`CONTEXT.md`](CONTEXT.md) 了解模块边界与项目词汇。 - 提交代码前阅读 [`CONTRIBUTING.md`](CONTRIBUTING.md) 与 [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)。 - 开发方向与已完成事项见 [`RoadMap.md`](RoadMap.md)。 diff --git a/package.json b/package.json index 4ffd27eb..2dea4d50 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,13 @@ "dev": "pnpm --filter @snowluma/core dev", "dev:mode": "cd dist && cross-env SNOWLUMA_DEV_MODE=1 SNOWLUMA_DEV_WEBUI=1 node index.mjs", "dev:web": "pnpm --filter webui dev", - "test": "pnpm -r --no-bail --if-present test", + "test": "pnpm --filter=\"./packages/**\" -r --no-bail --if-present test", "test:core": "pnpm --filter @snowluma/core test", "test:watch": "pnpm --filter @snowluma/core test:watch", "test:common": "pnpm --filter @snowluma/common test", "test:all": "pnpm test", - "test:native": "pnpm -r --if-present test:native", - "coverage": "pnpm --filter @snowluma/common test -- --coverage", + "test:native": "pnpm --filter=\"./packages/**\" -r --if-present test:native", + "coverage": "node tools/coverage.mjs", "typecheck": "pnpm -r --if-present typecheck", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index be5e636b..9ca64c3d 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -90,7 +90,7 @@ } }, "scripts": { - "test": "vitest run", + "test": "vitest run --exclude=\"tests/native/**\"", "test:native": "vitest run tests/native", "bench": "vitest bench --run", "typecheck": "tsc --noEmit" diff --git a/packages/websocket/package.json b/packages/websocket/package.json index 70620804..9976634d 100644 --- a/packages/websocket/package.json +++ b/packages/websocket/package.json @@ -14,10 +14,12 @@ }, "description": "SnowLuma in-tree WebSocket implementation. Loads packages/runtime/native/websocket--.node via process.dlopen().", "scripts": { + "test": "vitest run", "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^4.1.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86bd9346..d0ea4325 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,6 +355,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.2 + vitest: + specifier: ^4.1.4 + version: 4.1.4(@types/node@22.13.0)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(vite@8.1.0(@types/node@22.13.0)(esbuild@0.28.1)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)) packages/webui: dependencies: diff --git a/tools/coverage.mjs b/tools/coverage.mjs new file mode 100644 index 00000000..3064d97e --- /dev/null +++ b/tools/coverage.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const packagesRoot = path.join(repoRoot, 'packages'); +const coverageRoot = path.join(repoRoot, 'coverage'); + +function resolveVitestCli(manifestPath) { + const require = createRequire(manifestPath); + let directory = path.dirname(require.resolve('vitest')); + + while (true) { + const packagePath = path.join(directory, 'package.json'); + if (existsSync(packagePath)) { + const manifest = JSON.parse(readFileSync(packagePath, 'utf8')); + if (manifest.name === 'vitest') { + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.vitest; + if (typeof bin !== 'string') throw new Error('Vitest does not declare a CLI binary'); + return path.resolve(directory, bin); + } + } + + const parent = path.dirname(directory); + if (parent === directory) throw new Error(`Could not resolve Vitest CLI from ${manifestPath}`); + directory = parent; + } +} + +const workspaces = readdirSync(packagesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const directory = path.join(packagesRoot, entry.name); + const manifestPath = path.join(directory, 'package.json'); + if (!existsSync(manifestPath)) return null; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (typeof manifest.scripts?.test !== 'string') return null; + if (typeof manifest.devDependencies?.vitest !== 'string') { + throw new Error(`${manifest.name ?? entry.name} declares tests without a package-local Vitest dependency`); + } + return { + directory, + reportDirectory: path.join(coverageRoot, entry.name), + name: manifest.name ?? entry.name, + vitestCli: resolveVitestCli(manifestPath), + }; + }) + .filter(Boolean); + +if (workspaces.length === 0) { + throw new Error('No regular Vitest workspaces found'); +} + +rmSync(coverageRoot, { recursive: true, force: true }); + +for (const workspace of workspaces) { + console.log(`\n[coverage] ${workspace.name}`); + const result = spawnSync( + process.execPath, + [ + workspace.vitestCli, + 'run', + '--exclude=tests/native/**', + '--coverage', + '--coverage.provider=v8', + `--coverage.reportsDirectory=${workspace.reportDirectory}`, + '--coverage.reporter=text-summary', + '--coverage.reporter=json-summary', + '--coverage.reporter=html', + '--coverage.include=src/**/*.{js,mjs,cjs,ts,tsx}', + '--coverage.exclude=src/**/generated/**', + '--coverage.exclude=src/**/fixtures/**', + '--coverage.exclude=src/**/*.d.ts', + '--coverage.exclude=src/**/native/**', + '--coverage.exclude=dist/**', + '--coverage.exclude=**/native/**', + ], + { + cwd: workspace.directory, + stdio: 'inherit', + }, + ); + + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +console.log(`\n[coverage] Reports written to ${path.relative(repoRoot, coverageRoot)}/`);