Skip to content
This repository was archived by the owner on Oct 3, 2024. It is now read-only.

Commit f5bc68d

Browse files
Support for running user scripts in cross origin iframes
Introduces a new content method `frame = await tabs.frame(iframeElement)` which returns a new object upon which scripts can be run using `frame.run(() => {...})` and `frame.wait(() => {...})` and `frame.waitForNewPage(() => {...})`
1 parent 0a4f172 commit f5bc68d

29 files changed

+998
-118
lines changed

lib/asyncTimeout.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
'use strict';
22
const log = require('./logger')({MODULE: 'asyncTimeout'});
33

4-
const asyncTimeout = (func, timeout, timeoutMessage = `Asynchronous function "${func.name}" timed out after ${timeout}ms`) =>
4+
const asyncTimeout = (
5+
func,
6+
{timeout, timeoutMessage = `Asynchronous function "${func.name}" timed out after ${timeout}ms`, timeoutErrorName = 'Error'}
7+
) =>
58
async (...args) =>
69
new Promise((resolve, reject) => {
710
let timedOut = false;
@@ -11,7 +14,9 @@ const asyncTimeout = (func, timeout, timeoutMessage = `Asynchronous function "${
1114
() => {
1215
timer = 0;
1316
timedOut = true;
14-
reject(Error(timeoutMessage));
17+
const error = Error(timeoutMessage);
18+
error.name = timeoutErrorName;
19+
reject(error);
1520
},
1621
timeout
1722
);

lib/contentRpc/ContentRPC.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const asyncTimeout = require('../asyncTimeout');
44
const errorToObject = require('../errorToObject');
55
const createRpcRequestError = require('./createRpcRequestError');
66
const log = require('../logger')({hostname: 'content', MODULE: 'ContentRPC'});
7+
const {CONTENT_RPC_TIMEOUT_ERROR} = require('../scriptErrors');
78

89
const DEFAULT_CALL_TIMEOUT = 15004;
910

@@ -79,8 +80,11 @@ class ContentRPC {
7980

8081
const response = await asyncTimeout(
8182
contentRPC$call$sendMessage,
82-
timeout,
83-
`ContentRPC: Remote Call "${name}" timed out after ${timeout}ms`
83+
{
84+
timeout,
85+
timeoutErrorName: CONTENT_RPC_TIMEOUT_ERROR,
86+
timeoutMessage: `ContentRPC: Remote Call "${name}" timed out after ${timeout}ms`,
87+
}
8488
)();
8589

8690
if (!response) {

lib/contentRpc/TabContentRPC.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const asyncTimeout = require('../asyncTimeout');
44
const errorToObject = require('../errorToObject');
55
const createRpcRequestError = require('./createRpcRequestError');
66
const log = require('../logger')({hostname: 'background', MODULE: 'TabContentRPC'});
7+
const {CONTENT_RPC_TIMEOUT_ERROR} = require('../scriptErrors');
78

89
const DEFAULT_CALL_TIMEOUT = 15003;
910
const NOOP = () => {};
@@ -78,8 +79,11 @@ class TabContentRPCTab {
7879

7980
const response = await asyncTimeout(
8081
tabContentRPCTab$call$sendMessage,
81-
timeout,
82-
`TabContentRPC: Remote Call "${name}" timed out after ${timeout}ms`
82+
{
83+
timeout,
84+
timeoutErrorName: CONTENT_RPC_TIMEOUT_ERROR,
85+
timeoutMessage: `TabContentRPC: Remote Call "${name}" timed out after ${timeout}ms`,
86+
}
8387
)();
8488

8589
if (!response) {

lib/scriptErrors.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ const CONTENT_SCRIPT_ABORTED_ERROR = 'Openrunner:ContentScriptAbortedError';
5353
*/
5454
const SCRIPT_EXECUTION_TIMEOUT_ERROR = 'Openrunner:ScriptExecutionTimeoutError';
5555

56+
/**
57+
* The RPC call towards/from the content script has timeout out.
58+
*
59+
* Note: In most cases this error will be translated to a different one
60+
* @type {string}
61+
*/
62+
const CONTENT_RPC_TIMEOUT_ERROR = 'Openrunner:ContentRPCTimeoutError';
63+
64+
/**
65+
* The tabs.frame() call is waiting for the content of a frame (e.g. iframe) to become available, however this took too long.
66+
* This could happen for example if the frame src is set to "about:blank", or if the initial http request takes too long to complete.
67+
* @type {string}
68+
*/
69+
const FRAME_CONTENT_TIMEOUT_ERROR = 'Openrunner:FrameContentTimeoutError';
70+
5671
const createErrorShorthand = name => (message, cause = null) => {
5772
const err = new Error(message);
5873
err.name = name;
@@ -78,6 +93,8 @@ module.exports = {
7893
TRANSACTION_ABORTED_ERROR,
7994
CONTENT_SCRIPT_ABORTED_ERROR,
8095
SCRIPT_EXECUTION_TIMEOUT_ERROR,
96+
CONTENT_RPC_TIMEOUT_ERROR,
97+
FRAME_CONTENT_TIMEOUT_ERROR,
8198
illegalArgumentError: createErrorShorthand(ILLEGAL_ARGUMENT_ERROR),
8299
illegalStateError: createErrorShorthand(ILLEGAL_STATE_ERROR),
83100
navigateError: createErrorShorthand(NAVIGATE_ERROR),
@@ -86,5 +103,6 @@ module.exports = {
86103
transactionAbortedError: createErrorShorthand(TRANSACTION_ABORTED_ERROR),
87104
contentScriptAbortedError: createErrorShorthand(CONTENT_SCRIPT_ABORTED_ERROR),
88105
scriptExecutionTimeoutError: createErrorShorthand(SCRIPT_EXECUTION_TIMEOUT_ERROR),
106+
frameContentTimeoutError: createErrorShorthand(FRAME_CONTENT_TIMEOUT_ERROR),
89107
translateRpcErrorName,
90108
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"clone": "^2.1.1",
4848
"coveralls": "^3.0.0",
4949
"css.escape": "^1.5.1",
50+
"ejs": "^2.6.1",
5051
"eslint": ">=5.0.0",
5152
"eslint-plugin-import": "^2.7.0",
5253
"express": "^4.16.2",

runner-modules/tabs/lib/background/TabManager.js

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ const ScriptWindow = require('./ScriptWindow');
88
const TabTracker = require('./TabTracker');
99
const TabContentRPC = require('../../../../lib/contentRpc/TabContentRPC');
1010
const {resolveScriptContentEvalStack} = require('../../../../lib/errorParsing');
11-
const {mergeCoverageReports} = require('../../../../lib/mergeCoverage');
1211
const WaitForEvent = require('../../../../lib/WaitForEvent');
12+
const contentMethods = require('./contentMethods');
1313

1414
const TOP_FRAME_ID = 0;
1515

@@ -118,16 +118,7 @@ class TabManager extends EventEmitter {
118118
}
119119

120120
const frame = await this._registerAncestorFrames(tab, browserFrameId);
121-
122-
rpc.method('tabs.mainContentInit', () => this.handleTabMainContentInitialized(browserTabId, browserFrameId));
123-
rpc.method('tabs.contentInit', ({moduleName}) => this.handleTabModuleInitialized(browserTabId, browserFrameId, moduleName));
124-
rpc.method('core.submitCodeCoverage', contentCoverage => {
125-
// eslint-disable-next-line camelcase, no-undef
126-
const myCoverage = typeof __runner_coverage__ === 'object' && __runner_coverage__;
127-
if (myCoverage) {
128-
mergeCoverageReports(myCoverage, contentCoverage);
129-
}
130-
});
121+
rpc.methods(contentMethods(this, frame));
131122
this.emit('initializedTabRpc', {tab, frame, rpc});
132123
}
133124

@@ -154,16 +145,16 @@ class TabManager extends EventEmitter {
154145
handleTabMainContentInitialized(browserTabId, browserFrameId) {
155146
const tab = this.myTabs.getByBrowserTabId(browserTabId);
156147
const isMyTab = Boolean(tab);
157-
log.info({browserTabId, isMyTab}, 'Main tab content script has been initialized');
148+
log.info({browserTabId, browserFrameId, isMyTab}, 'Main tab content script has been initialized');
158149

159150
if (!isMyTab) {
160151
return; // the tab does not belong to this script
161152
}
162153

163154
this.myTabs.markUninitialized(browserTabId, browserFrameId);
164-
this.myTabs.expectInitToken(browserTabId, browserFrameId, 'tabs');
165155
const frame = tab.getFrame(browserFrameId);
166-
assert.isOk(frame, 'frame');
156+
assert.isOk(frame, `Frame ${browserFrameId} has not been registered for tab ${browserTabId}`);
157+
this.myTabs.expectInitToken(browserTabId, browserFrameId, 'tabs');
167158
const rpc = this.tabContentRPC.get(browserTabId, browserFrameId);
168159

169160
const files = [];
@@ -204,6 +195,11 @@ class TabManager extends EventEmitter {
204195

205196
const rpc = this.tabContentRPC.get(browserTabId, browserFrameId);
206197
rpc.callAndForget('tabs.initializedTabContent');
198+
199+
if (frame && frame.hasParentFrame) {
200+
const parentRpc = this.tabContentRPC.get(browserTabId, frame.parentBrowserFrameId);
201+
parentRpc.callAndForget('tabs.childFrameInitialized', {browserFrameId});
202+
}
207203
}
208204
}
209205

runner-modules/tabs/lib/background/TabTracker.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ const {generate: generateShortId} = require('shortid');
33
const SymbolTree = require('symbol-tree');
44
const {assert} = require('chai');
55

6+
const WaitForEvent = require('../../../../lib/WaitForEvent');
7+
68
const frameTree = new SymbolTree();
79
const NULL_FRAME_ID = -1; // same as WebExtension
810
const TOP_FRAME_ID = 0;
@@ -18,6 +20,7 @@ class Frame {
1820
this.destroyed = false;
1921
this.currentContentId = null;
2022
this.pendingInitTokens = new Set();
23+
this.childFrameTokenWait = new WaitForEvent(); // key is [frameToken]
2124
this.public = {
2225
get browserFrameId() {
2326
return self.browserFrameId;
@@ -65,9 +68,18 @@ class Frame {
6568
return self.currentContentId;
6669
},
6770

71+
async waitForChildFrameToken(token) {
72+
return await self.childFrameTokenWait.wait([String(token)]);
73+
},
74+
6875
isChild(otherFrame) {
6976
return self.isChild(otherFrame);
7077
},
78+
79+
resolveChildFrameToken(token, childFrame) {
80+
assert.isTrue(this.isChild(childFrame), 'Frame#resolveChildFrameToken() was called with a frame that is not a child');
81+
self.childFrameTokenWait.resolve([String(token)], Number(childFrame.browserFrameId));
82+
},
7183
};
7284
Object.freeze(this.public);
7385
Object.seal(this);
@@ -198,7 +210,7 @@ class Tab {
198210
}
199211

200212
frame.destroyed = true;
201-
this.frames.delete(browserFrameId);
213+
this.frames.delete(frame.browserFrameId);
202214
frameTree.remove(frame);
203215
}
204216
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
'use strict';
2+
const {assert} = require('chai');
3+
4+
const scriptFrameCommands = require('./scriptFrameCommands');
5+
const {mergeCoverageReports} = require('../../../../lib/mergeCoverage');
6+
7+
module.exports = (tabManager, frame) => {
8+
const {tab, browserFrameId} = frame;
9+
const {id: tabId, browserTabId} = tab;
10+
11+
const submitCodeCoverage = contentCoverage => {
12+
// eslint-disable-next-line camelcase, no-undef
13+
const myCoverage = typeof __runner_coverage__ === 'object' && __runner_coverage__;
14+
if (myCoverage) {
15+
mergeCoverageReports(myCoverage, contentCoverage);
16+
}
17+
};
18+
19+
const waitForChildFrameToken = async (token) => {
20+
return await frame.waitForChildFrameToken(String(token)); // returns the frameId
21+
};
22+
23+
const receivedFrameToken = async (token) => {
24+
frame.parentFrame.resolveChildFrameToken(token, frame);
25+
};
26+
27+
const validateFrameId = frameId => {
28+
// only allowed to execute commands on child frames (not ancestors, children of children, etc)
29+
const childFrame = tab.getFrame(frameId);
30+
assert.isTrue(frame.isChild(childFrame), 'Invalid frameId');
31+
};
32+
33+
const run = async ({frameId, code, arg}) => {
34+
validateFrameId(frameId);
35+
return await scriptFrameCommands.run({tabManager, tabId, frameId, code, arg});
36+
};
37+
38+
const waitForNewPage = async ({frameId, code, arg, timeoutMs}) => {
39+
validateFrameId(frameId);
40+
return await scriptFrameCommands.waitForNewPage({tabManager, tabId, frameId, code, arg, timeoutMs});
41+
};
42+
43+
const wait = async ({frameId, code, arg}) => {
44+
validateFrameId(frameId);
45+
return await scriptFrameCommands.wait({tabManager, tabId, frameId, code, arg});
46+
};
47+
48+
return new Map([
49+
['tabs.mainContentInit', () => tabManager.handleTabMainContentInitialized(browserTabId, browserFrameId)],
50+
['tabs.contentInit', ({moduleName}) => tabManager.handleTabModuleInitialized(browserTabId, browserFrameId, moduleName)],
51+
['core.submitCodeCoverage', submitCodeCoverage],
52+
['tabs.waitForChildFrameToken', waitForChildFrameToken],
53+
['tabs.receivedFrameToken', receivedFrameToken],
54+
['tabs.frameRun', run],
55+
['tabs.frameWait', wait],
56+
['tabs.frameWaitForNewPage', waitForNewPage],
57+
]);
58+
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use strict';
2+
const {illegalArgumentError, newPageWaitTimeoutError, CONTENT_SCRIPT_ABORTED_ERROR} = require('../../../../lib/scriptErrors');
3+
const delay = require('../../../../lib/delay');
4+
5+
const validateTabId = (method, tabManager, tabId) => {
6+
if (typeof tabId !== 'string' || !tabManager.hasTab(tabId)) {
7+
throw illegalArgumentError(`tabs.${method}(): invalid argument \`tabId\``);
8+
}
9+
};
10+
11+
const validateFrameId = (method, tabManager, tabId, frameId) => {
12+
if (typeof frameId !== 'number' ||
13+
frameId < 0 ||
14+
!Number.isFinite(frameId) ||
15+
!tabManager.getTab(tabId).hasFrame(frameId)
16+
) {
17+
throw illegalArgumentError(`tabs.${method}(): invalid argument \`frameId\` (${tabId} : ${frameId})`);
18+
}
19+
};
20+
21+
const run = async ({tabManager, tabId, frameId, code, arg}) => {
22+
validateTabId('run', tabManager, tabId);
23+
validateFrameId('run', tabManager, tabId, frameId);
24+
if (typeof code !== 'string') {
25+
throw illegalArgumentError('tabs.run(): invalid argument `code`');
26+
}
27+
28+
const metadata = Object.freeze({
29+
runBeginTime: Date.now(),
30+
});
31+
32+
return await tabManager.runContentScript(tabId, frameId, code, {arg, metadata});
33+
};
34+
35+
const waitForNewPage = async ({tabManager, tabId, frameId, code, arg, timeoutMs}) => {
36+
validateTabId('waitForNewPage', tabManager, tabId);
37+
validateFrameId('waitForNewPage', tabManager, tabId, frameId);
38+
if (typeof code !== 'string') {
39+
throw illegalArgumentError('tabs.waitForNewPage(): invalid argument `code`');
40+
}
41+
42+
const metadata = Object.freeze({
43+
runBeginTime: Date.now(),
44+
});
45+
46+
const waitForNewContentPromise = tabManager.waitForNewContent(tabId, frameId);
47+
try {
48+
const {reject} = await tabManager.runContentScript(tabId, frameId, code, {arg, metadata});
49+
// do not return `resolve` to avoid timing inconsistencies (e.g. the script may have been canceled because of the navigation)
50+
if (reject) {
51+
return {reject};
52+
}
53+
}
54+
catch (err) {
55+
// ignore errors which are caused by navigating away; that is what we are expecting
56+
if (err.name !== CONTENT_SCRIPT_ABORTED_ERROR) {
57+
throw err;
58+
}
59+
}
60+
61+
// the timeout does not start counting until the content script has completed its execution; this is by design
62+
await Promise.race([
63+
waitForNewContentPromise,
64+
delay(timeoutMs).then(() => Promise.reject(
65+
newPageWaitTimeoutError(`Waiting for a new page timed out after ${timeoutMs / 1000} seconds`)
66+
)),
67+
]);
68+
69+
return {reject: null};
70+
};
71+
72+
const wait = async ({tabManager, tabId, frameId, code, arg}) => {
73+
validateTabId('wait', tabManager, tabId);
74+
validateFrameId('wait', tabManager, tabId, frameId);
75+
if (typeof code !== 'string') {
76+
throw illegalArgumentError('tabs.wait(): invalid argument `code`');
77+
}
78+
79+
const waitMetadata = Object.freeze({
80+
waitBeginTime: Date.now(),
81+
});
82+
83+
const attempt = async (attemptNumber) => {
84+
try {
85+
const metadata = Object.assign({
86+
attemptNumber,
87+
runBeginTime: Date.now(),
88+
}, waitMetadata);
89+
return await tabManager.runContentScript(tabId, frameId, code, {arg, metadata});
90+
}
91+
catch (err) {
92+
if (err.name === CONTENT_SCRIPT_ABORTED_ERROR) {
93+
// runContentScript wait for a new tab to initialize
94+
return await attempt(attemptNumber + 1);
95+
}
96+
97+
throw err;
98+
}
99+
};
100+
101+
return await attempt(0);
102+
};
103+
104+
module.exports = {run, waitForNewPage, wait};

0 commit comments

Comments
 (0)