feat(frontend): make Storybook a validated, fully covered component workbench

Storybook existed only as an undocumented local tool: 9 of 24 reusable components had stories, autodocs pages were bare prop tables, nothing built or tested the stories, and no contributor doc mentioned the workbench existed.

Every reusable component under src/components/ now has a co-located story with enriched autodocs (component descriptions plus per-prop argTypes, kept as string metadata since the repo bans line comments). Stories double as headless Chromium tests through the Storybook vitest addon, with axe accessibility checks enforced as errors and play-function interaction tests covering the modals, the RHF field bridge, the config block, and the select-all buttons. The preview now mirrors the panel's real theme DOM (body class, shared AntD theme config, seeded theme storage) so what stories render matches production.

CI and make verify gain a static Storybook build as a compile gate, and the frontend test job installs Chromium so story tests run on every PR. Contributor docs (frontend README, CONTRIBUTING, agent guides) document the workbench, the story conventions, and the Controls setup. Node engines move to 24 LTS and gen:api drops the type-stripping flags that Node 24 makes default.
This commit is contained in:
MHSanaei
2026-07-14 03:37:21 +02:00
parent 4e928a1ce0
commit 7078abc14a
36 changed files with 2315 additions and 222 deletions
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { setDatepicker } from '@/hooks/useDatepicker';
import { ThemeProvider } from '@/hooks/useTheme';
import DateTimePicker from './DateTimePicker';
setDatepicker('gregorian');
function ClientExpiryDemo() {
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />
<Typography.Text type="secondary">
{value ? `user1@node-de expiryTime: ${value.valueOf()}` : 'user1@node-de expiryTime: 0 (never expires)'}
</Typography.Text>
</div>
);
}
function JalaliDemo() {
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
useEffect(() => {
setDatepicker('jalalian');
return () => setDatepicker('gregorian');
}, []);
return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
}
const meta = {
title: 'Form/DateTimePicker',
component: DateTimePicker,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
},
},
},
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
argTypes: {
value: { description: 'Selected moment as a Dayjs instance, or null when unset.' },
onChange: { description: 'Called with the picked Dayjs value, or null when cleared.' },
showTime: { description: 'Include an hour/minute/second selector alongside the date.' },
format: { description: 'Display format for the Gregorian picker input.' },
placeholder: { description: 'Input placeholder shown while no value is set.' },
disabled: { description: 'Disables the input and hides the clear button.' },
},
} satisfies Meta<typeof DateTimePicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
args: {
value: null,
onChange: () => undefined,
placeholder: 'Leave blank to never expire',
},
};
export const ClientExpiry: Story = {
args: { value: null, onChange: () => undefined },
render: () => <ClientExpiryDemo />,
};
export const DateOnly: Story = {
args: {
value: dayjs('2026-08-01'),
onChange: () => undefined,
showTime: false,
format: 'YYYY-MM-DD',
placeholder: 'Start date',
},
};
export const JalaliCalendar: Story = {
args: { value: null, onChange: () => undefined },
parameters: { docs: { disable: true } },
render: () => <JalaliDemo />,
};
@@ -0,0 +1,76 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import HeaderMapEditor, { type HeaderMapValue } from './HeaderMapEditor';
const meta = {
title: 'Form/HeaderMapEditor',
component: HeaderMapEditor,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Row-based editor for Xray HTTP header maps, used in the inbound/outbound stream forms. Mode `v1` emits one string per header name (WS / HTTPUpgrade / Hysteria masquerade); mode `v2` emits string arrays so headers can repeat (TCP HTTP camouflage request/response).',
},
},
},
argTypes: {
mode: { description: 'Wire shape: `v1` = string per name, `v2` = string[] per name (repeatable headers).' },
value: { description: 'Header map in the wire shape matching `mode`; converted to editable rows internally.' },
onChange: { description: 'Called with the rebuilt wire-shape map after every row edit, add, or remove.' },
},
} satisfies Meta<typeof HeaderMapEditor>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {
args: { mode: 'v1', onChange: () => undefined },
};
export const WsHostHeaders: Story = {
args: {
mode: 'v1',
value: {
Host: 'cdn.example.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
onChange: () => undefined,
},
};
export const TcpCamouflageRequest: Story = {
args: {
mode: 'v2',
value: {
Accept: ['text/html,application/xhtml+xml', 'application/json'],
'Accept-Encoding': ['gzip, deflate'],
Connection: ['keep-alive'],
Pragma: ['no-cache'],
},
onChange: () => undefined,
},
};
function WireShapeDemo() {
const [value, setValue] = useState<HeaderMapValue>({
Accept: ['text/html', 'application/json'],
'X-Forwarded-For': ['203.0.113.7'],
});
return (
<div style={{ maxWidth: 560 }}>
<HeaderMapEditor mode="v2" value={value} onChange={setValue} />
<pre style={{ marginTop: 16, padding: 12, borderRadius: 8, background: 'rgba(128, 128, 128, 0.12)' }}>
{JSON.stringify(value ?? {}, null, 2)}
</pre>
</div>
);
}
export const LiveWireShape: Story = {
args: { mode: 'v2', onChange: () => undefined },
render: () => <WireShapeDemo />,
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import { ThemeProvider } from '@/hooks/useTheme';
import JsonEditor from './JsonEditor';
const warpOutbound = JSON.stringify(
{
tag: 'warp-out',
protocol: 'wireguard',
settings: {
secretKey: 'yFXfmXX3Zn5tnpNJ7HAcbLvqcMVioqPDGV1GXn2FeV0=',
address: ['172.16.0.2/32', '2606:4700:110:8f81::2/128'],
peers: [
{
publicKey: 'bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: 'engage.cloudflareclient.com:2408',
},
],
mtu: 1280,
},
},
null,
2,
);
const realityStreamSettings = JSON.stringify(
{
network: 'tcp',
security: 'reality',
realitySettings: {
show: false,
dest: 'yahoo.com:443',
xver: 0,
serverNames: ['yahoo.com', 'www.yahoo.com'],
privateKey: 'wLc4dpQvRt8mK1nS9jH2fXaU7yEoB3iZ6vNqTgCkW5A',
shortIds: ['6ba85179e30d4fc2'],
},
},
null,
2,
);
const brokenInboundSettings = [
'{',
' "clients": [',
' {',
' "id": "9f4c3a2b-7d61-4e8a-b5c0-1f2e3d4a5b6c",',
' "email": "user1@node-de",',
' "flow": "xtls-rprx-vision",',
' }',
' ],',
' "decryption": "none"',
].join('\n');
function ControlledDemo() {
const [value, setValue] = useState(warpOutbound);
let parseError = '';
try {
JSON.parse(value);
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<JsonEditor value={value} onChange={setValue} minHeight="220px" maxHeight="360px" />
<Typography.Text type={parseError ? 'danger' : 'success'}>
{parseError ? `Parse error: ${parseError}` : `Valid JSON (${value.length} chars)`}
</Typography.Text>
</div>
);
}
const meta = {
title: 'Form/JsonEditor',
component: JsonEditor,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'CodeMirror-based JSON editor with syntax highlighting, live parse linting, and theme-aware styling. The panel uses it for raw xray config snippets — inbound settings, stream settings, and outbound JSON in the modals and settings pages.',
},
},
},
decorators: [
(Story) => (
<ThemeProvider>
<Story />
</ThemeProvider>
),
],
argTypes: {
value: { description: 'JSON document text; the editor resyncs when this prop changes.' },
onChange: { description: 'Called with the full document text on every edit.' },
minHeight: { description: 'CSS min-height of the scrollable editor area.' },
maxHeight: { description: 'CSS max-height before the editor scrolls internally.' },
readOnly: { description: 'Disables editing while keeping selection and scrolling.' },
},
} satisfies Meta<typeof JsonEditor>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { value: warpOutbound },
};
export const ReadOnly: Story = {
args: { value: realityStreamSettings, readOnly: true, minHeight: '200px' },
};
export const LintErrors: Story = {
args: { value: brokenInboundSettings, minHeight: '220px' },
};
export const Controlled: Story = {
args: { value: '' },
parameters: {
a11y: {
config: {
rules: [{ id: 'scrollable-region-focusable', enabled: false }],
},
},
},
render: () => <ControlledDemo />,
};
@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import RemarkTemplateField from './RemarkTemplateField';
const meta = {
title: 'Form/RemarkTemplateField',
component: RemarkTemplateField,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Text input augmented with a {{VAR}} token picker (insert-at-caret) and a live sample-based preview of the expanded remark. The panel uses it in subscription settings for the global Remark Template.',
},
},
},
argTypes: {
value: { description: 'Current template string; any {{VAR}} token enables the live preview below the input.' },
onChange: { description: 'Called with the updated template on typing or token insertion.' },
maxLength: { description: 'Maximum template length; picker insertions are clamped to it.' },
placeholder: { description: 'Placeholder shown while the template is empty.' },
},
} satisfies Meta<typeof RemarkTemplateField>;
export default meta;
type Story = StoryObj<typeof meta>;
function InteractiveDemo() {
const [value, setValue] = useState('{{STATUS_EMOJI}} {{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}}');
return <RemarkTemplateField value={value} onChange={setValue} maxLength={256} placeholder="{{INBOUND}}-{{EMAIL}}" />;
}
export const Empty: Story = {
args: {
value: '',
onChange: () => undefined,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const TokenTemplate: Story = {
args: {
value: '{{INBOUND}}-{{EMAIL}} | {{TRAFFIC_LEFT}} left | {{DAYS_LEFT}}d',
onChange: () => undefined,
maxLength: 256,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const PlainRemark: Story = {
args: {
value: 'Germany CDN node',
onChange: () => undefined,
maxLength: 256,
placeholder: '{{INBOUND}}-{{EMAIL}}',
},
};
export const Interactive: Story = {
args: {
value: '',
onChange: () => undefined,
},
render: () => <InteractiveDemo />,
};
@@ -0,0 +1,78 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button, Input, Popover, Typography } from 'antd';
import { previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
import RemarkVarPicker from './RemarkVarPicker';
const meta = {
title: 'Form/RemarkVarPicker',
component: RemarkVarPicker,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Grouped, tooltipped chip list of the {{VAR}} tokens the backend substitutes per client in subscription remarks. The hosts page shows it in a popover beside the remark-template field so operators can insert placeholders like {{EMAIL}} or {{TRAFFIC_LEFT}}.',
},
},
},
argTypes: {
onPick: { description: 'Called with the bare token (e.g. "EMAIL") when a chip is clicked or activated via keyboard.' },
},
} satisfies Meta<typeof RemarkVarPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
function TemplateBuilderDemo() {
const [template, setTemplate] = useState('{{INBOUND}}-{{EMAIL}} {{STATUS_EMOJI}} {{TRAFFIC_LEFT}} left');
return (
<div style={{ maxWidth: 520 }}>
<Input
value={template}
onChange={(e) => setTemplate(e.target.value)}
aria-label="Remark template"
style={{ fontFamily: 'monospace' }}
/>
<Typography.Paragraph type="secondary" style={{ margin: '8px 0 16px' }}>
Preview: {previewRemark(template)}
</Typography.Paragraph>
<RemarkVarPicker onPick={(token) => setTemplate((prev) => `${prev}${wrapToken(token)}`)} />
</div>
);
}
function PopoverDemo() {
const [lastPicked, setLastPicked] = useState('');
return (
<>
<Popover
content={<RemarkVarPicker onPick={(token) => setLastPicked(wrapToken(token))} />}
trigger="click"
placement="bottomRight"
title="Remark variables"
>
<Button>Insert variable</Button>
</Popover>
<div style={{ marginTop: 12 }}>Last picked: {lastPicked || '—'}</div>
</>
);
}
export const Default: Story = {
args: { onPick: () => undefined },
};
export const TemplateBuilder: Story = {
args: { onPick: () => undefined },
render: () => <TemplateBuilderDemo />,
};
export const InsidePopover: Story = {
args: { onPick: () => undefined },
render: () => <PopoverDemo />,
};
@@ -0,0 +1,132 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import { Select } from 'antd';
import SelectAllClearButtons from './SelectAllClearButtons';
const inboundOptions: Array<{ value: number; label: string }> = [
{ value: 1, label: 'VLESS Reality — 443' },
{ value: 2, label: 'VMess WS — 8443' },
{ value: 3, label: 'Trojan TCP — 2053' },
{ value: 4, label: 'Shadowsocks — 8388' },
];
const clientEmailOptions: Array<{ value: string; label: string }> = [
{ value: 'ava@corp.example', label: 'ava@corp.example' },
{ value: 'reza.mobile', label: 'reza.mobile' },
{ value: 'office-tv', label: 'office-tv' },
{ value: 'guest-42', label: 'guest-42' },
];
const meta = {
title: 'Form/SelectAllClearButtons',
component: SelectAllClearButtons,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Small "Select all" / "Clear all" button pair rendered above a multi-select. The panel places it over the attached-inbounds picker in the client form and the bulk attach/detach modals.',
},
},
},
argTypes: {
options: { description: 'Option list whose values define the "all" set; matches the AntD Select option shape.' },
value: { description: 'Currently selected values (controlled).' },
onChange: { description: 'Called with the union of the current selection and every option value, or with an empty array on clear.' },
selectAllLabel: { description: 'Override for the "Select all" button text; defaults to the translated inbound copy.' },
clearLabel: { description: 'Override for the "Clear all" button text; defaults to the translated inbound copy.' },
},
} satisfies Meta<typeof SelectAllClearButtons>;
export default meta;
type Story = StoryObj<typeof meta>;
function InboundPickerDemo() {
const [selected, setSelected] = useState<number[]>([1]);
return (
<div style={{ maxWidth: 360 }}>
<SelectAllClearButtons options={inboundOptions} value={selected} onChange={setSelected} />
<Select
mode="multiple"
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={inboundOptions}
placeholder="Select inbounds"
aria-label="Select inbounds"
maxTagCount="responsive"
/>
</div>
);
}
function ClientEmailsDemo() {
const [selected, setSelected] = useState<string[]>(['ava@corp.example']);
return (
<div style={{ maxWidth: 360 }}>
<SelectAllClearButtons
options={clientEmailOptions}
value={selected}
onChange={setSelected}
selectAllLabel="Select all clients"
clearLabel="Deselect clients"
/>
<Select
mode="multiple"
style={{ width: '100%' }}
value={selected}
onChange={setSelected}
options={clientEmailOptions}
placeholder="Select clients"
aria-label="Select clients"
maxTagCount="responsive"
/>
</div>
);
}
const placeholderArgs = {
options: [],
value: [],
onChange: () => undefined,
};
export const PartiallySelected: Story = {
args: {
options: [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }],
value: [1, 3],
onChange: () => undefined,
},
};
export const AllSelected: Story = {
args: {
options: [{ value: 1 }, { value: 2 }, { value: 3 }],
value: [1, 2, 3],
onChange: () => undefined,
},
};
export const WithInboundSelect: Story = {
args: placeholderArgs,
render: () => <InboundPickerDemo />,
};
export const CustomLabels: Story = {
args: placeholderArgs,
render: () => <ClientEmailsDemo />,
play: async ({ canvas, userEvent }) => {
const selectAll = canvas.getByRole('button', { name: 'Select all clients' });
const clearAll = canvas.getByRole('button', { name: 'Deselect clients' });
await expect(selectAll).toBeEnabled();
await userEvent.click(selectAll);
await expect(selectAll).toBeDisabled();
await userEvent.click(clearAll);
await expect(clearAll).toBeDisabled();
await expect(selectAll).toBeEnabled();
},
};
@@ -0,0 +1,210 @@
import { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { Button, Form, Input, InputNumber, Select, Switch, Typography } from 'antd';
import { FormProvider } from 'react-hook-form';
import { z } from 'zod';
import { FormField } from './FormField';
import { useZodForm } from './useZodForm';
const GB = 1024 * 1024 * 1024;
const meta = {
title: 'Form/RHF/FormField',
component: FormField,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Bridges one Ant Design control into react-hook-form: wraps the child in a Controller plus Form.Item, normalizes the onChange payload, and surfaces resolver errors as translated help text. Every RHF panel form (client, inbound, outbound, and host modals) builds its fields with it.',
},
},
},
argTypes: {
name: { description: 'Field path — a dotted string or an array of segments joined with dots.' },
control: { description: 'Optional react-hook-form control; falls back to the surrounding FormProvider.' },
label: { description: 'Form.Item label.' },
tooltip: { description: 'Form.Item tooltip shown next to the label.' },
extra: { description: 'Helper text rendered below the input.' },
valueProp: { description: 'Prop the child receives the value on: `value` (default) or `checked` for switches.' },
transform: { description: 'Optional input/output mappers, e.g. bytes stored in the form but GB shown in the input.' },
onAfterChange: { description: 'Called with the stored value after every change.' },
rules: { description: 'Controller-level validation rules applied on top of the form resolver.' },
required: { description: 'Marks the label with the required asterisk.' },
noStyle: { description: 'Render the bare input without Form.Item chrome.' },
children: { description: 'The single Ant Design control to wire up.' },
},
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
const ClientSchema = z.object({
email: z.string(),
flow: z.string(),
enable: z.boolean(),
});
function ClientDemo() {
const methods = useZodForm(ClientSchema, {
defaultValues: { email: 'user1@example.com', flow: 'xtls-rprx-vision', enable: true },
});
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="email" label="Email" tooltip="Unique identifier used to match client traffic" required>
<Input placeholder="user1@example.com" />
</FormField>
<FormField name="flow" label="Flow" extra="Only applies to VLESS over raw TLS">
<Select
options={[
{ label: 'none', value: '' },
{ label: 'xtls-rprx-vision', value: 'xtls-rprx-vision' },
]}
/>
</FormField>
<FormField name="enable" label="Enable" valueProp="checked">
<Switch />
</FormField>
</Form>
</FormProvider>
);
}
const TrafficSchema = z.object({
totalBytes: z.number(),
});
function TrafficDemo() {
const methods = useZodForm(TrafficSchema, { defaultValues: { totalBytes: 50 * GB } });
const totalBytes = methods.watch('totalBytes');
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField
name="totalBytes"
label="Total traffic (GB)"
extra="Stored on the client as bytes; 0 means unlimited"
transform={{
input: (value) => (typeof value === 'number' ? value / GB : value),
output: (value) => (typeof value === 'number' ? value * GB : 0),
}}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Typography.Text type="secondary">Form state: {totalBytes.toLocaleString()} bytes</Typography.Text>
</Form>
</FormProvider>
);
}
const InboundSchema = z.object({
remark: z.string().min(1, 'Remark is required'),
port: z
.number()
.min(1, 'Port must be between 1 and 65535')
.max(65535, 'Port must be between 1 and 65535'),
});
function ValidationDemo() {
const methods = useZodForm(InboundSchema, { defaultValues: { remark: '', port: 0 } });
useEffect(() => {
void methods.trigger();
}, [methods]);
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField name="remark" label="Remark" required>
<Input placeholder="vless-reality-443" />
</FormField>
<FormField name="port" label="Port" required>
<InputNumber style={{ width: '100%' }} />
</FormField>
<Button onClick={() => void methods.trigger()}>Validate</Button>
</Form>
</FormProvider>
);
}
const RealitySchema = z.object({
streamSettings: z.object({
realitySettings: z.object({
dest: z.string(),
serverNames: z.string(),
}),
}),
});
function NestedDemo() {
const methods = useZodForm(RealitySchema, {
defaultValues: {
streamSettings: {
realitySettings: { dest: 'yahoo.com:443', serverNames: 'yahoo.com,www.yahoo.com' },
},
},
});
return (
<FormProvider {...methods}>
<Form layout="vertical" style={{ maxWidth: 360 }}>
<FormField
name={['streamSettings', 'realitySettings', 'dest']}
label="Dest"
tooltip="Camouflage target the REALITY handshake is proxied to"
>
<Input />
</FormField>
<FormField
name="streamSettings.realitySettings.serverNames"
label="Server names"
extra="Comma-separated SNI list offered to clients"
>
<Input />
</FormField>
</Form>
</FormProvider>
);
}
const placeholderArgs = {
name: 'email',
children: <Input />,
};
export const ClientFields: Story = {
args: placeholderArgs,
render: () => <ClientDemo />,
};
export const TrafficTransform: Story = {
args: placeholderArgs,
render: () => <TrafficDemo />,
play: async ({ canvas, userEvent }) => {
const input = canvas.getByRole('spinbutton');
await userEvent.clear(input);
await userEvent.type(input, '100');
await expect(await canvas.findByText(/107,374,182,400 bytes/)).toBeVisible();
},
};
export const ValidationErrors: Story = {
args: placeholderArgs,
render: () => <ValidationDemo />,
play: async ({ canvas, userEvent }) => {
const remarkError = await canvas.findByText('Remark is required');
await waitFor(() => expect(remarkError).toBeVisible());
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' }));
await waitFor(() => expect(canvas.queryByText('Remark is required')).not.toBeInTheDocument());
await expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible();
},
};
export const NestedNames: Story = {
args: placeholderArgs,
render: () => <NestedDemo />,
};