mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 21:25:04 +03:00
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:
@@ -44,8 +44,8 @@ jobs:
|
|||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
allowed_non_write_users: "*"
|
allowed_non_write_users: "*"
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--model claude-opus-5.5
|
--model claude-opus-5-5
|
||||||
--effort high
|
--effort medium
|
||||||
--max-turns 300
|
--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/**)"
|
--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/**)"
|
--disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)"
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ jobs:
|
|||||||
# allowedTools only pre-approves; it denies nothing. Only the deny list
|
# allowedTools only pre-approves; it denies nothing. Only the deny list
|
||||||
# stops the review executing what it just checked out, or delegating.
|
# stops the review executing what it just checked out, or delegating.
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--model claude-opus-5.5
|
--model claude-opus-5-5
|
||||||
--effort high
|
--effort medium
|
||||||
--max-turns 300
|
--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"
|
--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"
|
--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"
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export const withTheme: Decorator = (Story, context) => {
|
|||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
return (
|
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 }}>
|
<div style={{ padding: 24, minWidth: 320 }}>
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types'
|
|||||||
import './GeoBrowserModal.css';
|
import './GeoBrowserModal.css';
|
||||||
|
|
||||||
const ENTRY_PAGE_SIZE = 100;
|
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 CATEGORY_SCROLL_HEIGHT = 438;
|
||||||
const ENTRY_FILTER_DELAY = 500;
|
const ENTRY_FILTER_DELAY = 500;
|
||||||
|
|
||||||
@@ -224,7 +228,12 @@ export default function GeoBrowserModal({
|
|||||||
[t],
|
[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',
|
dataIndex: 'kind',
|
||||||
@@ -391,9 +400,9 @@ export default function GeoBrowserModal({
|
|||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
rowKey={(entry, index) => `${entry.value}-${index}`}
|
rowKey="position"
|
||||||
columns={entryColumns}
|
columns={entryColumns}
|
||||||
dataSource={entriesQuery.data?.items ?? []}
|
dataSource={entryRows}
|
||||||
loading={entriesQuery.isLoading}
|
loading={entriesQuery.isLoading}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: entriesQuery.isError
|
emptyText: entriesQuery.isError
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ const EMPTY: NordFormValues = {
|
|||||||
serverId: null,
|
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' {
|
function loadLevel(load: number): 'low' | 'medium' | 'high' {
|
||||||
if (load < 30) return 'low';
|
if (load < 30) return 'low';
|
||||||
if (load < 70) return 'medium';
|
if (load < 70) return 'medium';
|
||||||
@@ -452,12 +459,16 @@ export default function NordModal({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{cities.length > 0 && (
|
{cities.length > 0 && (
|
||||||
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
|
<FormField
|
||||||
|
name="cityId"
|
||||||
|
label={t('pages.xray.outbound.city')}
|
||||||
|
transform={allCitiesTransform}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
data-testid="nord-city-select"
|
data-testid="nord-city-select"
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
options={[
|
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 })),
|
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ const EMPTY: PiaFormValues = {
|
|||||||
hostname: null,
|
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 {
|
function piaHostnameOf(outbound: PiaOutboundRow): string {
|
||||||
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
|
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
|
||||||
return outbound.piaHostname.trim();
|
return outbound.piaHostname.trim();
|
||||||
@@ -389,12 +396,16 @@ export default function PiaModal({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{regions.length > 0 && (
|
{regions.length > 0 && (
|
||||||
<FormField name="regionId" label={t('pages.xray.pia.region')}>
|
<FormField
|
||||||
|
name="regionId"
|
||||||
|
label={t('pages.xray.pia.region')}
|
||||||
|
transform={allRegionsTransform}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
data-testid="pia-region-select"
|
data-testid="pia-region-select"
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
showSearch={{ optionFilterProp: 'label' }}
|
||||||
options={[
|
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 })),
|
...regions.map((r) => ({ value: r.id, label: r.name })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export const ClientBulkAdjustFormSchema = z
|
|||||||
(v.limitHwid !== undefined && v.limitHwid !== null) ||
|
(v.limitHwid !== undefined && v.limitHwid !== null) ||
|
||||||
(v.adTag !== undefined && v.adTag.trim() !== ''),
|
(v.adTag !== undefined && v.adTag.trim() !== ''),
|
||||||
{
|
{
|
||||||
message: 'pages.clients.bulkAdjustNothing',
|
error: 'pages.clients.bulkAdjustNothing',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -370,7 +370,7 @@ export const ClientBulkAdjustFormSchema = z
|
|||||||
return /^[0-9a-fA-F]{32}$/.test(tag);
|
return /^[0-9a-fA-F]{32}$/.test(tag);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'pages.inbounds.form.mtgAdTagInvalid',
|
error: 'pages.inbounds.form.mtgAdTagInvalid',
|
||||||
path: ['adTag'],
|
path: ['adTag'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const InboundTlsSettingsSchema = TlsStreamSettingsSchema.extend({
|
|||||||
.array(InboundTlsCertSchema)
|
.array(InboundTlsCertSchema)
|
||||||
.default([])
|
.default([])
|
||||||
.refine((certificates) => certificates.some((cert) => cert.usage !== 'verify'), {
|
.refine((certificates) => certificates.some((cert) => cert.usage !== 'verify'), {
|
||||||
message: 'pages.inbounds.form.tlsServerCertificateRequired',
|
error: 'pages.inbounds.form.tlsServerCertificateRequired',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ export const SubBalancerFormSchema = z.object({
|
|||||||
.record(
|
.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
z
|
z
|
||||||
.number({ message: 'pages.settings.subBalancers.errWeightPositive' })
|
.number({ error: 'pages.settings.subBalancers.errWeightPositive' })
|
||||||
.positive('pages.settings.subBalancers.errWeightPositive'),
|
.positive('pages.settings.subBalancers.errWeightPositive'),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
sortOrder: z
|
sortOrder: z
|
||||||
.number({ message: 'pages.settings.subBalancers.errSortOrder' })
|
.number({ error: 'pages.settings.subBalancers.errSortOrder' })
|
||||||
.int('pages.settings.subBalancers.errSortOrder')
|
.int('pages.settings.subBalancers.errSortOrder')
|
||||||
.min(1, 'pages.settings.subBalancers.errSortOrder'),
|
.min(1, 'pages.settings.subBalancers.errSortOrder'),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const BalancerFormSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, 'pages.xray.balancerTagRequired')
|
.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'),
|
strategy: BalancerStrategyTypeSchema.default('random'),
|
||||||
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
|
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
|
||||||
fallbackTag: z.string().default(''),
|
fallbackTag: z.string().default(''),
|
||||||
@@ -143,7 +143,7 @@ export const OutboundTagSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, 'pages.xray.outboundTagRequired')
|
.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 BalancerFormValues = z.infer<typeof BalancerFormSchema>;
|
||||||
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
|
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
|
||||||
|
|||||||
@@ -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 { MemoryRouter } from 'react-router';
|
||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -13,16 +13,19 @@ afterEach(() => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderSidebar() {
|
// rc-menu registers its items in a microtask after render; settle it inside act().
|
||||||
return renderWithProviders(
|
async function renderSidebar() {
|
||||||
|
const view = renderWithProviders(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
await act(async () => {});
|
||||||
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
|
test('keeps the sidebar expanded after pinning it from the header and restores the choice', async () => {
|
||||||
const first = renderSidebar();
|
const first = await renderSidebar();
|
||||||
const sidebar = first.container.querySelector('.ant-layout-sider');
|
const sidebar = first.container.querySelector('.ant-layout-sider');
|
||||||
const sidebarRoot = first.container.querySelector('.ant-sidebar');
|
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();
|
first.unmount();
|
||||||
|
|
||||||
const second = renderSidebar();
|
const second = await renderSidebar();
|
||||||
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
|
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
|
||||||
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
|
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();
|
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns to the compact rail after unpinning', () => {
|
test('returns to the compact rail after unpinning', async () => {
|
||||||
const view = renderSidebar();
|
const view = await renderSidebar();
|
||||||
const sidebar = view.container.querySelector('.ant-layout-sider');
|
const sidebar = view.container.querySelector('.ant-layout-sider');
|
||||||
const sidebarRoot = view.container.querySelector('.ant-sidebar');
|
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');
|
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('labels the palette shortcut with the modifier the platform actually uses', () => {
|
test('labels the palette shortcut with the modifier the platform actually uses', async () => {
|
||||||
const view = renderSidebar();
|
const view = await renderSidebar();
|
||||||
const chip = view.container.querySelector('.sidebar-command-kbd');
|
const chip = view.container.querySelector('.sidebar-command-kbd');
|
||||||
expect(chip?.textContent).toBe('CtrlK');
|
expect(chip?.textContent).toBe('CtrlK');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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') {
|
function selectVariant(name: 'Standard' | 'Happ') {
|
||||||
fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
|
fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
|
||||||
}
|
}
|
||||||
@@ -129,8 +134,9 @@ describe('ClientQrModal Happ presentation', () => {
|
|||||||
vi.mocked(HttpUtil.post).mockReset();
|
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();
|
renderSubject();
|
||||||
|
await settleSubLinks();
|
||||||
|
|
||||||
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
||||||
true,
|
true,
|
||||||
@@ -139,8 +145,9 @@ describe('ClientQrModal Happ presentation', () => {
|
|||||||
expect(HttpUtil.post).not.toHaveBeenCalled();
|
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();
|
renderSubject();
|
||||||
|
await settleSubLinks();
|
||||||
|
|
||||||
expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
|
expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -148,7 +155,7 @@ describe('ClientQrModal Happ presentation', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
['missing', undefined],
|
['missing', undefined],
|
||||||
['false', false],
|
['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 = {
|
const subSettings: TestSubSettings = {
|
||||||
enable: SUB_SETTINGS.enable,
|
enable: SUB_SETTINGS.enable,
|
||||||
subURI: SUB_SETTINGS.subURI,
|
subURI: SUB_SETTINGS.subURI,
|
||||||
@@ -158,6 +165,7 @@ describe('ClientQrModal Happ presentation', () => {
|
|||||||
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
||||||
|
|
||||||
renderSubject({ subSettings });
|
renderSubject({ subSettings });
|
||||||
|
await settleSubLinks();
|
||||||
|
|
||||||
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
||||||
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
|
||||||
@@ -176,7 +184,7 @@ describe('ClientQrModal Happ presentation', () => {
|
|||||||
['false', false],
|
['false', false],
|
||||||
])(
|
])(
|
||||||
'replaces the blank Happ content with a persistent empty state when the gate is %s',
|
'replaces the blank Happ content with a persistent empty state when the gate is %s',
|
||||||
(_name, gate) => {
|
async (_name, gate) => {
|
||||||
const subSettings: TestSubSettings = {
|
const subSettings: TestSubSettings = {
|
||||||
enable: SUB_SETTINGS.enable,
|
enable: SUB_SETTINGS.enable,
|
||||||
subURI: SUB_SETTINGS.subURI,
|
subURI: SUB_SETTINGS.subURI,
|
||||||
@@ -186,6 +194,7 @@ describe('ClientQrModal Happ presentation', () => {
|
|||||||
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
if (gate !== undefined) subSettings.happLinkEnable = gate;
|
||||||
|
|
||||||
renderSubject({ subSettings });
|
renderSubject({ subSettings });
|
||||||
|
await settleSubLinks();
|
||||||
|
|
||||||
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
|
||||||
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) 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();
|
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 } });
|
const view = renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
|
||||||
|
await settleSubLinks();
|
||||||
selectVariant('Happ');
|
selectVariant('Happ');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
|
||||||
@@ -411,6 +421,7 @@ describe('ClientQrModal Happ presentation', () => {
|
|||||||
|
|
||||||
view.update({ open: false });
|
view.update({ open: false });
|
||||||
view.update({ open: true });
|
view.update({ open: true });
|
||||||
|
await settleSubLinks();
|
||||||
|
|
||||||
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -58,8 +58,9 @@ describe('clients table row cells', () => {
|
|||||||
expect(afterFirstRender).toBeGreaterThan(0);
|
expect(afterFirstRender).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Three simulated traffic pushes: the parent re-renders, the props do not change.
|
// Three simulated traffic pushes: the parent re-renders, the props do not change.
|
||||||
for (let i = 0; i < 3; i++) bump();
|
act(() => {
|
||||||
await Promise.resolve();
|
for (let i = 0; i < 3; i++) bump();
|
||||||
|
});
|
||||||
|
|
||||||
expect(reads.count).toBe(afterFirstRender);
|
expect(reads.count).toBe(afterFirstRender);
|
||||||
});
|
});
|
||||||
@@ -113,7 +114,9 @@ describe('clients table row cells', () => {
|
|||||||
</Harness>,
|
</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
|
// 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
|
// bundle, so the aria-labels are translated strings, not keys. Order is
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ describe('CommandPalette component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait past the 300ms debounce interval while bob fetch is still pending
|
// 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
|
// Stale Alice row must STILL not be rendered
|
||||||
expect(screen.queryByText('alice@example.com')).toBeNull();
|
expect(screen.queryByText('alice@example.com')).toBeNull();
|
||||||
@@ -258,7 +258,7 @@ describe('CommandPalette component', () => {
|
|||||||
|
|
||||||
// Add trailing whitespace
|
// Add trailing whitespace
|
||||||
fireEvent.change(input, { target: { value: 'abc ' } });
|
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
|
// No extra search call because trimmed query has not changed
|
||||||
const callsAfterAbcSpace = getSpy.mock.calls.filter((c) =>
|
const callsAfterAbcSpace = getSpy.mock.calls.filter((c) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react';
|
import { act, fireEvent, screen } from '@testing-library/react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 {
|
function openPicker(): void {
|
||||||
const input = document.querySelector('.ant-picker input');
|
const input = document.querySelector('.ant-picker input');
|
||||||
|
|||||||
@@ -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 { MemoryRouter } from 'react-router';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -6,15 +6,21 @@ import { AllSetting } from '@/models/setting';
|
|||||||
import GeneralTab from '@/pages/settings/GeneralTab';
|
import GeneralTab from '@/pages/settings/GeneralTab';
|
||||||
import { renderWithProviders } from './test-utils';
|
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', () => {
|
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();
|
const updateSetting = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
await renderGeneralTab(updateSetting);
|
||||||
<MemoryRouter initialEntries={['/settings']}>
|
|
||||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageSizeInput = screen.getByDisplayValue('25');
|
const pageSizeInput = screen.getByDisplayValue('25');
|
||||||
fireEvent.change(pageSizeInput, { target: { value: '' } });
|
fireEvent.change(pageSizeInput, { target: { value: '' } });
|
||||||
@@ -24,14 +30,10 @@ describe('GeneralTab', () => {
|
|||||||
expect((pageSizeInput as HTMLInputElement).value).toBe('25');
|
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();
|
const updateSetting = vi.fn();
|
||||||
|
|
||||||
renderWithProviders(
|
await renderGeneralTab(updateSetting);
|
||||||
<MemoryRouter initialEntries={['/settings']}>
|
|
||||||
<GeneralTab allSetting={new AllSetting({ pageSize: 25 })} updateSetting={updateSetting} />
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
|
fireEvent.change(screen.getByDisplayValue('25'), { target: { value: '0' } });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
|
||||||
await user.click(screen.getByText('telegram'));
|
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(['', '']);
|
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 { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
|
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
|
||||||
@@ -279,6 +279,8 @@ describe('InboundFormModal', () => {
|
|||||||
const post = vi.mocked(HttpUtil.post);
|
const post = vi.mocked(HttpUtil.post);
|
||||||
post.mockClear();
|
post.mockClear();
|
||||||
messageError.mockClear();
|
messageError.mockClear();
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
onTestFinished(() => consoleError.mockRestore());
|
||||||
renderModal();
|
renderModal();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
|
fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
|
||||||
@@ -294,6 +296,10 @@ describe('InboundFormModal', () => {
|
|||||||
expect.stringContaining('TLS certificate 1: Import a TLS certificate'),
|
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();
|
expect(post).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ const RULE = 'input-number(no-synthetic-clear)';
|
|||||||
|
|
||||||
function runGuard(target: string): string {
|
function runGuard(target: string): string {
|
||||||
try {
|
try {
|
||||||
execFileSync('./node_modules/.bin/oxlint', ['-c', `${FIXTURES}/guard.oxlintrc.json`, target], {
|
// .bin/oxlint is a sh shim Windows can't spawn; run the node entry directly.
|
||||||
encoding: 'utf8',
|
execFileSync(
|
||||||
stdio: 'pipe',
|
process.execPath,
|
||||||
});
|
['node_modules/oxlint/bin/oxlint', '-c', `${FIXTURES}/guard.oxlintrc.json`, target],
|
||||||
|
{ encoding: 'utf8', stdio: 'pipe' },
|
||||||
|
);
|
||||||
return '';
|
return '';
|
||||||
} catch (error) {
|
} 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 ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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 NordModal from '@/pages/xray/overrides/NordModal';
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
@@ -70,7 +70,10 @@ async function chooseOption(testId: string, labelPart: string) {
|
|||||||
`${item.getAttribute('title') ?? ''} ${item.textContent ?? ''}`.includes(labelPart),
|
`${item.getAttribute('title') ?? ''} ${item.textContent ?? ''}`.includes(labelPart),
|
||||||
);
|
);
|
||||||
if (!option) throw new Error(`Missing option containing ${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() {
|
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 () => {
|
it('disables Add when the selected server is already present', async () => {
|
||||||
mockNordApi();
|
mockNordApi();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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 PiaModal from '@/pages/xray/overrides/PiaModal';
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
@@ -106,7 +106,10 @@ async function chooseOption(testId: string, labelPart: string) {
|
|||||||
(item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
|
(item.getAttribute('title') ?? item.textContent ?? '').includes(labelPart),
|
||||||
);
|
);
|
||||||
if (!option) throw new Error(`Missing option containing ${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() {
|
async function clickAddOutbound() {
|
||||||
@@ -115,7 +118,10 @@ async function clickAddOutbound() {
|
|||||||
if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
|
if ((btn as HTMLButtonElement).disabled) throw new Error('Add outbound still disabled');
|
||||||
return btn;
|
return btn;
|
||||||
});
|
});
|
||||||
fireEvent.click(addButton);
|
// Adding provisions a key over HTTP; let that settle inside act().
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectPiaOutbound(
|
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 () => {
|
it('disables Add when the selected server is already in the list', async () => {
|
||||||
mockPiaApi();
|
mockPiaApi();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
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 RemarkTemplateField from '@/components/form/RemarkTemplateField';
|
||||||
import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
|
import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
|
||||||
@@ -29,7 +29,7 @@ describe('RemarkTemplateField', () => {
|
|||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
|
render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
|
||||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||||
textarea.focus();
|
act(() => textarea.focus());
|
||||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button'));
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('RuleFormModal edit preserves unsurfaced fields', () => {
|
|||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
|
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 onConfirm = vi.fn();
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { initReactI18next } from 'react-i18next';
|
|||||||
|
|
||||||
import enUS from '../../../internal/web/translation/en-US.json';
|
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', () => ({
|
vi.mock('persian-calendar-suite', () => ({
|
||||||
PersianDateTimePicker: () => null,
|
PersianDateTimePicker: () => null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export default defineConfig({
|
|||||||
name: 'unit',
|
name: 'unit',
|
||||||
include: ['src/test/**/*.test.ts'],
|
include: ['src/test/**/*.test.ts'],
|
||||||
environment: 'node',
|
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'],
|
setupFiles: ['./src/test/setup.ts', './src/test/setup.msw.ts'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user