// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* To have the IDE support for types when writing inspector-protocol tests:
*
* - `npm i devtools-protocol -g`
* - `cd $HOME && npm link devtools-protocol`
*
* Note that `devtools-protocol` package won't include your local changes
* to the protocol and might be slightly out-of-date. Update it from time to time.
*/
var TestRunner = class {
constructor(testBaseURL, targetBaseURL, log, completeTest, fetch, params) {
this._dumpInspectorProtocolMessages = false;
this._protocolTimeout = 0;
this._testBaseURL = testBaseURL;
this._targetBaseURL = targetBaseURL;
this._log = log;
this._completeTest = completeTest;
this._fetch = fetch;
this._params = params;
this._browserSession = new TestRunner.Session(this, '');
this._stableValues = new Map();
}
static get stabilizeNames() {
return [
'id',
'nodeId',
'objectId',
'scriptId',
'timestamp',
'backendNodeId',
'parentId',
'frameId',
'loaderId',
'baseURL',
'documentURL',
'styleSheetId',
'executionContextId',
'executionContextUniqueId',
'openerId',
'targetId',
'browserContextId',
'sessionId',
'receivedBytes',
'ownerNode',
'guid',
'requestId',
'openerFrameId',
'issueId',
'initiatingFrameId'
];
}
static extendStabilizeNames(extended) {
return [
...TestRunner.stabilizeNames,
...extended
]
};
startDumpingProtocolMessages() {
this._dumpInspectorProtocolMessages = true;
};
completeTest() {
this._completeTest.call(null);
}
log(item, title, stabilizeNames, stabilizeValues) {
if (typeof item === 'object')
return this._logObject(item, title, stabilizeNames, stabilizeValues);
this._log.call(null, item);
}
params(name) {
if (name) {
return this._params instanceof URLSearchParams
? this._params.get(name) : this._params[name];
}
return this._params;
}
_logObject(object, title, stabilizeNames = TestRunner.stabilizeNames, stabilizeValues = []) {
var lines = [];
const stableValues = this._stableValues;
function dumpValue(value, prefix, prefixWithName) {
if (typeof value === 'object' && value !== null) {
if (value instanceof Array)
dumpItems(value, prefix, prefixWithName);
else
dumpProperties(value, prefix, prefixWithName);
} else {
lines.push(prefixWithName + String(value).replace(/\n/g, ' '));
}
}
function dumpProperties(object, prefix, firstLinePrefix) {
prefix = prefix || '';
firstLinePrefix = firstLinePrefix || prefix;
lines.push(firstLinePrefix + '{');
var propertyNames = Object.keys(object);
propertyNames.sort();
for (var i = 0; i < propertyNames.length; ++i) {
var name = propertyNames[i];
if (!object.hasOwnProperty(name))
continue;
var prefixWithName = ' ' + prefix + name + ' : ';
var value = object[name];
if (stabilizeValues && stabilizeValues.includes(name)) {
if (!stableValues.has(value)) {
stableValues.set(value, `<${typeof value} ${stableValues.size}>`);
}
value = stableValues.get(value);
} else if (stabilizeNames && stabilizeNames.includes(name)) {
value = `<${typeof value}>`;
}
dumpValue(value, ' ' + prefix, prefixWithName);
}
lines.push(prefix + '}');
}
function dumpItems(object, prefix, firstLinePrefix) {
prefix = prefix || '';
firstLinePrefix = firstLinePrefix || prefix;
lines.push(firstLinePrefix + '[');
for (var i = 0; i < object.length; ++i)
dumpValue(object[i], ' ' + prefix, ' ' + prefix + '[' + i + '] : ');
lines.push(prefix + ']');
}
dumpValue(object, '', title || '');
this._log.call(null, lines.join('\n'));
}
trimURL(url) {
return url.replace(/^.*(([^/]*[/]){3}[^/]*)$/, '...$1');
}
url(relative) {
if (
relative.startsWith('http://') ||
relative.startsWith('https://') ||
relative.startsWith('file://') ||
relative.startsWith('chrome://') ||
relative === 'about:blank'
)
return relative;
return this._targetBaseURL + relative;
}
async runTestSuite(testSuite) {
for (var test of testSuite) {
this.log('\nRunning test: ' + test.name);
try {
await test();
} catch (e) {
this.log(`Error during test: ${e}\n${e.stack}`);
}
}
this.completeTest();
}
_checkExpectation(fail, name, messageObject) {
if (fail === !!messageObject.error) {
this.log('PASS: ' + name);
return true;
}
this.log('FAIL: ' + name + ': ' + JSON.stringify(messageObject));
this.completeTest();
return false;
}
expectedSuccess(name, messageObject) {
return this._checkExpectation(false, name, messageObject);
}
expectedError(name, messageObject) {
return this._checkExpectation(true, name, messageObject);
}
die(message, error) {
this.log(`${message}: ${error}\n${error.stack}`);
this.completeTest();
throw new Error(message);
}
fail(message) {
this.log('FAIL: ' + message);
this.completeTest();
}
async loadScript(url) {
var source = await this._fetch(this._testBaseURL + url);
return eval(`${source}\n//# sourceURL=${url}`);
};
async loadScriptAbsolute(url) {
var source = await this._fetch(url);
return eval(`${source}\n//# sourceURL=${url}`);
};
async loadScriptModule(path) {
const source = await this._fetch(this._testBaseURL + path);
return new Promise((resolve, reject) => {
const src = URL.createObjectURL(new Blob([source], { type: 'application/javascript' }));
const script = Object.assign(document.createElement('script'), {
src,
type: 'module',
onerror: reject,
onload: resolve
});
document.head.appendChild(script);
})
};
browserSession() {
return this._browserSession;
}
browserP() {
return this._browserSession.protocol;
}
async attachFullBrowserSession() {
const bp = this._browserSession.protocol;
const browserSessionId = (await bp.Target.attachToBrowserTarget()).result.sessionId;
return new TestRunner.Session(this, browserSessionId);
}
async createPage(options) {
options = options || {};
const browserProtocol = this._browserSession.protocol;
const params = {url: 'about:blank'};
if (options.width)
params.width = options.width;
if (options.height)
params.height = options.height;
if (options.enableBeginFrameControl)
params.enableBeginFrameControl = true;
if (options.createContextOptions) {
const browserContextId = (await browserProtocol.Target.createBrowserContext(options.createContextOptions)).result.browserContextId;
params.browserContextId = browserContextId;
}
const targetId = (await browserProtocol.Target.createTarget(params)).result.targetId;
const page = new TestRunner.Page(this, targetId);
let url = options.url || DevToolsHost.dummyPageURL;
if (!url) {
url = window.location.href;
url = url.substring(0, url.indexOf('inspector-protocol-test.html')) + 'inspector-protocol-page.html';
}
await page.navigate(url);
return page;
}
async _start(description, options) {
try {
if (!description)
throw new Error('Please provide a description for the test!');
this.log(description);
var page = await this.createPage(options);
if (options.html)
await page.loadHTML(options.html);
var session = await page.createSession();
return { page: page, session: session, dp: session.protocol };
} catch (e) {
this.die('Error starting the test', e);
}
};
startBlank(description, options) {
return this._start(description, options || {});
}
startHTML(html, description, options) {
options = options || {};
options.html = html;
return this._start(description, options);
}
startURL(url, description, options) {
options = options || {};
options.url = url;
return this._start(description, options);
}
startWithFrameControl(description, options) {
options = options || {};
options.width = options.width || 800;
options.height = options.height || 600;
options.createContextOptions = {};
options.enableBeginFrameControl = true;
return this._start(description, options);
}
async startBlankWithTabTarget(description) {
try {
if (!description)
throw new Error('Please provide a description for the test!');
this.log(description);
const bp = this.browserP();
const params = {url: 'about:blank', forTab: true};
const tabTargetId =
(await bp.Target.createTarget(params)).result.targetId;
const tabTargetSessionId = (await bp.Target.attachToTarget({
targetId: tabTargetId,
flatten: true
})).result.sessionId;
const tabTargetSession = new TestRunner.Session(this, tabTargetSessionId);
return {tabTargetSession};
} catch (e) {
this.die('Error starting the test', e);
}
}
async logStackTrace(debuggers, stackTrace, debuggerId) {
while (stackTrace) {
const {description, callFrames, parent, parentId} = stackTrace;
if (description)
this.log(`--${description}--`);
this.logCallFrames(callFrames);
if (parentId) {
if (parentId.debuggerId)
debuggerId = parentId.debuggerId;
let result = await debuggers.get(debuggerId).getStackTrace({
stackTraceId: parentId
});
stackTrace = result.stackTrace || result.result.stackTrace;
} else {
stackTrace = parent;
}
}
}
_replaceUUID(url) {
const uuidRegex = new RegExp('[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}');
return url.replace(uuidRegex, 'UUID');
}
logCallFrames(callFrames) {
for (let frame of callFrames) {
let functionName = frame.functionName || '(anonymous)';
let url = this._replaceUUID(frame.url);
let location = frame.location || frame;
this.log(`${functionName} at ${url}:${
location.lineNumber
}:${location.columnNumber}`);
}
}
};
TestRunner.Page = class {
constructor(testRunner, targetId) {
this._testRunner = testRunner;
this._targetId = targetId;
}
targetId() {
return this._targetId;
}
async createSession() {
let dp = this._testRunner._browserSession.protocol;
const sessionId = (await dp.Target.attachToTarget({targetId: this._targetId, flatten: true})).result.sessionId;
return new TestRunner.Session(this._testRunner, sessionId);
}
navigate(url) {
return this._navigate(this._testRunner.url(url));
}
async _navigate(url) {
var session = await this.createSession();
await session._navigate(url);
await session.disconnect();
}
async loadHTML(html) {
html = html.replace(/'/g, "\\'").replace(/\n/g, '\\n');
var session = await this.createSession();
await session.protocol.Runtime.evaluate({
awaitPromise: true,
expression: `
document.write('${html}');
// wait for all scripts to load
const promise = new Promise(x => window._loadHTMLResolve = x).then(() => {
delete window._loadHTMLResolve;
});
// We do a document.write here to serialize with the previous document.write
if (document.querySelector('script[src]'))
document.write('<script>window._loadHTMLResolve(); document.currentScript.remove();</script>');
else
window._loadHTMLResolve();
document.close();
promise;
`});
await session.disconnect();
}
};
TestRunner.Session = class {
constructor(testRunner, sessionId) {
this._testRunner = testRunner;
this._sessionId = sessionId;
this._requestId = 0;
this._eventHandlers = new Map();
this.protocol = this._setupProtocol();
this._parentSessionId = null;
DevToolsAPI._sessions.set(sessionId, this);
}
async disconnect() {
await DevToolsAPI._sendCommandOrDie(
this._parentSessionId, 'Target.detachFromTarget',
{sessionId: this._sessionId}, this._testRunner._protocolTimeout);
}
createChild(sessionId) {
const session = new TestRunner.Session(this._testRunner, sessionId);
session._parentSessionId = this._sessionId;
return session;
}
async attachChild(targetId) {
const {sessionId} = (await this.protocol.Target.attachToTarget({targetId, flatten: true})).result;
return this.createChild(sessionId);
}
async sendCommand(method, params) {
if (this._testRunner._dumpInspectorProtocolMessages)
this._testRunner.log(`frontend => backend: ${JSON.stringify({method, params, sessionId: this._sessionId})}`);
const result = await DevToolsAPI._sendCommand(
this._sessionId, method, params, this._testRunner._protocolTimeout);
if (this._testRunner._dumpInspectorProtocolMessages)
this._testRunner.log(`backend => frontend: ${JSON.stringify(result)}`);
return result;
}
async evaluate(code, ...args) {
return this._innerEvaluate(false /* awaitPromise */, false /* userGesture */, code, ...args);
}
async evaluateAsync(code, ...args) {
return this._innerEvaluate(true /* awaitPromise */, false /* userGesture */, code, ...args);
}
async evaluateAsyncWithUserGesture(code, ...args) {
return this._innerEvaluate(true /* awaitPromise */, true /* userGesture */, code, ...args);
}
async _innerEvaluate(awaitPromise, userGesture, code, ...args) {
if (typeof code === 'function') {
var argsString = args.map(JSON.stringify.bind(JSON)).join(', ');
code = `(${code.toString()})(${argsString})`;
}
var response = await this.protocol.Runtime.evaluate({expression: code, returnByValue: true, awaitPromise, userGesture});
if (response.error) {
const errorMessage = JSON.stringify(response.error);
const maybeAsync = awaitPromise ? 'async ' : '';
this._testRunner.log(`Error while evaluating ${maybeAsync}'${code}': ${errorMessage}`);
this._testRunner.completeTest();
} else {
return response.result.result.value;
}
}
navigate(url, waitUntil = 'load') {
return this._navigate(this._testRunner.url(url), waitUntil);
}
async _navigate(url, waitUntil = 'load') {
await this.protocol.Page.enable();
await this.protocol.Page.setLifecycleEventsEnabled({enabled: true});
const frameTree = await this.protocol.Page.getFrameTree();
const frameId = frameTree.result.frameTree.frame.id;
const navigatePromise = this.protocol.Page.navigate({url: url});
await this.protocol.Page.onceLifecycleEvent(
event => event.params.name === waitUntil && event.params.frameId === frameId);
await navigatePromise;
}
_dispatchMessage(message) {
if (this._testRunner._dumpInspectorProtocolMessages)
this._testRunner.log(`backend => frontend: ${JSON.stringify(message)}`);
var eventName = message.method;
for (var handler of (this._eventHandlers.get(eventName) || []))
handler(message);
}
/**
* @returns {import("devtools-protocol/types/protocol-tests-proxy-api").ProtocolTestsProxyApi.ProtocolApi}
*/
_setupProtocol() {
return new Proxy({}, {
get: (target, agentName, receiver) => new Proxy({}, {
get: (target, methodName, receiver) => {
const eventPattern = /^(on(ce)?|off)([A-Z][A-Za-z0-9]*)/;
var match = eventPattern.exec(methodName);
if (!match)
return args => this.sendCommand(
`${agentName}.${methodName}`, args || {});
var eventName = match[3];
eventName = eventName.charAt(0).toLowerCase() + eventName.slice(1);
if (match[1] === 'once')
return eventMatcher => this._waitForEvent(
`${agentName}.${eventName}`, eventMatcher);
if (match[1] === 'off')
return listener => this._removeEventHandler(
`${agentName}.${eventName}`, listener);
return listener => this._addEventHandler(
`${agentName}.${eventName}`, listener);
}
})
});
}
_addEventHandler(eventName, handler) {
var handlers = this._eventHandlers.get(eventName) || [];
handlers.push(handler);
this._eventHandlers.set(eventName, handlers);
}
_removeEventHandler(eventName, handler) {
var handlers = this._eventHandlers.get(eventName) || [];
var index = handlers.indexOf(handler);
if (index === -1)
return;
handlers.splice(index, 1);
this._eventHandlers.set(eventName, handlers);
}
_waitForEvent(eventName, eventMatcher) {
return TestRunner.wrapPromiseWithTimeout(
new Promise(callback => {
var handler = result => {
if (eventMatcher && !eventMatcher(result))
return;
this._removeEventHandler(eventName, handler);
callback(result);
};
this._addEventHandler(eventName, handler);
}),
this._testRunner._protocolTimeout,
`Waiting for ${eventName} timed out`);
}
};
// Helper class to collect information of auto attached targets and
// create `TestRunner.Session` from them.
TestRunner.ChildTargetManager = class {
// @param {TestRunner} testRunner
// @param {Session} session
constructor(testRunner, session) {
this._testRunner = testRunner;
this._session = session;
this._attachedTargets = [];
}
// @param {object|undefined} autoAttachParams
// @return {void}
//
// Issues `Target.setAutoAttach` and starts collecting auto attached
// `TargetInfo`.
async startAutoAttach(autoAttachParams) {
autoAttachParams = autoAttachParams ||
{autoAttach: true, flatten: true, waitForDebuggerOnStart: false};
this._session.protocol.Target.onAttachedToTarget(event => {
this._attachedTargets.push(event.params);
});
await this._session.protocol.Target.setAutoAttach(autoAttachParams);
}
// @param {(TargetInfo): bool} pred
// @return {TestRunner.Session|null}
findAttachedSession(pred) {
const found =
this._attachedTargets.find(({targetInfo}) => pred(targetInfo));
return found ? this._session.createChild(found.sessionId) : null;
}
// @return {TestRunner.Session|null}
findAttachedSessionPrimaryMainFrame() {
return this.findAttachedSession(
targetInfo =>
targetInfo.type === 'page' && targetInfo.subtype === undefined);
}
// @return {TestRunner.Session|null}
findAttachedSessionPrerender() {
return this.findAttachedSession(
targetInfo =>
targetInfo.type === 'page' && targetInfo.subtype === 'prerender');
}
};
var DevToolsAPI = {};
DevToolsAPI._requestId = 0;
DevToolsAPI._embedderMessageId = 0;
DevToolsAPI._dispatchTable = new Map();
DevToolsAPI._sessions = new Map();
DevToolsAPI._outputElement = null;
DevToolsAPI._log = function(text) {
if (!DevToolsAPI._outputElement) {
var intermediate = document.createElement('div');
document.body.appendChild(intermediate);
var intermediate2 = document.createElement('div');
intermediate.appendChild(intermediate2);
DevToolsAPI._outputElement = document.createElement('div');
DevToolsAPI._outputElement.className = 'output';
DevToolsAPI._outputElement.id = 'output';
DevToolsAPI._outputElement.style.whiteSpace = 'pre';
intermediate2.appendChild(DevToolsAPI._outputElement);
}
DevToolsAPI._outputElement.appendChild(document.createTextNode(text));
DevToolsAPI._outputElement.appendChild(document.createElement('br'));
};
DevToolsAPI._completeTest = function() {
testRunner.notifyDone();
};
DevToolsAPI._die = function(message, error) {
DevToolsAPI._log(`${message}: ${error}\n${error.stack}`);
DevToolsAPI._completeTest();
throw new Error();
};
DevToolsAPI.dispatchMessage = function(messageOrObject) {
var messageObject = (typeof messageOrObject === 'string' ? JSON.parse(messageOrObject) : messageOrObject);
var messageId = messageObject.id;
try {
if (typeof messageId === 'number') {
var handler = DevToolsAPI._dispatchTable.get(messageId);
if (handler) {
DevToolsAPI._dispatchTable.delete(messageId);
handler(messageObject);
} else {
DevToolsAPI._die(`Unexpected result id ${messageId}`);
}
} else {
var session = DevToolsAPI._sessions.get(messageObject.sessionId || '');
if (session)
session._dispatchMessage(messageObject);
}
} catch(e) {
DevToolsAPI._die(`Exception when dispatching message\n${JSON.stringify(messageObject)}`, e);
}
};
DevToolsAPI._sendCommand = function(sessionId, method, params, timeout = 0) {
var requestId = ++DevToolsAPI._requestId;
var messageObject = {'id': requestId, 'method': method, 'params': params};
if (sessionId)
messageObject.sessionId = sessionId;
var embedderMessage = {'id': ++DevToolsAPI._embedderMessageId, 'method': 'dispatchProtocolMessage', 'params': [JSON.stringify(messageObject)]};
DevToolsHost.sendMessageToEmbedder(JSON.stringify(embedderMessage));
return TestRunner.wrapPromiseWithTimeout(
new Promise(f => DevToolsAPI._dispatchTable.set(requestId, f)), timeout,
`${method} command timed out`);
};
DevToolsAPI._sendCommandOrDie = function(sessionId, method, params, timeout) {
return DevToolsAPI._sendCommand(sessionId, method, params, timeout)
.then(message => {
if (message.error)
DevToolsAPI._die(
'Error communicating with harness',
new Error(JSON.stringify(message.error)));
return message.result;
});
};
DevToolsAPI._fetch = function(url) {
return new Promise(fulfill => {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onreadystatechange = e => {
if (xhr.readyState !== XMLHttpRequest.DONE)
return;
if ([0, 200, 304].indexOf(xhr.status) === -1) // Testing harness file:/// results in 0.
DevToolsAPI._die(`${xhr.status} while fetching ${url}`, new Error());
else
fulfill(e.target.response);
};
xhr.send(null);
});
};
testRunner.dumpAsText();
testRunner.waitUntilDone();
testRunner.setPopupBlockingEnabled(false);
window.addEventListener('load', () => {
var params = new URLSearchParams(window.location.search);
if (!params.get('test'))
return;
var testScriptURL = params.get('test');
var testBaseURL = testScriptURL.substring(0, testScriptURL.lastIndexOf('/') + 1);
var targetPageURL = params.get('target') || params.get('test');
var targetBaseURL = targetPageURL.substring(0, targetPageURL.lastIndexOf('/') + 1);
DevToolsAPI._fetch(testScriptURL).then(testScript => {
var testRunner = new TestRunner(testBaseURL, targetBaseURL, DevToolsAPI._log, DevToolsAPI._completeTest, DevToolsAPI._fetch, params);
var testFunction = eval(`${testScript}\n//# sourceURL=${testScriptURL}`);
if (params.get('debug')) {
var dispatch = DevToolsAPI.dispatchMessage;
var messages = [];
DevToolsAPI.dispatchMessage = message => {
if (!messages.length) {
setTimeout(() => {
for (var message of messages.splice(0))
dispatch(message);
}, 0);
}
messages.push(message);
};
testRunner.log = console.log;
testRunner.completeTest = () => console.log('Test completed');
window.test = () => testFunction(testRunner);
return;
}
return testFunction(testRunner);
}).catch(reason => {
DevToolsAPI._log(`Error while executing test script: ${reason}\n${reason.stack}`);
DevToolsAPI._completeTest();
});
}, false);
window['onerror'] = (message, source, lineno, colno, error) => {
DevToolsAPI._log(`${error}\n${error.stack}`);
DevToolsAPI._completeTest();
};
window.addEventListener('unhandledrejection', e => {
DevToolsAPI._log(`Promise rejection: ${e.reason}\n${e.reason ? e.reason.stack : ''}`);
DevToolsAPI._completeTest();
}, false);
TestRunner.wrapPromiseWithTimeout = (promise, timeout, label) => {
if (!timeout)
return promise;
let timerId;
// For a clearer stack trace, creating the error first.
const error = new Error(`Timed out at ${label}`);
const timeoutPromise = new Promise(resolve => {
timerId = setTimeout(resolve, timeout);
});
return Promise.race([
promise.then(result => {
clearTimeout(timerId);
return result;
}),
timeoutPromise.then(() => Promise.reject(error))
]);
};
exports.TestRunner = TestRunner;