Bug 1930530 - [marionette] Correctly retry to dispatch actions when the browsing context is replaced. r=webdriver-reviewers,jdescottes

Differential Revision: https://phabricator.services.mozilla.com/D228658
This commit is contained in:
Henrik Skupin 2024-11-14 18:15:56 +00:00
parent 7b29a2787f
commit 87f8b4d440
4 changed files with 321 additions and 215 deletions

View File

@ -119,36 +119,49 @@ export class MarionetteCommandsChild extends JSWindowActorChild {
const { eventName, details } = options;
const win = this.contentWindow;
switch (eventName) {
case "synthesizeKeyDown":
lazy.event.sendKeyDown(details.eventData, win);
break;
case "synthesizeKeyUp":
lazy.event.sendKeyUp(details.eventData, win);
break;
case "synthesizeMouseAtPoint":
lazy.event.synthesizeMouseAtPoint(
details.x,
details.y,
details.eventData,
win
);
break;
case "synthesizeMultiTouch":
lazy.event.synthesizeMultiTouch(details.eventData, win);
break;
case "synthesizeWheelAtPoint":
lazy.event.synthesizeWheelAtPoint(
details.x,
details.y,
details.eventData,
win
);
break;
default:
throw new Error(
`${eventName} is not a supported event dispatch method`
try {
switch (eventName) {
case "synthesizeKeyDown":
lazy.event.sendKeyDown(details.eventData, win);
break;
case "synthesizeKeyUp":
lazy.event.sendKeyUp(details.eventData, win);
break;
case "synthesizeMouseAtPoint":
lazy.event.synthesizeMouseAtPoint(
details.x,
details.y,
details.eventData,
win
);
break;
case "synthesizeMultiTouch":
lazy.event.synthesizeMultiTouch(details.eventData, win);
break;
case "synthesizeWheelAtPoint":
lazy.event.synthesizeWheelAtPoint(
details.x,
details.y,
details.eventData,
win
);
break;
default:
throw new Error(
`${eventName} is not a supported event dispatch method`
);
}
} catch (e) {
if (e.message.includes("NS_ERROR_FAILURE")) {
// Event dispatch failed. Re-throwing as AbortError to allow retrying
// to dispatch the event.
throw new DOMException(
`Failed to dispatch event "${eventName}": ${e.message}`,
"AbortError"
);
}
throw e;
}
}
@ -683,8 +696,8 @@ export class MarionetteCommandsChild extends JSWindowActorChild {
}
browsingContext = childContexts[id];
} else {
const context = childContexts.find(context => {
return context.embedderElement === id;
const context = childContexts.find(childContext => {
return childContext.embedderElement === id;
});
if (!context) {
throw new lazy.error.NoSuchFrameError(

View File

@ -5,14 +5,12 @@
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
action: "chrome://remote/content/shared/webdriver/Actions.sys.mjs",
capture: "chrome://remote/content/shared/Capture.sys.mjs",
error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
getSeenNodesForBrowsingContext:
"chrome://remote/content/shared/webdriver/Session.sys.mjs",
json: "chrome://remote/content/marionette/json.sys.mjs",
Log: "chrome://remote/content/shared/Log.sys.mjs",
WebElement: "chrome://remote/content/marionette/web-reference.sys.mjs",
});
ChromeUtils.defineLazyGetter(lazy, "logger", () =>
@ -24,146 +22,41 @@ ChromeUtils.defineLazyGetter(lazy, "logger", () =>
let webDriverSessionId = null;
export class MarionetteCommandsParent extends JSWindowActorParent {
#actionsOptions;
#actionState;
#deferredDialogOpened;
actorCreated() {
// The {@link Actions.State} of the input actions.
this.#actionState = null;
// Options for actions to pass through performActions and releaseActions.
this.#actionsOptions = {
// Callbacks as defined in the WebDriver specification.
getElementOrigin: this.#getElementOrigin.bind(this),
isElementOrigin: this.#isElementOrigin.bind(this),
// Custom properties and callbacks
context: this.browsingContext,
assertInViewPort: this.#assertInViewPort.bind(this),
dispatchEvent: this.#dispatchEvent.bind(this),
getClientRects: this.#getClientRects.bind(this),
getInViewCentrePoint: this.#getInViewCentrePoint.bind(this),
};
this.#deferredDialogOpened = null;
}
/**
* Assert that the target coordinates are within the visible viewport.
*
* @param {Array.<number>} target
* Coordinates [x, y] of the target relative to the viewport.
* @param {BrowsingContext} _context
* Unused in Marionette.
*
* @returns {Promise<undefined>}
* Promise that rejects, if the coordinates are not within
* the visible viewport.
*
* @throws {MoveTargetOutOfBoundsError}
* If target is outside the viewport.
*/
#assertInViewPort(target, _context) {
assertInViewPort(target, _context) {
return this.sendQuery("MarionetteCommandsParent:_assertInViewPort", {
target,
});
}
/**
* Dispatch an event.
*
* @param {string} eventName
* Name of the event to be dispatched.
* @param {BrowsingContext} _context
* Unused in Marionette.
* @param {object} details
* Details of the event to be dispatched.
*
* @returns {Promise}
* Promise that resolves once the event is dispatched.
*/
#dispatchEvent(eventName, _context, details) {
dispatchEvent(eventName, details) {
return this.sendQuery("MarionetteCommandsParent:_dispatchEvent", {
eventName,
details,
});
}
/**
* Finalize an action command.
*
* @returns {Promise}
* Promise that resolves when the finalization is done.
*/
#finalizeAction() {
finalizeAction() {
return this.sendQuery("MarionetteCommandsParent:_finalizeAction");
}
/**
* Retrieves the WebElement reference of the origin.
*
* @param {ElementOrigin} origin
* Reference to the element origin of the action.
* @param {BrowsingContext} _context
* Unused in Marionette.
*
* @returns {WebElement}
* The WebElement reference.
*/
#getElementOrigin(origin, _context) {
return origin;
}
/**
* Retrieve the list of client rects for the element.
*
* @param {WebElement} element
* The web element reference to retrieve the rects from.
* @param {BrowsingContext} _context
* Unused in Marionette.
*
* @returns {Promise<Array<Map.<string, number>>>}
* Promise that resolves to a list of DOMRect-like objects.
*/
#getClientRects(element, _context) {
getClientRects(element, _context) {
return this.executeScript("return arguments[0].getClientRects()", [
element,
]);
}
/**
* Retrieve the in-view center point for the rect and visible viewport.
*
* @param {DOMRect} rect
* Size and position of the rectangle to check.
* @param { BrowsingContext } _context
* Unused in Marionette.
*
* @returns {Promise<Map.<string, number>>}
* X and Y coordinates that denotes the in-view centre point of
* `rect`.
*/
#getInViewCentrePoint(rect, _context) {
getInViewCentrePoint(rect, _context) {
return this.sendQuery("MarionetteCommandsParent:_getInViewCentrePoint", {
rect,
});
}
/**
* Checks if the given object is a valid element origin.
*
* @param {object} origin
* The object to check.
*
* @returns {boolean}
* True, if it's a WebElement.
*/
#isElementOrigin(origin) {
return lazy.WebElement.Identifier in origin;
}
async sendQuery(name, serializedValue) {
const seenNodes = lazy.getSeenNodesForBrowsingContext(
webDriverSessionId,
@ -378,34 +271,10 @@ export class MarionetteCommandsParent extends JSWindowActorParent {
});
}
async performActions(actions, asyncEventsEnabled) {
if (!asyncEventsEnabled) {
// Bug 1920959: Remove if we no longer need to dispatch in content.
await this.sendQuery("MarionetteCommandsParent:performActions", {
actions,
});
return;
}
// Bug 1821460: Use top-level browsing context.
if (this.#actionState === null) {
this.#actionState = new lazy.action.State();
}
const actionChain = await lazy.action.Chain.fromJSON(
this.#actionState,
performActions(actions) {
return this.sendQuery("MarionetteCommandsParent:performActions", {
actions,
this.#actionsOptions
);
// Enqueue to serialize access to input state.
await this.#actionState.enqueueAction(() =>
actionChain.dispatch(this.#actionState, this.#actionsOptions)
);
// Process async follow-up tasks in content before the reply is sent.
await this.#finalizeAction();
});
}
/**
@ -414,29 +283,8 @@ export class MarionetteCommandsParent extends JSWindowActorParent {
* as if the state was released by an explicit series of actions. It also
* clears all the internal state of the virtual devices.
*/
async releaseActions(asyncEventsEnabled) {
if (!asyncEventsEnabled) {
// Bug 1920959: Remove if we no longer need to dispatch in content.
await this.sendQuery("MarionetteCommandsParent:releaseActions");
return;
}
// Bug 1821460: Use top-level browsing context.
if (this.#actionState === null) {
return;
}
// Enqueue to serialize access to input state.
await this.#actionState.enqueueAction(() => {
const undoActions = this.#actionState.inputCancelList.reverse();
undoActions.dispatch(this.#actionState, this.#actionsOptions);
});
this.#actionState = null;
// Process async follow-up tasks in content before the reply is sent.
await this.#finalizeAction();
releaseActions() {
return this.sendQuery("MarionetteCommandsParent:releaseActions");
}
async switchToFrame(id) {
@ -528,16 +376,29 @@ export function getMarionetteCommandsActorProxy(browsingContextFn) {
get(target, methodName) {
return async (...args) => {
let attempts = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const browsingContext = browsingContextFn();
if (!browsingContext) {
throw new DOMException(
"No BrowsingContext found",
"NoBrowsingContext"
);
}
let browsingContext = browsingContextFn();
// If a top-level browsing context was replaced and retrying is allowed,
// retrieve the new one for the current browser.
if (
browsingContext?.isReplaced &&
browsingContext.top === browsingContext &&
!NO_RETRY_METHODS.includes(methodName)
) {
browsingContext = BrowsingContext.getCurrentTopByBrowserId(
browsingContext.browserId
);
}
if (!browsingContext) {
throw new lazy.error.UnknownError(
`BrowsingContext does no longer exist`
);
}
try {
// TODO: Scenarios where the window/tab got closed and
// currentWindowGlobal is null will be handled in Bug 1662808.
const actor =
@ -555,25 +416,27 @@ export function getMarionetteCommandsActorProxy(browsingContextFn) {
}
if (NO_RETRY_METHODS.includes(methodName)) {
const browsingContextId = browsingContextFn()?.id;
lazy.logger.trace(
`[${browsingContextId}] Querying "${methodName}" failed with` +
` ${e.name}, returning "null" as fallback`
`[${browsingContext.id}] Querying "${methodName}"` +
` failed with ${e.name}, returning "null" as fallback`
);
return null;
}
if (++attempts > MAX_ATTEMPTS) {
const browsingContextId = browsingContextFn()?.id;
lazy.logger.trace(
`[${browsingContextId}] Querying "${methodName} "` +
`reached the limit of retry attempts (${MAX_ATTEMPTS})`
`[${browsingContext.id}] Querying "${methodName}"` +
` reached the limit of retry attempts (${MAX_ATTEMPTS})`
);
throw e;
}
lazy.logger.trace(
`Retrying "${methodName}", attempt: ${attempts}`
`[${browsingContext.id}] Retrying "${methodName}"` +
`, attempt: ${attempts}`
);
await new Promise(resolve =>
Services.tm.dispatchToMainThread(resolve)
);
}
}

View File

@ -5,6 +5,7 @@
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
action: "chrome://remote/content/shared/webdriver/Actions.sys.mjs",
Addon: "chrome://remote/content/marionette/addon.sys.mjs",
AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
@ -102,6 +103,181 @@ const TOPIC_QUIT_APPLICATION_REQUESTED = "quit-application-requested";
* @namespace driver
*/
class ActionsHelper {
#actionsOptions;
#driver;
constructor(driver) {
this.#driver = driver;
// Options for actions to pass through performActions and releaseActions.
this.#actionsOptions = {
// Callbacks as defined in the WebDriver specification.
getElementOrigin: this.getElementOrigin.bind(this),
isElementOrigin: this.isElementOrigin.bind(this),
// Custom callbacks.
assertInViewPort: this.assertInViewPort.bind(this),
dispatchEvent: this.dispatchEvent.bind(this),
getClientRects: this.getClientRects.bind(this),
getInViewCentrePoint: this.getInViewCentrePoint.bind(this),
};
}
get actionsOptions() {
return this.#actionsOptions;
}
#getActor(browsingContext) {
return lazy.getMarionetteCommandsActorProxy(() => browsingContext);
}
/**
* Assert that the target coordinates are within the visible viewport.
*
* @param {Array.<number>} target
* Coordinates [x, y] of the target relative to the viewport.
* @param {BrowsingContext} browsingContext
* The browsing context to dispatch the event to.
*
* @returns {Promise<undefined>}
* Promise that rejects, if the coordinates are not within
* the visible viewport.
*
* @throws {MoveTargetOutOfBoundsError}
* If target is outside the viewport.
*/
assertInViewPort(target, browsingContext) {
return this.#getActor(browsingContext).assertInViewPort(target);
}
/**
* Dispatch an event.
*
* @param {string} eventName
* Name of the event to be dispatched.
* @param {BrowsingContext} browsingContext
* The browsing context to dispatch the event to.
* @param {object} details
* Details of the event to be dispatched.
*
* @returns {Promise}
* Promise that resolves once the event is dispatched.
*/
dispatchEvent(eventName, browsingContext, details) {
return this.#getActor(browsingContext).dispatchEvent(eventName, details);
}
/**
* Finalize an action command.
*
* @param {BrowsingContext} browsingContext
* The browsing context to dispatch the event to.
*
* @returns {Promise}
* Promise that resolves when the finalization is done.
*/
finalizeAction(browsingContext) {
this.#getActor(browsingContext).finalizeAction();
}
/**
* Retrieves the WebElement reference of the origin.
*
* @param {ElementOrigin} origin
* Reference to the element origin of the action.
* @param {BrowsingContext} _browsingContext
* Not used by Marionette.
*
* @returns {WebElement}
* The WebElement reference.
*/
getElementOrigin(origin, _browsingContext) {
return origin;
}
/**
* Retrieve the list of client rects for the element.
*
* @param {WebElement} element
* The web element reference to retrieve the rects from.
* @param {BrowsingContext} browsingContext
* The browsing context to dispatch the event to.
*
* @returns {Promise<Array<Map.<string, number>>>}
* Promise that resolves to a list of DOMRect-like objects.
*/
getClientRects(element, browsingContext) {
return this.#getActor(browsingContext).executeScript(
"return arguments[0].getClientRects()",
[element]
);
}
/**
* Retrieve the in-view center point for the rect and visible viewport.
*
* @param {DOMRect} rect
* Size and position of the rectangle to check.
* @param {BrowsingContext} browsingContext
* The browsing context to dispatch the event to.
*
* @returns {Promise<Map.<string, number>>}
* X and Y coordinates that denotes the in-view centre point of
* `rect`.
*/
getInViewCentrePoint(rect, browsingContext) {
return this.#getActor(browsingContext).getInViewCentrePoint(rect);
}
/**
* Retrieves the action's input state.
*
* @param {BrowsingContext} browsingContext
* The Browsing Context to retrieve the input state for.
*
* @returns {Actions.InputState}
* The action's input state.
*/
getInputState(browsingContext) {
// Bug 1821460: Fetch top-level browsing context.
let inputState = this.#driver._inputStates.get(browsingContext);
if (inputState === undefined) {
inputState = new lazy.action.State();
this.#driver._inputStates.set(browsingContext, inputState);
}
return inputState;
}
/**
* Checks if the given object is a valid element origin.
*
* @param {object} origin
* The object to check.
*
* @returns {boolean}
* True, if it's a WebElement.
*/
isElementOrigin(origin) {
return lazy.WebElement.Identifier in origin;
}
/**
* Resets the action's input state.
*
* @param {BrowsingContext} browsingContext
* The Browsing Context to reset the input state for.
*/
resetInputState(browsingContext) {
// Bug 1821460: Fetch top-level browsing context.
if (this.#driver._inputStates.has(browsingContext)) {
this.#driver._inputStates.delete(browsingContext);
}
}
}
/**
* Implements (parts of) the W3C WebDriver protocol. GeckoDriver lives
* in chrome space and mediates calls to the current browsing context's actor.
@ -140,6 +316,12 @@ export function GeckoDriver(server) {
// used for modal dialogs
this.dialog = null;
this.promptListener = null;
// Browsing context => input state.
// Bug 1821460: Move to WebDriver Session and share with Remote Agent.
this._inputStates = new WeakMap();
this._actionsHelper = new ActionsHelper(this);
}
/**
@ -1490,11 +1672,37 @@ GeckoDriver.prototype.setTimeouts = function (cmd) {
* Not yet available in current context.
*/
GeckoDriver.prototype.performActions = async function (cmd) {
lazy.assert.open(this.getBrowsingContext());
const { actions } = cmd.parameters;
const browsingContext = lazy.assert.open(this.getBrowsingContext());
await this._handleUserPrompts();
const actions = cmd.parameters.actions;
await this.getActor().performActions(actions, lazy.prefAsyncEventsEnabled);
if (!lazy.prefAsyncEventsEnabled) {
// Bug 1920959: Remove if we no longer need to dispatch in content.
await this.getActor().performActions(actions);
return;
}
// Bug 1821460: Fetch top-level browsing context.
const inputState = this._actionsHelper.getInputState(browsingContext);
const actionsOptions = {
...this._actionsHelper.actionsOptions,
context: browsingContext,
};
const actionChain = await lazy.action.Chain.fromJSON(
inputState,
actions,
actionsOptions
);
// Enqueue to serialize access to input state.
await inputState.enqueueAction(() =>
actionChain.dispatch(inputState, actionsOptions)
);
// Process async follow-up tasks in content before the reply is sent.
await this._actionsHelper.finalizeAction(browsingContext);
};
/**
@ -1508,10 +1716,32 @@ GeckoDriver.prototype.performActions = async function (cmd) {
* Not available in current context.
*/
GeckoDriver.prototype.releaseActions = async function () {
lazy.assert.open(this.getBrowsingContext());
const browsingContext = lazy.assert.open(this.getBrowsingContext());
await this._handleUserPrompts();
await this.getActor().releaseActions(lazy.prefAsyncEventsEnabled);
if (!lazy.prefAsyncEventsEnabled) {
// Bug 1920959: Remove if we no longer need to dispatch in content.
await this.getActor().releaseActions();
return;
}
// Bug 1821460: Fetch top-level browsing context.
const inputState = this._actionsHelper.getInputState(browsingContext);
const actionsOptions = {
...this._actionsHelper.actionsOptions,
context: browsingContext,
};
// Enqueue to serialize access to input state.
await inputState.enqueueAction(() => {
const undoActions = inputState.inputCancelList.reverse();
return undoActions.dispatch(inputState, actionsOptions);
});
this._actionsHelper.resetInputState(browsingContext);
// Process async follow-up tasks in content before the reply is sent.
await this._actionsHelper.finalizeAction(browsingContext);
};
/**

View File

@ -34,10 +34,10 @@ add_task(async function test_commandsActor_getActorProxy_noBrowsingContext() {
try {
await getMarionetteCommandsActorProxy(() => null).sendQuery("foo", "bar");
ok(false, "Expected NoBrowsingContext error not raised");
ok(false, "Expected error not raised");
} catch (e) {
ok(
e.message.includes("No BrowsingContext found"),
e.message.includes("BrowsingContext does no longer exist"),
"Expected default error message found"
);
}