Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core(global-listeners): iterate all execution contexts #15054

Merged
merged 6 commits into from
May 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions core/gather/driver/target-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,22 @@ class TargetManager extends ProtocolEventEmitter {

this._enabled = false;
this._rootCdpSession = cdpSession;
this._mainFrameId = '';

/**
* A map of target id to target/session information. Used to ensure unique
* attached targets.
* @type {Map<string, TargetWithSession>}
*/
this._targetIdToTargets = new Map();
/** @type {Map<string, LH.Crdp.Runtime.ExecutionContextDescription>} */
this._executionContextIdToDescriptions = new Map();

this._onSessionAttached = this._onSessionAttached.bind(this);
this._onFrameNavigated = this._onFrameNavigated.bind(this);
this._onExecutionContextCreated = this._onExecutionContextCreated.bind(this);
this._onExecutionContextDestroyed = this._onExecutionContextDestroyed.bind(this);
this._onExecutionContextsCleared = this._onExecutionContextsCleared.bind(this);
}

/**
Expand Down Expand Up @@ -97,6 +103,12 @@ class TargetManager extends ProtocolEventEmitter {
return this._findSession(rootSessionId);
}

mainFrameExecutionContexts() {
return [...this._executionContextIdToDescriptions.values()].filter(executionContext => {
return executionContext.auxData.frameId === this._mainFrameId;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this since we only listen on the root session?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, just adding for later in case that ever changes. Could be easy to overlook.

Copy link
Collaborator Author

@connorjclark connorjclark May 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

er, wait no this is important now. It filters out a number of execution contexts for this simple page:

Who Framed A11y Tester?!
<iframe src="a11y_tester.html" width="100%" height="100%"></iframe>

coming from the iframe. comment out the iframe, and only the first execution context is present

{
  id: 7,
  origin: 'http://localhost:10503',
  name: '',
  uniqueId: '5889311435627745206.-699647868331920856',
  auxData: {
    isDefault: true,
    type: 'default',
    frameId: 'E3DEC3BA2977996FAA61E3E4418D4122'
  }
}
{
  id: 9,
  origin: 'http://localhost:10503',
  name: '',
  uniqueId: '-9077244664686306541.-7829375488897728831',
  auxData: {
    isDefault: true,
    type: 'default',
    frameId: '48AA5C7F280A6810D012BAD09CB99732'
  }
}
{
  id: 11,
  origin: 'http://localhost:10503',
  name: '',
  uniqueId: '2049821010959430554.-6542202802565848863',
  auxData: {
    isDefault: true,
    type: 'default',
    frameId: '9548FD706B2909A5E58864CB898DDF7D'
  }
}
{
  id: 13,
  origin: '://',
  name: '',
  uniqueId: '7110504061362463461.-3201941903195113152',
  auxData: {
    isDefault: true,
    type: 'default',
    frameId: 'E1DD688E090D4775B24C805636F001C2'
  }
}

9 is the iframe in the main document, 11 is an inframe inside a11y_tester.html. not sure what 13 could be, perhaps an extenstion.

});
}

/**
* @param {LH.Puppeteer.CDPSession} cdpSession
*/
Expand Down Expand Up @@ -153,6 +165,27 @@ class TargetManager extends ProtocolEventEmitter {
}
}

/**
* @param {LH.Crdp.Runtime.ExecutionContextCreatedEvent} event
*/
_onExecutionContextCreated(event) {
if (event.context.name === '__puppeteer_utility_world__') return;
if (event.context.name === 'lighthouse_isolated_context') return;
connorjclark marked this conversation as resolved.
Show resolved Hide resolved

this._executionContextIdToDescriptions.set(event.context.uniqueId, event.context);
}

/**
* @param {LH.Crdp.Runtime.ExecutionContextDestroyedEvent} event
*/
_onExecutionContextDestroyed(event) {
this._executionContextIdToDescriptions.delete(event.executionContextUniqueId);
}

_onExecutionContextsCleared() {
this._executionContextIdToDescriptions.clear();
}

/**
* Returns a listener for all protocol events from session, and augments the
* event with the sessionId.
Expand Down Expand Up @@ -183,10 +216,17 @@ class TargetManager extends ProtocolEventEmitter {

this._enabled = true;
this._targetIdToTargets = new Map();
this._executionContextIdToDescriptions = new Map();

this._rootCdpSession.on('Page.frameNavigated', this._onFrameNavigated);
this._rootCdpSession.on('Runtime.executionContextCreated', this._onExecutionContextCreated);
this._rootCdpSession.on('Runtime.executionContextDestroyed', this._onExecutionContextDestroyed);
this._rootCdpSession.on('Runtime.executionContextsCleared', this._onExecutionContextsCleared);

await this._rootCdpSession.send('Page.enable');
await this._rootCdpSession.send('Runtime.enable');
connorjclark marked this conversation as resolved.
Show resolved Hide resolved

this._mainFrameId = (await this._rootCdpSession.send('Page.getFrameTree')).frameTree.frame.id;

// Start with the already attached root session.
await this._onSessionAttached(this._rootCdpSession);
Expand All @@ -197,14 +237,23 @@ class TargetManager extends ProtocolEventEmitter {
*/
async disable() {
this._rootCdpSession.off('Page.frameNavigated', this._onFrameNavigated);
this._rootCdpSession.off('Runtime.executionContextCreated', this._onExecutionContextCreated);
this._rootCdpSession.off('Runtime.executionContextDestroyed',
this._onExecutionContextDestroyed);
this._rootCdpSession.off('Runtime.executionContextsCleared', this._onExecutionContextsCleared);

for (const {cdpSession, protocolListener} of this._targetIdToTargets.values()) {
cdpSession.off('*', protocolListener);
cdpSession.off('sessionattached', this._onSessionAttached);
}

await this._rootCdpSession.send('Page.disable');
await this._rootCdpSession.send('Runtime.disable');

this._enabled = false;
this._targetIdToTargets = new Map();
this._executionContextIdToDescriptions = new Map();
this._mainFrameId = '';
}
}

Expand Down
59 changes: 38 additions & 21 deletions core/gather/gatherers/global-listeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* around page unload, but this can be expanded in the future.
*/

import log from 'lighthouse-logger';

import FRGatherer from '../base-gatherer.js';

class GlobalListeners extends FRGatherer {
Expand Down Expand Up @@ -61,30 +63,45 @@ class GlobalListeners extends FRGatherer {
async getArtifact(passContext) {
const session = passContext.driver.defaultSession;

// Get a RemoteObject handle to `window`.
const {result: {objectId}} = await session.sendCommand('Runtime.evaluate', {
expression: 'window',
returnByValue: false,
});
if (!objectId) {
throw new Error('Error fetching information about the global object');
}
/** @type {Array<LH.Artifacts.GlobalListener>} */
const listeners = [];

// And get all its listeners of interest.
const {listeners} = await session.sendCommand('DOMDebugger.getEventListeners', {objectId});
const filteredListeners = listeners.filter(GlobalListeners._filterForAllowlistedTypes)
.map(listener => {
const {type, scriptId, lineNumber, columnNumber} = listener;
return {
type,
scriptId,
lineNumber,
columnNumber,
};
});
for (const executionContext of passContext.driver.targetManager.mainFrameExecutionContexts()) {
// Get a RemoteObject handle to `window`.
let objectId;
try {
const {result} = await session.sendCommand('Runtime.evaluate', {
expression: 'window',
returnByValue: false,
uniqueContextId: executionContext.uniqueId,
});
if (!result.objectId) {
throw new Error('Error fetching information about the global object');
}
objectId = result.objectId;
} catch (err) {
// Execution context is no longer valid, but don't let that fail the gatherer.
log.warn('Execution context is no longer valid', executionContext, err);
continue;
}

// And get all its listeners of interest.
const response = await session.sendCommand('DOMDebugger.getEventListeners', {objectId});
for (const listener of response.listeners) {
if (GlobalListeners._filterForAllowlistedTypes(listener)) {
const {type, scriptId, lineNumber, columnNumber} = listener;
listeners.push({
type,
scriptId,
lineNumber,
columnNumber,
});
}
}
}

// Dedupe listeners with same underlying data.
return this.dedupeListeners(filteredListeners);
return this.dedupeListeners(listeners);
}
}

Expand Down
11 changes: 11 additions & 0 deletions core/legacy/gather/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ class Driver {
rootSession: () => {
return this.defaultSession;
},
// For legacy driver, only bother supporting access to the default execution context.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a reasonable time to stop trying for parity wrt legacy driver. So...just giving the default EC here.

mainFrameExecutionContexts: () => {
// @ts-expect-error - undefined ids are OK for purposes of calling protocol commands like Runtime.evaluate.
return [/** @type {LH.Crdp.Runtime.ExecutionContextDescription} */({
id: undefined,
uniqueId: undefined,
origin: '',
name: '',
auxData: {isDefault: true, type: 'default', frameId: ''},
})];
},
/**
* Bind to *any* protocol event.
* @param {'protocolevent'} event
Expand Down
4 changes: 4 additions & 0 deletions core/test/gather/driver-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ beforeEach(() => {
const puppeteerSession = createMockCdpSession();
puppeteerSession.send
.mockResponse('Page.enable')
.mockResponse('Page.getFrameTree', {frameTree: {frame: {id: 'mainFrameId'}}})
.mockResponse('Runtime.enable')
.mockResponse('Page.disable')
.mockResponse('Runtime.disable')
.mockResponse('Target.getTargetInfo', {targetInfo: {type: 'page', targetId: 'page'}})
.mockResponse('Network.enable')
.mockResponse('Target.setAutoAttach')
Expand Down
2 changes: 2 additions & 0 deletions core/test/gather/driver/network-monitor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ describe('NetworkMonitor', () => {
const cdpSessionMock = createMockCdpSession(id);
cdpSessionMock.send
.mockResponse('Page.enable')
.mockResponse('Page.getFrameTree', {frameTree: {frame: {id: 'mainFrameId'}}})
.mockResponse('Runtime.enable')
.mockResponse('Target.getTargetInfo', {targetInfo: {type: targetType, targetId: id}})
.mockResponse('Network.enable')
.mockResponse('Target.setAutoAttach')
Expand Down
11 changes: 10 additions & 1 deletion core/test/gather/driver/target-manager-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ describe('TargetManager', () => {
sendMock = sessionMock.send;
sendMock
.mockResponse('Page.enable')
.mockResponse('Page.getFrameTree', {frameTree: {frame: {id: 'mainFrameId'}}})
.mockResponse('Runtime.enable')
.mockResponse('Page.disable')
.mockResponse('Runtime.disable')
.mockResponse('Runtime.runIfWaitingForDebugger');
targetManager = new TargetManager(sessionMock.asCdpSession());
targetInfo = createTargetInfo();
Expand All @@ -55,6 +59,7 @@ describe('TargetManager', () => {

expect(sendMock.findAllInvocations('Target.setAutoAttach')).toHaveLength(1);
expect(sendMock.findAllInvocations('Runtime.runIfWaitingForDebugger')).toHaveLength(1);
expect(targetManager._mainFrameId).toEqual('mainFrameId');
});

it('should autoattach to further unique sessions', async () => {
Expand All @@ -78,7 +83,7 @@ describe('TargetManager', () => {
await targetManager.enable();

expect(sessionMock.on).toHaveBeenCalled();
const sessionListener = sessionMock.on.mock.calls[3][1];
const sessionListener = sessionMock.on.mock.calls.find(c => c[0] === 'sessionattached')[1];

// Original, attach.
expect(sendMock.findAllInvocations('Target.getTargetInfo')).toHaveLength(1);
Expand Down Expand Up @@ -258,6 +263,8 @@ describe('TargetManager', () => {
// Still mock command responses at session level.
rootSession.send = createMockSendCommandFn({useSessionId: false})
.mockResponse('Page.enable')
.mockResponse('Page.getFrameTree', {frameTree: {frame: {id: ''}}})
.mockResponse('Runtime.enable')
.mockResponse('Target.getTargetInfo', {targetInfo: rootTargetInfo})
.mockResponse('Network.enable')
.mockResponse('Target.setAutoAttach')
Expand Down Expand Up @@ -327,6 +334,8 @@ describe('TargetManager', () => {
// Still mock command responses at session level.
rootSession.send = createMockSendCommandFn({useSessionId: false})
.mockResponse('Page.enable')
.mockResponse('Page.getFrameTree', {frameTree: {frame: {id: ''}}})
.mockResponse('Runtime.enable')
.mockResponse('Target.getTargetInfo', {targetInfo})
.mockResponse('Network.enable')
.mockResponse('Target.setAutoAttach')
Expand Down
14 changes: 14 additions & 0 deletions core/test/scenarios/api-test-pptr.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import jestMock from 'jest-mock';
import * as api from '../../index.js';
import {createTestState, getAuditsBreakdown} from './pptr-test-utils.js';
import {LH_ROOT} from '../../../root.js';
import {TargetManager} from '../../gather/driver/target-manager.js';

describe('Fraggle Rock API', function() {
// eslint-disable-next-line no-invalid-this
Expand Down Expand Up @@ -147,6 +148,7 @@ describe('Fraggle Rock API', function() {

// eslint-disable-next-line max-len
it('should know target type of network requests from frames created before timespan', async () => {
const spy = jestMock.spyOn(TargetManager.prototype, '_onExecutionContextCreated');
state.server.baseDir = `${LH_ROOT}/cli/test/fixtures`;
const {page, serverBaseUrl} = state;

Expand Down Expand Up @@ -192,6 +194,18 @@ Array [
},
]
`);

// Check that TargetManager is getting execution context created events even if connecting
// to the page after they already exist.
// There are two execution contexts, one for the main frame and one for the iframe of
// the same origin.
const contextCreatedMainFrameCalls =
spy.mock.calls.filter(call => call[0].context.origin === 'http://localhost:10200');
// For some reason, puppeteer gives us two created events for every uniqueId,
// so using Set here to ignore that detail.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some poking around. I think it's because we Runtime.disable in the stopInstrumentation phase console messages gatherer

await driver.defaultSession.sendCommand('Runtime.disable');

Presumably Runtime.enable is called sometime in the getArtifact phase which re-emits the events.

Regardless, de-duping on the uniqueId is probably the safest way to handle this.

expect(new Set(contextCreatedMainFrameCalls.map(call => call[0].context.uniqueId)).size)
.toEqual(2);
spy.mockRestore();
});
});

Expand Down
1 change: 1 addition & 0 deletions types/gatherer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ declare module Gatherer {
url: () => Promise<string>;
targetManager: {
rootSession(): FRProtocolSession;
mainFrameExecutionContexts(): Array<Crdp.Runtime.ExecutionContextDescription>;
Copy link
Collaborator Author

@connorjclark connorjclark May 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although we only want to process main frames in global listeners, I don't want that to happen via a method called just executionContexts because that locks in the contract that this is only main frames (which could mess with potential plugins if we ever need to expand that). Having the gatherer provide the main frame id is not possible for snapshots, so this logic is kept in target manager. Hence, an explicit mainFrameExecutionContexts... and adding executionContexts just-cuz.

on(event: 'protocolevent', callback: (payload: Protocol.RawEventMessage) => void): void
off(event: 'protocolevent', callback: (payload: Protocol.RawEventMessage) => void): void
};
Expand Down