Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

messaging: page-world bridge #1213

Merged
merged 17 commits into from
Nov 15, 2024
8 changes: 7 additions & 1 deletion injected/entry-points/android.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import { AndroidMessagingConfig } from '../../messaging/index.js';

function initCode() {
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const processedConfig = processConfig($CONTENT_SCOPE$, $USER_UNPROTECTED_DOMAINS$, $USER_PREFERENCES$);
const config = $CONTENT_SCOPE$;
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const userUnprotectedDomains = $USER_UNPROTECTED_DOMAINS$;
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const userPreferences = $USER_PREFERENCES$;

const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences);
if (isGloballyDisabled(processedConfig)) {
return;
}
Expand Down
11 changes: 9 additions & 2 deletions injected/entry-points/apple.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@
* @module Apple integration
*/
import { load, init } from '../src/content-scope-features.js';
import { processConfig, isGloballyDisabled } from './../src/utils';
import { processConfig, isGloballyDisabled, platformSpecificFeatures } from './../src/utils';
import { isTrackerOrigin } from '../src/trackers';
import { WebkitMessagingConfig, TestTransportConfig } from '../../messaging/index.js';

function initCode() {
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const processedConfig = processConfig($CONTENT_SCOPE$, $USER_UNPROTECTED_DOMAINS$, $USER_PREFERENCES$);
const config = $CONTENT_SCOPE$;
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const userUnprotectedDomains = $USER_UNPROTECTED_DOMAINS$;
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const userPreferences = $USER_PREFERENCES$;

const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences, platformSpecificFeatures);

if (isGloballyDisabled(processedConfig)) {
return;
}
Expand Down
10 changes: 8 additions & 2 deletions injected/entry-points/windows.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
* @module Windows integration
*/
import { load, init } from '../src/content-scope-features.js';
import { processConfig, isGloballyDisabled, windowsSpecificFeatures } from './../src/utils';
import { processConfig, isGloballyDisabled, platformSpecificFeatures } from './../src/utils';
import { isTrackerOrigin } from '../src/trackers';
import { WindowsMessagingConfig } from '../../messaging/index.js';

function initCode() {
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const processedConfig = processConfig($CONTENT_SCOPE$, $USER_UNPROTECTED_DOMAINS$, $USER_PREFERENCES$, windowsSpecificFeatures);
const config = $CONTENT_SCOPE$;
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const userUnprotectedDomains = $USER_UNPROTECTED_DOMAINS$;
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
const userPreferences = $USER_PREFERENCES$;

const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences, platformSpecificFeatures);
if (isGloballyDisabled(processedConfig)) {
return;
}
Expand Down
107 changes: 107 additions & 0 deletions injected/integration-test/message-bridge-apple.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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';

/**
* This feature needs 2 sets of scripts in the page. For example,
* apple + apple-isolated
*
* @param {import("playwright-core").Page} page
* @param {import("@playwright/test").TestInfo} testInfo
*/
function setupBothContexts(page, testInfo) {
const pageWorld = ResultsCollector.create(page, testInfo.project.use);
const isolated = ResultsCollector.create(page, {
injectName: pageWorld.isolatedVariant(),
platform: pageWorld.platform.name,
});

// add user preferences to both
isolated.withUserPreferences({ messageSecret: 'ABC' });
pageWorld.withUserPreferences({ messageSecret: 'ABC' });

return { pageWorld, isolated };
}

test('message bridge when enabled (apple)', async ({ page }, testInfo) => {
// page.on('console', (msg) => console.log(msg.text()));
shakyShane marked this conversation as resolved.
Show resolved Hide resolved
const { pageWorld, isolated } = setupBothContexts(page, testInfo);

// seed the request->re
isolated.withMockResponse({
sampleData: /** @type {any} */ ({
ghi: 'jkl',
}),
});

// inject the scripts into the isolated world (with a different messaging context)
await isolated.setup({ config: ENABLED_CONFIG });

// now load the page
await pageWorld.load(ENABLED_HTML, ENABLED_CONFIG);

// simulate a push event
await isolated.simulateSubscriptionMessage('exampleFeature', 'onUpdate', { abc: 'def' });

// get all results
const results = await pageWorld.runTests();
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: 'contentScopeScriptsIsolated',
featureName: 'exampleFeature',
method: 'pixel',
params: {},
});

const { id, ...rest } = /** @type {import("@duckduckgo/messaging").RequestMessage} */ (request);

expect(rest).toStrictEqual({
context: 'contentScopeScriptsIsolated',
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 (apple)', async ({ page }, testInfo) => {
// page.on('console', (msg) => console.log(msg.text()));
shakyShane marked this conversation as resolved.
Show resolved Hide resolved
const { pageWorld, isolated } = setupBothContexts(page, testInfo);

// inject the scripts into the isolated world (with a different messaging context)
await isolated.setup({ config: DISABLED_CONFIG });

// 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.runTests();
expect(results['Creating the bridge, but it is unavailable']).toStrictEqual([
{ name: 'error', result: 'Did not install Message Bridge', expected: 'Did not install Message Bridge' },
]);
});
200 changes: 200 additions & 0 deletions injected/integration-test/page-objects/results-collector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { readFileSync } from 'fs';
import {
mockAndroidMessaging,
mockWebkitMessaging,
mockWindowsMessaging,
simulateSubscriptionMessage,
wrapWebkitScripts,
wrapWindowsScripts,
} from '@duckduckgo/messaging/lib/test-utils.mjs';
import { perPlatform } from '../type-helpers.mjs';

/**
* This is designed to allow you to execute Playwright tests using the various
shakyShane marked this conversation as resolved.
Show resolved Hide resolved
* artifacts we produce. For example, on the `apple` target this can be used to ensure
* your tests run against the *real* file that Apple platforms will use in production.
*
* It also handles injecting global variables (like those seen in the entry points within the 'inject' folder)
*
* It has convenience methods for allowing you to extract test-results from the in-page tests too.
*
* ```js
* test('testing against a built artifact and collecting results from the page', async ({ page }, testInfo) => {
* const collector = ResultsCollector.create(page, testInfo)
* await collector.load(HTML_PATH, CONFIG_PATH);
*
* const results = await collector.collectResultsFromPage();
*
* expect(results).toStrictEqual({
* "has DDG signal": [{
* "name": "navigator.duckduckgo.isDuckDuckGo()",
* "result": "true",
* "expected": "true"
* }],
* })
* })
* ```
*
*/
export class ResultsCollector {
#userPreferences = {};
#mockResponses = {};
/**
* @param {import('@playwright/test').Page} page
* @param {import('../type-helpers.mjs').Build} build
* @param {import('../type-helpers.mjs').PlatformInfo} platform
*/
constructor(page, build, platform) {
this.page = page;
this.build = build;
this.platform = platform;
}

/**
* @param {string} htmlPath
* @param {string} configPath
*/
async load(htmlPath, configPath) {
await this.setup({ config: configPath });
await this.page.goto(htmlPath);
return this;
}

/**
* @param {Record<string, any>} values
*/
withUserPreferences(values) {
this.#userPreferences = values;
return this;
}
/**
* @param {Record<string, any>} values
*/
withMockResponse(values) {
this.#mockResponses = values;
return this;
}

/**
* @param {object} params
* @param {Record<string, any> | string} params.config
* @return {Promise<void>}
*/
async setup(params) {
let { config } = params;
if (typeof config === 'string') {
config = JSON.parse(readFileSync(config, 'utf8'));
}

const wrapFn = this.build.switch({
'apple-isolated': () => wrapWebkitScripts,
apple: () => wrapWebkitScripts,
android: () => wrapWindowsScripts,
windows: () => wrapWindowsScripts,
});

// read the built file from disk and do replacements
const injectedJS = wrapFn(this.build.artifact, {
$CONTENT_SCOPE$: config,
$USER_UNPROTECTED_DOMAINS$: [],
$USER_PREFERENCES$: {
platform: { name: this.platform.name },
debug: true,
...this.#userPreferences,
},
});

const messagingMock = this.build.switch({
apple: () => mockWebkitMessaging,
'apple-isolated': () => mockWebkitMessaging,
windows: () => mockWindowsMessaging,
android: () => mockAndroidMessaging,
});

await this.page.addInitScript(messagingMock, {
messagingContext: this.messagingContext('n/a'),
responses: this.#mockResponses,
});

// attach the JS
await this.page.addInitScript(injectedJS);
}

collectResultsFromPage() {
return this.page.evaluate(() => {
return new Promise((resolve) => {
// @ts-expect-error - this is added by the test framework
if (window.results) return resolve(window.results);
window.addEventListener('results-ready', () => {
// @ts-expect-error - this is added by the test framework
resolve(window.results);
});
});
});
}

async runTests() {
const resultsPromise = this.page.evaluate(() => {
return new Promise((resolve) => {
if ('results' in window) {
resolve(window.results);
} else {
window.addEventListener('results-ready', () => {
// @ts-expect-error - this is added by the test framework
resolve(window.results);
});
}
});
});
// await this.page.getByTestId('render-results').click();
return await resultsPromise;
}

/**
* @param {string} featureName
* @param {string} name
* @param {Record<string, any>} payload
*/
async simulateSubscriptionMessage(featureName, name, payload) {
await this.page.evaluate(simulateSubscriptionMessage, {
messagingContext: this.messagingContext(featureName),
name,
payload,
injectName: this.build.name,
});
}

/**
* @param {string} featureName
* @return {import("@duckduckgo/messaging").MessagingContext}
*/
messagingContext(featureName) {
const context = this.build.name === 'apple-isolated' ? 'contentScopeScriptsIsolated' : 'contentScopeScripts';
return {
context,
featureName,
env: 'development',
};
}

/**
* @return {string}
*/
isolatedVariant() {
return this.build.switch({
apple: () => 'apple-isolated',
windows: () => 'windows',
});
}

/**
* Helper for creating an instance per platform
* @param {import('@playwright/test').Page} page
* @param {Record<string, any>} use
*/
static create(page, use) {
// Read the configuration object to determine which platform we're testing against
const { platformInfo, build } = perPlatform(use);
return new ResultsCollector(page, build, platformInfo);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"unprotectedTemporary": [],
"features": {
"navigatorInterface": {
"state": "enabled",
"exceptions": []
},
"messageBridge": {
"exceptions": [],
"state": "enabled",
"settings": {
"exampleFeature": "disabled"
}
}
}
}
Loading
Loading