summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts')
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts669
1 files changed, 472 insertions, 197 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts
index 1638c2cbdf..f2bfd5f64e 100644
--- a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts
@@ -6,15 +6,18 @@
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+import type {Observable} from '../../third_party/rxjs/rxjs.js';
import {
+ combineLatest,
+ defer,
+ delayWhen,
+ filter,
first,
firstValueFrom,
- forkJoin,
- from,
map,
- merge,
+ of,
raceWith,
- zip,
+ switchMap,
} from '../../third_party/rxjs/rxjs.js';
import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
@@ -25,85 +28,228 @@ import {
type WaitForOptions,
} from '../api/Frame.js';
import type {WaitForSelectorOptions} from '../api/Page.js';
-import {UnsupportedOperation} from '../common/Errors.js';
+import {PageEvent} from '../api/Page.js';
+import {
+ ConsoleMessage,
+ type ConsoleMessageLocation,
+} from '../common/ConsoleMessage.js';
+import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {Awaitable, NodeFor} from '../common/types.js';
-import {
- fromEmitterEvent,
- NETWORK_IDLE_TIME,
- timeout,
- UTILITY_WORLD_NAME,
-} from '../common/util.js';
-import {Deferred} from '../util/Deferred.js';
-import {disposeSymbol} from '../util/disposable.js';
-
-import type {BrowsingContext} from './BrowsingContext.js';
+import {debugError, fromEmitterEvent, timeout} from '../common/util.js';
+
+import {BidiCdpSession} from './CDPSession.js';
+import type {BrowsingContext} from './core/BrowsingContext.js';
+import type {Navigation} from './core/Navigation.js';
+import type {Request} from './core/Request.js';
+import {BidiDeserializer} from './Deserializer.js';
+import {BidiDialog} from './Dialog.js';
+import type {BidiElementHandle} from './ElementHandle.js';
import {ExposeableFunction} from './ExposedFunction.js';
+import {BidiHTTPRequest, requests} from './HTTPRequest.js';
import type {BidiHTTPResponse} from './HTTPResponse.js';
-import {
- getBiDiLifecycleEvent,
- getBiDiReadinessState,
- rewriteNavigationError,
-} from './lifecycle.js';
+import {BidiJSHandle} from './JSHandle.js';
import type {BidiPage} from './Page.js';
-import {
- MAIN_SANDBOX,
- PUPPETEER_SANDBOX,
- Sandbox,
- type SandboxChart,
-} from './Sandbox.js';
+import type {BidiRealm} from './Realm.js';
+import {BidiFrameRealm} from './Realm.js';
+import {rewriteNavigationError} from './util.js';
+import {BidiWebWorker} from './WebWorker.js';
-/**
- * Puppeteer's Frame class could be viewed as a BiDi BrowsingContext implementation
- * @internal
- */
export class BidiFrame extends Frame {
- #page: BidiPage;
- #context: BrowsingContext;
- #timeoutSettings: TimeoutSettings;
- #abortDeferred = Deferred.create<never>();
- #disposed = false;
- sandboxes: SandboxChart;
- override _id: string;
-
- constructor(
- page: BidiPage,
- context: BrowsingContext,
- timeoutSettings: TimeoutSettings,
- parentId?: string | null
+ static from(
+ parent: BidiPage | BidiFrame,
+ browsingContext: BrowsingContext
+ ): BidiFrame {
+ const frame = new BidiFrame(parent, browsingContext);
+ frame.#initialize();
+ return frame;
+ }
+
+ readonly #parent: BidiPage | BidiFrame;
+ readonly browsingContext: BrowsingContext;
+ readonly #frames = new WeakMap<BrowsingContext, BidiFrame>();
+ readonly realms: {default: BidiFrameRealm; internal: BidiFrameRealm};
+
+ override readonly _id: string;
+ override readonly client: BidiCdpSession;
+
+ private constructor(
+ parent: BidiPage | BidiFrame,
+ browsingContext: BrowsingContext
) {
super();
- this.#page = page;
- this.#context = context;
- this.#timeoutSettings = timeoutSettings;
- this._id = this.#context.id;
- this._parentId = parentId ?? undefined;
-
- this.sandboxes = {
- [MAIN_SANDBOX]: new Sandbox(undefined, this, context, timeoutSettings),
- [PUPPETEER_SANDBOX]: new Sandbox(
- UTILITY_WORLD_NAME,
- this,
- context.createRealmForSandbox(),
- timeoutSettings
+ this.#parent = parent;
+ this.browsingContext = browsingContext;
+
+ this._id = browsingContext.id;
+ this.client = new BidiCdpSession(this);
+ this.realms = {
+ default: BidiFrameRealm.from(this.browsingContext.defaultRealm, this),
+ internal: BidiFrameRealm.from(
+ this.browsingContext.createWindowRealm(
+ `__puppeteer_internal_${Math.ceil(Math.random() * 10000)}`
+ ),
+ this
),
};
}
- override get client(): CDPSession {
- return this.context().cdpSession;
+ #initialize(): void {
+ for (const browsingContext of this.browsingContext.children) {
+ this.#createFrameTarget(browsingContext);
+ }
+
+ this.browsingContext.on('browsingcontext', ({browsingContext}) => {
+ this.#createFrameTarget(browsingContext);
+ });
+ this.browsingContext.on('closed', () => {
+ for (const session of BidiCdpSession.sessions.values()) {
+ if (session.frame === this) {
+ void session.detach().catch(debugError);
+ }
+ }
+ this.page().trustedEmitter.emit(PageEvent.FrameDetached, this);
+ });
+
+ this.browsingContext.on('request', ({request}) => {
+ const httpRequest = BidiHTTPRequest.from(request, this);
+ request.once('success', () => {
+ // SAFETY: BidiHTTPRequest will create this before here.
+ this.page().trustedEmitter.emit(PageEvent.RequestFinished, httpRequest);
+ });
+
+ request.once('error', () => {
+ this.page().trustedEmitter.emit(PageEvent.RequestFailed, httpRequest);
+ });
+ });
+
+ this.browsingContext.on('navigation', ({navigation}) => {
+ navigation.once('fragment', () => {
+ this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this);
+ });
+ });
+ this.browsingContext.on('load', () => {
+ this.page().trustedEmitter.emit(PageEvent.Load, undefined);
+ });
+ this.browsingContext.on('DOMContentLoaded', () => {
+ this._hasStartedLoading = true;
+ this.page().trustedEmitter.emit(PageEvent.DOMContentLoaded, undefined);
+ this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this);
+ });
+
+ this.browsingContext.on('userprompt', ({userPrompt}) => {
+ this.page().trustedEmitter.emit(
+ PageEvent.Dialog,
+ BidiDialog.from(userPrompt)
+ );
+ });
+
+ this.browsingContext.on('log', ({entry}) => {
+ if (this._id !== entry.source.context) {
+ return;
+ }
+ if (isConsoleLogEntry(entry)) {
+ const args = entry.args.map(arg => {
+ return this.mainRealm().createHandle(arg);
+ });
+
+ const text = args
+ .reduce((value, arg) => {
+ const parsedValue =
+ arg instanceof BidiJSHandle && arg.isPrimitiveValue
+ ? BidiDeserializer.deserialize(arg.remoteValue())
+ : arg.toString();
+ return `${value} ${parsedValue}`;
+ }, '')
+ .slice(1);
+
+ this.page().trustedEmitter.emit(
+ PageEvent.Console,
+ new ConsoleMessage(
+ entry.method as any,
+ text,
+ args,
+ getStackTraceLocations(entry.stackTrace)
+ )
+ );
+ } else if (isJavaScriptLogEntry(entry)) {
+ const error = new Error(entry.text ?? '');
+
+ const messageHeight = error.message.split('\n').length;
+ const messageLines = error.stack!.split('\n').splice(0, messageHeight);
+
+ const stackLines = [];
+ if (entry.stackTrace) {
+ for (const frame of entry.stackTrace.callFrames) {
+ // Note we need to add `1` because the values are 0-indexed.
+ stackLines.push(
+ ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
+ frame.lineNumber + 1
+ }:${frame.columnNumber + 1})`
+ );
+ if (stackLines.length >= Error.stackTraceLimit) {
+ break;
+ }
+ }
+ }
+
+ error.stack = [...messageLines, ...stackLines].join('\n');
+ this.page().trustedEmitter.emit(PageEvent.PageError, error);
+ } else {
+ debugError(
+ `Unhandled LogEntry with type "${entry.type}", text "${entry.text}" and level "${entry.level}"`
+ );
+ }
+ });
+
+ this.browsingContext.on('worker', ({realm}) => {
+ const worker = BidiWebWorker.from(this, realm);
+ realm.on('destroyed', () => {
+ this.page().trustedEmitter.emit(PageEvent.WorkerDestroyed, worker);
+ });
+ this.page().trustedEmitter.emit(PageEvent.WorkerCreated, worker);
+ });
+ }
+
+ #createFrameTarget(browsingContext: BrowsingContext) {
+ const frame = BidiFrame.from(this, browsingContext);
+ this.#frames.set(browsingContext, frame);
+ this.page().trustedEmitter.emit(PageEvent.FrameAttached, frame);
+
+ browsingContext.on('closed', () => {
+ this.#frames.delete(browsingContext);
+ });
+
+ return frame;
+ }
+
+ get timeoutSettings(): TimeoutSettings {
+ return this.page()._timeoutSettings;
}
- override mainRealm(): Sandbox {
- return this.sandboxes[MAIN_SANDBOX];
+ override mainRealm(): BidiFrameRealm {
+ return this.realms.default;
}
- override isolatedRealm(): Sandbox {
- return this.sandboxes[PUPPETEER_SANDBOX];
+ override isolatedRealm(): BidiFrameRealm {
+ return this.realms.internal;
+ }
+
+ realm(id: string): BidiRealm | undefined {
+ for (const realm of Object.values(this.realms)) {
+ if (realm.realm.id === id) {
+ return realm;
+ }
+ }
+ return;
}
override page(): BidiPage {
- return this.#page;
+ let parent = this.#parent;
+ while (parent instanceof BidiFrame) {
+ parent = parent.#parent;
+ }
+ return parent;
}
override isOOPFrame(): never {
@@ -111,15 +257,36 @@ export class BidiFrame extends Frame {
}
override url(): string {
- return this.#context.url;
+ return this.browsingContext.url;
}
override parentFrame(): BidiFrame | null {
- return this.#page.frame(this._parentId ?? '');
+ if (this.#parent instanceof BidiFrame) {
+ return this.#parent;
+ }
+ return null;
}
override childFrames(): BidiFrame[] {
- return this.#page.childFrames(this.#context.id);
+ return [...this.browsingContext.children].map(child => {
+ return this.#frames.get(child)!;
+ });
+ }
+
+ #detached$() {
+ return defer(() => {
+ if (this.detached) {
+ return of(this as Frame);
+ }
+ return fromEmitterEvent(
+ this.page().trustedEmitter,
+ PageEvent.FrameDetached
+ ).pipe(
+ filter(detachedFrame => {
+ return detachedFrame === this;
+ })
+ );
+ });
}
@throwIfDetached
@@ -127,40 +294,23 @@ export class BidiFrame extends Frame {
url: string,
options: GoToOptions = {}
): Promise<BidiHTTPResponse | null> {
- const {
- waitUntil = 'load',
- timeout: ms = this.#timeoutSettings.navigationTimeout(),
- } = options;
-
- const [readiness, networkIdle] = getBiDiReadinessState(waitUntil);
-
- const result$ = zip(
- from(
- this.#context.connection.send('browsingContext.navigate', {
- context: this.#context.id,
- url,
- wait: readiness,
- })
+ const [response] = await Promise.all([
+ this.waitForNavigation(options),
+ // Some implementations currently only report errors when the
+ // readiness=interactive.
+ //
+ // Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601
+ this.browsingContext.navigate(
+ url,
+ Bidi.BrowsingContext.ReadinessState.Interactive
),
- ...(networkIdle !== null
- ? [
- this.#page.waitForNetworkIdle$({
- timeout: ms,
- concurrency: networkIdle === 'networkidle2' ? 2 : 0,
- idleTime: NETWORK_IDLE_TIME,
- }),
- ]
- : [])
- ).pipe(
- map(([{result}]) => {
- return result;
- }),
- raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())),
- rewriteNavigationError(url, ms)
+ ]).catch(
+ rewriteNavigationError(
+ url,
+ options.timeout ?? this.timeoutSettings.navigationTimeout()
+ )
);
-
- const result = await firstValueFrom(result$);
- return this.#page.getNavigationResponse(result.navigation);
+ return response;
}
@throwIfDetached
@@ -168,95 +318,105 @@ export class BidiFrame extends Frame {
html: string,
options: WaitForOptions = {}
): Promise<void> {
- const {
- waitUntil = 'load',
- timeout: ms = this.#timeoutSettings.navigationTimeout(),
- } = options;
-
- const [waitEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil);
-
- const result$ = zip(
- forkJoin([
- fromEmitterEvent(this.#context, waitEvent).pipe(first()),
- from(this.setFrameContent(html)),
- ]).pipe(
- map(() => {
- return null;
- })
+ await Promise.all([
+ this.setFrameContent(html),
+ firstValueFrom(
+ combineLatest([
+ this.#waitForLoad$(options),
+ this.#waitForNetworkIdle$(options),
+ ])
),
- ...(networkIdle !== null
- ? [
- this.#page.waitForNetworkIdle$({
- timeout: ms,
- concurrency: networkIdle === 'networkidle2' ? 2 : 0,
- idleTime: NETWORK_IDLE_TIME,
- }),
- ]
- : [])
- ).pipe(
- raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())),
- rewriteNavigationError('setContent', ms)
- );
-
- await firstValueFrom(result$);
- }
-
- context(): BrowsingContext {
- return this.#context;
+ ]);
}
@throwIfDetached
override async waitForNavigation(
options: WaitForOptions = {}
): Promise<BidiHTTPResponse | null> {
- const {
- waitUntil = 'load',
- timeout: ms = this.#timeoutSettings.navigationTimeout(),
- } = options;
-
- const [waitUntilEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil);
-
- const navigation$ = merge(
- forkJoin([
- fromEmitterEvent(
- this.#context,
- Bidi.ChromiumBidi.BrowsingContext.EventNames.NavigationStarted
- ).pipe(first()),
- fromEmitterEvent(this.#context, waitUntilEvent).pipe(first()),
- ]),
- fromEmitterEvent(
- this.#context,
- Bidi.ChromiumBidi.BrowsingContext.EventNames.FragmentNavigated
+ const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options;
+
+ const frames = this.childFrames().map(frame => {
+ return frame.#detached$();
+ });
+ return await firstValueFrom(
+ combineLatest([
+ fromEmitterEvent(this.browsingContext, 'navigation').pipe(
+ switchMap(({navigation}) => {
+ return this.#waitForLoad$(options).pipe(
+ delayWhen(() => {
+ if (frames.length === 0) {
+ return of(undefined);
+ }
+ return combineLatest(frames);
+ }),
+ raceWith(
+ fromEmitterEvent(navigation, 'fragment'),
+ fromEmitterEvent(navigation, 'failed').pipe(
+ map(({url}) => {
+ throw new Error(`Navigation failed: ${url}`);
+ })
+ ),
+ fromEmitterEvent(navigation, 'aborted').pipe(
+ map(({url}) => {
+ throw new Error(`Navigation aborted: ${url}`);
+ })
+ )
+ ),
+ switchMap(() => {
+ if (navigation.request) {
+ function requestFinished$(
+ request: Request
+ ): Observable<Navigation> {
+ // Reduces flakiness if the response events arrive after
+ // the load event.
+ // Usually, the response or error is already there at this point.
+ if (request.response || request.error) {
+ return of(navigation);
+ }
+ if (request.redirect) {
+ return requestFinished$(request.redirect);
+ }
+ return fromEmitterEvent(request, 'success')
+ .pipe(
+ raceWith(fromEmitterEvent(request, 'error')),
+ raceWith(fromEmitterEvent(request, 'redirect'))
+ )
+ .pipe(
+ switchMap(() => {
+ return requestFinished$(request);
+ })
+ );
+ }
+ return requestFinished$(navigation.request);
+ }
+ return of(navigation);
+ })
+ );
+ })
+ ),
+ this.#waitForNetworkIdle$(options),
+ ]).pipe(
+ map(([navigation]) => {
+ const request = navigation.request;
+ if (!request) {
+ return null;
+ }
+ const httpRequest = requests.get(request)!;
+ const lastRedirect = httpRequest.redirectChain().at(-1);
+ return (
+ lastRedirect !== undefined ? lastRedirect : httpRequest
+ ).response();
+ }),
+ raceWith(
+ timeout(ms),
+ this.#detached$().pipe(
+ map(() => {
+ throw new TargetCloseError('Frame detached.');
+ })
+ )
+ )
)
- ).pipe(
- map(result => {
- if (Array.isArray(result)) {
- return {result: result[1]};
- }
- return {result};
- })
);
-
- const result$ = zip(
- navigation$,
- ...(networkIdle !== null
- ? [
- this.#page.waitForNetworkIdle$({
- timeout: ms,
- concurrency: networkIdle === 'networkidle2' ? 2 : 0,
- idleTime: NETWORK_IDLE_TIME,
- }),
- ]
- : [])
- ).pipe(
- map(([{result}]) => {
- return result;
- }),
- raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow()))
- );
-
- const result = await firstValueFrom(result$);
- return this.#page.getNavigationResponse(result.navigation);
}
override waitForDevicePrompt(): never {
@@ -264,18 +424,7 @@ export class BidiFrame extends Frame {
}
override get detached(): boolean {
- return this.#disposed;
- }
-
- [disposeSymbol](): void {
- if (this.#disposed) {
- return;
- }
- this.#disposed = true;
- this.#abortDeferred.reject(new Error('Frame detached'));
- this.#context.dispose();
- this.sandboxes[MAIN_SANDBOX][disposeSymbol]();
- this.sandboxes[PUPPETEER_SANDBOX][disposeSymbol]();
+ return this.browsingContext.closed;
}
#exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>();
@@ -288,21 +437,27 @@ export class BidiFrame extends Frame {
`Failed to add page binding with name ${name}: globalThis['${name}'] already exists!`
);
}
- const exposeable = new ExposeableFunction(this, name, apply);
+ const exposeable = await ExposeableFunction.from(this, name, apply);
this.#exposedFunctions.set(name, exposeable);
- try {
- await exposeable.expose();
- } catch (error) {
- this.#exposedFunctions.delete(name);
- throw error;
+ }
+
+ async removeExposedFunction(name: string): Promise<void> {
+ const exposedFunction = this.#exposedFunctions.get(name);
+ if (!exposedFunction) {
+ throw new Error(
+ `Failed to remove page binding with name ${name}: window['${name}'] does not exists!`
+ );
}
+
+ this.#exposedFunctions.delete(name);
+ await exposedFunction[Symbol.asyncDispose]();
}
override waitForSelector<Selector extends string>(
selector: Selector,
options?: WaitForSelectorOptions
): Promise<ElementHandle<NodeFor<Selector>> | null> {
- if (selector.startsWith('aria')) {
+ if (selector.startsWith('aria') && !this.page().browser().cdpSupported) {
throw new UnsupportedOperation(
'ARIA selector is not supported for BiDi!'
);
@@ -310,4 +465,124 @@ export class BidiFrame extends Frame {
return super.waitForSelector(selector, options);
}
+
+ async createCDPSession(): Promise<CDPSession> {
+ const {sessionId} = await this.client.send('Target.attachToTarget', {
+ targetId: this._id,
+ flatten: true,
+ });
+ return new BidiCdpSession(this, sessionId);
+ }
+
+ @throwIfDetached
+ #waitForLoad$(options: WaitForOptions = {}): Observable<void> {
+ let {waitUntil = 'load'} = options;
+ const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options;
+
+ if (!Array.isArray(waitUntil)) {
+ waitUntil = [waitUntil];
+ }
+
+ const events = new Set<'load' | 'DOMContentLoaded'>();
+ for (const lifecycleEvent of waitUntil) {
+ switch (lifecycleEvent) {
+ case 'load': {
+ events.add('load');
+ break;
+ }
+ case 'domcontentloaded': {
+ events.add('DOMContentLoaded');
+ break;
+ }
+ }
+ }
+ if (events.size === 0) {
+ return of(undefined);
+ }
+
+ return combineLatest(
+ [...events].map(event => {
+ return fromEmitterEvent(this.browsingContext, event);
+ })
+ ).pipe(
+ map(() => {}),
+ first(),
+ raceWith(
+ timeout(ms),
+ this.#detached$().pipe(
+ map(() => {
+ throw new Error('Frame detached.');
+ })
+ )
+ )
+ );
+ }
+
+ @throwIfDetached
+ #waitForNetworkIdle$(options: WaitForOptions = {}): Observable<void> {
+ let {waitUntil = 'load'} = options;
+ if (!Array.isArray(waitUntil)) {
+ waitUntil = [waitUntil];
+ }
+
+ let concurrency = Infinity;
+ for (const event of waitUntil) {
+ switch (event) {
+ case 'networkidle0': {
+ concurrency = Math.min(0, concurrency);
+ break;
+ }
+ case 'networkidle2': {
+ concurrency = Math.min(2, concurrency);
+ break;
+ }
+ }
+ }
+ if (concurrency === Infinity) {
+ return of(undefined);
+ }
+
+ return this.page().waitForNetworkIdle$({
+ idleTime: 500,
+ timeout: options.timeout ?? this.timeoutSettings.timeout(),
+ concurrency,
+ });
+ }
+
+ @throwIfDetached
+ async setFiles(element: BidiElementHandle, files: string[]): Promise<void> {
+ await this.browsingContext.setFiles(
+ // SAFETY: ElementHandles are always remote references.
+ element.remoteValue() as Bidi.Script.SharedReference,
+ files
+ );
+ }
+}
+
+function isConsoleLogEntry(
+ event: Bidi.Log.Entry
+): event is Bidi.Log.ConsoleLogEntry {
+ return event.type === 'console';
+}
+
+function isJavaScriptLogEntry(
+ event: Bidi.Log.Entry
+): event is Bidi.Log.JavascriptLogEntry {
+ return event.type === 'javascript';
+}
+
+function getStackTraceLocations(
+ stackTrace?: Bidi.Script.StackTrace
+): ConsoleMessageLocation[] {
+ const stackTraceLocations: ConsoleMessageLocation[] = [];
+ if (stackTrace) {
+ for (const callFrame of stackTrace.callFrames) {
+ stackTraceLocations.push({
+ url: callFrame.url,
+ lineNumber: callFrame.lineNumber,
+ columnNumber: callFrame.columnNumber,
+ });
+ }
+ }
+ return stackTraceLocations;
}