feat: 为高频变更 JS 文件补充 smoke test 并建立 js-test-gate Rule

新增测试:
- 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
This commit is contained in:
gouki
2026-10-15 21:20:52 +00:00
parent 90695c82fd
commit a63fcb786f
5 changed files with 539 additions and 2 deletions
+242
View File
@@ -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: '<div></div>', 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');
});
});