mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-13 23:48:43 +00:00
ci: 落地 85% 行覆盖率硬门禁并校正 AGENTS.md
This commit is contained in:
parent
08b610a4f2
commit
392f9ae7f0
@ -1 +1 @@
|
||||
npm run test
|
||||
npm run coverage
|
||||
|
||||
29
AGENTS.md
29
AGENTS.md
@ -7,7 +7,11 @@
|
||||
|
||||
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime,采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
|
||||
|
||||
**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress
|
||||
编辑器本体使用 Vue 3;UI 通过 `@tmagic/design` + adapter 接入 Element Plus 或 TDesign Vue Next。
|
||||
|
||||
**技术栈:** Vue 3, TypeScript, Vite, rolldown, vitest, VitePress, Element Plus / TDesign Vue Next
|
||||
**环境:** Node.js `^20.19.0 || >=22.12.0`,pnpm `11.21.0`
|
||||
**当前版本:** `1.8.0-beta.25`
|
||||
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
|
||||
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
|
||||
|
||||
@ -15,10 +19,10 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
||||
|
||||
关键目录:
|
||||
|
||||
- `packages/` — 核心编辑器包
|
||||
- `runtime/` — Vue/React Runtime 实现
|
||||
- `vue-components/` — Vue 组件封装
|
||||
- `react-components/` — React 组件封装
|
||||
- `packages/` — 核心 npm 包(`editor`、`form`、`stage`、`core`、`cli`、`data-source`、`schema`、`form-schema`、`design`、`utils`、`dep`、`table`、`element-plus-adapter`、`tdesign-vue-next-adapter`)
|
||||
- `runtime/` — Vue/React Runtime,以及 `vue-runtime-help`、`react-runtime-help`、`tmagic-form`
|
||||
- `vue-components/` — Vue 业务组件封装
|
||||
- `react-components/` — React 业务组件封装
|
||||
- `playground/` — 演示 playground
|
||||
- `docs/` — VitePress 文档
|
||||
- `scripts/` — 构建和发布脚本
|
||||
@ -26,9 +30,12 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
||||
|
||||
## 开发约定
|
||||
|
||||
**分支策略:** dev=dev, test/prod=master
|
||||
**提交规范:** commitlint + husky,`type: 描述`
|
||||
**测试覆盖率:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%
|
||||
**分支策略(内部约定):** 日常开发走 `dev`;test/prod 对应 `master`。开源文档站与 playground 从 `dev` 发布。
|
||||
**提交规范:** commitlint(`@commitlint/config-conventional`)+ husky,格式 `type(scope): subject`,详见 `CONTRIBUTING.md`
|
||||
**测试:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%(lines)。`pnpm coverage` 执行两层硬门禁:
|
||||
1. 全仓 lines ≥ 85%(vitest `coverage.thresholds`)
|
||||
2. 工作区相对 HEAD 的 `packages/*/src` 变更逐文件 lines ≥ 85%(不含 design / UI adapter;未测新文件按 0% 计)
|
||||
pre-commit 跑 lint-staged 与 `pnpm check:type`,pre-push 跑 `pnpm coverage`。
|
||||
|
||||
**禁止事项:**
|
||||
|
||||
@ -43,14 +50,12 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
||||
pnpm pg:react # 启动 React playground
|
||||
pnpm build # 完整构建(DTS + 包)
|
||||
pnpm test # 运行测试
|
||||
pnpm coverage # 运行测试、生成覆盖率,并执行 85% 门禁
|
||||
pnpm check:type # TypeScript 类型检查
|
||||
pnpm lint-fix # ESLint 修复
|
||||
pnpm docs:dev # 启动文档开发
|
||||
pnpm release # 发版
|
||||
|
||||
## 当前状态
|
||||
|
||||
**当前里程碑:** {待人工填写}
|
||||
|
||||
## 深入阅读
|
||||
|
||||
| 文档 | 说明 |
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
"docs:build": "vitepress build docs",
|
||||
"reinstall": "pnpm clean:all && pnpm bootstrap",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage",
|
||||
"coverage": "vitest run --coverage && node scripts/check-coverage.mjs",
|
||||
"prepare": "husky",
|
||||
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
|
||||
"release": "node scripts/release.mjs"
|
||||
|
||||
133
scripts/check-coverage.mjs
Normal file
133
scripts/check-coverage.mjs
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 覆盖率硬门禁:新增/修改的 packages 源码 lines 覆盖率必须 ≥ 85%。
|
||||
* 范围与 vitest coverage include/exclude 对齐(不含 design / UI adapter)。
|
||||
*
|
||||
* node scripts/check-coverage.mjs [--root dir] [--summary file] [--files a,b]
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
export const COVERAGE_THRESHOLD = 85;
|
||||
|
||||
const GATED_SOURCE = new RegExp('^packages/(?!design/|element-plus-adapter/|tdesign-vue-next-adapter/)[^/]+/src/');
|
||||
const SOURCE_EXT = new RegExp('\\.(vue|ts|tsx|js|jsx|mjs)$');
|
||||
|
||||
const defaultRun = (command, cwd) => {
|
||||
try {
|
||||
return execSync(command, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const isGatedSourceFile = (relPath) => {
|
||||
const normalized = relPath.replaceAll('\\', '/');
|
||||
return GATED_SOURCE.test(normalized) && SOURCE_EXT.test(normalized) && !normalized.includes('/tests/');
|
||||
};
|
||||
|
||||
export const lookupFileCoverage = (summary, absPath) => {
|
||||
if (!summary || typeof summary !== 'object') return null;
|
||||
if (summary[absPath]) return summary[absPath];
|
||||
const normalized = absPath.replaceAll('\\', '/');
|
||||
if (summary[normalized]) return summary[normalized];
|
||||
const match = Object.keys(summary).find((key) => key.replaceAll('\\', '/') === normalized);
|
||||
return match ? summary[match] : null;
|
||||
};
|
||||
|
||||
export const getLinesPct = (stats) => {
|
||||
const pct = stats && stats.lines ? stats.lines.pct : undefined;
|
||||
if (typeof pct === 'number' && Number.isFinite(pct)) return pct;
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const checkChangedFilesCoverage = ({ changedFiles, summary, root, threshold = COVERAGE_THRESHOLD }) => {
|
||||
const failures = [];
|
||||
|
||||
for (const rel of changedFiles) {
|
||||
const normalized = rel.replaceAll('\\', '/');
|
||||
if (!isGatedSourceFile(normalized)) continue;
|
||||
|
||||
const abs = path.resolve(root, normalized);
|
||||
const pct = getLinesPct(lookupFileCoverage(summary, abs));
|
||||
if (pct < threshold) {
|
||||
failures.push({ file: normalized, pct });
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
export const collectGitChangedFiles = ({ run = defaultRun, cwd } = {}) => {
|
||||
const files = new Set();
|
||||
const linesOf = (command) =>
|
||||
String(run(command, cwd) || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 只检查工作区相对 HEAD 的变更,避免把已经合入分支、尚未补测的历史文件一次性卡住。
|
||||
for (const file of linesOf('git diff --name-only --diff-filter=ACMR HEAD')) files.add(file);
|
||||
for (const file of linesOf('git diff --name-only --cached --diff-filter=ACMR')) files.add(file);
|
||||
for (const file of linesOf('git ls-files --others --exclude-standard')) files.add(file);
|
||||
|
||||
return [...files];
|
||||
};
|
||||
|
||||
export const formatCoverageFailures = (failures, threshold = COVERAGE_THRESHOLD) => {
|
||||
const header = `Coverage gate failed (lines < ${threshold}%):`;
|
||||
const body = failures.map((item) => ` ${item.file}: ${item.pct}%`).join('\n');
|
||||
return body ? `${header}\n${body}` : header;
|
||||
};
|
||||
|
||||
export const runCoverageGate = ({
|
||||
root,
|
||||
summaryPath = path.join(root, 'coverage', 'coverage-summary.json'),
|
||||
changedFiles,
|
||||
run,
|
||||
log = console,
|
||||
} = {}) => {
|
||||
if (!existsSync(summaryPath)) {
|
||||
log.error(`Missing ${path.relative(root, summaryPath)}. Run pnpm coverage first.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const summary = JSON.parse(readFileSync(summaryPath, 'utf8'));
|
||||
const files = changedFiles ?? collectGitChangedFiles({ run, cwd: root });
|
||||
const failures = checkChangedFilesCoverage({ changedFiles: files, summary, root });
|
||||
|
||||
if (failures.length > 0) {
|
||||
log.error(formatCoverageFailures(failures));
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMain) {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
root: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
files: { type: 'string' },
|
||||
},
|
||||
});
|
||||
|
||||
const root = path.resolve(values.root || path.dirname(fileURLToPath(import.meta.url)), values.root ? '.' : '..');
|
||||
const summaryPath = values.summary
|
||||
? path.resolve(root, values.summary)
|
||||
: path.join(root, 'coverage', 'coverage-summary.json');
|
||||
const changedFiles = values.files
|
||||
? values.files
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
process.exit(runCoverageGate({ root, summaryPath, changedFiles }));
|
||||
}
|
||||
126
scripts/check-coverage.spec.ts
Normal file
126
scripts/check-coverage.spec.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
const cli = fileURLToPath(new URL('./check-coverage.mjs', import.meta.url));
|
||||
|
||||
const spawnCli = (args: string[]) => spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' });
|
||||
|
||||
describe('scripts/check-coverage.mjs', () => {
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true });
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
const writeSummary = (root: string, summary: Record<string, unknown>) => {
|
||||
const dir = path.join(root, 'coverage');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const summaryPath = path.join(dir, 'coverage-summary.json');
|
||||
fs.writeFileSync(summaryPath, JSON.stringify(summary));
|
||||
return summaryPath;
|
||||
};
|
||||
|
||||
test('缺少 coverage-summary.json 时失败', () => {
|
||||
const result = spawnCli(['--root', os.tmpdir(), '--summary', 'not-exist.json']);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Missing');
|
||||
});
|
||||
|
||||
test('非门禁文件即使没有覆盖率也通过', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
|
||||
tmpDirs.push(root);
|
||||
writeSummary(root, { total: { lines: { pct: 90 } } });
|
||||
|
||||
const result = spawnCli([
|
||||
'--root',
|
||||
root,
|
||||
'--files',
|
||||
'AGENTS.md,packages/design/src/Button.vue,playground/src/main.ts',
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
test('变更源码 lines < 85% 时失败', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
|
||||
tmpDirs.push(root);
|
||||
const abs = path.join(root, 'packages/editor/src/Editor.vue');
|
||||
writeSummary(root, {
|
||||
[abs]: { lines: { pct: 58.46 } },
|
||||
});
|
||||
|
||||
const result = spawnCli(['--root', root, '--files', 'packages/editor/src/Editor.vue']);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Coverage gate failed (lines < 85%):');
|
||||
expect(result.stderr).toContain('packages/editor/src/Editor.vue: 58.46%');
|
||||
});
|
||||
|
||||
test('变更源码达到 85% 时通过', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
|
||||
tmpDirs.push(root);
|
||||
const abs = path.join(root, 'packages/core/src/App.ts');
|
||||
writeSummary(root, {
|
||||
[abs]: { lines: { pct: 85 } },
|
||||
});
|
||||
|
||||
const result = spawnCli(['--root', root, '--files', 'packages/core/src/App.ts']);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
test('新文件不在 summary 中视为 0%', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
|
||||
tmpDirs.push(root);
|
||||
writeSummary(root, { total: { lines: { pct: 90 } } });
|
||||
|
||||
const result = spawnCli(['--root', root, '--files', 'packages/utils/src/brand-new.ts']);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('packages/utils/src/brand-new.ts: 0%');
|
||||
});
|
||||
|
||||
test('未传 --files 时从 git 工作区收集变更', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
|
||||
tmpDirs.push(root);
|
||||
spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' });
|
||||
fs.mkdirSync(path.join(root, 'packages/core/src'), { recursive: true });
|
||||
const rel = 'packages/core/src/App.ts';
|
||||
fs.writeFileSync(path.join(root, rel), 'export {}\n');
|
||||
writeSummary(root, {
|
||||
[path.join(root, rel)]: { lines: { pct: 90 } },
|
||||
});
|
||||
|
||||
const result = spawnCli(['--root', root]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
test('已提交文件不参与逐文件门禁', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-gate-'));
|
||||
tmpDirs.push(root);
|
||||
spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' });
|
||||
fs.mkdirSync(path.join(root, 'packages/editor/src'), { recursive: true });
|
||||
const rel = 'packages/editor/src/Editor.vue';
|
||||
fs.writeFileSync(path.join(root, rel), 'export {}\n');
|
||||
spawnSync('git', ['add', rel], { cwd: root, encoding: 'utf8' });
|
||||
spawnSync('git', ['-c', 'user.email=test@tmagic.local', '-c', 'user.name=test', 'commit', '-m', 'init'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
writeSummary(root, {
|
||||
[path.join(root, rel)]: { lines: { pct: 58.46 } },
|
||||
});
|
||||
|
||||
const result = spawnCli(['--root', root]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -31,6 +31,10 @@ export default defineConfig({
|
||||
'./packages/element-plus-adapter/**',
|
||||
'./packages/tdesign-vue-next-adapter/**',
|
||||
],
|
||||
reporter: ['text-summary', 'json-summary', 'html'],
|
||||
thresholds: {
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
@ -77,6 +81,16 @@ export default defineConfig({
|
||||
sequence: { groupOrder: 2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
name: 'scripts',
|
||||
include: ['./scripts/**/*.spec.ts'],
|
||||
environment: 'node',
|
||||
pool: 'forks',
|
||||
isolate: false,
|
||||
sequence: { groupOrder: 3 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user