From a63fcb786f3e060f9f98fbb05bd331330695e84a Mon Sep 17 00:00:00 2001 From: gouki Date: Thu, 15 Oct 2026 21:20:52 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=E9=AB=98=E9=A2=91=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=20JS=20=E6=96=87=E4=BB=B6=E8=A1=A5=E5=85=85=20smoke?= =?UTF-8?q?=20test=20=E5=B9=B6=E5=BB=BA=E7=AB=8B=20js-test-gate=20Rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增测试: - api.test.js: 28 个用例覆盖 VpsFmt 纯工具函数(OAuth 归一化、倒计时文案、费用文案、徽章样式) - app.test.js: 15 个用例覆盖 NAVS/VIEW_MAP/VALID_VIEWS 常量和 _viewFromHash 路由解析 - modals.test.js: 3 个用例验证 9 个模态框组件定义完整性 规则与钩子: - 新建 .qoder/rules/js-test-gate.md:编辑 static/js/ 下 JS 文件后运行 vitest - 更新 post-edit-check.sh:JS 编辑后 node --check + vitest(排除 vendored 文件) 部署闸门 check_js_tests(update.sh)已有 vitest run 步骤,现在测试覆盖从 1/7 文件提升到 4/7 --- .qoder/hooks/post-edit-check.sh | 12 +- .qoder/rules/js-test-gate.md | 18 +++ static/js/__tests__/api.test.js | 207 ++++++++++++++++++++++++ static/js/__tests__/app.test.js | 242 +++++++++++++++++++++++++++++ static/js/__tests__/modals.test.js | 62 ++++++++ 5 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 .qoder/rules/js-test-gate.md create mode 100644 static/js/__tests__/api.test.js create mode 100644 static/js/__tests__/app.test.js create mode 100644 static/js/__tests__/modals.test.js diff --git a/.qoder/hooks/post-edit-check.sh b/.qoder/hooks/post-edit-check.sh index 423d718..c1ad6db 100755 --- a/.qoder/hooks/post-edit-check.sh +++ b/.qoder/hooks/post-edit-check.sh @@ -1,7 +1,7 @@ #!/bin/bash # Post-edit hook: 自动验证编辑后的文件 # - Python 文件 → 运行 pytest -# - JavaScript 文件 → 运行 node --check +# - JavaScript 文件 → node --check 语法检查 + vitest 单元测试 input=$(cat) file_path=$(echo "$input" | jq -r '.tool_input.file_path') @@ -17,7 +17,15 @@ case "$rel_path" in ;; static/js/*.js) echo "🔍 检测到 JavaScript 文件变更: $rel_path" >&2 - node --check "$file_path" + node --check "$file_path" || exit 1 + # 语法通过后运行 vitest(排除 vendored 文件) + case "$rel_path" in + */vue.global.prod.js|*/tailwind.js|*/chart.umd.js) ;; + *) + cd "$QODER_PROJECT_DIR" || exit 0 + npx vitest run + ;; + esac ;; esac diff --git a/.qoder/rules/js-test-gate.md b/.qoder/rules/js-test-gate.md new file mode 100644 index 0000000..411697d --- /dev/null +++ b/.qoder/rules/js-test-gate.md @@ -0,0 +1,18 @@ +# vps-manager JavaScript 测试验证规则 + +## 触发条件 + +每次编辑 `vps-manager/static/js/` 下的 `.js` 文件后自动运行(排除第三方 vendored 文件如 `vue.global.prod.js`、`tailwind.js`、`chart.umd.js`)。 + +## 执行命令 + +```bash +cd vps-manager && npx vitest run +``` + +## 规则 + +- 全部通过才视为改动完成,失败则先修复再提交 +- 测试文件位于 `static/js/__tests__/` 目录,vitest 配置在 `vitest.config.js` +- 当前覆盖:`store.test.js`(状态管理)、`api.test.js`(工具函数)、`app.test.js`(导航路由)、`modals.test.js`(组件定义) +- 部署闸门 `deploy/update.sh` 的 `check_js_tests` 步骤会在 cc1 自动更新时运行 `npx vitest run`,失败则回滚并告警 diff --git a/static/js/__tests__/api.test.js b/static/js/__tests__/api.test.js new file mode 100644 index 0000000..30d7d47 --- /dev/null +++ b/static/js/__tests__/api.test.js @@ -0,0 +1,207 @@ +/** + * api.js smoke test + * + * 验证 VpsFmt 纯工具函数:OAuth 归一化、倒计时文案、费用文案、徽章样式等。 + * api.js 是 IIFE,会立即创建 window.VpsApi / window.VpsFmt, + * 用 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(() => { + 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, + }; + + ctx = vm.createContext({ + APP_CONFIG: { appName: 'Test', version: '0.0.0', commit: 'test' }, + window: {}, + localStorage, + fetch: async () => ({ ok: true, status: 200, json: async () => ({}) }), + console, + }); + ctx.window = ctx; + + const code = readFileSync( + resolve(import.meta.dirname, '..', 'api.js'), + 'utf-8', + ); + vm.runInContext(code, ctx); +}); + +/* ---------- VpsApi 基础 ---------- */ + +describe('VpsApi 初始化', () => { + it('暴露 get/post/put/del 方法', () => { + expect(typeof ctx.VpsApi.get).toBe('function'); + expect(typeof ctx.VpsApi.post).toBe('function'); + expect(typeof ctx.VpsApi.put).toBe('function'); + expect(typeof ctx.VpsApi.del).toBe('function'); + }); + + it('CFG 来自 window.APP_CONFIG', () => { + expect(ctx.VpsApi.CFG.appName).toBe('Test'); + }); +}); + +/* ---------- OAuth 归一化 ---------- */ + +describe('VpsFmt OAuth 函数', () => { + it('oauthLabel 预设 key 返回中文标签', () => { + expect(ctx.VpsFmt.oauthLabel('alipay')).toBe('支付宝'); + expect(ctx.VpsFmt.oauthLabel('github')).toBe('GitHub'); + expect(ctx.VpsFmt.oauthLabel('wechat')).toBe('微信'); + }); + + it('oauthLabel 未知 key 原样返回', () => { + expect(ctx.VpsFmt.oauthLabel('custom_provider')).toBe('custom_provider'); + expect(ctx.VpsFmt.oauthLabel('')).toBe(''); + expect(ctx.VpsFmt.oauthLabel(null)).toBe(''); + }); + + it('oauthDetail 预设返回「标签(key)」格式', () => { + expect(ctx.VpsFmt.oauthDetail('alipay')).toBe('支付宝(alipay)'); + expect(ctx.VpsFmt.oauthDetail('github')).toBe('GitHub(github)'); + }); + + it('oauthDetail 未知 key 只返回原文', () => { + expect(ctx.VpsFmt.oauthDetail('my_custom')).toBe('my_custom'); + }); + + it('normalizeOauth 小写 key 直通', () => { + expect(ctx.VpsFmt.normalizeOauth('github')).toBe('github'); + expect(ctx.VpsFmt.normalizeOauth(' GitHub ')).toBe('github'); + }); + + it('normalizeOauth 中文标签归一为 key', () => { + expect(ctx.VpsFmt.normalizeOauth('支付宝')).toBe('alipay'); + expect(ctx.VpsFmt.normalizeOauth('微信')).toBe('wechat'); + }); + + it('normalizeOauth 空值返回空字符串', () => { + expect(ctx.VpsFmt.normalizeOauth('')).toBe(''); + expect(ctx.VpsFmt.normalizeOauth(null)).toBe(''); + }); +}); + +/* ---------- 倒计时文案 ---------- */ + +describe('VpsFmt.countdownText', () => { + it('正数返回「N 天后」', () => { + expect(ctx.VpsFmt.countdownText(30)).toBe('30 天后'); + expect(ctx.VpsFmt.countdownText(1)).toBe('1 天后'); + }); + + it('负数返回「已过期 N 天」', () => { + expect(ctx.VpsFmt.countdownText(-5)).toBe('已过期 5 天'); + }); + + it('零返回「今天到期」', () => { + expect(ctx.VpsFmt.countdownText(0)).toBe('今天到期'); + }); + + it('null/undefined 返回默认占位', () => { + expect(ctx.VpsFmt.countdownText(null)).toBe('未设到期日'); + expect(ctx.VpsFmt.countdownText(undefined)).toBe('未设到期日'); + }); + + it('opts.empty 自定义占位', () => { + expect(ctx.VpsFmt.countdownText(null, { empty: '—' })).toBe('—'); + }); + + it('opts.unit="" 只返回「N 天」', () => { + expect(ctx.VpsFmt.countdownText(7, { unit: '' })).toBe('7 天'); + }); +}); + +/* ---------- 费用文案 ---------- */ + +describe('VpsFmt.costText', () => { + it('零返回「未填」', () => { + expect(ctx.VpsFmt.costText(0, 'USD')).toBe('未填'); + }); + + it('有值有币种拼接', () => { + expect(ctx.VpsFmt.costText(9.99, 'USD')).toBe('9.99 USD'); + }); + + it('有值无币种只返回数字字符串', () => { + expect(ctx.VpsFmt.costText(100, '')).toBe('100'); + expect(ctx.VpsFmt.costText(50, null)).toBe('50'); + }); +}); + +/* ---------- 到期日样式 ---------- */ + +describe('VpsFmt 到期日样式函数', () => { + it('expiryText 按天数区间返回不同样式类', () => { + expect(ctx.VpsFmt.expiryText(-1)).toContain('red'); + expect(ctx.VpsFmt.expiryText(3)).toContain('red'); + expect(ctx.VpsFmt.expiryText(15)).toContain('amber'); + expect(ctx.VpsFmt.expiryText(60)).toContain('slate'); + expect(ctx.VpsFmt.expiryText(null)).toContain('slate'); + }); + + it('expiryBadge 按天数区间返回不同背景色', () => { + expect(ctx.VpsFmt.expiryBadge(3)).toContain('red'); + expect(ctx.VpsFmt.expiryBadge(15)).toContain('amber'); + expect(ctx.VpsFmt.expiryBadge(60)).toContain('slate'); + }); +}); + +/* ---------- 标签映射表 ---------- */ + +describe('VpsFmt 标签映射表', () => { + it('TYPE_LABELS 包含核心资产类型', () => { + expect(ctx.VpsFmt.TYPE_LABELS.vps).toBe('VPS'); + expect(ctx.VpsFmt.TYPE_LABELS.domain).toBe('域名'); + expect(ctx.VpsFmt.TYPE_LABELS.ai_agent).toBe('AI账号'); + }); + + it('STATUS_LABELS 包含全部状态', () => { + expect(ctx.VpsFmt.STATUS_LABELS.active).toBe('使用中'); + expect(ctx.VpsFmt.STATUS_LABELS.expired).toBe('已过期'); + }); + + it('LOGIN_TYPE_LABELS 包含登录方式', () => { + expect(ctx.VpsFmt.LOGIN_TYPE_LABELS.password).toBe('密码登录'); + expect(ctx.VpsFmt.LOGIN_TYPE_LABELS.oauth).toBe('授权登录'); + }); + + it('OAUTH_PRESETS 是非空数组', () => { + expect(Array.isArray(ctx.VpsFmt.OAUTH_PRESETS)).toBe(true); + expect(ctx.VpsFmt.OAUTH_PRESETS.length).toBeGreaterThan(10); + }); +}); + +/* ---------- 徽章函数兜底 ---------- */ + +describe('VpsFmt 徽章函数兜底', () => { + it('typeBadge 未知类型返回默认样式', () => { + expect(ctx.VpsFmt.typeBadge('nonexistent')).toContain('slate'); + }); + + it('statusBadge 未知状态返回默认样式', () => { + expect(ctx.VpsFmt.statusBadge('unknown_status')).toContain('slate'); + }); + + it('loginTypeBadge 未知类型返回默认样式', () => { + expect(ctx.VpsFmt.loginTypeBadge('xxx')).toContain('slate'); + }); + + it('checkBadge 各状态返回对应颜色', () => { + expect(ctx.VpsFmt.checkBadge('pass')).toContain('emerald'); + expect(ctx.VpsFmt.checkBadge('warn')).toContain('amber'); + expect(ctx.VpsFmt.checkBadge('fail')).toContain('red'); + }); +}); diff --git a/static/js/__tests__/app.test.js b/static/js/__tests__/app.test.js new file mode 100644 index 0000000..78287ab --- /dev/null +++ b/static/js/__tests__/app.test.js @@ -0,0 +1,242 @@ +/** + * app.js smoke test + * + * 验证导航常量(NAVS / VIEW_MAP / VALID_VIEWS)和 Hash 路由解析函数。 + * app.js 末尾会 Vue.createApp + mount,通过 mock Vue 使 mount 为空操作跳过。 + */ +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(() => { + 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:createApp 返回链式调用对象,mount 为空跳过实际挂载 */ + const chainable = { component() { return chainable; }, mount() {} }; + const Vue = { + reactive: (o) => o, + computed: (fn) => ({ value: typeof fn === 'function' ? fn() : fn }), + createApp: () => chainable, + }; + + const store = { + view: 'dashboard', + loading: false, + error: '', + dark: false, + serverMetrics: {}, + search: '', + monitorTab: 'ssl', + assetModal: { show: false }, + confirmModal: { show: false }, + resultModal: { show: false }, + credentialModal: { show: false }, + accountModal: { show: false }, + providerModal: { show: false }, + accountsModal: { show: false }, + }; + + const Api = { CFG: { appName: 'Test', version: '0.0.0', commit: 'test' } }; + const Fmt = { + TYPE_LABELS: {}, STATUS_LABELS: {}, CATEGORY_LABELS: {}, + SERVICES_LABELS: {}, CYCLE_LABELS: {}, LOGIN_TYPE_LABELS: {}, + OAUTH_PRESETS: [], + normalizeOauth: (s) => (s || '').trim().toLowerCase(), + oauthLabel: (k) => k || '', + oauthDetail: (k) => k || '', + typeBadge: () => '', + statusBadge: () => '', + loginTypeBadge: () => '', + expiryText: () => '', + expiryBadge: () => '', + countdownText: () => '', + costText: () => '', + checkBadge: () => '', + }; + + const noop = () => {}; + const mockView = { template: '
', setup() { return {}; } }; + + ctx = vm.createContext({ + Vue, + localStorage, + console, + location: { hash: '#dashboard' }, + addEventListener: noop, + removeEventListener: noop, + window: {}, + document: { + documentElement: { classList: { toggle: noop, add: noop, remove: noop } }, + addEventListener: noop, + removeEventListener: noop, + createElement: () => ({ select: noop, value: '' }), + body: { appendChild: noop, removeChild: noop }, + }, + navigator: { clipboard: null }, + setTimeout: (fn) => { fn(); return 0; }, + clearTimeout: noop, + setInterval: () => 0, + clearInterval: noop, + confirm: () => true, + alert: noop, + fetch: async () => ({ ok: true, status: 200, json: async () => ({}) }), + store, + Api, + Fmt, + toast: { show: false, text: '' }, + loadAssets: noop, + loadAll: noop, + applyDark: noop, + openAssetCreate: noop, + openProviderCreate: noop, + openAICreate: noop, + openCredentialCreate: noop, + showResult: noop, + closeResult: noop, + closeConfirm: noop, + closeActions: noop, + closeDns: noop, + actionModal: { show: false }, + dnsModal: { show: false }, + DashboardView: mockView, + AssetsView: mockView, + MonitorView: mockView, + VaultView: mockView, + ProvidersView: mockView, + ServersView: mockView, + SettingsView: mockView, + AssetModal: mockView, + ProviderModal: mockView, + AccountModal: mockView, + AccountsViewModal: mockView, + CredentialModal: mockView, + ActionModal: mockView, + DnsModal: mockView, + ConfirmModal: mockView, + ResultModal: mockView, + }); + ctx.window = ctx; + + let code = readFileSync( + resolve(import.meta.dirname, '..', 'app.js'), + 'utf-8', + ); + 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); +}); + +/* ---------- NAVS 导航项 ---------- */ + +describe('NAVS 导航配置', () => { + it('包含 6 个导航项', () => { + expect(ctx.NAVS).toHaveLength(6); + }); + + it('每项均有 key / label / icon', () => { + for (const n of ctx.NAVS) { + expect(n.key).toBeTruthy(); + expect(n.label).toBeTruthy(); + expect(n.icon).toBeTruthy(); + } + }); + + it('包含核心导航键', () => { + const keys = ctx.NAVS.map(n => n.key); + expect(keys).toContain('dashboard'); + expect(keys).toContain('assets'); + expect(keys).toContain('monitor'); + expect(keys).toContain('vault'); + expect(keys).toContain('providers'); + expect(keys).toContain('settings'); + }); +}); + +/* ---------- VIEW_MAP 视图映射 ---------- */ + +describe('VIEW_MAP 视图映射', () => { + it('包含全部导航视图', () => { + expect(ctx.VIEW_MAP.dashboard).toBe('dashboard-view'); + expect(ctx.VIEW_MAP.assets).toBe('assets-view'); + expect(ctx.VIEW_MAP.monitor).toBe('monitor-view'); + expect(ctx.VIEW_MAP.vault).toBe('vault-view'); + expect(ctx.VIEW_MAP.providers).toBe('providers-view'); + expect(ctx.VIEW_MAP.settings).toBe('settings-view'); + }); + + it('包含隐藏路由 servers', () => { + expect(ctx.VIEW_MAP.servers).toBe('servers-view'); + }); +}); + +/* ---------- VALID_VIEWS ---------- */ + +describe('VALID_VIEWS', () => { + it('集合大小与 VIEW_MAP 键数一致', () => { + expect(ctx.VALID_VIEWS.size).toBe(Object.keys(ctx.VIEW_MAP).length); + }); + + it('包含全部 VIEW_MAP 键', () => { + for (const k of Object.keys(ctx.VIEW_MAP)) { + expect(ctx.VALID_VIEWS.has(k)).toBe(true); + } + }); +}); + +/* ---------- _viewFromHash 路由解析 ---------- */ + +describe('_viewFromHash 路由解析', () => { + it('空 hash 默认 dashboard', () => { + ctx.window.location.hash = ''; + expect(ctx._viewFromHash()).toBe('dashboard'); + }); + + it('#assets 解析为 assets', () => { + ctx.window.location.hash = '#assets'; + expect(ctx._viewFromHash()).toBe('assets'); + }); + + it('#/vault 解析为 vault(兼容斜杠)', () => { + ctx.window.location.hash = '#/vault'; + expect(ctx._viewFromHash()).toBe('vault'); + }); + + it('无效视图回退 dashboard', () => { + ctx.window.location.hash = '#nonexistent'; + expect(ctx._viewFromHash()).toBe('dashboard'); + }); + + it('忽略查询串', () => { + ctx.window.location.hash = '#monitor?tab=ssl'; + expect(ctx._viewFromHash()).toBe('monitor'); + }); +}); + +/* ---------- AppRoot 组件结构 ---------- */ + +describe('AppRoot 组件', () => { + it('已定义且包含 template 和 setup', () => { + expect(ctx.AppRoot).toBeDefined(); + expect(typeof ctx.AppRoot.template).toBe('string'); + expect(typeof ctx.AppRoot.setup).toBe('function'); + }); + + it('template 包含底部导航栏(移动端适配)', () => { + expect(ctx.AppRoot.template).toContain('md:hidden'); + }); + + it('template 包含桌面端侧边栏', () => { + expect(ctx.AppRoot.template).toContain('hidden md:flex'); + }); +}); diff --git a/static/js/__tests__/modals.test.js b/static/js/__tests__/modals.test.js new file mode 100644 index 0000000..b19b3fb --- /dev/null +++ b/static/js/__tests__/modals.test.js @@ -0,0 +1,62 @@ +/** + * modals.js smoke test + * + * 验证 9 个 Vue 模态框组件均已正确定义(含 template + setup)。 + * modals.js 只有 const XModal = { template, setup } 声明, + * setup() 内的外部引用(store / Fmt / Vue)只在 Vue 挂载时执行, + * 因此 VM 沙箱无需注入完整 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; + +const EXPECTED_MODALS = [ + 'AssetModal', + 'ProviderModal', + 'AccountModal', + 'AccountsViewModal', + 'CredentialModal', + 'ActionModal', + 'DnsModal', + 'ConfirmModal', + 'ResultModal', +]; + +beforeAll(() => { + ctx = vm.createContext({ console }); + ctx.window = ctx; + + let code = readFileSync( + resolve(import.meta.dirname, '..', 'modals.js'), + 'utf-8', + ); + // 同 store.test.js 约定:顶格 const → globalThis,使测试可通过 ctx.X 访问 + code = code.replace(/^const\s+(?!\{)(\w+)\s*=/gm, 'globalThis.$1 ='); + vm.runInContext(code, ctx); +}); + +describe('modals.js 组件定义完整性', () => { + it('全部 9 个模态框组件均已定义', () => { + for (const name of EXPECTED_MODALS) { + expect(ctx[name], `${name} 未定义`).toBeDefined(); + } + }); + + it('每个组件均包含 template 字符串和 setup 函数', () => { + for (const name of EXPECTED_MODALS) { + const comp = ctx[name]; + expect(typeof comp.template, `${name}.template 非字符串`).toBe('string'); + expect(comp.template.length, `${name}.template 为空`).toBeGreaterThan(10); + expect(typeof comp.setup, `${name}.setup 非函数`).toBe('function'); + } + }); + + it('template 包含 v-if 条件渲染(模态框显隐控制)', () => { + for (const name of EXPECTED_MODALS) { + expect(ctx[name].template, `${name}.template 缺少 v-if`).toContain('v-if'); + } + }); +});