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',
+ ]);
+ });
+});