// 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);
      });
    });
  });
});
