Add 'Show More' button to stack traces in frame details view

`JSPresentationUtils.buildStackTraceRows()` includes a logic to not
show all rows of a stack trace. Stack trace frames can be set to hidden
either because they are in source files which are on the ignore list, or
because the stack trace already contains a lot of rows.

This CL implements the logic to actually hide those rows in stack traces
in the frame details view and adds a button for showing all rows.

Screenshot: https://i.imgur.com/N73fTr2.mp4

Bug: 1159334
Change-Id: Id6fe171b5ab4bbb3136599e5c0bac5b5ab21bde3
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2764408
Commit-Queue: Wolfgang Beyer <[email protected]>
Reviewed-by: Sigurd Schneider <[email protected]>
diff --git a/front_end/i18n/locales/en-US.json b/front_end/i18n/locales/en-US.json
index 556c2d8..59a2fcf 100644
--- a/front_end/i18n/locales/en-US.json
+++ b/front_end/i18n/locales/en-US.json
@@ -7394,6 +7394,9 @@
   "resources/StackTrace.ts | cannotRenderStackTrace": {
     "message": "Cannot not render stack trace"
   },
+  "resources/StackTrace.ts | showSMoreFrames": {
+    "message": "{n, plural, =1 {Show # more frame} other {Show # more frames}}"
+  },
   "resources/StorageItemsView.ts | clearAll": {
     "message": "Clear All"
   },
diff --git a/front_end/i18n/locales/en-XL.json b/front_end/i18n/locales/en-XL.json
index f8ac1f3..df40e14 100644
--- a/front_end/i18n/locales/en-XL.json
+++ b/front_end/i18n/locales/en-XL.json
@@ -7394,6 +7394,9 @@
   "resources/StackTrace.ts | cannotRenderStackTrace": {
     "message": "Ĉán̂ńôt́ n̂ót̂ ŕêńd̂ér̂ śt̂áĉḱ t̂ŕâćê"
   },
+  "resources/StackTrace.ts | showSMoreFrames": {
+    "message": "{n, plural, =1 {Ŝh́ôẃ # m̂ór̂é f̂ŕâḿê} other {Śĥóŵ # ḿôŕê f́r̂ám̂éŝ}}"
+  },
   "resources/StorageItemsView.ts | clearAll": {
     "message": "Ĉĺêár̂ Ál̂ĺ"
   },
diff --git a/front_end/resources/FrameDetailsView.ts b/front_end/resources/FrameDetailsView.ts
index 86ced8c..0ad421f 100644
--- a/front_end/resources/FrameDetailsView.ts
+++ b/front_end/resources/FrameDetailsView.ts
@@ -14,6 +14,7 @@
 import * as WebComponents from '../ui/components/components.js';
 import * as UI from '../ui/ui.js';
 import * as Workspace from '../workspace/workspace.js';
+import * as Components from '../components/components.js';
 
 const UIStrings = {
   /**
@@ -507,14 +508,20 @@
 
   private maybeRenderCreationStacktrace(): LitHtml.TemplateResult|{} {
     if (this.frame && this.frame._creationStackTrace) {
+      // Disabled until https://crbug.com/1079231 is fixed.
+      // clang-format off
       return LitHtml.html`
         <devtools-report-key title=${i18nString(UIStrings.creationStackTraceExplanation)}>${
           i18nString(UIStrings.creationStackTrace)}</devtools-report-key>
         <devtools-report-value>
-          <devtools-resources-stack-trace .data=${{frame: this.frame} as StackTraceData}>
+          <devtools-resources-stack-trace .data=${{
+            frame: this.frame,
+            buildStackTraceRows: Components.JSPresentationUtils.buildStackTraceRows,
+          } as StackTraceData}>
           </devtools-resources-stack-trace>
         </devtools-report-value>
       `;
+      // clang-format on
     }
     return LitHtml.nothing;
   }
diff --git a/front_end/resources/StackTrace.ts b/front_end/resources/StackTrace.ts
index 6abc19a..b78f68d 100644
--- a/front_end/resources/StackTrace.ts
+++ b/front_end/resources/StackTrace.ts
@@ -13,82 +13,134 @@
   *@description Error message stating that something went wrong when tring to render stack trace
   */
   cannotRenderStackTrace: 'Cannot not render stack trace',
+  /**
+  *@description A link to show more frames in the stack trace if more are available. Never 0.
+  */
+  showSMoreFrames: '{n, plural, =1 {Show # more frame} other {Show # more frames}}',
 };
 const str_ = i18n.i18n.registerUIStrings('resources/StackTrace.ts', UIStrings);
 const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
 
 export interface StackTraceData {
   frame: SDK.ResourceTreeModel.ResourceTreeFrame;
+  buildStackTraceRows: (
+      stackTrace: Protocol.Runtime.StackTrace,
+      target: SDK.SDKModel.Target|null,
+      linkifier: Components.Linkifier.Linkifier,
+      tabStops: boolean|undefined,
+      updateCallback?: (arg0: (Components.JSPresentationUtils.StackTraceRegularRow|
+                               Components.JSPresentationUtils.StackTraceAsyncRow)[]) => void,
+      ) => (Components.JSPresentationUtils.StackTraceRegularRow | Components.JSPresentationUtils.StackTraceAsyncRow)[];
 }
 
 export class StackTrace extends HTMLElement {
   private readonly shadow = this.attachShadow({mode: 'open'});
   private readonly linkifier = new Components.Linkifier.Linkifier();
-  private expandableRows: LitHtml.TemplateResult[] = [];
+  private stackTraceRows: (Components.JSPresentationUtils.StackTraceRegularRow|
+                           Components.JSPresentationUtils.StackTraceAsyncRow)[] = [];
+  private showHidden = false;
 
   set data(data: StackTraceData) {
     const frame = data.frame;
-    let stackTraceRows: (Components.JSPresentationUtils.StackTraceRegularRow|
-                         Components.JSPresentationUtils.StackTraceAsyncRow)[] = [];
     if (frame && frame._creationStackTrace) {
-      stackTraceRows = Components.JSPresentationUtils.buildStackTraceRows(
+      this.stackTraceRows = data.buildStackTraceRows(
           frame._creationStackTrace, frame.resourceTreeModel().target(), this.linkifier, true,
-          this.createRowTemplates.bind(this));
-    }
-    this.createRowTemplates(stackTraceRows);
-  }
-
-  private createRowTemplates(stackTraceRows: (Components.JSPresentationUtils.StackTraceRegularRow|
-                                              Components.JSPresentationUtils.StackTraceAsyncRow)[]): void {
-    this.expandableRows = [];
-    for (const item of stackTraceRows) {
-      if ('functionName' in item) {
-        this.expandableRows.push(LitHtml.html`
-        <style>
-        .stack-trace-row {
-          display: flex;
-        }
-
-        .stack-trace-function-name {
-          width: 100px;
-        }
-
-        .stack-trace-source-location {
-          display: flex;
-          overflow: hidden;
-        }
-
-        .text-ellipsis {
-          overflow: hidden;
-          text-overflow: ellipsis;
-          white-space: nowrap;
-        }
-
-        .ignore-list-link {
-          opacity: 60%;
-        }
-      </style>
-          <div class="stack-trace-row">
-            <div class="stack-trace-function-name text-ellipsis" title="${item.functionName}">
-              ${item.functionName}
-            </div>
-            <div class="stack-trace-source-location">
-              ${item.link ? LitHtml.html`<div class="text-ellipsis">@\xA0${item.link}</div>` : LitHtml.nothing}
-            </div>
-          </div>
-        `);
-      }
-      if ('asyncDescription' in item) {
-        this.expandableRows.push(LitHtml.html`
-          <div>${item.asyncDescription}</div>
-        `);
-      }
+          this.onStackTraceRowsUpdated.bind(this));
     }
     this.render();
   }
 
+  private onStackTraceRowsUpdated(stackTraceRows: (Components.JSPresentationUtils.StackTraceRegularRow|
+                                                   Components.JSPresentationUtils.StackTraceAsyncRow)[]): void {
+    this.stackTraceRows = stackTraceRows;
+    this.render();
+  }
+
+  private onShowAllClick(): void {
+    this.showHidden = true;
+    this.render();
+  }
+
+  createRowTemplates(): LitHtml.TemplateResult[] {
+    const expandableRows = [];
+    let hiddenCallFramesCount = 0;
+    for (const item of this.stackTraceRows) {
+      if (this.showHidden || (!item.ignoreListHide && !item.rowCountHide)) {
+        if ('functionName' in item) {
+          expandableRows.push(LitHtml.html`
+            <style>
+              .stack-trace-row {
+                display: flex;
+              }
+
+              .stack-trace-function-name {
+                width: 100px;
+              }
+
+              .stack-trace-source-location {
+                display: flex;
+                overflow: hidden;
+              }
+
+              .text-ellipsis {
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+              }
+
+              .ignore-list-link {
+                opacity: 60%;
+              }
+            </style>
+            <div class="stack-trace-row">
+              <div class="stack-trace-function-name text-ellipsis" title="${item.functionName}">
+                ${item.functionName}
+              </div>
+              <div class="stack-trace-source-location">
+                ${item.link ? LitHtml.html`<div class="text-ellipsis">@\xA0${item.link}</div>` : LitHtml.nothing}
+              </div>
+            </div>
+          `);
+        }
+        if ('asyncDescription' in item) {
+          expandableRows.push(LitHtml.html`
+            <div>${item.asyncDescription}</div>
+          `);
+        }
+      }
+      if (!this.showHidden && 'functionName' in item && (item.ignoreListHide || item.rowCountHide)) {
+        hiddenCallFramesCount++;
+      }
+    }
+    if (hiddenCallFramesCount) {
+      // Disabled until https://crbug.com/1079231 is fixed.
+      // clang-format off
+      expandableRows.push(LitHtml.html`
+        <style>
+          button.link {
+            color: var(--color-link);
+            text-decoration: underline;
+            cursor: pointer;
+            padding: 2px 0; /* adjust focus ring size */
+            border: none;
+            background: none;
+            font-family: inherit;
+            font-size: inherit;
+          }
+        </style>
+        <div class="stack-trace-row">
+          <button class="link" @click=${(): void => this.onShowAllClick()}>
+            ${i18nString(UIStrings.showSMoreFrames, {n: hiddenCallFramesCount})}
+          </button>
+        </div>
+      `);
+      // clang-format on
+    }
+    return expandableRows;
+  }
+
   private render(): void {
-    if (!this.expandableRows.length) {
+    if (!this.stackTraceRows.length) {
       // Disabled until https://crbug.com/1079231 is fixed.
       // clang-format off
       LitHtml.render(
@@ -98,10 +150,11 @@
         this.shadow);
       return;
     }
+    const expandableRows = this.createRowTemplates();
     LitHtml.render(
       LitHtml.html`
         <devtools-expandable-list .data=${{
-          rows: this.expandableRows,
+          rows: expandableRows,
         } as UIComponents.ExpandableList.ExpandableListData}>
         </devtools-expandable-list>
       `,
diff --git a/test/unittests/front_end/helpers/DOMHelpers.ts b/test/unittests/front_end/helpers/DOMHelpers.ts
index 15a2433..0d56792 100644
--- a/test/unittests/front_end/helpers/DOMHelpers.ts
+++ b/test/unittests/front_end/helpers/DOMHelpers.ts
@@ -206,3 +206,14 @@
 export function stripLitHtmlCommentNodes(text: string) {
   return text.replaceAll('<!---->', '');
 }
+
+/**
+ * Returns an array of textContents
+ * NewLine and multiple space characters are replaced with single space character
+ */
+export function getCleanTextContentFromElements(shadowRoot: ShadowRoot, selector: string) {
+  const elements = Array.from(shadowRoot.querySelectorAll(selector));
+  return elements.map(element => {
+    return element.textContent ? element.textContent.trim().replace(/[ \n]+/g, ' ') : '';
+  });
+}
diff --git a/test/unittests/front_end/resources/BUILD.gn b/test/unittests/front_end/resources/BUILD.gn
index aa27d54..cd1d93e 100644
--- a/test/unittests/front_end/resources/BUILD.gn
+++ b/test/unittests/front_end/resources/BUILD.gn
@@ -9,6 +9,7 @@
   sources = [
     "FrameDetailsView_test.ts",
     "ServiceWorkerUpdateCycleView_test.ts",
+    "StackTrace_test.ts",
     "TrustTokensView_test.ts",
   ]
 
diff --git a/test/unittests/front_end/resources/FrameDetailsView_test.ts b/test/unittests/front_end/resources/FrameDetailsView_test.ts
index e373b8e..1600219 100644
--- a/test/unittests/front_end/resources/FrameDetailsView_test.ts
+++ b/test/unittests/front_end/resources/FrameDetailsView_test.ts
@@ -6,7 +6,7 @@
 import * as Resources from '../../../../front_end/resources/resources.js';
 import * as SDK from '../../../../front_end/sdk/sdk.js';
 import * as Components from '../../../../front_end/ui/components/components.js';
-import {assertShadowRoot, getElementWithinComponent, renderElementIntoDOM} from '../helpers/DOMHelpers.js';
+import {assertShadowRoot, getCleanTextContentFromElements, getElementWithinComponent, renderElementIntoDOM} from '../helpers/DOMHelpers.js';
 
 const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
 
@@ -61,13 +61,6 @@
   return newFrame;
 };
 
-function extractTextFromReportView(shadowRoot: ShadowRoot, selector: string) {
-  const elements = Array.from(shadowRoot.querySelectorAll(selector));
-  return elements.map(element => {
-    return element.textContent ? element.textContent.trim().replace(/[ \n]+/g, ' ') : '';
-  });
-}
-
 describe('FrameDetailsView', () => {
   it('renders with a title', async () => {
     const frame = makeFrame();
@@ -97,7 +90,7 @@
     await coordinator.done();
     await coordinator.done();  // 2nd call awaits async render
 
-    const keys = extractTextFromReportView(component.shadowRoot, 'devtools-report-key');
+    const keys = getCleanTextContentFromElements(component.shadowRoot, 'devtools-report-key');
     assert.deepEqual(keys, [
       'URL',
       'Origin',
@@ -111,7 +104,7 @@
       'Measure Memory',
     ]);
 
-    const values = extractTextFromReportView(component.shadowRoot, 'devtools-report-value');
+    const values = getCleanTextContentFromElements(component.shadowRoot, 'devtools-report-value');
     assert.deepEqual(values, [
       'https://www.example.com/path/page.html',
       'https://www.example.com',
@@ -131,7 +124,7 @@
     const expandableList =
         getElementWithinComponent(stackTrace, 'devtools-expandable-list', Components.ExpandableList.ExpandableList);
     assertShadowRoot(expandableList.shadowRoot);
-    const expandableListText = extractTextFromReportView(expandableList.shadowRoot, '.stack-trace-row');
+    const expandableListText = getCleanTextContentFromElements(expandableList.shadowRoot, '.stack-trace-row');
     assert.deepEqual(expandableListText, ['function1 @ www.example.com/script.js:16']);
   });
 });
diff --git a/test/unittests/front_end/resources/StackTrace_test.ts b/test/unittests/front_end/resources/StackTrace_test.ts
new file mode 100644
index 0000000..da1ae6c
--- /dev/null
+++ b/test/unittests/front_end/resources/StackTrace_test.ts
@@ -0,0 +1,142 @@
+// Copyright 2021 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 Components from '../../../../front_end/components/components.js';
+import * as Resources from '../../../../front_end/resources/resources.js';
+import * as SDK from '../../../../front_end/sdk/sdk.js';
+import * as UIComponents from '../../../../front_end/ui/components/components.js';
+import {assertElement, assertShadowRoot, dispatchClickEvent, getCleanTextContentFromElements, getElementWithinComponent, renderElementIntoDOM} from '../helpers/DOMHelpers.js';
+
+const {assert} = chai;
+
+const makeFrame =
+    (overrides: Partial<SDK.ResourceTreeModel.ResourceTreeFrame> = {}): SDK.ResourceTreeModel.ResourceTreeFrame => {
+      const newFrame: SDK.ResourceTreeModel.ResourceTreeFrame = {
+        resourceTreeModel: () => ({
+          target: () => ({}),
+        }),
+        ...overrides,
+      } as unknown as SDK.ResourceTreeModel.ResourceTreeFrame;
+      return newFrame;
+    };
+
+function mockBuildStackTraceRows(
+    stackTrace: Protocol.Runtime.StackTrace,
+    _target: SDK.SDKModel.Target|null,
+    _linkifier: Components.Linkifier.Linkifier,
+    _tabStops: boolean|undefined,
+    _updateCallback?: (arg0: (Components.JSPresentationUtils.StackTraceRegularRow|
+                              Components.JSPresentationUtils.StackTraceAsyncRow)[]) => void,
+    ): (Components.JSPresentationUtils.StackTraceRegularRow|Components.JSPresentationUtils.StackTraceAsyncRow)[] {
+  return stackTrace.callFrames.map(callFrame => ({
+                                     functionName: callFrame.functionName,
+                                     ignoreListHide: callFrame.url.includes('hidden'),
+                                     link: Components.Linkifier.Linkifier.linkifyURL(callFrame.url),
+                                     rowCountHide: false,
+                                   }));
+}
+
+describe('StackTrace', () => {
+  it('does not generate rows when there is no data', () => {
+    const component = new Resources.StackTrace.StackTrace();
+    const rows = component.createRowTemplates();
+    assert.deepEqual(rows, []);
+  });
+
+  it('generates rows from stack trace data', () => {
+    const frame = makeFrame({
+      _creationStackTrace: {
+        callFrames: [
+          {
+            functionName: 'function1',
+            url: 'http://www.example.com/script1.js',
+            lineNumber: 15,
+            columnNumber: 10,
+            scriptId: 'someScriptId',
+          },
+          {
+            functionName: 'function2',
+            url: 'http://www.example.com/script2.js',
+            lineNumber: 20,
+            columnNumber: 5,
+            scriptId: 'someScriptId',
+          },
+        ],
+      },
+    });
+    const component = new Resources.StackTrace.StackTrace();
+    renderElementIntoDOM(component);
+    component.data = {
+      frame: frame,
+      buildStackTraceRows: mockBuildStackTraceRows,
+    };
+
+    assertShadowRoot(component.shadowRoot);
+    const expandableList =
+        getElementWithinComponent(component, 'devtools-expandable-list', UIComponents.ExpandableList.ExpandableList);
+    assertShadowRoot(expandableList.shadowRoot);
+    const expandButton = expandableList.shadowRoot.querySelector('button.arrow-icon-button');
+    assertElement(expandButton, HTMLButtonElement);
+    dispatchClickEvent(expandButton);
+
+    const stackTraceText = getCleanTextContentFromElements(expandableList.shadowRoot, '.stack-trace-row');
+    assert.deepEqual(stackTraceText, [
+      'function1 @ www.example.com/script1.js',
+      'function2 @ www.example.com/script2.js',
+    ]);
+  });
+
+  it('hides hidden rows behind "show all" button', () => {
+    const frame = makeFrame({
+      _creationStackTrace: {
+        callFrames: [
+          {
+            functionName: 'function1',
+            url: 'http://www.example.com/script.js',
+            lineNumber: 15,
+            columnNumber: 10,
+            scriptId: 'someScriptId',
+          },
+          {
+            functionName: 'function2',
+            url: 'http://www.example.com/hidden.js',
+            lineNumber: 20,
+            columnNumber: 5,
+            scriptId: 'someScriptId',
+          },
+        ],
+      },
+    });
+    const component = new Resources.StackTrace.StackTrace();
+    renderElementIntoDOM(component);
+    component.data = {
+      frame: frame,
+      buildStackTraceRows: mockBuildStackTraceRows,
+    };
+
+    assertShadowRoot(component.shadowRoot);
+    const expandableList =
+        getElementWithinComponent(component, 'devtools-expandable-list', UIComponents.ExpandableList.ExpandableList);
+    assertShadowRoot(expandableList.shadowRoot);
+    const expandButton = expandableList.shadowRoot.querySelector('button.arrow-icon-button');
+    assertElement(expandButton, HTMLButtonElement);
+    dispatchClickEvent(expandButton);
+
+    let stackTraceText = getCleanTextContentFromElements(expandableList.shadowRoot, '.stack-trace-row');
+    assert.deepEqual(stackTraceText, [
+      'function1 @ www.example.com/script.js',
+      'Show 1 more frame',
+    ]);
+
+    const showAllButton = expandableList.shadowRoot.querySelector('.stack-trace-row button.link');
+    assertElement(showAllButton, HTMLButtonElement);
+    dispatchClickEvent(showAllButton);
+
+    stackTraceText = getCleanTextContentFromElements(expandableList.shadowRoot, '.stack-trace-row');
+    assert.deepEqual(stackTraceText, [
+      'function1 @ www.example.com/script.js',
+      'function2 @ www.example.com/hidden.js',
+    ]);
+  });
+});