Skip to content

Commit a8acb4a

Browse files
Connor ClarkDevtools-frontend LUCI CQ
authored andcommitted
[RPP] Use parsedTrace everywhere to hold all derived data
Instead of passing around insights, metadata and syntheticEventsManager separately, use the parsedTrace object. This CL makes pretty much everything that acts on a trace go through this interface. The result is more consistent code and fewer function arguments across much of the performance and trace code. Also renamed "parsedTraceFile" to "parsedTrace". This CL should have no functional change. Bug: 358583420 Change-Id: I7e87c86edf807bc81a4ec8db5cf260162864146f Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6936714 Reviewed-by: Paul Irish <[email protected]> Commit-Queue: Connor Clark <[email protected]>
1 parent 7ac3672 commit a8acb4a

File tree

122 files changed

+1737
-1812
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

122 files changed

+1737
-1812
lines changed

front_end/legacy_test_runner/performance_test_runner/TimelineTestRunner.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ PerformanceTestRunner.createTraceEngineDataFromEvents = async function(events) {
119119
await model.parse(events);
120120
// Model only has one trace, so we can hardcode 0 here to get the latest
121121
// result.
122-
return model.handlerData(0);
122+
return model.parsedTrace(0)?.data;
123123
};
124124

125125
PerformanceTestRunner.createTimelineController = function() {

front_end/models/ai_assistance/agents/PerformanceAgent.test.ts

Lines changed: 59 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -119,24 +119,26 @@ describeWithEnvironment('PerformanceAgent', () => {
119119
describeWithEnvironment('PerformanceAgent – call tree focus', () => {
120120
describe('getOrigin()', () => {
121121
it('calculates the origin of the selected node when it has a URL associated with it', async function() {
122-
const {data} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
122+
const parsedTrace = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
123123
// An Evaluate Script event, picked because it has a URL of googletagmanager.com/...
124-
const evalScriptEvent = allThreadEntriesInTrace(data).find(
125-
event => event.name === Trace.Types.Events.Name.EVALUATE_SCRIPT && event.ts === 122411195649);
124+
const evalScriptEvent =
125+
allThreadEntriesInTrace(parsedTrace)
126+
.find(event => event.name === Trace.Types.Events.Name.EVALUATE_SCRIPT && event.ts === 122411195649);
126127
assert.exists(evalScriptEvent);
127-
const aiCallTree = TimelineUtils.AICallTree.AICallTree.fromEvent(evalScriptEvent, data);
128+
const aiCallTree = TimelineUtils.AICallTree.AICallTree.fromEvent(evalScriptEvent, parsedTrace);
128129
assert.isOk(aiCallTree);
129130
const context = PerformanceTraceContext.fromCallTree(aiCallTree);
130131
assert.strictEqual(context.getOrigin(), 'https://www.googletagmanager.com');
131132
});
132133

133134
it('returns a random but deterministic "origin" for nodes that have no URL associated', async function() {
134-
const {data} = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
135+
const parsedTrace = await TraceLoader.traceEngine(this, 'web-dev-with-commit.json.gz');
135136
// A random layout event with no URL associated
136-
const layoutEvent = allThreadEntriesInTrace(data).find(
137-
event => event.name === Trace.Types.Events.Name.LAYOUT && event.ts === 122411130078);
137+
const layoutEvent =
138+
allThreadEntriesInTrace(parsedTrace)
139+
.find(event => event.name === Trace.Types.Events.Name.LAYOUT && event.ts === 122411130078);
138140
assert.exists(layoutEvent);
139-
const aiCallTree = TimelineUtils.AICallTree.AICallTree.fromEvent(layoutEvent, data);
141+
const aiCallTree = TimelineUtils.AICallTree.AICallTree.fromEvent(layoutEvent, parsedTrace);
140142
assert.isOk(aiCallTree);
141143
const context = PerformanceTraceContext.fromCallTree(aiCallTree);
142144
assert.strictEqual(context.getOrigin(), 'Layout_90829_259_122411130078');
@@ -145,11 +147,11 @@ describeWithEnvironment('PerformanceAgent – call tree focus', () => {
145147

146148
describe('run', function() {
147149
it('generates an answer', async function() {
148-
const {data} = await TraceLoader.traceEngine(this, 'web-dev-outermost-frames.json.gz');
150+
const parsedTrace = await TraceLoader.traceEngine(this, 'web-dev-outermost-frames.json.gz');
149151
// A basic Layout.
150-
const layoutEvt = allThreadEntriesInTrace(data).find(event => event.ts === 465457096322);
152+
const layoutEvt = allThreadEntriesInTrace(parsedTrace).find(event => event.ts === 465457096322);
151153
assert.exists(layoutEvt);
152-
const aiCallTree = TimelineUtils.AICallTree.AICallTree.fromEvent(layoutEvt, data);
154+
const aiCallTree = TimelineUtils.AICallTree.AICallTree.fromEvent(layoutEvt, parsedTrace);
153155
assert.exists(aiCallTree);
154156

155157
const agent = new PerformanceAgent(
@@ -264,30 +266,34 @@ const FAKE_INP_MODEL = {
264266
state: 'fail',
265267
frameId: '123',
266268
} as const;
267-
const FAKE_PARSED_TRACE = {
269+
const FAKE_HANDLER_DATA = {
268270
Meta: {traceBounds: {min: 0, max: 10}, mainFrameURL: 'https://www.example.com'},
269271
} as unknown as Trace.Handlers.Types.HandlerData;
270272
const FAKE_INSIGHTS = new Map([['', {model: {LCPBreakdown: FAKE_LCP_MODEL, INPBreakdown: FAKE_INP_MODEL}}]]) as
271273
unknown as Trace.Insights.Types.TraceInsightSets;
272274
const FAKE_METADATA = {} as unknown as Trace.Types.File.MetaData;
275+
const FAKE_PARSED_TRACE = {
276+
data: FAKE_HANDLER_DATA,
277+
insights: FAKE_INSIGHTS,
278+
metadata: FAKE_METADATA,
279+
} as unknown as Trace.TraceModel.ParsedTrace;
273280

274281
function createAgentForInsightConversation(opts: {aidaClient?: Host.AidaClient.AidaClient} = {}) {
275282
return new PerformanceAgent({aidaClient: opts.aidaClient ?? mockAidaClient()}, ConversationType.PERFORMANCE_INSIGHT);
276283
}
277284

278285
describeWithEnvironment('PerformanceAgent – insight focus', () => {
279286
it('uses the min and max bounds of the trace as the origin', async function() {
280-
const {data, insights, metadata} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
281-
assert.isOk(insights);
282-
const [firstNav] = data.Meta.mainFrameNavigations;
283-
const lcpBreakdown = getInsightOrError('LCPBreakdown', insights, firstNav);
284-
const context = PerformanceTraceContext.fromInsight(data, insights, metadata, lcpBreakdown);
287+
const parsedTrace = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
288+
assert.isOk(parsedTrace.insights);
289+
const [firstNav] = parsedTrace.data.Meta.mainFrameNavigations;
290+
const lcpBreakdown = getInsightOrError('LCPBreakdown', parsedTrace.insights, firstNav);
291+
const context = PerformanceTraceContext.fromInsight(parsedTrace, lcpBreakdown);
285292
assert.strictEqual(context.getOrigin(), 'trace-658799706428-658804825864');
286293
});
287294

288295
it('outputs the right title for the selected insight', async () => {
289-
const context =
290-
PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_INSIGHTS, FAKE_METADATA, FAKE_LCP_MODEL);
296+
const context = PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_LCP_MODEL);
291297
assert.strictEqual(context.getTitle(), 'Trace: www.example.com');
292298
});
293299

@@ -338,9 +344,9 @@ code
338344

339345
describe('handleContextDetails', () => {
340346
it('outputs the right context for the initial query from the user', async function() {
341-
const {data, insights, metadata} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
342-
assert.isOk(insights);
343-
const context = PerformanceTraceContext.fromInsight(data, insights, metadata, FAKE_LCP_MODEL);
347+
const parsedTrace = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
348+
assert.isOk(parsedTrace.insights);
349+
const context = PerformanceTraceContext.fromInsight(parsedTrace, FAKE_LCP_MODEL);
344350
const agent = createAgentForInsightConversation({
345351
aidaClient: mockAidaClient([[{
346352
explanation: 'This is the answer',
@@ -389,8 +395,7 @@ code
389395
aidaClient: {} as Host.AidaClient.AidaClient,
390396
});
391397

392-
const context =
393-
PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_INSIGHTS, FAKE_METADATA, FAKE_LCP_MODEL);
398+
const context = PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_LCP_MODEL);
394399
const finalQuery = await agent.enhanceQuery('What is this?', context);
395400
const expected =
396401
`User clicked on the LCPBreakdown insight, and then asked a question.\n\n# User question for you to answer:\nWhat is this?`;
@@ -403,8 +408,7 @@ code
403408
aidaClient: {} as Host.AidaClient.AidaClient,
404409
});
405410

406-
const context =
407-
PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_INSIGHTS, FAKE_METADATA, FAKE_LCP_MODEL);
411+
const context = PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_LCP_MODEL);
408412

409413
await agent.enhanceQuery('What is this?', context);
410414
const finalQuery = await agent.enhanceQuery('Help me understand?', context);
@@ -418,10 +422,8 @@ Help me understand?`;
418422
const agent = createAgentForInsightConversation({
419423
aidaClient: {} as Host.AidaClient.AidaClient,
420424
});
421-
const context1 =
422-
PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_INSIGHTS, FAKE_METADATA, FAKE_LCP_MODEL);
423-
const context2 =
424-
PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_INSIGHTS, FAKE_METADATA, FAKE_INP_MODEL);
425+
const context1 = PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_LCP_MODEL);
426+
const context2 = PerformanceTraceContext.fromInsight(FAKE_PARSED_TRACE, FAKE_INP_MODEL);
425427
const firstQuery = await agent.enhanceQuery('Q1', context1);
426428
const secondQuery = await agent.enhanceQuery('Q2', context1);
427429
const thirdQuery = await agent.enhanceQuery('Q3', context2);
@@ -434,11 +436,11 @@ Help me understand?`;
434436
describe('function calls', () => {
435437
it('can call getNetworkTrackSummary', async function() {
436438
const metricsSpy = sinon.spy(Host.userMetrics, 'performanceAINetworkSummaryResponseSize');
437-
const {data, insights, metadata} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
438-
assert.isOk(insights);
439-
const [firstNav] = data.Meta.mainFrameNavigations;
440-
const lcpBreakdown = getInsightOrError('LCPBreakdown', insights, firstNav);
441-
const bounds = data.Meta.traceBounds;
439+
const parsedTrace = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
440+
assert.isOk(parsedTrace.insights);
441+
const [firstNav] = parsedTrace.data.Meta.mainFrameNavigations;
442+
const lcpBreakdown = getInsightOrError('LCPBreakdown', parsedTrace.insights, firstNav);
443+
const bounds = parsedTrace.data.Meta.traceBounds;
442444
const agent = createAgentForInsightConversation({
443445
aidaClient: mockAidaClient([
444446
[{
@@ -448,7 +450,7 @@ Help me understand?`;
448450
[{explanation: 'done'}]
449451
])
450452
});
451-
const context = PerformanceTraceContext.fromInsight(data, insights, metadata, lcpBreakdown);
453+
const context = PerformanceTraceContext.fromInsight(parsedTrace, lcpBreakdown);
452454

453455
const responses = await Array.fromAsync(agent.run('test', {selected: context}));
454456
const action = responses.find(response => response.type === ResponseType.ACTION);
@@ -462,7 +464,7 @@ Help me understand?`;
462464
];
463465

464466
expectedRequestUrls.forEach(url => {
465-
const match = data.NetworkRequests.byTime.find(r => r.args.data.url === url);
467+
const match = parsedTrace.data.NetworkRequests.byTime.find(r => r.args.data.url === url);
466468
assert.isOk(match, `no request found for ${url}`);
467469
});
468470

@@ -489,11 +491,11 @@ Help me understand?`;
489491
it('can call getMainThreadTrackSummary', async function() {
490492
const metricsSpy = sinon.spy(Host.userMetrics, 'performanceAIMainThreadActivityResponseSize');
491493

492-
const {data, insights, metadata} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
493-
assert.isOk(insights);
494-
const [firstNav] = data.Meta.mainFrameNavigations;
495-
const lcpBreakdown = getInsightOrError('LCPBreakdown', insights, firstNav);
496-
const bounds = data.Meta.traceBounds;
494+
const parsedTrace = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
495+
assert.isOk(parsedTrace.insights);
496+
const [firstNav] = parsedTrace.data.Meta.mainFrameNavigations;
497+
const lcpBreakdown = getInsightOrError('LCPBreakdown', parsedTrace.insights, firstNav);
498+
const bounds = parsedTrace.data.Meta.traceBounds;
497499
const agent = createAgentForInsightConversation({
498500
aidaClient: mockAidaClient([
499501
[{
@@ -503,7 +505,7 @@ Help me understand?`;
503505
[{explanation: 'done'}]
504506
])
505507
});
506-
const context = PerformanceTraceContext.fromInsight(data, insights, metadata, lcpBreakdown);
508+
const context = PerformanceTraceContext.fromInsight(parsedTrace, lcpBreakdown);
507509

508510
const responses = await Array.fromAsync(agent.run('test', {selected: context}));
509511
const titleResponse = responses.find(response => response.type === ResponseType.TITLE);
@@ -531,18 +533,18 @@ Help me understand?`;
531533
});
532534

533535
it('will not send facts from a previous insight if the context changes', async function() {
534-
const {data, insights, metadata} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
535-
assert.isOk(insights);
536-
const [firstNav] = data.Meta.mainFrameNavigations;
537-
const lcpBreakdown = getInsightOrError('LCPBreakdown', insights, firstNav);
538-
const renderBlocking = getInsightOrError('RenderBlocking', insights, firstNav);
536+
const parsedTrace = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
537+
assert.isOk(parsedTrace.insights);
538+
const [firstNav] = parsedTrace.data.Meta.mainFrameNavigations;
539+
const lcpBreakdown = getInsightOrError('LCPBreakdown', parsedTrace.insights, firstNav);
540+
const renderBlocking = getInsightOrError('RenderBlocking', parsedTrace.insights, firstNav);
539541
const agent = createAgentForInsightConversation({
540542
aidaClient: mockAidaClient([
541543
[{explanation: '', functionCalls: [{name: 'getMainThreadTrackSummary', args: {}}]}],
542544
])
543545
});
544-
const lcpContext = PerformanceTraceContext.fromInsight(data, insights, metadata, lcpBreakdown);
545-
const renderBlockingContext = PerformanceTraceContext.fromInsight(data, insights, metadata, renderBlocking);
546+
const lcpContext = PerformanceTraceContext.fromInsight(parsedTrace, lcpBreakdown);
547+
const renderBlockingContext = PerformanceTraceContext.fromInsight(parsedTrace, renderBlocking);
546548

547549
// Populate the function calls for the LCP Context
548550
await Array.fromAsync(agent.run('test 1 LCP', {selected: lcpContext}));
@@ -555,17 +557,17 @@ Help me understand?`;
555557
});
556558

557559
it('will cache function calls as facts', async function() {
558-
const {data, insights, metadata} = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
559-
assert.isOk(insights);
560-
const [firstNav] = data.Meta.mainFrameNavigations;
561-
const lcpBreakdown = getInsightOrError('LCPBreakdown', insights, firstNav);
560+
const parsedTrace = await TraceLoader.traceEngine(this, 'lcp-discovery-delay.json.gz');
561+
assert.isOk(parsedTrace.insights);
562+
const [firstNav] = parsedTrace.data.Meta.mainFrameNavigations;
563+
const lcpBreakdown = getInsightOrError('LCPBreakdown', parsedTrace.insights, firstNav);
562564
const agent = createAgentForInsightConversation({
563565
aidaClient: mockAidaClient([
564566
[{explanation: '', functionCalls: [{name: 'getMainThreadTrackSummary', args: {}}]}],
565567
[{explanation: '', functionCalls: [{name: 'getNetworkTrackSummary', args: {}}]}], [{explanation: 'done'}]
566568
])
567569
});
568-
const context = PerformanceTraceContext.fromInsight(data, insights, metadata, lcpBreakdown);
570+
const context = PerformanceTraceContext.fromInsight(parsedTrace, lcpBreakdown);
569571
await Array.fromAsync(agent.run('test 1', {selected: context}));
570572
await Array.fromAsync(agent.run('test 2', {selected: context}));
571573
// First 6 are the always included high-level facts. The rests are from the function calls.
@@ -586,9 +588,8 @@ Help me understand?`;
586588

587589
describeWithEnvironment('PerformanceAgent – all focus', () => {
588590
it('uses the min and max bounds of the trace as the origin', async function() {
589-
const {data, insights, metadata} = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
590-
assert.isOk(insights);
591-
const context = PerformanceTraceContext.full(data, insights, metadata);
591+
const parsedTrace = await TraceLoader.traceEngine(this, 'lcp-images.json.gz');
592+
const context = PerformanceTraceContext.full(parsedTrace);
592593
assert.strictEqual(context.getOrigin(), 'trace-658799706428-658804825864');
593594
});
594595
});

front_end/models/ai_assistance/agents/PerformanceAgent.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -220,17 +220,13 @@ enum ScorePriority {
220220
}
221221

222222
export class PerformanceTraceContext extends ConversationContext<TimelineUtils.AIContext.AgentFocus> {
223-
static full(
224-
parsedTrace: Trace.Handlers.Types.HandlerData, insights: Trace.Insights.Types.TraceInsightSets,
225-
traceMetadata: Trace.Types.File.MetaData): PerformanceTraceContext {
226-
return new PerformanceTraceContext(TimelineUtils.AIContext.AgentFocus.full(parsedTrace, insights, traceMetadata));
223+
static full(parsedTrace: Trace.TraceModel.ParsedTrace): PerformanceTraceContext {
224+
return new PerformanceTraceContext(TimelineUtils.AIContext.AgentFocus.full(parsedTrace));
227225
}
228226

229-
static fromInsight(
230-
parsedTrace: Trace.Handlers.Types.HandlerData, insights: Trace.Insights.Types.TraceInsightSets,
231-
traceMetadata: Trace.Types.File.MetaData, insight: Trace.Insights.Types.InsightModel): PerformanceTraceContext {
232-
return new PerformanceTraceContext(
233-
TimelineUtils.AIContext.AgentFocus.fromInsight(parsedTrace, insights, traceMetadata, insight));
227+
static fromInsight(parsedTrace: Trace.TraceModel.ParsedTrace, insight: Trace.Insights.Types.InsightModel):
228+
PerformanceTraceContext {
229+
return new PerformanceTraceContext(TimelineUtils.AIContext.AgentFocus.fromInsight(parsedTrace, insight));
234230
}
235231

236232
static fromCallTree(callTree: TimelineUtils.AICallTree.AICallTree): PerformanceTraceContext {
@@ -246,9 +242,10 @@ export class PerformanceTraceContext extends ConversationContext<TimelineUtils.A
246242

247243
override getOrigin(): string {
248244
const focus = this.#focus.data;
245+
const data = focus.parsedTrace.data;
249246

250247
if (focus.type === 'full' || focus.type === 'insight') {
251-
const {min, max} = focus.parsedTrace.Meta.traceBounds;
248+
const {min, max} = data.Meta.traceBounds;
252249
return `trace-${min}-${max}`;
253250
}
254251

@@ -262,7 +259,7 @@ export class PerformanceTraceContext extends ConversationContext<TimelineUtils.A
262259
// Get the non-resolved (ignore sourcemaps) URL for the event. We use the
263260
// non-resolved URL as in the context of the AI Assistance panel, we care
264261
// about the origin it was served on.
265-
const nonResolvedURL = Trace.Handlers.Helpers.getNonResolvedURL(selectedEvent, focus.callTree.parsedTrace);
262+
const nonResolvedURL = Trace.Handlers.Helpers.getNonResolvedURL(selectedEvent, focus.callTree.parsedTrace.data);
266263
if (nonResolvedURL) {
267264
const origin = Common.ParsedURL.ParsedURL.extractOrigin(nonResolvedURL);
268265
if (origin) { // origin could be the empty string.
@@ -288,9 +285,10 @@ export class PerformanceTraceContext extends ConversationContext<TimelineUtils.A
288285

289286
override getTitle(): string {
290287
const focus = this.#focus.data;
288+
const data = focus.parsedTrace.data;
291289

292290
if (focus.type === 'full' || focus.type === 'insight') {
293-
const url = focus.insightSet?.url ?? new URL(focus.parsedTrace.Meta.mainFrameURL);
291+
const url = focus.insightSet?.url ?? new URL(data.Meta.mainFrameURL);
294292
return `Trace: ${url.hostname}`;
295293
}
296294

@@ -693,7 +691,7 @@ export class PerformanceAgent extends AiAgent<TimelineUtils.AIContext.AgentFocus
693691
return;
694692
}
695693

696-
const {parsedTrace, insightSet, traceMetadata} = focus.data;
694+
const {parsedTrace, insightSet} = focus.data;
697695

698696
this.declareFunction<{insightName: string}, {details: string}>('getInsightDetails', {
699697
description:
@@ -771,8 +769,8 @@ export class PerformanceAgent extends AiAgent<TimelineUtils.AIContext.AgentFocus
771769
return null;
772770
}
773771

774-
const clampedMin = Math.max(min ?? 0, parsedTrace.Meta.traceBounds.min);
775-
const clampedMax = Math.min(max ?? Number.POSITIVE_INFINITY, parsedTrace.Meta.traceBounds.max);
772+
const clampedMin = Math.max(min ?? 0, parsedTrace.data.Meta.traceBounds.min);
773+
const clampedMax = Math.min(max ?? Number.POSITIVE_INFINITY, parsedTrace.data.Meta.traceBounds.max);
776774
if (clampedMin > clampedMax) {
777775
return null;
778776
}
@@ -935,7 +933,8 @@ export class PerformanceAgent extends AiAgent<TimelineUtils.AIContext.AgentFocus
935933
});
936934

937935
const isFresh = TimelineUtils.FreshRecording.Tracker.instance().recordingIsFresh(parsedTrace);
938-
const hasScriptContents = traceMetadata.enhancedTraceVersion && parsedTrace.Scripts.scripts.some(s => s.content);
936+
const hasScriptContents =
937+
parsedTrace.metadata.enhancedTraceVersion && parsedTrace.data.Scripts.scripts.some(s => s.content);
939938

940939
if (isFresh || hasScriptContents) {
941940
this.declareFunction<{url: string}, {content: string}>('getResourceContent', {

0 commit comments

Comments
 (0)