diff --git a/.github/workflows/claude-issue-analyst.yml b/.github/workflows/claude-issue-analyst.yml index 76c18a097..55e9e6e64 100644 --- a/.github/workflows/claude-issue-analyst.yml +++ b/.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/**)" diff --git a/.github/workflows/claude-pr-review.yml b/.github/workflows/claude-pr-review.yml index 7420f59f1..52821bae9 100644 --- a/.github/workflows/claude-pr-review.yml +++ b/.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" diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 8737aebfa..93b3846a6 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -26,7 +26,9 @@ export const withTheme: Decorator = (Story, context) => { document.documentElement.removeAttribute('data-theme'); }, [dark]); return ( - + // The click wave outlives its story and re-renders from a ResizeObserver + // inside the next story's act(), tripping React's act-environment warning. +
diff --git a/frontend/src/components/geodata/GeoBrowserModal.tsx b/frontend/src/components/geodata/GeoBrowserModal.tsx index 31baacb37..ce956a009 100644 --- a/frontend/src/components/geodata/GeoBrowserModal.tsx +++ b/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 = useMemo( + const entryRows: GeoEntryRow[] = useMemo( + () => (entriesQuery.data?.items ?? []).map((entry, position) => ({ ...entry, position })), + [entriesQuery.data], + ); + + const entryColumns: ColumnsType = useMemo( () => [ { dataIndex: 'kind', @@ -391,9 +400,9 @@ export default function GeoBrowserModal({ `${entry.value}-${index}`} + rowKey="position" columns={entryColumns} - dataSource={entriesQuery.data?.items ?? []} + dataSource={entryRows} loading={entriesQuery.isLoading} locale={{ emptyText: entriesQuery.isError diff --git a/frontend/src/pages/xray/overrides/NordModal.tsx b/frontend/src/pages/xray/overrides/NordModal.tsx index e03948d08..77ddfa911 100644 --- a/frontend/src/pages/xray/overrides/NordModal.tsx +++ b/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({ {cities.length > 0 && ( - + ({ value: r.id, label: r.name })), ]} /> diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index abffb6063..f8c6adb98 100644 --- a/frontend/src/schemas/client.ts +++ b/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'], }, ); diff --git a/frontend/src/schemas/forms/inbound-form.ts b/frontend/src/schemas/forms/inbound-form.ts index 6740c6d08..b578925f5 100644 --- a/frontend/src/schemas/forms/inbound-form.ts +++ b/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', }), }); diff --git a/frontend/src/schemas/subBalancer.ts b/frontend/src/schemas/subBalancer.ts index a30ac1a66..ebd5aa909 100644 --- a/frontend/src/schemas/subBalancer.ts +++ b/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(), diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index ad3089bd0..6738d88bb 100644 --- a/frontend/src/schemas/xray.ts +++ b/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; export type RuleFormValues = z.infer; diff --git a/frontend/src/test/app-sidebar.test.tsx b/frontend/src/test/app-sidebar.test.tsx index 723a7f805..87fe0157c 100644 --- a/frontend/src/test/app-sidebar.test.tsx +++ b/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( , ); + 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'); }); diff --git a/frontend/src/test/client-qr-modal.test.tsx b/frontend/src/test/client-qr-modal.test.tsx index b41ba74e0..62c9b7ca6 100644 --- a/frontend/src/test/client-qr-modal.test.tsx +++ b/frontend/src/test/client-qr-modal.test.tsx @@ -116,6 +116,11 @@ function renderSubject(overrides: Partial = {}) { }; } +// 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, diff --git a/frontend/src/test/clients-row-cells-memo.test.tsx b/frontend/src/test/clients-row-cells-memo.test.tsx index c85616bb6..1f5fc0a35 100644 --- a/frontend/src/test/clients-row-cells-memo.test.tsx +++ b/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', () => { , ); - 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 diff --git a/frontend/src/test/command-palette.test.tsx b/frontend/src/test/command-palette.test.tsx index b48b19fb4..d2582cf87 100644 --- a/frontend/src/test/command-palette.test.tsx +++ b/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('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) => diff --git a/frontend/src/test/date-time-picker.test.tsx b/frontend/src/test/date-time-picker.test.tsx index 388ac736d..a83749fdd 100644 --- a/frontend/src/test/date-time-picker.test.tsx +++ b/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'); diff --git a/frontend/src/test/general-tab.test.tsx b/frontend/src/test/general-tab.test.tsx index d2895be19..db1765319 100644 --- a/frontend/src/test/general-tab.test.tsx +++ b/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) => void) { + renderWithProviders( + + + , + ); + 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( - - - , - ); + 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( - - - , - ); + await renderGeneralTab(updateSetting); fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } }); diff --git a/frontend/src/test/geo-browser-selection.test.tsx b/frontend/src/test/geo-browser-selection.test.tsx index 59a7ba88b..a26ac7575 100644 --- a/frontend/src/test/geo-browser-selection.test.tsx +++ b/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(['', '']); }); diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index c6a86ac17..8790a7a48 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/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(); }); diff --git a/frontend/src/test/input-number-guard.test.ts b/frontend/src/test/input-number-guard.test.ts index 9f68b2610..466d7b6c2 100644 --- a/frontend/src/test/input-number-guard.test.ts +++ b/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 ?? ''); } } diff --git a/frontend/src/test/nord-modal.test.tsx b/frontend/src/test/nord-modal.test.tsx index 647055bf1..922f99b45 100644 --- a/frontend/src/test/nord-modal.test.tsx +++ b/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(); + + 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('.nord-server-popup .ant-select-item-option'), + ).toHaveLength(2), + ); + }); + it('disables Add when the selected server is already present', async () => { mockNordApi(); renderWithProviders( diff --git a/frontend/src/test/pia-modal.test.tsx b/frontend/src/test/pia-modal.test.tsx index 8087dae50..90050e626 100644 --- a/frontend/src/test/pia-modal.test.tsx +++ b/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(); + + 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( diff --git a/frontend/src/test/remark-template-field.test.tsx b/frontend/src/test/remark-template-field.test.tsx index 66768750c..4abf94309 100644 --- a/frontend/src/test/remark-template-field.test.tsx +++ b/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(); 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')); diff --git a/frontend/src/test/rule-form-preserve-fields.test.tsx b/frontend/src/test/rule-form-preserve-fields.test.tsx index 34aad0935..07656a5e4 100644 --- a/frontend/src/test/rule-form-preserve-fields.test.tsx +++ b/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 } } }); diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index 654a6aed9..563c01fa9 100644 --- a/frontend/src/test/setup.components.ts +++ b/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, })); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 2e1da948f..e4662d006 100644 --- a/frontend/vitest.config.ts +++ b/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'], }, },