feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
@@ -0,0 +1,92 @@
/**
* 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.
*/
import type { Protocol } from 'devtools-protocol';
import { BrowsingContext, type Emulation, type UAClientHints } from '../../../protocol/protocol.js';
import { type LoggerFn } from '../../../utils/log.js';
import type { ContextConfigStorage } from '../browser/ContextConfigStorage.js';
import type { CdpTarget } from '../cdp/CdpTarget.js';
import type { Realm } from '../script/Realm.js';
import type { RealmStorage } from '../script/RealmStorage.js';
import type { EventManager } from '../session/EventManager.js';
import type { BrowsingContextStorage } from './BrowsingContextStorage.js';
export declare class BrowsingContextImpl {
#private;
static readonly LOGGER_PREFIX: "debug:browsingContext";
readonly userContext: string;
private constructor();
static create(id: BrowsingContext.BrowsingContext, parentId: BrowsingContext.BrowsingContext | null, userContext: string, cdpTarget: CdpTarget, eventManager: EventManager, browsingContextStorage: BrowsingContextStorage, realmStorage: RealmStorage, configStorage: ContextConfigStorage, url: string, originalOpener?: string, logger?: LoggerFn): BrowsingContextImpl;
/**
* @see https://html.spec.whatwg.org/multipage/document-sequences.html#navigable
*/
get navigableId(): string | undefined;
get navigationId(): string;
dispose(emitContextDestroyed: boolean): void;
/** Returns the ID of this context. */
get id(): BrowsingContext.BrowsingContext;
/** Returns the parent context ID. */
get parentId(): BrowsingContext.BrowsingContext | null;
/** Sets the parent context ID and updates parent's children. */
set parentId(parentId: BrowsingContext.BrowsingContext | null);
/** Returns the parent context. */
get parent(): BrowsingContextImpl | null;
/** Returns all direct children contexts. */
get directChildren(): BrowsingContextImpl[];
/** Returns all children contexts, flattened. */
get allChildren(): BrowsingContextImpl[];
/**
* Returns true if this is a top-level context.
* This is the case whenever the parent context ID is null.
*/
isTopLevelContext(): boolean;
get top(): BrowsingContextImpl;
addChild(childId: BrowsingContext.BrowsingContext): void;
get cdpTarget(): CdpTarget;
updateCdpTarget(cdpTarget: CdpTarget): void;
get url(): string;
lifecycleLoaded(): Promise<void>;
targetUnblockedOrThrow(): Promise<void>;
/** Returns a sandbox for internal helper scripts which is not exposed to the user.*/
getOrCreateHiddenSandbox(): Promise<Realm>;
/** Returns a sandbox which is exposed to user. */
getOrCreateUserSandbox(sandbox: string | undefined): Promise<Realm>;
/**
* Implements https://w3c.github.io/webdriver-bidi/#get-the-navigable-info.
*/
serializeToBidiValue(maxDepth?: number | null, addParentField?: boolean): BrowsingContext.Info;
onTargetInfoChanged(params: Protocol.Target.TargetInfoChangedEvent): void;
navigate(url: string, wait: BrowsingContext.ReadinessState): Promise<BrowsingContext.NavigateResult>;
reload(ignoreCache: boolean, wait: BrowsingContext.ReadinessState): Promise<BrowsingContext.NavigateResult>;
setViewport(viewport: BrowsingContext.Viewport | null, devicePixelRatio: number | null, screenOrientation: Emulation.ScreenOrientation | null): Promise<void>;
handleUserPrompt(accept?: boolean, userText?: string): Promise<void>;
activate(): Promise<void>;
captureScreenshot(params: BrowsingContext.CaptureScreenshotParameters): Promise<BrowsingContext.CaptureScreenshotResult>;
print(params: BrowsingContext.PrintParameters): Promise<BrowsingContext.PrintResult>;
close(): Promise<void>;
traverseHistory(delta: number): Promise<void>;
toggleModulesIfNeeded(): Promise<void>;
locateNodes(params: BrowsingContext.LocateNodesParameters): Promise<BrowsingContext.LocateNodesResult>;
setTimezoneOverride(timezone: string | null): Promise<void>;
setLocaleOverride(locale: string | null): Promise<void>;
setGeolocationOverride(geolocation: Emulation.GeolocationCoordinates | Emulation.GeolocationPositionError | null): Promise<void>;
setScriptingEnabled(scriptingEnabled: false | null): Promise<void>;
setUserAgentAndAcceptLanguage(userAgent: string | null | undefined, acceptLanguage: string | null | undefined, clientHints: UAClientHints.UserAgentClientHints.ClientHintsMetadata | null | undefined): Promise<void>;
setEmulatedNetworkConditions(networkConditions: Emulation.NetworkConditions | null): Promise<void>;
setTouchOverride(maxTouchPoints: number | null): Promise<void>;
setExtraHeaders(cdpExtraHeaders: Protocol.Network.Headers): Promise<Promise<any>>;
setScrollbarTypeOverride(scrollbarType: 'classic' | 'overlay' | null): Promise<void>;
}
export declare function serializeOrigin(origin: string): string;
@@ -0,0 +1,1447 @@
/**
* 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, NoSuchFrameException, NoSuchHistoryEntryException, NoSuchNodeException, 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 { uuidv4 } from '../../../utils/uuid.js';
import { getSharedId, parseSharedId } from '../script/SharedId.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;
// Used for running helper scripts.
#hiddenSandbox = uuidv4();
#downloadIdToUrlMap = new Map();
/**
* The ID of the parent browsing context.
* If null, this is a top-level context.
*/
#loaderId;
#parentId = null;
#originalOpener;
#lifecycle = {
DOMContentLoaded: new Deferred(),
load: new Deferred(),
};
#cdpTarget;
#defaultRealmDeferred = new Deferred();
#browsingContextStorage;
#eventManager;
#logger;
#navigationTracker;
#realmStorage;
#configStorage;
// Set when the user prompt is opened. Required to provide the type in closing event.
#lastUserPromptType;
constructor(id, parentId, userContext, cdpTarget, eventManager, browsingContextStorage, realmStorage, configStorage, url, originalOpener, logger) {
this.#cdpTarget = cdpTarget;
this.#id = id;
this.#parentId = parentId;
this.userContext = userContext;
this.#eventManager = eventManager;
this.#browsingContextStorage = browsingContextStorage;
this.#realmStorage = realmStorage;
this.#configStorage = configStorage;
this.#logger = logger;
this.#originalOpener = originalOpener;
// Register helper realm as hidden, so that it will not be reported to the user.
this.#realmStorage.hiddenSandboxes.add(this.#hiddenSandbox);
this.#navigationTracker = new NavigationTracker(url, id, eventManager, logger);
}
static create(id, parentId, userContext, cdpTarget, eventManager, browsingContextStorage, realmStorage, configStorage, url, originalOpener, logger) {
const context = new _a(id, parentId, userContext, cdpTarget, eventManager, browsingContextStorage, realmStorage, configStorage, url, originalOpener, 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.#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(null),
}, this.id);
}
// Dispose children after the events are emitted.
this.#deleteAllChildren();
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;
}
}
/** Returns a sandbox for internal helper scripts which is not exposed to the user.*/
async getOrCreateHiddenSandbox() {
return await this.#getOrCreateSandboxInternal(this.#hiddenSandbox);
}
/** Returns a sandbox which is exposed to user. */
async getOrCreateUserSandbox(sandbox) {
const realm = await this.#getOrCreateSandboxInternal(sandbox);
if (realm.isHidden()) {
throw new NoSuchFrameException(`Realm "${sandbox}" not found`);
}
return realm;
}
async #getOrCreateSandboxInternal(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];
}
/**
* Implements https://w3c.github.io/webdriver-bidi/#get-the-navigable-info.
*/
serializeToBidiValue(maxDepth = 0, addParentField = true) {
return {
context: this.#id,
url: this.url,
userContext: this.userContext,
originalOpener: this.#originalOpener ?? null,
clientWindow: `${this.cdpTarget.windowId}`,
children: maxDepth === null || maxDepth > 0
? this.directChildren.map((c) => c.serializeToBidiValue(maxDepth === null ? maxDepth : 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.fileChooserOpened', (params) => {
if (this.id !== params.frameId) {
return;
}
if (this.#loaderId === undefined) {
this.#logger?.(LogType.debugError, 'LoaderId should be defined when file upload is shown', params);
return;
}
const element = params.backendNodeId === undefined
? undefined
: {
sharedId: getSharedId(this.id, this.#loaderId, params.backendNodeId),
};
this.#eventManager.registerEvent({
type: 'event',
method: ChromiumBidi.Input.EventNames.FileDialogOpened,
params: {
context: this.id,
multiple: params.mode === 'selectMultiple',
element,
},
}, this.id);
});
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.cdpClient.on('Page.frameStartedNavigating', (params) => {
if (this.id !== params.frameId) {
return;
}
this.#navigationTracker.frameStartedNavigating(params.url, params.loaderId, params.navigationType);
});
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,
timestamp: getTimestamp(),
url: this.#navigationTracker.url,
},
}, this.id);
return;
}
});
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;
}
if (auxData.type === 'isolated' && name === '') {
// This is an internal isolated realm and it is not expected to be exposed to
// WebDriver BiDi users. Ignore it.
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) => {
// Checking for `params.frameId` for comptaibility with Chrome
// versions that do not have a frameId. TODO: remove once
// https://crrev.com/c/6487891 is in stable.
if (params.frameId && this.id !== params.frameId) {
return;
}
if (!params.frameId &&
this.#parentId &&
this.#cdpTarget.cdpClient !==
this.#browsingContextStorage.getContext(this.#parentId)?.cdpTarget
.cdpClient) {
// If CDP event `Page.javascriptDialogClosed` does not have a frameId, this
// heuristic emits the event only for top-level per-cdp target context, ignoring
// the event for same-process iframes. So the event will be emitted only once per
// CDP target. TODO: remove once https://crrev.com/c/6487891 is in stable.
return;
}
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) => {
// Checking for `params.frameId` for comptaibility with Chrome
// versions that do not have a frameId. TODO: remove once
// https://crrev.com/c/6487891 is in stable.
if (params.frameId && this.id !== params.frameId) {
return;
}
if (!params.frameId &&
this.#parentId &&
this.#cdpTarget.cdpClient !==
this.#browsingContextStorage.getContext(this.#parentId)?.cdpTarget
.cdpClient) {
// If CDP event `Page.javascriptDialogClosed` does not have a frameId, this
// heuristic emits the event only for top-level per-cdp target context, ignoring
// the event for same-process iframes. So the event will be emitted only once per
// CDP target. TODO: remove once https://crrev.com/c/6487891 is in stable.
return;
}
const promptType = _a.#getPromptType(params.type);
// 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;
}
});
this.#cdpTarget.browserCdpClient.on('Browser.downloadWillBegin', (params) => {
if (this.id !== params.frameId) {
return;
}
this.#downloadIdToUrlMap.set(params.guid, params.url);
this.#eventManager.registerEvent({
type: 'event',
method: ChromiumBidi.BrowsingContext.EventNames.DownloadWillBegin,
params: {
context: this.id,
suggestedFilename: params.suggestedFilename,
navigation: params.guid,
timestamp: getTimestamp(),
url: params.url,
},
}, this.id);
});
this.#cdpTarget.browserCdpClient.on('Browser.downloadProgress', (params) => {
if (!this.#downloadIdToUrlMap.has(params.guid)) {
// The event is not related to this browsing context.
return;
}
if (params.state === 'inProgress') {
// No need in reporting progress.
return;
}
const url = this.#downloadIdToUrlMap.get(params.guid);
switch (params.state) {
case 'canceled':
this.#eventManager.registerEvent({
type: 'event',
method: ChromiumBidi.BrowsingContext.EventNames.DownloadEnd,
params: {
status: 'canceled',
context: this.id,
navigation: params.guid,
timestamp: getTimestamp(),
url,
},
}, this.id);
break;
case 'completed':
this.#eventManager.registerEvent({
type: 'event',
method: ChromiumBidi.BrowsingContext.EventNames.DownloadEnd,
params: {
filepath: params.filePath ?? null,
status: 'complete',
context: this.id,
navigation: params.guid,
timestamp: getTimestamp(),
url,
},
}, this.id);
break;
default:
// Unreachable.
throw new UnknownErrorException(`Unknown download state: ${params.state}`);
}
});
}
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 */;
}
}
/**
* Returns either custom UserContext's prompt handler, global or default one.
*/
#getPromptHandler(promptType) {
const defaultPromptHandler = "dismiss" /* Session.UserPromptHandlerType.Dismiss */;
const contextConfig = this.#configStorage.getActiveConfig(this.top.id, this.userContext);
switch (promptType) {
case "alert" /* BrowsingContext.UserPromptType.Alert */:
return (contextConfig.userPromptHandler?.alert ??
contextConfig.userPromptHandler?.default ??
defaultPromptHandler);
case "beforeunload" /* BrowsingContext.UserPromptType.Beforeunload */:
return (contextConfig.userPromptHandler?.beforeUnload ??
contextConfig.userPromptHandler?.default ??
"accept" /* Session.UserPromptHandlerType.Accept */);
case "confirm" /* BrowsingContext.UserPromptType.Confirm */:
return (contextConfig.userPromptHandler?.confirm ??
contextConfig.userPromptHandler?.default ??
defaultPromptHandler);
case "prompt" /* BrowsingContext.UserPromptType.Prompt */:
return (contextConfig.userPromptHandler?.prompt ??
contextConfig.userPromptHandler?.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 navigationState = 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(navigationState, cdpNavigateResult.errorText);
throw new UnknownErrorException(cdpNavigateResult.errorText);
}
this.#navigationTracker.navigationCommandFinished(navigationState, cdpNavigateResult.loaderId);
this.#documentChanged(cdpNavigateResult.loaderId);
})();
// 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, navigationState),
// Throw an error if the navigation is canceled.
navigationState.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: navigationState.navigationId,
// Url can change due to redirects. Get the one from commandNavigation.
url: navigationState.url,
};
}
async #waitNavigation(wait, cdpCommandPromise, navigationState) {
await Promise.all([navigationState.committed, cdpCommandPromise]);
if (wait === "none" /* BrowsingContext.ReadinessState.None */) {
return;
}
if (navigationState.isFragmentNavigation === true) {
// After the cdp command is finished, the `fragmentNavigation` should be already
// settled. If it's the fragment navigation, wait for the `navigationStatus` to be
// finished, which happens after the fragment navigation happened. No need to wait for
// DOM events.
await navigationState.finished;
return;
}
if (wait === "interactive" /* BrowsingContext.ReadinessState.Interactive */) {
await this.#lifecycle.DOMContentLoaded;
return;
}
if (wait === "complete" /* BrowsingContext.ReadinessState.Complete */) {
await this.#lifecycle.load;
return;
}
throw new InvalidArgumentException(`Wait condition ${wait} is not supported`);
}
// TODO: support concurrent navigations analogous to `navigate`.
async reload(ignoreCache, wait) {
await this.targetUnblockedOrThrow();
this.#resetLifecycleIfFinished();
const navigationState = 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, navigationState),
// Throw an error if the navigation is canceled.
navigationState.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: navigationState.navigationId,
// Url can change due to redirects. Get the one from commandNavigation.
url: navigationState.url,
};
}
async setViewport(viewport, devicePixelRatio, screenOrientation) {
// Set the target's viewport.
const config = this.#configStorage.getActiveConfig(this.id, this.userContext);
await this.cdpTarget.setDeviceMetricsOverride(viewport, devicePixelRatio, screenOrientation, config.screenArea ?? null, config.scrollbarType ?? null);
}
async handleUserPrompt(accept, userText) {
await this.top.#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 hiddenSandboxRealm = await this.getOrCreateHiddenSandbox();
const originResult = await hiddenSandboxRealm.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': {
const hiddenSandboxRealm = await this.getOrCreateHiddenSandbox();
const result = await hiddenSandboxRealm.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 hiddenSandboxRealm.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(),
this.#cdpTarget.togglePreloadIfNeeded(),
]);
}
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);
}
#getLocatorDelegate(locator, maxNodeCount, startNodes) {
switch (locator.type) {
case 'context':
case 'accessibility':
throw new Error('Unreachable');
case 'css':
return {
functionDeclaration: String((cssSelector, maxNodeCount, ...startNodes) => {
const locateNodesUsingCss = (element) => {
if (!(element instanceof HTMLElement ||
element instanceof Document ||
element instanceof DocumentFragment ||
element instanceof SVGElement)) {
throw new Error('startNodes in css selector should be HTMLElement, SVGElement or 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,
],
};
}
}
async #locateNodesByLocator(realm, locator, startNodes, maxNodeCount, serializationOptions) {
if (locator.type === 'context') {
return await this.#locateNodesByContextLocator(locator, startNodes, realm, serializationOptions);
}
if (locator.type === 'accessibility') {
return await this.#locateNodesByAccessibility(locator, startNodes, maxNodeCount, realm);
}
// Select by injecting a script into the realm.
const locatorDelegate = this.#getLocatorDelegate(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, SVGElement or Document or DocumentFragment') {
throw new InvalidArgumentException('startNodes in css selector should be HTMLElement, SVGElement or 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 };
}
async #locateNodesByContextLocator(locator, startNodes, realm, serializationOptions) {
if (startNodes.length !== 0) {
throw new InvalidArgumentException('Start nodes are not supported');
}
const contextId = locator.value.context;
if (!contextId) {
throw new InvalidSelectorException('Invalid context');
}
const context = this.#browsingContextStorage.getContext(contextId);
const parent = context.parent;
if (!parent) {
throw new InvalidArgumentException('This context has no container');
}
try {
const { backendNodeId } = await parent.#cdpTarget.cdpClient.sendCommand('DOM.getFrameOwner', {
frameId: contextId,
});
const { object } = await parent.#cdpTarget.cdpClient.sendCommand('DOM.resolveNode', {
backendNodeId,
});
const locatorResult = await realm.callFunction(`function () { return this; }`, false, { handle: object.objectId }, [], "none" /* Script.ResultOwnership.None */, serializationOptions);
if (locatorResult.type === 'exception') {
throw new Error('Unknown exception');
}
return { nodes: [locatorResult.result] };
}
catch {
throw new InvalidArgumentException('Context does not exist');
}
}
async #locateNodesByAccessibility(locator, startNodes, maxNodeCount, realm) {
if (!locator.value.name && !locator.value.role) {
throw new InvalidSelectorException('Either name or role has to be specified');
}
await this.#cdpTarget.cdpClient.sendCommand('Accessibility.enable');
const startBackendNodeIds = [];
if (startNodes.length === 0) {
const { root: documentRoot } = await this.#cdpTarget.cdpClient.sendCommand('DOM.getDocument');
startBackendNodeIds.push(documentRoot.backendNodeId);
}
else {
for (const node of startNodes) {
if (node.sharedId) {
const parsed = parseSharedId(node.sharedId);
if (!parsed) {
throw new NoSuchNodeException(`Invalid sharedId: ${node.sharedId}`);
}
startBackendNodeIds.push(parsed.backendNodeId);
}
else {
if (node.handle) {
const { nodeId } = await this.#cdpTarget.cdpClient.sendCommand('DOM.requestNode', {
objectId: node.handle,
});
const { node: describedNode } = await this.#cdpTarget.cdpClient.sendCommand('DOM.describeNode', {
nodeId,
});
startBackendNodeIds.push(describedNode.backendNodeId);
}
else {
throw new NoSuchNodeException('Start node must have sharedId or handle');
}
}
}
}
const matchedBackendNodeIds = new Set();
for (const backendNodeId of startBackendNodeIds) {
const { nodes } = await this.#cdpTarget.cdpClient.sendCommand('Accessibility.queryAXTree', {
backendNodeId,
accessibleName: locator.value.name,
role: locator.value.role,
});
for (const node of nodes) {
if (node.backendDOMNodeId && node.role?.type === 'role') {
matchedBackendNodeIds.add(node.backendDOMNodeId);
if (maxNodeCount !== undefined &&
maxNodeCount > 0 &&
matchedBackendNodeIds.size >= maxNodeCount) {
break;
}
}
}
}
const resultNodes = await Promise.all(Array.from(matchedBackendNodeIds).map(async (backendNodeId) => {
const { object } = await this.#cdpTarget.cdpClient.sendCommand('DOM.resolveNode', {
backendNodeId,
});
// We need to use `serializeCdpObject` to convert it to BiDi format.
// We use `Script.ResultOwnership.None` as `locateNodes` returns weak references (nodes).
return await realm.serializeCdpObject(object, "none" /* Script.ResultOwnership.None */);
}));
return {
nodes: resultNodes.filter((result) => result.type === 'node'),
};
}
#getAllRelatedCdpTargets() {
const targets = new Set();
targets.add(this.cdpTarget);
this.allChildren.forEach((c) => targets.add(c.cdpTarget));
return Array.from(targets);
}
async setTimezoneOverride(timezone) {
await Promise.all(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setTimezoneOverride(timezone)));
}
async setLocaleOverride(locale) {
await Promise.all(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setLocaleOverride(locale)));
}
async setGeolocationOverride(geolocation) {
await Promise.all(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setGeolocationOverride(geolocation)));
}
async setScriptingEnabled(scriptingEnabled) {
await Promise.all(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setScriptingEnabled(scriptingEnabled)));
}
async setUserAgentAndAcceptLanguage(userAgent, acceptLanguage, clientHints) {
await Promise.all(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setUserAgentAndAcceptLanguage(userAgent, acceptLanguage, clientHints)));
}
async setEmulatedNetworkConditions(networkConditions) {
await Promise.all(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setEmulatedNetworkConditions(networkConditions)));
}
async setTouchOverride(maxTouchPoints) {
await Promise.allSettled(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setTouchOverride(maxTouchPoints)));
}
async setExtraHeaders(cdpExtraHeaders) {
await Promise.all(this.#getAllRelatedCdpTargets().map(async (cdpTarget) => await cdpTarget.setExtraHeaders(cdpExtraHeaders)));
}
async setScrollbarTypeOverride(scrollbarType) {
const config = this.#configStorage.getActiveConfig(this.id, this.userContext);
await this.cdpTarget.setDeviceMetricsOverride(config.viewport ?? null, config.devicePixelRatio ?? null, config.screenOrientation ?? null, config.screenArea ?? null, scrollbarType);
}
}
_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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
import type { CdpClient } from '../../../cdp/CdpClient.js';
import { BrowsingContext, type EmptyResult } from '../../../protocol/protocol.js';
import type { ContextConfigStorage } from '../browser/ContextConfigStorage.js';
import type { UserContextStorage } from '../browser/UserContextStorage.js';
import type { EventManager } from '../session/EventManager.js';
import type { BrowsingContextStorage } from './BrowsingContextStorage.js';
export declare class BrowsingContextProcessor {
#private;
constructor(browserCdpClient: CdpClient, browsingContextStorage: BrowsingContextStorage, userContextStorage: UserContextStorage, contextConfigStorage: ContextConfigStorage, eventManager: EventManager);
getTree(params: BrowsingContext.GetTreeParameters): BrowsingContext.GetTreeResult;
create(params: BrowsingContext.CreateParameters): Promise<BrowsingContext.CreateResult>;
navigate(params: BrowsingContext.NavigateParameters): Promise<BrowsingContext.NavigateResult>;
reload(params: BrowsingContext.ReloadParameters): Promise<EmptyResult>;
activate(params: BrowsingContext.ActivateParameters): Promise<EmptyResult>;
captureScreenshot(params: BrowsingContext.CaptureScreenshotParameters): Promise<BrowsingContext.CaptureScreenshotResult>;
print(params: BrowsingContext.PrintParameters): Promise<BrowsingContext.PrintResult>;
setViewport(params: BrowsingContext.SetViewportParameters): Promise<EmptyResult>;
traverseHistory(params: BrowsingContext.TraverseHistoryParameters): Promise<BrowsingContext.TraverseHistoryResult>;
handleUserPrompt(params: BrowsingContext.HandleUserPromptParameters): Promise<EmptyResult>;
close(params: BrowsingContext.CloseParameters): Promise<EmptyResult>;
locateNodes(params: BrowsingContext.LocateNodesParameters): Promise<BrowsingContext.LocateNodesResult>;
}
@@ -0,0 +1,263 @@
import { ChromiumBidi, InvalidArgumentException, NoSuchUserContextException, NoSuchAlertException, UnsupportedOperationException, } from '../../../protocol/protocol.js';
export class BrowsingContextProcessor {
#browserCdpClient;
#browsingContextStorage;
#contextConfigStorage;
#eventManager;
#userContextStorage;
constructor(browserCdpClient, browsingContextStorage, userContextStorage, contextConfigStorage, eventManager) {
this.#contextConfigStorage = contextConfigStorage;
this.#userContextStorage = userContextStorage;
this.#browserCdpClient = browserCdpClient;
this.#browsingContextStorage = browsingContextStorage;
this.#eventManager = eventManager;
this.#eventManager.addSubscribeHook(ChromiumBidi.BrowsingContext.EventNames.ContextCreated, this.#onContextCreatedSubscribeHook.bind(this));
}
getTree(params) {
const resultContexts = params.root === undefined
? this.#browsingContextStorage.getTopLevelContexts()
: [this.#browsingContextStorage.getContext(params.root)];
return {
contexts: resultContexts.map((c) => c.serializeToBidiValue(params.maxDepth ?? Number.MAX_VALUE)),
};
}
async create(params) {
let referenceContext;
let userContext = 'default';
if (params.referenceContext !== undefined) {
referenceContext = this.#browsingContextStorage.getContext(params.referenceContext);
if (!referenceContext.isTopLevelContext()) {
throw new InvalidArgumentException(`referenceContext should be a top-level context`);
}
userContext = referenceContext.userContext;
}
if (params.userContext !== undefined) {
userContext = params.userContext;
}
const existingContexts = this.#browsingContextStorage
.getAllContexts()
.filter((context) => context.userContext === userContext);
let newWindow = false;
switch (params.type) {
case "tab" /* BrowsingContext.CreateType.Tab */:
newWindow = false;
break;
case "window" /* BrowsingContext.CreateType.Window */:
newWindow = true;
break;
}
if (!existingContexts.length) {
// If there are no contexts in the given user context, we need to set
// newWindow to true as newWindow=false will be rejected.
newWindow = true;
}
let result;
try {
result = await this.#browserCdpClient.sendCommand('Target.createTarget', {
url: 'about:blank',
newWindow,
browserContextId: userContext === 'default' ? undefined : userContext,
background: params.background === true,
});
}
catch (err) {
if (
// See https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/devtools/protocol/target_handler.cc;l=90;drc=e80392ac11e48a691f4309964cab83a3a59e01c8
err.message.startsWith('Failed to find browser context with id') ||
// See https://source.chromium.org/chromium/chromium/src/+/main:headless/lib/browser/protocol/target_handler.cc;l=49;drc=e80392ac11e48a691f4309964cab83a3a59e01c8
err.message === 'browserContextId') {
throw new NoSuchUserContextException(`The context ${userContext} was not found`);
}
throw err;
}
// Wait for the new target to be attached and to be added to the browsing context
// storage.
const context = await this.#browsingContextStorage.waitForContext(result.targetId);
// Wait for the new tab to be loaded to avoid race conditions in the
// `browsingContext` events, when the `browsingContext.domContentLoaded` and
// `browsingContext.load` events from the initial `about:blank` navigation
// are emitted after the next navigation is started.
// Details: https://github.com/web-platform-tests/wpt/issues/35846
await context.lifecycleLoaded();
return { context: context.id };
}
navigate(params) {
const context = this.#browsingContextStorage.getContext(params.context);
return context.navigate(params.url, params.wait ?? "none" /* BrowsingContext.ReadinessState.None */);
}
reload(params) {
const context = this.#browsingContextStorage.getContext(params.context);
return context.reload(params.ignoreCache ?? false, params.wait ?? "none" /* BrowsingContext.ReadinessState.None */);
}
async activate(params) {
const context = this.#browsingContextStorage.getContext(params.context);
if (!context.isTopLevelContext()) {
throw new InvalidArgumentException('Activation is only supported on the top-level context');
}
await context.activate();
return {};
}
async captureScreenshot(params) {
const context = this.#browsingContextStorage.getContext(params.context);
return await context.captureScreenshot(params);
}
async print(params) {
const context = this.#browsingContextStorage.getContext(params.context);
return await context.print(params);
}
async setViewport(params) {
// Check the The viewport size limits is not checked by protocol parser, so we need to validate
// it manually:
// https://crsrc.org/c/content/browser/devtools/protocol/emulation_handler.cc;drc=f49e23d8e2bd190b42ec62284b8be10dcccd0446;l=660
const maxDimensionSize = 10_000_000;
if ((params.viewport?.height ?? 0) > maxDimensionSize ||
(params.viewport?.width ?? 0) > maxDimensionSize) {
throw new UnsupportedOperationException(`Viewport dimension over ${maxDimensionSize} are not supported`);
}
const config = {};
// `undefined` means no changes should be done to the config.
if (params.devicePixelRatio !== undefined) {
config.devicePixelRatio = params.devicePixelRatio;
}
if (params.viewport !== undefined) {
config.viewport = params.viewport;
}
const impactedTopLevelContexts = await this.#getRelatedTopLevelBrowsingContexts(params.context, params.userContexts);
for (const userContextId of params.userContexts ?? []) {
this.#contextConfigStorage.updateUserContextConfig(userContextId, config);
}
if (params.context !== undefined) {
this.#contextConfigStorage.updateBrowsingContextConfig(params.context, config);
}
await Promise.all(impactedTopLevelContexts.map(async (context) => {
const config = this.#contextConfigStorage.getActiveConfig(context.id, context.userContext);
await context.setViewport(config.viewport ?? null, config.devicePixelRatio ?? null, config.screenOrientation ?? null);
}));
return {};
}
/**
* Returns a list of top-level browsing context ids.
*/
async #getRelatedTopLevelBrowsingContexts(browsingContextId, userContextIds) {
if (browsingContextId === undefined && userContextIds === undefined) {
throw new InvalidArgumentException('Either userContexts or context must be provided');
}
if (browsingContextId !== undefined && userContextIds !== undefined) {
throw new InvalidArgumentException('userContexts and context are mutually exclusive');
}
if (browsingContextId !== undefined) {
const context = this.#browsingContextStorage.getContext(browsingContextId);
if (!context.isTopLevelContext()) {
throw new InvalidArgumentException('Emulating viewport is only supported on the top-level context');
}
return [context];
}
// Verify that all user contexts exist.
await this.#userContextStorage.verifyUserContextIdList(userContextIds);
const result = [];
for (const userContextId of userContextIds) {
const topLevelBrowsingContexts = this.#browsingContextStorage
.getTopLevelContexts()
.filter((browsingContext) => browsingContext.userContext === userContextId);
result.push(...topLevelBrowsingContexts);
}
// Remove duplicates. Compare `BrowsingContextImpl` by reference is correct here, as
// `browsingContextStorage` returns the same instance for the same id.
return [...new Set(result).values()];
}
async traverseHistory(params) {
const context = this.#browsingContextStorage.getContext(params.context);
if (!context) {
throw new InvalidArgumentException(`No browsing context with id ${params.context}`);
}
if (!context.isTopLevelContext()) {
throw new InvalidArgumentException('Traversing history is only supported on the top-level context');
}
await context.traverseHistory(params.delta);
return {};
}
async handleUserPrompt(params) {
const context = this.#browsingContextStorage.getContext(params.context);
try {
await context.handleUserPrompt(params.accept, params.userText);
}
catch (error) {
// Heuristically determine the error
// https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/protocol/page_handler.cc;l=1085?q=%22No%20dialog%20is%20showing%22&ss=chromium
if (error.message?.includes('No dialog is showing')) {
throw new NoSuchAlertException('No dialog is showing');
}
throw error;
}
return {};
}
async close(params) {
const context = this.#browsingContextStorage.getContext(params.context);
if (!context.isTopLevelContext()) {
throw new InvalidArgumentException(`Non top-level browsing context ${context.id} cannot be closed.`);
}
// Parent session of a page target session can be a `browser` or a `tab` session.
const parentCdpClient = context.cdpTarget.parentCdpClient;
try {
const detachedFromTargetPromise = new Promise((resolve) => {
const onContextDestroyed = (event) => {
if (event.targetId === params.context) {
parentCdpClient.off('Target.detachedFromTarget', onContextDestroyed);
resolve();
}
};
parentCdpClient.on('Target.detachedFromTarget', onContextDestroyed);
});
try {
if (params.promptUnload) {
await context.close();
}
else {
await parentCdpClient.sendCommand('Target.closeTarget', {
targetId: params.context,
});
}
}
catch (error) {
// Swallow error that arise from the session being destroyed. Rely on the
// `detachedFromTargetPromise` event to be resolved.
if (!parentCdpClient.isCloseError(error)) {
throw error;
}
}
// Sometimes CDP command finishes before `detachedFromTarget` event,
// sometimes after. Wait for the CDP command to be finished, and then wait
// for `detachedFromTarget` if it hasn't emitted.
await detachedFromTargetPromise;
}
catch (error) {
// Swallow error that arise from the page being destroyed
// Example is navigating to faulty SSL certificate
if (!(error.code === -32000 /* CdpErrorConstants.GENERIC_ERROR */ &&
error.message === 'Not attached to an active page')) {
throw error;
}
}
return {};
}
async locateNodes(params) {
const context = this.#browsingContextStorage.getContext(params.context);
return await context.locateNodes(params);
}
#onContextCreatedSubscribeHook(contextId) {
const context = this.#browsingContextStorage.getContext(contextId);
const contextsToReport = [
context,
...this.#browsingContextStorage.getContext(contextId).allChildren,
];
contextsToReport.forEach((context) => {
this.#eventManager.registerEvent({
type: 'event',
method: ChromiumBidi.BrowsingContext.EventNames.ContextCreated,
params: context.serializeToBidiValue(),
}, context.id);
});
return Promise.resolve();
}
}
//# sourceMappingURL=BrowsingContextProcessor.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
/**
* 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.
*/
import { type BrowsingContext } from '../../../protocol/protocol.js';
import type { BrowsingContextImpl } from './BrowsingContextImpl.js';
/** Container class for browsing contexts. */
export declare class BrowsingContextStorage {
#private;
/** Gets all top-level contexts, i.e. those with no parent. */
getTopLevelContexts(): BrowsingContextImpl[];
/** Gets all contexts. */
getAllContexts(): BrowsingContextImpl[];
/** Deletes the context with the given ID. */
deleteContextById(id: BrowsingContext.BrowsingContext): void;
/** Deletes the given context. */
deleteContext(context: BrowsingContextImpl): void;
/** Tracks the given context. */
addContext(context: BrowsingContextImpl): void;
/**
* Waits for a context with the given ID to be added and returns it.
*/
waitForContext(browsingContextId: BrowsingContext.BrowsingContext): Promise<BrowsingContextImpl>;
/** Returns true whether there is an existing context with the given ID. */
hasContext(id: BrowsingContext.BrowsingContext): boolean;
/** Gets the context with the given ID, if any. */
findContext(id: BrowsingContext.BrowsingContext): BrowsingContextImpl | undefined;
/** Returns the top-level context ID of the given context, if any. */
findTopLevelContextId(id: BrowsingContext.BrowsingContext | null): BrowsingContext.BrowsingContext | null;
findContextBySession(sessionId: string): BrowsingContextImpl | undefined;
/** Gets the context with the given ID, if any, otherwise throws. */
getContext(id: BrowsingContext.BrowsingContext): BrowsingContextImpl;
verifyTopLevelContextsList(contexts: BrowsingContext.BrowsingContext[] | undefined): Set<BrowsingContextImpl>;
verifyContextsList(contexts: BrowsingContext.BrowsingContext[]): void;
}
@@ -0,0 +1,130 @@
/**
* 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.
*/
import { NoSuchFrameException, InvalidArgumentException, } from '../../../protocol/protocol.js';
import { EventEmitter } from '../../../utils/EventEmitter.js';
/** Container class for browsing contexts. */
export class BrowsingContextStorage {
/** Map from context ID to context implementation. */
#contexts = new Map();
/** Event emitter for browsing context storage eventsis not expected to be exposed to
* the outside world. */
#eventEmitter = new EventEmitter();
/** Gets all top-level contexts, i.e. those with no parent. */
getTopLevelContexts() {
return this.getAllContexts().filter((context) => context.isTopLevelContext());
}
/** Gets all contexts. */
getAllContexts() {
return Array.from(this.#contexts.values());
}
/** Deletes the context with the given ID. */
deleteContextById(id) {
this.#contexts.delete(id);
}
/** Deletes the given context. */
deleteContext(context) {
this.#contexts.delete(context.id);
}
/** Tracks the given context. */
addContext(context) {
this.#contexts.set(context.id, context);
this.#eventEmitter.emit("added" /* BrowsingContextStorageEvents.Added */, {
browsingContext: context,
});
}
/**
* Waits for a context with the given ID to be added and returns it.
*/
waitForContext(browsingContextId) {
if (this.#contexts.has(browsingContextId)) {
return Promise.resolve(this.getContext(browsingContextId));
}
return new Promise((resolve) => {
const listener = (event) => {
if (event.browsingContext.id === browsingContextId) {
this.#eventEmitter.off("added" /* BrowsingContextStorageEvents.Added */, listener);
resolve(event.browsingContext);
}
};
this.#eventEmitter.on("added" /* BrowsingContextStorageEvents.Added */, listener);
});
}
/** Returns true whether there is an existing context with the given ID. */
hasContext(id) {
return this.#contexts.has(id);
}
/** Gets the context with the given ID, if any. */
findContext(id) {
return this.#contexts.get(id);
}
/** Returns the top-level context ID of the given context, if any. */
findTopLevelContextId(id) {
if (id === null) {
return null;
}
const maybeContext = this.findContext(id);
if (!maybeContext) {
return null;
}
const parentId = maybeContext.parentId ?? null;
if (parentId === null) {
return id;
}
return this.findTopLevelContextId(parentId);
}
findContextBySession(sessionId) {
for (const context of this.#contexts.values()) {
if (context.cdpTarget.cdpSessionId === sessionId) {
return context;
}
}
return;
}
/** Gets the context with the given ID, if any, otherwise throws. */
getContext(id) {
const result = this.findContext(id);
if (result === undefined) {
throw new NoSuchFrameException(`Context ${id} not found`);
}
return result;
}
verifyTopLevelContextsList(contexts) {
const foundContexts = new Set();
if (!contexts) {
return foundContexts;
}
for (const contextId of contexts) {
const context = this.getContext(contextId);
if (context.isTopLevelContext()) {
foundContexts.add(context);
}
else {
throw new InvalidArgumentException(`Non top-level context '${contextId}' given.`);
}
}
return foundContexts;
}
verifyContextsList(contexts) {
if (!contexts.length) {
return;
}
for (const contextId of contexts) {
this.getContext(contextId);
}
}
}
//# sourceMappingURL=BrowsingContextStorage.js.map
@@ -0,0 +1 @@
{"version":3,"file":"BrowsingContextStorage.js","sourceRoot":"","sources":["../../../../src/bidiMapper/modules/context/BrowsingContextStorage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EACL,oBAAoB,EAEpB,wBAAwB,GACzB,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAC,YAAY,EAAC,MAAM,gCAAgC,CAAC;AAY5D,6CAA6C;AAC7C,MAAM,OAAO,sBAAsB;IACjC,qDAAqD;IAC5C,SAAS,GAAG,IAAI,GAAG,EAGzB,CAAC;IACJ;4BACwB;IACf,aAAa,GAAG,IAAI,YAAY,EAA+B,CAAC;IAEzE,8DAA8D;IAC9D,mBAAmB;QACjB,OAAO,IAAI,CAAC,cAAc,EAAE,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAC9C,OAAO,CAAC,iBAAiB,EAAE,CAC5B,CAAC;IACJ,CAAC;IAED,yBAAyB;IACzB,cAAc;QACZ,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;IAC7C,CAAC;IAED,6CAA6C;IAC7C,iBAAiB,CAAC,EAAmC;QACnD,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IAC5B,CAAC;IAED,iCAAiC;IACjC,aAAa,CAAC,OAA4B;QACxC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IACpC,CAAC;IAED,gCAAgC;IAChC,UAAU,CAAC,OAA4B;QACrC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;QACxC,IAAI,CAAC,aAAa,CAAC,IAAI,mDAAqC;YAC1D,eAAe,EAAE,OAAO;SACzB,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACH,cAAc,CACZ,iBAAkD;QAElD,IAAI,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,iBAAiB,CAAC,EAAE,CAAC;YAC1C,OAAO,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC,CAAC;QAC7D,CAAC;QAED,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;YAC7B,MAAM,QAAQ,GAAG,CAAC,KAA6C,EAAE,EAAE;gBACjE,IAAI,KAAK,CAAC,eAAe,CAAC,EAAE,KAAK,iBAAiB,EAAE,CAAC;oBACnD,IAAI,CAAC,aAAa,CAAC,GAAG,mDAAqC,QAAQ,CAAC,CAAC;oBACrE,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;gBACjC,CAAC;YACH,CAAC,CAAC;YACF,IAAI,CAAC,aAAa,CAAC,EAAE,mDAAqC,QAAQ,CAAC,CAAC;QACtE,CAAC,CAAC,CAAC;IACL,CAAC;IAED,2EAA2E;IAC3E,UAAU,CAAC,EAAmC;QAC5C,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAChC,CAAC;IAED,kDAAkD;IAClD,WAAW,CACT,EAAmC;QAEnC,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAChC,CAAC;IAED,qEAAqE;IACrE,qBAAqB,CACnB,EAA0C;QAE1C,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;YAChB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,YAAY,GAAG,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;QAC1C,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,MAAM,QAAQ,GAAG,YAAY,CAAC,QAAQ,IAAI,IAAI,CAAC;QAC/C,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;YACtB,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,OAAO,IAAI,CAAC,qBAAqB,CAAC,QAAQ,CAAC,CAAC;IAC9C,CAAC;IAED,oBAAoB,CAAC,SAAiB;QACpC,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC;YAC9C,IAAI,OAAO,CAAC,SAAS,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;gBACjD,OAAO,OAAO,CAAC;YACjB,CAAC;QACH,CAAC;QACD,OAAO;IACT,CAAC;IAED,oEAAoE;IACpE,UAAU,CAAC,EAAmC;QAC5C,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;QACpC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,IAAI,oBAAoB,CAAC,WAAW,EAAE,YAAY,CAAC,CAAC;QAC5D,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,0BAA0B,CACxB,QAAuD;QAEvD,MAAM,aAAa,GAAG,IAAI,GAAG,EAAuB,CAAC;QACrD,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,aAAa,CAAC;QACvB,CAAC;QAED,KAAK,MAAM,SAAS,IAAI,QAAQ,EAAE,CAAC;YACjC,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAC3C,IAAI,OAAO,CAAC,iBAAiB,EAAE,EAAE,CAAC;gBAChC,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YAC7B,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,wBAAwB,CAChC,0BAA0B,SAAS,UAAU,CAC9C,CAAC;YACJ,CAAC;QACH,CAAC;QACD,OAAO,aAAa,CAAC;IACvB,CAAC;IAED,kBAAkB,CAAC,QAA2C;QAC5D,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;YACrB,OAAO;QACT,CAAC;QAED,KAAK,MAAM,SAAS,IAAI,QAAQ,EAAE,CAAC;YACjC,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,87 @@
import type { Protocol } from 'devtools-protocol';
import { type BrowsingContext } from '../../../protocol/protocol.js';
import { Deferred } from '../../../utils/Deferred.js';
import { type LoggerFn } from '../../../utils/log.js';
import type { EventManager } from '../session/EventManager.js';
export declare const enum NavigationEventName {
FragmentNavigated = "browsingContext.fragmentNavigated",
NavigationAborted = "browsingContext.navigationAborted",
NavigationFailed = "browsingContext.navigationFailed",
Load = "browsingContext.load"
}
export declare class NavigationResult {
readonly eventName: NavigationEventName;
readonly message?: string;
constructor(eventName: NavigationEventName, message?: string);
}
export declare class NavigationState {
#private;
readonly navigationId: `${string}-${string}-${string}-${string}-${string}`;
url: string;
loaderId?: string;
committed: Deferred<void>;
isFragmentNavigation?: boolean;
get finished(): Promise<NavigationResult>;
constructor(url: string, browsingContextId: string, isInitial: boolean, eventManager: EventManager);
navigationInfo(): BrowsingContext.NavigationInfo;
start(): void;
frameNavigated(): void;
fragmentNavigated(): void;
load(): void;
fail(message: string): void;
}
/**
* Keeps track of navigations. Details: http://go/webdriver:bidi-navigation
*/
export declare class NavigationTracker {
#private;
constructor(url: string, browsingContextId: string, eventManager: EventManager, logger?: LoggerFn);
/**
* Returns current started ongoing navigation. It can be either a started pending
* navigation, or one is already navigated.
*/
get currentNavigationId(): `${string}-${string}-${string}-${string}-${string}`;
/**
* Flags if the current navigation relates to the initial to `about:blank` navigation.
*/
get isInitialNavigation(): boolean;
/**
* Url of the last navigated navigation.
*/
get url(): string;
/**
* Creates a pending navigation e.g. when navigation command is called. Required to
* provide navigation id before the actual navigation is started. It will be used when
* navigation started. Can be aborted, failed, fragment navigated, or became a current
* navigation.
*/
createPendingNavigation(url: string, canBeInitialNavigation?: boolean): NavigationState;
dispose(): void;
onTargetInfoChanged(url: string): void;
/**
* @param {string} unreachableUrl indicated the navigation is actually failed.
*/
frameNavigated(url: string, loaderId: string, unreachableUrl?: string): void;
navigatedWithinDocument(url: string, navigationType: Protocol.Page.NavigatedWithinDocumentEvent['navigationType']): void;
/**
* Required to mark navigation as fully complete.
* TODO: navigation should be complete when it became the current one on
* `Page.frameNavigated` or on navigating command finished with a new loader Id.
*/
loadPageEvent(loaderId: string): void;
/**
* Fail navigation due to navigation command failed.
*/
failNavigation(navigation: NavigationState, errorText: string): void;
/**
* Updates the navigation's `loaderId` and sets it as current one, if it is a
* cross-document navigation.
*/
navigationCommandFinished(navigation: NavigationState, loaderId?: string): void;
frameStartedNavigating(url: string, loaderId: string, navigationType: string): void;
/**
* If there is a navigation with the loaderId equals to the network request id, it means
* that the navigation failed.
*/
networkLoadingFailed(loaderId: string, errorText: string): void;
}
@@ -0,0 +1,325 @@
/*
* Copyright 2024 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.
*
*/
import { ChromiumBidi, } from '../../../protocol/protocol.js';
import { Deferred } from '../../../utils/Deferred.js';
import { LogType } from '../../../utils/log.js';
import { getTimestamp } from '../../../utils/time.js';
import { urlMatchesAboutBlank } from '../../../utils/urlHelpers.js';
import { uuidv4 } from '../../../utils/uuid.js';
export class NavigationResult {
eventName;
message;
constructor(eventName, message) {
this.eventName = eventName;
this.message = message;
}
}
export class NavigationState {
navigationId = uuidv4();
#browsingContextId;
#started = false;
#finished = new Deferred();
url;
loaderId;
#isInitial;
#eventManager;
committed = new Deferred();
isFragmentNavigation;
get finished() {
return this.#finished;
}
constructor(url, browsingContextId, isInitial, eventManager) {
this.#browsingContextId = browsingContextId;
this.url = url;
this.#isInitial = isInitial;
this.#eventManager = eventManager;
}
navigationInfo() {
return {
context: this.#browsingContextId,
navigation: this.navigationId,
timestamp: getTimestamp(),
url: this.url,
};
}
start() {
if (
// Initial navigation should not be reported.
!this.#isInitial &&
// No need in reporting started navigation twice.
!this.#started &&
// No need for reporting fragment navigations. Step 13 vs step 16 of the spec:
// https://html.spec.whatwg.org/#beginning-navigation:webdriver-bidi-navigation-started
!this.isFragmentNavigation) {
this.#eventManager.registerEvent({
type: 'event',
method: ChromiumBidi.BrowsingContext.EventNames.NavigationStarted,
params: this.navigationInfo(),
}, this.#browsingContextId);
}
this.#started = true;
}
#finish(navigationResult) {
this.#started = true;
if (!this.#isInitial &&
!this.#finished.isFinished &&
navigationResult.eventName !== "browsingContext.load" /* NavigationEventName.Load */) {
this.#eventManager.registerEvent({
type: 'event',
method: navigationResult.eventName,
params: this.navigationInfo(),
}, this.#browsingContextId);
}
this.#finished.resolve(navigationResult);
}
frameNavigated() {
this.committed.resolve();
if (!this.#isInitial) {
this.#eventManager.registerEvent({
type: 'event',
method: ChromiumBidi.BrowsingContext.EventNames.NavigationCommitted,
params: this.navigationInfo(),
}, this.#browsingContextId);
}
}
fragmentNavigated() {
this.committed.resolve();
this.#finish(new NavigationResult("browsingContext.fragmentNavigated" /* NavigationEventName.FragmentNavigated */));
}
load() {
this.#finish(new NavigationResult("browsingContext.load" /* NavigationEventName.Load */));
}
fail(message) {
this.#finish(new NavigationResult(this.committed.isFinished
? "browsingContext.navigationAborted" /* NavigationEventName.NavigationAborted */
: "browsingContext.navigationFailed" /* NavigationEventName.NavigationFailed */, message));
}
}
/**
* Keeps track of navigations. Details: http://go/webdriver:bidi-navigation
*/
export class NavigationTracker {
#eventManager;
#logger;
#loaderIdToNavigationsMap = new Map();
#browsingContextId;
/**
* Last committed navigation is committed, but is not guaranteed to be finished, as it
* can still wait for `load` or `DOMContentLoaded` events.
*/
#lastCommittedNavigation;
/**
* Pending navigation is a navigation that is started but not yet committed.
*/
#pendingNavigation;
// Flags if the initial navigation to `about:blank` is in progress.
#isInitialNavigation = true;
constructor(url, browsingContextId, eventManager, logger) {
this.#browsingContextId = browsingContextId;
this.#eventManager = eventManager;
this.#logger = logger;
this.#isInitialNavigation = true;
// The initial navigation is always committed.
this.#lastCommittedNavigation = new NavigationState(url, browsingContextId, urlMatchesAboutBlank(url), this.#eventManager);
}
/**
* Returns current started ongoing navigation. It can be either a started pending
* navigation, or one is already navigated.
*/
get currentNavigationId() {
if (this.#pendingNavigation?.isFragmentNavigation === false) {
// Use pending navigation if it is started and it is not a fragment navigation.
return this.#pendingNavigation.navigationId;
}
// If the pending navigation is a fragment one, or if it is not exists, the last
// committed navigation should be used.
return this.#lastCommittedNavigation.navigationId;
}
/**
* Flags if the current navigation relates to the initial to `about:blank` navigation.
*/
get isInitialNavigation() {
return this.#isInitialNavigation;
}
/**
* Url of the last navigated navigation.
*/
get url() {
return this.#lastCommittedNavigation.url;
}
/**
* Creates a pending navigation e.g. when navigation command is called. Required to
* provide navigation id before the actual navigation is started. It will be used when
* navigation started. Can be aborted, failed, fragment navigated, or became a current
* navigation.
*/
createPendingNavigation(url, canBeInitialNavigation = false) {
this.#logger?.(LogType.debug, 'createCommandNavigation');
this.#isInitialNavigation =
canBeInitialNavigation &&
this.#isInitialNavigation &&
urlMatchesAboutBlank(url);
this.#pendingNavigation?.fail('navigation canceled by concurrent navigation');
const navigation = new NavigationState(url, this.#browsingContextId, this.#isInitialNavigation, this.#eventManager);
this.#pendingNavigation = navigation;
return navigation;
}
dispose() {
this.#pendingNavigation?.fail('navigation canceled by context disposal');
this.#lastCommittedNavigation.fail('navigation canceled by context disposal');
}
// Update the current url.
onTargetInfoChanged(url) {
this.#logger?.(LogType.debug, `onTargetInfoChanged ${url}`);
this.#lastCommittedNavigation.url = url;
}
#getNavigationForFrameNavigated(url, loaderId) {
if (this.#loaderIdToNavigationsMap.has(loaderId)) {
return this.#loaderIdToNavigationsMap.get(loaderId);
}
if (this.#pendingNavigation !== undefined &&
this.#pendingNavigation.loaderId === undefined) {
// This can be a pending navigation to `about:blank` created by a command. Use the
// pending navigation in this case.
return this.#pendingNavigation;
}
// Create a new pending navigation.
return this.createPendingNavigation(url, true);
}
/**
* @param {string} unreachableUrl indicated the navigation is actually failed.
*/
frameNavigated(url, loaderId, unreachableUrl) {
this.#logger?.(LogType.debug, `frameNavigated ${url}`);
if (unreachableUrl !== undefined) {
// The navigation failed.
const navigation = this.#loaderIdToNavigationsMap.get(loaderId) ??
this.#pendingNavigation ??
this.createPendingNavigation(unreachableUrl, true);
navigation.url = unreachableUrl;
navigation.start();
navigation.fail('the requested url is unreachable');
return;
}
const navigation = this.#getNavigationForFrameNavigated(url, loaderId);
if (navigation !== this.#lastCommittedNavigation) {
// Even though the `lastCommittedNavigation` is navigated, it still can be waiting
// for `load` or `DOMContentLoaded` events.
this.#lastCommittedNavigation.fail('navigation canceled by concurrent navigation');
}
navigation.url = url;
navigation.loaderId = loaderId;
this.#loaderIdToNavigationsMap.set(loaderId, navigation);
navigation.start();
navigation.frameNavigated();
this.#lastCommittedNavigation = navigation;
if (this.#pendingNavigation === navigation) {
this.#pendingNavigation = undefined;
}
}
navigatedWithinDocument(url, navigationType) {
this.#logger?.(LogType.debug, `navigatedWithinDocument ${url}, ${navigationType}`);
// Current navigation URL should be updated.
this.#lastCommittedNavigation.url = url;
if (navigationType !== 'fragment') {
// TODO: check for other navigation types, like `javascript`.
return;
}
// There is no way to map `navigatedWithinDocument` to a specific navigation. Consider
// it is the pending navigation, if it is a fragment one.
const fragmentNavigation = this.#pendingNavigation?.isFragmentNavigation === true
? this.#pendingNavigation
: new NavigationState(url, this.#browsingContextId, false, this.#eventManager);
// Finish ongoing navigation.
fragmentNavigation.fragmentNavigated();
if (fragmentNavigation === this.#pendingNavigation) {
this.#pendingNavigation = undefined;
}
}
/**
* Required to mark navigation as fully complete.
* TODO: navigation should be complete when it became the current one on
* `Page.frameNavigated` or on navigating command finished with a new loader Id.
*/
loadPageEvent(loaderId) {
this.#logger?.(LogType.debug, 'loadPageEvent');
// Even if it was an initial navigation, it is finished.
this.#isInitialNavigation = false;
this.#loaderIdToNavigationsMap.get(loaderId)?.load();
}
/**
* Fail navigation due to navigation command failed.
*/
failNavigation(navigation, errorText) {
this.#logger?.(LogType.debug, 'failCommandNavigation');
navigation.fail(errorText);
}
/**
* Updates the navigation's `loaderId` and sets it as current one, if it is a
* cross-document navigation.
*/
navigationCommandFinished(navigation, loaderId) {
this.#logger?.(LogType.debug, `finishCommandNavigation ${navigation.navigationId}, ${loaderId}`);
if (loaderId !== undefined) {
navigation.loaderId = loaderId;
this.#loaderIdToNavigationsMap.set(loaderId, navigation);
}
navigation.isFragmentNavigation = loaderId === undefined;
}
frameStartedNavigating(url, loaderId, navigationType) {
this.#logger?.(LogType.debug, `frameStartedNavigating ${url}, ${loaderId}`);
if (this.#pendingNavigation &&
this.#pendingNavigation?.loaderId !== undefined &&
this.#pendingNavigation?.loaderId !== loaderId) {
// If there is a pending navigation with loader id set, but not equal to the new
// loader id, cancel pending navigation.
this.#pendingNavigation?.fail('navigation canceled by concurrent navigation');
this.#pendingNavigation = undefined;
}
if (this.#loaderIdToNavigationsMap.has(loaderId)) {
const existingNavigation = this.#loaderIdToNavigationsMap.get(loaderId);
// Navigation can be changed from `sameDocument` to `differentDocument`.
existingNavigation.isFragmentNavigation =
NavigationTracker.#isFragmentNavigation(navigationType);
this.#pendingNavigation = existingNavigation;
return;
}
const pendingNavigation = this.#pendingNavigation ?? this.createPendingNavigation(url, true);
this.#loaderIdToNavigationsMap.set(loaderId, pendingNavigation);
pendingNavigation.isFragmentNavigation =
NavigationTracker.#isFragmentNavigation(navigationType);
pendingNavigation.url = url;
pendingNavigation.loaderId = loaderId;
pendingNavigation.start();
}
static #isFragmentNavigation(navigationType) {
// Page.frameStartedNavigating.navigationType can be one of the following values:
// reload, reloadBypassingCache, restore, restoreWithPost, historySameDocument,
// historyDifferentDocument, sameDocument, differentDocument.
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-frameStartedNavigating
return ['historySameDocument', 'sameDocument'].includes(navigationType);
}
/**
* If there is a navigation with the loaderId equals to the network request id, it means
* that the navigation failed.
*/
networkLoadingFailed(loaderId, errorText) {
this.#loaderIdToNavigationsMap.get(loaderId)?.fail(errorText);
}
}
//# sourceMappingURL=NavigationTracker.js.map
File diff suppressed because one or more lines are too long