inbound-form-modal.test.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. import { describe, it, expect } from 'vitest';
  2. import { screen, act, render, cleanup } from '@testing-library/react';
  3. import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
  4. import { DBInbound } from '@/models/dbinbound';
  5. import { ThemeProvider } from '@/hooks/useTheme';
  6. import {
  7. renderWithProviders,
  8. fieldLabels,
  9. listSelectOptions,
  10. chooseSelectOption,
  11. } from './test-utils';
  12. function renderModal() {
  13. return renderWithProviders(
  14. <InboundFormModal
  15. open
  16. mode="add"
  17. dbInbound={null}
  18. dbInbounds={[]}
  19. availableNodes={[]}
  20. onClose={() => {}}
  21. onSaved={() => {}}
  22. />,
  23. );
  24. }
  25. describe('InboundFormModal', () => {
  26. it('renders add mode without crashing', () => {
  27. renderModal();
  28. expect(document.querySelector('.ant-modal')).toBeTruthy();
  29. expect(fieldLabels().length).toBeGreaterThan(0);
  30. });
  31. it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
  32. renderModal();
  33. const protocols = listSelectOptions('protocol');
  34. expect(protocols.length).toBeGreaterThan(3);
  35. const labelsByProto: Record<string, string[]> = {};
  36. for (const proto of protocols) {
  37. chooseSelectOption('protocol', proto);
  38. // Flush antd Form.useWatch('protocol') before reading — without it every iteration
  39. // sees the same pre-update DOM and the loop asserts nothing (the original bug here).
  40. await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
  41. labelsByProto[proto] = fieldLabels();
  42. }
  43. // The loop must actually exercise protocol-specific rendering: distinct protocols
  44. // must yield distinct field sets (a vacuous loop makes them all identical).
  45. const distinctShapes = new Set(Object.values(labelsByProto).map((l) => l.join('|')));
  46. expect(distinctShapes.size).toBeGreaterThan(1);
  47. // Spot-check a protocol-distinguishing field that must appear after the switch.
  48. if (labelsByProto.shadowsocks) {
  49. expect(labelsByProto.shadowsocks).toContain('Encryption method');
  50. }
  51. }, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
  52. it('preserves custom share address strategy when editing a local inbound', async () => {
  53. renderWithProviders(
  54. <InboundFormModal
  55. open
  56. mode="edit"
  57. dbInbound={new DBInbound({
  58. id: 1,
  59. port: 12345,
  60. listen: '',
  61. protocol: 'shadowsocks',
  62. remark: 'edge',
  63. enable: true,
  64. settings: {
  65. method: '2022-blake3-aes-128-gcm',
  66. password: 'server-password',
  67. network: 'tcp,udp',
  68. clients: [],
  69. },
  70. streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
  71. sniffing: { enabled: false },
  72. nodeId: null,
  73. shareAddrStrategy: 'custom',
  74. shareAddr: 'edge.example.test',
  75. })}
  76. dbInbounds={[]}
  77. availableNodes={[]}
  78. onClose={() => {}}
  79. onSaved={() => {}}
  80. />,
  81. );
  82. const shareAddrInput = await screen.findByDisplayValue('edge.example.test');
  83. expect((shareAddrInput as HTMLInputElement).value).toBe('edge.example.test');
  84. });
  85. it('keeps the persisted node share strategy through the nodes-loading race (#5375)', async () => {
  86. const node = { id: 1, name: 'arm2', enable: true, status: 'online' } as never;
  87. const buildInbound = () => new DBInbound({
  88. id: 1,
  89. port: 23456,
  90. listen: '',
  91. protocol: 'vless',
  92. remark: 'noded',
  93. enable: true,
  94. settings: { clients: [] },
  95. streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
  96. sniffing: { enabled: false },
  97. nodeId: 1,
  98. shareAddrStrategy: 'node',
  99. });
  100. const flush = async () => { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); };
  101. const strategyItem = (title: string) =>
  102. document.querySelector(`.ant-select-content[title="${title}"]`);
  103. const modal = (nodes: never[], fetched: boolean) => (
  104. <ThemeProvider>
  105. <InboundFormModal
  106. open
  107. mode="edit"
  108. dbInbound={buildInbound()}
  109. dbInbounds={[]}
  110. availableNodes={nodes}
  111. availableNodesFetched={fetched}
  112. onClose={() => {}}
  113. onSaved={() => {}}
  114. />
  115. </ThemeProvider>
  116. );
  117. // Baseline: nodes already loaded, so the node option is offered and selected.
  118. render(modal([node], true));
  119. await flush();
  120. expect(strategyItem('Node address')).toBeTruthy();
  121. cleanup();
  122. // Race: the modal mounts before /nodes/list resolves (empty placeholder),
  123. // then nodes arrive. The persisted 'node' strategy must survive the gap and
  124. // stay selected once the option reappears — not silently revert to listen.
  125. const { rerender } = render(modal([], false));
  126. await flush();
  127. rerender(modal([node], true));
  128. await flush();
  129. expect(strategyItem('Node address')).toBeTruthy();
  130. expect(strategyItem('Inbound listen')).toBeFalsy();
  131. });
  132. });