Skip to content

Commit 5f08041

Browse files
Add patching changes
1 parent 847694d commit 5f08041

File tree

9 files changed

+155
-39
lines changed

9 files changed

+155
-39
lines changed

injected/entry-points/integration.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function generateConfig() {
2424
},
2525
site: {
2626
domain: topLevelUrl.hostname,
27+
url: topLevelUrl.href,
2728
isBroken: false,
2829
allowlisted: false,
2930
enabledFeatures: [

injected/src/config-feature.js

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { immutableJSONPatch } from 'immutable-json-patch';
22
import { camelcase, computeEnabledFeatures, matchHostname, parseFeatureSettings } from './utils.js';
3+
import { URLPattern } from 'urlpattern-polyfill';
34

45
export default class ConfigFeature {
56
/** @type {import('./utils.js').RemoteConfig | undefined} */
@@ -48,19 +49,55 @@ export default class ConfigFeature {
4849
* @protected
4950
*/
5051
matchDomainFeatureSetting(featureKeyName) {
51-
const domain = this.args?.site.domain;
52-
if (!domain) return [];
5352
const domains = this._getFeatureSettings()?.[featureKeyName] || [];
5453
return domains.filter((rule) => {
55-
if (Array.isArray(rule.domain)) {
56-
return rule.domain.some((domainRule) => {
57-
return matchHostname(domain, domainRule);
58-
});
59-
}
60-
return matchHostname(domain, rule.domain);
54+
return this.matchConditionalChanges(rule);
6155
});
6256
}
6357

58+
/**
59+
* Used to match conditional changes for a settings feature.
60+
* @typedef {object} ConditionBlock
61+
* @property {string[] | string} domain?
62+
* @property {object} urlPattern?
63+
*/
64+
65+
/**
66+
* Takes a conditional block and returns true if it applies.
67+
* All conditions must be met to return true.
68+
* @param {ConditionBlock} conditionBlock
69+
* @returns {boolean}
70+
*/
71+
matchConditionalChanges(conditionBlock) {
72+
// Check domain condition
73+
if (conditionBlock.domain && !this._matchDomainConditional(conditionBlock)) {
74+
return false;
75+
}
76+
77+
// Check URL pattern condition
78+
if (conditionBlock.urlPattern) {
79+
const pattern = new URLPattern(conditionBlock.urlPattern);
80+
if (!this.args?.site.url || !pattern.test(this.args?.site.url)) {
81+
return false;
82+
}
83+
}
84+
85+
// All conditions are met
86+
return true;
87+
}
88+
89+
_matchDomainConditional(conditionBlock) {
90+
if (!conditionBlock.domain) return false;
91+
const domain = this.args?.site.domain;
92+
if (!domain) return false;
93+
if (Array.isArray(conditionBlock.domain)) {
94+
return conditionBlock.domain.some((domainRule) => {
95+
return matchHostname(domain, domainRule);
96+
});
97+
}
98+
return matchHostname(domain, conditionBlock.domain);
99+
}
100+
64101
/**
65102
* Return the settings object for a feature
66103
* @param {string} [featureName] - The name of the feature to get the settings for; defaults to the name of the feature
@@ -131,13 +168,20 @@ export default class ConfigFeature {
131168
*/
132169
getFeatureSetting(featureKeyName, featureName) {
133170
let result = this._getFeatureSettings(featureName);
134-
if (featureKeyName === 'domains') {
135-
throw new Error('domains is a reserved feature setting key name');
171+
if (featureKeyName in ['domains', 'conditionalChanges']) {
172+
throw new Error(`${featureKeyName} is a reserved feature setting key name`);
136173
}
137-
const domainMatch = [...this.matchDomainFeatureSetting('domains')].sort((a, b) => {
138-
return a.domain.length - b.domain.length;
139-
});
140-
for (const match of domainMatch) {
174+
// We only support one of these keys at a time, where conditionalChanges takes precedence
175+
// TODO should we rename these?
176+
// TODO should we only support conditionalChanges to support other types of settings?
177+
let conditionalMatches = [];
178+
// Presence check using result to avoid the [] default response
179+
if (result?.conditionalChanges) {
180+
conditionalMatches = this.matchDomainFeatureSetting('conditionalChanges');
181+
} else {
182+
conditionalMatches = this.matchDomainFeatureSetting('domains');
183+
}
184+
for (const match of conditionalMatches) {
141185
if (match.patchSettings === undefined) {
142186
continue;
143187
}

injected/src/content-feature.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import ConfigFeature from './config-feature.js';
1717
/**
1818
* @typedef {object} Site
1919
* @property {string | null} domain
20+
* @property {string | null} url
2021
* @property {boolean} [isBroken]
2122
* @property {boolean} [allowlisted]
2223
* @property {string[]} [enabledFeatures]

injected/src/utils.js

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -118,31 +118,40 @@ export function hasThirdPartyOrigin(scriptOrigins) {
118118
}
119119

120120
/**
121-
* Best guess effort of the tabs hostname; where possible always prefer the args.site.domain
122-
* @returns {string|null} inferred tab hostname
121+
* @returns {URL | null}
123122
*/
124-
export function getTabHostname() {
125-
let framingOrigin = null;
123+
export function getTabUrl() {
124+
let framingURLString = null;
126125
try {
127126
// @ts-expect-error - globalThis.top is possibly 'null' here
128-
framingOrigin = globalThis.top.location.href;
127+
framingURLString = globalThis.top.location.href;
129128
} catch {
130-
framingOrigin = globalThis.document.referrer;
129+
framingURLString = globalThis.document.referrer;
131130
}
132131

132+
let framingURL;
133+
try {
134+
framingURL = new URL(framingURLString);
135+
} catch {
136+
framingURL = null;
137+
}
138+
return framingURL;
139+
}
140+
141+
/**
142+
* Best guess effort of the tabs hostname; where possible always prefer the args.site.domain
143+
* @returns {string|null} inferred tab hostname
144+
*/
145+
export function getTabHostname() {
146+
let topURLString = getTabUrl()?.hostname;
147+
// For about:blank, we can't get the top location
133148
// Not supported in Firefox
134149
if ('ancestorOrigins' in globalThis.location && globalThis.location.ancestorOrigins.length) {
135150
// ancestorOrigins is reverse order, with the last item being the top frame
136-
framingOrigin = globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1);
137-
}
138-
139-
try {
140-
// @ts-expect-error - framingOrigin is possibly 'null' here
141-
framingOrigin = new URL(framingOrigin).hostname;
142-
} catch {
143-
framingOrigin = null;
151+
// @ts-expect-error - globalThis.top is possibly 'null' here
152+
topURLString = globalThis.location.ancestorOrigins.item(globalThis.location.ancestorOrigins.length - 1);
144153
}
145-
return framingOrigin;
154+
return topURLString || null;
146155
}
147156

148157
/**
@@ -524,6 +533,7 @@ export function computeLimitedSiteObject() {
524533
const topLevelHostname = getTabHostname();
525534
return {
526535
domain: topLevelHostname,
536+
url: getTabUrl()?.href,
527537
};
528538
}
529539

injected/unit-test/content-feature.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('ContentFeature class', () => {
1616
const args = {
1717
site: {
1818
domain: 'beep.example.com',
19+
url: 'http://beep.example.com',
1920
},
2021
featureSettings: {
2122
test: {
@@ -48,6 +49,54 @@ describe('ContentFeature class', () => {
4849
expect(didRun).withContext('Should run').toBeTrue();
4950
});
5051

52+
it('Should trigger getFeatureSettingEnabled for the correct domain', () => {
53+
let didRun = false;
54+
class MyTestFeature2 extends ContentFeature {
55+
init() {
56+
expect(this.getFeatureSetting('test')).toBe('enabled3');
57+
expect(this.getFeatureSetting('otherTest')).toBe('enabled');
58+
expect(this.getFeatureSetting('otherOtherTest')).toBe('ding');
59+
expect(this.getFeatureSetting('arrayTest')).toBe('enabledArray');
60+
didRun = true;
61+
}
62+
}
63+
64+
const args = {
65+
site: {
66+
domain: 'beep.example.com',
67+
url: 'http://beep.example.com',
68+
},
69+
featureSettings: {
70+
test: {
71+
test: 'enabled',
72+
otherTest: 'disabled',
73+
otherOtherTest: 'ding',
74+
arrayTest: 'enabled',
75+
conditionalChanges: [
76+
{
77+
domain: 'example.com',
78+
patchSettings: [
79+
{ op: 'replace', path: '/test', value: 'enabled2' },
80+
{ op: 'replace', path: '/otherTest', value: 'enabled' },
81+
],
82+
},
83+
{
84+
domain: 'beep.example.com',
85+
patchSettings: [{ op: 'replace', path: '/test', value: 'enabled3' }],
86+
},
87+
{
88+
domain: ['meep.com', 'example.com'],
89+
patchSettings: [{ op: 'replace', path: '/arrayTest', value: 'enabledArray' }],
90+
},
91+
],
92+
},
93+
},
94+
};
95+
const me = new MyTestFeature2('test', {}, args);
96+
me.callInit(args);
97+
expect(didRun).withContext('Should run').toBeTrue();
98+
});
99+
51100
describe('addDebugFlag', () => {
52101
class MyTestFeature extends ContentFeature {
53102
// eslint-disable-next-line

injected/unit-test/helpers/polyfill-process-globals.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export function polyfillProcessGlobals() {
22
// Store original values to restore later
33
const originalDocument = globalThis.document;
44
const originalLocation = globalThis.location;
5+
const originalTop = globalThis.top;
56

67
// Apply the patch
78
globalThis.document = {
@@ -15,17 +16,16 @@ export function polyfillProcessGlobals() {
1516
},
1617
};
1718

18-
globalThis.location = {
19-
href: 'http://localhost:8080',
20-
// @ts-expect-error - ancestorOrigins is not defined in the type definition
21-
ancestorOrigins: {
22-
length: 0,
23-
},
24-
};
19+
globalThis.location = globalThis.document.location;
20+
21+
globalThis.top = Object.assign({}, originalTop, {
22+
location: globalThis.location,
23+
});
2524

2625
// Return a cleanup function
2726
return function cleanup() {
2827
globalThis.document = originalDocument;
2928
globalThis.location = originalLocation;
29+
globalThis.top = originalTop
3030
};
3131
}

injected/unit-test/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe('Helpers checks', () => {
6969
expect(processedConfig).toEqual({
7070
site: {
7171
domain: 'localhost',
72+
url: 'http://localhost:8080/',
7273
isBroken: false,
7374
allowlisted: false,
7475
// testFeatureTooBig is not enabled because it's minSupportedVersion is 100
@@ -147,6 +148,7 @@ describe('Helpers checks', () => {
147148
expect(processedConfig).toEqual({
148149
site: {
149150
domain: 'localhost',
151+
url: 'http://localhost:8080/',
150152
isBroken: false,
151153
allowlisted: false,
152154
// testFeatureTooBig is not enabled because it's minSupportedVersion is 100

package-lock.json

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"stylelint-csstree-validator": "^3.0.0",
5050
"typedoc": "^0.27.9",
5151
"typescript": "^5.8.2",
52-
"typescript-eslint": "^8.27.0"
52+
"typescript-eslint": "^8.27.0",
53+
"urlpattern-polyfill": "^10.0.0"
5354
},
5455
"dependencies": {
5556
"immutable-json-patch": "^6.0.1"

0 commit comments

Comments
 (0)