Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions packages/utils/src/__tests__/deeplinks.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
136 changes: 136 additions & 0 deletions packages/utils/src/deeplinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
export type DeeplinkAction =
| 'record'
| 'stop'
| 'pause'
| 'resume'
| 'switch-microphone'
| 'switch-camera';

export interface DeeplinkParams {
action: DeeplinkAction;
deviceId?: string;
[key: string]: string | undefined;
}

export const DEEPLINK_PREFIX = 'cap://';

function isValidAction(action: string): action is DeeplinkAction {
const validActions: DeeplinkAction[] = [
'record',
'stop',
'pause',
'resume',
'switch-microphone',
'switch-camera',
];
return validActions.includes(action as DeeplinkAction);
}

export function parseDeeplink(url: string): DeeplinkParams | null {
try {
if (!url || typeof url !== 'string') {
return null;
}

const trimmedUrl = url.trim();

if (!trimmedUrl.startsWith(DEEPLINK_PREFIX)) {
return null;
}

const urlPart = trimmedUrl.slice(DEEPLINK_PREFIX.length);

if (!urlPart) {
return null;
}

const [pathSegment, queryString] = urlPart.split('?');
const segments = pathSegment.split('/').filter(Boolean);
const action = segments[0];

if (!action || !isValidAction(action)) {
return null;
}

const queryParams: Record<string, string> = {};

if (queryString) {
try {
new URLSearchParams(queryString).forEach((value, key) => {
if (value) {
queryParams[key] = value;
}
});
} catch {

}
}

return {
action,
...queryParams,
};
} catch {
return null;
}
}

export function createDeeplink(
action: DeeplinkAction,
params?: Record<string, string | undefined>,
): string {
let url = `${DEEPLINK_PREFIX}${action}`;

const validParams = Object.entries(params || {})
.filter(([, value]) => value !== undefined && value !== '')
.reduce((acc, [key, value]) => {
acc[key] = value as string;
return acc;
}, {} as Record<string, string>);

if (Object.keys(validParams).length > 0) {
const searchParams = new URLSearchParams(validParams);
url += `?${searchParams.toString()}`;
}

return url;
}

export class DeeplinkBuilder {
private action: DeeplinkAction;
private params: Record<string, string> = {};

constructor(action: DeeplinkAction) {
this.action = action;
}

withParam(key: string, value: string): this {
if (key && value) {
this.params[key] = value;
}
return this;
}

withDeviceId(deviceId: string): this {
return this.withParam('deviceId', deviceId);
}

build(): string {
return createDeeplink(this.action, this.params);
}
}

export const DeeplinkActions = {
startRecording: (): string => createDeeplink('record'),
stopRecording: (): string => createDeeplink('stop'),
pauseRecording: (): string => createDeeplink('pause'),
resumeRecording: (): string => createDeeplink('resume'),
switchMicrophone: (deviceId: string): string => {
if (!deviceId) throw new Error('deviceId is required for switchMicrophone');
return createDeeplink('switch-microphone', { deviceId });
},
switchCamera: (deviceId: string): string => {
if (!deviceId) throw new Error('deviceId is required for switchCamera');
return createDeeplink('switch-camera', { deviceId });
},
};
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading