Skip to content

Commit 216c858

Browse files
Match on frame conditional (#1725)
* Add enum devices debugging * Disable device enumeration remotely * Fix lint * Add frame flexibility * Move to webCompat * Conditional frame matching * Test case * Frame test changes, still not working * Fix up frame testing * Simplify test checks * Remove bundle for debugging
1 parent 089d908 commit 216c858

File tree

6 files changed

+251
-20
lines changed

6 files changed

+251
-20
lines changed

injected/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,41 @@ npm run fake-extension # Runs an example extension used within the integration t
7676

7777
> **For detailed development setup instructions, debugging tips, and test build workflows, see the [Development Utilities](./docs/development-utilities.md) and [Testing Guide](./docs/testing-guide.md).**
7878
79+
**Running specific tests:**
80+
81+
To run a specific test or test suite, you can use the `--grep` flag to filter tests by name:
82+
83+
```shell
84+
# Run tests containing "Test infra" in their name
85+
npx playwright test pages.spec.js --grep "Test infra"
86+
87+
# Run tests containing "Conditional frame matching" in their name
88+
npx playwright test pages.spec.js --grep "Conditional frame matching"
89+
90+
# Run tests in headed mode (shows browser window)
91+
npx playwright test pages.spec.js --grep "Test infra" --headed
92+
```
93+
94+
**Debugging tests:**
95+
96+
For debugging, you can run tests in headed mode and add debugging output:
97+
98+
```shell
99+
# Run with browser visible and debugging enabled
100+
npx playwright test pages.spec.js --grep "Test infra" --headed --debug
101+
```
102+
103+
#### Feature Build process
104+
105+
To produce all artefacts that are used by platforms, just run the `npm run build` command.
106+
This will create platform specific code within the `build` folder (that is not checked in)
107+
108+
```shell
109+
npm run build
110+
```
111+
79112
## Third-Party Libraries
113+
We make use of the following submodules:
80114
- [Adguard Scriptlets](https://github.com/AdguardTeam/Scriptlets)
81115

82116
For detailed information about any specific topic, please refer to the [documentation](./docs/).

injected/entry-points/integration.js

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,9 @@
11
import { load, init } from '../src/content-scope-features.js';
22
import { TestTransportConfig } from '../../messaging/index.js';
3-
function getTopLevelURL() {
4-
try {
5-
// FROM: https://stackoverflow.com/a/7739035/73479
6-
// FIX: Better capturing of top level URL so that trackers in embedded documents are not considered first party
7-
if (window.location !== window.parent.location) {
8-
return new URL(window.location.href !== 'about:blank' ? document.referrer : window.parent.location.href);
9-
} else {
10-
return new URL(window.location.href);
11-
}
12-
} catch (error) {
13-
return new URL(location.href);
14-
}
15-
}
3+
import { getTabUrl } from '../src/utils.js';
164

175
function generateConfig() {
18-
const topLevelUrl = getTopLevelURL();
6+
const topLevelUrl = getTabUrl();
197
return {
208
debug: false,
219
sessionKey: 'randomVal',
@@ -35,8 +23,8 @@ function generateConfig() {
3523
},
3624
],
3725
site: {
38-
domain: topLevelUrl.hostname,
39-
url: topLevelUrl.href,
26+
domain: topLevelUrl?.hostname || '',
27+
url: topLevelUrl?.href || '',
4028
isBroken: false,
4129
allowlisted: false,
4230
enabledFeatures: [
@@ -86,7 +74,7 @@ function mergeDeep(target, ...sources) {
8674
}
8775

8876
async function initCode() {
89-
const topLevelUrl = getTopLevelURL();
77+
const topLevelUrl = getTabUrl();
9078
const processedConfig = generateConfig();
9179

9280
// mock Messaging and allow for tests to intercept them
@@ -116,7 +104,7 @@ async function initCode() {
116104
// mark this phase as loaded
117105
setStatus('loaded');
118106

119-
if (!topLevelUrl.searchParams.has('wait-for-init-args')) {
107+
if (!topLevelUrl?.searchParams.has('wait-for-init-args')) {
120108
await init(processedConfig);
121109
setStatus('initialized');
122110
return;
@@ -128,11 +116,14 @@ async function initCode() {
128116
async (evt) => {
129117
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
130118
const merged = mergeDeep(processedConfig, evt.detail);
119+
// @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f
120+
window.__testContentScopeArgs = merged;
131121
// init features
132122
await init(merged);
133123

134124
// set status to initialized so that tests can resume
135125
setStatus('initialized');
126+
document.dispatchEvent(new CustomEvent('content-scope-init-completed'));
136127
},
137128
{ once: true },
138129
);

injected/integration-test/test-pages/infra/config/conditional-matching.json

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@
1313
"type": "number",
1414
"value": 222
1515
}
16+
},
17+
"testApi1": {
18+
"type": "descriptor",
19+
"getterValue": {
20+
"type": "number",
21+
"value": 100
22+
},
23+
"define": true
24+
},
25+
"testApi2": {
26+
"type": "descriptor",
27+
"getterValue": {
28+
"type": "number",
29+
"value": 200
30+
},
31+
"define": true
1632
}
1733
},
1834
"conditionalChanges": [
@@ -27,11 +43,39 @@
2743
"value": 333
2844
}
2945
]
46+
},
47+
{
48+
"condition": {
49+
"context": {
50+
"top": true
51+
}
52+
},
53+
"patchSettings": [
54+
{
55+
"op": "replace",
56+
"path": "/apiChanges/testApi1/getterValue/value",
57+
"value": 43339
58+
}
59+
]
60+
},
61+
{
62+
"condition": {
63+
"context": {
64+
"frame": true
65+
}
66+
},
67+
"patchSettings": [
68+
{
69+
"op": "replace",
70+
"path": "/apiChanges/testApi1/getterValue/value",
71+
"value": 43338
72+
}
73+
]
3074
}
3175
]
3276
}
3377
}
3478
},
3579
"unprotectedTemporary": []
36-
}
80+
}
3781

injected/integration-test/test-pages/infra/pages/conditional-matching.html

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
expected: 222,
2222
},
2323
];
24+
2425
const oldPathname = window.location.pathname;
2526
const newUrl = new URL(window.location.href);
2627
newUrl.pathname = '/test/test/path';
2728
window.history.pushState(null, '', newUrl.href);
2829
await new Promise((resolve) => requestIdleCallback(resolve));
30+
2931
results.push({
3032
name: 'Expect URL to be changed',
3133
result: window.location.pathname,
@@ -36,12 +38,14 @@
3638
result: navigator.hardwareConcurrency,
3739
expected: 333,
3840
});
41+
3942
const popStatePromise = new Promise((resolve) => {
4043
window.addEventListener('popstate', resolve, { once: true });
4144
});
4245
// Call pop state to revert the URL
4346
window.history.back();
4447
await popStatePromise;
48+
4549
results.push({
4650
name: 'Expect URL to be reverted',
4751
result: window.location.pathname,
@@ -56,6 +60,143 @@
5660
return results;
5761
});
5862

63+
test('Conditional frame matching', async () => {
64+
const results = [];
65+
const frame = document.createElement('iframe');
66+
const scriptTag = 'script';
67+
68+
// Set up listener for iframe-ready-for-init before the iframe loads
69+
const iframeReadyPromise = new Promise((resolve) => {
70+
const handler = (event) => {
71+
if (event.data && event.data.type === 'iframe-ready-for-init') {
72+
window.removeEventListener('message', handler);
73+
resolve();
74+
}
75+
};
76+
window.addEventListener('message', handler);
77+
});
78+
79+
frame.srcdoc = `
80+
<!DOCTYPE html>
81+
<html>
82+
<head></head>
83+
<body>
84+
<${scriptTag}>
85+
// Wait for parent to signal when to load content scope script
86+
window.addEventListener('message', function handler(event) {
87+
if (event.data && event.data.type === 'load-content-scope') {
88+
window.removeEventListener('message', handler);
89+
90+
// Now load the content scope script
91+
const script = document.createElement('script');
92+
script.src = '/build/contentScope.js';
93+
script.onload = () => {
94+
// Immediately dispatch the init args
95+
document.dispatchEvent(new CustomEvent('content-scope-init-args', { detail: event.data.args }));
96+
};
97+
document.head.appendChild(script);
98+
}
99+
});
100+
101+
// Notify parent we're ready to receive load signal
102+
window.parent.postMessage({ type: 'iframe-ready-for-init' }, '*');
103+
104+
window.addEventListener('message', (event) => {
105+
if (event.data === 'getTestApi1') {
106+
console.log('[iframe] testApi1 value:', window.testApi1);
107+
event.source.postMessage({ type: 'testApi1', value: window.testApi1 }, event.origin);
108+
}
109+
if (event.data === 'getTestApi2') {
110+
console.log('[iframe] testApi2 value:', window.testApi2);
111+
event.source.postMessage({ type: 'testApi2', value: window.testApi2 }, event.origin);
112+
}
113+
});
114+
// Listen for content-scope-init-args event and signal completion
115+
document.addEventListener('content-scope-init-args', function (evt) {
116+
// Signal complete
117+
window.parent.postMessage('content-scope-init-completed', '*');
118+
});
119+
120+
// Also listen for the content scope status to be 'initialized'
121+
const checkStatus = () => {
122+
if (window.__content_scope_status === 'initialized') {
123+
window.parent.postMessage('content-scope-fully-initialized', '*');
124+
} else {
125+
setTimeout(checkStatus, 10);
126+
}
127+
};
128+
checkStatus();
129+
</${scriptTag}>
130+
</body>
131+
</html>
132+
`;
133+
document.body.appendChild(frame);
134+
await new Promise((resolve) => (frame.onload = resolve));
135+
// Wait for the iframe to signal it's ready for init
136+
await iframeReadyPromise;
137+
138+
// Send content-scope-init-args to the iframe (use parent's args if available)
139+
const args = window.__testContentScopeArgs || null;
140+
if (!args) {
141+
throw new Error('args is blank');
142+
}
143+
144+
// Filter out non-serializable properties for postMessage
145+
const serializableArgs = JSON.parse(JSON.stringify(args));
146+
frame.contentWindow.postMessage({ type: 'load-content-scope', args: serializableArgs }, '*');
147+
// Wait for the content-scope script to be initialized in the iframe
148+
await new Promise((resolve) => {
149+
window.addEventListener('message', function handler(event) {
150+
if (event.data === 'content-scope-init-completed') {
151+
window.removeEventListener('message', handler);
152+
resolve();
153+
}
154+
});
155+
});
156+
157+
// Wait for content scope to be fully initialized
158+
await new Promise((resolve) => {
159+
window.addEventListener('message', function handler(event) {
160+
if (event.data === 'content-scope-fully-initialized') {
161+
window.removeEventListener('message', handler);
162+
resolve();
163+
}
164+
});
165+
});
166+
167+
// Add a small delay to ensure API modifications are applied
168+
await new Promise((resolve) => setTimeout(resolve, 100));
169+
170+
const testApi1Promise = new Promise((resolve) => {
171+
window.addEventListener(
172+
'message',
173+
(event) => {
174+
// Only resolve for testApi1 responses
175+
if (event.data && event.data.type === 'testApi1') {
176+
resolve(event.data.value);
177+
}
178+
},
179+
{ once: true },
180+
);
181+
frame.contentWindow.postMessage('getTestApi1', '*');
182+
});
183+
const testApi1Value = await testApi1Promise;
184+
console.log('[parent] testApi1 from iframe:', testApi1Value);
185+
console.log('[parent] testApi1 in parent:', window.testApi1);
186+
187+
results.push({
188+
name: 'Ensure iframe changes work',
189+
result: testApi1Value,
190+
expected: 43338,
191+
});
192+
results.push({
193+
name: 'Expect frame top modification works',
194+
result: window.testApi1,
195+
expected: 43339,
196+
});
197+
return results;
198+
});
199+
59200
// eslint-disable-next-line no-undef
60201
renderResults();
61202
</script>

injected/src/config-feature.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ export default class ConfigFeature {
119119
* @property {object} [experiment]
120120
* @property {string} [experiment.experimentName]
121121
* @property {string} [experiment.cohort]
122+
* @property {object} [context]
123+
* @property {boolean} [context.frame] - true if the condition applies to frames
124+
* @property {boolean} [context.top] - true if the condition applies to the top frame
122125
*/
123126

124127
/**
@@ -144,6 +147,7 @@ export default class ConfigFeature {
144147
/** @type {Record<string, (conditionBlock: ConditionBlock) => boolean>} */
145148
const conditionChecks = {
146149
domain: this._matchDomainConditional,
150+
context: this._matchContextConditional,
147151
urlPattern: this._matchUrlPatternConditional,
148152
experiment: this._matchExperimentConditional,
149153
minSupportedVersion: this._matchMinSupportedVersion,
@@ -208,6 +212,23 @@ export default class ConfigFeature {
208212
});
209213
}
210214

215+
/**
216+
* Takes a condition block and returns true if the current context matches the context.
217+
* @param {ConditionBlock} conditionBlock
218+
* @returns {boolean}
219+
*/
220+
_matchContextConditional(conditionBlock) {
221+
if (!conditionBlock.context) return false;
222+
const isFrame = window.self !== window.top;
223+
if (conditionBlock.context.frame && isFrame) {
224+
return true;
225+
}
226+
if (conditionBlock.context.top && !isFrame) {
227+
return true;
228+
}
229+
return false;
230+
}
231+
211232
/**
212233
* Takes a condtion block and returns true if the current url matches the urlPattern.
213234
* @param {ConditionBlock} conditionBlock

injected/src/features/web-compat.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export class WebCompat extends ContentFeature {
127127
if (this.getFeatureSettingEnabled('modifyCookies')) {
128128
this.modifyCookies();
129129
}
130-
if (this.getFeatureSettingEnabled('disableDeviceEnumeration') || this.getFeatureSettingEnabled('disableDeviceEnumerationFrames')) {
130+
if (this.getFeatureSettingEnabled('disableDeviceEnumeration')) {
131131
this.preventDeviceEnumeration();
132132
}
133133
if (this.getFeatureSettingEnabled('enumerateDevices')) {

0 commit comments

Comments
 (0)