blob: 231ff06e4345fa279f2552c7e516cda62789c2d2 [file] [log] [blame] [edit]
const isDebug = false;
function logDebug(msg) {
if (isDebug)
console.log(msg);
}
/**
* Pause execution for the given duration.
*
* @param {Number} duration - Milliseconds
* @returns Promise<undefined>
*/
function pause(duration) {
return new Promise(resolve => {
setTimeout(resolve, duration);
});
}
/**
*
* @param {object[]} actions
* @param {"pointerMove" | "pointerDown" | "pointerUp" | "pause"} pointerType
* @returns Promise<undefined>
*/
async function dispatchMouseActions(actions, pointerType)
{
await pause(0);
eventSender.dragMode = false;
for (let action of actions) {
switch (action.type) {
case "pointerMove":
const origin = { x: 0, y: 0 };
const actionWindow = action.origin?.ownerDocument?.defaultView;
if (actionWindow && action.origin instanceof actionWindow.Element) {
const bounds = action.origin.getBoundingClientRect();
logDebug(`${action.origin.id} [${bounds.left}, ${bounds.top}, ${bounds.width}, ${bounds.height}]`);
origin.x = bounds.left + (bounds.width / 2.0);
origin.y = bounds.top + (bounds.height / 2.0);
}
logDebug(`eventSender.mouseMoveTo(${action.x + origin.x}, ${action.y + origin.y})`);
eventSender.mouseMoveTo(action.x + origin.x, action.y + origin.y, pointerType);
break;
case "pointerDown":
logDebug(`eventSender.mouseDown()`);
eventSender.mouseDown(action.button, [], pointerType);
break;
case "pointerUp":
logDebug(`eventSender.mouseUp()`);
eventSender.mouseUp(action.button, [], pointerType);
break;
case "pause":
logDebug(`pause(${action.duration})`);
await pause(action.duration);
break;
default:
throw new Error(`Unknown action type "${action.type}".`);
}
}
}
/**
*
* @param {object[]} actions
* @param {object} options
* @param {boolean} options.insertPauseAfterPointerUp
* @returns Promise<undefined>
*/
async function dispatchTouchActions(actions, options = { insertPauseAfterPointerUp: false })
{
let x = 0;
let y = 0;
let timeOffset = 0;
let pointerDown = false;
let id = 1;
const events = [];
for (let action of actions) {
const touch = {
inputType : "finger",
id,
x : action.x,
y : action.y,
pressure : 0
};
const command = {
inputType : "hand",
coordinateSpace : "content",
touches : [touch],
timeOffset
};
let timeOffsetIncrease = 0;
switch (action.type) {
case "pointerMove":
touch.phase = "moved";
const actionWindow = action.origin?.ownerDocument?.defaultView;
if (actionWindow && action.origin instanceof actionWindow.Element) {
const bounds = action.origin.getBoundingClientRect();
touch.x += bounds.left + (bounds.width / 2.0);
touch.y += bounds.top + (bounds.height / 2.0);
}
break;
case "pointerDown":
pointerDown = true;
touch.phase = "began";
if (action.x === undefined)
touch.x = x;
if (action.y === undefined)
touch.y = y;
break;
case "pointerUp":
pointerDown = false;
touch.phase = "ended";
touch.x = x;
touch.y = y;
id++;
// We need to add a pause after a pointer up to ensure that a subsequent tap may be recognized as such.
if (options.insertPauseAfterPointerUp)
timeOffsetIncrease = 0.5;
break;
case "pause":
timeOffsetIncrease = action.duration / 1000;
break;
default:
throw new Error(`Unknown action type "${action.type}".`);
}
if (action.type !== "pause") {
x = touch.x;
y = touch.y;
}
if (!pointerDown && touch.phase == "moved")
continue;
timeOffset += timeOffsetIncrease;
if (action.type !== "pause")
events.push(command);
}
const stream = JSON.stringify({ events });
logDebug(stream);
return new Promise(resolve => testRunner.runUIScript(`(function() {
(function() { uiController.sendEventStream('${stream}') })();
uiController.uiScriptComplete();
})();`, resolve));
}
function computeTickDuration(actions, sourceType)
{
sourceToActionWithDurationMap = { "pointer": "pointerMove", "wheel": "scroll" };
maxDuration = 0;
for (let action of actions) {
let duration;
if (action.type === "pause" || action.type === sourceToActionWithDurationMap[sourceType])
duration = action.duration;
if (duration !== undefined) {
if (duration > maxDuration)
maxDuration = duration;
}
}
return maxDuration;
}
async function renderingUpdate()
{
await new Promise(requestAnimationFrame);
await new Promise(resolve => setTimeout(resolve, 0));
}
async function waitForScrollCompletion()
{
if (testRunner.isIOSFamily) {
await new Promise(resolve => {
testRunner.runUIScript(`
(function() {
uiController.didEndScrollingCallback = function() {
uiController.uiScriptComplete();
}
})()`, resolve);
});
return renderingUpdate();
}
return new Promise(resolve => {
eventSender.callAfterScrollingCompletes(() => {
requestAnimationFrame(resolve);
});
});
}
async function ensurePresentationUpdate()
{
if (!testRunner.isWebKit2)
return renderingUpdate();
return new Promise(resolve => {
testRunner.runUIScript(`
uiController.doAfterPresentationUpdate(function() {
uiController.uiScriptComplete();
});`, resolve);
});
}
/**
*
* @param {object[]} actions
* @returns Promise<undefined>
*/
async function dispatchWheelActions(actions)
{
if (!window.eventSender)
throw new Error("window.eventSender is undefined.");
for (let action of actions) {
switch (action.type) {
case "pause":
logDebug(`pause(${action.duration})`);
await pause(action.duration);
break;
case "scroll":
// FIXME(261810): Follow tick-based event dispatch logic rather than sending scroll events instantaneously
// https://w3c.github.io/webdriver/#dfn-perform-a-scroll
let { x, y, origin, deltaX, deltaY, duration } = action;
if ((x < 0) || (x > window.innerWidth) || (y < 0) || (y > window.innerHeight))
throw new Error('Move target out of bounds');
if (duration === undefined)
duration = computeTickDuration(actions, "wheel");
const originWindow = origin?.ownerDocument?.defaultView;
if (originWindow && origin instanceof originWindow.Element) {
const bounds = origin.getBoundingClientRect();
logDebug(() => `${origin.id} [${bounds.left}, ${bounds.top}, ${bounds.width}, ${bounds.height}]`);
x += bounds.left + (bounds.width / 2.0);
y += bounds.top + (bounds.height / 2.0);
}
const scrollEvents = [
{
type : "wheel",
viewX : x,
viewY : y,
deltaX : 0,
deltaY : -deltaY,
phase : "began"
},
{
type : "wheel",
deltaX : -deltaX,
deltaY : 0,
phase : "changed"
},
{
type : "wheel",
momentumPhase : "ended"
}
];
eventSender.monitorWheelEvents();
await ensurePresentationUpdate();
const eventStreamAsString = JSON.stringify({ events: scrollEvents });
await new Promise(resolve => {
testRunner.runUIScript(`
(function() {
uiController.sendEventStream(\`${eventStreamAsString}\`, () => {
uiController.uiScriptComplete();
});
})();
`, resolve);
});
await waitForScrollCompletion();
break;
default:
throw new Error(`Unrecognized wheel action type: ${action.type}`);
}
}
}
if (window.test_driver_internal === undefined)
window.test_driver_internal = { };
// https://w3c.github.io/webdriver/#keyboard-actions
// FIXME: Add more cases.
const SeleniumCharCodeToEventSenderKey = {
0xE003: { key: 'delete' },
0XE004: { key: '\t' },
0XE005: { key: 'clear' },
0XE006: { key: '\r' },
0XE007: { key: '\n' },
0xE008: { key: 'leftShift', modifier: 'shiftKey' },
0xE009: { key: 'leftControl', modifier: 'ctrlKey' },
0xE00A: { key: 'leftAlt', modifier: 'altKey' },
0XE00C: { key: 'escape' },
0xE00D: { key: ' ' },
0XE00E: { key: 'pageUp' },
0XE00F: { key: 'pageDown' },
0XE010: { key: 'end' },
0XE011: { key: 'home' },
0xE012: { key: 'leftArrow' },
0xE013: { key: 'upArrow' },
0xE014: { key: 'rightArrow' },
0xE015: { key: 'downArrow' },
0XE016: { key: 'insert' },
0XE017: { key: 'delete' },
0XE018: { key: ';' },
0XE019: { key: '=' },
0XE031: { key: 'F1' },
0XE032: { key: 'F2' },
0XE033: { key: 'F3' },
0XE034: { key: 'F4' },
0XE035: { key: 'F5' },
0XE036: { key: 'F6' },
0XE037: { key: 'F7' },
0XE038: { key: 'F8' },
0XE039: { key: 'F9' },
0XE03A: { key: 'F10' },
0XE03B: { key: 'F11' },
0XE03C: { key: 'F12' },
0xE03D: { key: 'leftMeta', modifier: 'metaKey' }, // a.k.a. commandKey
0xE050: { key: 'rightShift', modifier: 'shiftKey' },
0xE051: { key: 'rightControl', modifier: 'ctrlKey' },
0xE052: { key: 'rightAlt', modifier: 'altKey' },
0xE052: { key: 'rightMeta', modifier: 'metaKey' }, // a.k.a. commandKey
};
function convertSeleniumKeyCode(key)
{
const code = key.charCodeAt(0);
return SeleniumCharCodeToEventSenderKey[code] || { key: key };
}
/**
*
* @param {Element} element
* @param {string[]} keys
* @returns Promise<undefined>
*/
window.test_driver_internal.send_keys = async function(element, keys)
{
element.focus();
const keyList = [];
const modifiers = [];
for (const key of keys) {
let convertedKey = convertSeleniumKeyCode(key);
keyList.push(convertedKey.key);
if (convertedKey.modifier)
modifiers.push(convertedKey.modifier);
}
if (testRunner.isIOSFamily && testRunner.isWebKit2) {
await new Promise((resolve) => {
testRunner.runUIScript(`
const keyList = JSON.parse('${JSON.stringify(keyList)}');
for (const key of keyList)
uiController.keyDown(key, modifiers);`, resolve);
});
return;
}
for (const key of keyList)
eventSender.keyDown(key, modifiers);
}
/**
*
* @param {Element} element
* @param {DOMRect} coords
* @returns Promise<undefined>
*/
window.test_driver_internal.click = async function (element, coords)
{
if (testRunner.isIOSFamily && testRunner.isWebKit2) {
await new Promise((resolve) => {
testRunner.runUIScript(`
uiController.singleTapAtPoint(${coords.x}, ${coords.y}, function() {
uiController.uiScriptComplete();
});`, resolve);
});
return;
}
eventSender.mouseMoveTo(coords.x, coords.y);
eventSender.mouseDown();
eventSender.mouseUp();
}
/**
*
* @param {object[]} sources
* @returns Promise<undefined>
*/
window.test_driver_internal.action_sequence = async function(sources)
{
// https://w3c.github.io/webdriver/#processing-actions
let noneSource;
let pointerSource;
let keySource;
let wheelSource;
for (let source of sources) {
switch (source.type) {
case "none":
noneSource = source;
break;
case "pointer":
pointerSource = source;
break;
case "key":
keySource = source;
break;
case "wheel":
wheelSource = source;
break;
default:
throw new Error(`Unknown source type "${source.type}".`);
}
}
if (keySource && pointerSource)
throw new Error("testdriver-vendor.js for WebKit does not yet support mixing key and pointer sources");
if (keySource) {
let modifiersInEffect = [];
const events = [];
for (const action of keySource.actions) {
switch (action.type) {
case 'keyDown': {
const key = convertSeleniumKeyCode(action.value);
if (key.modifier)
modifiersInEffect.push(key.modifier);
else
events.push({type: 'rawKeyDown', arguments: [key.key, modifiersInEffect.slice(0)]});
break;
}
case 'keyUp': {
const key = convertSeleniumKeyCode(action.value);
if (key.modifier)
modifiersInEffect = modifiersInEffect.filter((modifier) => modifier != key.modifier);
else
events.push({type: 'rawKeyUp', arguments: [key.key, modifiersInEffect.slice(0)]});
break;
}
case 'pause':
// FIXME: Use eventSender.leapForward.
throw new Error('testdriver-vendor.js for WebKit does not yet support pause key action');
default:
throw new Error(`Unknown key action type "${action.type}" encountered in testdriver-vendor.js`);
}
}
if (noneSource) {
let injectedActions = 0;
noneSource.actions.forEach((action, index) => {
if (action.duration > 0) {
events.splice(index + injectedActions + 1, 0, {type: 'leapForward', arguments: [action.duration]});
injectedActions++;
}
});
}
if (testRunner.isIOSFamily && testRunner.isWebKit2) {
return await new Promise((resolve) => {
testRunner.runUIScript(`
const events = JSON.parse('${JSON.stringify(events)}');
for (const event of events)
eventSender[event.type](...event.arguments);`, resolve);
});
}
for (const event of events)
eventSender[event.type](...event.arguments);
return;
}
// If we have a "none" source, let's inject any pause with non-zero durations into the pointer source
// after the matching action in the pointer source.
if (noneSource) {
let injectedActions = 0;
noneSource.actions.forEach((action, index) => {
if (action.duration > 0) {
if (pointerSource)
pointerSource.actions.splice(index + injectedActions + 1, 0, action);
if (wheelSource)
wheelSource.actions.splice(index + injectedActions + 1, 0, action);
injectedActions++;
}
});
}
if (wheelSource)
await dispatchWheelActions(wheelSource.actions);
if (pointerSource) {
const { pointerType } = pointerSource.parameters;
if (!["mouse", "touch", "pen"].includes(pointerType))
throw new Error(`Unknown pointer type "${pointerType}".`);
logDebug(JSON.stringify(pointerSource));
if (pointerType === "touch")
await dispatchTouchActions(pointerSource.actions);
else if (testRunner.isIOSFamily && "createTouch" in document)
await dispatchTouchActions(pointerSource.actions, { insertPauseAfterPointerUp: true });
else if (pointerType === "mouse" || pointerType === "pen")
await dispatchMouseActions(pointerSource.actions, pointerType);
}
};
/**
*
* @param {Object} permission_params
* @param {PermissionDescriptor} permission_params.descriptor
* @param {PermissionState} permission_params.state
* @returns Promise<undefined>
*/
window.test_driver_internal.set_permission = async function(permission_params)
{
switch (permission_params.descriptor.name) {
case "camera":
if (window.testRunner && testRunner.setCameraPermission)
testRunner.setCameraPermission(permission_params.state === "granted");
break;
case "background-fetch":
if (window.testRunner && testRunner.setBackgroundFetchPermission)
testRunner.setBackgroundFetchPermission(permission_params.state === "granted");
break;
case "geolocation":
const granted = permission_params.state === "granted";
testRunner.setGeolocationPermission(granted);
if (granted) {
await pause(10);
window.testRunner.setMockGeolocationPosition(51.478, -0.166, 100);
}
break;
case "microphone":
if (window.testRunner && testRunner.setMicrophonePermission)
testRunner.setMicrophonePermission(permission_params.state === "granted");
break;
case "screen-wake-lock":
testRunner.setScreenWakeLockPermission(permission_params.state == "granted");
break;
case "storage-access":
await testRunner.setStorageAccessPermission(permission_params.state === "granted", location.href);
break;
default:
throw new Error(`Unsupported permission name "${permission_params.descriptor.name}".`);
}
};
/**
*
* @param {Window?} context
* @returns Promise<undefined>
*/
window.test_driver_internal.delete_all_cookies = async function(context=null)
{
context = context ?? window;
return new Promise(r => context.testRunner.removeAllCookies(r));
}
/**
*
* @param {Window?} context
* @returns Promise<sequence<WebDriverCookieData>>
*/
window.test_driver_internal.get_all_cookies = async function(context=null)
{
context = context ?? window;
return internals.webDriverGetCookies(context.document);
}
/**
*
* @param DOMString name
* @param {Window?} context
* @returns Promise<WebDriverCookieData?>
*/
window.test_driver_internal.get_named_cookie = async function(name, context=null)
{
context = context ?? window;
const cookies = internals.webDriverGetCookies(context.document);
for (const cookie of cookies) {
if (cookie.name == name)
return cookie;
}
return null;
}
window.test_driver_internal.generate_test_report = async function(message, context=null)
{
context = context ?? window;
context.testRunner.generateTestReport(message);
}
/**
*
* @param {Window?} context
* @returns {Promise<boolean>} - if activation was consumed for the given context
*/
window.test_driver_internal.consume_user_activation = async function(context)
{
context = context ?? window;
return context.internals.consumeTransientActivation();
}
/**
*
* @param {Window?} context
* @returns {Promise<DOMRect>}
* @returns {Promise<void>}
*/
window.test_driver_internal.minimize_window = async function (context=null)
{
context = context ?? window;
if (context.document?.fullscreenElement)
await context.document.exitFullscreen();
const rect = internals.visualViewportRect();
if (context.document.visibilityState === "visible") {
await new Promise(resolve => {
context.addEventListener("visibilitychange", resolve, { once: true });
testRunner.setPageVisibility("hidden");
});
}
return rect;
}
/**
*
* @param {DOMRect} rect
* @param {Window?} context
* @returns {Promise<void>}
*/
window.test_driver_internal.set_window_rect = async function (rect, context=null)
{
if (!rect || typeof rect !== "object" || typeof rect.width !== "number" || typeof rect.height !== "number")
throw new Error("Invalid rect");
context = context ?? window;
if (context.document?.fullscreenElement)
await context.document.exitFullscreen();
context.testRunner.setViewSize(rect.width, rect.height);
if (context.document.visibilityState === "visible")
return;
await new Promise(resolve => {
context.addEventListener("visibilitychange", resolve, { once: true });
testRunner.setPageVisibility("visible");
});
}
function waitFor(condition)
{
return new Promise((resolve, reject) => {
let interval = setInterval(() => {
if (condition()) {
clearInterval(interval);
resolve();
}
}, 0);
});
}
/**
*
* @param {Element} element
* @returns {Promise<string>}
*/
window.test_driver_internal.get_computed_label = async function (element, context=null) {
if (!element)
return "";
context = context ?? window;
return context.internals.getComputedLabel(element);
}
/**
*
* @param {Element} element
* @returns {Promise<string>}
*/
window.test_driver_internal.get_computed_role = async function (element, context=null) {
if (!element)
return "";
context = context ?? window;
return context.internals.getComputedRole(element);
}
/**
*
* @param {string} origin
* @param {string} embedding_origin
* @param {boolean} blocked
* @returns {Promise<void>}
*/
window.test_driver_internal.set_storage_access = async function (origin, embedding_origin, blocked, context=null) {
// FIXME: This function should support setting storage access for specific origins.
if (origin != "*" || embedding_origin != "*")
throw new Error("Unsupported value for origin or embedding_origin");
context = context ?? window;
await context.testRunner.setStorageAccess(blocked);
}