Commit 5c7916dd authored by liguangyu06's avatar liguangyu06
Browse files

增加本地 git 门禁与通关凭证,避免未跑通的用例脚本进入仓库。

parent 0559c658
...@@ -6,8 +6,9 @@ ...@@ -6,8 +6,9 @@
```bash ```bash
cp .env.example .env # 填写 TEST_USER / TEST_PASS cp .env.example .env # 填写 TEST_USER / TEST_PASS
npm ci # postinstall 自动安装 chromium 到 .playwright-browsers/ npm ci # postinstall 安装 chromium;prepare 安装 git 门禁 hook
npm run typecheck # CI 同样会跑 tsc --noEmit npm run typecheck # CI 同样会跑 tsc --noEmit
npm run gate # 未执行通过的用例脚本不能 commit/push,见 docs/git-gate.md
npx playwright test npx playwright test
npx playwright test --ui npx playwright test --ui
``` ```
......
# 提交门禁(企业标准)
未执行通过的 UI 自动化脚本**不能**直接 `git commit` / `git push`。
## 分层
| 层 | 卡什么 | 谁执行 |
|----|--------|--------|
| 本地 pre-commit | typecheck、禁止 `test.only`、CSV↔spec 一致、相关 spec 须有通关凭证 | `.git/hooks/pre-commit` |
| 本地 pre-push | 同上,范围是将推送的提交 | `.git/hooks/pre-push` |
| Jenkins | `Typecheck` + 套件执行失败则构建失败 | `Jenkinsfile` |
GitLab 流水线当前 `when: never`(Runner 无 npm),**不能**当执行门禁。合并前以 Jenkins 结果为准。
## 通关凭证
Playwright 跑完后由 `reporters/gate.reporter.ts` 写入 `reports/gate/passed/`(已 gitignore):
- 至少 1 条 **passed** 才记凭证
- 有失败 / 中断会删凭证
- 全 skip 不算通过
- spec 或其 Page/Locator/Data 变更后 hash 失效,必须重跑
## 命令
```bash
npm run hooks:install # npm ci 也会装
npm run gate # 检查工作区变更
npm run test:basic-notice
npm run test:basic:all-pages
```
## 放行
仅事故应急:`SKIP_GIT_GATE=1 git commit ...`,须在说明里写原因。默认禁止。
...@@ -11,6 +11,11 @@ ...@@ -11,6 +11,11 @@
"test:headed": "playwright test --headed", "test:headed": "playwright test --headed",
"test:debug": "playwright test --debug", "test:debug": "playwright test --debug",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"prepare": "node scripts/install-git-hooks.mjs",
"hooks:install": "node scripts/install-git-hooks.mjs",
"gate": "node scripts/git-gate.mjs --mode=check",
"gate:commit": "node scripts/git-gate.mjs --mode=commit",
"gate:push": "node scripts/git-gate.mjs --mode=push",
"test:ci": "node scripts/run-ci.mjs", "test:ci": "node scripts/run-ci.mjs",
"test:ci:smoke": "node scripts/run-ci.mjs --suite=smoke", "test:ci:smoke": "node scripts/run-ci.mjs --suite=smoke",
"test:ci:nightly": "node scripts/run-ci.mjs --suite=nightly", "test:ci:nightly": "node scripts/run-ci.mjs --suite=nightly",
......
...@@ -65,6 +65,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] { ...@@ -65,6 +65,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] {
} }
reporters.push(['./reporters/flake.reporter.ts']); reporters.push(['./reporters/flake.reporter.ts']);
reporters.push(['./reporters/gate.reporter.ts']);
return reporters; return reporters;
} }
......
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
type Counts = { passed: number; failed: number; skipped: number; interrupted: number };
type StampApi = {
recordSpecResult: (
specRel: string,
counts: Counts,
) => { recorded: boolean; reason?: string };
};
/**
* 用例文件全部通过后写入本地门禁凭证。
* 失败或未实际执行(全 skip)会清除凭证,阻止提交。
*/
export default class GateReporter implements Reporter {
private readonly byFile = new Map<string, Counts>();
private api: StampApi | undefined;
private async loadApi(): Promise<StampApi> {
if (this.api) return this.api;
const url = pathToFileURL(path.join(process.cwd(), 'scripts/lib/gate-stamp.mjs')).href;
this.api = (await import(url)) as StampApi;
return this.api;
}
onTestEnd(test: TestCase, result: TestResult): void {
if (test.parent.project()?.name === 'setup') return;
const file = test.location.file;
if (!file.includes(`${path.sep}tests${path.sep}`) || !file.endsWith('.spec.ts')) return;
const counts = this.byFile.get(file) ?? { passed: 0, failed: 0, skipped: 0, interrupted: 0 };
if (result.status === 'passed') counts.passed += 1;
else if (result.status === 'skipped') counts.skipped += 1;
else if (result.status === 'interrupted') counts.interrupted += 1;
else counts.failed += 1;
this.byFile.set(file, counts);
}
async onEnd(): Promise<void> {
const api = await this.loadApi();
for (const [abs, counts] of this.byFile) {
const specRel = path.relative(process.cwd(), abs).split(path.sep).join('/');
api.recordSpecResult(specRel, counts);
}
}
}
#!/usr/bin/env node
/**
* 企业提交门禁:未执行通过的用例脚本不得 commit / push。
*
* node scripts/git-gate.mjs --mode=commit
* node scripts/git-gate.mjs --mode=push
* node scripts/git-gate.mjs --mode=check
*
* 紧急放行(须说明原因,会打印警告):SKIP_GIT_GATE=1
*/
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { ROOT, toPosix, verifyStamp } from './lib/gate-stamp.mjs';
const ONLY_RE = /\b(?:test|describe)\.only\s*\(/;
/** CSV 文件名 → spec 映射(按需扩展;基础管理四页暂不入库故不在此登记)。 */
const CSV_SPEC = [];
function parseArgs(argv) {
const out = { mode: 'check', files: [] };
for (const arg of argv) {
if (arg.startsWith('--mode=')) out.mode = arg.slice(7);
else if (arg.startsWith('--files=')) out.files = arg.slice(8).split(',').filter(Boolean);
}
return out;
}
function gitLines(args) {
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
if (result.status !== 0) return [];
return (result.stdout || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function stagedFiles() {
return gitLines(['diff', '--cached', '--name-only', '--diff-filter=ACMR']).map(toPosix);
}
function pushFiles() {
const upstream = gitLines(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])[0];
const range = upstream ? `${upstream}...HEAD` : 'HEAD';
return gitLines(['diff', '--name-only', '--diff-filter=ACMR', range]).map(toPosix);
}
function findSpecsImporting(relFile) {
const needle = path.posix.basename(relFile).replace(/\.(ts|mjs|js)$/, '');
const specs = gitLines(['ls-files', 'tests/**/*.spec.ts']).map(toPosix);
const hits = [];
for (const spec of specs) {
const text = readFileSync(path.join(ROOT, spec), 'utf8');
if (text.includes(needle)) hits.push(spec);
}
return hits;
}
function csvToSpec(file) {
const hits = [];
for (const [key, spec] of CSV_SPEC) {
if (file.includes(key)) hits.push(spec);
}
return hits;
}
function resolveAffectedSpecs(files) {
const specs = new Set();
for (const file of files) {
if (/^tests\/.+\.spec\.ts$/.test(file)) specs.add(file);
if (file.startsWith('pages/') || file.startsWith('locators/') || file.startsWith('data/')) {
for (const spec of findSpecsImporting(file)) specs.add(spec);
}
if (file.startsWith('testcase/') && file.endsWith('.csv')) {
for (const spec of csvToSpec(file)) specs.add(spec);
}
}
return [...specs].sort();
}
function assertNoOnly(files) {
const offenders = [];
for (const file of files) {
if (!file.endsWith('.ts')) continue;
const text = readFileSync(path.join(ROOT, file), 'utf8');
if (ONLY_RE.test(text)) offenders.push(file);
}
if (offenders.length) {
throw new Error(`禁止提交 test.only / describe.only:\n ${offenders.join('\n ')}`);
}
}
function assertCsvSpecSync(files) {
if (!CSV_SPEC.length) return;
const need = files.some((file) => file.startsWith('testcase/'));
if (!need) return;
throw new Error('testcase CSV 变更需配置 CSV_SPEC 与校验脚本后再提交');
}
function runTypecheck() {
const result = spawnSync('npm', ['run', 'typecheck'], { cwd: ROOT, stdio: 'inherit', shell: process.platform === 'win32' });
if (result.status !== 0) {
throw new Error('typecheck 未通过,拒绝提交');
}
}
function printBlock(spec, reason) {
const script = spec
.replace(/^tests\/spd\//, '')
.replace(/\.spec\.ts$/, '');
return ` ${spec}
原因:${reason}
请先执行并通过:npx playwright test ${spec} --project=chromium
或:npm run test:${script.replace(/\.stat-.+$/, '')}`;
}
function checkStamps(specs) {
const blocked = [];
for (const spec of specs) {
const result = verifyStamp(spec);
if (!result.ok) blocked.push(printBlock(spec, result.reason));
}
if (blocked.length) {
throw new Error(
`门禁拒绝:以下用例脚本未执行通过,不能提交/推送到 git。\n${blocked.join('\n')}\n\n说明:全 skip 不算通过;失败会清除凭证。紧急放行仅允许 SKIP_GIT_GATE=1(须同步说明原因)。`,
);
}
}
function main() {
if (process.env.SKIP_GIT_GATE === '1') {
console.warn('[gate] SKIP_GIT_GATE=1,已跳过本地门禁(不符合企业默认标准)');
return;
}
const args = parseArgs(process.argv.slice(2));
const files =
args.files.length > 0
? args.files.map(toPosix)
: args.mode === 'push'
? pushFiles()
: args.mode === 'commit'
? stagedFiles()
: [
...gitLines(['diff', '--name-only', '--diff-filter=ACMR', 'HEAD']),
...gitLines(['ls-files', '--others', '--exclude-standard']),
].map(toPosix);
if (!files.length) {
console.log('[gate] 无待检文件');
return;
}
assertNoOnly(files);
const tsChanged = files.some((file) => file.endsWith('.ts') || file.endsWith('.mjs'));
if (tsChanged) runTypecheck();
assertCsvSpecSync(files);
const specs = resolveAffectedSpecs(files);
if (!specs.length) {
console.log('[gate] 未触及可执行用例脚本,静态检查通过');
return;
}
console.log(`[gate] 需通关凭证的 spec:\n ${specs.join('\n ')}`);
checkStamps(specs);
console.log('[gate] 通关:相关用例已执行通过,允许进入 git');
}
try {
main();
} catch (error) {
console.error(`\n[gate] ${error.message}\n`);
process.exit(1);
}
#!/bin/sh
# 企业门禁:未执行通过的用例脚本不能 commit
if [ "$SKIP_GIT_GATE" = "1" ]; then
echo "[gate] SKIP_GIT_GATE=1,跳过 pre-commit(不符合默认标准)"
exit 0
fi
exec node scripts/git-gate.mjs --mode=commit
#!/bin/sh
# 企业门禁:未执行通过的用例脚本不能 push
if [ "$SKIP_GIT_GATE" = "1" ]; then
echo "[gate] SKIP_GIT_GATE=1,跳过 pre-push(不符合默认标准)"
exit 0
fi
exec node scripts/git-gate.mjs --mode=push
#!/usr/bin/env node
/** 把仓库内 scripts/githooks 安装到 .git/hooks,不改 git config。 */
import { chmodSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const SRC = path.join(ROOT, 'scripts/githooks');
const DEST = path.join(ROOT, '.git/hooks');
if (!existsSync(path.join(ROOT, '.git'))) {
console.log('[hooks] 非 git 仓库,跳过');
process.exit(0);
}
mkdirSync(DEST, { recursive: true });
for (const name of ['pre-commit', 'pre-push']) {
const from = path.join(SRC, name);
const to = path.join(DEST, name);
copyFileSync(from, to);
try {
chmodSync(to, 0o755);
} catch {
// Windows 可忽略
}
console.log(`[hooks] installed ${name}`);
}
/**
* 本地门禁通关凭证:用例文件执行通过后写入 reports/gate/passed/。
* 凭证不入库;源码变更后 hash 失效,必须重跑。
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
export const GATE_DIR = path.join(ROOT, 'reports/gate/passed');
const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
export function toPosix(file) {
return file.split(path.sep).join('/');
}
export function stampPath(specRel) {
const safe = toPosix(specRel).replace(/[\\/]/g, '__');
return path.join(GATE_DIR, `${safe}.json`);
}
function resolveImport(fromFile, spec) {
const base = path.resolve(path.dirname(path.join(ROOT, fromFile)), spec);
for (const candidate of [base, `${base}.ts`, `${base}.mjs`, `${base}.js`, path.join(base, 'index.ts')]) {
const rel = toPosix(path.relative(ROOT, candidate));
if (existsSync(candidate) && !rel.startsWith('..')) return rel;
}
return '';
}
/** spec + 其本地 import 闭包(pages / locators / data / fixtures 等)。 */
export function collectSourceFiles(specRel) {
const start = toPosix(specRel);
const files = new Set();
const queue = [start];
while (queue.length) {
const current = queue.shift();
if (!current || files.has(current)) continue;
const abs = path.join(ROOT, current);
if (!existsSync(abs)) continue;
files.add(current);
if (!/\.(ts|mjs|js)$/.test(current)) continue;
const text = readFileSync(abs, 'utf8');
for (const match of text.matchAll(IMPORT_RE)) {
const next = resolveImport(current, match[1]);
if (next) queue.push(next);
}
}
return [...files].sort();
}
export function hashSources(files) {
const hash = createHash('sha256');
for (const file of files) {
const abs = path.join(ROOT, file);
hash.update(file);
hash.update('\0');
hash.update(existsSync(abs) ? readFileSync(abs) : Buffer.from(''));
hash.update('\0');
}
return hash.digest('hex');
}
export function readStamp(specRel) {
const file = stampPath(specRel);
if (!existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return null;
}
}
export function verifyStamp(specRel) {
const stamp = readStamp(specRel);
if (!stamp) {
return { ok: false, reason: '尚无执行通过凭证' };
}
const files = collectSourceFiles(specRel);
const hash = hashSources(files);
if (stamp.hash !== hash) {
return { ok: false, reason: '源码已变更,旧凭证失效' };
}
if ((stamp.failed ?? 0) > 0 || (stamp.interrupted ?? 0) > 0) {
return { ok: false, reason: '最近一次执行存在失败' };
}
if ((stamp.passed ?? 0) < 1) {
return { ok: false, reason: '没有实际通过的用例(全 skip / 未执行不算通过)' };
}
return { ok: true, stamp };
}
export function writeStamp(specRel, counts) {
const files = collectSourceFiles(specRel);
const payload = {
spec: toPosix(specRel),
hash: hashSources(files),
sources: files,
passed: counts.passed ?? 0,
failed: counts.failed ?? 0,
skipped: counts.skipped ?? 0,
interrupted: counts.interrupted ?? 0,
at: new Date().toISOString(),
};
mkdirSync(GATE_DIR, { recursive: true });
writeFileSync(stampPath(specRel), `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
return payload;
}
export function clearStamp(specRel) {
const file = stampPath(specRel);
if (existsSync(file)) rmSync(file);
}
export function recordSpecResult(specRel, counts) {
const failed = (counts.failed ?? 0) + (counts.interrupted ?? 0);
if (failed > 0 || (counts.passed ?? 0) < 1) {
clearStamp(specRel);
return { recorded: false, reason: failed > 0 ? 'failed' : 'not-executed' };
}
writeStamp(specRel, counts);
return { recorded: true };
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment