Files
3x-ui/frontend/src/test/client-qr-modal.test.tsx
T
MHSanaei 8f47b53879 style(settings): fold the Happ settings into four tabs
Seven tabs, several holding one or two settings, made the page hard to
scan. The encrypted-links switch moves above the tabs under
auto-detection, the colour profile joins the banners under Appearance &
Theme, and Android per-app proxy joins Network & TUN Engine. The QR
modal's settings link no longer needs a happTab selector, and the three
orphaned tab-label keys are dropped from every locale.
2026-09-27 16:06:42 +02:00

658 lines
24 KiB
TypeScript

import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createRef, forwardRef, useImperativeHandle, useState } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import type { HappLinkResult } from '@/generated/types';
import type { ClientRecord } from '@/hooks/useClients';
import ClientQrModal from '@/pages/clients/ClientQrModal';
import { HttpUtil, Msg } from '@/utils';
import { renderWithProviders } from './test-utils';
vi.mock('@/pages/inbounds/qr', () => ({
QrPanel: ({ value, showQr = true }: { value: string; showQr?: boolean }) => (
<div data-testid="qr-panel-value" data-show-qr={String(showQr)}>
{value}
</div>
),
}));
const STANDARD_LINK = 'https://panel.example/sub/alpha';
const HAPP_LINK = 'happ://crypt5/encrypted-alpha';
const HAPP_OPTION_LABEL = 'Happ Encrypted Link';
const SOURCE_TOO_LONG_HINT =
'The subscription URL exceeds the panel limit of 8192 UTF-8 bytes. Shorten the subscription URL or use Standard.';
const CLIENT: ClientRecord = { id: 42, email: 'alice@example.com', subId: 'alpha' };
const SUB_SETTINGS = {
enable: true,
subURI: 'https://panel.example/sub/',
subJsonURI: '',
subJsonEnable: false,
happLinkEnable: true,
};
type TestSubSettings = Omit<typeof SUB_SETTINGS, 'happLinkEnable'> & {
happLinkEnable?: boolean;
};
interface SubjectProps {
open: boolean;
client: ClientRecord | null;
subSettings: TestSubSettings;
onOpenChange: (open: boolean) => void;
}
interface SubjectHandle {
update: (patch: Partial<SubjectProps>) => void;
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function success(encryptedLink = HAPP_LINK) {
return new Msg<HappLinkResult>(true, '', { encryptedLink });
}
const Subject = forwardRef<SubjectHandle, { overrides: Partial<SubjectProps> }>(function Subject(
{ overrides },
ref,
) {
const [props, setProps] = useState<SubjectProps>({
open: true,
client: CLIENT,
subSettings: SUB_SETTINGS,
onOpenChange: vi.fn(),
...overrides,
});
useImperativeHandle(
ref,
() => ({
update: (patch) => setProps((current) => ({ ...current, ...patch })),
}),
[],
);
return (
<ClientQrModal
open={props.open}
client={props.client}
inboundsById={{}}
subSettings={props.subSettings}
onOpenChange={props.onOpenChange}
/>
);
});
function LocationProbe() {
const location = useLocation();
return (
<output data-testid="location">
{location.pathname}
{location.search}
{location.hash}
</output>
);
}
function renderSubject(overrides: Partial<SubjectProps> = {}) {
const subjectRef = createRef<SubjectHandle>();
const onOpenChange = vi.fn();
const view = renderWithProviders(
<MemoryRouter initialEntries={['/clients']}>
<Subject ref={subjectRef} overrides={{ onOpenChange, ...overrides }} />
<LocationProbe />
</MemoryRouter>,
);
return {
...view,
onOpenChange,
update(patch: Partial<SubjectProps>) {
act(() => subjectRef.current?.update(patch));
},
};
}
// Opening fetches sub links with no visible loading state; settle it inside act().
async function settleSubLinks() {
await act(async () => {});
}
function selectVariant(name: 'Standard' | 'Happ') {
fireEvent.click(screen.getByRole('radio', { name: name === 'Happ' ? /Happ/ : name }));
}
function actionButton(name: 'Retry' | 'Regenerate') {
return screen.getByRole('button', { name: new RegExp(name) }) as HTMLButtonElement;
}
describe('ClientQrModal Happ presentation', () => {
beforeEach(() => {
vi.mocked(HttpUtil.post).mockReset();
});
it('opens on Standard without generating a Happ link', async () => {
renderSubject();
await settleSubLinks();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
);
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
expect(HttpUtil.post).not.toHaveBeenCalled();
});
it('names the Happ option as an encrypted link', async () => {
renderSubject();
await settleSubLinks();
expect(screen.getByRole('radio', { name: HAPP_OPTION_LABEL })).toBeTruthy();
});
it.each([
['missing', undefined],
['false', false],
])('marks the selectable Happ option as locked when the gate is %s', async (_name, gate) => {
const subSettings: TestSubSettings = {
enable: SUB_SETTINGS.enable,
subURI: SUB_SETTINGS.subURI,
subJsonURI: SUB_SETTINGS.subJsonURI,
subJsonEnable: SUB_SETTINGS.subJsonEnable,
};
if (gate !== undefined) subSettings.happLinkEnable = gate;
renderSubject({ subSettings });
await settleSubLinks();
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
expect(standard.checked).toBe(true);
expect(happ.disabled).toBe(false);
expect(
screen.getByLabelText('Enable Happ link generation in Settings before using Happ.', {
selector: '.anticon-lock',
}),
).toBeTruthy();
expect(HttpUtil.post).not.toHaveBeenCalled();
});
it.each([
['missing', undefined],
['false', false],
])(
'replaces the blank Happ content with a persistent empty state when the gate is %s',
async (_name, gate) => {
const subSettings: TestSubSettings = {
enable: SUB_SETTINGS.enable,
subURI: SUB_SETTINGS.subURI,
subJsonURI: SUB_SETTINGS.subJsonURI,
subJsonEnable: SUB_SETTINGS.subJsonEnable,
};
if (gate !== undefined) subSettings.happLinkEnable = gate;
renderSubject({ subSettings });
await settleSubLinks();
const standard = screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement;
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ }) as HTMLInputElement;
fireEvent.click(happ);
expect(standard.checked).toBe(false);
expect(happ.checked).toBe(true);
expect(screen.getByText('Happ encrypted link generation is not enabled')).toBeTruthy();
expect(
screen.getByText(
'Enable local generation of encrypted Happ subscription links. (Only for Happ)',
),
).toBeTruthy();
expect(screen.getByRole('button', { name: 'Go to Settings' })).toBeTruthy();
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
expect(HttpUtil.post).not.toHaveBeenCalled();
},
);
it('removes the hover and focus tooltip from the locked Happ option', async () => {
renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
const happ = screen.getByRole('radio', { name: /Happ Encrypted Link/ });
const happLabel = happ.closest('label');
expect(happLabel).not.toBeNull();
fireEvent.mouseEnter(happLabel!);
fireEvent.focus(happ);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 250));
});
expect(screen.queryByRole('tooltip')).toBeNull();
});
it('closes the QR modal and deep-links to Happ settings without generating', async () => {
const view = renderSubject({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
await settleSubLinks();
selectVariant('Happ');
fireEvent.click(screen.getByRole('button', { name: 'Go to Settings' }));
expect(view.onOpenChange).toHaveBeenCalledOnce();
expect(view.onOpenChange).toHaveBeenCalledWith(false);
expect(screen.getByTestId('location').textContent).toBe(
'/settings?subscriptionTab=happ#subscription',
);
expect(HttpUtil.post).not.toHaveBeenCalled();
});
it('returns to Standard without auto-generating when the gate is enabled after selecting Happ', async () => {
const view = renderSubject({
subSettings: { ...SUB_SETTINGS, happLinkEnable: false },
});
selectVariant('Happ');
expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).checked).toBe(true);
expect(HttpUtil.post).not.toHaveBeenCalled();
view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: true } });
await waitFor(() =>
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
),
);
expect(
screen.queryByText(
'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
),
).toBeNull();
expect(HttpUtil.post).not.toHaveBeenCalled();
});
it('keeps the local encryption notice out of Standard and shows it only in Happ', async () => {
vi.mocked(HttpUtil.post).mockReturnValue(new Promise(() => {}));
renderSubject();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
);
expect(
screen.queryByText(
'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
),
).toBeNull();
selectVariant('Happ');
expect(
await screen.findByText(
'Generated locally. Anyone with this link may be able to recover or share the subscription URL.',
),
).toBeTruthy();
expect(HttpUtil.post).toHaveBeenCalledOnce();
});
it('posts once with no body and silent errors when Standard switches to Happ', async () => {
const request = deferred<Msg<HappLinkResult>>();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
renderSubject();
const dialogCount = screen.queryAllByRole('dialog').length;
selectVariant('Happ');
await waitFor(() => {
expect(HttpUtil.post).toHaveBeenCalledOnce();
expect(HttpUtil.post).toHaveBeenCalledWith('/panel/api/clients/happLink/42', undefined, {
silent: true,
});
});
expect(confirmSpy).not.toHaveBeenCalled();
expect(screen.queryAllByRole('dialog')).toHaveLength(dialogCount);
confirmSpy.mockRestore();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
false,
);
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
});
it('removes the Happ value on leave and makes a fresh request on re-entry', async () => {
vi.mocked(HttpUtil.post)
.mockResolvedValueOnce(success())
.mockResolvedValueOnce(success('happ://crypt5/encrypted-second'));
renderSubject();
selectVariant('Happ');
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
selectVariant('Standard');
expect(screen.queryByText(HAPP_LINK)).toBeNull();
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
selectVariant('Happ');
expect(await screen.findByText('happ://crypt5/encrypted-second')).toBeTruthy();
expect(HttpUtil.post).toHaveBeenCalledTimes(2);
});
it('keeps request B current when request A resolves after leaving and re-entering Happ', async () => {
const requestA = deferred<Msg<HappLinkResult>>();
const requestB = deferred<Msg<HappLinkResult>>();
vi.mocked(HttpUtil.post)
.mockReturnValueOnce(requestA.promise)
.mockReturnValueOnce(requestB.promise);
renderSubject();
selectVariant('Happ');
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
selectVariant('Standard');
selectVariant('Happ');
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
await act(async () => {
requestA.resolve(success('happ://crypt5/request-a'));
await requestA.promise;
});
expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
await act(async () => {
requestB.resolve(success('happ://crypt5/request-b'));
await requestB.promise;
});
expect(await screen.findByText('happ://crypt5/request-b')).toBeTruthy();
expect(screen.queryByText('happ://crypt5/request-a')).toBeNull();
});
it('returns to Standard and ignores an in-flight response when the gate turns off', async () => {
const request = deferred<Msg<HappLinkResult>>();
vi.mocked(HttpUtil.post).mockReturnValue(request.promise);
const view = renderSubject();
selectVariant('Happ');
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
await waitFor(() => {
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
);
expect((screen.getByRole('radio', { name: /Happ/ }) as HTMLInputElement).disabled).toBe(
false,
);
});
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).toBeNull();
await act(async () => {
request.resolve(success('happ://crypt5/retired-by-gate'));
await request.promise;
});
expect(screen.queryByText('happ://crypt5/retired-by-gate')).toBeNull();
expect(HttpUtil.post).toHaveBeenCalledOnce();
});
it('keeps the dialog mounted through close and shows loading instead of noLinks on reopen', async () => {
const get = vi.mocked(HttpUtil.get);
const previousGet = get.getMockImplementation();
get.mockReturnValue(new Promise(() => {}));
try {
const view = renderSubject({ subSettings: { ...SUB_SETTINGS, enable: false } });
const dialog = screen.getByRole('dialog');
view.update({ open: false });
expect(document.body.contains(dialog)).toBe(true);
view.update({ open: true });
await waitFor(() =>
expect(screen.getByRole('dialog').querySelector('[aria-busy="true"]')).not.toBeNull(),
);
expect(screen.queryByText(/No shareable links/)).toBeNull();
} finally {
get.mockImplementation(previousGet!);
}
});
it('resets to Standard across close and reopen without reusing a prior Happ value', async () => {
vi.mocked(HttpUtil.post).mockResolvedValue(success());
const view = renderSubject();
selectVariant('Happ');
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
view.update({ open: false });
view.update({ open: true });
await settleSubLinks();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
);
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
expect(screen.queryByText(HAPP_LINK)).toBeNull();
expect(HttpUtil.post).toHaveBeenCalledOnce();
});
it.each([
['leaving Happ', (_view: ReturnType<typeof renderSubject>) => selectVariant('Standard')],
['closing', (view: ReturnType<typeof renderSubject>) => view.update({ open: false })],
[
'changing client id',
(view: ReturnType<typeof renderSubject>) => view.update({ client: { ...CLIENT, id: 77 } }),
],
[
'changing subId',
(view: ReturnType<typeof renderSubject>) =>
view.update({ client: { ...CLIENT, subId: 'beta' } }),
],
[
'changing the effective subscription source',
(view: ReturnType<typeof renderSubject>) =>
view.update({
subSettings: { ...SUB_SETTINGS, subURI: 'https://other.example/sub/' },
}),
],
])('ignores a generation response after %s', async (_name, retire) => {
const oldRequest = deferred<Msg<HappLinkResult>>();
const nextRequest = deferred<Msg<HappLinkResult>>();
vi.mocked(HttpUtil.post)
.mockReturnValueOnce(oldRequest.promise)
.mockReturnValue(nextRequest.promise);
const view = renderSubject();
selectVariant('Happ');
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
retire(view);
await act(async () => {
oldRequest.resolve(success('happ://crypt5/retired-response'));
await oldRequest.promise;
});
expect(screen.queryByText('happ://crypt5/retired-response')).toBeNull();
});
it('shows only the localized generic hint and Retry after a backend failure', async () => {
vi.mocked(HttpUtil.post).mockResolvedValue(
new Msg<HappLinkResult>(false, 'provider token leaked by backend', null),
);
renderSubject();
selectVariant('Happ');
await screen.findByText('Retry');
expect(actionButton('Retry').disabled).toBe(false);
expect(
screen.getByText(
'The Happ link could not be generated. Retry, or check Overview -> Logs for details.',
),
).toBeTruthy();
expect(screen.queryByText(/provider token leaked/i)).toBeNull();
});
it('retries with a fresh request and exposes Regenerate after success', async () => {
vi.mocked(HttpUtil.post)
.mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'backend detail', null))
.mockResolvedValueOnce(success());
renderSubject();
selectVariant('Happ');
await screen.findByText('Retry');
fireEvent.click(actionButton('Retry'));
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
expect(actionButton('Regenerate').disabled).toBe(false);
expect(HttpUtil.post).toHaveBeenCalledTimes(2);
});
it('explains a source length failure without Retry and keeps Standard available', async () => {
vi.mocked(HttpUtil.post).mockResolvedValue(
new Msg<HappLinkResult>(false, 'happ_source_too_long', null),
);
renderSubject();
selectVariant('Happ');
expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
expect(screen.queryByText('happ_source_too_long')).toBeNull();
selectVariant('Standard');
expect(screen.getByTestId('qr-panel-value').textContent).toBe(STANDARD_LINK);
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
expect(HttpUtil.post).toHaveBeenCalledOnce();
});
it.each([
['non-exact error code', false, 'happ_source_too_long token=secret'],
['successful malformed response', true, 'happ_source_too_long'],
])('does not trust a %s as a source length failure', async (_name, successful, message) => {
vi.mocked(HttpUtil.post).mockResolvedValue(new Msg<HappLinkResult>(successful, message, null));
renderSubject();
selectVariant('Happ');
expect(await screen.findByRole('button', { name: 'Retry' })).toBeTruthy();
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
expect(screen.queryByText(message)).toBeNull();
});
it('clears a source length failure when the subscription source changes', async () => {
vi.mocked(HttpUtil.post)
.mockResolvedValueOnce(new Msg<HappLinkResult>(false, 'happ_source_too_long', null))
.mockResolvedValueOnce(success());
const view = renderSubject();
selectVariant('Happ');
expect(await screen.findByText(SOURCE_TOO_LONG_HINT)).toBeTruthy();
view.update({ subSettings: { ...SUB_SETTINGS, subURI: 'https://short.example/sub/' } });
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).checked).toBe(
true,
);
selectVariant('Happ');
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
});
it('ignores a source length failure from a retired request', async () => {
const retired = deferred<Msg<HappLinkResult>>();
vi.mocked(HttpUtil.post).mockReturnValueOnce(retired.promise).mockResolvedValueOnce(success());
renderSubject();
selectVariant('Happ');
selectVariant('Standard');
selectVariant('Happ');
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
await act(async () => {
retired.resolve(new Msg<HappLinkResult>(false, 'happ_source_too_long', null));
await retired.promise;
});
expect(screen.getByTestId('qr-panel-value').textContent).toBe(HAPP_LINK);
expect(screen.queryByText(SOURCE_TOO_LONG_HINT)).toBeNull();
});
it.each([
['Retry', new Msg<HappLinkResult>(false, 'backend detail', null)],
['Regenerate', success()],
])('does not let a stale %s action bypass a disabled gate', async (action, response) => {
vi.mocked(HttpUtil.post).mockResolvedValue(response);
const view = renderSubject();
selectVariant('Happ');
const staleAction = await screen.findByRole('button', { name: new RegExp(action) });
view.update({ subSettings: { ...SUB_SETTINGS, happLinkEnable: false } });
await waitFor(() =>
expect(screen.queryByRole('button', { name: new RegExp(action) })).toBeNull(),
);
fireEvent.click(staleAction);
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledOnce());
});
it('clears the old QR and hides duplicate regeneration while loading', async () => {
const regeneration = deferred<Msg<HappLinkResult>>();
vi.mocked(HttpUtil.post)
.mockResolvedValueOnce(success())
.mockReturnValueOnce(regeneration.promise);
renderSubject();
selectVariant('Happ');
expect(await screen.findByText(HAPP_LINK)).toBeTruthy();
await waitFor(() => expect(actionButton('Regenerate').disabled).toBe(false));
fireEvent.click(actionButton('Regenerate'));
await waitFor(() => expect(HttpUtil.post).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
expect(screen.queryByRole('button', { name: /Regenerate|Retry/ })).toBeNull();
expect((screen.getByRole('radio', { name: 'Standard' }) as HTMLInputElement).disabled).toBe(
false,
);
});
it.each([
['ordinary', 'happ://crypt5/AaBbCc-._~'],
['standard Base64', 'happ://crypt5/AaBb+Cc/Dd=='],
['maximum-size QR', `happ://crypt5/${'a'.repeat(2939)}`],
])('passes a valid %s encryptedLink unchanged to QrPanel', async (_name, exactLink) => {
vi.mocked(HttpUtil.post).mockResolvedValue(success(exactLink));
renderSubject();
selectVariant('Happ');
const panel = await screen.findByTestId('qr-panel-value');
expect(panel.textContent).toBe(exactLink);
expect(panel.getAttribute('data-show-qr')).toBe('true');
});
it.each([
['ASCII', `happ://crypt5/${'a'.repeat(2940)}`],
['multi-byte', `happ://crypt5/${'界'.repeat(1000)}`],
])('keeps a valid %s link available when it is too large for a QR code', async (_name, link) => {
vi.mocked(HttpUtil.post).mockResolvedValue(success(link));
renderSubject();
selectVariant('Happ');
const panel = await screen.findByTestId('qr-panel-value');
expect(panel.textContent).toBe(link);
expect(panel.getAttribute('data-show-qr')).toBe('false');
expect(
screen.getByText(
'This Happ link is valid, but it is too long to display as a QR code. Use Copy to use the complete link.',
),
).toBeTruthy();
expect(actionButton('Regenerate').disabled).toBe(false);
});
it.each([
['non-string', { encryptedLink: 7 }],
['stale crypt4 format', { encryptedLink: 'happ://crypt4/old-format' }],
['empty payload', { encryptedLink: 'happ://crypt5/' }],
['wrong scheme', { encryptedLink: 'https://provider.example/link' }],
['whitespace', { encryptedLink: 'happ://crypt5/has space' }],
['control character', { encryptedLink: 'happ://crypt5/example\n' }],
])('rejects a %s encryptedLink before rendering QrPanel', async (_name, obj) => {
vi.mocked(HttpUtil.post).mockResolvedValue(new Msg(true, '', obj));
renderSubject();
selectVariant('Happ');
await screen.findByText('Retry');
expect(actionButton('Retry').disabled).toBe(false);
expect(screen.queryByTestId('qr-panel-value')).toBeNull();
});
});