/** * 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)); }); });