refactor: sync emulation state with secondary clients (#10849)

This commit is contained in:
Alex Rudenko 2023-09-07 10:05:57 +02:00 committed by GitHub
parent e92e4b2c82
commit 72175906a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 199 additions and 20 deletions

View File

@ -17,10 +17,24 @@ import {Protocol} from 'devtools-protocol';
import {GeolocationOptions, MediaFeature} from '../api/Page.js';
import {assert} from '../util/assert.js';
import {invokeAtMostOnceForArguments} from '../util/decorators.js';
import {isErrorLike} from '../util/ErrorLike.js';
import {CDPSession, CDPSessionEmittedEvents} from './Connection.js';
import {Viewport} from './PuppeteerViewport.js';
import {debugError} from './util.js';
interface ViewportState {
viewport?: Viewport;
}
interface IdleOverridesState {
overrides?: {
isUserActive: boolean;
isScreenUnlocked: boolean;
};
active: boolean;
}
/**
* @internal
@ -31,7 +45,10 @@ export class EmulationManager {
#hasTouch = false;
#javascriptEnabled = true;
#viewport?: Viewport;
#viewportState: ViewportState = {};
#idleOverridesState: IdleOverridesState = {
active: false,
};
#secondaryClients = new Set<CDPSession>();
constructor(client: CDPSession) {
@ -45,10 +62,11 @@ export class EmulationManager {
async registerSpeculativeSession(client: CDPSession): Promise<void> {
this.#secondaryClients.add(client);
await this.#applyViewport(client);
client.once(CDPSessionEmittedEvents.Disconnected, () => {
return this.#secondaryClients.delete(client);
});
void this.#syncViewport().catch(debugError);
void this.#syncIdleState().catch(debugError);
}
get javascriptEnabled(): boolean {
@ -56,9 +74,11 @@ export class EmulationManager {
}
async emulateViewport(viewport: Viewport): Promise<boolean> {
this.#viewport = viewport;
this.#viewportState = {
viewport,
};
await this.#applyViewport(this.#client);
await this.#syncViewport();
const mobile = viewport.isMobile || false;
const hasTouch = viewport.hasTouch || false;
@ -67,22 +87,27 @@ export class EmulationManager {
this.#emulatingMobile = mobile;
this.#hasTouch = hasTouch;
if (!reloadNeeded) {
// If the page will be reloaded, no need to adjust secondary clients.
await Promise.all(
Array.from(this.#secondaryClients).map(client => {
return this.#applyViewport(client);
})
);
}
return reloadNeeded;
}
async #applyViewport(client: CDPSession): Promise<void> {
const viewport = this.#viewport;
if (!viewport) {
async #syncViewport() {
await Promise.all([
this.#applyViewport(this.#client, this.#viewportState),
...Array.from(this.#secondaryClients).map(client => {
return this.#applyViewport(client, this.#viewportState);
}),
]);
}
@invokeAtMostOnceForArguments
async #applyViewport(
client: CDPSession,
viewportState: ViewportState
): Promise<void> {
if (!viewportState.viewport) {
return;
}
const {viewport} = viewportState;
const mobile = viewport.isMobile || false;
const width = viewport.width;
const height = viewport.height;
@ -111,13 +136,37 @@ export class EmulationManager {
isUserActive: boolean;
isScreenUnlocked: boolean;
}): Promise<void> {
if (overrides) {
await this.#client.send('Emulation.setIdleOverride', {
isUserActive: overrides.isUserActive,
isScreenUnlocked: overrides.isScreenUnlocked,
this.#idleOverridesState = {
active: true,
overrides,
};
await this.#syncIdleState();
}
async #syncIdleState() {
await Promise.all([
this.#emulateIdleState(this.#client, this.#idleOverridesState),
...Array.from(this.#secondaryClients).map(client => {
return this.#emulateIdleState(client, this.#idleOverridesState);
}),
]);
}
@invokeAtMostOnceForArguments
async #emulateIdleState(
client: CDPSession,
idleStateState: IdleOverridesState
): Promise<void> {
if (!idleStateState.active) {
return;
}
if (idleStateState.overrides) {
await client.send('Emulation.setIdleOverride', {
isUserActive: idleStateState.overrides.isUserActive,
isScreenUnlocked: idleStateState.overrides.isScreenUnlocked,
});
} else {
await this.#client.send('Emulation.clearIdleOverride');
await client.send('Emulation.clearIdleOverride');
}
}

View File

@ -0,0 +1,89 @@
/**
* Copyright 2023 Google Inc. All rights reserved.
*
* 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 {describe, it} from 'node:test';
import expect from 'expect';
import sinon from 'sinon';
import {invokeAtMostOnceForArguments} from './decorators.js';
describe('decorators', function () {
describe('invokeAtMostOnceForArguments', () => {
it('should delegate calls', () => {
const spy = sinon.spy();
class Test {
@invokeAtMostOnceForArguments
test(obj1: object, obj2: object) {
spy(obj1, obj2);
}
}
const t = new Test();
expect(spy.callCount).toBe(0);
const obj1 = {};
const obj2 = {};
t.test(obj1, obj2);
expect(spy.callCount).toBe(1);
});
it('should prevent repeated calls', () => {
const spy = sinon.spy();
class Test {
@invokeAtMostOnceForArguments
test(obj1: object, obj2: object) {
spy(obj1, obj2);
}
}
const t = new Test();
expect(spy.callCount).toBe(0);
const obj1 = {};
const obj2 = {};
t.test(obj1, obj2);
expect(spy.callCount).toBe(1);
expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy();
t.test(obj1, obj2);
expect(spy.callCount).toBe(1);
expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy();
const obj3 = {};
t.test(obj1, obj3);
expect(spy.callCount).toBe(2);
expect(spy.lastCall.calledWith(obj1, obj3)).toBeTruthy();
});
it('should throw an error for dynamic argumetns', () => {
class Test {
@invokeAtMostOnceForArguments
test(..._args: unknown[]) {}
}
const t = new Test();
t.test({});
expect(() => {
t.test({}, {});
}).toThrow();
});
it('should throw an error for non object arguments', () => {
class Test {
@invokeAtMostOnceForArguments
test(..._args: unknown[]) {}
}
const t = new Test();
expect(() => {
t.test(1);
}).toThrow();
});
});
});

View File

@ -72,3 +72,44 @@ export function throwIfDisposed<This extends Disposed>(
};
};
}
/**
* The decorator only invokes the target if the target has not been invoked with
* the same arguments before. The decorated method throws an error if it's
* invoked with a different number of elements: if you decorate a method, it
* should have the same number of arguments
*
* @internal
*/
export function invokeAtMostOnceForArguments(
target: (this: unknown, ...args: any[]) => any,
_: unknown
): typeof target {
const cache = new WeakMap();
let cacheDepth = -1;
return function (this: unknown, ...args: unknown[]) {
if (cacheDepth === -1) {
cacheDepth = args.length;
}
if (cacheDepth !== args.length) {
throw new Error(
'Memoized method was called with the wrong number of arguments'
);
}
let freshArguments = false;
let cacheIterator = cache;
for (const arg of args) {
if (cacheIterator.has(arg as object)) {
cacheIterator = cacheIterator.get(arg as object)!;
} else {
freshArguments = true;
cacheIterator.set(arg as object, new WeakMap());
cacheIterator = cacheIterator.get(arg as object)!;
}
}
if (!freshArguments) {
return;
}
return target.call(this, ...args);
};
}