summaryrefslogtreecommitdiffstats
path: root/remote/cdp/test/browser/head.js
diff options
context:
space:
mode:
Diffstat (limited to 'remote/cdp/test/browser/head.js')
-rw-r--r--remote/cdp/test/browser/head.js638
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;
+ }
+}