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
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<unknown>;
|
||||
listProfiles: () => Promise<unknown>;
|
||||
saveProfile: (profile: { name: string; serverUrl: string; authToken?: string | null }) => Promise<unknown>;
|
||||
deleteProfile: (profileId: string) => Promise<void>;
|
||||
setActiveProfile: (profileId: string | null) => Promise<unknown>;
|
||||
getDesktopModeState: () => Promise<unknown>;
|
||||
setDesktopMode: (mode: "local" | "remote") => Promise<unknown>;
|
||||
startQrScan: () => Promise<unknown>;
|
||||
openConnectionManager: () => Promise<void>;
|
||||
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));
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user