Fixes #22115
This commit is contained in:
Pavel Feldman 2023-03-31 18:18:45 -07:00 коммит произвёл GitHub
Родитель 41e3e6d13f
Коммит 3b9e62432d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
5 изменённых файлов: 173 добавлений и 9 удалений

Просмотреть файл

@ -47,7 +47,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
readonly world: types.World | null; readonly world: types.World | null;
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World|null) { constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World|null) {
super(frame, delegate); super(frame, delegate, world || 'content-script');
this.frame = frame; this.frame = frame;
this.world = world; this.world = world;
} }
@ -114,7 +114,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
); );
})(); })();
`; `;
this._injectedScriptPromise = this.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', undefined, objectId)); this._injectedScriptPromise = this.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', 'InjectedScript', objectId));
} }
return this._injectedScriptPromise; return this._injectedScriptPromise;
} }
@ -455,6 +455,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
// Do not await here, just in case the renderer is stuck (e.g. on alert) // Do not await here, just in case the renderer is stuck (e.g. on alert)
// and we won't be able to cleanup. // and we won't be able to cleanup.
hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {}); hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {});
hitTargetInterceptionHandle!.dispose();
}); });
} }
@ -470,7 +471,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (restoreModifiers) if (restoreModifiers)
await this._page.keyboard._ensureModifiers(restoreModifiers); await this._page.keyboard._ensureModifiers(restoreModifiers);
if (hitTargetInterceptionHandle) { if (hitTargetInterceptionHandle) {
const stopHitTargetInterception = hitTargetInterceptionHandle.evaluate(h => h.stop()).catch(e => 'done' as const); const stopHitTargetInterception = hitTargetInterceptionHandle.evaluate(h => h.stop()).catch(e => 'done' as const).finally(() => {
hitTargetInterceptionHandle?.dispose();
});
if (!options.noWaitAfter) { if (!options.noWaitAfter) {
// When noWaitAfter is passed, we do not want to accidentally stall on // When noWaitAfter is passed, we do not want to accidentally stall on
// non-committed navigation blocking the evaluate. // non-committed navigation blocking the evaluate.

Просмотреть файл

@ -70,7 +70,7 @@ export class ElectronApplication extends SdkObject {
this._nodeElectronHandlePromise = new Promise(f => { this._nodeElectronHandlePromise = new Promise(f => {
this._nodeSession.on('Runtime.executionContextCreated', async (event: any) => { this._nodeSession.on('Runtime.executionContextCreated', async (event: any) => {
if (event.context.auxData && event.context.auxData.isDefault) { if (event.context.auxData && event.context.auxData.isDefault) {
this._nodeExecutionContext = new js.ExecutionContext(this, new CRExecutionContext(this._nodeSession, event.context)); this._nodeExecutionContext = new js.ExecutionContext(this, new CRExecutionContext(this._nodeSession, event.context), 'electron');
f(await js.evaluate(this._nodeExecutionContext, false /* returnByValue */, `process.mainModule.require('electron')`)); f(await js.evaluate(this._nodeExecutionContext, false /* returnByValue */, `process.mainModule.require('electron')`));
} }
}); });

Просмотреть файл

@ -64,9 +64,11 @@ export class ExecutionContext extends SdkObject {
private _delegate: ExecutionContextDelegate; private _delegate: ExecutionContextDelegate;
private _utilityScriptPromise: Promise<JSHandle> | undefined; private _utilityScriptPromise: Promise<JSHandle> | undefined;
private _contextDestroyedRace = new ScopedRace(); private _contextDestroyedRace = new ScopedRace();
readonly worldNameForTest: string;
constructor(parent: SdkObject, delegate: ExecutionContextDelegate) { constructor(parent: SdkObject, delegate: ExecutionContextDelegate, worldNameForTest: string) {
super(parent, 'execution-context'); super(parent, 'execution-context');
this.worldNameForTest = worldNameForTest;
this._delegate = delegate; this._delegate = delegate;
} }
@ -122,7 +124,7 @@ export class ExecutionContext extends SdkObject {
${utilityScriptSource.source} ${utilityScriptSource.source}
return new (module.exports.UtilityScript())(); return new (module.exports.UtilityScript())();
})();`; })();`;
this._utilityScriptPromise = this._raceAgainstContextDestroyed(this._delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', undefined, objectId))); this._utilityScriptPromise = this._raceAgainstContextDestroyed(this._delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', 'UtilityScript', objectId)));
} }
return this._utilityScriptPromise; return this._utilityScriptPromise;
} }
@ -153,6 +155,8 @@ export class JSHandle<T = any> extends SdkObject {
this._value = value; this._value = value;
this._objectType = type; this._objectType = type;
this._preview = this._objectId ? preview || `JSHandle@${this._objectType}` : String(value); this._preview = this._objectId ? preview || `JSHandle@${this._objectType}` : String(value);
if (this._objectId && (globalThis as any).leakedJSHandles)
(globalThis as any).leakedJSHandles.set(this, new Error('Leaked JSHandle'));
} }
callFunctionNoReply(func: Function, arg: any) { callFunctionNoReply(func: Function, arg: any) {
@ -211,8 +215,11 @@ export class JSHandle<T = any> extends SdkObject {
if (this._disposed) if (this._disposed)
return; return;
this._disposed = true; this._disposed = true;
if (this._objectId) if (this._objectId) {
this._context.releaseHandle(this._objectId).catch(e => {}); this._context.releaseHandle(this._objectId).catch(e => {});
if ((globalThis as any).leakedJSHandles)
(globalThis as any).leakedJSHandles.delete(this);
}
} }
override toString(): string { override toString(): string {
@ -227,13 +234,16 @@ export class JSHandle<T = any> extends SdkObject {
return this._preview; return this._preview;
} }
worldNameForTest(): string {
return this._context.worldNameForTest;
}
_setPreview(preview: string) { _setPreview(preview: string) {
this._preview = preview; this._preview = preview;
if (this._previewCallback) if (this._previewCallback)
this._previewCallback(preview); this._previewCallback(preview);
} }
async objectCount(): Promise<number> { async objectCount(): Promise<number> {
if (!this._objectId) if (!this._objectId)
throw new Error('Can only count objects for a handle that points to the constructor prototype'); throw new Error('Can only count objects for a handle that points to the constructor prototype');

Просмотреть файл

@ -723,7 +723,7 @@ export class Worker extends SdkObject {
} }
_createExecutionContext(delegate: js.ExecutionContextDelegate) { _createExecutionContext(delegate: js.ExecutionContextDelegate) {
this._existingExecutionContext = new js.ExecutionContext(this, delegate); this._existingExecutionContext = new js.ExecutionContext(this, delegate, 'worker');
this._executionContextCallback(this._existingExecutionContext); this._executionContextCallback(this._existingExecutionContext);
} }

Просмотреть файл

@ -0,0 +1,151 @@
/**
* 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 { MultiMap } from '../../packages/playwright-core/lib/utils/multimap';
import { test, expect } from './pageTest';
function leakedJSHandles(): string {
const map = new MultiMap();
for (const [h, e] of (globalThis as any).leakedJSHandles) {
const name = `[${h.worldNameForTest()}] ${h.preview()}`;
if (name === '[main] UtilityScript' || name === '[utility] UtilityScript' || name === '[main] InjectedScript' || name === '[utility] InjectedScript')
continue;
map.set(e.stack, name);
}
if (!map.size)
return '';
const lines: string[] = [];
lines.push('=============================');
lines.push('Leaked JSHandles:');
for (const key of map.keys()) {
lines.push('=============================');
for (const value of map.get(key))
lines.push(value);
lines.push('in ' + key);
}
return lines.join('\n');
}
async function objectCounts(pageImpl, constructorName: string): Promise<{ main: number, utility: number }> {
const result = { main: 0, utility: 0 };
for (const world of ['main', 'utility']) {
const context = await pageImpl.mainFrame()._context(world);
const prototype = await context.evaluateHandle(name => (window as any)[name].prototype, constructorName);
result[world] = await prototype.objectCount();
}
return result;
}
test.beforeEach(() => {
(globalThis as any).leakedJSHandles = new Map();
});
test.afterEach(() => {
(globalThis as any).leakedJSHandles = null;
});
test('click should not leak', async ({ page, browserName, toImpl }) => {
await page.setContent(`
<button>static button 1</button>
<button>static button 2</button>
<div id="buttons"></div>
`);
// Create JS wrappers for static elements.
await page.evaluate(() => document.querySelectorAll('button'));
for (let i = 0; i < 25; ++i) {
await page.evaluate(i => {
const element = document.createElement('button');
element.textContent = 'dynamic ' + i;
document.getElementById('buttons').appendChild(element);
}, i);
await page.locator('#buttons > button').click();
await page.evaluate(() => {
document.getElementById('buttons').textContent = '';
});
}
expect(leakedJSHandles()).toBeFalsy();
if (browserName === 'chromium') {
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
expect(counts.main + counts.utility).toBeLessThan(25);
}
});
test('fill should not leak', async ({ page, mode, browserName, toImpl }) => {
test.skip(mode !== 'default');
await page.setContent(`
<input value="static input 1"</input>
<input value="static input 2"</input>
<div id="inputs"></div>
`);
// Create JS wrappers for static elements.
await page.evaluate(() => document.querySelectorAll('input'));
for (let i = 0; i < 25; ++i) {
await page.evaluate(i => {
const element = document.createElement('input');
document.getElementById('inputs').appendChild(element);
}, i);
await page.locator('#inputs > input').fill('input ' + i);
await page.evaluate(() => {
document.getElementById('inputs').textContent = '';
});
}
expect(leakedJSHandles()).toBeFalsy();
if (browserName === 'chromium') {
const counts = await objectCounts(toImpl(page), 'HTMLInputElement');
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
expect(counts.main + counts.utility).toBeLessThan(25);
}
});
test('expect should not leak', async ({ page, mode, browserName, toImpl }) => {
test.skip(mode !== 'default');
await page.setContent(`
<button>static button 1</button>
<button>static button 2</button>
<div id="buttons"></div>
`);
for (let i = 0; i < 25; ++i) {
await page.evaluate(i => {
const element = document.createElement('button');
element.textContent = 'dynamic ' + i;
document.getElementById('buttons').appendChild(element);
}, i);
await expect(page.locator('#buttons > button')).toBeVisible();
await page.evaluate(() => {
document.getElementById('buttons').textContent = '';
});
}
expect(leakedJSHandles()).toBeFalsy();
if (browserName === 'chromium') {
const counts = await objectCounts(toImpl(page), 'HTMLButtonElement');
expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2);
expect(counts.main + counts.utility).toBeLessThan(25);
}
});