/* @vitest-environment happy-dom */ /* eslint-disable vue/one-component-per-file */ import type { EnvironmentDashboardApi } from '#/api/system/environment'; import { mount } from '@vue/test-utils'; import { defineComponent, h, nextTick } from 'vue'; import EnvironmentDashboardPage from '@test-source/apps/web-antdv-next/src/views/dashboard/analytics/index.vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getEnvironmentDashboard, runEnvironmentSelfCheck, } from '#/api/system/environment'; vi.mock('#/api/system/environment', () => ({ getEnvironmentDashboard: vi.fn(), getEnvironmentDashboardEventsUrl: vi.fn((lastEventId?: string) => lastEventId ? `/system/environment/events/stream?lastEventId=${encodeURIComponent(lastEventId)}` : '/system/environment/events/stream', ), runEnvironmentSelfCheck: vi.fn(), })); vi.mock('antdv-next', () => ({ Alert: defineComponent({ name: 'MockAlert', props: { message: { default: '', type: String }, type: { default: 'info', type: String }, }, setup(props, { slots }) { return () => h('div', { role: 'alert' }, [props.message, slots.description?.()]); }, }), Badge: defineComponent({ name: 'MockBadge', props: { status: { default: 'default', type: String }, text: { default: '', type: String }, }, setup(props) { return () => h('span', props.text as string); }, }), Button: defineComponent({ name: 'MockButton', props: { disabled: Boolean, loading: Boolean, type: { default: 'default', type: String }, }, emits: ['click'], setup(props, { emit, slots }) { return () => h( 'button', { disabled: props.disabled, type: 'button', onClick: () => emit('click'), }, slots.default?.(), ); }, }), Card: defineComponent({ name: 'MockCard', props: { title: { default: '', type: String }, }, setup(props, { slots }) { return () => h('section', [ props.title ? h('h2', props.title as string) : null, slots.default?.(), ]); }, }), Empty: defineComponent({ name: 'MockEmpty', setup() { return () => h('div', 'empty'); }, }), Space: defineComponent({ name: 'MockSpace', setup(_, { slots }) { return () => h('div', slots.default?.()); }, }), Spin: defineComponent({ name: 'MockSpin', props: { spinning: Boolean, }, setup(_, { slots }) { return () => h('div', slots.default?.()); }, }), Tag: defineComponent({ name: 'MockTag', props: { color: { default: 'default', type: String }, }, setup(_, { slots }) { return () => h('span', slots.default?.()); }, }), Tooltip: defineComponent({ name: 'MockTooltip', props: { title: { default: '', type: String }, }, setup(_, { slots }) { return () => h('span', slots.default?.()); }, }), })); type FakeEventSourceListener = (event: MessageEvent) => void; class FakeEventSource { static instances: FakeEventSource[] = []; closed = false; readonly listeners = new Map>(); readonly url: string; constructor(url: string) { this.url = url; FakeEventSource.instances.push(this); } addEventListener(type: string, listener: FakeEventSourceListener) { const listeners = this.listeners.get(type) ?? new Set(); listeners.add(listener); this.listeners.set(type, listeners); } close() { this.closed = true; } dispatch(type: string, payload: unknown) { const event = new MessageEvent(type, { data: JSON.stringify(payload), }); for (const listener of this.listeners.get(type) ?? []) { listener(event); } } removeEventListener(type: string, listener: FakeEventSourceListener) { this.listeners.get(type)?.delete(listener); } } async function flushDashboardUpdates() { await Promise.resolve(); await nextTick(); await Promise.resolve(); await nextTick(); } function createDashboardFixture( includeMqttEvent = false, ): EnvironmentDashboardApi.EnvironmentDashboardResponse { return { actions: [ { disabledReason: '第一版只允许只读自检', enabled: true, id: 'run-self-check', label: '只读自检', riskLevel: 'low', }, { disabledReason: '高风险操作需要人工审批', enabled: false, id: 'trigger-jenkins-deploy', label: '触发 Jenkins 部署', riskLevel: 'high', }, ], events: includeMqttEvent ? [ { eventId: 'evt-mqtt-1', observedAt: '2026-06-18 10:02:00', severity: 'degraded', siteId: 'nas-prod', sourceKind: 'mqtt', summary: 'MQTT reported NapCat degraded', topic: 'kt/env/nas-prod/napcat', }, ] : [], generatedAt: '2026-06-18 10:00:00', refreshedAt: '2026-06-18 10:00:01', sites: [ { id: 'local-dev', label: 'Local Dev', nodes: [ { id: 'local-dev-host', label: 'Local Host', services: [ { id: 'admin-local', label: 'Admin Local', signals: [ { evidence: [], id: 'admin-local-http', label: 'HTTP', sourceKind: 'live', status: 'ok', summary: 'Vite reachable', }, ], status: 'ok', summary: 'Admin is reachable', }, ], status: 'ok', }, ], status: 'online', summary: 'local ready', }, { id: 'nas-prod', label: 'NAS Production', nodes: [ { id: 'nas-node', label: 'NAS Node', services: [ { id: 'jenkins', label: 'Jenkins', signals: [ { evidence: [ { observedAt: '2026-06-18 10:00:01', source: 'ENV_DASHBOARD_JENKINS_URL', summary: 'ENV_DASHBOARD_JENKINS_URL missing', type: 'unwired', }, ], id: 'jenkins-config', label: 'Read-only config', sourceKind: 'unwired', status: 'unwired', summary: 'Jenkins read-only config missing', }, ], status: 'unwired', summary: 'Jenkins read-only config missing', }, { id: 'k8s', label: 'K8s', signals: [ { evidence: [ { observedAt: '2026-06-18 10:00:01', source: 'ENV_DASHBOARD_K8S_API_SERVER', summary: 'ENV_DASHBOARD_K8S_API_SERVER missing', type: 'unwired', }, ], id: 'k8s-config', label: 'Read-only config', sourceKind: 'unwired', status: 'unwired', summary: 'K8s read-only config missing', }, ], status: 'unwired', summary: 'K8s read-only config missing', }, { id: 'napcat', label: 'NapCat', signals: [ { evidence: [], id: 'napcat-login', label: 'Login', sourceKind: 'live', status: 'ok', summary: 'NapCat online', }, ], status: 'ok', summary: 'NapCat online', }, ], status: 'unwired', }, ], status: 'unknown', summary: 'remote evidence partial', }, { id: 'tencent-cloud', label: 'Tencent Cloud', nodes: [], status: 'unknown', summary: 'cloud config pending', }, { id: 'r4se', label: 'r4se', nodes: [], status: 'isolated', summary: 'remote site isolated', }, ], summary: { blocked: 0, degraded: includeMqttEvent ? 1 : 0, down: 0, ok: 2, totalSignals: 4, unknown: 0, unwired: 2, }, topology: { edges: [ { from: 'local-dev', id: 'edge-local-api', label: 'serves', to: 'admin-local', }, ], nodes: [], }, }; } describe('environment dashboard page', () => { beforeEach(() => { vi.clearAllMocks(); FakeEventSource.instances = []; vi.stubGlobal('EventSource', FakeEventSource); vi.stubGlobal('mqtt', { connect: vi.fn(), }); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it('renders all four sites from the dashboard snapshot', async () => { vi.mocked(getEnvironmentDashboard).mockResolvedValue( createDashboardFixture(), ); const wrapper = mount(EnvironmentDashboardPage); await flushDashboardUpdates(); expect(wrapper.text()).toContain('Local Dev'); expect(wrapper.text()).toContain('NAS Production'); expect(wrapper.text()).toContain('Tencent Cloud'); expect(wrapper.text()).toContain('r4se'); }); it('renders disabled write actions with their reason', async () => { vi.mocked(getEnvironmentDashboard).mockResolvedValue( createDashboardFixture(), ); const wrapper = mount(EnvironmentDashboardPage); await flushDashboardUpdates(); const deployButton = wrapper .findAll('button') .find((button) => button.text().includes('触发 Jenkins 部署')); expect(deployButton?.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('高风险操作需要人工审批'); }); it('renders API-provided MQTT events without instantiating a MQTT client', async () => { vi.mocked(getEnvironmentDashboard).mockResolvedValue( createDashboardFixture(true), ); const wrapper = mount(EnvironmentDashboardPage); await flushDashboardUpdates(); expect(wrapper.text()).toContain('MQTT reported NapCat degraded'); expect((globalThis as any).mqtt.connect).not.toHaveBeenCalled(); }); it('updates one node from an environment-signal SSE without reloading the dashboard', async () => { vi.mocked(getEnvironmentDashboard).mockResolvedValue( createDashboardFixture(), ); const wrapper = mount(EnvironmentDashboardPage); await flushDashboardUpdates(); FakeEventSource.instances[0]?.dispatch('environment-signal', { eventId: 'evt-signal-1', observedAt: '2026-06-18 10:03:00', serviceId: 'napcat', severity: 'degraded', signalId: 'napcat-login', siteId: 'nas-prod', sourceKind: 'mqtt', summary: 'NapCat login degraded by SSE', topic: 'kt/env/nas-prod/napcat', }); await flushDashboardUpdates(); expect(getEnvironmentDashboard).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('NapCat login degraded by SSE'); }); it('loads exactly one snapshot when SSE requests a snapshot', async () => { vi.mocked(getEnvironmentDashboard) .mockResolvedValueOnce(createDashboardFixture()) .mockResolvedValueOnce(createDashboardFixture(true)); mount(EnvironmentDashboardPage); await flushDashboardUpdates(); FakeEventSource.instances[0]?.dispatch('snapshot-required', { eventId: 'evt-gap', observedAt: '2026-06-18 10:04:00', severity: 'unknown', siteId: 'nas-prod', sourceKind: 'local', summary: 'Replay gap requires one snapshot', topic: 'kt/env/snapshot-required', }); await flushDashboardUpdates(); expect(getEnvironmentDashboard).toHaveBeenCalledTimes(2); }); it('does not register dashboard polling or timer-based refresh', async () => { const setIntervalSpy = vi.spyOn(globalThis, 'setInterval'); const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); vi.mocked(getEnvironmentDashboard).mockResolvedValue( createDashboardFixture(), ); mount(EnvironmentDashboardPage); await flushDashboardUpdates(); expect(setIntervalSpy).not.toHaveBeenCalled(); expect(setTimeoutSpy).not.toHaveBeenCalled(); }); it('runs readonly self-check from the action button', async () => { vi.mocked(getEnvironmentDashboard).mockResolvedValue( createDashboardFixture(), ); vi.mocked(runEnvironmentSelfCheck).mockResolvedValue( createDashboardFixture(true), ); const wrapper = mount(EnvironmentDashboardPage); await flushDashboardUpdates(); await wrapper .findAll('button') .find((button) => button.text().includes('只读自检')) ?.trigger('click'); await flushDashboardUpdates(); expect(runEnvironmentSelfCheck).toHaveBeenCalledTimes(1); }); it('closes EventSource when the route page unmounts', async () => { vi.mocked(getEnvironmentDashboard).mockResolvedValue( createDashboardFixture(), ); const wrapper = mount(EnvironmentDashboardPage); await flushDashboardUpdates(); wrapper.unmount(); expect(FakeEventSource.instances[0]?.closed).toBe(true); }); });