diff --git a/lighthouse-core/fraggle-rock/config/default-config.js b/lighthouse-core/fraggle-rock/config/default-config.js index 519b4f7d8c66..6050ef52c2c5 100644 --- a/lighthouse-core/fraggle-rock/config/default-config.js +++ b/lighthouse-core/fraggle-rock/config/default-config.js @@ -13,6 +13,7 @@ const artifacts = { DevtoolsLog: '', Trace: '', Accessibility: '', + AnchorElements: '', AppCacheManifest: '', CacheContents: '', ConsoleMessages: '', @@ -49,6 +50,7 @@ const defaultConfig = { /* eslint-disable max-len */ {id: artifacts.Accessibility, gatherer: 'accessibility'}, + {id: artifacts.AnchorElements, gatherer: 'anchor-elements'}, {id: artifacts.AppCacheManifest, gatherer: 'dobetterweb/appcache'}, {id: artifacts.CacheContents, gatherer: 'cache-contents'}, {id: artifacts.ConsoleMessages, gatherer: 'console-messages'}, @@ -83,6 +85,7 @@ const defaultConfig = { artifacts.Trace, artifacts.Accessibility, + artifacts.AnchorElements, artifacts.AppCacheManifest, artifacts.CacheContents, artifacts.ConsoleMessages, diff --git a/lighthouse-core/gather/driver.js b/lighthouse-core/gather/driver.js index 00fc106ffcb3..671e4e36d62c 100644 --- a/lighthouse-core/gather/driver.js +++ b/lighthouse-core/gather/driver.js @@ -7,7 +7,6 @@ const Fetcher = require('./fetcher.js'); const ExecutionContext = require('./driver/execution-context.js'); -const LHElement = require('../lib/lh-element.js'); const LHError = require('../lib/lh-error.js'); const NetworkRequest = require('../lib/network-request.js'); const EventEmitter = require('events').EventEmitter; @@ -379,29 +378,6 @@ class Driver { return !!this._domainEnabledCounts.get(domain); } - /** - * @param {string} objectId Object ID for the resolved DOM node - * @param {string} propName Name of the property - * @return {Promise} The property value, or null, if property not found - */ - async getObjectProperty(objectId, propName) { - const propertiesResponse = await this.sendCommand('Runtime.getProperties', { - objectId, - accessorPropertiesOnly: true, - generatePreview: false, - ownProperties: false, - }); - - const propertyForName = propertiesResponse.result - .find(property => property.name === propName); - - if (propertyForName && propertyForName.value) { - return propertyForName.value.value; - } else { - return null; - } - } - /** * Return the body of the response with the given ID. Rejects if getting the * body times out. @@ -419,68 +395,6 @@ class Driver { return result.body; } - /** - * @param {string} selector Selector to find in the DOM - * @return {Promise} The found element, or null, resolved in a promise - */ - async querySelector(selector) { - const documentResponse = await this.sendCommand('DOM.getDocument'); - const rootNodeId = documentResponse.root.nodeId; - - const targetNode = await this.sendCommand('DOM.querySelector', { - nodeId: rootNodeId, - selector, - }); - - if (targetNode.nodeId === 0) { - return null; - } - return new LHElement(targetNode, this); - } - - /** - * Resolves a backend node ID (from a trace event, protocol, etc) to the object ID for use with - * `Runtime.callFunctionOn`. `undefined` means the node could not be found. - * - * @param {number} backendNodeId - * @return {Promise} - */ - async resolveNodeIdToObjectId(backendNodeId) { - try { - const resolveNodeResponse = await this.sendCommand('DOM.resolveNode', {backendNodeId}); - return resolveNodeResponse.object.objectId; - } catch (err) { - if (/No node.*found/.test(err.message) || - /Node.*does not belong to the document/.test(err.message)) return undefined; - throw err; - } - } - - /** - * Resolves a proprietary devtools node path (created from page-function.js) to the object ID for use - * with `Runtime.callFunctionOn`. `undefined` means the node could not be found. - * Requires `DOM.getDocument` to have been called since the object's creation or it will always be `undefined`. - * - * @param {string} devtoolsNodePath - * @return {Promise} - */ - async resolveDevtoolsNodePathToObjectId(devtoolsNodePath) { - try { - const {nodeId} = await this.sendCommand('DOM.pushNodeByPathToFrontend', { - path: devtoolsNodePath, - }); - - const {object: {objectId}} = await this.sendCommand('DOM.resolveNode', { - nodeId, - }); - - return objectId; - } catch (err) { - if (/No node.*found/.test(err.message)) return undefined; - throw err; - } - } - /** * @param {{x: number, y: number}} position * @return {Promise} diff --git a/lighthouse-core/gather/driver/dom.js b/lighthouse-core/gather/driver/dom.js new file mode 100644 index 000000000000..c5629fca103d --- /dev/null +++ b/lighthouse-core/gather/driver/dom.js @@ -0,0 +1,58 @@ +/** + * @license Copyright 2021 The Lighthouse Authors. 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. + */ +'use strict'; + +/** + * @param {Error} err + * @return {undefined} + */ +function handlePotentialMissingNodeError(err) { + if ( + /No node.*found/.test(err.message) || + /Node.*does not belong to the document/.test(err.message) + ) { + return undefined; + } + throw err; +} + +/** + * Resolves a backend node ID (from a trace event, protocol, etc) to the object ID for use with + * `Runtime.callFunctionOn`. `undefined` means the node could not be found. + * + * @param {LH.Gatherer.FRProtocolSession} session + * @param {number} backendNodeId + * @return {Promise} + */ +async function resolveNodeIdToObjectId(session, backendNodeId) { + try { + const resolveNodeResponse = await session.sendCommand('DOM.resolveNode', {backendNodeId}); + return resolveNodeResponse.object.objectId; + } catch (err) { + return handlePotentialMissingNodeError(err); + } +} + +/** + * Resolves a proprietary devtools node path (created from page-function.js) to the object ID for use + * with `Runtime.callFunctionOn`. `undefined` means the node could not be found. + * Requires `DOM.getDocument` to have been called since the object's creation or it will always be `undefined`. + * + * @param {LH.Gatherer.FRProtocolSession} session + * @param {string} path + * @return {Promise} + */ +async function resolveDevtoolsNodePathToObjectId(session, path) { + try { + const {nodeId} = await session.sendCommand('DOM.pushNodeByPathToFrontend', {path}); + const {object: {objectId}} = await session.sendCommand('DOM.resolveNode', {nodeId}); + return objectId; + } catch (err) { + return handlePotentialMissingNodeError(err); + } +} + +module.exports = {resolveNodeIdToObjectId, resolveDevtoolsNodePathToObjectId}; diff --git a/lighthouse-core/gather/gatherers/anchor-elements.js b/lighthouse-core/gather/gatherers/anchor-elements.js index 07f0ed2ac2d0..c1aee9cc5cc7 100644 --- a/lighthouse-core/gather/gatherers/anchor-elements.js +++ b/lighthouse-core/gather/gatherers/anchor-elements.js @@ -7,7 +7,8 @@ /* global getNodeDetails */ -const Gatherer = require('./gatherer.js'); +const FRGatherer = require('../../fraggle-rock/gather/base-gatherer.js'); +const dom = require('../driver/dom.js'); const pageFunctions = require('../../lib/page-functions.js'); /* eslint-env browser, node */ @@ -74,30 +75,35 @@ function collectAnchorElements() { /* c8 ignore stop */ /** - * @param {LH.Gatherer.PassContext['driver']} driver + * @param {LH.Gatherer.FRProtocolSession} session * @param {string} devtoolsNodePath * @return {Promise>} */ -async function getEventListeners(driver, devtoolsNodePath) { - const objectId = await driver.resolveDevtoolsNodePathToObjectId(devtoolsNodePath); +async function getEventListeners(session, devtoolsNodePath) { + const objectId = await dom.resolveDevtoolsNodePathToObjectId(session, devtoolsNodePath); if (!objectId) return []; - const response = await driver.sendCommand('DOMDebugger.getEventListeners', { + const response = await session.sendCommand('DOMDebugger.getEventListeners', { objectId, }); return response.listeners.map(({type}) => ({type})); } -class AnchorElements extends Gatherer { +class AnchorElements extends FRGatherer { + /** @type {LH.Gatherer.GathererMeta} */ + meta = { + supportedModes: ['snapshot', 'navigation'], + } + /** - * @param {LH.Gatherer.PassContext} passContext + * @param {LH.Gatherer.FRTransitionalContext} passContext * @return {Promise} */ - async afterPass(passContext) { - const driver = passContext.driver; + async getArtifact(passContext) { + const session = passContext.driver.defaultSession; - const anchors = await driver.executionContext.evaluate(collectAnchorElements, { + const anchors = await passContext.driver.executionContext.evaluate(collectAnchorElements, { args: [], useIsolation: true, deps: [ @@ -105,12 +111,12 @@ class AnchorElements extends Gatherer { pageFunctions.getNodeDetailsString, ], }); - await driver.sendCommand('DOM.enable'); + await session.sendCommand('DOM.enable'); // DOM.getDocument is necessary for pushNodesByBackendIdsToFrontend to properly retrieve nodeIds if the `DOM` domain was enabled before this gatherer, invoke it to be safe. - await driver.sendCommand('DOM.getDocument', {depth: -1, pierce: true}); + await session.sendCommand('DOM.getDocument', {depth: -1, pierce: true}); const anchorsWithEventListeners = anchors.map(async anchor => { - const listeners = await getEventListeners(driver, anchor.node.devtoolsNodePath); + const listeners = await getEventListeners(session, anchor.node.devtoolsNodePath); return { ...anchor, @@ -119,7 +125,7 @@ class AnchorElements extends Gatherer { }); const result = await Promise.all(anchorsWithEventListeners); - await driver.sendCommand('DOM.disable'); + await session.sendCommand('DOM.disable'); return result; } } diff --git a/lighthouse-core/gather/gatherers/trace-elements.js b/lighthouse-core/gather/gatherers/trace-elements.js index f424ecc92da0..a41fcbc936fd 100644 --- a/lighthouse-core/gather/gatherers/trace-elements.js +++ b/lighthouse-core/gather/gatherers/trace-elements.js @@ -14,6 +14,7 @@ */ const Gatherer = require('./gatherer.js'); +const dom = require('../driver/dom.js'); const pageFunctions = require('../../lib/page-functions.js'); const TraceProcessor = require('../../lib/tracehouse/trace-processor.js'); const RectHelpers = require('../../lib/rect-helpers.js'); @@ -275,7 +276,7 @@ class TraceElements extends Gatherer { const backendNodeId = backendNodeData[i].nodeId; let response; try { - const objectId = await driver.resolveNodeIdToObjectId(backendNodeId); + const objectId = await dom.resolveNodeIdToObjectId(driver.defaultSession, backendNodeId); if (!objectId) continue; response = await driver.sendCommand('Runtime.callFunctionOn', { objectId, diff --git a/lighthouse-core/lib/lh-element.js b/lighthouse-core/lib/lh-element.js deleted file mode 100644 index 725462f2a0d3..000000000000 --- a/lighthouse-core/lib/lh-element.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @license Copyright 2016 The Lighthouse Authors. 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. - */ -'use strict'; - -const Driver = require('../gather/driver.js'); // eslint-disable-line no-unused-vars - -class LHElement { - /** - * @param {{nodeId: number}} element - * @param {Driver} driver - */ - constructor(element, driver) { - if (!element || !driver) { - throw Error('Driver and element required to create Element'); - } - this.driver = driver; - this.element = element; - } - - /** - * @param {string} name Attribute name - * @return {Promise} The attribute value or null if not found - */ - getAttribute(name) { - return this.driver - .sendCommand('DOM.getAttributes', { - nodeId: this.element.nodeId, - }) - /** - * @param resp The element attribute names & values are interleaved - */ - .then(resp => { - const attrIndex = resp.attributes.indexOf(name); - if (attrIndex === -1) { - return null; - } - - return resp.attributes[attrIndex + 1]; - }); - } - - /** - * @return {number} - */ - getNodeId() { - return this.element.nodeId; - } - - /** - * @param {string} propName Property name - * @return {Promise} The property value - */ - getProperty(propName) { - return this.driver - .sendCommand('DOM.resolveNode', { - nodeId: this.element.nodeId, - }) - .then(resp => { - if (!resp.object.objectId) { - return null; - } - return this.driver.getObjectProperty(resp.object.objectId, propName); - }) - .catch(() => null); - } -} - -module.exports = LHElement; diff --git a/lighthouse-core/test/fraggle-rock/api-test-pptr.js b/lighthouse-core/test/fraggle-rock/api-test-pptr.js index 0a74df8518ce..42536ede532a 100644 --- a/lighthouse-core/test/fraggle-rock/api-test-pptr.js +++ b/lighthouse-core/test/fraggle-rock/api-test-pptr.js @@ -95,7 +95,7 @@ describe('Fraggle Rock API', () => { const {auditResults, erroredAudits, failedAudits} = getAuditsBreakdown(lhr); // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached. - expect(auditResults.length).toMatchInlineSnapshot(`72`); + expect(auditResults.length).toMatchInlineSnapshot(`73`); expect(erroredAudits).toHaveLength(0); expect(failedAudits.map(audit => audit.id)).toContain('label'); @@ -159,7 +159,7 @@ describe('Fraggle Rock API', () => { const {lhr} = result; const {auditResults, failedAudits, erroredAudits} = getAuditsBreakdown(lhr); // TODO(FR-COMPAT): This assertion can be removed when full compatibility is reached. - expect(auditResults.length).toMatchInlineSnapshot(`102`); + expect(auditResults.length).toMatchInlineSnapshot(`103`); expect(erroredAudits).toHaveLength(0); const failedAuditIds = failedAudits.map(audit => audit.id); diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js index 806a16f76d27..8af58fb2cc85 100644 --- a/lighthouse-core/test/gather/driver-test.js +++ b/lighthouse-core/test/gather/driver-test.js @@ -7,7 +7,6 @@ const Driver = require('../../gather/driver.js'); const Connection = require('../../gather/connections/connection.js'); -const LHElement = require('../../lib/lh-element.js'); const {protocolGetVersionResponse} = require('./fake-driver.js'); const { createMockSendCommandFn, @@ -44,59 +43,6 @@ beforeEach(() => { driver = new Driver(connectionStub); }); -describe('.querySelector(All)', () => { - it('returns null when DOM.querySelector finds no node', async () => { - connectionStub.sendCommand = createMockSendCommandFn() - .mockResponse('DOM.getDocument', {root: {nodeId: 249}}) - .mockResponse('DOM.querySelector', {nodeId: 0}); - - const result = await driver.querySelector('invalid'); - expect(result).toEqual(null); - }); - - it('returns element instance when DOM.querySelector finds a node', async () => { - connectionStub.sendCommand = createMockSendCommandFn() - .mockResponse('DOM.getDocument', {root: {nodeId: 249}}) - .mockResponse('DOM.querySelector', {nodeId: 231}); - - const result = await driver.querySelector('meta head'); - expect(result).toBeInstanceOf(LHElement); - }); -}); - -describe('.getObjectProperty', () => { - it('returns value when getObjectProperty finds property name', async () => { - const property = { - name: 'testProp', - value: { - value: 123, - }, - }; - - connectionStub.sendCommand = createMockSendCommandFn() - .mockResponse('Runtime.getProperties', {result: [property]}); - - const result = await driver.getObjectProperty('objectId', 'testProp'); - expect(result).toEqual(123); - }); - - it('returns null when getObjectProperty finds no property name', async () => { - connectionStub.sendCommand = createMockSendCommandFn() - .mockResponse('Runtime.getProperties', {result: []}); - - const result = await driver.getObjectProperty('objectId', 'testProp'); - expect(result).toEqual(null); - }); - - it('returns null when getObjectProperty finds property name with no value', async () => { - connectionStub.sendCommand = createMockSendCommandFn() - .mockResponse('Runtime.getProperties', {result: [{name: 'testProp'}]}); - - const result = await driver.getObjectProperty('objectId', 'testProp'); - expect(result).toEqual(null); - }); -}); - describe('.getRequestContent', () => { it('throws if getRequestContent takes too long', async () => { const mockTimeout = 5000; diff --git a/lighthouse-core/test/gather/driver/dom-test.js b/lighthouse-core/test/gather/driver/dom-test.js new file mode 100644 index 000000000000..23c66b693a57 --- /dev/null +++ b/lighthouse-core/test/gather/driver/dom-test.js @@ -0,0 +1,73 @@ +/** + * @license Copyright 2021 The Lighthouse Authors. 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. + */ +'use strict'; + +/* eslint-env jest */ + +const {createMockSession} = require('../../fraggle-rock/gather/mock-driver.js'); +const dom = require('../../../gather/driver/dom.js'); + +let sessionMock = createMockSession(); + +beforeEach(() => { + sessionMock = createMockSession(); +}); + +describe('.resolveNodeIdToObjectId', () => { + it('resolves to the object id', async () => { + sessionMock.sendCommand.mockResponse('DOM.resolveNode', {object: {objectId: 'one'}}); + const objectId = await dom.resolveNodeIdToObjectId(sessionMock.asSession(), 1); + expect(objectId).toEqual('one'); + }); + + it('handle missing nodes', async () => { + sessionMock.sendCommand.mockRejectedValue(new Error('No node 1 found')); + const objectId = await dom.resolveNodeIdToObjectId(sessionMock.asSession(), 1); + expect(objectId).toEqual(undefined); + }); + + it('handle nodes in other documents', async () => { + sessionMock.sendCommand.mockRejectedValue(new Error('Node 1 does not belong to the document')); + const objectId = await dom.resolveNodeIdToObjectId(sessionMock.asSession(), 1); + expect(objectId).toEqual(undefined); + }); + + it('raise other exceptions', async () => { + const error = new Error('PROTOCOL_TIMEOUT'); + sessionMock.sendCommand.mockRejectedValue(error); + await expect(dom.resolveNodeIdToObjectId(sessionMock.asSession(), 1)).rejects.toEqual(error); + }); +}); + +describe('.resolveDevtoolsNodePathToObjectId', () => { + it('resolves to the object id', async () => { + sessionMock.sendCommand + .mockResponse('DOM.pushNodeByPathToFrontend', {nodeId: 1}) + .mockResponse('DOM.resolveNode', {object: {objectId: 'one'}}); + const objectId = await dom.resolveDevtoolsNodePathToObjectId(sessionMock.asSession(), 'div'); + expect(objectId).toEqual('one'); + }); + + it('handle missing nodes', async () => { + sessionMock.sendCommand.mockRejectedValue(new Error('No node 1 found')); + const objectId = await dom.resolveDevtoolsNodePathToObjectId(sessionMock.asSession(), 'div'); + expect(objectId).toEqual(undefined); + }); + + it('handle nodes in other documents', async () => { + sessionMock.sendCommand.mockRejectedValue(new Error('Node 1 does not belong to the document')); + const objectId = await dom.resolveDevtoolsNodePathToObjectId(sessionMock.asSession(), 'div'); + expect(objectId).toEqual(undefined); + }); + + it('raise other exceptions', async () => { + const error = new Error('PROTOCOL_TIMEOUT'); + sessionMock.sendCommand.mockRejectedValue(error); + await expect( + dom.resolveDevtoolsNodePathToObjectId(sessionMock.asSession(), 'div') + ).rejects.toEqual(error); + }); +}); diff --git a/lighthouse-core/test/lib/lh-element-test.js b/lighthouse-core/test/lib/lh-element-test.js deleted file mode 100644 index 263c4bb700c1..000000000000 --- a/lighthouse-core/test/lib/lh-element-test.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license Copyright 2016 The Lighthouse Authors. 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. - */ -'use strict'; - -const LHElement = require('../../lib/lh-element.js'); -const assert = require('assert').strict; - -class DriverStub { - sendCommand(command) { - switch (command) { - case 'DOM.getAttributes': - return Promise.resolve({attributes: ['rel', 'manifest']}); - - case 'DOM.resolveNode': - return Promise.resolve({object: {objectId: 'test'}}); - - default: - throw Error(`Stub not implemented: ${command}`); - } - } - - getObjectProperty() { - return Promise.resolve('123'); - } -} - -/* global describe, it, beforeEach */ -describe('LHElement', () => { - let stubbedDriver; - let stubbedElement; - - beforeEach(() => { - stubbedDriver = new DriverStub(); - stubbedElement = {nodeId: 642}; - }); - - it('throws when no driver or element is passed', () => { - assert.throws(() => { - const _ = new LHElement(); - }); - }); - - it('throws when no driver is passed', () => { - assert.throws(() => { - const _ = new LHElement(stubbedElement, undefined); - }); - }); - - it('throws when no element is passed', () => { - assert.throws(() => { - const _ = new LHElement(undefined, stubbedDriver); - }); - }); - - it('returns null from getAttribute when no attribute found', () => { - const element = new LHElement(stubbedElement, stubbedDriver); - return element.getAttribute('notanattribute').then(value => { - assert.equal(value, null); - }); - }); - - it('returns attribute value from getAttribute', () => { - const element = new LHElement(stubbedElement, stubbedDriver); - return element.getAttribute('rel').then(value => { - assert.equal(value, 'manifest'); - }); - }); - - it('returns property value from getProperty', () => { - const element = new LHElement(stubbedElement, stubbedDriver); - return element.getProperty('test').then(value => { - assert.equal(value, '123'); - }); - }); -}); diff --git a/types/artifacts.d.ts b/types/artifacts.d.ts index 8275fd809708..18ca6142befe 100644 --- a/types/artifacts.d.ts +++ b/types/artifacts.d.ts @@ -20,7 +20,6 @@ declare global { export interface Artifacts extends BaseArtifacts, GathererArtifacts {} export type FRArtifacts = StrictOmit