fix(frontend): make inbound forms accessible to screen readers (#7025)

Expose selectable options and selected values, associate validation guidance with controls, and contain keyboard focus in inbound dialogs. Preserve create and edit serialization and add Chromium keyboard and accessibility checks.
This commit is contained in:
Hamed
2026-11-05 20:38:49 +03:30
committed by GitHub
parent dbb6212988
commit f4030c889c
29 changed files with 875 additions and 110 deletions
+9
View File
@@ -365,6 +365,15 @@ jobs:
- name: Build Storybook
run: npm run build-storybook
working-directory: frontend
- name: Inbound keyboard and accessibility tree
working-directory: frontend
run: |
set -euo pipefail
./node_modules/.bin/vite preview --outDir storybook-static --host 127.0.0.1 --port 6006 > /tmp/inbound-preview.log 2>&1 &
preview_pid=$!
trap 'kill "$preview_pid" 2>/dev/null || true' EXIT
curl --retry 30 --retry-connrefused --retry-delay 1 --retry-max-time 45 -fsS http://127.0.0.1:6006/iframe.html > /dev/null
node tools/check-inbound-keyboard.mjs http://127.0.0.1:6006
- name: Audit
run: npm run audit
working-directory: frontend
+27
View File
@@ -47,6 +47,33 @@ production-style links work without round-tripping through Go.
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
### Inbound accessibility verification
`npm test` includes the real inbound form and native-select regression fixtures.
After `npm run build-storybook`, serve the built stories with
`npx vite preview --outDir storybook-static --host 127.0.0.1 --port 6006` and, in
another terminal, run `node tools/check-inbound-keyboard.mjs`. CI runs this check
too. It uses real Chromium Tab/Arrow/Enter input, checks native selected values
in the accessibility tree, captures create/edit requests, and verifies focus
containment and return. Its HTTP boundary is stubbed; backend configuration
generation and server validation are unchanged.
Mobile viewport emulation and accessibility-tree checks do not prove TalkBack
or NVDA compatibility. On actual Android TalkBack/Chrome and Windows NVDA:
1. Open Add Inbound, explore Protocol, open the native picker, choose Shadowsocks
and confirm the control announces its selected value. Cancel another picker
and confirm the value and focus stay unchanged.
2. Visit Protocol, select an encryption method, then Stream; choose RAW,
WebSocket, gRPC and XHTTP and verify the dependent fields and their labels.
3. Check Security's None/TLS/REALITY radios; try certificate/uTLS selections and
the ALPN multi-select, searchable node/fallback fields, clearing and custom tags.
4. Trigger a custom-share-address validation error from another tab. Confirm
the form reveals/focuses the field and reads its hint/error. Correct it and save.
5. Edit the inbound, confirm Protocol remains disabled, save a transport change,
and close/reopen with keyboard and touch exploration. Focus must return to
the opener and Tab/Shift+Tab must stay inside the open dialog.
### One-off: scan for deprecated APIs
Run this command to sweep the codebase for usages of APIs marked
@@ -0,0 +1,14 @@
.native-select {
padding-inline: 11px;
font-family: inherit;
cursor: pointer;
}
.native-select:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.native-select:disabled {
cursor: not-allowed;
}
@@ -0,0 +1,67 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { NativeSelect } from './NativeSelect';
const meta = {
title: 'Form/NativeSelect',
component: NativeSelect,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Native single selection for short, fixed lists. Labels, values, keyboard interaction and the mobile picker belong to the browser. Index keys keep number and string values distinct in onChange; searchable, clearable and multi-select fields continue using Ant Design.',
},
},
},
argTypes: {
options: {
description: 'Ordered labels, typed string/number values and optional disabled choices.',
},
value: {
description: 'Controlled form value. Saved values absent from the list remain visible.',
},
onChange: {
description: 'Receives the original typed option value for RHF or Ant Design Form.',
},
placeholder: { description: 'Shown until the form has a value.' },
disabled: {
description: 'Uses native disabled behavior and preserves the controlled form value.',
},
size: {
description: 'Ant Design token size, without turning the control into a multi-row listbox.',
},
},
args: {
'aria-label': 'Protocol',
options: [
{ value: 'vless', label: 'VLESS' },
{ value: 'shadowsocks', label: 'Shadowsocks' },
{ value: 'tuic', label: 'TUIC', disabled: true },
],
},
} satisfies Meta<typeof NativeSelect>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SingleSelection: Story = {
render: function Render(args) {
const [value, setValue] = useState<string | number>('vless');
return <NativeSelect {...args} value={value} onChange={setValue} />;
},
play: async ({ canvas, userEvent }) => {
const select = canvas.getByRole('combobox', { name: 'Protocol' });
await expect(select).toHaveDisplayValue('VLESS');
await userEvent.selectOptions(
select,
within(select).getByRole('option', { name: 'Shadowsocks' }),
);
await expect(select).toHaveDisplayValue('Shadowsocks');
await expect(within(select).getByRole('option', { name: 'Shadowsocks' })).toHaveProperty(
'selected',
true,
);
},
};
@@ -0,0 +1,70 @@
import type { ComponentPropsWithRef } from 'react';
import { theme } from 'antd';
import './NativeSelect.css';
export interface NativeSelectProps<Value extends string | number> extends Omit<
ComponentPropsWithRef<'select'>,
'value' | 'defaultValue' | 'onChange' | 'multiple' | 'children' | 'size'
> {
value?: Value | null;
onChange?: (value: Value) => void;
options: readonly { value: Value; label: string | number; disabled?: boolean }[];
placeholder?: string;
size?: 'small' | 'middle' | 'large';
}
export function NativeSelect<Value extends string | number>({
value,
options,
onChange,
placeholder,
disabled,
className,
style,
size,
...props
}: NativeSelectProps<Value>) {
const { token } = theme.useToken();
const selected = options.findIndex((option) => Object.is(option.value, value));
const hasUnlistedValue = selected < 0 && value != null;
return (
<select
{...props}
className={['native-select', className].filter(Boolean).join(' ')}
disabled={disabled}
value={selected < 0 ? '' : String(selected)}
onChange={(event) => {
const option = options[Number(event.target.value)];
if (event.target.value !== '' && option && !option.disabled) onChange?.(option.value);
}}
style={{
width: '100%',
minWidth: 0,
height:
size === 'small'
? token.controlHeightSM
: size === 'large'
? token.controlHeightLG
: token.controlHeight,
border: `1px solid ${props['aria-invalid'] === true || props['aria-invalid'] === 'true' ? token.colorError : token.colorBorder}`,
borderRadius: token.borderRadius,
backgroundColor: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
color: disabled ? token.colorTextDisabled : token.colorText,
fontSize: token.fontSize,
...style,
}}
>
{selected < 0 && (
<option value="" disabled={!hasUnlistedValue} hidden={!hasUnlistedValue}>
{hasUnlistedValue ? String(value) : (placeholder ?? '')}
</option>
)}
{options.map((option, index) => (
<option key={String(index)} value={String(index)} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
);
}
@@ -221,6 +221,9 @@ export const ValidationErrors: Story = {
play: async ({ canvas, userEvent }) => {
const remarkError = await canvas.findByText('Remark is required');
await waitFor(() => expect(remarkError).toBeVisible());
await expect(canvas.getByRole('textbox', { name: 'Remark' })).toHaveAccessibleDescription(
'Remark is required',
);
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
+12 -9
View File
@@ -1,5 +1,5 @@
import { cloneElement, useId } from 'react';
import type { CSSProperties, ReactElement, ReactNode } from 'react';
import type { AriaAttributes, CSSProperties, ReactElement, ReactNode } from 'react';
import { Controller } from 'react-hook-form';
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
import { Form } from 'antd';
@@ -50,12 +50,10 @@ export function FormField<T extends FieldValues = FieldValues>({
const dottedName = toDotted(name) as Path<T>;
const generatedId = useId();
const hintId = useId();
const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
.props;
const errorId = `${generatedId}-error`;
const extraId = `${generatedId}-extra`;
const childOwnProps = (children as ReactElement<{ id?: string } & AriaAttributes>).props;
const fieldId = childOwnProps.id ?? generatedId;
const describedBy = tooltip
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
: undefined;
return (
<Controller
@@ -67,8 +65,14 @@ export function FormField<T extends FieldValues = FieldValues>({
const help = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: undefined;
const describedBy =
[childOwnProps['aria-describedby'], tooltip && hintId, extra && extraId, help && errorId]
.filter(Boolean)
.join(' ') || undefined;
const childProps: Record<string, unknown> = {
id: fieldId,
'aria-describedby': describedBy,
'aria-invalid': fieldState.invalid || childOwnProps['aria-invalid'],
[valueProp]: displayValue,
onChange: (...args: unknown[]) => {
const raw = normalizeAntdOnChange(args, valueProp);
@@ -79,16 +83,15 @@ export function FormField<T extends FieldValues = FieldValues>({
onBlur: field.onBlur,
ref: field.ref,
};
if (describedBy) childProps['aria-describedby'] = describedBy;
return (
<Form.Item
label={label}
htmlFor={label ? fieldId : undefined}
tooltip={tooltip}
extra={extra}
extra={extra ? <span id={extraId}>{extra}</span> : extra}
required={required}
validateStatus={fieldState.error ? 'error' : undefined}
help={help}
help={help ? <span id={errorId}>{help}</span> : undefined}
noStyle={noStyle}
className={className}
style={style}
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { Button, Divider, Form, Input, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
@@ -68,7 +69,7 @@ export default function CustomSockoptList({
<Input placeholder="decimal, e.g. 19" />
</Form.Item>
<Form.Item label="Type" name={[field.name, 'type']}>
<Select options={TYPE_OPTIONS} />
<NativeSelect options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="Value" name={[field.name, 'value']}>
<Input placeholder="value" />
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useEffect, useId, useRef, useState } from 'react';
import {
AutoComplete,
@@ -407,7 +408,7 @@ function TcpMasksList({
}
/>
{allowPresets && (
<Select
<NativeSelect
id={`${uid}-tcp-presets`}
size="small"
style={{ minWidth: 160 }}
@@ -478,7 +479,7 @@ function TcpMaskItem({
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
<NativeSelect
onChange={(v) =>
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
}
@@ -1123,7 +1124,7 @@ function UdpMaskItem({
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select onChange={onTypeChange} options={options} />
<NativeSelect onChange={onTypeChange} options={options} />
</Form.Item>
<Form.Item
@@ -1147,7 +1148,7 @@ function UdpMaskItem({
return (
<>
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
<Select
<NativeSelect
options={[
{ value: '', label: 'Original / AES-128-GCM' },
{ value: 'dns', label: 'DNS' },
@@ -1327,7 +1328,7 @@ function SalamanderUdpMaskSettings({
: 'Scrambles each packet into random-looking bytes.'
}
>
<Select
<NativeSelect
id={`${uid}-salamander-mode`}
value={mode}
onChange={(next) => {
@@ -1947,7 +1948,7 @@ function ItemEditor({
<>
{allowVariables && (
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
<Select
<NativeSelect
id={`${uid}-kind`}
value={kind}
onChange={onKindChange}
@@ -1957,7 +1958,7 @@ function ItemEditor({
)}
{kind === 'bytes' && (
<Form.Item label="Type" name={[fieldName, 'type']}>
<Select
<NativeSelect
onChange={onTypeChange}
options={[
{ value: 'array', label: 'Array' },
@@ -2122,7 +2123,7 @@ function QuicParamsForm({
return (
<>
<Form.Item label="Congestion" name={[...base, 'congestion']}>
<Select
<NativeSelect
options={[
{ value: 'reno', label: 'Reno' },
{ value: 'bbr', label: 'BBR' },
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
@@ -89,7 +90,7 @@ export default function XDriveForm() {
return (
<>
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
<Select options={serviceOptions} />
<NativeSelect options={serviceOptions} />
</FormField>
<FormField
label={t('pages.inbounds.form.xdrive.remoteFolder')}
@@ -0,0 +1,121 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within, waitFor } from 'storybook/test';
import { Button } from 'antd';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { setDatepicker } from '@/hooks/useDatepicker';
import InboundFormModal from './InboundFormModal';
const meta = {
title: 'Inbounds/InboundFormModal',
component: InboundFormModal,
tags: ['autodocs'],
beforeEach: () => {
setDatepicker('gregorian');
},
args: {
open: false,
mode: 'add',
dbInbound: null,
dbInbounds: [],
onClose: () => {},
onSaved: () => {},
},
parameters: {
docs: {
description: {
component:
'The real inbound form. Exercise named options, keyboard navigation, validation and modal focus here; verify TalkBack/Chrome and NVDA separately before claiming screen-reader compatibility.',
},
},
},
} satisfies Meta<typeof InboundFormModal>;
export default meta;
type Story = StoryObj<typeof meta>;
function InboundDemo({ edit = false }: { edit?: boolean }) {
const [open, setOpen] = useState(false);
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
);
const [inbound] = useState(
() =>
new DBInbound({
id: 41,
protocol: 'shadowsocks',
enable: true,
port: 14443,
settings: {
method: 'chacha20-ietf-poly1305',
password: 'example-password',
network: 'tcp,udp',
clients: [],
},
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
}),
);
return (
<QueryClientProvider client={queryClient}>
<AntdRootConfig>
<ThemeProvider>
<Button onClick={() => setOpen(true)}>{edit ? 'Edit inbound' : 'Add inbound'}</Button>
<InboundFormModal
open={open}
mode={edit ? 'edit' : 'add'}
dbInbound={edit ? inbound : null}
dbInbounds={[]}
availableNodes={[]}
onClose={() => setOpen(false)}
onSaved={() => {}}
/>
</ThemeProvider>
</AntdRootConfig>
</QueryClientProvider>
);
}
export const Create: Story = {
render: () => <InboundDemo />,
};
export const SelectableOptions: Story = {
...Create,
play: async ({ canvas, canvasElement, userEvent }) => {
const page = within(canvasElement.ownerDocument.body);
const opener = canvas.getByRole('button', { name: 'Add inbound' });
await userEvent.click(opener);
const dialog = await page.findByRole('dialog', { name: 'Add Inbound' });
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
await userEvent.selectOptions(
protocol,
within(protocol).getByRole('option', { name: 'shadowsocks' }),
);
await expect(protocol).toHaveDisplayValue('shadowsocks');
await expect(protocol).toHaveFocus();
await userEvent.click(within(dialog).getByRole('tab', { name: 'Protocol' }));
const cipher = within(dialog).getByRole('combobox', { name: 'Encryption method' });
await userEvent.selectOptions(
cipher,
within(cipher).getByRole('option', { name: 'chacha20-ietf-poly1305' }),
);
await userEvent.click(within(dialog).getByRole('tab', { name: 'Stream' }));
const transport = within(dialog).getByRole('combobox', { name: 'Transmission' });
await userEvent.selectOptions(
transport,
within(transport).getByRole('option', { name: 'WebSocket' }),
);
await expect(within(dialog).getByRole('textbox', { name: 'Path' })).toHaveValue('/');
await userEvent.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]);
await waitFor(() => expect(opener).toHaveFocus());
},
};
export const Edit: Story = {
render: () => <InboundDemo edit />,
};
@@ -1,8 +1,22 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useEffect, useRef, useState, useId } from 'react';
import type { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
import {
Alert,
ConfigProvider,
Form,
Input,
InputNumber,
Modal,
Radio,
Select,
Switch,
Tabs,
message,
} from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
@@ -92,6 +106,28 @@ const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
const SHARE_ADDR_HOSTNAME_RE =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
function inboundPopupContainer(trigger?: HTMLElement): HTMLElement {
return (
trigger?.closest<HTMLElement>('.ant-modal-body') ?? trigger?.parentElement ?? document.body
);
}
function loopInboundFocus(event: KeyboardEvent<HTMLDivElement>) {
if (event.key !== 'Tab') return;
const first = event.currentTarget.querySelector<HTMLButtonElement>(
'.ant-modal-close:not(:disabled)',
);
const last = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'.ant-modal-footer button:not(:disabled)',
),
).at(-1);
if (first && last && event.target === (event.shiftKey ? first : last)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
}
}
function isValidShareAddrInput(value: string): boolean {
const v = value.trim();
if (v.length === 0) return true;
@@ -677,7 +713,7 @@ export default function InboundFormModal({
)}
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
<NativeSelect id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
</FormField>
<FormField
@@ -695,7 +731,7 @@ export default function InboundFormModal({
label={t('pages.inbounds.form.shareAddrStrategy')}
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
>
<Select
<NativeSelect
options={SHARE_ADDR_STRATEGIES.filter(
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
).map((strategy) => ({
@@ -780,7 +816,7 @@ export default function InboundFormModal({
</Form.Item>
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
<Select
<NativeSelect
options={TRAFFIC_RESETS.map((r) => ({
value: r,
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
@@ -958,7 +994,7 @@ export default function InboundFormModal({
<>
{hasSelectableTransport && (
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
<Select
<NativeSelect
id={`${uid}-network`}
style={{ width: '75%' }}
value={network}
@@ -1034,8 +1070,11 @@ export default function InboundFormModal({
const securityTab = (
<>
<Form.Item label={t('pages.inbounds.securityTab')}>
<Form.Item
label={<span id={`${uid}-security-label`}>{t('pages.inbounds.securityTab')}</span>}
>
<Radio.Group
aria-labelledby={`${uid}-security-label`}
value={security}
buttonStyle="solid"
disabled={!tlsOk}
@@ -1174,7 +1213,7 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />;
return (
<>
<ConfigProvider virtual={false} getPopupContainer={inboundPopupContainer}>
{messageContextHolder}
{modalContextHolder}
<Modal
@@ -1188,6 +1227,11 @@ export default function InboundFormModal({
onOk={submit}
onCancel={onClose}
destroyOnHidden
modalRender={(content) => (
<div role="presentation" onKeyDown={loopInboundFocus}>
{content}
</div>
)}
>
<FormProvider {...methods}>
<Form
@@ -1273,6 +1317,6 @@ export default function InboundFormModal({
</Form>
</FormProvider>
</Modal>
</>
</ConfigProvider>
);
}
@@ -1,6 +1,7 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { Form, Input, InputNumber, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
@@ -60,7 +61,7 @@ export default function HysteriaFields() {
{masq && (
<>
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}>
<Select
<NativeSelect
options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
@@ -1,5 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { Input, Select, Switch } from 'antd';
import { Input, Switch } from 'antd';
import { FormField } from '@/components/form/rhf';
import AccountsList from './accounts-list';
@@ -10,7 +11,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
<>
<AccountsList />
<FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
<Select
<NativeSelect
options={[
{ value: 'noauth', label: 'noauth' },
{ value: 'password', label: 'password' },
@@ -1,6 +1,7 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, Select, Space, Switch } from 'antd';
import { Button, Form, Input, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { useFormContext } from 'react-hook-form';
@@ -25,7 +26,9 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
}}
>
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
<NativeSelect
options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))}
/>
</FormField>
{isSSWith2022 && (
<Form.Item htmlFor={`${uid}-password`} label={t('password')}>
@@ -48,7 +51,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
</Form.Item>
)}
<FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
<Select
<NativeSelect
style={{ width: 120 }}
options={[
{ value: 'tcp,udp', label: 'TCP, UDP' },
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -111,7 +112,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.logLevel')}
tooltip={t('pages.xray.tuic.logLevelHint')}
>
<Select
<NativeSelect
options={[
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
@@ -199,7 +200,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.congestionControl')}
tooltip={t('pages.xray.tuic.congestionControlHint')}
>
<Select
<NativeSelect
options={[
{ label: 'BBR', value: 'bbr' },
{ label: 'CUBIC', value: 'cubic' },
@@ -244,7 +245,7 @@ export default function TuicFields() {
label={t('pages.xray.tuic.udpRelayMode')}
tooltip={t('pages.xray.tuic.udpRelayModeHint')}
>
<Select
<NativeSelect
options={[
{ label: 'Native (Recommended)', value: 'native' },
{ label: 'QUIC', value: 'quic' },
@@ -1,5 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch } from 'antd';
import { Input, InputNumber, Switch } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
@@ -21,7 +22,7 @@ export default function TunnelFields() {
name={['settings', 'allowedNetwork']}
label={t('pages.inbounds.form.allowedNetwork')}
>
<Select
<NativeSelect
options={[
{ value: 'tcp,udp', label: 'TCP, UDP' },
{ value: 'tcp', label: 'TCP' },
@@ -1,6 +1,7 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
@@ -50,7 +51,7 @@ export default function VlessFields({
</FormField>
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap>
<Select
<NativeSelect
id={`${uid}-authKind`}
value={authKind}
onChange={(v) => setAuthKind(v)}
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useState, useId } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
@@ -112,7 +113,9 @@ export default function RealityForm({
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
label="uTLS"
>
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
<NativeSelect
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
</FormField>
<Form.Item
htmlFor={`${uid}-target`}
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -154,7 +155,7 @@ function CertRow({
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
label={t('pages.inbounds.form.usageOption')}
>
<Select
<NativeSelect
style={{ width: '50%' }}
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
/>
@@ -212,7 +213,7 @@ function EchSockoptSection() {
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<Select
<NativeSelect
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
/>
</FormField>
@@ -354,14 +355,14 @@ export default function TlsForm({
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
<Space.Compact block>
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
<Select
<NativeSelect
id={`${uid}-minVersion`}
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
/>
</FormField>
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
<Select
<NativeSelect
aria-label={t('pages.inbounds.form.maxVersion')}
style={{ width: '50%' }}
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
@@ -370,7 +371,7 @@ export default function TlsForm({
</Space.Compact>
</Form.Item>
<FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS">
<Select
<NativeSelect
options={[
{ value: '', label: 'None' },
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
@@ -1,3 +1,4 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
@@ -207,13 +208,13 @@ export default function SockoptForm({
name={['streamSettings', 'sockopt', 'tcpcongestion']}
label={t('pages.inbounds.form.tcpCongestion')}
>
<Select
<NativeSelect
style={{ width: '50%' }}
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
/>
</FormField>
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
<Select
<NativeSelect
style={{ width: '50%' }}
options={[
{ value: 'off', label: 'Off' },
@@ -1,5 +1,6 @@
import { NativeSelect } from '@/components/form/NativeSelect';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
import { AutoComplete, Input, InputNumber, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
@@ -81,7 +82,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'mode']}
label={t('pages.inbounds.info.mode')}
>
<Select
<NativeSelect
style={{ width: '50%' }}
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
value: m,
@@ -149,7 +150,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
label={t('pages.inbounds.form.uplinkHttpMethod')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
@@ -187,7 +188,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
label={t('pages.inbounds.form.paddingPlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
@@ -201,7 +202,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
label={t('pages.inbounds.form.paddingMethod')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
@@ -215,7 +216,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
label={t('pages.inbounds.form.sessionPlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
@@ -259,7 +260,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
label={t('pages.inbounds.form.sequencePlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
@@ -283,7 +284,7 @@ export default function XhttpForm() {
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
label={t('pages.inbounds.form.uplinkDataPlacement')}
>
<Select
<NativeSelect
options={[
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfigProvider } from 'antd';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { HttpUtil } from '@/utils';
import { renderWithProviders } from './test-utils';
function renderInbound(mode: 'add' | 'edit' = 'add') {
const inbound = new DBInbound({
id: 41,
protocol: 'vless',
enable: true,
port: 14443,
settings: { clients: [], decryption: 'none', encryption: 'none' },
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
});
renderWithProviders(
<ConfigProvider theme={{ token: { motion: false } }}>
<InboundFormModal
open
mode={mode}
dbInbound={mode === 'edit' ? inbound : null}
dbInbounds={[]}
availableNodes={[]}
onClose={() => {}}
onSaved={() => {}}
/>
</ConfigProvider>,
);
return userEvent.setup();
}
describe('Inbound screen-reader interaction', () => {
it('exposes the selected protocol as the closed control value', () => {
renderInbound();
expect(screen.getByRole('combobox', { name: 'Protocol' })).toHaveDisplayValue('vless');
});
it('exposes all visible protocol options as selectable items within its dialog', async () => {
const user = renderInbound();
const dialog = screen.getByRole('dialog', { name: 'Add Inbound' });
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
const shadowsocks = within(protocol).getByRole('option', { name: 'shadowsocks' });
expect(within(protocol).getByRole('option', { name: 'tuic' })).toBeInTheDocument();
expect(
within(protocol).getByRole('option', { name: 'vless', selected: true }),
).toBeInTheDocument();
await user.selectOptions(protocol, shadowsocks);
expect(protocol).toHaveDisplayValue('shadowsocks');
await user.click(screen.getByRole('tab', { name: 'Protocol' }));
expect(screen.getByRole('combobox', { name: 'Encryption method' })).toBeVisible();
});
it('selects transport by its option role and submits the actual create payload', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound();
await user.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' });
await user.selectOptions(
transport,
within(transport).getByRole('option', { name: 'WebSocket' }),
);
const path = screen.getByRole('textbox', { name: 'Path' });
await user.clear(path);
await user.type(path, '/accessible');
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() =>
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/add', expect.anything()),
);
const payload = post.mock.calls.find(
([url]) => url === '/panel/api/inbounds/add',
)![1] as Record<string, unknown>;
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
network: 'ws',
security: 'none',
wsSettings: { path: '/accessible' },
});
});
it('preserves the disabled protocol in edit mode and exposes a named security group', async () => {
const user = renderInbound('edit');
expect(screen.getByRole('combobox', { name: 'Protocol' })).toBeDisabled();
await user.click(screen.getByRole('tab', { name: 'Security' }));
const security = screen.getByRole('radiogroup', { name: 'Security' });
expect(
within(security)
.getAllByRole('radio')
.map((radio) => (radio as HTMLInputElement).value),
).toEqual(['none', 'tls', 'reality']);
});
it('preserves edit serialization after a keyboard-accessible transport change', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound('edit');
await user.click(screen.getByRole('tab', { name: 'Stream' }));
const transport = screen.getByRole('combobox', { name: 'Transmission' });
await user.selectOptions(transport, within(transport).getByRole('option', { name: 'gRPC' }));
await user.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() =>
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/update/41', expect.anything()),
);
const payload = post.mock.calls.find(
([url]) => url === '/panel/api/inbounds/update/41',
)![1] as Record<string, unknown>;
expect(payload).toMatchObject({ protocol: 'vless', port: 14443, enable: true });
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
network: 'grpc',
security: 'none',
grpcSettings: { serviceName: '' },
});
expect(JSON.parse(String(payload.settings))).not.toHaveProperty('clients');
});
it('reveals and describes a validation error on a hidden tab without submitting', async () => {
const post = vi.mocked(HttpUtil.post).mockClear();
const user = renderInbound();
const strategy = screen.getByRole('combobox', { name: 'Share address strategy' });
await user.selectOptions(strategy, within(strategy).getByRole('option', { name: 'Custom' }));
const address = screen.getByRole('textbox', { name: 'Custom share address' });
await user.type(address, 'https://example.test/path');
await user.click(screen.getByRole('tab', { name: 'Stream' }));
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(address).toBeVisible());
expect(address).toHaveAttribute('aria-invalid', 'true');
expect(address).toHaveAccessibleDescription(expect.stringContaining('without a scheme'));
await waitFor(() => expect(address).toHaveFocus());
expect(post.mock.calls.filter(([url]) => url === '/panel/api/inbounds/add')).toEqual([]);
});
});
+31 -53
View File
@@ -1,11 +1,12 @@
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, within } from '@testing-library/react';
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
import { DBInbound } from '@/models/dbinbound';
import { ThemeProvider } from '@/hooks/useTheme';
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
import { HttpUtil } from '@/utils';
import { Protocols } from '@/schemas/primitives';
import {
renderWithProviders,
fieldLabels,
@@ -40,20 +41,8 @@ function renderModal() {
);
}
// The protocol dropdown is virtualized, so jsdom renders only its first options; step
// the active option by keyboard from the current protocol in the options' own order.
function chooseVirtualizedProtocol(optionText: string) {
const input = document.getElementById('protocol') as HTMLElement;
const select = input.closest('.ant-select') as HTMLElement;
const current = (select.textContent ?? '').trim();
const order = Object.values(Protocols) as string[];
const steps = order.indexOf(optionText) - order.indexOf(current);
if (order.indexOf(optionText) < 0 || order.indexOf(current) < 0 || steps < 0) {
throw new Error(`cannot step from '${current}' to '${optionText}'`);
}
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
for (let i = 0; i < steps; i++) fireEvent.keyDown(input, { key: 'ArrowDown', keyCode: 40 });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 });
function chooseProtocol(optionText: string) {
chooseSelectOption('protocol', optionText);
}
function primaryButton(): HTMLElement {
@@ -115,12 +104,6 @@ async function renderCloneLikeEdit(dbInbound: DBInbound) {
}
describe('InboundFormModal', () => {
it('renders add mode without crashing', () => {
renderModal();
expect(document.querySelector('.ant-modal')).toBeTruthy();
expect(fieldLabels().length).toBeGreaterThan(0);
});
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
renderModal();
const protocols = listSelectOptions('protocol');
@@ -143,9 +126,8 @@ describe('InboundFormModal', () => {
expect(distinctShapes.size).toBeGreaterThan(1);
// Spot-check a protocol-distinguishing field that must appear after the switch.
if (labelsByProto.shadowsocks) {
expect(labelsByProto.shadowsocks).toContain('Encryption method');
}
expect(labelsByProto.shadowsocks).toContain('Encryption method');
expect(labelsByProto.vless).not.toContain('Encryption method');
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
it('preserves custom share address strategy when editing a local inbound', async () => {
@@ -241,27 +223,31 @@ describe('InboundFormModal', () => {
await new Promise((r) => setTimeout(r, 0));
});
};
const strategyItem = (title: string) =>
document.querySelector(`.ant-select-content[title="${title}"]`);
const selectedStrategy = () =>
within(screen.getByRole('combobox', { name: 'Share address strategy' })).getByRole('option', {
selected: true,
}).textContent;
const modal = (nodes: never[], fetched: boolean) => (
<ThemeProvider>
<InboundFormModal
open
mode="edit"
dbInbound={buildInbound()}
dbInbounds={[]}
availableNodes={nodes}
availableNodesFetched={fetched}
onClose={() => {}}
onSaved={() => {}}
/>
</ThemeProvider>
<AntdRootConfig>
<ThemeProvider>
<InboundFormModal
open
mode="edit"
dbInbound={buildInbound()}
dbInbounds={[]}
availableNodes={nodes}
availableNodesFetched={fetched}
onClose={() => {}}
onSaved={() => {}}
/>
</ThemeProvider>
</AntdRootConfig>
);
// Baseline: nodes already loaded, so the node option is offered and selected.
render(modal([node], true));
await flush();
expect(strategyItem('Node address')).toBeTruthy();
expect(selectedStrategy()).toBe('Node address');
cleanup();
// Race: the modal mounts before /nodes/list resolves (empty placeholder),
@@ -271,8 +257,7 @@ describe('InboundFormModal', () => {
await flush();
rerender(modal([node], true));
await flush();
expect(strategyItem('Node address')).toBeTruthy();
expect(strategyItem('Inbound listen')).toBeFalsy();
expect(selectedStrategy()).toBe('Node address');
});
it('surfaces a Reality validation error and switches to its tab', async () => {
@@ -344,16 +329,9 @@ describe('InboundFormModal', () => {
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
(el) =>
el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission',
);
const select = transmission?.querySelector('.ant-select') as HTMLElement;
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
(o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === 'XDRIVE',
) as HTMLElement;
fireEvent.click(option);
const transport = screen.getByRole('combobox', { name: 'Transmission' });
const option = within(transport).getByRole('option', { name: 'XDRIVE' }) as HTMLOptionElement;
fireEvent.change(transport, { target: { value: option.value } });
for (const [label, value] of [
['Remote folder', 'xray-tunnel'],
['Client ID', 'id.apps.googleusercontent.com'],
@@ -387,7 +365,7 @@ describe('InboundFormModal', () => {
post.mockClear();
renderModal();
chooseVirtualizedProtocol('masque');
chooseProtocol('masque');
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormProvider, useForm } from 'react-hook-form';
import { NativeSelect } from '@/components/form/NativeSelect';
import { FormField } from '@/components/form/rhf';
const options = [
{ value: 7, label: 'Number seven' },
{ value: '7', label: 'Text seven' },
{ value: 0, label: 'Zero' },
{ value: '', label: 'None' },
{ value: 9, label: 'Unavailable', disabled: true },
];
function Harness({
onSubmit,
disabled = false,
}: {
onSubmit: (values: { choice: string | number }) => void;
disabled?: boolean;
}) {
const methods = useForm({ defaultValues: { choice: 7 as string | number } });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormField name="choice" label="Choice">
<NativeSelect<string | number> disabled={disabled} options={options} />
</FormField>
<button type="submit">Save</button>
</form>
</FormProvider>
);
}
describe('NativeSelect form values', () => {
it('preserves numeric, string and empty values through RHF instead of coercing to DOM strings', async () => {
const onSubmit = vi.fn();
render(<Harness onSubmit={onSubmit} />);
const user = userEvent.setup();
const select = screen.getByRole('combobox', { name: 'Choice' });
expect(select).toHaveDisplayValue('Number seven');
for (const [label, value] of [
['Text seven', '7'],
['Zero', 0],
['None', ''],
] as const) {
await user.selectOptions(select, screen.getByRole('option', { name: label }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(onSubmit).toHaveBeenLastCalledWith({ choice: value }, expect.anything()),
);
}
await user.selectOptions(select, screen.getByRole('option', { name: 'Unavailable' }));
expect(select).toHaveDisplayValue('None');
});
it('keeps disabled values intact and skips the control during tab navigation', async () => {
const onSubmit = vi.fn();
render(<Harness disabled onSubmit={onSubmit} />);
const user = userEvent.setup();
await user.tab();
expect(screen.getByRole('button', { name: 'Save' })).toHaveFocus();
await user.keyboard('{Enter}');
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ choice: 7 }, expect.anything()));
});
it('preserves an unlisted edit value and follows reordered dynamic options without changing it', async () => {
const onChange = vi.fn();
const view = render(
<NativeSelect
aria-label="Choice"
value="saved-custom"
options={[{ value: 'default', label: 'Default' }]}
onChange={onChange}
/>,
);
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('saved-custom');
view.rerender(
<NativeSelect
aria-label="Choice"
value="saved-custom"
options={[
{ value: 'new', label: 'New' },
{ value: 'saved-custom', label: 'Saved choice' },
]}
onChange={onChange}
/>,
);
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('Saved choice');
expect(onChange).not.toHaveBeenCalled();
await userEvent
.setup()
.selectOptions(screen.getByRole('combobox'), screen.getByRole('option', { name: 'New' }));
expect(onChange).toHaveBeenCalledWith('new');
});
});
@@ -31,6 +31,13 @@ function formItemByLabel(label: string): Element | undefined {
}
function chooseOptionByLabel(label: string, optionText: string) {
const native = formItemByLabel(label)?.querySelector('select');
if (native) {
const option = Array.from(native.options).find((item) => item.text === optionText);
if (!option) throw new Error(`Option '${optionText}' not found for ${label}`);
fireEvent.change(native, { target: { value: option.value } });
return;
}
const select = formItemByLabel(label)?.querySelector('.ant-select');
if (!select) throw new Error(`${label} select not found`);
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -93,6 +94,10 @@ describe('FormField', () => {
fireEvent.click(screen.getByText('Save'));
await screen.findByText('email-required');
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAccessibleDescription(
'email-required',
);
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAttribute('aria-invalid', 'true');
expect(onSubmit).not.toHaveBeenCalled();
});
});
+13
View File
@@ -49,6 +49,12 @@ function openDropdownOptions(): string[] {
}
export function listSelectOptions(fieldId: string): string[] {
const native = document.getElementById(fieldId);
if (native instanceof HTMLSelectElement) {
return Array.from(native.options)
.filter((option) => !option.hidden)
.map((option) => option.text);
}
const select = selectRootForField(fieldId);
openSelect(select);
const opts = openDropdownOptions();
@@ -57,6 +63,13 @@ export function listSelectOptions(fieldId: string): string[] {
}
export function chooseSelectOption(fieldId: string, optionText: string) {
const native = document.getElementById(fieldId);
if (native instanceof HTMLSelectElement) {
const option = Array.from(native.options).find((item) => item.text === optionText);
if (!option) throw new Error(`Option '${optionText}' not found for field '${fieldId}'`);
fireEvent.change(native, { target: { value: option.value } });
return;
}
const select = selectRootForField(fieldId);
openSelect(select);
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
+155
View File
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import { chromium, devices } from 'playwright';
const base = process.argv[2] || 'http://127.0.0.1:6006';
const browser = await chromium.launch({ headless: true });
try {
for (const mobile of [false, true]) {
const context = await browser.newContext(mobile ? devices['Pixel 7'] : {});
const page = await context.newPage();
const requests = [];
await page.route('**/panel/api/**', async (route) => {
const request = route.request();
if (/\/inbounds\/(add|update\/41)$/.test(new URL(request.url()).pathname)) {
requests.push({ url: new URL(request.url()).pathname, payload: request.postDataJSON() });
}
await route.fulfill({
json: { success: true, msg: '', obj: { id: 41, datepicker: 'gregorian' } },
});
});
async function tabTo(locator) {
for (let i = 0; i < 100; i++) {
if (await locator.evaluate((element) => element === document.activeElement)) return;
await page.keyboard.press('Tab');
const escaped = await page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]');
return dialog && !dialog.contains(document.activeElement)
? document.activeElement.outerHTML
: '';
});
assert.ok(!escaped, `Tab escaped the open inbound dialog: ${escaped.slice(0, 200)}`);
}
assert.fail('Control was not reachable with Tab');
}
async function activateTab(dialog, name) {
const tabs = dialog.getByRole('tab');
const names = await tabs.allTextContents();
const current = dialog.getByRole('tab', { selected: true });
const from = names.indexOf(await current.textContent());
const to = names.findIndex((text) => text.endsWith(name));
assert.ok(from >= 0 && to >= 0, `Missing named tab ${name}`);
await tabTo(current);
for (let i = 0; i < Math.abs(to - from); i++) {
await page.keyboard.press(to > from ? 'ArrowRight' : 'ArrowLeft');
await page.keyboard.press('Enter');
}
assert.equal(
await dialog
.getByRole('tab', { name: new RegExp(`${name}$`) })
.getAttribute('aria-selected'),
'true',
);
}
async function selectByKey(locator, key, label) {
await tabTo(locator);
if (key.length === 1) await page.keyboard.press(key);
else await page.keyboard.type(key);
assert.equal(await locator.evaluate((element) => element.selectedOptions[0].text), label);
assert.ok(
await locator.evaluate((element) => element === document.activeElement),
'Selection lost focus',
);
}
for (const edit of [false, true]) {
await page.goto(
`${base}/iframe.html?id=inbounds-inboundformmodal--${edit ? 'edit' : 'create'}&viewMode=story`,
);
const opener = page.getByRole('button', {
name: edit ? 'Edit inbound' : 'Add inbound',
exact: true,
});
await opener.waitFor();
await tabTo(opener);
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: edit ? 'Modify Inbound' : 'Add Inbound' });
await dialog.waitFor();
await page.waitForFunction(
() =>
!document
.querySelector('[role="dialog"]')
?.getAnimations()
.some((animation) => animation.playState === 'running'),
);
const protocol = dialog.getByRole('combobox', { name: 'Protocol', exact: true });
if (edit) assert.ok(await protocol.isDisabled());
else {
await activateTab(dialog, 'Security');
const group = dialog.getByRole('radiogroup', { name: 'Security' });
await tabTo(group.getByRole('radio', { name: 'None' }));
await page.keyboard.press('ArrowRight');
assert.ok(await group.getByRole('radio', { name: 'TLS' }).isChecked());
await page.keyboard.press('ArrowRight');
assert.ok(await group.getByRole('radio', { name: 'Reality' }).isChecked());
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
assert.ok(await group.getByRole('radio', { name: 'None' }).isChecked());
await activateTab(dialog, 'Basics');
if (!mobile) {
await tabTo(protocol);
await page.keyboard.press('Alt+ArrowDown');
await page.keyboard.press('Escape');
assert.ok(await dialog.isVisible(), 'Canceling a native picker closed the dialog');
}
await selectByKey(protocol, 's', 'shadowsocks');
await activateTab(dialog, 'Protocol');
await selectByKey(
dialog.getByRole('combobox', { name: 'Encryption method' }),
'chacha20-ietf-poly1305',
'chacha20-ietf-poly1305',
);
}
await activateTab(dialog, 'Stream');
await selectByKey(
dialog.getByRole('combobox', { name: 'Transmission' }),
edit ? 'g' : 'w',
edit ? 'gRPC' : 'WebSocket',
);
const cdp = await context.newCDPSession(page);
const ax = await cdp.send('Accessibility.getFullAXTree');
const transport = ax.nodes.find(
(node) => node.role?.value === 'combobox' && node.name?.value.trim() === 'Transmission',
);
assert.equal(transport?.value?.value, edit ? 'gRPC' : 'WebSocket');
const submit = dialog.getByRole('button', {
name: edit ? 'Save Changes' : 'Create',
exact: true,
});
await tabTo(submit);
await page.keyboard.press('Enter');
await dialog.waitFor({ state: 'hidden' });
await page.waitForFunction(
() =>
document.activeElement?.textContent === 'Add inbound' ||
document.activeElement?.textContent === 'Edit inbound',
);
const saved = requests.at(-1);
assert.equal(saved?.url, edit ? '/panel/api/inbounds/update/41' : '/panel/api/inbounds/add');
assert.equal(saved.payload.protocol, 'shadowsocks');
const settings = JSON.parse(saved.payload.settings);
assert.equal(settings.method, 'chacha20-ietf-poly1305');
assert.equal(JSON.parse(saved.payload.streamSettings).network, edit ? 'grpc' : 'ws');
if (edit) assert.ok(!('clients' in settings), 'Editing must preserve server-owned clients');
console.log(
`PASS ${mobile ? 'mobile viewport' : 'desktop'} ${edit ? 'edit' : 'create'}: keyboard, AX value, payload, focus return`,
);
}
await context.close();
}
} finally {
await browser.close();
}