mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* Keep pairing QR code within compact tile * Move pairing screenshot after viewport assertion * test(pairing): verify the complete QR code on Android The native regression failed with the fixed-size SVG because the code was clipped at both screen edges. Verify the complete square and visible margins through the Android app, and keep browser geometry assertions in the pairing helper. Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com> --------- Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
244 lines
9.8 KiB
TypeScript
244 lines
9.8 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
import type { IsolatedHostDaemon } from "./isolated-host-daemon";
|
|
import type { OutdatedDaemon } from "./daemon-update";
|
|
import { openSettings, gotoAppShell } from "./app";
|
|
import {
|
|
openSettingsHost,
|
|
openSettingsHostSection,
|
|
seedSavedSettingsHosts,
|
|
selectSettingsHost,
|
|
} from "./settings";
|
|
import { expectAppRoute } from "./route-assertions";
|
|
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
|
|
|
interface PairingHostInput {
|
|
serverId: string;
|
|
label: string;
|
|
endpoint: string;
|
|
}
|
|
|
|
export async function prepareLocalPairingHost(
|
|
page: Page,
|
|
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
|
additionalHosts: PairingHostInput[] = [],
|
|
): Promise<void> {
|
|
await page.addInitScript((localServerId) => {
|
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
|
platform: "darwin",
|
|
invoke: async (command: string) => {
|
|
if (command === "desktop_daemon_status") {
|
|
return {
|
|
serverId: localServerId,
|
|
status: "running",
|
|
listen: null,
|
|
hostname: null,
|
|
pid: null,
|
|
home: "",
|
|
version: null,
|
|
desktopManaged: true,
|
|
error: null,
|
|
};
|
|
}
|
|
if (command === "get_desktop_settings") {
|
|
return {
|
|
releaseChannel: "stable",
|
|
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
getPendingOpenProject: async () => null,
|
|
events: { on: async () => () => undefined },
|
|
opener: {
|
|
openUrl: async (url: string) => {
|
|
localStorage.setItem("@paseo:e2e-opened-url", url);
|
|
},
|
|
},
|
|
};
|
|
}, daemon.serverId);
|
|
|
|
await preparePairingHost(page, daemon, additionalHosts);
|
|
}
|
|
|
|
export async function preparePairingHost(
|
|
page: Page,
|
|
daemon: IsolatedHostDaemon | OutdatedDaemon,
|
|
additionalHosts: PairingHostInput[] = [],
|
|
): Promise<void> {
|
|
await seedSavedSettingsHosts(page, [
|
|
{
|
|
serverId: daemon.serverId,
|
|
label: "Local pairing host",
|
|
endpoint: "port" in daemon ? `127.0.0.1:${daemon.port}` : daemon.endpoint,
|
|
},
|
|
...additionalHosts,
|
|
]);
|
|
await gotoAppShell(page);
|
|
await openSettings(page);
|
|
await openSettingsHost(page, daemon.serverId);
|
|
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
|
await openSettingsHostSection(page, daemon.serverId, "pair-device");
|
|
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
|
|
}
|
|
|
|
export async function openPairDeviceModal(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: /Pair a device/ }).click();
|
|
await expect(page.getByTestId("host-page-pair-device-card")).toBeVisible();
|
|
}
|
|
|
|
export async function expectRelayConsent(page: Page): Promise<void> {
|
|
const modal = page.getByTestId("host-page-pair-device-card");
|
|
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
|
|
await expect(modal.getByText(/end-to-end encrypted/)).toBeVisible();
|
|
await expect(modal.getByRole("link", { name: "Read how Paseo relay works" })).toBeVisible();
|
|
await expect(modal.getByText(/TCP, Tailscale, or another VPN/)).toBeVisible();
|
|
await expect(modal.getByRole("img", { name: "Pairing QR code" })).toHaveCount(0);
|
|
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
|
|
await expect(modal.getByRole("button", { name: "Copy", exact: true })).toHaveCount(0);
|
|
}
|
|
|
|
export async function declineRelay(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: "Not now", exact: true }).click();
|
|
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
|
|
}
|
|
|
|
export async function enableRelayAndExpectOffer(page: Page): Promise<void> {
|
|
const enableButton = page.getByRole("button", { name: "Enable relay", exact: true });
|
|
await enableButton.click();
|
|
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
|
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
|
await expect(page.getByText("Enable relay?", { exact: true })).toHaveCount(0);
|
|
}
|
|
|
|
export async function expectPairingOffer(page: Page): Promise<void> {
|
|
await expect(page.getByRole("img", { name: "Pairing QR code" })).toBeVisible();
|
|
await expect(page.getByRole("textbox", { name: "Pairing link" })).toHaveValue(/#offer=/);
|
|
await expect(
|
|
page.getByRole("alert").filter({
|
|
hasText: "Treat this pairing link like a password. Anyone with it can access this daemon.",
|
|
}),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
|
}
|
|
|
|
export async function closePairDeviceModal(page: Page): Promise<void> {
|
|
await page
|
|
.getByTestId("host-page-pair-device-card")
|
|
.getByRole("button", { name: "Close", exact: true })
|
|
.click();
|
|
await expect(page.getByTestId("host-page-pair-device-card")).toHaveCount(0);
|
|
}
|
|
|
|
export async function reloadAndOpenPairDevice(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
|
if (!nonce) throw new Error("Expected e2e seed nonce");
|
|
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
|
});
|
|
await page.reload();
|
|
await openPairDeviceModal(page);
|
|
}
|
|
|
|
export async function enableRelayAndExpectFailure(
|
|
page: Page,
|
|
expectedError = "Relay is controlled by a daemon launch override",
|
|
): Promise<void> {
|
|
await page.getByRole("button", { name: "Enable relay", exact: true }).click();
|
|
const modal = page.getByTestId("host-page-pair-device-card");
|
|
await expect(modal.getByRole("alert")).toContainText(expectedError);
|
|
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
|
|
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
|
|
}
|
|
|
|
export async function retryRelayAndExpectFailure(
|
|
page: Page,
|
|
expectedError = "Relay is controlled by a daemon launch override",
|
|
): Promise<void> {
|
|
const modal = page.getByTestId("host-page-pair-device-card");
|
|
await modal.getByRole("button", { name: "Retry", exact: true }).click();
|
|
await expect(modal.getByRole("alert")).toContainText(expectedError);
|
|
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeEnabled();
|
|
}
|
|
|
|
export async function openPairDeviceFromHome(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
|
|
if (!nonce) throw new Error("Expected e2e seed nonce");
|
|
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
|
});
|
|
await page.goto("/open-project");
|
|
await page.getByTestId("open-project-pair-device").click();
|
|
await expect(page.getByTestId("open-project-pair-device-modal")).toBeVisible();
|
|
}
|
|
|
|
export async function expectRelayUpdateRequired(page: Page): Promise<void> {
|
|
const modal = page.getByTestId("host-page-pair-device-card");
|
|
await expect(
|
|
modal.getByText("Update the host to enable relay from Paseo Desktop."),
|
|
).toBeVisible();
|
|
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toHaveCount(0);
|
|
await expect(modal.getByRole("textbox", { name: "Pairing link" })).toHaveCount(0);
|
|
}
|
|
|
|
export function observePairingOfferRequests(page: Page, daemon: OutdatedDaemon): () => void {
|
|
let requestCount = 0;
|
|
const port = daemon.endpoint.slice(daemon.endpoint.lastIndexOf(":") + 1);
|
|
page.on("websocket", (socket) => {
|
|
if (!socket.url().includes(`:${port}`)) return;
|
|
socket.on("framesent", ({ payload }) => {
|
|
const message = typeof payload === "string" ? payload : payload.toString();
|
|
if (message.includes('"type":"daemon.get_pairing_offer.request"')) requestCount += 1;
|
|
});
|
|
});
|
|
return () => expect(requestCount).toBe(0);
|
|
}
|
|
|
|
export async function expectPairingDisconnected(page: Page): Promise<void> {
|
|
const modal = page.getByTestId("host-page-pair-device-card");
|
|
await expect(modal.getByRole("alert")).toContainText("Host is not connected");
|
|
await expect(modal.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
|
}
|
|
|
|
export async function switchPairDeviceToHost(page: Page, serverId: string): Promise<void> {
|
|
await selectSettingsHost(page, serverId);
|
|
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "pair-device"));
|
|
await expect(page.getByTestId("host-page-pair-device-row")).toBeVisible();
|
|
}
|
|
|
|
export async function openRelaySecurityDocs(page: Page): Promise<void> {
|
|
await page.getByRole("link", { name: "Read how Paseo relay works" }).click();
|
|
await expect
|
|
.poll(() => page.evaluate(() => localStorage.getItem("@paseo:e2e-opened-url")))
|
|
.toBe("https://paseo.sh/docs/security");
|
|
}
|
|
|
|
export function expectDaemonPidUnchanged(
|
|
before: number | undefined,
|
|
after: number | undefined,
|
|
): void {
|
|
expect(before).toBeDefined();
|
|
expect(after).toBe(before);
|
|
}
|
|
|
|
export async function expectCompletePairingQr(page: Page): Promise<void> {
|
|
const qr = page.getByRole("img", { name: "Pairing QR code" });
|
|
await qr.scrollIntoViewIfNeeded();
|
|
// IntersectionObserver rounds fractional sheet/scroll coordinates.
|
|
await expect(qr).toBeInViewport({ ratio: 0.999 });
|
|
const bounds = await qr.evaluate((element) => {
|
|
const tile = element.parentElement;
|
|
if (!tile) throw new Error("Pairing QR tile is missing");
|
|
const qrRect = element.getBoundingClientRect();
|
|
const tileRect = tile.getBoundingClientRect();
|
|
return {
|
|
qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
|
|
tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
|
|
};
|
|
});
|
|
expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
|
|
expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
|
|
expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
|
|
expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
|
|
}
|