diff --git a/runner-modules/tabs/lib/background/TabManager.js b/runner-modules/tabs/lib/background/TabManager.js index 6b0e51d..6a4f2df 100644 --- a/runner-modules/tabs/lib/background/TabManager.js +++ b/runner-modules/tabs/lib/background/TabManager.js @@ -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; @@ -118,16 +118,7 @@ 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}); } @@ -135,6 +126,14 @@ class TabManager extends EventEmitter { 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'); diff --git a/runner-modules/tabs/lib/background/TabTracker.js b/runner-modules/tabs/lib/background/TabTracker.js index 73dbfb1..2fa987b 100644 --- a/runner-modules/tabs/lib/background/TabTracker.js +++ b/runner-modules/tabs/lib/background/TabTracker.js @@ -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; @@ -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; @@ -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); diff --git a/runner-modules/tabs/lib/background/contentMethods.js b/runner-modules/tabs/lib/background/contentMethods.js new file mode 100644 index 0000000..174ebd7 --- /dev/null +++ b/runner-modules/tabs/lib/background/contentMethods.js @@ -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], + ]); +}; diff --git a/runner-modules/tabs/lib/background/scriptFrameCommands.js b/runner-modules/tabs/lib/background/scriptFrameCommands.js new file mode 100644 index 0000000..08b3ff0 --- /dev/null +++ b/runner-modules/tabs/lib/background/scriptFrameCommands.js @@ -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}; diff --git a/runner-modules/tabs/lib/background/tabsMethods.js b/runner-modules/tabs/lib/background/tabsMethods.js index 1a681bd..120065f 100644 --- a/runner-modules/tabs/lib/background/tabsMethods.js +++ b/runner-modules/tabs/lib/background/tabsMethods.js @@ -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 () => { @@ -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([ diff --git a/runner-modules/tabs/lib/content/Frame.js b/runner-modules/tabs/lib/content/Frame.js new file mode 100644 index 0000000..6dee62e --- /dev/null +++ b/runner-modules/tabs/lib/content/Frame.js @@ -0,0 +1,67 @@ +'use strict'; +const {translateRpcErrorName} = require('../../../../lib/scriptErrors'); +const parseTimeoutArgument = require('../../../../lib/parseTimeoutArgument'); +const extendStack = require('../../../../lib/extendStack'); +const maybeThrowUserScriptError = require('../maybeThrowUserScriptError'); + +/** + * @param {ContentRPC} rpc + * @return {Frame} + */ +module.exports = rpc => { + class Frame { + constructor(id) { + this.id = id; + Object.freeze(this); + } + + async run(func, arg) { + return extendStack(async () => { + const code = func.toString(); + + // An error thrown by whatever is in `code` is returned as `reject`, all other errors will be thrown by rpcCall() + const {resolve, reject} = await rpc.call({name: 'tabs.frameRun', timeout: 0}, { + frameId: this.id, + code, + arg, + }) + .catch(translateRpcErrorName); + maybeThrowUserScriptError(reject); + return resolve; + }); + } + + async wait(func, arg) { + return extendStack(async () => { + const code = func.toString(); + + const {resolve, reject} = await rpc.call({name: 'tabs.frameWait', timeout: 0}, { + frameId: this.id, + code, + arg, + }) + .catch(translateRpcErrorName); + maybeThrowUserScriptError(reject); + return resolve; + }); + } + + async waitForNewPage(func, arg, {timeout = '30s'} = {}) { + return extendStack(async () => { + const code = func.toString(); + const timeoutMs = parseTimeoutArgument(timeout); + + const {resolve, reject} = await rpc.call({name: 'tabs.frameWaitForNewPage', timeout: 0}, { + frameId: this.id, + code, + arg, + timeoutMs, + }) + .catch(translateRpcErrorName); + maybeThrowUserScriptError(reject); + return resolve; + }); + } + } + return Frame; +}; diff --git a/runner-modules/tabs/lib/content/index.js b/runner-modules/tabs/lib/content/index.js index e4cf613..978abee 100644 --- a/runner-modules/tabs/lib/content/index.js +++ b/runner-modules/tabs/lib/content/index.js @@ -7,6 +7,7 @@ const tabsMethods = require('./tabsMethods'); const log = require('../../../../lib/logger')({hostname: 'content', MODULE: 'tabs/content/index'}); const contentUnloadEvent = require('./contentUnloadEvent'); const ModuleRegister = require('../../../../lib/ModuleRegister'); +const tabsModule = require('./tabsModule'); log.debug('Initializing...'); @@ -61,12 +62,33 @@ try { } }; + const handleWindowMessage = event => { + // Note!! These messages could come from anywhere (the web)! + const {data} = event; + if (typeof data !== 'object') { + return; + } + + const {openrunnerTabsFrameToken} = data; + + if (typeof openrunnerTabsFrameToken !== 'string') { + return; + } + + event.stopImmediatePropagation(); + rpc.callAndForget('tabs.receivedFrameToken', String(openrunnerTabsFrameToken)); + }; + + moduleRegister.registerModule('tabs', Promise.resolve(tabsModule({eventEmitter, getModule, rpc}))); + window.openRunnerRegisterRunnerModule = openRunnerRegisterRunnerModule; + window.addEventListener('message', handleWindowMessage, false); // Workaround for firefox bug (last tested to occur in v57) // it seems that sometimes this content script is executed so early that firefox still has to perform some kind of house keeping, // which causes our global variable to disappear. assigning the global variable again in a microtask works around this bug. Promise.resolve().then(() => { window.openRunnerRegisterRunnerModule = openRunnerRegisterRunnerModule; + window.addEventListener('message', handleWindowMessage, false); // has no effect if already added }); log.debug('Initialized... Notifying the background script'); diff --git a/runner-modules/tabs/lib/content/tabsMethods.js b/runner-modules/tabs/lib/content/tabsMethods.js index 85810e8..a259983 100644 --- a/runner-modules/tabs/lib/content/tabsMethods.js +++ b/runner-modules/tabs/lib/content/tabsMethods.js @@ -77,8 +77,14 @@ module.exports = (moduleRegister, eventEmitter) => { } }; + const childFrameBeforeNavigate = ({browserFrameId, url}) => { + log.debug('childFrameBeforeNavigate'); + eventEmitter.emit('tabs.childFrameBeforeNavigate', {browserFrameId, url}); + }; + return new Map([ ['tabs.initializedTabContent', initializedTabContent], ['tabs.run', run], + ['tabs.childFrameBeforeNavigate', childFrameBeforeNavigate], ]); }; diff --git a/runner-modules/tabs/lib/content/tabsModule.js b/runner-modules/tabs/lib/content/tabsModule.js new file mode 100644 index 0000000..9f9d970 --- /dev/null +++ b/runner-modules/tabs/lib/content/tabsModule.js @@ -0,0 +1,81 @@ +/* global crypto */ +'use strict'; +const {illegalArgumentError} = require('../../../../lib/scriptErrors'); +const initFrame = require('./Frame'); +const log = require('../../../../lib/logger')({hostname: 'content', MODULE: 'tabs/content/tabsModule'}); + +const generateFrameToken = () => { + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + let result = ''; + for (const byte of buf) { + result += byte.toString(16).padStart(2, '0'); + } + return result; +}; + +module.exports = ({eventEmitter, getModule, rpc}) => { + const Frame = initFrame(rpc); + const frameMapping = new WeakMap(); + const pendingFrameMappings = new Set(); + const sendToken = (element, frameToken) => { + try { + element.contentWindow.postMessage({openrunnerTabsFrameToken: frameToken}, '*'); + } + catch (err) { + log.warn({err}, 'Failed to send frame to toke to child frame'); + } + }; + + eventEmitter.on('tabs.childFrameBeforeNavigate', () => { + // The background script has notified us that one of our child frames is navigating to a new document + // This means that our previous postMessage might have gotten lost! (e.g. sent to about:blank, or sent + // just before the iframe unloads). So try sending it again... + for (const {element, frameToken} of pendingFrameMappings) { + sendToken(element, frameToken); + } + }); + + const getFrame = async element => { + { + const frame = element && frameMapping.get(element); + if (frame) { + return frame; + } + } + + // todo support and frameset + if (!element || + typeof element !== 'object' || + typeof element.nodeName !== 'string' || + element.nodeName.toUpperCase() !== 'IFRAME' || + typeof element.contentWindow !== 'object' || + typeof element.contentWindow.postMessage !== 'function') { + throw illegalArgumentError('tabs.frame(): First argument must be an iframe DOM element'); + } + + const frameToken = generateFrameToken(); + + // 'tabs.waitForChildFrameToken' also marks the token as valid, any other token is not accepted + const waitForTokenPromise = rpc.call({timeout: 0, name: 'tabs.waitForChildFrameToken'}, frameToken); + const pendingEntry = Object.freeze({element, frameToken}); + pendingFrameMappings.add(pendingEntry); + try { + sendToken(element, frameToken); + + const frameId = await waitForTokenPromise; + + const frame = new Frame(frameId); + frameMapping.set(element, frame); + return frame; + } + finally { + pendingFrameMappings.delete(pendingEntry); + } + }; + + return Object.freeze({ + Frame, + frame: getFrame, + }); +}; diff --git a/runner-modules/tabs/lib/maybeThrowUserScriptError.js b/runner-modules/tabs/lib/maybeThrowUserScriptError.js new file mode 100644 index 0000000..c6e93f8 --- /dev/null +++ b/runner-modules/tabs/lib/maybeThrowUserScriptError.js @@ -0,0 +1,19 @@ +'use strict'; + +/** + * Parses the message.reject object received over RPC by the remote frame. + * For example as a response to tab.run() / frame.run() + * @param {?Object} rpcRejectObject + * @throws {Error} If rpcRejectObject is not falsy + */ +const maybeThrowUserScriptError = rpcRejectObject => { + if (rpcRejectObject) { + const err = new Error(rpcRejectObject.message); + err.data = rpcRejectObject.data; + err.name = rpcRejectObject.name; + err.stack = rpcRejectObject.stack; + throw err; + } +}; + +module.exports = maybeThrowUserScriptError; diff --git a/runner-modules/tabs/lib/script-env/Tab.js b/runner-modules/tabs/lib/script-env/Tab.js index 545a18c..2663c7f 100644 --- a/runner-modules/tabs/lib/script-env/Tab.js +++ b/runner-modules/tabs/lib/script-env/Tab.js @@ -2,21 +2,12 @@ const {navigateError, illegalArgumentError, translateRpcErrorName} = require('../../../../lib/scriptErrors'); const parseTimeoutArgument = require('../../../../lib/parseTimeoutArgument'); const extendStack = require('../../../../lib/extendStack'); +const maybeThrowUserScriptError = require('../maybeThrowUserScriptError'); const LEFT_QUOTE = '\u201c'; const RIGHT_QUOTE = '\u201d'; const ALLOWED_URL_REGEXP = /^https?:\/\//; -const maybeThrowScriptEnvError = (reject) => { - if (reject) { // error thrown by the user script - const err = new Error(reject.message); - err.data = reject.data; - err.name = reject.name; - err.stack = reject.stack; - throw err; - } -}; - module.exports = script => { class Tab { constructor(id) { @@ -56,7 +47,7 @@ module.exports = script => { arg, }) .catch(translateRpcErrorName); - maybeThrowScriptEnvError(reject); + maybeThrowUserScriptError(reject); return resolve; }); } @@ -71,7 +62,7 @@ module.exports = script => { arg, }) .catch(translateRpcErrorName); - maybeThrowScriptEnvError(reject); + maybeThrowUserScriptError(reject); return resolve; }); } @@ -88,7 +79,7 @@ module.exports = script => { timeoutMs, }) .catch(translateRpcErrorName); - maybeThrowScriptEnvError(reject); + maybeThrowUserScriptError(reject); return resolve; }); }