mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
feat: enhance TrustModal with new states and improve pairing flow tests
This commit is contained in:
@@ -84,6 +84,10 @@ import { App } from './App';
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.location.hash = '';
|
||||
Object.defineProperty(navigator, 'usb', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
checkProvisionedMock.mockResolvedValue(false);
|
||||
ensureAnisetteMock.mockResolvedValue({ anisetteData: fakeAnisette, provisioned: true });
|
||||
loginAccountMock.mockResolvedValue(fakeContext);
|
||||
@@ -115,6 +119,35 @@ describe('App — page + nav', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('App — pairing modal', () => {
|
||||
it('keeps trust pending separate from paired success and retries cleanly', async () => {
|
||||
const pendingError = new Error('Pair error=PairingDialogResponsePending');
|
||||
pairDeviceFlowMock
|
||||
.mockImplementationOnce(async (ctx) => {
|
||||
ctx.onTrustPending();
|
||||
throw pendingError;
|
||||
})
|
||||
.mockResolvedValueOnce({ udid: 'UDID-TEST', name: 'iPhone' });
|
||||
isPairingDialogPendingErrorMock.mockImplementation(
|
||||
(error) => error === pendingError || String(error).includes('PairingDialogResponsePending'),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Sign & Install/ }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Connect Device' }));
|
||||
|
||||
expect(await screen.findByText('Trust This Device')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Device Paired')).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'I Trusted This Device' }));
|
||||
expect(await screen.findByText('Device Paired')).toBeInTheDocument();
|
||||
await waitFor(() => expect(pairDeviceFlowMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
expect(screen.queryByText('Device Paired')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('App — login modal', () => {
|
||||
it('opens login modal on Add Account, then logs in and navigates to sign', async () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Header, type AppPage } from './components/Header';
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { LoginModal } from './components/LoginModal';
|
||||
import { SignPage } from './components/SignPage';
|
||||
import { TrustModal } from './components/TrustModal';
|
||||
import { TrustModal, type TrustModalState } from './components/TrustModal';
|
||||
import { TwoFactorModal } from './components/TwoFactorModal';
|
||||
import { ProgressCard } from './components/ProgressCard';
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useLog } from './lib/use-log';
|
||||
|
||||
const LOGIN_PAGE_HASH = '#/login';
|
||||
const SIGN_PAGE_HASH = '#/sign';
|
||||
const TRUST_MODAL_CLOSED: TrustModalState = 'closed';
|
||||
|
||||
type BusyState = {
|
||||
pair: boolean;
|
||||
@@ -50,6 +51,11 @@ type BusyState = {
|
||||
install: boolean;
|
||||
};
|
||||
|
||||
type PairFlowResult =
|
||||
| { kind: 'paired'; info: PairedDeviceInfo }
|
||||
| { kind: 'pending' }
|
||||
| { kind: 'failed' };
|
||||
|
||||
const idleBusy: BusyState = { pair: false, loginSign: false, sign: false, install: false };
|
||||
|
||||
function resolvePageFromHash(hash: string): AppPage {
|
||||
@@ -105,7 +111,7 @@ export function App() {
|
||||
const lastInstallPercentRef = useRef<number>(0);
|
||||
|
||||
// Modals
|
||||
const [trustOpen, setTrustOpen] = useState<boolean>(false);
|
||||
const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
|
||||
const [twoFactor, setTwoFactor] = useState<{
|
||||
open: boolean;
|
||||
submit: ((code: string) => void) | null;
|
||||
@@ -131,6 +137,7 @@ export function App() {
|
||||
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
|
||||
|
||||
const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]);
|
||||
const trustOpen = trustState !== TRUST_MODAL_CLOSED;
|
||||
|
||||
// ---- log + progress plumbing ----
|
||||
const addLog = useCallback(
|
||||
@@ -259,17 +266,25 @@ export function App() {
|
||||
}, [knownUdids, selectedTargetUdid]);
|
||||
|
||||
// ---- pair flow ----
|
||||
const handlePair = useCallback(async (): Promise<PairedDeviceInfo | null> => {
|
||||
if (busyRef.current.pair) return null;
|
||||
/**
|
||||
* lockdownd can return PairingDialogResponsePending before the on-device trust sheet is resolved.
|
||||
* Keep that state separate so the UI never renders "paired" until pairDevice + startSession both finish.
|
||||
*/
|
||||
const runPairFlow = useCallback(async (options: {
|
||||
showSuccess: boolean;
|
||||
startLogMessage: string;
|
||||
}): Promise<PairFlowResult> => {
|
||||
if (busyRef.current.pair) return { kind: 'failed' };
|
||||
setBusy((prev) => ({ ...prev, pair: true }));
|
||||
setTrustOpen(true);
|
||||
addLog('pair: please continue on your device');
|
||||
setTrustState('pairing');
|
||||
addLog(options.startLogMessage);
|
||||
try {
|
||||
const info = await pairDeviceFlow({
|
||||
log: addLog,
|
||||
clientRef: directClientRef,
|
||||
onStateChange: () => {},
|
||||
onTrustPending: () => {
|
||||
setTrustState('pending');
|
||||
addLog('pair: waiting for trust confirmation on device');
|
||||
},
|
||||
});
|
||||
@@ -279,20 +294,34 @@ export function App() {
|
||||
setSelectedTargetUdid(info.udid);
|
||||
saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid);
|
||||
setPairRecordsVersion((v) => v + 1);
|
||||
setTrustOpen(false);
|
||||
return info;
|
||||
setTrustState(options.showSuccess ? 'paired' : TRUST_MODAL_CLOSED);
|
||||
return { kind: 'paired', info };
|
||||
} catch (error) {
|
||||
if (isPairingDialogPendingError(error)) {
|
||||
return null;
|
||||
return { kind: 'pending' };
|
||||
}
|
||||
setTrustOpen(false);
|
||||
setTrustState(TRUST_MODAL_CLOSED);
|
||||
addLog(`pair failed: ${formatError(error)}`);
|
||||
return null;
|
||||
return { kind: 'failed' };
|
||||
} finally {
|
||||
setBusy((prev) => ({ ...prev, pair: false }));
|
||||
}
|
||||
}, [addLog]);
|
||||
|
||||
const handlePair = useCallback(() => {
|
||||
void runPairFlow({
|
||||
showSuccess: true,
|
||||
startLogMessage: 'pair: please continue on your device',
|
||||
});
|
||||
}, [runPairFlow]);
|
||||
|
||||
const handleTrustRetry = useCallback(() => {
|
||||
void runPairFlow({
|
||||
showSuccess: true,
|
||||
startLogMessage: 'pair: retrying after trust confirmation',
|
||||
});
|
||||
}, [runPairFlow]);
|
||||
|
||||
// ---- login flow ----
|
||||
const handleLogin = useCallback(async () => {
|
||||
if (busyRef.current.loginSign) return;
|
||||
@@ -433,12 +462,24 @@ export function App() {
|
||||
log: addLog,
|
||||
clientRef: directClientRef,
|
||||
onStateChange: () => {},
|
||||
onTrustPending: () => setTrustOpen(true),
|
||||
onTrustPending: () => setTrustState('pending'),
|
||||
});
|
||||
let currentDeviceUdid = pairedDeviceInfo?.udid ?? null;
|
||||
if (!client.isSessionStarted) {
|
||||
const freshInfo = await handlePair();
|
||||
currentDeviceUdid = freshInfo?.udid ?? null;
|
||||
const pairResult = await runPairFlow({
|
||||
showSuccess: false,
|
||||
startLogMessage: 'install: device trust required, continue on your device',
|
||||
});
|
||||
if (pairResult.kind === 'pending') {
|
||||
addLog('install paused: finish trusting the device, then install again');
|
||||
setProgress({ percent: 0, status: 'idle' });
|
||||
return;
|
||||
}
|
||||
if (pairResult.kind === 'failed') {
|
||||
setProgress({ percent: 0, status: 'failed' });
|
||||
return;
|
||||
}
|
||||
currentDeviceUdid = pairResult.info.udid;
|
||||
}
|
||||
if (currentDeviceUdid !== targetUdid) {
|
||||
throw new Error('connected device udid does not match selected target');
|
||||
@@ -457,7 +498,7 @@ export function App() {
|
||||
} finally {
|
||||
setBusy((prev) => ({ ...prev, install: false }));
|
||||
}
|
||||
}, [addLog, handlePair, pairedDeviceInfo, prepared, selectedIpaFile, selectedTargetUdid]);
|
||||
}, [addLog, pairedDeviceInfo, prepared, runPairFlow, selectedIpaFile, selectedTargetUdid]);
|
||||
|
||||
// ---- switch account ----
|
||||
const handleSwitchAccount = useCallback(
|
||||
@@ -620,7 +661,11 @@ export function App() {
|
||||
onDismiss={handleDismissProgress}
|
||||
/>
|
||||
|
||||
<TrustModal open={trustOpen} onClose={() => setTrustOpen(false)} pairing={busy.pair} />
|
||||
<TrustModal
|
||||
state={trustState}
|
||||
onClose={() => setTrustState(TRUST_MODAL_CLOSED)}
|
||||
onRetry={handleTrustRetry}
|
||||
/>
|
||||
<TwoFactorModal
|
||||
open={twoFactor.open}
|
||||
onSubmit={handleTwoFactorSubmit}
|
||||
|
||||
@@ -5,20 +5,31 @@ import { TrustModal } from './TrustModal';
|
||||
|
||||
describe('TrustModal', () => {
|
||||
it('is hidden when closed', () => {
|
||||
const { container } = render(<TrustModal open={false} onClose={() => {}} pairing={false} />);
|
||||
const { container } = render(<TrustModal state="closed" onClose={() => {}} onRetry={() => {}} />);
|
||||
const modal = container.querySelector('.modal');
|
||||
expect(modal).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(modal).toBeNull();
|
||||
});
|
||||
|
||||
it('shows pairing state with spinner when pairing', () => {
|
||||
render(<TrustModal open onClose={() => {}} pairing />);
|
||||
render(<TrustModal state="pairing" onClose={() => {}} onRetry={() => {}} />);
|
||||
expect(screen.getByText('Continue on Your Device')).toBeInTheDocument();
|
||||
expect(screen.getByText('Waiting for device…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pending state with retry and close actions', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
render(<TrustModal state="pending" onClose={onClose} onRetry={onRetry} />);
|
||||
expect(screen.getByText('Trust This Device')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'I Trusted This Device' }));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows success state with Continue button when paired', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<TrustModal open onClose={onClose} pairing={false} />);
|
||||
render(<TrustModal state="paired" onClose={onClose} onRetry={() => {}} />);
|
||||
expect(screen.getByText('Device Paired')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Button } from './ui/Button';
|
||||
import { Modal } from './ui/Modal';
|
||||
|
||||
export type TrustModalState = 'closed' | 'pairing' | 'pending' | 'paired';
|
||||
|
||||
interface TrustModalProps {
|
||||
open: boolean;
|
||||
state: TrustModalState;
|
||||
onClose: () => void;
|
||||
pairing: boolean;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
|
||||
export function TrustModal({ state, onClose, onRetry }: TrustModalProps) {
|
||||
if (state === 'closed') return null;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} labelledBy="trust-title" closeOnBackdrop={false} closeOnEscape={!pairing}>
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
labelledBy="trust-title"
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={state !== 'pairing'}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface" aria-hidden="true">
|
||||
<svg
|
||||
@@ -26,7 +37,7 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{pairing ? (
|
||||
{state === 'pairing' ? (
|
||||
<>
|
||||
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
||||
Continue on Your Device
|
||||
@@ -43,15 +54,35 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
|
||||
<span>Waiting for device…</span>
|
||||
</div>
|
||||
</>
|
||||
) : state === 'pending' ? (
|
||||
<>
|
||||
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
||||
Trust This Device
|
||||
</h2>
|
||||
<p className="mt-2 text-[13px] leading-[1.6] text-muted">
|
||||
Approve the trust prompt on your iPhone or iPad, then return here to finish pairing.
|
||||
</p>
|
||||
<p className="mt-1.5 text-[12px] leading-[1.5] text-subtle">
|
||||
Tap <strong className="font-semibold text-ink">Trust</strong> and enter your passcode if asked.
|
||||
</p>
|
||||
<div className="mt-5 grid w-full gap-2 sm:grid-cols-2">
|
||||
<Button variant="ghost" onClick={onClose} className="w-full">
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onRetry} className="w-full">
|
||||
I Trusted This Device
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
||||
Device Paired
|
||||
</h2>
|
||||
<p className="mt-2 text-[13px] leading-[1.6] text-muted">Your device is connected and ready for signing.</p>
|
||||
<button type="button" onClick={onClose} className="btn btn-primary mt-5 w-full">
|
||||
<Button variant="primary" onClick={onClose} className="mt-5 w-full">
|
||||
Continue
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user