UNPKG

chrome-devtools-frontend

Version:
1,301 lines (1,160 loc) • 52.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 Common from '../../../core/common/common.js'; import * as Host from '../../../core/host/host.js'; import * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import * as Protocol from '../../../generated/protocol.js'; import type * as Persistence from '../../../models/persistence/persistence.js'; import * as Workspace from '../../../models/workspace/workspace.js'; import { dispatchInputEvent, getCleanTextContentFromElements, renderElementIntoDOM, } from '../../../testing/DOMHelpers.js'; import {describeWithEnvironment} from '../../../testing/EnvironmentHelpers.js'; import { createWorkspaceProject, setUpEnvironment, } from '../../../testing/OverridesHelpers.js'; import { recordedMetricsContain, resetRecordedMetrics, } from '../../../testing/UserMetricsHelpers.js'; import * as RenderCoordinator from '../../../ui/components/render_coordinator/render_coordinator.js'; import * as NetworkForward from '../forward/forward.js'; import * as NetworkComponents from './components.js'; const {urlString} = Platform.DevToolsPath; const enum HeaderAttribute { HEADER_NAME = 'HeaderName', HEADER_VALUE = 'HeaderValue', } async function renderResponseHeaderSection(request: SDK.NetworkRequest.NetworkRequest): Promise<NetworkComponents.ResponseHeaderSection.ResponseHeaderSection> { const component = new NetworkComponents.ResponseHeaderSection.ResponseHeaderSection(); renderElementIntoDOM(component); Object.setPrototypeOf(request, SDK.NetworkRequest.NetworkRequest.prototype); component.data = { request, toReveal: {section: NetworkForward.UIRequestLocation.UIHeaderSection.RESPONSE, header: 'highlighted-header'}, }; await RenderCoordinator.done(); assert.instanceOf(component, HTMLElement); assert.isNotNull(component.shadowRoot); return component; } async function editHeaderRow( component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, index: number, headerAttribute: HeaderAttribute, newValue: string) { assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.isTrue(rows.length >= index + 1, 'Trying to edit row with index greater than # of rows.'); const row = rows[index]; assert.isNotNull(row.shadowRoot); const selector = headerAttribute === HeaderAttribute.HEADER_NAME ? '.header-name' : '.header-value'; const editableComponent = row.shadowRoot.querySelector(`${selector} devtools-editable-span`); assert.instanceOf(editableComponent, HTMLElement); assert.isNotNull(editableComponent.shadowRoot); const editable = editableComponent.shadowRoot.querySelector('.editable'); assert.instanceOf(editable, HTMLSpanElement); editable.focus(); editable.innerText = newValue; dispatchInputEvent(editable, {inputType: 'insertText', data: newValue, bubbles: true, composed: true}); editable.blur(); await RenderCoordinator.done(); } async function removeHeaderRow( component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, index: number): Promise<void> { assert.isNotNull(component.shadowRoot); const row = component.shadowRoot.querySelectorAll('devtools-header-section-row')[index]; assert.instanceOf(row, HTMLElement); assert.isNotNull(row.shadowRoot); const button = row.shadowRoot.querySelector('.remove-header'); assert.instanceOf(button, HTMLElement); button.click(); await RenderCoordinator.done(); } async function setupHeaderEditing( headerOverridesFileContent: string, actualHeaders: SDK.NetworkRequest.NameValue[], originalHeaders: SDK.NetworkRequest.NameValue[]) { const request = { sortedResponseHeaders: actualHeaders, originalResponseHeaders: originalHeaders, setCookieHeaders: [], blockedResponseCookies: () => [], wasBlocked: () => false, url: () => 'https://www.example.com/', getAssociatedData: () => null, setAssociatedData: () => {}, } as unknown as SDK.NetworkRequest.NetworkRequest; return await setupHeaderEditingWithRequest(headerOverridesFileContent, request); } async function setupHeaderEditingWithRequest( headerOverridesFileContent: string, request: SDK.NetworkRequest.NetworkRequest) { const networkPersistenceManager = await createWorkspaceProject(urlString`file:///path/to/overrides`, [ { name: '.headers', path: 'www.example.com/', content: headerOverridesFileContent, }, ]); const project = networkPersistenceManager.project(); let spy = sinon.spy(); if (project) { const uiSourceCode = project.uiSourceCodeForURL(urlString`file:///path/to/overrides/www.example.com/.headers`); if (uiSourceCode) { spy = sinon.spy(uiSourceCode, 'setWorkingCopy'); } } const component = await renderResponseHeaderSection(request); assert.isNotNull(component.shadowRoot); return {component, spy}; } function checkHeaderSectionRow( row: NetworkComponents.HeaderSectionRow.HeaderSectionRow, headerName: string, headerValue: string, isOverride: boolean, isNameEditable: boolean, isValueEditable: boolean, isHighlighted = false, isDeleted = false): void { assert.isNotNull(row.shadowRoot); const rowElement = row.shadowRoot.querySelector('.row'); assert.strictEqual(rowElement?.classList.contains('header-overridden'), isOverride); assert.strictEqual(rowElement?.classList.contains('header-highlight'), isHighlighted); assert.strictEqual(rowElement?.classList.contains('header-deleted'), isDeleted); const nameEditableComponent = row.shadowRoot.querySelector<NetworkComponents.EditableSpan.EditableSpan>('.header-name devtools-editable-span'); if (isNameEditable) { assert.instanceOf(nameEditableComponent, HTMLElement); assert.isNotNull(nameEditableComponent.shadowRoot); const nameEditable = nameEditableComponent.shadowRoot.querySelector('.editable'); assert.instanceOf(nameEditable, HTMLSpanElement); const textContent = nameEditable.textContent?.trim() + (row.shadowRoot.querySelector('.header-name')?.textContent || '').trim(); assert.strictEqual(textContent, headerName); } else { assert.isNull(nameEditableComponent); assert.strictEqual(row.shadowRoot.querySelector('.header-name')?.textContent?.trim(), headerName); } const valueEditableComponent = row.shadowRoot.querySelector<NetworkComponents.EditableSpan.EditableSpan>('.header-value devtools-editable-span'); if (isValueEditable) { assert.instanceOf(valueEditableComponent, HTMLElement); assert.isNotNull(valueEditableComponent.shadowRoot); const valueEditable = valueEditableComponent.shadowRoot.querySelector('.editable'); assert.instanceOf(valueEditable, HTMLSpanElement); assert.strictEqual(valueEditable.textContent?.trim(), headerValue); } else { assert.isNull(valueEditableComponent); assert.strictEqual(row.shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue); } } function isRowFocused( component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, rowIndex: number): boolean { assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.isTrue(rows.length > rowIndex); return Boolean(rows[rowIndex].shadowRoot?.activeElement); } describeWithEnvironment('ResponseHeaderSection', () => { beforeEach(async () => { await setUpEnvironment(); resetRecordedMetrics(); }); it('renders detailed reason for blocked requests', async () => { const request = { sortedResponseHeaders: [ {name: 'content-length', value: '661'}, ], blockedResponseCookies: () => [], wasBlocked: () => true, blockedReason: () => Protocol.Network.BlockedReason.CorpNotSameOriginAfterDefaultedToSameOriginByCoep, originalResponseHeaders: [], setCookieHeaders: [], url: () => 'https://www.example.com/', getAssociatedData: () => null, setAssociatedData: () => {}, } as unknown as SDK.NetworkRequest.NetworkRequest; const component = await renderResponseHeaderSection(request); assert.isNotNull(component.shadowRoot); const row = component.shadowRoot.querySelectorAll('devtools-header-section-row')[1]; assert.instanceOf(row, HTMLElement); assert.isNotNull(row.shadowRoot); const regex = /^\s*not-set\s*cross-origin-resource-policy\s*$/; assert.isTrue(regex.test(row.shadowRoot.querySelector('.header-name')?.textContent || '')); assert.strictEqual(row.shadowRoot.querySelector('.header-value')?.textContent?.trim(), ''); assert.strictEqual( getCleanTextContentFromElements(row.shadowRoot, '.call-to-action')[0], `To use this resource from a different origin, the server needs to specify a cross-origin resource policy in the response headers: Cross-Origin-Resource-Policy: same-site Choose this option if the resource and the document are served from the same site. Cross-Origin-Resource-Policy: cross-origin Only choose this option if an arbitrary website including this resource does not impose a security risk. Learn more`, ); }); it('displays info about blocked "Set-Cookie"-headers', async () => { const request = { sortedResponseHeaders: [{name: 'Set-Cookie', value: 'secure=only; Secure'}], blockedResponseCookies: () => [{ blockedReasons: ['SecureOnly', 'OverwriteSecure'], cookieLine: 'secure=only; Secure', cookie: null, }], wasBlocked: () => false, originalResponseHeaders: [], setCookieHeaders: [], url: () => 'https://www.example.com/', getAssociatedData: () => null, setAssociatedData: () => {}, } as unknown as SDK.NetworkRequest.NetworkRequest; const component = await renderResponseHeaderSection(request); assert.isNotNull(component.shadowRoot); const row = component.shadowRoot.querySelector('devtools-header-section-row'); assert.instanceOf(row, HTMLElement); assert.isNotNull(row.shadowRoot); assert.strictEqual(row.shadowRoot.querySelector('.header-name')?.textContent?.trim(), 'set-cookie'); assert.strictEqual(row.shadowRoot.querySelector('.header-value')?.textContent?.trim(), 'secure=only; Secure'); const icon = row.shadowRoot.querySelector('devtools-icon'); assert.instanceOf(icon, HTMLElement); assert.strictEqual( icon.title, 'This attempt to set a cookie via a Set-Cookie header was blocked because it had the ' + '"Secure" attribute but was not received over a secure connection.\nThis attempt to ' + 'set a cookie via a Set-Cookie header was blocked because it was not sent over a ' + 'secure connection and would have overwritten a cookie with the Secure attribute.'); }); it('marks overridden headers', async () => { const request = { sortedResponseHeaders: [ // keep names in alphabetical order {name: 'duplicate-both-no-mismatch', value: 'foo'}, {name: 'duplicate-both-no-mismatch', value: 'bar'}, {name: 'duplicate-both-with-mismatch', value: 'Chrome'}, {name: 'duplicate-both-with-mismatch', value: 'DevTools'}, {name: 'duplicate-different-order', value: 'aaa'}, {name: 'duplicate-different-order', value: 'bbb'}, {name: 'duplicate-in-actual-headers', value: 'first'}, {name: 'duplicate-in-actual-headers', value: 'second'}, {name: 'duplicate-in-original-headers', value: 'two'}, {name: 'duplicate-single-line', value: 'first line, second line'}, {name: 'is-in-original-headers', value: 'not an override'}, {name: 'not-in-original-headers', value: 'is an override'}, {name: 'triplicate', value: '1'}, {name: 'triplicate', value: '2'}, {name: 'triplicate', value: '2'}, {name: 'xyz', value: 'contains \tab'}, ], blockedResponseCookies: () => [], wasBlocked: () => false, originalResponseHeaders: [ // keep names in alphabetical order {name: 'duplicate-both-no-mismatch', value: 'foo'}, {name: 'duplicate-both-no-mismatch', value: 'bar'}, {name: 'duplicate-both-with-mismatch', value: 'Chrome'}, {name: 'duplicate-both-with-mismatch', value: 'Canary'}, {name: 'duplicate-different-order', value: 'bbb'}, {name: 'duplicate-different-order', value: 'aaa'}, {name: 'duplicate-in-actual-headers', value: 'first'}, {name: 'duplicate-in-original-headers', value: 'one'}, {name: 'duplicate-in-original-headers', value: 'two'}, {name: 'duplicate-single-line', value: 'first line'}, {name: 'duplicate-single-line', value: 'second line'}, {name: 'is-in-original-headers', value: 'not an override'}, {name: 'triplicate', value: '1'}, {name: 'triplicate', value: '1'}, {name: 'triplicate', value: '2'}, {name: 'xyz', value: 'contains \tab'}, ], setCookieHeaders: [], url: () => 'https://www.example.com/', getAssociatedData: () => null, setAssociatedData: () => {}, } as unknown as SDK.NetworkRequest.NetworkRequest; const component = await renderResponseHeaderSection(request); assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); const checkRow = (shadowRoot: ShadowRoot, headerName: string, headerValue: string, isOverride: boolean) => { assert.strictEqual(shadowRoot.querySelector('.header-name')?.textContent?.trim(), headerName); assert.strictEqual(shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue); assert.strictEqual(shadowRoot.querySelector('.row')?.classList.contains('header-overridden'), isOverride); }; assert.isNotNull(rows[0].shadowRoot); checkRow(rows[0].shadowRoot, 'duplicate-both-no-mismatch', 'foo', false); assert.isNotNull(rows[1].shadowRoot); checkRow(rows[1].shadowRoot, 'duplicate-both-no-mismatch', 'bar', false); assert.isNotNull(rows[2].shadowRoot); checkRow(rows[2].shadowRoot, 'duplicate-both-with-mismatch', 'Chrome', true); assert.isNotNull(rows[3].shadowRoot); checkRow(rows[3].shadowRoot, 'duplicate-both-with-mismatch', 'DevTools', true); assert.isNotNull(rows[4].shadowRoot); checkRow(rows[4].shadowRoot, 'duplicate-different-order', 'aaa', true); assert.isNotNull(rows[5].shadowRoot); checkRow(rows[5].shadowRoot, 'duplicate-different-order', 'bbb', true); assert.isNotNull(rows[6].shadowRoot); checkRow(rows[6].shadowRoot, 'duplicate-in-actual-headers', 'first', true); assert.isNotNull(rows[7].shadowRoot); checkRow(rows[7].shadowRoot, 'duplicate-in-actual-headers', 'second', true); assert.isNotNull(rows[8].shadowRoot); checkRow(rows[8].shadowRoot, 'duplicate-in-original-headers', 'two', true); assert.isNotNull(rows[9].shadowRoot); checkRow(rows[9].shadowRoot, 'duplicate-single-line', 'first line, second line', false); assert.isNotNull(rows[10].shadowRoot); checkRow(rows[10].shadowRoot, 'is-in-original-headers', 'not an override', false); assert.isNotNull(rows[11].shadowRoot); checkRow(rows[11].shadowRoot, 'not-in-original-headers', 'is an override', true); assert.isNotNull(rows[12].shadowRoot); checkRow(rows[12].shadowRoot, 'triplicate', '1', true); assert.isNotNull(rows[13].shadowRoot); checkRow(rows[13].shadowRoot, 'triplicate', '2', true); assert.isNotNull(rows[14].shadowRoot); checkRow(rows[14].shadowRoot, 'triplicate', '2', true); assert.isNotNull(rows[15].shadowRoot); checkRow(rows[15].shadowRoot, 'xyz', 'contains ab', false); }); it('correctly sets headers as "editable" when matching ".headers" file exists and setting is turned on', async () => { await createWorkspaceProject(urlString`file:///path/to/overrides`, [ { name: '.headers', path: 'www.example.com/', content: `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`, }, ]); const request = { sortedResponseHeaders: [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'overridden server'}, ], blockedResponseCookies: () => [], wasBlocked: () => false, originalResponseHeaders: [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'original server'}, ], setCookieHeaders: [], url: () => 'https://www.example.com/', getAssociatedData: () => null, setAssociatedData: () => {}, } as unknown as SDK.NetworkRequest.NetworkRequest; const component = await renderResponseHeaderSection(request); assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); checkHeaderSectionRow(rows[0], 'cache-control', 'max-age=600', false, false, true); checkHeaderSectionRow(rows[1], 'server', 'overridden server', true, false, true); Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(false); component.data = {request}; await RenderCoordinator.done(); checkHeaderSectionRow(rows[0], 'cache-control', 'max-age=600', false, false, false); checkHeaderSectionRow(rows[1], 'server', 'overridden server', true, false, false); Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(true); }); it('does not set headers as "editable" when matching ".headers" file cannot be parsed correctly', async () => { await createWorkspaceProject(urlString`file:///path/to/overrides`, [ { name: '.headers', path: 'www.example.com/', // 'headers' contains the invalid key 'no-name' and will therefore // cause a parsing error. content: `[ { "applyTo": "index.html", "headers": [{ "no-name": "server", "value": "overridden server" }] } ]`, }, ]); const request = { sortedResponseHeaders: [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'overridden server'}, ], blockedResponseCookies: () => [], wasBlocked: () => false, originalResponseHeaders: [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'original server'}, ], setCookieHeaders: [], url: () => 'https://www.example.com/', getAssociatedData: () => null, setAssociatedData: () => {}, } as unknown as SDK.NetworkRequest.NetworkRequest; // A console error is emitted when '.headers' cannot be parsed correctly. // We don't need that noise in the test output. sinon.stub(console, 'error'); const component = await renderResponseHeaderSection(request); assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.isNotNull(rows[0].shadowRoot); checkHeaderSectionRow(rows[0], 'cache-control', 'max-age=600', false, false, false); assert.isNotNull(rows[1].shadowRoot); checkHeaderSectionRow(rows[1], 'server', 'overridden server', true, false, false); }); it('can edit original headers', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const actualHeaders = [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'overridden server'}, ]; const originalHeaders = [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'original server'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'max-age=9999'); const expected = [{ applyTo: 'index.html', headers: [ { name: 'server', value: 'overridden server', }, { name: 'cache-control', value: 'max-age=9999', }, ], }]; assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); assert.isTrue(recordedMetricsContain( Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken, Host.UserMetrics.Action.HeaderOverrideHeaderEdited)); }); it('can handle tab-character in header value', async () => { const headers = [ {name: 'foo', value: 'syn\tax'}, ]; const {component, spy} = await setupHeaderEditing('[]', headers, headers); assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 1); checkHeaderSectionRow(rows[0], 'foo', 'syn ax', false, false, true); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'syn ax'); sinon.assert.notCalled(spy); checkHeaderSectionRow(rows[0], 'foo', 'syn ax', false, false, true); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'syntax'); const expected = [{ applyTo: 'index.html', headers: [ { name: 'foo', value: 'syntax', }, ], }]; assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); checkHeaderSectionRow(rows[0], 'foo', 'syntax', true, false, true); }); it('can edit overridden headers', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const actualHeaders = [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'overridden server'}, ]; const originalHeaders = [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'original server'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); await editHeaderRow(component, 1, HeaderAttribute.HEADER_VALUE, 'edited value'); const expected = [{ applyTo: 'index.html', headers: [ { name: 'server', value: 'edited value', }, ], }]; assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); assert.isTrue(recordedMetricsContain( Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken, Host.UserMetrics.Action.HeaderOverrideHeaderEdited)); }); it('can remove header overrides', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [ { "name": "highlighted-header", "value": "overridden highlighted-header" }, { "name": "cache-control", "value": "max-age=9999" }, { "name": "added", "value": "foo" } ] } ]`; const actualHeaders = [ {name: 'added', value: 'foo'}, {name: 'cache-control', value: 'max-age=9999'}, {name: 'highlighted-header', value: 'overridden highlighted-header'}, ]; const originalHeaders = [ {name: 'cache-control', value: 'max-age=600'}, {name: 'highlighted-header', value: 'original highlighted-header'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); assert.isNotNull(component.shadowRoot); let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 3); checkHeaderSectionRow(rows[0], 'added', 'foo', true, false, true); checkHeaderSectionRow(rows[1], 'cache-control', 'max-age=9999', true, false, true); checkHeaderSectionRow(rows[2], 'highlighted-header', 'overridden highlighted-header', true, false, true, true); await removeHeaderRow(component, 2); let expected = [{ applyTo: 'index.html', headers: [ { name: 'cache-control', value: 'max-age=9999', }, { name: 'added', value: 'foo', }, ], }]; sinon.assert.callCount(spy, 1); assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); assert.isTrue(recordedMetricsContain( Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken, Host.UserMetrics.Action.HeaderOverrideHeaderRemoved)); rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 3); checkHeaderSectionRow(rows[0], 'added', 'foo', true, false, true); checkHeaderSectionRow(rows[1], 'cache-control', 'max-age=9999', true, false, true); checkHeaderSectionRow( rows[2], 'highlighted-header', 'overridden highlighted-header', true, false, false, true, true); spy.resetHistory(); await removeHeaderRow(component, 0); expected = [{ applyTo: 'index.html', headers: [ { name: 'cache-control', value: 'max-age=9999', }, ], }]; sinon.assert.callCount(spy, 1); assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 3); checkHeaderSectionRow(rows[0], 'added', 'foo', true, false, false, false, true); checkHeaderSectionRow(rows[1], 'cache-control', 'max-age=9999', true, false, true); checkHeaderSectionRow( rows[2], 'highlighted-header', 'overridden highlighted-header', true, false, false, true, true); }); it('can remove the last header override', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [ { "name": "server", "value": "overridden server" } ] } ]`; const actualHeaders = [ {name: 'server', value: 'overridden server'}, ]; const originalHeaders = [ {name: 'server', value: 'original server'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); await removeHeaderRow(component, 0); const expected: Persistence.NetworkPersistenceManager.HeaderOverride[] = []; sinon.assert.callCount(spy, 1); assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); assert.isTrue(recordedMetricsContain( Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken, Host.UserMetrics.Action.HeaderOverrideHeaderRemoved)); }); it('can handle non-breaking spaces when removing header overrides', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [ { "name": "added", "value": "space\xa0between" } ] } ]`; const actualHeaders = [ {name: 'added', value: 'space between'}, {name: 'cache-control', value: 'max-age=600'}, ]; const originalHeaders = [ {name: 'cache-control', value: 'max-age=600'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); assert.isNotNull(component.shadowRoot); let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'added', 'space between', true, false, true); checkHeaderSectionRow(rows[1], 'cache-control', 'max-age=600', false, false, true); await removeHeaderRow(component, 0); const expected: Persistence.NetworkPersistenceManager.HeaderOverride[] = []; sinon.assert.callCount(spy, 1); assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'added', 'space between', true, false, false, false, true); checkHeaderSectionRow(rows[1], 'cache-control', 'max-age=600', false, false, true); }); it('does not generate header overrides which have "applyTo" but empty "headers" array', async () => { const actualHeaders = [ {name: 'server', value: 'original server'}, ]; const {component, spy} = await setupHeaderEditing('[]', actualHeaders, actualHeaders); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'overridden server'); const expected = [{ applyTo: 'index.html', headers: [ { name: 'server', value: 'overridden server', }, ], }]; sinon.assert.callCount(spy, 1); assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2))); spy.resetHistory(); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'original server'); sinon.assert.callCount(spy, 1); assert.isTrue(spy.calledOnceWith(JSON.stringify([], null, 2))); }); it('can add headers', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const actualHeaders = [{name: 'server', value: 'overridden server'}]; const originalHeaders = [{name: 'server', value: 'original server'}]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); assert.isNotNull(component.shadowRoot); const addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.instanceOf(addHeaderButton, HTMLElement); addHeaderButton.click(); await RenderCoordinator.done(); let expected = [{ applyTo: 'index.html', headers: [ { name: 'server', value: 'overridden server', }, { name: 'header-name', value: 'header value', }, ], }]; sinon.assert.calledWith(spy.getCall(-1), JSON.stringify(expected, null, 2)); assert.isTrue(recordedMetricsContain( Host.InspectorFrontendHostAPI.EnumeratedHistogram.ActionTaken, Host.UserMetrics.Action.HeaderOverrideHeaderAdded)); await editHeaderRow(component, 1, HeaderAttribute.HEADER_NAME, 'foo'); expected = [{ applyTo: 'index.html', headers: [ { name: 'server', value: 'overridden server', }, { name: 'foo', value: 'header value', }, ], }]; sinon.assert.calledWith(spy.getCall(-1), JSON.stringify(expected, null, 2)); await editHeaderRow(component, 1, HeaderAttribute.HEADER_VALUE, 'bar'); expected = [{ applyTo: 'index.html', headers: [ { name: 'server', value: 'overridden server', }, { name: 'foo', value: 'bar', }, ], }]; sinon.assert.calledWith(spy.getCall(-1), JSON.stringify(expected, null, 2)); }); it('does not persist invalid header names', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const actualHeaders = [{name: 'server', value: 'overridden server'}]; const originalHeaders = [{name: 'server', value: 'original server'}]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); assert.isNotNull(component.shadowRoot); const addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.instanceOf(addHeaderButton, HTMLElement); addHeaderButton.click(); await RenderCoordinator.done(); sinon.assert.callCount(spy, 1); await editHeaderRow(component, 1, HeaderAttribute.HEADER_NAME, 'valid'); sinon.assert.callCount(spy, 2); await editHeaderRow(component, 1, HeaderAttribute.HEADER_NAME, 'in:valid'); sinon.assert.callCount(spy, 2); }); it('can remove a newly added header', async () => { const actualHeaders = [ {name: 'server', value: 'original server'}, ]; const {component, spy} = await setupHeaderEditing('[]', actualHeaders, actualHeaders); assert.isNotNull(component.shadowRoot); const addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.instanceOf(addHeaderButton, HTMLElement); addHeaderButton.click(); await RenderCoordinator.done(); const expected = [{ applyTo: 'index.html', headers: [ { name: 'header-name', value: 'header value', }, ], }]; sinon.assert.calledWith(spy.getCall(-1), JSON.stringify(expected, null, 2)); let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'server', 'original server', false, false, true); checkHeaderSectionRow(rows[1], 'header-name', 'header value', true, true, true); spy.resetHistory(); await removeHeaderRow(component, 1); sinon.assert.callCount(spy, 1); assert.isTrue(spy.calledOnceWith(JSON.stringify([], null, 2))); rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'server', 'original server', false, false, true); checkHeaderSectionRow(rows[1], 'header-name', 'header value', true, false, false, false, true); }); it('renders headers as (not) editable depending on overall overrides setting', async () => { const request = SDK.NetworkRequest.NetworkRequest.create( 'requestId' as Protocol.Network.RequestId, urlString`https://www.example.com/index.html`, urlString``, null, null, null); request.responseHeaders = [{name: 'server', value: 'overridden server'}]; request.originalResponseHeaders = [{name: 'server', value: 'original server'}]; const {component} = await setupHeaderEditingWithRequest('[]', request); assert.isNotNull(component.shadowRoot); const addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.instanceOf(addHeaderButton, HTMLElement); addHeaderButton.click(); await RenderCoordinator.done(); let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'server', 'overridden server', true, false, true); checkHeaderSectionRow(rows[1], 'header-name', 'header value', true, true, true); component.remove(); Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(false); const component2 = await renderResponseHeaderSection(request); assert.isNotNull(component2.shadowRoot); rows = component2.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'server', 'overridden server', true, false, false); checkHeaderSectionRow(rows[1], 'header-name', 'header value', true, false, false); component2.remove(); Common.Settings.Settings.instance().moduleSetting('persistence-network-overrides-enabled').set(true); const component3 = await renderResponseHeaderSection(request); assert.isNotNull(component3.shadowRoot); rows = component3.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'server', 'overridden server', true, false, true); checkHeaderSectionRow(rows[1], 'header-name', 'header value', true, true, true); }); it('can show the "edit header" button', async () => { const request = SDK.NetworkRequest.NetworkRequest.create( 'requestId' as Protocol.Network.RequestId, urlString`https://www.foo.com/index.html`, urlString``, null, null, null); request.responseHeaders = [{name: 'foo', value: 'bar'}]; request.originalResponseHeaders = [{name: 'foo', value: 'bar'}]; const {component} = await setupHeaderEditingWithRequest('[]', request); assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 1); assert.isNotNull(rows[0].shadowRoot); assert.isNotNull(rows[0].shadowRoot.querySelector('.enable-editing')); }); it('does not show the "edit header" button for requests with a forbidden URL', async () => { const request = SDK.NetworkRequest.NetworkRequest.create( 'requestId' as Protocol.Network.RequestId, urlString`chrome://terms/`, urlString``, null, null, null); request.responseHeaders = [{name: 'foo', value: 'bar'}]; request.originalResponseHeaders = [{name: 'foo', value: 'bar'}]; const {component} = await setupHeaderEditingWithRequest('[]', request); assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 1); assert.isNotNull(rows[0].shadowRoot); assert.isNull(rows[0].shadowRoot.querySelector('.enable-editing')); }); it('can edit multiple headers', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const actualHeaders = [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'overridden server'}, ]; const originalHeaders = [ {name: 'cache-control', value: 'max-age=600'}, {name: 'server', value: 'original server'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'edited cache-control'); await editHeaderRow(component, 1, HeaderAttribute.HEADER_VALUE, 'edited server'); const expected = [{ applyTo: 'index.html', headers: [ { name: 'cache-control', value: 'edited cache-control', }, { name: 'server', value: 'edited server', }, ], }]; sinon.assert.calledWith(spy.lastCall, JSON.stringify(expected, null, 2)); }); it('can edit multiple headers which have the same name', async () => { const headerOverridesFileContent = '[]'; const actualHeaders = [ {name: 'link', value: 'first value'}, {name: 'link', value: 'second value'}, ]; const originalHeaders = [ {name: 'link', value: 'first value'}, {name: 'link', value: 'second value'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'third value'); let expected = [{ applyTo: 'index.html', headers: [ { name: 'link', value: 'third value', }, ], }]; sinon.assert.calledWith(spy.lastCall, JSON.stringify(expected, null, 2)); await editHeaderRow(component, 1, HeaderAttribute.HEADER_VALUE, 'fourth value'); expected = [{ applyTo: 'index.html', headers: [ { name: 'link', value: 'third value', }, { name: 'link', value: 'fourth value', }, ], }]; sinon.assert.calledWith(spy.lastCall, JSON.stringify(expected, null, 2)); }); it('can edit multiple headers which have the same name and which are already overridden', async () => { const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [ { "name": "link", "value": "third value" }, { "name": "link", "value": "fourth value" } ] } ]`; const actualHeaders = [ {name: 'link', value: 'third value'}, {name: 'link', value: 'fourth value'}, ]; const originalHeaders = [ {name: 'link', value: 'first value'}, {name: 'link', value: 'second value'}, ]; const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders); await editHeaderRow(component, 1, HeaderAttribute.HEADER_VALUE, 'fifth value'); let expected = [{ applyTo: 'index.html', headers: [ { name: 'link', value: 'third value', }, { name: 'link', value: 'fifth value', }, ], }]; sinon.assert.calledWith(spy.lastCall, JSON.stringify(expected, null, 2)); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'sixth value'); expected = [{ applyTo: 'index.html', headers: [ { name: 'link', value: 'sixth value', }, { name: 'link', value: 'fifth value', }, ], }]; sinon.assert.calledWith(spy.lastCall, JSON.stringify(expected, null, 2)); }); it('persists edits to header overrides and resurfaces them upon component (re-)creation', async () => { const request = SDK.NetworkRequest.NetworkRequest.create( 'requestId' as Protocol.Network.RequestId, urlString`https://www.example.com/index.html`, urlString``, null, null, null); request.responseHeaders = [{name: 'server', value: 'overridden server'}]; request.originalResponseHeaders = [{name: 'server', value: 'original server'}]; const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const {component, spy} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request); assert.isNotNull(component.shadowRoot); const addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.instanceOf(addHeaderButton, HTMLElement); addHeaderButton.click(); await RenderCoordinator.done(); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'unit test'); await editHeaderRow(component, 1, HeaderAttribute.HEADER_NAME, 'foo'); await editHeaderRow(component, 1, HeaderAttribute.HEADER_VALUE, 'bar'); const expected = [{ applyTo: 'index.html', headers: [ { name: 'server', value: 'unit test', }, { name: 'foo', value: 'bar', }, ], }]; sinon.assert.calledWith(spy.getCall(-1), JSON.stringify(expected, null, 2)); component.remove(); const component2 = await renderResponseHeaderSection(request); assert.isNotNull(component2.shadowRoot); const rows = component2.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 2); checkHeaderSectionRow(rows[0], 'server', 'unit test', true, false, true); checkHeaderSectionRow(rows[1], 'foo', 'bar', true, true, true); }); it('focuses on newly added header rows on initial render', async () => { const request = SDK.NetworkRequest.NetworkRequest.create( 'requestId' as Protocol.Network.RequestId, urlString`https://www.example.com/index.html`, urlString``, null, null, null); request.responseHeaders = [{name: 'server', value: 'overridden server'}]; request.originalResponseHeaders = [{name: 'server', value: 'original server'}]; const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const {component} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request); assert.isNotNull(component.shadowRoot); const addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.instanceOf(addHeaderButton, HTMLElement); addHeaderButton.click(); await RenderCoordinator.done(); assert.isFalse(isRowFocused(component, 0)); assert.isTrue(isRowFocused(component, 1)); component.remove(); const component2 = await renderResponseHeaderSection(request); assert.isNotNull(component2.shadowRoot); assert.isFalse(isRowFocused(component2, 0)); assert.isFalse(isRowFocused(component2, 1)); }); it('can handle removal of ".headers" file', async () => { const request = SDK.NetworkRequest.NetworkRequest.create( 'requestId' as Protocol.Network.RequestId, urlString`https://www.example.com/index.html`, urlString``, null, null, null); request.responseHeaders = [{name: 'server', value: 'overridden server'}]; request.originalResponseHeaders = [{name: 'server', value: 'original server'}]; const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [{ "name": "server", "value": "overridden server" }] } ]`; const {component} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request); assert.isNotNull(component.shadowRoot); let addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.instanceOf(addHeaderButton, HTMLElement); addHeaderButton.click(); await RenderCoordinator.done(); await editHeaderRow(component, 0, HeaderAttribute.HEADER_VALUE, 'unit test'); sinon.stub(Workspace.Workspace.WorkspaceImpl.instance(), 'uiSourceCodeForURL').callsFake(() => null); component.data = {request}; await RenderCoordinator.done(); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 1); checkHeaderSectionRow(rows[0], 'server', 'overridden server', true, false, false); addHeaderButton = component.shadowRoot.querySelector('.add-header-button'); assert.isNull(addHeaderButton); }); it('handles rendering and editing \'set-cookie\' headers', async () => { const request = SDK.NetworkRequest.NetworkRequest.create( 'requestId' as Protocol.Network.RequestId, urlString`https://www.example.com/index.html`, urlString``, null, null, null); request.responseHeaders = [ {name: 'Cache-Control', value: 'max-age=600'}, {name: 'Z-Header', value: 'zzz'}, ]; request.originalResponseHeaders = [ {name: 'Set-Cookie', value: 'bar=original'}, {name: 'Set-Cookie', value: 'foo=original'}, {name: 'Set-Cookie', value: 'malformed'}, {name: 'Cache-Control', value: 'max-age=600'}, {name: 'Z-header', value: 'zzz'}, ]; request.setCookieHeaders = [ {name: 'Set-Cookie', value: 'bar=original'}, {name: 'Set-Cookie', value: 'foo=overridden'}, {name: 'Set-Cookie', value: 'user=12345'}, {name: 'Set-Cookie', value: 'malformed'}, {name: 'Set-Cookie', value: 'wrong format'}, ]; const headerOverridesFileContent = `[ { "applyTo": "index.html", "headers": [ { "name": "set-cookie", "value": "foo=overridden" }, { "name": "set-cookie", "value": "user=12345" }, { "name": "set-cookie", "value": "wrong format" } ] } ]`; const {component, spy} = await setupHeaderEditingWithRequest(headerOverridesFileContent, request); assert.isNotNull(component.shadowRoot); const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row'); assert.lengthOf(rows, 7); assert.isNotNull(rows[0].shadowRoot); checkHeaderSectionRow(rows[0], 'cache-control', 'max-age=600', false, false, true); assert.isNotNull(rows[1].shadowRoot); checkHeaderSectionRow(rows[1], 'set-cookie', 'bar=original', false, false, true); assert.isNotNull(rows[2].shadowRoot); checkHeaderSectionRow(rows[2], 'set-cookie', 'foo=overridden', true, false, true); assert.isNotNull(rows[3].shadowRoot); checkHeaderSectionRow(rows[3], 'set-cookie', 'user=12345', true, false, true); assert.isNotNull(rows[4].shadowRoot); checkHeaderSectionRow(rows[4], 'set-cookie', 'malformed', false, false, true); assert.isNotNull(rows[5].shadowRoot); checkHeaderSectionRow(rows[5], 'set-cookie', 'wrong format', true, false, true); assert.isNotNull(rows[6].shadowRoot); checkHeaderSectionRow(rows[6], 'z-header', 'zzz', false, false, true); await editHeaderRow(component, 2, HeaderAttribute.HEADER_VALUE, 'foo=edited'); const expected = [{ applyTo: 'index.html', headers: [ { name: 'set-cookie', value: 'user=12345', }, { name: 'set-cookie', value: 'wrong format', }, { name: 'set-cookie', value: 'foo=edited', }, ], }]; sinon.assert.calledWith(spy.getCall(-1), JSON.stringify(expected, null, 2)); await editHeaderRow(component, 1, HeaderAttribute.HEADER_VALUE, 'bar=edited'); expected[0].headers.push({name: 'set-cookie', value: 'bar=edited'}); sinon.assert.calledWith(spy.getCall(-1), JSON.stringify(expected, null, 2)); }); it('ignores capitalisation of the `s