Selaa lähdekoodia

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.
MHSanaei 16 tuntia sitten
vanhempi
sitoutus
89e200ead4

+ 2 - 2
.github/workflows/claude-issue-analyst.yml

@@ -44,8 +44,8 @@ jobs:
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
           allowed_non_write_users: "*"
           claude_args: |
-            --model claude-opus-5.5
-            --effort high
+            --model claude-opus-5-5
+            --effort medium
             --max-turns 300
             --allowedTools "Bash(gh label list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh issue comment ${{ github.event.issue.number }}:*),Bash(gh issue edit ${{ github.event.issue.number }} --add-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --remove-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --title:*),Bash(gh issue close ${{ github.event.issue.number }}:*),Bash(gh search issues:*),Bash(gh search commits:*),Bash(gh search prs:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh release list:*),Bash(gh release view:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git ls-tree:*),Bash(git tag:*),Read,Glob,Grep,Write(//tmp/**),Edit(//tmp/**)"
             --disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)"

+ 2 - 2
.github/workflows/claude-pr-review.yml

@@ -118,8 +118,8 @@ jobs:
           # allowedTools only pre-approves; it denies nothing. Only the deny list
           # stops the review executing what it just checked out, or delegating.
           claude_args: |
-            --model claude-opus-5.5
-            --effort high
+            --model claude-opus-5-5
+            --effort medium
             --max-turns 300
             --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
             --disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"

+ 3 - 1
frontend/.storybook/preview.tsx

@@ -26,7 +26,9 @@ export const withTheme: Decorator = (Story, context) => {
     document.documentElement.removeAttribute('data-theme');
   }, [dark]);
   return (
-    <ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
+    // The click wave outlives its story and re-renders from a ResizeObserver
+    // inside the next story's act(), tripping React's act-environment warning.
+    <ConfigProvider theme={buildAntdThemeConfig(dark, false)} wave={{ disabled: true }}>
       <div style={{ padding: 24, minWidth: 320 }}>
         <Story />
       </div>

+ 12 - 3
frontend/src/components/geodata/GeoBrowserModal.tsx

@@ -24,6 +24,10 @@ import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types'
 import './GeoBrowserModal.css';
 
 const ENTRY_PAGE_SIZE = 100;
+
+// Attributes are dropped server-side, so kind:value repeats within real
+// geosite categories; the page position is the only unique row key.
+type GeoEntryRow = GeoEntry & { position: number };
 const CATEGORY_SCROLL_HEIGHT = 438;
 const ENTRY_FILTER_DELAY = 500;
 
@@ -224,7 +228,12 @@ export default function GeoBrowserModal({
     [t],
   );
 
-  const entryColumns: ColumnsType<GeoEntry> = useMemo(
+  const entryRows: GeoEntryRow[] = useMemo(
+    () => (entriesQuery.data?.items ?? []).map((entry, position) => ({ ...entry, position })),
+    [entriesQuery.data],
+  );
+
+  const entryColumns: ColumnsType<GeoEntryRow> = useMemo(
     () => [
       {
         dataIndex: 'kind',
@@ -391,9 +400,9 @@ export default function GeoBrowserModal({
                     <Table
                       size="small"
                       showHeader={false}
-                      rowKey={(entry, index) => `${entry.value}-${index}`}
+                      rowKey="position"
                       columns={entryColumns}
-                      dataSource={entriesQuery.data?.items ?? []}
+                      dataSource={entryRows}
                       loading={entriesQuery.isLoading}
                       locale={{
                         emptyText: entriesQuery.isError

+ 13 - 2
frontend/src/pages/xray/overrides/NordModal.tsx

@@ -86,6 +86,13 @@ const EMPTY: NordFormValues = {
   serverId: null,
 };
 
+// antd warns on a null option value, so "All Cities" is a sentinel mapped back to null.
+const ALL_CITIES = '__all__';
+const allCitiesTransform = {
+  input: (value: unknown) => value ?? ALL_CITIES,
+  output: (value: unknown) => (value === ALL_CITIES ? null : value),
+};
+
 function loadLevel(load: number): 'low' | 'medium' | 'high' {
   if (load < 30) return 'low';
   if (load < 70) return 'medium';
@@ -452,12 +459,16 @@ export default function NordModal({
                   </FormField>
 
                   {cities.length > 0 && (
-                    <FormField name="cityId" label={t('pages.xray.outbound.city')}>
+                    <FormField
+                      name="cityId"
+                      label={t('pages.xray.outbound.city')}
+                      transform={allCitiesTransform}
+                    >
                       <Select
                         data-testid="nord-city-select"
                         showSearch={{ optionFilterProp: 'label' }}
                         options={[
-                          { value: null, label: t('pages.xray.outbound.allCities') },
+                          { value: ALL_CITIES, label: t('pages.xray.outbound.allCities') },
                           ...cities.map((c) => ({ value: c.id, label: c.name })),
                         ]}
                       />

+ 13 - 2
frontend/src/pages/xray/overrides/PiaModal.tsx

@@ -73,6 +73,13 @@ const EMPTY: PiaFormValues = {
   hostname: null,
 };
 
+// antd warns on a null option value, so "All Regions" is a sentinel mapped back to null.
+const ALL_REGIONS = '__all__';
+const allRegionsTransform = {
+  input: (value: unknown) => value ?? ALL_REGIONS,
+  output: (value: unknown) => (value === ALL_REGIONS ? null : value),
+};
+
 function piaHostnameOf(outbound: PiaOutboundRow): string {
   if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
     return outbound.piaHostname.trim();
@@ -389,12 +396,16 @@ export default function PiaModal({
                 </FormField>
 
                 {regions.length > 0 && (
-                  <FormField name="regionId" label={t('pages.xray.pia.region')}>
+                  <FormField
+                    name="regionId"
+                    label={t('pages.xray.pia.region')}
+                    transform={allRegionsTransform}
+                  >
                     <Select
                       data-testid="pia-region-select"
                       showSearch={{ optionFilterProp: 'label' }}
                       options={[
-                        { value: null, label: t('pages.xray.pia.allRegions') },
+                        { value: ALL_REGIONS, label: t('pages.xray.pia.allRegions') },
                         ...regions.map((r) => ({ value: r.id, label: r.name })),
                       ]}
                     />

+ 2 - 2
frontend/src/schemas/client.ts

@@ -360,7 +360,7 @@ export const ClientBulkAdjustFormSchema = z
       (v.limitHwid !== undefined && v.limitHwid !== null) ||
       (v.adTag !== undefined && v.adTag.trim() !== ''),
     {
-      message: 'pages.clients.bulkAdjustNothing',
+      error: 'pages.clients.bulkAdjustNothing',
     },
   )
   .refine(
@@ -370,7 +370,7 @@ export const ClientBulkAdjustFormSchema = z
       return /^[0-9a-fA-F]{32}$/.test(tag);
     },
     {
-      message: 'pages.inbounds.form.mtgAdTagInvalid',
+      error: 'pages.inbounds.form.mtgAdTagInvalid',
       path: ['adTag'],
     },
   );

+ 1 - 1
frontend/src/schemas/forms/inbound-form.ts

@@ -52,7 +52,7 @@ const InboundTlsSettingsSchema = TlsStreamSettingsSchema.extend({
     .array(InboundTlsCertSchema)
     .default([])
     .refine((certificates) => certificates.some((cert) => cert.usage !== 'verify'), {
-      message: 'pages.inbounds.form.tlsServerCertificateRequired',
+      error: 'pages.inbounds.form.tlsServerCertificateRequired',
     }),
 });
 

+ 2 - 2
frontend/src/schemas/subBalancer.ts

@@ -33,12 +33,12 @@ export const SubBalancerFormSchema = z.object({
     .record(
       z.string(),
       z
-        .number({ message: 'pages.settings.subBalancers.errWeightPositive' })
+        .number({ error: 'pages.settings.subBalancers.errWeightPositive' })
         .positive('pages.settings.subBalancers.errWeightPositive'),
     )
     .optional(),
   sortOrder: z
-    .number({ message: 'pages.settings.subBalancers.errSortOrder' })
+    .number({ error: 'pages.settings.subBalancers.errSortOrder' })
     .int('pages.settings.subBalancers.errSortOrder')
     .min(1, 'pages.settings.subBalancers.errSortOrder'),
   enabled: z.boolean(),

+ 2 - 2
frontend/src/schemas/xray.ts

@@ -132,7 +132,7 @@ export const BalancerFormSchema = z.object({
     .string()
     .trim()
     .min(1, 'pages.xray.balancerTagRequired')
-    .refine((val) => !val.startsWith('_bl_'), { message: 'pages.xray.balancer.reservedPrefix' }),
+    .refine((val) => !val.startsWith('_bl_'), { error: 'pages.xray.balancer.reservedPrefix' }),
   strategy: BalancerStrategyTypeSchema.default('random'),
   selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
   fallbackTag: z.string().default(''),
@@ -143,7 +143,7 @@ export const OutboundTagSchema = z
   .string()
   .trim()
   .min(1, 'pages.xray.outboundTagRequired')
-  .refine((val) => !val.startsWith('_bl_'), { message: 'pages.xray.balancer.reservedPrefix' });
+  .refine((val) => !val.startsWith('_bl_'), { error: 'pages.xray.balancer.reservedPrefix' });
 
 export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
 export type RuleFormValues = z.infer<typeof RuleFormSchema>;

+ 13 - 10
frontend/src/test/app-sidebar.test.tsx

@@ -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
frontend/src/test/client-qr-modal.test.tsx

@@ -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,

+ 7 - 4
frontend/src/test/clients-row-cells-memo.test.tsx

@@ -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
frontend/src/test/command-palette.test.tsx

@@ -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('[email protected]')).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
frontend/src/test/date-time-picker.test.tsx

@@ -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
frontend/src/test/general-tab.test.tsx

@@ -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' } });
 

+ 2 - 2
frontend/src/test/geo-browser-selection.test.tsx

@@ -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(['', '']);
   });
 

+ 7 - 1
frontend/src/test/inbound-form-modal.test.tsx

@@ -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
frontend/src/test/input-number-guard.test.ts

@@ -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
frontend/src/test/nord-modal.test.tsx

@@ -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
frontend/src/test/pia-modal.test.tsx

@@ -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(

+ 2 - 2
frontend/src/test/remark-template-field.test.tsx

@@ -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'));

+ 1 - 0
frontend/src/test/rule-form-preserve-fields.test.tsx

@@ -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 - 0
frontend/src/test/setup.components.ts

@@ -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,
 }));

+ 2 - 0
frontend/vitest.config.ts

@@ -25,6 +25,8 @@ export default defineConfig({
           name: 'unit',
           include: ['src/test/**/*.test.ts'],
           environment: 'node',
+          // msw probes localStorage on load; Node 25+'s file-less one warns per worker.
+          execArgv: ['--no-experimental-webstorage'],
           setupFiles: ['./src/test/setup.ts', './src/test/setup.msw.ts'],
         },
       },