* fix(desktop): restore direct LAN connections Desktop 0.3.0 routed remote WebSockets through the Electron main process, causing previously working macOS LAN connections to fail with EHOSTUNREACH. Revert custom direct headers and restore the renderer WebSocket path used before the regression. * fix(app): replace host connections by identity Keep one connection per stable ID when TLS or password settings change. This preserves the current-main upsert invariant while the custom-header rollback removes the unrelated transport feature.
219 lines
8.7 KiB
TypeScript
219 lines
8.7 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("button", { name: "Enabling...", exact: true })).toBeDisabled();
|
|
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("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);
|
|
}
|