chore: cleanup more state upon disconnect (#36132)

This commit is contained in:
Dmitry Gozman 2025-05-30 13:59:20 +00:00 committed by GitHub
parent 43a086a8de
commit e58f076d42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 72 additions and 22 deletions

View File

@ -115,8 +115,8 @@ class JSCoverage {
} }
async stop(): Promise<channels.PageStopJSCoverageResult> { async stop(): Promise<channels.PageStopJSCoverageResult> {
assert(this._enabled, 'JSCoverage is not enabled'); if (!this._enabled)
this._enabled = false; return { entries: [] };
const [profileResponse] = await Promise.all([ const [profileResponse] = await Promise.all([
this._client.send('Profiler.takePreciseCoverage'), this._client.send('Profiler.takePreciseCoverage'),
this._client.send('Profiler.stopPreciseCoverage'), this._client.send('Profiler.stopPreciseCoverage'),
@ -124,6 +124,7 @@ class JSCoverage {
this._client.send('Debugger.disable'), this._client.send('Debugger.disable'),
] as const); ] as const);
eventsHelper.removeEventListeners(this._eventListeners); eventsHelper.removeEventListeners(this._eventListeners);
this._enabled = false;
const coverage: channels.PageStopJSCoverageResult = { entries: [] }; const coverage: channels.PageStopJSCoverageResult = { entries: [] };
for (const entry of profileResponse.result) { for (const entry of profileResponse.result) {
@ -197,14 +198,15 @@ class CSSCoverage {
} }
async stop(): Promise<channels.PageStopCSSCoverageResult> { async stop(): Promise<channels.PageStopCSSCoverageResult> {
assert(this._enabled, 'CSSCoverage is not enabled'); if (!this._enabled)
this._enabled = false; return { entries: [] };
const ruleTrackingResponse = await this._client.send('CSS.stopRuleUsageTracking'); const ruleTrackingResponse = await this._client.send('CSS.stopRuleUsageTracking');
await Promise.all([ await Promise.all([
this._client.send('CSS.disable'), this._client.send('CSS.disable'),
this._client.send('DOM.disable'), this._client.send('DOM.disable'),
]); ]);
eventsHelper.removeEventListeners(this._eventListeners); eventsHelper.removeEventListeners(this._eventListeners);
this._enabled = false;
// aggregate by styleSheetId // aggregate by styleSheetId
const styleSheetIdToCoverage = new Map(); const styleSheetIdToCoverage = new Map();

View File

@ -54,6 +54,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
private _bindings: PageBinding[] = []; private _bindings: PageBinding[] = [];
private _initScritps: InitScript[] = []; private _initScritps: InitScript[] = [];
private _dialogHandler: (dialog: Dialog) => boolean; private _dialogHandler: (dialog: Dialog) => boolean;
private _clockPaused = false;
static from(parentScope: DispatcherScope, context: BrowserContext): BrowserContextDispatcher { static from(parentScope: DispatcherScope, context: BrowserContext): BrowserContextDispatcher {
const result = parentScope.connection.existingDispatcher<BrowserContextDispatcher>(context); const result = parentScope.connection.existingDispatcher<BrowserContextDispatcher>(context);
@ -343,10 +344,12 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
async clockPauseAt(params: channels.BrowserContextClockPauseAtParams, metadata?: CallMetadata | undefined): Promise<channels.BrowserContextClockPauseAtResult> { async clockPauseAt(params: channels.BrowserContextClockPauseAtParams, metadata?: CallMetadata | undefined): Promise<channels.BrowserContextClockPauseAtResult> {
await this._context.clock.pauseAt(params.timeString ?? params.timeNumber ?? 0); await this._context.clock.pauseAt(params.timeString ?? params.timeNumber ?? 0);
this._clockPaused = true;
} }
async clockResume(params: channels.BrowserContextClockResumeParams, metadata?: CallMetadata | undefined): Promise<channels.BrowserContextClockResumeResult> { async clockResume(params: channels.BrowserContextClockResumeParams, metadata?: CallMetadata | undefined): Promise<channels.BrowserContextClockResumeResult> {
await this._context.clock.resume(); await this._context.clock.resume();
this._clockPaused = false;
} }
async clockRunFor(params: channels.BrowserContextClockRunForParams, metadata?: CallMetadata | undefined): Promise<channels.BrowserContextClockRunForResult> { async clockRunFor(params: channels.BrowserContextClockRunForParams, metadata?: CallMetadata | undefined): Promise<channels.BrowserContextClockRunForResult> {
@ -386,5 +389,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
this._bindings = []; this._bindings = [];
this._context.removeInitScripts(this._initScritps).catch(() => {}); this._context.removeInitScripts(this._initScritps).catch(() => {});
this._initScritps = []; this._initScritps = [];
if (this._clockPaused)
this._context.clock.resume().catch(() => {});
this._clockPaused = false;
} }
} }

View File

@ -49,6 +49,8 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
private _bindings: PageBinding[] = []; private _bindings: PageBinding[] = [];
private _initScripts: InitScript[] = []; private _initScripts: InitScript[] = [];
private _locatorHandlers = new Set<number>(); private _locatorHandlers = new Set<number>();
private _jsCoverageActive = false;
private _cssCoverageActive = false;
static from(parentScope: BrowserContextDispatcher, page: Page): PageDispatcher { static from(parentScope: BrowserContextDispatcher, page: Page): PageDispatcher {
return PageDispatcher.fromNullable(parentScope, page)!; return PageDispatcher.fromNullable(parentScope, page)!;
@ -229,7 +231,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
async updateSubscription(params: channels.PageUpdateSubscriptionParams): Promise<void> { async updateSubscription(params: channels.PageUpdateSubscriptionParams): Promise<void> {
if (params.event === 'fileChooser') if (params.event === 'fileChooser')
await this._page.setFileChooserIntercepted(params.enabled); await this._page.setFileChooserInterceptedBy(params.enabled, this);
if (params.enabled) if (params.enabled)
this._subscriptions.add(params.event); this._subscriptions.add(params.event);
else else
@ -304,23 +306,29 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
} }
async startJSCoverage(params: channels.PageStartJSCoverageParams, metadata: CallMetadata): Promise<void> { async startJSCoverage(params: channels.PageStartJSCoverageParams, metadata: CallMetadata): Promise<void> {
this._jsCoverageActive = true;
const coverage = this._page.coverage as CRCoverage; const coverage = this._page.coverage as CRCoverage;
await coverage.startJSCoverage(params); await coverage.startJSCoverage(params);
} }
async stopJSCoverage(params: channels.PageStopJSCoverageParams, metadata: CallMetadata): Promise<channels.PageStopJSCoverageResult> { async stopJSCoverage(params: channels.PageStopJSCoverageParams, metadata: CallMetadata): Promise<channels.PageStopJSCoverageResult> {
const coverage = this._page.coverage as CRCoverage; const coverage = this._page.coverage as CRCoverage;
return await coverage.stopJSCoverage(); const result = await coverage.stopJSCoverage();
this._jsCoverageActive = false;
return result;
} }
async startCSSCoverage(params: channels.PageStartCSSCoverageParams, metadata: CallMetadata): Promise<void> { async startCSSCoverage(params: channels.PageStartCSSCoverageParams, metadata: CallMetadata): Promise<void> {
this._cssCoverageActive = true;
const coverage = this._page.coverage as CRCoverage; const coverage = this._page.coverage as CRCoverage;
await coverage.startCSSCoverage(params); await coverage.startCSSCoverage(params);
} }
async stopCSSCoverage(params: channels.PageStopCSSCoverageParams, metadata: CallMetadata): Promise<channels.PageStopCSSCoverageResult> { async stopCSSCoverage(params: channels.PageStopCSSCoverageParams, metadata: CallMetadata): Promise<channels.PageStopCSSCoverageResult> {
const coverage = this._page.coverage as CRCoverage; const coverage = this._page.coverage as CRCoverage;
return await coverage.stopCSSCoverage(); const result = await coverage.stopCSSCoverage();
this._cssCoverageActive = false;
return result;
} }
_onFrameAttached(frame: Frame) { _onFrameAttached(frame: Frame) {
@ -343,6 +351,13 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
for (const uid of this._locatorHandlers) for (const uid of this._locatorHandlers)
this._page.unregisterLocatorHandler(uid); this._page.unregisterLocatorHandler(uid);
this._locatorHandlers.clear(); this._locatorHandlers.clear();
this._page.setFileChooserInterceptedBy(false, this).catch(() => {});
if (this._jsCoverageActive)
(this._page.coverage as CRCoverage).stopJSCoverage().catch(() => {});
this._jsCoverageActive = false;
if (this._cssCoverageActive)
(this._page.coverage as CRCoverage).stopCSSCoverage().catch(() => {});
this._cssCoverageActive = false;
} }
} }

View File

@ -151,7 +151,7 @@ export class Page extends SdkObject {
private _emulatedSize: EmulatedSize | undefined; private _emulatedSize: EmulatedSize | undefined;
private _extraHTTPHeaders: types.HeadersArray | undefined; private _extraHTTPHeaders: types.HeadersArray | undefined;
private _emulatedMedia: Partial<EmulatedMedia> = {}; private _emulatedMedia: Partial<EmulatedMedia> = {};
private _interceptFileChooser = false; private _fileChooserInterceptedBy = new Set<any>();
private readonly _pageBindings = new Map<string, PageBinding>(); private readonly _pageBindings = new Map<string, PageBinding>();
initScripts: InitScript[] = []; initScripts: InitScript[] = [];
readonly screenshotter: Screenshotter; readonly screenshotter: Screenshotter;
@ -260,19 +260,16 @@ export class Page extends SdkObject {
await this.setClientRequestInterceptor(undefined); await this.setClientRequestInterceptor(undefined);
await this.setServerRequestInterceptor(undefined); await this.setServerRequestInterceptor(undefined);
await this.setFileChooserIntercepted(false);
// Re-navigate once init scripts are gone. // Re-navigate once init scripts are gone.
// TODO: we should have a timeout for `resetForReuse`. // TODO: we should have a timeout for `resetForReuse`.
await this.mainFrame().goto(metadata, 'about:blank', { timeout: 0 }); await this.mainFrame().goto(metadata, 'about:blank', { timeout: 0 });
this._emulatedSize = undefined; this._emulatedSize = undefined;
this._emulatedMedia = {}; this._emulatedMedia = {};
this._extraHTTPHeaders = undefined; this._extraHTTPHeaders = undefined;
this._interceptFileChooser = false;
await Promise.all([ await Promise.all([
this.delegate.updateEmulatedViewportSize(), this.delegate.updateEmulatedViewportSize(),
this.delegate.updateEmulateMedia(), this.delegate.updateEmulateMedia(),
this.delegate.updateFileChooserInterception(),
]); ]);
await this.delegate.resetForReuse(); await this.delegate.resetForReuse();
@ -744,13 +741,18 @@ export class Page extends SdkObject {
} }
} }
async setFileChooserIntercepted(enabled: boolean): Promise<void> { async setFileChooserInterceptedBy(enabled: boolean, by: any): Promise<void> {
this._interceptFileChooser = enabled; const wasIntercepted = this.fileChooserIntercepted();
await this.delegate.updateFileChooserInterception(); if (enabled)
this._fileChooserInterceptedBy.add(by);
else
this._fileChooserInterceptedBy.delete(by);
if (wasIntercepted !== this.fileChooserIntercepted())
await this.delegate.updateFileChooserInterception();
} }
fileChooserIntercepted() { fileChooserIntercepted() {
return this._interceptFileChooser; return this._fileChooserInterceptedBy.size > 0;
} }
frameNavigatedToNewDocument(frame: frames.Frame) { frameNavigatedToNewDocument(frame: frames.Frame) {

View File

@ -52,6 +52,12 @@ const test = playwrightTest.extend<ExtraFixtures>({
test.slow(true, 'All connect tests are slow'); test.slow(true, 'All connect tests are slow');
test.skip(({ mode }) => mode.startsWith('service')); test.skip(({ mode }) => mode.startsWith('service'));
async function disconnect(page: Page) {
await page.context().browser().close();
// Give disconnect some time to cleanup.
await new Promise(f => setTimeout(f, 1000));
}
test('should connect two clients', async ({ connect, remoteServer, server }) => { test('should connect two clients', async ({ connect, remoteServer, server }) => {
const browserA = await connect(remoteServer.wsEndpoint()); const browserA = await connect(remoteServer.wsEndpoint());
expect(browserA.contexts().length).toBe(0); expect(browserA.contexts().length).toBe(0);
@ -79,7 +85,8 @@ test('should connect two clients', async ({ connect, remoteServer, server }) =>
await expect(pageB2).toHaveURL('/frames/frame.html'); await expect(pageB2).toHaveURL('/frames/frame.html');
// Both contexts and pages should be still operational after any client disconnects. // Both contexts and pages should be still operational after any client disconnects.
await browserA.close(); await disconnect(pageA1);
await expect(pageB1).toHaveURL(server.EMPTY_PAGE); await expect(pageB1).toHaveURL(server.EMPTY_PAGE);
await expect(pageB2).toHaveURL(server.PREFIX + '/frames/frame.html'); await expect(pageB2).toHaveURL(server.PREFIX + '/frames/frame.html');
}); });
@ -109,20 +116,39 @@ test('should receive viewport size changes', async ({ twoPages }) => {
await expect.poll(() => pageA.viewportSize()).toEqual({ width: 456, height: 567 }); await expect.poll(() => pageA.viewportSize()).toEqual({ width: 456, height: 567 });
}); });
test('should not allow parallel js coverage', async ({ twoPages, browserName }) => { test('should not allow parallel js coverage and cleanup upon disconnect', async ({ twoPages, browserName }) => {
test.skip(browserName !== 'chromium'); test.skip(browserName !== 'chromium');
const { pageA, pageB } = twoPages; const { pageA, pageB } = twoPages;
await pageA.coverage.startJSCoverage(); await pageA.coverage.startJSCoverage();
const error = await pageB.coverage.startJSCoverage().catch(e => e); const error = await pageB.coverage.startJSCoverage().catch(e => e);
expect(error.message).toContain('JSCoverage is already enabled'); expect(error.message).toContain('JSCoverage is already enabled');
// Should cleanup coverage on disconnect and allow another client to start it.
await disconnect(pageA);
await pageB.coverage.startJSCoverage();
}); });
test('should not allow parallel css coverage', async ({ twoPages, browserName }) => { test('should not allow parallel css coverage', async ({ twoPages, browserName }) => {
test.skip(browserName !== 'chromium'); test.skip(browserName !== 'chromium');
const { pageA, pageB } = twoPages; const { pageA, pageB } = twoPages;
await pageA.coverage.startCSSCoverage(); await pageA.coverage.startCSSCoverage();
const error = await pageB.coverage.startCSSCoverage().catch(e => e); const error = await pageB.coverage.startCSSCoverage().catch(e => e);
expect(error.message).toContain('CSSCoverage is already enabled'); expect(error.message).toContain('CSSCoverage is already enabled');
// Should cleanup coverage on disconnect and allow another client to start it.
await disconnect(pageA);
await pageB.coverage.startCSSCoverage();
});
test('should unpause clock', async ({ twoPages }) => {
const { pageA, pageB } = twoPages;
await pageA.clock.install({ time: 1000 });
await pageA.clock.pauseAt(2000);
const promise = pageB.evaluate(() => new Promise(f => setTimeout(f, 1000)));
await disconnect(pageA);
await promise;
}); });
test('last emulateMedia wins', async ({ twoPages }) => { test('last emulateMedia wins', async ({ twoPages }) => {
@ -153,8 +179,7 @@ test('should remove exposed bindings upon disconnect', async ({ twoPages }) => {
await pageB.context().exposeBinding('contextBindingB', () => 'contextBindingBResult'); await pageB.context().exposeBinding('contextBindingB', () => 'contextBindingBResult');
expect(await pageA.evaluate(() => (window as any).contextBindingB())).toBe('contextBindingBResult'); expect(await pageA.evaluate(() => (window as any).contextBindingB())).toBe('contextBindingBResult');
await pageA.context().browser().close(); await disconnect(pageA);
await new Promise(f => setTimeout(f, 1000)); // Give disconnect some time to cleanup.
expect(await pageB.evaluate(() => (window as any).pageBindingA)).toBe(undefined); expect(await pageB.evaluate(() => (window as any).pageBindingA)).toBe(undefined);
expect(await pageB.evaluate(() => (window as any).contextBindingA)).toBe(undefined); expect(await pageB.evaluate(() => (window as any).contextBindingA)).toBe(undefined);
@ -179,8 +204,7 @@ test('should remove init scripts upon disconnect', async ({ twoPages, server })
expect(await pageA.evaluate(() => (window as any).pageValueB)).toBe('pageValueB'); expect(await pageA.evaluate(() => (window as any).pageValueB)).toBe('pageValueB');
expect(await pageA.evaluate(() => (window as any).contextValueB)).toBe('contextValueB'); expect(await pageA.evaluate(() => (window as any).contextValueB)).toBe('contextValueB');
await pageB.context().browser().close(); await disconnect(pageB);
await new Promise(f => setTimeout(f, 1000)); // Give disconnect some time to cleanup.
await pageA.goto(server.EMPTY_PAGE); await pageA.goto(server.EMPTY_PAGE);
expect(await pageA.evaluate(() => (window as any).pageValueB)).toBe(undefined); expect(await pageA.evaluate(() => (window as any).pageValueB)).toBe(undefined);
@ -216,7 +240,8 @@ test('should remove locator handlers upon disconnect', async ({ twoPages, server
(window as any).setupAnnoyingInterstitial('mouseover', 1); (window as any).setupAnnoyingInterstitial('mouseover', 1);
}); });
await pageA.context().browser().close(); await disconnect(pageA);
const error = await pageB.locator('#target').click({ timeout: 3000 }).catch(e => e); const error = await pageB.locator('#target').click({ timeout: 3000 }).catch(e => e);
expect(error.message).toContain('Timeout 3000ms exceeded'); expect(error.message).toContain('Timeout 3000ms exceeded');
expect(error.message).toContain('intercepts pointer events'); expect(error.message).toContain('intercepts pointer events');