UNPKG

chrome-devtools-frontend

Version:
661 lines (585 loc) • 28.6 kB
// Copyright 2022 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Protocol from '../../../generated/protocol.js'; import { describeWithMockConnection, } from '../../../testing/MockConnection.js'; import {getBaseTraceParseModelData} from '../../../testing/TraceHelpers.js'; import * as Trace from '../trace.js'; import * as RootCauses from './RootCauses.js'; type ParsedTrace = Trace.Handlers.Types.ParsedTrace; type ParsedTraceMutable = Trace.Handlers.Types.ParsedTraceMutable; function assertArrayHasNoNulls<T>(inputArray: Array<T|null>): asserts inputArray is T[] { inputArray.forEach((item, index) => { if (item === null) { assert.fail(`Found null at array index ${index}`); } }); } function createMockStyle(cssProperties: {name: string, value: string}[]): Protocol.CSS.CSSStyle { return {cssProperties, shorthandEntries: []}; } function createMockMatchedRules(cssProperties: {name: string, value: string}[]): Protocol.CSS.RuleMatch[] { return [{ rule: { style: createMockStyle(cssProperties), selectorList: {selectors: [], text: ''}, origin: Protocol.CSS.StyleSheetOrigin.Regular, }, matchingSelectors: [], }]; } describeWithMockConnection('LayoutShift root causes', () => { /* * This test has to do a lot of mocking and creating of fake data in order * to function. Normally in the performance panel tests we prefer to parse a * real trace and use that, but in this case because LayoutShift root causes * rely on having an actual DevTools instance open with access to the DOM, * we can't do that. So therefore we completely mock the set of data * required. */ describe('assigns root causes to layout shifts', () => { let layoutShifts: RootCauses.LayoutShiftRootCauses; let prePaintEvents: Trace.Types.Events.PrePaint[]; let resizeEvents: Trace.Types.Events.LayoutInvalidationTracking[]; let injectedIframeEvents: Trace.Types.Events.LayoutInvalidationTracking[]; let fontChanges: Trace.Types.Events.LayoutInvalidationTracking[]; let unknownLayoutInvalidation: Trace.Types.Events.LayoutInvalidationTracking[]; let domNodeByBackendIdMap: Map<Protocol.DOM.BackendNodeId, Protocol.DOM.Node|null>; let model: ParsedTrace; let modelMut: ParsedTraceMutable; let resizeEventsNodeIds: number[]; let iframesNodeIds: number[]; let shifts: Trace.Types.Events.SyntheticLayoutShift[]; let matchedStylesMock: Omit<Protocol.CSS.GetMatchedStylesForNodeResponse, 'getError'>; let protocolInterface: RootCauses.RootCauseProtocolInterface; let computedStylesMock: Protocol.CSS.CSSComputedStyleProperty[]; let fontFaceMock: Protocol.CSS.FontFace; const fontSource = 'mock-source.woff'; const renderBlockSource = 'mock-source.css'; beforeEach(() => { fontFaceMock = {fontFamily: 'Roboto', src: fontSource, fontDisplay: 'swap'} as Protocol.CSS.FontFace; // Layout shifts for which we want to extract potential root causes. shifts = [{ts: 10}, {ts: 30}, {ts: 50}, {ts: 70}, {ts: 90}] as unknown as Trace.Types.Events.SyntheticLayoutShift[]; // Initialize the shifts. for (const shift of shifts) { shift.args = { frame: 'frame-id-123', }; shift.name = Trace.Types.Events.Name.SYNTHETIC_LAYOUT_SHIFT; } const clusters = [{events: shifts}] as unknown as Trace.Types.Events.SyntheticLayoutShiftCluster[]; // PrePaint events to which each layout shift belongs. prePaintEvents = [{ts: 5, dur: 30}, {ts: 45, dur: 30}, {ts: 85, dur: 10}] as unknown as Trace.Types.Events.PrePaint[]; resizeEvents = [{ts: 0}, {ts: 25}, {ts: 80}, {ts: 100}] as unknown as Trace.Types.Events.LayoutInvalidationTracking[]; injectedIframeEvents = [{ts: 2}, {ts: 81}] as unknown as Trace.Types.Events.LayoutInvalidationTracking[]; fontChanges = [{ts: 3}, {ts: 35}] as unknown as Trace.Types.Events.LayoutInvalidationTracking[]; unknownLayoutInvalidation = [{ts: 4}, {ts: 36}] as unknown as Trace.Types.Events.LayoutInvalidationTracking[]; // |Resize|---|Iframe|---|Fonts-|---|--PrePaint 1--|----|Resize|---|Fonts-|-|---PrePaint 2---|---|Resize|---|Iframe|---|PrePaint 3| // ----------------------------------|LS 1|-|LS 2|----------------------------|LS 3|-|LS 4|-----------------------------|LS 5| // Initialize the LI events by adding a nodeId and setting a reason so that they // aren't filtered out. for (let i = 0; i < resizeEvents.length; i++) { resizeEvents[i].args = { data: { nodeId: i + 1 as Protocol.DOM.BackendNodeId, reason: Trace.Types.Events.LayoutInvalidationReason.SIZE_CHANGED, nodeName: 'IMG', frame: 'frame-id-123', }, }; } for (let i = 0; i < injectedIframeEvents.length; i++) { injectedIframeEvents[i].args = { data: { nodeId: i + 11 as Protocol.DOM.BackendNodeId, reason: Trace.Types.Events.LayoutInvalidationReason.ADDED_TO_LAYOUT, nodeName: 'IFRAME', frame: 'frame-id-123', }, }; } for (let i = 0; i < fontChanges.length; i++) { fontChanges[i].args = { data: { nodeId: i + 21 as Protocol.DOM.BackendNodeId, reason: Trace.Types.Events.LayoutInvalidationReason.FONTS_CHANGED, nodeName: 'DIV', frame: 'frame-id-123', }, }; } for (let i = 0; i < unknownLayoutInvalidation.length; i++) { unknownLayoutInvalidation[i].args = { data: { nodeId: i + 31 as Protocol.DOM.BackendNodeId, reason: Trace.Types.Events.LayoutInvalidationReason.UNKNOWN, nodeName: 'DIV', frame: 'frame-id-123', }, }; } const layoutInvalidationEvents = [ ...resizeEvents, ...injectedIframeEvents, ...fontChanges, ...unknownLayoutInvalidation, ].sort((a, b) => a.ts - b.ts); for (const e of layoutInvalidationEvents) { e.name = Trace.Types.Events.Name.LAYOUT_INVALIDATION_TRACKING; } // Map from fake BackendNodeId to fake Protocol.DOM.Node used by the handler to // resolve the nodeIds in the traces. const domNodeByBackendIdMapEntries: [Protocol.DOM.BackendNodeId, Protocol.DOM.Node|null][] = []; const domNodeByIdMap = new Map<Protocol.DOM.NodeId, Protocol.DOM.Node>(); for (let i = 0 as Protocol.DOM.BackendNodeId; i < layoutInvalidationEvents.length; i++) { const backendNodeId = layoutInvalidationEvents[i].args.data.nodeId; const nodeId = i as unknown as Protocol.DOM.NodeId; const nodeName = layoutInvalidationEvents[i].args.data.nodeName || 'DIV'; const fakeNode = { backendNodeId, nodeId, localName: nodeName.toLowerCase(), nodeName, attributes: [], nodeType: Node.ELEMENT_NODE, } as unknown as Protocol.DOM.Node; domNodeByBackendIdMapEntries.push([backendNodeId, fakeNode]); domNodeByIdMap.set(nodeId, fakeNode); } domNodeByBackendIdMap = new Map(domNodeByBackendIdMapEntries) as unknown as Map<Protocol.DOM.BackendNodeId, Protocol.DOM.Node|null>; model = getBaseTraceParseModelData(); modelMut = model as unknown as ParsedTraceMutable; // Now fake out the relevant LayoutShift data modelMut.LayoutShifts.prePaintEvents = prePaintEvents; modelMut.LayoutShifts.layoutInvalidationEvents = layoutInvalidationEvents; modelMut.LayoutShifts.backendNodeIds = [...domNodeByBackendIdMap.keys()] as Protocol.DOM.BackendNodeId[]; modelMut.LayoutShifts.clusters = clusters; modelMut.LayoutShifts.scheduleStyleInvalidationEvents = []; modelMut.Initiators = { eventToInitiator: new Map(), initiatorToEvents: new Map(), }; resizeEventsNodeIds = resizeEvents.map(li => Number(li.args.data.nodeId)); iframesNodeIds = injectedIframeEvents.map(li => Number(li.args.data.nodeId)); computedStylesMock = []; matchedStylesMock = {}; protocolInterface = { getInitiatorForRequest(_: string): Protocol.Network.Initiator | null { return null; }, async pushNodesByBackendIdsToFrontend(backendNodeIds: Protocol.DOM.BackendNodeId[]): Promise<Protocol.DOM.NodeId[]> { return backendNodeIds.map(id => { const node = domNodeByBackendIdMap.get(id); if (!node) { throw new Error('unexpected backend id'); } return node.nodeId; }); }, async getNode(nodeId: Protocol.DOM.NodeId): Promise<Protocol.DOM.Node> { const node = domNodeByIdMap.get(nodeId); if (!node) { throw new Error('unexpected id'); } return node; }, async getComputedStyleForNode(_: Protocol.DOM.NodeId): Promise<Protocol.CSS.CSSComputedStyleProperty[]> { return computedStylesMock; }, async getMatchedStylesForNode(_: Protocol.DOM.NodeId): Promise<Protocol.CSS.GetMatchedStylesForNodeResponse> { return { ...matchedStylesMock, getError: () => undefined, }; }, fontFaceForSource(url: string): Protocol.CSS.FontFace | undefined { if (url === fontFaceMock.src) { return fontFaceMock; } return; }, }; layoutShifts = new RootCauses.LayoutShiftRootCauses(protocolInterface, {enableIframeRootCauses: true}); }); it('uses cached node details', async () => { // Use duplicate node ids for invalidation events that use `getNode` resizeEvents.forEach(e => { e.args.data.nodeId = 1 as Protocol.DOM.BackendNodeId; }); injectedIframeEvents.forEach(e => { e.args.data.nodeId = 11 as Protocol.DOM.BackendNodeId; }); const getNodeSpy = sinon.spy(protocolInterface, 'getNode'); const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); assert.strictEqual(getNodeSpy.callCount, 2); }); describe('Unsized media', () => { it('marks unsized media node in LayoutInvalidation events as a potential root cause to layout shifts correctly', async () => { const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); const shiftCausesNodeIds = rootCauses.map(cause => { return cause.unsizedMedia.map(media => Number(media.node.backendNodeId)); }); // Test the nodes from the LI events are assigned as the potential root causes to layout shifts correctly. assert.lengthOf(shiftCausesNodeIds[0], 1); assert.strictEqual(shiftCausesNodeIds[0][0], resizeEventsNodeIds[0]); assert.lengthOf(shiftCausesNodeIds[1], 1); assert.strictEqual(shiftCausesNodeIds[1][0], resizeEventsNodeIds[0]); assert.lengthOf(shiftCausesNodeIds[2], 1); assert.strictEqual(shiftCausesNodeIds[2][0], resizeEventsNodeIds[1]); assert.lengthOf(shiftCausesNodeIds[3], 1); assert.strictEqual(shiftCausesNodeIds[3][0], resizeEventsNodeIds[1]); assert.lengthOf(shiftCausesNodeIds[4], 1); assert.strictEqual(shiftCausesNodeIds[4][0], resizeEventsNodeIds[2]); }); it('sets partially sized media\'s authored dimensions properly, using inline styles.', async () => { // Set height using inline and matched CSS styles. matchedStylesMock = { attributesStyle: createMockStyle([]), inlineStyle: createMockStyle([{name: 'height', value: '20px'}]), matchedCSSRules: createMockMatchedRules([{name: 'height', value: '10px'}]), }; const rootCause = await layoutShifts.rootCausesForEvent(model, shifts[0]); const authoredDimensions = rootCause?.unsizedMedia[0].authoredDimensions; if (!authoredDimensions) { assert.fail('Expected defined authored dimensions'); } // Assert inline styles are preferred. assert.strictEqual(authoredDimensions.height, '20px'); assert.isUndefined(authoredDimensions.width); assert.isUndefined(authoredDimensions.aspectRatio); }); it('sets partially sized media\'s authored dimensions properly, using matched CSS rules.', async () => { // Set height using matched CSS rules. matchedStylesMock = { attributesStyle: createMockStyle([{name: 'height', value: '10px'}]), inlineStyle: createMockStyle([]), matchedCSSRules: createMockMatchedRules([{name: 'height', value: '30px'}]), }; const rootCause = await layoutShifts.rootCausesForEvent(model, shifts[1]); const authoredDimensions = rootCause?.unsizedMedia[0].authoredDimensions; if (!authoredDimensions) { assert.fail('Expected defined authored dimensions'); } // Assert matched CSS rules styles are preferred. assert.strictEqual(authoredDimensions.height, '30px'); }); it('sets partially unsized media\'s computed dimensions properly.', async () => { const height = '10px'; const width = '20px'; computedStylesMock = [ {name: 'height', value: height}, {name: 'width', value: width}, ]; const rootCause = await layoutShifts.rootCausesForEvent(model, shifts[1]); const computedDimensions = rootCause?.unsizedMedia[0].computedDimensions; if (!computedDimensions) { assert.fail('Expected defined computed dimensions'); } // Assert correct computed styles are set. assert.strictEqual(computedDimensions.height, height); assert.strictEqual(computedDimensions.width, width); }); async function assertAmountOfBlamedLayoutInvalidations(amount: number) { const allShiftsRootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); const nodesFromLayoutInvalidations = new Set<number>(); for (const currentShiftRootCauses of allShiftsRootCauses) { if (currentShiftRootCauses === null) { continue; } for (const media of currentShiftRootCauses.unsizedMedia) { nodesFromLayoutInvalidations.add(media.node.backendNodeId); } } assert.strictEqual(nodesFromLayoutInvalidations.size, amount); } it('ignores media with inline height and width', async () => { matchedStylesMock = { attributesStyle: createMockStyle([{name: 'height', value: '10px'}, {name: 'width', value: '10px'}]), inlineStyle: createMockStyle([]), matchedCSSRules: createMockMatchedRules([]), }; await assertAmountOfBlamedLayoutInvalidations(0); }); it('ignores media with CSS height and width', async () => { matchedStylesMock = { attributesStyle: createMockStyle([]), inlineStyle: createMockStyle([]), matchedCSSRules: createMockMatchedRules([{name: 'height', value: '10px'}, {name: 'width', value: '10px'}]), }; await assertAmountOfBlamedLayoutInvalidations(0); }); it('ignores media with height and aspect ratio', async () => { matchedStylesMock = { attributesStyle: createMockStyle([{name: 'height', value: '10px'}, {name: 'aspect-ratio', value: '1'}]), inlineStyle: createMockStyle([]), matchedCSSRules: createMockMatchedRules([]), }; await assertAmountOfBlamedLayoutInvalidations(0); }); it('ignores media with explicit height and width', async () => { matchedStylesMock = { attributesStyle: createMockStyle([{name: 'height', value: '10px'}]), inlineStyle: createMockStyle([{name: 'width', value: '10px'}]), matchedCSSRules: createMockMatchedRules([]), }; await assertAmountOfBlamedLayoutInvalidations(0); }); it('ignores media with fixed position as potential root causes of layout shifts', async () => { computedStylesMock = [{name: 'position', value: 'fixed'}]; await assertAmountOfBlamedLayoutInvalidations(0); }); it('does not ignore media with only height or width explicitly set as potential root causes of layout shifts', async () => { matchedStylesMock = { attributesStyle: createMockStyle([{name: 'height', value: '10px'}]), inlineStyle: createMockStyle([]), matchedCSSRules: createMockMatchedRules([]), }; await assertAmountOfBlamedLayoutInvalidations(3); }); it('does not error when there are no layout shifts', async () => { // Layout shifts for which we want to associate LayoutInvalidation events as potential root causes. shifts = [{ts: 10}, {ts: 30}, {ts: 50}, {ts: 70}, {ts: 90}] as unknown as Trace.Types.Events.SyntheticLayoutShift[]; // Initialize the shifts. for (const shift of shifts) { shift.args = { frame: 'frame-id-123', }; shift.name = Trace.Types.Events.Name.SYNTHETIC_LAYOUT_SHIFT; } const clusters = [{events: shifts}] as unknown as Trace.Types.Events.SyntheticLayoutShiftCluster[]; modelMut.LayoutShifts.clusters = clusters; assert.doesNotThrow(async () => { await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); }); }); }); describe('Injected iframes', () => { it('marks injected iframes in LayoutInvalidation events as a potential root cause to layout shifts correctly', async () => { const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); const shiftCausesNodeIds = rootCauses.map(cause => { return cause.iframes.map(node => Number(node.iframe.backendNodeId)); }); // Test the nodes from the LI events are assigned as the potential root causes to layout shifts correctly. assert.lengthOf(shiftCausesNodeIds[0], 1); assert.strictEqual(shiftCausesNodeIds[0][0], iframesNodeIds[0]); assert.lengthOf(shiftCausesNodeIds[4], 1); assert.strictEqual(shiftCausesNodeIds[4][0], iframesNodeIds[1]); }); it('ignores injected iframes if disabled', async () => { layoutShifts = new RootCauses.LayoutShiftRootCauses(protocolInterface, {enableIframeRootCauses: false}); const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); assert(rootCauses.every(cause => cause.iframes.length === 0), 'contained iframe root causes'); }); it('ignores events that could not add or resize an iframe', async () => { injectedIframeEvents.forEach(e => { e.args.data.nodeName = 'DIV'; e.args.data.reason = Trace.Types.Events.LayoutInvalidationReason.SIZE_CHANGED; }); const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); assert(rootCauses.every(cause => cause.iframes.length === 0), 'contained iframe root causes'); }); }); describe('Font changes', () => { // Mock two font network request that finished right before the mocked layout invalidation events // that correspond to font changes. const fontRequests = [ { dur: Trace.Types.Timing.Micro(2), ts: Trace.Types.Timing.Micro(0), args: { data: { url: fontSource, mimeType: 'font/woff2', }, }, }, { dur: Trace.Types.Timing.Micro(30), ts: Trace.Types.Timing.Micro(0), args: { data: { url: fontSource, mimeType: 'font/woff2', }, }, }, ] as unknown as Trace.Types.Events.SyntheticNetworkRequest[]; it('marks fonts changes in LayoutInvalidation events as a potential root cause to layout shifts correctly', async () => { modelMut.NetworkRequests.byTime = fontRequests; const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); const shiftCausesNodeIds = rootCauses.map(cause => { return cause.fontChanges; }); // Test the font requests are marked as potential layout shift root causes // in the correct order. assert.deepEqual(shiftCausesNodeIds[0][0]?.request, fontRequests[0]); assert.deepEqual(shiftCausesNodeIds[1][0]?.request, fontRequests[0]); assert.deepEqual(shiftCausesNodeIds[2][0]?.request, fontRequests[1]); assert.deepEqual(shiftCausesNodeIds[3][0]?.request, fontRequests[1]); assert.deepEqual(shiftCausesNodeIds[2][1]?.request, fontRequests[0]); assert.deepEqual(shiftCausesNodeIds[3][1]?.request, fontRequests[0]); }); it('ignores requests for fonts whose font-display property is "optional"', async () => { const optionalFontRequests = [{ dur: Trace.Types.Timing.Micro(2), ts: Trace.Types.Timing.Micro(0), args: { data: { url: fontSource, mimeType: 'font/woff2', }, }, }] as unknown as Trace.Types.Events.SyntheticNetworkRequest[]; modelMut.NetworkRequests.byTime = optionalFontRequests; fontFaceMock = {fontFamily: 'Roboto', src: fontSource, fontDisplay: 'optional'} as Protocol.CSS.FontFace; const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); const shiftCausesNodeIds = rootCauses.map(cause => { return cause.fontChanges; }); // Test no font request is marked as potential layout shift root causes assert.lengthOf(shiftCausesNodeIds[0], 0); assert.lengthOf(shiftCausesNodeIds[1], 0); assert.lengthOf(shiftCausesNodeIds[2], 0); assert.lengthOf(shiftCausesNodeIds[3], 0); }); it('ignores requests for fonts that lie outside the fixed time window from ending at the "font change" layout invalidation event', async () => { const optionalFontRequests = [{ dur: Trace.Types.Timing.Micro(2), ts: Trace.Types.Timing.Micro(85), args: { data: { url: fontSource, mimeType: 'font/woff2', }, }, }] as unknown as Trace.Types.Events.SyntheticNetworkRequest[]; modelMut.NetworkRequests.byTime = optionalFontRequests; fontFaceMock = {fontFamily: 'Roboto', src: fontSource, fontDisplay: 'swap'} as Protocol.CSS.FontFace; const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); const shiftCausesNodeIds = rootCauses.map(cause => { return cause.fontChanges; }); // Test no font request is marked as potential layout shift root causes assert.lengthOf(shiftCausesNodeIds[0], 0); assert.lengthOf(shiftCausesNodeIds[1], 0); assert.lengthOf(shiftCausesNodeIds[2], 0); assert.lengthOf(shiftCausesNodeIds[3], 0); }); }); describe('Render blocking request', () => { const RenderBlockingRequest = [ { dur: Trace.Types.Timing.Micro(2), ts: Trace.Types.Timing.Micro(0), args: { data: { url: renderBlockSource, mimeType: 'text/plain', renderBlocking: 'blocking', }, }, }, { dur: Trace.Types.Timing.Micro(30), ts: Trace.Types.Timing.Micro(0), args: { data: { url: renderBlockSource, mimeType: 'text/css', renderBlocking: 'non_blocking', }, }, }, ] as Trace.Types.Events.SyntheticNetworkRequest[]; it('marks render blocks in LayoutInvalidation events as a potential root cause to layout shifts correctly', async () => { modelMut.NetworkRequests.byTime = RenderBlockingRequest; const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); const shiftCausesNodeIds = rootCauses.map(cause => { return cause.renderBlockingRequests; }); // Test the rendering block requests are marked as potential layout shift root causes // in the correct order. assert.deepEqual(shiftCausesNodeIds[2][0]?.request, RenderBlockingRequest[0]); assert.deepEqual(shiftCausesNodeIds[3][0]?.request, RenderBlockingRequest[0]); assert.deepEqual(shiftCausesNodeIds[4][0]?.request, RenderBlockingRequest[0]); }); }); describe('Scripts causing relayout/style recalc', () => { it('adds a Layout initiator\'s stack trace to the corresponding layout shift root causes.', async () => { const mockStackTrace = [ { scriptId: 0, functionName: 'foo', columnNumber: 10, lineNumber: 1, url: 'Main.js', }, { scriptId: 2, functionName: 'bar', columnNumber: 10, lineNumber: 20, url: 'Main.js', }, ]; // Mock a Layout event, which corresponds to the last shift. // a stack trace. modelMut.Renderer.allTraceEntries = [{ name: 'Layout', ts: 82, } as unknown as Trace.Types.Events.Event]; const node = { entry: model.Renderer.allTraceEntries[0], } as Trace.Helpers.TreeHelpers.TraceEntryNode; model.Renderer.entryToNode.set(model.Renderer.allTraceEntries[0], node); // Fake out the initiator detection and link the Layout event with a fake InvalidateLayout event. model.Initiators.eventToInitiator.set(model.Renderer.allTraceEntries[0], { name: 'InvalidateLayout', args: { data: { stackTrace: mockStackTrace, }, }, } as Trace.Types.Events.Event); // Verify the Layout initiator's stack trace is added to the last shift. const rootCauses = await Promise.all(shifts.map(shift => layoutShifts.rootCausesForEvent(model, shift))); assertArrayHasNoNulls(rootCauses); const rootCauseStackTraces = rootCauses.map(cause => { return cause.scriptStackTrace; }); const stackTracesForLastShift = rootCauseStackTraces.at(-1); if (!stackTracesForLastShift) { assert.fail('No stack traces found for layout shift'); return; } assert.strictEqual(stackTracesForLastShift, mockStackTrace); }); }); }); });