Make relay opt-in when pairing devices (#2706)
* feat: make relay opt-in for device pairing * fix(relay): keep pairing state authoritative * fix(relay): refresh pairing state across transports * fix(onboard): honor explicit relay opt-out * fix(relay): surface transport startup failures * fix(pairing): gate offers for older daemons * test(cli): rely on real pairing transport coverage * fix(cli): report live relay without a pid file * fix(relay): synchronize config during daemon startup * fix(relay): preserve mutability for ignored overrides * fix(cli): use reachable daemon for pairing * fix(app): redirect invalid pair device routes * fix(pairing): support remote host device pairing Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals. * fix(pairing): validate pairing destinations Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state. * fix(pairing): render QR codes across platforms Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack. * fix(cli): pair from persisted relay state Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
This commit is contained in:
@@ -25,6 +25,7 @@ const test = base.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
@@ -129,6 +130,14 @@ const test = base.extend<{
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
relayConfigOutdatedDaemon: async ({}, provide) => {
|
||||
const daemon = await startOutdatedDaemon({
|
||||
daemonStatusRpcCapability: false,
|
||||
relayConfigCapability: false,
|
||||
});
|
||||
await provide(daemon);
|
||||
await daemon.close();
|
||||
},
|
||||
projectPickerFixture: async ({}, provide) => {
|
||||
const resource = await createProjectPickerFixture();
|
||||
const { fixture } = resource;
|
||||
|
||||
@@ -780,6 +780,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENABLED: "true",
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
|
||||
@@ -24,6 +24,8 @@ type OutdatedDaemonMessage = OutdatedDaemonReadyMessage | OutdatedDaemonErrorMes
|
||||
|
||||
export async function startOutdatedDaemon(options?: {
|
||||
desktopManaged?: boolean;
|
||||
daemonStatusRpcCapability?: boolean;
|
||||
relayConfigCapability?: boolean;
|
||||
}): Promise<OutdatedDaemon> {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -37,6 +39,8 @@ export async function startOutdatedDaemon(options?: {
|
||||
...process.env,
|
||||
E2E_METRO_PORT: metroPort,
|
||||
E2E_DESKTOP_MANAGED: options?.desktopManaged === true ? "1" : "0",
|
||||
E2E_DAEMON_STATUS_RPC_CAPABILITY: options?.daemonStatusRpcCapability === false ? "0" : "1",
|
||||
E2E_RELAY_CONFIG_CAPABILITY: options?.relayConfigCapability === false ? "0" : "1",
|
||||
},
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { once } from "node:events";
|
||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -10,10 +10,18 @@ export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
port: number;
|
||||
paseoHome: string;
|
||||
getPid(): number | undefined;
|
||||
restart(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface IsolatedHostDaemonOptions {
|
||||
mutableRelay?: {
|
||||
enabled: boolean;
|
||||
endpoint?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -76,7 +84,10 @@ async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function startIsolatedHostDaemon(serverId: string): Promise<IsolatedHostDaemon> {
|
||||
export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
): Promise<IsolatedHostDaemon> {
|
||||
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
|
||||
let port = await getAvailablePort();
|
||||
while (port === 6767 || port === primaryPort) port = await getAvailablePort();
|
||||
@@ -85,6 +96,26 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
|
||||
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"));
|
||||
if (options.mutableRelay) {
|
||||
const endpoint =
|
||||
options.mutableRelay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
daemon: {
|
||||
relay: {
|
||||
enabled: options.mutableRelay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
@@ -96,7 +127,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
PASEO_SERVER_ID: serverId,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
}),
|
||||
@@ -135,6 +166,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise<Isolate
|
||||
serverId,
|
||||
port,
|
||||
paseoHome,
|
||||
getPid: () => child.pid,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await stopProcess(child);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export type SettingsSection = keyof typeof SECTION_LABELS;
|
||||
type HostSection =
|
||||
| "projects"
|
||||
| "connections"
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
@@ -359,8 +360,7 @@ export async function expectProviderInstalledInSettings(
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
export async function expectHostNoDaemonLifecycleRow(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import { connectDaemonClient } from "./helpers/daemon-client-loader";
|
||||
import {
|
||||
closePairDeviceModal,
|
||||
declineRelay,
|
||||
enableRelayAndExpectFailure,
|
||||
enableRelayAndExpectOffer,
|
||||
expectDaemonPidUnchanged,
|
||||
expectPairingOffer,
|
||||
expectPairingDisconnected,
|
||||
expectRelayUpdateRequired,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
openPairDeviceFromHome,
|
||||
openRelaySecurityDocs,
|
||||
observePairingOfferRequests,
|
||||
prepareLocalPairingHost,
|
||||
preparePairingHost,
|
||||
reloadAndOpenPairDevice,
|
||||
retryRelayAndExpectFailure,
|
||||
switchPairDeviceToHost,
|
||||
} from "./helpers/pair-device";
|
||||
|
||||
interface RelayConfigDaemonClient {
|
||||
close(): Promise<void>;
|
||||
connect(): Promise<void>;
|
||||
patchDaemonConfig(config: { relay: { enabled: boolean } }): Promise<unknown>;
|
||||
}
|
||||
|
||||
test.describe("local device relay pairing", () => {
|
||||
let relayOffDaemon: IsolatedHostDaemon;
|
||||
let relayEnableDaemon: IsolatedHostDaemon;
|
||||
let relayOverrideDaemon: IsolatedHostDaemon;
|
||||
let relayEnabledDaemon: IsolatedHostDaemon;
|
||||
let invalidRelayDaemon: IsolatedHostDaemon;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
[
|
||||
relayOffDaemon,
|
||||
relayEnableDaemon,
|
||||
relayOverrideDaemon,
|
||||
relayEnabledDaemon,
|
||||
invalidRelayDaemon,
|
||||
] = await Promise.all([
|
||||
startIsolatedHostDaemon("pair-device-relay-off", {
|
||||
mutableRelay: { enabled: false },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-enable", {
|
||||
mutableRelay: { enabled: false },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-override"),
|
||||
startIsolatedHostDaemon("pair-device-relay-enabled", {
|
||||
mutableRelay: { enabled: true },
|
||||
}),
|
||||
startIsolatedHostDaemon("pair-device-relay-invalid", {
|
||||
mutableRelay: { enabled: false, endpoint: "invalid-endpoint" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await Promise.all([
|
||||
relayOffDaemon.close(),
|
||||
relayEnableDaemon.close(),
|
||||
relayOverrideDaemon.close(),
|
||||
relayEnabledDaemon.close(),
|
||||
invalidRelayDaemon.close(),
|
||||
]);
|
||||
});
|
||||
|
||||
test("asks for consent and creates no QR when declined", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await declineRelay(page);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
});
|
||||
|
||||
test("opens relay security documentation through the desktop opener", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await openRelaySecurityDocs(page);
|
||||
});
|
||||
|
||||
test("opens the same relay consent dialog from the home screen", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceFromHome(page);
|
||||
const modal = page.getByTestId("open-project-pair-device-modal");
|
||||
await expect(modal.getByText("Enable relay?", { exact: true })).toBeVisible();
|
||||
await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens relay consent in browser web", async ({ page }) => {
|
||||
await preparePairingHost(page, relayOffDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
});
|
||||
|
||||
test("shows an actionable error when the daemon disconnects", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-disconnect", {
|
||||
mutableRelay: { enabled: false },
|
||||
});
|
||||
try {
|
||||
await prepareLocalPairingHost(page, daemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await daemon.close();
|
||||
await expectPairingDisconnected(page);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("pairs a device through the selected remote host", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOffDaemon, [
|
||||
{
|
||||
serverId: relayEnabledDaemon.serverId,
|
||||
label: "Remote pairing host",
|
||||
endpoint: `127.0.0.1:${relayEnabledDaemon.port}`,
|
||||
},
|
||||
]);
|
||||
await switchPairDeviceToHost(page, relayEnabledDaemon.serverId);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("enables relay live and keeps the saved offer after reload", async ({ page }) => {
|
||||
const daemonPid = relayEnableDaemon.getPid();
|
||||
await prepareLocalPairingHost(page, relayEnableDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await enableRelayAndExpectOffer(page);
|
||||
expectDaemonPidUnchanged(daemonPid, relayEnableDaemon.getPid());
|
||||
await closePairDeviceModal(page);
|
||||
await reloadAndOpenPairDevice(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("keeps consent actionable when a launch override rejects enable", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayOverrideDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await enableRelayAndExpectFailure(page);
|
||||
await retryRelayAndExpectFailure(page);
|
||||
});
|
||||
|
||||
test("keeps consent actionable when relay transport startup fails", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, invalidRelayDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayConsent(page);
|
||||
await enableRelayAndExpectFailure(page, "Invalid host:port");
|
||||
await retryRelayAndExpectFailure(page, "Invalid host:port");
|
||||
});
|
||||
|
||||
test("shows an offer immediately when relay is already enabled", async ({ page }) => {
|
||||
await prepareLocalPairingHost(page, relayEnabledDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
});
|
||||
|
||||
test("refreshes the mounted offer when another client disables relay", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-live-config-push", {
|
||||
mutableRelay: { enabled: true },
|
||||
});
|
||||
const client = await connectDaemonClient<RelayConfigDaemonClient>({
|
||||
clientIdPrefix: "pair-device-live-config-push",
|
||||
port: daemon.port,
|
||||
});
|
||||
try {
|
||||
await prepareLocalPairingHost(page, daemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
await client.patchDaemonConfig({ relay: { enabled: false } });
|
||||
await expectRelayConsent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("asks for a host update when live relay config is unsupported", async ({
|
||||
page,
|
||||
relayConfigOutdatedDaemon,
|
||||
}) => {
|
||||
const expectNoUnsupportedRequest = observePairingOfferRequests(page, relayConfigOutdatedDaemon);
|
||||
await prepareLocalPairingHost(page, relayConfigOutdatedDaemon);
|
||||
await openPairDeviceModal(page);
|
||||
await expectRelayUpdateRequired(page);
|
||||
expectNoUnsupportedRequest();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
expectHostInjectMcpCard,
|
||||
expectHostActionCards,
|
||||
expectHostProvidersCard,
|
||||
expectHostNoLocalOnlyRows,
|
||||
expectHostNoDaemonLifecycleRow,
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
@@ -126,7 +126,7 @@ test.describe("Settings host page", () => {
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
test("host section does not render daemon lifecycle controls for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -136,8 +136,8 @@ test.describe("Settings host page", () => {
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
await expectHostNoLocalOnlyRows(page);
|
||||
// TODO: add a local-daemon fixture for positive daemon lifecycle coverage.
|
||||
await expectHostNoDaemonLifecycleRow(page);
|
||||
});
|
||||
|
||||
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user