// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import { assert } from 'chai';
import * as Platform from '../../../core/platform/platform.js';
import { describeWithEnvironment } from '../../../testing/EnvironmentHelpers.js';
import { getFirstOrError, getInsightOrError, processTrace } from '../../../testing/InsightHelpers.js';
import { TraceLoader } from '../../../testing/TraceLoader.js';
import * as Trace from '../trace.js';
const { urlString } = Platform.DevToolsPath;
describeWithEnvironment('NetworkDependencyTree', function () {
    let insight;
    before(async function () {
        const { data, insights } = await processTrace(this, 'lcp-multiple-frames.json.gz');
        const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
        insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
        assert.isOk(insight);
    });
    it('calculates network dependency tree', () => {
        // The network dependency tree in this trace is, |app.js| took longer than |app.css|, so |app.js| will be first.
        // | .../index.html (ts:566777570990, dur:5005590)
        // |
        // | | .../app.js (ts:566782574106, dur:11790)
        // | | .../app.css (ts:566782573909, dur:7205)
        assert.isOk(insight);
        assert.lengthOf(insight.rootNodes, 1);
        const root = insight.rootNodes[0];
        assert.strictEqual(root.request.args.data.url, 'http://localhost:8787/lcp-iframes/index.html');
        assert.strictEqual(root.timeFromInitialRequest, Trace.Types.Timing.Micro(root.request.dur));
        assert.lengthOf(root.children, 2);
        const [child0, child1] = insight.rootNodes[0].children;
        assert.strictEqual(child0.request.args.data.url, 'http://localhost:8787/lcp-iframes/app.js');
        assert.strictEqual(child0.timeFromInitialRequest, Trace.Types.Timing.Micro(child0.request.ts + child0.request.dur - root.request.ts));
        assert.lengthOf(child0.children, 0);
        assert.strictEqual(child1.request.args.data.url, 'http://localhost:8787/lcp-iframes/app.css');
        assert.strictEqual(child1.timeFromInitialRequest, Trace.Types.Timing.Micro(child1.request.ts + child1.request.dur - root.request.ts));
        assert.lengthOf(child1.children, 0);
    });
    it('Calculate the max critical path latency', () => {
        // The chain |index.html(root) -> app.js(child0)| is the longest
        assert.isOk(insight);
        const root = insight.rootNodes[0];
        const child0 = root.children[0];
        assert.strictEqual(insight.maxTime, Trace.Types.Timing.Micro(child0.request.ts + child0.request.dur - root.request.ts));
    });
    it('Marks the longest network dependency chain', () => {
        assert.isOk(insight);
        const root = insight.rootNodes[0];
        const [child0, child1] = root.children;
        // The chain |index.html(root) -> app.js(child0)| is the longest
        assert.isTrue(root.isLongest);
        assert.isTrue(child0.isLongest);
        // The |app.css| is not in the longest chain
        assert.isNotTrue(child1.isLongest);
    });
    it('Store the all parents and children events for all requests', () => {
        assert.isOk(insight);
        const root = insight.rootNodes[0];
        const [child0, child1] = root.children;
        // There are three chains from Lantern:
        //   |index.html(root)|
        //   |index.html(root) -> app.js(child0)|
        //   |index.html(root) -> app.css(child1)|
        // Both child0 and child1 are related to the root
        assert.sameDeepMembers([...root.relatedRequests], [root.request, child0.request, child1.request]);
        // Only root and child0 are related to the child0
        assert.sameDeepMembers([...child0.relatedRequests], [root.request, child0.request]);
        // Only root and child1 are related to the child1
        assert.sameDeepMembers([...child1.relatedRequests], [root.request, child1.request]);
    });
    it('Fail the audit when there at least one chain with at least two requests', () => {
        assert.isOk(insight);
        assert.isTrue(insight.fail);
    });
    it('Does not fail the audit when there is only main doc request', async function () {
        // Need to load a file with only main doc in the the critical requests chains.
        const { data, insights } = await processTrace(this, 'image-delivery.json.gz');
        const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
        insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
        assert.isFalse(insight.fail);
    });
    it('Calculates the relatedEvents map (event to warning map)', async function () {
        TraceLoader.setTestTimeout(this);
        // Need to load a file with longer dependency chain for this test.
        // Only those requests whose depth >= 2 will be added to the related events.
        const { data, insights } = await processTrace(this, 'web-dev-screenshot-source-ids.json.gz');
        const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
        insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
        // For NetworkDependencyTree, the relatedEvents is a map format.
        assert.isFalse(Array.isArray(insight.relatedEvents));
        const relatedEvents = insight.relatedEvents;
        // There are a few chains, let test the first chain
        // |web.dev -> /css -> KFO7CnqEu9…UBA.woff2|
        const root = insight.rootNodes[0];
        const child0 = root.children[0];
        const child00 = child0.children[0];
        // Root's depth is 0, so there isn't any warning message
        assert.deepEqual(relatedEvents.get(root.request), []);
        // child0's depth is 1, so there isn't any warning message
        assert.deepEqual(relatedEvents.get(child0.request), []);
        // child00's depth is 2, so there is one warning message
        assert.deepEqual(relatedEvents.get(child00.request), [Trace.Insights.Models.NetworkDependencyTree.UIStrings.warningDescription]);
    });
});
describe('generatePreconnectedOrigins', () => {
    describe('generatePreconnectedOriginsFromDom', () => {
        const mockParsedTrace = {
            NetworkRequests: {
                linkPreconnectEvents: [],
                byId: new Map(),
            },
        };
        const mockContext = {};
        beforeEach(() => {
            mockParsedTrace.NetworkRequests.linkPreconnectEvents.length = 0;
        });
        it('should mark preconnect origins as not unused when they match context requests', () => {
            mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
                args: {
                    data: {
                        url: 'https://example.com',
                        node_id: 1,
                        frame: 'frame-id',
                    },
                },
            });
            const mockContextRequests = [{
                    args: {
                        data: {
                            url: 'https://example.com/script.js',
                        },
                    },
                }];
            const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(mockParsedTrace, mockContext, mockContextRequests, /* preconnectCandidates */ []);
            assert.deepEqual(preconnectOrigins, [{
                    node_id: 1,
                    frame: 'frame-id',
                    url: 'https://example.com',
                    unused: false,
                    crossorigin: false,
                    source: 'DOM'
                }]);
        });
        it('should mark preconnect origins as unused when they do not match context requests', () => {
            mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
                args: {
                    data: {
                        url: 'https://example.com',
                        node_id: 1,
                        frame: 'frame-id',
                    },
                },
            });
            const mockContextRequests = [{
                    args: {
                        data: {
                            url: 'https://other.com/image.png',
                        },
                    },
                }];
            const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(mockParsedTrace, mockContext, mockContextRequests, /* preconnectCandidates */ []);
            assert.deepEqual(preconnectOrigins, [{
                    node_id: 1,
                    frame: 'frame-id',
                    url: 'https://example.com',
                    unused: true,
                    crossorigin: false,
                    source: 'DOM'
                }]);
        });
        it('sets crossorigin to true when a matching preconnect candidate exists', () => {
            mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
                args: {
                    data: {
                        url: 'https://example.com',
                        node_id: 1,
                        frame: 'frame-id',
                    },
                },
            });
            const mockPreconnectCandidates = [{ origin: urlString `https://example.com`, wastedMs: 100 }];
            const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(mockParsedTrace, mockContext, /* mockContextRequests */ [], 
            /* preconnectCandidates */ mockPreconnectCandidates);
            assert.deepEqual(preconnectOrigins, [{
                    node_id: 1,
                    frame: 'frame-id',
                    url: 'https://example.com',
                    unused: true,
                    crossorigin: true,
                    source: 'DOM'
                }]);
        });
        it('sets crossorigin to false when no matching preconnect candidate exists', () => {
            mockParsedTrace.NetworkRequests.linkPreconnectEvents.push({
                args: {
                    data: {
                        url: 'https://example.com',
                        node_id: 1,
                        frame: 'frame-id',
                    },
                },
            });
            const mockPreconnectCandidates = [{ origin: urlString `https://other.com`, wastedMs: 100 }];
            const preconnectOrigins = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectedOrigins(mockParsedTrace, mockContext, /* mockContextRequests */ [], 
            /* preconnectCandidates */ mockPreconnectCandidates);
            assert.deepEqual(preconnectOrigins, [{
                    node_id: 1,
                    frame: 'frame-id',
                    url: 'https://example.com',
                    unused: true,
                    crossorigin: false,
                    source: 'DOM'
                }]);
        });
    });
    describeWithEnvironment('PreconnectedOriginFromResponseHeader', function () {
        let insight;
        let documentRequest;
        before(async function () {
            const { data, insights } = await processTrace(this, 'preconnect-advice.json.gz');
            const firstNav = getFirstOrError(data.Meta.navigationsByNavigationId.values());
            insight = getInsightOrError('NetworkDependencyTree', insights, firstNav);
            assert.isOk(insight);
            documentRequest =
                data.NetworkRequests.byTime.find(req => req.args.data.requestId === firstNav.args.data?.navigationId);
        });
        it('correctly generate the preconnected origins', () => {
            // There are 4 preconnected origins, 3 from DOM, and 1 from response header.
            assert.isOk(insight);
            assert.lengthOf(insight.preconnectedOrigins, 4);
            // A sanity check to avoid TS error.
            assert.isDefined(documentRequest);
            const expected = [
                {
                    node_id: 57,
                    frame: '3773BAB92FB5A26C6B03EAD6CF821791',
                    url: 'https://www.youtube.com/',
                    unused: true,
                    crossorigin: false,
                    source: 'DOM',
                },
                {
                    node_id: 58,
                    frame: '3773BAB92FB5A26C6B03EAD6CF821791',
                    url: 'https://www.google.com/',
                    unused: true,
                    crossorigin: false,
                    source: 'DOM',
                },
                {
                    node_id: 59,
                    frame: '3773BAB92FB5A26C6B03EAD6CF821791',
                    url: 'http://example.com/',
                    unused: true,
                    crossorigin: false,
                    source: 'DOM',
                },
                {
                    url: 'https://example.com/',
                    headerText: '<https://example.com/>; rel=preconnect',
                    request: documentRequest,
                    unused: true,
                    crossorigin: false,
                    source: 'ResponseHeader',
                },
            ];
            assert.deepEqual(insight?.preconnectedOrigins, expected);
        });
    });
    describe('handleLinkResponseHeader', () => {
        it('should return an empty array for null or empty input', () => {
            assert.deepEqual(Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(''), []);
            assert.deepEqual(Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(null), []);
        });
        it('should parse a valid preconnect link with quotes', () => {
            const linkHeader = '<https://example.com>; rel="preconnect"';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, [{ url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"' }]);
        });
        it('should parse a valid preconnect link without quotes', () => {
            const linkHeader = '<https://example.com>; rel=preconnect';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, [{ url: 'https://example.com', headerText: '<https://example.com>; rel=preconnect' }]);
        });
        it('should parse multiple preconnect links', () => {
            const linkHeader = '<https://example.com>; rel="preconnect", <https://other.com>; rel=preconnect';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, [
                { url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"' },
                { url: 'https://other.com', headerText: '<https://other.com>; rel=preconnect' },
            ]);
        });
        it('should parse a preconnect link with other parameters', () => {
            const linkHeader = '<https://example.com>; rel="preconnect"; crossorigin';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, [{ url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"; crossorigin' }]);
        });
        it('should parse a preconnect link with comma in urls', () => {
            const linkHeader = '<https://imaginary.url.notreal/segment;foo=bar;baz/item?name=What,+me+worry>; rel="preconnect"';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, [{
                    url: 'https://imaginary.url.notreal/segment;foo=bar;baz/item?name=What,+me+worry',
                    headerText: '<https://imaginary.url.notreal/segment;foo=bar;baz/item?name=What,+me+worry>; rel="preconnect"'
                }]);
        });
        it('should ignore links with other rel values', () => {
            const linkHeader = '<https://example.com>; rel="preload"';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, []);
        });
        it('should ignore invalid links (missing <>)', () => {
            const linkHeader = 'https://example.com; rel="preconnect"';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, []);
        });
        it('should ignore invalid links (missing rel)', () => {
            const linkHeader = '<https://example.com>; crossorigin';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, []);
        });
        it('should handle mixed valid and invalid links', () => {
            const linkHeader = '<https://example.com>; rel="preconnect", https://other.com; rel=preconnect, <https://another.com>; rel="preload"';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, [{ url: 'https://example.com', headerText: '<https://example.com>; rel="preconnect"' }]);
        });
        it('should not loop infinitely on a malformed link part at the end', () => {
            const linkHeader = '<https://a.com>; rel=preconnect, <https://b.com';
            const result = Trace.Insights.Models.NetworkDependencyTree.handleLinkResponseHeader(linkHeader);
            assert.deepEqual(result, [{ url: 'https://a.com', headerText: '<https://a.com>; rel=preconnect' }]);
        });
    });
});
describeWithEnvironment('generatePreconnectCandidates', () => {
    const mockParsedTrace = {
        NetworkRequests: {
            incompleteInitiator: new Map(),
            byTime: [],
            byId: new Map(),
            linkPreconnectEvents: [],
        },
        Renderer: {
            entryToNode: new Map(),
        },
        Samples: {
            entryToNode: new Map(),
        }
    };
    const mockContext = {
        // This is not need to calculate the data of this insight, but is needed to check this is a context with lantern data.
        navigation: {},
        lantern: {
            simulator: {
                getOptions: () => ({ rtt: 200, additionalRttByOrigin: new Map() }),
            },
            metrics: {
                largestContentfulPaint: {
                    pessimisticGraph: {
                        traverse: (cb) => {
                            cb({ type: 'network', request: { url: 'https://example.com/script.js' } });
                            cb({ type: 'network', request: { url: 'https://example.com/first.js' } });
                            cb({ type: 'network', request: { url: 'https://example.com/second.js' } });
                            cb({ type: 'network', request: { url: 'https://other.com/image.png' } });
                        },
                    },
                },
                firstContentfulPaint: {
                    pessimisticGraph: {
                        traverse: (cb) => {
                            cb({ type: 'network', request: { url: 'https://example.com/script.js' } });
                        },
                    },
                },
            },
        },
        bounds: { min: 0, max: 1000000 },
        navigationId: 'main-request',
    };
    const mainRequest = {
        args: {
            data: {
                url: 'https://main.com',
                requestId: 'main-request',
                syntheticData: { finishTime: 1_000 },
                timing: { connectEnd: 0, connectStart: 0 }
            },
        },
        ts: 0,
        rawSourceEvent: {
            cat: 'devtools.timeline',
            name: 'ResourceSendRequest',
        }
    };
    const validRequest = {
        args: {
            data: {
                url: 'https://example.com/script.js',
                syntheticData: { sendStartTime: 2_000 },
                timing: { dnsStart: 100, dnsEnd: 200, connectStart: 300, connectEnd: 400 },
            },
        },
        ts: 1500,
        rawSourceEvent: {
            cat: 'devtools.timeline',
            name: 'ResourceSendRequest',
        }
    };
    beforeEach(() => {
        mockParsedTrace.NetworkRequests.incompleteInitiator.clear();
        mockParsedTrace.NetworkRequests.byTime.length = 0;
        mockParsedTrace.NetworkRequests.byId.clear();
        mockParsedTrace.NetworkRequests.linkPreconnectEvents.length = 0;
        mockParsedTrace.NetworkRequests.byTime.push(mainRequest);
        mockParsedTrace.NetworkRequests.byId.set(mainRequest.args.data.requestId, mainRequest);
    });
    it('generates preconnect results for valid requests', () => {
        mockParsedTrace.NetworkRequests.byTime.push(validRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 1);
        assert.strictEqual(preconnectCandidates[0].origin, 'https://example.com');
        // |validRequest->sendStartTime| - |mainRequest->finishTime| + |validRequest->dnsStart|
        assert.strictEqual(preconnectCandidates[0].wastedMs, 101);
    });
    it('generates preconnect results and sort them by wasted time', () => {
        mockParsedTrace.NetworkRequests.byTime.push(validRequest);
        const otherValidRequest = JSON.parse(JSON.stringify(validRequest));
        otherValidRequest.args.data.url = 'https://other.com/image.png';
        otherValidRequest.args.data.syntheticData.sendStartTime = Trace.Types.Timing.Micro(3_000);
        mockParsedTrace.NetworkRequests.byTime.push(otherValidRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 2);
        // other.com has a wasted time of 102 ms, while example.com has 101 ms. So other.com will be the first.
        assert.strictEqual(preconnectCandidates[0].origin, 'https://other.com');
        // |otherValidRequest->sendStartTime| - |mainRequest->finishTime| + |otherValidRequest->dnsStart|
        assert.strictEqual(preconnectCandidates[0].wastedMs, 102);
        assert.strictEqual(preconnectCandidates[1].origin, 'https://example.com');
        // |validRequest->sendStartTime| - |mainRequest->finishTime| + |validRequest->dnsStart|
        assert.strictEqual(preconnectCandidates[1].wastedMs, 101);
    });
    it('shouldn\'t suggest preconnect when requests have same origin as main request', () => {
        const sameOriginRequest = JSON.parse(JSON.stringify(validRequest));
        sameOriginRequest.args.data.url = 'https://main.com/some-resource';
        mockParsedTrace.NetworkRequests.byTime.push(sameOriginRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 0);
    });
    it('shouldn\'t suggest preconnect when initiator is main resource', () => {
        const initiatedByMainRequest = JSON.parse(JSON.stringify(validRequest));
        initiatedByMainRequest.args.data.url = 'https://example.com/script.js';
        mockParsedTrace.NetworkRequests.byTime.push(initiatedByMainRequest);
        mockParsedTrace.NetworkRequests.incompleteInitiator.set(initiatedByMainRequest, mainRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 0);
    });
    it('shouldn\'t suggest non http(s) protocols as preconnect', () => {
        const nonHttpRequest = JSON.parse(JSON.stringify(validRequest));
        nonHttpRequest.args.data.url = 'data:text/plain;base64,hello';
        mockParsedTrace.NetworkRequests.byTime.push(nonHttpRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 0);
    });
    it('shouldn\'t suggest preconnect when request has been fired after 15s', () => {
        const aboveThresholdRequest = JSON.parse(JSON.stringify(validRequest));
        // sendStartTime is way above the threshold (15000ms)
        aboveThresholdRequest.args.data.syntheticData.sendStartTime = Trace.Types.Timing.Micro(20_000_000);
        mockParsedTrace.NetworkRequests.byTime.push(aboveThresholdRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 0);
    });
    it('shouldn\'t suggest preconnect when requests are not in LCP graph', () => {
        const notInLCPRequest = JSON.parse(JSON.stringify(validRequest));
        notInLCPRequest.args.data.url = 'https://not-in-lcp.com/some-resource';
        mockParsedTrace.NetworkRequests.byTime.push(notInLCPRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 0);
    });
    it('should only list an origin once', () => {
        const firstRequest = JSON.parse(JSON.stringify(validRequest));
        firstRequest.args.data.url = 'https://example.com/first.js';
        mockParsedTrace.NetworkRequests.byTime.push(firstRequest);
        const secondRequest = JSON.parse(JSON.stringify(validRequest));
        secondRequest.args.data.url = 'https://example.com/second.js';
        mockParsedTrace.NetworkRequests.byTime.push(secondRequest);
        const preconnectCandidates = Trace.Insights.Models.NetworkDependencyTree.generatePreconnectCandidates(mockParsedTrace, mockContext, mockParsedTrace.NetworkRequests.byTime);
        assert.lengthOf(preconnectCandidates, 1);
        assert.strictEqual(preconnectCandidates[0].origin, 'https://example.com');
        // First request has a wasted time of 101 ms, while second request has 51 ms.
        // So the final waste time will be the longer one: 101 ms.
        assert.strictEqual(preconnectCandidates[0].wastedMs, 101);
    });
});
//# sourceMappingURL=NetworkDependencyTree.test.js.map