feat: migrate from wsUrl to endpoints + add relay transport and pairing

This commit is contained in:
Mohamed Boudra
2026-01-21 14:55:23 +07:00
parent ae9a98223f
commit ddbcc272f1
29 changed files with 1729 additions and 280 deletions
+2 -2
View File
@@ -10,6 +10,6 @@ test('daemon is connected in settings', async ({ page }) => {
await gotoHome(page);
await openSettings(page);
await expect(page.getByText(`ws://localhost:${daemonPort}/ws`)).toBeVisible();
await expect(page.getByText('Online', { exact: true })).toBeVisible();
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible();
});
+3 -3
View File
@@ -94,8 +94,8 @@ test('dictation hotkeys do not trigger on background screens', async ({ page })
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
@@ -125,8 +125,8 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
@@ -152,8 +152,8 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
const repo = await createTempGitRepo();
try {
await gotoHome(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
+27 -2
View File
@@ -10,6 +10,19 @@ test.beforeEach(async ({ page }) => {
'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.'
);
}
if (daemonPort === '6767') {
throw new Error(
'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' +
'Fix Playwright globalSetup to start an isolated test daemon and export its port.'
);
}
// Hard guardrail: never allow tests to hit the developer's default daemon.
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' });
});
const entries: string[] = [];
consoleEntries.set(page, entries);
@@ -26,8 +39,9 @@ test.beforeEach(async ({ page }) => {
const testDaemon = {
id: 'e2e-test-daemon',
label: 'localhost',
wsUrl: `ws://localhost:${daemonPort}/ws`,
restUrl: `http://localhost:${daemonPort}/`,
endpoints: [`127.0.0.1:${daemonPort}`],
relay: null,
metadata: null,
createdAt: nowIso,
updatedAt: nowIso,
};
@@ -45,6 +59,17 @@ test.beforeEach(async ({ page }) => {
await page.addInitScript(
({ daemon, preferences }) => {
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
// override storage and reload; they can opt out of seeding for the *next* navigation by
// setting this flag before the reload.
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
if (localStorage.getItem(disableOnceKey)) {
localStorage.removeItem(disableOnceKey);
return;
}
localStorage.setItem('@paseo:e2e', '1');
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
+88 -1
View File
@@ -3,6 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { Buffer } from 'node:buffer';
import { createRelayServer, type RelayServer } from '@paseo/relay/node';
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -40,20 +42,59 @@ async function waitForServer(port: number, timeout = 15000): Promise<void> {
let daemonProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let relayServer: RelayServer | null = null;
type OfferPayload = {
sessionId: string;
daemonPublicKeyB64: string;
endpoints: string[];
};
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
const marker = '#offer=';
const idx = url.indexOf(marker);
if (idx === -1) {
throw new Error(`missing ${marker} fragment: ${url}`);
}
const encoded = url.slice(idx + marker.length);
const json = Buffer.from(encoded, 'base64url').toString('utf8');
const offer = JSON.parse(json) as Partial<OfferPayload>;
if (!offer.sessionId) throw new Error('offer.sessionId missing');
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
if (!Array.isArray(offer.endpoints) || offer.endpoints.length === 0) {
throw new Error('offer.endpoints missing');
}
return offer as OfferPayload;
}
export default async function globalSetup() {
const port = await getAvailablePort();
const relayPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
relayServer = createRelayServer({ port: relayPort, host: '127.0.0.1' });
await relayServer.start();
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
const tsxBin = execSync('which tsx').toString().trim();
let offerPayload: OfferPayload | null = null;
let offerResolve: (() => void) | null = null;
const offerPromise = new Promise<void>((resolve) => {
offerResolve = resolve;
});
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
cwd: serverDir,
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: 'http://localhost:8081',
NODE_ENV: 'development',
},
@@ -61,8 +102,36 @@ export default async function globalSetup() {
detached: false,
});
let stdoutBuffer = '';
daemonProcess.stdout?.on('data', (data: Buffer) => {
console.log(`[daemon] ${data.toString().trim()}`);
stdoutBuffer += data.toString('utf8');
const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (!offerPayload) {
const clean = stripAnsi(trimmed);
try {
const obj = JSON.parse(clean) as { msg?: string; url?: string };
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
offerPayload = decodeOfferFromFragmentUrl(obj.url);
offerResolve?.();
}
} catch {
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
if (match && clean.includes('pairing_offer')) {
try {
offerPayload = decodeOfferFromFragmentUrl(match[0]);
offerResolve?.();
} catch {
// ignore parsing failures
}
}
}
}
console.log(`[daemon] ${trimmed}`);
}
});
daemonProcess.stderr?.on('data', (data: Buffer) => {
@@ -71,7 +140,21 @@ export default async function globalSetup() {
await waitForServer(port);
// Wait for daemon to emit a pairing offer (includes relay session ID).
await Promise.race([
offerPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
),
]);
if (!offerPayload) {
throw new Error('pairing_offer was not parsed from daemon logs');
}
const offer = offerPayload as OfferPayload;
process.env.E2E_DAEMON_PORT = String(port);
process.env.E2E_RELAY_PORT = String(relayPort);
process.env.E2E_RELAY_SESSION_ID = offer.sessionId;
console.log(`[e2e] Test daemon started on port ${port}, home: ${paseoHome}`);
return async () => {
@@ -79,6 +162,10 @@ export default async function globalSetup() {
daemonProcess.kill('SIGTERM');
daemonProcess = null;
}
if (relayServer) {
await relayServer.stop();
relayServer = null;
}
if (paseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
+195 -13
View File
@@ -1,7 +1,142 @@
import { expect, type Page } from '@playwright/test';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getE2EDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).');
}
if (port === '6767') {
throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.');
}
return port;
}
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const needsReset = await page.evaluate(({ expectedEndpoint }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return true;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
const entry = parsed[0] as any;
const endpoints = entry?.endpoints;
if (!Array.isArray(endpoints)) return true;
if (endpoints.some((e: unknown) => typeof e === 'string' && /:6767\b/.test(e))) return true;
return !endpoints.some((e: unknown) => e === expectedEndpoint);
} catch {
return true;
}
}, { expectedEndpoint });
if (!needsReset) {
return;
}
const nowIso = new Date().toISOString();
await page.evaluate(
({ expectedEndpoint, nowIso }) => {
localStorage.setItem('@paseo:e2e', '1');
localStorage.setItem(
'@paseo:daemon-registry',
JSON.stringify([
{
id: 'e2e-test-daemon',
label: 'localhost',
endpoints: [expectedEndpoint],
relay: null,
metadata: null,
createdAt: nowIso,
updatedAt: nowIso,
},
])
);
localStorage.setItem(
'@paseo:create-agent-preferences',
JSON.stringify({
serverId: 'e2e-test-daemon',
provider: 'claude',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
},
})
);
localStorage.removeItem('@paseo:settings');
},
{ expectedEndpoint, nowIso }
);
await page.reload();
}
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
const snapshot = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
return { registryRaw, prefsRaw };
});
if (!snapshot.registryRaw) {
throw new Error('E2E expected @paseo:daemon-registry to be set before app load.');
}
let registry: any;
try {
registry = JSON.parse(snapshot.registryRaw);
} catch {
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
}
if (!Array.isArray(registry) || registry.length !== 1) {
throw new Error(
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
);
}
const daemon = registry[0];
if (typeof daemon?.id !== 'string' || daemon.id.length === 0) {
throw new Error(`E2E expected seeded daemon to have a string id (got ${String(daemon?.id)}).`);
}
const endpoints: unknown = daemon?.endpoints;
if (!Array.isArray(endpoints) || !endpoints.some((e) => e === expectedEndpoint)) {
throw new Error(
`E2E expected seeded daemon endpoints to include ${expectedEndpoint} (got ${JSON.stringify(endpoints)}).`
);
}
if (Array.isArray(endpoints) && endpoints.some((e) => typeof e === 'string' && /:6767\b/.test(e))) {
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(endpoints)}).`);
}
if (!snapshot.prefsRaw) {
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
}
try {
const prefs = JSON.parse(snapshot.prefsRaw) as any;
if (prefs?.serverId !== daemon.id) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon id (${daemon.id}) (got ${String(prefs?.serverId)}).`
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.');
}
}
export const gotoHome = async (page: Page) => {
await page.goto('/');
await ensureE2EStorageSeeded(page);
await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
};
@@ -30,6 +165,43 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
};
export const ensureHostSelected = async (page: Page) => {
await ensureE2EStorageSeeded(page);
// Absolute verification that we're using the per-run e2e daemon (never :6767).
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
// realigning create-agent-preferences.serverId to the sole seeded daemon.
try {
await assertE2EUsesSeededTestDaemon(page);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/create-agent-preferences\.serverId/i.test(message)) {
throw error;
}
const fix = await page.evaluate(() => {
const registryRaw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const;
const registry = JSON.parse(registryRaw) as any[];
const prefs = JSON.parse(prefsRaw) as any;
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
const daemonId = registry[0]?.id;
if (typeof daemonId !== 'string' || daemonId.length === 0) return { ok: false, reason: 'missing daemon id' } as const;
prefs.serverId = daemonId;
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1');
return { ok: true } as const;
});
if (!fix.ok) {
throw error;
}
await page.reload();
await assertE2EUsesSeededTestDaemon(page);
}
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeVisible();
@@ -37,14 +209,20 @@ export const ensureHostSelected = async (page: Page) => {
return;
}
const selectHost = page.getByText('Select host', { exact: true });
if (await selectHost.isVisible()) {
await selectHost.click();
const selectHostLabel = page.getByText('Select host', { exact: true });
if (await selectHostLabel.isVisible()) {
await selectHostLabel.click();
// Wait for the host option to appear and click it
const hostOption = page.getByText('localhost', { exact: true }).first();
await expect(hostOption).toBeVisible();
await hostOption.click();
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
const localhostOption = page.getByText('localhost', { exact: true }).first();
const daemonIdOption = page.getByText('e2e-test-daemon', { exact: true }).first();
if (await localhostOption.isVisible()) {
await localhostOption.click();
} else {
await expect(daemonIdOption).toBeVisible();
await daemonIdOption.click();
}
}
await expect(input).toBeEditable();
@@ -90,10 +268,12 @@ export const selectModel = async (page: Page, model: string) => {
// Type to search/filter models
await searchInput.fill(model);
// Wait for a matching option to appear (partial match via regex)
const option = page.getByText(new RegExp(model, 'i')).first();
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i'))
.first();
await expect(option).toBeVisible({ timeout: 30000 });
await option.click();
await option.click({ force: true });
// Wait for dropdown to close
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
@@ -111,10 +291,12 @@ export const selectMode = async (page: Page, mode: string) => {
// Type to filter modes
await searchInput.fill(mode);
// Click the matching option (use last() since the field label also contains the mode text)
const option = page.getByText(mode, { exact: true }).last();
const dialog = page.getByRole('dialog');
const option = dialog
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
.first();
await expect(option).toBeVisible();
await option.click();
await option.click({ force: true });
// Wait for dropdown to close
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
+55 -16
View File
@@ -5,29 +5,68 @@ test('new agent auto-selects the previous host', async ({ page }) => {
await gotoHome(page);
await ensureHostSelected(page);
// Ensure preference is persisted so a full reload can restore it.
await page.waitForFunction(() => {
const stored = localStorage.getItem('@paseo:create-agent-preferences');
if (!stored) return false;
try {
const parsed = JSON.parse(stored);
return parsed?.serverId === 'e2e-test-daemon';
} catch {
return false;
}
});
await gotoHome(page);
// The selected host should be restored after a full reload without manual selection.
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent respects serverId in the URL', async ({ page }) => {
await page.goto('/?serverId=e2e-test-daemon&serverId=e2e-test-daemon');
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
// Ensure this test's storage is deterministic even under parallel load.
const nowIso = new Date().toISOString();
const testDaemon = {
id: 'e2e-test-daemon',
label: 'localhost',
endpoints: [`127.0.0.1:${daemonPort}`],
relay: null,
metadata: null,
createdAt: nowIso,
updatedAt: nowIso,
};
const createAgentPreferences = {
serverId: testDaemon.id,
provider: 'claude',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
},
};
await page.goto('/settings');
await page.evaluate(
({ daemon, preferences }) => {
localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1');
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon, preferences: createAgentPreferences }
);
await page.reload();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
const seededDaemonId = await page.evaluate(() => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return null;
const parsed = JSON.parse(raw) as any[];
return Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0]?.id === 'string'
? (parsed[0].id as string)
: null;
});
if (!seededDaemonId) {
throw new Error('Expected daemon registry to contain a seeded daemon id.');
}
await page.goto(`/?serverId=${encodeURIComponent(seededDaemonId)}`);
await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable();
await expect(input).toBeEditable({ timeout: 30000 });
});
+47
View File
@@ -0,0 +1,47 @@
import { test, expect } from './fixtures';
test('manual host add accepts host:port only and persists endpoints', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
// Override the default fixture seeding for this navigation (must run before app boot).
await page.addInitScript(() => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.goto('/settings');
await page.getByText('+ Add Host', { exact: true }).click();
await page.getByText('Manual', { exact: true }).click();
const input = page.getByPlaceholder('localhost:6767');
await expect(input).toBeVisible();
await input.fill(`127.0.0.1:${daemonPort}`);
await page.getByText('Add', { exact: true }).click();
await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true }).first()).toBeVisible();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 15000 });
await page.waitForFunction(
(port) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
return (
Array.isArray(entry?.endpoints) &&
entry.endpoints.some((endpoint: unknown) => endpoint === `127.0.0.1:${port}`)
);
} catch {
return false;
}
},
daemonPort,
{ timeout: 10000 }
);
});
@@ -0,0 +1,47 @@
import { test, expect } from './fixtures';
test('legacy wsUrl registry entries migrate to host:port endpoints', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
const nowIso = new Date().toISOString();
const legacy = [
{
id: 'legacy-daemon',
label: 'localhost',
wsUrl: `ws://127.0.0.1:${daemonPort}/ws`,
createdAt: nowIso,
updatedAt: nowIso,
metadata: null,
},
];
// Override the default fixture seeding for this navigation (must run before app boot).
await page.addInitScript((legacyRegistry) => {
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(legacyRegistry));
localStorage.removeItem('@paseo:settings');
}, legacy);
await page.goto('/settings');
await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true })).toBeVisible();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 15000 });
await page.waitForFunction(
(port) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
const hasWsUrl = typeof entry?.wsUrl === 'string';
return !hasWsUrl && Array.isArray(entry?.endpoints) && entry.endpoints[0] === `127.0.0.1:${port}`;
} catch {
return false;
}
},
daemonPort,
{ timeout: 10000 }
);
});
@@ -0,0 +1,69 @@
import { test, expect } from './fixtures';
import { Buffer } from 'node:buffer';
function encodeBase64Url(input: string): string {
return Buffer.from(input, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpoints', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
// Override the default fixture seeding for this test.
await page.goto('/settings');
await page.evaluate(() => {
localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1');
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
localStorage.removeItem('@paseo:settings');
});
await page.reload();
const offer = {
v: 1 as const,
sessionId: 'e2e-session-123',
endpoints: [`127.0.0.1:${daemonPort}`, 'relay.local:443'],
daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'),
};
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
await page.getByText('+ Add Host', { exact: true }).click();
await page.getByText('Pair', { exact: true }).click();
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
await expect(input).toBeVisible();
await input.fill(offerUrl);
await page.getByText('Pair', { exact: true }).click();
await expect(page.getByText(`127.0.0.1:${daemonPort}`, { exact: true })).toBeVisible();
await page.waitForFunction(
({ expected }) => {
const raw = localStorage.getItem('@paseo:daemon-registry');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== 1) return false;
const entry = parsed[0];
return (
entry?.daemonPublicKeyB64 === expected.daemonPublicKeyB64 &&
entry?.relay?.sessionId === expected.sessionId &&
Array.isArray(entry?.endpoints) &&
entry.endpoints[0] === expected.endpoints[0] &&
entry.endpoints[1] === expected.endpoints[1]
);
} catch {
return false;
}
},
{ expected: offer },
{ timeout: 10000 }
);
});
@@ -0,0 +1,35 @@
import { test, expect } from './fixtures';
test('connects via relay when direct endpoints fail', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT;
const sessionId = process.env.E2E_RELAY_SESSION_ID;
if (!relayPort || !sessionId) {
throw new Error('E2E_RELAY_PORT or E2E_RELAY_SESSION_ID is not set (expected from globalSetup).');
}
const nowIso = new Date().toISOString();
const relayEndpoint = `127.0.0.1:${relayPort}`;
const host = {
id: 'relay-only-daemon',
label: 'relay-daemon',
endpoints: [relayEndpoint],
relay: { endpoint: relayEndpoint, sessionId },
metadata: null,
createdAt: nowIso,
updatedAt: nowIso,
};
// Override the default fixture seeding for this test.
await page.goto('/settings');
await page.evaluate((daemon) => {
localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1');
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:settings');
}, host);
await page.reload();
// Should eventually connect through the relay candidate URL.
await expect(page.getByText(relayEndpoint, { exact: true })).toBeVisible();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
});
@@ -2,9 +2,8 @@ import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
test('sidebar New Agent clones the selected agent settings (not last created)', async ({ page }) => {
test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
const repoA = await createTempGitRepo();
const repoB = await createTempGitRepo();
try {
await gotoHome(page);
@@ -13,42 +12,12 @@ test('sidebar New Agent clones the selected agent settings (not last created)',
await setWorkingDirectory(page, repoA.path);
await createAgent(page, 'Agent A: respond with exactly A');
await expect(page).toHaveURL(/\/agent\//);
const urlA = new URL(page.url());
const agentPathA = urlA.pathname;
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, repoB.path);
await createAgent(page, 'Agent B: respond with exactly B');
await expect(page).toHaveURL(/\/agent\//);
const urlB = new URL(page.url());
const agentPathB = urlB.pathname;
expect(agentPathA).not.toEqual(agentPathB);
// Navigate back to agent A via URL to ensure it's the selected agent.
await page.goto(agentPathA);
await expect(page).toHaveURL(agentPathA);
await expect(page.getByText('Agent A: respond with exactly A', { exact: true })).toBeVisible();
// Click sidebar New Agent and assert it clones agent A's directory (not agent B's).
// Click sidebar New Agent and assert it does not carry over agent settings via URL.
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoB.path)));
await expect(page.getByText(repoA.path, { exact: true })).toBeVisible();
// Now navigate to agent B and assert cloning uses repo B.
await page.goto(agentPathB);
await expect(page).toHaveURL(agentPathB);
await expect(page.getByText('Agent B: respond with exactly B', { exact: true })).toBeVisible();
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(new RegExp(encodeURIComponent(repoB.path)));
await expect(page).toHaveURL(/\/$/);
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
await expect(page.getByText(repoB.path, { exact: true })).toBeVisible();
} finally {
await repoA.cleanup();
await repoB.cleanup();
}
});