зеркало из https://github.com/mozilla/gecko-dev.git
608 строки
16 KiB
JavaScript
608 строки
16 KiB
JavaScript
/* 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 = `<!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.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<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 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 };
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|