feat(frontend): 引入 vitest 测试框架,为 store.js 添加 smoke test
- 新增 package.json + vitest.config.js,16 个测试覆盖核心状态初始化、 表单工厂、payload 构建、费用归一化、OAuth 候选持久化 - deploy/update.sh 新增 check_js_tests() 闸门,位于 JS 语法闸门之后、 pytest 闸门之前;缺 node/node_modules 时优雅跳过 - update.sh 依赖安装阶段新增 npm install,确保 vitest 可用 - .gitignore 忽略 node_modules/ 与 package-lock.json
This commit is contained in:
@@ -239,6 +239,35 @@ check_js_syntax() {
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
# ---- 前端 JS 单元测试闸门:syntax 通过后验证核心状态逻辑 ----
|
||||
check_js_tests() {
|
||||
local node_bin="" nvm_node
|
||||
nvm_node="$(ls -d "${HOME:-/root}"/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1 || true)"
|
||||
for _cand in ".venv/bin/node" "node" "$nvm_node"; do
|
||||
[ -n "$_cand" ] || continue
|
||||
if command -v "$_cand" >/dev/null 2>&1; then node_bin="$_cand"; break; fi
|
||||
done
|
||||
if [ -z "$node_bin" ]; then
|
||||
echo "[$(date '+%F %T')] ⚠️ 未找到 node,跳过 JS 测试闸门"
|
||||
return 0
|
||||
fi
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "[$(date '+%F %T')] ⚠️ node_modules 不存在,跳过 JS 测试闸门(需先 npm install)"
|
||||
return 0
|
||||
fi
|
||||
echo "[$(date '+%F %T')] JS 测试闸门:使用 $node_bin(npx vitest run)"
|
||||
# 用 node 的完整路径运行 npx,确保找到 node_modules/.bin/vitest
|
||||
local npx_bin
|
||||
npx_bin="$(dirname "$node_bin")/npx"
|
||||
[ -x "$npx_bin" ] || npx_bin="npx"
|
||||
if ! "$npx_bin" vitest run 2>&1; then
|
||||
echo "[$(date '+%F %T')] ❌ JS 测试失败"
|
||||
return 1
|
||||
fi
|
||||
echo "[$(date '+%F %T')] ✓ JS 测试通过"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 同步 systemd 单元文件:deploy/ 下的 .service/.timer 与 /etc/systemd/system
|
||||
# 不一致时拷贝并 reload;timer 变更额外 enable --now(新调度周期立即生效且开机自启),
|
||||
# 主服务 unit 变更额外 restart(否则光 daemon-reload 日志说已更新、实际没生效)。
|
||||
@@ -309,6 +338,20 @@ if [ -x ".venv/bin/pip" ]; then
|
||||
echo "[$(date '+%F %T')] 警告:依赖安装失败,服务可能因 ImportError 起不来;请手动执行 .venv/bin/pip install -r requirements.txt 看完整报错"
|
||||
fi
|
||||
fi
|
||||
# 前端 JS 测试依赖(vitest):package.json 存在时自动 npm install,无变化时 npm 快速跳过
|
||||
if [ -f "package.json" ]; then
|
||||
local_npm=""
|
||||
for _cand in ".venv/bin/npm" "npm"; do
|
||||
if command -v "$_cand" >/dev/null 2>&1; then local_npm="$_cand"; break; fi
|
||||
done
|
||||
if [ -n "$local_npm" ]; then
|
||||
if ! "$local_npm" install --prefer-offline -q 2>&1; then
|
||||
echo "[$(date '+%F %T')] 警告:npm install 失败,JS 测试闸门将被跳过"
|
||||
fi
|
||||
else
|
||||
echo "[$(date '+%F %T')] ⚠️ 未找到 npm,跳过前端依赖安装"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 前端 JS 语法闸门 ----
|
||||
if ! check_js_syntax; then
|
||||
@@ -327,6 +370,23 @@ if ! check_js_syntax; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 前端 JS 测试闸门 ----
|
||||
if ! check_js_tests; then
|
||||
FAIL_REASON="前端 JS 测试失败(vitest run 未通过,已中止上线)"
|
||||
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON},回滚至 ${LOCAL:0:8}"
|
||||
git reset --hard -q "$LOCAL"
|
||||
systemctl restart "$SERVICE_NAME" || true
|
||||
mark_failed "$REMOTE" "$FAIL_REASON"
|
||||
send_alert "🚨 vps-manager 部署失败:JS 测试闸门拦截
|
||||
坏版本: ${REMOTE:0:8}
|
||||
回滚至: ${LOCAL:0:8}
|
||||
失败原因: ${FAIL_REASON}
|
||||
时间: $(date '+%F %T')
|
||||
主机: cc1:${APP_DIR}
|
||||
排查: npx vitest run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 测试闸门:restart 前用 pytest 拦截坏版本 ----
|
||||
# 旧版本服务仍在运行(尚未 restart),测试失败时只需回退磁盘代码、不 restart,
|
||||
# 避免坏版本上线。使用规则要求的 .venv/bin/python 解释器。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* store.js smoke test
|
||||
*
|
||||
* 验证核心状态初始化、表单工厂、payload 构建、费用归一化、OAuth 候选持久化。
|
||||
* store.js 依赖全局 Vue / window.VpsApi / window.VpsFmt,无法直接 import,
|
||||
* 因此用 vm.runInNewContext 在受控沙箱内加载,注入最小 mock。
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
let ctx;
|
||||
|
||||
beforeAll(() => {
|
||||
/* ---- localStorage mock(跨 runInNewContext 共享) ---- */
|
||||
const _store = {};
|
||||
const localStorage = {
|
||||
getItem: (k) => (k in _store ? _store[k] : null),
|
||||
setItem: (k, v) => { _store[k] = String(v); },
|
||||
removeItem: (k) => { delete _store[k]; },
|
||||
clear: () => { for (const k of Object.keys(_store)) delete _store[k]; },
|
||||
get length() { return Object.keys(_store).length; },
|
||||
key: (i) => Object.keys(_store)[i] ?? null,
|
||||
};
|
||||
|
||||
/* ---- Vue 最小 mock:reactive / computed 直透 ---- */
|
||||
const Vue = {
|
||||
reactive: (o) => o,
|
||||
computed: (fn) => ({ value: typeof fn === 'function' ? fn() : fn }),
|
||||
};
|
||||
|
||||
/* ---- VpsApi mock ---- */
|
||||
const VpsApi = {
|
||||
getApiKey: () => '',
|
||||
setApiKey: () => {},
|
||||
get: async () => [],
|
||||
post: async () => ({}),
|
||||
put: async () => ({}),
|
||||
del: async () => ({}),
|
||||
};
|
||||
|
||||
/* ---- VpsFmt mock ---- */
|
||||
const VpsFmt = {
|
||||
normalizeOauth: (s) => (s || '').trim().toLowerCase(),
|
||||
OAUTH_PRESETS: [{ key: 'github', label: 'GitHub' }],
|
||||
};
|
||||
|
||||
/* ---- 沙箱上下文 ---- */
|
||||
ctx = vm.createContext({
|
||||
Vue,
|
||||
window: {},
|
||||
localStorage,
|
||||
navigator: { clipboard: null },
|
||||
document: {
|
||||
documentElement: { classList: { toggle: () => {}, add: () => {}, remove: () => {} } },
|
||||
createElement: () => ({ select: () => {}, value: '' }),
|
||||
body: { appendChild: () => {}, removeChild: () => {} },
|
||||
},
|
||||
confirm: () => true,
|
||||
alert: () => {},
|
||||
setTimeout: (fn) => { fn(); return 0; },
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
console,
|
||||
});
|
||||
// window 自引用,让 window.X 与裸标识 X 等价
|
||||
ctx.window = ctx;
|
||||
|
||||
/* ---- 注入全局 API / Fmt(模拟 api.js 先行加载) ---- */
|
||||
ctx.VpsApi = VpsApi;
|
||||
ctx.VpsFmt = VpsFmt;
|
||||
|
||||
/* ---- 加载 store.js ---- */
|
||||
let code = readFileSync(
|
||||
resolve(import.meta.dirname, '..', 'store.js'),
|
||||
'utf-8',
|
||||
);
|
||||
// store.js 用 const/function 声明顶层标识,vm 上下文中 const 不会挂到 context 对象上,
|
||||
// 测试无法通过 ctx.store 访问。利用 store.js 的编码约定(顶层声明顶格、函数体缩进 2 空格),
|
||||
// 将顶格的 const X = … 改写为 globalThis.X = …,顶层 function X(…) 改写为 globalThis.X = function X(…)
|
||||
code = code.replace(/^const\s+(?!\{)(\w+)\s*=/gm, 'globalThis.$1 =');
|
||||
code = code.replace(/^function\s+(\w+)/gm, 'globalThis.$1 = function $1');
|
||||
vm.runInContext(code, ctx);
|
||||
});
|
||||
|
||||
/* ---------- 测试用例 ---------- */
|
||||
|
||||
describe('store 核心状态初始化', () => {
|
||||
it('包含正确的默认视图与空数组', () => {
|
||||
expect(ctx.store.view).toBe('dashboard');
|
||||
expect(ctx.store.assets).toEqual([]);
|
||||
expect(ctx.store.allAssets).toEqual([]);
|
||||
expect(ctx.store.providers).toEqual([]);
|
||||
expect(ctx.store.accounts).toEqual([]);
|
||||
expect(ctx.store.credentials).toEqual([]);
|
||||
expect(ctx.store.loading).toBe(false);
|
||||
expect(ctx.store.error).toBe('');
|
||||
});
|
||||
|
||||
it('适配器矩阵初始为空对象', () => {
|
||||
expect(ctx.store.adapters).toEqual({ adapters: [], supported_types: [], capabilities: {} });
|
||||
});
|
||||
|
||||
it('深色模式默认为 false(测试环境无 localStorage 预设)', () => {
|
||||
expect(ctx.store.dark).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emptyAssetForm 表单工厂', () => {
|
||||
it('返回完整表单结构且默认值正确', () => {
|
||||
const form = ctx.emptyAssetForm();
|
||||
expect(form.name).toBe('');
|
||||
expect(form.asset_type).toBe('vps');
|
||||
expect(form.cost).toBe(0);
|
||||
expect(form.currency).toBe('USD');
|
||||
expect(form.status).toBe('active');
|
||||
expect(form.auto_renew).toBe(false);
|
||||
// 子表单存在
|
||||
expect(form.vps_detail).toBeDefined();
|
||||
expect(form.domain_detail).toBeDefined();
|
||||
expect(form.ai_detail).toBeDefined();
|
||||
expect(form.cloudflare_detail).toBeDefined();
|
||||
// VPS 子表单关键字段
|
||||
expect(form.vps_detail.cpu_cores).toBe(1);
|
||||
expect(form.vps_detail.ssh_port).toBe(22);
|
||||
expect(form.vps_detail.login_method).toBe('key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAssetPayload 载荷构建', () => {
|
||||
it('VPS 类型包含 vps_detail', () => {
|
||||
const form = ctx.emptyAssetForm();
|
||||
form.name = 'test-vps';
|
||||
form.provider = 'vultr';
|
||||
form.vps_detail.ip_address = '1.2.3.4';
|
||||
form.vps_detail.cpu_cores = 4;
|
||||
|
||||
const payload = ctx.buildAssetPayload(form);
|
||||
expect(payload.name).toBe('test-vps');
|
||||
expect(payload.asset_type).toBe('vps');
|
||||
expect(payload.vps_detail).toBeDefined();
|
||||
expect(payload.vps_detail.ip_address).toBe('1.2.3.4');
|
||||
expect(payload.vps_detail.cpu_cores).toBe(4);
|
||||
// 不应包含其他类型的 detail
|
||||
expect(payload.domain_detail).toBeUndefined();
|
||||
expect(payload.ai_detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('domain 类型包含 domain_detail', () => {
|
||||
const form = ctx.emptyAssetForm();
|
||||
form.asset_type = 'domain';
|
||||
form.domain_detail.domain_name = 'example.com';
|
||||
form.domain_detail.registrar = 'namesilo';
|
||||
|
||||
const payload = ctx.buildAssetPayload(form);
|
||||
expect(payload.asset_type).toBe('domain');
|
||||
expect(payload.domain_detail.domain_name).toBe('example.com');
|
||||
expect(payload.vps_detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ai_agent 类型包含 ai_detail', () => {
|
||||
const form = ctx.emptyAssetForm();
|
||||
form.asset_type = 'ai_agent';
|
||||
form.ai_detail.provider = 'openai';
|
||||
form.ai_detail.balance = 100;
|
||||
|
||||
const payload = ctx.buildAssetPayload(form);
|
||||
expect(payload.asset_type).toBe('ai_agent');
|
||||
expect(payload.ai_detail.provider).toBe('openai');
|
||||
expect(payload.ai_detail.balance).toBe(100);
|
||||
});
|
||||
|
||||
it('空值字段提交 null 而非空字符串', () => {
|
||||
const form = ctx.emptyAssetForm();
|
||||
const payload = ctx.buildAssetPayload(form);
|
||||
expect(payload.provider_id).toBeNull();
|
||||
expect(payload.remark).toBeNull();
|
||||
expect(payload.expiry_date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizedMonthly 费用归一化', () => {
|
||||
it('月付原值返回', () => {
|
||||
expect(ctx.normalizedMonthly(10, 'monthly')).toBe(10);
|
||||
});
|
||||
|
||||
it('年付除以 12', () => {
|
||||
expect(ctx.normalizedMonthly(120, 'yearly')).toBe(10);
|
||||
});
|
||||
|
||||
it('季付除以 3', () => {
|
||||
expect(ctx.normalizedMonthly(30, 'quarterly')).toBe(10);
|
||||
});
|
||||
|
||||
it('零或空值返回 0', () => {
|
||||
expect(ctx.normalizedMonthly(0, 'monthly')).toBe(0);
|
||||
expect(ctx.normalizedMonthly(null, 'yearly')).toBe(0);
|
||||
expect(ctx.normalizedMonthly(undefined, '')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth 候选持久化(_readOauthList / _writeOauthList)', () => {
|
||||
it('读取不存在的 key 返回空数组', () => {
|
||||
expect(ctx._readOauthList('nonexistent_key')).toEqual([]);
|
||||
});
|
||||
|
||||
it('写入后读取一致', () => {
|
||||
ctx._writeOauthList('test_oauth_key', ['github', 'gitlab']);
|
||||
expect(ctx._readOauthList('test_oauth_key')).toEqual(['github', 'gitlab']);
|
||||
});
|
||||
|
||||
it('写入截断至 24 条', () => {
|
||||
const big = Array.from({ length: 30 }, (_, i) => `provider_${i}`);
|
||||
ctx._writeOauthList('test_truncate', big);
|
||||
expect(ctx._readOauthList('test_truncate')).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('过滤非字符串与空值', () => {
|
||||
ctx._writeOauthList('test_filter', ['ok', '', null, 123, 'fine']);
|
||||
// _writeOauthList 只做 slice,_readOauthList 做类型过滤
|
||||
const result = ctx._readOauthList('test_filter');
|
||||
expect(result).toEqual(['ok', '', null, 123, 'fine'].filter(x => typeof x === 'string' && x));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['static/js/**/*.test.js'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user