From 8e764609591455c0f672c0a1ac08d33ac57a2729 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Tue, 28 Jan 2025 11:06:19 +0000 Subject: [PATCH] enable messageBridge on Android + tests (#1395) * enable messageBridge on Android + tests * fixed tests * fixing duckplayer special-pages tests * privacy config --- .../message-bridge-android.spec.js | 84 +++++++++++++++++++ .../page-objects/duckplayer-overlays.js | 5 ++ .../page-objects/results-collector.js | 3 + injected/playwright.config.js | 6 +- injected/src/features.js | 2 +- messaging/lib/test-utils.mjs | 15 +++- package-lock.json | 2 +- special-pages/shared/mocks.js | 1 + 8 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 injected/integration-test/message-bridge-android.spec.js diff --git a/injected/integration-test/message-bridge-android.spec.js b/injected/integration-test/message-bridge-android.spec.js new file mode 100644 index 000000000..6163b6564 --- /dev/null +++ b/injected/integration-test/message-bridge-android.spec.js @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test'; +import { ResultsCollector } from './page-objects/results-collector.js'; +import { readOutgoingMessages } from '@duckduckgo/messaging/lib/test-utils.mjs'; + +const ENABLED_CONFIG = 'integration-test/test-pages/message-bridge/config/message-bridge-enabled.json'; +const DISABLED_CONFIG = 'integration-test/test-pages/message-bridge/config/message-bridge-disabled.json'; +const ENABLED_HTML = '/message-bridge/pages/enabled.html'; +const DISABLED_HTML = '/message-bridge/pages/disabled.html'; + +test('message bridge when enabled (android)', async ({ page }, testInfo) => { + const pageWorld = ResultsCollector.create(page, testInfo.project.use); + + // seed the request->re + pageWorld.withMockResponse({ + sampleData: /** @type {any} */ ({ + ghi: 'jkl', + }), + }); + + pageWorld.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); + + // now load the page + await pageWorld.load(ENABLED_HTML, ENABLED_CONFIG); + + // simulate a push event + await pageWorld.simulateSubscriptionMessage('exampleFeature', 'onUpdate', { abc: 'def' }); + + // get all results + const results = await pageWorld.results(); + expect(results['Creating the bridge']).toStrictEqual([ + { name: 'bridge.notify', result: 'function', expected: 'function' }, + { name: 'bridge.request', result: 'function', expected: 'function' }, + { name: 'bridge.subscribe', result: 'function', expected: 'function' }, + { name: 'data', result: [{ abc: 'def' }, { ghi: 'jkl' }], expected: [{ abc: 'def' }, { ghi: 'jkl' }] }, + ]); + + // verify messaging calls + const calls = await page.evaluate(readOutgoingMessages); + expect(calls.length).toBe(2); + const pixel = calls[0].payload; + const request = calls[1].payload; + + expect(pixel).toStrictEqual({ + context: 'contentScopeScripts', + featureName: 'exampleFeature', + method: 'pixel', + params: {}, + }); + + const { id, ...rest } = /** @type {import("@duckduckgo/messaging").RequestMessage} */ (request); + + expect(rest).toStrictEqual({ + context: 'contentScopeScripts', + featureName: 'exampleFeature', + method: 'sampleData', + params: {}, + }); + + if (!('id' in request)) throw new Error('unreachable'); + + expect(typeof request.id).toBe('string'); + expect(request.id.length).toBeGreaterThan(10); +}); + +test('message bridge when disabled (android)', async ({ page }, testInfo) => { + const pageWorld = ResultsCollector.create(page, testInfo.project.use); + + // now load the main page + await pageWorld.load(DISABLED_HTML, DISABLED_CONFIG); + + // verify no outgoing calls were made + const calls = await page.evaluate(readOutgoingMessages); + expect(calls).toHaveLength(0); + + // get all results + const results = await pageWorld.results(); + expect(results['Creating the bridge, but it is unavailable']).toStrictEqual([ + { name: 'error', result: 'Did not install Message Bridge', expected: 'Did not install Message Bridge' }, + ]); +}); diff --git a/injected/integration-test/page-objects/duckplayer-overlays.js b/injected/integration-test/page-objects/duckplayer-overlays.js index e860bbdfa..7e91d54e4 100644 --- a/injected/integration-test/page-objects/duckplayer-overlays.js +++ b/injected/integration-test/page-objects/duckplayer-overlays.js @@ -80,6 +80,11 @@ export class DuckplayerOverlays { }, sendDuckPlayerPixel: {}, }); + this.collector.withUserPreferences({ + messageSecret: 'ABC', + javascriptInterface: 'javascriptInterface', + messageCallback: 'messageCallback', + }); page.on('console', (msg) => { console.log(msg.type(), msg.text()); }); diff --git a/injected/integration-test/page-objects/results-collector.js b/injected/integration-test/page-objects/results-collector.js index eae3aef36..aa7a2169c 100644 --- a/injected/integration-test/page-objects/results-collector.js +++ b/injected/integration-test/page-objects/results-collector.js @@ -172,6 +172,7 @@ export class ResultsCollector { messagingContext: this.messagingContext('n/a'), responses: this.#mockResponses, messageCallback: 'messageCallback', + javascriptInterface: this.#userPreferences.javascriptInterface, }); const wrapFn = this.build.switch({ @@ -234,6 +235,8 @@ export class ResultsCollector { name, payload, injectName: this.build.name, + messageCallback: this.#userPreferences.messageCallback, + messageSecret: this.#userPreferences.messageSecret, }); } diff --git a/injected/playwright.config.js b/injected/playwright.config.js index 5155cc8cd..fe6cde2fb 100644 --- a/injected/playwright.config.js +++ b/injected/playwright.config.js @@ -41,7 +41,11 @@ export default defineConfig({ }, { name: 'android', - testMatch: ['integration-test/duckplayer-mobile.spec.js', 'integration-test/web-compat-android.spec.js'], + testMatch: [ + 'integration-test/duckplayer-mobile.spec.js', + 'integration-test/web-compat-android.spec.js', + 'integration-test/message-bridge-android.spec.js', + ], use: { injectName: 'android', platform: 'android', ...devices['Galaxy S5'] }, }, { diff --git a/injected/src/features.js b/injected/src/features.js index 895f80b8b..f717ba915 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -33,7 +33,7 @@ const otherFeatures = /** @type {const} */ ([ export const platformSupport = { apple: ['webCompat', ...baseFeatures], 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge'], - android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer'], + android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-autofill-password-import': ['autofillPasswordImport'], windows: ['cookie', ...baseFeatures, 'windowsPermissionUsage', 'duckPlayer', 'brokerProtection', 'breakageReporting'], firefox: ['cookie', ...baseFeatures, 'clickToLoad'], diff --git a/messaging/lib/test-utils.mjs b/messaging/lib/test-utils.mjs index 2b7c920a5..3efa56543 100644 --- a/messaging/lib/test-utils.mjs +++ b/messaging/lib/test-utils.mjs @@ -274,6 +274,7 @@ export function mockWebkitMessaging(params) { * messagingContext: import('../index.js').MessagingContext, * responses: Record, * messageCallback: string + * javascriptInterface?: string * }} params */ export function mockAndroidMessaging(params) { @@ -284,7 +285,8 @@ export function mockAndroidMessaging(params) { outgoing: [], }, }; - window[params.messagingContext.context] = { + if (!params.javascriptInterface) throw new Error('`javascriptInterface` is required for Android mocking'); + window[params.javascriptInterface] = { /** * @param {string} jsonString * @param {string} secret @@ -322,7 +324,7 @@ export function mockAndroidMessaging(params) { id: msg.id, }; - globalThis.messageCallback?.(secret, r); + globalThis[params.messageCallback]?.(secret, r); }, }; } @@ -406,6 +408,8 @@ export function wrapWebkitScripts(js, replacements) { * @param {string} params.name * @param {Record} params.payload * @param {NonNullable} params.injectName + * @param {string} [params.messageCallback] - optional name of a global method where messages can be delivered (android) + * @param {string} [params.messageSecret] - optional message secret for platforms that require it (android) */ export function simulateSubscriptionMessage(params) { const subscriptionEvent = { @@ -421,6 +425,13 @@ export function simulateSubscriptionMessage(params) { fn(subscriptionEvent); break; } + case 'android': { + if (!params.messageCallback || !params.messageSecret) + throw new Error('`messageCallback` + `messageSecret` needed to simulate subscription event on Android'); + + window[params.messageCallback]?.(params.messageSecret, subscriptionEvent); + break; + } case 'apple': case 'apple-isolated': { if (!(params.name in window)) throw new Error('subscription fn not found for: ' + params.injectName); diff --git a/package-lock.json b/package-lock.json index ad1765264..f127c832c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -256,7 +256,7 @@ }, "node_modules/@duckduckgo/privacy-configuration": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#c3485ccbf9a0339242e1ce1879e926d4587c60df", + "resolved": "git+ssh://git@github.com/duckduckgo/privacy-configuration.git#70310533205bbc2a37b577e8511402bfb7382d65", "dev": true, "license": "Apache 2.0", "dependencies": { diff --git a/special-pages/shared/mocks.js b/special-pages/shared/mocks.js index 8dee35639..f3e62929d 100644 --- a/special-pages/shared/mocks.js +++ b/special-pages/shared/mocks.js @@ -59,6 +59,7 @@ export class Mocks { messagingContext: this.messagingContext, responses: this._defaultResponses, messageCallback: 'messageCallback', + javascriptInterface: this.messagingContext.context, }); }, integration: async () => {