From 30d0553c6495afa6661953a7c22379120e0dcbd1 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 8 May 2026 01:53:58 -0700 Subject: [PATCH] feat(FN-3409): add shell regression matrix for mobile, desktop, and dashboa The merge completes a multi-step shell regression hardening effort spanning desktop, mobile, and dashboard entry points, adding 12 test files or test expansions to lock down the shell preload and native contracts. A companion fix exempts internal coordination tools (`list_agents`, `delegate_task`, e Fusion-Task-Id: FN-3409 --- docs/architecture.md | 8 ++++ packages/dashboard/README.md | 6 +++ .../app/__tests__/shell-native.test.ts | 23 ++++++++++- .../app/components/__tests__/App.test.tsx | 30 +++++++++++++- .../__tests__/ShellConnectionStatus.test.tsx | 1 + packages/desktop/README.md | 8 ++++ .../desktop/src/__tests__/preload.test.ts | 39 +++++++++++++++++- packages/mobile/README.md | 8 ++++ .../src/__tests__/connection-profiles.test.ts | 39 ++++++++++++++++++ .../mobile/src/__tests__/native-shell.test.ts | 40 ++++++++++++++++++- .../mobile/src/__tests__/qr-scanner.test.ts | 15 +++++++ 11 files changed, 209 insertions(+), 8 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4c38f8d4c..b49480c13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,6 +67,14 @@ Persistence ownership by host: These are shell-owned persistence layers, intentionally separate from Fusion project/global settings. +### Shell contract regression matrix (FN-3409) + +Cross-package automated tests now lock: +- **Mobile shell**: first-run remote onboarding inputs (QR/manual + optional token), saved-profile edit/switch, and restore-on-reinit persistence. +- **Desktop shell**: first-run/last-used mode restore, local-vs-remote startup behavior, and preload bridge channel compatibility for connection management. +- **Dashboard shell awareness**: canonical per-viewport connection-manager entry placement, browser-safe fallback (no shell-only controls), and host-context/native-helper resolution without ad-hoc window bridge access. +- **Sensitive data handling**: dashboard-facing native status surfaces expose profile label/origin metadata only; auth tokens are not surfaced. + ### High-level runtime diagram ```text diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index 3e1440743..4e19bda58 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -22,6 +22,12 @@ For dashboard chrome, use the centralized helper/component path: Desktop connection-management actions must go through `window.fusionAPI.openConnectionManager()` (wrapped by `shell-native.ts`), not ad-hoc renderer IPC calls. +Regression tests lock shell-aware placement and fallback behavior: +- desktop renders a single header connection-status entry point +- mobile renders a single More-sheet connection-status entry point +- browser/no-shell mode renders no shell-only controls and does not throw +- `ShellConnectionStatus` action control remains a non-submit button (`type="button"`) for form safety + ## Canonical dashboard host-context contract Dashboard host detection is centralized in `app/shell-host.ts` and exposed to React via `ShellHostProvider` (`app/context/ShellHostContext.tsx`). diff --git a/packages/dashboard/app/__tests__/shell-native.test.ts b/packages/dashboard/app/__tests__/shell-native.test.ts index 51df5f203..fc2f1bdd0 100644 --- a/packages/dashboard/app/__tests__/shell-native.test.ts +++ b/packages/dashboard/app/__tests__/shell-native.test.ts @@ -28,7 +28,7 @@ describe("shell-native", () => { expect(openConnectionManager).toHaveBeenCalledTimes(1); }); - it("uses mobile fusionShell capability and extracts metadata", async () => { + it("uses mobile fusionShell capability and extracts metadata without exposing auth token", async () => { const openConnectionManager = vi.fn(async () => undefined); const target = { ...window, @@ -37,7 +37,7 @@ describe("shell-native", () => { getState: vi.fn(async () => ({ host: "mobile-shell", activeProfileId: "p1", - profiles: [{ id: "p1", name: "Remote 1", serverUrl: "https://fusion.example.com/root", createdAt: "", updatedAt: "" }], + profiles: [{ id: "p1", name: "Remote 1", serverUrl: "https://fusion.example.com/root", authToken: "secret", createdAt: "", updatedAt: "" }], })), }, } as unknown as Window & typeof globalThis; @@ -51,9 +51,28 @@ describe("shell-native", () => { expect(result.profileId).toBe("p1"); expect(result.profileLabel).toBe("Remote 1"); expect(result.serverOrigin).toBe("https://fusion.example.com"); + expect((result as unknown as { authToken?: string }).authToken).toBeUndefined(); await expect(result.openConnectionManager()).resolves.toEqual({ ok: true }); }); + it("falls back to host connection id when shell state has no active profile", async () => { + const target = { + ...window, + fusionShell: { + openConnectionManager: vi.fn(async () => undefined), + getState: vi.fn(async () => ({ host: "mobile-shell", activeProfileId: null, profiles: [] })), + }, + } as unknown as Window & typeof globalThis; + + const result = await getShellConnectionNativeResult( + { kind: "mobile-shell", mode: "remote", connectionId: "host-profile", serverUrl: "https://remote.example.com/base" }, + target, + ); + + expect(result.profileId).toBe("host-profile"); + expect(result.serverOrigin).toBe("https://remote.example.com"); + }); + it("surfaces invocation failures", async () => { const target = { ...window, diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index c80e7a2ee..033b485ca 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -3686,7 +3686,8 @@ describe("App shell connection status plumbing", () => { expect(screen.queryByTestId("shell-connection-status-button")).toBeNull(); }); - it("renders shell connection status for mobile shell host", async () => { + it("renders shell connection status for mobile shell host in mobile More sheet only", async () => { + mockUseViewportMode.mockReturnValue("mobile"); mockShellHostContextValue.host = { kind: "mobile-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" }; mockGetShellConnectionNativeResult.mockResolvedValueOnce({ hostKind: "mobile-shell", @@ -3701,7 +3702,32 @@ describe("App shell connection status plumbing", () => { await waitFor(() => { expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host); - expect(screen.getByTestId("shell-connection-status-button")).toBeInTheDocument(); }); + + expect(screen.queryByTestId("shell-connection-status-button")).toBeNull(); + fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); + expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1); + expect(screen.getByTestId("mobile-more-shell-connection")).toBeInTheDocument(); + }); + + it("keeps desktop shell connection status in header and out of mobile sheet", async () => { + mockUseViewportMode.mockReturnValue("desktop"); + mockShellHostContextValue.host = { kind: "desktop-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" }; + mockGetShellConnectionNativeResult.mockResolvedValueOnce({ + hostKind: "desktop-shell", + available: true, + mode: "remote", + profileLabel: "Prod", + serverOrigin: "https://fusion.example.com", + openConnectionManager: async () => ({ ok: true }), + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1); + }); + + expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull(); }); }); diff --git a/packages/dashboard/app/components/__tests__/ShellConnectionStatus.test.tsx b/packages/dashboard/app/components/__tests__/ShellConnectionStatus.test.tsx index a7516f03e..e08a5879a 100644 --- a/packages/dashboard/app/components/__tests__/ShellConnectionStatus.test.tsx +++ b/packages/dashboard/app/components/__tests__/ShellConnectionStatus.test.tsx @@ -28,6 +28,7 @@ describe("ShellConnectionStatus", () => { expect(screen.getByText("Desktop")).toBeInTheDocument(); expect(screen.getByText("Prod ยท https://fusion.example.com")).toBeInTheDocument(); expect(screen.getByText("Switch server")).toBeInTheDocument(); + expect(screen.getByTestId("shell-connection-status-button")).toHaveAttribute("type", "button"); }); it("renders remote mobile mode summary", () => { diff --git a/packages/desktop/README.md b/packages/desktop/README.md index de0b42046..a0d31f0de 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -190,6 +190,14 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t All preload typings are declared in `src/types.d.ts`. +### Regression coverage locked by tests + +Desktop tests under `src/__tests__/` now explicitly lock: +- first-run mode projection and last-used mode restore (`choose`/`local`/`remote`) +- local runtime startup only when local mode is active (and no unexpected startup in remote mode) +- remote mode handoff persistence across relaunch behavior +- preload `fusionShell` bridge channel wiring (`shell:getState`, profile CRUD/switching, mode state, QR, and connection-manager open) + ## Module Integration Overview ```text diff --git a/packages/desktop/src/__tests__/preload.test.ts b/packages/desktop/src/__tests__/preload.test.ts index 21de1c90c..88762e15d 100644 --- a/packages/desktop/src/__tests__/preload.test.ts +++ b/packages/desktop/src/__tests__/preload.test.ts @@ -70,9 +70,44 @@ describe("preload", () => { expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:openConnectionManager"); }); - it("fusionShell subscribes and unsubscribes state listener", async () => { + it("fusionShell delegates connection-management methods to IPC", async () => { await importPreloadModule(); - const shell = getExposed<{ subscribe: (listener: (state: unknown) => void) => () => void }>("fusionShell"); + const shell = getExposed<{ + getState: () => Promise; + listProfiles: () => Promise; + saveProfile: (profile: { name: string; serverUrl: string; authToken?: string | null }) => Promise; + deleteProfile: (profileId: string) => Promise; + setActiveProfile: (profileId: string | null) => Promise; + getDesktopModeState: () => Promise; + setDesktopMode: (mode: "local" | "remote") => Promise; + startQrScan: () => Promise; + openConnectionManager: () => Promise; + subscribe: (listener: (state: unknown) => void) => () => void; + }>("fusionShell"); + + await shell?.getState(); + await shell?.listProfiles(); + await shell?.saveProfile({ name: "Prod", serverUrl: "https://fusion.example.com", authToken: "token" }); + await shell?.deleteProfile("p1"); + await shell?.setActiveProfile("p1"); + await shell?.getDesktopModeState(); + await shell?.setDesktopMode("local"); + await shell?.startQrScan(); + await shell?.openConnectionManager(); + + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:getState"); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:listProfiles"); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:saveProfile", { + name: "Prod", + serverUrl: "https://fusion.example.com", + authToken: "token", + }); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:deleteProfile", "p1"); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:setActiveProfile", "p1"); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:getDesktopModeState"); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:setDesktopMode", "local"); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:startQrScan"); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:openConnectionManager"); const unsubscribe = shell?.subscribe(() => undefined); expect(mocks.ipcRenderer.on).toHaveBeenCalledWith("shell:state", expect.any(Function)); diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 78b849f92..6dfbe5cbc 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -14,6 +14,14 @@ Mobile uses a shell-level onboarding flow for first-run connection setup before Native wrappers are isolated under `src/plugins/native-shell.ts`, `src/plugins/connection-profiles.ts`, and `src/plugins/qr-scanner.ts` so dashboard code never calls vendor-specific APIs directly. +### Regression coverage locked by tests + +`packages/mobile/src/__tests__/connection-profiles.test.ts`, `native-shell.test.ts`, and `qr-scanner.test.ts` now lock these contracts: +- first-run remote setup via QR/manual payloads (including optional auth token handling) +- saved-profile edit, active-profile switching, and persisted-state restore across module reinit/relaunch +- bridge reads (`getState`, `listProfiles`) plus connection-manager event dispatch +- malformed/empty QR payload handling and unavailable-scanner fallback behavior + ## Push Notifications `PushNotificationManager` supports two complementary notification channels: diff --git a/packages/mobile/src/__tests__/connection-profiles.test.ts b/packages/mobile/src/__tests__/connection-profiles.test.ts index 07cee3bc2..9e19e8f3d 100644 --- a/packages/mobile/src/__tests__/connection-profiles.test.ts +++ b/packages/mobile/src/__tests__/connection-profiles.test.ts @@ -42,6 +42,45 @@ describe("connection-profiles", () => { ); }); + it("updates existing saved profile by id", async () => { + const { saveShellProfile, listShellProfiles } = await import("../plugins/connection-profiles.js"); + + const profile = await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com", authToken: "old" }); + const updated = await saveShellProfile({ + id: profile.id, + name: "Production", + serverUrl: "https://fusion.example.com/root/", + authToken: "new", + }); + + expect(updated.id).toBe(profile.id); + expect(updated.name).toBe("Production"); + expect(updated.serverUrl).toBe("https://fusion.example.com/root"); + expect(updated.authToken).toBe("new"); + + const profiles = await listShellProfiles(); + expect(profiles).toHaveLength(1); + expect(profiles[0]?.id).toBe(profile.id); + }); + + it("switches active profile and restores state across module re-init", async () => { + const { saveShellProfile, setActiveShellProfile, loadShellProfiles } = await import("../plugins/connection-profiles.js"); + + const first = await saveShellProfile({ name: "Prod", serverUrl: "https://fusion.example.com" }); + const second = await saveShellProfile({ name: "Staging", serverUrl: "https://staging.example.com" }); + await setActiveShellProfile(first.id); + const switched = await setActiveShellProfile(second.id); + + expect(switched.activeProfileId).toBe(second.id); + expect(switched.profiles.find((profile) => profile.id === second.id)?.lastUsedAt).toBeTruthy(); + + vi.resetModules(); + const reloadedModule = await import("../plugins/connection-profiles.js"); + const reloaded = await reloadedModule.loadShellProfiles(); + expect(reloaded.activeProfileId).toBe(second.id); + expect(reloaded.profiles).toHaveLength(2); + }); + it("clears active profile when deleted", async () => { const { saveShellProfile, setActiveShellProfile, loadShellProfiles, deleteShellProfile } = await import("../plugins/connection-profiles.js"); diff --git a/packages/mobile/src/__tests__/native-shell.test.ts b/packages/mobile/src/__tests__/native-shell.test.ts index 2d4377c40..af362cf9c 100644 --- a/packages/mobile/src/__tests__/native-shell.test.ts +++ b/packages/mobile/src/__tests__/native-shell.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildMobileShellHandoff } from "../plugins/shell-handoff.js"; const state = { activeProfileId: null as string | null, @@ -8,12 +9,12 @@ const state = { vi.mock("../plugins/connection-profiles.js", () => ({ loadShellProfiles: vi.fn(async () => state), listShellProfiles: vi.fn(async () => state.profiles), - saveShellProfile: vi.fn(async (profile: { name: string; serverUrl: string }) => { + saveShellProfile: vi.fn(async (profile: { name: string; serverUrl: string; authToken?: string | null }) => { const saved = { id: "p1", name: profile.name, serverUrl: profile.serverUrl, - authToken: null, + authToken: profile.authToken ?? null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", lastUsedAt: null, @@ -68,6 +69,41 @@ describe("MobileNativeShellBridge", () => { (globalThis as { window?: Window }).window = originalWindow; }); + it("returns state and listProfiles from persisted storage", async () => { + const { MobileNativeShellBridge } = await import("../plugins/native-shell.js"); + const bridge = new MobileNativeShellBridge(scanner as never); + + const profile = await bridge.saveProfile({ name: "Prod", serverUrl: "https://fusion.example.com" }); + await bridge.setActiveProfile(profile.id); + + const stateSnapshot = await bridge.getState(); + const profiles = await bridge.listProfiles(); + + expect(stateSnapshot.host).toBe("mobile-shell"); + expect(stateSnapshot.activeProfileId).toBe(profile.id); + expect(profiles).toHaveLength(1); + expect(profiles[0]?.id).toBe(profile.id); + }); + + it("supports QR onboarding handoff with optional auth token", async () => { + scanner.scanConnection.mockResolvedValueOnce({ serverUrl: "https://fusion.example.com", authToken: "token-123" }); + const { MobileNativeShellBridge } = await import("../plugins/native-shell.js"); + const bridge = new MobileNativeShellBridge(scanner as never); + + const scan = await bridge.startQrScan(); + const saved = await bridge.saveProfile({ name: "QR Remote", serverUrl: scan.serverUrl, authToken: scan.authToken ?? null }); + const state = await bridge.setActiveProfile(saved.id); + const handoff = buildMobileShellHandoff(state); + + expect(scan).toEqual({ serverUrl: "https://fusion.example.com", authToken: "token-123" }); + expect(handoff.kind).toBe("remote-launch"); + if (handoff.kind === "remote-launch") { + const url = new URL(handoff.url); + expect(url.searchParams.get("profileId")).toBe(saved.id); + expect(url.searchParams.get("token")).toBe("token-123"); + } + }); + it("rejects desktop mode switch", async () => { const { MobileNativeShellBridge } = await import("../plugins/native-shell.js"); const bridge = new MobileNativeShellBridge(scanner as never); diff --git a/packages/mobile/src/__tests__/qr-scanner.test.ts b/packages/mobile/src/__tests__/qr-scanner.test.ts index 6c2598f52..d81c0ecaa 100644 --- a/packages/mobile/src/__tests__/qr-scanner.test.ts +++ b/packages/mobile/src/__tests__/qr-scanner.test.ts @@ -17,6 +17,21 @@ describe("qr-scanner", () => { expect(parsed).toEqual({ serverUrl: "https://fusion.example.com", authToken: "abc" }); }); + it("throws for empty payload", () => { + expect(() => parseQrConnectionPayload(" ")).toThrow("QR scan returned empty payload"); + }); + + it("throws for invalid payload", () => { + expect(() => parseQrConnectionPayload("not-a-fusion-connection")).toThrow( + "QR payload is not a valid Fusion connection payload", + ); + }); + + it("throws when scanner is unavailable", async () => { + const scanner = new QrScanner(); + await expect(scanner.scanConnection()).rejects.toThrow("QR scanner is not available on this platform"); + }); + it("uses adapter scanning", async () => { const scanner = new QrScanner({ scan: vi.fn(async () => "https://fusion.example.com") }); await expect(scanner.scanConnection()).resolves.toEqual({ serverUrl: "https://fusion.example.com", authToken: null });