Files
vps-manager/static/js/__tests__/store.test.js
T
gouki d6a5ec8b71 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
2026-10-14 12:51:56 +00:00

227 lines
7.7 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.
/**
* 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));
});
});