Skip to content

Commit 7dae717

Browse files
szuendDevtools-frontend LUCI CQ
authored andcommitted
[stack_trace] Add StackTraceModel.createFromProtocolRuntime
This CL adds the first of a handful of methods that allow creation of 'StackTrace' instances based of different sources. Follow-up CLs will add Error.stack parsing and creating 'StackTrace' instances from DebuggerPausedInfo. Since 'StackTraceModel' is private to the 'impl' package, the only intended user is the 'DebuggerWorkspaceBinding'. The 'DebuggerWorkspaceBinding' will act as a thin wrapper around the different 'StackTraceModel.create*' functions since it has to provide the actual "frame translation" implementation. [email protected] Bug: 433162438 Change-Id: Iab88a292e67e5e1bb43945376ef4ab44d71f90a2 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6869592 Reviewed-by: Philip Pfaffe <[email protected]> Commit-Queue: Simon Zünd <[email protected]>
1 parent 120c373 commit 7dae717

File tree

5 files changed

+231
-10
lines changed

5 files changed

+231
-10
lines changed

front_end/models/stack_trace/BUILD.gn

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ devtools_module("stack_trace") {
1010

1111
deps = [
1212
"../../core/common:bundle",
13-
"../../core/sdk:bundle",
14-
"../../generated",
1513
"../workspace:bundle",
1614
]
1715
}
@@ -57,12 +55,15 @@ ts_library("unittests") {
5755

5856
sources = [
5957
"StackTraceImpl.test.ts",
58+
"StackTraceModel.test.ts",
6059
"Trie.test.ts",
6160
]
6261

6362
deps = [
6463
":bundle",
6564
":impl",
65+
"../../core/sdk:bundle",
66+
"../../generated",
6667
"../../testing",
6768
]
6869
}

front_end/models/stack_trace/StackTrace.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,8 @@
33
// found in the LICENSE file.
44

55
import type * as Common from '../../core/common/common.js';
6-
import type * as SDK from '../../core/sdk/sdk.js';
7-
import type * as Protocol from '../../generated/protocol.js';
86
import type * as Workspace from '../workspace/workspace.js';
97

10-
export interface Factory {
11-
createFromProtocolRuntime(stackTrace: Protocol.Runtime.StackTrace, target: SDK.Target.Target): Promise<StackTrace>;
12-
}
13-
148
export interface StackTrace extends Common.EventTarget.EventTarget<EventTypes> {
159
readonly syncFragment: Fragment;
1610
readonly asyncFragments: readonly AsyncFragment[];
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright 2025 The Chromium Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import * as SDK from '../../core/sdk/sdk.js';
6+
import type * as Protocol from '../../generated/protocol.js';
7+
import {createTarget} from '../../testing/EnvironmentHelpers.js';
8+
import {describeWithMockConnection, setMockConnectionResponseHandler} from '../../testing/MockConnection.js';
9+
import {protocolCallFrame, stringifyStackTrace} from '../../testing/StackTraceHelpers.js';
10+
11+
import * as StackTraceImpl from './stack_trace_impl.js';
12+
13+
describeWithMockConnection('StackTraceModel', () => {
14+
describe('createFromProtocolRuntime', () => {
15+
const identityTranslateFn: StackTraceImpl.StackTraceModel.TranslateRawFrames = (frames, _target) =>
16+
Promise.resolve(frames.map(f => [{
17+
url: f.url,
18+
name: f.functionName,
19+
line: f.lineNumber,
20+
column: f.columnNumber,
21+
}]));
22+
23+
function setup() {
24+
const target = createTarget();
25+
return {
26+
model: target.model(StackTraceImpl.StackTraceModel.StackTraceModel)!,
27+
};
28+
}
29+
30+
it('correctly handles a stack trace with only a sync fragment', async () => {
31+
const {model} = setup();
32+
33+
const stackTrace = await model.createFromProtocolRuntime(
34+
{
35+
callFrames: [
36+
'foo.js:1:foo:1:10',
37+
'bar.js:2:bar:2:20',
38+
'baz.js:3:baz:3:30',
39+
].map(protocolCallFrame)
40+
},
41+
identityTranslateFn);
42+
43+
assert.strictEqual(stringifyStackTrace(stackTrace), [
44+
'at foo (foo.js:1:10)',
45+
'at bar (bar.js:2:20)',
46+
'at baz (baz.js:3:30)',
47+
].join('\n'));
48+
});
49+
50+
it('correctly handles async fragments from the same target', async () => {
51+
const {model} = setup();
52+
53+
const stackTrace = await model.createFromProtocolRuntime(
54+
{
55+
callFrames: [
56+
'foo.js:1:foo:1:10',
57+
'foo.js:1:bar:2:20',
58+
].map(protocolCallFrame),
59+
parent: {
60+
description: 'setTimeout',
61+
callFrames: [
62+
'bar.js:2:barFnX:1:10',
63+
'bar.js:2:barFnY:2:20',
64+
].map(protocolCallFrame),
65+
parent: {
66+
description: 'await',
67+
callFrames: [
68+
'baz.js:3:bazFnY:1:10',
69+
'baz.js:3:bazFnY:2:20',
70+
].map(protocolCallFrame),
71+
}
72+
}
73+
},
74+
identityTranslateFn);
75+
76+
assert.strictEqual(stringifyStackTrace(stackTrace), [
77+
'at foo (foo.js:1:10)',
78+
'at bar (foo.js:2:20)',
79+
'--- setTimeout -------------------------',
80+
'at barFnX (bar.js:1:10)',
81+
'at barFnY (bar.js:2:20)',
82+
'--- await ------------------------------',
83+
'at bazFnY (baz.js:1:10)',
84+
'at bazFnY (baz.js:2:20)',
85+
].join('\n'));
86+
});
87+
88+
it('correctly handles a async fragments from different targets', async () => {
89+
{
90+
let index = 0;
91+
setMockConnectionResponseHandler('Debugger.enable', () => ({debuggerId: `target${index++}`}));
92+
sinon.stub(SDK.DebuggerModel.DebuggerModel, 'resyncDebuggerIdForModels');
93+
}
94+
const {model} = setup();
95+
const [model1, model2] = [
96+
createTarget().model(SDK.DebuggerModel.DebuggerModel)!, createTarget().model(SDK.DebuggerModel.DebuggerModel)!
97+
];
98+
99+
await Promise.all([
100+
model1.once(SDK.DebuggerModel.Events.DebuggerIsReadyToPause),
101+
model2.once(SDK.DebuggerModel.Events.DebuggerIsReadyToPause)
102+
]);
103+
104+
sinon.stub(model1, 'fetchAsyncStackTrace').returns(Promise.resolve({
105+
description: 'setTimeout',
106+
callFrames: [
107+
'bar.js:2:barFnX:1:10',
108+
'bar.js:2:barFnY:2:20',
109+
].map(protocolCallFrame),
110+
parentId: {id: 'async-fragment-2', debuggerId: model2.debuggerId() as Protocol.Runtime.UniqueDebuggerId},
111+
}));
112+
sinon.stub(model2, 'fetchAsyncStackTrace').returns(Promise.resolve({
113+
description: 'await',
114+
callFrames: [
115+
'baz.js:3:bazFnY:1:10',
116+
'baz.js:3:bazFnY:2:20',
117+
].map(protocolCallFrame),
118+
}));
119+
120+
const stackTrace = await model.createFromProtocolRuntime(
121+
{
122+
callFrames: [
123+
'foo.js:1:foo:1:10',
124+
'foo.js:1:bar:2:20',
125+
].map(protocolCallFrame),
126+
parentId: {id: 'async-fragment-1', debuggerId: model1.debuggerId() as Protocol.Runtime.UniqueDebuggerId},
127+
},
128+
identityTranslateFn);
129+
130+
assert.strictEqual(stringifyStackTrace(stackTrace), [
131+
'at foo (foo.js:1:10)',
132+
'at bar (foo.js:2:20)',
133+
'--- setTimeout -------------------------',
134+
'at barFnX (bar.js:1:10)',
135+
'at barFnY (bar.js:2:20)',
136+
'--- await ------------------------------',
137+
'at bazFnY (baz.js:1:10)',
138+
'at bazFnY (baz.js:2:20)',
139+
].join('\n'));
140+
});
141+
142+
it('calls the translate function with the correct raw frames', async () => {
143+
const {model} = setup();
144+
const callFrames = [
145+
'foo.js:1:foo:1:10',
146+
'bar.js:2:bar:2:20',
147+
'baz.js:3:baz:3:30',
148+
].map(protocolCallFrame);
149+
const translateSpy = sinon.spy(identityTranslateFn);
150+
151+
await model.createFromProtocolRuntime({callFrames}, translateSpy);
152+
153+
sinon.assert.calledOnceWithMatch(translateSpy, callFrames, model.target());
154+
});
155+
156+
it('throws if the translation function returns the wrong number of frames', async () => {
157+
const {model} = setup();
158+
159+
try {
160+
await model.createFromProtocolRuntime(
161+
{
162+
callFrames: [protocolCallFrame('foo.js:1:foo:1:10')],
163+
},
164+
() => Promise.resolve([]));
165+
assert.fail('Expected translateFragment to throw');
166+
} catch {
167+
}
168+
});
169+
});
170+
});

front_end/models/stack_trace/StackTraceModel.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,20 @@
33
// found in the LICENSE file.
44

55
import * as SDK from '../../core/sdk/sdk.js';
6+
import type * as Protocol from '../../generated/protocol.js';
67

7-
import {FragmentImpl} from './StackTraceImpl.js';
8+
import type * as StackTrace from './stack_trace.js';
9+
import {AsyncFragmentImpl, FragmentImpl, FrameImpl, StackTraceImpl} from './StackTraceImpl.js';
810
import {type RawFrame, Trie} from './Trie.js';
911

12+
/**
13+
* A stack trace translation function.
14+
*
15+
* Any implementation must return an array with the same length as `frames`.
16+
*/
17+
export type TranslateRawFrames = (frames: readonly RawFrame[], target: SDK.Target.Target) =>
18+
Promise<Array<Array<Pick<StackTrace.StackTrace.Frame, 'url'|'uiSourceCode'|'name'|'line'|'column'>>>>;
19+
1020
/**
1121
* The {@link StackTraceModel} is a thin wrapper around a fragment trie.
1222
*
@@ -15,9 +25,53 @@ import {type RawFrame, Trie} from './Trie.js';
1525
export class StackTraceModel extends SDK.SDKModel.SDKModel<unknown> {
1626
readonly #trie = new Trie();
1727

18-
createFragment(frames: RawFrame[]): FragmentImpl {
28+
/** @returns the {@link StackTraceModel} for the target, or the model for the primaryPageTarget when passing null/undefined */
29+
static #modelForTarget(target: SDK.Target.Target|null|undefined): StackTraceModel {
30+
const model = (target ?? SDK.TargetManager.TargetManager.instance().primaryPageTarget())?.model(StackTraceModel);
31+
if (!model) {
32+
throw new Error('Unable to find StackTraceModel');
33+
}
34+
return model;
35+
}
36+
37+
async createFromProtocolRuntime(stackTrace: Protocol.Runtime.StackTrace, rawFramesToUIFrames: TranslateRawFrames):
38+
Promise<StackTrace.StackTrace.StackTrace> {
39+
const translatePromises: Array<Promise<unknown>> = [];
40+
41+
const fragment = this.#createFragment(stackTrace.callFrames);
42+
translatePromises.push(this.#translateFragment(fragment, rawFramesToUIFrames));
43+
44+
const asyncFragments: AsyncFragmentImpl[] = [];
45+
const debuggerModel = this.target().model(SDK.DebuggerModel.DebuggerModel);
46+
if (debuggerModel) {
47+
for await (const {stackTrace: asyncStackTrace, target} of debuggerModel.iterateAsyncParents(stackTrace)) {
48+
const model = StackTraceModel.#modelForTarget(target);
49+
const asyncFragment = model.#createFragment(asyncStackTrace.callFrames);
50+
translatePromises.push(model.#translateFragment(asyncFragment, rawFramesToUIFrames));
51+
asyncFragments.push(new AsyncFragmentImpl(asyncStackTrace.description ?? '', asyncFragment));
52+
}
53+
}
54+
55+
await Promise.all(translatePromises);
56+
57+
return new StackTraceImpl(fragment, asyncFragments);
58+
}
59+
60+
#createFragment(frames: RawFrame[]): FragmentImpl {
1961
return FragmentImpl.getOrCreate(this.#trie.insert(frames));
2062
}
63+
64+
async #translateFragment(fragment: FragmentImpl, rawFramesToUIFrames: TranslateRawFrames): Promise<void> {
65+
const rawFrames = fragment.node.getCallStack().map(node => node.rawFrame).toArray();
66+
const uiFrames = await rawFramesToUIFrames(rawFrames, this.target());
67+
console.assert(rawFrames.length === uiFrames.length, 'Broken rawFramesToUIFrames implementation');
68+
69+
let i = 0;
70+
for (const node of fragment.node.getCallStack()) {
71+
node.frames = uiFrames[i++].map(
72+
frame => new FrameImpl(frame.url, frame.uiSourceCode, frame.name, frame.line, frame.column));
73+
}
74+
}
2175
}
2276

2377
SDK.SDKModel.SDKModel.register(StackTraceModel, {capabilities: SDK.Target.Capability.NONE, autostart: false});

front_end/models/stack_trace/stack_trace_impl.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
// found in the LICENSE file.
44

55
import * as StackTraceImpl from './StackTraceImpl.js';
6+
import * as StackTraceModel from './StackTraceModel.js';
67
import * as Trie from './Trie.js';
78

89
export {
910
StackTraceImpl,
11+
StackTraceModel,
1012
Trie,
1113
};

0 commit comments

Comments
 (0)