Skip to content

Commit

Permalink
Support for running user scripts in cross origin iframes
Browse files Browse the repository at this point in the history
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(() => {...})`
  • Loading branch information
Joris-van-der-Wel committed Aug 20, 2018
1 parent b170728 commit 1ca8613
Show file tree
Hide file tree
Showing 11 changed files with 388 additions and 107 deletions.
21 changes: 10 additions & 11 deletions runner-modules/tabs/lib/background/TabManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ const ScriptWindow = require('./ScriptWindow');
const TabTracker = require('./TabTracker');
const TabContentRPC = require('../../../../lib/contentRpc/TabContentRPC');
const {resolveScriptContentEvalStack} = require('../../../../lib/errorParsing');
const {mergeCoverageReports} = require('../../../../lib/mergeCoverage');
const WaitForEvent = require('../../../../lib/WaitForEvent');
const contentMethods = require('./contentMethods');

const TOP_FRAME_ID = 0;

Expand Down Expand Up @@ -118,23 +118,22 @@ class TabManager extends EventEmitter {
}

const frame = await this._registerAncestorFrames(tab, browserFrameId);

rpc.method('tabs.mainContentInit', () => this.handleTabMainContentInitialized(browserTabId, browserFrameId));
rpc.method('tabs.contentInit', ({moduleName}) => this.handleTabModuleInitialized(browserTabId, browserFrameId, moduleName));
rpc.method('core.submitCodeCoverage', contentCoverage => {
// eslint-disable-next-line camelcase, no-undef
const myCoverage = typeof __runner_coverage__ === 'object' && __runner_coverage__;
if (myCoverage) {
mergeCoverageReports(myCoverage, contentCoverage);
}
});
rpc.methods(contentMethods(this, frame));
this.emit('initializedTabRpc', {tab, frame, rpc});
}

handleWebNavigationOnBeforeNavigate({tabId: browserTabId, frameId: browserFrameId, url}) {
try {
log.debug({browserTabId, browserFrameId, url}, 'browser.webNavigation.onBeforeNavigate');
this.myTabs.markUninitialized(browserTabId, browserFrameId);
const tab = this.myTabs.getByBrowserTabId(browserTabId);
if (tab) {
const frame = tab.getFrame(browserFrameId);
if (frame && frame.hasParentFrame) {
const rpc = this.tabContentRPC.get(browserTabId, frame.parentBrowserFrameId);
rpc.callAndForget('tabs.childFrameBeforeNavigate', {browserFrameId, url});
}
}
}
catch (err) {
log.error({err}, 'Error in browser.webNavigation.onBeforeNavigate');
Expand Down
12 changes: 12 additions & 0 deletions runner-modules/tabs/lib/background/TabTracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const {generate: generateShortId} = require('shortid');
const SymbolTree = require('symbol-tree');
const {assert} = require('chai');

const WaitForEvent = require('../../../../lib/WaitForEvent');

const frameTree = new SymbolTree();
const NULL_FRAME_ID = -1; // same as WebExtension
const TOP_FRAME_ID = 0;
Expand All @@ -18,6 +20,7 @@ class Frame {
this.destroyed = false;
this.currentContentId = null;
this.pendingInitTokens = new Set();
this.childFrameTokenWait = new WaitForEvent(); // key is [frameToken]
this.public = {
get browserFrameId() {
return self.browserFrameId;
Expand Down Expand Up @@ -65,9 +68,18 @@ class Frame {
return self.currentContentId;
},

async waitForChildFrameToken(token) {
return await self.childFrameTokenWait.wait([String(token)]);
},

isChild(otherFrame) {
return self.isChild(otherFrame);
},

resolveChildFrameToken(token, childFrame) {
assert.isTrue(this.isChild(childFrame), 'Frame#resolveChildFrameToken() was called with a frame that is not a child');
self.childFrameTokenWait.resolve([String(token)], Number(childFrame.browserFrameId));
},
};
Object.freeze(this.public);
Object.seal(this);
Expand Down
58 changes: 58 additions & 0 deletions runner-modules/tabs/lib/background/contentMethods.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use strict';
const {assert} = require('chai');

const scriptFrameCommands = require('./scriptFrameCommands');
const {mergeCoverageReports} = require('../../../../lib/mergeCoverage');

module.exports = (tabManager, frame) => {
const {tab, browserFrameId} = frame;
const {id: tabId, browserTabId} = tab;

const submitCodeCoverage = contentCoverage => {
// eslint-disable-next-line camelcase, no-undef
const myCoverage = typeof __runner_coverage__ === 'object' && __runner_coverage__;
if (myCoverage) {
mergeCoverageReports(myCoverage, contentCoverage);
}
};

const waitForChildFrameToken = async (token) => {
return await frame.waitForChildFrameToken(String(token)); // returns the frameId
};

const receivedFrameToken = async (token) => {
frame.parentFrame.resolveChildFrameToken(token, frame);
};

const validateFrameId = frameId => {
// only allowed to execute commands on child frames (not ancestors, children of children, etc)
const childFrame = tab.getFrame(frameId);
assert.isTrue(frame.isChild(childFrame), 'Invalid frame id');
};

const run = async ({frameId, code, arg}) => {
validateFrameId(frameId);
return await scriptFrameCommands.run({tabManager, tabId, frameId, code, arg});
};

const waitForNewPage = async ({frameId, code, arg, timeoutMs}) => {
validateFrameId(frameId);
return await scriptFrameCommands.waitForNewPage({tabManager, tabId, frameId, code, arg, timeoutMs});
};

const wait = async ({frameId, code, arg}) => {
validateFrameId(frameId);
return await scriptFrameCommands.wait({tabManager, tabId, frameId, code, arg});
};

return new Map([
['tabs.mainContentInit', () => tabManager.handleTabMainContentInitialized(browserTabId, browserFrameId)],
['tabs.contentInit', ({moduleName}) => tabManager.handleTabModuleInitialized(browserTabId, browserFrameId, moduleName)],
['core.submitCodeCoverage', submitCodeCoverage],
['tabs.waitForChildFrameToken', waitForChildFrameToken],
['tabs.receivedFrameToken', receivedFrameToken],
['tabs.frameRun', run],
['tabs.frameWait', wait],
['tabs.frameWaitForNewPage', waitForNewPage],
]);
};
104 changes: 104 additions & 0 deletions runner-modules/tabs/lib/background/scriptFrameCommands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use strict';
const {illegalArgumentError, newPageWaitTimeoutError, CONTENT_SCRIPT_ABORTED_ERROR} = require('../../../../lib/scriptErrors');
const delay = require('../../../../lib/delay');

const validateTabId = (method, tabManager, tabId) => {
if (typeof tabId !== 'string' || !tabManager.hasTab(tabId)) {
throw illegalArgumentError(`tabs.${method}(): invalid argument \`tabId\``);
}
};

const validateFrameId = (method, tabManager, tabId, frameId) => {
if (typeof frameId !== 'number' ||
frameId < 0 ||
!Number.isFinite(frameId) ||
!tabManager.getTab(tabId).hasFrame(frameId)
) {
throw illegalArgumentError(`tabs.${method}(): invalid argument \`frameId\` (${tabId} : ${frameId})`);
}
};

const run = async ({tabManager, tabId, frameId, code, arg}) => {
validateTabId('run', tabManager, tabId);
validateFrameId('run', tabManager, tabId, frameId);
if (typeof code !== 'string') {
throw illegalArgumentError('tabs.run(): invalid argument `code`');
}

const metadata = Object.freeze({
runBeginTime: Date.now(),
});

return await tabManager.runContentScript(tabId, frameId, code, {arg, metadata});
};

const waitForNewPage = async ({tabManager, tabId, frameId, code, arg, timeoutMs}) => {
validateTabId('waitForNewPage', tabManager, tabId);
validateFrameId('waitForNewPage', tabManager, tabId, frameId);
if (typeof code !== 'string') {
throw illegalArgumentError('tabs.waitForNewPage(): invalid argument `code`');
}

const metadata = Object.freeze({
runBeginTime: Date.now(),
});

const waitForNewContentPromise = tabManager.waitForNewContent(tabId, frameId);
try {
const {reject} = await tabManager.runContentScript(tabId, frameId, code, {arg, metadata});
// do not return `resolve` to avoid timing inconsistencies (e.g. the script may have been canceled because of the navigation)
if (reject) {
return {reject};
}
}
catch (err) {
// ignore errors which are caused by navigating away; that is what we are expecting
if (err.name !== CONTENT_SCRIPT_ABORTED_ERROR) {
throw err;
}
}

// the timeout does not start counting until the content script has completed its execution; this is by design
await Promise.race([
waitForNewContentPromise,
delay(timeoutMs).then(() => Promise.reject(
newPageWaitTimeoutError(`Waiting for a new page timed out after ${timeoutMs / 1000} seconds`)
)),
]);

return {reject: null};
};

const wait = async ({tabManager, tabId, frameId, code, arg}) => {
validateTabId('wait', tabManager, tabId);
validateFrameId('wait', tabManager, tabId, frameId);
if (typeof code !== 'string') {
throw illegalArgumentError('tabs.wait(): invalid argument `code`');
}

const waitMetadata = Object.freeze({
waitBeginTime: Date.now(),
});

const attempt = async (attemptNumber) => {
try {
const metadata = Object.assign({
attemptNumber,
runBeginTime: Date.now(),
}, waitMetadata);
return await tabManager.runContentScript(tabId, frameId, code, {arg, metadata});
}
catch (err) {
if (err.name === CONTENT_SCRIPT_ABORTED_ERROR) {
// runContentScript wait for a new tab to initialize
return await attempt(attemptNumber + 1);
}

throw err;
}
};

return await attempt(0);
};

module.exports = {run, waitForNewPage, wait};
88 changes: 5 additions & 83 deletions runner-modules/tabs/lib/background/tabsMethods.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
'use strict';
const {illegalArgumentError, newPageWaitTimeoutError, CONTENT_SCRIPT_ABORTED_ERROR} = require('../../../../lib/scriptErrors');
const delay = require('../../../../lib/delay');

const ALLOWED_URL_REGEXP = /^https?:\/\//;
const {illegalArgumentError} = require('../../../../lib/scriptErrors');
const scriptFrameCommands = require('./scriptFrameCommands');

module.exports = (tabManager) => {
const createTab = async () => {
Expand Down Expand Up @@ -47,92 +46,15 @@ module.exports = (tabManager) => {
};

const run = async ({id, code, arg}) => {
if (typeof id !== 'string' || !tabManager.hasTab(id)) {
throw illegalArgumentError('tabs.run(): invalid argument `id`');
}

if (typeof code !== 'string') {
throw illegalArgumentError('tabs.run(): invalid argument `code`');
}

const metadata = Object.freeze({
runBeginTime: Date.now(),
});

return await tabManager.runContentScript(id, tabManager.TOP_FRAME_ID, code, {arg, metadata});
return await scriptFrameCommands.run({tabManager, tabId: id, frameId: tabManager.TOP_FRAME_ID, code, arg});
};

const waitForNewPage = async ({id, code, arg, timeoutMs}) => {
if (typeof id !== 'string' || !tabManager.hasTab(id)) {
throw illegalArgumentError('tabs.run(): invalid argument `id`');
}

if (typeof code !== 'string') {
throw illegalArgumentError('tabs.run(): invalid argument `code`');
}

const metadata = Object.freeze({
runBeginTime: Date.now(),
});

const waitForNewContentPromise = tabManager.waitForNewContent(id, tabManager.TOP_FRAME_ID);
try {
const {reject} = await tabManager.runContentScript(id, tabManager.TOP_FRAME_ID, code, {arg, metadata});
// do not return `resolve` to avoid timing inconsistencies (e.g. the script may have been canceled because of the navigation)
if (reject) {
return {reject};
}
}
catch (err) {
// ignore errors which are caused by navigation away; that is what we are expecting
if (err.name !== CONTENT_SCRIPT_ABORTED_ERROR) {
throw err;
}
}

// the timeout does not start counting until the content script has completed its execution; this is by design
await Promise.race([
waitForNewContentPromise,
delay(timeoutMs).then(() => Promise.reject(
newPageWaitTimeoutError(`Waiting for a new page timed out after ${timeoutMs / 1000} seconds`)
)),
]);

return {reject: null};
return await scriptFrameCommands.waitForNewPage({tabManager, tabId: id, frameId: tabManager.TOP_FRAME_ID, code, arg, timeoutMs});
};

const wait = async ({id, code, arg}) => {
if (typeof id !== 'string' || !tabManager.hasTab(id)) {
throw illegalArgumentError('tabs.wait(): invalid argument `id`');
}

if (typeof code !== 'string') {
throw illegalArgumentError('tabs.wait(): invalid argument `code`');
}

const waitMetadata = Object.freeze({
waitBeginTime: Date.now(),
});

const attempt = async (attemptNumber) => {
try {
const metadata = Object.assign({
attemptNumber,
runBeginTime: Date.now(),
}, waitMetadata);
return await tabManager.runContentScript(id, tabManager.TOP_FRAME_ID, code, {arg, metadata});
}
catch (err) {
if (err.name === CONTENT_SCRIPT_ABORTED_ERROR) {
// runContentScript wait for a new tab to initialize
return await attempt(attemptNumber + 1);
}

throw err;
}
};

return await attempt(0);
return await scriptFrameCommands.wait({tabManager, tabId: id, frameId: tabManager.TOP_FRAME_ID, code, arg});
};

return new Map([
Expand Down
Loading

0 comments on commit 1ca8613

Please sign in to comment.