fix(frontend): key geo entries by page position and clear test-suite noise

Zod 4: use the `error` param instead of the deprecated `message`.
lint:deprecated missed these because tsgolint's no-deprecated does not
resolve object-literal properties on a `string | Params` union.

Geodata: key geo entry rows by page position. antd deprecates rowKey's
index argument, and kind:value repeats within a page because the reader
drops domain attributes (22 pairs in geosite_IR.dat, 108 in geosite_RU).

Nord/PIA: the "All cities/regions" option used a null value, which antd
warns on. Map it through a sentinel at the Select boundary so form state
stays null, with tests that fail when the sentinel is not mapped back.

Tests:
- Run the oxlint guard through node; .bin/oxlint is a sh shim Windows
  cannot spawn, and the swallowed error left both guard cases vacuous.
- Start unit workers with --no-experimental-webstorage; msw's localStorage
  probe made Node 25+ warn once per forked worker.
- Set IS_REACT_ACT_ENVIRONMENT, which RTL never sets with globals: false,
  and settle the async updates it exposed inside act(). The row-cells
  memo test now fails when memo is removed.
- Disable antd's click wave in Storybook; it re-rendered inside the next
  story's act() and tripped "not configured to support act".
- Assert InboundFormModal's validation log instead of leaking it, and
  give the rule-form test a well-formed clients/list response.
This commit is contained in:
MHSanaei
2026-09-25 21:21:03 +02:00
parent a03228c455
commit 89e200ead4
25 changed files with 186 additions and 70 deletions
+13 -10
View File
@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react';
import { act, fireEvent, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, expect, test, vi } from 'vitest';
@@ -13,16 +13,19 @@ afterEach(() => {
localStorage.clear();
});
function renderSidebar() {
return renderWithProviders(
// rc-menu registers its items in a microtask after render; settle it inside act().
async function renderSidebar() {
const view = renderWithProviders(
<MemoryRouter>
<AppSidebar />
</MemoryRouter>,
);
await act(async () => {});
return view;
}
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
const first = renderSidebar();
test('keeps the sidebar expanded after pinning it from the header and restores the choice', async () => {
const first = await renderSidebar();
const sidebar = first.container.querySelector('.ant-layout-sider');
const sidebarRoot = first.container.querySelector('.ant-sidebar');
@@ -42,7 +45,7 @@ test('keeps the sidebar expanded after pinning it from the header and restores t
first.unmount();
const second = renderSidebar();
const second = await renderSidebar();
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
@@ -51,8 +54,8 @@ test('keeps the sidebar expanded after pinning it from the header and restores t
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
});
test('returns to the compact rail after unpinning', () => {
const view = renderSidebar();
test('returns to the compact rail after unpinning', async () => {
const view = await renderSidebar();
const sidebar = view.container.querySelector('.ant-layout-sider');
const sidebarRoot = view.container.querySelector('.ant-sidebar');
@@ -66,8 +69,8 @@ test('returns to the compact rail after unpinning', () => {
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
});
test('labels the palette shortcut with the modifier the platform actually uses', () => {
const view = renderSidebar();
test('labels the palette shortcut with the modifier the platform actually uses', async () => {
const view = await renderSidebar();
const chip = view.container.querySelector('.sidebar-command-kbd');
expect(chip?.textContent).toBe('CtrlK');
});
+16 -5
View File
@@ -116,6 +116,11 @@ function renderSubject(overrides: Partial<SubjectProps> = {}) {
};
}
// Opening fetches sub links with no visible loading state; settle it inside act().
async function settleSubLinks() {
await act(async () => {});
}
function selectVariant(name: 'Standard' | 'Happ') {
fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
}
@@ -129,8 +134,9 @@ describe('ClientQrModal Happ presentation', () => {
vi.mocked(HttpUtil.post).mockReset();
});
it('opens on Standard without generating a Happ link', () => {
it('opens on Standard without generating a Happ link', async () => {
renderSubject();
await settleSubLinks();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
@@ -139,8 +145,9 @@ describe('ClientQrModal Happ presentation', () => {
expect(HttpUtil.post).not.toHaveBeenCalled();
});
it('names the Happ option as an encrypted link', () => {
it('names the Happ option as an encrypted link', async () => {
renderSubject();
await settleSubLinks();
expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
});
@@ -148,7 +155,7 @@ describe('ClientQrModal Happ presentation', () => {
it.each([
['missing', undefined],
['false', false],
])('marks the selectable Happ option as locked when the gate is %s', (_name, gate) => {
])('marks the selectable Happ option as locked when the gate is %s', async (_name, gate) => {
const subSettings: TestSubSettings = {
enable: SUB_SETTINGS.enable,
subURI: SUB_SETTINGS.subURI,
@@ -158,6 +165,7 @@ describe('ClientQrModal Happ presentation', () => {
if (gate !== undefined) subSettings.happLinkEnable = gate;
renderSubject({ subSettings });
await settleSubLinks();
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
@@ -176,7 +184,7 @@ describe('ClientQrModal Happ presentation', () => {
['false', false],
])(
'replaces the blank Happ content with a persistent empty state when the gate is %s',
(_name, gate) => {
async (_name, gate) => {
const subSettings: TestSubSettings = {
enable: SUB_SETTINGS.enable,
subURI: SUB_SETTINGS.subURI,
@@ -186,6 +194,7 @@ describe('ClientQrModal Happ presentation', () => {
if (gate !== undefined) subSettings.happLinkEnable = gate;
renderSubject({ subSettings });
await settleSubLinks();
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
@@ -222,8 +231,9 @@ describe('ClientQrModal Happ presentation', () => {
expect(screen.queryByRole('tooltip')).toBeNull();
});
it('closes the QR modal and deep-links to Happ settings without generating', () => {
it('closes the QR modal and deep-links to Happ settings without generating', async () => {
const view = renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
await settleSubLinks();
selectVariant('Happ');
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
@@ -411,6 +421,7 @@ describe('ClientQrModal Happ presentation', () => {
view.update({ open: false });
view.update({ open: true });
await settleSubLinks();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
@@ -58,8 +58,9 @@ describe('clients table row cells', () => {
expect(afterFirstRender).toBeGreaterThan(0);
// Three simulated traffic pushes: the parent re-renders, the props do not change.
for (let i = 0; i < 3; i++) bump();
await Promise.resolve();
act(() => {
for (let i = 0; i < 3; i++) bump();
});
expect(reads.count).toBe(afterFirstRender);
});
@@ -113,7 +114,9 @@ describe('clients table row cells', () => {
</Harness>,
);
for (let i = 0; i < 3; i++) bump();
act(() => {
for (let i = 0; i < 3; i++) bump();
});
// Queried by position rather than label: the suite loads the real en-US
// bundle, so the aria-labels are translated strings, not keys. Order is
+2 -2
View File
@@ -203,7 +203,7 @@ describe('CommandPalette component', () => {
});
// Wait past the 300ms debounce interval while bob fetch is still pending
await new Promise((resolve) => setTimeout(resolve, 350));
await act(() => new Promise((resolve) => setTimeout(resolve, 350)));
// Stale Alice row must STILL not be rendered
expect(screen.queryByText('alice@example.com')).toBeNull();
@@ -258,7 +258,7 @@ describe('CommandPalette component', () => {
// Add trailing whitespace
fireEvent.change(input, { target: { value: 'abc ' } });
await new Promise((resolve) => setTimeout(resolve, 350));
await act(() => new Promise((resolve) => setTimeout(resolve, 350)));
// No extra search call because trimmed query has not changed
const callsAfterAbcSpace = getSpy.mock.calls.filter((c) =>
+3 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react';
import { act, fireEvent, screen } from '@testing-library/react';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -25,7 +25,8 @@ vi.mock('persian-calendar-suite', () => ({
),
}));
afterEach(() => setDatepicker('gregorian'));
// Runs before the shared cleanup(), so the picker is still mounted and re-renders.
afterEach(() => act(() => setDatepicker('gregorian')));
function openPicker(): void {
const input = document.querySelector('.ant-picker input');
+15 -13
View File
@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react';
import { act, fireEvent, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
@@ -6,15 +6,21 @@ import { AllSetting } from '@/models/setting';
import GeneralTab from '@/pages/settings/GeneralTab';
import { renderWithProviders } from './test-utils';
// Mounting fetches inbound options with no visible change; settle it inside act().
async function renderGeneralTab(updateSetting: (patch: Partial<AllSetting>) => void) {
renderWithProviders(
<MemoryRouter initialEntries={['/settings']}>
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
</MemoryRouter>,
);
await act(async () => {});
}
describe('GeneralTab', () => {
it('keeps the stored page size when the field is cleared', () => {
it('keeps the stored page size when the field is cleared', async () => {
const updateSetting = vi.fn();
renderWithProviders(
<MemoryRouter initialEntries={['/settings']}>
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
</MemoryRouter>,
);
await renderGeneralTab(updateSetting);
const pageSizeInput = screen.getByDisplayValue('25');
fireEvent.change(pageSizeInput, { target: { value: '' } });
@@ -24,14 +30,10 @@ describe('GeneralTab', () => {
expect((pageSizeInput as HTMLInputElement).value).toBe('25');
});
it('forwards typed page sizes unchanged, zero included', () => {
it('forwards typed page sizes unchanged, zero included', async () => {
const updateSetting = vi.fn();
renderWithProviders(
<MemoryRouter initialEntries={['/settings']}>
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
</MemoryRouter>,
);
await renderGeneralTab(updateSetting);
fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -209,7 +209,7 @@ describe('GeoBrowserModal selection', () => {
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
await user.click(screen.getByText('telegram'));
await new Promise((resolve) => setTimeout(resolve, 800));
await act(() => new Promise((resolve) => setTimeout(resolve, 800)));
expect(entryFilters(get)).toEqual(['', '']);
});
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, onTestFinished, vi } from 'vitest';
import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
@@ -279,6 +279,8 @@ describe('InboundFormModal', () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
messageError.mockClear();
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
onTestFinished(() => consoleError.mockRestore());
renderModal();
fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
@@ -294,6 +296,10 @@ describe('InboundFormModal', () => {
expect.stringContaining('TLS certificate 1: Import a TLS certificate'),
);
});
expect(consoleError).toHaveBeenCalledWith('[InboundFormModal] schema validation failed:', [
'TLS certificate 1: Import a TLS certificate or enter its file path before saving',
'TLS certificate 1: Import the TLS private key or enter its file path before saving',
]);
expect(post).not.toHaveBeenCalled();
});
+9 -5
View File
@@ -9,13 +9,17 @@ const RULE = 'input-number(no-synthetic-clear)';
function runGuard(target: string): string {
try {
execFileSync('./node_modules/.bin/oxlint', ['-c', `${FIXTURES}/guard.oxlintrc.json`, target], {
encoding: 'utf8',
stdio: 'pipe',
});
// .bin/oxlint is a sh shim Windows can't spawn; run the node entry directly.
execFileSync(
process.execPath,
['node_modules/oxlint/bin/oxlint', '-c', `${FIXTURES}/guard.oxlintrc.json`, target],
{ encoding: 'utf8', stdio: 'pipe' },
);
return '';
} catch (error) {
return String((error as { stdout?: string }).stdout ?? '');
const { status, stdout } = error as { status?: number | null; stdout?: string };
if (typeof status !== 'number') throw error;
return String(stdout ?? '');
}
}
+24 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import NordModal from '@/pages/xray/overrides/NordModal';
import { HttpUtil, Msg } from '@/utils';
@@ -70,7 +70,10 @@ async function chooseOption(testId: string, labelPart: string) {
`${item.getAttribute('title') ?? ''} ${item.textContent ?? ''}`.includes(labelPart),
);
if (!option) throw new Error(`Missing option containing ${labelPart}`);
fireEvent.click(option);
// Picking a country fetches its servers; let that settle inside act().
await act(async () => {
fireEvent.click(option);
});
}
async function clickAddOutbound() {
@@ -239,6 +242,25 @@ describe('NordVPN modal', () => {
);
});
it('lists every server again when All Cities is chosen after a city', async () => {
mockNordApi();
renderWithProviders(<NordHarness />);
await waitFor(() => expect(screen.getByText('nord-token')).toBeTruthy());
await chooseOption('nord-country-select', 'United States');
await chooseOption('nord-city-select', 'New York');
await chooseOption('nord-city-select', 'All Cities');
const serverNode = screen.getByTestId('nord-server-select');
const serverSelect = serverNode.closest('.ant-select') ?? serverNode;
fireEvent.mouseDown(serverSelect.querySelector('.ant-select-selector') ?? serverSelect);
await waitFor(() =>
expect(
document.querySelectorAll<HTMLElement>('.nord-server-popup .ant-select-item-option'),
).toHaveLength(2),
);
});
it('disables Add when the selected server is already present', async () => {
mockNordApi();
renderWithProviders(
+28 -3
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import PiaModal from '@/pages/xray/overrides/PiaModal';
import { HttpUtil, Msg } from '@/utils';
@@ -106,7 +106,10 @@ async function chooseOption(testId: string, labelPart: string) {
(item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
);
if (!option) throw new Error(`Missing option containing ${labelPart}`);
fireEvent.click(option);
// Picking a country fetches its servers; let that settle inside act().
await act(async () => {
fireEvent.click(option);
});
}
async function clickAddOutbound() {
@@ -115,7 +118,10 @@ async function clickAddOutbound() {
if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
return btn;
});
fireEvent.click(addButton);
// Adding provisions a key over HTTP; let that settle inside act().
await act(async () => {
fireEvent.click(addButton);
});
}
function expectPiaOutbound(
@@ -226,6 +232,25 @@ describe('PIA modal', () => {
});
});
it('lists every server again when All regions is chosen after a region', async () => {
mockPiaApi();
renderWithProviders(<PiaHarness />);
await waitFor(() => expect(screen.getByText('p*****67')).toBeTruthy());
await chooseOption('pia-country-select', 'US');
await chooseOption('pia-region-select', 'US West');
await chooseOption('pia-region-select', 'All regions');
const serverNode = screen.getByTestId('pia-server-select');
const serverSelect = serverNode.closest('.ant-select') ?? serverNode;
fireEvent.mouseDown(serverSelect.querySelector('.ant-select-selector') ?? serverSelect);
await waitFor(() =>
expect(
visibleOptions().filter((option) => /useast1|uswest1/.test(option.textContent ?? '')),
).toHaveLength(2),
);
});
it('disables Add when the selected server is already in the list', async () => {
mockPiaApi();
renderWithProviders(
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import RemarkTemplateField from '@/components/form/RemarkTemplateField';
import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
@@ -29,7 +29,7 @@ describe('RemarkTemplateField', () => {
const onChange = vi.fn();
render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
textarea.focus();
act(() => textarea.focus());
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
fireEvent.click(screen.getByRole('button'));
@@ -12,6 +12,7 @@ describe('RuleFormModal edit preserves unsurfaced fields', () => {
afterEach(() => vi.restoreAllMocks());
it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
const onConfirm = vi.fn();
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+3
View File
@@ -5,6 +5,9 @@ import { initReactI18next } from 'react-i18next';
import enUS from '../../../internal/web/translation/en-US.json';
// RTL sets this from a global beforeAll, which never runs with `globals: false`.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock('persian-calendar-suite', () => ({
PersianDateTimePicker: () => null,
}));