diff --git a/injected/integration-test/web-compat.spec.js b/injected/integration-test/web-compat.spec.js index d331c318b1..16953e0af3 100644 --- a/injected/integration-test/web-compat.spec.js +++ b/injected/integration-test/web-compat.spec.js @@ -133,15 +133,176 @@ test.describe('Ensure Notification interface is injected', () => { }); }); -test.describe('Permissions API', () => { - // Fake the Permission API not existing in this browser - const removePermissionsScript = ` - Object.defineProperty(window.navigator, 'permissions', { writable: true, value: undefined }) - `; +// Shared utility functions for permissions tests +function checkForPermissions() { + return !!window.navigator.permissions; +} + +/** + * Shared test setup for permissions tests + * @param {import("@playwright/test").Page} page + * @param {Object} options - Setup options + * @param {boolean} [options.removePermissions=false] - Whether to remove permissions API + * @param {boolean} [options.enablePermissionsPresent=false] - Whether to enable permissionsPresent feature + */ +async function setupPermissionsTest(page, options = {}) { + const { removePermissions = false, enablePermissionsPresent = false } = options; + + const featureSettings = { + webCompat: { + permissions: { + state: 'enabled', + supportedPermissions: { + geolocation: {}, + push: { + name: 'notifications', + }, + camera: { + name: 'video_capture', + native: true, + }, + }, + }, + }, + }; - function checkForPermissions() { - return !!window.navigator.permissions; + if (enablePermissionsPresent) { + featureSettings.webCompat.permissionsPresent = { + state: 'enabled', + }; } + + const removePermissionsScript = removePermissions + ? ` + Object.defineProperty(window.navigator, 'permissions', { writable: true, value: undefined }) + ` + : undefined; + + await gotoAndWait( + page, + '/blank.html', + { + site: { + enabledFeatures: ['webCompat'], + }, + featureSettings, + }, + removePermissionsScript, + ); +} + +/** + * Shared permission checking function + * @param {import("@playwright/test").Page} page + * @param {any} name + * @return {Promise<{result: any, message: *}>} + */ +async function checkPermission(page, name) { + const payload = `window.navigator.permissions.query(${JSON.stringify({ name })})`; + const result = await page.evaluate(payload).catch((e) => { + return { threw: e }; + }); + const message = await page.evaluate(() => { + return globalThis.shareReq; + }); + return { result, message }; +} + +/** + * Shared test cases for permissions functionality + */ +const permissionsTestCases = { + /** + * Test that permissions API is exposed when enabled + * @param {import("@playwright/test").Page} page + */ + async testPermissionsExposed(page) { + const hasPermissions = await page.evaluate(checkForPermissions); + expect(hasPermissions).toEqual(true); + }, + + /** + * Test error handling for unsupported permissions + * @param {import("@playwright/test").Page} page + */ + async testUnsupportedPermission(page) { + const { result } = await checkPermission(page, 'notexistent'); + expect(result.threw).not.toBeUndefined(); + expect(result.threw.message).toContain('notexistent'); + }, + + /** + * Test default prompt response + * @param {import("@playwright/test").Page} page + */ + async testDefaultPrompt(page) { + const { result } = await checkPermission(page, 'geolocation'); + expect(result).toMatchObject({ name: 'geolocation', state: 'prompt' }); + }, + + /** + * Test name override functionality + * @param {import("@playwright/test").Page} page + */ + async testNameOverride(page) { + const { result } = await checkPermission(page, 'push'); + expect(result).toMatchObject({ name: 'notifications', state: 'prompt' }); + }, + + /** + * Test native permission with successful messaging + * @param {import("@playwright/test").Page} page + */ + async testNativePermissionSuccess(page) { + await page.evaluate(() => { + globalThis.cssMessaging.impl.request = (req) => { + globalThis.shareReq = req; + return Promise.resolve({ state: 'granted' }); + }; + }); + const { result, message } = await checkPermission(page, 'camera'); + expect(result).toMatchObject({ name: 'video_capture', state: 'granted' }); + expect(message).toMatchObject({ featureName: 'webCompat', method: 'permissionsQuery', params: { name: 'camera' } }); + }, + + /** + * Test native permission with unexpected response + * @param {import("@playwright/test").Page} page + */ + async testNativePermissionUnexpectedResponse(page) { + page.on('console', (msg) => { + console.log(`PAGE LOG: ${msg.text()}`); + }); + + await page.evaluate(() => { + globalThis.cssMessaging.impl.request = (message) => { + globalThis.shareReq = message; + return Promise.resolve({ noState: 'xxx' }); + }; + }); + const { result, message } = await checkPermission(page, 'camera'); + expect(result).toMatchObject({ name: 'video_capture', state: 'prompt' }); + expect(message).toMatchObject({ featureName: 'webCompat', method: 'permissionsQuery', params: { name: 'camera' } }); + }, + + /** + * Test native permission with messaging error + * @param {import("@playwright/test").Page} page + */ + async testNativePermissionError(page) { + await page.evaluate(() => { + globalThis.cssMessaging.impl.request = (message) => { + globalThis.shareReq = message; + return Promise.reject(new Error('something wrong')); + }; + }); + const { result, message } = await checkPermission(page, 'camera'); + expect(result).toMatchObject({ name: 'video_capture', state: 'prompt' }); + expect(message).toMatchObject({ featureName: 'webCompat', method: 'permissionsQuery', params: { name: 'camera' } }); + }, +}; + +test.describe('Permissions API', () => { function checkObjectDescriptorIsNotPresent() { const descriptor = Object.getOwnPropertyDescriptor(window.navigator, 'permissions'); return descriptor === undefined; @@ -155,124 +316,138 @@ test.describe('Permissions API', () => { expect(initialPermissions).toEqual(true); const initialDescriptorSerialization = await page.evaluate(checkObjectDescriptorIsNotPresent); expect(initialDescriptorSerialization).toEqual(true); - await gotoAndWait(page, '/blank.html', { site: { enabledFeatures: [] } }, removePermissionsScript); + // Remove permissions API without enabling webCompat feature + await gotoAndWait( + page, + '/blank.html', + { site: { enabledFeatures: [] } }, + ` + Object.defineProperty(window.navigator, 'permissions', { writable: true, value: undefined }) + `, + ); const noPermissions = await page.evaluate(checkForPermissions); expect(noPermissions).toEqual(false); }); }); test.describe('enabled feature', () => { - /** - * @param {import("@playwright/test").Page} page - */ - async function before(page) { - await gotoAndWait( - page, - '/blank.html', - { - site: { - enabledFeatures: ['webCompat'], - }, - featureSettings: { - webCompat: { - permissions: { - state: 'enabled', - supportedPermissions: { - geolocation: {}, - push: { - name: 'notifications', - }, - camera: { - name: 'video_capture', - native: true, - }, - }, - }, - }, - }, - }, - removePermissionsScript, - ); - } - /** - * @param {import("@playwright/test").Page} page - * @param {any} name - * @return {Promise<{result: any, message: *}>} - */ - async function checkPermission(page, name) { - const payload = `window.navigator.permissions.query(${JSON.stringify({ name })})`; - const result = await page.evaluate(payload).catch((e) => { - return { threw: e }; - }); - const message = await page.evaluate(() => { - return globalThis.shareReq; - }); - return { result, message }; - } test('should expose window.navigator.permissions when enabled', async ({ page }) => { - await before(page); - const hasPermissions = await page.evaluate(checkForPermissions); - expect(hasPermissions).toEqual(true); + await setupPermissionsTest(page, { removePermissions: true }); + await permissionsTestCases.testPermissionsExposed(page); const modifiedDescriptorSerialization = await page.evaluate(checkObjectDescriptorIsNotPresent); // This fails in a test condition purely because we have to add a descriptor to modify the prop expect(modifiedDescriptorSerialization).toEqual(false); }); + test('should throw error when permission not supported', async ({ page }) => { - await before(page); - const { result } = await checkPermission(page, 'notexistent'); - expect(result.threw).not.toBeUndefined(); - expect(result.threw.message).toContain('notexistent'); + await setupPermissionsTest(page, { removePermissions: true }); + await permissionsTestCases.testUnsupportedPermission(page); }); + test('should return prompt by default', async ({ page }) => { - await before(page); - const { result } = await checkPermission(page, 'geolocation'); - expect(result).toMatchObject({ name: 'geolocation', state: 'prompt' }); + await setupPermissionsTest(page, { removePermissions: true }); + await permissionsTestCases.testDefaultPrompt(page); }); + test('should return updated name when configured', async ({ page }) => { - await before(page); - const { result } = await checkPermission(page, 'push'); - expect(result).toMatchObject({ name: 'notifications', state: 'prompt' }); + await setupPermissionsTest(page, { removePermissions: true }); + await permissionsTestCases.testNameOverride(page); }); + test('should propagate result from native when configured', async ({ page }) => { - await before(page); - // Fake result from native - await page.evaluate(() => { - globalThis.cssMessaging.impl.request = (req) => { - globalThis.shareReq = req; - return Promise.resolve({ state: 'granted' }); - }; - }); - const { result, message } = await checkPermission(page, 'camera'); - expect(result).toMatchObject({ name: 'video_capture', state: 'granted' }); - expect(message).toMatchObject({ featureName: 'webCompat', method: 'permissionsQuery', params: { name: 'camera' } }); + await setupPermissionsTest(page, { removePermissions: true }); + await permissionsTestCases.testNativePermissionSuccess(page); }); + test('should default to prompt when native sends unexpected response', async ({ page }) => { - await before(page); - page.on('console', (msg) => { - console.log(`PAGE LOG: ${msg.text()}`); + await setupPermissionsTest(page, { removePermissions: true }); + await permissionsTestCases.testNativePermissionUnexpectedResponse(page); + }); + + test('should default to prompt when native error occurs', async ({ page }) => { + await setupPermissionsTest(page, { removePermissions: true }); + await permissionsTestCases.testNativePermissionError(page); + }); + }); +}); + +test.describe('Permissions API - when present', () => { + test.describe('disabled feature', () => { + test('should not modify existing permissions API', async ({ page }) => { + await gotoAndWait(page, '/blank.html', { site: { enabledFeatures: [] } }); + const hasPermissions = await page.evaluate(checkForPermissions); + expect(hasPermissions).toEqual(true); + + // Test that the original API behavior is preserved + // Only test if the query method is actually available + const originalQuery = await page.evaluate(() => { + return window.navigator.permissions.query; }); + // Only run the assertion if the query method is available + // This can happen in test environments where the API is partially implemented + if (typeof originalQuery !== 'undefined') { + expect(typeof originalQuery).toBe('function'); + } + }); + }); + + test.describe('enabled feature', () => { + test('should preserve existing permissions API', async ({ page }) => { + await setupPermissionsTest(page, { enablePermissionsPresent: true }); + await permissionsTestCases.testPermissionsExposed(page); + }); + + test('should fall through to original API for non-native permissions', async ({ page }) => { + await setupPermissionsTest(page, { enablePermissionsPresent: true }); + const { result } = await checkPermission(page, 'geolocation'); + // Should use original API behavior, not our custom implementation + expect(result).toBeDefined(); + // The result should be a native PermissionStatus, not our custom one + expect(result.constructor.name).toBe('PermissionStatus'); + }); + + test('should fall through to original API for unsupported permissions', async ({ page }) => { + await setupPermissionsTest(page, { enablePermissionsPresent: true }); + await permissionsTestCases.testUnsupportedPermission(page); + }); + + test('should intercept native permissions and return custom result', async ({ page }) => { + await setupPermissionsTest(page, { enablePermissionsPresent: true }); + await permissionsTestCases.testNativePermissionSuccess(page); + }); + + test('should fall through to original API when native messaging fails', async ({ page }) => { + await setupPermissionsTest(page, { enablePermissionsPresent: true }); await page.evaluate(() => { globalThis.cssMessaging.impl.request = (message) => { globalThis.shareReq = message; - return Promise.resolve({ noState: 'xxx' }); + return Promise.reject(new Error('something wrong')); }; }); const { result, message } = await checkPermission(page, 'camera'); - expect(result).toMatchObject({ name: 'video_capture', state: 'prompt' }); + // Should fall through to original API when messaging fails + expect(result).toBeDefined(); expect(message).toMatchObject({ featureName: 'webCompat', method: 'permissionsQuery', params: { name: 'camera' } }); }); - test('should default to prompt when native error occurs', async ({ page }) => { - await before(page); + + test('should fall through to original API for invalid arguments', async ({ page }) => { + await setupPermissionsTest(page, { enablePermissionsPresent: true }); + const { result } = await checkPermission(page, null); + // Should use original API validation + expect(result.threw).not.toBeUndefined(); + }); + + test('should use configured name override for native permissions', async ({ page }) => { + await setupPermissionsTest(page, { enablePermissionsPresent: true }); await page.evaluate(() => { - globalThis.cssMessaging.impl.request = (message) => { - globalThis.shareReq = message; - return Promise.reject(new Error('something wrong')); + globalThis.cssMessaging.impl.request = (req) => { + globalThis.shareReq = req; + return Promise.resolve({ state: 'denied' }); }; }); - const { result, message } = await checkPermission(page, 'camera'); - expect(result).toMatchObject({ name: 'video_capture', state: 'prompt' }); - expect(message).toMatchObject({ featureName: 'webCompat', method: 'permissionsQuery', params: { name: 'camera' } }); + const { result } = await checkPermission(page, 'push'); + expect(result).toMatchObject({ name: 'notifications', state: 'denied' }); }); }); }); diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 4ff0b8728a..7beed1e59b 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -38,6 +38,17 @@ function canShare(data) { return true; } +// Shadowned class for PermissionStatus for use in shimming +// eslint-disable-next-line no-redeclare +class PermissionStatus extends EventTarget { + constructor(name, state) { + super(); + this.name = name; + this.state = state; + this.onchange = null; // noop + } +} + /** * Clean data before sending to the Android side * @returns {ShareRequestData} @@ -263,25 +274,65 @@ export class WebCompat extends ContentFeature { }); } + /** + * Handles permission query with native messaging support. + * @param {Object} query - The permission query object + * @param {Object} settings - The permission settings + * @returns {Promise} - Returns PermissionStatus if handled, null to fall through + */ + async handlePermissionQuery(query, settings) { + if (!query?.name || !settings?.supportedPermissions?.[query.name]?.native) { + return null; + } + + try { + const permSetting = settings.supportedPermissions[query.name]; + const returnName = permSetting.name || query.name; + const response = await this.messaging.request(MSG_PERMISSIONS_QUERY, query); + const returnStatus = response.state || 'prompt'; + return new PermissionStatus(returnName, returnStatus); + } catch (err) { + return null; // Fall through to original method + } + } + + permissionsPresentFix(settings) { + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = new Proxy(originalQuery, { + apply: async (target, thisArg, args) => { + this.addDebugFlag(); + + // Let the original method handle validation and exceptions + const query = args[0]; + + // Try to handle with native messaging + const result = await this.handlePermissionQuery(query, settings); + if (result) { + return result; + } + + // Fall through to original method for all other cases + return Reflect.apply(target, thisArg, args); + }, + }); + } + /** * Adds missing permissions API for Android WebView. */ permissionsFix(settings) { if (window.navigator.permissions) { + if (this.getFeatureSettingEnabled('permissionsPresent')) { + this.permissionsPresentFix(settings); + } return; } const permissions = {}; - class PermissionStatus extends EventTarget { - constructor(name, state) { - super(); - this.name = name; - this.state = state; - this.onchange = null; // noop - } - } permissions.query = new Proxy( async (query) => { this.addDebugFlag(); + + // Validate required arguments if (!query) { throw new TypeError("Failed to execute 'query' on 'Permissions': 1 argument required, but only 0 present."); } @@ -295,17 +346,17 @@ export class WebCompat extends ContentFeature { `Failed to execute 'query' on 'Permissions': Failed to read the 'name' property from 'PermissionDescriptor': The provided value '${query.name}' is not a valid enum value of type PermissionName.`, ); } + + // Try to handle with native messaging + const result = await this.handlePermissionQuery(query, settings); + if (result) { + return result; + } + + // Fall back to default behavior const permSetting = settings.supportedPermissions[query.name]; const returnName = permSetting.name || query.name; - let returnStatus = settings.permissionResponse || 'prompt'; - if (permSetting.native) { - try { - const response = await this.messaging.request(MSG_PERMISSIONS_QUERY, query); - returnStatus = response.state || 'prompt'; - } catch (err) { - // do nothing - keep returnStatus as-is - } - } + const returnStatus = settings.permissionResponse || 'prompt'; return Promise.resolve(new PermissionStatus(returnName, returnStatus)); }, {