test(fe): 阶段0止血——pre-commit守卫+postSafe+jest.config+CI脚本+otcformat精度修复

This commit is contained in:
hjhan
2026-07-29 22:33:45 +08:00
parent daaf993126
commit 6b32f254b1
8 changed files with 514 additions and 1 deletions
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# =============================================================================
# pre-commit — 提交前自动守卫
# =============================================================================
# 做两件事(任一失败即阻断提交):
# 1. guard_arch.js — 扫描新增/修改行,禁止在 Vue 组件里内联金额计算
# 2. jest — 跑前端单测,确保不引入回归
#
# 安装方式(开发者只需执行一次):
# cd <repo-root>
# cp YLErpWeb/fe-tests/hooks/pre-commit .git/hooks/pre-commit
# chmod +x .git/hooks/pre-commit
#
# 跳过方式(紧急情况,不推荐):
# git commit --no-verify
#
# CI 也应调用本脚本(或等价的 npm test + node guard_arch.js)。
# =============================================================================
set -euo pipefail
# 定位仓库根目录
REPO_ROOT=$(git rev-parse --show-toplevel)
FE_TESTS_DIR="$REPO_ROOT/YLErpWeb/fe-tests"
# 自动加载 nvm(非交互 shell 中 nvm 不会自动加载)
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ] && ! command -v node &>/dev/null; then
source "$NVM_DIR/nvm.sh" 2>/dev/null || true
# 如果有 .nvmrc 就用它,否则用默认版本
if [ -f "$REPO_ROOT/.nvmrc" ]; then
nvm use --silent 2>/dev/null || true
else
# 尝试用已安装的最新版本
nvm use --silent --lts 2>/dev/null || nvm use --silent node 2>/dev/null || true
fi
fi
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}[pre-commit] 开始提交前守卫检查...${NC}"
# -----------------------------------------------------------------------------
# 1. guard_arch.js — 架构闸门(零依赖,纯 Node)
# -----------------------------------------------------------------------------
# 只在存在 node 时运行
if command -v node &>/dev/null; then
echo -e "${YELLOW}[pre-commit] ① 架构闸门 (guard_arch.js)...${NC}"
if node "$FE_TESTS_DIR/guard_arch.js"; then
echo -e "${GREEN}[pre-commit] ✅ 架构闸门通过${NC}"
else
echo -e "${RED}[pre-commit] ❌ 架构闸门未通过,提交被阻断${NC}"
echo -e "${YELLOW} 请把金额/精度计算抽到 *Calc 模块(参考 swapCalc.js${NC}"
exit 1
fi
else
echo -e "${YELLOW}[pre-commit] ⚠️ 未找到 node,跳过架构闸门(建议安装 nvm + node 20${NC}"
fi
# -----------------------------------------------------------------------------
# 2. jest — 前端单测(需要先 npm install
# -----------------------------------------------------------------------------
if [ -f "$FE_TESTS_DIR/node_modules/.bin/jest" ]; then
echo -e "${YELLOW}[pre-commit] ② 前端单测 (jest)...${NC}"
# 只在有 JS 源文件改动时才跑测试
JS_CHANGED=$(git diff --cached --name-only --diff-filter=ACM -- "*.js" | grep "wwwroot/Scripts/" || true)
if [ -z "$JS_CHANGED" ]; then
echo -e "${GREEN}[pre-commit] ⏭️ 无前端源文件改动,跳过单测${NC}"
else
echo -e "${YELLOW} 改动的前端文件:${NC}"
echo "$JS_CHANGED" | sed 's/^/ /'
if (cd "$FE_TESTS_DIR" && npx jest --no-coverage --silent 2>&1); then
echo -e "${GREEN}[pre-commit] ✅ 前端单测通过${NC}"
else
echo -e "${RED}[pre-commit] ❌ 前端单测未通过,提交被阻断${NC}"
echo -e "${YELLOW} 修复测试后重新提交,或用 git commit --no-verify 跳过(不推荐)${NC}"
exit 1
fi
fi
else
echo -e "${YELLOW}[pre-commit] ⚠️ 未安装 jestnode_modules 缺失),跳过单测${NC}"
echo -e "${YELLOW} 建议:cd YLErpWeb/fe-tests && npm install${NC}"
fi
echo -e "${GREEN}[pre-commit] ✅ 所有守卫检查通过,继续提交${NC}"
exit 0
+49
View File
@@ -0,0 +1,49 @@
/**
* jest.config.js — 前端测试配置
* ============================================================================
* 运行方式:cd YLErpWeb/fe-tests && npx jest
*
* 设计要点:
* - testEnvironment: jsdom — 模拟浏览器环境(jQuery / Vue 组件测试需要)
* - testMatch: 只跑 *.test.js,不跑 _shim_run.js / guard_arch.js
* - collectCoverage: 默认不开(影响速度),用 --coverage 手动开启
* - coverageThreshold: 新代码覆盖率门槛,逐步提高
* - moduleDirectories: 让测试文件能 require('jquery') 等npm包
* ============================================================================
*/
module.exports = {
// 保持 node 环境:部分测试文件自行 require('jsdom') 搭建 DOM
// 若改为 jsdom 环境会与之冲突(TextEncoder 未定义等问题)。
testEnvironment: 'node',
// 只匹配 *.test.js
testMatch: ['**/*.test.js'],
// 模块查找路径(node_modules + wwwroot/Scripts 便于 require 源文件)
moduleDirectories: ['node_modules', '../wwwroot/Scripts'],
// 覆盖率配置(--coverage 时生效)
collectCoverageFrom: [
'../wwwroot/Scripts/app/swaptrade/swapCalc.js',
'../wwwroot/Scripts/fast/fastVue.base.js',
// 逐步加入更多文件
],
coverageDirectory: 'coverage',
// 覆盖率门槛(初始宽松,逐步收紧)
coverageThreshold: {
global: {
branches: 40,
functions: 50,
lines: 50,
statements: 50,
},
},
// 不转换 node_modules(本项目 JS 是原生 ES5,不需要 babel
transform: {},
// verbose 输出
verbose: false,
};
+144
View File
@@ -0,0 +1,144 @@
/**
* main.postSafe.test.js — main.postSafe() 错误守卫封装测试
* ============================================================================
* 目的:验证 postSafe 正确将 jQuery Deferred 的 .done/.fail 映射到 Promise 的 resolve/reject
* 防止「业务错误走 reject、.done 不触发」陷阱复发。
*
* 覆盖场景:
* 1. 成功请求 → Promise resolve
* 2. 业务错误(resp.success === false)→ Promise reject
* 3. 网络异常 → Promise reject
* 4. async/await 语法兼容性
* 5. 与 main.post 行为对比(陷阱复现)
*
* 运行:cd YLErpWeb/fe-tests && npx jest main.postSafe
* ============================================================================
*/
// 搭建最小 DOM 环境
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
// Mock main 对象(与 main.js 中的逻辑等价)
var main = {};
// main.post: 返回 jQuery Deferred promise,测试中通过 _lastDeferred 控制结果
main.post = function (url, data, options) {
var doneCb = [], failCb = [];
var promise = {
done: function (cb) { doneCb.push(cb); return promise; },
fail: function (cb) { failCb.push(cb); return promise; },
};
main._lastDeferred = {
resolve: function (resp) { doneCb.forEach(function (cb) { cb(resp); }); },
reject: function (resp) { failCb.forEach(function (cb) { cb(resp); }); },
promise: function () { return promise; },
};
return main._lastDeferred.promise();
};
// main.postSafe: 与 main.js 中完全相同的实现
main.postSafe = function (url, data, options) {
return new Promise(function (resolve, reject) {
main.post(url, data, options)
.done(function (resp) { resolve(resp); })
.fail(function (resp) { reject(resp); });
});
};
describe('main.postSafe — Promise 封装守卫', function () {
test('成功请求 → Promise resolve(resp)', function () {
var p = main.postSafe('/api/test', { foo: 1 });
// postSafe 内部已调用 main.post_lastDeferred 已就绪
main._lastDeferred.resolve({ success: true, data: 'ok' });
return p.then(function (resp) {
expect(resp.success).toBe(true);
expect(resp.data).toBe('ok');
});
});
test('业务错误(resp.success===false)→ Promise reject(resp)', function () {
var p = main.postSafe('/api/test', { foo: 1 });
main._lastDeferred.reject({ success: false, msg: '算不出来' });
return p.catch(function (resp) {
expect(resp.success).toBe(false);
expect(resp.msg).toBe('算不出来');
});
});
test('网络异常 → Promise reject', function () {
var p = main.postSafe('/api/test', { foo: 1 });
main._lastDeferred.reject({ errcode: 500, msg: '请求失败' });
return p.catch(function (resp) {
expect(resp.errcode).toBe(500);
});
});
test('async/await 语法兼容 — 成功路径', async function () {
var p = main.postSafe('/api/test', { foo: 1 });
main._lastDeferred.resolve({ success: true, value: 42 });
var resp = await p;
expect(resp.success).toBe(true);
expect(resp.value).toBe(42);
});
test('async/await 语法兼容 — 失败路径', async function () {
var p = main.postSafe('/api/test', { foo: 1 });
main._lastDeferred.reject({ success: false, msg: '计算失败' });
var caught = null;
try {
await p;
} catch (resp) {
caught = resp;
}
expect(caught).not.toBeNull();
expect(caught.success).toBe(false);
expect(caught.msg).toBe('计算失败');
});
});
describe('main.postSafe — 与 main.post 行为对比(陷阱复现)', function () {
test('main.post 的 .done 在业务错误时不触发(陷阱复现)', function () {
var doneCalled = false;
var failCalled = false;
main.post('/api/test', {}).done(function () {
doneCalled = true;
}).fail(function () {
failCalled = true;
});
// 业务错误 → reject
main._lastDeferred.reject({ success: false });
expect(doneCalled).toBe(false); // .done 没触发!这就是陷阱
expect(failCalled).toBe(true); // .fail 触发了
});
test('main.postSafe 的 .then 在业务错误时不触发(安全)', function () {
var thenCalled = false;
var caught = null;
var p = main.postSafe('/api/test', {}).then(function () {
thenCalled = true;
}).catch(function (resp) {
caught = resp;
});
// 业务错误 → reject
main._lastDeferred.reject({ success: false, msg: 'biz error' });
// Promise 是微任务,需要等一轮
return p.then(function () {
expect(thenCalled).toBe(false); // .then 没触发
expect(caught).not.toBeNull(); // .catch 触发了
expect(caught.msg).toBe('biz error');
});
});
});
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# =============================================================================
# run-ci-checks.sh — CI 守卫入口脚本
# =============================================================================
# Jenkins / GitLab CI / GitHub Actions 等任一 CI 系统调用本脚本即可。
#
# 做三件事(任一失败即 exit 1,阻断 CI):
# 1. guard_arch.js — 架构闸门:禁止新增内联金额计算
# 2. jest — 前端单测:防止回归
# 3. rebuild-bundles — bundle 校验:确保产物与源文件一致
#
# 用法:
# bash YLErpWeb/fe-tests/run-ci-checks.sh
#
# 前置条件:
# - Node.js 18+ 已安装(CI 环境通常自带)
# - cd YLErpWeb/fe-tests && npm install 已执行
# =============================================================================
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
FE_TESTS_DIR="$REPO_ROOT/YLErpWeb/fe-tests"
YLERPWEB_DIR="$REPO_ROOT/YLErpWeb"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAILED=0
run_check() {
local name="$1"
local cmd="$2"
echo -e "\n${YELLOW}========================================${NC}"
echo -e "${YELLOW}CI Check: ${name}${NC}"
echo -e "${YELLOW}========================================${NC}"
if eval "$cmd"; then
echo -e "${GREEN}${name} PASSED${NC}"
else
echo -e "${RED}${name} FAILED${NC}"
FAILED=$((FAILED + 1))
fi
}
# -----------------------------------------------------------------------------
# Check 1: 架构闸门
# -----------------------------------------------------------------------------
if command -v node &>/dev/null; then
run_check "guard_arch.js (架构闸门)" \
"node '$FE_TESTS_DIR/guard_arch.js'"
else
echo -e "${YELLOW}⚠️ node 未安装,跳过架构闸门${NC}"
fi
# -----------------------------------------------------------------------------
# Check 2: 前端单测
# -----------------------------------------------------------------------------
if [ -f "$FE_TESTS_DIR/node_modules/.bin/jest" ]; then
run_check "jest (前端单测)" \
"cd '$FE_TESTS_DIR' && npx jest --no-coverage --silent"
else
echo -e "${YELLOW}⚠️ jest 未安装,尝试自动安装...${NC}"
if command -v npm &>/dev/null; then
(cd "$FE_TESTS_DIR" && npm install --silent)
run_check "jest (前端单测)" \
"cd '$FE_TESTS_DIR' && npx jest --no-coverage --silent"
else
echo -e "${RED}❌ npm 不可用,无法运行前端单测${NC}"
FAILED=$((FAILED + 1))
fi
fi
# -----------------------------------------------------------------------------
# Check 3: Bundle 校验(可选,需要 python3
# -----------------------------------------------------------------------------
if [ -f "$YLERPWEB_DIR/rebuild-bundles.py" ] && command -v python3 &>/dev/null; then
run_check "bundle 校验 (rebuild-bundles.py --verify)" \
"cd '$YLERPWEB_DIR' && python3 rebuild-bundles.py --verify"
else
echo -e "${YELLOW}⚠️ python3 或 rebuild-bundles.py 不可用,跳过 bundle 校验${NC}"
fi
# -----------------------------------------------------------------------------
# 汇总
# -----------------------------------------------------------------------------
echo -e "\n${YELLOW}========================================${NC}"
echo -e "${YELLOW}CI Checks Summary${NC}"
echo -e "${YELLOW}========================================${NC}"
if [ "$FAILED" -eq 0 ]; then
echo -e "${GREEN}✅ All checks passed${NC}"
exit 0
else
echo -e "${RED}${FAILED} check(s) failed${NC}"
exit 1
fi