feat(amneziawg): support persistent keepalive ranges (#6881)

AmneziaWG clients accept a randomised PersistentKeepalive range such as 15-25,
which makes keepalive timing harder to fingerprint; the panel only stored a
single number. A client's keepalive may now be a range, validated the way
AmneziaWG parses it. The downloadable .conf and vpn:// profile carry the range,
Clash/Mihomo output gets its lower bound, and plain WireGuard stays scalar-only.
The range lives in an additive wg_keep_alive_range column, so the integer
column and an older panel binary keep working after a rollback.
This commit is contained in:
YoungReckless4
2026-10-24 07:36:49 +03:00
committed by GitHub
parent 207c705300
commit 35e09191de
59 changed files with 1871 additions and 146 deletions
+18 -4
View File
@@ -1759,9 +1759,15 @@
"type": "string"
},
"keepAlive": {
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
"nullable": true,
"type": "integer"
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitIp": {
"description": "IP limit for this client",
@@ -1981,7 +1987,15 @@
"type": "integer"
},
"keepAlive": {
"type": "integer"
"description": "Numeric values or AmneziaWG ranges.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitHwid": {
"type": "integer"
+1 -1
View File
@@ -424,7 +424,7 @@ export const EXAMPLES: Record<string, unknown> = {
"forwardedPorts": "",
"group": "",
"id": "",
"keepAlive": null,
"keepAlive": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
+18 -4
View File
@@ -1733,9 +1733,15 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string"
},
"keepAlive": {
"description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value",
"nullable": true,
"type": "integer"
"description": "Numeric values or AmneziaWG ranges; 0 disables, omission preserves.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitIp": {
"description": "IP limit for this client",
@@ -1955,7 +1961,15 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer"
},
"keepAlive": {
"type": "integer"
"description": "Numeric values or AmneziaWG ranges.",
"oneOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"limitHwid": {
"type": "integer"
+2 -2
View File
@@ -420,7 +420,7 @@ export interface Client {
forwardedPorts?: string;
group?: string;
id?: string;
keepAlive?: number | null;
keepAlive?: number | string;
limitIp: number;
password?: string;
preSharedKey?: string;
@@ -471,7 +471,7 @@ export interface ClientRecord {
forwardedPorts: string;
group: string;
id: number;
keepAlive: number;
keepAlive: number | string;
limitHwid: number;
limitIp: number;
password: string;
+2 -2
View File
@@ -441,7 +441,7 @@ export const ClientSchema = z.object({
forwardedPorts: z.string().optional(),
group: z.string().optional(),
id: z.string().optional(),
keepAlive: z.number().int().nullable().optional(),
keepAlive: z.union([z.number().int(), z.string()]).optional(),
limitIp: z.number().int(),
password: z.string().optional(),
preSharedKey: z.string().optional(),
@@ -495,7 +495,7 @@ export const ClientRecordSchema = z.object({
forwardedPorts: z.string(),
group: z.string(),
id: z.number().int(),
keepAlive: z.number().int(),
keepAlive: z.union([z.number().int(), z.string()]),
limitHwid: z.number().int(),
limitIp: z.number().int(),
password: z.string(),
+5 -1
View File
@@ -1,5 +1,6 @@
import { Base64, Wireguard } from '@/utils';
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive';
import type { Inbound } from '@/schemas/api/inbound';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
@@ -1127,7 +1128,10 @@ export function genAmneziaWGConfig(input: GenAmneziaWGLinkInput): string {
lines.push(`PresharedKey = ${client.preSharedKey}`);
}
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${address}:${port}`);
if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
if (typeof client.keepAlive === 'string') {
if (!AmneziaWGKeepAliveRangeSchema.safeParse(client.keepAlive).success) return '';
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
} else if (typeof client.keepAlive === 'number' && client.keepAlive > 0) {
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
}
return joinConfLines(lines);
+57 -7
View File
@@ -38,9 +38,14 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useClientHwids } from '@/hooks/useClientHwids';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import ClientHwidListModal from '@/components/clients/ClientHwidList';
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
import {
OptionalAmneziaWGKeepAliveInputSchema,
OptionalNumericKeepAliveInputSchema,
keepAliveInputValue,
} from '@/schemas/primitives/keepalive';
import type {
ClientRecord,
InboundOption,
@@ -137,7 +142,7 @@ type Values = ClientFormValues & {
wgAllowedIPs: string;
awgAllowedIPs: string;
awgForwardedPorts: string;
wgKeepAlive: number;
wgKeepAlive: number | string | undefined;
secret: string;
adTag: string;
};
@@ -389,7 +394,7 @@ export default function ClientFormModal({
wgAllowedIPs: wgTunnelIPs ?? client.allowedIPs ?? '',
awgAllowedIPs: awgTunnelIPs ?? client.allowedIPs ?? '',
awgForwardedPorts: client.forwardedPorts || '',
wgKeepAlive: client.keepAlive ?? 0,
wgKeepAlive: client.keepAlive,
secret: client.secret || '',
adTag: client.adTag || '',
};
@@ -641,6 +646,25 @@ export default function ClientFormModal({
async function onSubmit() {
const values = methods.getValues();
const unchangedLegacyKeepAlive =
isEdit &&
typeof client?.keepAlive === 'number' &&
client.keepAlive > 65535 &&
values.wgKeepAlive === client.keepAlive;
if (showAmneziawg && !unchangedLegacyKeepAlive) {
const keepAliveSchema = showWireguard
? OptionalNumericKeepAliveInputSchema
: OptionalAmneziaWGKeepAliveInputSchema;
const keepAliveResult = keepAliveSchema.safeParse(values.wgKeepAlive);
if (!keepAliveResult.success) {
methods.setError('wgKeepAlive', {
type: 'validate',
message: keepAliveResult.error.issues[0]?.message ?? 'validation.invalid',
});
return;
}
methods.clearErrors('wgKeepAlive');
}
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
const validated = schema.safeParse({
email: values.email,
@@ -712,7 +736,9 @@ export default function ClientFormModal({
// so both protocols share this one field set — see wgPrivateKey etc.
// below and the AmneziaWG-labeled variants of the same inputs.
clientPayload.privateKey = values.wgPrivateKey;
clientPayload.keepAlive = values.wgKeepAlive;
if (!unchangedLegacyKeepAlive && values.wgKeepAlive !== undefined) {
clientPayload.keepAlive = values.wgKeepAlive;
}
clientPayload.publicKey = values.wgPublicKey;
if (values.wgPreSharedKey) {
clientPayload.preSharedKey = values.wgPreSharedKey;
@@ -1339,10 +1365,34 @@ export default function ClientFormModal({
<FormField
name="wgKeepAlive"
label={t('pages.clients.tunnelKeepAlive')}
extra={t('pages.clients.tunnelKeepAliveHint')}
transform={{ output: (v) => Number(v) || 0 }}
extra={t(
showAmneziawg && !showWireguard
? 'pages.clients.amneziaWgKeepAliveHint'
: 'pages.clients.tunnelKeepAliveHint',
)}
transform={{
output:
showAmneziawg && !showWireguard
? keepAliveInputValue
: (value) => Number(value) || 0,
}}
rules={
showAmneziawg
? {
validate: rhfZodValidate(
showWireguard
? OptionalNumericKeepAliveInputSchema
: OptionalAmneziaWGKeepAliveInputSchema,
),
}
: undefined
}
>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
{showAmneziawg && !showWireguard ? (
<Input placeholder="25" />
) : (
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
)}
</FormField>
{showAmneziawg && (
<FormField
@@ -3,6 +3,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
import { preferPublicHost, resolveShareHost } from '@/lib/xray/inbound-link';
import { effectiveMtu } from '@/lib/xray/amneziawg-obfuscation';
import { joinConfLines } from '@/lib/xray/conf-lines';
import { AmneziaWGKeepAliveRangeSchema } from '@/schemas/primitives/keepalive';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
// AmneziaWG clients are wire-identical to WireGuard clients (same
@@ -17,7 +18,7 @@ export function isAmneziaWGClient(client: ClientRecord | null | undefined): bool
client.publicKey ||
client.allowedIPs ||
client.preSharedKey ||
client.keepAlive
client.keepAlive !== undefined
);
}
@@ -102,7 +103,11 @@ export function buildAmneziaWGClientConfig(
lines.push('[Peer]', `PublicKey = ${server?.publicKey || ''}`);
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
if (client.keepAlive && client.keepAlive > 0)
if (typeof client.keepAlive === 'string') {
if (!AmneziaWGKeepAliveRangeSchema.safeParse(client.keepAlive).success) return '';
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
} else if (client.keepAlive && client.keepAlive > 0) {
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
}
return joinConfLines(lines);
}
@@ -11,7 +11,7 @@ export function isWireguardClient(client: ClientRecord | null | undefined): bool
client.publicKey ||
client.allowedIPs ||
client.preSharedKey ||
client.keepAlive
typeof client.keepAlive === 'number'
);
}
@@ -53,7 +53,7 @@ export function buildWireguardClientConfig(
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
if (client.keepAlive && client.keepAlive > 0)
if (typeof client.keepAlive === 'number' && client.keepAlive > 0)
lines.push(`PersistentKeepalive = ${client.keepAlive}`);
return joinConfLines(lines);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive';
const nullableStringArray = z
.array(z.string())
@@ -52,7 +53,7 @@ export const ClientRecordSchema = z
publicKey: z.string().optional(),
allowedIPs: z.string().optional(),
preSharedKey: z.string().optional(),
keepAlive: z.number().optional(),
keepAlive: OptionalClientKeepAliveSchema,
forwardedPorts: z.string().optional(),
secret: z.string().optional(),
adTag: z.string().optional(),
@@ -0,0 +1,40 @@
import { z } from 'zod';
const invalidKeepAlive = 'pages.clients.amneziaWgKeepAliveInvalid';
const MAX_KEEPALIVE_SECONDS = 65535;
export const AmneziaWGKeepAliveRangeSchema = z.string().refine((value) => {
if (!/^(0|[1-9]\d*)-(0|[1-9]\d*)$/.test(value)) return false;
const [minimum, maximum] = value.split('-').map(Number);
return minimum >= 0 && minimum <= maximum && maximum <= MAX_KEEPALIVE_SECONDS;
}, invalidKeepAlive);
// Numeric values stay permissive here so older stored values remain readable.
export const ClientKeepAliveSchema = z.union([z.number(), AmneziaWGKeepAliveRangeSchema]);
export const OptionalClientKeepAliveSchema = z.preprocess(
(value) => (value == null ? undefined : value),
ClientKeepAliveSchema.optional(),
);
const boundedSeconds = z
.number()
.refine(
(value) => Number.isInteger(value) && value >= 0 && value <= MAX_KEEPALIVE_SECONDS,
invalidKeepAlive,
);
export const OptionalNumericKeepAliveInputSchema = z.preprocess(
(value) => (value == null || value === '' ? undefined : value),
boundedSeconds.optional(),
);
export const OptionalAmneziaWGKeepAliveInputSchema = z.preprocess(
(value) => (value == null || value === '' ? undefined : value),
z.union([boundedSeconds, AmneziaWGKeepAliveRangeSchema]).optional(),
);
export function keepAliveInputValue(value: unknown): number | string | undefined {
if (value == null || value === '') return undefined;
const text = String(value);
return /^\d+$/.test(text) ? Number(text) : text;
}
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { OptionalClientKeepAliveSchema } from '@/schemas/primitives/keepalive';
// AntD InputNumber emits null (not undefined) when the user clears it, and
// the form store hands that null straight to safeParse on submit — a bare
@@ -23,7 +24,7 @@ export const AmneziawgClientSchema = z.object({
publicKey: z.string().optional(),
preSharedKey: z.string().optional(),
allowedIPs: z.array(z.string()).default([]),
keepAlive: optionalClearedInt(z.number().int().min(0)),
keepAlive: OptionalClientKeepAliveSchema,
forwardedPorts: z.string().default(''),
email: z.string().min(1),
limitIp: z.number().int().min(0).default(0),
@@ -0,0 +1,256 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { buildAmneziaWGClientConfig, isAmneziaWGClient } from '@/pages/clients/amneziawgConfig';
import { genAmneziaWGConfig } from '@/lib/xray/inbound-link';
import { buildWireguardClientConfig, isWireguardClient } from '@/pages/clients/wireguardConfig';
import ClientFormModal from '@/pages/clients/ClientFormModal';
import { ClientRecordSchema } from '@/schemas/client';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import type { AmneziawgInboundSettings } from '@/schemas/protocols/inbound/amneziawg';
import {
OptionalAmneziaWGKeepAliveInputSchema,
OptionalNumericKeepAliveInputSchema,
keepAliveInputValue,
} from '@/schemas/primitives/keepalive';
import { renderWithProviders } from './test-utils';
const awgInbound = {
id: 17,
tag: 'awg-17',
protocol: 'amneziawg',
port: 51820,
awgServer: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
} as unknown as InboundOption;
const baseClient = {
email: 'peer-1',
privateKey: 'clientPrivKey==',
allowedIPs: '10.8.1.2/32',
keepAlive: 25,
} as unknown as ClientRecord;
describe('AWG keepalive range UI contract', () => {
it('accepts range-valued API records while preserving legacy numeric values', () => {
expect(ClientRecordSchema.parse({ email: 'peer-1', keepAlive: '15-25' }).keepAlive).toBe(
'15-25',
);
expect(ClientRecordSchema.parse({ email: 'legacy', keepAlive: 70000 }).keepAlive).toBe(70000);
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '15-65536' }).success).toBe(
false,
);
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '015-25' }).success).toBe(false);
expect(ClientRecordSchema.safeParse({ email: 'bad', keepAlive: '25-15' }).success).toBe(false);
expect(ClientRecordSchema.parse({ email: 'peer-1', keepAlive: '0-65535' }).keepAlive).toBe(
'0-65535',
);
expect(OptionalNumericKeepAliveInputSchema.safeParse('15-25').success).toBe(false);
expect(OptionalAmneziaWGKeepAliveInputSchema.parse(keepAliveInputValue('0'))).toBe(0);
expect(OptionalAmneziaWGKeepAliveInputSchema.parse(undefined)).toBeUndefined();
});
it('writes the exact range to a native AmneziaWG profile', () => {
const conf = buildAmneziaWGClientConfig(
{ ...baseClient, keepAlive: '15-25' } as unknown as ClientRecord,
awgInbound,
'awg.example.test',
);
expect(conf).toContain('PersistentKeepalive = 15-25');
});
it('writes the exact range to an AmneziaWG share profile', () => {
const settings = {
server: { publicKey: 'serverPubKey==', jc: 4, jmin: 40, jmax: 100, s1: 30, s2: 90 },
clients: [
{
email: 'peer-1',
privateKey: 'clientPrivKey==',
allowedIPs: ['10.8.1.2/32'],
keepAlive: '15-25',
},
],
} as unknown as AmneziawgInboundSettings;
const conf = genAmneziaWGConfig({
settings,
address: 'awg.example.test',
port: 51820,
remark: 'peer-1',
peerIndex: 0,
});
expect(conf).toContain('PersistentKeepalive = 15-25');
});
it('never exports a range into a plain WireGuard profile', () => {
const conf = buildWireguardClientConfig(
{ ...baseClient, keepAlive: '15-25' } as unknown as ClientRecord,
{ id: 18, tag: 'wg-18', protocol: 'wireguard', port: 51821 } as unknown as InboundOption,
'wg.example.test',
);
expect(conf).toContain('PrivateKey = clientPrivKey==');
expect(conf).toContain('Endpoint = wg.example.test:51821');
expect(conf).not.toContain('PersistentKeepalive');
const rangeOnlyClient = { email: 'peer-1', keepAlive: '15-25' } as unknown as ClientRecord;
expect(isWireguardClient(rangeOnlyClient)).toBe(false);
expect(isAmneziaWGClient(rangeOnlyClient)).toBe(true);
});
it('keeps the shared WireGuard and AWG form numeric-only', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
renderWithProviders(
<QueryClientProvider client={queryClient}>
<ClientFormModal
open
mode="edit"
client={baseClient}
inbounds={[
awgInbound,
{
id: 18,
tag: 'wg-18',
protocol: 'wireguard',
port: 51821,
} as unknown as InboundOption,
]}
attachedIds={[17, 18]}
save={vi.fn().mockResolvedValue({ success: true })}
onOpenChange={() => {}}
/>
</QueryClientProvider>,
);
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
(tab) => tab.textContent?.trim() === 'Credentials',
);
if (!credentialsTab) throw new Error('Credentials tab not found');
fireEvent.click(credentialsTab);
const keepAliveLabel = Array.from(
document.querySelectorAll<HTMLLabelElement>('.ant-form-item-label label'),
).find((label) => label.textContent?.trim() === 'Keepalive (seconds)');
if (!keepAliveLabel?.htmlFor) throw new Error('Keepalive field label not found');
const keepAlive = await screen.findByRole('spinbutton', { name: 'Keepalive (seconds)' });
expect(document.getElementById(keepAliveLabel.htmlFor)).toBe(keepAlive);
expect(screen.queryByPlaceholderText('25')).toBeNull();
});
it('submits an AWG range from the client form', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const save = vi.fn().mockResolvedValue({ success: true });
renderWithProviders(
<QueryClientProvider client={queryClient}>
<ClientFormModal
open
mode="edit"
client={baseClient}
inbounds={[awgInbound]}
attachedIds={[17]}
save={save}
onOpenChange={() => {}}
/>
</QueryClientProvider>,
);
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
(tab) => tab.textContent?.trim() === 'Credentials',
);
if (!credentialsTab) throw new Error('Credentials tab not found');
fireEvent.click(credentialsTab);
const keepAlive = await screen.findByPlaceholderText('25');
fireEvent.change(keepAlive, { target: { value: '15-25' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(save).toHaveBeenCalled());
expect((save.mock.calls[0][0] as Record<string, unknown>).keepAlive).toBe('15-25');
});
it('preserves an unchanged legacy numeric value outside the new range', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const save = vi.fn().mockResolvedValue({ success: true });
renderWithProviders(
<QueryClientProvider client={queryClient}>
<ClientFormModal
open
mode="edit"
client={{ ...baseClient, keepAlive: 70000 } as unknown as ClientRecord}
inbounds={[awgInbound]}
attachedIds={[17]}
save={save}
onOpenChange={() => {}}
/>
</QueryClientProvider>,
);
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
(tab) => tab.textContent?.trim() === 'Credentials',
);
if (!credentialsTab) throw new Error('Credentials tab not found');
fireEvent.click(credentialsTab);
await screen.findByPlaceholderText('25');
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(save).toHaveBeenCalled());
expect(Object.hasOwn(save.mock.calls[0][0] as object, 'keepAlive')).toBe(false);
});
it('rejects out-of-bounds ranges before saving', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const save = vi.fn().mockResolvedValue({ success: true });
renderWithProviders(
<QueryClientProvider client={queryClient}>
<ClientFormModal
open
mode="edit"
client={baseClient}
inbounds={[awgInbound]}
attachedIds={[17]}
save={save}
onOpenChange={() => {}}
/>
</QueryClientProvider>,
);
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
(tab) => tab.textContent?.trim() === 'Credentials',
);
if (!credentialsTab) throw new Error('Credentials tab not found');
fireEvent.click(credentialsTab);
const keepAlive = await screen.findByPlaceholderText('25');
fireEvent.change(keepAlive, { target: { value: '15-65536' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() =>
expect(document.querySelector('.ant-form-item-explain-error')?.textContent).toContain(
'Enter a whole number or range from 0 to 65535.',
),
);
expect(save).not.toHaveBeenCalled();
});
it('omits a cleared AWG value so editing another field preserves the stored value', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const save = vi.fn().mockResolvedValue({ success: true });
renderWithProviders(
<QueryClientProvider client={queryClient}>
<ClientFormModal
open
mode="edit"
client={baseClient}
inbounds={[awgInbound]}
attachedIds={[17]}
save={save}
onOpenChange={() => {}}
/>
</QueryClientProvider>,
);
const credentialsTab = Array.from(document.querySelectorAll('.ant-tabs-tab')).find(
(tab) => tab.textContent?.trim() === 'Credentials',
);
if (!credentialsTab) throw new Error('Credentials tab not found');
fireEvent.click(credentialsTab);
const keepAlive = await screen.findByPlaceholderText('25');
fireEvent.change(keepAlive, { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => expect(save).toHaveBeenCalled());
expect(Object.hasOwn(save.mock.calls[0][0] as object, 'keepAlive')).toBe(false);
});
});