feat(ui): Add profile now offers 'Sign in with a signer (Amber)' first
The Amber pairing QR existed but only behind the sidebar's Signer Mode, so clicking 'Add profile' expectedly led to a local-key form and users never found the signer flow. The modal now opens as a choice: - 'Sign in with a signer app (Amber)' — mints the nostrconnect:// QR inline, polls signer status every 2s (same channel as Signer Mode), and flips to an 'Amber is now your signer!' confirmation once the handshake lands; cancelling mid-pairing aborts it cleanly. - 'Create a new key on this computer' — the previous local-key form, unchanged, with a Back step. frontend: 125 tests passed (CreateProfileModal suite rewritten to cover choice, QR start, connected poll, cancel-abort; App.test updated for the new dialog title), typecheck/lint/format:check/electron:build/build green.
This commit is contained in:
parent
224df38598
commit
5b13162a36
3 changed files with 317 additions and 20 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
import { shortenNpub } from '../lib/format';
|
import { shortenNpub } from '../lib/format';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
|
|
@ -11,14 +12,15 @@ interface CreateProfileModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Phase = 'form' | 'creating' | 'success';
|
type Phase = 'choice' | 'local' | 'creating' | 'success' | 'pairing' | 'paired';
|
||||||
|
|
||||||
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
||||||
const { state, createProfile } = useApp();
|
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [phase, setPhase] = useState<Phase>('form');
|
const [phase, setPhase] = useState<Phase>('choice');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [createdNpub, setCreatedNpub] = useState<string | null>(null);
|
const [createdNpub, setCreatedNpub] = useState<string | null>(null);
|
||||||
|
const [pairingQr, setPairingQr] = useState<string | null>(null);
|
||||||
const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`);
|
const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
const shorten = state?.settings.shorten_npub ?? true;
|
||||||
|
|
@ -26,15 +28,88 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setLabel('');
|
setLabel('');
|
||||||
setPhase('form');
|
setPhase('choice');
|
||||||
setError(null);
|
setError(null);
|
||||||
setCreatedNpub(null);
|
setCreatedNpub(null);
|
||||||
// Let the modal mount before focusing.
|
setPairingQr(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Let the local-form input take focus once that step mounts.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'local') {
|
||||||
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [open]);
|
}, [phase]);
|
||||||
|
|
||||||
|
// Pairing is a server-side handshake with no push channel: poll the
|
||||||
|
// signer status while this modal sits on the QR, exactly like the Signer
|
||||||
|
// Mode screen does. Connected → show success; an error → show it (the
|
||||||
|
// backend also clears pairing_uri, so the QR view exits on failure).
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'pairing') return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const status = await nip46Status();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (status.connected) {
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
if (!cancelled) setPhase('paired');
|
||||||
|
} else if (status.error) {
|
||||||
|
if (!cancelled) setError(status.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Transient IPC errors are fine; the next poll retries.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
const timer = setInterval(() => void tick(), 2000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [phase, nip46Status, refresh]);
|
||||||
|
|
||||||
|
const [livePairingUri, setLivePairingUri] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'pairing' || livePairingUri) return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
void nip46Status()
|
||||||
|
.then((status) => {
|
||||||
|
if (!cancelled && status.pairing_uri) setLivePairingUri(status.pairing_uri);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [phase, livePairingUri, nip46Status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!livePairingUri) {
|
||||||
|
setPairingQr(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
QRCode.toDataURL(livePairingUri, {
|
||||||
|
width: 480,
|
||||||
|
margin: 2,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
})
|
||||||
|
.then((url) => {
|
||||||
|
if (!cancelled) setPairingQr(url);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError('Could not render the pairing QR code.');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [livePairingUri]);
|
||||||
|
|
||||||
const canSubmit = label.trim().length > 0 && phase !== 'creating';
|
const canSubmit = label.trim().length > 0 && phase !== 'creating';
|
||||||
|
|
||||||
|
|
@ -50,13 +125,151 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
||||||
setCreatedNpub(summary.npub);
|
setCreatedNpub(summary.npub);
|
||||||
setPhase('success');
|
setPhase('success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPhase('form');
|
setPhase('local');
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startPairing = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const status = await nip46PairStart(label.trim() || 'Remote Signer');
|
||||||
|
if (status.pairing_uri) setLivePairingUri(status.pairing_uri);
|
||||||
|
setPhase('pairing');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leaving the QR view mid-pairing aborts the in-flight pairing; nothing
|
||||||
|
// was persisted yet, so teardown is safe at any point (same as Signer
|
||||||
|
// Mode's "Cancel pairing").
|
||||||
|
const cancelPairing = async () => {
|
||||||
|
setLivePairingUri(null);
|
||||||
|
setPairingQr(null);
|
||||||
|
setPhase('choice');
|
||||||
|
try {
|
||||||
|
await nip46Disconnect();
|
||||||
|
} catch {
|
||||||
|
// Best-effort abort; a dead pairing attempt expires on its own.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (phase === 'pairing') {
|
||||||
|
void cancelPairing();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (phase === 'choice') {
|
||||||
return (
|
return (
|
||||||
<Modal open={open} title="Create a Nostr profile" onClose={onClose}>
|
<Modal open={open} title="Add a profile" onClose={handleClose}>
|
||||||
|
<div className="create-explainer">
|
||||||
|
<p>How do you want this profile to sign?</p>
|
||||||
|
</div>
|
||||||
|
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
<Button variant="primary" onClick={() => void startPairing()}>
|
||||||
|
<Icon name="key" size={16} />
|
||||||
|
Sign in with a signer app (Amber)
|
||||||
|
</Button>
|
||||||
|
<p className="hint" style={{ marginTop: -4 }}>
|
||||||
|
Show a QR code to Amber on your phone — your keys stay on the phone, and every signature
|
||||||
|
is approved there.
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" onClick={() => setPhase('local')}>
|
||||||
|
<Icon name="shield" size={16} />
|
||||||
|
Create a new key on this computer
|
||||||
|
</Button>
|
||||||
|
<p className="hint" style={{ marginTop: -4 }}>
|
||||||
|
A brand-new local identity whose private key lives in this app's vault.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'pairing') {
|
||||||
|
return (
|
||||||
|
<Modal open={open} title="Sign in with Amber" onClose={handleClose}>
|
||||||
|
<div className="signer-pairing">
|
||||||
|
<p>
|
||||||
|
Scan this code with <strong>Amber</strong> (or any NIP-46 signer) and approve the
|
||||||
|
connection.
|
||||||
|
</p>
|
||||||
|
{pairingQr ? (
|
||||||
|
<img
|
||||||
|
src={pairingQr}
|
||||||
|
alt="Pairing QR code"
|
||||||
|
style={{
|
||||||
|
width: 260,
|
||||||
|
height: 260,
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'block',
|
||||||
|
margin: '12px auto',
|
||||||
|
background: '#fff',
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="hint" style={{ textAlign: 'center' }}>
|
||||||
|
Preparing the pairing code…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="hint" style={{ textAlign: 'center' }}>
|
||||||
|
Waiting for the signer to scan… the profile appears automatically once approved. The
|
||||||
|
code expires after a few minutes.
|
||||||
|
</p>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button variant="ghost" onClick={() => void cancelPairing()}>
|
||||||
|
Cancel pairing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{livePairingUri && (
|
||||||
|
<details style={{ marginTop: 12 }}>
|
||||||
|
<summary className="hint">Or copy the pairing link</summary>
|
||||||
|
<code className="mono" style={{ wordBreak: 'break-all', fontSize: 11 }}>
|
||||||
|
{livePairingUri}
|
||||||
|
</code>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'paired') {
|
||||||
|
return (
|
||||||
|
<Modal open={open} title="Signer connected" onClose={onClose}>
|
||||||
|
<div className="create-success">
|
||||||
|
<div className="create-success-icon" aria-hidden="true">
|
||||||
|
<Icon name="check" size={26} />
|
||||||
|
</div>
|
||||||
|
<h3>Amber is now your signer!</h3>
|
||||||
|
<p>
|
||||||
|
The connected account was added as a profile and selected. Every signature will ask for
|
||||||
|
approval in Amber — nothing to install here, and the connection comes back automatically
|
||||||
|
after restarts.
|
||||||
|
</p>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button variant="primary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={phase === 'local' || phase === 'creating' ? 'Create a Nostr profile' : 'Add a profile'}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
{phase === 'success' && createdNpub ? (
|
{phase === 'success' && createdNpub ? (
|
||||||
<div className="create-success">
|
<div className="create-success">
|
||||||
<div className="create-success-icon" aria-hidden="true">
|
<div className="create-success-icon" aria-hidden="true">
|
||||||
|
|
@ -115,8 +328,12 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<Button variant="ghost" onClick={onClose} disabled={phase === 'creating'}>
|
<Button
|
||||||
Cancel
|
variant="ghost"
|
||||||
|
onClick={() => setPhase('choice')}
|
||||||
|
disabled={phase === 'creating'}
|
||||||
|
>
|
||||||
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ describe('App', () => {
|
||||||
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
|
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /Create a new profile/i }));
|
await user.click(screen.getByRole('button', { name: /Create a new profile/i }));
|
||||||
expect(
|
expect(await screen.findByRole('dialog', { name: 'Add a profile' })).toBeInTheDocument();
|
||||||
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the main screen after loading with an existing profile', async () => {
|
it('renders the main screen after loading with an existing profile', async () => {
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,35 @@ import { renderWithApp } from './render';
|
||||||
import { makeEmptyState } from './apiMock';
|
import { makeEmptyState } from './apiMock';
|
||||||
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||||
|
|
||||||
|
vi.mock('qrcode', () => ({
|
||||||
|
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
|
||||||
|
}));
|
||||||
|
|
||||||
describe('CreateProfileModal', () => {
|
describe('CreateProfileModal', () => {
|
||||||
it('creates a profile through the backend and shows a success confirmation', async () => {
|
it('offers the signer (Amber) and local-key choices first', async () => {
|
||||||
|
const backend = createFakeBackend(makeEmptyState());
|
||||||
|
installFakeBackend(backend);
|
||||||
|
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
await screen.findByRole('dialog', { name: 'Add a profile' });
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /Create a new key on this computer/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a local-key profile through the backend and shows a success confirmation', async () => {
|
||||||
const backend = createFakeBackend(makeEmptyState());
|
const backend = createFakeBackend(makeEmptyState());
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderWithApp(<CreateProfileModal open onClose={onClose} />);
|
renderWithApp(<CreateProfileModal open onClose={onClose} />);
|
||||||
|
|
||||||
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
|
await screen.findByRole('dialog', { name: 'Add a profile' });
|
||||||
|
await userEvent
|
||||||
|
.setup()
|
||||||
|
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
|
||||||
|
|
||||||
const input = screen.getByLabelText('Profile name');
|
const input = screen.getByLabelText('Profile name');
|
||||||
await userEvent.setup().type(input, 'Sam');
|
await userEvent.setup().type(input, 'Sam');
|
||||||
|
|
@ -36,20 +57,81 @@ describe('CreateProfileModal', () => {
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
|
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
|
||||||
|
|
||||||
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
|
await screen.findByRole('dialog', { name: 'Add a profile' });
|
||||||
|
await userEvent
|
||||||
|
.setup()
|
||||||
|
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
|
||||||
expect(screen.getByRole('button', { name: 'Create profile' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Create profile' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes without creating when Cancel is clicked', async () => {
|
it('closes without creating when Back then close is used', async () => {
|
||||||
const backend = createFakeBackend(makeEmptyState());
|
const backend = createFakeBackend(makeEmptyState());
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderWithApp(<CreateProfileModal open onClose={onClose} />);
|
renderWithApp(<CreateProfileModal open onClose={onClose} />);
|
||||||
|
|
||||||
|
await userEvent
|
||||||
|
.setup()
|
||||||
|
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
|
||||||
await userEvent.setup().type(screen.getByLabelText('Profile name'), 'Sam');
|
await userEvent.setup().type(screen.getByLabelText('Profile name'), 'Sam');
|
||||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }));
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Back' }));
|
||||||
|
// Back returns to the choice step; nothing was created yet.
|
||||||
expect(onClose).toHaveBeenCalled();
|
|
||||||
expect(backend.state.profiles).toHaveLength(0);
|
expect(backend.state.profiles).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts Amber pairing from the choice step and shows the QR', async () => {
|
||||||
|
const backend = createFakeBackend(makeEmptyState());
|
||||||
|
installFakeBackend(backend);
|
||||||
|
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument();
|
||||||
|
expect(backend.requests.some((r) => r.method === 'nip46_pair_start')).toBe(true);
|
||||||
|
expect(await screen.findByAltText('Pairing QR code')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the connected confirmation once the poll reports the signer online', async () => {
|
||||||
|
const backend = createFakeBackend(makeEmptyState());
|
||||||
|
installFakeBackend(backend);
|
||||||
|
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
|
||||||
|
await screen.findByText(/Waiting for the signer to scan/i);
|
||||||
|
|
||||||
|
// Amber approves: the fake backend now reports the handshake done. The
|
||||||
|
// modal polls the signer status every 2s, so wait past one interval.
|
||||||
|
backend.setNip46({
|
||||||
|
type: 'nip46',
|
||||||
|
connected: true,
|
||||||
|
relays: ['wss://relay.test'],
|
||||||
|
connected_relays: ['wss://relay.test'],
|
||||||
|
pending_approvals: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Amber is now your signer!', {}, { timeout: 5000 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts an in-flight pairing when Cancel pairing is clicked', async () => {
|
||||||
|
const backend = createFakeBackend(makeEmptyState());
|
||||||
|
installFakeBackend(backend);
|
||||||
|
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
|
||||||
|
await screen.findByText(/Waiting for the signer to scan/i);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
|
||||||
|
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue