Skip to content

Commit c0ca1d3

Browse files
finnurbrekiDevtools-frontend LUCI CQ
authored andcommitted
[DeepLink]: Allow showing additional context in Performance graph.
This CL enables tracing functions `console.timestamp()` and `performance.mark()`/`measure()` to add additional context to the custom track. Specifically, this includes adding the ability to add a DevTools-Extension-specific url, which can point back to the extension to provide additional context. This requires enabling via a chrome flag: devtools-deep-link-via-extensibility-api Bug: 427430112 Change-Id: I7a3b2d7bcebd3b6bc91cb12610dcdad0c341ed68 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6662880 Commit-Queue: Finnur Thorarinsson <[email protected]> Reviewed-by: Paul Irish <[email protected]>
1 parent 680d60f commit c0ca1d3

File tree

11 files changed

+448
-54
lines changed

11 files changed

+448
-54
lines changed

extension-api/ExtensionAPI.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ export namespace Chrome {
9595
create(title: string, iconPath: string, pagePath: string, callback?: (panel: ExtensionPanel) => unknown): void;
9696
openResource(url: string, lineNumber: number, columnNumber?: number, callback?: () => unknown): void;
9797

98+
setOpenResourceHandler(
99+
callback?: (resource: Resource, lineNumber: number, columnNumber: number) => void, scheme?: string): void;
100+
98101
/**
99102
* Fired when the theme changes in DevTools.
100103
*

front_end/core/root/Runtime.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,10 @@ export interface HostConfigAiCodeCompletion {
397397
userTier: string;
398398
}
399399

400+
export interface HostConfigDeepLinksViaExtensibilityApi {
401+
enabled: boolean;
402+
}
403+
400404
export interface HostConfigVeLogging {
401405
enabled: boolean;
402406
testing: boolean;
@@ -450,6 +454,7 @@ export type HostConfig = Platform.TypeScriptUtilities.RecursivePartial<{
450454
aidaAvailability: AidaAvailability,
451455
channel: Channel,
452456
devToolsConsoleInsights: HostConfigConsoleInsights,
457+
devToolsDeepLinksViaExtensibilityApi: HostConfigDeepLinksViaExtensibilityApi,
453458
devToolsFreestyler: HostConfigFreestyler,
454459
devToolsAiAssistanceNetworkAgent: HostConfigAiAssistanceNetworkAgent,
455460
devToolsAiAssistanceFileAgent: HostConfigAiAssistanceFileAgent,

front_end/models/extensions/ExtensionAPI.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export namespace PrivateAPI {
230230
interface SetOpenResourceHandlerRequest {
231231
command: Commands.SetOpenResourceHandler;
232232
handlerPresent: boolean;
233+
urlScheme?: string;
233234
}
234235
interface SetThemeChangeHandlerRequest {
235236
command: Commands.SetThemeChangeHandler;
@@ -473,7 +474,8 @@ namespace APIImpl {
473474

474475
export interface Panels extends PublicAPI.Chrome.DevTools.Panels {
475476
get SearchAction(): Record<string, string>;
476-
setOpenResourceHandler(callback?: (resource: PublicAPI.Chrome.DevTools.Resource, lineNumber: number) => unknown):
477+
setOpenResourceHandler(
478+
callback?: (resource: PublicAPI.Chrome.DevTools.Resource, lineNumber: number, columnNumber: number) => unknown):
477479
void;
478480
setThemeChangeHandler(callback?: (themeName: string) => unknown): void;
479481
}
@@ -693,15 +695,17 @@ self.injectedExtensionAPI = function(
693695
},
694696

695697
setOpenResourceHandler: function(
696-
callback: (resource: PublicAPI.Chrome.DevTools.Resource, lineNumber: number) => unknown): void {
698+
callback: (resource: PublicAPI.Chrome.DevTools.Resource, lineNumber: number, columnNumber: number) => unknown,
699+
urlScheme?: string): void {
697700
const hadHandler = extensionServer.hasHandler(PrivateAPI.Events.OpenResource);
698701

699702
function callbackWrapper(message: unknown): void {
700703
// Allow the panel to show itself when handling the event.
701704
userAction = true;
702705
try {
703-
const {resource, lineNumber} = message as {resource: APIImpl.ResourceData, lineNumber: number};
704-
callback.call(null, new (Constructor(Resource))(resource), lineNumber);
706+
const {resource, lineNumber, columnNumber} =
707+
message as {resource: APIImpl.ResourceData, lineNumber: number, columnNumber: number};
708+
callback.call(null, new (Constructor(Resource))(resource), lineNumber, columnNumber);
705709
} finally {
706710
userAction = false;
707711
}
@@ -716,7 +720,7 @@ self.injectedExtensionAPI = function(
716720
// Only send command if we either removed an existing handler or added handler and had none before.
717721
if (hadHandler === !callback) {
718722
extensionServer.sendRequest(
719-
{command: PrivateAPI.Commands.SetOpenResourceHandler, handlerPresent: Boolean(callback)});
723+
{command: PrivateAPI.Commands.SetOpenResourceHandler, handlerPresent: Boolean(callback), urlScheme});
720724
}
721725
},
722726

front_end/models/extensions/ExtensionServer.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import * as Platform from '../../core/platform/platform.js';
88
import * as SDK from '../../core/sdk/sdk.js';
99
import * as Protocol from '../../generated/protocol.js';
1010
import {createTarget, expectConsoleLogs} from '../../testing/EnvironmentHelpers.js';
11+
import {spyCall} from '../../testing/ExpectStubCall.js';
1112
import {
1213
describeWithDevtoolsExtension,
1314
getExtensionOrigin,
1415
} from '../../testing/ExtensionHelpers.js';
1516
import {MockProtocolBackend} from '../../testing/MockScopeChain.js';
1617
import {addChildFrame, FRAME_URL, getMainFrame} from '../../testing/ResourceTreeHelpers.js';
1718
import {encodeSourceMap} from '../../testing/SourceMapEncoder.js';
19+
import * as Components from '../../ui/legacy/components/utils/utils.js';
1820
import * as UI from '../../ui/legacy/legacy.js';
1921
import * as Bindings from '../bindings/bindings.js';
2022
import * as Extensions from '../extensions/extensions.js';
@@ -176,6 +178,57 @@ describeWithDevtoolsExtension('Extensions', {}, context => {
176178
});
177179
});
178180

181+
describeWithDevtoolsExtension('Extensions', {}, context => {
182+
beforeEach(() => {
183+
createTarget().setInspectedURL(urlString`http://example.com`);
184+
});
185+
186+
it('can register and unregister a global open resource handler', async () => {
187+
const registerLinkHandlerSpy = spyCall(Components.Linkifier.Linkifier, 'registerLinkHandler');
188+
const unregisterLinkHandlerSpy = spyCall(Components.Linkifier.Linkifier, 'unregisterLinkHandler');
189+
190+
// Register without a specific scheme (global handler).
191+
context.chrome.devtools?.panels.setOpenResourceHandler(() => {});
192+
193+
const registration = await (await registerLinkHandlerSpy).args[0];
194+
assert.strictEqual(registration.title, 'TestExtension');
195+
assert.isUndefined(registration.scheme);
196+
assert.isFunction(registration.handler);
197+
assert.isFunction(registration.filter);
198+
199+
// Now unregister the extension.
200+
context.chrome.devtools?.panels.setOpenResourceHandler();
201+
202+
const unregistration = await (await unregisterLinkHandlerSpy).args[0];
203+
assert.strictEqual(unregistration.title, 'TestExtension');
204+
assert.isUndefined(unregistration.scheme);
205+
assert.isFunction(unregistration.handler);
206+
assert.isFunction(unregistration.filter);
207+
});
208+
209+
it('can register and unregister a scheme specific open resource handler', async () => {
210+
const registerLinkHandlerSpy = spyCall(Components.Linkifier.Linkifier, 'registerLinkHandler');
211+
const unregisterLinkHandlerSpy = spyCall(Components.Linkifier.Linkifier, 'unregisterLinkHandler');
212+
213+
context.chrome.devtools?.panels.setOpenResourceHandler(() => {}, 'foo-extension:');
214+
215+
const registration = await (await registerLinkHandlerSpy).args[0];
216+
assert.strictEqual(registration.title, 'TestExtension');
217+
assert.strictEqual(registration.scheme, 'foo-extension:');
218+
assert.isFunction(registration.handler);
219+
assert.isFunction(registration.filter);
220+
221+
// Now unregister the extension.
222+
context.chrome.devtools?.panels.setOpenResourceHandler();
223+
224+
const unregistration = await (await unregisterLinkHandlerSpy).args[0];
225+
assert.strictEqual(unregistration.title, 'TestExtension');
226+
assert.isUndefined(unregistration.scheme);
227+
assert.isFunction(unregistration.handler);
228+
assert.isFunction(unregistration.filter);
229+
});
230+
});
231+
179232
describeWithDevtoolsExtension('Extensions', {}, context => {
180233
expectConsoleLogs({
181234
warn: ['evaluate: the main frame is not yet available'],

front_end/models/extensions/ExtensionServer.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export class HostsPolicy {
106106
}
107107

108108
class RegisteredExtension {
109+
openResourceScheme: null|string = null;
109110
constructor(readonly name: string, readonly hostsPolicy: HostsPolicy, readonly allowFileAccess: boolean) {
110111
}
111112

@@ -118,6 +119,10 @@ class RegisteredExtension {
118119
return false;
119120
}
120121

122+
if (this.openResourceScheme && inspectedURL.startsWith(this.openResourceScheme)) {
123+
return true;
124+
}
125+
121126
if (!ExtensionServer.canInspectURL(inspectedURL)) {
122127
return false;
123128
}
@@ -817,11 +822,23 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
817822
if (!extension) {
818823
throw new Error('Received a message from an unregistered extension');
819824
}
825+
if (message.urlScheme) {
826+
extension.openResourceScheme = message.urlScheme;
827+
}
828+
const extensionOrigin = this.getExtensionOrigin(port);
820829
const {name} = extension;
830+
const registration = {
831+
title: name,
832+
origin: extensionOrigin,
833+
scheme: message.urlScheme,
834+
handler: this.handleOpenURL.bind(this, port),
835+
filter: (url: Platform.DevToolsPath.UrlString, schemes: Set<string>) =>
836+
Components.Linkifier.Linkifier.shouldHandleOpenResource(extension.openResourceScheme, url, schemes),
837+
};
821838
if (message.handlerPresent) {
822-
Components.Linkifier.Linkifier.registerLinkHandler(name, this.handleOpenURL.bind(this, port));
839+
Components.Linkifier.Linkifier.registerLinkHandler(registration);
823840
} else {
824-
Components.Linkifier.Linkifier.unregisterLinkHandler(name);
841+
Components.Linkifier.Linkifier.unregisterLinkHandler(registration);
825842
}
826843
return undefined;
827844
}
@@ -846,10 +863,25 @@ export class ExtensionServer extends Common.ObjectWrapper.ObjectWrapper<EventTyp
846863
}
847864

848865
private handleOpenURL(
849-
port: MessagePort, contentProvider: TextUtils.ContentProvider.ContentProvider, lineNumber: number): void {
850-
if (this.extensionAllowedOnURL(contentProvider.contentURL(), port)) {
851-
port.postMessage(
852-
{command: 'open-resource', resource: this.makeResource(contentProvider), lineNumber: lineNumber + 1});
866+
port: MessagePort, contentProviderOrUrl: TextUtils.ContentProvider.ContentProvider|string, lineNumber?: number,
867+
columnNumber?: number): void {
868+
let url: Platform.DevToolsPath.UrlString;
869+
let resource: {url: string, type: string};
870+
if (typeof contentProviderOrUrl !== 'string') {
871+
url = contentProviderOrUrl.contentURL();
872+
resource = this.makeResource(contentProviderOrUrl);
873+
} else {
874+
url = contentProviderOrUrl as Platform.DevToolsPath.UrlString;
875+
resource = {url, type: Common.ResourceType.resourceTypes.Other.name()};
876+
}
877+
878+
if (this.extensionAllowedOnURL(url, port)) {
879+
port.postMessage({
880+
command: 'open-resource',
881+
resource,
882+
lineNumber: lineNumber ? lineNumber + 1 : undefined,
883+
columnNumber: columnNumber ? columnNumber + 1 : undefined,
884+
});
853885
}
854886
}
855887

front_end/models/trace/handlers/ExtensionTraceDataHandler.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -214,14 +214,8 @@ export function extractPerformanceAPIExtensionEntries(
214214
}
215215
}
216216

217-
export function extensionDataInPerformanceTiming(
218-
timing: Types.Events.SyntheticUserTimingPair|Types.Events.PerformanceMark): Types.Extensions.ExtensionDataPayload|
219-
null {
220-
const timingDetail =
221-
Types.Events.isPerformanceMark(timing) ? timing.args.data?.detail : timing.args.data.beginEvent.args.detail;
222-
if (!timingDetail) {
223-
return null;
224-
}
217+
function parseDetail(timingDetail: string, key: string): Types.Extensions.ExtensionDataPayload|
218+
Types.Extensions.ExtensionTrackEntryPayloadDeeplink|null {
225219
try {
226220
// Attempt to parse the detail as an object that might be coming from a
227221
// DevTools Perf extension.
@@ -230,19 +224,41 @@ export function extensionDataInPerformanceTiming(
230224
// 2.Not be an object - in which case the `in` check will error.
231225
// If we hit either of these cases, we just ignore this mark and move on.
232226
const detailObj = JSON.parse(timingDetail);
233-
if (!('devtools' in detailObj)) {
227+
if (!(key in detailObj)) {
234228
return null;
235229
}
236-
if (!Types.Extensions.isValidExtensionPayload(detailObj.devtools)) {
230+
if (!Types.Extensions.isValidExtensionPayload(detailObj[key])) {
237231
return null;
238232
}
239-
return detailObj.devtools;
233+
return detailObj[key];
240234
} catch {
241235
// No need to worry about this error, just discard this event and don't
242236
// treat it as having any useful information for the purposes of extensions
243237
return null;
244238
}
245239
}
240+
241+
function extensionPayloadForConsoleApi(timing: Types.Events.ConsoleTimeStamp):
242+
Types.Extensions.ExtensionTrackEntryPayloadDeeplink|null {
243+
if (!timing.args.data || !('devtools' in timing.args.data)) {
244+
return null;
245+
}
246+
247+
return parseDetail(`{"additionalContext": ${timing.args.data.devtools} }`, 'additionalContext') as
248+
Types.Extensions.ExtensionTrackEntryPayloadDeeplink;
249+
}
250+
251+
export function extensionDataInPerformanceTiming(
252+
timing: Types.Events.SyntheticUserTimingPair|Types.Events.PerformanceMark): Types.Extensions.ExtensionDataPayload|
253+
null {
254+
const timingDetail =
255+
Types.Events.isPerformanceMark(timing) ? timing.args.data?.detail : timing.args.data.beginEvent.args.detail;
256+
if (!timingDetail) {
257+
return null;
258+
}
259+
return parseDetail(timingDetail, 'devtools') as Types.Extensions.ExtensionDataPayload;
260+
}
261+
246262
/**
247263
* Extracts extension data from a `console.timeStamp` event.
248264
*
@@ -272,14 +288,22 @@ export function extensionDataInConsoleTimeStamp(timeStamp: Types.Events.ConsoleT
272288
if (trackName === '' || trackName === undefined) {
273289
return null;
274290
}
291+
292+
let additionalContext: Types.Extensions.ExtensionTrackEntryPayloadDeeplink|undefined;
293+
const payload = extensionPayloadForConsoleApi(timeStamp);
294+
if (payload) {
295+
additionalContext = payload;
296+
}
297+
275298
return {
276299
// the color is defaulted to primary if it's value isn't one from
277300
// the defined palette (see ExtensionUI::extensionEntryColor) so
278301
// we don't need to check the value is valid here.
279302
color: String(timeStamp.args.data.color) as Types.Extensions.ExtensionTrackEntryPayload['color'],
280303
track: String(trackName),
281304
dataType: 'track-entry',
282-
trackGroup: timeStamp.args.data.trackGroup !== undefined ? String(timeStamp.args.data.trackGroup) : undefined
305+
trackGroup: timeStamp.args.data.trackGroup !== undefined ? String(timeStamp.args.data.trackGroup) : undefined,
306+
additionalContext
283307
};
284308
}
285309

front_end/models/trace/types/Extensions.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import type * as Platform from '../../../core/platform/platform.js';
6+
57
import type {
68
Args, ConsoleTimeStamp, Event, PerformanceMark, PerformanceMeasureBegin, Phase, SyntheticBased} from
79
'./TraceEvents.js';
@@ -37,6 +39,14 @@ export interface ExtensionDataPayloadBase {
3739

3840
export type ExtensionDataPayload = ExtensionTrackEntryPayload|ExtensionMarkerPayload;
3941

42+
export interface ExtensionTrackEntryPayloadDeeplink {
43+
// The URL (deep-link) to show in the summary for the track.
44+
url: Platform.DevToolsPath.UrlString;
45+
// The label to show in front of the URL when the deep-link is shown in the
46+
// graph.
47+
description: string;
48+
}
49+
4050
export interface ExtensionTrackEntryPayload extends ExtensionDataPayloadBase {
4151
// Typed as possibly undefined since when no data type is provided
4252
// the entry is defaulted to a track entry
@@ -51,6 +61,9 @@ export interface ExtensionTrackEntryPayload extends ExtensionDataPayloadBase {
5161
// same value in this property as well as the same value in the track
5262
// property.
5363
trackGroup?: string;
64+
// Additional context (deep-link URL) that can be shown in the summary for the
65+
// track.
66+
additionalContext?: ExtensionTrackEntryPayloadDeeplink;
5467
}
5568

5669
export interface ExtensionMarkerPayload extends ExtensionDataPayloadBase {
@@ -85,8 +98,16 @@ export function isExtensionPayloadTrackEntry(payload: {track?: string, dataType?
8598
return validEntryType && hasTrack;
8699
}
87100

88-
export function isValidExtensionPayload(payload: {track?: string, dataType?: string}): payload is ExtensionDataPayload {
89-
return isExtensionPayloadMarker(payload) || isExtensionPayloadTrackEntry(payload);
101+
export function isConsoleTimestampPayloadTrackEntry(payload: {description?: string, url?: string}):
102+
payload is ExtensionTrackEntryPayloadDeeplink {
103+
return payload.url !== undefined && payload.description !== undefined;
104+
}
105+
106+
export function isValidExtensionPayload(
107+
payload: {track?: string, dataType?: string, description?: string, url?: string}): payload is ExtensionDataPayload|
108+
ExtensionTrackEntryPayloadDeeplink {
109+
return isExtensionPayloadMarker(payload) || isExtensionPayloadTrackEntry(payload) ||
110+
isConsoleTimestampPayloadTrackEntry(payload);
90111
}
91112

92113
export function isSyntheticExtensionEntry(entry: Event): entry is SyntheticExtensionEntry {

front_end/models/trace/types/TraceEvents.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type * as Platform from '../../../core/platform/platform.js';
66
import type * as Protocol from '../../../generated/protocol.js';
77

8+
import type {ExtensionTrackEntryPayloadDeeplink} from './Extensions.js';
89
import type {Micro, Milli, Seconds, TraceWindowMicro} from './Timing.js';
910

1011
// Trace Events.
@@ -1478,6 +1479,9 @@ export interface ConsoleTimeStamp extends Event {
14781479
track?: string|number,
14791480
trackGroup?: string|number,
14801481
color?: string|number,
1482+
devtools?: {
1483+
link: ExtensionTrackEntryPayloadDeeplink,
1484+
},
14811485
sampleTraceId?: number,
14821486
},
14831487
};

0 commit comments

Comments
 (0)