Files
buzz/desktop/tests/e2e/mobile-pairing-qr.spec.ts
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

452 lines
18 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
const SCREENSHOT_DIR = "test-results/mobile-pairing-qr";
test.beforeEach(async ({ page }) => {
await installMockBridge(page, { pairingStartDelayMs: 300 });
});
async function emitPairingEvent(page: Page, event: string, payload?: unknown) {
await page.evaluate(
async ({ eventName, eventPayload }) => {
const internals = (
window as Window & {
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
args: Record<string, unknown>,
) => Promise<unknown>;
};
}
).__TAURI_INTERNALS__;
if (!internals?.invoke) {
throw new Error("Tauri E2E event bridge is unavailable");
}
await internals.invoke("plugin:event|emit", {
event: eventName,
payload: eventPayload,
});
},
{ eventName: event, eventPayload: payload },
);
}
test("mobile pairing starts on demand and reveals the QR code", 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();
mkdirSync(SCREENSHOT_DIR, { recursive: true });
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(
await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "start_pairing",
).length,
),
).toBe(0);
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);
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-start.png` });
await startButton.click();
const loadingSpinner = card.getByTestId("pairing-loading-spinner");
await expect(loadingSpinner).toBeVisible();
await expect(loadingSpinner).toHaveCSS("animation-name", "spin");
const qrCode = page.getByTestId("mobile-pairing-qr");
const copyButton = card.getByTestId("copy-pairing-code");
await expect(qrCode).toBeVisible();
await expect(copyButton).toHaveText("Copy pairing code");
await expect(startButton).toHaveCount(0);
await expect(card.getByText("Pair mobile device")).toHaveCount(0);
await expect(page.getByTestId("mobile-pairing-dialog")).toHaveCount(0);
await expect(
page.getByText("Securely transfer your identity via NIP-AB protocol"),
).toHaveCount(0);
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
expect(await qrCode.locator("circle").count()).toBeGreaterThan(100);
expect(await qrCode.locator(".buzz-qr-cell-reveal").count()).toBeGreaterThan(
100,
);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-name",
"buzz-qr-cell-reveal",
);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-duration",
"0.058s",
);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-timing-function",
"linear",
);
await expect(
qrCode.locator('[data-qr-cell-row="56"].buzz-qr-cell-reveal').first(),
).toHaveCSS("animation-delay", "0.189s");
await expect(copyButton).toHaveCSS("animation-name", "enter");
await expect(copyButton).toHaveCSS("animation-duration", "0.25s");
await expect(qrCode.locator("image")).toHaveAttribute(
"href",
"/app-icon@2x.png",
);
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),
);
expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(0.5);
expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(
0.5,
);
await emitPairingEvent(page, "pairing-error", {
message: "Session timed out",
});
await expect(qrCode).toHaveCount(0);
await expect(copyButton).toHaveCount(0);
await expect(card.getByText("Pairing code expired.")).toBeVisible();
const regenerateButton = card.getByTestId("regenerate-pairing-button");
await expect(regenerateButton).toHaveText("Generate new pairing code");
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-expired.png` });
await regenerateButton.click();
await expect(loadingSpinner).toBeVisible();
await expect(qrCode).toBeVisible();
await expect(copyButton).toBeVisible();
expect(
await page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "start_pairing",
).length,
),
).toBe(2);
await waitForAnimations(page);
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-card.png` });
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();
await page.getByTestId("profile-popover-settings").click();
await page.getByTestId("settings-nav-mobile").click();
const card = page.getByTestId("mobile-pairing-card");
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(confirmation).toBeVisible();
await confirmation.getByTestId("deny-sas").click();
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(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");
});