import { describe, it, expect } from 'vitest'; import { screen, act, render, cleanup } from '@testing-library/react'; import InboundFormModal from '@/pages/inbounds/form/InboundFormModal'; import { DBInbound } from '@/models/dbinbound'; import { ThemeProvider } from '@/hooks/useTheme'; import { renderWithProviders, fieldLabels, listSelectOptions, chooseSelectOption, } from './test-utils'; function renderModal() { return renderWithProviders( {}} onSaved={() => {}} />, ); } describe('InboundFormModal', () => { it('renders add mode without crashing', () => { renderModal(); expect(document.querySelector('.ant-modal')).toBeTruthy(); expect(fieldLabels().length).toBeGreaterThan(0); }); it('field structure differs per protocol (not a vacuous snapshot loop)', async () => { renderModal(); const protocols = listSelectOptions('protocol'); expect(protocols.length).toBeGreaterThan(3); const labelsByProto: Record = {}; for (const proto of protocols) { chooseSelectOption('protocol', proto); // Flush antd Form.useWatch('protocol') before reading — without it every iteration // sees the same pre-update DOM and the loop asserts nothing (the original bug here). await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); labelsByProto[proto] = fieldLabels(); } // The loop must actually exercise protocol-specific rendering: distinct protocols // must yield distinct field sets (a vacuous loop makes them all identical). const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|'))); expect(distinctShapes.size).toBeGreaterThan(1); // Spot-check a protocol-distinguishing field that must appear after the switch. if (labelsByProto.shadowsocks) { expect(labelsByProto.shadowsocks).toContain('Encryption method'); } }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners it('preserves custom share address strategy when editing a local inbound', async () => { renderWithProviders( {}} onSaved={() => {}} />, ); const shareAddrInput = await screen.findByDisplayValue('edge.example.test'); expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test'); }); it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => { const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never; const buildInbound = () => new DBInbound({ id: 1, port: 23456, listen: '', protocol: 'vless', remark: 'noded', enable: true, settings: { clients: [] }, streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} }, sniffing: { enabled: false }, nodeId: 1, shareAddrStrategy: 'node', }); const flush = async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); }; const strategyItem = (title: string) => document.querySelector(`.ant-select-content[title="${title}"]`); const modal = (nodes: never[], fetched: boolean) => ( {}} onSaved={() => {}} /> ); // Baseline: nodes already loaded, so the node option is offered and selected. render(modal([node], true)); await flush(); expect(strategyItem('Node address')).toBeTruthy(); cleanup(); // Race: the modal mounts before /nodes/list resolves (empty placeholder), // then nodes arrive. The persisted 'node' strategy must survive the gap and // stay selected once the option reappears — not silently revert to listen. const { rerender } = render(modal([], false)); await flush(); rerender(modal([node], true)); await flush(); expect(strategyItem('Node address')).toBeTruthy(); expect(strategyItem('Inbound listen')).toBeFalsy(); }); });