From d6a5ec8b7195a486e17b89dbb0a3d035f151d208 Mon Sep 17 00:00:00 2001 From: gouki Date: Wed, 14 Oct 2026 12:51:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E5=BC=95=E5=85=A5=20vitest?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E6=A1=86=E6=9E=B6=EF=BC=8C=E4=B8=BA=20sto?= =?UTF-8?q?re.js=20=E6=B7=BB=E5=8A=A0=20smoke=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- deploy/update.sh | 60 ++++++++ package.json | 9 ++ static/js/__tests__/store.test.js | 226 ++++++++++++++++++++++++++++++ vitest.config.js | 7 + 4 files changed, 302 insertions(+) create mode 100644 package.json create mode 100644 static/js/__tests__/store.test.js create mode 100644 vitest.config.js diff --git a/deploy/update.sh b/deploy/update.sh index a6253b3..808a74c 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -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 解释器。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b1a593 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3" + } +} diff --git a/static/js/__tests__/store.test.js b/static/js/__tests__/store.test.js new file mode 100644 index 0000000..9bf4167 --- /dev/null +++ b/static/js/__tests__/store.test.js @@ -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)); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..d81a4c1 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['static/js/**/*.test.js'], + }, +});