Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@
"npmRebuild": false,
"appId": "com.maestro.app",
"productName": "Maestro",
"protocols": [
{
"name": "Maestro",
"schemes": ["maestro"]
}
],
"publish": {
"provider": "github",
"owner": "RunMaestro",
Expand Down
141 changes: 141 additions & 0 deletions src/__tests__/main/deep-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Tests for deep link URL parsing
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock electron before importing the module under test
vi.mock('electron', () => ({
app: {
isPackaged: false,
setAsDefaultProtocolClient: vi.fn(),
requestSingleInstanceLock: vi.fn().mockReturnValue(true),
on: vi.fn(),
quit: vi.fn(),
},
BrowserWindow: {
getAllWindows: vi.fn().mockReturnValue([]),
},
}));

vi.mock('../../main/utils/logger', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));

vi.mock('../../main/utils/safe-send', () => ({
isWebContentsAvailable: vi.fn().mockReturnValue(true),
}));

import { parseDeepLink } from '../../main/deep-links';

describe('parseDeepLink', () => {
describe('focus action', () => {
it('should parse maestro://focus', () => {
expect(parseDeepLink('maestro://focus')).toEqual({ action: 'focus' });
});

it('should parse empty path as focus', () => {
expect(parseDeepLink('maestro://')).toEqual({ action: 'focus' });
});

it('should parse protocol-only as focus', () => {
expect(parseDeepLink('maestro:')).toEqual({ action: 'focus' });
});
});

describe('session action', () => {
it('should parse session URL', () => {
expect(parseDeepLink('maestro://session/abc123')).toEqual({
action: 'session',
sessionId: 'abc123',
});
});

it('should parse session URL with tab', () => {
expect(parseDeepLink('maestro://session/abc123/tab/tab456')).toEqual({
action: 'session',
sessionId: 'abc123',
tabId: 'tab456',
});
});

it('should decode URI-encoded session IDs', () => {
expect(parseDeepLink('maestro://session/session%20with%20space')).toEqual({
action: 'session',
sessionId: 'session with space',
});
});

it('should decode URI-encoded tab IDs', () => {
expect(parseDeepLink('maestro://session/abc/tab/tab%2Fslash')).toEqual({
action: 'session',
sessionId: 'abc',
tabId: 'tab/slash',
});
});

it('should return null for session without ID', () => {
expect(parseDeepLink('maestro://session')).toBeNull();
expect(parseDeepLink('maestro://session/')).toBeNull();
});

it('should ignore extra path segments after tab ID', () => {
const result = parseDeepLink('maestro://session/abc/tab/tab1/extra/stuff');
expect(result).toEqual({
action: 'session',
sessionId: 'abc',
tabId: 'tab1',
});
});
});

describe('group action', () => {
it('should parse group URL', () => {
expect(parseDeepLink('maestro://group/grp789')).toEqual({
action: 'group',
groupId: 'grp789',
});
});

it('should decode URI-encoded group IDs', () => {
expect(parseDeepLink('maestro://group/group%20name')).toEqual({
action: 'group',
groupId: 'group name',
});
});

it('should return null for group without ID', () => {
expect(parseDeepLink('maestro://group')).toBeNull();
expect(parseDeepLink('maestro://group/')).toBeNull();
});
});

describe('Windows compatibility', () => {
it('should handle Windows maestro: prefix (no double slash)', () => {
expect(parseDeepLink('maestro:session/abc123')).toEqual({
action: 'session',
sessionId: 'abc123',
});
});

it('should handle Windows focus without double slash', () => {
expect(parseDeepLink('maestro:focus')).toEqual({ action: 'focus' });
});
});

describe('error handling', () => {
it('should return null for unrecognized resource', () => {
expect(parseDeepLink('maestro://unknown/abc')).toBeNull();
});

it('should return null for completely malformed URLs', () => {
// parseDeepLink is tolerant of most inputs, but unrecognized resources return null
expect(parseDeepLink('maestro://settings')).toBeNull();
});
});
});
48 changes: 47 additions & 1 deletion src/__tests__/main/ipc/handlers/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ipcMain } from 'electron';
const mocks = vi.hoisted(() => ({
mockNotificationShow: vi.fn(),
mockNotificationIsSupported: vi.fn().mockReturnValue(true),
mockNotificationOn: vi.fn(),
}));

// Mock electron with a proper class for Notification
Expand All @@ -29,6 +30,9 @@ vi.mock('electron', () => {
show() {
mocks.mockNotificationShow();
}
on(event: string, handler: () => void) {
mocks.mockNotificationOn(event, handler);
}
static isSupported() {
return mocks.mockNotificationIsSupported();
}
Expand All @@ -55,6 +59,15 @@ vi.mock('../../../../main/utils/logger', () => ({
},
}));

// Mock deep-links module (used by notification click handler)
vi.mock('../../../../main/deep-links', () => ({
parseDeepLink: vi.fn((url: string) => {
if (url.includes('session/')) return { action: 'session', sessionId: 'test-session' };
return { action: 'focus' };
}),
dispatchDeepLink: vi.fn(),
}));

// Mock child_process - must include default export
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
Expand Down Expand Up @@ -99,6 +112,8 @@ import {
describe('Notification IPC Handlers', () => {
let handlers: Map<string, Function>;

const mockGetMainWindow = vi.fn().mockReturnValue(null);

beforeEach(() => {
vi.clearAllMocks();
resetNotificationState();
Expand All @@ -107,13 +122,14 @@ describe('Notification IPC Handlers', () => {
// Reset mocks
mocks.mockNotificationIsSupported.mockReturnValue(true);
mocks.mockNotificationShow.mockClear();
mocks.mockNotificationOn.mockClear();

// Capture registered handlers
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => {
handlers.set(channel, handler);
});

registerNotificationsHandlers();
registerNotificationsHandlers({ getMainWindow: mockGetMainWindow });
});

afterEach(() => {
Expand Down Expand Up @@ -186,6 +202,36 @@ describe('Notification IPC Handlers', () => {
});
});

describe('notification:show click-to-navigate', () => {
it('should register click handler when sessionId is provided', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body', 'session-123');

expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function));
});

it('should register click handler when sessionId and tabId are provided', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body', 'session-123', 'tab-456');

expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function));
});

it('should not register click handler when sessionId is not provided', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body');

expect(mocks.mockNotificationOn).not.toHaveBeenCalled();
});

it('should not register click handler when sessionId is undefined', async () => {
const handler = handlers.get('notification:show')!;
await handler({}, 'Title', 'Body', undefined, undefined);

expect(mocks.mockNotificationOn).not.toHaveBeenCalled();
});
});

describe('notification:stopSpeak', () => {
it('should return error when no active notification process', async () => {
const handler = handlers.get('notification:stopSpeak')!;
Expand Down
Loading
Loading