Skip to content

Commit bd12a7b

Browse files
Add Web Notifications support (#2067)
* Add webNotifications feature for macOS - Add web-notifications.js feature with Notification API polyfill - Add message schema for showNotification, closeNotification, requestPermission - Add unit tests - Register feature for apple platform - Add debug logging for feature loading * Fix: Coerce notification options to strings for Swift decoding - Convert title, body, icon, tag to String() in constructor - Add tests for non-string option coercion - Ensures Swift Decodable can parse numeric tag values * Move webNotifications to webCompat setting - Add webNotificationsFix() to web-compat.js as independent setting - Move message definitions from web-notifications/ to web-compat/ - Remove webNotifications from standalone feature registration - Delete standalone web-notifications.js and types - Replace unit tests with integration tests in web-compat.spec.js - Regenerate web-compat.ts types for new messages * Add nativeEnabled setting and isSecureContext gate to webNotifications - Add isSecureContext check (crypto.randomUUID requires secure context) - Add nativeEnabled setting (defaults true, when false returns denied and skips native calls) - Add integration tests for nativeEnabled: false behavior * Remove debug logging from webNotificationsFix * Address PR feedback for webNotificationsFix - Update secure context comment to clarify Notification API requirement - Replace wrapToString with shimInterface for consistent constructor wrapping * Revert shimInterface change due to TypeScript constraints shimInterface requires full interface implementation including EventTarget, which is impractical for the Notification polyfill. Reverting to wrapToString. * Use wrapFunction for Notification constructor, fix close() onclose handler, improve permission flow * Update Notification.permission after requestPermission resolves * Make close() idempotent to prevent multiple onclose events
1 parent 75d3aa2 commit bd12a7b

File tree

8 files changed

+592
-3
lines changed

8 files changed

+592
-3
lines changed

injected/integration-test/web-compat.spec.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,206 @@ test.describe('Ensure Notification interface is injected', () => {
153153
});
154154
});
155155

156+
test.describe('webNotifications', () => {
157+
/**
158+
* @param {import("@playwright/test").Page} page
159+
*/
160+
async function beforeWebNotifications(page) {
161+
await gotoAndWait(page, '/blank.html', {
162+
site: { enabledFeatures: ['webCompat'] },
163+
featureSettings: { webCompat: { webNotifications: 'enabled' } },
164+
});
165+
}
166+
167+
test('should override Notification API when enabled', async ({ page }) => {
168+
await beforeWebNotifications(page);
169+
const hasNotification = await page.evaluate(() => 'Notification' in window);
170+
expect(hasNotification).toEqual(true);
171+
});
172+
173+
test('should return default for permission initially', async ({ page }) => {
174+
await beforeWebNotifications(page);
175+
const permission = await page.evaluate(() => window.Notification.permission);
176+
expect(permission).toEqual('default');
177+
});
178+
179+
test('should return 2 for maxActions', async ({ page }) => {
180+
await beforeWebNotifications(page);
181+
const maxActions = await page.evaluate(() => {
182+
// @ts-expect-error - maxActions is experimental
183+
return window.Notification.maxActions;
184+
});
185+
expect(maxActions).toEqual(2);
186+
});
187+
188+
test('should send showNotification message when constructing', async ({ page }) => {
189+
await beforeWebNotifications(page);
190+
await page.evaluate(() => {
191+
globalThis.notifyCalls = [];
192+
globalThis.cssMessaging.impl.notify = (msg) => {
193+
globalThis.notifyCalls.push(msg);
194+
};
195+
});
196+
197+
await page.evaluate(() => new window.Notification('Test Title', { body: 'Test Body' }));
198+
199+
const calls = await page.evaluate(() => globalThis.notifyCalls);
200+
expect(calls.length).toBeGreaterThan(0);
201+
expect(calls[0]).toMatchObject({
202+
featureName: 'webCompat',
203+
method: 'showNotification',
204+
params: { title: 'Test Title', body: 'Test Body' },
205+
});
206+
});
207+
208+
test('should send closeNotification message on close()', async ({ page }) => {
209+
await beforeWebNotifications(page);
210+
await page.evaluate(() => {
211+
globalThis.notifyCalls = [];
212+
globalThis.cssMessaging.impl.notify = (msg) => {
213+
globalThis.notifyCalls.push(msg);
214+
};
215+
});
216+
217+
await page.evaluate(() => {
218+
const n = new window.Notification('Test');
219+
n.close();
220+
});
221+
222+
const calls = await page.evaluate(() => globalThis.notifyCalls);
223+
const closeCall = calls.find((c) => c.method === 'closeNotification');
224+
expect(closeCall).toBeDefined();
225+
expect(closeCall).toMatchObject({
226+
featureName: 'webCompat',
227+
method: 'closeNotification',
228+
});
229+
expect(closeCall.params.id).toBeDefined();
230+
});
231+
232+
test('should only fire onclose once when close() is called multiple times', async ({ page }) => {
233+
await beforeWebNotifications(page);
234+
235+
const closeCount = await page.evaluate(() => {
236+
let count = 0;
237+
const notification = new window.Notification('Test');
238+
notification.onclose = () => {
239+
count++;
240+
};
241+
242+
// Call close() multiple times - should only fire onclose once
243+
notification.close();
244+
notification.close();
245+
notification.close();
246+
247+
return count;
248+
});
249+
250+
expect(closeCount).toEqual(1);
251+
});
252+
253+
test('should propagate requestPermission result from native', async ({ page }) => {
254+
await beforeWebNotifications(page);
255+
await page.evaluate(() => {
256+
globalThis.cssMessaging.impl.request = () => {
257+
return Promise.resolve({ permission: 'denied' });
258+
};
259+
});
260+
261+
const permission = await page.evaluate(() => window.Notification.requestPermission());
262+
expect(permission).toEqual('denied');
263+
});
264+
265+
test('should update Notification.permission after requestPermission resolves', async ({ page }) => {
266+
await beforeWebNotifications(page);
267+
268+
// Initially should be 'default'
269+
const initialPermission = await page.evaluate(() => window.Notification.permission);
270+
expect(initialPermission).toEqual('default');
271+
272+
// Mock native to return 'granted'
273+
await page.evaluate(() => {
274+
globalThis.cssMessaging.impl.request = () => {
275+
return Promise.resolve({ permission: 'granted' });
276+
};
277+
});
278+
279+
await page.evaluate(() => window.Notification.requestPermission());
280+
281+
// After requestPermission, Notification.permission should reflect the new state
282+
const updatedPermission = await page.evaluate(() => window.Notification.permission);
283+
expect(updatedPermission).toEqual('granted');
284+
});
285+
286+
test('should return denied when native error occurs', async ({ page }) => {
287+
await beforeWebNotifications(page);
288+
await page.evaluate(() => {
289+
globalThis.cssMessaging.impl.request = () => {
290+
return Promise.reject(new Error('native error'));
291+
};
292+
});
293+
294+
const permission = await page.evaluate(() => window.Notification.requestPermission());
295+
expect(permission).toEqual('denied');
296+
});
297+
298+
test('requestPermission should have native-looking toString()', async ({ page }) => {
299+
await beforeWebNotifications(page);
300+
301+
const requestPermissionToString = await page.evaluate(() => window.Notification.requestPermission.toString());
302+
expect(requestPermissionToString).toEqual('function requestPermission() { [native code] }');
303+
});
304+
});
305+
306+
test.describe('webNotifications with nativeEnabled: false', () => {
307+
/**
308+
* @param {import("@playwright/test").Page} page
309+
*/
310+
async function beforeWebNotificationsDisabled(page) {
311+
await gotoAndWait(page, '/blank.html', {
312+
site: { enabledFeatures: ['webCompat'] },
313+
featureSettings: { webCompat: { webNotifications: { state: 'enabled', nativeEnabled: false } } },
314+
});
315+
}
316+
317+
test('should return denied for permission when nativeEnabled is false', async ({ page }) => {
318+
await beforeWebNotificationsDisabled(page);
319+
const permission = await page.evaluate(() => window.Notification.permission);
320+
expect(permission).toEqual('denied');
321+
});
322+
323+
test('should not send showNotification when nativeEnabled is false', async ({ page }) => {
324+
await beforeWebNotificationsDisabled(page);
325+
await page.evaluate(() => {
326+
globalThis.notifyCalls = [];
327+
globalThis.cssMessaging.impl.notify = (msg) => {
328+
globalThis.notifyCalls.push(msg);
329+
};
330+
});
331+
332+
await page.evaluate(() => new window.Notification('Test Title'));
333+
334+
const calls = await page.evaluate(() => globalThis.notifyCalls);
335+
expect(calls.length).toEqual(0);
336+
});
337+
338+
test('should return denied from requestPermission without calling native', async ({ page }) => {
339+
await beforeWebNotificationsDisabled(page);
340+
await page.evaluate(() => {
341+
globalThis.requestCalls = [];
342+
globalThis.cssMessaging.impl.request = (msg) => {
343+
globalThis.requestCalls.push(msg);
344+
return Promise.resolve({ permission: 'granted' });
345+
};
346+
});
347+
348+
const permission = await page.evaluate(() => window.Notification.requestPermission());
349+
const calls = await page.evaluate(() => globalThis.requestCalls);
350+
351+
expect(permission).toEqual('denied');
352+
expect(calls.length).toEqual(0);
353+
});
354+
});
355+
156356
test.describe('Permissions API', () => {
157357
// Fake the Permission API not existing in this browser
158358
const removePermissionsScript = `

0 commit comments

Comments
 (0)