diff --git a/lib/WaitForEvent.js b/lib/WaitForEvent.js index 48d0ad9..f4a8059 100644 --- a/lib/WaitForEvent.js +++ b/lib/WaitForEvent.js @@ -1,12 +1,18 @@ 'use strict'; +const DeepStore = require('deep-store'); + const NOOP = () => {}; class WaitForEvent { constructor() { - this._pendingWaits = new Map(); + this._pendingWaits = new DeepStore(); Object.freeze(this); } + /** + * @param {Array} key + * @param {Function} func + */ async wait(key, func = NOOP) { { const waitData = this._pendingWaits.get(key); @@ -43,10 +49,20 @@ class WaitForEvent { return true; } - resolve(key, value) { + /** + * @param {Array} key + * @param {*} value + * @return {Boolean} + */ + resolve(key, value = undefined) { return this._fulfill(false, key, value); } + /** + * @param {Array} key + * @param {Error} error + * @return {Boolean} + */ reject(key, error) { return this._fulfill(true, key, error); } diff --git a/lib/contentRpc/TabContentRPC.js b/lib/contentRpc/TabContentRPC.js index 11dc055..da9bc4a 100644 --- a/lib/contentRpc/TabContentRPC.js +++ b/lib/contentRpc/TabContentRPC.js @@ -7,6 +7,7 @@ const log = require('../logger')({hostname: 'background', MODULE: 'TabContentRPC const DEFAULT_CALL_TIMEOUT = 15003; const NOOP = () => {}; +const promiseTry = async fn => fn(); class TabContentRPCTab { constructor(browserTabId, browserFrameId, tabContentRPC) { @@ -15,6 +16,7 @@ class TabContentRPCTab { this._methods = new MethodRegistrations(); this._tabContentRPC = tabContentRPC; this._destroyed = false; + this._initializePromise = null; Object.seal(this); } @@ -172,7 +174,7 @@ class TabContentRPC { const {id: browserTabId} = tab; const rpc = this.get(browserTabId, browserFrameId); - return Promise.resolve().then(() => rpc._handle(message)); + return rpc._initializePromise.then(() => rpc._handle(message)); } handleTabsRemoved(browserTabId, removeInfo) { @@ -202,7 +204,9 @@ class TabContentRPC { const rpc = new TabContentRPCTab(browserTabId, browserFrameId, this); this.rpcMap.get(browserTabId).set(browserFrameId, rpc); - this.onRpcInitialize({browserTabId, browserFrameId, rpc}); + rpc._initializePromise = promiseTry(() => this.onRpcInitialize({browserTabId, browserFrameId, rpc})) + .catch(err => log.error({err}, 'Error while calling onRpcInitialize')) + .then(() => undefined); return rpc; } diff --git a/manifest.json b/manifest.json index 6f409a1..e6e4dbf 100644 --- a/manifest.json +++ b/manifest.json @@ -41,11 +41,11 @@ "matches": [ "*://*/*" ], - "all_frames": false, + "all_frames": true, "js": [ "build/tabs-content.js" ], "run_at": "document_start" } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 09ac592..a750352 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "chai-dom": "^1.5.0", "chai-subset": "^1.6.0", "cjson": "^0.5.0", + "deep-store": "1.1.0", "error-stack-parser": "^2.0.1", "fs-extra": "^7.0.0", "istanbul-lib-instrument": "^1.9.1", @@ -83,6 +84,7 @@ "react-dom": "^16.0.0", "shortid": "^2.2.8", "split": "^1.0.1", + "symbol-tree": "^3.2.2", "ws": "^6.0.0", "yargs": "^12.0.1" }, diff --git a/runner-modules/runResult/lib/Event.js b/runner-modules/runResult/lib/Event.js index ea32f72..50a201c 100644 --- a/runner-modules/runResult/lib/Event.js +++ b/runner-modules/runResult/lib/Event.js @@ -93,6 +93,7 @@ class Event { timing: new TimePeriod(), type: String(type), tabId: null, + frameId: null, tabContentId: null, }); Object.freeze(this); @@ -286,6 +287,22 @@ class Event { this[PRIVATE].tabId = value ? String(value) : null; } + /** + * + * @return {?number} + */ + get frameId() { + return this[PRIVATE].frameId; + } + + /** + * + * @param {?number} value + */ + set frameId(value) { + this[PRIVATE].frameId = value ? Number(value) : null; + } + /** * * @return {?string} @@ -333,6 +350,7 @@ class Event { metaData: metaData, children: [...this.children].map(event => event.toJSONObject()), tabId: this.tabId, + frameId: this.frameId, tabContentId: this.tabContentId, }; /* eslint-enable sort-keys */ diff --git a/runner-modules/runResult/lib/background/index.js b/runner-modules/runResult/lib/background/index.js index c7f2bf5..d9e5b48 100644 --- a/runner-modules/runResult/lib/background/index.js +++ b/runner-modules/runResult/lib/background/index.js @@ -16,12 +16,13 @@ TimePoint.setCounterFunc(() => ({ })); module.exports = script => { - const handleTabsInitializedTabRpc = ({tab, rpc}) => { + const handleTabsInitializedTabRpc = ({tab, frame, rpc}) => { rpc.method('runResult.scriptResult', result => { try { log.debug('Received script result object from content'); for (const event of result.events) { event.tabId = tab.id; + event.frameId = frame.browserFrameId; event.tabContentId = tab.currentContentId; } scriptResult.mergeJSONObject(result); diff --git a/runner-modules/tabs/lib/background/ScriptWindow.js b/runner-modules/tabs/lib/background/ScriptWindow.js index a1edabc..cde866b 100644 --- a/runner-modules/tabs/lib/background/ScriptWindow.js +++ b/runner-modules/tabs/lib/background/ScriptWindow.js @@ -15,7 +15,7 @@ class ScriptWindow extends EventEmitter { this.openPromise = null; this.firstTabCreation = true; this.closed = false; - this._navigationCompletedWait = new WaitForEvent(); // key is the browserTabId + this._navigationCompletedWait = new WaitForEvent(); // key is [browserTabId] this._sizeMinusViewport = Object.freeze({width: 0, height: 0}); this.handleWebNavigationCompleted = this.handleWebNavigationCompleted.bind(this); Object.seal(this); @@ -39,7 +39,7 @@ class ScriptWindow extends EventEmitter { return; } - this._navigationCompletedWait.resolve(browserTabId); + this._navigationCompletedWait.resolve([browserTabId]); } catch (err) { log.error({err}, 'Error in browser.webNavigation.onCompleted'); @@ -82,7 +82,7 @@ class ScriptWindow extends EventEmitter { */ async _gatherBrowserWindowDetails(browserWindowId, firstTabId) { // navigate to blank.html and wait for the load event - await this._navigationCompletedWait.wait(firstTabId, async () => { + await this._navigationCompletedWait.wait([firstTabId], async () => { await this.browserTabs.update(firstTabId, {url: BLANK_HTML}); }); diff --git a/runner-modules/tabs/lib/background/TabManager.js b/runner-modules/tabs/lib/background/TabManager.js index f3b8d9a..6b0e51d 100644 --- a/runner-modules/tabs/lib/background/TabManager.js +++ b/runner-modules/tabs/lib/background/TabManager.js @@ -1,8 +1,9 @@ 'use strict'; const EventEmitter = require('events').EventEmitter; +const {assert} = require('chai'); const log = require('../../../../lib/logger')({hostname: 'background', MODULE: 'tabs/background/TabManager'}); -const {contentScriptAbortedError, illegalStateError} = require('../../../../lib/scriptErrors'); +const {contentScriptAbortedError} = require('../../../../lib/scriptErrors'); const ScriptWindow = require('./ScriptWindow'); const TabTracker = require('./TabTracker'); const TabContentRPC = require('../../../../lib/contentRpc/TabContentRPC'); @@ -10,6 +11,8 @@ const {resolveScriptContentEvalStack} = require('../../../../lib/errorParsing'); const {mergeCoverageReports} = require('../../../../lib/mergeCoverage'); const WaitForEvent = require('../../../../lib/WaitForEvent'); +const TOP_FRAME_ID = 0; + class TabManager extends EventEmitter { constructor({runtime: browserRuntime, windows: browserWindows, tabs: browserTabs, webNavigation: browserWebNavigation}) { super(); @@ -27,16 +30,16 @@ class TabManager extends EventEmitter { context: 'runner-modules/tabs', onRpcInitialize: obj => this.handleRpcInitialize(obj), }); - this._navigationCommittedWait = new WaitForEvent(); // key is the browserTabId + this._navigationCommittedWait = new WaitForEvent(); // key is [browserTabId, browserFrameId] this.handleTabCreated = this.handleTabCreated.bind(this); this.handleWebNavigationOnBeforeNavigate = this.handleWebNavigationOnBeforeNavigate.bind(this); this.handleWebNavigationOnCommitted = this.handleWebNavigationOnCommitted.bind(this); - this.handleTabInitialized = this.handleTabInitialized.bind(this); + this.handleTabMainContentInitialized = this.handleTabMainContentInitialized.bind(this); this.handleTabsRemoved = this.handleTabsRemoved.bind(this); this.scriptWindow.on('windowCreated', ({browserWindowId}) => this.emit('windowCreated', {browserWindowId})); this.scriptWindow.on('windowClosed', ({browserWindowId}) => this.emit('windowClosed', {browserWindowId})); - + this.TOP_FRAME_ID = TOP_FRAME_ID; Object.seal(this); } @@ -67,7 +70,7 @@ class TabManager extends EventEmitter { log.debug({browserTabId: browserTabId, tabIsInMyWindow}, 'browser.tabs.onCreated'); if (tabIsInMyWindow) { - this.myTabs.register(browserTabId); + this.myTabs.registerTab(browserTabId); } else { return; // the tab does not belong to this script @@ -91,19 +94,33 @@ class TabManager extends EventEmitter { this.myTabs.markClosed(browserTabId); } - handleRpcInitialize({browserTabId, browserFrameId, rpc}) { + async _registerAncestorFrames(tab, browserFrameId) { + const {browserTabId} = tab; + const {parentFrameId: parentBrowserFrameId} = await this.browserWebNavigation.getFrame({ + tabId: browserTabId, + frameId: browserFrameId, + }); + + // top = 0; -1 = there is no parent + if (parentBrowserFrameId >= 0 && parentBrowserFrameId !== browserFrameId && !tab.hasFrame(parentBrowserFrameId)) { + await this._registerAncestorFrames(tab, parentBrowserFrameId); + } + + return this.myTabs.registerFrame(browserTabId, parentBrowserFrameId, browserFrameId); + } + + async handleRpcInitialize({browserTabId, browserFrameId, rpc}) { const tab = this.myTabs.getByBrowserTabId(browserTabId); + log.debug({browserTabId, browserFrameId, myTab: Boolean(tab)}, 'handleRpcInitialize'); if (!tab) { return; // not my tab } - if (browserFrameId) { - return; // todo - } + const frame = await this._registerAncestorFrames(tab, browserFrameId); - rpc.method('tabs.mainContentInit', () => this.handleTabInitialized(browserTabId)); - rpc.method('tabs.contentInit', ({moduleName}) => this.handleTabModuleInitialized(browserTabId, moduleName)); + 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__; @@ -111,40 +128,30 @@ class TabManager extends EventEmitter { mergeCoverageReports(myCoverage, contentCoverage); } }); - this.emit('initializedTabRpc', {tab, rpc}); + this.emit('initializedTabRpc', {tab, frame, rpc}); } - handleWebNavigationOnBeforeNavigate({tabId: browserTabId, frameId, url}) { + handleWebNavigationOnBeforeNavigate({tabId: browserTabId, frameId: browserFrameId, url}) { try { - log.debug({browserTabId, frameId, url}, 'browser.webNavigation.onBeforeNavigate'); - - if (frameId) { // frameId === 0 is top; otherwise it is an iframe - return; - } - - this.myTabs.markUninitialized(browserTabId); + log.debug({browserTabId, browserFrameId, url}, 'browser.webNavigation.onBeforeNavigate'); + this.myTabs.markUninitialized(browserTabId, browserFrameId); } catch (err) { log.error({err}, 'Error in browser.webNavigation.onBeforeNavigate'); } } - handleWebNavigationOnCommitted({tabId: browserTabId, frameId, url}) { + handleWebNavigationOnCommitted({tabId: browserTabId, frameId: browserFrameId, url}) { try { - log.debug({browserTabId, frameId, url}, 'browser.webNavigation.onCommitted'); - - if (frameId) { // frameId === 0 is top; otherwise it is an iframe - return; - } - - this._navigationCommittedWait.resolve(browserTabId); + log.debug({browserTabId, browserFrameId, url}, 'browser.webNavigation.onCommitted'); + this._navigationCommittedWait.resolve([browserTabId, browserFrameId], null); } catch (err) { log.error({err}, 'Error in browser.webNavigation.onCommitted'); } } - handleTabInitialized(browserTabId) { + handleTabMainContentInitialized(browserTabId, browserFrameId) { const tab = this.myTabs.getByBrowserTabId(browserTabId); const isMyTab = Boolean(tab); log.info({browserTabId, isMyTab}, 'Main tab content script has been initialized'); @@ -153,97 +160,97 @@ class TabManager extends EventEmitter { return; // the tab does not belong to this script } - this.myTabs.markUninitialized(browserTabId); - this.myTabs.expectInitToken(browserTabId, 'tabs'); - const rpc = this.tabContentRPC.get(browserTabId, TabContentRPC.TOP_LEVEL_FRAME_ID); + this.myTabs.markUninitialized(browserTabId, browserFrameId); + this.myTabs.expectInitToken(browserTabId, browserFrameId, 'tabs'); + const frame = tab.getFrame(browserFrameId); + assert.isOk(frame, 'frame'); + const rpc = this.tabContentRPC.get(browserTabId, browserFrameId); const files = []; const executeContentScript = (initToken, file) => { - log.debug({browserTabId, initToken, file}, 'Executing content script for runner module'); - this.myTabs.expectInitToken(browserTabId, String(initToken)); + log.debug({browserTabId, browserFrameId, initToken, file}, 'Executing content script for runner module'); + this.myTabs.expectInitToken(browserTabId, browserFrameId, String(initToken)); files.push(String(file)); }; - this.emit('initializingTabContent', {tab, executeContentScript, rpc}); + this.emit('initializingTabContent', {tab, frame, executeContentScript, rpc}); for (const file of files) { this.browserTabs.executeScript(browserTabId, { allFrames: false, - frameId: 0, // top + frameId: browserFrameId, file, runAt: 'document_start', }); } - this._markInitialized(browserTabId, 'tabs'); + this._markInitialized(browserTabId, browserFrameId, 'tabs'); } - handleTabModuleInitialized(browserTabId, moduleName) { + handleTabModuleInitialized(browserTabId, browserFrameId, moduleName) { log.debug({browserTabId, moduleName}, 'Module tab content script has been initialized'); - this._markInitialized(browserTabId, moduleName); + this._markInitialized(browserTabId, browserFrameId, moduleName); } - _markInitialized(browserTabId, initToken) { - if (this.myTabs.markInitialized(browserTabId, initToken)) { - log.info({browserTabId}, 'All tab content scripts have initialized'); + _markInitialized(browserTabId, browserFrameId, initToken) { + if (this.myTabs.markInitialized(browserTabId, browserFrameId, initToken)) { + log.info({browserTabId, browserFrameId}, 'All tab content scripts have initialized'); + const tab = this.myTabs.getByBrowserTabId(browserTabId); - this.emit('initializedTabContent', {tab}); + assert.isOk(tab, 'tab'); + const frame = tab.getFrame(browserFrameId); + assert.isOk(frame, 'frame'); + this.emit('initializedTabContent', {tab, frame}); - const rpc = this.tabContentRPC.get(browserTabId, TabContentRPC.TOP_LEVEL_FRAME_ID); + const rpc = this.tabContentRPC.get(browserTabId, browserFrameId); rpc.callAndForget('tabs.initializedTabContent'); } } async createTab() { - if (!this._attached) { - throw illegalStateError('TabManager.createTab: Not initialized yet or in the progress of cleaning up'); - } + assert.isTrue(this._attached, 'TabManager#createTab: Not initialized yet or in the progress of cleaning up'); // note: "about:blank" might cause our content scripts to not run, but that is okay: that will simply cause the tab to not be // marked as "initialized". runner scripts are expected to call tab.navigate(url) before interacting further with that tab const browserTab = await this.scriptWindow.createTab('about:blank'); const {id: browserTabId} = browserTab; - this.myTabs.register(browserTabId); + this.myTabs.registerTab(browserTabId); return this.myTabs.getByBrowserTabId(browserTabId); } hasTab(id) { - return this.myTabs.has(id); + return this.myTabs.hasTab(id); } getTab(id) { - return this.myTabs.get(id); + return this.myTabs.getTab(id); } async navigateTab(id, url) { - if (!this._attached) { - throw illegalStateError('TabManager.navigateTab: Not initialized yet or in the progress of cleaning up'); - } + assert.isTrue(this._attached, 'TabManager#navigateTab: Not initialized yet or in the progress of cleaning up'); - const {browserTabId} = this.myTabs.get(id); - this.myTabs.markUninitialized(browserTabId); + const {browserTabId} = this.myTabs.getTab(id); + this.myTabs.markUninitialized(browserTabId, TOP_FRAME_ID); // wait for the onCommitted event (which occurs even if there was an error downloading the page) // a new navigation might fail if onCommitted has not fired yet (firefox 57). - await this._navigationCommittedWait.wait(browserTabId, async () => { - log.debug({browserTabId, url}, 'Navigating tab to new url'); - await browser.tabs.update(browserTabId, {url}); - await this.myTabs.waitForTabInitialization(browserTabId); + await this._navigationCommittedWait.wait([browserTabId, TOP_FRAME_ID], async () => { + log.debug({browserTabId, TOP_FRAME_ID, url}, 'Navigating tab to new url'); + await this.browserTabs.update(browserTabId, {url}); + await this.myTabs.waitForTabContentInitialization(browserTabId, TOP_FRAME_ID); }); } - async runContentScript(id, code, {arg, metadata = {}} = {}) { - if (!this._attached) { - throw illegalStateError('TabManager.runContentScript: Not initialized yet or in the progress of cleaning up'); - } + async runContentScript(id, browserFrameId, code, {arg, metadata = {}} = {}) { + assert.isTrue(this._attached, 'TabManager#runContentScript: Not initialized yet or in the progress of cleaning up'); - const {browserTabId} = this.myTabs.get(id); - await this.myTabs.waitForTabInitialization(browserTabId); - const rpc = this.tabContentRPC.get(browserTabId, TabContentRPC.TOP_LEVEL_FRAME_ID); + const {browserTabId} = this.myTabs.getTab(id); + await this.myTabs.waitForTabContentInitialization(browserTabId, browserFrameId); + const rpc = this.tabContentRPC.get(browserTabId, browserFrameId); const rpcPromise = Promise.race([ rpc.call({name: 'tabs.run', timeout: 0}, {code, arg, metadata}), - this.myTabs.waitForTabUninitialization(browserTabId).then(() => { + this.myTabs.waitForTabUninitialization(browserTabId, browserFrameId).then(() => { throw contentScriptAbortedError( 'The web page has navigated away while the execution of the content script was pending' ); @@ -259,13 +266,11 @@ class TabManager extends EventEmitter { return {resolve, reject}; } - async waitForNewContent(id) { - if (!this._attached) { - throw illegalStateError('TabManager.waitForNewContent: Not initialized yet or in the progress of cleaning up'); - } + async waitForNewContent(id, browserFrameId) { + assert.isTrue(this._attached, 'TabManager#waitForNewContent: Not initialized yet or in the progress of cleaning up'); - const {browserTabId} = this.myTabs.get(id); - await this.myTabs.waitForNextTabInitialization(browserTabId); + const {browserTabId} = this.myTabs.getTab(id); + await this.myTabs.waitForNextTabContentInitialization(browserTabId, browserFrameId); } async getBrowserWindowId() { @@ -280,24 +285,25 @@ class TabManager extends EventEmitter { } async closeScriptWindow() { - if (!this._attached) { - throw illegalStateError('TabManager.closeScriptWindow: Not initialized yet or in the progress of cleaning up'); - } + assert.isTrue(this._attached, 'TabManager.closeScriptWindow: Not initialized yet or in the progress of cleaning up'); + + const frames = [...this.myTabs.frames()]; log.debug({ - browserTabIds: [...this.myTabs].map(tab => tab.browserTabId), - }, 'Calling tabs.contentUnload for all active tabs'); + frames: frames.map(frame => ({tab: frame.tab.browserTabId, frame: frame.browserFrameId})), + }, 'Calling tabs.contentUnload for all active frames'); const promises = []; - for (const {browserTabId, initialized} of this.myTabs) { + for (const frame of frames) { + const {tab: {browserTabId}, browserFrameId, initialized} = frame; if (!initialized) { continue; } - const rpc = this.tabContentRPC.get(browserTabId, TabContentRPC.TOP_LEVEL_FRAME_ID); + const rpc = this.tabContentRPC.get(browserTabId, browserFrameId); promises.push( rpc.call({name: 'tabs.contentUnload', timeout: 5001}) - .catch(err => log.warn({err, browserTabId}, 'Error calling tabs.contentUnload for tab')) + .catch(err => log.warn({err, browserTabId, browserFrameId}, 'Error calling tabs.contentUnload for frame')) ); } await Promise.all(promises); diff --git a/runner-modules/tabs/lib/background/TabTracker.js b/runner-modules/tabs/lib/background/TabTracker.js index 34d66c3..73dbfb1 100644 --- a/runner-modules/tabs/lib/background/TabTracker.js +++ b/runner-modules/tabs/lib/background/TabTracker.js @@ -1,12 +1,118 @@ 'use strict'; const {generate: generateShortId} = require('shortid'); +const SymbolTree = require('symbol-tree'); +const {assert} = require('chai'); + +const frameTree = new SymbolTree(); +const NULL_FRAME_ID = -1; // same as WebExtension +const TOP_FRAME_ID = 0; + +class Frame { + constructor(tab, browserFrameId) { + const self = this; + frameTree.initialize(this); + this.browserFrameId = browserFrameId; + this.tab = tab; + this.initCount = 0; + this.initMarked = false; + this.destroyed = false; + this.currentContentId = null; + this.pendingInitTokens = new Set(); + this.public = { + get browserFrameId() { + return self.browserFrameId; + }, + + get parentFrame() { + const parent = self.parentFrame; + return parent && parent.public; + }, + + get hasParentFrame() { + return self.hasParentFrame; + }, + + get parentBrowserFrameId() { + return self.parentBrowserFrameId; + }, + + get tab() { + return self.tab.public; + }, + + /** + * Has this frame been destroyed? This means that the parent frame has navigated away + * @return {boolean} + */ + get destroyed() { + return self.destroyed; + }, + + /** + * Is this frame currently initialized? If `false`: the frame has just been created, or is busy navigating to a new URL + * @return {boolean} + */ + get initialized() { + return self.initialized; + }, + + /** + * An unique ID which represents a single frame-content instance. Navigating to a new URL resets this id. + * This id might be null before the first navigation + * @return {?string} + */ + get currentContentId() { + return self.currentContentId; + }, + + isChild(otherFrame) { + return self.isChild(otherFrame); + }, + }; + Object.freeze(this.public); + Object.seal(this); + } + + /** + * @return {?Frame} + */ + get parentFrame() { + return frameTree.parent(this); + } + + get hasParentFrame() { + return Boolean(this.parentFrame); + } + + get parentBrowserFrameId() { + const parent = this.parentFrame; + return parent ? parent.browserFrameId : NULL_FRAME_ID; + } + + get initialized() { + return Boolean(!this.tab.closed && !this.destroyed && this.initMarked && this.pendingInitTokens.size === 0); + } + + isChild(otherFrame) { + return Boolean(otherFrame && otherFrame.parentBrowserFrameId === this.browserFrameId); + } +} class Tab { constructor(id, browserTabId) { const self = this; - this.public = Object.freeze({ - id, - browserTabId, + this.id = id; + this.browserTabId = browserTabId; + this.frames = new Map(); // browserFrameId => Frame + this.closed = false; + this.public = { + get id() { + return self.id; + }, + + get browserTabId() { + return self.browserTabId; + }, /** * Has this tab been closed? * @return {boolean} @@ -30,25 +136,71 @@ class Tab { get currentContentId() { return self.currentContentId; }, - }); - this.initCount = 0; - this.initMarked = false; - this.currentContentId = null; - this.pendingInitTokens = new Set(); - this.closed = false; + + hasFrame(browserFrameId) { + return self.hasFrame(browserFrameId); + }, + + getFrame(browserFrameId) { + const frame = self.getFrame(browserFrameId); + return frame && frame.public; + }, + + get topFrame() { + return this.getFrame(TOP_FRAME_ID); + }, + }; + Object.freeze(this.public); Object.seal(this); } - get id() { - return this.public.id; + hasFrame(browserFrameId) { + return this.frames.has(browserFrameId); } - get browserTabId() { - return this.public.browserTabId; + /** + * @param {number} browserFrameId + * @return {?Frame} + */ + getFrame(browserFrameId) { + return this.frames.get(browserFrameId) || null; } - get initialized() { - return Boolean(!this.closed && this.initMarked && this.pendingInitTokens.size === 0); + /** + * @param {number} parentBrowserFrameId + * @param {number} browserFrameId + * @return {?Frame} + */ + createFrame(parentBrowserFrameId, browserFrameId) { + assert.isFalse(this.hasFrame(browserFrameId), 'Tab#createFrame(): Given browserFrameId already exists'); + const frame = new Frame(this, browserFrameId); + + // -1 is used by the WebExtension api to indicate that there is no parent + if (parentBrowserFrameId >= 0) { + const parent = this.getFrame(parentBrowserFrameId); + assert.isOk(parent, 'Tab#createFrame(): Given parentBrowserFrameId does not exist'); + frameTree.appendChild(parent, frame); + } + + this.frames.set(browserFrameId, frame); + return this.getFrame(browserFrameId); + } + + destroyFrame(browserFrameId, {descendantsOnly = false} = {}) { + const topFrame = this.getFrame(browserFrameId); + if (!topFrame) { + return; + } + + for (const frame of frameTree.treeIterator(topFrame)) { + if (descendantsOnly && frame === topFrame) { + continue; + } + + frame.destroyed = true; + this.frames.delete(browserFrameId); + frameTree.remove(frame); + } } } @@ -67,7 +219,15 @@ class TabTracker { } } - register(browserTabId) { + * frames() { + for (const tab of this.tabs.values()) { + for (const frame of tab.frames.values()) { + yield frame.public; + } + } + } + + registerTab(browserTabId) { { const tab = this.tabsByBrowserId.get(browserTabId); if (tab) { @@ -75,22 +235,52 @@ class TabTracker { } } - const id = generateShortId(); + const id = generateShortId(); // this id is visible to openrunner scripts, the browserTabId is not const tab = new Tab(id, browserTabId); this.tabs.set(id, tab); this.tabsByBrowserId.set(browserTabId, tab); return tab.public; } - has(id) { - return this.tabs.has(id); + registerFrame(browserTabId, parentBrowserFrameId, browserFrameId) { + const tab = this.tabsByBrowserId.get(browserTabId); + assert.isOk(tab, 'registerFrame(): the given browserTabId has not been registered'); + + { + const frame = tab.getFrame(browserFrameId); + if (frame) { + assert.strictEqual( + frame.parentBrowserFrameId, + parentBrowserFrameId, + 'TabTracker#registerFrame called multiple times with different values for parentBrowserFrameId' + ); + + return frame.public; + } + } + + const frame = tab.createFrame(parentBrowserFrameId, browserFrameId); + return frame.public; + } + + hasTab(tabId) { + return this.tabs.has(tabId); } - get(id) { - const tab = this.tabs.get(id); + getTab(tabId) { + const tab = this.tabs.get(tabId); return tab ? tab.public : null; } + _getFramePrivate(browserTabId, browserFrameId) { + const tab = this.tabsByBrowserId.get(browserTabId); + if (!tab) { + return null; + } + + return tab.getFrame(browserFrameId); + } + hasBrowserTabId(browserTabId) { return this.tabsByBrowserId.has(browserTabId); } @@ -100,90 +290,100 @@ class TabTracker { return tab ? tab.public : null; } - markUninitialized(browserTabId) { + markUninitialized(browserTabId, browserFrameId) { const tab = this.tabsByBrowserId.get(browserTabId); if (!tab) { return; } - tab.initMarked = false; + const frame = tab.getFrame(browserFrameId); + if (!frame) { + return; + } + + frame.initMarked = false; for (const resolver of this.waitForTabUninitializationResolvers) { - if (resolver.browserTabId === browserTabId) { + if (resolver.browserTabId === browserTabId && resolver.browserFrameId === browserFrameId) { this.waitForTabUninitializationResolvers.delete(resolver); resolver.resolve(); } } - } - expectInitToken(browserTabId, initToken) { - const tab = this.tabsByBrowserId.get(browserTabId); - if (!tab) { - throw Error('expectInitToken(): the given browserTabId has not been registered'); - } - - tab.pendingInitTokens.add(initToken); + // This frame is navigating to somewhere else. All the DOM nodes will be destroyed, including the iframes + tab.destroyFrame(browserFrameId, {descendantsOnly: true}); } - markInitialized(browserTabId, initToken) { - const tab = this.tabsByBrowserId.get(browserTabId); - if (!tab) { - throw Error('markInitialized(): the given browserTabId has not been registered'); - } + expectInitToken(browserTabId, browserFrameId, initToken) { + const frame = this._getFramePrivate(browserTabId, browserFrameId); + assert.isOk(frame, 'expectInitToken(): the given browserTabId and browserFrameId combination has not been registered'); + frame.pendingInitTokens.add(initToken); + } - const wasInitialized = tab.initialized; - const wasInitMarked = tab.initMarked; - tab.initMarked = true; - tab.pendingInitTokens.delete(initToken); + markInitialized(browserTabId, browserFrameId, initToken) { + const frame = this._getFramePrivate(browserTabId, browserFrameId); + assert.isOk(frame, 'markInitialized(): the given browserTabId and browserFrameId combination has not been registered'); + const wasInitialized = frame.initialized; + const wasInitMarked = frame.initMarked; + frame.initMarked = true; + frame.pendingInitTokens.delete(initToken); if (!wasInitMarked) { - tab.currentContentId = generateShortId(); + frame.currentContentId = generateShortId(); } - if (tab.initialized) { + if (frame.initialized) { if (!wasInitialized) { - ++tab.initCount; + ++frame.initCount; } - const {initCount} = tab; + const {initCount} = frame; for (const resolver of this.waitForTabInitializationResolvers) { - if (resolver.browserTabId === browserTabId && initCount >= resolver.expectedInitCount) { + if (resolver.browserTabId === browserTabId && + resolver.browserFrameId === browserFrameId && + initCount >= resolver.expectedInitCount + ) { this.waitForTabInitializationResolvers.delete(resolver); resolver.resolve(); } } } - return tab.initialized; + return frame.initialized; } - async waitForTabInitialization(browserTabId) { - const tab = this.tabsByBrowserId.get(browserTabId); - if (tab && tab.initialized) { + async waitForTabContentInitialization(browserTabId, browserFrameId) { + const frame = this._getFramePrivate(browserTabId, browserFrameId); + if (frame && frame.initialized) { return; } - await this.waitForNextTabInitialization(browserTabId); + await this.waitForNextTabContentInitialization(browserTabId, browserFrameId); } - async waitForNextTabInitialization(browserTabId) { - const tab = this.tabsByBrowserId.get(browserTabId); - const initCount = tab ? tab.initCount : 0; + async waitForNextTabContentInitialization(browserTabId, browserFrameId) { + const frame = this._getFramePrivate(browserTabId, browserFrameId); + const initCount = frame ? frame.initCount : 0; await new Promise(resolve => this.waitForTabInitializationResolvers.add({ browserTabId, + browserFrameId, resolve, expectedInitCount: initCount + 1, })); } - async waitForTabUninitialization(browserTabId) { - const tab = this.tabsByBrowserId.get(browserTabId); - if (!tab || !tab.initialized) { + async waitForTabUninitialization(browserTabId, browserFrameId) { + const frame = this._getFramePrivate(browserTabId, browserFrameId); + if (!frame || !frame.initialized) { return; } - await new Promise(resolve => this.waitForTabUninitializationResolvers.add({browserTabId, resolve})); + await new Promise(resolve => this.waitForTabUninitializationResolvers.add({ + browserTabId, + browserFrameId, + resolve, + })); } markClosed(browserTabId) { @@ -193,6 +393,7 @@ class TabTracker { } tab.closed = true; + this.markUninitialized(browserTabId, 0); } } diff --git a/runner-modules/tabs/lib/background/tabsMethods.js b/runner-modules/tabs/lib/background/tabsMethods.js index 5f479f5..1a681bd 100644 --- a/runner-modules/tabs/lib/background/tabsMethods.js +++ b/runner-modules/tabs/lib/background/tabsMethods.js @@ -59,7 +59,7 @@ module.exports = (tabManager) => { runBeginTime: Date.now(), }); - return await tabManager.runContentScript(id, code, {arg, metadata}); + return await tabManager.runContentScript(id, tabManager.TOP_FRAME_ID, code, {arg, metadata}); }; const waitForNewPage = async ({id, code, arg, timeoutMs}) => { @@ -75,9 +75,9 @@ module.exports = (tabManager) => { runBeginTime: Date.now(), }); - const waitForNewContentPromise = tabManager.waitForNewContent(id); + const waitForNewContentPromise = tabManager.waitForNewContent(id, tabManager.TOP_FRAME_ID); try { - const {reject} = await tabManager.runContentScript(id, code, {arg, metadata}); + 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}; @@ -120,7 +120,7 @@ module.exports = (tabManager) => { attemptNumber, runBeginTime: Date.now(), }, waitMetadata); - return await tabManager.runContentScript(id, code, {arg, metadata}); + return await tabManager.runContentScript(id, tabManager.TOP_FRAME_ID, code, {arg, metadata}); } catch (err) { if (err.name === CONTENT_SCRIPT_ABORTED_ERROR) { diff --git a/test/unit/WaitForEvent.test.js b/test/unit/WaitForEvent.test.js index 7d45287..9894b9d 100644 --- a/test/unit/WaitForEvent.test.js +++ b/test/unit/WaitForEvent.test.js @@ -9,54 +9,54 @@ const WaitForEvent = require('../../lib/WaitForEvent'); describe('WaitForEvent', () => { it('Should resolve the correct event', async () => { const wait = new WaitForEvent(); - const promise = wait.wait('foo'); - const promise2 = wait.wait('foo'); + const promise = wait.wait(['foo']); + const promise2 = wait.wait(['foo']); let resolved = false; promise.then(() => { resolved = true; }); promise2.then(() => { resolved = true; }); await Promise.delay(10); eq(resolved, false); - wait.resolve('bar'); + wait.resolve(['bar']); await Promise.delay(10); eq(resolved, false); - wait.resolve('foo', 123); + wait.resolve(['foo'], 123); eq(await promise, 123); eq(await promise2, 123); }); it('Should reject the correct event', async () => { const wait = new WaitForEvent(); - const promise = wait.wait('foo'); + const promise = wait.wait(['foo']); let rejected = false; promise.catch(() => { rejected = true; }); await Promise.delay(10); eq(rejected, false); - wait.reject('bar', Error('Error from test! bar')); + wait.reject(['bar'], Error('Error from test! bar')); await Promise.delay(10); eq(rejected, false); - wait.reject('foo', Error('Error from test! foo')); + wait.reject(['foo'], Error('Error from test! foo')); await isRejected(promise, Error, 'Error from test! foo'); }); it('Should resolve the event multiple times', async () => { const wait = new WaitForEvent(); { - const promise = wait.wait('foo'); - wait.resolve('foo', 123); + const promise = wait.wait(['foo']); + wait.resolve(['foo'], 123); eq(await promise, 123); } { - const promise = wait.wait('foo'); - wait.resolve('foo', 456); + const promise = wait.wait(['foo']); + wait.resolve(['foo'], 456); eq(await promise, 456); } { - const promise = wait.wait('foo'); - wait.resolve('foo', 789); + const promise = wait.wait(['foo']); + wait.resolve(['foo'], 789); eq(await promise, 789); } diff --git a/test/unit/contentRpc/TabContentRPC.test.js b/test/unit/contentRpc/TabContentRPC.test.js index 9e62475..afa346c 100644 --- a/test/unit/contentRpc/TabContentRPC.test.js +++ b/test/unit/contentRpc/TabContentRPC.test.js @@ -5,6 +5,7 @@ require('chai').use(require('chai-subset')); const {assert: {deepEqual: deq, strictEqual: eq, throws, isFunction, isRejected, containSubset}} = require('chai'); const sinon = require('sinon'); +const PromiseFateTracker = require('../../utilities/PromiseFateTracker'); const Wait = require('../../utilities/Wait'); const TabContentRPC = require('../../../lib/contentRpc/TabContentRPC'); const explicitPromise = require('../../../lib/explicitPromise'); @@ -275,6 +276,46 @@ describe('TabContentRPC', () => { eq(onRpcInitialize.callCount, 1); }); + it('Should wait for onRpcInitialize to resolve', async () => { + const fate = new PromiseFateTracker(); + const fooMethod = sinon.spy(a => a * 2); + let onRpcInitializeResolve; + const onRpcInitialize = sinon.spy(async ({rpc}) => { + await new Promise(r => {onRpcInitializeResolve = r;}); + rpc.method('foo', fooMethod); + }); + const rpc = new TabContentRPC({browserRuntime, browserTabs, context: 'fooContext', onRpcInitialize}); + rpc.attach(); + rpc.reinitialize('my tab id 1234', 0); + eq(onRpcInitialize.callCount, 1); + + const onMessageCallback = browserRuntime.onMessage.addListener.firstCall.args[0]; + fate.track('foo', onMessageCallback( + { + method: 'foo', + params: [123], + rpcContext: 'fooContext', + }, + { + id: 'openrunner@computest.nl', + tab: { + id: 'my tab id 1234', + }, + frameId: 0, + url: 'https://computest.nl/', + }, + )); + + await new Promise(r => setTimeout(r, 100)); + eq(fooMethod.callCount, 0); + fate.assertPending('foo'); + + onRpcInitializeResolve(); + await fate.waitForAllSettled(); + fate.assertResolved('foo', {result: 246}); + eq(fooMethod.callCount, 1); + }); + it('Should call registered methods when a browser runtime message has been received and return the rejected error', async () => { const fooMethod = async x => { const err = Error('Error from a test! ' + x); diff --git a/test/unit/runResult/Event.test.js b/test/unit/runResult/Event.test.js index 779ad0d..3060a03 100644 --- a/test/unit/runResult/Event.test.js +++ b/test/unit/runResult/Event.test.js @@ -28,6 +28,7 @@ describe('Event', () => { eq(event.longTitle, ''); eq(event.shortTitle, ''); isNull(event.tabId); + isNull(event.frameId); isNull(event.tabContentId); lengthOf([...event.metaData], 0); isNull(event.getMetaData('foo')); @@ -50,12 +51,14 @@ describe('Event', () => { event.shortTitle = 'my short title'; event.longTitle = 'my very looooong title'; event.tabId = 'f1234'; + event.frameId = 1001; event.tabContentId = 'f6574'; eq(event.comment, 'my comment!'); eq(event.shortTitle, 'my short title'); eq(event.longTitle, 'my very looooong title'); eq(event.tabId, 'f1234'); + eq(event.frameId, 1001); eq(event.tabContentId, 'f6574'); }); @@ -65,12 +68,14 @@ describe('Event', () => { event.shortTitle = 456; event.longTitle = 789; event.tabId = 1234; + event.frameId = 4321; event.tabContentId = 5678; eq(event.comment, '123'); eq(event.shortTitle, '456'); eq(event.longTitle, '789'); eq(event.tabId, '1234'); + eq(event.frameId, 4321); eq(event.tabContentId, '5678'); }); @@ -149,6 +154,7 @@ describe('Event', () => { event.shortTitle = 'my short title'; event.longTitle = 'my very looooong title'; event.tabId = 'f1234'; + event.frameId = 1247; event.tabContentId = 'f6574'; event.addChild(new Event('child type')); event.setMetaData('foo', 'bar'); @@ -201,10 +207,12 @@ describe('Event', () => { metaData: {}, children: [], tabId: null, + frameId: null, tabContentId: null, }, ], tabId: 'f1234', + frameId: 1247, tabContentId: 'f6574', }); diff --git a/test/unit/runResult/RunResult.test.js b/test/unit/runResult/RunResult.test.js index c094211..642ca67 100644 --- a/test/unit/runResult/RunResult.test.js +++ b/test/unit/runResult/RunResult.test.js @@ -428,6 +428,7 @@ describe('RunResult', () => { metaData: {}, children: [], tabId: null, + frameId: null, tabContentId: null, }, { @@ -454,6 +455,7 @@ describe('RunResult', () => { metaData: {}, children: [], tabId: null, + frameId: null, tabContentId: null, }, { @@ -480,6 +482,7 @@ describe('RunResult', () => { metaData: {}, children: [], tabId: null, + frameId: null, tabContentId: null, }, ], @@ -622,6 +625,7 @@ describe('RunResult', () => { metaData: {}, children: [], tabId: null, + frameId: null, tabContentId: null, }, { @@ -648,6 +652,7 @@ describe('RunResult', () => { metaData: {}, children: [], tabId: null, + frameId: null, tabContentId: null, }, { @@ -674,6 +679,7 @@ describe('RunResult', () => { metaData: {}, children: [], tabId: null, + frameId: null, tabContentId: null, }, ],