feat(FN-1118): add mobile sharing and deep-link managers

- Implement ShareManager with native Capacitor sharing, web share fallback, and clipboard fallback plus typed share lifecycle events
- Implement DeepLinkManager with native appUrlOpen and browser hash listeners, URL parsing for fusion schemes and universal links, and error events
- Wire plugin exports and initializePlugins support for share/deepLinks options, and configure Capacitor iOS/Android fusion URL schemes
- Add comprehensive Vitest coverage for sharing and deep-link behavior and document usage in the mobile package README
This commit is contained in:
gsxdsm
2026-04-08 00:52:24 -07:00
parent b280bc711e
commit 0c55b9edfc
9 changed files with 1219 additions and 12 deletions

View File

@@ -0,0 +1,325 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type AppUrlOpenListener = (event: { url: string }) => void;
const mockState = vi.hoisted(() => {
const state: {
isNativePlatform: ReturnType<typeof vi.fn>;
addListener: ReturnType<typeof vi.fn>;
appListenerRemove: ReturnType<typeof vi.fn>;
appUrlOpenListener?: AppUrlOpenListener;
} = {
isNativePlatform: vi.fn(() => false),
addListener: vi.fn(),
appListenerRemove: vi.fn(async () => {}),
appUrlOpenListener: undefined,
};
state.addListener.mockImplementation(
async (eventName: string, callback: AppUrlOpenListener) => {
if (eventName === "appUrlOpen") {
state.appUrlOpenListener = callback;
}
return { remove: state.appListenerRemove };
},
);
return state;
});
vi.mock("@capacitor/core", () => ({
Capacitor: {
isNativePlatform: mockState.isNativePlatform,
},
}));
vi.mock("@capacitor/app", () => ({
App: {
addListener: mockState.addListener,
},
}));
import { DeepLinkManager } from "../plugins/deep-links.js";
const setupWindowMock = () => {
const listeners = new Map<string, (event: Event) => void>();
const location = { hash: "" };
const addEventListener = vi.fn((event: string, handler: (evt: Event) => void) => {
listeners.set(event, handler);
});
const removeEventListener = vi.fn((event: string, handler: (evt: Event) => void) => {
const existing = listeners.get(event);
if (existing === handler) {
listeners.delete(event);
}
});
vi.stubGlobal("window", {
location,
addEventListener,
removeEventListener,
});
return {
listeners,
location,
addEventListener,
removeEventListener,
};
};
describe("DeepLinkManager", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.isNativePlatform.mockReturnValue(false);
mockState.appUrlOpenListener = undefined;
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("URL parsing: fusion://task/FN-123", () => {
const manager = new DeepLinkManager();
const payload = manager.handleUrl("fusion://task/FN-123");
expect(payload).toEqual({
url: "fusion://task/FN-123",
target: "task",
taskId: "FN-123",
});
});
it("URL parsing: fusion://project/my-project", () => {
const manager = new DeepLinkManager();
const payload = manager.handleUrl("fusion://project/my-project");
expect(payload).toEqual({
url: "fusion://project/my-project",
target: "project",
projectId: "my-project",
});
});
it("URL parsing: fusion://project/my-project/task/FN-123", () => {
const manager = new DeepLinkManager();
const payload = manager.handleUrl("fusion://project/my-project/task/FN-123");
expect(payload).toEqual({
url: "fusion://project/my-project/task/FN-123",
target: "project",
projectId: "my-project",
taskId: "FN-123",
});
});
it("URL parsing: fusion://settings", () => {
const manager = new DeepLinkManager();
const payload = manager.handleUrl("fusion://settings");
expect(payload).toEqual({
url: "fusion://settings",
target: "settings",
});
});
it("URL parsing: fusion://agents", () => {
const manager = new DeepLinkManager();
const payload = manager.handleUrl("fusion://agents");
expect(payload).toEqual({
url: "fusion://agents",
target: "agents",
});
});
it("URL parsing: fusion://task/FN-123?tab=workflow", () => {
const manager = new DeepLinkManager();
const payload = manager.handleUrl("fusion://task/FN-123?tab=workflow");
expect(payload).toEqual({
url: "fusion://task/FN-123?tab=workflow",
target: "task",
taskId: "FN-123",
params: { tab: "workflow" },
});
});
it("URL parsing: universal link https://app.fusion.dev/?task=FN-123", () => {
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
const payload = manager.handleUrl("https://app.fusion.dev/?task=FN-123");
expect(payload).toEqual({
url: "https://app.fusion.dev/?task=FN-123",
taskId: "FN-123",
projectId: undefined,
target: undefined,
});
});
it("URL parsing: universal link from unrecognized host emits deeplink:error", () => {
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
const onError = vi.fn();
manager.on("deeplink:error", onError);
const payload = manager.handleUrl("https://untrusted.example/?task=FN-123");
expect(payload).toBeNull();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://untrusted.example/?task=FN-123",
error: expect.any(Error),
}),
);
});
it("URL parsing: malformed URL emits deeplink:error and handleUrl returns null", () => {
const manager = new DeepLinkManager();
const onError = vi.fn();
manager.on("deeplink:error", onError);
const payload = manager.handleUrl("not a url");
expect(payload).toBeNull();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
url: "not a url",
error: expect.any(Error),
}),
);
});
it("URL parsing: original URL preserved in url field of all payloads", () => {
const manager = new DeepLinkManager({ universalLinkHosts: ["app.fusion.dev"] });
const customPayload = manager.handleUrl("fusion://task/FN-321");
const universalPayload = manager.handleUrl(
"https://app.fusion.dev/?task=FN-654&target=task",
);
expect(customPayload?.url).toBe("fusion://task/FN-321");
expect(universalPayload?.url).toBe(
"https://app.fusion.dev/?task=FN-654&target=task",
);
});
it("Native listener: initialize() registers App.addListener(appUrlOpen) on native platform", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new DeepLinkManager();
await manager.initialize();
expect(mockState.addListener).toHaveBeenCalledWith("appUrlOpen", expect.any(Function));
});
it("Native listener: incoming appUrlOpen event is parsed and emits deeplink:received", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new DeepLinkManager();
const onReceived = vi.fn();
manager.on("deeplink:received", onReceived);
await manager.initialize();
mockState.appUrlOpenListener?.({ url: "fusion://task/FN-900" });
expect(onReceived).toHaveBeenCalledWith({
url: "fusion://task/FN-900",
target: "task",
taskId: "FN-900",
});
});
it("Native listener: initialize() does NOT register App listener on non-native platform", async () => {
mockState.isNativePlatform.mockReturnValue(false);
const manager = new DeepLinkManager();
setupWindowMock();
await manager.initialize();
expect(mockState.addListener).not.toHaveBeenCalled();
});
it("Browser listener: initialize() registers hashchange listener on non-native platform", async () => {
const windowMock = setupWindowMock();
const manager = new DeepLinkManager();
await manager.initialize();
expect(windowMock.addEventListener).toHaveBeenCalledWith("hashchange", expect.any(Function));
});
it("Browser listener: #deeplink=fusion://task/FN-123 hash change triggers deeplink:received event", async () => {
const windowMock = setupWindowMock();
const manager = new DeepLinkManager();
const onReceived = vi.fn();
manager.on("deeplink:received", onReceived);
await manager.initialize();
windowMock.location.hash = `#deeplink=${encodeURIComponent("fusion://task/FN-123")}`;
const hashHandler = windowMock.listeners.get("hashchange");
hashHandler?.(new Event("hashchange"));
expect(onReceived).toHaveBeenCalledWith({
url: "fusion://task/FN-123",
target: "task",
taskId: "FN-123",
});
});
it("Lifecycle: initialize() is idempotent (second call is no-op)", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new DeepLinkManager();
await manager.initialize();
await manager.initialize();
expect(mockState.addListener).toHaveBeenCalledTimes(1);
});
it("Lifecycle: getScheme() returns the configured scheme string", () => {
const manager = new DeepLinkManager({ scheme: "fusion-custom://" });
expect(manager.getScheme()).toBe("fusion-custom://");
});
it("Lifecycle: destroy() removes Capacitor App listener via handle.remove()", async () => {
mockState.isNativePlatform.mockReturnValue(true);
const manager = new DeepLinkManager();
await manager.initialize();
await manager.destroy();
expect(mockState.appListenerRemove).toHaveBeenCalledTimes(1);
});
it("Lifecycle: destroy() removes browser hashchange listener", async () => {
const windowMock = setupWindowMock();
const manager = new DeepLinkManager();
await manager.initialize();
await manager.destroy();
expect(windowMock.removeEventListener).toHaveBeenCalledWith(
"hashchange",
expect.any(Function),
);
});
it("Lifecycle: destroy() removes all EventEmitter listeners", async () => {
const manager = new DeepLinkManager();
const onReceived = vi.fn();
manager.on("deeplink:received", onReceived);
await manager.destroy();
manager.emit("deeplink:received", { url: "fusion://task/FN-001" });
expect(onReceived).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,245 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockState = vi.hoisted(() => ({
isNativePlatform: vi.fn(() => false),
nativeShare: vi.fn(),
}));
vi.mock("@capacitor/core", () => ({
Capacitor: {
isNativePlatform: mockState.isNativePlatform,
},
}));
vi.mock("@capacitor/share", () => ({
Share: {
share: mockState.nativeShare,
},
}));
import { ShareManager } from "../plugins/share.js";
describe("ShareManager", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState.isNativePlatform.mockReturnValue(false);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("Native share: Share.share() called with correct title, text (truncated), and deep link URL", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
const manager = new ShareManager();
const longDescription = "x".repeat(250);
await manager.shareTask({ id: "FN-123", description: longDescription });
expect(mockState.nativeShare).toHaveBeenCalledWith({
title: "Task FN-123",
text: `${"x".repeat(200)}...`,
url: "fusion://task/FN-123",
});
});
it("Native share: emits share:success and returns true when share completes with activityType", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockResolvedValue({ activityType: "mail" });
const manager = new ShareManager();
const onSuccess = vi.fn();
manager.on("share:success", onSuccess);
const result = await manager.shareTask({
id: "FN-123",
title: "Test Task",
description: "Short description",
});
expect(result).toBe(true);
expect(onSuccess).toHaveBeenCalledWith({ taskId: "FN-123" });
});
it("Native share: emits share:cancelled and returns false when activityType is undefined", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockResolvedValue({});
const manager = new ShareManager();
const onCancelled = vi.fn();
manager.on("share:cancelled", onCancelled);
const result = await manager.shareTask({
id: "FN-124",
description: "Short description",
});
expect(result).toBe(false);
expect(onCancelled).toHaveBeenCalledWith({ taskId: "FN-124" });
});
it("Native share: emits share:error and returns false when Share.share() throws", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockRejectedValue(new Error("share failed"));
const manager = new ShareManager();
const onError = vi.fn();
manager.on("share:error", onError);
const result = await manager.shareTask({
id: "FN-125",
description: "Short description",
});
expect(result).toBe(false);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
taskId: "FN-125",
error: expect.any(Error),
}),
);
});
it("Native share: uses task.title when provided, falls back to Task {id} when title is undefined", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
const manager = new ShareManager();
await manager.shareTask({ id: "FN-126", title: "Explicit Title", description: "Body" });
await manager.shareTask({ id: "FN-127", description: "Body" });
expect(mockState.nativeShare).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ title: "Explicit Title" }),
);
expect(mockState.nativeShare).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ title: "Task FN-127" }),
);
});
it("Native share: constructs correct deep link URL fusion://task/{id}", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
const manager = new ShareManager();
await manager.shareTask({ id: "KB-777", description: "Task body" });
expect(mockState.nativeShare).toHaveBeenCalledWith(
expect.objectContaining({ url: "fusion://task/KB-777" }),
);
});
it("Native share: respects custom deepLinkBaseUrl option", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
const manager = new ShareManager({ deepLinkBaseUrl: "fusion://project/demo/task/" });
await manager.shareTask({ id: "FN-201", description: "Task body" });
expect(mockState.nativeShare).toHaveBeenCalledWith(
expect.objectContaining({ url: "fusion://project/demo/task/FN-201" }),
);
});
it("Native share: truncates description longer than 200 chars and appends ...", async () => {
mockState.isNativePlatform.mockReturnValue(true);
mockState.nativeShare.mockResolvedValue({ activityType: "copy" });
const manager = new ShareManager();
await manager.shareTask({ id: "FN-202", description: "a".repeat(201) });
expect(mockState.nativeShare).toHaveBeenCalledWith(
expect.objectContaining({ text: `${"a".repeat(200)}...` }),
);
});
it("Web fallback: calls navigator.share() when not native but Web Share API is available", async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
share,
clipboard: {
writeText: vi.fn(),
},
});
const manager = new ShareManager();
const result = await manager.shareTask({ id: "FN-203", description: "Body" });
expect(result).toBe(true);
expect(share).toHaveBeenCalledWith({
title: "Task FN-203",
text: "Body",
url: "fusion://task/FN-203",
});
});
it("Web fallback: emits share:cancelled when navigator.share() rejects with AbortError", async () => {
const share = vi.fn().mockRejectedValue({ name: "AbortError" });
vi.stubGlobal("navigator", {
share,
clipboard: {
writeText: vi.fn(),
},
});
const manager = new ShareManager();
const onCancelled = vi.fn();
manager.on("share:cancelled", onCancelled);
const result = await manager.shareTask({ id: "FN-204", description: "Body" });
expect(result).toBe(false);
expect(onCancelled).toHaveBeenCalledWith({ taskId: "FN-204" });
});
it("Clipboard fallback: copies deep link URL via navigator.clipboard.writeText() when neither native nor Web Share API", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
clipboard: {
writeText,
},
});
const manager = new ShareManager();
const result = await manager.shareTask({ id: "FN-205", description: "Body" });
expect(result).toBe(true);
expect(writeText).toHaveBeenCalledWith("fusion://task/FN-205");
});
it("Lifecycle: initialize() succeeds and sets initialized state", async () => {
const manager = new ShareManager();
await manager.initialize();
expect((manager as any).initialized).toBe(true);
});
it("Lifecycle: initialize() is idempotent (second call is no-op)", async () => {
const manager = new ShareManager();
await manager.initialize();
await expect(manager.initialize()).resolves.toBeUndefined();
expect((manager as any).initialized).toBe(true);
});
it("Lifecycle: getDeepLinkBaseUrl() returns the configured URL", () => {
const manager = new ShareManager({ deepLinkBaseUrl: "fusion://custom/" });
expect(manager.getDeepLinkBaseUrl()).toBe("fusion://custom/");
});
it("Lifecycle: destroy() removes all listeners and resets initialized state", async () => {
const manager = new ShareManager();
const onSuccess = vi.fn();
manager.on("share:success", onSuccess);
await manager.initialize();
await manager.destroy();
expect((manager as any).initialized).toBe(false);
manager.emit("share:success", { taskId: "FN-206" });
expect(onSuccess).not.toHaveBeenCalled();
});
});