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:
325
packages/mobile/src/__tests__/deep-links.test.ts
Normal file
325
packages/mobile/src/__tests__/deep-links.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
245
packages/mobile/src/__tests__/share.test.ts
Normal file
245
packages/mobile/src/__tests__/share.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user