diff --git a/packages/app/maestro/assert-online.yaml b/packages/app/maestro/assert-online.yaml new file mode 100644 index 000000000..c64a12e23 --- /dev/null +++ b/packages/app/maestro/assert-online.yaml @@ -0,0 +1,39 @@ +appId: com.moboudra.paseo +--- +- runFlow: + when: + visible: "Open in.*Paseo.*\\?" + commands: + - tapOn: "Open" + +- tapOn: + text: "Open" + optional: true + +- runFlow: + when: + visible: "Development servers" + commands: + - tapOn: + text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*" + optional: true + - tapOn: + text: "Enter URL manually" + optional: true + +- runFlow: + when: + visible: "Continue" + commands: + - tapOn: "Continue" + +- runFlow: + when: + visible: "Go home" + commands: + - tapOn: + point: "50%,20%" + +- extendedWaitUntil: + visible: ".*Online.*" + timeout: 90000 diff --git a/packages/app/maestro/flows/dev-client.yaml b/packages/app/maestro/flows/dev-client.yaml new file mode 100644 index 000000000..36f29bce7 --- /dev/null +++ b/packages/app/maestro/flows/dev-client.yaml @@ -0,0 +1,27 @@ +# NOTE: Maestro requires a config section for every flow file, even when used +# via `runFlow`. Keep this aligned with the dev build application id. +appId: com.moboudra.paseo +--- +# Reusable flow to handle Expo dev client screens after launch. +# Unlike `flows/launch.yaml`, this does not assert any particular app route. + +# Handle dev launcher - tap localhost server (dev builds only) +- tapOn: + text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*" + optional: true + +# Dismiss dev menu intro modal +- runFlow: + when: + visible: "Continue" + commands: + - tapOn: "Continue" + +# Dismiss dev menu if it opens +- runFlow: + when: + visible: "Go home" + commands: + - tapOn: + point: "50%,20%" + diff --git a/packages/app/maestro/flows/launch.yaml b/packages/app/maestro/flows/launch.yaml index 5a2fe37c2..83fa11387 100644 --- a/packages/app/maestro/flows/launch.yaml +++ b/packages/app/maestro/flows/launch.yaml @@ -1,6 +1,6 @@ # NOTE: Maestro requires a config section for every flow file, even when used # via `runFlow`. Keep this aligned with the dev build application id. -appId: com.moboudra.paseo.dev +appId: com.moboudra.paseo --- # Reusable flow to handle dev client screens after launchApp # Works with both release builds (no-ops) and dev builds diff --git a/packages/app/maestro/launch-clear.yaml b/packages/app/maestro/launch-clear.yaml new file mode 100644 index 000000000..29a0f5512 --- /dev/null +++ b/packages/app/maestro/launch-clear.yaml @@ -0,0 +1,6 @@ +appId: com.moboudra.paseo +--- +- launchApp: + clearState: true +- runFlow: flows/dev-client.yaml + diff --git a/packages/app/maestro/relay-pairing.yaml b/packages/app/maestro/relay-pairing.yaml index c956921d3..6cb4dee0b 100644 --- a/packages/app/maestro/relay-pairing.yaml +++ b/packages/app/maestro/relay-pairing.yaml @@ -1,4 +1,4 @@ -appId: com.moboudra.paseo.dev +appId: com.moboudra.paseo --- - launchApp: clearState: true @@ -6,13 +6,13 @@ appId: com.moboudra.paseo.dev - tapOn: "Paste pairing link" -- tapOn: - text: "https://app.paseo.sh/#offer=..." - eraseText - inputText: ${PAIRING_OFFER_URL} - tapOn: "Pair" -- assertVisible: "New Agent" +- extendedWaitUntil: + visible: "New agent" + timeout: 30000 # Confirm we actually connected (relay is the only available connection). - assertVisible: "Online" diff --git a/packages/app/maestro/relay-then-direct.yaml b/packages/app/maestro/relay-then-direct.yaml index 41c0ff032..a0202f2de 100644 --- a/packages/app/maestro/relay-then-direct.yaml +++ b/packages/app/maestro/relay-then-direct.yaml @@ -1,15 +1,14 @@ -appId: com.moboudra.paseo.dev +appId: com.moboudra.paseo --- - launchApp: clearState: true - runFlow: flows/launch.yaml - tapOn: "Paste pairing link" -- tapOn: - text: "https://app.paseo.sh/#offer=..." - eraseText - inputText: ${PAIRING_OFFER_URL} -- tapOn: "Pair" +- tapOn: + point: "75%,36%" - extendedWaitUntil: visible: "New agent" diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 98ebb0874..ea430729f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -297,15 +297,24 @@ function OfferLinkListener({ }: { upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise; }) { + const router = useRouter(); + useEffect(() => { let cancelled = false; const handleUrl = (url: string | null) => { if (!url) return; if (!url.includes("#offer=")) return; - void upsertDaemonFromOfferUrl(url).catch((error) => { - if (cancelled) return; - console.warn("[Linking] Failed to import pairing offer", error); - }); + void upsertDaemonFromOfferUrl(url) + .then((profile) => { + if (cancelled) return; + const serverId = (profile as any)?.serverId; + if (typeof serverId !== "string" || !serverId) return; + router.replace({ pathname: "/", params: { serverId } }); + }) + .catch((error) => { + if (cancelled) return; + console.warn("[Linking] Failed to import pairing offer", error); + }); }; void Linking.getInitialURL().then(handleUrl).catch(() => undefined); @@ -318,7 +327,7 @@ function OfferLinkListener({ cancelled = true; subscription.remove(); }; - }, [upsertDaemonFromOfferUrl]); + }, [router, upsertDaemonFromOfferUrl]); return null; } diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 9254993f6..46cb35d37 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -124,11 +124,14 @@ export function PairLinkModal({ visible, onClose, onSaved }: PairLinkModalProps) Pairing link - + Cancel void handleSave()} disabled={isSaving} testID="pair-link-submit" + accessible + accessibilityRole="button" + accessibilityLabel="Pair" > diff --git a/packages/relay/src/crypto.test.ts b/packages/relay/src/crypto.test.ts index 8c57720fc..d02d1826f 100644 --- a/packages/relay/src/crypto.test.ts +++ b/packages/relay/src/crypto.test.ts @@ -149,7 +149,7 @@ describe("crypto", () => { const daemonKeyPair = await generateKeyPair(); const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey); - // QR code would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] } + // QR code would contain: { serverId, daemonPubKeyB64, relay: { endpoint } } // === CLIENT SIDE (scans QR) === const clientKeyPair = await generateKeyPair(); diff --git a/packages/relay/src/e2e.test.ts b/packages/relay/src/e2e.test.ts index 0106ddfa7..672eeada7 100644 --- a/packages/relay/src/e2e.test.ts +++ b/packages/relay/src/e2e.test.ts @@ -25,18 +25,18 @@ describe("E2E Relay with E2EE", () => { }); it("full flow: daemon and client exchange encrypted messages through relay", async () => { - const sessionId = "test-session-" + Date.now(); + const serverId = "test-session-" + Date.now(); // === DAEMON SIDE === // Generate keypair (public key goes in QR) const daemonKeyPair = await generateKeyPair(); const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey); - // QR would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] } + // QR would contain: { serverId, daemonPubKeyB64, relay: { endpoint } } // Daemon connects to relay as "server" role const daemonWs = new WebSocket( - `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server` + `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server` ); await new Promise((resolve, reject) => { @@ -59,7 +59,7 @@ describe("E2E Relay with E2EE", () => { // Client connects to relay as "client" role const clientWs = new WebSocket( - `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client` + `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client` ); await new Promise((resolve, reject) => { @@ -149,7 +149,7 @@ describe("E2E Relay with E2EE", () => { }); it("relay only sees opaque bytes after handshake", async () => { - const sessionId = "opaque-test-" + Date.now(); + const serverId = "opaque-test-" + Date.now(); // Setup keys const daemonKeyPair = await generateKeyPair(); @@ -172,10 +172,10 @@ describe("E2E Relay with E2EE", () => { // Connect both const daemonWs = new WebSocket( - `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=server` + `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=server` ); const clientWs = new WebSocket( - `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${sessionId}&role=client` + `ws://127.0.0.1:${TEST_PORT}/ws?serverId=${serverId}&role=client` ); await Promise.all([ @@ -212,7 +212,7 @@ describe("E2E Relay with E2EE", () => { }); it("wrong key cannot decrypt", async () => { - const sessionId = "wrong-key-test-" + Date.now(); + const serverId = "wrong-key-test-" + Date.now(); // Setup - daemon and client with correct keys const daemonKeyPair = await generateKeyPair(); diff --git a/packages/relay/src/live-relay.e2e.test.ts b/packages/relay/src/live-relay.e2e.test.ts index 56b794f4c..62a57be1d 100644 --- a/packages/relay/src/live-relay.e2e.test.ts +++ b/packages/relay/src/live-relay.e2e.test.ts @@ -35,9 +35,9 @@ describe("Live relay (relay.paseo.sh) E2E", () => { liveIt("bridges encrypted traffic end-to-end", { timeout: 45_000 }, async () => { await withRetry( async () => { - const sessionId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const serverUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=server`; - const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(sessionId)}&role=client`; + const serverId = `live-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const serverUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=server`; + const clientUrl = `${RELAY_BASE_URL}/ws?serverId=${encodeURIComponent(serverId)}&role=client`; // === Key setup === const daemonKeyPair = await generateKeyPair();