/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
const { RemoteAgentError } = ChromeUtils.import(
"chrome://remote/content/Error.jsm"
);
const { RemoteAgent } = ChromeUtils.import(
"chrome://remote/content/RemoteAgent.jsm"
);
const TIMEOUT_MULTIPLIER = SpecialPowers.isDebugBuild ? 4 : 1;
const TIMEOUT_EVENTS = 1000 * TIMEOUT_MULTIPLIER;
/*
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;
await RemoteAgent.listen(Services.io.newURI("http://localhost:9222"));
info("CDP server started");
try {
const CDP = await getCDP();
if (createTab) {
tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
const browsingContextId = tab.linkedBrowser.browsingContext.id;
const targets = await CDP.List();
target = targets.find(
target => target.browsingContextId === browsingContextId
);
}
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");
}
await RemoteAgent.close();
info("CDP server stopped");
// 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/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 { host, port, path } = options;
const url = `http://${host}:${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, plus the one
// for the main process target.
async function getDiscoveredTargets(Target) {
return new Promise(resolve => {
const targets = [];
const unsubscribe = Target.targetCreated(target => {
targets.push(target);
if (targets.length >= gBrowser.tabs.length + 1) {
unsubscribe();
resolve(targets);
}
});
Target.setDiscoverTargets({ discover: true });
});
}
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 = `\n\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.loadURI(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.
*
* @return {Map}
* 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 file 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.
*
* @return {Promise}
* @resolves {string}
* Returns the final path of the created file.
*/
async function createFile(contents, options = {}) {
let { path = null, remove = true } = options;
if (!path) {
const basePath = OS.Path.join(OS.Constants.Path.tmpDir, "remote-agent.txt");
const { file, path: tmpPath } = await OS.File.openUnique(basePath, {
humanReadable: true,
});
await file.close();
path = tmpPath;
}
let encoder = new TextEncoder();
let array = encoder.encode(contents);
const count = await OS.File.writeAtomic(path, array, {
encoding: "utf-8",
tmpPath: path + ".tmp",
});
is(count, contents.length, "All data has been written to file");
const file = await OS.File.open(path);
// Automatically remove the file once the test has finished
if (remove) {
registerCleanupFunction(async () => {
await file.close();
await OS.File.remove(path, { ignoreAbsent: true });
});
}
return { file, path };
}
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) {
ContentTask.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
* @return {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.
*
* @return {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
*
* @return {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
*
* @return {{ 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
*
* @return {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
*
* @return {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;
}
}