Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/test-notification-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@cuewise/browser-extension': patch
'@cuewise/macos': patch
'@cuewise/app': patch
'@cuewise/shared': patch
---

A Send test button under Settings → Notifications shows what a reminder looks like, and the Notifications switch now really turns them off.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ apps/
// Host variants so a command-only context can't accidentally subscribe.
interface Scheduler { scheduleAt(id, when): Promise<void>; cancel(id): Promise<void>; }
interface SchedulerHost extends Scheduler { onFire(handler): () => void; }
interface Notifier { notify(opts): Promise<void>; clear(id): Promise<void>; }
interface Notifier { notify(opts): Promise<void>; clear(id): Promise<void>; permission(): Promise<NotifierPermission>; }
interface NotifierHost extends Notifier { onClick(handler): () => void; onAction(handler): () => void; }
interface KeyValueStore { get(key, area); set(key, value, area); remove(key, area); getUsage(area); }

Expand Down
29 changes: 29 additions & 0 deletions apps/browser-extension/e2e/test-notification.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test, type Worker } from '@playwright/test';
import { buildExtension, launchExtension, openNewTab } from './extension-harness';

// ENG-119: the Settings "Send test" button creates a real chrome.notifications entry in the
// REAL built extension — the closest an automated check gets to the OS drawing it.
test.beforeAll(() => {
buildExtension();
});

async function shownNotificationIds(worker: Worker): Promise<string[]> {
return worker.evaluate(async () => Object.keys(await chrome.notifications.getAll()));
}

test('Send test creates a notification the worker can see', async () => {
const session = await launchExtension();
const { context, worker, extensionId } = session;
const page = await openNewTab(session);

await page.goto(`chrome-extension://${extensionId}/index.html#settings`);
await page.getByPlaceholder('Search settings…').fill('test notification');
await page.getByRole('button', { name: 'Send test' }).click();

await expect(page.getByText(/^Sent\./)).toBeVisible();
await expect(async () => {
expect(await shownNotificationIds(worker)).toContain('reminder-test');
}).toPass({ timeout: 5_000 });

await context.close();
});
20 changes: 20 additions & 0 deletions apps/browser-extension/src/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ vi.mock('@cuewise/storage', () => ({
}),
// Runs at module load now, sync or no sync — must resolve, background.ts chains off it.
ensureSettingsMigrated: vi.fn(() => Promise.resolve()),
// The fire path checks the Notifications switch before it notifies.
getSettings: vi.fn(async () => (await import('@cuewise/shared')).DEFAULT_SETTINGS),
}));

type AlarmListener = (alarm: { name: string }) => void;
Expand Down Expand Up @@ -471,6 +473,24 @@ describe('background: reminder alarm guards', () => {
});
});

// The Settings test notification carries a reminder id nothing is stored under.
describe('background: the test notification', () => {
it.each([
['Done', 0],
['Snooze', 1],
])('clears it on %s without writing or arming', async (_label, buttonIndex) => {
getRemindersMock.mockResolvedValue([]);

fireButton('reminder-test', buttonIndex);

await vi.waitFor(() => {
expect(chromeMock.notifications.clear).toHaveBeenCalledWith('reminder-test');
});
expect(setRemindersMock).not.toHaveBeenCalled();
expect(chromeMock.alarms.create).not.toHaveBeenCalled();
});
});

describe('background: notification click', () => {
it('clears the notification and focuses an existing tab', async () => {
chromeMock.tabs.query.mockResolvedValueOnce([{ id: 5, windowId: 1 }]);
Expand Down
7 changes: 7 additions & 0 deletions apps/browser-extension/src/platform/chrome-notifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type ButtonListener = (id: string, buttonIndex: number) => void;
const notifications = {
create: vi.fn((_id: string, _options: unknown) => Promise.resolve('id')),
clear: vi.fn((_id: string) => Promise.resolve(true)),
getPermissionLevel: vi.fn(() => Promise.resolve('granted' as 'granted' | 'denied')),
onClicked: { addListener: vi.fn(), removeListener: vi.fn() },
onButtonClicked: { addListener: vi.fn(), removeListener: vi.fn() },
};
Expand Down Expand Up @@ -82,4 +83,10 @@ describe('ChromeNotifier', () => {

expect(handler).toHaveBeenCalledWith('reminder-9', 1);
});

it.each(['granted', 'denied'] as const)("reports Chrome's %s permission level", async (level) => {
notifications.getPermissionLevel.mockResolvedValueOnce(level);

expect(await new ChromeNotifier().permission()).toBe(level);
});
});
11 changes: 10 additions & 1 deletion apps/browser-extension/src/platform/chrome-notifier.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { logger, type NotifierHost, type NotifyOptions } from '@cuewise/shared';
import {
logger,
type NotifierHost,
type NotifierPermission,
type NotifyOptions,
} from '@cuewise/shared';

const ICON_PATH = 'icons/icon-128.png';

Expand All @@ -25,6 +30,10 @@ export class ChromeNotifier implements NotifierHost {
await chrome.notifications.clear(id);
}

async permission(): Promise<NotifierPermission> {
return chrome.notifications.getPermissionLevel();
}

onClick(handler: (id: string) => void | Promise<void>): () => void {
const listener = async (id: string) => {
try {
Expand Down
27 changes: 26 additions & 1 deletion apps/browser-extension/src/platform/web-notifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { logger } from '@cuewise/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebNotifier } from './web-notifier';

Expand All @@ -11,6 +12,7 @@ function stubNotification(permission: NotificationPermission) {
describe('WebNotifier', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('delivers via the web Notification API when permission is granted', async () => {
Expand All @@ -21,12 +23,19 @@ describe('WebNotifier', () => {
expect(notification).toHaveBeenCalledWith('Pomodoro Timer', { body: 'Done!' });
});

it('does nothing when permission is not granted', async () => {
// Error, not warn: 'error' is the shipped log level, so anything quieter is never seen.
it('delivers nothing when permission is not granted, and says so', async () => {
const notification = stubNotification('denied');
const errorLog = vi.spyOn(logger, 'error').mockImplementation(() => {});

await new WebNotifier().notify({ id: 'x', title: 'T', body: 'B' });

expect(notification).not.toHaveBeenCalled();
expect(errorLog).toHaveBeenCalledWith(
'Web notification not delivered: permission not granted',
undefined,
{ id: 'x' }
);
});

it('exposes no-op interaction subscriptions', () => {
Expand All @@ -35,4 +44,20 @@ describe('WebNotifier', () => {
expect(() => notifier.onClick(() => {})()).not.toThrow();
expect(() => notifier.onAction(() => {})()).not.toThrow();
});

it.each([
['granted', 'granted'],
['denied', 'denied'],
['default', 'unknown'],
] as const)('maps the web permission %s to %s', async (web, expected) => {
stubNotification(web);

expect(await new WebNotifier().permission()).toBe(expected);
});

it('reports unknown where there is no Notification API', async () => {
vi.stubGlobal('Notification', undefined);

expect(await new WebNotifier().permission()).toBe('unknown');
});
});
22 changes: 19 additions & 3 deletions apps/browser-extension/src/platform/web-notifier.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { NotifierHost, NotifyOptions } from '@cuewise/shared';
import {
logger,
type NotifierHost,
type NotifierPermission,
type NotifyOptions,
} from '@cuewise/shared';

/**
* Notifier for contexts without chrome.notifications (dev/web): delivers via the
Expand All @@ -7,13 +12,24 @@ import type { NotifierHost, NotifyOptions } from '@cuewise/shared';
*/
export class WebNotifier implements NotifierHost {
async notify(opts: NotifyOptions): Promise<void> {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification(opts.title, { body: opts.body });
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') {
logger.error('Web notification not delivered: permission not granted', undefined, {
id: opts.id,
});
return;
}
new Notification(opts.title, { body: opts.body });
}

async clear(_id: string): Promise<void> {}

async permission(): Promise<NotifierPermission> {
if (typeof Notification === 'undefined' || Notification.permission === 'default') {
return 'unknown';
}
return Notification.permission;
}

onClick(_handler: (id: string) => void | Promise<void>): () => void {
return () => {};
}
Expand Down
4 changes: 2 additions & 2 deletions apps/browser-extension/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { configurePlatform } from '@cuewise/shared';
import { installChromeStorageMock } from '@cuewise/test-utils/mocks';
import { fakeNotifier, installChromeStorageMock } from '@cuewise/test-utils/mocks';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeEach, vi } from 'vitest';
import '@testing-library/jest-dom';
Expand Down Expand Up @@ -39,7 +39,7 @@ beforeEach(() => {
scheduleAt: async () => {},
cancel: async () => {},
},
notifier: { notify: async () => {}, clear: async () => {} },
notifier: fakeNotifier(),
});
});

Expand Down
123 changes: 123 additions & 0 deletions apps/macos/src/platform/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { logger } from '@cuewise/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const isPermissionGrantedMock = vi.fn<() => Promise<boolean>>();
const requestPermissionMock = vi.fn<() => Promise<'granted' | 'denied' | 'default'>>();
const sendNotificationMock = vi.fn();

vi.mock('@tauri-apps/plugin-notification', () => ({
isPermissionGranted: () => isPermissionGrantedMock(),
requestPermission: () => requestPermissionMock(),
sendNotification: (options: unknown) => sendNotificationMock(options),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() }));

import { TauriNotifier, WebNotifier } from './index';

beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('TauriNotifier.permission', () => {
it('reports granted when the plugin has permission', async () => {
isPermissionGrantedMock.mockResolvedValue(true);

expect(await new TauriNotifier().permission()).toBe('granted');
});

it('reports unknown, never denied, when the plugin has no permission', async () => {
isPermissionGrantedMock.mockResolvedValue(false);

expect(await new TauriNotifier().permission()).toBe('unknown');
});
});

describe('TauriNotifier.notify', () => {
it('sends without asking when permission is already granted', async () => {
isPermissionGrantedMock.mockResolvedValue(true);

await new TauriNotifier().notify({ id: 'reminder-1', title: 'T', body: 'B' });

expect(requestPermissionMock).not.toHaveBeenCalled();
expect(sendNotificationMock).toHaveBeenCalledWith({ title: 'T', body: 'B' });
});

it('asks for permission once when it is missing, then sends', async () => {
isPermissionGrantedMock.mockResolvedValue(false);
requestPermissionMock.mockResolvedValue('granted');

await new TauriNotifier().notify({ id: 'reminder-1', title: 'T', body: 'B' });

expect(requestPermissionMock).toHaveBeenCalledOnce();
expect(sendNotificationMock).toHaveBeenCalledWith({ title: 'T', body: 'B' });
});

it('sends nothing when permission is refused, and says so', async () => {
isPermissionGrantedMock.mockResolvedValue(false);
requestPermissionMock.mockResolvedValue('denied');
const errorLog = vi.spyOn(logger, 'error').mockImplementation(() => {});

await new TauriNotifier().notify({ id: 'reminder-1', title: 'T', body: 'B' });

expect(sendNotificationMock).not.toHaveBeenCalled();
expect(errorLog).toHaveBeenCalledWith(
'Native notification not delivered: permission refused',
undefined,
{ id: 'reminder-1' }
);
});
});

function stubNotification(permission: 'granted' | 'denied' | 'default'): void {
const mock = vi.fn();
(mock as unknown as { permission: string }).permission = permission;
vi.stubGlobal('Notification', mock);
}

describe('WebNotifier.notify', () => {
it('delivers through the web API when permission is granted', async () => {
stubNotification('granted');

await new WebNotifier().notify({ id: 'reminder-1', title: 'T', body: 'B' });

expect(vi.mocked(Notification)).toHaveBeenCalledWith('T', { body: 'B', tag: 'reminder-1' });
});

it('delivers nothing when permission is not granted, and says so', async () => {
stubNotification('default');
const errorLog = vi.spyOn(logger, 'error').mockImplementation(() => {});

await new WebNotifier().notify({ id: 'reminder-1', title: 'T', body: 'B' });

expect(vi.mocked(Notification)).not.toHaveBeenCalled();
expect(errorLog).toHaveBeenCalledWith(
'Web notification not delivered: permission not granted',
undefined,
{ id: 'reminder-1' }
);
});
});

describe('WebNotifier.permission', () => {
it.each([
['granted', 'granted'],
['denied', 'denied'],
['default', 'unknown'],
] as const)('maps the web permission %s to %s', async (web, expected) => {
stubNotification(web);

expect(await new WebNotifier().permission()).toBe(expected);
});

it('reports unknown where there is no Notification API', async () => {
vi.stubGlobal('Notification', undefined);

expect(await new WebNotifier().permission()).toBe('unknown');
});
});
Loading
Loading