Files
vps-manager/static/js/__tests__/app.test.js
T
gouki a63fcb786f 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
2026-10-15 21:20:52 +00:00

243 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});
});