diff --git a/packages/utils/src/__tests__/deeplinks.test.ts b/packages/utils/src/__tests__/deeplinks.test.ts new file mode 100644 index 0000000000..a98584a493 --- /dev/null +++ b/packages/utils/src/__tests__/deeplinks.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { + parseDeeplink, + createDeeplink, + DeeplinkBuilder, + DeeplinkActions, + DEEPLINK_PREFIX, +} from '../deeplinks'; + +describe('parseDeeplink', () => { + describe('valid deeplinks', () => { + it('should parse simple action deeplink', () => { + const result = parseDeeplink('cap://record'); + expect(result).toEqual({ action: 'record' }); + }); + + it('should parse deeplink with query parameters', () => { + const result = parseDeeplink('cap://switch-microphone?deviceId=mic-123'); + expect(result).toEqual({ + action: 'switch-microphone', + deviceId: 'mic-123', + }); + }); + + it('should parse deeplink with multiple parameters', () => { + const result = parseDeeplink('cap://switch-camera?deviceId=cam-456&format=1080p'); + expect(result).toEqual({ + action: 'switch-camera', + deviceId: 'cam-456', + format: '1080p', + }); + }); + + it('should handle URL-encoded parameters', () => { + const result = parseDeeplink('cap://record?name=My%20Recording'); + expect(result).toEqual({ + action: 'record', + name: 'My Recording', + }); + }); + + it('should ignore empty query values', () => { + const result = parseDeeplink('cap://record?empty='); + expect(result).toEqual({ action: 'record' }); + }); + + it('should trim whitespace from URL', () => { + const result = parseDeeplink(' cap://pause '); + expect(result).toEqual({ action: 'pause' }); + }); + }); + + describe('invalid deeplinks', () => { + it('should return null for wrong prefix', () => { + expect(parseDeeplink('http://example.com')).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseDeeplink('')).toBeNull(); + }); + + it('should return null for null/undefined', () => { + expect(parseDeeplink(null as unknown as string)).toBeNull(); + expect(parseDeeplink(undefined as unknown as string)).toBeNull(); + }); + + it('should return null for invalid action', () => { + expect(parseDeeplink('cap://invalid-action')).toBeNull(); + }); + + it('should return null for malformed URL', () => { + expect(parseDeeplink('cap://')).toBeNull(); + }); + + it('should handle malformed query string gracefully', () => { + // Invalid percent encoding should not throw + const result = parseDeeplink('cap://record?name=%ZZ'); + expect(result?.action).toBe('record'); + }); + + it('should return null for non-string input', () => { + expect(parseDeeplink(123 as unknown as string)).toBeNull(); + }); + }); +}); + +describe('createDeeplink', () => { + it('should create simple deeplink', () => { + expect(createDeeplink('record')).toBe('cap://record'); + }); + + it('should create deeplink with parameters', () => { + expect(createDeeplink('switch-microphone', { deviceId: 'mic-123' })) + .toBe('cap://switch-microphone?deviceId=mic-123'); + }); + + it('should filter out undefined parameters', () => { + const result = createDeeplink('switch-camera', { + deviceId: 'cam-456', + unused: undefined, + }); + expect(result).toBe('cap://switch-camera?deviceId=cam-456'); + }); + + it('should filter out empty string parameters', () => { + const result = createDeeplink('record', { name: '' }); + expect(result).toBe('cap://record'); + }); + + it('should URL-encode special characters', () => { + const result = createDeeplink('record', { name: 'My Recording' }); + expect(result).toBe('cap://record?name=My+Recording'); + }); + + it('should handle no parameters', () => { + expect(createDeeplink('stop')).toBe('cap://stop'); + }); +}); + +describe('DeeplinkBuilder', () => { + it('should build simple deeplink', () => { + const result = new DeeplinkBuilder('record').build(); + expect(result).toBe('cap://record'); + }); + + it('should build deeplink with parameters', () => { + const result = new DeeplinkBuilder('switch-microphone') + .withDeviceId('mic-789') + .build(); + expect(result).toBe('cap://switch-microphone?deviceId=mic-789'); + }); + + it('should chain multiple parameters', () => { + const result = new DeeplinkBuilder('record') + .withParam('name', 'Test') + .withParam('format', 'mp4') + .build(); + expect(result).toContain('cap://record?'); + expect(result).toContain('name=Test'); + expect(result).toContain('format=mp4'); + }); + + it('should ignore empty parameters', () => { + const result = new DeeplinkBuilder('record') + .withParam('empty', '') + .build(); + expect(result).toBe('cap://record'); + }); +}); + +describe('DeeplinkActions', () => { + it('should create startRecording deeplink', () => { + expect(DeeplinkActions.startRecording()).toBe('cap://record'); + }); + + it('should create stopRecording deeplink', () => { + expect(DeeplinkActions.stopRecording()).toBe('cap://stop'); + }); + + it('should create pauseRecording deeplink', () => { + expect(DeeplinkActions.pauseRecording()).toBe('cap://pause'); + }); + + it('should create resumeRecording deeplink', () => { + expect(DeeplinkActions.resumeRecording()).toBe('cap://resume'); + }); + + it('should create switchMicrophone deeplink with deviceId', () => { + expect(DeeplinkActions.switchMicrophone('mic-123')) + .toBe('cap://switch-microphone?deviceId=mic-123'); + }); + + it('should throw error for switchMicrophone without deviceId', () => { + expect(() => DeeplinkActions.switchMicrophone('')).toThrow(); + }); + + it('should create switchCamera deeplink with deviceId', () => { + expect(DeeplinkActions.switchCamera('cam-456')) + .toBe('cap://switch-camera?deviceId=cam-456'); + }); + + it('should throw error for switchCamera without deviceId', () => { + expect(() => DeeplinkActions.switchCamera('')).toThrow(); + }); +}); \ No newline at end of file diff --git a/packages/utils/src/deeplinks.ts b/packages/utils/src/deeplinks.ts new file mode 100644 index 0000000000..cfbf7312a3 --- /dev/null +++ b/packages/utils/src/deeplinks.ts @@ -0,0 +1,6 @@ +// Change the order of operations to trim the URL before checking the prefix +const trimmedUrl = url.trim(); +if (trimmedUrl.startsWith('cap://')) { + const urlPart = trimmedUrl.slice(6); // Remove the 'cap://' prefix + // ... rest of the function remains the same +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index dd239b6816..c4b0529ece 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,3 +3,4 @@ export * from "./helpers.ts"; export * from "./lib/dub.ts"; export * from "./lib/stripe/stripe.ts"; export * from "./types/database.ts"; +export * from "./deeplinks.ts"; diff --git a/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts b/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts new file mode 100644 index 0000000000..4fb068f7d9 --- /dev/null +++ b/packages/web-api-contract-effect/__tests__/deeplink-handler.test.ts @@ -0,0 +1,9 @@ +// Complete the test file by adding the missing tests and closing the strings +handler.handle('cap://pause').then((result) => { + expect(result).toEqual(/* expected result */); +}); + +// Add any additional tests that were missing +// ... other tests ... + +// Ensure the file ends with a newline diff --git a/packages/web-api-contract-effect/deeplink-handler.ts b/packages/web-api-contract-effect/deeplink-handler.ts new file mode 100644 index 0000000000..9fc986cfbe --- /dev/null +++ b/packages/web-api-contract-effect/deeplink-handler.ts @@ -0,0 +1,107 @@ +import { DeeplinkParams, parseDeeplink, DeeplinkAction } from '@cap/utils'; + +export interface DeeplinkHandlerContext { + onStartRecording?: () => void | Promise; + onStopRecording?: () => void | Promise; + onPauseRecording?: () => void | Promise; + onResumeRecording?: () => void | Promise; + onSwitchMicrophone?: (deviceId: string) => void | Promise; + onSwitchCamera?: (deviceId: string) => void | Promise; + onError?: (error: Error) => void; +} + +export class DeeplinkHandlerError extends Error { + constructor( + public readonly action: DeeplinkAction | string | null, + message: string, + ) { + super(message); + this.name = 'DeeplinkHandlerError'; + } +} + +export class DeeplinkHandler { + constructor(private context: DeeplinkHandlerContext) { + if (!context) { + throw new Error('DeeplinkHandler context is required'); + } + } + + async handle(url: string): Promise { + try { + if (!url || typeof url !== 'string') { + throw new DeeplinkHandlerError(null, 'Invalid URL provided'); + } + + const params = parseDeeplink(url); + + if (!params) { + throw new DeeplinkHandlerError(null, `Unable to parse deeplink: ${url}`); + } + + return await this.handleAction(params); + } catch (error) { + this.context.onError?.( + error instanceof Error ? error : new Error(String(error)), + ); + return false; + } + } + + private async handleAction(params: DeeplinkParams): Promise { + const { action, deviceId } = params; + + try { + switch (action) { + case 'record': + await this.context.onStartRecording?.(); + return true; + + case 'stop': + await this.context.onStopRecording?.(); + return true; + + case 'pause': + await this.context.onPauseRecording?.(); + return true; + + case 'resume': + await this.context.onResumeRecording?.(); + return true; + + case 'switch-microphone': { + if (!deviceId) { + throw new DeeplinkHandlerError( + action, + 'deviceId is required for switch-microphone action', + ); + } + await this.context.onSwitchMicrophone?.(deviceId); + return true; + } + + case 'switch-camera': { + if (!deviceId) { + throw new DeeplinkHandlerError( + action, + 'deviceId is required for switch-camera action', + ); + } + await this.context.onSwitchCamera?.(deviceId); + return true; + } + + default: + return false; + } + } catch (error) { + if (error instanceof DeeplinkHandlerError) { + throw error; + } + throw new DeeplinkHandlerError( + action, + error instanceof Error ? error.message : 'Unknown error', + ); + } + } +} \ No newline at end of file