feat: enhance TrustModal with new states and improve pairing flow tests

This commit is contained in:
LiBr
2026-04-13 21:06:22 +08:00
parent c6bf32c426
commit 6f3d8fdd76
4 changed files with 147 additions and 27 deletions

View File

@@ -84,6 +84,10 @@ import { App } from './App';
beforeEach(() => { beforeEach(() => {
window.localStorage.clear(); window.localStorage.clear();
window.location.hash = ''; window.location.hash = '';
Object.defineProperty(navigator, 'usb', {
configurable: true,
value: {},
});
checkProvisionedMock.mockResolvedValue(false); checkProvisionedMock.mockResolvedValue(false);
ensureAnisetteMock.mockResolvedValue({ anisetteData: fakeAnisette, provisioned: true }); ensureAnisetteMock.mockResolvedValue({ anisetteData: fakeAnisette, provisioned: true });
loginAccountMock.mockResolvedValue(fakeContext); 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', () => { describe('App — login modal', () => {
it('opens login modal on Add Account, then logs in and navigates to sign', async () => { it('opens login modal on Add Account, then logs in and navigates to sign', async () => {
render(<App />); render(<App />);

View File

@@ -7,7 +7,7 @@ import { Header, type AppPage } from './components/Header';
import { LoginPage } from './components/LoginPage'; import { LoginPage } from './components/LoginPage';
import { LoginModal } from './components/LoginModal'; import { LoginModal } from './components/LoginModal';
import { SignPage } from './components/SignPage'; import { SignPage } from './components/SignPage';
import { TrustModal } from './components/TrustModal'; import { TrustModal, type TrustModalState } from './components/TrustModal';
import { TwoFactorModal } from './components/TwoFactorModal'; import { TwoFactorModal } from './components/TwoFactorModal';
import { ProgressCard } from './components/ProgressCard'; import { ProgressCard } from './components/ProgressCard';
@@ -42,6 +42,7 @@ import { useLog } from './lib/use-log';
const LOGIN_PAGE_HASH = '#/login'; const LOGIN_PAGE_HASH = '#/login';
const SIGN_PAGE_HASH = '#/sign'; const SIGN_PAGE_HASH = '#/sign';
const TRUST_MODAL_CLOSED: TrustModalState = 'closed';
type BusyState = { type BusyState = {
pair: boolean; pair: boolean;
@@ -50,6 +51,11 @@ type BusyState = {
install: boolean; install: boolean;
}; };
type PairFlowResult =
| { kind: 'paired'; info: PairedDeviceInfo }
| { kind: 'pending' }
| { kind: 'failed' };
const idleBusy: BusyState = { pair: false, loginSign: false, sign: false, install: false }; const idleBusy: BusyState = { pair: false, loginSign: false, sign: false, install: false };
function resolvePageFromHash(hash: string): AppPage { function resolvePageFromHash(hash: string): AppPage {
@@ -105,7 +111,7 @@ export function App() {
const lastInstallPercentRef = useRef<number>(0); const lastInstallPercentRef = useRef<number>(0);
// Modals // Modals
const [trustOpen, setTrustOpen] = useState<boolean>(false); const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
const [twoFactor, setTwoFactor] = useState<{ const [twoFactor, setTwoFactor] = useState<{
open: boolean; open: boolean;
submit: ((code: string) => void) | null; submit: ((code: string) => void) | null;
@@ -131,6 +137,7 @@ export function App() {
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null; const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]); const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]);
const trustOpen = trustState !== TRUST_MODAL_CLOSED;
// ---- log + progress plumbing ---- // ---- log + progress plumbing ----
const addLog = useCallback( const addLog = useCallback(
@@ -259,17 +266,25 @@ export function App() {
}, [knownUdids, selectedTargetUdid]); }, [knownUdids, selectedTargetUdid]);
// ---- pair flow ---- // ---- 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 })); setBusy((prev) => ({ ...prev, pair: true }));
setTrustOpen(true); setTrustState('pairing');
addLog('pair: please continue on your device'); addLog(options.startLogMessage);
try { try {
const info = await pairDeviceFlow({ const info = await pairDeviceFlow({
log: addLog, log: addLog,
clientRef: directClientRef, clientRef: directClientRef,
onStateChange: () => {}, onStateChange: () => {},
onTrustPending: () => { onTrustPending: () => {
setTrustState('pending');
addLog('pair: waiting for trust confirmation on device'); addLog('pair: waiting for trust confirmation on device');
}, },
}); });
@@ -279,20 +294,34 @@ export function App() {
setSelectedTargetUdid(info.udid); setSelectedTargetUdid(info.udid);
saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid); saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid);
setPairRecordsVersion((v) => v + 1); setPairRecordsVersion((v) => v + 1);
setTrustOpen(false); setTrustState(options.showSuccess ? 'paired' : TRUST_MODAL_CLOSED);
return info; return { kind: 'paired', info };
} catch (error) { } catch (error) {
if (isPairingDialogPendingError(error)) { if (isPairingDialogPendingError(error)) {
return null; return { kind: 'pending' };
} }
setTrustOpen(false); setTrustState(TRUST_MODAL_CLOSED);
addLog(`pair failed: ${formatError(error)}`); addLog(`pair failed: ${formatError(error)}`);
return null; return { kind: 'failed' };
} finally { } finally {
setBusy((prev) => ({ ...prev, pair: false })); setBusy((prev) => ({ ...prev, pair: false }));
} }
}, [addLog]); }, [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 ---- // ---- login flow ----
const handleLogin = useCallback(async () => { const handleLogin = useCallback(async () => {
if (busyRef.current.loginSign) return; if (busyRef.current.loginSign) return;
@@ -433,12 +462,24 @@ export function App() {
log: addLog, log: addLog,
clientRef: directClientRef, clientRef: directClientRef,
onStateChange: () => {}, onStateChange: () => {},
onTrustPending: () => setTrustOpen(true), onTrustPending: () => setTrustState('pending'),
}); });
let currentDeviceUdid = pairedDeviceInfo?.udid ?? null; let currentDeviceUdid = pairedDeviceInfo?.udid ?? null;
if (!client.isSessionStarted) { if (!client.isSessionStarted) {
const freshInfo = await handlePair(); const pairResult = await runPairFlow({
currentDeviceUdid = freshInfo?.udid ?? null; 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) { if (currentDeviceUdid !== targetUdid) {
throw new Error('connected device udid does not match selected target'); throw new Error('connected device udid does not match selected target');
@@ -457,7 +498,7 @@ export function App() {
} finally { } finally {
setBusy((prev) => ({ ...prev, install: false })); setBusy((prev) => ({ ...prev, install: false }));
} }
}, [addLog, handlePair, pairedDeviceInfo, prepared, selectedIpaFile, selectedTargetUdid]); }, [addLog, pairedDeviceInfo, prepared, runPairFlow, selectedIpaFile, selectedTargetUdid]);
// ---- switch account ---- // ---- switch account ----
const handleSwitchAccount = useCallback( const handleSwitchAccount = useCallback(
@@ -620,7 +661,11 @@ export function App() {
onDismiss={handleDismissProgress} onDismiss={handleDismissProgress}
/> />
<TrustModal open={trustOpen} onClose={() => setTrustOpen(false)} pairing={busy.pair} /> <TrustModal
state={trustState}
onClose={() => setTrustState(TRUST_MODAL_CLOSED)}
onRetry={handleTrustRetry}
/>
<TwoFactorModal <TwoFactorModal
open={twoFactor.open} open={twoFactor.open}
onSubmit={handleTwoFactorSubmit} onSubmit={handleTwoFactorSubmit}

View File

@@ -5,20 +5,31 @@ import { TrustModal } from './TrustModal';
describe('TrustModal', () => { describe('TrustModal', () => {
it('is hidden when closed', () => { 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'); const modal = container.querySelector('.modal');
expect(modal).toHaveAttribute('aria-hidden', 'true'); expect(modal).toBeNull();
}); });
it('shows pairing state with spinner when pairing', () => { 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('Continue on Your Device')).toBeInTheDocument();
expect(screen.getByText('Waiting for 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 () => { it('shows success state with Continue button when paired', async () => {
const onClose = vi.fn(); const onClose = vi.fn();
render(<TrustModal open onClose={onClose} pairing={false} />); render(<TrustModal state="paired" onClose={onClose} onRetry={() => {}} />);
expect(screen.getByText('Device Paired')).toBeInTheDocument(); expect(screen.getByText('Device Paired')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Continue' })); await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);

View File

@@ -1,14 +1,25 @@
import { Button } from './ui/Button';
import { Modal } from './ui/Modal'; import { Modal } from './ui/Modal';
export type TrustModalState = 'closed' | 'pairing' | 'pending' | 'paired';
interface TrustModalProps { interface TrustModalProps {
open: boolean; state: TrustModalState;
onClose: () => void; 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 ( 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="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"> <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface" aria-hidden="true">
<svg <svg
@@ -26,7 +37,7 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
</svg> </svg>
</div> </div>
{pairing ? ( {state === 'pairing' ? (
<> <>
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink"> <h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
Continue on Your Device Continue on Your Device
@@ -43,15 +54,35 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
<span>Waiting for device</span> <span>Waiting for device</span>
</div> </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"> <h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
Device Paired Device Paired
</h2> </h2>
<p className="mt-2 text-[13px] leading-[1.6] text-muted">Your device is connected and ready for signing.</p> <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 Continue
</button> </Button>
</> </>
)} )}
</div> </div>