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 });