Extend cookie deprecation metadata issue to include opt-out percentages, if present.

Sample screenshots:
With 1P opt-out: https://screenshot.googleplex.com/8DfCEkp2WC6gJPU
With 3P opt-out: https://screenshot.googleplex.com/XEZnoDPNHuvk2BE

Also include cookie operation to determine whether to show the Read or Set description. Screenshot: https://screenshot.googleplex.com/BCkjXKX9yqd9WS7

Bug: b/341051114, b/341794619
Change-Id: Id19b50a22642d6dcfad413b9f1db6849cc822935
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/5551153
Reviewed-by: Joshua Hood <jdh@chromium.org>
Reviewed-by: Wolfgang Beyer <wolfi@chromium.org>
Commit-Queue: Anton Maliev <amaliev@chromium.org>
diff --git a/front_end/models/issues_manager/CookieDeprecationMetadataIssue.ts b/front_end/models/issues_manager/CookieDeprecationMetadataIssue.ts
index 557b671..295f224 100644
--- a/front_end/models/issues_manager/CookieDeprecationMetadataIssue.ts
+++ b/front_end/models/issues_manager/CookieDeprecationMetadataIssue.ts
@@ -24,7 +24,9 @@
 
   constructor(
       issueDetails: Protocol.Audits.CookieDeprecationMetadataIssueDetails, issuesModel: SDK.IssuesModel.IssuesModel) {
-    super(Protocol.Audits.InspectorIssueCode.CookieDeprecationMetadataIssue, issuesModel);
+    // Set a distinct code for ReadCookie and SetCookie issues, so they are grouped separately.
+    const issueCode = Protocol.Audits.InspectorIssueCode.CookieDeprecationMetadataIssue + '_' + issueDetails.operation;
+    super(issueCode, issuesModel);
     this.#issueDetails = issueDetails;
   }
 
@@ -33,8 +35,20 @@
   }
 
   getDescription(): MarkdownIssueDescription {
+    const fileName = this.#issueDetails.operation === 'SetCookie' ? 'cookieWarnMetadataGrantSet.md' :
+                                                                    'cookieWarnMetadataGrantRead.md';
+
+    let optOutText = '';
+    if (this.#issueDetails.isOptOutTopLevel && this.#issueDetails.optOutPercentage >= 0) {
+      optOutText = '\n\n (Top level site opt-out: ' + this.#issueDetails.optOutPercentage +
+          '% - [learn more](gracePeriodStagedControlExplainer))';
+    }
+
     return {
-      file: 'cookieWarnMetadataGrantRead.md',
+      file: fileName,
+      substitutions: new Map([
+        ['PLACEHOLDER_topleveloptout', optOutText],
+      ]),
       links: [
         {
           link: 'https://developer.chrome.com/docs/privacy-sandbox/third-party-cookie-phase-out/',
@@ -56,13 +70,6 @@
     return JSON.stringify(this.#issueDetails);
   }
 
-  override metadataAllowedSites(): Iterable<string> {
-    if (this.#issueDetails.allowedSites) {
-      return this.#issueDetails.allowedSites;
-    }
-    return [];
-  }
-
   static fromInspectorIssue(issuesModel: SDK.IssuesModel.IssuesModel, inspectorIssue: Protocol.Audits.InspectorIssue):
       CookieDeprecationMetadataIssue[] {
     const details = inspectorIssue.details.cookieDeprecationMetadataIssueDetails;
diff --git a/front_end/models/issues_manager/Issue.ts b/front_end/models/issues_manager/Issue.ts
index 4ce0551..cc43329 100644
--- a/front_end/models/issues_manager/Issue.ts
+++ b/front_end/models/issues_manager/Issue.ts
@@ -173,10 +173,6 @@
     return [];
   }
 
-  metadataAllowedSites(): Iterable<string> {
-    return [];
-  }
-
   isAssociatedWithRequestId(requestId: string): boolean {
     for (const request of this.requests()) {
       if (request.requestId === requestId) {
diff --git a/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantRead.md b/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantRead.md
index cd8b990..5f2379f 100644
--- a/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantRead.md
+++ b/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantRead.md
@@ -1,3 +1,4 @@
 # Third-party websites are allowed to read cookies on this page
 
-One or more websites are allowed to bypass user settings to read third-party cookies on this page. Chrome no longer supports third-party cookies and web developers should take steps to remove these reads without disrupting user experience. To forcefully block these third-party cookies, update [user settings](https://support.google.com/chrome/answer/95647).
+One or more websites are allowed to bypass user settings to read third-party cookies on this page. Chrome no longer supports third-party cookies and web developers should take steps to remove these reads without disrupting user experience. To forcefully block these third-party cookies, update [user settings](manageCookiesHelpPage).
+{PLACEHOLDER_topleveloptout}
diff --git a/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantSet.md b/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantSet.md
index 67ce5de..1b25b44 100644
--- a/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantSet.md
+++ b/front_end/models/issues_manager/descriptions/cookieWarnMetadataGrantSet.md
@@ -1,3 +1,4 @@
 # Third-party websites are allowed to set cookies on this page
 
-One or more websites are allowed to bypass user settings to set third-party cookies on this page. Chrome no longer supports third-party cookies and web developers should take steps to remove these sets without disrupting user experience. To forcefully block these third-party cookies, update [user settings](https://support.google.com/chrome/answer/95647).
+One or more websites are allowed to bypass user settings to set third-party cookies on this page. Chrome no longer supports third-party cookies and web developers should take steps to remove these sets without disrupting user experience. To forcefully block these third-party cookies, update [user settings](manageCookiesHelpPage).
+{PLACEHOLDER_topleveloptout}
diff --git a/front_end/models/issues_manager/issues_manager.ts b/front_end/models/issues_manager/issues_manager.ts
index a15b61c..3482ccb 100644
--- a/front_end/models/issues_manager/issues_manager.ts
+++ b/front_end/models/issues_manager/issues_manager.ts
@@ -7,6 +7,7 @@
 import * as ClientHintIssue from './ClientHintIssue.js';
 import * as ContentSecurityPolicyIssue from './ContentSecurityPolicyIssue.js';
 import * as ContrastCheckTrigger from './ContrastCheckTrigger.js';
+import * as CookieDeprecationMetadataIssue from './CookieDeprecationMetadataIssue.js';
 import * as CookieIssue from './CookieIssue.js';
 import * as CorsIssue from './CorsIssue.js';
 import * as CrossOriginEmbedderPolicyIssue from './CrossOriginEmbedderPolicyIssue.js';
@@ -34,6 +35,7 @@
   ClientHintIssue,
   ContentSecurityPolicyIssue,
   ContrastCheckTrigger,
+  CookieDeprecationMetadataIssue,
   CookieIssue,
   CorsIssue,
   CrossOriginEmbedderPolicyIssue,
diff --git a/front_end/panels/issues/AffectedMetadataAllowedSitesView.ts b/front_end/panels/issues/AffectedMetadataAllowedSitesView.ts
index 17c09c2..a091194 100644
--- a/front_end/panels/issues/AffectedMetadataAllowedSitesView.ts
+++ b/front_end/panels/issues/AffectedMetadataAllowedSitesView.ts
@@ -4,6 +4,7 @@
 
 import * as i18n from '../../core/i18n/i18n.js';
 import type * as Platform from '../../core/platform/platform.js';
+import * as UI from '../../ui/legacy/legacy.js';
 
 import {AffectedResourcesView} from './AffectedResourcesView.js';
 
@@ -27,14 +28,33 @@
 
   override update(): void {
     this.clear();
-    const allowedSites = this.issue.getMetadataAllowedSites();
+    const issues = this.issue.getCookieDeprecationMetadataIssues();
     let count = 0;
 
-    for (const site of allowedSites) {
+    for (const issueData of issues) {
       const row = document.createElement('tr');
       row.classList.add('affected-resource-directive');
 
-      this.appendIssueDetailCell(row, site);
+      const textContentElement = document.createElement('div');
+      const textElement = document.createElement('span');
+      textElement.textContent = issueData.details().allowedSites.join(', ');
+      textContentElement.appendChild(textElement);
+
+      if (!issueData.details().isOptOutTopLevel && issueData.details().optOutPercentage > 0) {
+        const optOutTextElement = document.createElement('span');
+        optOutTextElement.textContent = ' (opt-out: ' + issueData.details().optOutPercentage + '% - ';
+        textContentElement.appendChild(optOutTextElement);
+
+        const linkElement = UI.XLink.XLink.create(
+            'https://github.com/amaliev/3pcd-exemption-heuristics/blob/main/explainer.md', 'learn more');
+        textContentElement.appendChild(linkElement);
+
+        const endTextElement = document.createElement('span');
+        endTextElement.textContent = ')';
+        textContentElement.appendChild(endTextElement);
+      }
+
+      this.appendIssueDetailCell(row, textContentElement);
       this.affectedResources.appendChild(row);
       count++;
     }
diff --git a/front_end/panels/issues/AffectedResourcesView.ts b/front_end/panels/issues/AffectedResourcesView.ts
index 8a6a002..2cd0f1c 100644
--- a/front_end/panels/issues/AffectedResourcesView.ts
+++ b/front_end/panels/issues/AffectedResourcesView.ts
@@ -274,17 +274,25 @@
     header.appendChild(info);
   }
 
-  protected createIssueDetailCell(textContent: string, additionalClass: string|null = null): HTMLTableDataCellElement {
+  protected createIssueDetailCell(textContent: string|HTMLElement, additionalClass: string|null = null):
+      HTMLTableDataCellElement {
     const cell = document.createElement('td');
-    cell.textContent = textContent;
+
+    if (typeof textContent === 'string') {
+      cell.textContent = textContent;
+    } else {
+      cell.appendChild(textContent);
+    }
+
     if (additionalClass) {
       cell.classList.add(additionalClass);
     }
     return cell;
   }
 
-  protected appendIssueDetailCell(element: HTMLElement, textContent: string, additionalClass: string|null = null):
-      HTMLTableDataCellElement {
+  protected appendIssueDetailCell(
+      element: HTMLElement, textContent: string|HTMLElement,
+      additionalClass: string|null = null): HTMLTableDataCellElement {
     const cell = this.createIssueDetailCell(textContent, additionalClass);
     element.appendChild(cell);
     return cell;
diff --git a/front_end/panels/issues/IssueAggregator.ts b/front_end/panels/issues/IssueAggregator.ts
index aa9cce1..3804f46 100644
--- a/front_end/panels/issues/IssueAggregator.ts
+++ b/front_end/panels/issues/IssueAggregator.ts
@@ -40,7 +40,8 @@
   #deprecationIssues = new Set<IssuesManager.DeprecationIssue.DeprecationIssue>();
   #issueKind = IssuesManager.Issue.IssueKind.Improvement;
   #lowContrastIssues = new Set<IssuesManager.LowTextContrastIssue.LowTextContrastIssue>();
-  #metadataAllowedSites = new Set<string>();
+  #cookieDeprecationMetadataIssues =
+      new Set<IssuesManager.CookieDeprecationMetadataIssue.CookieDeprecationMetadataIssue>();
   #mixedContentIssues = new Set<IssuesManager.MixedContentIssue.MixedContentIssue>();
   #sharedArrayBufferIssues = new Set<IssuesManager.SharedArrayBufferIssue.SharedArrayBufferIssue>();
   #quirksModeIssues = new Set<IssuesManager.QuirksModeIssue.QuirksModeIssue>();
@@ -94,8 +95,9 @@
     return this.#heavyAdIssues;
   }
 
-  getMetadataAllowedSites(): Iterable<string> {
-    return this.#metadataAllowedSites.values();
+  getCookieDeprecationMetadataIssues():
+      Iterable<IssuesManager.CookieDeprecationMetadataIssue.CookieDeprecationMetadataIssue> {
+    return this.#cookieDeprecationMetadataIssues;
   }
 
   getMixedContentIssues(): Iterable<IssuesManager.MixedContentIssue.MixedContentIssue> {
@@ -200,10 +202,8 @@
         this.#affectedLocations.set(key, location);
       }
     }
-    for (const site of issue.metadataAllowedSites()) {
-      if (!this.#metadataAllowedSites.has(site)) {
-        this.#metadataAllowedSites.add(site);
-      }
+    if (issue instanceof IssuesManager.CookieDeprecationMetadataIssue.CookieDeprecationMetadataIssue) {
+      this.#cookieDeprecationMetadataIssues.add(issue);
     }
     if (issue instanceof IssuesManager.MixedContentIssue.MixedContentIssue) {
       this.#mixedContentIssues.add(issue);
diff --git a/front_end/ui/components/markdown_view/MarkdownLinksMap.ts b/front_end/ui/components/markdown_view/MarkdownLinksMap.ts
index f0153d1..54b528a 100644
--- a/front_end/ui/components/markdown_view/MarkdownLinksMap.ts
+++ b/front_end/ui/components/markdown_view/MarkdownLinksMap.ts
@@ -51,7 +51,8 @@
   ],
   ['PNASecureContextRestrictionFeatureStatus', 'https://chromestatus.com/feature/5954091755241472'],
   ['https://w3c.github.io/uievents/#legacy-event-types', 'https://w3c.github.io/uievents/#legacy-event-types'],
-  ['https://support.google.com/chrome/answer/95647', 'https://support.google.com/chrome/answer/95647'],
+  ['manageCookiesHelpPage', 'https://support.google.com/chrome/answer/95647'],
+  ['gracePeriodStagedControlExplainer', 'https://github.com/amaliev/3pcd-exemption-heuristics/blob/main/explainer.md'],
 ]);
 
 export const getMarkdownLink = (key: string): string => {
diff --git a/test/e2e/issues/cookie-deprecation-metadata-issues_test.ts b/test/e2e/issues/cookie-deprecation-metadata-issues_test.ts
index 25bd577..ae48a86 100644
--- a/test/e2e/issues/cookie-deprecation-metadata-issues_test.ts
+++ b/test/e2e/issues/cookie-deprecation-metadata-issues_test.ts
@@ -27,6 +27,9 @@
         'details': {
           'cookieDeprecationMetadataIssueDetails': {
             'allowedSites': ['example_1.test'],
+            'optOutPercentage': 25,
+            'isOptOutTopLevel': true,
+            'operation': 'ReadCookie',
           },
         },
       };
@@ -37,6 +40,9 @@
         'details': {
           'cookieDeprecationMetadataIssueDetails': {
             'allowedSites': ['example_2.test'],
+            'optOutPercentage': 50,
+            'isOptOutTopLevel': false,
+            'operation': 'ReadCookie',
           },
         },
       };
@@ -50,7 +56,7 @@
     await ensureResourceSectionIsExpanded(section);
     const expectedTableRows = [
       ['example_1.test'],
-      ['example_2.test'],
+      ['example_2.test (opt-out: 50% - learn more)'],
     ];
     await waitForTableFromResourceSectionContents(section.content, expectedTableRows);
   });