diff --git a/src/rpc/client/browser.ts b/src/rpc/client/browser.ts index 2c11f61fbd..19083e34a6 100644 --- a/src/rpc/client/browser.ts +++ b/src/rpc/client/browser.ts @@ -19,7 +19,7 @@ import { BrowserChannel, BrowserInitializer } from '../channels'; import { BrowserContext } from './browserContext'; import { Page } from './page'; import { ChannelOwner } from './channelOwner'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { Events } from '../../events'; export class Browser extends ChannelOwner { @@ -36,12 +36,13 @@ export class Browser extends ChannelOwner { return browser ? Browser.from(browser) : null; } - constructor(connection: Connection, channel: BrowserChannel, initializer: BrowserInitializer) { - super(connection, channel, initializer); - channel.on('close', () => { + constructor(scope: ConnectionScope, guid: string, initializer: BrowserInitializer) { + super(scope, guid, initializer, true); + this._channel.on('close', () => { this._isConnected = false; this.emit(Events.Browser.Disconnected); this._isClosedOrClosing = true; + this._scope.dispose(); }); } diff --git a/src/rpc/client/browserContext.ts b/src/rpc/client/browserContext.ts index 9bd03b6a8f..2d6bc2170b 100644 --- a/src/rpc/client/browserContext.ts +++ b/src/rpc/client/browserContext.ts @@ -23,7 +23,7 @@ import { BrowserContextChannel, BrowserContextInitializer } from '../channels'; import { ChannelOwner } from './channelOwner'; import { helper } from '../../helper'; import { Browser } from './browser'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { Events } from '../../events'; import { TimeoutSettings } from '../../timeoutSettings'; @@ -44,8 +44,8 @@ export class BrowserContext extends ChannelOwner { const page = Page.from(p); this._pages.add(page); @@ -193,6 +193,7 @@ export class BrowserContext extends ChannelOwner { diff --git a/src/rpc/client/browserServer.ts b/src/rpc/client/browserServer.ts index d699a58b85..f54f2ce7de 100644 --- a/src/rpc/client/browserServer.ts +++ b/src/rpc/client/browserServer.ts @@ -16,7 +16,7 @@ import { ChildProcess } from 'child_process'; import { BrowserServerChannel, BrowserServerInitializer } from '../channels'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { ChannelOwner } from './channelOwner'; import { Events } from '../../events'; @@ -25,9 +25,9 @@ export class BrowserServer extends ChannelOwner this.emit(Events.BrowserServer.Close)); + constructor(scope: ConnectionScope, guid: string, initializer: BrowserServerInitializer) { + super(scope, guid, initializer); + this._channel.on('close', () => this.emit(Events.BrowserServer.Close)); } process(): ChildProcess { diff --git a/src/rpc/client/browserType.ts b/src/rpc/client/browserType.ts index f0cd919f33..dc3eceb7c0 100644 --- a/src/rpc/client/browserType.ts +++ b/src/rpc/client/browserType.ts @@ -19,12 +19,12 @@ import { BrowserTypeChannel, BrowserTypeInitializer } from '../channels'; import { Browser } from './browser'; import { BrowserContext } from './browserContext'; import { ChannelOwner } from './channelOwner'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { BrowserServer } from './browserServer'; export class BrowserType extends ChannelOwner { - constructor(connection: Connection, channel: BrowserTypeChannel, initializer: BrowserTypeInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: BrowserTypeInitializer) { + super(scope, guid, initializer); } executablePath(): string { diff --git a/src/rpc/client/channelOwner.ts b/src/rpc/client/channelOwner.ts index efcfd239e7..fab135ee0f 100644 --- a/src/rpc/client/channelOwner.ts +++ b/src/rpc/client/channelOwner.ts @@ -16,19 +16,38 @@ import { EventEmitter } from 'events'; import { Channel } from '../channels'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; export abstract class ChannelOwner extends EventEmitter { readonly _channel: T; readonly _initializer: Initializer; - readonly _connection: Connection; - static clientSymbol = Symbol('client'); + readonly _scope: ConnectionScope; - constructor(connection: Connection, channel: T, initializer: Initializer) { + constructor(scope: ConnectionScope, guid: string, initializer: Initializer, isScope?: boolean) { super(); - this._connection = connection; - this._channel = channel; + this._scope = isScope ? scope.createChild(guid) : scope; + const base = new EventEmitter(); + this._channel = new Proxy(base, { + get: (obj: any, prop) => { + if (String(prop).startsWith('_')) + return obj[prop]; + if (prop === 'then') + return obj.then; + if (prop === 'emit') + return obj.emit; + if (prop === 'on') + return obj.on; + if (prop === 'once') + return obj.once; + if (prop === 'addEventListener') + return obj.addListener; + if (prop === 'removeEventListener') + return obj.removeListener; + return (params: any) => scope.sendMessageToServer({ guid, method: String(prop), params }); + }, + }); + this._channel._object = this; + this._channel._guid = guid; this._initializer = initializer; - (channel as any)[ChannelOwner.clientSymbol] = this; } } diff --git a/src/rpc/client/connection.ts b/src/rpc/client/connection.ts index 9d83b47b63..21eca97c82 100644 --- a/src/rpc/client/connection.ts +++ b/src/rpc/client/connection.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { EventEmitter } from 'ws'; import { Browser } from './browser'; import { BrowserContext } from './browserContext'; import { BrowserType } from './browserType'; @@ -26,7 +25,6 @@ import { Request, Response, Route } from './network'; import { Page, BindingCall } from './page'; import { Worker } from './worker'; import debug = require('debug'); -import { Channel } from '../channels'; import { ConsoleMessage } from './consoleMessage'; import { Dialog } from './dialog'; import { Download } from './download'; @@ -34,83 +32,21 @@ import { parseError } from '../serializers'; import { BrowserServer } from './browserServer'; export class Connection { - private _channels = new Map(); - private _waitingForObject = new Map(); + readonly _objects = new Map>(); + readonly _waitingForObject = new Map(); onmessage = (message: string): void => {}; private _lastId = 0; private _callbacks = new Map void, reject: (a: Error) => void }>(); + readonly _scopes = new Map(); + private _rootScript: ConnectionScope; - constructor() {} - - private _createRemoteObject(type: string, guid: string, initializer: any): any { - const channel = this._createChannel(guid) as any; - this._channels.set(guid, channel); - let result: ChannelOwner; - initializer = this._replaceGuidsWithChannels(initializer); - switch (type) { - case 'bindingCall': - result = new BindingCall(this, channel, initializer); - break; - case 'browser': - result = new Browser(this, channel, initializer); - break; - case 'browserServer': - result = new BrowserServer(this, channel, initializer); - break; - case 'browserType': - result = new BrowserType(this, channel, initializer); - break; - case 'context': - result = new BrowserContext(this, channel, initializer); - break; - case 'consoleMessage': - result = new ConsoleMessage(this, channel, initializer); - break; - case 'dialog': - result = new Dialog(this, channel, initializer); - break; - case 'download': - result = new Download(this, channel, initializer); - break; - case 'elementHandle': - result = new ElementHandle(this, channel, initializer); - break; - case 'frame': - result = new Frame(this, channel, initializer); - break; - case 'jsHandle': - result = new JSHandle(this, channel, initializer); - break; - case 'page': - result = new Page(this, channel, initializer); - break; - case 'request': - result = new Request(this, channel, initializer); - break; - case 'response': - result = new Response(this, channel, initializer); - break; - case 'route': - result = new Route(this, channel, initializer); - break; - case 'worker': - result = new Worker(this, channel, initializer); - break; - default: - throw new Error('Missing type ' + type); - } - channel._object = result; - const callback = this._waitingForObject.get(guid); - if (callback) { - callback(result); - this._waitingForObject.delete(guid); - } - return result; + constructor() { + this._rootScript = this.createScope(''); } - waitForObjectWithKnownName(guid: string): Promise { - if (this._channels.has(guid)) - return this._channels.get(guid)!._object; + async waitForObjectWithKnownName(guid: string): Promise { + if (this._objects.has(guid)) + return this._objects.get(guid)!; return new Promise(f => this._waitingForObject.set(guid, f)); } @@ -122,6 +58,16 @@ export class Connection { return new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject })); } + _debugScopeState(): any { + const scopeState: any = {}; + scopeState.objects = [...this._objects.keys()]; + scopeState.scopes = [...this._scopes.values()].map(scope => ({ + _guid: scope._guid, + objects: [...scope._objects.keys()] + })); + return scopeState; + } + dispatch(message: string) { const parsedMessage = JSON.parse(message); const { id, guid, method, params, result, error } = parsedMessage; @@ -138,36 +84,15 @@ export class Connection { debug('pw:channel:event')(parsedMessage); if (method === '__create__') { - this._createRemoteObject(params.type, guid, params.initializer); + const scopeObject = this._objects.get(guid); + const scope = scopeObject ? scopeObject._scope : this._rootScript; + scope.createRemoteObject(params.type, params.guid, params.initializer); return; } - const channel = this._channels.get(guid)!; - channel.emit(method, this._replaceGuidsWithChannels(params)); + const object = this._objects.get(guid)!; + object._channel.emit(method, this._replaceGuidsWithChannels(params)); } - private _createChannel(guid: string): Channel { - const base = new EventEmitter(); - (base as any)._guid = guid; - return new Proxy(base, { - get: (obj: any, prop) => { - if (String(prop).startsWith('_')) - return obj[prop]; - if (prop === 'then') - return obj.then; - if (prop === 'emit') - return obj.emit; - if (prop === 'on') - return obj.on; - if (prop === 'once') - return obj.once; - if (prop === 'addEventListener') - return obj.addListener; - if (prop === 'removeEventListener') - return obj.removeListener; - return (params: any) => this.sendMessageToServer({ guid, method: String(prop), params }); - }, - }); - } private _replaceChannelsWithGuids(payload: any): any { if (!payload) @@ -188,13 +113,13 @@ export class Connection { return payload; } - private _replaceGuidsWithChannels(payload: any): any { + _replaceGuidsWithChannels(payload: any): any { if (!payload) return payload; if (Array.isArray(payload)) return payload.map(p => this._replaceGuidsWithChannels(p)); - if (payload.guid && this._channels.has(payload.guid)) - return this._channels.get(payload.guid); + if (payload.guid && this._objects.has(payload.guid)) + return this._objects.get(payload.guid)!._channel; // TODO: send base64 if (payload instanceof Buffer) return payload; @@ -206,4 +131,120 @@ export class Connection { } return payload; } + + createScope(guid: string): ConnectionScope { + const scope = new ConnectionScope(this, guid); + this._scopes.set(guid, scope); + return scope; + } +} + +export class ConnectionScope { + private _connection: Connection; + readonly _objects = new Map>(); + private _children = new Set(); + private _parent: ConnectionScope | undefined; + readonly _guid: string; + + constructor(connection: Connection, guid: string) { + this._connection = connection; + this._guid = guid; + } + + createChild(guid: string): ConnectionScope { + const scope = this._connection.createScope(guid); + this._children.add(scope); + scope._parent = this; + return scope; + } + + dispose() { + // Take care of hierarchy. + for (const child of [...this._children]) + child.dispose(); + this._children.clear(); + + // Delete self from scopes and objects. + this._connection._scopes.delete(this._guid); + this._connection._objects.delete(this._guid); + + // Delete all of the objects from connection. + for (const guid of this._objects.keys()) + this._connection._objects.delete(guid); + + // Clean up from parent. + if (this._parent) { + this._parent._objects.delete(this._guid); + this._parent._children.delete(this); + } + } + + async sendMessageToServer(message: { guid: string, method: string, params: any }): Promise { + return this._connection.sendMessageToServer(message); + } + + createRemoteObject(type: string, guid: string, initializer: any): any { + let result: ChannelOwner; + initializer = this._connection._replaceGuidsWithChannels(initializer); + switch (type) { + case 'bindingCall': + result = new BindingCall(this, guid, initializer); + break; + case 'browser': + result = new Browser(this, guid, initializer); + break; + case 'browserServer': + result = new BrowserServer(this, guid, initializer); + break; + case 'browserType': + result = new BrowserType(this, guid, initializer); + break; + case 'context': + result = new BrowserContext(this, guid, initializer); + break; + case 'consoleMessage': + result = new ConsoleMessage(this, guid, initializer); + break; + case 'dialog': + result = new Dialog(this, guid, initializer); + break; + case 'download': + result = new Download(this, guid, initializer); + break; + case 'elementHandle': + result = new ElementHandle(this, guid, initializer); + break; + case 'frame': + result = new Frame(this, guid, initializer); + break; + case 'jsHandle': + result = new JSHandle(this, guid, initializer); + break; + case 'page': + result = new Page(this, guid, initializer); + break; + case 'request': + result = new Request(this, guid, initializer); + break; + case 'response': + result = new Response(this, guid, initializer); + break; + case 'route': + result = new Route(this, guid, initializer); + break; + case 'worker': + result = new Worker(this, guid, initializer); + break; + default: + throw new Error('Missing type ' + type); + } + this._connection._objects.set(guid, result); + this._objects.set(guid, result); + const callback = this._connection._waitingForObject.get(guid); + if (callback) { + callback(result); + this._connection._waitingForObject.delete(guid); + } + return result; + } } diff --git a/src/rpc/client/consoleMessage.ts b/src/rpc/client/consoleMessage.ts index 9055e65c10..ffc940b603 100644 --- a/src/rpc/client/consoleMessage.ts +++ b/src/rpc/client/consoleMessage.ts @@ -19,15 +19,15 @@ import { ConsoleMessageLocation } from '../../types'; import { JSHandle } from './jsHandle'; import { ConsoleMessageChannel, ConsoleMessageInitializer } from '../channels'; import { ChannelOwner } from './channelOwner'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; export class ConsoleMessage extends ChannelOwner { static from(request: ConsoleMessageChannel): ConsoleMessage { return request._object; } - constructor(connection: Connection, channel: ConsoleMessageChannel, initializer: ConsoleMessageInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: ConsoleMessageInitializer) { + super(scope, guid, initializer); } type(): string { diff --git a/src/rpc/client/dialog.ts b/src/rpc/client/dialog.ts index 760bff09b0..773c9952da 100644 --- a/src/rpc/client/dialog.ts +++ b/src/rpc/client/dialog.ts @@ -15,7 +15,7 @@ */ import { DialogChannel, DialogInitializer } from '../channels'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { ChannelOwner } from './channelOwner'; export class Dialog extends ChannelOwner { @@ -23,8 +23,8 @@ export class Dialog extends ChannelOwner { return request._object; } - constructor(connection: Connection, channel: DialogChannel, initializer: DialogInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: DialogInitializer) { + super(scope, guid, initializer); } type(): string { diff --git a/src/rpc/client/download.ts b/src/rpc/client/download.ts index 04e1e7f816..e07fccbdb5 100644 --- a/src/rpc/client/download.ts +++ b/src/rpc/client/download.ts @@ -16,7 +16,7 @@ import * as fs from 'fs'; import { DownloadChannel, DownloadInitializer } from '../channels'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { ChannelOwner } from './channelOwner'; import { Readable } from 'stream'; @@ -25,8 +25,8 @@ export class Download extends ChannelOwner return request._object; } - constructor(connection: Connection, channel: DownloadChannel, initializer: DownloadInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: DownloadInitializer) { + super(scope, guid, initializer); } url(): string { diff --git a/src/rpc/client/elementHandle.ts b/src/rpc/client/elementHandle.ts index 61b0f3e7b3..8789a858db 100644 --- a/src/rpc/client/elementHandle.ts +++ b/src/rpc/client/elementHandle.ts @@ -18,7 +18,7 @@ import * as types from '../../types'; import { ElementHandleChannel, JSHandleInitializer } from '../channels'; import { Frame } from './frame'; import { FuncOn, JSHandle, serializeArgument, parseResult } from './jsHandle'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; export class ElementHandle extends JSHandle { readonly _elementChannel: ElementHandleChannel; @@ -31,9 +31,9 @@ export class ElementHandle extends JSHandle { return handle ? ElementHandle.from(handle) : null; } - constructor(connection: Connection, channel: ElementHandleChannel, initializer: JSHandleInitializer) { - super(connection, channel, initializer); - this._elementChannel = channel; + constructor(scope: ConnectionScope, guid: string, initializer: JSHandleInitializer) { + super(scope, guid, initializer); + this._elementChannel = this._channel as ElementHandleChannel; } asElement(): ElementHandle | null { diff --git a/src/rpc/client/frame.ts b/src/rpc/client/frame.ts index dc1ab1abc1..2b74e74b12 100644 --- a/src/rpc/client/frame.ts +++ b/src/rpc/client/frame.ts @@ -25,7 +25,7 @@ import { JSHandle, Func1, FuncOn, SmartHandle, serializeArgument, parseResult } import * as network from './network'; import { Response } from './network'; import { Page } from './page'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { normalizeFilePayloads } from '../serializers'; export type GotoOptions = types.NavigateOptions & { @@ -50,8 +50,8 @@ export class Frame extends ChannelOwner { return frame ? Frame.from(frame) : null; } - constructor(connection: Connection, channel: FrameChannel, initializer: FrameInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: FrameInitializer) { + super(scope, guid, initializer); this._parentFrame = Frame.fromNullable(initializer.parentFrame); if (this._parentFrame) this._parentFrame._childFrames.add(this); diff --git a/src/rpc/client/jsHandle.ts b/src/rpc/client/jsHandle.ts index a3a171ea81..8ae8e1a2b8 100644 --- a/src/rpc/client/jsHandle.ts +++ b/src/rpc/client/jsHandle.ts @@ -17,7 +17,7 @@ import { JSHandleChannel, JSHandleInitializer } from '../channels'; import { ElementHandle } from './elementHandle'; import { ChannelOwner } from './channelOwner'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { serializeAsCallArgument, parseEvaluationResultValue } from '../../common/utilityScriptSerializers'; type NoHandles = Arg extends JSHandle ? never : (Arg extends object ? { [Key in keyof Arg]: NoHandles } : Arg); @@ -47,10 +47,10 @@ export class JSHandle extends ChannelOwner this._preview = preview); + this._channel.on('previewUpdated', preview => this._preview = preview); } async evaluate(pageFunction: FuncOn, arg: Arg): Promise; diff --git a/src/rpc/client/network.ts b/src/rpc/client/network.ts index 729a182298..ceff61ceaa 100644 --- a/src/rpc/client/network.ts +++ b/src/rpc/client/network.ts @@ -19,7 +19,7 @@ import * as types from '../../types'; import { RequestChannel, ResponseChannel, RouteChannel, RequestInitializer, ResponseInitializer, RouteInitializer } from '../channels'; import { ChannelOwner } from './channelOwner'; import { Frame } from './frame'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { normalizeFulfillParameters } from '../serializers'; export type NetworkCookie = { @@ -58,8 +58,8 @@ export class Request extends ChannelOwner { return request ? Request.from(request) : null; } - constructor(connection: Connection, channel: RequestChannel, initializer: RequestInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: RequestInitializer) { + super(scope, guid, initializer); this._redirectedFrom = Request.fromNullable(initializer.redirectedFrom); if (this._redirectedFrom) this._redirectedFrom._redirectedTo = this; @@ -138,8 +138,8 @@ export class Route extends ChannelOwner { return route._object; } - constructor(connection: Connection, channel: RouteChannel, initializer: RouteInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: RouteInitializer) { + super(scope, guid, initializer); } request(): Request { @@ -176,8 +176,8 @@ export class Response extends ChannelOwner return response ? Response.from(response) : null; } - constructor(connection: Connection, channel: ResponseChannel, initializer: ResponseInitializer) { - super(connection, channel, initializer); + constructor(scope: ConnectionScope, guid: string, initializer: ResponseInitializer) { + super(scope, guid, initializer); } url(): string { diff --git a/src/rpc/client/page.ts b/src/rpc/client/page.ts index b6d4cccc20..1b522ec2e1 100644 --- a/src/rpc/client/page.ts +++ b/src/rpc/client/page.ts @@ -22,7 +22,7 @@ import { assert, assertMaxArguments, helper, Listener } from '../../helper'; import { TimeoutSettings } from '../../timeoutSettings'; import * as types from '../../types'; import { BindingCallChannel, BindingCallInitializer, Channel, PageChannel, PageInitializer } from '../channels'; -import { Connection } from './connection'; +import { ConnectionScope } from './connection'; import { parseError, serializeError } from '../serializers'; import { Accessibility } from './accessibility'; import { BrowserContext } from './browserContext'; @@ -67,11 +67,11 @@ export class Page extends ChannelOwner { return page ? Page.from(page) : null; } - constructor(connection: Connection, channel: PageChannel, initializer: PageInitializer) { - super(connection, channel, initializer); - this.accessibility = new Accessibility(channel); - this.keyboard = new Keyboard(channel); - this.mouse = new Mouse(channel); + constructor(scope: ConnectionScope, guid: string, initializer: PageInitializer) { + super(scope, guid, initializer); + this.accessibility = new Accessibility(this._channel); + this.keyboard = new Keyboard(this._channel); + this.mouse = new Mouse(this._channel); this._mainFrame = Frame.from(initializer.mainFrame); this._mainFrame._page = this; @@ -505,8 +505,8 @@ export class BindingCall extends ChannelOwner { return worker._object; } - constructor(connection: Connection, channel: WorkerChannel, initializer: WorkerInitializer) { - super(connection, channel, initializer); - channel.on('close', () => { + constructor(scope: ConnectionScope, guid: string, initializer: WorkerInitializer) { + super(scope, guid, initializer); + this._channel.on('close', () => { this._page!._workers.delete(this); this.emit(Events.Worker.Close, this); }); diff --git a/src/rpc/server.ts b/src/rpc/server.ts index f1f8911a80..b5deb7f1c1 100644 --- a/src/rpc/server.ts +++ b/src/rpc/server.ts @@ -24,8 +24,7 @@ const transport = new Transport(process.stdout, process.stdin); transport.onmessage = message => dispatcherConnection.dispatch(message); dispatcherConnection.onmessage = message => transport.send(message); -const dispatcherScope = dispatcherConnection.createScope(); const playwright = new Playwright(__dirname, require('../../browsers.json')['browsers']); -new BrowserTypeDispatcher(dispatcherScope, playwright.chromium!); -new BrowserTypeDispatcher(dispatcherScope, playwright.firefox!); -new BrowserTypeDispatcher(dispatcherScope, playwright.webkit!); +new BrowserTypeDispatcher(dispatcherConnection.rootScope(), playwright.chromium!); +new BrowserTypeDispatcher(dispatcherConnection.rootScope(), playwright.firefox!); +new BrowserTypeDispatcher(dispatcherConnection.rootScope(), playwright.webkit!); diff --git a/src/rpc/server/browserContextDispatcher.ts b/src/rpc/server/browserContextDispatcher.ts index b6fda0b4f5..19da3279bd 100644 --- a/src/rpc/server/browserContextDispatcher.ts +++ b/src/rpc/server/browserContextDispatcher.ts @@ -29,12 +29,12 @@ export class BrowserContextDispatcher extends Dispatcher PageDispatcher.from(scope, p)) - }); + }, true); this._context = context; context.on(Events.BrowserContext.Page, page => this._dispatchEvent('page', PageDispatcher.from(this._scope, page))); context.on(Events.BrowserContext.Close, () => { this._dispatchEvent('close'); - scope.dispose(); + this._scope.dispose(); }); } diff --git a/src/rpc/server/browserDispatcher.ts b/src/rpc/server/browserDispatcher.ts index d71bd929bf..2dcadfa84b 100644 --- a/src/rpc/server/browserDispatcher.ts +++ b/src/rpc/server/browserDispatcher.ts @@ -24,15 +24,15 @@ import { Dispatcher, DispatcherScope } from './dispatcher'; export class BrowserDispatcher extends Dispatcher implements BrowserChannel { constructor(scope: DispatcherScope, browser: BrowserBase) { - super(scope, browser, 'browser', {}); + super(scope, browser, 'browser', {}, true); browser.on(Events.Browser.Disconnected, () => { this._dispatchEvent('close'); - scope.dispose(); + this._scope.dispose(); }); } async newContext(params: { options?: types.BrowserContextOptions }): Promise { - return new BrowserContextDispatcher(this._scope.createChild(), await this._object.newContext(params.options) as BrowserContextBase); + return new BrowserContextDispatcher(this._scope, await this._object.newContext(params.options) as BrowserContextBase); } async close(): Promise { diff --git a/src/rpc/server/browserTypeDispatcher.ts b/src/rpc/server/browserTypeDispatcher.ts index ff9444eba4..1a2bbf975d 100644 --- a/src/rpc/server/browserTypeDispatcher.ts +++ b/src/rpc/server/browserTypeDispatcher.ts @@ -29,17 +29,17 @@ export class BrowserTypeDispatcher extends Dispatcher { const browser = await this._object.launch(params.options || undefined); - return new BrowserDispatcher(this._scope.createChild(), browser as BrowserBase); + return new BrowserDispatcher(this._scope, browser as BrowserBase); } async launchPersistentContext(params: { userDataDir: string, options?: types.LaunchOptions & types.BrowserContextOptions }): Promise { const browserContext = await this._object.launchPersistentContext(params.userDataDir, params.options); - return new BrowserContextDispatcher(this._scope.createChild(), browserContext as BrowserContextBase); + return new BrowserContextDispatcher(this._scope, browserContext as BrowserContextBase); } async launchServer(params: { options?: types.LaunchServerOptions }): Promise { @@ -48,6 +48,6 @@ export class BrowserTypeDispatcher extends Dispatcher { const browser = await this._object.connect(params.options); - return new BrowserDispatcher(this._scope.createChild(), browser as BrowserBase); + return new BrowserDispatcher(this._scope, browser as BrowserBase); } } diff --git a/src/rpc/server/dispatcher.ts b/src/rpc/server/dispatcher.ts index 0df1e64d0b..722d68ef8f 100644 --- a/src/rpc/server/dispatcher.ts +++ b/src/rpc/server/dispatcher.ts @@ -41,15 +41,15 @@ export class Dispatcher extends EventEmitter implements Chann protected _scope: DispatcherScope; _object: Type; - constructor(scope: DispatcherScope, object: Type, type: string, initializer: Initializer, guid = type + '@' + helper.guid()) { + constructor(scope: DispatcherScope, object: Type, type: string, initializer: Initializer, isScope?: boolean, guid = type + '@' + helper.guid()) { super(); this._type = type; this._guid = guid; this._object = object; - this._scope = scope; + this._scope = isScope ? scope.createChild(guid) : scope; scope.bind(this._guid, this); (object as any)[dispatcherSymbol] = this; - this._scope.sendMessageToClient(this._guid, '__create__', { type, initializer }); + this._scope.sendMessageToClient(scope.guid, '__create__', { type, initializer, guid }); } _dispatchEvent(method: string, params: Dispatcher | any = {}) { @@ -61,17 +61,19 @@ export class DispatcherScope { private _connection: DispatcherConnection; private _dispatchers = new Map>(); private _parent: DispatcherScope | undefined; - private _childScopes = new Set(); + readonly _children = new Set(); + readonly guid: string; - constructor(connection: DispatcherConnection, parent?: DispatcherScope) { + constructor(connection: DispatcherConnection, guid: string, parent?: DispatcherScope) { this._connection = connection; this._parent = parent; + this.guid = guid; if (parent) - parent._childScopes.add(this); + parent._children.add(this); } - createChild(): DispatcherScope { - return new DispatcherScope(this._connection, this); + createChild(guid: string): DispatcherScope { + return new DispatcherScope(this._connection, guid, this); } bind(guid: string, arg: Dispatcher) { @@ -80,30 +82,52 @@ export class DispatcherScope { } dispose() { - for (const child of [...this._childScopes]) + // Take care of hierarchy. + for (const child of [...this._children]) child.dispose(); - this._childScopes.clear(); + this._children.clear(); + + // Delete self from scopes and objects. + this._connection._dispatchers.delete(this.guid); + + // Delete all of the objects from connection. for (const guid of this._dispatchers.keys()) this._connection._dispatchers.delete(guid); - if (this._parent) - this._parent._childScopes.delete(this); + + if (this._parent) { + this._parent._children.delete(this); + this._parent._dispatchers.delete(this.guid); + } } async sendMessageToClient(guid: string, method: string, params: any): Promise { this._connection._sendMessageToClient(guid, method, params); } + + _dumpScopeState(scopes: any[]): any { + const scopeState: any = { _guid: this.guid }; + scopeState.objects = [...this._dispatchers.keys()]; + scopes.push(scopeState); + [...this._children].map(c => c._dumpScopeState(scopes)); + return scopeState; + } } export class DispatcherConnection { readonly _dispatchers = new Map>(); + private _rootScope: DispatcherScope; onmessage = (message: string) => {}; async _sendMessageToClient(guid: string, method: string, params: any): Promise { this.onmessage(JSON.stringify({ guid, method, params: this._replaceDispatchersWithGuids(params) })); } - createScope(): DispatcherScope { - return new DispatcherScope(this); + constructor() { + this._rootScope = new DispatcherScope(this, ''); + } + + rootScope(): DispatcherScope { + return this._rootScope; } async dispatch(message: string) { @@ -114,6 +138,14 @@ export class DispatcherConnection { this.onmessage(JSON.stringify({ id, error: serializeError(new Error('Target browser or context has been closed')) })); return; } + if (method === 'debugScopeState') { + const dispatcherState: any = {}; + dispatcherState.objects = [...this._dispatchers.keys()]; + dispatcherState.scopes = []; + this._rootScope._dumpScopeState(dispatcherState.scopes); + this.onmessage(JSON.stringify({ id, result: dispatcherState })); + return; + } try { const result = await (dispatcher as any)[method](this._replaceGuidsWithDispatchers(params)); this.onmessage(JSON.stringify({ id, result: this._replaceDispatchersWithGuids(result) })); @@ -122,7 +154,7 @@ export class DispatcherConnection { } } - private _replaceDispatchersWithGuids(payload: any): any { + _replaceDispatchersWithGuids(payload: any): any { if (!payload) return payload; if (payload instanceof Dispatcher) diff --git a/test/channels.spec.js b/test/channels.spec.js new file mode 100644 index 0000000000..c12f025b9d --- /dev/null +++ b/test/channels.spec.js @@ -0,0 +1,99 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications 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. + */ + +const path = require('path'); +const util = require('util'); +const vm = require('vm'); +const { FFOX, CHROMIUM, WEBKIT, WIN, CHANNEL } = require('./utils').testOptions(browserType); + +describe.skip(!CHANNEL)('Channels', function() { + it('should work', async({browser}) => { + expect(!!browser._channel).toBeTruthy(); + }); + it('should scope context handles', async({browser, server}) => { + const GOLDEN_PRECONDITION = { + objects: [ 'chromium', 'browser' ], + scopes: [ + { _guid: '', objects: [ 'chromium', 'browser' ] }, + { _guid: 'browser', objects: [] } + ] + }; + await expectScopeState(browser, GOLDEN_PRECONDITION); + + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await expectScopeState(browser, { + objects: [ 'chromium', 'browser', 'context', 'frame', 'page', 'request', 'response' ], + scopes: [ + { _guid: '', objects: [ 'chromium', 'browser' ] }, + { _guid: 'browser', objects: ['context'] }, + { _guid: 'context', objects: ['frame', 'page', 'request', 'response'] } + ] + }); + + await context.close(); + await expectScopeState(browser, GOLDEN_PRECONDITION); + }); + + it('should browser handles', async({browserType, defaultBrowserOptions}) => { + const GOLDEN_PRECONDITION = { + objects: [ 'chromium', 'browser' ], + scopes: [ + { _guid: '', objects: [ 'chromium', 'browser' ] }, + { _guid: 'browser', objects: [] } + ] + }; + await expectScopeState(browserType, GOLDEN_PRECONDITION); + + const browser = await browserType.launch(defaultBrowserOptions); + await browser.newContext(); + await expectScopeState(browserType, { + objects: [ 'chromium', 'browser', 'browser', 'context' ], + scopes: [ + { _guid: '', objects: [ 'chromium', 'browser', 'browser' ] }, + { _guid: 'browser', objects: [] }, + { _guid: 'browser', objects: ['context'] }, + { _guid: 'context', objects: [] }, + ] + }); + + await browser.close(); + await expectScopeState(browserType, GOLDEN_PRECONDITION); + }); +}); + +async function expectScopeState(object, golden) { + const remoteState = trimGuids(await object._channel.debugScopeState()); + const localState = trimGuids(object._scope._connection._debugScopeState()); + expect(localState).toEqual(golden); + expect(remoteState).toEqual(golden); +} + +function trimGuids(object) { + if (Array.isArray(object)) + return object.map(trimGuids); + if (typeof object === 'object') { + const result = {}; + for (const key in object) + result[key] = trimGuids(object[key]); + return result; + } + if (typeof object === 'string') + return object ? object.match(/[^@]+/)[0] : ''; + return object; +} \ No newline at end of file diff --git a/test/page.spec.js b/test/page.spec.js index 419dd64cc5..6a23875f87 100644 --- a/test/page.spec.js +++ b/test/page.spec.js @@ -1305,9 +1305,3 @@ describe('Page api coverage', function() { expect(await frame.evaluate(() => document.querySelector('textarea').value)).toBe('a'); }); }); - -describe.skip(!CHANNEL)('Page channel', function() { - it('page should be client stub', async({page, server}) => { - expect(!!page._channel).toBeTruthy(); - }); -}); diff --git a/test/test.config.js b/test/test.config.js index 924d44f7d1..9e5cb8cd22 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -214,6 +214,7 @@ module.exports = { files: [ './browser.spec.js', './browsercontext.spec.js', + './channels.spec.js', './ignorehttpserrors.spec.js', './popup.spec.js', './recorder.spec.js', diff --git a/test/test.js b/test/test.js index c79c7eb0f4..1f4dd4804a 100644 --- a/test/test.js +++ b/test/test.js @@ -114,7 +114,7 @@ function collect(browserNames) { await new Promise(f => setImmediate(f)); return result; }; - new BrowserTypeDispatcher(dispatcherConnection.createScope(), browserType); + new BrowserTypeDispatcher(dispatcherConnection.rootScope(), browserType); overridenBrowserType = await connection.waitForObjectWithKnownName(browserType.name()); } state.browserType = overridenBrowserType;