Skip to content

Commit c41a65e

Browse files
Support arbitrary depth config overloading of DOM properties (#383)
* Support arbitrary depth config overloading of DOM properties * Add support for unsupported var declaration * Move code generation out into a module and run snapshot tests * Better describe the snapshot testing * Windows \r sad
1 parent 25b6089 commit c41a65e

File tree

17 files changed

+359
-75
lines changed

17 files changed

+359
-75
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Sources/ContentScopeScripts/dist/
55
integration-test/extension/contentScope.js
66
integration-test/pages/build
77
packages/special-pages/pages/**/public
8+
unit-test/script-overload-snapshots/

.github/workflows/tests.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
name: Test
22

3-
on: [push, pull_request]
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
48

59
jobs:
610
unit:

CODEOWNERS

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ inject/chrome.js @jonathanKingston @sammacbeth
1414
inject/windows.js @jonathanKingston @q71114 @szanto90balazs
1515

1616
# Test owners
17-
integration-tests/test-pages/ @kdzwinel @jonathanKingston
17+
integration-tests/test-pages/ @kdzwinel @jonathanKingston
18+
unit-tests/script-overload-snapshots/ @shakyShane @jonathanKingston @englehardt

integration-test/test-pages/runtime-checks/config/script-overload.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020
"document.cookie": {
2121
"type": "string",
2222
"value": "testingThisOut"
23+
},
24+
"navigator.mediaSession.playbackState": {
25+
"type": "string",
26+
"value": "playing"
27+
},
28+
"navigator.mediaSession.doesNotExist.depth.a.lot": {
29+
"type": "string",
30+
"value": "boop"
2331
}
2432
}
2533
}

integration-test/test-pages/runtime-checks/pages/script-overload.html

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
n: navigator.userAgent,
2525
w: window.navigator.userAgent,
2626
g: globalThis.navigator.userAgent,
27+
pb: navigator.mediaSession.playbackState,
28+
wpb: window.navigator.mediaSession.playbackState,
29+
gpb: globalThis.navigator.mediaSession.playbackState,
30+
undef: navigator.mediaSession.doesNotExist.depth.a.lot
2731
}
2832
`;
2933
scriptElement.id = 'overloadedScript';
@@ -34,14 +38,23 @@
3438
const scripty = document.querySelector('script#overloadedScript');
3539
const nodeAndFakeNodeMatch = scripty === scriptElement;
3640
const expectedUserAgentOverload = 'testingThisOut';
41+
const expectedPlaybackState = 'playing';
3742
// We shouldn't break out of the context we're overloading
3843
const doesntMatchParentContext = navigator.userAgent !== 'testingThisOut';
3944
return [
4045
{ name: 'hadInspectorNode', result: hadInspectorNode, expected: true },
4146
{ name: 'instanceof matches HTMLScriptElement', result: instanceofResult, expected: true },
4247
{ name: 'script ran', result: window.scriptyRan, expected: true },
4348
{ name: 'node and fake node match', result: nodeAndFakeNodeMatch, expected: false },
44-
{ name: 'user agent is overloaded', result: window.scriptOutput, expected: { n: expectedUserAgentOverload, w: expectedUserAgentOverload, g: expectedUserAgentOverload } },
49+
{ name: 'user agent is overloaded', result: window.scriptOutput, expected: {
50+
n: expectedUserAgentOverload,
51+
w: expectedUserAgentOverload,
52+
g: expectedUserAgentOverload,
53+
pb: expectedPlaybackState,
54+
wpb: expectedPlaybackState,
55+
gpb: expectedPlaybackState,
56+
undef: 'boop'
57+
} },
4558
{ name: 'user agent doesnt match parent context', result: doesntMatchParentContext, expected: true }
4659
];
4760
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"lint": "eslint . && npm run tsc",
2727
"lint-no-output-globals": "eslint --no-eslintrc --config=build-output.eslintrc --no-ignore Sources/ContentScopeScripts/dist/contentScope.js",
2828
"lint-fix": "eslint . --fix && npm run tsc",
29+
"pretest-unit": "node scripts/generateOverloadSnapshots.js",
2930
"test-unit": "jasmine --config=unit-test/config.json",
3031
"test-int": "npm run build-integration && jasmine --config=integration-test/config.js",
3132
"test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int",

scripts/check-for-changes.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
git update-index --refresh
1+
git update-index --refresh
2+
git diff-index --patch-with-raw HEAD --
23
git diff-index --quiet HEAD --

scripts/generateOverloadSnapshots.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { wrapScriptCodeOverload } from '../src/features/runtime-checks/script-overload.js'
2+
3+
import { join } from 'node:path'
4+
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
5+
import { cwd } from './script-utils.js'
6+
const ROOT = join(cwd(import.meta.url))
7+
const configPath = join(ROOT, '../unit-test/script-overload-snapshots/config')
8+
9+
/**
10+
* Generates a bunch of snapshots for script-overload.js results using the configs.
11+
* These are used in unit-test/script-overload.js and ran automatically in automation so that we verify the output is correct.
12+
*/
13+
function generateOut () {
14+
if (process.platform === 'win32') {
15+
console.log('skipping test generation on windows')
16+
return
17+
}
18+
19+
const files = readdirSync(configPath)
20+
for (const fileName of files) {
21+
const config = readFileSync(join(configPath, fileName)).toString()
22+
const out = wrapScriptCodeOverload('console.log(1)', JSON.parse(config))
23+
const outName = fileName.replace(/.json$/, '.js')
24+
writeFileSync(join(ROOT, '../unit-test/script-overload-snapshots/out/', outName), out)
25+
}
26+
}
27+
28+
generateOut()

src/features/runtime-checks.js

Lines changed: 3 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/* global TrustedScriptURL, TrustedScript */
22

33
import ContentFeature from '../content-feature.js'
4-
import { DDGProxy, getStackTraceOrigins, getStack, matchHostname, injectGlobalStyles, createStyleElement, processAttr } from '../utils.js'
4+
import { DDGProxy, getStackTraceOrigins, getStack, matchHostname, injectGlobalStyles, createStyleElement } from '../utils.js'
5+
import { wrapScriptCodeOverload } from './runtime-checks/script-overload.js'
56

67
let stackDomains = []
78
let matchAllStackDomains = false
@@ -124,75 +125,7 @@ class DDGRuntimeChecks extends HTMLElement {
124125
// @ts-expect-error TrustedScript is not defined in the TS lib
125126
if (supportedTrustedTypes && el.textContent instanceof TrustedScript) return
126127

127-
const config = scriptOverload
128-
const processedConfig = {}
129-
for (const [key, value] of Object.entries(config)) {
130-
processedConfig[key] = processAttr(value)
131-
}
132-
// Don't do anything if the config is empty
133-
if (Object.keys(processedConfig).length === 0) return
134-
135-
/**
136-
* @param {*} scope
137-
* @param {Record<string, any>} outputs
138-
* @returns {Proxy}
139-
*/
140-
function constructProxy (scope, outputs) {
141-
return new Proxy(scope, {
142-
get (target, property, receiver) {
143-
const targetObj = target[property]
144-
if (typeof targetObj === 'function') {
145-
return (...args) => {
146-
return Reflect.apply(target[property], target, args)
147-
}
148-
} else {
149-
if (typeof property === 'string' && property in outputs) {
150-
return Reflect.get(outputs, property, receiver)
151-
}
152-
return Reflect.get(target, property, receiver)
153-
}
154-
}
155-
})
156-
}
157-
158-
let prepend = ''
159-
const aggregatedLookup = new Map()
160-
/* Convert the config into a map of scopePath -> { key: value } */
161-
for (const [key, value] of Object.entries(processedConfig)) {
162-
const path = key.split('.')
163-
const scopePath = path.slice(0, -1).join('.')
164-
const pathOut = path[path.length - 1]
165-
if (aggregatedLookup.has(scopePath)) {
166-
aggregatedLookup.get(scopePath)[pathOut] = value
167-
} else {
168-
aggregatedLookup.set(scopePath, {
169-
[pathOut]: value
170-
})
171-
}
172-
}
173-
174-
for (const [key, value] of aggregatedLookup) {
175-
const path = key.split('.')
176-
if (path.length !== 1) {
177-
console.error('Invalid config, currently only one layer depth is supported')
178-
continue
179-
}
180-
const scopeName = path[0]
181-
prepend += `
182-
let ${scopeName} = constructProxy(parentScope.${scopeName}, ${JSON.stringify(value)});
183-
`
184-
}
185-
const keysOut = [...aggregatedLookup.keys()].join(',\n')
186-
prepend += `
187-
const window = constructProxy(parentScope, {
188-
${keysOut}
189-
});
190-
const globalThis = constructProxy(parentScope, {
191-
${keysOut}
192-
});
193-
`
194-
const innerCode = prepend + el.textContent
195-
el.textContent = '(function (parentScope) {' + constructProxy.toString() + ' ' + innerCode + '})(globalThis)'
128+
el.textContent = wrapScriptCodeOverload(el.textContent, scriptOverload)
196129
}
197130

198131
/**
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { processAttr } from '../../utils.js'
2+
3+
/**
4+
* Code generates wrapping variables for code that is injected into the page
5+
* @param {*} code
6+
* @param {*} config
7+
* @returns {string}
8+
*/
9+
export function wrapScriptCodeOverload (code, config) {
10+
const processedConfig = {}
11+
for (const [key, value] of Object.entries(config)) {
12+
processedConfig[key] = processAttr(value)
13+
}
14+
// Don't do anything if the config is empty
15+
if (Object.keys(processedConfig).length === 0) return code
16+
17+
/**
18+
* @param {*} scope
19+
* @param {Record<string, any>} outputs
20+
* @returns {Proxy}
21+
*/
22+
function constructProxy (scope, outputs) {
23+
if (Object.is(scope)) {
24+
// Should not happen, but just in case fail safely
25+
console.error('Runtime checks: Scope must be an object', scope, outputs)
26+
return scope
27+
}
28+
return new Proxy(scope, {
29+
get (target, property, receiver) {
30+
const targetObj = target[property]
31+
if (typeof targetObj === 'function') {
32+
return (...args) => {
33+
return Reflect.apply(target[property], target, args)
34+
}
35+
} else {
36+
if (typeof property === 'string' && property in outputs) {
37+
return Reflect.get(outputs, property, receiver)
38+
}
39+
return Reflect.get(target, property, receiver)
40+
}
41+
}
42+
})
43+
}
44+
45+
let prepend = ''
46+
const aggregatedLookup = new Map()
47+
let currentScope = null
48+
/* Convert the config into a map of scopePath -> { key: value } */
49+
for (const [key, value] of Object.entries(processedConfig)) {
50+
const path = key.split('.')
51+
52+
currentScope = aggregatedLookup
53+
const pathOut = path[path.length - 1]
54+
// Traverse the path and create the nested objects
55+
path.slice(0, -1).forEach((pathPart, index) => {
56+
if (!currentScope.has(pathPart)) {
57+
currentScope.set(pathPart, new Map())
58+
}
59+
currentScope = currentScope.get(pathPart)
60+
})
61+
currentScope.set(pathOut, value)
62+
}
63+
64+
/**
65+
* Output scope variable definitions to arbitrary depth
66+
*/
67+
function stringifyScope (scope, scopePath) {
68+
let output = ''
69+
for (const [key, value] of scope) {
70+
const varOutName = [...scopePath, key].join('_')
71+
if (value instanceof Map) {
72+
const proxyName = `_proxyFor_${varOutName}`
73+
output += `
74+
let ${proxyName}
75+
if (${scopePath.join('?.')}?.${key} === undefined) {
76+
${proxyName} = Object.bind(null);
77+
} else {
78+
${proxyName} = ${scopePath.join('.')}.${key};
79+
}
80+
`
81+
const keys = Array.from(value.keys())
82+
output += stringifyScope(value, [...scopePath, key])
83+
const proxyOut = keys.map((keyName) => `${keyName}: ${[...scopePath, key, keyName].join('_')}`)
84+
output += `
85+
let ${varOutName} = constructProxy(${proxyName}, {${proxyOut.join(', ')}});
86+
`
87+
// If we're at the top level, we need to add the window and globalThis variables (Eg: let navigator = parentScope_navigator)
88+
if (scopePath.length === 1) {
89+
output += `
90+
let ${key} = ${varOutName};
91+
`
92+
}
93+
} else {
94+
output += `
95+
let ${varOutName} = ${JSON.stringify(value)};
96+
`
97+
}
98+
}
99+
return output
100+
}
101+
102+
prepend += stringifyScope(aggregatedLookup, ['parentScope'])
103+
// Stringify top level keys
104+
const keysOut = [...aggregatedLookup.keys()].map((keyName) => `${keyName}: parentScope_${keyName}`).join(',\n')
105+
prepend += `
106+
const window = constructProxy(parentScope, {
107+
${keysOut}
108+
});
109+
const globalThis = constructProxy(parentScope, {
110+
${keysOut}
111+
});
112+
`
113+
const innerCode = prepend + code
114+
return '(function (parentScope) {' + constructProxy.toString() + ' ' + innerCode + '})(globalThis)'
115+
}

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"packages"
1919
],
2020
"exclude": [
21+
"unit-test/script-overload-snapshots",
2122
"integration-test/pages",
2223
"integration-test/extension",
2324
"packages/special-pages/pages/**/public",

unit-test/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"spec_dir": "unit-test",
33
"spec_files": [
44
"**/*[sS]pec.?(m)js",
5-
"**/*.js"
5+
"**/*.js",
6+
"!unit-test/script-overload-snapshots/out/*.js"
67
],
78
"jsLoader": "import",
89
"helpers": [
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"navigator.userAgent": {
3+
"type": "string",
4+
"value": "testingThisOut"
5+
},
6+
"document.cookie": {
7+
"type": "string",
8+
"value": "testingThisOut"
9+
},
10+
"navigator.mediaSession.playbackState": {
11+
"type": "string",
12+
"value": "playing"
13+
},
14+
"navigator.mediaSession.doesNotExist.depth.a.lot": {
15+
"type": "string",
16+
"value": "boop"
17+
}
18+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"single": {
3+
"type": "string",
4+
"value": "meep"
5+
}
6+
}

0 commit comments

Comments
 (0)