diff options
Diffstat (limited to 'remote/cdp/test/browser/head.js')
-rw-r--r-- | remote/cdp/test/browser/head.js | 638 |
1 files changed, 638 insertions, 0 deletions
diff --git a/remote/cdp/test/browser/head.js b/remote/cdp/test/browser/head.js new file mode 100644 index 0000000000..6fffc7a312 --- /dev/null +++ b/remote/cdp/test/browser/head.js @@ -0,0 +1,638 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// window.RemoteAgent is a simple object set in browser.js, and importing +// RemoteAgent conflicts with that. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { RemoteAgent } = ChromeUtils.importESModule( + "chrome://remote/content/components/RemoteAgent.sys.mjs" +); +const { RemoteAgentError } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/Error.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); +const { Stream } = ChromeUtils.importESModule( + "chrome://remote/content/cdp/StreamRegistry.sys.mjs" +); + +const TIMEOUT_MULTIPLIER = getTimeoutMultiplier(); +const TIMEOUT_EVENTS = 1000 * TIMEOUT_MULTIPLIER; + +function getTimeoutMultiplier() { + if ( + AppConstants.DEBUG || + AppConstants.MOZ_CODE_COVERAGE || + AppConstants.ASAN || + AppConstants.TSAN + ) { + return 4; + } + + return 1; +} + +/* +add_task() is overriden to setup and teardown a test environment +making it easier to write browser-chrome tests for the remote +debugger. + +Before the task is run, the nsIRemoteAgent listener is started and +a CDP client is connected to it. A new tab is also added. These +three things are exposed to the provided task like this: + + add_task(async function testName(client, CDP, tab) { + // client is an instance of the CDP class + // CDP is ./chrome-remote-interface.js + // tab is a fresh tab, destroyed after the test + }); + +Also target discovery is getting enabled, which means that targetCreated, +targetDestroyed, and targetInfoChanged events will be received by the client. + +add_plain_task() may be used to write test tasks without the implicit +setup and teardown described above. +*/ + +const add_plain_task = add_task.bind(this); + +this.add_task = function (taskFn, opts = {}) { + const { + createTab = true, // By default run each test in its own tab + } = opts; + + const fn = async function () { + let client, tab, target; + + try { + const CDP = await getCDP(); + + if (createTab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + const tabId = TabManager.getIdForBrowser(tab.linkedBrowser); + + const targets = await CDP.List(); + target = targets.find(target => target.id === tabId); + } + + client = await CDP({ target }); + info("CDP client instantiated"); + + // Bug 1605722 - Workaround to not hang when waiting for Target events + await getDiscoveredTargets(client.Target); + + await taskFn({ client, CDP, tab }); + + if (createTab) { + // taskFn may resolve within a tick after opening a new tab. + // We shouldn't remove the newly opened tab in the same tick. + // Wait for the next tick here. + await TestUtils.waitForTick(); + BrowserTestUtils.removeTab(tab); + } + } catch (e) { + // Display better error message with the server side stacktrace + // if an error happened on the server side: + if (e.response) { + throw RemoteAgentError.fromJSON(e.response); + } else { + throw e; + } + } finally { + if (client) { + await client.close(); + info("CDP client closed"); + } + + // Close any additional tabs, so that only a single tab remains open + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } + } + }; + + Object.defineProperty(fn, "name", { value: taskFn.name, writable: false }); + add_plain_task(fn); +}; + +/** + * Create a test document in an invisible window. + * This window will be automatically closed on test teardown. + */ +function createTestDocument() { + const browser = Services.appShell.createWindowlessBrowser(true); + registerCleanupFunction(() => browser.close()); + + // Create a system principal content viewer to ensure there is a valid + // empty document using system principal and avoid any wrapper issues + // when using document's JS Objects. + const webNavigation = browser.docShell.QueryInterface(Ci.nsIWebNavigation); + const system = Services.scriptSecurityManager.getSystemPrincipal(); + webNavigation.createAboutBlankContentViewer(system, system); + + return webNavigation.document; +} + +/** + * Retrieve an intance of CDP object from chrome-remote-interface library + */ +async function getCDP() { + // Instantiate a background test document in order to load the library + // as in a web page + const document = createTestDocument(); + + const window = document.defaultView.wrappedJSObject; + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/remote/cdp/test/browser/chrome-remote-interface.js", + window + ); + + // Implements `criRequest` to be called by chrome-remote-interface + // library in order to do the cross-domain http request, which, + // in a regular Web page, is impossible. + window.criRequest = (options, callback) => { + const { path } = options; + const url = `http://${RemoteAgent.host}:${RemoteAgent.port}${path}`; + const xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + + // Prevent "XML Parsing Error: syntax error" error messages + xhr.overrideMimeType("text/plain"); + + xhr.send(null); + xhr.onload = () => callback(null, xhr.responseText); + xhr.onerror = e => callback(e, null); + }; + + return window.CDP; +} + +async function getScrollbarSize() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const scrollbarHeight = {}; + const scrollbarWidth = {}; + + content.windowUtils.getScrollbarSize( + false, + scrollbarWidth, + scrollbarHeight + ); + return { + width: scrollbarWidth.value, + height: scrollbarHeight.value, + }; + }); +} + +function getTargets(CDP) { + return new Promise((resolve, reject) => { + CDP.List(null, (err, targets) => { + if (err) { + reject(err); + return; + } + resolve(targets); + }); + }); +} + +// Wait for all Target.targetCreated events. One for each tab. +async function getDiscoveredTargets(Target, options = {}) { + const { discover = true, filter } = options; + + const targets = []; + const unsubscribe = Target.targetCreated(target => { + targets.push(target.targetInfo); + }); + + await Target.setDiscoverTargets({ + discover, + filter, + }).finally(() => unsubscribe()); + + return targets; +} + +async function openTab(Target, options = {}) { + const { activate = false } = options; + + info("Create a new tab and wait for the target to be created"); + const targetCreated = Target.targetCreated(); + const newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + const { targetInfo } = await targetCreated; + + is(targetInfo.type, "page"); + + if (activate) { + await Target.activateTarget({ + targetId: targetInfo.targetId, + }); + info(`New tab with target id ${targetInfo.targetId} created and activated`); + } else { + info(`New tab with target id ${targetInfo.targetId} created`); + } + + return { targetInfo, newTab }; +} + +async function openWindow(Target, options = {}) { + const { activate = false } = options; + + info("Create a new window and wait for the target to be created"); + const targetCreated = Target.targetCreated(); + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newTab = newWindow.gBrowser.selectedTab; + const { targetInfo } = await targetCreated; + is(targetInfo.type, "page"); + + if (activate) { + await Target.activateTarget({ + targetId: targetInfo.targetId, + }); + info( + `New window with target id ${targetInfo.targetId} created and activated` + ); + } else { + info(`New window with target id ${targetInfo.targetId} created`); + } + + return { targetInfo, newWindow, newTab }; +} + +/** Creates a data URL for the given source document. */ +function toDataURL(src, doctype = "html") { + let doc, mime; + switch (doctype) { + case "html": + mime = "text/html;charset=utf-8"; + doc = `<!doctype html>\n<meta charset=utf-8>\n${src}`; + break; + default: + throw new Error("Unexpected doctype: " + doctype); + } + + return `data:${mime},${encodeURIComponent(doc)}`; +} + +function convertArgument(arg) { + if (typeof arg === "bigint") { + return { unserializableValue: `${arg.toString()}n` }; + } + if (Object.is(arg, -0)) { + return { unserializableValue: "-0" }; + } + if (Object.is(arg, Infinity)) { + return { unserializableValue: "Infinity" }; + } + if (Object.is(arg, -Infinity)) { + return { unserializableValue: "-Infinity" }; + } + if (Object.is(arg, NaN)) { + return { unserializableValue: "NaN" }; + } + + return { value: arg }; +} + +async function evaluate(client, contextId, pageFunction, ...args) { + const { Runtime } = client; + + if (typeof pageFunction === "string") { + return Runtime.evaluate({ + expression: pageFunction, + contextId, + returnByValue: true, + awaitPromise: true, + }); + } else if (typeof pageFunction === "function") { + return Runtime.callFunctionOn({ + functionDeclaration: pageFunction.toString(), + executionContextId: contextId, + arguments: args.map(convertArgument), + returnByValue: true, + awaitPromise: true, + }); + } + throw new Error("pageFunction: expected 'string' or 'function'"); +} + +/** + * Load a given URL in the currently selected tab + */ +async function loadURL(url, expectedURL = undefined) { + expectedURL = expectedURL || url; + + const browser = gBrowser.selectedTab.linkedBrowser; + const loaded = BrowserTestUtils.browserLoaded(browser, true, expectedURL); + + BrowserTestUtils.loadURIString(browser, url); + await loaded; +} + +/** + * Enable the Runtime domain + */ +async function enableRuntime(client) { + const { Runtime } = client; + + // Enable watching for new execution context + await Runtime.enable(); + info("Runtime domain has been enabled"); + + // Calling Runtime.enable will emit executionContextCreated for the existing contexts + const { context } = await Runtime.executionContextCreated(); + ok(!!context.id, "The execution context has an id"); + ok(context.auxData.isDefault, "The execution context is the default one"); + ok(!!context.auxData.frameId, "The execution context has a frame id set"); + + return context; +} + +/** + * Retrieve the value of a property on the content window. + */ +function getContentProperty(prop) { + info(`Retrieve ${prop} on the content window`); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [prop], + _prop => content[_prop] + ); +} + +/** + * Retrieve all frames for the current tab as flattened list. + * + * @returns {Map<number, Frame>} + * Flattened list of frames as Map + */ +async function getFlattenedFrameTree(client) { + const { Page } = client; + + function flatten(frames) { + return frames.reduce((result, current) => { + result.set(current.frame.id, current.frame); + if (current.childFrames) { + const frames = flatten(current.childFrames); + result = new Map([...result, ...frames]); + } + return result; + }, new Map()); + } + + const { frameTree } = await Page.getFrameTree(); + return flatten(Array(frameTree)); +} + +/** + * Return a new promise, which resolves after ms have been elapsed + */ +function timeoutPromise(ms) { + return new Promise(resolve => { + window.setTimeout(resolve, ms); + }); +} + +/** Fail a test. */ +function fail(message) { + ok(false, message); +} + +/** + * Create a stream with the specified contents. + * + * @param {string} contents + * Contents of the file. + * @param {object} options + * @param {string=} options.path + * Path of the file. Defaults to the temporary directory. + * @param {boolean=} options.remove + * If true, automatically remove the file after the test. Defaults to true. + * + * @returns {Promise<Stream>} + */ +async function createFileStream(contents, options = {}) { + let { path = null, remove = true } = options; + + if (!path) { + path = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "remote-agent.txt" + ); + } + + await IOUtils.writeUTF8(path, contents); + + const stream = new Stream(path); + if (remove) { + registerCleanupFunction(() => stream.destroy()); + } + + return stream; +} + +async function throwScriptError(options = {}) { + const { inContent = true } = options; + + const addScriptErrorInternal = options => { + const { + flag = Ci.nsIScriptError.errorFlag, + innerWindowId = content.windowGlobalChild.innerWindowId, + } = options; + + const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.initWithWindowID( + options.text, + options.sourceName || "sourceName", + null, + options.lineNumber || 0, + options.columnNumber || 0, + flag, + options.category || "javascript", + innerWindowId + ); + Services.console.logMessage(scriptError); + }; + + if (inContent) { + SpecialPowers.spawn( + gBrowser.selectedBrowser, + [options], + addScriptErrorInternal + ); + } else { + options.innerWindowId = window.windowGlobalChild.innerWindowId; + addScriptErrorInternal(options); + } +} + +class RecordEvents { + /** + * A timeline of events chosen by calls to `addRecorder`. + * Call `configure`` for each client event you want to record. + * Then `await record(someTimeout)` to record a timeline that you + * can make assertions about. + * + * const history = new RecordEvents(expectedNumberOfEvents); + * + * history.addRecorder({ + * event: Runtime.executionContextDestroyed, + * eventName: "Runtime.executionContextDestroyed", + * messageFn: payload => { + * return `Received Runtime.executionContextDestroyed for id ${payload.executionContextId}`; + * }, + * }); + * + * + * @param {number} total + * Number of expected events. Stop recording when this number is exceeded. + * + */ + constructor(total) { + this.events = []; + this.promises = new Set(); + this.subscriptions = new Set(); + this.total = total; + } + + /** + * Configure an event to be recorded and logged. + * The recording stops once we accumulate more than the expected + * total of all configured events. + * + * @param {object} options + * @param {CDPEvent} options.event + * https://github.com/cyrus-and/chrome-remote-interface#clientdomaineventcallback + * @param {string} options.eventName + * Name to use for reporting. + * @param {Function=} options.callback + * ({ eventName, payload }) => {} to be called when each event is received + * @param {function(payload):string=} options.messageFn + */ + addRecorder(options = {}) { + const { + event, + eventName, + messageFn = () => `Recorded ${eventName}`, + callback, + } = options; + + const promise = new Promise(resolve => { + const unsubscribe = event(payload => { + info(messageFn(payload)); + this.events.push({ eventName, payload, index: this.events.length }); + callback?.({ eventName, payload, index: this.events.length - 1 }); + if (this.events.length > this.total) { + this.subscriptions.delete(unsubscribe); + unsubscribe(); + resolve(this.events); + } + }); + this.subscriptions.add(unsubscribe); + }); + + this.promises.add(promise); + } + + /** + * Register a promise to await while recording the timeline. The returned + * callback resolves the registered promise and adds `step` + * to the timeline, along with an associated payload, if provided. + * + * @param {string} step + * @returns {Function} callback + */ + addPromise(step) { + let callback; + const promise = new Promise(resolve => { + callback = value => { + resolve(); + info(`Recorded ${step}`); + this.events.push({ + eventName: step, + payload: value, + index: this.events.length, + }); + return value; + }; + }); + + this.promises.add(promise); + return callback; + } + + /** + * Record events until we hit the timeout or the expected total is exceeded. + * + * @param {number=} timeout + * Timeout in milliseconds. Defaults to 1000. + * + * @returns {Array<{ eventName, payload, index }>} Recorded events + */ + async record(timeout = TIMEOUT_EVENTS) { + await Promise.race([Promise.all(this.promises), timeoutPromise(timeout)]); + for (const unsubscribe of this.subscriptions) { + unsubscribe(); + } + return this.events; + } + + /** + * Filter events based on predicate + * + * @param {Function} predicate + * + * @returns {Array<{ eventName, payload, index }>} + * The list of events matching the filter. + */ + filter(predicate) { + return this.events.filter(predicate); + } + + /** + * Find first occurrence of the given event. + * + * @param {string} eventName + * + * @returns {{ eventName, payload, index }} The event, if any. + */ + findEvent(eventName) { + const event = this.events.find(el => el.eventName == eventName); + if (event) { + return event; + } + return {}; + } + + /** + * Find given events. + * + * @param {string} eventName + * + * @returns {Array<{ eventName, payload, index }>} + * The events, if any. + */ + findEvents(eventName) { + return this.events.filter(event => event.eventName == eventName); + } + + /** + * Find index of first occurrence of the given event. + * + * @param {string} eventName + * + * @returns {number} The event index, -1 if not found. + */ + indexOf(eventName) { + const event = this.events.find(el => el.eventName == eventName); + if (event) { + return event.index; + } + return -1; + } +} |