Report detailed blocked reason in headers view

This adds detailed explanations to the headers view component for
requests blocked by COEP (originally added in
https://crrev.com/c/2083095).

Screenshot: https://i.imgur.com/i71ibMl.png

Bug: 1297533
Change-Id: Ia5fc6f137492f81baa6d70ce927696f96c2b4d61
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3740584
Commit-Queue: Wolfgang Beyer <[email protected]>
Reviewed-by: Danil Somsikov <[email protected]>
diff --git a/front_end/panels/network/components/BUILD.gn b/front_end/panels/network/components/BUILD.gn
index 6158983..96f8e4a 100644
--- a/front_end/panels/network/components/BUILD.gn
+++ b/front_end/panels/network/components/BUILD.gn
@@ -23,9 +23,13 @@
   ]
 
   deps = [
+    "../../../core/common:bundle",
+    "../../../core/host:bundle",
     "../../../core/i18n:bundle",
+    "../../../core/platform:bundle",
     "../../../core/sdk:bundle",
     "../../../generated:protocol",
+    "../../../models/issues_manager:bundle",
     "../../../panels/utils:bundle",
     "../../../ui/components/buttons:bundle",
     "../../../ui/components/data_grid:bundle",
diff --git a/front_end/panels/network/components/RequestHeadersView.css b/front_end/panels/network/components/RequestHeadersView.css
index d5b2ed7..36693ea 100644
--- a/front_end/panels/network/components/RequestHeadersView.css
+++ b/front_end/panels/network/components/RequestHeadersView.css
@@ -109,3 +109,58 @@
   white-space: pre-wrap;
   word-break: break-all;
 }
+
+.header-badge-text {
+  font-variant: small-caps;
+  font-weight: 500;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.header-badge {
+  display: inline;
+  margin-right: 0.75em;
+  background-color: var(--color-accent-red);
+  color: var(--color-background);
+  border-radius: 100vh;
+  padding-left: 6px;
+  padding-right: 6px;
+}
+
+.call-to-action {
+  background-color: var(--color-background-elevation-1);
+  padding: 8px;
+  border-radius: 2px;
+}
+
+.call-to-action-body {
+  padding: 6px 0;
+  margin-left: 9.5px;
+  border-left: 2px solid var(--issue-color-yellow);
+  padding-left: 18px;
+  line-height: 20px;
+}
+
+.call-to-action .explanation {
+  font-weight: bold;
+}
+
+.call-to-action code {
+  font-size: 90%;
+}
+
+.call-to-action .example .comment::before {
+  content: " — ";
+}
+
+.link,
+.devtools-link {
+  color: var(--color-link);
+  text-decoration: underline;
+  cursor: pointer;
+  padding: 2px 0; /* adjust focus ring size */
+}
+
+.inline-icon {
+  vertical-align: middle;
+}
diff --git a/front_end/panels/network/components/RequestHeadersView.ts b/front_end/panels/network/components/RequestHeadersView.ts
index 3332ca2..e422f8c 100644
--- a/front_end/panels/network/components/RequestHeadersView.ts
+++ b/front_end/panels/network/components/RequestHeadersView.ts
@@ -3,11 +3,16 @@
 // found in the LICENSE file.
 
 import * as Common from '../../../core/common/common.js';
+import * as Host from '../../../core/host/host.js';
 import * as i18n from '../../../core/i18n/i18n.js';
+import * as Platform from '../../../core/platform/platform.js';
 import {assertNotNullOrUndefined} from '../../../core/platform/platform.js';
 import * as SDK from '../../../core/sdk/sdk.js';
+import * as Protocol from '../../../generated/protocol.js';
+import * as IssuesManager from '../../../models/issues_manager/issues_manager.js';
 import * as Buttons from '../../../ui/components/buttons/buttons.js';
 import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
+import * as IconButton from '../../../ui/components/icon_button/icon_button.js';
 import * as Input from '../../../ui/components/input/input.js';
 import * as UI from '../../../ui/legacy/legacy.js';
 import * as LitHtml from '../../../ui/lit-html/lit-html.js';
@@ -19,6 +24,11 @@
 
 const UIStrings = {
   /**
+  *@description Text in Headers View of the Network panel
+  */
+  chooseThisOptionIfTheResourceAnd:
+      'Choose this option if the resource and the document are served from the same site.',
+  /**
   *@description Text in Request Headers View of the Network panel
   */
   fromDiskCache: '(from disk cache)',
@@ -47,10 +57,23 @@
   */
   general: 'General',
   /**
+  *@description Text that is usually a hyperlink to more documentation
+  */
+  learnMore: 'Learn more',
+  /**
+  *@description Text for a link to the issues panel
+  */
+  learnMoreInTheIssuesTab: 'Learn more in the issues tab',
+  /**
   *@description Label for a checkbox to switch between raw and parsed headers
   */
   raw: 'Raw',
   /**
+  *@description Text in Headers View of the Network panel
+  */
+  onlyChooseThisOptionIfAn:
+      'Only choose this option if an arbitrary website including this resource does not impose a security risk.',
+  /**
   *@description Text in Request Headers View of the Network panel
   */
   referrerPolicy: 'Referrer Policy',
@@ -82,9 +105,35 @@
   *@description HTTP response code
   */
   statusCode: 'Status Code',
+  /**
+  *@description Text in Headers View of the Network panel
+  */
+  thisDocumentWasBlockedFrom:
+      'This document was blocked from loading in an `iframe` with a `sandbox` attribute because this document specified a cross-origin opener policy.',
+  /**
+  *@description Text in Headers View of the Network panel
+  */
+  toEmbedThisFrameInYourDocument:
+      'To embed this frame in your document, the response needs to enable the cross-origin embedder policy by specifying the following response header:',
+  /**
+  *@description Text in Headers View of the Network panel
+  */
+  toUseThisResourceFromADifferent:
+      'To use this resource from a different origin, the server needs to specify a cross-origin resource policy in the response headers:',
+  /**
+  *@description Text in Headers View of the Network panel
+  */
+  toUseThisResourceFromADifferentOrigin:
+      'To use this resource from a different origin, the server may relax the cross-origin resource policy response header:',
+  /**
+  *@description Text in Headers View of the Network panel
+  */
+  toUseThisResourceFromADifferentSite:
+      'To use this resource from a different site, the server may relax the cross-origin resource policy response header:',
 };
 const str_ = i18n.i18n.registerUIStrings('panels/network/components/RequestHeadersView.ts', UIStrings);
 const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
+const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(undefined, str_);
 
 export class RequestHeadersView extends UI.Widget.VBox {
   readonly #headersView = new RequestHeadersComponent();
@@ -152,6 +201,39 @@
   #renderResponseHeaders(): LitHtml.TemplateResult {
     assertNotNullOrUndefined(this.#request);
 
+    const headersWithIssues = [];
+    if (this.#request.wasBlocked()) {
+      const headerWithIssues =
+          BlockedReasonDetails.get((this.#request.blockedReason() as Protocol.Network.BlockedReason));
+      if (headerWithIssues) {
+        headersWithIssues.push(headerWithIssues);
+      }
+    }
+
+    function mergeHeadersWithIssues(
+        headers: SDK.NetworkRequest.NameValue[], headersWithIssues: HeaderDescriptor[]): HeaderDescriptor[] {
+      let i = 0, j = 0;
+      const result: HeaderDescriptor[] = [];
+      while (i < headers.length && j < headersWithIssues.length) {
+        if (headers[i].name < headersWithIssues[j].name) {
+          result.push({...headers[i++], headerNotSet: false});
+        } else if (headers[i].name > headersWithIssues[j].name) {
+          result.push({...headersWithIssues[j++], headerNotSet: true});
+        } else {
+          result.push({...headersWithIssues[j++], ...headers[i++], headerNotSet: false});
+        }
+      }
+      while (i < headers.length) {
+        result.push({...headers[i++], headerNotSet: false});
+      }
+      while (j < headersWithIssues.length) {
+        result.push({...headersWithIssues[j++], headerNotSet: true});
+      }
+      return result;
+    }
+
+    const mergedHeaders = mergeHeadersWithIssues(this.#request.sortedResponseHeaders.slice(), headersWithIssues);
+
     const toggleShowRaw = (): void => {
       this.#showResponseHeadersText = !this.#showResponseHeadersText;
       this.#render();
@@ -172,12 +254,7 @@
       >
         ${this.#showResponseHeadersText ?
             this.#renderRawHeaders(this.#request.responseHeadersText, true) : html`
-          ${this.#request.sortedResponseHeaders.map(header => html`
-            <div class="row">
-              <div class="header-name">${header.name}:</div>
-              <div class="header-value">${header.value}</div>
-            </div>
-          `)}
+          ${mergedHeaders.map(header => this.#renderHeader(header))}
         `}
       </${Category.litTagName}>
     `;
@@ -186,13 +263,17 @@
   #renderRequestHeaders(): LitHtml.TemplateResult {
     assertNotNullOrUndefined(this.#request);
 
+    const headers = this.#request.requestHeaders().slice();
+    headers.sort(function(a, b) {
+      return Platform.StringUtilities.compare(a.name.toLowerCase(), b.name.toLowerCase());
+    });
+    const requestHeadersText = this.#request.requestHeadersText();
+
     const toggleShowRaw = (): void => {
       this.#showRequestHeadersText = !this.#showRequestHeadersText;
       this.#render();
     };
 
-    const requestHeadersText = this.#request.requestHeadersText();
-
     // Disabled until https://crbug.com/1079231 is fixed.
     // clang-format off
     return html`
@@ -208,17 +289,85 @@
       >
         ${(this.#showRequestHeadersText && requestHeadersText) ?
             this.#renderRawHeaders(requestHeadersText, false) : html`
-          ${this.#request.requestHeaders().map(header => html`
-            <div class="row">
-              <div class="header-name">${header.name}:</div>
-              <div class="header-value">${header.value}</div>
-            </div>
-          `)}
+          ${headers.map(header => this.#renderHeader({...header, headerNotSet: false}))}
         `}
       </${Category.litTagName}>
     `;
   }
 
+  #renderHeader(header: HeaderDescriptor): LitHtml.TemplateResult {
+    return html`
+      <div class="row">
+        <div class="header-name">${header.headerNotSet ? html`<div class="header-badge header-badge-text">not-set</div>` : ''}${header.name}:</div>
+        <div class="header-value ${header.headerValueIncorrect ? 'header-warning' : ''}">${header.value?.toString()||''}</div>
+      </div>
+      ${this.#maybeRenderHeaderDetails(header.details)}
+    `;
+  }
+
+  #maybeRenderHeaderDetails(headerDetails?: HeaderDetailsDescriptor): LitHtml.LitTemplate {
+    if (!headerDetails) {
+      return LitHtml.nothing;
+    }
+    return html`
+      <div class="header-details">
+        <div class="call-to-action">
+          <div class="call-to-action-body">
+            <div class="explanation">${headerDetails.explanation()}</div>
+            ${headerDetails.examples.map(example => html`
+              <div class="example">
+                <code>${example.codeSnippet}</code>
+                ${example.comment ? html`
+                  <span class="comment">${example.comment()}</span>
+                ` : ''}
+              </div>
+            `)}
+            ${this.#maybeRenderHeaderDetailsLink(headerDetails)}
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  #maybeRenderHeaderDetailsLink(headerDetails?: HeaderDetailsDescriptor): LitHtml.LitTemplate {
+    if (this.#request && IssuesManager.RelatedIssue.hasIssueOfCategory(this.#request, IssuesManager.Issue.IssueCategory.CrossOriginEmbedderPolicy)) {
+      const followLink = (): void => {
+        Host.userMetrics.issuesPanelOpenedFrom(Host.UserMetrics.IssueOpener.LearnMoreLinkCOEP);
+        if (this.#request) {
+          void IssuesManager.RelatedIssue.reveal(
+              this.#request, IssuesManager.Issue.IssueCategory.CrossOriginEmbedderPolicy);
+        }
+      };
+      return html`
+        <div class="devtools-link" @click=${followLink}>
+          <${IconButton.Icon.Icon.litTagName} class="inline-icon" .data=${{
+            iconName: 'issue-exclamation-icon',
+            color: 'var(--issue-color-yellow)',
+            width: '16px',
+            height: '16px',
+            } as IconButton.Icon.IconData}>
+          </${IconButton.Icon.Icon.litTagName}>
+          ${i18nString(UIStrings.learnMoreInTheIssuesTab)}
+        </div>
+      `;
+    }
+    if (headerDetails?.link) {
+      return html`
+        <x-link href=${headerDetails.link.url} class="link">
+          <${IconButton.Icon.Icon.litTagName} class="inline-icon" .data=${{
+            iconName: 'link_icon',
+            color: 'var(--color-link)',
+            width: '16px',
+            height: '16px',
+          } as IconButton.Icon.IconData}>
+          </${IconButton.Icon.Icon.litTagName}
+          >${i18nString(UIStrings.learnMore)}
+        </x-link>
+      `;
+    }
+    return LitHtml.nothing;
+  }
+
   #renderRawHeaders(rawHeadersText: string, forResponseHeaders: boolean): LitHtml.TemplateResult {
     const trimmed = rawHeadersText.trim();
     const showFull = forResponseHeaders ? this.#showResponseHeadersTextFull : this.#showRequestHeadersTextFull;
@@ -434,3 +583,118 @@
     'devtools-request-headers-category': Category;
   }
 }
+
+interface HeaderDetailsDescriptor {
+  explanation: () => string;
+  examples: Array<{
+    codeSnippet: string,
+    comment?: (() => string),
+  }>;
+  link: {
+    url: string,
+  }|null;
+}
+
+interface HeaderDescriptor {
+  name: string;
+  value: Object|null;
+  headerValueIncorrect?: boolean|null;
+  details?: HeaderDetailsDescriptor;
+  headerNotSet: boolean|null;
+}
+
+const BlockedReasonDetails = new Map<Protocol.Network.BlockedReason, HeaderDescriptor>([
+  [
+    Protocol.Network.BlockedReason.CoepFrameResourceNeedsCoepHeader,
+    {
+      name: 'cross-origin-embedder-policy',
+      value: null,
+      headerValueIncorrect: null,
+      details: {
+        explanation: i18nLazyString(UIStrings.toEmbedThisFrameInYourDocument),
+        examples: [{codeSnippet: 'Cross-Origin-Embedder-Policy: require-corp', comment: undefined}],
+        link: {url: 'https://web.dev/coop-coep/'},
+      },
+      headerNotSet: null,
+    },
+  ],
+  [
+    Protocol.Network.BlockedReason.CorpNotSameOriginAfterDefaultedToSameOriginByCoep,
+    {
+      name: 'cross-origin-resource-policy',
+      value: null,
+      headerValueIncorrect: null,
+      details: {
+        explanation: i18nLazyString(UIStrings.toUseThisResourceFromADifferent),
+        examples: [
+          {
+            codeSnippet: 'Cross-Origin-Resource-Policy: same-site',
+            comment: i18nLazyString(UIStrings.chooseThisOptionIfTheResourceAnd),
+          },
+          {
+            codeSnippet: 'Cross-Origin-Resource-Policy: cross-origin',
+            comment: i18nLazyString(UIStrings.onlyChooseThisOptionIfAn),
+          },
+        ],
+        link: {url: 'https://web.dev/coop-coep/'},
+      },
+      headerNotSet: null,
+    },
+  ],
+  [
+    Protocol.Network.BlockedReason.CoopSandboxedIframeCannotNavigateToCoopPage,
+    {
+      name: 'cross-origin-opener-policy',
+      value: null,
+      headerValueIncorrect: false,
+      details: {
+        explanation: i18nLazyString(UIStrings.thisDocumentWasBlockedFrom),
+        examples: [],
+        link: {url: 'https://web.dev/coop-coep/'},
+      },
+      headerNotSet: null,
+    },
+  ],
+  [
+    Protocol.Network.BlockedReason.CorpNotSameSite,
+    {
+      name: 'cross-origin-resource-policy',
+      value: null,
+      headerValueIncorrect: true,
+      details: {
+        explanation: i18nLazyString(UIStrings.toUseThisResourceFromADifferentSite),
+        examples: [
+          {
+            codeSnippet: 'Cross-Origin-Resource-Policy: cross-origin',
+            comment: i18nLazyString(UIStrings.onlyChooseThisOptionIfAn),
+          },
+        ],
+        link: null,
+      },
+      headerNotSet: null,
+    },
+  ],
+  [
+    Protocol.Network.BlockedReason.CorpNotSameOrigin,
+    {
+      name: 'cross-origin-resource-policy',
+      value: null,
+      headerValueIncorrect: true,
+      details: {
+        explanation: i18nLazyString(UIStrings.toUseThisResourceFromADifferentOrigin),
+        examples: [
+          {
+            codeSnippet: 'Cross-Origin-Resource-Policy: same-site',
+            comment: i18nLazyString(UIStrings.chooseThisOptionIfTheResourceAnd),
+          },
+          {
+            codeSnippet: 'Cross-Origin-Resource-Policy: cross-origin',
+            comment: i18nLazyString(UIStrings.onlyChooseThisOptionIfAn),
+          },
+        ],
+        link: null,
+      },
+      headerNotSet: null,
+    },
+  ],
+]);