diff --git a/packages/app/e2e/helpers/startup-dsl.ts b/packages/app/e2e/helpers/startup-dsl.ts new file mode 100644 index 000000000..90f3d71b2 --- /dev/null +++ b/packages/app/e2e/helpers/startup-dsl.ts @@ -0,0 +1,232 @@ +import { expect, type Page } from "../fixtures"; +import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; + +const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once"; +const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; +const REGISTRY_KEY = "@paseo:daemon-registry"; +const E2E_KEY = "@paseo:e2e"; + +type SavedHostInput = { + serverId: string; + label: string; + endpoint: string; +}; + +export function startupScenario(page: Page) { + return new StartupScenario(page); +} + +class StartupScenario { + private readonly page: Page; + private savedHosts: SavedHostInput[] = []; + private desktopBridge = false; + private blockedEndpointPorts = new Set(); + private viewport: { width: number; height: number } | null = null; + + constructor(page: Page) { + this.page = page; + } + + withMobileViewport(): this { + this.viewport = { width: 390, height: 844 }; + return this; + } + + withSavedHost(input: SavedHostInput): this { + this.savedHosts.push(input); + const port = input.endpoint.match(/:(\d+)$/)?.[1]; + if (port) { + this.blockedEndpointPorts.add(port); + } + return this; + } + + withPendingDesktopDaemon(): this { + this.desktopBridge = true; + return this; + } + + withBlockedPort(port: string): this { + this.blockedEndpointPorts.add(port); + return this; + } + + async openRoot(): Promise { + await this.prepare(); + await this.page.goto("/"); + return new StartupAssertions(this.page); + } + + async openHostWorkspace(input: { + serverId: string; + workspaceId: string; + }): Promise { + await this.prepare(); + await this.page.goto( + `/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`, + ); + return new StartupAssertions(this.page); + } + + private async prepare(): Promise { + if (this.viewport) { + await this.page.setViewportSize(this.viewport); + } + + if (this.desktopBridge) { + await installPendingDesktopBridge(this.page); + } + + for (const port of this.blockedEndpointPorts) { + await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => { + await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." }); + }); + } + + if (this.savedHosts.length === 0) { + return; + } + + // Let the shared fixture create its seed nonce, then opt out of that seed for + // the next navigation so this scenario owns the stored host registry. + await this.page.goto("/"); + const nowIso = new Date().toISOString(); + const registry = this.savedHosts.map((host) => + buildStoredHost({ + serverId: host.serverId, + endpoint: host.endpoint, + label: host.label, + nowIso, + }), + ); + const firstHost = registry[0]; + if (!firstHost) { + throw new Error("Expected at least one startup test host."); + } + const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId); + + await this.page.evaluate( + ({ keys, registry, createAgentPreferences }) => { + const nonce = localStorage.getItem(keys.seedNonce); + if (!nonce) { + throw new Error("Expected e2e seed nonce before overriding startup registry."); + } + + localStorage.setItem(keys.e2e, "1"); + localStorage.setItem(keys.registry, JSON.stringify(registry)); + localStorage.setItem( + "@paseo:create-agent-preferences", + JSON.stringify(createAgentPreferences), + ); + localStorage.setItem(keys.disableDefaultSeedOnce, nonce); + }, + { + keys: { + disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY, + e2e: E2E_KEY, + registry: REGISTRY_KEY, + seedNonce: SEED_NONCE_KEY, + }, + registry, + createAgentPreferences, + }, + ); + } +} + +class StartupAssertions { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async expectsReconnectWelcome(): Promise { + await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 }); + await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible(); + await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible(); + await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible(); + await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible(); + await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0); + return this; + } + + async expectsNoSavedHostStatus(input: { label: string }): Promise { + await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0); + await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0); + await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0); + return this; + } + + async expectsNoLocalServerStartupCopy(): Promise { + await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0); + await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount( + 0, + ); + return this; + } + + async expectsDesktopDaemonStartup(): Promise { + await expect(this.page.getByText("Starting local server...", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + return this; + } + + async expectsSidebarHidden(): Promise { + await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0); + await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0); + return this; + } +} + +async function installPendingDesktopBridge(page: Page): Promise { + await page.addInitScript(() => { + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + platform: "darwin", + invoke: async (command: string) => { + if (command === "start_desktop_daemon") { + await new Promise(() => { + // Keep the daemon in the startup phase until the test ends. + }); + } + if (command === "desktop_daemon_status") { + return { + serverId: "srv_desktop_pending", + status: "starting", + listen: null, + hostname: null, + pid: null, + home: "", + version: null, + desktopManaged: true, + error: null, + }; + } + if (command === "desktop_daemon_logs") { + return { logPath: "", contents: "" }; + } + return null; + }, + getPendingOpenProject: async () => null, + events: { on: async () => () => undefined }, + }; + }); +} + +function buildStoredHost(input: { + serverId: string; + endpoint: string; + label: string; + nowIso: string; +}) { + return buildSeededHost(input); +} + +function buildStoredCreateAgentPreferences(serverId: string) { + return buildCreateAgentPreferences(serverId); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/app/e2e/startup-loading.spec.ts b/packages/app/e2e/startup-loading.spec.ts new file mode 100644 index 000000000..0e91ca968 --- /dev/null +++ b/packages/app/e2e/startup-loading.spec.ts @@ -0,0 +1,63 @@ +import { test } from "./fixtures"; +import { startupScenario } from "./helpers/startup-dsl"; + +function getE2EDaemonPort(): string { + const port = process.env.E2E_DAEMON_PORT; + if (!port) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + return port; +} + +function getE2EServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +test.describe("Startup loading presentation", () => { + test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => { + const startup = await startupScenario(page) + .withMobileViewport() + .withSavedHost({ + serverId: "srv_unreachable_mobile", + label: "Dev", + endpoint: "127.0.0.1:45678", + }) + .openRoot(); + + await startup.expectsReconnectWelcome(); + await startup.expectsNoSavedHostStatus({ label: "Dev" }); + await startup.expectsNoLocalServerStartupCopy(); + }); + + test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({ + page, + }) => { + const startup = await startupScenario(page) + .withPendingDesktopDaemon() + .withBlockedPort(getE2EDaemonPort()) + .openRoot(); + + await startup.expectsDesktopDaemonStartup(); + await startup.expectsSidebarHidden(); + }); + + test("host-route refresh does not render route chrome around the bootstrap splash", async ({ + page, + }) => { + const serverId = getE2EServerId(); + const startup = await startupScenario(page) + .withPendingDesktopDaemon() + .withBlockedPort(getE2EDaemonPort()) + .openHostWorkspace({ + serverId, + workspaceId: "workspace-1", + }); + + await startup.expectsDesktopDaemonStartup(); + await startup.expectsSidebarHidden(); + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index cc202fe1b..fd1f26624 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -817,8 +817,10 @@ function AppWithSidebar({ children }: { children: ReactNode }) { const pathname = usePathname(); const params = useGlobalSearchParams<{ open?: string | string[] }>(); const hosts = useHosts(); + const storeReady = useStoreReady(); const activeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); - const shouldShowAppChrome = activeServerId !== null; + const shouldShowAppChrome = + storeReady && activeServerId !== null && hosts.some((host) => host.serverId === activeServerId); useEffect(() => { if (!activeServerId || hosts.length === 0) { diff --git a/packages/app/src/components/host-route-bootstrap-boundary.tsx b/packages/app/src/components/host-route-bootstrap-boundary.tsx index 363e0e850..af28a3655 100644 --- a/packages/app/src/components/host-route-bootstrap-boundary.tsx +++ b/packages/app/src/components/host-route-bootstrap-boundary.tsx @@ -1,13 +1,15 @@ import type { ReactNode } from "react"; import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout"; +import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { StartupSplashScreen } from "@/screens/startup-splash-screen"; export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) { const storeReady = useStoreReady(); const bootstrapState = useHostRuntimeBootstrapState(); + const isDesktop = shouldUseDesktopDaemon(); if (!storeReady) { - return ; + return ; } return <>{children}; diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 56677f5ee..52e740590 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -5,12 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; -import { - getHostRuntimeStore, - isHostRuntimeConnected, - useHostRuntimeSnapshot, - useHosts, -} from "@/runtime/host-runtime"; +import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; import { Button } from "@/components/ui/button"; @@ -94,39 +89,6 @@ const styles = StyleSheet.create((theme) => ({ actionTextPrimary: { color: theme.colors.accentForeground, }, - hostList: { - width: "100%", - maxWidth: 420, - marginTop: theme.spacing[6], - borderTopWidth: 1, - borderTopColor: theme.colors.border, - paddingTop: theme.spacing[4], - gap: theme.spacing[2], - }, - hostRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - }, - hostLabel: { - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - hostStatus: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - hostStatusError: { - color: theme.colors.destructive, - fontSize: theme.fontSize.sm, - }, setupLink: { flexDirection: "row", alignItems: "center", @@ -189,50 +151,6 @@ function useAnyHostOnline(serverIds: string[]): string | null { ); } -function HostStatusRow({ serverId, label }: { serverId: string; label: string }) { - const { theme } = useUnistyles(); - const snapshot = useHostRuntimeSnapshot(serverId); - const status = snapshot?.connectionStatus ?? "connecting"; - const lastError = snapshot?.lastError ?? null; - - let dotColor: string; - let statusText: string; - let isError = false; - - switch (status) { - case "online": - dotColor = theme.colors.success; - statusText = "Online"; - break; - case "connecting": - case "idle": - dotColor = theme.colors.foregroundMuted; - statusText = "Connecting…"; - break; - case "offline": - dotColor = theme.colors.foregroundMuted; - statusText = "Offline"; - break; - case "error": - dotColor = theme.colors.destructive; - statusText = lastError ? lastError.slice(0, 40) : "Connection error"; - isError = true; - break; - } - - return ( - - - - {label} - - - {statusText} - - - ); -} - export interface WelcomeScreenProps { onHostAdded?: (profile: HostProfile) => void; } @@ -306,7 +224,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { }, ]; - const showHostList = hosts.length > 0 && !anyOnlineServerId; + const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId; return ( @@ -322,11 +240,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { - Welcome to Paseo - {showHostList ? ( - Connecting to your hosts… + {isConnectingToSavedHosts ? ( + Connecting… ) : ( <> + Welcome to Paseo Connect your computer to get started {isNative ? ( - {showHostList && ( - - {hosts.map((host) => ( - - ))} - - )} -