feat(rpc): plumb CDPSession (#2862)
This commit is contained in:
parent
2a86ead0ac
commit
0c80c22716
|
|
@ -122,6 +122,7 @@ export const CRSessionEvents = {
|
||||||
|
|
||||||
export class CRSession extends EventEmitter {
|
export class CRSession extends EventEmitter {
|
||||||
_connection: CRConnection | null;
|
_connection: CRConnection | null;
|
||||||
|
_eventListener?: (method: string, params?: Object) => void;
|
||||||
private readonly _callbacks = new Map<number, {resolve: (o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
|
private readonly _callbacks = new Map<number, {resolve: (o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
|
||||||
private readonly _targetType: string;
|
private readonly _targetType: string;
|
||||||
private readonly _sessionId: string;
|
private readonly _sessionId: string;
|
||||||
|
|
@ -182,7 +183,11 @@ export class CRSession extends EventEmitter {
|
||||||
callback.resolve(object.result);
|
callback.resolve(object.result);
|
||||||
} else {
|
} else {
|
||||||
assert(!object.id);
|
assert(!object.id);
|
||||||
Promise.resolve().then(() => this.emit(object.method!, object.params));
|
Promise.resolve().then(() => {
|
||||||
|
if (this._eventListener)
|
||||||
|
this._eventListener(object.method!, object.params);
|
||||||
|
this.emit(object.method!, object.params);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ export interface BrowserChannel extends Channel {
|
||||||
|
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
newContext(params: types.BrowserContextOptions): Promise<BrowserContextChannel>;
|
newContext(params: types.BrowserContextOptions): Promise<BrowserContextChannel>;
|
||||||
|
|
||||||
|
// Chromium-specific.
|
||||||
|
newBrowserCDPSession(): Promise<CDPSessionChannel>;
|
||||||
}
|
}
|
||||||
export type BrowserInitializer = {};
|
export type BrowserInitializer = {};
|
||||||
|
|
||||||
|
|
@ -330,3 +333,14 @@ export type DownloadInitializer = {
|
||||||
url: string,
|
url: string,
|
||||||
suggestedFilename: string,
|
suggestedFilename: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Chromium-specific.
|
||||||
|
export interface CDPSessionChannel extends Channel {
|
||||||
|
on(event: 'event', callback: (params: { method: string, params?: Object }) => void): this;
|
||||||
|
on(event: 'disconnected', callback: () => void): this;
|
||||||
|
|
||||||
|
send(params: { method: string, params?: Object }): Promise<Object>;
|
||||||
|
detach(): Promise<void>;
|
||||||
|
}
|
||||||
|
export type CDPSessionInitializer = {};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { Page } from './page';
|
||||||
import { ChannelOwner } from './channelOwner';
|
import { ChannelOwner } from './channelOwner';
|
||||||
import { ConnectionScope } from './connection';
|
import { ConnectionScope } from './connection';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
|
import { CDPSession } from './cdpSession';
|
||||||
|
|
||||||
export class Browser extends ChannelOwner<BrowserChannel, BrowserInitializer> {
|
export class Browser extends ChannelOwner<BrowserChannel, BrowserInitializer> {
|
||||||
readonly _contexts = new Set<BrowserContext>();
|
readonly _contexts = new Set<BrowserContext>();
|
||||||
|
|
@ -77,4 +78,9 @@ export class Browser extends ChannelOwner<BrowserChannel, BrowserInitializer> {
|
||||||
this._isClosedOrClosing = true;
|
this._isClosedOrClosing = true;
|
||||||
await this._channel.close();
|
await this._channel.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chromium-specific.
|
||||||
|
async newBrowserCDPSession(): Promise<CDPSession> {
|
||||||
|
return CDPSession.from(await this._channel.newBrowserCDPSession());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
src/rpc/client/cdpSession.ts
Normal file
57
src/rpc/client/cdpSession.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* 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 { CDPSessionChannel, CDPSessionInitializer } from '../channels';
|
||||||
|
import { ConnectionScope } from './connection';
|
||||||
|
import { ChannelOwner } from './channelOwner';
|
||||||
|
import { Protocol } from '../../chromium/protocol';
|
||||||
|
|
||||||
|
export class CDPSession extends ChannelOwner<CDPSessionChannel, CDPSessionInitializer> {
|
||||||
|
static from(cdpSession: CDPSessionChannel): CDPSession {
|
||||||
|
return (cdpSession as any)._object;
|
||||||
|
}
|
||||||
|
|
||||||
|
on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||||
|
addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||||
|
off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||||
|
removeListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||||
|
once: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||||
|
|
||||||
|
constructor(scope: ConnectionScope, guid: string, initializer: CDPSessionInitializer) {
|
||||||
|
super(scope, guid, initializer, true);
|
||||||
|
|
||||||
|
this._channel.on('event', ({ method, params }) => this.emit(method, params));
|
||||||
|
this._channel.on('disconnected', () => this._scope.dispose());
|
||||||
|
|
||||||
|
this.on = super.on;
|
||||||
|
this.addListener = super.addListener;
|
||||||
|
this.off = super.removeListener;
|
||||||
|
this.removeListener = super.removeListener;
|
||||||
|
this.once = super.once;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T extends keyof Protocol.CommandParameters>(
|
||||||
|
method: T,
|
||||||
|
params?: Protocol.CommandParameters[T]
|
||||||
|
): Promise<Protocol.CommandReturnValues[T]> {
|
||||||
|
const result = await this._channel.send({ method, params });
|
||||||
|
return result as Protocol.CommandReturnValues[T];
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach() {
|
||||||
|
return this._channel.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ import { Dialog } from './dialog';
|
||||||
import { Download } from './download';
|
import { Download } from './download';
|
||||||
import { parseError } from '../serializers';
|
import { parseError } from '../serializers';
|
||||||
import { BrowserServer } from './browserServer';
|
import { BrowserServer } from './browserServer';
|
||||||
|
import { CDPSession } from './cdpSession';
|
||||||
|
|
||||||
export class Connection {
|
export class Connection {
|
||||||
readonly _objects = new Map<string, ChannelOwner<any, any>>();
|
readonly _objects = new Map<string, ChannelOwner<any, any>>();
|
||||||
|
|
@ -192,6 +193,10 @@ export class ConnectionScope {
|
||||||
case 'browserType':
|
case 'browserType':
|
||||||
result = new BrowserType(this, guid, initializer);
|
result = new BrowserType(this, guid, initializer);
|
||||||
break;
|
break;
|
||||||
|
case 'cdpSession':
|
||||||
|
// Chromium-specific.
|
||||||
|
result = new CDPSession(this, guid, initializer);
|
||||||
|
break;
|
||||||
case 'context':
|
case 'context':
|
||||||
result = new BrowserContext(this, guid, initializer);
|
result = new BrowserContext(this, guid, initializer);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ import { Browser, BrowserBase } from '../../browser';
|
||||||
import { BrowserContextBase } from '../../browserContext';
|
import { BrowserContextBase } from '../../browserContext';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import * as types from '../../types';
|
import * as types from '../../types';
|
||||||
import { BrowserChannel, BrowserContextChannel, BrowserInitializer } from '../channels';
|
import { BrowserChannel, BrowserContextChannel, BrowserInitializer, CDPSessionChannel } from '../channels';
|
||||||
import { BrowserContextDispatcher } from './browserContextDispatcher';
|
import { BrowserContextDispatcher } from './browserContextDispatcher';
|
||||||
|
import { CDPSessionDispatcher } from './cdpSessionDispatcher';
|
||||||
import { Dispatcher, DispatcherScope } from './dispatcher';
|
import { Dispatcher, DispatcherScope } from './dispatcher';
|
||||||
|
import { CRBrowser } from '../../chromium/crBrowser';
|
||||||
|
|
||||||
export class BrowserDispatcher extends Dispatcher<Browser, BrowserInitializer> implements BrowserChannel {
|
export class BrowserDispatcher extends Dispatcher<Browser, BrowserInitializer> implements BrowserChannel {
|
||||||
constructor(scope: DispatcherScope, browser: BrowserBase) {
|
constructor(scope: DispatcherScope, browser: BrowserBase) {
|
||||||
|
|
@ -38,4 +40,10 @@ export class BrowserDispatcher extends Dispatcher<Browser, BrowserInitializer> i
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
await this._object.close();
|
await this._object.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chromium-specific.
|
||||||
|
async newBrowserCDPSession(): Promise<CDPSessionChannel> {
|
||||||
|
const crBrowser = this._object as CRBrowser;
|
||||||
|
return new CDPSessionDispatcher(this._scope, await crBrowser.newBrowserCDPSession());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/rpc/server/cdpSessionDispatcher.ts
Normal file
38
src/rpc/server/cdpSessionDispatcher.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* 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 { CRSession, CRSessionEvents } from '../../chromium/crConnection';
|
||||||
|
import { CDPSessionChannel, CDPSessionInitializer } from '../channels';
|
||||||
|
import { Dispatcher, DispatcherScope } from './dispatcher';
|
||||||
|
|
||||||
|
export class CDPSessionDispatcher extends Dispatcher<CRSession, CDPSessionInitializer> implements CDPSessionChannel {
|
||||||
|
constructor(scope: DispatcherScope, crSession: CRSession) {
|
||||||
|
super(scope, crSession, 'cdpSession', {}, true);
|
||||||
|
crSession._eventListener = (method, params) => this._dispatchEvent('event', { method, params });
|
||||||
|
crSession.on(CRSessionEvents.Disconnected, () => {
|
||||||
|
this._dispatchEvent('disconnected');
|
||||||
|
this._scope.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(params: { method: string, params?: Object }): Promise<Object> {
|
||||||
|
return this._object.send(params.method as any, params.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(): Promise<void> {
|
||||||
|
return this._object.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,15 +15,13 @@
|
||||||
* limitations under the License.
|
* 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);
|
const { FFOX, CHROMIUM, WEBKIT, WIN, CHANNEL } = require('./utils').testOptions(browserType);
|
||||||
|
|
||||||
describe.skip(!CHANNEL)('Channels', function() {
|
describe.skip(!CHANNEL)('Channels', function() {
|
||||||
it('should work', async({browser}) => {
|
it('should work', async({browser}) => {
|
||||||
expect(!!browser._channel).toBeTruthy();
|
expect(!!browser._channel).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should scope context handles', async({browser, server}) => {
|
it('should scope context handles', async({browser, server}) => {
|
||||||
const GOLDEN_PRECONDITION = {
|
const GOLDEN_PRECONDITION = {
|
||||||
objects: [ 'chromium', 'browser' ],
|
objects: [ 'chromium', 'browser' ],
|
||||||
|
|
@ -50,7 +48,31 @@ describe.skip(!CHANNEL)('Channels', function() {
|
||||||
await expectScopeState(browser, GOLDEN_PRECONDITION);
|
await expectScopeState(browser, GOLDEN_PRECONDITION);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should browser handles', async({browserType, defaultBrowserOptions}) => {
|
it('should scope CDPSession handles', async({browserType, browser, server}) => {
|
||||||
|
const GOLDEN_PRECONDITION = {
|
||||||
|
objects: [ 'chromium', 'browser' ],
|
||||||
|
scopes: [
|
||||||
|
{ _guid: '', objects: [ 'chromium', 'browser' ] },
|
||||||
|
{ _guid: 'browser', objects: [] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
await expectScopeState(browserType, GOLDEN_PRECONDITION);
|
||||||
|
|
||||||
|
const session = await browser.newBrowserCDPSession();
|
||||||
|
await expectScopeState(browserType, {
|
||||||
|
objects: [ 'chromium', 'browser', 'cdpSession' ],
|
||||||
|
scopes: [
|
||||||
|
{ _guid: '', objects: [ 'chromium', 'browser' ] },
|
||||||
|
{ _guid: 'browser', objects: ['cdpSession'] },
|
||||||
|
{ _guid: 'cdpSession', objects: [] },
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.detach();
|
||||||
|
await expectScopeState(browserType, GOLDEN_PRECONDITION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scope browser handles', async({browserType, defaultBrowserOptions}) => {
|
||||||
const GOLDEN_PRECONDITION = {
|
const GOLDEN_PRECONDITION = {
|
||||||
objects: [ 'chromium', 'browser' ],
|
objects: [ 'chromium', 'browser' ],
|
||||||
scopes: [
|
scopes: [
|
||||||
|
|
@ -96,4 +118,4 @@ function trimGuids(object) {
|
||||||
if (typeof object === 'string')
|
if (typeof object === 'string')
|
||||||
return object ? object.match(/[^@]+/)[0] : '';
|
return object ? object.match(/[^@]+/)[0] : '';
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,18 @@ describe.skip(CHANNEL)('ChromiumBrowserContext.createSession', function() {
|
||||||
await context.close();
|
await context.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe.skip(CHANNEL)('ChromiumBrowser.newBrowserCDPSession', function() {
|
describe('ChromiumBrowser.newBrowserCDPSession', function() {
|
||||||
it('should work', async function({page, browser, server}) {
|
it('should work', async function({page, browser, server}) {
|
||||||
const session = await browser.newBrowserCDPSession();
|
const session = await browser.newBrowserCDPSession();
|
||||||
|
|
||||||
const version = await session.send('Browser.getVersion');
|
const version = await session.send('Browser.getVersion');
|
||||||
expect(version.userAgent).toBeTruthy();
|
expect(version.userAgent).toBeTruthy();
|
||||||
|
|
||||||
|
let gotEvent = false;
|
||||||
|
session.on('Target.targetCreated', () => gotEvent = true);
|
||||||
|
await session.send('Target.setDiscoverTargets', { discover: true });
|
||||||
|
expect(gotEvent).toBe(true);
|
||||||
|
|
||||||
await session.detach();
|
await session.detach();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,14 @@ const writeFileAsync = util.promisify(fs.writeFile);
|
||||||
|
|
||||||
const PROJECT_DIR = path.join(__dirname, '..', '..');
|
const PROJECT_DIR = path.join(__dirname, '..', '..');
|
||||||
|
|
||||||
async function recursiveReadDir(dirPath) {
|
async function recursiveReadDir(dirPath, exclude) {
|
||||||
const files = [];
|
const files = [];
|
||||||
|
if (exclude.includes(dirPath))
|
||||||
|
return files;
|
||||||
for (const file of await readdirAsync(dirPath)) {
|
for (const file of await readdirAsync(dirPath)) {
|
||||||
const fullPath = path.join(dirPath, file);
|
const fullPath = path.join(dirPath, file);
|
||||||
if ((await statAsync(fullPath)).isDirectory())
|
if ((await statAsync(fullPath)).isDirectory())
|
||||||
files.push(...await recursiveReadDir(fullPath))
|
files.push(...await recursiveReadDir(fullPath, exclude))
|
||||||
else
|
else
|
||||||
files.push(fullPath);
|
files.push(fullPath);
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +102,7 @@ class Source {
|
||||||
async save() {
|
async save() {
|
||||||
await writeFileAsync(this.filePath(), this.text());
|
await writeFileAsync(this.filePath(), this.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAs(path) {
|
async saveAs(path) {
|
||||||
await writeFileAsync(path, this.text());
|
await writeFileAsync(path, this.text());
|
||||||
}
|
}
|
||||||
|
|
@ -118,11 +120,12 @@ class Source {
|
||||||
/**
|
/**
|
||||||
* @param {string} dirPath
|
* @param {string} dirPath
|
||||||
* @param {string=} extension
|
* @param {string=} extension
|
||||||
|
* @param {Array<string>=} exclude
|
||||||
* @return {!Promise<!Array<!Source>>}
|
* @return {!Promise<!Array<!Source>>}
|
||||||
*/
|
*/
|
||||||
static async readdir(dirPath, extension = '') {
|
static async readdir(dirPath, extension = '', exclude = []) {
|
||||||
extension = extension.toLowerCase();
|
extension = extension.toLowerCase();
|
||||||
const filePaths = (await recursiveReadDir(dirPath)).filter(fileName => fileName.toLowerCase().endsWith(extension));
|
const filePaths = (await recursiveReadDir(dirPath, exclude)).filter(fileName => fileName.toLowerCase().endsWith(extension));
|
||||||
return Promise.all(filePaths.map(filePath => Source.readFile(filePath)));
|
return Promise.all(filePaths.map(filePath => Source.readFile(filePath)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ async function run() {
|
||||||
const browser = await playwright.chromium.launch();
|
const browser = await playwright.chromium.launch();
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
const checkPublicAPI = require('./check_public_api');
|
const checkPublicAPI = require('./check_public_api');
|
||||||
const jsSources = await Source.readdir(path.join(PROJECT_DIR, 'src'));
|
const rpcDir = path.join(PROJECT_DIR, 'src', 'rpc');
|
||||||
|
const jsSources = await Source.readdir(path.join(PROJECT_DIR, 'src'), '', [rpcDir]);
|
||||||
messages.push(...await checkPublicAPI(page, [api], jsSources));
|
messages.push(...await checkPublicAPI(page, [api], jsSources));
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ let documentation;
|
||||||
const api = await Source.readFile(path.join(PROJECT_DIR, 'docs', 'api.md'));
|
const api = await Source.readFile(path.join(PROJECT_DIR, 'docs', 'api.md'));
|
||||||
const {documentation: mdDocumentation} = await require('../doclint/check_public_api/MDBuilder')(page, [api]);
|
const {documentation: mdDocumentation} = await require('../doclint/check_public_api/MDBuilder')(page, [api]);
|
||||||
await browser.close();
|
await browser.close();
|
||||||
const sources = await Source.readdir(path.join(PROJECT_DIR, 'src'));
|
const rpcDir = path.join(PROJECT_DIR, 'src', 'rpc');
|
||||||
|
const sources = await Source.readdir(path.join(PROJECT_DIR, 'src'), '', [rpcDir]);
|
||||||
const {documentation: jsDocumentation} = await require('../doclint/check_public_api/JSBuilder').checkSources(sources);
|
const {documentation: jsDocumentation} = await require('../doclint/check_public_api/JSBuilder').checkSources(sources);
|
||||||
documentation = mergeDocumentation(mdDocumentation, jsDocumentation);
|
documentation = mergeDocumentation(mdDocumentation, jsDocumentation);
|
||||||
const handledClasses = new Set();
|
const handledClasses = new Set();
|
||||||
|
|
@ -408,7 +409,7 @@ function mergeClasses(mdClass, jsClass) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateDevicesTypes() {
|
function generateDevicesTypes() {
|
||||||
const namedDevices =
|
const namedDevices =
|
||||||
Object.keys(devices)
|
Object.keys(devices)
|
||||||
.map(name => ` ${JSON.stringify(name)}: DeviceDescriptor;`)
|
.map(name => ` ${JSON.stringify(name)}: DeviceDescriptor;`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue