1295 lines
58 KiB
JavaScript
1295 lines
58 KiB
JavaScript
/**
|
|
* Copyright 2022 Google LLC.
|
|
* Copyright (c) Microsoft Corporation.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
var _a;
|
|
import { ChromiumBidi, InvalidArgumentException, InvalidSelectorException, NoSuchElementException, NoSuchHistoryEntryException, UnableToCaptureScreenException, UnknownErrorException, UnsupportedOperationException, } from '../../../protocol/protocol.js';
|
|
import { assert } from '../../../utils/assert.js';
|
|
import { Deferred } from '../../../utils/Deferred.js';
|
|
import { LogType } from '../../../utils/log.js';
|
|
import { getTimestamp } from '../../../utils/time.js';
|
|
import { inchesFromCm } from '../../../utils/unitConversions.js';
|
|
import { WindowRealm } from '../script/WindowRealm.js';
|
|
import { NavigationResult, NavigationTracker, } from './NavigationTracker.js';
|
|
export class BrowsingContextImpl {
|
|
static LOGGER_PREFIX = `${LogType.debug}:browsingContext`;
|
|
/** Direct children browsing contexts. */
|
|
#children = new Set();
|
|
/** The ID of this browsing context. */
|
|
#id;
|
|
userContext;
|
|
/**
|
|
* The ID of the parent browsing context.
|
|
* If null, this is a top-level context.
|
|
*/
|
|
#loaderId;
|
|
#parentId = null;
|
|
// Keeps track of the previously set viewport.
|
|
#previousViewport = { width: 0, height: 0 };
|
|
#originalOpener;
|
|
#lifecycle = {
|
|
DOMContentLoaded: new Deferred(),
|
|
load: new Deferred(),
|
|
};
|
|
#cdpTarget;
|
|
#defaultRealmDeferred = new Deferred();
|
|
#browsingContextStorage;
|
|
#eventManager;
|
|
#logger;
|
|
#navigationTracker;
|
|
#realmStorage;
|
|
// The deferred will be resolved when the default realm is created.
|
|
#unhandledPromptBehavior;
|
|
// Set when the user prompt is opened. Required to provide the type in closing event.
|
|
#lastUserPromptType;
|
|
constructor(id, parentId, userContext, cdpTarget, eventManager, browsingContextStorage, realmStorage, url, originalOpener, unhandledPromptBehavior, logger) {
|
|
this.#cdpTarget = cdpTarget;
|
|
this.#id = id;
|
|
this.#parentId = parentId;
|
|
this.userContext = userContext;
|
|
this.#eventManager = eventManager;
|
|
this.#browsingContextStorage = browsingContextStorage;
|
|
this.#realmStorage = realmStorage;
|
|
this.#unhandledPromptBehavior = unhandledPromptBehavior;
|
|
this.#logger = logger;
|
|
this.#originalOpener = originalOpener;
|
|
this.#navigationTracker = new NavigationTracker(url, id, eventManager, logger);
|
|
}
|
|
static create(id, parentId, userContext, cdpTarget, eventManager, browsingContextStorage, realmStorage, url, originalOpener, unhandledPromptBehavior, logger) {
|
|
const context = new _a(id, parentId, userContext, cdpTarget, eventManager, browsingContextStorage, realmStorage, url, originalOpener, unhandledPromptBehavior, logger);
|
|
context.#initListeners();
|
|
browsingContextStorage.addContext(context);
|
|
if (!context.isTopLevelContext()) {
|
|
context.parent.addChild(context.id);
|
|
}
|
|
// Hold on the `contextCreated` event until the target is unblocked. This is required,
|
|
// as the parent of the context can be set later in case of reconnecting to an
|
|
// existing browser instance + OOPiF.
|
|
eventManager.registerPromiseEvent(context.targetUnblockedOrThrow().then(() => {
|
|
return {
|
|
kind: 'success',
|
|
value: {
|
|
type: 'event',
|
|
method: ChromiumBidi.BrowsingContext.EventNames.ContextCreated,
|
|
params: {
|
|
...context.serializeToBidiValue(),
|
|
// Hack to provide the initial URL of the context, as it can be changed
|
|
// between the page target is attached and unblocked, as the page is not
|
|
// fully paused in MPArch session (https://crbug.com/372842894).
|
|
// TODO: remove once https://crbug.com/372842894 is addressed.
|
|
url,
|
|
},
|
|
},
|
|
};
|
|
}, (error) => {
|
|
return {
|
|
kind: 'error',
|
|
error,
|
|
};
|
|
}), context.id, ChromiumBidi.BrowsingContext.EventNames.ContextCreated);
|
|
return context;
|
|
}
|
|
/**
|
|
* @see https://html.spec.whatwg.org/multipage/document-sequences.html#navigable
|
|
*/
|
|
get navigableId() {
|
|
return this.#loaderId;
|
|
}
|
|
get navigationId() {
|
|
return this.#navigationTracker.currentNavigationId;
|
|
}
|
|
dispose(emitContextDestroyed) {
|
|
this.#navigationTracker.dispose();
|
|
this.#deleteAllChildren();
|
|
this.#realmStorage.deleteRealms({
|
|
browsingContextId: this.id,
|
|
});
|
|
// Delete context from the parent.
|
|
if (!this.isTopLevelContext()) {
|
|
this.parent.#children.delete(this.id);
|
|
}
|
|
// Fail all ongoing navigations.
|
|
this.#failLifecycleIfNotFinished();
|
|
if (emitContextDestroyed) {
|
|
this.#eventManager.registerEvent({
|
|
type: 'event',
|
|
method: ChromiumBidi.BrowsingContext.EventNames.ContextDestroyed,
|
|
params: this.serializeToBidiValue(),
|
|
}, this.id);
|
|
}
|
|
this.#eventManager.clearBufferedEvents(this.id);
|
|
this.#browsingContextStorage.deleteContextById(this.id);
|
|
}
|
|
/** Returns the ID of this context. */
|
|
get id() {
|
|
return this.#id;
|
|
}
|
|
/** Returns the parent context ID. */
|
|
get parentId() {
|
|
return this.#parentId;
|
|
}
|
|
/** Sets the parent context ID and updates parent's children. */
|
|
set parentId(parentId) {
|
|
if (this.#parentId !== null) {
|
|
this.#logger?.(LogType.debugError, 'Parent context already set');
|
|
// Cannot do anything except logging, as throwing will stop event processing. So
|
|
// just return,
|
|
return;
|
|
}
|
|
this.#parentId = parentId;
|
|
if (!this.isTopLevelContext()) {
|
|
this.parent.addChild(this.id);
|
|
}
|
|
}
|
|
/** Returns the parent context. */
|
|
get parent() {
|
|
if (this.parentId === null) {
|
|
return null;
|
|
}
|
|
return this.#browsingContextStorage.getContext(this.parentId);
|
|
}
|
|
/** Returns all direct children contexts. */
|
|
get directChildren() {
|
|
return [...this.#children].map((id) => this.#browsingContextStorage.getContext(id));
|
|
}
|
|
/** Returns all children contexts, flattened. */
|
|
get allChildren() {
|
|
const children = this.directChildren;
|
|
return children.concat(...children.map((child) => child.allChildren));
|
|
}
|
|
/**
|
|
* Returns true if this is a top-level context.
|
|
* This is the case whenever the parent context ID is null.
|
|
*/
|
|
isTopLevelContext() {
|
|
return this.#parentId === null;
|
|
}
|
|
get top() {
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
let topContext = this;
|
|
let parent = topContext.parent;
|
|
while (parent) {
|
|
topContext = parent;
|
|
parent = topContext.parent;
|
|
}
|
|
return topContext;
|
|
}
|
|
addChild(childId) {
|
|
this.#children.add(childId);
|
|
}
|
|
#deleteAllChildren(emitContextDestroyed = false) {
|
|
this.directChildren.map((child) => child.dispose(emitContextDestroyed));
|
|
}
|
|
get cdpTarget() {
|
|
return this.#cdpTarget;
|
|
}
|
|
updateCdpTarget(cdpTarget) {
|
|
this.#cdpTarget = cdpTarget;
|
|
this.#initListeners();
|
|
}
|
|
get url() {
|
|
return this.#navigationTracker.url;
|
|
}
|
|
async lifecycleLoaded() {
|
|
await this.#lifecycle.load;
|
|
}
|
|
async targetUnblockedOrThrow() {
|
|
const result = await this.#cdpTarget.unblocked;
|
|
if (result.kind === 'error') {
|
|
throw result.error;
|
|
}
|
|
}
|
|
async getOrCreateSandbox(sandbox) {
|
|
if (sandbox === undefined || sandbox === '') {
|
|
// Default realm is not guaranteed to be created at this point, so return a deferred.
|
|
return await this.#defaultRealmDeferred;
|
|
}
|
|
let maybeSandboxes = this.#realmStorage.findRealms({
|
|
browsingContextId: this.id,
|
|
sandbox,
|
|
});
|
|
if (maybeSandboxes.length === 0) {
|
|
await this.#cdpTarget.cdpClient.sendCommand('Page.createIsolatedWorld', {
|
|
frameId: this.id,
|
|
worldName: sandbox,
|
|
});
|
|
// `Runtime.executionContextCreated` should be emitted by the time the
|
|
// previous command is done.
|
|
maybeSandboxes = this.#realmStorage.findRealms({
|
|
browsingContextId: this.id,
|
|
sandbox,
|
|
});
|
|
assert(maybeSandboxes.length !== 0);
|
|
}
|
|
// It's possible for more than one sandbox to be created due to provisional
|
|
// frames. In this case, it's always the first one (i.e. the oldest one)
|
|
// that is more relevant since the user may have set that one up already
|
|
// through evaluation.
|
|
return maybeSandboxes[0];
|
|
}
|
|
serializeToBidiValue(maxDepth = 0, addParentField = true) {
|
|
return {
|
|
context: this.#id,
|
|
url: this.url,
|
|
userContext: this.userContext,
|
|
originalOpener: this.#originalOpener ?? null,
|
|
// TODO(#2646): Implement Client Window correctly
|
|
clientWindow: '',
|
|
children: maxDepth > 0
|
|
? this.directChildren.map((c) => c.serializeToBidiValue(maxDepth - 1, false))
|
|
: null,
|
|
...(addParentField ? { parent: this.#parentId } : {}),
|
|
};
|
|
}
|
|
onTargetInfoChanged(params) {
|
|
this.#navigationTracker.onTargetInfoChanged(params.targetInfo.url);
|
|
}
|
|
#initListeners() {
|
|
this.#cdpTarget.cdpClient.on('Network.loadingFailed', (params) => {
|
|
// Detect navigation errors like `net::ERR_BLOCKED_BY_RESPONSE`.
|
|
// Network related to navigation has request id equals to navigation's loader id.
|
|
this.#navigationTracker.networkLoadingFailed(params.requestId, params.errorText);
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Page.frameNavigated', (params) => {
|
|
if (this.id !== params.frame.id) {
|
|
return;
|
|
}
|
|
this.#navigationTracker.frameNavigated(params.frame.url + (params.frame.urlFragment ?? ''), params.frame.loaderId,
|
|
// `unreachableUrl` indicates if the navigation failed.
|
|
params.frame.unreachableUrl);
|
|
// At the point the page is initialized, all the nested iframes from the
|
|
// previous page are detached and realms are destroyed.
|
|
// Delete children from context.
|
|
this.#deleteAllChildren();
|
|
this.#documentChanged(params.frame.loaderId);
|
|
});
|
|
this.#cdpTarget.on("frameStartedNavigating" /* TargetEvents.FrameStartedNavigating */, (params) => {
|
|
this.#logger?.(LogType.debugInfo, `Received ${"frameStartedNavigating" /* TargetEvents.FrameStartedNavigating */} event`, params);
|
|
// The frame ID can be either a browsing context id, or not set in case of the frame
|
|
// is the top-level in the current CDP target.
|
|
const possibleFrameIds = [
|
|
this.id,
|
|
...(this.cdpTarget.id === this.id ? [undefined] : []),
|
|
];
|
|
if (!possibleFrameIds.includes(params.frameId)) {
|
|
return;
|
|
}
|
|
this.#navigationTracker.frameStartedNavigating(params.url, params.loaderId);
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Page.navigatedWithinDocument', (params) => {
|
|
if (this.id !== params.frameId) {
|
|
return;
|
|
}
|
|
this.#navigationTracker.navigatedWithinDocument(params.url, params.navigationType);
|
|
if (params.navigationType === 'historyApi') {
|
|
this.#eventManager.registerEvent({
|
|
type: 'event',
|
|
method: 'browsingContext.historyUpdated',
|
|
params: {
|
|
context: this.id,
|
|
url: this.#navigationTracker.url,
|
|
},
|
|
}, this.id);
|
|
return;
|
|
}
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Page.frameRequestedNavigation', (params) => {
|
|
if (this.id !== params.frameId) {
|
|
return;
|
|
}
|
|
this.#navigationTracker.frameRequestedNavigation(params.url);
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Page.lifecycleEvent', (params) => {
|
|
if (this.id !== params.frameId) {
|
|
return;
|
|
}
|
|
if (params.name === 'init') {
|
|
this.#documentChanged(params.loaderId);
|
|
return;
|
|
}
|
|
if (params.name === 'commit') {
|
|
this.#loaderId = params.loaderId;
|
|
return;
|
|
}
|
|
// If mapper attached to the page late, it might miss init and
|
|
// commit events. In that case, save the first loaderId for this
|
|
// frameId.
|
|
if (!this.#loaderId) {
|
|
this.#loaderId = params.loaderId;
|
|
}
|
|
// Ignore event from not current navigation.
|
|
if (params.loaderId !== this.#loaderId) {
|
|
return;
|
|
}
|
|
switch (params.name) {
|
|
case 'DOMContentLoaded':
|
|
if (!this.#navigationTracker.isInitialNavigation) {
|
|
// Do not emit for the initial navigation.
|
|
this.#eventManager.registerEvent({
|
|
type: 'event',
|
|
method: ChromiumBidi.BrowsingContext.EventNames.DomContentLoaded,
|
|
params: {
|
|
context: this.id,
|
|
navigation: this.#navigationTracker.currentNavigationId,
|
|
timestamp: getTimestamp(),
|
|
url: this.#navigationTracker.url,
|
|
},
|
|
}, this.id);
|
|
}
|
|
this.#lifecycle.DOMContentLoaded.resolve();
|
|
break;
|
|
case 'load':
|
|
if (!this.#navigationTracker.isInitialNavigation) {
|
|
// Do not emit for the initial navigation.
|
|
this.#eventManager.registerEvent({
|
|
type: 'event',
|
|
method: ChromiumBidi.BrowsingContext.EventNames.Load,
|
|
params: {
|
|
context: this.id,
|
|
navigation: this.#navigationTracker.currentNavigationId,
|
|
timestamp: getTimestamp(),
|
|
url: this.#navigationTracker.url,
|
|
},
|
|
}, this.id);
|
|
}
|
|
// The initial navigation is finished.
|
|
this.#navigationTracker.loadPageEvent(params.loaderId);
|
|
this.#lifecycle.load.resolve();
|
|
break;
|
|
}
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Runtime.executionContextCreated', (params) => {
|
|
const { auxData, name, uniqueId, id } = params.context;
|
|
if (!auxData || auxData.frameId !== this.id) {
|
|
return;
|
|
}
|
|
let origin;
|
|
let sandbox;
|
|
// Only these execution contexts are supported for now.
|
|
switch (auxData.type) {
|
|
case 'isolated':
|
|
sandbox = name;
|
|
// Sandbox should have the same origin as the context itself, but in CDP
|
|
// it has an empty one.
|
|
if (!this.#defaultRealmDeferred.isFinished) {
|
|
this.#logger?.(LogType.debugError, 'Unexpectedly, isolated realm created before the default one');
|
|
}
|
|
origin = this.#defaultRealmDeferred.isFinished
|
|
? this.#defaultRealmDeferred.result.origin
|
|
: // This fallback is not expected to be ever reached.
|
|
'';
|
|
break;
|
|
case 'default':
|
|
origin = serializeOrigin(params.context.origin);
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
const realm = new WindowRealm(this.id, this.#browsingContextStorage, this.#cdpTarget.cdpClient, this.#eventManager, id, this.#logger, origin, uniqueId, this.#realmStorage, sandbox);
|
|
if (auxData.isDefault) {
|
|
this.#defaultRealmDeferred.resolve(realm);
|
|
// Initialize ChannelProxy listeners for all the channels of all the
|
|
// preload scripts related to this BrowsingContext.
|
|
// TODO: extend for not default realms by the sandbox name.
|
|
void Promise.all(this.#cdpTarget
|
|
.getChannels()
|
|
.map((channel) => channel.startListenerFromWindow(realm, this.#eventManager)));
|
|
}
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Runtime.executionContextDestroyed', (params) => {
|
|
if (this.#defaultRealmDeferred.isFinished &&
|
|
this.#defaultRealmDeferred.result.executionContextId ===
|
|
params.executionContextId) {
|
|
this.#defaultRealmDeferred = new Deferred();
|
|
}
|
|
this.#realmStorage.deleteRealms({
|
|
cdpSessionId: this.#cdpTarget.cdpSessionId,
|
|
executionContextId: params.executionContextId,
|
|
});
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Runtime.executionContextsCleared', () => {
|
|
if (!this.#defaultRealmDeferred.isFinished) {
|
|
this.#defaultRealmDeferred.reject(new UnknownErrorException('execution contexts cleared'));
|
|
}
|
|
this.#defaultRealmDeferred = new Deferred();
|
|
this.#realmStorage.deleteRealms({
|
|
cdpSessionId: this.#cdpTarget.cdpSessionId,
|
|
});
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Page.javascriptDialogClosed', (params) => {
|
|
const accepted = params.result;
|
|
if (this.#lastUserPromptType === undefined) {
|
|
this.#logger?.(LogType.debugError, 'Unexpectedly no opening prompt event before closing one');
|
|
}
|
|
this.#eventManager.registerEvent({
|
|
type: 'event',
|
|
method: ChromiumBidi.BrowsingContext.EventNames.UserPromptClosed,
|
|
params: {
|
|
context: this.id,
|
|
accepted,
|
|
// `lastUserPromptType` should never be undefined here, so fallback to
|
|
// `UNKNOWN`. The fallback is required to prevent tests from hanging while
|
|
// waiting for the closing event. The cast is required, as the `UNKNOWN` value
|
|
// is not standard.
|
|
type: this.#lastUserPromptType ??
|
|
'UNKNOWN',
|
|
userText: accepted && params.userInput ? params.userInput : undefined,
|
|
},
|
|
}, this.id);
|
|
this.#lastUserPromptType = undefined;
|
|
});
|
|
this.#cdpTarget.cdpClient.on('Page.javascriptDialogOpening', (params) => {
|
|
const promptType = _a.#getPromptType(params.type);
|
|
if (params.type === 'beforeunload') {
|
|
this.#navigationTracker.beforeunload();
|
|
}
|
|
// Set the last prompt type to provide it in closing event.
|
|
this.#lastUserPromptType = promptType;
|
|
const promptHandler = this.#getPromptHandler(promptType);
|
|
this.#eventManager.registerEvent({
|
|
type: 'event',
|
|
method: ChromiumBidi.BrowsingContext.EventNames.UserPromptOpened,
|
|
params: {
|
|
context: this.id,
|
|
handler: promptHandler,
|
|
type: promptType,
|
|
message: params.message,
|
|
...(params.type === 'prompt'
|
|
? { defaultValue: params.defaultPrompt }
|
|
: {}),
|
|
},
|
|
}, this.id);
|
|
switch (promptHandler) {
|
|
// Based on `unhandledPromptBehavior`, check if the prompt should be handled
|
|
// automatically (`accept`, `dismiss`) or wait for the user to do it.
|
|
case "accept" /* Session.UserPromptHandlerType.Accept */:
|
|
void this.handleUserPrompt(true);
|
|
break;
|
|
case "dismiss" /* Session.UserPromptHandlerType.Dismiss */:
|
|
void this.handleUserPrompt(false);
|
|
break;
|
|
case "ignore" /* Session.UserPromptHandlerType.Ignore */:
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
static #getPromptType(cdpType) {
|
|
switch (cdpType) {
|
|
case 'alert':
|
|
return "alert" /* BrowsingContext.UserPromptType.Alert */;
|
|
case 'beforeunload':
|
|
return "beforeunload" /* BrowsingContext.UserPromptType.Beforeunload */;
|
|
case 'confirm':
|
|
return "confirm" /* BrowsingContext.UserPromptType.Confirm */;
|
|
case 'prompt':
|
|
return "prompt" /* BrowsingContext.UserPromptType.Prompt */;
|
|
}
|
|
}
|
|
#getPromptHandler(promptType) {
|
|
const defaultPromptHandler = "dismiss" /* Session.UserPromptHandlerType.Dismiss */;
|
|
switch (promptType) {
|
|
case "alert" /* BrowsingContext.UserPromptType.Alert */:
|
|
return (this.#unhandledPromptBehavior?.alert ??
|
|
this.#unhandledPromptBehavior?.default ??
|
|
defaultPromptHandler);
|
|
case "beforeunload" /* BrowsingContext.UserPromptType.Beforeunload */:
|
|
return (this.#unhandledPromptBehavior?.beforeUnload ??
|
|
this.#unhandledPromptBehavior?.default ??
|
|
"accept" /* Session.UserPromptHandlerType.Accept */);
|
|
case "confirm" /* BrowsingContext.UserPromptType.Confirm */:
|
|
return (this.#unhandledPromptBehavior?.confirm ??
|
|
this.#unhandledPromptBehavior?.default ??
|
|
defaultPromptHandler);
|
|
case "prompt" /* BrowsingContext.UserPromptType.Prompt */:
|
|
return (this.#unhandledPromptBehavior?.prompt ??
|
|
this.#unhandledPromptBehavior?.default ??
|
|
defaultPromptHandler);
|
|
}
|
|
}
|
|
#documentChanged(loaderId) {
|
|
if (loaderId === undefined || this.#loaderId === loaderId) {
|
|
return;
|
|
}
|
|
// Document changed.
|
|
this.#resetLifecycleIfFinished();
|
|
this.#loaderId = loaderId;
|
|
// Delete all child iframes and notify about top level destruction.
|
|
this.#deleteAllChildren(true);
|
|
}
|
|
#resetLifecycleIfFinished() {
|
|
if (this.#lifecycle.DOMContentLoaded.isFinished) {
|
|
this.#lifecycle.DOMContentLoaded = new Deferred();
|
|
}
|
|
else {
|
|
this.#logger?.(_a.LOGGER_PREFIX, 'Document changed (DOMContentLoaded)');
|
|
}
|
|
if (this.#lifecycle.load.isFinished) {
|
|
this.#lifecycle.load = new Deferred();
|
|
}
|
|
else {
|
|
this.#logger?.(_a.LOGGER_PREFIX, 'Document changed (load)');
|
|
}
|
|
}
|
|
#failLifecycleIfNotFinished() {
|
|
if (!this.#lifecycle.DOMContentLoaded.isFinished) {
|
|
this.#lifecycle.DOMContentLoaded.reject(new UnknownErrorException('navigation canceled'));
|
|
}
|
|
if (!this.#lifecycle.load.isFinished) {
|
|
this.#lifecycle.load.reject(new UnknownErrorException('navigation canceled'));
|
|
}
|
|
}
|
|
async navigate(url, wait) {
|
|
try {
|
|
new URL(url);
|
|
}
|
|
catch {
|
|
throw new InvalidArgumentException(`Invalid URL: ${url}`);
|
|
}
|
|
const commandNavigation = this.#navigationTracker.createPendingNavigation(url);
|
|
// Navigate and wait for the result. If the navigation fails, the error event is
|
|
// emitted and the promise is rejected.
|
|
const cdpNavigatePromise = (async () => {
|
|
const cdpNavigateResult = await this.#cdpTarget.cdpClient.sendCommand('Page.navigate', {
|
|
url,
|
|
frameId: this.id,
|
|
});
|
|
if (cdpNavigateResult.errorText) {
|
|
// If navigation failed, no pending navigation is left.
|
|
this.#navigationTracker.failNavigation(commandNavigation, cdpNavigateResult.errorText);
|
|
throw new UnknownErrorException(cdpNavigateResult.errorText);
|
|
}
|
|
this.#navigationTracker.navigationCommandFinished(commandNavigation, cdpNavigateResult.loaderId);
|
|
this.#documentChanged(cdpNavigateResult.loaderId);
|
|
})();
|
|
if (wait === "none" /* BrowsingContext.ReadinessState.None */) {
|
|
return {
|
|
navigation: commandNavigation.navigationId,
|
|
url,
|
|
};
|
|
}
|
|
// Wait for either the navigation is finished or canceled by another navigation.
|
|
const result = await Promise.race([
|
|
// No `loaderId` means same-document navigation.
|
|
this.#waitNavigation(wait, cdpNavigatePromise),
|
|
// Throw an error if the navigation is canceled.
|
|
commandNavigation.finished,
|
|
]);
|
|
if (result instanceof NavigationResult) {
|
|
if (
|
|
// TODO: check after decision on the spec is done:
|
|
// https://github.com/w3c/webdriver-bidi/issues/799.
|
|
result.eventName === "browsingContext.navigationAborted" /* NavigationEventName.NavigationAborted */ ||
|
|
result.eventName === "browsingContext.navigationFailed" /* NavigationEventName.NavigationFailed */) {
|
|
throw new UnknownErrorException(result.message ?? 'unknown exception');
|
|
}
|
|
}
|
|
return {
|
|
navigation: commandNavigation.navigationId,
|
|
// Url can change due to redirects. Get the one from commandNavigation.
|
|
url: commandNavigation.url,
|
|
};
|
|
}
|
|
async #waitNavigation(wait, cdpCommandPromise) {
|
|
switch (wait) {
|
|
case "none" /* BrowsingContext.ReadinessState.None */:
|
|
return;
|
|
case "interactive" /* BrowsingContext.ReadinessState.Interactive */:
|
|
await cdpCommandPromise;
|
|
await this.#lifecycle.DOMContentLoaded;
|
|
return;
|
|
case "complete" /* BrowsingContext.ReadinessState.Complete */:
|
|
await cdpCommandPromise;
|
|
await this.#lifecycle.load;
|
|
return;
|
|
}
|
|
}
|
|
// TODO: support concurrent navigations analogous to `navigate`.
|
|
async reload(ignoreCache, wait) {
|
|
await this.targetUnblockedOrThrow();
|
|
this.#resetLifecycleIfFinished();
|
|
const commandNavigation = this.#navigationTracker.createPendingNavigation(this.#navigationTracker.url);
|
|
const cdpReloadPromise = this.#cdpTarget.cdpClient.sendCommand('Page.reload', {
|
|
ignoreCache,
|
|
});
|
|
// Wait for either the navigation is finished or canceled by another navigation.
|
|
const result = await Promise.race([
|
|
// No `loaderId` means same-document navigation.
|
|
this.#waitNavigation(wait, cdpReloadPromise),
|
|
// Throw an error if the navigation is canceled.
|
|
commandNavigation.finished,
|
|
]);
|
|
if (result instanceof NavigationResult) {
|
|
if (result.eventName === "browsingContext.navigationAborted" /* NavigationEventName.NavigationAborted */ ||
|
|
result.eventName === "browsingContext.navigationFailed" /* NavigationEventName.NavigationFailed */) {
|
|
throw new UnknownErrorException(result.message ?? 'unknown exception');
|
|
}
|
|
}
|
|
return {
|
|
navigation: commandNavigation.navigationId,
|
|
// Url can change due to redirects. Get the one from commandNavigation.
|
|
url: commandNavigation.url,
|
|
};
|
|
}
|
|
async setViewport(viewport, devicePixelRatio) {
|
|
if (viewport === null && devicePixelRatio === null) {
|
|
await this.#cdpTarget.cdpClient.sendCommand('Emulation.clearDeviceMetricsOverride');
|
|
}
|
|
else {
|
|
try {
|
|
let appliedViewport;
|
|
if (viewport === undefined) {
|
|
appliedViewport = this.#previousViewport;
|
|
}
|
|
else if (viewport === null) {
|
|
appliedViewport = {
|
|
width: 0,
|
|
height: 0,
|
|
};
|
|
}
|
|
else {
|
|
appliedViewport = viewport;
|
|
}
|
|
this.#previousViewport = appliedViewport;
|
|
await this.#cdpTarget.cdpClient.sendCommand('Emulation.setDeviceMetricsOverride', {
|
|
width: this.#previousViewport.width,
|
|
height: this.#previousViewport.height,
|
|
deviceScaleFactor: devicePixelRatio ? devicePixelRatio : 0,
|
|
mobile: false,
|
|
dontSetVisibleSize: true,
|
|
});
|
|
}
|
|
catch (err) {
|
|
if (err.message.startsWith(
|
|
// https://crsrc.org/c/content/browser/devtools/protocol/emulation_handler.cc;l=257;drc=2f6eee84cf98d4227e7c41718dd71b82f26d90ff
|
|
'Width and height values must be positive')) {
|
|
throw new UnsupportedOperationException('Provided viewport dimensions are not supported');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
async handleUserPrompt(accept, userText) {
|
|
await this.#cdpTarget.cdpClient.sendCommand('Page.handleJavaScriptDialog', {
|
|
accept: accept ?? true,
|
|
promptText: userText,
|
|
});
|
|
}
|
|
async activate() {
|
|
await this.#cdpTarget.cdpClient.sendCommand('Page.bringToFront');
|
|
}
|
|
async captureScreenshot(params) {
|
|
if (!this.isTopLevelContext()) {
|
|
throw new UnsupportedOperationException(`Non-top-level 'context' (${params.context}) is currently not supported`);
|
|
}
|
|
const formatParameters = getImageFormatParameters(params);
|
|
let captureBeyondViewport = false;
|
|
let script;
|
|
params.origin ??= 'viewport';
|
|
switch (params.origin) {
|
|
case 'document': {
|
|
script = String(() => {
|
|
const element = document.documentElement;
|
|
return {
|
|
x: 0,
|
|
y: 0,
|
|
width: element.scrollWidth,
|
|
height: element.scrollHeight,
|
|
};
|
|
});
|
|
captureBeyondViewport = true;
|
|
break;
|
|
}
|
|
case 'viewport': {
|
|
script = String(() => {
|
|
const viewport = window.visualViewport;
|
|
return {
|
|
x: viewport.pageLeft,
|
|
y: viewport.pageTop,
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
};
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
const realm = await this.getOrCreateSandbox(undefined);
|
|
const originResult = await realm.callFunction(script, false);
|
|
assert(originResult.type === 'success');
|
|
const origin = deserializeDOMRect(originResult.result);
|
|
assert(origin);
|
|
let rect = origin;
|
|
if (params.clip) {
|
|
const clip = params.clip;
|
|
if (params.origin === 'viewport' && clip.type === 'box') {
|
|
// For viewport origin, the clip is relative to the viewport, while the CDP
|
|
// screenshot is relative to the document. So correction for the viewport position
|
|
// is required.
|
|
clip.x += origin.x;
|
|
clip.y += origin.y;
|
|
}
|
|
rect = getIntersectionRect(await this.#parseRect(clip), origin);
|
|
}
|
|
if (rect.width === 0 || rect.height === 0) {
|
|
throw new UnableToCaptureScreenException(`Unable to capture screenshot with zero dimensions: width=${rect.width}, height=${rect.height}`);
|
|
}
|
|
return await this.#cdpTarget.cdpClient.sendCommand('Page.captureScreenshot', {
|
|
clip: { ...rect, scale: 1.0 },
|
|
...formatParameters,
|
|
captureBeyondViewport,
|
|
});
|
|
}
|
|
async print(params) {
|
|
if (!this.isTopLevelContext()) {
|
|
throw new UnsupportedOperationException('Printing of non-top level contexts is not supported');
|
|
}
|
|
const cdpParams = {};
|
|
if (params.background !== undefined) {
|
|
cdpParams.printBackground = params.background;
|
|
}
|
|
if (params.margin?.bottom !== undefined) {
|
|
cdpParams.marginBottom = inchesFromCm(params.margin.bottom);
|
|
}
|
|
if (params.margin?.left !== undefined) {
|
|
cdpParams.marginLeft = inchesFromCm(params.margin.left);
|
|
}
|
|
if (params.margin?.right !== undefined) {
|
|
cdpParams.marginRight = inchesFromCm(params.margin.right);
|
|
}
|
|
if (params.margin?.top !== undefined) {
|
|
cdpParams.marginTop = inchesFromCm(params.margin.top);
|
|
}
|
|
if (params.orientation !== undefined) {
|
|
cdpParams.landscape = params.orientation === 'landscape';
|
|
}
|
|
if (params.page?.height !== undefined) {
|
|
cdpParams.paperHeight = inchesFromCm(params.page.height);
|
|
}
|
|
if (params.page?.width !== undefined) {
|
|
cdpParams.paperWidth = inchesFromCm(params.page.width);
|
|
}
|
|
if (params.pageRanges !== undefined) {
|
|
for (const range of params.pageRanges) {
|
|
if (typeof range === 'number') {
|
|
continue;
|
|
}
|
|
const rangeParts = range.split('-');
|
|
if (rangeParts.length < 1 || rangeParts.length > 2) {
|
|
throw new InvalidArgumentException(`Invalid page range: ${range} is not a valid integer range.`);
|
|
}
|
|
if (rangeParts.length === 1) {
|
|
void parseInteger(rangeParts[0] ?? '');
|
|
continue;
|
|
}
|
|
let lowerBound;
|
|
let upperBound;
|
|
const [rangeLowerPart = '', rangeUpperPart = ''] = rangeParts;
|
|
if (rangeLowerPart === '') {
|
|
lowerBound = 1;
|
|
}
|
|
else {
|
|
lowerBound = parseInteger(rangeLowerPart);
|
|
}
|
|
if (rangeUpperPart === '') {
|
|
upperBound = Number.MAX_SAFE_INTEGER;
|
|
}
|
|
else {
|
|
upperBound = parseInteger(rangeUpperPart);
|
|
}
|
|
if (lowerBound > upperBound) {
|
|
throw new InvalidArgumentException(`Invalid page range: ${rangeLowerPart} > ${rangeUpperPart}`);
|
|
}
|
|
}
|
|
cdpParams.pageRanges = params.pageRanges.join(',');
|
|
}
|
|
if (params.scale !== undefined) {
|
|
cdpParams.scale = params.scale;
|
|
}
|
|
if (params.shrinkToFit !== undefined) {
|
|
cdpParams.preferCSSPageSize = !params.shrinkToFit;
|
|
}
|
|
try {
|
|
const result = await this.#cdpTarget.cdpClient.sendCommand('Page.printToPDF', cdpParams);
|
|
return {
|
|
data: result.data,
|
|
};
|
|
}
|
|
catch (error) {
|
|
// Effectively zero dimensions.
|
|
if (error.message ===
|
|
'invalid print parameters: content area is empty') {
|
|
throw new UnsupportedOperationException(error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
/**
|
|
* See
|
|
* https://w3c.github.io/webdriver-bidi/#:~:text=If%20command%20parameters%20contains%20%22clip%22%3A
|
|
*/
|
|
async #parseRect(clip) {
|
|
switch (clip.type) {
|
|
case 'box':
|
|
return { x: clip.x, y: clip.y, width: clip.width, height: clip.height };
|
|
case 'element': {
|
|
// TODO: #1213: Use custom sandbox specifically for Chromium BiDi
|
|
const sandbox = await this.getOrCreateSandbox(undefined);
|
|
const result = await sandbox.callFunction(String((element) => {
|
|
return element instanceof Element;
|
|
}), false, { type: 'undefined' }, [clip.element]);
|
|
if (result.type === 'exception') {
|
|
throw new NoSuchElementException(`Element '${clip.element.sharedId}' was not found`);
|
|
}
|
|
assert(result.result.type === 'boolean');
|
|
if (!result.result.value) {
|
|
throw new NoSuchElementException(`Node '${clip.element.sharedId}' is not an Element`);
|
|
}
|
|
{
|
|
const result = await sandbox.callFunction(String((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
height: rect.height,
|
|
width: rect.width,
|
|
};
|
|
}), false, { type: 'undefined' }, [clip.element]);
|
|
assert(result.type === 'success');
|
|
const rect = deserializeDOMRect(result.result);
|
|
if (!rect) {
|
|
throw new UnableToCaptureScreenException(`Could not get bounding box for Element '${clip.element.sharedId}'`);
|
|
}
|
|
return rect;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
async close() {
|
|
await this.#cdpTarget.cdpClient.sendCommand('Page.close');
|
|
}
|
|
async traverseHistory(delta) {
|
|
if (delta === 0) {
|
|
return;
|
|
}
|
|
const history = await this.#cdpTarget.cdpClient.sendCommand('Page.getNavigationHistory');
|
|
const entry = history.entries[history.currentIndex + delta];
|
|
if (!entry) {
|
|
throw new NoSuchHistoryEntryException(`No history entry at delta ${delta}`);
|
|
}
|
|
await this.#cdpTarget.cdpClient.sendCommand('Page.navigateToHistoryEntry', {
|
|
entryId: entry.id,
|
|
});
|
|
}
|
|
async toggleModulesIfNeeded() {
|
|
await Promise.all([
|
|
this.#cdpTarget.toggleNetworkIfNeeded(),
|
|
this.#cdpTarget.toggleDeviceAccessIfNeeded(),
|
|
]);
|
|
}
|
|
async locateNodes(params) {
|
|
// TODO: create a dedicated sandbox instead of `#defaultRealm`.
|
|
return await this.#locateNodesByLocator(await this.#defaultRealmDeferred, params.locator, params.startNodes ?? [], params.maxNodeCount, params.serializationOptions);
|
|
}
|
|
async #getLocatorDelegate(realm, locator, maxNodeCount, startNodes) {
|
|
switch (locator.type) {
|
|
case 'css':
|
|
return {
|
|
functionDeclaration: String((cssSelector, maxNodeCount, ...startNodes) => {
|
|
const locateNodesUsingCss = (element) => {
|
|
if (!(element instanceof HTMLElement ||
|
|
element instanceof Document ||
|
|
element instanceof DocumentFragment)) {
|
|
throw new Error('startNodes in css selector should be HTMLElement, Document or DocumentFragment');
|
|
}
|
|
return [...element.querySelectorAll(cssSelector)];
|
|
};
|
|
startNodes = startNodes.length > 0 ? startNodes : [document];
|
|
const returnedNodes = startNodes
|
|
.map((startNode) =>
|
|
// TODO: stop search early if `maxNodeCount` is reached.
|
|
locateNodesUsingCss(startNode))
|
|
.flat(1);
|
|
return maxNodeCount === 0
|
|
? returnedNodes
|
|
: returnedNodes.slice(0, maxNodeCount);
|
|
}),
|
|
argumentsLocalValues: [
|
|
// `cssSelector`
|
|
{ type: 'string', value: locator.value },
|
|
// `maxNodeCount` with `0` means no limit.
|
|
{ type: 'number', value: maxNodeCount ?? 0 },
|
|
// `startNodes`
|
|
...startNodes,
|
|
],
|
|
};
|
|
case 'xpath':
|
|
return {
|
|
functionDeclaration: String((xPathSelector, maxNodeCount, ...startNodes) => {
|
|
// https://w3c.github.io/webdriver-bidi/#locate-nodes-using-xpath
|
|
const evaluator = new XPathEvaluator();
|
|
const expression = evaluator.createExpression(xPathSelector);
|
|
const locateNodesUsingXpath = (element) => {
|
|
const xPathResult = expression.evaluate(element, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
|
|
const returnedNodes = [];
|
|
for (let i = 0; i < xPathResult.snapshotLength; i++) {
|
|
returnedNodes.push(xPathResult.snapshotItem(i));
|
|
}
|
|
return returnedNodes;
|
|
};
|
|
startNodes = startNodes.length > 0 ? startNodes : [document];
|
|
const returnedNodes = startNodes
|
|
.map((startNode) =>
|
|
// TODO: stop search early if `maxNodeCount` is reached.
|
|
locateNodesUsingXpath(startNode))
|
|
.flat(1);
|
|
return maxNodeCount === 0
|
|
? returnedNodes
|
|
: returnedNodes.slice(0, maxNodeCount);
|
|
}),
|
|
argumentsLocalValues: [
|
|
// `xPathSelector`
|
|
{ type: 'string', value: locator.value },
|
|
// `maxNodeCount` with `0` means no limit.
|
|
{ type: 'number', value: maxNodeCount ?? 0 },
|
|
// `startNodes`
|
|
...startNodes,
|
|
],
|
|
};
|
|
case 'innerText':
|
|
// https://w3c.github.io/webdriver-bidi/#locate-nodes-using-inner-text
|
|
if (locator.value === '') {
|
|
throw new InvalidSelectorException('innerText locator cannot be empty');
|
|
}
|
|
return {
|
|
functionDeclaration: String((innerTextSelector, fullMatch, ignoreCase, maxNodeCount, maxDepth, ...startNodes) => {
|
|
const searchText = ignoreCase
|
|
? innerTextSelector.toUpperCase()
|
|
: innerTextSelector;
|
|
const locateNodesUsingInnerText = (node, currentMaxDepth) => {
|
|
const returnedNodes = [];
|
|
if (node instanceof DocumentFragment ||
|
|
node instanceof Document) {
|
|
const children = [...node.children];
|
|
children.forEach((child) =>
|
|
// `currentMaxDepth` is not decremented intentionally according to
|
|
// https://github.com/w3c/webdriver-bidi/pull/713.
|
|
returnedNodes.push(...locateNodesUsingInnerText(child, currentMaxDepth)));
|
|
return returnedNodes;
|
|
}
|
|
if (!(node instanceof HTMLElement)) {
|
|
return [];
|
|
}
|
|
const element = node;
|
|
const nodeInnerText = ignoreCase
|
|
? element.innerText?.toUpperCase()
|
|
: element.innerText;
|
|
if (!nodeInnerText.includes(searchText)) {
|
|
return [];
|
|
}
|
|
const childNodes = [];
|
|
for (const child of element.children) {
|
|
if (child instanceof HTMLElement) {
|
|
childNodes.push(child);
|
|
}
|
|
}
|
|
if (childNodes.length === 0) {
|
|
if (fullMatch && nodeInnerText === searchText) {
|
|
returnedNodes.push(element);
|
|
}
|
|
else {
|
|
if (!fullMatch) {
|
|
// Note: `nodeInnerText.includes(searchText)` is already checked
|
|
returnedNodes.push(element);
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
const childNodeMatches =
|
|
// Don't search deeper if `maxDepth` is reached.
|
|
currentMaxDepth <= 0
|
|
? []
|
|
: childNodes
|
|
.map((child) => locateNodesUsingInnerText(child, currentMaxDepth - 1))
|
|
.flat(1);
|
|
if (childNodeMatches.length === 0) {
|
|
// Note: `nodeInnerText.includes(searchText)` is already checked
|
|
if (!fullMatch || nodeInnerText === searchText) {
|
|
returnedNodes.push(element);
|
|
}
|
|
}
|
|
else {
|
|
returnedNodes.push(...childNodeMatches);
|
|
}
|
|
}
|
|
// TODO: stop search early if `maxNodeCount` is reached.
|
|
return returnedNodes;
|
|
};
|
|
// TODO: stop search early if `maxNodeCount` is reached.
|
|
startNodes = startNodes.length > 0 ? startNodes : [document];
|
|
const returnedNodes = startNodes
|
|
.map((startNode) =>
|
|
// TODO: stop search early if `maxNodeCount` is reached.
|
|
locateNodesUsingInnerText(startNode, maxDepth))
|
|
.flat(1);
|
|
return maxNodeCount === 0
|
|
? returnedNodes
|
|
: returnedNodes.slice(0, maxNodeCount);
|
|
}),
|
|
argumentsLocalValues: [
|
|
// `innerTextSelector`
|
|
{ type: 'string', value: locator.value },
|
|
// `fullMatch` with default `true`.
|
|
{ type: 'boolean', value: locator.matchType !== 'partial' },
|
|
// `ignoreCase` with default `false`.
|
|
{ type: 'boolean', value: locator.ignoreCase === true },
|
|
// `maxNodeCount` with `0` means no limit.
|
|
{ type: 'number', value: maxNodeCount ?? 0 },
|
|
// `maxDepth` with default `1000` (same as default full serialization depth).
|
|
{ type: 'number', value: locator.maxDepth ?? 1000 },
|
|
// `startNodes`
|
|
...startNodes,
|
|
],
|
|
};
|
|
case 'accessibility': {
|
|
// https://w3c.github.io/webdriver-bidi/#locate-nodes-using-accessibility-attributes
|
|
if (!locator.value.name && !locator.value.role) {
|
|
throw new InvalidSelectorException('Either name or role has to be specified');
|
|
}
|
|
// The next two commands cause a11y caches for the target to be
|
|
// preserved. We probably do not need to disable them if the
|
|
// client is using a11y features, but we could by calling
|
|
// Accessibility.disable.
|
|
await Promise.all([
|
|
this.#cdpTarget.cdpClient.sendCommand('Accessibility.enable'),
|
|
this.#cdpTarget.cdpClient.sendCommand('Accessibility.getRootAXNode'),
|
|
]);
|
|
const bindings = await realm.evaluate(
|
|
/* expression=*/ '({getAccessibleName, getAccessibleRole})',
|
|
/* awaitPromise=*/ false, "root" /* Script.ResultOwnership.Root */,
|
|
/* serializationOptions= */ undefined,
|
|
/* userActivation=*/ false,
|
|
/* includeCommandLineApi=*/ true);
|
|
if (bindings.type !== 'success') {
|
|
throw new Error('Could not get bindings');
|
|
}
|
|
if (bindings.result.type !== 'object') {
|
|
throw new Error('Could not get bindings');
|
|
}
|
|
return {
|
|
functionDeclaration: String((name, role, bindings, maxNodeCount, ...startNodes) => {
|
|
const returnedNodes = [];
|
|
let aborted = false;
|
|
function collect(contextNodes, selector) {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
for (const contextNode of contextNodes) {
|
|
let match = true;
|
|
if (selector.role) {
|
|
const role = bindings.getAccessibleRole(contextNode);
|
|
if (selector.role !== role) {
|
|
match = false;
|
|
}
|
|
}
|
|
if (selector.name) {
|
|
const name = bindings.getAccessibleName(contextNode);
|
|
if (selector.name !== name) {
|
|
match = false;
|
|
}
|
|
}
|
|
if (match) {
|
|
if (maxNodeCount !== 0 &&
|
|
returnedNodes.length === maxNodeCount) {
|
|
aborted = true;
|
|
break;
|
|
}
|
|
returnedNodes.push(contextNode);
|
|
}
|
|
const childNodes = [];
|
|
for (const child of contextNode.children) {
|
|
if (child instanceof HTMLElement) {
|
|
childNodes.push(child);
|
|
}
|
|
}
|
|
collect(childNodes, selector);
|
|
}
|
|
}
|
|
startNodes =
|
|
startNodes.length > 0
|
|
? startNodes
|
|
: Array.from(document.documentElement.children).filter((c) => c instanceof HTMLElement);
|
|
collect(startNodes, {
|
|
role,
|
|
name,
|
|
});
|
|
return returnedNodes;
|
|
}),
|
|
argumentsLocalValues: [
|
|
// `name`
|
|
{ type: 'string', value: locator.value.name || '' },
|
|
// `role`
|
|
{ type: 'string', value: locator.value.role || '' },
|
|
// `bindings`.
|
|
{ handle: bindings.result.handle },
|
|
// `maxNodeCount` with `0` means no limit.
|
|
{ type: 'number', value: maxNodeCount ?? 0 },
|
|
// `startNodes`
|
|
...startNodes,
|
|
],
|
|
};
|
|
}
|
|
}
|
|
}
|
|
async #locateNodesByLocator(realm, locator, startNodes, maxNodeCount, serializationOptions) {
|
|
const locatorDelegate = await this.#getLocatorDelegate(realm, locator, maxNodeCount, startNodes);
|
|
serializationOptions = {
|
|
...serializationOptions,
|
|
// The returned object is an array of nodes, so no need in deeper JS serialization.
|
|
maxObjectDepth: 1,
|
|
};
|
|
const locatorResult = await realm.callFunction(locatorDelegate.functionDeclaration, false, { type: 'undefined' }, locatorDelegate.argumentsLocalValues, "none" /* Script.ResultOwnership.None */, serializationOptions);
|
|
if (locatorResult.type !== 'success') {
|
|
this.#logger?.(_a.LOGGER_PREFIX, 'Failed locateNodesByLocator', locatorResult);
|
|
// Heuristic to detect invalid selector for different types of selectors.
|
|
if (
|
|
// CSS selector.
|
|
locatorResult.exceptionDetails.text?.endsWith('is not a valid selector.') ||
|
|
// XPath selector.
|
|
locatorResult.exceptionDetails.text?.endsWith('is not a valid XPath expression.')) {
|
|
throw new InvalidSelectorException(`Not valid selector ${typeof locator.value === 'string' ? locator.value : JSON.stringify(locator.value)}`);
|
|
}
|
|
// Heuristic to detect if the `startNode` is not an `HTMLElement` in css selector.
|
|
if (locatorResult.exceptionDetails.text ===
|
|
'Error: startNodes in css selector should be HTMLElement, Document or DocumentFragment') {
|
|
throw new InvalidArgumentException('startNodes in css selector should be HTMLElement, Document or DocumentFragment');
|
|
}
|
|
throw new UnknownErrorException(`Unexpected error in selector script: ${locatorResult.exceptionDetails.text}`);
|
|
}
|
|
if (locatorResult.result.type !== 'array') {
|
|
throw new UnknownErrorException(`Unexpected selector script result type: ${locatorResult.result.type}`);
|
|
}
|
|
// Check there are no non-node elements in the result.
|
|
const nodes = locatorResult.result.value.map((value) => {
|
|
if (value.type !== 'node') {
|
|
throw new UnknownErrorException(`Unexpected selector script result element: ${value.type}`);
|
|
}
|
|
return value;
|
|
});
|
|
return { nodes };
|
|
}
|
|
}
|
|
_a = BrowsingContextImpl;
|
|
export function serializeOrigin(origin) {
|
|
// https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin
|
|
if (['://', ''].includes(origin)) {
|
|
origin = 'null';
|
|
}
|
|
return origin;
|
|
}
|
|
function getImageFormatParameters(params) {
|
|
const { quality, type } = params.format ?? {
|
|
type: 'image/png',
|
|
};
|
|
switch (type) {
|
|
case 'image/png': {
|
|
return { format: 'png' };
|
|
}
|
|
case 'image/jpeg': {
|
|
return {
|
|
format: 'jpeg',
|
|
...(quality === undefined ? {} : { quality: Math.round(quality * 100) }),
|
|
};
|
|
}
|
|
case 'image/webp': {
|
|
return {
|
|
format: 'webp',
|
|
...(quality === undefined ? {} : { quality: Math.round(quality * 100) }),
|
|
};
|
|
}
|
|
}
|
|
throw new InvalidArgumentException(`Image format '${type}' is not a supported format`);
|
|
}
|
|
function deserializeDOMRect(result) {
|
|
if (result.type !== 'object' || result.value === undefined) {
|
|
return;
|
|
}
|
|
const x = result.value.find(([key]) => {
|
|
return key === 'x';
|
|
})?.[1];
|
|
const y = result.value.find(([key]) => {
|
|
return key === 'y';
|
|
})?.[1];
|
|
const height = result.value.find(([key]) => {
|
|
return key === 'height';
|
|
})?.[1];
|
|
const width = result.value.find(([key]) => {
|
|
return key === 'width';
|
|
})?.[1];
|
|
if (x?.type !== 'number' ||
|
|
y?.type !== 'number' ||
|
|
height?.type !== 'number' ||
|
|
width?.type !== 'number') {
|
|
return;
|
|
}
|
|
return {
|
|
x: x.value,
|
|
y: y.value,
|
|
width: width.value,
|
|
height: height.value,
|
|
};
|
|
}
|
|
/** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */
|
|
function normalizeRect(box) {
|
|
return {
|
|
...(box.width < 0
|
|
? {
|
|
x: box.x + box.width,
|
|
width: -box.width,
|
|
}
|
|
: {
|
|
x: box.x,
|
|
width: box.width,
|
|
}),
|
|
...(box.height < 0
|
|
? {
|
|
y: box.y + box.height,
|
|
height: -box.height,
|
|
}
|
|
: {
|
|
y: box.y,
|
|
height: box.height,
|
|
}),
|
|
};
|
|
}
|
|
/** @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection */
|
|
function getIntersectionRect(first, second) {
|
|
first = normalizeRect(first);
|
|
second = normalizeRect(second);
|
|
const x = Math.max(first.x, second.x);
|
|
const y = Math.max(first.y, second.y);
|
|
return {
|
|
x,
|
|
y,
|
|
width: Math.max(Math.min(first.x + first.width, second.x + second.width) - x, 0),
|
|
height: Math.max(Math.min(first.y + first.height, second.y + second.height) - y, 0),
|
|
};
|
|
}
|
|
function parseInteger(value) {
|
|
value = value.trim();
|
|
if (!/^[0-9]+$/.test(value)) {
|
|
throw new InvalidArgumentException(`Invalid integer: ${value}`);
|
|
}
|
|
return parseInt(value);
|
|
}
|
|
//# sourceMappingURL=BrowsingContextImpl.js.map
|