Improve desktop mobile pairing flow (#5024)

## Summary
- add stable three-step guidance to desktop mobile pairing
- move code confirmation inline and show animated completion states
- preserve pairing reset behavior and reduced-motion support

## Test plan
- `pnpm --dir desktop check`
- `pnpm --dir desktop exec tsc --noEmit`
- `pnpm --dir desktop exec playwright test
tests/e2e/mobile-pairing-qr.spec.ts --project=smoke`
- pre-push desktop suite: 4,387 tests passed

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Fizz <50a12680c76f1a52c0b7af8dbb17e02c583227c290fb93b9a3defb456114223f@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-06 07:45:21 -07:00
committed by GitHub
co-authored by Fizz
parent 5babb97ca3
commit 480c41ebf1
2 changed files with 593 additions and 221 deletions
@@ -4,11 +4,10 @@ import {
Copy,
LoaderCircle,
RefreshCw,
ShieldCheck,
TriangleAlert,
X,
} from "lucide-react";
import { listen } from "@tauri-apps/api/event";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import {
@@ -16,15 +15,9 @@ import {
confirmPairingSas,
startPairing,
} from "@/shared/api/tauri";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { StyledQrCode } from "@/shared/ui/styled-qr-code";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
@@ -39,6 +32,8 @@ type PairingStep =
| "done"
| "error";
const PAIRING_CODE_DIGIT_POSITIONS = [0, 1, 2, 3, 4, 5] as const;
function pairingErrorMessage(error: unknown) {
const message =
error instanceof Error
@@ -58,114 +53,189 @@ function isPairingSessionTimeout(message: string) {
return message.toLowerCase().includes("session timed out");
}
function PairingStatusDialog({
onClose,
function PairingStepIndicator({
complete,
label,
testId,
}: {
complete: boolean;
label: string;
testId: string;
}) {
const shouldReduceMotion = useReducedMotion() ?? false;
const hiddenState = shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(2px)", opacity: 0, scale: 0.25 };
const visibleState = shouldReduceMotion
? { opacity: 1 }
: { filter: "blur(0px)", opacity: 1, scale: 1 };
return (
<span
aria-hidden="true"
className={cn(
"relative flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-base font-semibold transition-[background-color,color] duration-[250ms] ease-in-out motion-reduce:transition-none",
complete
? "bg-green-600 text-white"
: "bg-secondary text-secondary-foreground",
)}
data-completed={complete ? "true" : "false"}
data-testid={testId}
>
<AnimatePresence initial={false}>
<motion.span
animate={visibleState}
className="absolute inset-0 flex items-center justify-center"
data-state={complete ? "complete" : "pending"}
exit={hiddenState}
initial={hiddenState}
key={complete ? "complete" : "pending"}
transition={
shouldReduceMotion
? { duration: 0 }
: { duration: 0.25, ease: "easeInOut" }
}
>
{complete ? <Check className="h-6 w-6" /> : label}
</motion.span>
</AnimatePresence>
</span>
);
}
function PairingSteps({ step }: { step: PairingStep }) {
const hasScanned =
step === "sas" || step === "transferring" || step === "done";
const hasConfirmed = step === "transferring" || step === "done";
const isPaired = step === "done";
return (
<ol
className="flex min-h-[266px] min-w-0 flex-1 flex-col justify-center gap-6 py-2"
data-testid="mobile-pairing-steps"
>
<li className="flex min-w-0 items-start gap-4">
<PairingStepIndicator
complete={hasScanned}
label="1"
testId="mobile-pairing-scan-step-indicator"
/>
<div className="min-w-0 pt-0.5">
<p className="text-base font-medium">Scan QR code</p>
<p className="mt-1 text-sm text-muted-foreground/80">
Open Buzz on your mobile device and scan the code shown here.
</p>
</div>
</li>
<li className="flex min-w-0 items-start gap-4">
<PairingStepIndicator
complete={hasConfirmed}
label="2"
testId="mobile-pairing-confirm-step-indicator"
/>
<div className="min-w-0 pt-0.5">
<p className="text-base font-medium">Confirm mobile code</p>
<p className="mt-1 text-sm text-muted-foreground/80">
Check that the six-digit code matches on both devices, then confirm
it.
</p>
</div>
</li>
<li
className="flex min-w-0 items-start gap-4"
data-testid="mobile-pairing-final-step"
>
<PairingStepIndicator
complete={isPaired}
label="3"
testId="mobile-pairing-final-step-indicator"
/>
<div aria-live="polite" className="min-w-0 pt-0.5">
<p className="text-base font-medium">
{isPaired ? "Paired" : "Pair your mobile app"}
</p>
<p className="mt-1 text-sm text-muted-foreground/80">
{isPaired
? "Your mobile app is now connected to this relay."
: "Your mobile app will connect after you confirm the code."}
</p>
</div>
</li>
</ol>
);
}
function PairingCodeConfirmation({
onConfirm,
onDeny,
sasCode,
step,
}: {
onClose: () => void;
onConfirm: () => void;
onDeny: () => void;
sasCode: string | null;
step: PairingStep;
sasCode: string;
}) {
const open = step === "sas" || step === "transferring" || step === "done";
const formattedCode = `${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)}`;
return (
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
open={open}
<div
className="grid h-[266px] w-full grid-rows-[auto_1fr_auto] text-center"
data-testid="mobile-pairing-code-confirmation"
>
<DialogContent
className="max-w-md gap-0 overflow-hidden border-0 px-6 pb-6 pt-6"
data-testid="mobile-pairing-dialog"
<p
className="self-start text-base font-medium"
data-testid="pairing-sas-title"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="shrink-0 pb-5 pr-8">
<DialogTitle>Pair mobile device</DialogTitle>
<DialogDescription>
{step === "sas"
? "Verify the security code matches your mobile device."
: step === "done"
? "Your mobile device is now paired."
: "Securely sending your identity to the mobile app."}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto pt-4">
{step === "sas" && sasCode ? (
<div className="space-y-4">
<div className="flex flex-col items-center gap-3 py-4">
<ShieldCheck className="h-10 w-10 text-primary" />
<p className="text-sm font-medium">
Verify this code matches your mobile device
</p>
<div className="rounded-xl border-2 border-primary/30 bg-primary/5 px-8 py-4">
<p
className="font-mono text-4xl font-bold tracking-[0.3em]"
data-testid="pairing-sas-code"
>
{sasCode.slice(0, 3)} {sasCode.slice(3)}
</p>
</div>
<p className="text-center text-xs text-muted-foreground">
You are about to transfer your Buzz identity to another
device. Only confirm if you initiated this pairing.
</p>
</div>
<div className="flex gap-2">
<Button
className="flex-1"
data-testid="deny-sas"
onClick={onDeny}
variant="outline"
>
<X className="mr-1.5 h-4 w-4" />
Cancel
</Button>
<Button
className="flex-1"
data-testid="confirm-sas"
onClick={onConfirm}
>
<Check className="mr-1.5 h-4 w-4" />
Codes match
</Button>
</div>
</div>
) : step === "transferring" ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<LoaderCircle
aria-hidden="true"
className="h-6 w-6 animate-spin text-muted-foreground"
/>
<p className="text-sm text-muted-foreground">
Sending identity to mobile device...
</p>
</div>
) : step === "done" ? (
<div
className="flex flex-col items-center justify-center gap-3 py-8"
data-testid="mobile-pairing-done"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Check className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<p className="text-sm font-medium">Mobile device paired</p>
<p className="text-center text-xs text-muted-foreground">
Your mobile app is now connected to this relay.
</p>
</div>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
Confirm mobile code
</p>
<fieldset
className="flex w-full self-center justify-center gap-[6px]"
data-testid="pairing-sas-code"
>
<legend className="sr-only">Confirmation code {formattedCode}</legend>
{PAIRING_CODE_DIGIT_POSITIONS.map((position) => (
<span
aria-hidden="true"
className={cn(
// The cell box is px-frozen on purpose. The digits stay
// rem-based (`text-2xl`) so they scale with Cmd +/- zoom, but a
// rem-sized cell (`w-10`) plus rem gaps grew the six cells past
// this fixed 266px column at 150% text scale and overlapped the
// step guidance. Freezing the box keeps the code block the same
// width at every zoom level; a zoomed digit still fits inside.
"flex w-[40px] shrink-0 items-center justify-center rounded-xl border border-input/60 bg-background py-3 font-mono text-2xl font-semibold text-foreground",
position === 3 && "ml-[8px]",
)}
data-testid={`pairing-sas-code-digit-${position + 1}`}
key={position}
>
{sasCode[position] ?? ""}
</span>
))}
</fieldset>
<div
className="flex w-[240px] flex-col gap-2 self-end justify-self-center"
data-testid="pairing-sas-actions"
>
<Button
className="w-full"
data-testid="confirm-sas"
onClick={onConfirm}
>
<Check />
Codes match
</Button>
<Button
className="w-full"
data-testid="deny-sas"
onClick={onDeny}
variant="outline"
>
Cancel
</Button>
</div>
</div>
);
}
@@ -312,21 +382,6 @@ export function MobilePairingCard({
setStep("error");
}
function handleStatusDialogClose() {
pairingActiveRef.current = false;
if (stepRef.current === "done") {
setStep("idle");
setQrUri(null);
setSasCode(null);
setError(null);
return;
}
cancelPairing().catch(() => {});
setError("Pairing was canceled.");
setStep("error");
}
return (
<section className="min-w-0" data-testid="settings-mobile">
<SettingsSectionHeader
@@ -341,105 +396,156 @@ export function MobilePairingCard({
/>
<SettingsOptionGroup
className="mx-auto w-fit max-w-full"
className="w-full [container-type:inline-size]"
data-testid="mobile-pairing-card"
>
<SettingsOptionRow className="flex-col items-stretch justify-start gap-3 p-4">
<div
className="flex min-h-[266px] w-[266px] shrink-0 items-center justify-center rounded-lg border border-border/70 bg-white p-3"
data-testid="mobile-pairing-qr-container"
>
{step === "qr" && qrUri ? (
<StyledQrCode
animate
centerImageSrc="/app-icon@2x.png"
data-testid="mobile-pairing-qr"
size={240}
title="Mobile pairing QR code"
value={qrUri}
/>
) : step === "expired" ? (
<div className="flex max-w-52 origin-center animate-in flex-col items-center gap-3 text-center fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none">
<p className="text-sm text-muted-foreground">
Pairing code expired.
</p>
<Button
data-testid="regenerate-pairing-button"
onClick={beginPairing}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="mr-1.5 h-4 w-4" />
Generate new pairing code
</Button>
</div>
) : step === "error" ? (
<div className="flex max-w-52 flex-col items-center gap-3 text-center">
<TriangleAlert className="h-6 w-6 text-destructive" />
<p className="text-sm text-destructive">
{error ?? "Pairing session ended."}
</p>
<Button
data-testid="retry-pairing-button"
onClick={beginPairing}
size="sm"
variant="outline"
>
Try again
</Button>
</div>
) : step === "idle" ? (
currentPubkey ? (
<Button
data-testid="start-pairing-button"
onClick={beginPairing}
type="button"
>
Start pairing
</Button>
) : (
<p className="max-w-44 text-center text-sm text-muted-foreground">
Sign in to generate a mobile pairing code.
</p>
)
) : (
<div className="flex flex-col items-center justify-center gap-3">
<LoaderCircle
aria-hidden="true"
className="h-6 w-6 animate-spin text-muted-foreground"
data-testid="pairing-loading-spinner"
{/* Persistent polite live region. The pairing steps swap the QR view
for the inline code confirmation asynchronously, and a screen
reader would otherwise get no signal that a code is now waiting.
This stays mounted for every step so the announcement is reliable
(a region added at the same time as its text often isn't spoken)
and is visually hidden, so it changes nothing on screen. */}
<p aria-live="polite" className="sr-only" data-testid="pairing-status">
{step === "sas" && sasCode
? `Verification code ${sasCode.slice(0, 3)} ${sasCode.slice(3, 6)} ready. Check that it matches on your mobile device, then confirm the codes match.`
: step === "transferring"
? "Codes confirmed. Pairing your mobile device."
: step === "done"
? "Your mobile app is now paired."
: ""}
</p>
<SettingsOptionRow
// Side-by-side is gated on the *card's* own width, not the viewport.
// `sm:` fires at an 800px viewport, but the settings sidebar and
// content padding leave the card only ~443px there — the QR column
// and gap ate all of it, collapsing the step guidance to ~1px and
// stretching the card past 1000px tall. 46rem is the narrowest card
// width where the steps column still gets a readable ~294px.
className="flex-col items-stretch justify-start gap-14 px-6 pb-4 pt-15 [@container(min-width:46rem)]:flex-row [@container(min-width:46rem)]:items-start [@container(min-width:46rem)]:px-15"
data-testid="mobile-pairing-layout"
>
<div className="flex w-[266px] max-w-full shrink-0 flex-col gap-3">
<div
className={cn(
"flex min-h-[266px] w-[266px] max-w-full items-center justify-center rounded-lg border",
step === "sas" || step === "transferring" || step === "done"
? "border-transparent bg-transparent p-0"
: "border-border/70 bg-background p-3",
)}
data-testid="mobile-pairing-qr-container"
>
{step === "sas" && sasCode ? (
<PairingCodeConfirmation
onConfirm={() => void handleConfirmSas()}
onDeny={handleDenySas}
sasCode={sasCode}
/>
<p className="text-sm text-muted-foreground">
Starting pairing...
</p>
</div>
)}
) : step === "transferring" ? (
<div className="flex flex-col items-center justify-center gap-3 text-center">
<LoaderCircle
aria-hidden="true"
className="h-6 w-6 animate-spin text-muted-foreground"
data-testid="pairing-transfer-spinner"
/>
<p className="text-sm text-muted-foreground">
Pairing mobile device...
</p>
</div>
) : step === "qr" && qrUri ? (
<StyledQrCode
animate
centerImageSrc="/app-icon@2x.png"
data-testid="mobile-pairing-qr"
size={240}
title="Mobile pairing QR code"
value={qrUri}
/>
) : step === "expired" ? (
<div className="flex max-w-52 origin-center animate-in flex-col items-center gap-3 text-center fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none">
<p className="text-sm text-muted-foreground">
Pairing code expired.
</p>
<Button
data-testid="regenerate-pairing-button"
onClick={beginPairing}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="mr-1.5 h-4 w-4" />
Generate new pairing code
</Button>
</div>
) : step === "error" ? (
<div className="flex max-w-52 flex-col items-center gap-3 text-center">
<TriangleAlert className="h-6 w-6 text-destructive" />
<p className="text-sm text-destructive">
{error ?? "Pairing session ended."}
</p>
<Button
data-testid="retry-pairing-button"
onClick={beginPairing}
size="sm"
variant="outline"
>
Try again
</Button>
</div>
) : step === "idle" ? (
currentPubkey ? (
<Button
data-testid="start-pairing-button"
onClick={beginPairing}
type="button"
>
Start pairing
</Button>
) : (
<p className="max-w-44 text-center text-sm text-muted-foreground">
Sign in to generate a mobile pairing code.
</p>
)
) : step === "done" ? (
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Check className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<p className="text-base font-medium">Paired</p>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-3">
<LoaderCircle
aria-hidden="true"
className="h-6 w-6 animate-spin text-muted-foreground"
data-testid="pairing-loading-spinner"
/>
<p className="text-sm text-muted-foreground">
Starting pairing...
</p>
</div>
)}
</div>
<div className="h-8" data-testid="mobile-pairing-copy-slot">
{step === "qr" && qrUri ? (
<Button
className="h-8 w-full origin-top animate-in fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none"
data-testid="copy-pairing-code"
onClick={handleCopy}
size="sm"
type="button"
variant="outline"
>
<Copy className="mr-1.5 h-4 w-4" />
Copy pairing code
</Button>
) : null}
</div>
</div>
{step === "qr" && qrUri ? (
<Button
className="w-full origin-top animate-in fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none"
data-testid="copy-pairing-code"
onClick={handleCopy}
size="sm"
type="button"
variant="outline"
>
<Copy className="mr-1.5 h-4 w-4" />
Copy pairing code
</Button>
) : null}
<PairingSteps step={step} />
</SettingsOptionRow>
</SettingsOptionGroup>
<PairingStatusDialog
onClose={handleStatusDialogClose}
onConfirm={() => void handleConfirmSas()}
onDeny={handleDenySas}
sasCode={sasCode}
step={step}
/>
</section>
);
}
+274 -8
View File
@@ -47,10 +47,50 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
const section = page.getByTestId("settings-mobile");
const card = page.getByTestId("mobile-pairing-card");
const layout = card.getByTestId("mobile-pairing-layout");
const qrContainer = page.getByTestId("mobile-pairing-qr-container");
const steps = card.getByTestId("mobile-pairing-steps");
const scanStepIndicator = card.getByTestId(
"mobile-pairing-scan-step-indicator",
);
const confirmStepIndicator = card.getByTestId(
"mobile-pairing-confirm-step-indicator",
);
const finalStep = card.getByTestId("mobile-pairing-final-step");
const startButton = card.getByTestId("start-pairing-button");
await expect(card).toBeVisible();
await expect(startButton).toHaveText("Start pairing");
await expect(steps.getByText("Scan QR code", { exact: true })).toBeVisible();
await expect(
steps.getByText("Confirm mobile code", { exact: true }),
).toBeVisible();
await expect(
finalStep.getByText("Pair your mobile app", { exact: true }),
).toBeVisible();
const finalStepIndicator = finalStep.getByTestId(
"mobile-pairing-final-step-indicator",
);
await expect(finalStepIndicator).toHaveText("3");
await expect(scanStepIndicator).toHaveAttribute("data-completed", "false");
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false");
await expect(finalStepIndicator).toHaveCSS("width", "48px");
await expect(finalStepIndicator).toHaveCSS("height", "48px");
expect(
await finalStepIndicator.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).borderRadius),
),
).toBeGreaterThanOrEqual(24);
const indicatorBackground = await finalStepIndicator.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
const primaryActionBackground = await startButton.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
expect(indicatorBackground).not.toBe(primaryActionBackground);
await expect(layout).toHaveCSS("padding-top", "60px");
await expect(layout).toHaveCSS("padding-right", "60px");
await expect(layout).toHaveCSS("padding-left", "60px");
await expect(layout).toHaveCSS("column-gap", "56px");
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
await expect(page.getByTestId("copy-pairing-code")).toHaveCount(0);
expect(
@@ -64,8 +104,20 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
const sectionBox = await section.boundingBox();
const cardBox = await card.boundingBox();
const initialStepsBox = await steps.boundingBox();
expect(sectionBox).not.toBeNull();
expect(cardBox).not.toBeNull();
expect(initialStepsBox).not.toBeNull();
const initialQrBox = await qrContainer.boundingBox();
expect(initialQrBox).not.toBeNull();
const qrTopSpace = (initialQrBox?.y ?? 0) - (cardBox?.y ?? 0);
const qrLeftSpace = (initialQrBox?.x ?? 0) - (cardBox?.x ?? 0);
const qrBottomSpace =
(cardBox?.y ?? 0) +
(cardBox?.height ?? 0) -
((initialQrBox?.y ?? 0) + (initialQrBox?.height ?? 0));
expect(Math.abs(qrTopSpace - qrLeftSpace)).toBeLessThan(0.5);
expect(Math.abs(qrTopSpace - qrBottomSpace)).toBeLessThan(0.5);
const sectionCenter = (sectionBox?.x ?? 0) + (sectionBox?.width ?? 0) / 2;
const cardCenter = (cardBox?.x ?? 0) + (cardBox?.width ?? 0) / 2;
expect(Math.abs(sectionCenter - cardCenter)).toBeLessThan(0.5);
@@ -118,8 +170,19 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
await waitForAnimations(page);
const qrBox = await qrContainer.boundingBox();
const copyBox = await copyButton.boundingBox();
const stepsBox = await steps.boundingBox();
const qrCardBox = await card.boundingBox();
expect(qrBox).not.toBeNull();
expect(copyBox).not.toBeNull();
expect(stepsBox).not.toBeNull();
expect(qrCardBox).not.toBeNull();
expect(qrBox?.x ?? 0).toBeLessThan(stepsBox?.x ?? 0);
expect(Math.abs((stepsBox?.y ?? 0) - (initialStepsBox?.y ?? 0))).toBeLessThan(
0.5,
);
expect(
Math.abs((qrCardBox?.height ?? 0) - (cardBox?.height ?? 0)),
).toBeLessThan(0.5);
expect(copyBox?.y ?? 0).toBeGreaterThan(
(qrBox?.y ?? 0) + (qrBox?.height ?? 0),
);
@@ -158,6 +221,178 @@ test("mobile pairing starts on demand and reveals the QR code", async ({
await qrCode.screenshot({ path: `${SCREENSHOT_DIR}/pairing-qr.png` });
});
test("pairing completion updates the final step and resets after leaving", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
const card = page.getByTestId("mobile-pairing-card");
const finalStep = card.getByTestId("mobile-pairing-final-step");
const scanStepIndicator = card.getByTestId(
"mobile-pairing-scan-step-indicator",
);
const confirmStepIndicator = card.getByTestId(
"mobile-pairing-confirm-step-indicator",
);
await card.getByTestId("start-pairing-button").click();
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
const confirmation = card.getByTestId("mobile-pairing-code-confirmation");
await expect(scanStepIndicator).toHaveAttribute("data-completed", "true");
await expect(scanStepIndicator.locator('[data-state="complete"]')).toHaveCSS(
"opacity",
"1",
);
await expect(scanStepIndicator.locator("svg")).toHaveCount(1);
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "false");
await expect(page.getByTestId("mobile-pairing-dialog")).toHaveCount(0);
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
const confirmationCode = confirmation.getByTestId("pairing-sas-code");
await expect(confirmationCode).toHaveAccessibleName(
"Confirmation code 123 456",
);
await expect(
confirmationCode.locator('[data-testid^="pairing-sas-code-digit-"]'),
).toHaveCount(6);
await expect(
confirmationCode.getByTestId("pairing-sas-code-digit-1"),
).toHaveCSS("border-radius", "12px");
await expect(
confirmationCode.getByTestId("pairing-sas-code-digit-1"),
).toHaveCSS("box-shadow", "none");
const firstCodeDigit = confirmationCode.getByTestId(
"pairing-sas-code-digit-1",
);
const firstCodeDigitBox = await firstCodeDigit.boundingBox();
expect(firstCodeDigitBox).not.toBeNull();
expect(firstCodeDigitBox?.width ?? 0).toBeGreaterThan(32);
expect(firstCodeDigitBox?.height ?? 0).toBeGreaterThan(48);
const secondCodeDigitBox = await confirmationCode
.getByTestId("pairing-sas-code-digit-2")
.boundingBox();
const thirdCodeDigitBox = await confirmationCode
.getByTestId("pairing-sas-code-digit-3")
.boundingBox();
const fourthCodeDigitBox = await confirmationCode
.getByTestId("pairing-sas-code-digit-4")
.boundingBox();
expect(secondCodeDigitBox).not.toBeNull();
expect(thirdCodeDigitBox).not.toBeNull();
expect(fourthCodeDigitBox).not.toBeNull();
const regularDigitGap =
(thirdCodeDigitBox?.x ?? 0) -
((secondCodeDigitBox?.x ?? 0) + (secondCodeDigitBox?.width ?? 0));
const groupedDigitGap =
(fourthCodeDigitBox?.x ?? 0) -
((thirdCodeDigitBox?.x ?? 0) + (thirdCodeDigitBox?.width ?? 0));
expect(groupedDigitGap).toBeGreaterThan(regularDigitGap);
await expect(card.getByTestId("mobile-pairing-qr-container")).toHaveCSS(
"border-color",
"rgba(0, 0, 0, 0)",
);
const confirmButton = confirmation.getByTestId("confirm-sas");
const cancelButton = confirmation.getByTestId("deny-sas");
const confirmationBox = await confirmation.boundingBox();
const confirmationTitleBox = await confirmation
.getByTestId("pairing-sas-title")
.boundingBox();
const confirmationCodeBox = await confirmationCode.boundingBox();
const confirmationActionsBox = await confirmation
.getByTestId("pairing-sas-actions")
.boundingBox();
expect(confirmationBox).not.toBeNull();
expect(confirmationTitleBox).not.toBeNull();
expect(confirmationCodeBox).not.toBeNull();
expect(confirmationActionsBox).not.toBeNull();
expect(
Math.abs((confirmationTitleBox?.y ?? 0) - (confirmationBox?.y ?? 0)),
).toBeLessThan(0.5);
const codeCenter =
(confirmationCodeBox?.y ?? 0) + (confirmationCodeBox?.height ?? 0) / 2;
const availableCodeCenter =
((confirmationTitleBox?.y ?? 0) +
(confirmationTitleBox?.height ?? 0) +
(confirmationActionsBox?.y ?? 0)) /
2;
expect(Math.abs(availableCodeCenter - codeCenter)).toBeLessThan(0.5);
expect(
Math.abs(
(confirmationActionsBox?.y ?? 0) +
(confirmationActionsBox?.height ?? 0) -
((confirmationBox?.y ?? 0) + (confirmationBox?.height ?? 0)),
),
).toBeLessThan(0.5);
await expect(confirmButton).toHaveCSS("height", "36px");
await expect(cancelButton).toHaveCSS("height", "36px");
await expect(confirmButton.locator("svg")).toHaveCount(1);
await expect(cancelButton.locator("svg")).toHaveCount(0);
const confirmBox = await confirmButton.boundingBox();
const cancelBox = await cancelButton.boundingBox();
expect(confirmBox).not.toBeNull();
expect(cancelBox).not.toBeNull();
expect(confirmBox?.y ?? 0).toBeLessThan(cancelBox?.y ?? 0);
await expect(
confirmation.getByText(/Only confirm if you started this pairing/),
).toHaveCount(0);
await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS(
"font-size",
"16px",
);
mkdirSync(SCREENSHOT_DIR, { recursive: true });
await waitForAnimations(page);
await card.screenshot({
path: `${SCREENSHOT_DIR}/pairing-code-confirmation.png`,
});
await confirmation.getByTestId("confirm-sas").click();
await expect(card.getByText("Pairing mobile device...")).toBeVisible();
await expect(confirmStepIndicator).toHaveAttribute("data-completed", "true");
await expect(
confirmStepIndicator.locator('[data-state="complete"]'),
).toHaveCSS("opacity", "1");
await expect(confirmStepIndicator.locator("svg")).toHaveCount(1);
await emitPairingEvent(page, "pairing-complete");
await expect(finalStep.getByText("Paired", { exact: true })).toBeVisible();
await expect(
finalStep.getByText("Your mobile app is now connected to this relay."),
).toBeVisible();
await expect(
finalStep.getByTestId("mobile-pairing-final-step-indicator").locator("svg"),
).toHaveCount(1);
await expect(
finalStep.getByTestId("mobile-pairing-final-step-indicator"),
).toHaveAttribute("data-completed", "true");
const pairedSurface = card.getByTestId("mobile-pairing-qr-container");
await expect(
pairedSurface.getByText("Paired", { exact: true }),
).toBeVisible();
await expect(
pairedSurface.getByText("Your mobile app is now connected to this relay."),
).toHaveCount(0);
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-complete.png` });
await page.getByTestId("settings-nav-updates").click();
await page.getByTestId("settings-nav-mobile").click();
const restartedCard = page.getByTestId("mobile-pairing-card");
await expect(restartedCard.getByTestId("start-pairing-button")).toBeVisible();
await expect(
restartedCard
.getByTestId("mobile-pairing-final-step")
.getByText("Pair your mobile app", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
});
test("late pairing events are ignored after canceling", async ({ page }) => {
await page.goto("/");
await page.getByTestId("open-settings").click();
@@ -169,17 +404,48 @@ test("late pairing events are ignored after canceling", async ({ page }) => {
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
const dialog = page.getByTestId("mobile-pairing-dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Close" }).click();
const confirmation = card.getByTestId("mobile-pairing-code-confirmation");
await expect(confirmation).toBeVisible();
await confirmation.getByTestId("deny-sas").click();
await expect(dialog).toHaveCount(0);
await expect(card.getByText("Pairing was canceled.")).toBeVisible();
await expect(confirmation).toHaveCount(0);
await expect(
card.getByText("The codes didn't match. Pairing was canceled."),
).toBeVisible();
await emitPairingEvent(page, "pairing-complete");
await emitPairingEvent(page, "pairing-sas-received", { sas: "654321" });
await expect(dialog).toHaveCount(0);
await expect(page.getByTestId("mobile-pairing-done")).toHaveCount(0);
await expect(card.getByText("Pairing was canceled.")).toBeVisible();
await expect(confirmation).toHaveCount(0);
await expect(
card
.getByTestId("mobile-pairing-final-step")
.getByText("Paired", { exact: true }),
).toHaveCount(0);
await expect(
card.getByText("The codes didn't match. Pairing was canceled."),
).toBeVisible();
});
test("step completion respects reduced motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
await page.getByTestId("open-settings").click();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
const card = page.getByTestId("mobile-pairing-card");
const scanStepIndicator = card.getByTestId(
"mobile-pairing-scan-step-indicator",
);
await card.getByTestId("start-pairing-button").click();
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
await expect(scanStepIndicator).toHaveAttribute("data-completed", "true");
await expect(scanStepIndicator).toHaveCSS("transition-property", "none");
const completedContent = scanStepIndicator.locator('[data-state="complete"]');
await expect(completedContent).toHaveCSS("opacity", "1");
await expect(completedContent).toHaveCSS("transform", "none");
});