From e8650fe0baf6b57ad61277b3daff592472ff5f2f Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 24 Sep 2026 15:04:36 +0200 Subject: [PATCH 01/24] Preserve marker schemas when comparing profiles Combine schemas from all input profiles instead of keeping only the first profile's schemas. Schema-driven screenshots need their field definitions to translate image URL indexes and create screenshot tracks, including when only the second profile contains screenshots. --- src/profile-logic/merge-compare.ts | 15 ++++++-- src/test/unit/merge-compare.test.ts | 56 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index a588e739e9..c13f550acd 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -124,9 +124,20 @@ export function mergeProfilesForDiffing( (resultProfile.meta as any)[key] = value; } } - // Ensure it has a copy of the marker schema and categories, even though these could + // Combine the marker schemas of all profiles, so that markers coming from + // any of them keep being understood. + const markerSchemaNames = new Set(); + resultProfile.meta.markerSchema = []; + for (const profile of profiles) { + for (const schema of profile.meta.markerSchema) { + if (!markerSchemaNames.has(schema.name)) { + markerSchemaNames.add(schema.name); + resultProfile.meta.markerSchema.push(schema); + } + } + } + // Ensure it has a copy of the categories, even though these could // be different between the two profiles. - resultProfile.meta.markerSchema = profiles[0].meta.markerSchema; resultProfile.meta.categories = profiles[0].meta.categories; resultProfile.meta.interval = Math.min( diff --git a/src/test/unit/merge-compare.test.ts b/src/test/unit/merge-compare.test.ts index 0c03235189..39e11e3abb 100644 --- a/src/test/unit/merge-compare.test.ts +++ b/src/test/unit/merge-compare.test.ts @@ -367,6 +367,62 @@ describe('mergeProfilesForDiffing function', function () { ]); }); + it('keeps the screenshot markers of a profile when the other profile has none', function () { + const { profile: profileA } = getProfileFromTextSamples('A B C'); + const { profile: profileB, stringTable: stringTableB } = + getProfileFromTextSamples('D'); + const screenshotUrl = 'Screenshot Url'; + addMarkersToThreadWithCorrespondingSamples( + profileB.threads[0], + profileB.shared, + [ + [ + 'CompositorScreenshot 0', + 1, + 2, + { + type: 'CompositorScreenshot', + url: stringTableB.indexForString(screenshotUrl), + windowID: '0', + windowWidth: 300, + windowHeight: 150, + }, + ], + ] + ); + profileB.meta.markerSchema.push({ + name: 'CompositorScreenshot', + display: ['marker-chart', 'marker-table'], + fields: [{ key: 'url', label: 'Image', format: 'unique-string' }], + }); + + const profileState = stateFromLocation({ + pathname: '/public/fakehash1/', + search: '?thread=0&v=3', + hash: '', + }); + const { profile: mergedProfile } = mergeProfilesForDiffing( + [profileA, profileB], + [profileState, profileState] + ); + + expect(mergedProfile.meta.markerSchema.map(({ name }) => name)).toContain( + 'CompositorScreenshot' + ); + + const mergedStringTable = StringTable.withBackingArray( + mergedProfile.shared.stringArray + ); + const screenshotData = ensureExists( + mergedProfile.threads[1].markers.data.find( + (data) => data !== null && data.type === 'CompositorScreenshot' + ) + ); + expect( + mergedStringTable.getString(ensureExists((screenshotData as any).url)) + ).toBe(screenshotUrl); + }); + it('should preserve transforms and produce matching call trees', () => { /** * This test verifies that when profiles are merged with transforms applied: From 6ce494e34aad5e7d67b65e1b4000a05804494917 Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 24 Sep 2026 15:07:10 +0200 Subject: [PATCH 02/24] Add screenshot marker field formats Add schema formats for image string indexes and window dimensions so marker rendering can describe screenshots without knowing their payload field names. Resolve the image aspect ratio through a sibling size field and keep image data out of text formatting and marker searches. --- docs-developer/CHANGELOG-formats.md | 4 +++ src/components/tooltip/Marker.tsx | 33 ++++++++++++++++--- src/profile-logic/marker-schema.ts | 21 ++++++++++-- .../__snapshots__/marker-schema.test.ts.snap | 27 +++++++++++++++ src/test/unit/marker-schema.test.ts | 23 ++++++++++++- src/types/markers.ts | 9 ++++- 6 files changed, 109 insertions(+), 8 deletions(-) diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index 5acec048ca..13b20a9f79 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -6,6 +6,10 @@ Note that this is not an exhaustive list. Processed profile format upgraders can ## Processed profile format +### Version 76 + +Two marker schema field formats were added: `screenshot-size`, whose value is a `{ width, height }` object, and `screenshot-data-url`, an object format `{ type: "screenshot-data-url", sizeFieldForAspectRatio }` whose value is a string table index holding an image data URL. + ### Version 75 The func table (`profile.shared.funcTable`) representation changed, mirroring the v71 frame table change: diff --git a/src/components/tooltip/Marker.tsx b/src/components/tooltip/Marker.tsx index a7728ca21a..d99fbe51c0 100644 --- a/src/components/tooltip/Marker.tsx +++ b/src/components/tooltip/Marker.tsx @@ -56,6 +56,7 @@ import type { MarkerSchemaByName, MarkerIndex, MarkerFormatType, + MarkerPayload, InnerWindowID, Page, Pid, @@ -294,7 +295,8 @@ class MarkerTooltipContents extends React.PureComponent { value, thread.stringTable, threadIdToNameMap, - processIdToNameMap + processIdToNameMap, + data )} ); @@ -587,7 +589,7 @@ const URL_REGEXP = /^(https?:\/\/)\S+$/; /** * This function may return structured markup for some types suchs as table, - * list, or urls. For other types this falls back to formatFromMarkerSchema + * list, urls, or images. For other types this falls back to formatFromMarkerSchema * above. */ export function renderMarkerFieldValue( @@ -596,7 +598,9 @@ export function renderMarkerFieldValue( value: any, stringTable: StringTable, threadIdToNameMap?: Map, - processIdToNameMap?: Map + processIdToNameMap?: Map, + // The payload the value comes from, for formats that refer to a sibling field. + payload?: MarkerPayload | null ): React.ReactElement | string { if (value === undefined || value === null) { console.warn(`Formatting ${value} for ${JSON.stringify(markerType)}`); @@ -653,7 +657,8 @@ export function renderMarkerFieldValue( cell, stringTable, threadIdToNameMap, - processIdToNameMap + processIdToNameMap, + payload )} ); @@ -665,6 +670,26 @@ export function renderMarkerFieldValue( ); } + case 'screenshot-data-url': { + const size = (payload as any)?.[format.sizeFieldForAspectRatio]; + return ( + + ); + } default: throw new Error( `Unknown format type ${JSON.stringify(format as never)}` diff --git a/src/profile-logic/marker-schema.ts b/src/profile-logic/marker-schema.ts index c7c82bee0f..0e745024eb 100644 --- a/src/profile-logic/marker-schema.ts +++ b/src/profile-logic/marker-schema.ts @@ -619,9 +619,12 @@ export function formatFromMarkerSchema( rows.push(...cellRows); return rows.map((row) => `(${row.join(', ')})`).join(','); } + case 'screenshot-data-url': + // Don't expand a base64-encoded image into text output. + return '(screenshot)'; default: throw new Error( - `Unknown format type ${JSON.stringify(format.type as never)}` + `Unknown format type ${JSON.stringify(format as never)}` ); } } @@ -674,6 +677,8 @@ export function formatFromMarkerSchema( formatFromMarkerSchema(markerType, 'string', v, stringTable) ) .join(', '); + case 'screenshot-size': + return `${value.width}px × ${value.height}px`; default: console.warn( `A marker schema of type "${markerType}" had an unknown format ${JSON.stringify( @@ -707,7 +712,16 @@ export function markerPayloadMatchesSearch( continue; } - if (isStringIndexFormat(payloadField.format)) { + const { format } = payloadField; + if (typeof format === 'object' && format.type === 'screenshot-data-url') { + // Searching a base64-encoded image isn't useful. + continue; + } + if (format === 'screenshot-size') { + value = formatFromMarkerSchema(data.type, format, value, stringTable); + } + + if (isStringIndexFormat(format)) { if (typeof value !== 'number') { console.warn( `In marker ${marker.name}, the key ${payloadField.key} has an invalid value "${value}" as a unique string, it isn't a number.` @@ -738,6 +752,9 @@ export function markerPayloadMatchesSearch( export function isStringIndexFormat( format: MarkerFormatType | undefined ): boolean { + if (typeof format === 'object') { + return format.type === 'screenshot-data-url'; + } return ( format === 'unique-string' || format === 'flow-id' || diff --git a/src/test/unit/__snapshots__/marker-schema.test.ts.snap b/src/test/unit/__snapshots__/marker-schema.test.ts.snap index 9f60bce48e..50216e9a4f 100644 --- a/src/test/unit/__snapshots__/marker-schema.test.ts.snap +++ b/src/test/unit/__snapshots__/marker-schema.test.ts.snap @@ -278,6 +278,33 @@ Array [ , "a, b", ], + Array [ + "screenshot-size", + Object { + "height": 1000, + "width": 1280, + }, + "1280px × 1000px", + "1280px × 1000px", + ], + Array [ + Object { + "sizeFieldForAspectRatio": "windowSize", + "type": "screenshot-data-url", + }, + 0, + , + "(screenshot)", + ], ] `; diff --git a/src/test/unit/marker-schema.test.ts b/src/test/unit/marker-schema.test.ts index a1c13831ab..b30b3de50d 100644 --- a/src/test/unit/marker-schema.test.ts +++ b/src/test/unit/marker-schema.test.ts @@ -7,6 +7,7 @@ import { parseLabel, extensionTextMarkerSchema, markerSchemaFrontEndOnly, + isStringIndexFormat, } from '../../profile-logic/marker-schema'; import { renderMarkerFieldValue } from 'firefox-profiler/components/tooltip/Marker'; import type { @@ -593,8 +594,17 @@ describe('marker schema formatting', function () { ], ['list', []], ['list', ['a', 'b']], + ['screenshot-size', { width: 1280, height: 1000 }], + // Without a payload there's no size field to look up, + // so the image falls back to a maximum size. + [ + { type: 'screenshot-data-url', sizeFieldForAspectRatio: 'windowSize' }, + 0, + ], ]; - const stringTable = StringTable.withBackingArray([]); + const stringTable = StringTable.withBackingArray([ + 'data:image/jpeg;base64,AAAA', + ]); expect( entries.map(([format, value]: [MarkerFormatType, any]): string[][] => [ format, @@ -606,6 +616,17 @@ describe('marker schema formatting', function () { }); }); +describe('computeStringIndexMarkerFieldsByDataType', function () { + it('treats the screenshot data URL as a string index', function () { + expect( + isStringIndexFormat({ + type: 'screenshot-data-url', + sizeFieldForAspectRatio: 'windowSize', + }) + ).toBe(true); + }); +}); + describe('getMarkerSchema', function () { it('combines front-end and Gecko marker schema', function () { const { profile } = getProfileFromTextSamples('A'); diff --git a/src/types/markers.ts b/src/types/markers.ts index 7ee06d246a..9f05946ea5 100644 --- a/src/types/markers.ts +++ b/src/types/markers.ts @@ -86,7 +86,14 @@ export type MarkerFormatType = | 'pid' | 'tid' | 'list' - | { type: 'table'; columns: TableColumnFormat[] }; + // The size of a window in pixels, as a { width, height } object. + // "Label: 1280px × 1000px" + | 'screenshot-size' + | { type: 'table'; columns: TableColumnFormat[] } + // An image data URL, stored as an index into the profile's string table. + // It is rendered at the aspect ratio of the 'screenshot-size' field named by + // `sizeFieldForAspectRatio`. + | { type: 'screenshot-data-url'; sizeFieldForAspectRatio: string }; type TableColumnFormat = { // type for formatting, default is string From d4be20a51b249e8ecf0f27a888261f657bdd5fe9 Mon Sep 17 00:00:00 2001 From: fatadel Date: Mon, 12 Oct 2026 13:50:00 +0200 Subject: [PATCH 03/24] Expose and clear URL-imported call tree search in the CLI This fixes missing search-filter information when loading profiles from URLs. The imported search is now shown in session and query output, and filter clear removes it instead of leaving it active. Fixes #6385 --- profiler-cli/README.md | 2 +- profiler-cli/schemas.txt | 6 +- profiler-cli/src/commands/filter.ts | 4 +- profiler-cli/src/formatters.ts | 28 +++- .../unit/context-header-formatting.test.ts | 52 ++++++ src/profile-query/index.ts | 32 +++- src/profile-query/types.ts | 3 + .../profile-query/profile-querier-url.test.ts | 155 ++++++++++++++++++ 8 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 src/test/unit/profile-query/profile-querier-url.test.ts diff --git a/profiler-cli/README.md b/profiler-cli/README.md index 81a278e7b2..cfd3a03958 100644 --- a/profiler-cli/README.md +++ b/profiler-cli/README.md @@ -57,7 +57,7 @@ profiler-cli zoom clear # Clear all zoom ranges (return to fu profiler-cli filter push # Push a sticky sample filter (see filter flags below) profiler-cli filter pop [N] # Pop the last N filters (default: 1) profiler-cli filter list # List active filters for current thread -profiler-cli filter clear # Remove all filters for current thread +profiler-cli filter clear # Remove current thread filters and session call tree search profiler-cli sourcemap sources # List bundle sources eligible for a source map (src-N handles) profiler-cli sourcemap apply # Apply a .map file to de-minify JS stacks [--to ] profiler-cli status # Show session status (selected thread, zoom ranges, filters) diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 8108f5e5ca..5e264e5877 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -11,7 +11,8 @@ SessionContext (present on all command results): resultThreads: [{ threadIndex, name, processName }], currentViewRange: { start, startName, end, endName } | null, rootRange: { start, end }, - callTreeSummaryStrategy: CallTreeSummaryStrategy + callTreeSummaryStrategy: CallTreeSummaryStrategy, + callTreeSearch? } PermalinkFields (merged into any command result when --permalink or @@ -331,7 +332,8 @@ profiler-cli status --json viewRanges: [{ start, startName, end, endName }], rootRange: { start, end }, filterStacks: [{ threadHandle, filters: FilterEntry[] }], - callTreeSummaryStrategy: CallTreeSummaryStrategy + callTreeSummaryStrategy: CallTreeSummaryStrategy, + callTreeSearch? } profiler-cli permalink --json diff --git a/profiler-cli/src/commands/filter.ts b/profiler-cli/src/commands/filter.ts index d5ca65d51e..98a37068ad 100644 --- a/profiler-cli/src/commands/filter.ts +++ b/profiler-cli/src/commands/filter.ts @@ -97,7 +97,9 @@ export function registerFilterCommand( addGlobalOptions( filter .command('clear') - .description('Remove all filters for current thread') + .description( + 'Remove current thread filters and the session call tree search' + ) .option('--thread ', 'Thread handle') ).action(async (opts) => { await runCommand( diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 56513aeeed..ebbfc7a3f5 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -203,7 +203,7 @@ function formatThreadList( /** * Format a SessionContext as a compact header line. - * Shows current thread selection, zoom range, and full profile duration. + * Shows current thread selection, zoom range, full profile duration, and search. */ export function formatContextHeader( context: SessionContext, @@ -247,7 +247,10 @@ export function formatContextHeader( (activeFilters?.length ?? 0) + (ephemeralFilters?.length ?? 0); const filterInfo = totalFilterCount > 0 ? ` | Filters: ${totalFilterCount}` : ''; - return `[Thread: ${threadInfo}${selectedInfo} | View: ${viewInfo} | Full: ${fullInfo}${filterInfo}]`; + const searchInfo = context.callTreeSearch + ? ` | Call tree search: "${context.callTreeSearch}"` + : ''; + return `[Thread: ${threadInfo}${selectedInfo} | View: ${viewInfo} | Full: ${fullInfo}${filterInfo}${searchInfo}]`; } /** @@ -271,6 +274,11 @@ export function formatStatusResult(result: StatusResult): string { } const filterLines: string[] = []; + if (result.callTreeSearch) { + filterLines.push( + ` Call tree search (all threads): "${result.callTreeSearch}"` + ); + } for (const stack of result.filterStacks) { if (stack.filters.length === 0) { continue; @@ -307,8 +315,13 @@ export function formatFilterStackResult(result: FilterStackResult): string { if (result.message) { lines.push(result.message); } + if (result.callTreeSearch) { + lines.push(`Call tree search (all threads): "${result.callTreeSearch}"`); + } if (result.filters.length === 0) { - lines.push(`No active filters for ${result.threadHandle}`); + if (!result.callTreeSearch) { + lines.push(`No active filters for ${result.threadHandle}`); + } } else { lines.push(`Filters for ${result.threadHandle} (applied in order):`); for (const f of result.filters) { @@ -1250,7 +1263,6 @@ function formatSamplesPreamble(result: { result.activeOnly && isTiming ? 'Note: active samples only (idle excluded) — use --include-idle to include idle samples.\n\n' : ''; - const searchNote = result.search ? `Search: "${result.search}"\n\n` : ''; const filtersParts: string[] = [ ...(result.activeFilters?.map((f) => `[${f.index}] ${f.description}`) ?? []), @@ -1258,7 +1270,7 @@ function formatSamplesPreamble(result: { ]; const filtersNote = filtersParts.length > 0 ? `Filters: ${filtersParts.join(', ')}\n\n` : ''; - return `${contextHeader}\n\nThread: ${result.friendlyThreadName}\n\n${dataSourceNote}${activeOnlyNote}${searchNote}${filtersNote}`; + return `${contextHeader}\n\nThread: ${result.friendlyThreadName}\n\n${dataSourceNote}${activeOnlyNote}${filtersNote}`; } /** @@ -1271,7 +1283,7 @@ export function formatThreadSamplesResult( if (result.search && result.topFunctionsByTotal.length === 0) { output += - `No samples matched --search "${result.search}".\n` + + `No samples matched call tree search "${result.search}".\n` + 'Tip: --search keeps samples with a matching frame anywhere in the stack.\n' + ' Use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + ' "|" is treated as a literal character, not OR.\n'; @@ -1383,7 +1395,7 @@ export function formatThreadSamplesTopDownResult( // Top-down call tree const topDownEmpty = result.search - ? `No samples matched --search "${result.search}".\n` + + ? `No samples matched call tree search "${result.search}".\n` + 'Tip: use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + ' "|" is treated as a literal character, not OR.' : undefined; @@ -1403,7 +1415,7 @@ export function formatThreadSamplesBottomUpResult( // Bottom-up call tree (inverted tree shows callers) if (result.invertedCallTree) { const bottomUpEmpty = result.search - ? `No samples matched --search "${result.search}".\n` + + ? `No samples matched call tree search "${result.search}".\n` + 'Tip: use comma to require multiple terms (all must appear), e.g. --search "foo,bar".\n' + ' "|" is treated as a literal character, not OR.' : undefined; diff --git a/profiler-cli/src/test/unit/context-header-formatting.test.ts b/profiler-cli/src/test/unit/context-header-formatting.test.ts index 1b8e00e22f..6323d8d9ff 100644 --- a/profiler-cli/src/test/unit/context-header-formatting.test.ts +++ b/profiler-cli/src/test/unit/context-header-formatting.test.ts @@ -12,6 +12,7 @@ import { formatContextHeader, + formatFilterStackResult, formatStatusResult, formatThreadSelectResult, } from '../../formatters'; @@ -100,6 +101,12 @@ describe('formatContextHeader', function () { '[Thread: No thread selected' ); }); + + it('shows the effective call tree search', function () { + expect( + formatContextHeader(createContext({ callTreeSearch: 'testGetValue' })) + ).toContain('Call tree search: "testGetValue"'); + }); }); describe('formatStatusResult', function () { @@ -119,6 +126,51 @@ describe('formatStatusResult', function () { 'Selected thread: t-94 (GeckoMain, WebExtensions)' ); }); + + it('shows session search even when there are no thread transforms', function () { + const result: StatusResult = { + type: 'status', + selectedThreadHandle: 't-94', + selectedThreads: createContext().selectedThreads, + viewRanges: [], + rootRange: { start: 0, end: 3000 }, + filterStacks: [], + callTreeSummaryStrategy: 'timing', + callTreeSearch: 'testGetValue', + }; + const output = formatStatusResult(result); + expect(output).toContain('Call tree search (all threads): "testGetValue"'); + expect(output).not.toContain('Filters: none'); + }); +}); + +describe('formatFilterStackResult', function () { + it('shows session search even when the thread has no transforms', function () { + const output = formatFilterStackResult({ + type: 'filter-stack', + threadHandle: 't-0', + filters: [], + callTreeSearch: 'testGetValue', + }); + expect(output).toBe('Call tree search (all threads): "testGetValue"'); + }); + + it('shows session search alongside numbered thread transforms', function () { + const output = formatFilterStackResult({ + type: 'filter-stack', + threadHandle: 't-0', + callTreeSearch: 'testGetValue', + filters: [ + { + index: 1, + transforms: [{ type: 'merge-function', funcIndex: 0 }], + description: 'merge: f-0', + }, + ], + }); + expect(output).toContain('Call tree search (all threads): "testGetValue"'); + expect(output).toContain('1. merge: f-0'); + }); }); describe('formatThreadSelectResult', function () { diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index e663dbebeb..84f492638f 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -947,6 +947,8 @@ export class ProfileQuerier { type: 'filter-stack', threadHandle: actualHandle, filters, + callTreeSearch: + getCurrentSearchString(this._store.getState()) || undefined, action: 'push', message: `Pushed filter ${pushed.index}: ${pushed.description}`, }; @@ -998,13 +1000,15 @@ export class ProfileQuerier { type: 'filter-stack', threadHandle: actualHandle, filters, + callTreeSearch: + getCurrentSearchString(this._store.getState()) || undefined, action: 'pop', message, }; } /** - * Clear all transforms from the thread's transform stack. + * Clear the thread's transforms and the session-wide call tree search. */ filterClear(threadHandle?: string): FilterStackResult { const threadIndexes = this._resolveThreadIndexes(threadHandle); @@ -1013,10 +1017,15 @@ export class ProfileQuerier { threadHandle ?? this._threadMap.handleForThreadIndexes(threadIndexes); const entryCount = this._syncPushGroups(threadsKey).length; + const search = getCurrentSearchString(this._store.getState()); + const filterCount = entryCount + (search ? 1 : 0); this._pushGroupSizes.set(threadsKey, []); if (entryCount > 0) { this._store.dispatch(popTransformsFromStackForThreads(threadsKey, 0)); } + if (search) { + this._store.dispatch(changeCallTreeSearchString('')); + } return { type: 'filter-stack', @@ -1024,14 +1033,14 @@ export class ProfileQuerier { filters: [], action: 'clear', message: - entryCount === 0 + filterCount === 0 ? 'No filters to clear' - : `Cleared ${entryCount} filter(s)`, + : `Cleared ${filterCount} filter(s)`, }; } /** - * List the thread's full Redux transform stack as filter entries. + * List the thread's transforms and the session-wide call tree search. */ filterList(threadHandle?: string): FilterStackResult { const threadIndexes = this._resolveThreadIndexes(threadHandle); @@ -1043,6 +1052,8 @@ export class ProfileQuerier { type: 'filter-stack', threadHandle: actualHandle, filters: this._collectFilterEntries(threadsKey), + callTreeSearch: + getCurrentSearchString(this._store.getState()) || undefined, }; } @@ -1071,7 +1082,11 @@ export class ProfileQuerier { const result = this._withEphemeralView( threadIndexes, { includeIdle, callTreeSearch: search, sampleFilters, strategy }, - collect + () => { + const result = collect(); + const context = this._getContext(strategy, threadIndexes); + return { ...result, search: context.callTreeSearch, context }; + } ); const activeFilters = this._collectFilterEntries( getThreadsKey(threadIndexes) @@ -1079,11 +1094,9 @@ export class ProfileQuerier { return { ...result, activeOnly, - search: search || undefined, activeFilters: activeFilters.length > 0 ? activeFilters : undefined, ephemeralFilters: sampleFilters && sampleFilters.length > 0 ? sampleFilters : undefined, - context: this._getContext(strategy, threadIndexes), }; } @@ -1272,8 +1285,7 @@ export class ProfileQuerier { * This is a lightweight version of getStatus() that includes only * the current view range (not the full stack). * - * Commands given a one-shot --strategy pass it as `effectiveStrategy`: the - * store has already been restored to the session value by then. + * Commands collecting context after restoring a one-shot --strategy pass it as `effectiveStrategy`. */ private _getContext( effectiveStrategy?: CallTreeSummaryStrategy, @@ -1306,6 +1318,7 @@ export class ProfileQuerier { rootRange, callTreeSummaryStrategy: effectiveStrategy ?? this._getEffectiveStrategy(state), + callTreeSearch: getCurrentSearchString(state) || undefined, }; } @@ -1363,6 +1376,7 @@ export class ProfileQuerier { rootRange, filterStacks, callTreeSummaryStrategy: this._getEffectiveStrategy(state), + callTreeSearch: getCurrentSearchString(state) || undefined, }; } diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 7714e37bac..03d23c66fd 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -120,6 +120,7 @@ export type FilterStackResult = { type: 'filter-stack'; threadHandle: string; filters: FilterEntry[]; + callTreeSearch?: string; action?: 'push' | 'pop' | 'clear'; message?: string; }; @@ -161,6 +162,7 @@ export type SessionContext = { end: number; }; callTreeSummaryStrategy: CallTreeSummaryStrategy; + callTreeSearch?: string; }; /** @@ -220,6 +222,7 @@ export type StatusResult = { filters: FilterEntry[]; }>; callTreeSummaryStrategy: CallTreeSummaryStrategy; + callTreeSearch?: string; }; // ===== Category Breakdown ===== diff --git a/src/test/unit/profile-query/profile-querier-url.test.ts b/src/test/unit/profile-query/profile-querier-url.test.ts new file mode 100644 index 0000000000..312ccac198 --- /dev/null +++ b/src/test/unit/profile-query/profile-querier-url.test.ts @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ProfileQuerier } from 'firefox-profiler/profile-query'; +import { INTERVAL } from 'firefox-profiler/app-logic/constants'; +import { serializeProfileToJsonString } from 'firefox-profiler/profile-logic/process-profile'; +import { + addRawMarkersToThread, + getProfileFromTextSamples, +} from '../../fixtures/profiles/processed-profile'; + +const PROFILE_URL = 'https://example.com/profile.json'; +const PROFILER_URL = `https://profiler.firefox.com/from-url/${encodeURIComponent(PROFILE_URL)}`; + +describe('ProfileQuerier URL view state', function () { + beforeEach(function () { + const samples = ` + 1000 1010 1020 + A A A + B B C + `; + const { profile } = getProfileFromTextSamples(samples, samples); + profile.meta.symbolicated = true; + addRawMarkersToThread(profile.threads[0], profile.shared, [ + { + name: 'Test marker', + startTime: 1010, + endTime: 1015, + phase: INTERVAL, + }, + ]); + window.fetchMock.get(PROFILE_URL, serializeProfileToJsonString(profile)); + }); + + async function loadWithSearch() { + return ProfileQuerier.load( + `${PROFILER_URL}/calltree/?search=B&range=0m21&thread=0&v=17` + ); + } + + it('reports imported search in status, filters, and sample queries', async function () { + const querier = await loadWithSearch(); + expect((await querier.getStatus()).callTreeSearch).toBe('B'); + expect(querier.filterList().callTreeSearch).toBe('B'); + const samples = await querier.threadSamples('t-0', true); + expect(samples.search).toBe('B'); + expect(samples.context.callTreeSearch).toBe('B'); + expect(samples.categoryBreakdown.totalSamples).toBe(2); + expect( + (await querier.threadFunctions('t-0', undefined, true)).context + .callTreeSearch + ).toBe('B'); + expect( + new URL((await querier.permalink()).url).searchParams.get('search') + ).toBe('B'); + }); + + it('clears imported search even when the transform stack is empty', async function () { + const querier = await loadWithSearch(); + expect(querier.filterList().filters).toEqual([]); + expect(querier.filterClear().message).toBe('Cleared 1 filter(s)'); + expect((await querier.getStatus()).callTreeSearch).toBeUndefined(); + expect(querier.filterList().callTreeSearch).toBeUndefined(); + const samples = await querier.threadSamples('t-0', true); + expect(samples.search).toBeUndefined(); + expect(samples.context.callTreeSearch).toBeUndefined(); + expect(samples.categoryBreakdown.totalSamples).toBe(3); + expect( + (await querier.threadSamplesTopDown('t-0', undefined, true)) + .regularCallTree.totalSamples + ).toBe(3); + expect( + (await querier.threadSamplesBottomUp('t-0', undefined, true)) + .invertedCallTree?.totalSamples + ).toBe(3); + expect( + (await querier.threadFunctions('t-0', undefined, true)).functions.map( + (f) => f.name + ) + ).toContain('C'); + expect( + new URL((await querier.permalink()).url).searchParams.has('search') + ).toBe(false); + }); + + it('temporarily overrides imported search without changing the session', async function () { + const querier = await loadWithSearch(); + const samples = await querier.threadSamples('t-0', true, 'C'); + expect(samples.categoryBreakdown.totalSamples).toBe(1); + expect(samples.search).toBe('C'); + expect(samples.context.callTreeSearch).toBe('C'); + expect((await querier.getStatus()).callTreeSearch).toBe('B'); + expect( + (await querier.threadSamples('t-0', true)).categoryBreakdown.totalSamples + ).toBe(2); + }); + + it('pops thread transforms while retaining the session search', async function () { + const querier = await loadWithSearch(); + const pushed = querier.filterPush({ type: 'merge', funcIndexes: [0] }); + expect(pushed.callTreeSearch).toBe('B'); + expect(pushed.filters).toHaveLength(1); + const popped = querier.filterPop(); + expect(popped.callTreeSearch).toBe('B'); + expect(popped.filters).toEqual([]); + expect( + (await querier.threadSamples('t-0', true)).categoryBreakdown.totalSamples + ).toBe(2); + }); + + it('clears global search and only the specified thread transforms', async function () { + const querier = await loadWithSearch(); + querier.filterPush({ type: 'merge', funcIndexes: [0] }, 't-0'); + querier.filterPush({ type: 'merge', funcIndexes: [0] }, 't-1'); + expect( + (await querier.threadSamples('t-1', true)).categoryBreakdown.totalSamples + ).toBe(2); + expect(querier.filterClear('t-1').message).toBe('Cleared 2 filter(s)'); + expect(querier.filterList('t-1').filters).toEqual([]); + expect(querier.filterList('t-0').filters).toHaveLength(1); + expect( + (await querier.threadSamples('t-0', true)).categoryBreakdown.totalSamples + ).toBe(3); + expect( + (await querier.threadSamples('t-1', true)).categoryBreakdown.totalSamples + ).toBe(3); + }); + + it('gives the same sample queries for raw URLs and marker-selection links', async function () { + const raw = await ProfileQuerier.load(PROFILE_URL); + const linked = await ProfileQuerier.load( + `${PROFILER_URL}/marker-table/?marker=0&thread=0&v=17` + ); + for (const querier of [raw, linked]) { + await querier.threadSelect('t-0'); + await querier.pushViewRange('5ms,21ms'); + } + const samples = await raw.threadSamples('t-0', true); + expect(samples.categoryBreakdown.totalSamples).toBe(2); + expect(samples.context.currentViewRange).toEqual( + expect.objectContaining({ start: 1005, end: 1021 }) + ); + expect(await linked.threadSamples('t-0', true)).toEqual(samples); + expect(await linked.threadSamplesTopDown('t-0', undefined, true)).toEqual( + await raw.threadSamplesTopDown('t-0', undefined, true) + ); + expect(await linked.threadSamplesBottomUp('t-0', undefined, true)).toEqual( + await raw.threadSamplesBottomUp('t-0', undefined, true) + ); + expect(await linked.threadFunctions('t-0', undefined, true)).toEqual( + await raw.threadFunctions('t-0', undefined, true) + ); + }); +}); From 0fbefa761e967508ca2ed1efe32e1f098f725975 Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:10:13 +0000 Subject: [PATCH 04/24] Update all Yarn dependencies (2026-10-14) --- package.json | 10 +++--- yarn.lock | 93 +++++++++++++++++++++++++++------------------------- 2 files changed, 54 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index d8429226c7..5e3924ccfa 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "devtools-reps": "^0.27.7", "escape-string-regexp": "^4.0.0", "gecko-profiler-demangle": "^0.4.0", - "idb": "^8.0.3", + "idb": "^8.0.4", "iongraph-web": "0.2.1", "json-slabs": "^0.4.0", "jszip": "^3.10.2", @@ -123,7 +123,7 @@ "@babel/plugin-transform-class-properties": "^8.0.1", "@babel/plugin-transform-private-methods": "^8.0.1", "@babel/plugin-transform-private-property-in-object": "^8.0.5", - "@babel/preset-env": "^8.0.6", + "@babel/preset-env": "^8.0.7", "@babel/preset-react": "^8.0.1", "@babel/preset-typescript": "^8.0.1", "@eslint/js": "^9.39.5", @@ -163,7 +163,7 @@ "eslint-plugin-testing-library": "^7.16.2", "fake-indexeddb": "^6.2.5", "fetch-mock": "^12.6.0", - "globals": "^17.12.0", + "globals": "^17.13.0", "husky": "^9.1.7", "jest": "^30.5.2", "jest-environment-jsdom": "^30.5.2", @@ -174,10 +174,10 @@ "open": "^11.0.4", "oxfmt": "^0.72.0", "patch-package": "^8.0.1", - "postcss": "^8.5.28", + "postcss": "^8.5.29", "postinstall-postinstall": "^2.1.0", "rimraf": "^6.1.3", - "stylelint": "^17.15.0", + "stylelint": "^17.16.0", "stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-standard": "^40.0.0", "typescript": "^6.0.3", diff --git a/yarn.lock b/yarn.lock index ff7d90d4d0..f91d45f1b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -158,16 +158,16 @@ lru-cache "^5.1.1" semver "^6.3.1" -"@babel/helper-compilation-targets@^8.0.0", "@babel/helper-compilation-targets@^8.0.5", "@babel/helper-compilation-targets@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.6.tgz#ade47f2107c2ce83f0e2afad92795c23642f7343" - integrity sha512-HTrtZ4+iA0+kzdbCgvdSk1U4pEl3BvpGnlUbualCGNbrVhhQvhgBzurTUmqpWyvyHbWYWaIfdx2mOlL9LJjsew== +"@babel/helper-compilation-targets@^8.0.0", "@babel/helper-compilation-targets@^8.0.5", "@babel/helper-compilation-targets@^8.0.6", "@babel/helper-compilation-targets@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.7.tgz#7ece9e53952af8f769e15ecabc8b899cb017f745" + integrity sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg== dependencies: "@babel/compat-data" "^8.0.5" "@babel/helper-validator-option" "^8.0.0" browserslist "^4.24.0" flru "1.0.2" - verkit "^0.3.2" + verkit "^0.5.0" "@babel/helper-create-class-features-plugin@^7.29.7": version "7.29.7" @@ -763,10 +763,10 @@ dependencies: "@babel/helper-plugin-utils" "^7.29.7" -"@babel/plugin-transform-block-scoping@^8.0.5": - version "8.0.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-8.0.5.tgz#7535ea81b772c994885d448a74a6a63540d9a765" - integrity sha512-czkN3g76Ns2wMe1C0IuceKUCqCe/yJ1by0qz4h4znq74K/Ks7K2Fx6Dplq9ecS65Z3rJHxT9a/kDSF2LbEov8w== +"@babel/plugin-transform-block-scoping@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-8.0.7.tgz#2996db2700f74f204cb8ece64ab790d6cd8166dd" + integrity sha512-JhQli/zQZPXyDO/dhA2OWjDSsKDRgd0jVeN80qsDp4twAmbP8otJNWKwqRZzbsGATNmbsVfVM0b4L9lR5h+8WA== dependencies: "@babel/helper-plugin-utils" "^8.0.1" @@ -968,10 +968,10 @@ "@babel/helper-plugin-utils" "^7.29.7" "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7" -"@babel/plugin-transform-for-of@^8.0.1": - version "8.0.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-8.0.1.tgz#f7acc2de37a169def39af87545eaef7861ca941d" - integrity sha512-rpeXtgELjpIBQH/+YmyFlD9timPEVCyqY+TNednzoeoTYvXSBEeUvYnYE+BK8rB8m6hHiNK7aL9QWKhGifEJCw== +"@babel/plugin-transform-for-of@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-8.0.7.tgz#0e9a955f8371354228780c335f2d4e2339e3f0c8" + integrity sha512-h+/9Kyy55fWlnLDmMIWwz21nWLFodIZYpvvCq7gW6Fb55via7sTYPymeTNKbh8N9XZjZ025ClVtg0kxIPnEjGA== dependencies: "@babel/helper-plugin-utils" "^8.0.1" "@babel/helper-skip-transparent-expression-wrappers" "^8.0.0" @@ -1602,13 +1602,13 @@ core-js-compat "^3.48.0" semver "^6.3.1" -"@babel/preset-env@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-8.0.6.tgz#be6ff38ff1e08b8525d072f6350eeb887054a63d" - integrity sha512-Ekl110OgRBQtibBTXrXyuxhy2p7QxpzWT45cFOZCBFKBan3o8vN54sQ26GtqyTyORSNWKx0GOmIdLTTu/Y+prA== +"@babel/preset-env@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-8.0.7.tgz#d2fac8364d59626250d0ec72612187f6ff25eeb1" + integrity sha512-c4/6ls3f+XPhxE3iT0/Wm7Z3abRS5zH5QmXBT6LPzpxgDKyhPsQk0+xNmChY0Rvh4yotKD87K2Api6f5KSaT8w== dependencies: "@babel/compat-data" "^8.0.5" - "@babel/helper-compilation-targets" "^8.0.6" + "@babel/helper-compilation-targets" "^8.0.7" "@babel/helper-plugin-utils" "^8.0.1" "@babel/helper-validator-option" "^8.0.0" "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^8.0.1" @@ -1621,7 +1621,7 @@ "@babel/plugin-transform-async-generator-functions" "^8.0.6" "@babel/plugin-transform-async-to-generator" "^8.0.1" "@babel/plugin-transform-block-scoped-functions" "^8.0.1" - "@babel/plugin-transform-block-scoping" "^8.0.5" + "@babel/plugin-transform-block-scoping" "^8.0.7" "@babel/plugin-transform-class-properties" "^8.0.1" "@babel/plugin-transform-class-static-block" "^8.0.5" "@babel/plugin-transform-classes" "^8.0.5" @@ -1634,7 +1634,7 @@ "@babel/plugin-transform-explicit-resource-management" "^8.0.5" "@babel/plugin-transform-exponentiation-operator" "^8.0.1" "@babel/plugin-transform-export-namespace-from" "^8.0.1" - "@babel/plugin-transform-for-of" "^8.0.1" + "@babel/plugin-transform-for-of" "^8.0.7" "@babel/plugin-transform-function-name" "^8.0.1" "@babel/plugin-transform-json-strings" "^8.0.1" "@babel/plugin-transform-literals" "^8.0.1" @@ -1671,7 +1671,7 @@ "@babel/preset-modules" "^0.2.0" babel-plugin-polyfill-corejs3 "^1.0.0" core-js-compat "^3.48.0" - verkit "^0.3.2" + verkit "^0.5.0" "@babel/preset-modules@0.1.6-no-external-plugins": version "0.1.6-no-external-plugins" @@ -6232,10 +6232,10 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^17.12.0: - version "17.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-17.12.0.tgz#910a368b8f093d3feb3f77a56dcefa05647d698e" - integrity sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA== +globals@^17.13.0: + version "17.13.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-17.13.0.tgz#ab2134b5ededbf2fbe2831dd0acc36f4f1ddeac5" + integrity sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg== globalthis@^1.0.4: version "1.0.4" @@ -6661,10 +6661,10 @@ idb@^7.0.1: resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== -idb@^8.0.3: - version "8.0.3" - resolved "https://registry.yarnpkg.com/idb/-/idb-8.0.3.tgz#c91e558f15a8d53f1d7f53a094d226fc3ad71fd9" - integrity sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg== +idb@^8.0.4: + version "8.0.4" + resolved "https://registry.yarnpkg.com/idb/-/idb-8.0.4.tgz#95405f3d62c81df23024c01cbb59d38494ecaa0b" + integrity sha512-jd0ITCrN+Zah4J7R/Z2jnR3W7A4BXDAjmo62MwWuSjxCiueUAf7X5rbodEdidWzeknwXHmLpdZkoiiCppNi30Q== ieee754@^1.1.13: version "1.2.1" @@ -9211,10 +9211,10 @@ namedtuplemap@^1.0.0: resolved "https://registry.yarnpkg.com/namedtuplemap/-/namedtuplemap-1.0.0.tgz#c231789c5b7b47b4b3a3e73317f03ddde1ebcb34" integrity sha1-wjF4nFt7R7Szo+czF/A93eHryzQ= -nanoid@^3.3.18: - version "3.3.19" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" - integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== +nanoid@^3.3.19: + version "3.3.20" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.20.tgz#dfd4444d85aa10111ef66ba85ed72e08ba0855d8" + integrity sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ== napi-postinstall@^0.3.4: version "0.3.4" @@ -9924,14 +9924,14 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.32, postcss@^8.5.26, postcss@^8.5.28: - version "8.5.28" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" - integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== +postcss@^8.4.32, postcss@^8.5.26, postcss@^8.5.29: + version "8.5.29" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.29.tgz#086a0ded2c0257c71efb8c68776b477774714b4a" + integrity sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ== dependencies: - nanoid "^3.3.18" + nanoid "^3.3.19" picocolors "^1.1.1" - source-map-js "^1.2.1" + source-map-js "^1.2.2" postinstall-postinstall@^2.1.0: version "2.1.0" @@ -11004,7 +11004,7 @@ smol-toml@^1.9.0: resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.9.0.tgz#f36b8dc7eb621c541c48f9fba6925dab95172817" integrity sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ== -source-map-js@^1.2.1: +source-map-js@^1.2.1, source-map-js@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab" integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw== @@ -11418,10 +11418,10 @@ stylelint-order@^6.0.2: postcss "^8.4.32" postcss-sorting "^8.0.2" -stylelint@^17.15.0: - version "17.15.0" - resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-17.15.0.tgz#724532b29f07f14e0a67f24c287661d86bdc4877" - integrity sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA== +stylelint@^17.16.0: + version "17.16.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-17.16.0.tgz#1d35ff4963122bcd55fa20573856689c3d43b366" + integrity sha512-ejlWnbJ13x1GU32+zZXa6sYQJsKKkwSdv2B4rAdO3OWdEmvhSJKcXi5pOphQYSzyaj1kXpuGJr7pcMBNfDIuPw== dependencies: "@csstools/css-calc" "^3.3.0" "@csstools/css-parser-algorithms" "^4.0.0" @@ -12258,6 +12258,11 @@ verkit@^0.3.2: resolved "https://registry.yarnpkg.com/verkit/-/verkit-0.3.2.tgz#0511ea63310551b62a20c010aa820350c5c350c6" integrity sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg== +verkit@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/verkit/-/verkit-0.5.0.tgz#8b7617dda9ad2edb7851c6a4ff5f3ca428482401" + integrity sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g== + vfile-find-up@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/vfile-find-up/-/vfile-find-up-6.0.0.tgz#51a9ae3abd41bd739bc10f91922e3e26aee12508" From 439a94014a0e40592d7252ea5fd0863119081301 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 14 Aug 2026 15:39:59 -0400 Subject: [PATCH 05/24] Accept more fields from symbolication. Traditionally, we were only using symbolication for C++ / Rust functions, not for JS functions - when profiling Firefox, JS functions already appear fully symbolicated in the profile JSON, they don't need to be symbolicated in the front-end. If we want to treat JS functions more like native functions, where we start with an address and then get all the information via symbolication, we need to make sure we cover the pieces of information that JS functions have but which we've never had for native functions: - frame column - function start line - function start column For example, the column is crucial if we eventually want to support source mapped JS for profiles from samply + jitdump. This commit lets us add those pieces of information via new fields in the symbolication response. All the new properties in the symbolication response are treated as optional; the current symbolication service and older samply versions do not have these fields. This also adds support for an `isScript` property, so that symbolication can create both JS and non-JS frames. In the past, we were relying on the information in the unsymbolicated frame, which means that samply needed to include accurate "isJS" information in the unsymbolicated profile. (It did that by string-matching the jitdump symbol name during profile recording.) --- .../mozilla-symbolication-api.ts | 59 +++++-- src/profile-logic/symbol-store.ts | 61 +++++--- src/profile-logic/symbolication.ts | 111 +++++++++----- src/test/fixtures/example-symbol-table.ts | 133 +++++++++++++--- src/test/store/symbolication.test.ts | 139 ++++++++++++++++- .../unit/mozilla-symbolication-api.test.ts | 144 ++++++++++++++++++ src/test/unit/symbolication.test.ts | 119 +++++++++++++++ src/types/profile.ts | 5 +- 8 files changed, 672 insertions(+), 99 deletions(-) create mode 100644 src/test/unit/mozilla-symbolication-api.test.ts create mode 100644 src/test/unit/symbolication.test.ts diff --git a/src/profile-logic/mozilla-symbolication-api.ts b/src/profile-logic/mozilla-symbolication-api.ts index e0aa9a1e57..de1df60155 100644 --- a/src/profile-logic/mozilla-symbolication-api.ts +++ b/src/profile-logic/mozilla-symbolication-api.ts @@ -37,16 +37,35 @@ export type QuerySymbolicationApiCallback = ( // says whether the symbol server had symbols for this library. const APIFoundModulesV5Schema = v.record(v.string(), v.nullable(v.boolean())); +// The properties which describe the position inside a function. These are +// present both on the frame for the outer function at the looked-up address and +// on the frames for the functions that were inlined into it. +const APIFramePositionInfoV5Schema = { + // The path of the file that contains the function this frame was in, optional. + // For JIT frames for script functions, this is the script URL. + file: v.optional(v.string()), + // The 1-based line number that contains the source code that generated the + // instructions at the address, optional. e.g. 543 + line: v.optional(v.number()), + // The 1-based column number inside `line`, optional. e.g. 12 + col: v.optional(v.number()), + // The 1-based line number at which this frame's function starts, optional. + // e.g. 538 + function_start_line: v.optional(v.number()), + // The 1-based column number (inside function_start_line) at which this + // frame's function starts, optional. e.g. 3 + function_start_col: v.optional(v.number()), + // Whether this frame's function is a function in a script, e.g. a JS function + // that was compiled to machine code by a JIT compiler. Optional; symbol + // servers which only deal with native code don't return this property. + is_script: v.optional(v.boolean()), +}; + // Information about functions that were inlined at this address. const APIInlineFrameInfoV5Schema = v.object({ // The name of the function this inline frame was in, if known. function: v.optional(v.string()), - // The path of the file that contains the function this inline frame was in, optional. - file: v.optional(v.string()), - // The line number that contains the source code for this inline frame that - // contributed to the instruction at the looked-up address, optional. - // e.g. 543 - line: v.optional(v.number()), + ...APIFramePositionInfoV5Schema, }); const APIFrameInfoV5Schema = v.object({ @@ -58,16 +77,19 @@ const APIFrameInfoV5Schema = v.object({ frame: v.number(), // The name of the function this frame was in, if symbols were found. function: v.optional(v.string()), + // The raw name of the symbol which contains the requested address, if it + // differs from `function`. `function` usually comes from the debug info, + // `symbol` usually comes from the symbol table. + // Jitdump example: function "doWork", symbol "Ion: doWork (app.js:42:10)" + // C++ example: function "someFunc(int)", symbol "someFunc(int).cold". + symbol: v.optional(v.string()), // The hex offset between the requested address and the start of the function, // e.g. "0x3c". function_offset: v.optional(v.string()), // An optional size, in bytes, of the machine code of the outer function that // this address belongs to, as a hex string, e.g. "0x270". function_size: v.optional(v.string()), - // The path of the file that contains the function this frame was in, optional. - file: v.optional(v.string()), - // The line number that contains the source code that generated the instructions at the address, optional. - line: v.optional(v.number()), + ...APIFramePositionInfoV5Schema, // Information about functions that were inlined at this address. // Ordered from inside to outside. inlines: v.optional(v.array(APIInlineFrameInfoV5Schema)), @@ -130,14 +152,18 @@ function getV5ResultForLibRequest( let inlines; if (info.inlines !== undefined) { const inlineCount = info.inlines.length; - inlines = info.inlines.map(({ function: name, file, line }, i) => { + inlines = info.inlines.map((inline, i) => { const depth = inlineCount - i; return { name: - name ?? + inline.function ?? ``, - file, - line, + file: inline.file, + line: inline.line, + column: inline.col, + functionStartLine: inline.function_start_line, + functionStartColumn: inline.function_start_col, + isScript: inline.is_script, }; }); } @@ -149,9 +175,14 @@ function getV5ResultForLibRequest( addressResult = { name, + symbolName: info.symbol, symbolAddress: address - functionOffset, file: info.file, line: info.line, + column: info.col, + functionStartLine: info.function_start_line, + functionStartColumn: info.function_start_col, + isScript: info.is_script, inlines, functionSize, }; diff --git a/src/profile-logic/symbol-store.ts b/src/profile-logic/symbol-store.ts index df39c76a30..b1ee9daab9 100644 --- a/src/profile-logic/symbol-store.ts +++ b/src/profile-logic/symbol-store.ts @@ -27,37 +27,58 @@ export type LibSymbolicationResponse = error: Error; }; -export type AddressResult = { - // The name of the outer function that this address belongs to. +// Information about the function that a frame was in, and about the position +// inside that function. This is used both for the outer function at a +// looked-up address (see AddressResult) and for the functions that were inlined +// into it. +// All fields other than `name` are optional because the information may not be +// known by the symbolication source, or because the symbolication method does +// not expose it. +export type AddressInlineFrame = { + // The name of the function that this frame was in. name: string; + // The path of the file that contains the source code of this frame's function. + file?: string; + // The 1-based line number that contains the source code which generated the + // instructions at the address. + line?: number; + // The 1-based column number inside `line` for the source code which generated + // the instructions at the address. + column?: number; + // The 1-based line number at which this frame's function starts. + functionStartLine?: number; + // The 1-based column number (inside functionStartLine) at which this frame's + // function starts. + functionStartColumn?: number; + // Whether this frame's function is a "script" function, e.g. JavaScript, + // as opposed to a native frame, e.g. C++ or a JS engine internal frame like + // a trampoline or a builtin. + // If this property is not set, we will keep the script-ness of the frame as + // it was before symbolication. + isScript?: boolean; +}; + +export type AddressResult = AddressInlineFrame & { // The address (relative to the library) where the function that // contains this address starts, i.e. the address of the function symbol. symbolAddress: number; - // The path of the file that contains the source code of the outer function that contains - // this address. - // Optional because the information may not be known by the symbolication source, or because - // the symbolication method does not expose it. - file?: string; - // The line number that contains the source code of the outer function that generated the - // instructions at the address, optional. - // Optional because the information may not be known by the symbolication source, or because - // the symbolication method does not expose it. - line?: number; + // The raw name of the symbol at symbolAddress, if it differs from `name`. + // `name` is the function name from the debug information, which can be a + // "nicer" name than the symbol name: For native code, the debug info can + // contain the source-level function name whereas the symbol name is the + // (demangled) linkage name, and for JIT frames the symbol name can contain + // the JIT tier and the script location, e.g. "Ion: doWork (app.js:42:10)", + // whereas `name` is just "doWork". + symbolName?: string; // An optional inline callstack, ordered from inside to outside. - // addressResult.name calls addressResult.inlines[inlines.length - 1].function, which - // calls addressResult.inlines[inlines.length - 2].function etc. + // addressResult.name calls addressResult.inlines[inlines.length - 1].name, which + // calls addressResult.inlines[inlines.length - 2].name etc. inlines?: Array; // An optional size, in bytes, of the machine code of the outer function that // this address belongs to. functionSize?: number; }; -export type AddressInlineFrame = { - name: string; - file?: string; - line?: number; -}; - export interface SymbolProvider { // Cheap, should be called first. requestSymbolsFromServer( diff --git a/src/profile-logic/symbolication.ts b/src/profile-logic/symbolication.ts index 8bc6ccf39a..91b7e075dd 100644 --- a/src/profile-logic/symbolication.ts +++ b/src/profile-logic/symbolication.ts @@ -22,6 +22,7 @@ import type { Profile, RawProfileSharedData, RawThread, + RawFuncTable, RawStackTable, SourceTable, IndexIntoFuncTable, @@ -38,6 +39,7 @@ import type { import { FrameFlag, FuncFlag } from 'firefox-profiler/types'; import type { AbstractSymbolStore, + AddressInlineFrame, AddressResult, LibSymbolicationRequest, } from './symbol-store'; @@ -243,6 +245,9 @@ export type FuncToFuncsMap = Map; // The mutable tables which the symbolication steps of one batch operate on. // These are created once per batch and mutated in place by each step. type SymbolicationTables = { + // Read fallback metadata from the original table because func slots in the + // builder can be recycled before their original frames are processed. + oldFuncTable: RawFuncTable; frameTable: RawFrameTableBuilder; funcTable: RawFuncTableBuilder; nativeSymbols: RawNativeSymbolTableBuilder; @@ -565,6 +570,7 @@ export function applySymbolicationSteps( } } const tables: SymbolicationTables = { + oldFuncTable: oldShared.funcTable, frameTable, funcTable, nativeSymbols, @@ -658,6 +664,7 @@ function _partiallyApplySymbolicationStep( > ): void { const { + oldFuncTable, frameTable, funcTable, nativeSymbols, @@ -785,7 +792,12 @@ function _partiallyApplySymbolicationStep( // and give the canonical symbol the right address and symbol. const availableNativeSymbolIterator = availableNativeSymbols.values(); for (const [symbolAddress, addressResult] of symbolAddressToInfoMap) { - const symbolStringIndex = stringTable.indexForString(addressResult.name); + // The native symbol gets the raw symbol name. This can be different from the + // name we use for the func, for example for JIT frames whose symbol name is + // "Ion: doWork (app.js:42:10)" but whose function name is just "doWork". + const symbolStringIndex = stringTable.indexForString( + addressResult.symbolName ?? addressResult.name + ); let symbolIndex = symbolAddressToCanonicalSymbolIndexMap.get(symbolAddress); if (symbolIndex === undefined) { // Repurpose a symbol from availableNativeSymbols as the canonical symbol for this @@ -839,15 +851,16 @@ function _partiallyApplySymbolicationStep( const address = frameTable.address[frameIndex]; let addressResult = resultsForLib.get(address); if (addressResult === undefined) { - const symbolName = nativeSymbols.name[nativeSymbolIndex]; let fileNameIndex = null; - if ((funcTable.flags[oldFunc] & FuncFlag.HasSource) !== 0) { - const sourceIndex = funcTable.source[oldFunc]; + if ((oldFuncTable.flags[oldFunc] & FuncFlag.HasSource) !== 0) { + const sourceIndex = oldFuncTable.source[oldFunc]; fileNameIndex = sources.filename[sourceIndex]; } + // No symbols were found for this address. Make a synthetic result that + // just leaves all pieces of information as they were. addressResult = { symbolAddress: nativeSymbols.address[nativeSymbolIndex], - name: stringTable.getString(symbolName), + name: stringTable.getString(oldFuncTable.name[oldFunc]), file: fileNameIndex !== null ? stringTable.getString(fileNameIndex) @@ -856,17 +869,25 @@ function _partiallyApplySymbolicationStep( (frameTable.flags[frameIndex] & FrameFlag.HasLine) !== 0 ? frameTable.line[frameIndex] : undefined, + column: + (frameTable.flags[frameIndex] & FrameFlag.HasColumn) !== 0 + ? frameTable.column[frameIndex] + : undefined, + functionStartLine: + (oldFuncTable.flags[oldFunc] & FuncFlag.HasLine) !== 0 + ? oldFuncTable.lineNumber[oldFunc] + : undefined, + functionStartColumn: + (oldFuncTable.flags[oldFunc] & FuncFlag.HasColumn) !== 0 + ? oldFuncTable.columnNumber[oldFunc] + : undefined, }; } // Make a combined list which contains both the outer function and the inlines. - const framesAtThisAddress = addressResult.inlines + const framesAtThisAddress: AddressInlineFrame[] = addressResult.inlines ? addressResult.inlines.slice() : []; - framesAtThisAddress.push({ - name: addressResult.name, - file: addressResult.file, - line: addressResult.line, - }); + framesAtThisAddress.push(addressResult); framesAtThisAddress.reverse(); // Now the frames are from outside to inside. const inlineExpansionFrames = []; @@ -882,39 +903,48 @@ function _partiallyApplySymbolicationStep( frameInfo.file !== undefined ? stringTable.indexForString(frameInfo.file) : null; - // Group frames into the same function if the have the same function name - // and the same file. - const funcKey = `${functionStringIndex}:${fileNameStringIndex ?? ''}`; + const isJS = + frameInfo.isScript ?? + (oldFuncTable.flags[oldFunc] & FuncFlag.IsJS) !== 0; + // If symbolication tells us whether this is a script function, don't + // carry over the RelevantForJS flag from the unsymbolicated func. + const relevantForJS = + frameInfo.isScript === undefined && + (oldFuncTable.flags[oldFunc] & FuncFlag.RelevantForJS) !== 0; + const funcStartLine = frameInfo.functionStartLine ?? null; + const funcStartColumn = frameInfo.functionStartColumn ?? null; + // Group frames into the same function if they have the same function name, + // the same file, and the same function start position. (The start position + // matters for script functions: A script can contain multiple different + // functions with the same name, e.g. multiple anonymous functions.) + const funcKey = `${functionStringIndex}:${fileNameStringIndex ?? ''}:${funcStartLine ?? ''}:${funcStartColumn ?? ''}:${isJS ? 'js' : ''}`; let funcIndex = funcKeyToFuncMap.get(funcKey); if (funcIndex === undefined) { funcIndex = availableFuncIter.next().value; - const preservedFlagsMask = FuncFlag.IsJS | FuncFlag.RelevantForJS; if (funcIndex === undefined) { - // Need a new func. + // Need a new func. All its fields are filled below. funcIndex = funcTable.length; - funcTable.flags[funcIndex] = - (funcTable.flags[oldFunc] & preservedFlagsMask) | - FuncFlag.HasResource; - funcTable.resource[funcIndex] = resourceIndex; - funcTable.source[funcIndex] = 0; - funcTable.lineNumber[funcIndex] = 0; - funcTable.columnNumber[funcIndex] = 0; - funcTable.originalLocation[funcIndex] = 0; - // The name field will be filled below. funcTable.length++; - } else { - // Reuse an existing func slot: preserve IsJS/RelevantForJS, set - // HasResource, and clear the other flag bits since we're overwriting - // the source/line/column/originalLocation columns below. - funcTable.flags[funcIndex] = - (funcTable.flags[funcIndex] & preservedFlagsMask) | - FuncFlag.HasResource; - funcTable.resource[funcIndex] = resourceIndex; - funcTable.lineNumber[funcIndex] = 0; - funcTable.columnNumber[funcIndex] = 0; - funcTable.originalLocation[funcIndex] = 0; } + let funcFlags: number = FuncFlag.HasResource; + if (isJS) { + funcFlags |= FuncFlag.IsJS; + } + if (relevantForJS) { + funcFlags |= FuncFlag.RelevantForJS; + } + if (funcStartLine !== null) { + funcFlags |= FuncFlag.HasLine; + } + if (funcStartColumn !== null) { + funcFlags |= FuncFlag.HasColumn; + } + funcTable.flags[funcIndex] = funcFlags; funcTable.name[funcIndex] = functionStringIndex; + funcTable.resource[funcIndex] = resourceIndex; + funcTable.lineNumber[funcIndex] = funcStartLine ?? 0; + funcTable.columnNumber[funcIndex] = funcStartColumn ?? 0; + funcTable.originalLocation[funcIndex] = 0; // Store filename in sources table if we have one if (fileNameStringIndex !== null) { // Find or create the native source entry for this filename. @@ -987,10 +1017,15 @@ function _partiallyApplySymbolicationStep( flags &= ~FrameFlag.HasLine; frameTable.line[expansionFrameIndex] = 0; } - flags &= ~FrameFlag.HasColumn; + if (frameInfo.column !== undefined) { + flags |= FrameFlag.HasColumn; + frameTable.column[expansionFrameIndex] = frameInfo.column; + } else { + flags &= ~FrameFlag.HasColumn; + frameTable.column[expansionFrameIndex] = 0; + } frameTable.flags[expansionFrameIndex] = flags; frameTable.func[expansionFrameIndex] = funcIndex; - frameTable.column[expansionFrameIndex] = 0; inlineExpansionFrames.push(expansionFrameIndex); } if (inlineExpansionFrames.length > 1) { diff --git a/src/test/fixtures/example-symbol-table.ts b/src/test/fixtures/example-symbol-table.ts index 40af714732..2cca946cf2 100644 --- a/src/test/fixtures/example-symbol-table.ts +++ b/src/test/fixtures/example-symbol-table.ts @@ -10,21 +10,34 @@ import type { export type LineRange = { startAddress: number; line: number; + column?: number; inlinedCall?: InlinedCall; }; -export type InlinedCall = { +// The information about a function which our simulated symbolication API +// exposes for the function containing a frame, both for the outer function of a +// symbol and for inlined calls. +export type ExampleFunctionInfo = { name: string; - file: string; + file?: string; + functionStartLine?: number; + functionStartColumn?: number; + isScript?: boolean; +}; + +export type InlinedCall = ExampleFunctionInfo & { lineRanges: LineRange[]; }; -export type ExampleSymbolTableSymbols = Array<{ - address: number; - name: string; - file?: string; - lineRanges?: LineRange[]; -}>; +export type ExampleSymbolTableSymbols = Array< + ExampleFunctionInfo & { + address: number; + // The raw name of this symbol, if it differs from the name of the function + // which the debug info gives for this symbol. + symbolName?: string; + lineRanges?: LineRange[]; + } +>; export type ExampleSymbolTable = { symbols: ExampleSymbolTableSymbols; @@ -61,27 +74,27 @@ function _getInlinesRecursive( return []; } - // There is an inlined call covering this address. Get the function name - // of the function that is called, and the filename for this function. - const { name, file, lineRanges } = inlinedCall; + // There is an inlined call covering this address. Get the information about + // the function that is called. + const { lineRanges, ...functionInfo } = inlinedCall; // The inlined call may have produced multiple instructions from different // lines of source code. Find the line for the looked up address, and // potentially more inlines. - const { line, inlines } = _getLineAndInlines(lineRanges, address); + const { line, column, inlines } = _getLineAndInlines(lineRanges, address); // We build the full inline stack recursively, from inside to outside. // `inlines` has the deeper inline calls. Add our "outside" call to it. - return (inlines ?? []).concat([{ name, file, line }]); + return (inlines ?? []).concat([{ ...functionInfo, line, column }]); } -// Look up `address` in the LineRange array, and return the line number -// and inline stack at that address. +// Look up `address` in the LineRange array, and return the line number, column +// number and inline stack at that address. // This function is part of the simulated symbolication API for testing. function _getLineAndInlines( lineRanges: LineRange[] | undefined, address: number -): { line?: number; inlines?: AddressInlineFrame[] } { +): { line?: number; column?: number; inlines?: AddressInlineFrame[] } { if (!lineRanges) { return {}; } @@ -93,7 +106,7 @@ function _getLineAndInlines( const lineRange = lineRanges[j]; if (address >= lineRange.startAddress) { // We found the line range which covers this address. - const { line, inlinedCall } = lineRange; + const { line, column, inlinedCall } = lineRange; // We now at which line the address was (at the current level of the inline stack). @@ -101,8 +114,8 @@ function _getLineAndInlines( // at this address. This recurses. const inlines = _getInlinesRecursive(inlinedCall, address); - // Return the line and the inlines. - return { line, inlines }; + // Return the line, column and the inlines. + return { line, column, inlines }; } } return {}; @@ -113,11 +126,25 @@ function _makeGetAddressResultFunction( ): (address: number) => AddressResult | null { return function getAddressResult(address: number) { for (let i = syms.length - 1; i >= 0; i--) { - const { address: symbolAddress, name, file, lineRanges } = syms[i]; + const { + address: symbolAddress, + lineRanges, + ...functionInfoAndSymbolName + } = syms[i]; if (address >= symbolAddress) { - // We found the right symbol. See if we can find a line number and inlines for this address. - const { line, inlines } = _getLineAndInlines(lineRanges, address); - return { name, symbolAddress, file, line, inlines }; + // We found the right symbol. See if we can find a line number, column + // number and inlines for this address. + const { line, column, inlines } = _getLineAndInlines( + lineRanges, + address + ); + return { + ...functionInfoAndSymbolName, + symbolAddress, + line, + column, + inlines, + }; } } return null; @@ -284,6 +311,11 @@ export const partialSymbolTableAsTuple = partialSymbolTable.asTuple; // A symbol table for a JIT dump file (e.g. jit-52344.dump), simulating JS // functions symbolicated from samply + jitdump. The outer function // "renderButton.js" has an inlined call to "useState.js" at address 0x000a. +// +// This symbol table only contains the information that older samply versions +// give us: function names, files and lines. Specifically, no column information +// and no isScript information. Without isScript, we keep the script-ness of +// the frame before symbolication. const jitDumpSyms = [ { address: 0, @@ -322,3 +354,58 @@ export const jitDumpSymbolTable: ExampleSymbolTable = { asTuple: _makeSymbolTableAsTuple(jitDumpSyms), getAddressResult: _makeGetAddressResultFunction(jitDumpSyms), }; + +// Like `jitDumpSyms`, but with more information, specifically with isScript +// and with functionStartLine/Column info. +const jitDumpWithScriptInfoSyms = [ + { + address: 0, + symbolName: 'Ion: renderButton (Button.tsx:40:16)', + name: 'renderButton', + file: 'Button.tsx', + functionStartLine: 40, + functionStartColumn: 16, + isScript: true, + lineRanges: [ + { + startAddress: 0x0, + line: 42, + column: 5, + }, + { + startAddress: 0x8, + line: 45, + column: 12, + inlinedCall: { + name: 'useState', + file: 'react.js', + functionStartLine: 98, + functionStartColumn: 21, + isScript: true, + lineRanges: [ + { + startAddress: 0x8, + line: 100, + column: 7, + }, + ], + }, + }, + ], + }, + { + address: 0x2000, + symbolName: 'Baseline: runJobs (scheduler.js:10:1)', + name: 'runJobs', + file: 'scheduler.js', + functionStartLine: 10, + functionStartColumn: 1, + isScript: true, + }, +]; + +export const jitDumpWithScriptInfoSymbolTable: ExampleSymbolTable = { + symbols: jitDumpWithScriptInfoSyms, + asTuple: _makeSymbolTableAsTuple(jitDumpWithScriptInfoSyms), + getAddressResult: _makeGetAddressResultFunction(jitDumpWithScriptInfoSyms), +}; diff --git a/src/test/store/symbolication.test.ts b/src/test/store/symbolication.test.ts index d48ebfb130..1c230bc968 100644 --- a/src/test/store/symbolication.test.ts +++ b/src/test/store/symbolication.test.ts @@ -8,10 +8,11 @@ import { completeSymbolTable, partialSymbolTable, jitDumpSymbolTable, + jitDumpWithScriptInfoSymbolTable, } from '../fixtures/example-symbol-table'; import type { ExampleSymbolTable } from '../fixtures/example-symbol-table'; import type { MarkerPayload } from 'firefox-profiler/types'; -import { ResourceType, FrameFlag } from 'firefox-profiler/types'; +import { ResourceType, FrameFlag, FuncFlag } from 'firefox-profiler/types'; import type { AddressResult, LibSymbolicationRequest, @@ -75,6 +76,7 @@ describe('doSymbolicateProfile', function () { const symbolTables: Partial> = { 'firefox.pdb': firefoxSymbolTable, 'jit-52344.dump': jitDumpSymbolTable, + 'jit-98765.dump': jitDumpWithScriptInfoSymbolTable, }; const symbolTable = symbolTables[lib.debugName]; @@ -508,7 +510,9 @@ describe('doSymbolicateProfile', function () { await doSymbolicateProfile(dispatch, profile, symbolStore); // Check that the `useState.js` node shows up in the JS-only call tree. - // This function is an inline frame from the jitdump symbol info. + // This function is an inline frame from the jitdump symbol info. This symbol + // table doesn't say anything about script functions, so the JS-ness of these + // funcs comes from what the profile said about these frames. dispatch(changeImplementationFilter('js')); expect(formatTree(getCallTree(getState()))).toEqual([ '- renderButton.js (total: 1, self: —)', @@ -517,6 +521,124 @@ describe('doSymbolicateProfile', function () { ]); }); + it('marks script functions from JIT frames as JS, including inline frames', async () => { + const { + store: { dispatch, getState }, + profile, + symbolStore, + switchSymbolProviderMode, + funcNamesToFuncIndexes, + } = init(_createUnsymbolicatedJitProfileWithoutJSFuncs()); + + switchSymbolProviderMode('from-server'); + + await doSymbolicateProfile(dispatch, profile, symbolStore); + + // Check that the `useState` node shows up in the JS-only call tree. + // This function is an inline frame from the jitdump symbol info. None of + // these funcs were JS funcs before symbolication; they are only JS funcs + // because the symbolication result said that they are script functions. + dispatch(changeImplementationFilter('js')); + expect(formatTree(getCallTree(getState()))).toEqual([ + '- renderButton (total: 1, self: —)', + ' - useState (total: 1, self: 1)', + '- runJobs (total: 1, self: 1)', + ]); + + const thread = getThread(getState()); + const { frameTable, funcTable, nativeSymbols, stringTable } = thread; + const sources = ProfileViewSelectors.getSourceTable(getState()); + const [renderButtonFunc, useStateFunc, runJobsFunc] = + funcNamesToFuncIndexes(['renderButton', 'useState', 'runJobs']); + + // All three funcs are script functions, with the script URL as their file + // and with the position at which the function starts in that script. + const getFuncInfo = (funcIndex: number) => ({ + isJS: (funcTable.flags[funcIndex] & FuncFlag.IsJS) !== 0, + relevantForJS: + (funcTable.flags[funcIndex] & FuncFlag.RelevantForJS) !== 0, + file: stringTable.getString( + sources.filename[ensureExists(funcTable.source[funcIndex])] + ), + line: + (funcTable.flags[funcIndex] & FuncFlag.HasLine) !== 0 + ? funcTable.lineNumber[funcIndex] + : null, + column: + (funcTable.flags[funcIndex] & FuncFlag.HasColumn) !== 0 + ? funcTable.columnNumber[funcIndex] + : null, + }); + expect(getFuncInfo(renderButtonFunc)).toEqual({ + isJS: true, + relevantForJS: false, + file: 'Button.tsx', + line: 40, + column: 16, + }); + expect(getFuncInfo(useStateFunc)).toEqual({ + isJS: true, + relevantForJS: false, + file: 'react.js', + line: 98, + column: 21, + }); + expect(getFuncInfo(runJobsFunc)).toEqual({ + isJS: true, + relevantForJS: false, + file: 'scheduler.js', + line: 10, + column: 1, + }); + + // The frames have the line and column at which the code was executing. + // Address 0xa expands into two frames: renderButton at Button.tsx:45:12, + // which has an inlined call to useState at react.js:100:7. + const framesAt0xa = frameTable.address.reduce( + (frames, address, frameIndex) => + address === 0xa ? [...frames, frameIndex] : frames, + [] + ); + expect( + framesAt0xa.map((frameIndex) => ({ + func: funcTable.name[frameTable.func[frameIndex]], + isInlined: (frameTable.flags[frameIndex] & FrameFlag.IsInlined) !== 0, + line: + (frameTable.flags[frameIndex] & FrameFlag.HasLine) !== 0 + ? frameTable.line[frameIndex] + : null, + column: + (frameTable.flags[frameIndex] & FrameFlag.HasColumn) !== 0 + ? frameTable.column[frameIndex] + : null, + })) + ).toEqual([ + { + func: funcTable.name[renderButtonFunc], + isInlined: false, + line: 45, + column: 12, + }, + { + func: funcTable.name[useStateFunc], + isInlined: true, + line: 100, + column: 7, + }, + ]); + + // The native symbols keep the raw jitdump symbol names, which include the + // JIT tier, even though the funcs have the bare function names. + expect( + Array.from({ length: nativeSymbols.length }, (_, symbolIndex) => + stringTable.getString(nativeSymbols.name[symbolIndex]) + ) + ).toEqual([ + 'Ion: renderButton (Button.tsx:40:16)', + 'Baseline: runJobs (scheduler.js:10:1)', + ]); + }); + it('can re-symbolicate a partially-symbolicated profile even if it needs to add funcs to the funcTable', async () => { const { store: { dispatch, getState }, @@ -669,8 +791,21 @@ function _createUnsymbolicatedProfile() { function _createUnsymbolicatedJitProfile() { // See jitDumpSyms (in example-symbol-table.ts) for the corresponding symbols. + // Func names ending in "js" are JS funcs in this test fixture, so these frames + // are known to be JS frames even before symbolication. const { profile } = getProfileFromTextSamples(` renderButton.js[lib:jit-52344.dump][address:a] runJobs.js[lib:jit-52344.dump][address:2000] `); return profile; } + +function _createUnsymbolicatedJitProfileWithoutJSFuncs() { + // See jitDumpWithScriptInfoSyms (in example-symbol-table.ts) for the + // corresponding symbols. Before symbolication, these frames are just addresses + // in a "library" (the jitdump file), and nothing tells us that they are JS + // frames - that's information which only symbolication gives us. + const { profile } = getProfileFromTextSamples(` + 0x000a[lib:jit-98765.dump][address:a] 0x2000[lib:jit-98765.dump][address:2000] + `); + return profile; +} diff --git a/src/test/unit/mozilla-symbolication-api.test.ts b/src/test/unit/mozilla-symbolication-api.test.ts new file mode 100644 index 0000000000..b5cbc75ceb --- /dev/null +++ b/src/test/unit/mozilla-symbolication-api.test.ts @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { requestSymbols } from '../../profile-logic/mozilla-symbolication-api'; +import type { + LibSymbolicationRequest, + LibSymbolicationResponse, +} from '../../profile-logic/symbol-store'; + +describe('requestSymbols', function () { + const lib = { debugName: 'jit-52344.dump', breakpadId: 'DEADBEEF0' }; + + function requestAddresses( + addresses: number[], + stacks: unknown[][] + ): Promise { + const requests: LibSymbolicationRequest[] = [ + { lib, addresses: new Set(addresses) }, + ]; + return requestSymbols('test symbol server', requests, async () => ({ + results: [ + { + found_modules: { 'jit-52344.dump/DEADBEEF0': true }, + stacks, + }, + ], + })); + } + + it('parses the properties which describe a JS function that was JIT-compiled', async () => { + // This is the shape of the response we get from samply for a frame in JIT + // code for which it found a jitdump file: The function name, script URL and + // function start position have been parsed out of the raw symbol name, and + // we're told that this is a script function. + const responses = await requestAddresses( + [0x1a], + [ + [ + { + module_offset: '0x1a', + module: 'jit-52344.dump', + frame: 0, + function: 'renderButton', + symbol: 'Ion: renderButton (Button.tsx:40:16)', + function_offset: '0x1a', + function_size: '0x120', + file: 'Button.tsx', + line: 45, + col: 12, + function_start_line: 40, + function_start_col: 16, + is_script: true, + inlines: [ + { + function: 'useState', + file: 'react.js', + line: 100, + col: 7, + function_start_line: 98, + function_start_col: 21, + is_script: true, + }, + ], + }, + ], + ] + ); + + expect(responses).toEqual([ + { + type: 'SUCCESS', + lib, + results: new Map([ + [ + 0x1a, + { + name: 'renderButton', + symbolName: 'Ion: renderButton (Button.tsx:40:16)', + symbolAddress: 0, + file: 'Button.tsx', + line: 45, + column: 12, + functionStartLine: 40, + functionStartColumn: 16, + isScript: true, + functionSize: 0x120, + inlines: [ + { + name: 'useState', + file: 'react.js', + line: 100, + column: 7, + functionStartLine: 98, + functionStartColumn: 21, + isScript: true, + }, + ], + }, + ], + ]), + }, + ]); + }); + + it('leaves the new properties undefined if the symbol server does not supply them', async () => { + // Symbol servers which only deal with native code, e.g. the Mozilla + // symbolication server, don't return any of the new properties. + const responses = await requestAddresses( + [0x20], + [ + [ + { + module_offset: '0x20', + module: 'jit-52344.dump', + frame: 0, + function: 'DoSomething', + function_offset: '0x8', + file: 'something.cpp', + line: 12, + }, + ], + ] + ); + + const [response] = responses; + if (response.type !== 'SUCCESS') { + throw new Error('Expected a successful response'); + } + expect(response.results.get(0x20)).toEqual({ + name: 'DoSomething', + symbolName: undefined, + symbolAddress: 0x18, + file: 'something.cpp', + line: 12, + column: undefined, + functionStartLine: undefined, + functionStartColumn: undefined, + isScript: undefined, + inlines: undefined, + functionSize: undefined, + }); + }); +}); diff --git a/src/test/unit/symbolication.test.ts b/src/test/unit/symbolication.test.ts new file mode 100644 index 0000000000..7e1c4fa851 --- /dev/null +++ b/src/test/unit/symbolication.test.ts @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { applySymbolicationSteps } from '../../profile-logic/symbolication'; +import { FuncFlag } from 'firefox-profiler/types'; + +describe('applySymbolicationSteps', () => { + it('uses original script flags when inline expansion recycles function slots', () => { + const { profile } = getProfileFromTextSamples(` + first[lib:jit.dump][address:a] second[lib:jit.dump][address:20] + `); + const { shared } = applySymbolicationSteps( + profile.threads, + profile.shared, + [ + { + libSymbolicationInfo: { + resourceIndex: 0, + libIndex: 0, + allFuncsForThisLib: new Set([0, 1]), + allNativeSymbolsForThisLib: new Set(), + allFramesForThisLib: [0, 1], + frameAddresses: [0xa, 0x20], + }, + resultsForLib: new Map([ + [ + 0xa, + { + name: 'first', + symbolAddress: 0, + isScript: true, + inlines: [ + { name: 'inner' }, + { name: 'inline', isScript: true }, + ], + }, + ], + [0x20, { name: 'second', symbolAddress: 0x20 }], + ]), + }, + ] + ); + + // The explicit script frames overwrite both original native func slots. + // Frames without isScript must still inherit their original native flags, + // including the innermost inline of the first frame. + for (const name of ['inner', 'second']) { + const func = shared.funcTable.name.indexOf( + shared.stringArray.indexOf(name) + ); + expect(func).not.toBe(-1); + expect( + shared.funcTable.flags[func] & (FuncFlag.IsJS | FuncFlag.RelevantForJS) + ).toBe(0); + } + }); + + it('keeps the function display name when re-symbolication omits an address', () => { + const { profile } = getProfileFromTextSamples(` + first[lib:jit.dump][address:a] + `); + const libSymbolicationInfo = { + resourceIndex: 0, + libIndex: 0, + allFuncsForThisLib: new Set([0]), + allNativeSymbolsForThisLib: new Set(), + allFramesForThisLib: [0], + frameAddresses: [0xa], + }; + const symbolName = 'Ion: first (app.js:1:1)'; + const first = applySymbolicationSteps(profile.threads, profile.shared, [ + { + libSymbolicationInfo, + resultsForLib: new Map([ + [ + 0xa, + { + name: 'first', + symbolName, + symbolAddress: 0, + isScript: true, + file: 'app.js', + functionStartLine: 1, + functionStartColumn: 1, + line: 3, + column: 4, + }, + ], + ]), + }, + ]); + const { shared } = applySymbolicationSteps(first.threads, first.shared, [ + { + libSymbolicationInfo: { + ...libSymbolicationInfo, + allNativeSymbolsForThisLib: new Set([0]), + }, + resultsForLib: new Map(), + }, + ]); + + const func = shared.frameTable.func[0]; + const symbol = shared.frameTable.nativeSymbol[0]; + expect(shared.stringArray[shared.funcTable.name[func]]).toBe('first'); + expect(shared.stringArray[shared.nativeSymbols.name[symbol]]).toBe( + symbolName + ); + expect(shared.funcTable.flags[func]).toBe(first.shared.funcTable.flags[0]); + expect(shared.funcTable.lineNumber[func]).toBe(1); + expect(shared.funcTable.columnNumber[func]).toBe(1); + expect(shared.frameTable.line[0]).toBe(3); + expect(shared.frameTable.column[0]).toBe(4); + expect( + shared.stringArray[shared.sources.filename[shared.funcTable.source[func]]] + ).toBe('app.js'); + }); +}); diff --git a/src/types/profile.ts b/src/types/profile.ts index 8337046d64..b6e2356603 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -444,8 +444,9 @@ export type RawFuncTable = { // Only meaningful when `HasResource` is set in `flags`. resource: Array | Int32Array; - // These are non-null for JS functions only. The line and column describe the - // location of the *start* of the JS function. As for the information about which + // These are set for JS functions, and for any other function whose start + // position was supplied by symbolication. The line and column describe the + // location of the *start* of the function. As for the information about // which lines / columns inside the function were actually hit during execution, // that information is stored in the frameTable, not in the funcTable. // Only meaningful when `HasSource` is set. From b1d746ef89b4a369ef2b60c73377e3660954be4e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 14 Oct 2026 19:22:11 -0400 Subject: [PATCH 06/24] Use inverted transforms in the tests for inverted call node paths. The tests for updating inverted call node paths applied non-inverted transforms: a non-inverted "focus-subtree" on [Z, Y], which doesn't exist in the non-inverted tree, and "merge-call-node", which isn't available for inverted trees. The expected paths didn't exist in the transformed tree, so the tests passed without covering a realistic case. Use an inverted "focus-subtree" and a "merge-function" instead, and check that the selected path refers to an existing call node. --- src/test/store/transforms.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/test/store/transforms.test.ts b/src/test/store/transforms.test.ts index e783f2f0a5..7d3601e318 100644 --- a/src/test/store/transforms.test.ts +++ b/src/test/store/transforms.test.ts @@ -2582,13 +2582,16 @@ describe('expanded and selected CallNodePaths on inverted trees', function () { type: 'focus-subtree', callNodePath: [Z, Y], implementation: 'combined', - inverted: false, + inverted: true, }) ); expect(selectedThreadSelectors.getSelectedCallNodePath(getState())).toEqual( [Y, X, B] ); + expect( + selectedThreadSelectors.getSelectedCallNodeIndex(getState()) + ).not.toBeNull(); assertSetContainsOnly( selectedThreadSelectors.getExpandedCallNodePaths(getState()), [ @@ -2599,22 +2602,24 @@ describe('expanded and selected CallNodePaths on inverted trees', function () { ); }); - it('can update call node references for merging a call node', function () { + it('can update call node references for merging a function', function () { const { dispatch, getState } = storeWithProfile(profile); // This opens expands the call nodes up to this point. dispatch(changeInvertCallstack(true)); dispatch(changeSelectedCallNode(threadIndex, selectedCallNodePath)); dispatch( addTransformToStack(threadIndex, { - type: 'merge-call-node', - callNodePath: [Z, Y], - implementation: 'combined', + type: 'merge-function', + funcIndex: Y, }) ); expect(selectedThreadSelectors.getSelectedCallNodePath(getState())).toEqual( [Z, X, B] ); + expect( + selectedThreadSelectors.getSelectedCallNodeIndex(getState()) + ).not.toBeNull(); assertSetContainsOnly( selectedThreadSelectors.getExpandedCallNodePaths(getState()), [ From 711a6f051dc1928be20f526cb3ddb20c6a550d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Thu, 15 Oct 2026 15:20:06 +0200 Subject: [PATCH 07/24] Add --js-sources-from-func-names to profiler-edit The JS funcs of samply profiles don't have a source. Their location is only in their name, like "onFilterChange (http://127.0.0.1:62764/assets/index.js:25:354)", so --map-source-paths had no sources to embed for them. The new --js-sources-from-func-names option moves the source, line and column out of the name of these JS funcs, like the processing of Gecko profiles does, so that their sources and source maps can be embedded. Unnamed functions are named "(root scope) ", like in Gecko profiles. The JS funcs that already have a source are left alone, and the option can be dropped once samply provides the source of JS funcs. --- src/node-tools/profiler-edit.ts | 15 ++++ src/profile-logic/embed-js-sources.ts | 108 ++++++++++++++++++++++++- src/test/unit/embed-js-sources.test.ts | 78 +++++++++++++++++- src/test/unit/profiler-edit.test.ts | 15 ++++ 4 files changed, 214 insertions(+), 2 deletions(-) diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 9ebf93d6b5..a525b77e99 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -46,6 +46,7 @@ import { remapCountersAndProfilerOverhead, } from 'firefox-profiler/profile-logic/merge-compare'; import { + attachSourcesFromFuncNames, embedJsSources, type SourceReader, } from 'firefox-profiler/profile-logic/embed-js-sources'; @@ -77,6 +78,9 @@ import { runSourceMapSymbolicationNode } from 'firefox-profiler/profile-query/so * * node node-tools-dist/profiler-edit.js -i speedometer.json.gz -o out.json.gz \ * --map-source-paths mapping.json + * + * node node-tools-dist/profiler-edit.js -i samply-profile.json -o out.json.gz \ + * --js-sources-from-func-names --map-source-paths mapping.json */ export type ProfileSource = @@ -101,6 +105,7 @@ export interface CliOptions { symbolicateWithServer?: string; symbolicateWasm: WasmSymbolicationCliSpec[]; mapSourcePaths?: string; + jsSourcesFromFuncNames?: boolean; insertLabelFrames?: string; onlyKeepThreadsWithMarkersMatching?: string; mergeNonOverlappingThreadsByName?: boolean; @@ -389,6 +394,11 @@ export async function run(options: CliOptions) { loadWasmSymbolicationSpecs(options.symbolicateWasm) ); + if (options.jsSourcesFromFuncNames) { + console.log('Finding the sources of JS functions in their names...'); + attachSourcesFromFuncNames(profile); + } + if (options.mapSourcePaths !== undefined) { console.log('Embedding sources from mapped paths...'); const mappings = parseSourcePathMappings( @@ -522,6 +532,10 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { '--map-source-paths ', 'JSON file mapping JS source URLs to local directories, to embed the sources and apply their source maps' ) + .option( + '--js-sources-from-func-names', + 'Give JS functions the source in their name, like "foo (url:line:column)", if no JS function has a source' + ) .option('--insert-label-frames ', 'TOML file with label definitions') .option( '--only-keep-threads-with-markers-matching ', @@ -586,6 +600,7 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { typeof opts.mapSourcePaths === 'string' && opts.mapSourcePaths !== '' ? opts.mapSourcePaths : undefined, + jsSourcesFromFuncNames: opts.jsSourcesFromFuncNames === true, insertLabelFrames: typeof opts.insertLabelFrames === 'string' && opts.insertLabelFrames !== '' diff --git a/src/profile-logic/embed-js-sources.ts b/src/profile-logic/embed-js-sources.ts index aa329484a3..078de6d3f4 100644 --- a/src/profile-logic/embed-js-sources.ts +++ b/src/profile-logic/embed-js-sources.ts @@ -14,7 +14,12 @@ import { StringTable } from 'firefox-profiler/utils/string-table'; import type { RawSourceMap } from 'source-map'; import type { SourceMapRunner } from 'firefox-profiler/profile-logic/source-maps/worker-types'; -import type { IndexIntoSourceTable, Profile } from 'firefox-profiler/types'; +import { FuncFlag } from 'firefox-profiler/types'; +import type { + IndexIntoSourceTable, + IndexIntoStringTable, + Profile, +} from 'firefox-profiler/types'; /** * Returns the text of the file at this URL, or null if it can't be read. @@ -101,6 +106,107 @@ function _cacheByKey( }; } +// Matches "name (url:line:column)", where the URL has a scheme like http:// +// or resource://. +const FUNC_NAME_WITH_LOCATION_REGEXP = + /^(.*) \(([a-z][a-z0-9+.-]*:\/\/.+?):([0-9]+):([0-9]+)\)$/i; + +/** + * Some profiles only have the location of a JS function in its name, like + * "foo (http://example.com/a.js:12:5)". This is the case for the JS funcs of + * samply profiles. Move the source, line and column out of the name of these + * JS funcs, like the processing of Gecko profiles does, so that their sources + * can be embedded too. The JS funcs that already have a source are left alone. + */ +export function attachSourcesFromFuncNames(profile: Profile): void { + const { funcTable, sources } = profile.shared; + const stringArray = profile.shared.stringArray.slice(); + const stringTable = StringTable.withBackingArray(stringArray); + const flags = funcTable.flags.slice(); + const name = funcTable.name.slice(); + const source = funcTable.source.slice(); + const lineNumber = funcTable.lineNumber.slice(); + const columnNumber = funcTable.columnNumber.slice(); + const newSources = { + length: sources.length, + id: sources.id.slice(), + filename: sources.filename.slice(), + startLine: sources.startLine.slice(), + startColumn: sources.startColumn.slice(), + sourceMapURL: sources.sourceMapURL.slice(), + content: sources.content.slice(), + }; + const sourceIndexByFilename = new Map< + IndexIntoStringTable, + IndexIntoSourceTable + >(); + + let attachedCount = 0; + for (let funcIndex = 0; funcIndex < funcTable.length; funcIndex++) { + if ( + (flags[funcIndex] & FuncFlag.IsJS) === 0 || + flags[funcIndex] & FuncFlag.HasSource + ) { + continue; + } + const match = FUNC_NAME_WITH_LOCATION_REGEXP.exec( + stringArray[funcTable.name[funcIndex]] + ); + if (match === null) { + continue; + } + const [, funcName, url, line, column] = match; + const filenameIndex = stringTable.indexForString(url); + let sourceIndex = sourceIndexByFilename.get(filenameIndex); + if (sourceIndex === undefined) { + sourceIndex = newSources.length++; + newSources.id.push(null); + newSources.filename.push(filenameIndex); + newSources.startLine.push(1); + newSources.startColumn.push(1); + newSources.sourceMapURL.push(null); + newSources.content.push(null); + sourceIndexByFilename.set(filenameIndex, sourceIndex); + } + // SpiderMonkey names the functions without a name "*". Name them like + // Gecko profiles do. + name[funcIndex] = stringTable.indexForString( + funcName === '*' ? `(root scope) ${url}` : funcName + ); + source[funcIndex] = sourceIndex; + flags[funcIndex] |= FuncFlag.HasSource; + if ((flags[funcIndex] & FuncFlag.HasLine) === 0) { + lineNumber[funcIndex] = Number(line); + flags[funcIndex] |= FuncFlag.HasLine; + } + if ((flags[funcIndex] & FuncFlag.HasColumn) === 0) { + columnNumber[funcIndex] = Number(column); + flags[funcIndex] |= FuncFlag.HasColumn; + } + attachedCount++; + } + + if (attachedCount === 0) { + return; + } + profile.shared = { + ...profile.shared, + funcTable: { + ...funcTable, + flags, + name, + source, + lineNumber, + columnNumber, + }, + sources: newSources, + stringArray, + }; + console.log( + `Found the sources of ${attachedCount} funcs in their names, in ${newSources.length - sources.length} new sources.` + ); +} + /** * Store the text of every JS source that `readSource` can find in the * profile's sources table, then apply the source maps of these sources with diff --git a/src/test/unit/embed-js-sources.test.ts b/src/test/unit/embed-js-sources.test.ts index 55d5a001f5..5353fcc333 100644 --- a/src/test/unit/embed-js-sources.test.ts +++ b/src/test/unit/embed-js-sources.test.ts @@ -3,7 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { SourceMapGenerator } from 'source-map'; -import { embedJsSources } from '../../profile-logic/embed-js-sources'; +import { + attachSourcesFromFuncNames, + embedJsSources, +} from '../../profile-logic/embed-js-sources'; import type { SourceReader } from '../../profile-logic/embed-js-sources'; import { runSourceMapSymbolicationNode } from '../../profile-query/source-map'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; @@ -162,4 +165,77 @@ describe('embedJsSources', function () { ); expect(profile.shared.sources.content[0]).toBe(null); }); + + it('finds the sources of JS funcs in their names', async function () { + const { profile } = getProfileFromTextSamples('Ajs'); + const { funcTable, stringArray } = profile.shared; + funcTable.flags[0] &= ~FuncFlag.HasSource; + stringArray[funcTable.name[0]] = `a (${BUNDLE_URL}:1:10)`; + attachSourcesFromFuncNames(profile); + await embedJsSources( + profile, + makeReader({ [BUNDLE_URL]: BUNDLE_SOURCE }), + runSourceMapSymbolicationNode + ); + const { sources } = profile.shared; + const newFuncTable = profile.shared.funcTable; + expect(newFuncTable.flags[0] & FuncFlag.HasSource).toBeTruthy(); + expect(profile.shared.stringArray[newFuncTable.name[0]]).toBe('a'); + expect(newFuncTable.lineNumber[0]).toBe(1); + expect(newFuncTable.columnNumber[0]).toBe(10); + const sourceIndex = newFuncTable.source[0]; + expect(profile.shared.stringArray[sources.filename[sourceIndex]]).toBe( + BUNDLE_URL + ); + expect(sources.content[sourceIndex]).toBe(BUNDLE_SOURCE); + }); + + it('uses one source for the funcs with the same URL', function () { + const { profile } = getProfileFromTextSamples(` + Ajs + Bjs + `); + const { funcTable, stringArray } = profile.shared; + funcTable.flags[0] &= ~FuncFlag.HasSource; + funcTable.flags[1] &= ~FuncFlag.HasSource; + stringArray[funcTable.name[0]] = `a (${BUNDLE_URL}:1:10)`; + stringArray[funcTable.name[1]] = `b (${BUNDLE_URL}:1:20)`; + attachSourcesFromFuncNames(profile); + const newFuncTable = profile.shared.funcTable; + expect(newFuncTable.flags[1] & FuncFlag.HasSource).toBeTruthy(); + expect(newFuncTable.source[1]).toBe(newFuncTable.source[0]); + }); + + it('names the functions without a name after their file', function () { + const { profile } = getProfileFromTextSamples('Ajs'); + const { funcTable, stringArray } = profile.shared; + funcTable.flags[0] &= ~FuncFlag.HasSource; + stringArray[funcTable.name[0]] = `* (${BUNDLE_URL}:1:1)`; + attachSourcesFromFuncNames(profile); + const newFuncTable = profile.shared.funcTable; + expect(profile.shared.stringArray[newFuncTable.name[0]]).toBe( + `(root scope) ${BUNDLE_URL}` + ); + }); + + it('leaves the JS funcs that already have a source alone', function () { + const { profile } = getProfileFromTextSamples(`Ajs[file:${BUNDLE_URL}]`); + const { funcTable, stringArray } = profile.shared; + const name = 'a (http://127.0.0.1:62763/other.js:1:10)'; + stringArray[funcTable.name[0]] = name; + const sourceIndex = funcTable.source[0]; + attachSourcesFromFuncNames(profile); + const newFuncTable = profile.shared.funcTable; + expect(profile.shared.stringArray[newFuncTable.name[0]]).toBe(name); + expect(newFuncTable.source[0]).toBe(sourceIndex); + }); + + it('ignores the names of non-JS funcs', function () { + const { profile } = getProfileFromTextSamples('A'); + const { funcTable, stringArray } = profile.shared; + stringArray[funcTable.name[0]] = `Interpreter: a (${BUNDLE_URL}:1:10)`; + attachSourcesFromFuncNames(profile); + expect(profile.shared.funcTable.flags[0] & FuncFlag.HasSource).toBe(0); + expect(profile.shared.sources.length).toBe(0); + }); }); diff --git a/src/test/unit/profiler-edit.test.ts b/src/test/unit/profiler-edit.test.ts index e4acb4f432..96226af9c3 100644 --- a/src/test/unit/profiler-edit.test.ts +++ b/src/test/unit/profiler-edit.test.ts @@ -136,6 +136,21 @@ describe('makeOptionsFromArgv', function () { expect(options.mapSourcePaths).toEqual('/path/to/mapping.json'); }); + it('recognizes optional --js-sources-from-func-names', function () { + const args = [ + ...commonArgs, + '-i', + '/path/to/profile.json', + '-o', + '/path/to/output.json', + ]; + expect(makeOptionsFromArgv(args).jsSourcesFromFuncNames).toBe(false); + expect( + makeOptionsFromArgv([...args, '--js-sources-from-func-names']) + .jsSourcesFromFuncNames + ).toBe(true); + }); + it('recognizes optional --insert-label-frames', function () { const options = makeOptionsFromArgv([ ...commonArgs, From fa1d23a9b411587bcf2af94c457932c6b4f53d61 Mon Sep 17 00:00:00 2001 From: Pontoon Date: Fri, 16 Oct 2026 15:40:27 +0000 Subject: [PATCH 08/24] Pontoon/Firefox Profiler: Update Ukrainian (uk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lobodzets (uk) Co-authored-by: Іhor Hordiichuk (uk) Co-authored-by: uya2521 (uk) --- locales/uk/app.ftl | 238 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 219 insertions(+), 19 deletions(-) diff --git a/locales/uk/app.ftl b/locales/uk/app.ftl index a1fae62036..8c037d133c 100644 --- a/locales/uk/app.ftl +++ b/locales/uk/app.ftl @@ -21,6 +21,16 @@ AppHeader--app-header =
{ -profiler-brand-name }
— Захопити, щоб завантажити дані на profiler.firefox.com. +Home--instructions-content2 = + Для запису профілів швидкодії потрібен { -firefox-brand-name } для комп'ютера. + Однак, наявні профілі можна переглядати в будь-якому сучасному браузері. +Home--fenix-instructions-directly = + { -firefox-android-brand-name } можна профілювати безпосередньо на цьому пристрої. + Докладніше у статті Профілювання { -firefox-android-brand-name } безпосередньо на пристрої. +Home--fenix-instructions-remotely = + Ви також можете створити профіль { -firefox-android-brand-name } віддалено з + { -firefox-brand-name } для комп’ютера. Докладніше про це у документації: + Віддалене профілювання { -firefox-android-brand-name }. Home--record-instructions-start-stop = Зупинити й почати запис профілю Home--record-instructions-capture-load = Захопити й завантажити профіль Home--profiler-motto = Отримайте профіль швидкодії. Проаналізуйте його. Поділіться ним. Зробіть Інтернет швидшим. @@ -314,6 +371,7 @@ Home--additional-content-title = Завантажити наявні профі Home--additional-content-content = Ви можете перетягнути файл профілю сюди, щоб завантажити його, або: Home--compare-recordings-info = Ви також можете порівняти записи. Відкрити інтерфейс порівняння. Home--your-recent-uploaded-recordings-title = Ваші недавно вивантажені записи +Home--dark-mode-title = Темний режим # We replace the elements such as and with links to the # documentation to use these tools. Home--load-files-from-other-tools2 = @@ -332,6 +390,15 @@ Home--chrome-extension-recording-instructions = або ярлики для запуску та зупинки профілювання. Ви також можете експортувати профілі та завантажити їх тут для детального аналізу. +## IdleSearchField +## The component that is used for all the search inputs in the application. + +# `/` here overrides Firefox's Type Ahead Find shortcut, which would +# otherwise trigger an unhelpful find bar on top of the profiler UI. +# The shortcut itself is not localizable. +IdleSearchField--search-input2 = + .placeholder = Введіть умови фільтра (/) + ## JsTracerSettings ## JSTracer is an experimental feature and it's currently disabled. See Bug 1565788. @@ -398,7 +465,14 @@ MarkerContextMenu--select-the-sender-thread = Виберіть потік-від # This string is used on the marker filters menu item when clicked on the filter icon. # Variables: # $filter (String) - Search string that will be used to filter the markers. -MarkerFiltersContextMenu--drop-samples-outside-of-markers-matching = Перетягніть зразки за межі маркерів, що відповідають “{ $filter }” +MarkerFiltersContextMenu--drop-samples-outside-of-markers-matching = Перетягніть вибірки за межі маркерів, що відповідають “{ $filter }” + +## MarkerCopyTableContextMenu +## This is the menu when the copy icon is clicked in Marker Chart and Marker +## Table panels. + +MarkerCopyTableContextMenu--copy-table-as-plain = Копіювати таблицю маркерів як звичайний текст +MarkerCopyTableContextMenu--copy-table-as-markdown = Копіювати таблицю маркерів як Markdown ## MarkerSettings ## This is used in all panels related to markers. @@ -408,6 +482,14 @@ MarkerSettings--panel-search = .title = Показувати лише маркери, що відповідають певній назві MarkerSettings--marker-filters = .title = Фільтр маркерів +MarkerSettings--copy-table = + .title = Копіювати таблицю як текст +# This string is used when the user tries to copy a marker table with +# more than 10000 rows. +# Variable: +# $rows (Number) - Number of rows the marker table has +# $maxRows (Number) - Number of maximum rows that can be copied +MarkerSettings--copy-table-exceeed-max-rows = Кількість рядків перевищує ліміт: { $rows } > { $maxRows }. Скопіюються лише перші рядки { $maxRows }. ## MarkerSidebar ## This is the sidebar component that is used in Marker Table panel. @@ -422,6 +504,16 @@ MarkerTable--duration = Тривалість MarkerTable--name = Назва MarkerTable--details = Подробиці +## MarkerTooltip +## This is the component for Marker Tooltip panel. + +# This is used as the tooltip for the filter button in marker tooltips. +# Variables: +# $filter (String) - Search string that will be used to filter the markers. +MarkerTooltip--filter-button-tooltip = + .aria-label = Показувати лише маркери, що відповідають: “{ $filter }” + .title = Показувати лише маркери, що відповідають: “{ $filter }” + ## MenuButtons ## These strings are used for the buttons at the top of the profile viewer. @@ -429,6 +521,12 @@ MenuButtons--index--metaInfo-button = .label = Дані профілю MenuButtons--index--full-view = Повний огляд MenuButtons--index--cancel-upload = Скасувати вивантаження +MenuButtons--index--save = + .label = Зберегти… +MenuButtons--index--share = + .label = Поділитися… +MenuButtons--index--reshare = + .label = Поділитися повторно… MenuButtons--index--share-error-uploading = .label = Помилка вивантаження MenuButtons--index--revert = Повернутися до початкового профілю @@ -449,6 +547,28 @@ MenuButtons--metaInfo--resymbolicate-profile = Повторно символіз MenuButtons--metaInfo--symbolicate-profile = Символізувати профіль MenuButtons--metaInfo--attempting-resymbolicate = Спроба повторно символізувати профіль MenuButtons--metaInfo--currently-symbolicating = Наразі профіль символізується +MenuButtons--metaInfo--source-maps = Карти джерел: +# The trailing ellipsis indicates that clicking the button opens a file picker. +MenuButtons--metaInfo--apply-source-map = Застосувати карту джерел… + .title = Завантажте файл .map з диска для символізації мініфікованого пакета JavaScript, щоб відновити оригінальні назви функцій та розташування джерел. +# Shown when the uploaded map could match more than one source and the user has +# to choose which one it applies to. +MenuButtons--metaInfo--source-map-choose-bundle = Оберіть, до якого пакету застосовується ця карта джерел: +# Button to confirm the chosen source and apply the source map to it. +MenuButtons--metaInfo--source-map-apply = Застосувати +# Button to dismiss the source chooser without symbolicating. +MenuButtons--metaInfo--source-map-cancel = Скасувати +# Shown after symbolication finished and original sources were resolved. +# Variable: +# $filename (String) - The bundle source the source map was applied to. +MenuButtons--metaInfo--source-map-success = Оригінальне джерело { $filename } зіставлено. +# Shown after symbolication finished but no stack positions matched the map. +# Variable: +# $filename (String) - The bundle source the source map was applied to. +MenuButtons--metaInfo--source-map-no-match = Жодна позиція стеку в { $filename } не відповідає цій карті джерела. +MenuButtons--metaInfo--source-map-error-invalid = Вибраний файл не є дійсною картою джерела. +MenuButtons--metaInfo--source-map-error-no-eligible = Цей профіль не має JS-пакетів з URL-адресами карти джерела. +MenuButtons--metaInfo--source-map-error-failed = Не вдалося застосувати цю карту джерел до профілю. MenuButtons--metaInfo--cpu-model = Модель ЦП: MenuButtons--metaInfo--cpu-cores = Ядра ЦП: MenuButtons--metaInfo--main-memory = Основна пам'ять: @@ -503,6 +623,8 @@ MenuButtons--metaInfo--profiling-started = Запис розпочато: MenuButtons--metaInfo--profiling-session = Тривалість запису: MenuButtons--metaInfo--main-process-started = Основний процес розпочато: MenuButtons--metaInfo--main-process-ended = Основний процес завершено: +MenuButtons--metaInfo--file-name = Назва файлу: +MenuButtons--metaInfo--file-size = Розмір файлу: MenuButtons--metaInfo--interval = Інтервал: MenuButtons--metaInfo--buffer-capacity = Обсяг буфера: MenuButtons--metaInfo--buffer-duration = Тривалість буфера: @@ -519,6 +641,8 @@ MenuButtons--metaInfo--buffer-duration-seconds = MenuButtons--metaInfo--buffer-duration-unlimited = Необмежено MenuButtons--metaInfo--application = Застосунок MenuButtons--metaInfo--name-and-version = Назва та версія: +# The time between application startup and when the profiler was started +MenuButtons--metaInfo--application-uptime2 = Час роботи: MenuButtons--metaInfo--update-channel = Канал оновлень: MenuButtons--metaInfo--build-id = ID збірки: MenuButtons--metaInfo--build-type = Тип збірки: @@ -555,13 +679,13 @@ MenuButtons--metaOverheadStatistics-mean = Середнє MenuButtons--metaOverheadStatistics-max = Макс MenuButtons--metaOverheadStatistics-min = Мін MenuButtons--metaOverheadStatistics-statkeys-overhead = Спожиті для роботи ресурси - .title = Час отримання всіх потоків. + .title = Час створення вибірки всіх потоків. MenuButtons--metaOverheadStatistics-statkeys-cleaning = Очищення .title = Час викинути застарілі дані. MenuButtons--metaOverheadStatistics-statkeys-counter = Лічильник .title = Час збору всіх лічильників MenuButtons--metaOverheadStatistics-statkeys-interval = Інтервал: - .title = Зафіксований інтервал між двома зразками. + .title = Зафіксований інтервал між двома вибірками. MenuButtons--metaOverheadStatistics-statkeys-lockings = Блокування .title = Час до блокування перед виконанням вимірювань. MenuButtons--metaOverheadStatistics-overhead-duration = Тривалість споживання ресурсів на роботу: @@ -581,8 +705,15 @@ MenuButtons--publish--renderCheckbox-label-preference = Включити зна MenuButtons--publish--renderCheckbox-label-private-browsing = Включити дані з вікон приватного перегляду MenuButtons--publish--renderCheckbox-label-private-browsing-warning-image = .title = Цей профіль містить дані приватного перегляду +MenuButtons--publish--renderCheckbox-label-argument-values = Включити значення аргументів функції трасування виконання JavaScript +MenuButtons--publish--renderCheckbox-label-argument-values-warning-image = + .title = Цей профіль містить значення аргументів функцій, записані зі сторінки, які можуть містити персональні дані MenuButtons--publish--share-performance-profile = Поділитися профілем швидкодії +MenuButtons--publish--reshare-performance-profile = Повторно поділитися профілем швидкодії +MenuButtons--publish--save-performance-profile = Зберегти профіль швидкодії +MenuButtons--publish--local-profile-description = Цей профіль не вивантажено та буде втрачено після закриття цієї вкладки. Щоб залишити його, збережіть його на своєму комп’ютері або завантажте за допомогою функції Поділитися. MenuButtons--publish--info-description = Вивантажте свій профіль і зробіть його доступним для всіх, хто має посилання. +MenuButtons--publish--download-info-description = Зберегти цей профіль як файл на своєму комп'ютері. MenuButtons--publish--info-description-default = Типово ваші особисті дані вилучаються. MenuButtons--publish--info-description-firefox-nightly2 = Цей профіль з { -firefox-nightly-brand-name }, тому типово включено більшість інформації. MenuButtons--publish--include-additional-data = Включити додаткові дані, які можуть розкрити вашу ідентичність @@ -591,6 +722,7 @@ MenuButtons--publish--upload-title = Вивантаження профілю… MenuButtons--publish--cancel-upload = Скасувати вивантаження MenuButtons--publish--message-something-went-wrong = Йой, під час вивантаження профілю сталася якась халепа. MenuButtons--publish--message-try-again = Повторити спробу +MenuButtons--publish--save = Зберегти MenuButtons--publish--compressing = Стиснення… MenuButtons--publish--error-while-compressing = Помилка під час стиснення, спробуйте прибрати прапорці біля деяких полів, щоб зменшити розмір профілю. @@ -723,7 +855,7 @@ StackSettings--implementation-native2 = Вбудовані StackSettings--stack-implementation-label = Фільтр стеків: StackSettings--use-data-source-label = Джерело даних: StackSettings--call-tree-strategy-timing = Таймінги - .title = Створити підсумок окремих стеків виконаного впродовж певного часу коду + .title = Підсумовувати, використовуючи вибіркові стеки виконаного коду з плином часу StackSettings--call-tree-strategy-js-allocations = Розподіл ресурсів JavaScript .title = Підсумовувати розподілені байти JavaScript (без вивільнених) StackSettings--call-tree-strategy-native-retained-allocations = Утримана пам'ять @@ -736,6 +868,8 @@ StackSettings--call-tree-strategy-native-deallocations-sites = Вивільне .title = Підсумовувати байти вивільненої сайтом пам'яті, на якому її було вивільнено StackSettings--invert-call-stack = Інвертувати стек викликів .title = Сортувати за часом, витраченим у вузлі виклику, ігноруючи його дочірні вузли. +StackSettings--include-idle-samples = Включити вибірки часу неактивності + .title = Приберіть позначку, щоб сховати вибірки, кінцевий фрейм яких перебуває в категорії Простій. StackSettings--show-user-timing = Показати таймінги користувача StackSettings--use-stack-chart-same-widths = Використовувати однакову ширину для кожного стека StackSettings--panel-search = @@ -797,6 +931,32 @@ TrackNameButton--hide-track = TrackNameButton--hide-process = .title = Сховати процес +## TrackMemoryGraph +## This is used to show the memory graph of that process in the timeline part of +## the UI. To learn more about it, visit: +## https://profiler.firefox.com/docs/#/./memory-allocations?id=memory-track + +# Variables: +# $value (String) - the relative memory at this time (e.g. "5MB") +TrackMemoryGraph--relative-memory-at-this-time2 = { $value } + .label = відносна пам'ять на цю мить +# Variables: +# $value (String) - the memory range across the graph (e.g. "5MB") +TrackMemoryGraph--memory-range-in-graph2 = { $value } + .label = діапазон пам'яті в графіку +# Variables: +# $value (String) - count of allocations and deallocations since the previous sample +TrackMemoryGraph--allocations-and-deallocations-since-the-previous-sample2 = { $value } + .label = розподіл і вивільнення після попередньої вибірки + +## TrackProcessCPUGraph +## This is used to show the CPU usage of a process over time in the timeline. + +# Variables: +# $value (String) - the CPU usage at this sample (e.g. "50%") +TrackProcessCPUGraph--cpu = { $value } + .label = ЦП + ## TrackPower ## This is used to show the power used by the CPU and other chips in a computer, ## graphed over time. @@ -822,6 +982,11 @@ TrackPower--tooltip-power-watt = { $value } Вт # $value (String) - the power value at this location TrackPower--tooltip-power-milliwatt = { $value } мВт .label = Потужність +# This is used in the tooltip when the instant power value uses the microwatt unit. +# Variables: +# $value (String) - the power value at this location +TrackPower--tooltip-power-microwatt = { $value } мкВт + .label = Потужність # This is used in the tooltip when the power value uses the kilowatt unit. # Variables: # $value (String) - the power value at this location @@ -837,6 +1002,11 @@ TrackPower--tooltip-average-power-watt = { $value } Вт # $value (String) - the power value at this location TrackPower--tooltip-average-power-milliwatt = { $value } мВт .label = Середня потужність у поточній вибірці +# This is used in the tooltip when the power value uses the microwatt unit. +# Variables: +# $value (String) - the power value at this location +TrackPower--tooltip-average-power-microwatt = { $value } мкВт + .label = Середня потужність у поточній вибірці # This is used in the tooltip when the energy used in the current range uses the # kilowatt-hour unit. # Variables: @@ -906,12 +1076,12 @@ TrackPower--tooltip-energy-carbon-used-in-preview-microwatthour = { $value } м # $value (String) - the value for the data transfer speed. # Will contain the unit (eg. B, KB, MB) TrackBandwidthGraph--speed = { $value } за секунду - .label = Швидкість передавання для цього зразка + .label = Швидкість передавання для цієї вибірки # This is used in the tooltip of the bandwidth track. # Variables: # $value (String) - how many read or write operations were performed since the previous sample TrackBandwidthGraph--read-write-operations-since-the-previous-sample = { $value } - .label = операцій читання/запису від попереднього зразка + .label = операцій читання/запису від попередньої вибірки # This is used in the tooltip of the bandwidth track. # Variables: # $value (String) - the total of transfered data until the hovered time. @@ -971,6 +1141,12 @@ TransformNavigator--focus-subtree = Вузол фокусування: { $item } # Variables: # $item (String) - Name of the function that transform applied to. TransformNavigator--focus-function = Сфокусуватися: { $item } +# "Focus self" transform. +# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus-on-function-self +# Also see the translation note above CallNodeContextMenu--transform-focus-self. +# Variables: +# $item (String) - Name of the function that transform applied to. +TransformNavigator--focus-self = Фокус на собі: { $item } # "Focus category" transform. The word "Focus" has the meaning of an adjective here. # See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus-category # Variables: @@ -1009,7 +1185,7 @@ TransformNavigator--collapse-function-subtree = Згорнути піддере # "Drop samples outside of markers matching ..." transform. # Variables: # $item (String) - Search filter of the markers that transform will apply to. -TransformNavigator--drop-samples-outside-of-markers-matching = Перетягніть зразки за межі маркерів, що відповідають: “{ $item }” +TransformNavigator--drop-samples-outside-of-markers-matching = Перетягніть вибірки за межі маркерів, що відповідають: “{ $item }” ## "Bottom box" - a view which contains the source view and the assembly view, ## at the bottom of the profiler UI @@ -1042,6 +1218,12 @@ BottomBox--assembly-code-not-available-title = Код асемблера нед # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Перегляньте обговорення #4520, щоб дізнатися про підтримувані сценарії та заплановані вдосконалення. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Вийти з повноекранного режиму +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Повноекранний режим SourceView--close-button = .title = Закрити вікно з кодом @@ -1098,6 +1280,13 @@ SourceView--not-in-archive-error-when-obtaining-source = Файл { $pathInArchi # $url (String) - The URL from which the "archive" file was downloaded. # $parsingErrorMessage (String) - The raw internal error message during parsing, not localized SourceView--archive-parsing-error-when-obtaining-source = Не вдалося проаналізувати архів за адресою { $url }: { $parsingErrorMessage } +# Displayed below SourceView--cannot-obtain-source, if a JS file could not be found in +# the browser. +# Variables: +# $url (String) - The URL of the JS source file. +# $sourceUuid (number) - The UUID of the JS source file. +# $errorMessage (String) - The raw internal error message, not localized +SourceView--not-in-browser-error-when-obtaining-js-source = Браузеру не вдалося отримати файл джерела для { $url } з sourceUuid { $sourceUuid }: { $errorMessage }. ## Toggle buttons in the top right corner of the bottom box @@ -1109,6 +1298,17 @@ AssemblyView--show-button = # Assembly refers to the low-level programming language. AssemblyView--hide-button = .title = Сховати перегляд асемблера +# The "◀" button above the assembly view. +AssemblyView--prev-button = + .title = Попередня +# The "▶" button above the assembly view. +AssemblyView--next-button = + .title = Наступна +# The label showing the current position and total count above the assembly view. +# Variables: +# $current (Number) - The current position (1-indexed). +# $total (Number) - The total count. +AssemblyView--position-label = { $current } із { $total } ## UploadedRecordingsHome ## This is the page that displays all the profiles that user has uploaded. From a5a917533327bd9ebdc7779143e7055f9fb4f13f Mon Sep 17 00:00:00 2001 From: Pontoon Date: Sat, 17 Oct 2026 10:50:28 +0000 Subject: [PATCH 09/24] Pontoon/Firefox Profiler: Update Greek (el) Co-authored-by: Jim Spentzos (el) --- locales/el/app.ftl | 1 + 1 file changed, 1 insertion(+) diff --git a/locales/el/app.ftl b/locales/el/app.ftl index da89e5f31c..47b8a89602 100644 --- a/locales/el/app.ftl +++ b/locales/el/app.ftl @@ -699,6 +699,7 @@ MenuButtons--publish--renderCheckbox-label-argument-values-warning-image = MenuButtons--publish--share-performance-profile = Κοινή χρήση προφίλ επιδόσεων MenuButtons--publish--reshare-performance-profile = Νέα κοινή χρήση προφίλ επιδόσεων MenuButtons--publish--save-performance-profile = Αποθήκευση προφίλ επιδόσεων +MenuButtons--publish--local-profile-description = Αυτό το προφίλ δεν έχει μεταφορτωθεί και θα χαθεί όταν κλείσετε αυτήν την καρτέλα. Για να το διατηρήσετε, αποθηκεύστε το στον υπολογιστή σας ή μεταφορτώστε το με την επιλογή «Κοινή χρήση». MenuButtons--publish--info-description = Ανεβάστε το προφίλ σας και κάντε το προσβάσιμο σε οποιονδήποτε έχει τον σύνδεσμο. MenuButtons--publish--download-info-description = Αποθηκεύστε αυτό το προφίλ ως αρχείο στον υπολογιστή σας. MenuButtons--publish--info-description-default = Από προεπιλογή, αφαιρούνται τα προσωπικά σας δεδομένα. From 20e6f3c7b48f350d5294f64e8b1b7b3079b13d37 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sat, 17 Oct 2026 18:59:56 -0400 Subject: [PATCH 10/24] Support index source maps. An index map has a sections array instead of a top-level mappings string. Each section pairs a generated-code offset with an embedded source map (ECMA-426 section 10). Closure Compiler output, for example, can come with one. The source-map library already reads index maps, but parseSourceMapFileContents rejected them, so profiler-edit's --map-source-paths and the "apply source map file" button skipped them. Accept and validate index maps, and widen the type of the parsed map to RawSourceMap | RawIndexMap. --- src/actions/receive-profile.ts | 6 +- src/actions/source-map-symbolication.ts | 4 +- src/app-logic/browser-connection.ts | 6 +- src/app-logic/web-channel.ts | 4 +- src/profile-logic/embed-js-sources.ts | 6 +- src/profile-logic/source-maps/matching.ts | 88 ++++++++++++------- src/profile-logic/source-maps/store.ts | 9 +- src/profile-logic/source-maps/worker-types.ts | 4 +- src/profile-query/function-annotate.ts | 4 +- src/test/unit/embed-js-sources.test.ts | 37 ++++++++ src/test/unit/source-map-matching.test.ts | 32 ++++++- src/utils/query-api.ts | 6 +- 12 files changed, 149 insertions(+), 57 deletions(-) diff --git a/src/actions/receive-profile.ts b/src/actions/receive-profile.ts index 4bbf0226cf..c27ee1a593 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -79,7 +79,7 @@ import { import { doSourceMapSymbolication } from './source-map-symbolication'; import type { SourceMapSymbolicationResult } from './source-map-symbolication'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store'; import type { RequestedLib, ImplementationFilter, @@ -849,7 +849,7 @@ async function doResolveSourceMaps( browserConnection: BrowserConnection, dispatch: Dispatch ): Promise<{ - resolvedSourceMaps: Map; + resolvedSourceMaps: Map; compiledSources: Map; }> { const { sources, stringArray } = profile.shared; @@ -872,7 +872,7 @@ async function doResolveSourceMaps( } // Fetch source maps and compiled sources in parallel, ignoring individual failures. - const resolvedSourceMaps: Map = new Map(); + const resolvedSourceMaps: Map = new Map(); const compiledSources: Map = new Map(); dispatch({ type: 'START_SOURCE_MAP_FETCHING' }); diff --git a/src/actions/source-map-symbolication.ts b/src/actions/source-map-symbolication.ts index 5c57c35efe..597dad287b 100644 --- a/src/actions/source-map-symbolication.ts +++ b/src/actions/source-map-symbolication.ts @@ -20,7 +20,7 @@ import type { EligibleSource, SourceMapAmbiguityReason, } from 'firefox-profiler/profile-logic/source-maps/matching'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; /** @@ -40,7 +40,7 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; export type SourceMapSymbolicationResult = 'applied' | 'no-match' | 'error'; export function doSourceMapSymbolication( - resolvedSourceMaps: Map, + resolvedSourceMaps: Map, compiledSources: Map, run: SourceMapRunner = _runSourceMapWorker ): ThunkAction> { diff --git a/src/app-logic/browser-connection.ts b/src/app-logic/browser-connection.ts index 1ef37bede9..5d6a2dd35e 100644 --- a/src/app-logic/browser-connection.ts +++ b/src/app-logic/browser-connection.ts @@ -16,7 +16,7 @@ import { getJSSourcesViaWebChannelV7, getSourceMapViaWebChannel, } from './web-channel'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store'; import type { Milliseconds, FaviconData, @@ -92,7 +92,7 @@ export interface BrowserConnection { // Get source map of the given source directly from the browser. // Requires WebChannel version 7+. - getSourceMap(sourceId: string): Promise; + getSourceMap(sourceId: string): Promise; // True when the browser exposes GET_SOURCE_MAP (WebChannel version 7+). // Callers use this to gate source-map-based features. @@ -247,7 +247,7 @@ class BrowserConnectionImpl implements BrowserConnection { return []; } - async getSourceMap(sourceId: string): Promise { + async getSourceMap(sourceId: string): Promise { if (!this.supportsGetSourceMap) { throw new Error( "Can't use getSourceMap in Firefox versions with the old WebChannel." diff --git a/src/app-logic/web-channel.ts b/src/app-logic/web-channel.ts index 3316c6e822..997862f613 100644 --- a/src/app-logic/web-channel.ts +++ b/src/app-logic/web-channel.ts @@ -8,7 +8,7 @@ import type { ExternalMarkersData, FaviconData, } from 'firefox-profiler/types'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store'; /** * This file is in charge of handling the message managing between profiler.firefox.com @@ -162,7 +162,7 @@ type GetPageFaviconsResponse = Array; type OpenScriptInTabDebuggerResponse = void; type GetJSSourceReponseItem = { sourceText: string } | { error: string }; type GetJSSourcesResponse = Array; -type GetSourceMapResponse = RawSourceMap; +type GetSourceMapResponse = SourceMap; // TypeScript function overloads for request/response pairs. function _sendMessageWithResponse( diff --git a/src/profile-logic/embed-js-sources.ts b/src/profile-logic/embed-js-sources.ts index aa329484a3..be3640c933 100644 --- a/src/profile-logic/embed-js-sources.ts +++ b/src/profile-logic/embed-js-sources.ts @@ -12,7 +12,7 @@ import { parseSourceMapFileContents } from 'firefox-profiler/profile-logic/sourc import { base64StringToBytes } from 'firefox-profiler/utils/base64'; import { StringTable } from 'firefox-profiler/utils/string-table'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store'; import type { SourceMapRunner } from 'firefox-profiler/profile-logic/source-maps/worker-types'; import type { IndexIntoSourceTable, Profile } from 'firefox-profiler/types'; @@ -70,7 +70,7 @@ function _resolveSourceMapURL( async function _readSourceMap( resolvedSourceMapURL: string, readSource: SourceReader -): Promise { +): Promise { const isInline = resolvedSourceMapURL.startsWith('data:'); const sourceMapText = isInline ? _decodeDataUrl(resolvedSourceMapURL) @@ -119,7 +119,7 @@ export async function embedJsSources( const stringTable = StringTable.withBackingArray(stringArray); let embeddedCount = 0; const compiledSources = new Map(); - const resolvedSourceMaps = new Map(); + const resolvedSourceMaps = new Map(); // Gecko keys source rows by their ScriptSource id, so a script that's loaded // more than once can have several rows with the same URL. const readSourceOnce = _cacheByKey(readSource); diff --git a/src/profile-logic/source-maps/matching.ts b/src/profile-logic/source-maps/matching.ts index b79684f716..13cd4715be 100644 --- a/src/profile-logic/source-maps/matching.ts +++ b/src/profile-logic/source-maps/matching.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { IndexIntoSourceTable, SourceTable } from 'firefox-profiler/types'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from './store'; /** * A source row that already carries a sourceMapURL, and is therefore eligible @@ -60,45 +60,69 @@ export function getSourcesWithSourceMapURL( return eligible; } -/** - * Parse the text contents of a `.map` file into a RawSourceMap. Returns null - * for invalid JSON, for JSON that isn't a source map (missing version / - * mappings / sources), and for index maps. - * - * An index map replaces the top-level `mappings` string with a `sections` - * array, each section pairing a generated-code offset with an embedded source - * map (ECMA-426 section 10: https://tc39.es/ecma426/#sec-index-source-map). - * We skip them because no mainstream web bundler emits one, and we currently - * don't support it. - */ -export function parseSourceMapFileContents(text: string): RawSourceMap | null { - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return null; +function _isPosition(value: unknown): boolean { + if (value === null || typeof value !== 'object') { + return false; } + const position = value as Record; + return ( + typeof position.line === 'number' && typeof position.column === 'number' + ); +} - if (parsed === null || typeof parsed !== 'object') { - return null; +function _isRegularSourceMap(value: unknown): boolean { + if (value === null || typeof value !== 'object') { + return false; } + const map = value as Record; + return ( + typeof map.version === 'number' && + typeof map.mappings === 'string' && + Array.isArray(map.sources) + ); +} - const map = parsed as Record; +// An index map replaces the top-level `mappings` string with a `sections` +// array, each section pairing a generated-code offset with an embedded source +// map (ECMA-426 section 10: https://tc39.es/ecma426/#sec-index-source-map). +// The embedded maps must be regular source maps: the spec decodes them with +// DecodeSourceMap, so index maps can't be nested. Sections that reference +// their map by `url` instead are not supported by the source-map library. +function _isIndexSourceMap(map: Record): boolean { + return ( + typeof map.version === 'number' && + Array.isArray(map.sections) && + map.sections.every( + (section) => + section !== null && + typeof section === 'object' && + _isPosition(section.offset) && + _isRegularSourceMap(section.map) + ) + ); +} - // Index maps use `sections` instead of top-level `mappings`. Reject them. - if ('sections' in map) { - return null; +function _isSourceMap(value: unknown): value is SourceMap { + if (value === null || typeof value !== 'object') { + return false; } + const map = value as Record; + return 'sections' in map ? _isIndexSourceMap(map) : _isRegularSourceMap(map); +} - if ( - typeof map.version !== 'number' || - typeof map.mappings !== 'string' || - !Array.isArray(map.sources) - ) { +/** + * Parse the text contents of a `.map` file into a source map. Returns null + * for invalid JSON, and for JSON that isn't a source map (missing version / + * mappings / sources, or malformed index map sections). + */ +export function parseSourceMapFileContents(text: string): SourceMap | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { return null; } - - return parsed as RawSourceMap; + return _isSourceMap(parsed) ? parsed : null; } /** @@ -158,7 +182,7 @@ function matchByBasename( * one, which we match unconditionally regardless of name. */ export function matchSourceMapToSource( - map: RawSourceMap, + map: SourceMap, uploadedFileName: string, eligible: EligibleSource[] ): SourceMapMatchResult { diff --git a/src/profile-logic/source-maps/store.ts b/src/profile-logic/source-maps/store.ts index 1ba9a22ebc..2078bfd673 100644 --- a/src/profile-logic/source-maps/store.ts +++ b/src/profile-logic/source-maps/store.ts @@ -3,11 +3,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { SourceMapConsumer as LibSourceMapConsumer } from 'source-map'; -import type { RawSourceMap } from 'source-map'; +import type { RawIndexMap, RawSourceMap } from 'source-map'; import type { IndexIntoSourceTable } from 'firefox-profiler/types'; export type SourceMapConsumer = LibSourceMapConsumer; +/** + * A parsed source map, either a regular one or an index map made of sections. + */ +export type SourceMap = RawSourceMap | RawIndexMap; + /** * Holds pre-parsed SourceMapConsumer instances keyed by IndexIntoSourceTable. * Initialized from the resolvedSourceMaps already fetched at profile load time. @@ -20,7 +25,7 @@ export class SourceMapStore { } static async create( - resolvedSourceMaps: Map, + resolvedSourceMaps: Map, wasmUrl: string ): Promise { const consumers = new Map(); diff --git a/src/profile-logic/source-maps/worker-types.ts b/src/profile-logic/source-maps/worker-types.ts index 92022bbd00..4548397727 100644 --- a/src/profile-logic/source-maps/worker-types.ts +++ b/src/profile-logic/source-maps/worker-types.ts @@ -7,7 +7,7 @@ import type { IndexIntoFrameTable, IndexIntoSourceTable, } from 'firefox-profiler/types'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from './store'; import type { SourceMapSymbolicationInput } from './symbolication'; /** @@ -15,7 +15,7 @@ import type { SourceMapSymbolicationInput } from './symbolication'; * All values are structured-cloned (copied) across the thread boundary. */ export type WorkerInput = SourceMapSymbolicationInput & { - resolvedSourceMaps: Map; + resolvedSourceMaps: Map; // Compiled source texts keyed by bundle source index. compiledSources: Map; }; diff --git a/src/profile-query/function-annotate.ts b/src/profile-query/function-annotate.ts index f9125ef45b..48c5ce012f 100644 --- a/src/profile-query/function-annotate.ts +++ b/src/profile-query/function-annotate.ts @@ -26,7 +26,7 @@ import { import { fetchAssembly } from 'firefox-profiler/utils/fetch-assembly'; import { fetchSource } from 'firefox-profiler/utils/fetch-source'; import type { ExternalCommunicationDelegate } from 'firefox-profiler/utils/query-api'; -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store'; import type { Profile, IndexIntoFuncTable, @@ -64,7 +64,7 @@ class NodeExternalCommunicationDelegate implements ExternalCommunicationDelegate throw new Error('No browser connection available in profiler-cli'); } - async fetchSourceMapFromBrowser(_sourceId: string): Promise { + async fetchSourceMapFromBrowser(_sourceId: string): Promise { throw new Error('No browser connection available in profiler-cli'); } } diff --git a/src/test/unit/embed-js-sources.test.ts b/src/test/unit/embed-js-sources.test.ts index 55d5a001f5..cdbf633e40 100644 --- a/src/test/unit/embed-js-sources.test.ts +++ b/src/test/unit/embed-js-sources.test.ts @@ -129,6 +129,43 @@ describe('embedJsSources', function () { }); }); + it('applies an index source map', async function () { + // The bundle is a concatenation of two chunks, and the function is in the + // second one, on the bundle's second line. + const firstChunk = 'var c=1;'; + const firstChunkMap = new SourceMapGenerator({ file: 'first.js' }); + firstChunkMap.addMapping({ + source: '../src/first.js', + original: { line: 1, column: 0 }, + generated: { line: 1, column: 0 }, + }); + const indexMap = JSON.stringify({ + version: 3, + file: 'bundle.js', + sections: [ + { offset: { line: 0, column: 0 }, map: firstChunkMap.toJSON() }, + { offset: { line: 1, column: 0 }, map: JSON.parse(buildSourceMap()) }, + ], + }); + const profile = makeProfile(BUNDLE_URL, { sourceMapURL: 'bundle.js.map' }); + profile.shared.funcTable.lineNumber[0] = 2; + profile.shared.frameTable.line[0] = 2; + await embedJsSources( + profile, + makeReader({ + [BUNDLE_URL]: firstChunk + '\n' + BUNDLE_SOURCE, + [BUNDLE_URL + '.map']: indexMap, + }), + runSourceMapSymbolicationNode + ); + expect(getOriginalLocationOfFunc(profile)).toEqual({ + name: 'greet', + filename: ORIGINAL_FILENAME, + line: 1, + content: ORIGINAL_SOURCE, + }); + }); + it('falls back to the sourceMappingURL comment', async function () { const profile = makeProfile(BUNDLE_URL); const bundleWithComment = diff --git a/src/test/unit/source-map-matching.test.ts b/src/test/unit/source-map-matching.test.ts index a3ee4051b6..328cad6c0c 100644 --- a/src/test/unit/source-map-matching.test.ts +++ b/src/test/unit/source-map-matching.test.ts @@ -81,9 +81,35 @@ describe('parseSourceMapFileContents', function () { expect(parseSourceMapFileContents('null')).toBeNull(); }); - it('rejects index maps (with a sections field)', function () { - const indexMap = JSON.stringify({ version: 3, sections: [] }); - expect(parseSourceMapFileContents(indexMap)).toBeNull(); + it('parses an index map', function () { + const indexMap = { + version: 3, + file: 'bundle.js', + sections: [ + { offset: { line: 0, column: 0 }, map: makeMap() }, + { offset: { line: 10, column: 0 }, map: makeMap() }, + ], + }; + expect(parseSourceMapFileContents(JSON.stringify(indexMap))).toEqual( + indexMap + ); + }); + + it('returns null for malformed index maps', function () { + const section = { offset: { line: 0, column: 0 }, map: makeMap() }; + for (const indexMap of [ + { version: 3, sections: 'not an array' }, + { version: 3, sections: [{ ...section, offset: { line: 0 } }] }, + { version: 3, sections: [{ ...section, map: { version: 3 } }] }, + { version: 3, sections: [{ offset: section.offset, url: 'a.map' }] }, + // Nested index maps are not allowed. + { + version: 3, + sections: [{ ...section, map: { version: 3, sections: [section] } }], + }, + ]) { + expect(parseSourceMapFileContents(JSON.stringify(indexMap))).toBeNull(); + } }); }); diff --git a/src/utils/query-api.ts b/src/utils/query-api.ts index 975e2d4098..4cc5935558 100644 --- a/src/utils/query-api.ts +++ b/src/utils/query-api.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import type { RawSourceMap } from 'source-map'; +import type { SourceMap } from 'firefox-profiler/profile-logic/source-maps/store'; import type { MixedObject, ApiQueryError } from 'firefox-profiler/types'; import type { BrowserConnection } from 'firefox-profiler/app-logic/browser-connection'; @@ -29,7 +29,7 @@ export interface ExternalCommunicationDelegate { // Get source map of the given source directly from the browser. // Requires WebChannel version 7+. - fetchSourceMapFromBrowser(sourceId: string): Promise; + fetchSourceMapFromBrowser(sourceId: string): Promise; } export type ApiQueryResult = @@ -175,7 +175,7 @@ export class RegularExternalCommunicationDelegate implements ExternalCommunicati return browserConnection.getJSSource(source); } - fetchSourceMapFromBrowser(sourceId: string): Promise { + fetchSourceMapFromBrowser(sourceId: string): Promise { const browserConnection = this._browserConnection; if (browserConnection === null) { throw new Error('No connection to the browser.'); From 333a9233921e4a62da3b4d61a59205c6abbd5d94 Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 24 Sep 2026 16:12:30 +0200 Subject: [PATCH 11/24] Normalize screenshot marker payloads Store window dimensions as one windowSize value so a schema field can describe them together. Normalize window IDs to strings at the profile boundary so consumers can use one representation regardless of whether Gecko supplies a numeric ID or a hexadecimal string. Apply the same payload shape in Gecko processing, Chrome import, and the processed profile upgrader. --- docs-developer/CHANGELOG-formats.md | 4 +- src/app-logic/constants.ts | 2 +- src/components/timeline/TrackScreenshots.tsx | 24 ++--- src/components/tooltip/Marker.tsx | 15 +-- src/profile-logic/import/chrome.ts | 3 +- src/profile-logic/marker-data.ts | 6 +- src/profile-logic/process-profile.ts | 11 +- .../processed-profile-versioning.ts | 20 ++++ src/test/components/TooltipMarker.test.tsx | 7 +- .../fixtures/profiles/processed-profile.ts | 7 +- src/test/fixtures/upgrades/processed-3.json | 40 ++++++- .../__snapshots__/profiler-edit.test.ts.snap | 8 +- .../__snapshots__/profile-view.test.ts.snap | 2 +- src/test/store/receive-profile.test.ts | 6 +- .../__snapshots__/marker-data.test.ts.snap | 6 +- .../profile-conversion.test.ts.snap | 36 +++---- .../profile-upgrading.test.ts.snap | 100 ++++++++++++------ src/test/unit/marker-data.test.ts | 66 +++++++++++- src/test/unit/merge-compare.test.ts | 9 +- .../unit/profile-query/marker-utils.test.ts | 8 +- src/types/markers.ts | 48 ++++----- 21 files changed, 278 insertions(+), 150 deletions(-) diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index 13b20a9f79..7233b0ccbf 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -8,7 +8,9 @@ Note that this is not an exhaustive list. Processed profile format upgraders can ### Version 76 -Two marker schema field formats were added: `screenshot-size`, whose value is a `{ width, height }` object, and `screenshot-data-url`, an object format `{ type: "screenshot-data-url", sizeFieldForAspectRatio }` whose value is a string table index holding an image data URL. +The `CompositorScreenshot` marker payload's `windowWidth` and `windowHeight` fields were replaced with a single `windowSize` field of the form `{ width, height }`. The `windowID` field is now always a string. + +Two marker schema field formats were added to describe these markers: `screenshot-size`, whose value is a `{ width, height }` object, and `screenshot-data-url`, an object format `{ type: "screenshot-data-url", sizeFieldForAspectRatio }` whose value is a string table index holding an image data URL. ### Version 75 diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index f98ea0b975..21eaa55802 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -12,7 +12,7 @@ export const GECKO_PROFILE_VERSION = 37; // The current version of the "processed" profile format. // Please don't forget to update the processed profile format changelog in // `docs-developer/CHANGELOG-formats.md`. -export const PROCESSED_PROFILE_VERSION = 75; +export const PROCESSED_PROFILE_VERSION = 76; // The following are the margin sizes for the left and right of the timeline. Independent // components need to share these values. diff --git a/src/components/timeline/TrackScreenshots.tsx b/src/components/timeline/TrackScreenshots.tsx index 4ec906de4e..282a55015d 100644 --- a/src/components/timeline/TrackScreenshots.tsx +++ b/src/components/timeline/TrackScreenshots.tsx @@ -244,19 +244,17 @@ class HoverPreview extends PureComponent { return null; } - if (payload.url === undefined) { + const { url, windowSize } = payload; + if (url === undefined || windowSize === undefined) { return null; } - const { url } = payload; - const maximumHoverSize = isMakingPreviewSelection ? MAXIMUM_HOVER_SIZE_WHEN_SELECTING_RANGE : MAXIMUM_HOVER_SIZE; - // Type guard: payload.url !== undefined means it has windowWidth and windowHeight const { width: hoverWidth, height: hoverHeight } = computeScreenshotSize( - payload as { windowWidth: number; windowHeight: number }, + windowSize, maximumHoverSize ); @@ -354,18 +352,12 @@ class ScreenshotStrip extends PureComponent { // Coerce the payload into a screenshot one. const payload: ScreenshotPayload = screenshots[screenshotIndex] .data as any; - if (payload.url === undefined) { + const { url: urlStringIndex, windowSize } = payload; + if (urlStringIndex === undefined || windowSize === undefined) { continue; } - const { - url: urlStringIndex, - windowWidth, - windowHeight, - } = payload as ScreenshotPayload & { - windowWidth: number; - windowHeight: number; - }; - const scaledImageWidth = (trackHeight * windowWidth) / windowHeight; + const scaledImageWidth = + (trackHeight * windowSize.width) / windowSize.height; images.push(
{ {/* The following image is centered and cropped by the outer container. */} { break; } case 'CompositorScreenshot': { - if ( - data.url !== undefined && - 'windowWidth' in data && - 'windowHeight' in data - ) { + if (data.url !== undefined && data.windowSize !== undefined) { const { width, height } = computeScreenshotSize( - data, + data.windowSize, MAXIMUM_IMAGE_SIZE ); details.push( @@ -380,7 +376,7 @@ class MarkerTooltipContents extends React.PureComponent { key="CompositorScreenshot-window size" > <> - {data.windowWidth}px × {data.windowHeight}px + {data.windowSize.width}px × {data.windowSize.height}px , { + // The CompositorScreenshot marker payload's `windowWidth` and `windowHeight` fields were replaced with + // a single `windowSize` field, and `windowID` is now always a string. + for (const thread of profile.threads) { + const { markers } = thread; + for (let i = 0; i < markers.length; i++) { + const data = markers.data[i]; + if (!data || data.type !== 'CompositorScreenshot') { + continue; + } + const { windowWidth, windowHeight } = data; + data.windowID = String(data.windowID); + if (windowWidth !== undefined && windowHeight !== undefined) { + data.windowSize = { width: windowWidth, height: windowHeight }; + } + delete data.windowWidth; + delete data.windowHeight; + } + } + }, // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. }; diff --git a/src/test/components/TooltipMarker.test.tsx b/src/test/components/TooltipMarker.test.tsx index c80c87ae87..5f2ccc7c24 100644 --- a/src/test/components/TooltipMarker.test.tsx +++ b/src/test/components/TooltipMarker.test.tsx @@ -1180,8 +1180,7 @@ describe('TooltipMarker', function () { type: 'CompositorScreenshot', url: screenshotUrlIndex, windowID: 'XXX', - windowWidth: 600, - windowHeight: 300, + windowSize: { width: 600, height: 300 }, }, ], ]); @@ -1213,7 +1212,6 @@ describe('TooltipMarker', function () { { type: 'CompositorScreenshot', windowID: 'XXX', - url: undefined, }, ], ]); @@ -1300,8 +1298,7 @@ describe('TooltipMarker', function () { type: 'CompositorScreenshot', url: screenshotUrlIndex, windowID: 'XXX', - windowWidth: 600, - windowHeight: 300, + windowSize: { width: 600, height: 300 }, }, ], ] diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index ffdca15c3a..9032ddd9d7 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -380,8 +380,7 @@ export function makeCompositorScreenshot( type: 'CompositorScreenshot', url: 0, windowID: '', - windowWidth: 100, - windowHeight: 100, + windowSize: { width: 100, height: 100 }, }, }; } @@ -1390,8 +1389,7 @@ export function getScreenshotMarkersForWindowId( type: 'CompositorScreenshot', url: 0, // Some arbitrary string. windowID, - windowWidth: 300, - windowHeight: 150, + windowSize: { width: 300, height: 150 }, }, ]); } @@ -1408,7 +1406,6 @@ export function getScreenshotTrackProfile() { { type: 'CompositorScreenshot', windowID: '1', - url: undefined, }, ], ]); diff --git a/src/test/fixtures/upgrades/processed-3.json b/src/test/fixtures/upgrades/processed-3.json index df3eb93eaa..2594b4d624 100644 --- a/src/test/fixtures/upgrades/processed-3.json +++ b/src/test/fixtures/upgrades/processed-3.json @@ -347,30 +347,36 @@ ] }, "markers": { - "length": 10, + "length": 13, "name": [ 4, 5, 10, 10, + 18, 5, 17, 8, 8, 9, - 14 + 18, + 14, + 19 ], "time": [ 0, 2, 4, 5, + 6, 8, 8, 9, 10, 11, - 13 + 12, + 13, + 14 ], "data": [ { @@ -396,6 +402,13 @@ "interval": "end", "type": "tracing" }, + { + "type": "CompositorScreenshot", + "url": 20, + "windowID": 42, + "windowWidth": 1280, + "windowHeight": 1000 + }, { "category": "Paint", "interval": "end", @@ -428,6 +441,13 @@ "startTime": 11, "endTime": 12 }, + { + "type": "CompositorScreenshot", + "url": 20, + "windowID": "42", + "windowWidth": 1280, + "windowHeight": 1000 + }, { "type": "Network", "startTime": 3, @@ -446,6 +466,10 @@ "requestStart": 10, "responseStart": 11, "responseEnd": 12 + }, + { + "type": "CompositorScreenshot", + "windowID": 42 } ], "category": [ @@ -455,10 +479,13 @@ 4, 4, 4, + 4, 5, 5, 1, - 7 + 4, + 7, + 4 ] }, "samples": { @@ -527,7 +554,10 @@ "Load 32: https://github.com/rustwasm/wasm-bindgen/issues/3", "DiskIO", "FileIO", - "TextureCacheFree" + "TextureCacheFree", + "CompositorScreenshot", + "CompositorScreenshotWindowDestroyed", + "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUD" ], "pid": "Unknown Process 1" }, diff --git a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap index f666a08608..4e7be1eef9 100644 --- a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap +++ b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap @@ -87,7 +87,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -1473,7 +1473,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -2859,7 +2859,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -4245,7 +4245,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "a.out", "sampleUnits": Object { diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index d37242ce85..702b836990 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -435,7 +435,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "Firefox", "sourceURL": "", diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index afc2309460..a768f16605 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -1757,8 +1757,7 @@ describe('actions/receive-profile', function () { type: 'CompositorScreenshot', url: 0, // Some arbitrary string. windowID: '0', - windowWidth: 300, - windowHeight: 150, + windowSize: { width: 300, height: 150 }, }, ], ]); @@ -1771,8 +1770,7 @@ describe('actions/receive-profile', function () { type: 'CompositorScreenshot', url: 0, // Some arbitrary string. windowID: '1', - windowWidth: 300, - windowHeight: 150, + windowSize: { width: 300, height: 150 }, }, ], ]); diff --git a/src/test/unit/__snapshots__/marker-data.test.ts.snap b/src/test/unit/__snapshots__/marker-data.test.ts.snap index df135ea0a7..344a6dbae1 100644 --- a/src/test/unit/__snapshots__/marker-data.test.ts.snap +++ b/src/test/unit/__snapshots__/marker-data.test.ts.snap @@ -179,9 +179,11 @@ Array [ "data": Object { "type": "CompositorScreenshot", "url": 21, - "windowHeight": 1000, "windowID": "0x136888400", - "windowWidth": 1280, + "windowSize": Object { + "height": 1000, + "width": 1280, + }, }, "end": 25, "name": "CompositorScreenshot", diff --git a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap index 956a59ec20..9ae12c5ce4 100644 --- a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap @@ -42,7 +42,7 @@ Object { "RefreshDriverTick", "Network", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "ART Trace (Android)", "symbolicated": true, "version": 37, @@ -1022,7 +1022,7 @@ Object { "RefreshDriverTick", "Network", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "ART Trace (Android)", "symbolicated": true, "version": 37, @@ -2305,7 +2305,7 @@ Object { "markerSchemaNames": Array [ "EventDispatch", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Chrome Trace", "symbolicated": true, "version": 37, @@ -2697,7 +2697,7 @@ Object { "markerSchemaNames": Array [ "EventDispatch", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Chrome Trace", "symbolicated": true, "version": 37, @@ -3086,7 +3086,7 @@ Object { "markerSchemaNames": Array [ "EventDispatch", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Chrome Trace", "symbolicated": true, "version": 37, @@ -3187,7 +3187,7 @@ Object { "markerSchemaNames": Array [ "EventDispatch", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Chrome Trace", "symbolicated": true, "version": 37, @@ -3540,7 +3540,7 @@ Object { "markerSchemaNames": Array [ "EventDispatch", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Chrome Trace", "symbolicated": true, "version": 37, @@ -3605,7 +3605,7 @@ Object { "markerSchemaNames": Array [ "EventDispatch", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Chrome Trace", "symbolicated": true, "version": 37, @@ -3759,7 +3759,7 @@ Object { "markerSchemaNames": Array [ "EventDispatch", ], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Chrome Trace", "symbolicated": true, "version": 37, @@ -3817,7 +3817,7 @@ Object { "importedFrom": undefined, "interval": 1, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Firefox", "symbolicated": true, "version": 37, @@ -4207,7 +4207,7 @@ Object { "importedFrom": undefined, "interval": 1, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Firefox", "symbolicated": true, "version": 37, @@ -4265,7 +4265,7 @@ Object { "importedFrom": undefined, "interval": 1, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Firefox", "symbolicated": true, "version": 37, @@ -4323,7 +4323,7 @@ Object { "importedFrom": undefined, "interval": 1, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Firefox", "symbolicated": true, "version": 37, @@ -4643,7 +4643,7 @@ Object { "importedFrom": "Simpleperf", "interval": 0, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "com.example.sampleapplication", "symbolicated": undefined, "version": 30, @@ -5019,7 +5019,7 @@ Object { "importedFrom": "Simpleperf", "interval": 0, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "com.example.sampleapplication", "symbolicated": undefined, "version": 30, @@ -5319,7 +5319,7 @@ Object { "importedFrom": "dhat", "interval": 1, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "target/debug/examples/work_log (dhat)", "symbolicated": true, "version": 37, @@ -5452,7 +5452,7 @@ Object { "importedFrom": undefined, "interval": 1, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Flamegraph", "symbolicated": true, "version": 37, @@ -5510,7 +5510,7 @@ Object { "importedFrom": undefined, "interval": 1, "markerSchemaNames": Array [], - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "product": "Flamegraph", "symbolicated": true, "version": 37, diff --git a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap index 75f5976ec3..f1b839e349 100644 --- a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap @@ -40,7 +40,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -7794,7 +7794,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -9212,7 +9212,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -10798,7 +10798,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 75, + "preprocessedProfileVersion": 76, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -11147,15 +11147,15 @@ Object { 0, ], "name": Array [ - 7, - 8, - 9, 10, 11, - 8, - 9, - 15, - 16, + 12, + 13, + 14, + 11, + 12, + 18, + 19, ], "originalLocation": Array [ 0, @@ -11212,10 +11212,10 @@ Object { 1, ], "name": Array [ - 8, - 9, - 8, - 9, + 11, + 12, + 11, + 12, ], }, "resourceTable": Object { @@ -11226,9 +11226,9 @@ Object { ], "length": 3, "name": Array [ - 13, - 12, - 14, + 16, + 15, + 17, ], "type": Array [ 1, @@ -11247,7 +11247,7 @@ Object { null, ], "filename": Array [ - 12, + 15, ], "id": Array [ null, @@ -11326,10 +11326,13 @@ Object { "VsyncTimestamp", "Reflow", "Rasterize", + "CompositorScreenshot", + "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUD", "TextureCacheFree", "DOMEvent", "MinorGC", "Load 32: https://github.com/rustwasm/wasm-bindgen/issues/3", + "CompositorScreenshotWindowDestroyed", "(root)", "0x100000f84", "0x100001a45", @@ -11353,10 +11356,13 @@ Object { 4, 4, 4, + 4, 5, 5, 1, + 4, 7, + 4, ], "data": Array [ Object { @@ -11382,6 +11388,15 @@ Object { "interval": "end", "type": "tracing", }, + Object { + "type": "CompositorScreenshot", + "url": 4, + "windowID": "42", + "windowSize": Object { + "height": 1000, + "width": 1280, + }, + }, Object { "category": "Paint", "interval": "end", @@ -11409,6 +11424,15 @@ Object { "type": "DOMEvent", }, Object {}, + Object { + "type": "CompositorScreenshot", + "url": 4, + "windowID": "42", + "windowSize": Object { + "height": 1000, + "width": 1280, + }, + }, Object { "URI": "https://github.com/rustwasm/wasm-bindgen/issues/3", "connectEnd": 9, @@ -11428,55 +11452,71 @@ Object { "tcpConnectEnd": 7, "type": "Network", }, + Object { + "type": "CompositorScreenshot", + "windowID": "42", + }, ], "endTime": Array [ null, null, null, 5, + null, 8, null, null, 10, 12, + null, 13, + null, ], - "length": 10, + "length": 13, "name": Array [ 0, 1, 2, 2, - 1, 3, - 4, - 4, + 1, 5, 6, + 6, + 7, + 3, + 8, + 9, ], "phase": Array [ 0, 2, 2, 3, + 0, 3, 0, 2, 3, 1, + 0, 1, + 0, ], "startTime": Array [ 0, 2, 4, null, + 6, null, 8, 9, null, 11, + 12, 3, + 14, ], }, "name": "GeckoMain", @@ -11618,9 +11658,9 @@ Object { 2, 2, 1, - 4, - 4, - 5, + 6, + 6, + 7, ], "phase": Array [ 0, @@ -11804,10 +11844,10 @@ Object { 2, 2, 1, - 4, - 4, - 5, - 14, + 6, + 6, + 7, + 17, ], "phase": Array [ 0, diff --git a/src/test/unit/marker-data.test.ts b/src/test/unit/marker-data.test.ts index e8b42906f1..d01bb97fbf 100644 --- a/src/test/unit/marker-data.test.ts +++ b/src/test/unit/marker-data.test.ts @@ -325,12 +325,69 @@ describe('Derive markers from Gecko phase markers', function () { ]); }); + it.each([0, 42, '42', '0xAAAAAAAAA'])( + 'normalizes screenshot window ID %p to a string', + (windowID) => { + const screenshot = { + type: 'CompositorScreenshot' as const, + windowID, + url: 16, + windowWidth: 1280, + windowHeight: 1000, + }; + const { profile, markers } = setupWithTestDefinedMarkers([ + { + name: 'CompositorScreenshot', + startTime: 1, + endTime: null, + phase: INSTANT, + data: screenshot, + }, + { + name: 'CompositorScreenshot', + startTime: 2, + endTime: null, + phase: INSTANT, + data: { ...screenshot, windowID: String(windowID) }, + }, + { + name: 'CompositorScreenshotWindowDestroyed', + startTime: 3, + endTime: null, + phase: INSTANT, + data: { type: 'CompositorScreenshot', windowID }, + }, + ]); + + expect(profile.threads[0].markers.data).toEqual( + Array(3).fill( + expect.objectContaining({ + type: 'CompositorScreenshot', + windowID: String(windowID), + }) + ) + ); + expect(markers.slice(0, 2)).toEqual( + [1, 2].map((start) => + expect.objectContaining({ + name: 'CompositorScreenshot', + start, + end: start + 1, + data: expect.objectContaining({ + windowID: String(windowID), + windowSize: { width: 1280, height: 1000 }, + }), + }) + ) + ); + } + ); + it('has special handling for CompositorScreenshot', function () { const basePayload = { type: 'CompositorScreenshot' as const, url: 16, - windowWidth: 1280, - windowHeight: 1000, + windowSize: { width: 1280, height: 1000 }, }; const payloadsForWindowA: ScreenshotPayload[] = [ { @@ -339,7 +396,7 @@ describe('Derive markers from Gecko phase markers', function () { }, { ...basePayload, - windowWidth: 500, + windowSize: { width: 500, height: 1000 }, windowID: '0xAAAAAAAAA', }, ]; @@ -812,8 +869,7 @@ describe('deriveMarkersFromRawMarkerTable', function () { type: 'CompositorScreenshot', url: expect.anything(), windowID: '0x136888400', - windowWidth: 1280, - windowHeight: 1000, + windowSize: { width: 1280, height: 1000 }, }, name: 'CompositorScreenshot', start: 25, diff --git a/src/test/unit/merge-compare.test.ts b/src/test/unit/merge-compare.test.ts index 39e11e3abb..b7703880ee 100644 --- a/src/test/unit/merge-compare.test.ts +++ b/src/test/unit/merge-compare.test.ts @@ -384,8 +384,7 @@ describe('mergeProfilesForDiffing function', function () { type: 'CompositorScreenshot', url: stringTableB.indexForString(screenshotUrl), windowID: '0', - windowWidth: 300, - windowHeight: 150, + windowSize: { width: 300, height: 150 }, }, ], ] @@ -791,8 +790,7 @@ describe('mergeThreads function', function () { type: 'CompositorScreenshot', url: screenshot1UrlIndex, windowID: 'XXX', - windowWidth: 300, - windowHeight: 600, + windowSize: { width: 300, height: 600 }, }, ], ]); @@ -806,8 +804,7 @@ describe('mergeThreads function', function () { type: 'CompositorScreenshot', url: screenshot2UrlIndex, windowID: 'YYY', - windowWidth: 300, - windowHeight: 600, + windowSize: { width: 300, height: 600 }, }, ], ]); diff --git a/src/test/unit/profile-query/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts index 10d4f7f8bb..18d333efb7 100644 --- a/src/test/unit/profile-query/marker-utils.test.ts +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -1022,8 +1022,7 @@ describe('collectThreadMarkers list option', function () { type: 'CompositorScreenshot', url: urlIdx, windowID: '0x1', - windowWidth: 1280, - windowHeight: 951, + windowSize: { width: 1280, height: 951 }, }); markers.length++; @@ -1059,7 +1058,7 @@ describe('collectThreadMarkers list option', function () { length: url.length, preview: url, }); - expect(m.data!.windowWidth).toBe(1280); + expect(m.data!.windowSize).toEqual({ width: 1280, height: 951 }); }); it('elides screenshot image blobs from data but keeps the key', function () { @@ -1089,8 +1088,7 @@ describe('collectThreadMarkers list option', function () { // The small fields next to it must survive: for this marker type there is // no schema, so `data` is the only route to the window dimensions. expect(m.fields).toBeUndefined(); - expect(m.data!.windowWidth).toBe(1280); - expect(m.data!.windowHeight).toBe(951); + expect(m.data!.windowSize).toEqual({ width: 1280, height: 951 }); // The whole row must stay small. expect(JSON.stringify(m).length).toBeLessThan(1000); }); diff --git a/src/types/markers.ts b/src/types/markers.ts index 9f05946ea5..5eb6cd9fd4 100644 --- a/src/types/markers.ts +++ b/src/types/markers.ts @@ -723,30 +723,28 @@ type VsyncTimestampPayload = { type: 'VsyncTimestamp'; }; -export type ScreenshotPayload = - | { - type: 'CompositorScreenshot'; - // This field represents the data url of the image. It is saved in the string table. - url: IndexIntoStringTable; - // A memory address that can uniquely identify a window. It has no meaning other than - // a way to identify a window. - windowID: string; - // The original dimensions of the window that was captured. The actual image that is - // stored in the string table will be scaled down from the original size. - windowWidth: number; - windowHeight: number; - } - // Markers that represent the closing of a window (name === 'CompositorScreenshotWindowDestroyed') - // only have a windowID data. - | { - type: 'CompositorScreenshot'; - // A memory address that can uniquely identify a window. It has no meaning other than - // a way to identify a window. - windowID: string; - // Having the property present but void makes it easier to deal with Flow in - // our flow version. - url: void; - }; +export type ScreenshotPayload = { + type: 'CompositorScreenshot'; + // A value that can uniquely identify a window. It has no meaning other than + // a way to identify a window. + windowID: string; + // This field represents the data url of the image. It is saved in the string table. + // The marker that only closes a window's last screenshot doesn't have one. + url?: IndexIntoStringTable; + // The original dimensions of the window that was captured. The actual image that is + // stored in the string table will be scaled down from the original size. + windowSize?: { width: number; height: number }; +}; + +export type ScreenshotPayload_Gecko = { + type: 'CompositorScreenshot'; + windowID: number | string; + url?: IndexIntoStringTable; + // Gecko writes the window dimensions as two separate fields. + // Profile processing folds them into a single `windowSize` field. + windowWidth?: number; + windowHeight?: number; +}; export type StyleMarkerPayload = { type: 'Styles'; @@ -920,7 +918,7 @@ export type MarkerPayload_Gecko = | GCMajorMarkerPayload_Gecko | GCSliceMarkerPayload_Gecko | VsyncTimestampPayload - | ScreenshotPayload + | ScreenshotPayload_Gecko | CcMarkerTracing | ArbitraryEventTracing | NavigationMarkerPayload From fa11a4a805cb22bcaacdf7d990d46575482b9563 Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 24 Sep 2026 16:14:36 +0200 Subject: [PATCH 12/24] Describe CompositorScreenshot markers with a persisted schema Persist screenshot field definitions with profiles so tooltip rendering, string-index translation, and query output can use the schema instead of separate CompositorScreenshot cases. Identify image URLs by their field format to keep query results compact. --- src/components/tooltip/Marker.tsx | 57 ------------------ src/profile-logic/import/chrome.ts | 12 ++++ src/profile-logic/marker-schema.ts | 4 -- src/profile-logic/process-profile.ts | 59 ++++++++++++------- .../process-screenshot-markers.ts | 27 +++++++++ .../processed-profile-versioning.ts | 31 ++++++++++ src/profile-query/formatters/marker-info.ts | 46 +++++++++------ src/test/components/TooltipMarker.test.tsx | 2 + .../__snapshots__/TooltipMarker.test.tsx.snap | 29 +++++---- .../fixtures/profiles/processed-profile.ts | 33 +++++++++++ .../profile-conversion.test.ts.snap | 2 + .../profile-upgrading.test.ts.snap | 28 +++++++++ src/test/unit/marker-schema.test.ts | 14 ++++- src/test/unit/merge-compare.test.ts | 7 +-- .../unit/profile-query/marker-utils.test.ts | 22 ++++--- 15 files changed, 248 insertions(+), 125 deletions(-) create mode 100644 src/profile-logic/process-screenshot-markers.ts diff --git a/src/components/tooltip/Marker.tsx b/src/components/tooltip/Marker.tsx index 60788caaf8..5d3fae8270 100644 --- a/src/components/tooltip/Marker.tsx +++ b/src/components/tooltip/Marker.tsx @@ -354,63 +354,6 @@ class MarkerTooltipContents extends React.PureComponent { ); break; } - case 'CompositorScreenshot': { - if (data.url !== undefined && data.windowSize !== undefined) { - const { width, height } = computeScreenshotSize( - data.windowSize, - MAXIMUM_IMAGE_SIZE - ); - details.push( - - - , - - <> - {data.windowSize.width}px × {data.windowSize.height}px - - , - - This marker spans the time between each composite of a window - and shows the window contents during that time. - , - - {data.windowID} - - ); - } else if (marker.name === 'CompositorScreenshotWindowDestroyed') { - details.push( - - This marker shows the moment a window has been destroyed. - , - - {data.windowID} - - ); - } - break; - } default: // Do nothing } diff --git a/src/profile-logic/import/chrome.ts b/src/profile-logic/import/chrome.ts index c8c6a292ac..7a9ad62b04 100644 --- a/src/profile-logic/import/chrome.ts +++ b/src/profile-logic/import/chrome.ts @@ -31,6 +31,7 @@ import { } from 'firefox-profiler/app-logic/constants'; import { getTimeRangeForThread } from '../profile-data'; +import { compositorScreenshotMarkerSchema } from '../process-screenshot-markers'; import { GlobalDataCollector } from '../global-data-collector'; // Chrome Tracing Event Spec: @@ -849,9 +850,18 @@ async function processTracingEvents( const { shared } = globalDataCollector.finish(); profile.shared = shared; + let hasScreenshots = false; for (const [thread, threadInfo] of threadInfoByThread) { thread.samples = finishRawSamplesTableBuilder(threadInfo.samples); thread.markers = finishRawMarkerTableBuilder(threadInfo.markers); + if ( + thread.markers.data.some((data) => data?.type === 'CompositorScreenshot') + ) { + hasScreenshots = true; + } + } + if (hasScreenshots) { + profile.meta.markerSchema.push(compositorScreenshotMarkerSchema); } return profile; @@ -897,6 +907,8 @@ async function extractScreenshots( markers.data.push({ type: 'CompositorScreenshot', url: stringTable.indexForString(urlString), + // Chrome's Screenshot events don't say which window they belong to, so + // they all share one made-up window ID. windowID: 'id', windowSize: { width: size.width, height: size.height }, }); diff --git a/src/profile-logic/marker-schema.ts b/src/profile-logic/marker-schema.ts index 0e745024eb..98cb4b0bc2 100644 --- a/src/profile-logic/marker-schema.ts +++ b/src/profile-logic/marker-schema.ts @@ -772,10 +772,6 @@ export function computeStringIndexMarkerFieldsByDataType( ): Map { const stringIndexMarkerFieldsByDataType = new Map(); - // 'CompositorScreenshot' markers currently don't have a schema (#5303), - // hardcode the url field (which is a string index) until they do. - stringIndexMarkerFieldsByDataType.set('CompositorScreenshot', ['url']); - for (const schema of markerSchemas) { const { name, fields } = schema; const stringIndexFields = []; diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index d79325bfb2..a279986563 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -66,6 +66,7 @@ import { extensionTextMarkerSchema, } from '../profile-logic/marker-schema'; import { convertJsTracerToThread } from '../profile-logic/js-tracer'; +import { compositorScreenshotMarkerSchema } from './process-screenshot-markers'; import type { StringTable } from '../utils/string-table'; import type { @@ -1819,14 +1820,17 @@ function _convertGeckoMarkerSchema( * primary list that is stored on the processed profile's meta object. */ function processMarkerSchema(geckoProfile: GeckoProfile): MarkerSchema[] { - const combinedSchemas: MarkerSchema[] = geckoProfile.meta.markerSchema.map( - _convertGeckoMarkerSchema - ); + const combinedSchemas: MarkerSchema[] = geckoProfile.meta.markerSchema + .filter(({ name }) => name !== 'CompositorScreenshot') + .map(_convertGeckoMarkerSchema); const names: Set = new Set(combinedSchemas.map(({ name }) => name)); for (const subprocess of geckoProfile.processes) { for (const markerSchema of subprocess.meta.markerSchema) { - if (!names.has(markerSchema.name)) { + if ( + markerSchema.name !== 'CompositorScreenshot' && + !names.has(markerSchema.name) + ) { names.add(markerSchema.name); combinedSchemas.push(_convertGeckoMarkerSchema(markerSchema)); } @@ -1861,7 +1865,6 @@ function processMarkerSchema(geckoProfile: GeckoProfile): MarkerSchema[] { ) { combinedSchemas.push(extensionTextMarkerSchema); } - return combinedSchemas; } @@ -2021,7 +2024,10 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { const markerSchema = processMarkerSchema(geckoProfile); const stringIndexMarkerFieldsByDataType = - computeStringIndexMarkerFieldsByDataType(markerSchema); + computeStringIndexMarkerFieldsByDataType([ + ...markerSchema, + compositorScreenshotMarkerSchema, + ]); const extensions: ExtensionTable = geckoProfile.meta.extensions ? _toStructOfArrays(geckoProfile.meta.extensions) @@ -2031,15 +2037,29 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { globalDataCollector.addExtensionOrigins(extensions); - for (const thread of geckoProfile.threads) { - threads.push( - _processThread( - thread, - geckoProfile, - stringIndexMarkerFieldsByDataType, - globalDataCollector - ) + let hasCompositorScreenshots = false; + const processThread = ( + thread: GeckoThread, + processProfile: GeckoProfile | GeckoSubprocessProfile + ): RawThread => { + const newThread = _processThread( + thread, + processProfile, + stringIndexMarkerFieldsByDataType, + globalDataCollector ); + if ( + newThread.markers.data.some( + (data) => data?.type === 'CompositorScreenshot' + ) + ) { + hasCompositorScreenshots = true; + } + return newThread; + }; + + for (const thread of geckoProfile.threads) { + threads.push(processThread(thread, geckoProfile)); } const counters: RawCounter[] = _processCounters(geckoProfile, threads, 0); const nullableProfilerOverhead: Array = [ @@ -2050,12 +2070,7 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { const adjustTimestampsBy = subprocessProfile.meta.startTime - geckoProfile.meta.startTime; for (const thread of subprocessProfile.threads) { - const newThread: RawThread = _processThread( - thread, - subprocessProfile, - stringIndexMarkerFieldsByDataType, - globalDataCollector - ); + const newThread = processThread(thread, subprocessProfile); newThread.samples = adjustTableTimeDeltas( newThread.samples, adjustTimestampsBy @@ -2102,6 +2117,10 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { ); } + if (hasCompositorScreenshots) { + markerSchema.push(compositorScreenshotMarkerSchema); + } + let pages = [...(geckoProfile.pages || [])]; for (const subprocessProfile of geckoProfile.processes) { diff --git a/src/profile-logic/process-screenshot-markers.ts b/src/profile-logic/process-screenshot-markers.ts new file mode 100644 index 0000000000..dc39bc7d1b --- /dev/null +++ b/src/profile-logic/process-screenshot-markers.ts @@ -0,0 +1,27 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { oneLine } from 'common-tags'; +import type { MarkerSchema } from 'firefox-profiler/types'; + +export const compositorScreenshotMarkerSchema: MarkerSchema = { + name: 'CompositorScreenshot', + display: ['marker-chart', 'marker-table'], + fields: [ + { + key: 'url', + label: 'Image', + format: { + type: 'screenshot-data-url', + sizeFieldForAspectRatio: 'windowSize', + }, + }, + { key: 'windowSize', label: 'Window Size', format: 'screenshot-size' }, + { key: 'windowID', label: 'Window ID', format: 'string' }, + ], + description: oneLine` + This marker spans the time between each composite of a window and shows + the window contents during that time. + `, +}; diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index b65d28426c..8c21e0d485 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -3673,6 +3673,8 @@ const _upgraders: { [76]: (profile: any) => { // The CompositorScreenshot marker payload's `windowWidth` and `windowHeight` fields were replaced with // a single `windowSize` field, and `windowID` is now always a string. + // A CompositorScreenshot marker schema is added if there are any CompositorScreenshot markers. + let hasCompositorScreenshots = false; for (const thread of profile.threads) { const { markers } = thread; for (let i = 0; i < markers.length; i++) { @@ -3681,6 +3683,7 @@ const _upgraders: { continue; } const { windowWidth, windowHeight } = data; + hasCompositorScreenshots = true; data.windowID = String(data.windowID); if (windowWidth !== undefined && windowHeight !== undefined) { data.windowSize = { width: windowWidth, height: windowHeight }; @@ -3689,6 +3692,34 @@ const _upgraders: { delete data.windowHeight; } } + + profile.meta.markerSchema = profile.meta.markerSchema.filter( + ({ name }: any) => name !== 'CompositorScreenshot' + ); + if (hasCompositorScreenshots) { + profile.meta.markerSchema.push({ + name: 'CompositorScreenshot', + display: ['marker-chart', 'marker-table'], + fields: [ + { + key: 'url', + label: 'Image', + format: { + type: 'screenshot-data-url', + sizeFieldForAspectRatio: 'windowSize', + }, + }, + { + key: 'windowSize', + label: 'Window Size', + format: 'screenshot-size', + }, + { key: 'windowID', label: 'Window ID', format: 'string' }, + ], + description: + 'This marker spans the time between each composite of a window and shows the window contents during that time.', + }); + } }, // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index b16259ef84..57f3e2e5b0 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -42,6 +42,7 @@ import type { Lib, IndexIntoStackTable, MarkerSchemaByName, + MarkerFormatType, } from 'firefox-profiler/types'; import type { StringTable } from 'firefox-profiler/utils/string-table'; import type { @@ -891,6 +892,7 @@ export function collectThreadMarkers( const label = getMarkerLabel(markerIndex); const data = collectMarkerData( marker, + markerSchemaByName, stringIndexFieldsByDataType, stringTable ); @@ -1068,6 +1070,7 @@ export function collectProfileMarkers( ), data: collectMarkerData( marker, + markerSchemaByName, stringIndexFieldsByDataType, stringTable ), @@ -1306,7 +1309,7 @@ function collectMarkerFields( fields.push({ key: field.key, label: field.label || field.key, - value, + value: truncateScreenshotDataUrl(value, field.format), formattedValue, }); } @@ -1315,21 +1318,33 @@ function collectMarkerFields( return fields; } -// Image blobs, elided from `data` because `--list` repeats them on every row. -// Keyed by field, not by size: a 2855-char screenshot data URL sits *below* a -// legitimate 2939-char `prefValue`, so no byte cap separates the two. -const ELIDED_PAYLOAD_FIELDS: ReadonlyMap> = new Map( - [['CompositorScreenshot', new Set(['url'])]] -); - export type ElidedDataValue = { elided: true; length: number; preview: string; }; +function truncateScreenshotDataUrl( + resolvedValue: unknown, + format: MarkerFormatType | undefined +): unknown { + if ( + typeof format === 'object' && + format.type === 'screenshot-data-url' && + typeof resolvedValue === 'string' + ) { + return { + elided: true, + length: resolvedValue.length, + preview: resolvedValue.slice(0, 64), + } satisfies ElidedDataValue; + } + return resolvedValue; +} + function collectMarkerData( marker: Marker, + markerSchemaByName: MarkerSchemaByName, stringIndexFieldsByDataType: Map, stringTable: StringTable ): { [key: string]: any } | undefined { @@ -1339,7 +1354,7 @@ function collectMarkerData( } const stringIndexFields = stringIndexFieldsByDataType.get(payload.type); - const elidedFields = ELIDED_PAYLOAD_FIELDS.get(payload.type); + const schema = markerSchemaByName[payload.type]; const data: { [key: string]: any } = {}; for (const [key, value] of Object.entries(payload)) { if (OMITTED_PAYLOAD_KEYS.has(key) || value === undefined) { @@ -1349,15 +1364,10 @@ function collectMarkerData( stringIndexFields?.includes(key) && typeof value === 'number' ? stringTable.getString(value, '(empty)') : value; - if (elidedFields?.has(key) && typeof resolved === 'string') { - data[key] = { - elided: true, - length: resolved.length, - preview: resolved.slice(0, 64), - } satisfies ElidedDataValue; - } else { - data[key] = resolved; - } + data[key] = truncateScreenshotDataUrl( + resolved, + schema?.fields.find((field) => field.key === key)?.format + ); } // A payload of only `type` yields no keys here; report nothing. diff --git a/src/test/components/TooltipMarker.test.tsx b/src/test/components/TooltipMarker.test.tsx index 5f2ccc7c24..e18af68e04 100644 --- a/src/test/components/TooltipMarker.test.tsx +++ b/src/test/components/TooltipMarker.test.tsx @@ -12,6 +12,7 @@ import { import { TooltipMarker } from '../../components/tooltip/Marker'; import { storeWithProfile } from '../fixtures/stores'; import { + addCompositorScreenshotSchemaIfNeeded, addMarkersToThreadWithCorrespondingSamples, getProfileFromTextSamples, getNetworkMarkers, @@ -1184,6 +1185,7 @@ describe('TooltipMarker', function () { }, ], ]); + addCompositorScreenshotSchemaIfNeeded(profile); const store = storeWithProfile(profile); const { getState } = store; diff --git a/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap b/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap index 011f064231..19fbb30030 100644 --- a/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap +++ b/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap @@ -2037,7 +2037,11 @@ exports[`TooltipMarker renders the tooltip of CompositorScreenshotWindowDestroye Description :
- This marker shows the moment a window has been destroyed. +
+ This marker spans the time between each composite of a window and shows the window contents during that time. +
@@ -5189,6 +5193,17 @@ exports[`TooltipMarker shows image of CompositorScreenshot markers 1`] = `
+
+ Description + : +
+
+ This marker spans the time between each composite of a window and shows the window contents during that time. +
@@ -5206,17 +5221,7 @@ exports[`TooltipMarker shows image of CompositorScreenshot markers 1`] = ` Window Size :
- 600 - px × - 300 - px -
- Description - : -
- This marker spans the time between each composite of a window and shows the window contents during that time. + 600px × 300px
diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index 9032ddd9d7..6e24047412 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -420,9 +420,42 @@ export function getProfileWithMarkers( ...getThreadWithMarkers(profile.shared, testDefinedMarkers), tid: i, })); + addCompositorScreenshotSchemaIfNeeded(profile); return profile; } +export const compositorScreenshotMarkerSchema: MarkerSchema = { + name: 'CompositorScreenshot', + display: ['marker-chart', 'marker-table'], + fields: [ + { + key: 'url', + label: 'Image', + format: { + type: 'screenshot-data-url', + sizeFieldForAspectRatio: 'windowSize', + }, + }, + { key: 'windowSize', label: 'Window Size', format: 'screenshot-size' }, + { key: 'windowID', label: 'Window ID', format: 'string' }, + ], + description: + 'This marker spans the time between each composite of a window and shows the window contents during that time.', +}; + +export function addCompositorScreenshotSchemaIfNeeded(profile: Profile): void { + if ( + profile.threads.some((thread) => + thread.markers.data.some((data) => data?.type === 'CompositorScreenshot') + ) + ) { + profile.meta.markerSchema = [ + ...profile.meta.markerSchema, + compositorScreenshotMarkerSchema, + ]; + } +} + /** * This profile is useful for marker table tests. The markers were taken from * real-world values. diff --git a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap index 9ae12c5ce4..757d590fba 100644 --- a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap @@ -2304,6 +2304,7 @@ Object { "interval": 0.5, "markerSchemaNames": Array [ "EventDispatch", + "CompositorScreenshot", ], "preprocessedProfileVersion": 76, "product": "Chrome Trace", @@ -2696,6 +2697,7 @@ Object { "interval": 0.5, "markerSchemaNames": Array [ "EventDispatch", + "CompositorScreenshot", ], "preprocessedProfileVersion": 76, "product": "Chrome Trace", diff --git a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap index f1b839e349..d6325126f7 100644 --- a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap @@ -10794,6 +10794,34 @@ Object { ], "name": "tracing", }, + Object { + "description": "This marker spans the time between each composite of a window and shows the window contents during that time.", + "display": Array [ + "marker-chart", + "marker-table", + ], + "fields": Array [ + Object { + "format": Object { + "sizeFieldForAspectRatio": "windowSize", + "type": "screenshot-data-url", + }, + "key": "url", + "label": "Image", + }, + Object { + "format": "screenshot-size", + "key": "windowSize", + "label": "Window Size", + }, + Object { + "format": "string", + "key": "windowID", + "label": "Window ID", + }, + ], + "name": "CompositorScreenshot", + }, ], "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", diff --git a/src/test/unit/marker-schema.test.ts b/src/test/unit/marker-schema.test.ts index b30b3de50d..db4fba7a30 100644 --- a/src/test/unit/marker-schema.test.ts +++ b/src/test/unit/marker-schema.test.ts @@ -8,6 +8,7 @@ import { extensionTextMarkerSchema, markerSchemaFrontEndOnly, isStringIndexFormat, + computeStringIndexMarkerFieldsByDataType, } from '../../profile-logic/marker-schema'; import { renderMarkerFieldValue } from 'firefox-profiler/components/tooltip/Marker'; import type { @@ -18,7 +19,10 @@ import type { import { getDefaultCategories } from '../../profile-logic/data-structures'; import { storeWithProfile } from '../fixtures/stores'; import { getMarkerSchema } from '../../selectors/profile'; -import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { + getProfileFromTextSamples, + compositorScreenshotMarkerSchema, +} from '../fixtures/profiles/processed-profile'; import { FILE_IO_TABLE_LABEL, markerSchemaForTests, @@ -625,6 +629,14 @@ describe('computeStringIndexMarkerFieldsByDataType', function () { }) ).toBe(true); }); + + it('finds screenshot data URLs in the screenshot marker schema', function () { + expect( + computeStringIndexMarkerFieldsByDataType([ + compositorScreenshotMarkerSchema, + ]) + ).toEqual(new Map([['CompositorScreenshot', ['url']]])); + }); }); describe('getMarkerSchema', function () { diff --git a/src/test/unit/merge-compare.test.ts b/src/test/unit/merge-compare.test.ts index b7703880ee..c2b7624b53 100644 --- a/src/test/unit/merge-compare.test.ts +++ b/src/test/unit/merge-compare.test.ts @@ -11,6 +11,7 @@ import { stateFromLocation } from '../../app-logic/url-handling'; import { getProfileFromTextSamples, getProfileWithMarkers, + addCompositorScreenshotSchemaIfNeeded, addMarkersToThreadWithCorrespondingSamples, } from '../fixtures/profiles/processed-profile'; import { markerSchemaForTests } from '../fixtures/profiles/marker-schema'; @@ -389,11 +390,7 @@ describe('mergeProfilesForDiffing function', function () { ], ] ); - profileB.meta.markerSchema.push({ - name: 'CompositorScreenshot', - display: ['marker-chart', 'marker-table'], - fields: [{ key: 'url', label: 'Image', format: 'unique-string' }], - }); + addCompositorScreenshotSchemaIfNeeded(profileB); const profileState = stateFromLocation({ pathname: '/public/fakehash1/', diff --git a/src/test/unit/profile-query/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts index 18d333efb7..4d6530b398 100644 --- a/src/test/unit/profile-query/marker-utils.test.ts +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -23,6 +23,7 @@ import { getProfileWithMarkers, getProfileFromTextSamples, getNetworkMarkers, + compositorScreenshotMarkerSchema, } from '../../fixtures/profiles/processed-profile'; import type { NetworkMarkersOptions, @@ -998,13 +999,14 @@ describe('collectThreadMarkers list option', function () { * Build a profile with one CompositorScreenshot marker whose `url` is a real * index into the string table, rather than an already-resolved string. * - * Index resolution is driven by the marker schema: only fields the schema - * declares as unique-string are looked up. `markerSchemaForTests` has no - * CompositorScreenshot entry, so `url` reaches the output as the raw index - * and the elision path sees it unresolved. + * The screenshot schema identifies `url` as a string-table index holding image data. */ function setupWithScreenshotMarker(url: string) { const { profile } = getProfileFromTextSamples('someFunc'); + profile.meta.markerSchema = [ + ...profile.meta.markerSchema, + compositorScreenshotMarkerSchema, + ]; const thread = profile.threads[0]; const stringTable = StringTable.withBackingArray( profile.shared.stringArray @@ -1012,7 +1014,6 @@ describe('collectThreadMarkers list option', function () { const urlIdx = stringTable.indexForString(url); const markerNameIdx = stringTable.indexForString('CompositorScreenshot'); const markers = getRawMarkerTableBuilderFromExisting(thread.markers); - thread.markers = markers; markers.name.push(markerNameIdx); markers.startTime.push(1); markers.endTime.push(null); @@ -1025,6 +1026,7 @@ describe('collectThreadMarkers list option', function () { windowSize: { width: 1280, height: 951 }, }); markers.length++; + thread.markers = finishRawMarkerTableBuilder(markers); const store = storeWithProfile(profile); const threadMap = new ThreadMap(); @@ -1085,9 +1087,13 @@ describe('collectThreadMarkers list option', function () { length: url.length, preview: url.slice(0, 64), }); - // The small fields next to it must survive: for this marker type there is - // no schema, so `data` is the only route to the window dimensions. - expect(m.fields).toBeUndefined(); + expect(m.fields!.find(({ key }) => key === 'url')!.value).toEqual( + m.data!.url + ); + expect(m.fields!.find(({ key }) => key === 'windowSize')!.value).toEqual({ + width: 1280, + height: 951, + }); expect(m.data!.windowSize).toEqual({ width: 1280, height: 951 }); // The whole row must stay small. expect(JSON.stringify(m).length).toBeLessThan(1000); From cf6c212b8beaa2b18f4c313cdf2b2e218d378c2c Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 24 Sep 2026 16:16:46 +0200 Subject: [PATCH 13/24] Store screenshot markers as start and end marker pairs Encode screenshot lifetimes as start and end marker pairs and include the window ID in their names. Generic interval derivation can then match screenshots without its own per-window bookkeeping. Window destruction closes the last screenshot. An unclosed window leaves an incomplete interval extending to the end of the thread. --- docs-developer/CHANGELOG-formats.md | 2 + src/profile-logic/import/chrome.ts | 14 +- src/profile-logic/marker-data.ts | 68 ---------- src/profile-logic/process-profile.ts | 16 ++- .../process-screenshot-markers.ts | 103 ++++++++++++++- .../processed-profile-versioning.ts | 93 +++++++++++++- src/profile-logic/tracks.ts | 36 +++--- src/test/components/TooltipMarker.test.tsx | 16 --- src/test/components/TrackContextMenu.test.tsx | 6 +- src/test/components/TrackScreenshots.test.tsx | 15 +-- .../__snapshots__/TooltipMarker.test.tsx.snap | 70 +--------- .../TrackScreenshots.test.tsx.snap | 22 ++-- .../fixtures/profiles/processed-profile.ts | 83 +++++++----- src/test/store/profile-view.test.ts | 8 +- .../__snapshots__/marker-data.test.ts.snap | 3 +- .../profile-conversion.test.ts.snap | 12 +- .../profile-upgrading.test.ts.snap | 28 ++-- src/test/unit/marker-data.test.ts | 120 ++++++++++++------ 18 files changed, 414 insertions(+), 301 deletions(-) diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index 7233b0ccbf..2338d8b646 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -10,6 +10,8 @@ Note that this is not an exhaustive list. Processed profile format upgraders can The `CompositorScreenshot` marker payload's `windowWidth` and `windowHeight` fields were replaced with a single `windowSize` field of the form `{ width, height }`. The `windowID` field is now always a string. +These markers are now stored as pairs of start and end markers instead of instant markers. The window ID is part of the marker name (`CompositorScreenshot `), so starts and ends are matched by name like any other marker pair. The `CompositorScreenshotWindowDestroyed` marker is gone: it is now the end marker of that window's last screenshot. The last screenshot of a window that is never destroyed has no end marker, and is extended to the end of the thread. + Two marker schema field formats were added to describe these markers: `screenshot-size`, whose value is a `{ width, height }` object, and `screenshot-data-url`, an object format `{ type: "screenshot-data-url", sizeFieldForAspectRatio }` whose value is a string table index holding an image data URL. ### Version 75 diff --git a/src/profile-logic/import/chrome.ts b/src/profile-logic/import/chrome.ts index 7a9ad62b04..71f4318e85 100644 --- a/src/profile-logic/import/chrome.ts +++ b/src/profile-logic/import/chrome.ts @@ -31,7 +31,10 @@ import { } from 'firefox-profiler/app-logic/constants'; import { getTimeRangeForThread } from '../profile-data'; -import { compositorScreenshotMarkerSchema } from '../process-screenshot-markers'; +import { + compositorScreenshotMarkerSchema, + convertScreenshotMarkersToStartEnd, +} from '../process-screenshot-markers'; import { GlobalDataCollector } from '../global-data-collector'; // Chrome Tracing Event Spec: @@ -854,10 +857,13 @@ async function processTracingEvents( for (const [thread, threadInfo] of threadInfoByThread) { thread.samples = finishRawSamplesTableBuilder(threadInfo.samples); thread.markers = finishRawMarkerTableBuilder(threadInfo.markers); - if ( - thread.markers.data.some((data) => data?.type === 'CompositorScreenshot') - ) { + const markers = convertScreenshotMarkersToStartEnd( + thread.markers, + stringTable + ); + if (markers !== null) { hasScreenshots = true; + thread.markers = markers; } } if (hasScreenshots) { diff --git a/src/profile-logic/marker-data.ts b/src/profile-logic/marker-data.ts index 669fb11840..0f63eea0d7 100644 --- a/src/profile-logic/marker-data.ts +++ b/src/profile-logic/marker-data.ts @@ -563,7 +563,6 @@ export function correlateIPCMarkers( * the thread range. For the reverse situation, it's set to the start. * * There is also some special handling of different markers. - * - CompositorScreenshot - They are turned from Instant markers to Interval markers * - IPC - They are matched up. * - Network - They have different network phases. * @@ -621,10 +620,6 @@ export function deriveMarkersFromRawMarkerTable( } as MarkerPayload; } - // We don't add a screenshot marker as we find it, because to know its - // duration we need to wait until the next one or the end of the profile. So - // we keep it here. - const previousScreenshotMarkers: Map = new Map(); for ( let rawMarkerIndex = 0; rawMarkerIndex < rawMarkers.length; @@ -732,51 +727,6 @@ export function deriveMarkersFromRawMarkerTable( continue; } - case 'CompositorScreenshot': { - // Screenshot markers are already ordered. In the raw marker table, - // they're Instant markers, but since they're valid until the following - // raw marker of the same type and the same window, we convert them to - // Interval markers with a a start and end time. - - const { windowID } = data; - const previousScreenshotMarker = - previousScreenshotMarkers.get(windowID); - if (previousScreenshotMarker !== undefined) { - previousScreenshotMarkers.delete(windowID); - const previousStartTime = ensureExists( - rawMarkers.startTime[previousScreenshotMarker], - 'Expected to find a start time for a screenshot marker.' - ); - const thisStartTime = ensureExists( - maybeStartTime, - 'The CompositorScreenshot is assumed to have a start time.' - ); - const data = rawMarkers.data[previousScreenshotMarker]; - const markerThreadId = rawMarkers.threadId - ? rawMarkers.threadId[previousScreenshotMarker] - : null; - addMarker([previousScreenshotMarker], { - start: previousStartTime, - end: thisStartTime, - name: 'CompositorScreenshot', - category, - threadId: markerThreadId, - data, - }); - } - if (stringArray[name] === 'CompositorScreenshotWindowDestroyed') { - // This marker is added when a window is destroyed. In this case we - // don't want to store it as the start of the next compositor - // marker. But we do want to keep it, so we break out of the - // switch/case so that the standard processing happens. - break; - } else { - previousScreenshotMarkers.set(windowID, rawMarkerIndex); - } - - continue; - } - case 'IPC': { const sharedData = ipcCorrelations.get( // Older profiles don't have a tid, but they also don't have the IPC markers. @@ -994,24 +944,6 @@ export function deriveMarkersFromRawMarkerTable( }); } - // And we also need to add the "last screenshot markers". - for (const previousScreenshotMarker of previousScreenshotMarkers.values()) { - const start = ensureExists( - rawMarkers.startTime[previousScreenshotMarker], - 'Expected to find a CompositorScreenshot marker with a start time.' - ); - addMarker([previousScreenshotMarker], { - start, - end: Math.max(endOfThread, start), - name: 'CompositorScreenshot', - category: rawMarkers.category[previousScreenshotMarker], - threadId: rawMarkers.threadId - ? rawMarkers.threadId[previousScreenshotMarker] - : null, - data: rawMarkers.data[previousScreenshotMarker], - }); - } - return { markers, markerIndexToRawMarkerIndexes }; } diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index a279986563..0a52545713 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -66,7 +66,10 @@ import { extensionTextMarkerSchema, } from '../profile-logic/marker-schema'; import { convertJsTracerToThread } from '../profile-logic/js-tracer'; -import { compositorScreenshotMarkerSchema } from './process-screenshot-markers'; +import { + compositorScreenshotMarkerSchema, + convertScreenshotMarkersToStartEnd, +} from './process-screenshot-markers'; import type { StringTable } from '../utils/string-table'; import type { @@ -2048,12 +2051,13 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { stringIndexMarkerFieldsByDataType, globalDataCollector ); - if ( - newThread.markers.data.some( - (data) => data?.type === 'CompositorScreenshot' - ) - ) { + const markers = convertScreenshotMarkersToStartEnd( + newThread.markers, + globalDataCollector.getStringTable() + ); + if (markers !== null) { hasCompositorScreenshots = true; + newThread.markers = markers; } return newThread; }; diff --git a/src/profile-logic/process-screenshot-markers.ts b/src/profile-logic/process-screenshot-markers.ts index dc39bc7d1b..311751e26d 100644 --- a/src/profile-logic/process-screenshot-markers.ts +++ b/src/profile-logic/process-screenshot-markers.ts @@ -3,7 +3,21 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { oneLine } from 'common-tags'; -import type { MarkerSchema } from 'firefox-profiler/types'; +import { INTERVAL_START, INTERVAL_END } from '../app-logic/constants'; +import { + getRawMarkerTableBuilder, + finishRawMarkerTableBuilder, +} from './data-structures'; +import { ensureExists } from '../utils/types'; +import type { StringTable } from '../utils/string-table'; +import type { + IndexIntoStringTable, + MarkerPayload, + MarkerPhase, + MarkerSchema, + Milliseconds, + RawMarkerTable, +} from 'firefox-profiler/types'; export const compositorScreenshotMarkerSchema: MarkerSchema = { name: 'CompositorScreenshot', @@ -25,3 +39,90 @@ export const compositorScreenshotMarkerSchema: MarkerSchema = { the window contents during that time. `, }; + +/** + * Gecko emits one instant marker per composite of a window, plus a + * CompositorScreenshotWindowDestroyed marker when the window goes away. + * Each screenshot is valid until the next one for the same window, so rewrite + * them into start / end marker pairs. + * A window that is never destroyed keeps its last screenshot open, + * so that marker gets extended to the end of the thread. + * + * Returns null when the table has no screenshot markers. + */ +export function convertScreenshotMarkersToStartEnd( + markers: RawMarkerTable, + stringTable: StringTable +): RawMarkerTable | null { + const hasScreenshots = markers.data.some( + (data) => data !== null && data.type === 'CompositorScreenshot' + ); + if (!hasScreenshots) { + return null; + } + + const newMarkers = getRawMarkerTableBuilder(); + const { threadId } = markers; + if (threadId !== undefined) { + newMarkers.threadId = []; + } + const openWindows = new Set(); + + function push( + name: IndexIntoStringTable, + startTime: Milliseconds | null, + endTime: Milliseconds | null, + phase: MarkerPhase, + sourceIndex: number, + data: MarkerPayload | null + ) { + newMarkers.name.push(name); + newMarkers.startTime.push(startTime); + newMarkers.endTime.push(endTime); + newMarkers.phase.push(phase); + newMarkers.category.push(markers.category[sourceIndex]); + newMarkers.data.push(data); + if (threadId !== undefined) { + ensureExists(newMarkers.threadId).push(threadId[sourceIndex]); + } + newMarkers.length++; + } + + for (let i = 0; i < markers.length; i++) { + const data = markers.data[i]; + if (data === null || data.type !== 'CompositorScreenshot') { + push( + markers.name[i], + markers.startTime[i], + markers.endTime[i], + markers.phase[i], + i, + data + ); + continue; + } + + const { windowID } = data; + const time = markers.startTime[i]; + const name = stringTable.indexForString(`CompositorScreenshot ${windowID}`); + const isWindowDestroyed = + stringTable.getString(markers.name[i]) === + 'CompositorScreenshotWindowDestroyed'; + if (openWindows.delete(windowID) || isWindowDestroyed) { + // The end marker carries the payload type so that code walking the raw table + // can recognize it during sanitization. + push(name, null, time, INTERVAL_END, i, { + type: 'CompositorScreenshot', + windowID, + }); + } + if (isWindowDestroyed) { + continue; + } + + push(name, time, null, INTERVAL_START, i, data); + openWindows.add(windowID); + } + + return finishRawMarkerTableBuilder(newMarkers); +} diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index 8c21e0d485..ecce65c657 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -3672,25 +3672,104 @@ const _upgraders: { }, [76]: (profile: any) => { // The CompositorScreenshot marker payload's `windowWidth` and `windowHeight` fields were replaced with - // a single `windowSize` field, and `windowID` is now always a string. + // a single `windowSize` field, `windowID` is now always a string, and these markers are now stored as + // start / end marker pairs whose name carries the window ID, instead of instant markers. + // The CompositorScreenshotWindowDestroyed marker becomes the end marker of that window's last screenshot. // A CompositorScreenshot marker schema is added if there are any CompositorScreenshot markers. + const INTERVAL_START = 2; + const INTERVAL_END = 3; + const stringTable = StringTable.withBackingArray( + profile.shared.stringArray + ); + let hasCompositorScreenshots = false; for (const thread of profile.threads) { const { markers } = thread; + if ( + !markers.data.some((data: any) => data?.type === 'CompositorScreenshot') + ) { + continue; + } + hasCompositorScreenshots = true; + + const newMarkers: any = { + data: [], + name: [], + startTime: [], + endTime: [], + phase: [], + category: [], + length: 0, + }; + const hasThreadId = Boolean(markers.threadId); + if (hasThreadId) { + newMarkers.threadId = []; + } + + const push = ( + name: number, + startTime: number | null, + endTime: number | null, + phase: number, + sourceIndex: number, + data: any + ) => { + newMarkers.name.push(name); + newMarkers.startTime.push(startTime); + newMarkers.endTime.push(endTime); + newMarkers.phase.push(phase); + newMarkers.category.push(markers.category[sourceIndex]); + newMarkers.data.push(data); + if (hasThreadId) { + newMarkers.threadId.push(markers.threadId[sourceIndex]); + } + newMarkers.length++; + }; + + const openWindows = new Set(); for (let i = 0; i < markers.length; i++) { const data = markers.data[i]; if (!data || data.type !== 'CompositorScreenshot') { + push( + markers.name[i], + markers.startTime[i], + markers.endTime[i], + markers.phase[i], + i, + data + ); continue; } - const { windowWidth, windowHeight } = data; - hasCompositorScreenshots = true; - data.windowID = String(data.windowID); + + const windowID = String(data.windowID); + const time = markers.startTime[i]; + const name = stringTable.indexForString( + `CompositorScreenshot ${windowID}` + ); + const isWindowDestroyed = + stringTable.getString(markers.name[i]) === + 'CompositorScreenshotWindowDestroyed'; + if (openWindows.delete(windowID) || isWindowDestroyed) { + push(name, null, time, INTERVAL_END, i, { + type: 'CompositorScreenshot', + windowID, + }); + } + if (isWindowDestroyed) { + continue; + } + + const { windowWidth, windowHeight, ...startData } = data; + startData.windowID = windowID; if (windowWidth !== undefined && windowHeight !== undefined) { - data.windowSize = { width: windowWidth, height: windowHeight }; + startData.windowSize = { width: windowWidth, height: windowHeight }; } - delete data.windowWidth; - delete data.windowHeight; + + push(name, time, null, INTERVAL_START, i, startData); + openWindows.add(windowID); } + + thread.markers = newMarkers; } profile.meta.markerSchema = profile.meta.markerSchema.filter( diff --git a/src/profile-logic/tracks.ts b/src/profile-logic/tracks.ts index e78533ef31..563a01c9e8 100644 --- a/src/profile-logic/tracks.ts +++ b/src/profile-logic/tracks.ts @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { - ScreenshotPayload, Profile, RawProfileSharedData, RawThread, @@ -29,9 +28,9 @@ import { getMarkerTypesForDisplay, } from './marker-data'; import { intersectSets, subtractSets } from '../utils/set'; -import { StringTable } from '../utils/string-table'; import { splitSearchString, stringsToRegExp } from '../utils/string'; import { ensureExists, assertExhaustiveCheck } from '../utils/types'; +import { INTERVAL_END } from '../app-logic/constants'; export type TracksWithOrder = { readonly globalTracks: GlobalTrack[]; @@ -717,12 +716,6 @@ export function computeGlobalTracks( let globalTracks: GlobalTrack[] = []; // Create the global tracks. - const { stringArray } = profile.shared; - const stringTable = StringTable.withBackingArray(stringArray); - const screenshotNameIndex = stringTable.hasString('CompositorScreenshot') - ? stringTable.indexForString('CompositorScreenshot') - : null; - for ( let threadIndex = 0; threadIndex < profile.threads.length; @@ -762,20 +755,23 @@ export function computeGlobalTracks( } } - // Check for screenshots. + // Windows must keep being added in the order their first screenshot was taken: + // shared URLs refer to global tracks by index, + // so a different order would change what an existing URL selects. const ids: Set = new Set(); - if (screenshotNameIndex !== null) { - for (let markerIndex = 0; markerIndex < markers.length; markerIndex++) { - if (markers.name[markerIndex] === screenshotNameIndex) { - // Coerce the payload to a screenshot one. Don't do a runtime check that - // this is correct. - const data = markers.data[markerIndex] as ScreenshotPayload; - ids.add(data.windowID); - } - } - for (const id of ids) { - globalTracks.push({ type: 'screenshots', id, threadIndex }); + for (let markerIndex = 0; markerIndex < markers.length; markerIndex++) { + const data = markers.data[markerIndex]; + if ( + markers.phase[markerIndex] === INTERVAL_END || + data === null || + data.type !== 'CompositorScreenshot' + ) { + continue; } + ids.add(data.windowID); + } + for (const id of ids) { + globalTracks.push({ type: 'screenshots', id, threadIndex }); } } diff --git a/src/test/components/TooltipMarker.test.tsx b/src/test/components/TooltipMarker.test.tsx index e18af68e04..b8dbd6a589 100644 --- a/src/test/components/TooltipMarker.test.tsx +++ b/src/test/components/TooltipMarker.test.tsx @@ -1205,22 +1205,6 @@ describe('TooltipMarker', function () { expect(container.firstChild).toMatchSnapshot(); }); - it('renders the tooltip of CompositorScreenshotWindowDestroyed', () => { - setupWithPayload([ - [ - 'CompositorScreenshotWindowDestroyed', - 1, - 2, - { - type: 'CompositorScreenshot', - windowID: 'XXX', - }, - ], - ]); - - expect(document.body).toMatchSnapshot(); - }); - it('shows the source thread for markers from a merged thread', function () { // We construct a profile that has 2 threads from 2 different tabs. const tab1Domain = 'https://mozilla.org'; diff --git a/src/test/components/TrackContextMenu.test.tsx b/src/test/components/TrackContextMenu.test.tsx index 89807f1bdc..813a5bd347 100644 --- a/src/test/components/TrackContextMenu.test.tsx +++ b/src/test/components/TrackContextMenu.test.tsx @@ -36,7 +36,7 @@ import { getScreenshotTrackProfile, getNetworkTrackProfile, addIPCMarkerPairToThreads, - getThreadWithMarkers, + getThreadWithRawMarkers, getScreenshotMarkersForWindowId, } from '../fixtures/profiles/processed-profile'; @@ -1229,14 +1229,14 @@ describe('timeline/TrackContextMenu', function () { // add a couple of global screenshots tracks profile.threads.push({ - ...getThreadWithMarkers( + ...getThreadWithRawMarkers( profile.shared, getScreenshotMarkersForWindowId('0', 5) ), tid: profile.threads.length, }); profile.threads.push({ - ...getThreadWithMarkers( + ...getThreadWithRawMarkers( profile.shared, getScreenshotMarkersForWindowId('1', 5) ), diff --git a/src/test/components/TrackScreenshots.test.tsx b/src/test/components/TrackScreenshots.test.tsx index 43ccf90be8..6fbfff7b06 100644 --- a/src/test/components/TrackScreenshots.test.tsx +++ b/src/test/components/TrackScreenshots.test.tsx @@ -204,8 +204,10 @@ describe('timeline/TrackScreenshots', function () { const profile = getScreenshotTrackProfile(); const { shared, threads } = profile; const [thread] = threads; - const markerIndexA = thread.markers.length - 3; - const markerIndexB = thread.markers.length - 2; + // Screenshots are stored as start / end pairs, so these are the start markers + // of the second to last and third to last screenshots. + const markerIndexA = thread.markers.length - 5; + const markerIndexB = thread.markers.length - 3; // We keep the last marker so that the profile's root range is correct. _setScreenshotMarkersToUnknown(thread, shared, markerIndexA, markerIndexB); @@ -232,8 +234,9 @@ describe('timeline/TrackScreenshots', function () { const { shared, threads } = profile; const [thread] = threads; + // The start markers of the first two screenshots. const markerIndexA = 0; - const markerIndexB = 1; + const markerIndexB = 2; _setScreenshotMarkersToUnknown(thread, shared, markerIndexA, markerIndexB); @@ -396,15 +399,11 @@ function _setScreenshotMarkersToUnknown( shared: RawProfileSharedData, ...markerIndexes: IndexIntoRawMarkerTable[] ) { - // Remove off the last few screenshot markers const stringTable = StringTable.withBackingArray(shared.stringArray); const unknownStringIndex = stringTable.indexForString('Unknown'); - const screenshotStringIndex = stringTable.indexForString( - 'CompositorScreenshot' - ); for (const markerIndex of markerIndexes) { // Double check that we've actually got screenshot markers: - if (thread.markers.name[markerIndex] !== screenshotStringIndex) { + if (thread.markers.data[markerIndex]?.type !== 'CompositorScreenshot') { throw new Error('This is not a screenshot marker.'); } thread.markers.name[markerIndex] = unknownStringIndex; diff --git a/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap b/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap index 19fbb30030..f5fda6d905 100644 --- a/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap +++ b/src/test/components/__snapshots__/TooltipMarker.test.tsx.snap @@ -1994,74 +1994,6 @@ exports[`TooltipMarker renders properly redirect network markers without additio
`; -exports[`TooltipMarker renders the tooltip of CompositorScreenshotWindowDestroyed 1`] = ` - -
-
-
-
-
- 1ms -
-
- - CompositorScreenshotWindowDestroyed - -
-
-
-
-
- Description - : -
-
- This marker spans the time between each composite of a window and shows the window contents during that time. -
-
- Window ID - : -
- XXX -
- Track - : -
- Empty -
-
-
- -`; - exports[`TooltipMarker renders tooltips for various markers: Bailout_ShapeGuard after getelem on line 3666 of resource://foo.js -> resource://bar.js:3662-10 1`] = `
- 2ms + 1ms
@@ -24,7 +24,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` >
@@ -34,7 +34,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` >
@@ -44,7 +44,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > @@ -54,7 +54,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > @@ -64,7 +64,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > @@ -74,7 +74,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > @@ -84,7 +84,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > @@ -94,7 +94,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > @@ -104,7 +104,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > @@ -114,7 +114,7 @@ exports[`timeline/TrackScreenshots matches the component snapshot 1`] = ` > diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index 6e24047412..924a7f03e8 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -372,19 +372,33 @@ export function makeIntervalMarker( * A utility to make TestDefinedRawMarker */ export function makeCompositorScreenshot( - startTime: Milliseconds + startTime: Milliseconds, + windowID: string = '0' ): TestDefinedRawMarker { return { - ...makeInstantMarker('CompositorScreenshot', startTime), + ...makeStartMarker(`CompositorScreenshot ${windowID}`, startTime), data: { type: 'CompositorScreenshot', url: 0, - windowID: '', - windowSize: { width: 100, height: 100 }, + windowID, + windowSize: { width: 300, height: 150 }, }, }; } +/** + * A utility to make TestDefinedRawMarker + */ +export function makeCompositorScreenshotEnd( + endTime: Milliseconds, + windowID: string = '0' +): TestDefinedRawMarker { + return { + ...makeEndMarker(`CompositorScreenshot ${windowID}`, endTime), + data: { type: 'CompositorScreenshot', windowID }, + }; +} + export function getUserTiming( name: string, startTime: Milliseconds, @@ -424,6 +438,20 @@ export function getProfileWithMarkers( return profile; } +export function getProfileWithRawMarkers( + ...markersPerThread: TestDefinedRawMarker[][] +): Profile { + const profile = getEmptyProfile(); + profile.meta.markerSchema = markerSchemaForTests; + + profile.threads = markersPerThread.map((markers, i) => ({ + ...getThreadWithRawMarkers(profile.shared, markers), + tid: i, + })); + addCompositorScreenshotSchemaIfNeeded(profile); + return profile; +} + export const compositorScreenshotMarkerSchema: MarkerSchema = { name: 'CompositorScreenshot', display: ['marker-chart', 'marker-table'], @@ -1408,39 +1436,34 @@ export function getIPCTrackProfile() { return getProfileWithMarkers(arrayOfIPCMarkers); } +/** + * One screenshot per millisecond, starting at 0. + * Each one ends where the next one starts; + * the last one is left open unless `destroyTime` is given. + */ export function getScreenshotMarkersForWindowId( windowID: string, - count: number -): TestDefinedMarker[] { - return Array(count) - .fill(undefined) - .map((_, i) => [ - 'CompositorScreenshot', - i, - null, - { - type: 'CompositorScreenshot', - url: 0, // Some arbitrary string. - windowID, - windowSize: { width: 300, height: 150 }, - }, - ]); + count: number, + destroyTime: Milliseconds | null = null +): TestDefinedRawMarker[] { + const markers: TestDefinedRawMarker[] = []; + for (let i = 0; i < count; i++) { + if (i > 0) { + markers.push(makeCompositorScreenshotEnd(i, windowID)); + } + markers.push(makeCompositorScreenshot(i, windowID)); + } + if (destroyTime !== null) { + markers.push(makeCompositorScreenshotEnd(destroyTime, windowID)); + } + return markers; } export function getScreenshotTrackProfile() { - return getProfileWithMarkers([ + return getProfileWithRawMarkers([ ...getScreenshotMarkersForWindowId('0', 5), // This window isn't closed, so we should repeat the last screenshot - ...getScreenshotMarkersForWindowId('1', 5), // This window is closed after screenshot 6. + ...getScreenshotMarkersForWindowId('1', 5, 6), // This window is closed after screenshot 6. ...getScreenshotMarkersForWindowId('2', 10), // This window isn't closed and define the profile length - [ - 'CompositorScreenshotWindowDestroyed', - 6, - null, - { - type: 'CompositorScreenshot', - windowID: '1', - }, - ], ]); } diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 2d9401ef55..5aebb5625e 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -2099,9 +2099,9 @@ describe('actions/ProfileView', function () { const screenshots = screenshotMarkersById.get('0'); expect(screenshots?.length).toEqual(5); for (const screenshot of screenshots ?? []) { - expect(screenshot.name).toEqual('CompositorScreenshot'); + expect(screenshot.name).toEqual('CompositorScreenshot 0'); } - expect(screenshotMarkersById.get('1')?.length).toEqual(6); + expect(screenshotMarkersById.get('1')?.length).toEqual(5); expect(screenshotMarkersById.get('2')?.length).toEqual(10); }); @@ -2110,9 +2110,9 @@ describe('actions/ProfileView', function () { const [{ markers }] = profile.threads; const { dispatch, getState } = storeWithProfile(profile); - // Double check that there are 21 markers in the test data, and commit a + // Double check that there are 38 raw markers in the test data, and commit a // subsection of that range. - expect(markers.length).toBe(21); + expect(markers.length).toBe(38); dispatch(ProfileView.commitRange(3.1, 7.5)); // Get out the markers. diff --git a/src/test/unit/__snapshots__/marker-data.test.ts.snap b/src/test/unit/__snapshots__/marker-data.test.ts.snap index 344a6dbae1..47bf7283f3 100644 --- a/src/test/unit/__snapshots__/marker-data.test.ts.snap +++ b/src/test/unit/__snapshots__/marker-data.test.ts.snap @@ -186,7 +186,8 @@ Array [ }, }, "end": 25, - "name": "CompositorScreenshot", + "incomplete": true, + "name": "CompositorScreenshot 0x136888400", "start": 25, "threadId": null, }, diff --git a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap index 757d590fba..787419bd3a 100644 --- a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap @@ -2318,17 +2318,17 @@ Object { "nativeSymbols": 0, "resources": 1, "stacks": 26, - "strings": 82, + "strings": 83, }, "threads": Array [ Object { "isMainThread": true, "jsAllocationCount": 0, - "markerCount": 483, + "markerCount": 512, "markerNamesTop": Array [ "MessageLoop::RunTask (376)", "BrowserCrApplication::sendEvent (74)", - "CompositorScreenshot (30)", + "CompositorScreenshot id (59)", "LatencyInfo.Flow (2)", "TracingStartedInBrowser (1)", ], @@ -2711,17 +2711,17 @@ Object { "nativeSymbols": 0, "resources": 1, "stacks": 26, - "strings": 82, + "strings": 83, }, "threads": Array [ Object { "isMainThread": true, "jsAllocationCount": 0, - "markerCount": 483, + "markerCount": 512, "markerNamesTop": Array [ "MessageLoop::RunTask (376)", "BrowserCrApplication::sendEvent (74)", - "CompositorScreenshot (30)", + "CompositorScreenshot id (59)", "LatencyInfo.Flow (2)", "TracingStartedInBrowser (1)", ], diff --git a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap index d6325126f7..0963bc344d 100644 --- a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap @@ -11371,6 +11371,7 @@ Object { "firefox-webcontent", "setTimeout handler", "Element.getBoundingClientRect", + "CompositorScreenshot 42", ], }, "threads": Array [ @@ -11389,6 +11390,7 @@ Object { 5, 1, 4, + 4, 7, 4, ], @@ -11452,6 +11454,10 @@ Object { "type": "DOMEvent", }, Object {}, + Object { + "type": "CompositorScreenshot", + "windowID": "42", + }, Object { "type": "CompositorScreenshot", "url": 4, @@ -11496,40 +11502,43 @@ Object { null, 10, 12, + 12, null, 13, - null, + 14, ], - "length": 13, + "length": 14, "name": Array [ 0, 1, 2, 2, - 3, + 20, 1, 5, 6, 6, 7, - 3, + 20, + 20, 8, - 9, + 20, ], "phase": Array [ 0, 2, 2, 3, - 0, + 2, 3, 0, 2, 3, 1, - 0, + 3, + 2, 1, - 0, + 3, ], "startTime": Array [ 0, @@ -11542,9 +11551,10 @@ Object { 9, null, 11, + null, 12, 3, - 14, + null, ], }, "name": "GeckoMain", diff --git a/src/test/unit/marker-data.test.ts b/src/test/unit/marker-data.test.ts index d01bb97fbf..98237a94e8 100644 --- a/src/test/unit/marker-data.test.ts +++ b/src/test/unit/marker-data.test.ts @@ -31,6 +31,7 @@ import { makeIntervalMarker, makeInstantMarker, makeCompositorScreenshot, + makeCompositorScreenshotEnd, makeStartMarker, makeEndMarker, } from '../fixtures/profiles/processed-profile'; @@ -360,17 +361,17 @@ describe('Derive markers from Gecko phase markers', function () { ]); expect(profile.threads[0].markers.data).toEqual( - Array(3).fill( + Array(4).fill( expect.objectContaining({ type: 'CompositorScreenshot', windowID: String(windowID), }) ) ); - expect(markers.slice(0, 2)).toEqual( + expect(markers).toEqual( [1, 2].map((start) => expect.objectContaining({ - name: 'CompositorScreenshot', + name: `CompositorScreenshot ${windowID}`, start, end: start + 1, data: expect.objectContaining({ @@ -383,7 +384,7 @@ describe('Derive markers from Gecko phase markers', function () { } ); - it('has special handling for CompositorScreenshot', function () { + it('turns the instant CompositorScreenshot markers into intervals', function () { const basePayload = { type: 'CompositorScreenshot' as const, url: 16, @@ -408,44 +409,77 @@ describe('Derive markers from Gecko phase markers', function () { const startTimesForWindowA = [2, 5]; const startTimesForWindowB = [3, 6]; - const { markers, getState } = setupWithTestDefinedMarkers([ - { - name: 'CompositorScreenshot', - startTime: startTimesForWindowA[0], - endTime: null, - phase: INTERVAL_START, - data: payloadsForWindowA[0], - }, - { - name: 'CompositorScreenshot', - startTime: startTimesForWindowB[0], - endTime: null, - phase: INTERVAL_START, - data: payloadsForWindowB[0], - }, + const windowBDestroyedTime = 8; + const windowCDestroyedTime = 4; + + const screenshot = ( + startTime: number, + data: ScreenshotPayload + ): TestDefinedGeckoMarker => ({ + name: 'CompositorScreenshot', + startTime, + endTime: null, + phase: INSTANT, + data, + }); + + const { profile, markers, getState } = setupWithTestDefinedMarkers([ + screenshot(startTimesForWindowA[0], payloadsForWindowA[0]), + screenshot(startTimesForWindowB[0], payloadsForWindowB[0]), + screenshot(startTimesForWindowA[1], payloadsForWindowA[1]), + // Window C had no screenshot in the profile. { - name: 'CompositorScreenshot', - startTime: startTimesForWindowA[1], + name: 'CompositorScreenshotWindowDestroyed', + startTime: windowCDestroyedTime, endTime: null, - phase: INTERVAL_START, - data: payloadsForWindowA[1], + phase: INSTANT, + data: { + type: 'CompositorScreenshot', + windowID: '0xCCCCCCCCC', + }, }, + screenshot(startTimesForWindowB[1], payloadsForWindowB[1]), { - name: 'CompositorScreenshot', - startTime: startTimesForWindowB[1], + name: 'CompositorScreenshotWindowDestroyed', + startTime: windowBDestroyedTime, endTime: null, - phase: INTERVAL_START, - data: payloadsForWindowB[1], + phase: INSTANT, + data: { + type: 'CompositorScreenshot', + windowID: payloadsForWindowB[0].windowID, + }, }, ]); + expect(profile.meta.markerSchema).toContainEqual( + expect.objectContaining({ + name: 'CompositorScreenshot', + display: expect.arrayContaining(['marker-chart']), + }) + ); + const threadRange = selectedThreadSelectors.getThreadRange(getState()); expect(markers).toEqual([ - // The two firsts have a duration from the first screenshot to the next in + // Window C was destroyed without any screenshot, so its marker is only + // known to end there. + { + name: 'CompositorScreenshot 0xCCCCCCCCC', + data: { + type: 'CompositorScreenshot', + windowID: '0xCCCCCCCCC', + }, + start: threadRange.start, + end: windowCDestroyedTime, + category: 0, + threadId: null, + incomplete: true, + }, + + // The two next have a duration from the first screenshot to the next in // the same window. { - name: 'CompositorScreenshot', + name: 'CompositorScreenshot 0xAAAAAAAAA', data: { ...payloadsForWindowA[0], url: expect.anything(), @@ -456,7 +490,7 @@ describe('Derive markers from Gecko phase markers', function () { threadId: null, }, { - name: 'CompositorScreenshot', + name: 'CompositorScreenshot 0xBBBBBBBBB', data: { ...payloadsForWindowB[0], url: expect.anything(), @@ -467,9 +501,10 @@ describe('Derive markers from Gecko phase markers', function () { threadId: null, }, - // The 2 lasts have a duration until the end of the thread range. + // Window A is still open, so its last screenshot is extended to the end + // of the thread range. { - name: 'CompositorScreenshot', + name: 'CompositorScreenshot 0xAAAAAAAAA', data: { ...payloadsForWindowA[1], url: expect.anything(), @@ -478,15 +513,18 @@ describe('Derive markers from Gecko phase markers', function () { end: threadRange.end, category: 0, threadId: null, + incomplete: true, }, + + // Window B was destroyed, so its last screenshot ends there. { - name: 'CompositorScreenshot', + name: 'CompositorScreenshot 0xBBBBBBBBB', data: { ...payloadsForWindowB[1], url: expect.anything(), }, start: startTimesForWindowB[1], - end: threadRange.end, + end: windowBDestroyedTime, category: 0, threadId: null, }, @@ -558,7 +596,7 @@ describe('deriveMarkersFromRawMarkerTable', function () { 'tracing:ArbitraryName', 'Network:Load 32: https://github.com/rustwasm/wasm-bindgen/issues/5', 'FileIO:FileIO', - 'CompositorScreenshot:CompositorScreenshot', + 'CompositorScreenshot:CompositorScreenshot 0x136888400', 'PreferenceRead:PreferenceRead', 'Text:RefreshDriverTick', 'NoPayloadUserData:Navigation::Start', @@ -871,9 +909,10 @@ describe('deriveMarkersFromRawMarkerTable', function () { windowID: '0x136888400', windowSize: { width: 1280, height: 1000 }, }, - name: 'CompositorScreenshot', + name: 'CompositorScreenshot 0x136888400', start: 25, end: 25, + incomplete: true, }); }); }); @@ -1011,12 +1050,17 @@ describe('filterRawMarkerTableToRange', () => { end: 5.6, markers: [ makeCompositorScreenshot(0), + makeCompositorScreenshotEnd(3), makeCompositorScreenshot(3), + makeCompositorScreenshotEnd(7), makeCompositorScreenshot(7), ], }); - expect(Array.from(rawMarkerTable.startTime)).toEqual([0, 3]); + // Both markers of a screenshot's pair are kept, and the screenshot taken + // after the range is dropped. + expect(Array.from(rawMarkerTable.startTime)).toEqual([0, 0, 3, 0]); + expect(Array.from(rawMarkerTable.endTime)).toEqual([0, 3, 0, 7]); }); it('keeps a screenshot markers happening before the range if there is no other marker', () => { @@ -1029,7 +1073,7 @@ describe('filterRawMarkerTableToRange', () => { makeInstantMarker('EndMarkerOutOfRange', 8), ], }); - expect(processedMarkerNames).toEqual(['CompositorScreenshot']); + expect(processedMarkerNames).toEqual(['CompositorScreenshot 0']); }); it('filters network markers', () => { From 165565a6bb52e9c825de151dcf63ae122617ac49 Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 24 Sep 2026 16:18:26 +0200 Subject: [PATCH 14/24] Create screenshot tracks from marker schemas Select screenshot markers through the timeline-screenshots display location and group tracks by marker name. Read image fields from their schema so tracks do not depend on CompositorScreenshot payload names. Use the same classification during sanitization and comparison, and keep first-screenshot ordering so shared track indexes remain stable. --- docs-developer/CHANGELOG-formats.md | 2 + src/components/timeline/GlobalTrack.tsx | 7 +- src/components/timeline/TrackScreenshots.tsx | 73 ++++++++++---- src/profile-logic/marker-data.ts | 22 ----- src/profile-logic/merge-compare.ts | 30 +++--- .../process-screenshot-markers.ts | 2 +- .../processed-profile-versioning.ts | 2 +- src/profile-logic/sanitize.ts | 10 +- src/profile-logic/tracks.ts | 21 +++-- src/selectors/per-thread/markers.ts | 25 ++++- src/test/components/TrackContextMenu.test.tsx | 2 + src/test/components/TrackScreenshots.test.tsx | 94 ++++++++++++++++++- .../fixtures/profiles/processed-profile.ts | 2 +- src/test/store/profile-view.test.ts | 24 +++-- src/test/store/publish.test.ts | 3 + src/test/store/receive-profile.test.ts | 40 +++++++- src/test/store/tracks.test.ts | 52 ++++++++++ .../profile-upgrading.test.ts.snap | 1 + src/test/unit/marker-data.test.ts | 2 +- src/test/unit/sanitize.test.ts | 24 +++++ src/test/unit/tracks-sanitization.test.ts | 10 +- src/types/markers.ts | 2 + src/types/profile-derived.ts | 2 +- 23 files changed, 362 insertions(+), 90 deletions(-) diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index 2338d8b646..a7df5934b3 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -14,6 +14,8 @@ These markers are now stored as pairs of start and end markers instead of instan Two marker schema field formats were added to describe these markers: `screenshot-size`, whose value is a `{ width, height }` object, and `screenshot-data-url`, an object format `{ type: "screenshot-data-url", sizeFieldForAspectRatio }` whose value is a string table index holding an image data URL. +A new marker schema display location, `timeline-screenshots`, was added. Markers in this location create Screenshot tracks and are grouped by marker name. Markers for the same window must use the same name, and markers for different windows must use different names. + ### Version 75 The func table (`profile.shared.funcTable`) representation changed, mirroring the v71 frame table change: diff --git a/src/components/timeline/GlobalTrack.tsx b/src/components/timeline/GlobalTrack.tsx index a8046f1368..358c2ab57d 100644 --- a/src/components/timeline/GlobalTrack.tsx +++ b/src/components/timeline/GlobalTrack.tsx @@ -139,9 +139,12 @@ class GlobalTrackComponent extends PureComponent { ); } case 'screenshots': { - const { threadIndex, id } = globalTrack; + const { threadIndex, markerName } = globalTrack; return ( - + ); } case 'visual-progress': { diff --git a/src/components/timeline/TrackScreenshots.tsx b/src/components/timeline/TrackScreenshots.tsx index 282a55015d..6fe595ba97 100644 --- a/src/components/timeline/TrackScreenshots.tsx +++ b/src/components/timeline/TrackScreenshots.tsx @@ -6,6 +6,7 @@ import { PureComponent } from 'react'; import explicitConnect from 'firefox-profiler/utils/connect'; import { getCommittedRange, + getMarkerSchemaByName, getPreviewSelectionIsBeingModified, } from 'firefox-profiler/selectors/profile'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; @@ -16,10 +17,13 @@ import { import { updatePreviewSelection } from 'firefox-profiler/actions/profile-view'; import { createPortal } from 'react-dom'; import { computeScreenshotSize } from 'firefox-profiler/profile-logic/marker-data'; +import { getSchemaFromMarker } from 'firefox-profiler/profile-logic/marker-schema'; import { FULL_TRACK_SCREENSHOT_HEIGHT } from 'firefox-profiler/app-logic/constants'; import type { - ScreenshotPayload, + IndexIntoStringTable, + MarkerPayload, + MarkerSchemaByName, ThreadIndex, Thread, Marker, @@ -33,10 +37,11 @@ import './TrackScreenshots.css'; type OwnProps = { readonly threadIndex: ThreadIndex; - readonly windowId: string; + readonly markerName: string; }; type StateProps = { readonly thread: Thread; + readonly markerSchemaByName: MarkerSchemaByName; readonly rangeStart: Milliseconds; readonly rangeEnd: Milliseconds; readonly screenshots: Marker[]; @@ -127,6 +132,7 @@ class Screenshots extends PureComponent { const { screenshots, thread, + markerSchemaByName, isMakingPreviewSelection, width, rangeStart, @@ -134,12 +140,12 @@ class Screenshots extends PureComponent { } = this.props; const { pageX, offsetX, containerTop } = this.state; - let payload: ScreenshotPayload | null = null; + let payload: MarkerPayload | null = null; if (offsetX !== null) { const screenshotIndex = this.findScreenshotAtMouse(offsetX); if (screenshotIndex !== null) { - payload = screenshots[screenshotIndex].data as any; + payload = screenshots[screenshotIndex].data; } } @@ -153,6 +159,7 @@ class Screenshots extends PureComponent { > { {payload ? ( ({ mapStateToProps: (state, ownProps) => { - const { threadIndex, windowId } = ownProps; + const { threadIndex, markerName } = ownProps; const selectors = getThreadSelectors(threadIndex); const { start, end } = getCommittedRange(state); return { thread: selectors.getRangeFilteredThread(state), + markerSchemaByName: getMarkerSchemaByName(state), screenshots: - selectors.getRangeFilteredScreenshotsById(state).get(windowId) || + selectors.getRangeFilteredScreenshotsByName(state).get(markerName) || EMPTY_SCREENSHOTS_TRACK, threadName: selectors.getFriendlyThreadName(state), rangeStart: start, @@ -208,6 +217,7 @@ export const TimelineTrackScreenshots = explicitConnect< type HoverPreviewProps = { readonly thread: Thread; + readonly markerSchemaByName: MarkerSchemaByName; readonly rangeStart: Milliseconds; readonly rangeEnd: Milliseconds; readonly isMakingPreviewSelection: boolean; @@ -216,9 +226,32 @@ type HoverPreviewProps = { readonly containerTop: null | number; readonly width: number; readonly trackHeight: number; - readonly payload: ScreenshotPayload; + readonly payload: MarkerPayload; }; +function getScreenshotImageData( + payload: MarkerPayload | null, + markerSchemaByName: MarkerSchemaByName +): { + url: IndexIntoStringTable; + size: { width: number; height: number }; +} | null { + const schema = getSchemaFromMarker(markerSchemaByName, payload); + if (!payload || !schema) { + return null; + } + for (const { key, format } of schema.fields) { + if (typeof format === 'object' && format.type === 'screenshot-data-url') { + const url = (payload as any)[key] as IndexIntoStringTable | undefined; + const size = (payload as any)[format.sizeFieldForAspectRatio] as + | { width: number; height: number } + | undefined; + return url === undefined || size === undefined ? null : { url, size }; + } + } + return null; +} + const MAXIMUM_HOVER_SIZE = 350; const MAXIMUM_HOVER_SIZE_WHEN_SELECTING_RANGE = 100; @@ -231,6 +264,7 @@ class HoverPreview extends PureComponent { override render() { const { thread, + markerSchemaByName, isMakingPreviewSelection, width, pageX, @@ -244,17 +278,18 @@ class HoverPreview extends PureComponent { return null; } - const { url, windowSize } = payload; - if (url === undefined || windowSize === undefined) { + const imageData = getScreenshotImageData(payload, markerSchemaByName); + if (imageData === null) { return null; } + const { url, size } = imageData; const maximumHoverSize = isMakingPreviewSelection ? MAXIMUM_HOVER_SIZE_WHEN_SELECTING_RANGE : MAXIMUM_HOVER_SIZE; const { width: hoverWidth, height: hoverHeight } = computeScreenshotSize( - windowSize, + size, maximumHoverSize ); @@ -302,6 +337,7 @@ class HoverPreview extends PureComponent { type ScreenshotStripProps = { readonly thread: Thread; + readonly markerSchemaByName: MarkerSchemaByName; readonly rangeStart: Milliseconds; readonly rangeEnd: Milliseconds; readonly screenshots: Marker[]; @@ -313,6 +349,7 @@ class ScreenshotStrip extends PureComponent { override render() { const { thread, + markerSchemaByName, width: outerContainerWidth, rangeStart, rangeEnd, @@ -349,15 +386,15 @@ class ScreenshotStrip extends PureComponent { break; } } - // Coerce the payload into a screenshot one. - const payload: ScreenshotPayload = screenshots[screenshotIndex] - .data as any; - const { url: urlStringIndex, windowSize } = payload; - if (urlStringIndex === undefined || windowSize === undefined) { + const imageData = getScreenshotImageData( + screenshots[screenshotIndex].data, + markerSchemaByName + ); + if (imageData === null) { continue; } - const scaledImageWidth = - (trackHeight * windowSize.width) / windowSize.height; + const { url, size } = imageData; + const scaledImageWidth = (trackHeight * size.width) / size.height; images.push(
{ {/* The following image is centered and cropped by the outer container. */} Marker, - markerIndexes: MarkerIndex[] -): Map { - const idToScreenshotMarkers = new Map(); - for (const markerIndex of markerIndexes) { - const marker = getMarker(markerIndex); - const { data } = marker; - if (data && data.type === 'CompositorScreenshot') { - let markers = idToScreenshotMarkers.get(data.windowID); - if (markers === undefined) { - markers = []; - idToScreenshotMarkers.set(data.windowID, markers); - } - - markers.push(marker); - } - } - - return idToScreenshotMarkers; -} - function _shouldSanitizePIICategory( category: MarkerSchemaPIICategory, PIIToBeRemoved: RemoveProfileInformation diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index c13f550acd..6d9437040b 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -37,6 +37,7 @@ import { filterRawMarkerTableToRange, deriveMarkersFromRawMarkerTable, correlateIPCMarkers, + getMarkerTypesForDisplay, } from './marker-data'; import { computeStringIndexMarkerFieldsByDataType } from './marker-schema'; import { ensureExists, getFirstItemFromSet } from '../utils/types'; @@ -124,8 +125,7 @@ export function mergeProfilesForDiffing( (resultProfile.meta as any)[key] = value; } } - // Combine the marker schemas of all profiles, so that markers coming from - // any of them keep being understood. + // Combine the marker schemas. const markerSchemaNames = new Set(); resultProfile.meta.markerSchema = []; for (const profile of profiles) { @@ -147,6 +147,10 @@ export function mergeProfilesForDiffing( // Precompute marker fields that need adjusting. const stringIndexMarkerFieldsByDataType = computeStringIndexMarkerFieldsByDataType(resultProfile.meta.markerSchema); + const screenshotMarkerTypes = getMarkerTypesForDisplay( + resultProfile.meta.markerSchema, + 'timeline-screenshots' + ); // If all profiles have an unknown symbolication status, we keep this unknown // status for the combined profile. Otherwise, we mark the combined profile @@ -202,6 +206,14 @@ export function mergeProfilesForDiffing( } let thread = { ...profile.threads[selectedThreadIndex] }; + // Make sure that screenshot markers make it into the merged profile, even + // if they're not on the selected thread. + thread.markers = getThreadMarkersAndScreenshotMarkers( + profile.threads, + profile.threads[selectedThreadIndex], + screenshotMarkerTypes + ); + transformStacks[i] = translateTransformStack( profileSpecific.transforms[selectedThreadIndex] ?? [], translationMaps @@ -225,13 +237,6 @@ export function mergeProfilesForDiffing( _mapNullableStack(stackIndex, oldStackToNewStackPlusOne) ); - // Make sure that screenshot markers make it into the merged profile, even - // if they're not on the selected thread. - thread.markers = getThreadMarkersAndScreenshotMarkers( - profile.threads, - thread - ); - // We filter the profile using the range from the state for this profile. const zeroAt = getTimeRangeIncludingAllThreads(profile).start; const committedRange = @@ -1479,13 +1484,14 @@ function mergeMarkers(threads: RawThread[]): RawMarkerTable { /** * Returns a RawMarkerTable which contains all the markers from targetThread, - * as well as any CompositorScreenshot markers found on any other threads. + * as well as any screenshot markers found on any other threads. * * `targetThread` is expected to be one of the threads in `threads`. */ function getThreadMarkersAndScreenshotMarkers( threads: RawThread[], - targetThread: RawThread + targetThread: RawThread, + screenshotMarkerTypes: Set ): RawMarkerTable { const targetMarkerTable = getRawMarkerTableBuilderFromExisting( targetThread.markers @@ -1503,7 +1509,7 @@ function getThreadMarkersAndScreenshotMarkers( for (let markerIndex = 0; markerIndex < markers.length; markerIndex++) { const data = markers.data[markerIndex]; - if (data === null || data.type !== 'CompositorScreenshot') { + if (data === null || !screenshotMarkerTypes.has(data.type)) { continue; } targetMarkerTable.data.push(data); diff --git a/src/profile-logic/process-screenshot-markers.ts b/src/profile-logic/process-screenshot-markers.ts index 311751e26d..952075e722 100644 --- a/src/profile-logic/process-screenshot-markers.ts +++ b/src/profile-logic/process-screenshot-markers.ts @@ -21,7 +21,7 @@ import type { export const compositorScreenshotMarkerSchema: MarkerSchema = { name: 'CompositorScreenshot', - display: ['marker-chart', 'marker-table'], + display: ['marker-chart', 'marker-table', 'timeline-screenshots'], fields: [ { key: 'url', diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index ecce65c657..a73d57c02c 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -3778,7 +3778,7 @@ const _upgraders: { if (hasCompositorScreenshots) { profile.meta.markerSchema.push({ name: 'CompositorScreenshot', - display: ['marker-chart', 'marker-table'], + display: ['marker-chart', 'marker-table', 'timeline-screenshots'], fields: [ { key: 'url', diff --git a/src/profile-logic/sanitize.ts b/src/profile-logic/sanitize.ts index 614fadfa19..97a143f927 100644 --- a/src/profile-logic/sanitize.ts +++ b/src/profile-logic/sanitize.ts @@ -15,6 +15,7 @@ import { removeURLs } from '../utils/string'; import { filterRawMarkerTableToRangeWithMarkersToDelete, sanitizeMarkerFromSchema, + getMarkerTypesForDisplay, } from './marker-data'; import { getSchemaFromMarker } from './marker-schema'; import { @@ -140,6 +141,11 @@ export function sanitizePII( stringArray, }; + const screenshotMarkerTypes = getMarkerTypesForDisplay( + Object.values(markerSchemaByName), + 'timeline-screenshots' + ); + let stackFlags: Uint8Array | null = null; if (windowIdFromPrivateBrowsing.size > 0) { @@ -344,6 +350,7 @@ export function sanitizePII( PIIToBeRemoved, windowIdFromPrivateBrowsing, markerSchemaByName, + screenshotMarkerTypes, stackFlags ); @@ -459,6 +466,7 @@ function sanitizeThreadPII( PIIToBeRemoved: RemoveProfileInformation, windowIdFromPrivateBrowsing: Set, markerSchemaByName: MarkerSchemaByName, + screenshotMarkerTypes: Set, stackFlags: Uint8Array | null ): RawThread | null { if (PIIToBeRemoved.shouldRemoveThreads.has(threadIndex)) { @@ -517,7 +525,7 @@ function sanitizeThreadPII( if ( PIIToBeRemoved.shouldRemoveThreadsWithScreenshots.has(threadIndex) && currentMarker && - currentMarker.type === 'CompositorScreenshot' + screenshotMarkerTypes.has(currentMarker.type) ) { markersToDelete.add(i); } diff --git a/src/profile-logic/tracks.ts b/src/profile-logic/tracks.ts index 563a01c9e8..c03b11e9f3 100644 --- a/src/profile-logic/tracks.ts +++ b/src/profile-logic/tracks.ts @@ -552,7 +552,7 @@ function _trackIdentityKey( case 'process': return `process:${track.pid}`; case 'screenshots': - return `screenshots:${track.id}`; + return `screenshots:${track.markerName}`; case 'visual-progress': case 'perceptual-visual-progress': case 'contentful-visual-progress': @@ -605,7 +605,7 @@ function _trackIdentityKey( /** * Map each old TrackIndex to its new TrackIndex when both old and new track * lists describe the same profile across a sanitization step. Tracks are - * matched by stable identity (pid, screenshot id, threadIndex, counterIndex, + * matched by stable identity (pid, screenshot marker name, threadIndex, counterIndex, * visual-progress singleton, or marker schema name plus marker name string); * old-side thread, counter, and string-table indexes are normalized through * the supplied translation maps before comparison. Tracks with no match in @@ -714,6 +714,13 @@ export function computeGlobalTracks( }; const globalTracksByPid: Map = new Map(); let globalTracks: GlobalTrack[] = []; + const markerSchema = computeCombinedMarkerSchemaList( + profile.meta.markerSchema || [] + ); + const screenshotTimelineMarkerTypes = getMarkerTypesForDisplay( + markerSchema, + 'timeline-screenshots' + ); // Create the global tracks. for ( @@ -758,20 +765,20 @@ export function computeGlobalTracks( // Windows must keep being added in the order their first screenshot was taken: // shared URLs refer to global tracks by index, // so a different order would change what an existing URL selects. - const ids: Set = new Set(); + const markerNames: Set = new Set(); for (let markerIndex = 0; markerIndex < markers.length; markerIndex++) { const data = markers.data[markerIndex]; if ( markers.phase[markerIndex] === INTERVAL_END || data === null || - data.type !== 'CompositorScreenshot' + !screenshotTimelineMarkerTypes.has(data.type) ) { continue; } - ids.add(data.windowID); + markerNames.add(profile.shared.stringArray[markers.name[markerIndex]]); } - for (const id of ids) { - globalTracks.push({ type: 'screenshots', id, threadIndex }); + for (const markerName of markerNames) { + globalTracks.push({ type: 'screenshots', markerName, threadIndex }); } } diff --git a/src/selectors/per-thread/markers.ts b/src/selectors/per-thread/markers.ts index 27d5b86483..d80a0d3ec0 100644 --- a/src/selectors/per-thread/markers.ts +++ b/src/selectors/per-thread/markers.ts @@ -550,14 +550,29 @@ export function getMarkerSelectorsPerThread( MarkerTimingLogic.getMarkerTiming ); + const getRangeFilteredScreenshotMarkerIndexes = + getTimelineMarkerIndexesBySchemaLocation('timeline-screenshots'); + /** - * This groups screenshot markers by their window ID. + * This groups screenshot markers by their name. */ - const getRangeFilteredScreenshotsById: Selector> = + const getRangeFilteredScreenshotsByName: Selector> = createSelector( getMarkerGetter, - getCommittedRangeFilteredMarkerIndexes, - MarkerData.groupScreenshotsById + getRangeFilteredScreenshotMarkerIndexes, + (getMarker, markerIndexes) => { + const screenshotsByName = new Map(); + for (const markerIndex of markerIndexes) { + const marker = getMarker(markerIndex); + let screenshots = screenshotsByName.get(marker.name); + if (screenshots === undefined) { + screenshots = []; + screenshotsByName.set(marker.name, screenshots); + } + screenshots.push(marker); + } + return screenshotsByName; + } ); /** @@ -797,7 +812,7 @@ export function getMarkerSelectorsPerThread( getTimelineIPCMarkerIndexes, getTimelineMarkerIndexesBySchemaLocation, getNetworkTrackTiming, - getRangeFilteredScreenshotsById, + getRangeFilteredScreenshotsByName, getSearchFilteredMarkerIndexes, getPreviewFilteredMarkerIndexes, getSelectedMarkerIndex, diff --git a/src/test/components/TrackContextMenu.test.tsx b/src/test/components/TrackContextMenu.test.tsx index 813a5bd347..97f4ec4f34 100644 --- a/src/test/components/TrackContextMenu.test.tsx +++ b/src/test/components/TrackContextMenu.test.tsx @@ -35,6 +35,7 @@ import { import { getScreenshotTrackProfile, getNetworkTrackProfile, + addCompositorScreenshotSchemaIfNeeded, addIPCMarkerPairToThreads, getThreadWithRawMarkers, getScreenshotMarkersForWindowId, @@ -1242,6 +1243,7 @@ describe('timeline/TrackContextMenu', function () { ), tid: profile.threads.length, }); + addCompositorScreenshotSchemaIfNeeded(profile); const { store } = setup(profile); diff --git a/src/test/components/TrackScreenshots.test.tsx b/src/test/components/TrackScreenshots.test.tsx index 6fbfff7b06..7d803912ae 100644 --- a/src/test/components/TrackScreenshots.test.tsx +++ b/src/test/components/TrackScreenshots.test.tsx @@ -79,6 +79,93 @@ describe('timeline/TrackScreenshots', function () { expect(size).toEqual({ width: 350, height: 175 }); }); + it('renders screenshot fields identified by the schema', () => { + const profile = getScreenshotTrackProfile(); + const { markers } = profile.threads[0]; + const firstPayload = markers.data[0]; + if (firstPayload?.type !== 'CompositorScreenshot') { + throw new Error('Expected a screenshot marker.'); + } + const imageUrl = profile.shared.stringArray[ensureExists(firstPayload.url)]; + markers.data = markers.data.map((payload) => { + if (payload?.type !== 'CompositorScreenshot') { + return payload; + } + return { + type: 'NoPayloadUserData' as const, + ...(payload.url !== undefined && { image: payload.url }), + ...(payload.windowSize !== undefined && { + dimensions: { width: 100, height: 200 }, + }), + }; + }); + profile.meta.markerSchema = [ + { + name: 'NoPayloadUserData', + display: ['timeline-screenshots'], + fields: [ + { key: 'dimensions', format: 'screenshot-size' }, + { + key: 'image', + format: { + type: 'screenshot-data-url', + sizeFieldForAspectRatio: 'dimensions', + }, + }, + ], + }, + ]; + + const { container, moveMouseAndGetImageSize, screenshotHover } = + setup(profile); + const thumbnail = ensureExists( + container.querySelector('.timelineTrackScreenshotImg') + ); + expect(thumbnail).toHaveAttribute('src', imageUrl); + expect(thumbnail).toHaveStyle({ + width: `${FULL_TRACK_SCREENSHOT_HEIGHT / 2}px`, + height: `${FULL_TRACK_SCREENSHOT_HEIGHT}px`, + }); + expect(moveMouseAndGetImageSize(LEFT)).toEqual({ + width: 175, + height: 350, + }); + expect(screenshotHover().querySelector('img')).toHaveAttribute( + 'src', + imageUrl + ); + }); + + it.each(['url', 'windowSize'] as const)( + 'does not render images without %s', + (field) => { + const profile = getScreenshotTrackProfile(); + for (const payload of profile.threads[0].markers.data) { + if (payload?.type === 'CompositorScreenshot') { + delete payload[field]; + } + } + + const { container, moveMouse, screenshotHover } = setup(profile); + expect(container.querySelector('img')).toBeNull(); + moveMouse(LEFT); + expect(screenshotHover).toThrow(); + } + ); + + it('does not render images without a screenshot image field in the schema', () => { + const profile = getScreenshotTrackProfile(); + profile.meta.markerSchema = profile.meta.markerSchema.map((schema) => ({ + ...schema, + fields: [], + })); + + const { container, moveMouse, screenshotHover } = setup(profile); + expect(container.querySelector('img')).toBeNull(); + moveMouse(LEFT); + expect(screenshotHover).toThrow(); + }); + it('sets a preview selection when clicking with the mouse', () => { const { selectionOverlay, screenshotClick, getState } = setup( undefined, @@ -287,7 +374,12 @@ describe('timeline/TrackScreenshots', function () { function setup( profile: Profile = getScreenshotTrackProfile(), - component = + component = ( + + ) ) { const store = storeWithProfile(profile); const { getState, dispatch } = store; diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index 924a7f03e8..8612906e47 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -454,7 +454,7 @@ export function getProfileWithRawMarkers( export const compositorScreenshotMarkerSchema: MarkerSchema = { name: 'CompositorScreenshot', - display: ['marker-chart', 'marker-table'], + display: ['marker-chart', 'marker-table', 'timeline-screenshots'], fields: [ { key: 'url', diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 5aebb5625e..fdd3646329 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -2087,22 +2087,26 @@ describe('actions/ProfileView', function () { }); }); - describe('getRangeFilteredScreenshotsById', function () { + describe('getRangeFilteredScreenshotsByName', function () { it('can extract some screenshot markers', function () { const profile = getScreenshotTrackProfile(); const { getState } = storeWithProfile(profile); - const screenshotMarkersById = - selectedThreadSelectors.getRangeFilteredScreenshotsById(getState()); - const keys = [...screenshotMarkersById.keys()]; + const screenshotMarkersByName = + selectedThreadSelectors.getRangeFilteredScreenshotsByName(getState()); + const keys = [...screenshotMarkersByName.keys()]; expect(keys.length).toEqual(3); - const screenshots = screenshotMarkersById.get('0'); + const screenshots = screenshotMarkersByName.get('CompositorScreenshot 0'); expect(screenshots?.length).toEqual(5); for (const screenshot of screenshots ?? []) { expect(screenshot.name).toEqual('CompositorScreenshot 0'); } - expect(screenshotMarkersById.get('1')?.length).toEqual(5); - expect(screenshotMarkersById.get('2')?.length).toEqual(10); + expect( + screenshotMarkersByName.get('CompositorScreenshot 1')?.length + ).toEqual(5); + expect( + screenshotMarkersByName.get('CompositorScreenshot 2')?.length + ).toEqual(10); }); it('filters screenshots within a range selection', function () { @@ -2116,9 +2120,9 @@ describe('actions/ProfileView', function () { dispatch(ProfileView.commitRange(3.1, 7.5)); // Get out the markers. - const screenshotMarkersById = - selectedThreadSelectors.getRangeFilteredScreenshotsById(getState()); - const screenshots = screenshotMarkersById.get('2'); + const screenshotMarkersByName = + selectedThreadSelectors.getRangeFilteredScreenshotsByName(getState()); + const screenshots = screenshotMarkersByName.get('CompositorScreenshot 2'); if (!screenshots) { throw new Error('No screenshots found.'); } diff --git a/src/test/store/publish.test.ts b/src/test/store/publish.test.ts index a38d10b558..8d9423af1a 100644 --- a/src/test/store/publish.test.ts +++ b/src/test/store/publish.test.ts @@ -44,6 +44,7 @@ import { urlFromState } from '../../app-logic/url-handling'; import { getHasZipFile } from '../../selectors/zipped-profiles'; import { getProfileFromTextSamples, + addCompositorScreenshotSchemaIfNeeded, addRawMarkersToThread, makeCompositorScreenshot, } from '../fixtures/profiles/processed-profile'; @@ -670,6 +671,7 @@ describe('attemptToPublish', function () { addRawMarkersToThread(profile.threads[0], profile.shared, [ makeCompositorScreenshot(0.5), ]); + addCompositorScreenshotSchemaIfNeeded(profile); const store = storeWithProfile(profile); const { dispatch, getState, resolveUpload, assertUploadSuccess } = @@ -771,6 +773,7 @@ describe('attemptToPublish', function () { addRawMarkersToThread(profile.threads[2], profile.shared, [ makeCompositorScreenshot(0.5), ]); + addCompositorScreenshotSchemaIfNeeded(profile); const store = storeWithProfile(profile); const { dispatch, getState, resolveUpload, assertUploadSuccess } = diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index a768f16605..39073b85ac 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -1750,7 +1750,7 @@ describe('actions/receive-profile', function () { //Get profiles with one screenshot track const profile1 = getProfileWithMarkers([ [ - 'CompositorScreenshot', + 'CompositorScreenshot 0', 0, null, { @@ -1763,7 +1763,7 @@ describe('actions/receive-profile', function () { ]); const profile2 = getProfileWithMarkers([ [ - 'CompositorScreenshot', + 'CompositorScreenshot 1', 0, null, { @@ -1795,6 +1795,42 @@ describe('actions/receive-profile', function () { ]); }); + it('includes the screenshot track when only one profile has screenshot markers', async function () { + const store = blankStore(); + const profile1 = getProfileWithMarkers([]); + const profile2 = getProfileWithMarkers([ + [ + 'CompositorScreenshot 1', + 0, + null, + { + type: 'CompositorScreenshot', + url: 0, // Some arbitrary string. + windowID: '1', + windowSize: { width: 300, height: 150 }, + }, + ], + ]); + const { resultProfile } = await setup( + { + profile1: profile1, + profile2: profile2, + }, + { + url1: 'https://fakeurl.com/public/fakehash1/?thread=0&v=3', + url2: 'https://fakeurl.com/public/fakehash1/?thread=0&v=3', + } + ); + + store.dispatch(viewProfile(resultProfile)); + expect(getHumanReadableTracks(store.getState())).toEqual([ + 'show [screenshots]', + 'show [thread Empty default] SELECTED', + 'show [thread Empty default]', + 'show [thread Diff between 1 and 2 comparison]', + ]); + }); + it('gives a fatal error when the selected thread index is out of bounds', async function () { const { dispatch, getState } = blankStore(); const { profile1, profile2 } = getSomeProfiles(); diff --git a/src/test/store/tracks.test.ts b/src/test/store/tracks.test.ts index 4a128f35be..8361c2e294 100644 --- a/src/test/store/tracks.test.ts +++ b/src/test/store/tracks.test.ts @@ -7,8 +7,11 @@ import { getNetworkTrackProfile, addIPCMarkerPairToThreads, getProfileWithMarkers, + getProfileWithRawMarkers, getProfileFromTextSamples, getProfileWithThreadCPUDelta, + makeCompositorScreenshot, + makeCompositorScreenshotEnd, } from '../fixtures/profiles/processed-profile'; import { getEmptyThread } from '../../profile-logic/data-structures'; import { storeWithProfile } from '../fixtures/stores'; @@ -345,6 +348,55 @@ describe('ordering and hiding', function () { ]); }); + it('keeps screenshot track indexes stable when there are unmatched end markers', function () { + const profile = getProfileWithRawMarkers([ + makeCompositorScreenshotEnd(0, '0'), + makeCompositorScreenshotEnd(1, '2'), + makeCompositorScreenshot(2, '1'), + makeCompositorScreenshot(3, '2'), + makeCompositorScreenshotEnd(4, '1'), + makeCompositorScreenshotEnd(5, '2'), + ]); + const { getState } = storeWithProfile(profile); + + expect(ProfileViewSelectors.getGlobalTracks(getState())).toEqual([ + expect.objectContaining({ type: 'process' }), + { + type: 'screenshots', + markerName: 'CompositorScreenshot 1', + threadIndex: 0, + }, + { + type: 'screenshots', + markerName: 'CompositorScreenshot 2', + threadIndex: 0, + }, + ]); + }); + + it('creates screenshot tracks from the schema display location', function () { + const customScreenshot = { + type: 'Url' as const, + url: '', + windowID: 42, + }; + const profile = getProfileWithMarkers([ + ['CustomScreenshot', 0, null, customScreenshot], + ]); + profile.meta.markerSchema.push({ + name: 'Url', + display: ['marker-chart', 'timeline-screenshots'], + fields: [], + }); + + const { getState } = storeWithProfile(profile); + expect(ProfileViewSelectors.getGlobalTracks(getState())).toContainEqual({ + type: 'screenshots', + markerName: 'CustomScreenshot', + threadIndex: 0, + }); + }); + describe('sorting of track types to ensure proper URL backwards compatibility', function () { const stableIndexOrder = [ 'process', diff --git a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap index 0963bc344d..1ded6c752d 100644 --- a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap @@ -10799,6 +10799,7 @@ Object { "display": Array [ "marker-chart", "marker-table", + "timeline-screenshots", ], "fields": Array [ Object { diff --git a/src/test/unit/marker-data.test.ts b/src/test/unit/marker-data.test.ts index 98237a94e8..fc73025304 100644 --- a/src/test/unit/marker-data.test.ts +++ b/src/test/unit/marker-data.test.ts @@ -454,7 +454,7 @@ describe('Derive markers from Gecko phase markers', function () { expect(profile.meta.markerSchema).toContainEqual( expect.objectContaining({ name: 'CompositorScreenshot', - display: expect.arrayContaining(['marker-chart']), + display: expect.arrayContaining(['timeline-screenshots']), }) ); diff --git a/src/test/unit/sanitize.test.ts b/src/test/unit/sanitize.test.ts index 18fcf0b560..5a00a8aa3e 100644 --- a/src/test/unit/sanitize.test.ts +++ b/src/test/unit/sanitize.test.ts @@ -13,6 +13,7 @@ import { addMarkersToThreadWithCorrespondingSamples, addInnerWindowIdToStacks, getNetworkMarkers, + compositorScreenshotMarkerSchema, } from '../fixtures/profiles/processed-profile'; import { ensureExists } from '../../utils/types'; import { @@ -162,6 +163,7 @@ describe('sanitizePII', function () { }, ], }, + CompositorScreenshot: compositorScreenshotMarkerSchema, ...extraMarkerSchemas, }; const markerSchemaByName = computeMarkerSchemaByName( @@ -578,6 +580,28 @@ describe('sanitizePII', function () { } }); + it('should sanitize the screenshots of any marker type shown in a screenshot track', function () { + const profile = getProfileWithMarkers([ + ['MyScreenshot', 0, 1, { type: 'MyScreenshot', url: 0 } as any], + ['Other', 0, 1], + ]); + const { sanitizedProfile } = setup( + { shouldRemoveThreadsWithScreenshots: new Set([0]) }, + profile, + { + MyScreenshot: { + name: 'MyScreenshot', + display: ['marker-chart', 'timeline-screenshots'], + fields: [], + }, + } + ); + + const { markers } = sanitizedProfile.threads[0]; + expect(markers.length).toBe(1); + expect(sanitizedProfile.shared.stringArray[markers.name[0]]).toBe('Other'); + }); + it('should sanitize the pages information', function () { const { originalProfile, sanitizedProfile } = setup({ shouldRemoveUrls: true, diff --git a/src/test/unit/tracks-sanitization.test.ts b/src/test/unit/tracks-sanitization.test.ts index 6880d76052..00cd5d25d7 100644 --- a/src/test/unit/tracks-sanitization.test.ts +++ b/src/test/unit/tracks-sanitization.test.ts @@ -33,13 +33,13 @@ describe('computeOldTrackIndexToNewTrackIndexMap', function () { expect(map.get(2)).toBe(1); }); - it('matches screenshot tracks by id', function () { + it('matches screenshot tracks by marker name', function () { const oldTracks: Track[] = [ - { type: 'screenshots', id: 'win-A', threadIndex: 0 }, - { type: 'screenshots', id: 'win-B', threadIndex: 0 }, + { type: 'screenshots', markerName: 'win-A', threadIndex: 0 }, + { type: 'screenshots', markerName: 'win-B', threadIndex: 0 }, ]; const newTracks: Track[] = [ - { type: 'screenshots', id: 'win-B', threadIndex: 0 }, + { type: 'screenshots', markerName: 'win-B', threadIndex: 0 }, ]; const map = computeOldTrackIndexToNewTrackIndexMap({ oldTracks, @@ -234,7 +234,7 @@ describe('computeOldTrackIndexToNewTrackIndexMap', function () { it('handles a mixed track list (process + screenshots + visual-progress)', function () { const oldTracks: Track[] = [ { type: 'process', pid: '1', mainThreadIndex: 0 }, - { type: 'screenshots', id: 'win-A', threadIndex: 0 }, + { type: 'screenshots', markerName: 'win-A', threadIndex: 0 }, { type: 'process', pid: '2', mainThreadIndex: 1 }, { type: 'visual-progress' }, ]; diff --git a/src/types/markers.ts b/src/types/markers.ts index 5eb6cd9fd4..3563841668 100644 --- a/src/types/markers.ts +++ b/src/types/markers.ts @@ -125,6 +125,8 @@ export type MarkerDisplayLocation = | 'timeline-fileio' // This adds markers to the Network track. | 'timeline-network' + // This adds markers to the Screenshots track. + | 'timeline-screenshots' // TODO - This is not supported yet. | 'stack-chart'; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 682c845df5..10fbae4239 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -733,7 +733,7 @@ export type GlobalTrack = } | { readonly type: 'screenshots'; - readonly id: string; + readonly markerName: string; readonly threadIndex: ThreadIndex; } | { readonly type: 'visual-progress' } From 741f24245d011f54f1cdc04b7e00e32069741291 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 20 Oct 2026 10:32:05 -0400 Subject: [PATCH 15/24] Make the scrollable editor view in the source view cover the full height of the panel, even if the source code is short. --- src/components/shared/CodeView.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/shared/CodeView.css b/src/components/shared/CodeView.css index c10ece4461..a87062fe88 100644 --- a/src/components/shared/CodeView.css +++ b/src/components/shared/CodeView.css @@ -78,6 +78,7 @@ .cm-editor { min-height: 0; + flex: 1; } .cm-scroller { From df8311c47a4a8804df02aaefa254be4e04ab523d Mon Sep 17 00:00:00 2001 From: "depfu[bot]" <23717796+depfu[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:10:11 +0000 Subject: [PATCH 16/24] Update all Yarn dependencies (2026-10-21) --- package.json | 14 +++--- yarn.lock | 128 +++++++++++++++++++++++++++------------------------ 2 files changed, 74 insertions(+), 68 deletions(-) diff --git a/package.json b/package.json index 5e3924ccfa..ca4a98199f 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-rust": "^6.0.2", - "@codemirror/language": "^6.12.4", + "@codemirror/language": "^6.13.1", "@codemirror/state": "^6.7.6", "@codemirror/view": "^6.43.13", "@firefox-devtools/react-contextmenu": "^5.2.4", @@ -102,7 +102,7 @@ "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", "photon-colors": "^3.3.2", - "protobufjs": "^8.8.0", + "protobufjs": "^8.8.1", "query-string": "^9.5.1", "react": "~19.3.0", "react-dom": "~19.3.0", @@ -112,20 +112,20 @@ "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", "reselect": "^4.1.8", - "smol-toml": "^1.9.0", + "smol-toml": "^1.9.1", "source-map": "^0.8.0", "url": "^0.11.4", "valibot": "^1.5.0", "workbox-window": "^7.4.1" }, "devDependencies": { - "@babel/core": "^8.0.6", + "@babel/core": "^8.0.7", "@babel/plugin-transform-class-properties": "^8.0.1", "@babel/plugin-transform-private-methods": "^8.0.1", "@babel/plugin-transform-private-property-in-object": "^8.0.5", "@babel/preset-env": "^8.0.7", "@babel/preset-react": "^8.0.1", - "@babel/preset-typescript": "^8.0.1", + "@babel/preset-typescript": "^8.0.7", "@eslint/js": "^9.39.5", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", @@ -156,7 +156,7 @@ "eslint": "^9.39.5", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jest": "^29.16.6", + "eslint-plugin-jest": "^29.17.0", "eslint-plugin-jest-dom": "^5.10.1", "eslint-plugin-jest-formatting": "^3.1.0", "eslint-plugin-react": "^7.37.5", @@ -169,7 +169,7 @@ "jest-environment-jsdom": "^30.5.2", "jest-extended": "^7.0.0", "local-web-server": "^5.4.0", - "lockfile-lint": "^5.0.1", + "lockfile-lint": "^5.1.0", "npm-run-all2": "^8.0.4", "open": "^11.0.4", "oxfmt": "^0.72.0", diff --git a/yarn.lock b/yarn.lock index f91d45f1b2..fbe5a24e53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -88,18 +88,18 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/core@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-8.0.6.tgz#9f868b67f2e8ef63795b9da6866b0e14a9f2b11c" - integrity sha512-5zwYt1V4ji3mXKKRIAkohSJ29WepxNwxQi+7y7P5AcVPh1dcxnZBU2cfxHnPwS+u9+lZnXPsv30TQaF2xg+Tfg== +"@babel/core@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-8.0.7.tgz#794f5ac863e0ba30f76a7a7fa546ad3c103ca652" + integrity sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA== dependencies: "@babel/code-frame" "^8.0.6" "@babel/generator" "^8.0.6" - "@babel/helper-compilation-targets" "^8.0.6" - "@babel/helpers" "^8.0.5" - "@babel/parser" "^8.0.6" + "@babel/helper-compilation-targets" "^8.0.7" + "@babel/helpers" "^8.0.7" + "@babel/parser" "^8.0.7" "@babel/template" "^8.0.0" - "@babel/traverse" "^8.0.6" + "@babel/traverse" "^8.0.7" "@babel/types" "^8.0.6" "@types/gensync" "^1.0.5" convert-source-map "^2.0.0" @@ -107,8 +107,8 @@ gensync "^1.0.0-beta.2" import-meta-resolve "^4.2.0" json5 "^2.2.3" - obug "^2.1.1" - verkit "^0.3.2" + obug "^3.0.0" + verkit "^0.5.0" "@babel/generator@^7.27.5", "@babel/generator@^7.29.7": version "7.29.7" @@ -158,7 +158,7 @@ lru-cache "^5.1.1" semver "^6.3.1" -"@babel/helper-compilation-targets@^8.0.0", "@babel/helper-compilation-targets@^8.0.5", "@babel/helper-compilation-targets@^8.0.6", "@babel/helper-compilation-targets@^8.0.7": +"@babel/helper-compilation-targets@^8.0.0", "@babel/helper-compilation-targets@^8.0.5", "@babel/helper-compilation-targets@^8.0.7": version "8.0.7" resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.7.tgz#7ece9e53952af8f769e15ecabc8b899cb017f745" integrity sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg== @@ -425,13 +425,13 @@ "@babel/template" "^7.29.7" "@babel/types" "^7.29.7" -"@babel/helpers@^8.0.5": - version "8.0.5" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-8.0.5.tgz#164c8c914eb19965e073c6193060b44edf28b9bf" - integrity sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA== +"@babel/helpers@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-8.0.7.tgz#9a3771a3dbf416f7936c1aa46749b4e2c6853cbf" + integrity sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w== dependencies: "@babel/template" "^8.0.0" - "@babel/types" "^8.0.5" + "@babel/types" "^8.0.6" "@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.29.7": version "7.29.7" @@ -440,10 +440,10 @@ dependencies: "@babel/types" "^7.29.7" -"@babel/parser@^8.0.0", "@babel/parser@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-8.0.6.tgz#5de0b4f9899dae7497f1aea6a6c81bd0863f04df" - integrity sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg== +"@babel/parser@^8.0.0", "@babel/parser@^8.0.6", "@babel/parser@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-8.0.7.tgz#9230a40e3c9b3f2e72282f582f669ada4e645af2" + integrity sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA== dependencies: "@babel/types" "^8.0.6" @@ -1452,7 +1452,7 @@ dependencies: "@babel/helper-plugin-utils" "^8.0.1" -"@babel/plugin-transform-typescript@^8.0.1": +"@babel/plugin-transform-typescript@^8.0.6": version "8.0.6" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-8.0.6.tgz#6dcc92a06e9f6304297b930a81d6ae7e5eb78a3a" integrity sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw== @@ -1705,15 +1705,15 @@ "@babel/plugin-transform-react-jsx-development" "^8.0.1" "@babel/plugin-transform-react-pure-annotations" "^8.0.1" -"@babel/preset-typescript@^8.0.1": - version "8.0.1" - resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-8.0.1.tgz#603e709db117e5fafef296de087e8a0a7679a543" - integrity sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A== +"@babel/preset-typescript@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-8.0.7.tgz#c31c9b7d3c976872775c72b96b9c8dea98a70bba" + integrity sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q== dependencies: "@babel/helper-plugin-utils" "^8.0.1" "@babel/helper-validator-option" "^8.0.0" "@babel/plugin-transform-modules-commonjs" "^8.0.1" - "@babel/plugin-transform-typescript" "^8.0.1" + "@babel/plugin-transform-typescript" "^8.0.6" "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5": version "7.26.10" @@ -1753,18 +1753,18 @@ "@babel/types" "^7.29.7" debug "^4.3.1" -"@babel/traverse@^8.0.0", "@babel/traverse@^8.0.5", "@babel/traverse@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-8.0.6.tgz#326487cd1a1aaec88e3e4c1fc29fee8a5bcf1c12" - integrity sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ== +"@babel/traverse@^8.0.0", "@babel/traverse@^8.0.5", "@babel/traverse@^8.0.6", "@babel/traverse@^8.0.7": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-8.0.7.tgz#8be67c3c8b6bebcdba134633dd48c33234cef7bc" + integrity sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg== dependencies: "@babel/code-frame" "^8.0.6" "@babel/generator" "^8.0.6" "@babel/helper-globals" "^8.0.6" - "@babel/parser" "^8.0.6" + "@babel/parser" "^8.0.7" "@babel/template" "^8.0.0" "@babel/types" "^8.0.6" - obug "^2.1.1" + obug "^3.0.0" "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.3", "@babel/types@^7.29.7", "@babel/types@^7.3.0", "@babel/types@^7.4.4": version "7.29.7" @@ -1844,12 +1844,13 @@ "@codemirror/language" "^6.0.0" "@lezer/rust" "^1.0.0" -"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.4", "@codemirror/language@^6.6.0": - version "6.12.4" - resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.4.tgz#01e70fd5aa3a8a067ff1dfec75d5b6394cdfa058" - integrity sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A== +"@codemirror/language@^6.0.0", "@codemirror/language@^6.13.1", "@codemirror/language@^6.6.0": + version "6.13.1" + resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.13.1.tgz#61eb04c0265102ff9bb24d9a3ee633d37e84e324" + integrity sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw== dependencies: "@codemirror/state" "^6.0.0" + "@codemirror/streamparser" "^6.0.0" "@codemirror/view" "^6.23.0" "@lezer/common" "^1.5.0" "@lezer/highlight" "^1.0.0" @@ -1872,6 +1873,11 @@ dependencies: "@marijn/find-cluster-break" "^1.0.0" +"@codemirror/streamparser@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@codemirror/streamparser/-/streamparser-6.0.0.tgz#cc48a8f67e9001222d69938fabab157d68ce59d6" + integrity sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w== + "@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.43.13": version "6.43.13" resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.13.tgz#a2d0286bf9e6051cb89076fd1c8dd58b907008d0" @@ -5521,10 +5527,10 @@ eslint-plugin-jest-formatting@^3.1.0: resolved "https://registry.yarnpkg.com/eslint-plugin-jest-formatting/-/eslint-plugin-jest-formatting-3.1.0.tgz#b26dd5a40f432b642dcc880021a771bb1c93dcd2" integrity sha512-XyysraZ1JSgGbLSDxjj5HzKKh0glgWf+7CkqxbTqb7zEhW7X2WHo5SBQ8cGhnszKN+2Lj3/oevBlHNbHezoc/A== -eslint-plugin-jest@^29.16.6: - version "29.16.6" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-29.16.6.tgz#336b2fd72b0548af338ff0ce6532cb5d1dd25c0a" - integrity sha512-q8TVr0rlNvUD0XnafGWkwtPeX+tTl2llP86EDl3sJtwWrQA/JT9SIu2hLLZbhhX6fT5SDE4O0gIKyZUujKnkYA== +eslint-plugin-jest@^29.17.0: + version "29.17.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz#513444e5f0b2ed559116f0be02a41e6e22e84f39" + integrity sha512-6jtpU6MhYKbagkES02TupWAUUooJpZtKg94Ax0b8HhUorv3InSTBU8RRtlSBe0LocOcShXOpyec3PHzPDkJCSg== dependencies: "@typescript-eslint/utils" "^8.0.0" @@ -8126,23 +8132,23 @@ locate-path@^7.1.0: dependencies: p-locate "^6.0.0" -lockfile-lint-api@^5.9.2: - version "5.9.2" - resolved "https://registry.yarnpkg.com/lockfile-lint-api/-/lockfile-lint-api-5.9.2.tgz#c9ca335d4aa46c90d8b8467a92ed6670b5db0ed9" - integrity sha512-3QhxWxl3jT9GcMxuCnTsU8Tz5U6U1lKBlKBu2zOYOz/x3ONUoojEtky3uzoaaDgExcLqIX0Aqv2I7TZXE383CQ== +lockfile-lint-api@^5.10.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/lockfile-lint-api/-/lockfile-lint-api-5.10.0.tgz#8c5c37e55c51789b5ca820bcacf545f98ba5895c" + integrity sha512-RVWSdDNiBX3MVLZmM7R2cFDzorb+gzUm71lcOxksLf12LlWTnBPeyB3cwEO7uRk64eKi8XvC2ynWwUqnpSQbFw== dependencies: "@yarnpkg/parsers" "^3.0.0-rc.48.1" debug "^4.3.4" object-hash "^3.0.0" -lockfile-lint@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/lockfile-lint/-/lockfile-lint-5.0.1.tgz#bc783bc890233d16818cb5b5ecb3faa763d44c51" - integrity sha512-Ukjf5yGBQwfl7L2niV3in7bU5wEww3+4Dkw89JGTzOuq18tzS7jaszl2oO7M6u+jcim080MfX5E4Gokt1KhRHQ== +lockfile-lint@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/lockfile-lint/-/lockfile-lint-5.1.0.tgz#968906e9179ccbdc5771a7f8e0c232268187fbb2" + integrity sha512-ULquTBaeF4IgdBAj4dPSxyJ3EQv12VTjbdC6RcImDP9IOgrWUAoPIFLTNPua6A8vaIVxKnXhZdQwkPPc4Z3lcA== dependencies: cosmiconfig "^9.0.0" debug "^4.3.4" - lockfile-lint-api "^5.9.2" + lockfile-lint-api "^5.10.0" tinyglobby "^0.2.15" yargs "^17.7.2" @@ -9452,10 +9458,10 @@ object.values@^1.2.1: define-properties "^1.2.1" es-object-atoms "^1.0.0" -obug@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/obug/-/obug-2.2.1.tgz#9c453efeeda822050eb9774242e969c784d64d78" - integrity sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q== +obug@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/obug/-/obug-3.0.0.tgz#2ddfc17d1c1dd8c825eebd127099da307c919299" + integrity sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw== on-finished@^2.3.0, on-finished@~2.4.1: version "2.4.1" @@ -10011,10 +10017,10 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== -protobufjs@^8.8.0: - version "8.8.0" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.8.0.tgz#7ed071d94b6755f63cc9936ecd82e4deac7cf624" - integrity sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg== +protobufjs@^8.8.1: + version "8.8.1" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.8.1.tgz#61668e88c4e6931fb3db0c8dfbf7019cb6516d91" + integrity sha512-d9e9179bjXSJdpJ3+R9fP8hzXsnKLyxTjHLG1dUzje8a27pPyXCe6PrcRtgx2rjoM3IZzHd2vAs+6J/BsO1k7Q== dependencies: long "^5.3.2" @@ -10999,10 +11005,10 @@ smob@^1.0.0: resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab" integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig== -smol-toml@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.9.0.tgz#f36b8dc7eb621c541c48f9fba6925dab95172817" - integrity sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ== +smol-toml@^1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.9.1.tgz#1262834a084c3ce2985900450956a580f95cf243" + integrity sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ== source-map-js@^1.2.1, source-map-js@^1.2.2: version "1.2.2" From d638afd7ddf108769f9067b8a932e45d9a23c63d Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 8 Oct 2026 14:17:41 +0200 Subject: [PATCH 17/24] Adapt CLI marker timestamp precision to the current view Replace marker timestamp formatting based on timestamp magnitude with fractional seconds whose precision follows the current view width. This lets displayed times be reused as zoom inputs and keeps nearby events distinguishable when zoomed in. --- profiler-cli/src/formatters.ts | 37 +++-- .../src/test/unit/marker-formatting.test.ts | 126 ++++++++++++++++-- 2 files changed, 146 insertions(+), 17 deletions(-) diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 56513aeeed..0c3ed73256 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -61,6 +61,7 @@ import { truncateFunctionName } from '../../src/profile-query/function-list'; import { describeSpec } from '../../src/profile-query/filter-stack'; import { formatTimestamp as formatDuration, + formatSeconds, formatBytes, } from 'firefox-profiler/utils/format-numbers'; @@ -87,6 +88,17 @@ const INLINE_LEGEND = const BAR_WIDTH = 28; +function createTimestampFormatter(context: SessionContext) { + const range = context.currentViewRange ?? context.rootRange; + // Resolve a thousandth of the view, with at least millisecond precision + // and a nanosecond floor to avoid exposing floating-point noise. + const precision = Math.max( + 0.000001, + Math.min(1, (range.end - range.start) / 1000) + ); + return (time: number) => formatSeconds(time, 5, 3, precision); +} + /** * Render aligned `label / bar / count / percentage` rows. `barRatio` is the * fraction of the bar's full width to fill, which is not always the same as @@ -499,7 +511,8 @@ export function formatMarkerInfoResult( result: WithContext ): string { const contextHeader = formatContextHeader(result.context); - return `${contextHeader}\n\n${formatMarkerInfoBody(result)}`; + const formatTimestamp = createTimestampFormatter(result.context); + return `${contextHeader}\n\n${formatMarkerInfoBody(result, formatTimestamp)}`; } /** @@ -511,6 +524,7 @@ export function formatMarkerInfoMultiResult( result: WithContext ): string { const contextHeader = formatContextHeader(result.context); + const formatTimestamp = createTimestampFormatter(result.context); const total = result.markers.length + result.errors.length; const records: string[] = []; @@ -525,7 +539,9 @@ export function formatMarkerInfoMultiResult( const prefix = `[${position}/${total}] `; const marker = byHandle.get(markerHandle); if (marker) { - records.push(prefix + formatMarkerInfoBody(marker).trimEnd()); + records.push( + prefix + formatMarkerInfoBody(marker, formatTimestamp).trimEnd() + ); continue; } const error = errorsByHandle.get(markerHandle); @@ -555,7 +571,10 @@ export function formatMarkerInfoMultiResult( /** * Format one marker info record, below the context header. */ -function formatMarkerInfoBody(result: MarkerInfoResult): string { +function formatMarkerInfoBody( + result: MarkerInfoResult, + formatTimestamp: (time: number) => string +): string { let output = `Marker ${result.markerHandle}: ${result.name}`; if (result.tooltipLabel) { output += ` - ${result.tooltipLabel}`; @@ -566,9 +585,9 @@ function formatMarkerInfoBody(result: MarkerInfoResult): string { output += `Type: ${result.markerType ?? 'None'}\n`; output += `Category: ${result.category.name}\n`; - const startStr = formatDuration(result.start); + const startStr = formatTimestamp(result.start); if (result.end !== null) { - const endStr = formatDuration(result.end); + const endStr = formatTimestamp(result.end); const durationStr = formatDuration(result.duration!); output += `Time: ${startStr} - ${endStr} (${durationStr})\n`; } else { @@ -595,7 +614,7 @@ function formatMarkerInfoBody(result: MarkerInfoResult): string { if (result.stack && result.stack.frames.length > 0) { output += '\nStack trace:\n'; if (result.stack.capturedAt !== undefined) { - output += ` Captured at: ${formatDuration(result.stack.capturedAt)}\n`; + output += ` Captured at: ${formatTimestamp(result.stack.capturedAt)}\n`; } for (let i = 0; i < result.stack.frames.length; i++) { @@ -1457,9 +1476,10 @@ export function formatThreadMarkersResult( // Flat list mode: one row per marker in chronological order if (result.flatMarkers) { + const formatTimestamp = createTimestampFormatter(result.context); for (const m of result.flatMarkers) { const stackIndicator = m.hasStack ? '✓' : '✗'; - const startStr = `t=${formatDuration(m.start)}`; + const startStr = `t=${formatTimestamp(m.start)}`; const durationStr = m.duration !== undefined ? formatDuration(m.duration) : 'instant'; const labelSuffix = m.label !== m.name ? ` ${m.label}` : ''; @@ -2262,9 +2282,10 @@ export function formatProfileMarkersResult( } lines.push('Legend: ✓ = has stack trace, ✗ = no stack trace\n'); + const formatTimestamp = createTimestampFormatter(result.context); for (const m of result.markers) { const stackIndicator = m.hasStack ? '✓' : '✗'; - const startStr = `t=${formatDuration(m.start)}`; + const startStr = `t=${formatTimestamp(m.start)}`; const durationStr = m.duration !== undefined ? formatDuration(m.duration) : 'instant'; const labelSuffix = m.label !== m.name ? ` ${m.label}` : ''; diff --git a/profiler-cli/src/test/unit/marker-formatting.test.ts b/profiler-cli/src/test/unit/marker-formatting.test.ts index 9d41c0441d..f17ace4385 100644 --- a/profiler-cli/src/test/unit/marker-formatting.test.ts +++ b/profiler-cli/src/test/unit/marker-formatting.test.ts @@ -5,6 +5,7 @@ import { formatProfileMarkersResult, formatThreadMarkersResult, + formatMarkerInfoResult, formatMarkerInfoMultiResult, } from '../../formatters'; import type { @@ -185,10 +186,10 @@ describe('formatThreadMarkersResult flat list mode', function () { expect(output).not.toContain('By Category'); }); - // `start` is already profile-start-relative, so `t=` must print it verbatim. + // `start` is already profile-start-relative, so `t=` must use that time base. // Needs a non-zero `rootRange.start`: at the 0 used elsewhere in this file, a // second subtraction would be invisible. - it('renders the flat marker start verbatim, without re-subtracting rootRange.start', function () { + it('renders the flat marker start without re-subtracting rootRange.start', function () { const result = makeResult({ context: { ...createContext(), rootRange: { start: 9.2, end: 3000 } }, filteredMarkerCount: 1, @@ -197,8 +198,8 @@ describe('formatThreadMarkersResult flat list mode', function () { const output = formatThreadMarkersResult(result); const line = output.split('\n').find((l) => l.includes('m-1'))!; - expect(line).toContain('t=549.34ms'); - expect(line).not.toContain('540.14ms'); // 549.34 - 9.2, if subtracted twice + expect(line).toContain('t=0.549s'); + expect(line).not.toContain('0.540s'); // 549.34 - 9.2, if subtracted twice }); }); @@ -282,6 +283,113 @@ function makeProfileMarker( }; } +describe.each([ + { + name: 'thread markers', + format: (start: number, context: SessionContext) => + formatThreadMarkersResult( + makeResult({ context, flatMarkers: [makeFlat({ start })] }) + ), + }, + { + name: 'profile markers', + format: (start: number, context: SessionContext) => + formatProfileMarkersResult({ + ...makeProfileMarkersResult({ + markers: [makeProfileMarker({ start })], + totalCount: 1, + matchingThreadCount: 1, + }), + context, + }), + }, + { + name: 'single marker info', + format: (start: number, context: SessionContext) => + formatMarkerInfoResult({ ...makeMarkerInfo({ start }), context }), + }, + { + name: 'multiple marker info', + format: (start: number, context: SessionContext) => + formatMarkerInfoMultiResult( + makeMultiResult({ context, markers: [makeMarkerInfo({ start })] }) + ), + }, +])('$name timestamp precision', ({ format }) => { + it('uses fractional seconds with precision from the current view', () => { + const context: SessionContext = { + ...createContext(), + rootRange: { start: 9.2, end: 90000 }, + }; + expect(format(63871, context)).toContain('63.871s'); + expect(format(63896, context)).toContain('63.896s'); + + context.currentViewRange = { + start: 63870, + end: 63875, + startName: 'ts-0', + endName: 'ts-1', + }; + expect(format(63871.76, context)).toContain('63.871760s'); + expect(format(63871.86, context)).toContain('63.871860s'); + }); +}); + +describe('timestamp precision bounds', () => { + it('uses the full profile width when no zoom is active', () => { + const context = { ...createContext(), rootRange: { start: 9, end: 14 } }; + const output = formatThreadMarkersResult( + makeResult({ context, flatMarkers: [makeFlat({ start: 2.08376 })] }) + ); + expect(output).toContain('t=0.002084s'); + }); + + it('limits precision to nanoseconds for extremely narrow ranges', () => { + const context = { + ...createContext(), + rootRange: { start: 0, end: 0.000001 }, + }; + const output = formatThreadMarkersResult( + makeResult({ context, flatMarkers: [makeFlat({ start: 0.0000006 })] }) + ); + expect(output).toContain('t=0.000000001s'); + }); +}); + +describe('marker info interval timestamp precision', () => { + it('formats both endpoints and the stack capture time with view precision', () => { + const context: SessionContext = { + ...createContext(), + currentViewRange: { + start: 2082, + end: 2087, + startName: 'ts-0', + endName: 'ts-1', + }, + }; + const marker = makeMarkerInfo({ + start: 2083.76, + end: 2083.86, + duration: 0.1, + stack: { + capturedAt: 2083.81, + frames: [{ name: 'Paint', nameWithLibrary: 'Paint' }], + truncated: false, + }, + }); + const outputs = [ + formatMarkerInfoResult({ ...marker, context }), + formatMarkerInfoMultiResult( + makeMultiResult({ context, markers: [marker] }) + ), + ]; + for (const output of outputs) { + expect(output).toContain('Time: 2.083760s - 2.083860s (100μs)'); + expect(output).toContain('Captured at: 2.083810s'); + } + }); +}); + describe('formatProfileMarkersResult', function () { it('shows the thread column on every row and a per-thread breakdown', function () { const output = formatProfileMarkersResult( @@ -620,8 +728,8 @@ describe('formatMarkerInfoMultiResult', function () { }); // Mirrors the single-handle guard: `start` is already profile-start-relative, - // so each record must print it verbatim. Needs a non-zero `rootRange.start`. - it('renders record times verbatim, without re-subtracting rootRange.start', function () { + // so each record must use that time base. Needs a non-zero `rootRange.start`. + it('renders record times without re-subtracting rootRange.start', function () { const output = formatMarkerInfoMultiResult( makeMultiResult({ context: { ...createContext(), rootRange: { start: 9.2, end: 3000 } }, @@ -633,9 +741,9 @@ describe('formatMarkerInfoMultiResult', function () { }) ); - expect(output).toContain('549.34ms'); - expect(output).not.toContain('540.14ms'); // 549.34 - 9.2, if subtracted twice - expect(output).toContain('700ms - 750ms'); + expect(output).toContain('0.549s'); + expect(output).not.toContain('0.540s'); // 549.34 - 9.2, if subtracted twice + expect(output).toContain('0.700s - 0.750s'); }); it('omits the range warning when the query did not set one', function () { From e70e0eba5c9e043d03c4549f828a7a82f001edec Mon Sep 17 00:00:00 2001 From: fatadel Date: Wed, 21 Oct 2026 15:50:01 +0200 Subject: [PATCH 18/24] Use fractional seconds for CLI marker stack timestamps Replace duration formatting for marker stack capture times with the shared timestamp formatter. Fractional seconds and precision based on the current view keep capture times consistent with other marker commands and distinguish nearby events. --- profiler-cli/src/formatters.ts | 3 ++- .../src/test/unit/marker-formatting.test.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 0c3ed73256..c857d4a703 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -479,6 +479,7 @@ export function formatMarkerStackResult( result: WithContext ): string { const contextHeader = formatContextHeader(result.context); + const formatTimestamp = createTimestampFormatter(result.context); let output = `${contextHeader} Stack trace for marker ${result.markerHandle}: ${result.markerName}\n`; @@ -489,7 +490,7 @@ Stack trace for marker ${result.markerHandle}: ${result.markerName}\n`; } if (result.stack.capturedAt !== undefined) { - output += `\nCaptured at: ${formatDuration(result.stack.capturedAt)}\n`; + output += `\nCaptured at: ${formatTimestamp(result.stack.capturedAt)}\n`; } for (let i = 0; i < result.stack.frames.length; i++) { diff --git a/profiler-cli/src/test/unit/marker-formatting.test.ts b/profiler-cli/src/test/unit/marker-formatting.test.ts index f17ace4385..f1fdc2676a 100644 --- a/profiler-cli/src/test/unit/marker-formatting.test.ts +++ b/profiler-cli/src/test/unit/marker-formatting.test.ts @@ -7,6 +7,7 @@ import { formatThreadMarkersResult, formatMarkerInfoResult, formatMarkerInfoMultiResult, + formatMarkerStackResult, } from '../../formatters'; import type { ThreadMarkersResult, @@ -303,6 +304,24 @@ describe.each([ context, }), }, + { + name: 'marker stack', + format: (start: number, context: SessionContext) => + formatMarkerStackResult({ + type: 'marker-stack', + markerIndex: 0, + threadHandle: 't-0', + friendlyThreadName: 'GeckoMain', + markerHandle: 'm-1', + markerName: 'DOMEvent', + stack: { + capturedAt: start, + frames: [{ name: 'Paint', nameWithLibrary: 'Paint' }], + truncated: false, + }, + context, + }), + }, { name: 'single marker info', format: (start: number, context: SessionContext) => From 5b4b5cfaafa3cf5536bcbfae8d4a6871bd8e18b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Wed, 21 Oct 2026 17:17:10 +0200 Subject: [PATCH 19/24] Silence profile loader logs in profile-querier-url tests --- src/test/unit/profile-query/profile-querier-url.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test/unit/profile-query/profile-querier-url.test.ts b/src/test/unit/profile-query/profile-querier-url.test.ts index 312ccac198..4297c0d162 100644 --- a/src/test/unit/profile-query/profile-querier-url.test.ts +++ b/src/test/unit/profile-query/profile-querier-url.test.ts @@ -15,6 +15,9 @@ const PROFILER_URL = `https://profiler.firefox.com/from-url/${encodeURIComponent describe('ProfileQuerier URL view state', function () { beforeEach(function () { + // The loader logs its progress for the CLI, silence it here. + jest.spyOn(console, 'log').mockImplementation(() => {}); + const samples = ` 1000 1010 1020 A A A From e0b5e5ed476d1edecf46618eff2a062848d5c7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 20 Oct 2026 13:38:30 +0200 Subject: [PATCH 20/24] Look for a source file's address proof in all of its frames findAddressProofForFile took the first func with the file as its source, then that func's first frame, and gave up if that frame had no address. A file can still have other frames with addresses, which the symbolication API would accept just as well. Go through the frames instead and take the first one that has an address and a func with the file as its source. Files that were fetchable but failed now load in the source view and in profiler-cli. The helpers will also be used to find the addresses for all files at once when sharing source code. --- src/profile-logic/profile-data.ts | 63 ++++++++++++++++-------------- src/test/unit/profile-data.test.ts | 19 +++++++++ 2 files changed, 52 insertions(+), 30 deletions(-) diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 002ac3514d..baed4507e9 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -4660,6 +4660,35 @@ export function nudgeReturnAddresses(profile: Profile): Profile { return newProfile; } +/** + * The source of a frame with an address, which is what can prove to the + * symbolication API's /source/v1 that the source belongs to the library. Null + * when the frame has no address or its func no source. + */ +function _getAddressProvenSource( + frameTable: RawFrameTable, + funcTable: RawFuncTable, + frameIndex: IndexIntoFrameTable +): IndexIntoSourceTable | null { + if ((frameTable.flags[frameIndex] & FrameFlag.HasAddress) === 0) { + return null; + } + const funcIndex = frameTable.func[frameIndex]; + if ((funcTable.flags[funcIndex] & FuncFlag.HasSource) === 0) { + return null; + } + return funcTable.source[funcIndex]; +} + +function _getAddressProofForFrame( + profile: Profile, + frameIndex: IndexIntoFrameTable +): AddressProof { + const { frameTable } = profile.shared; + const { debugName, breakpadId } = profile.libs[frameTable.lib[frameIndex]]; + return { debugName, breakpadId, address: frameTable.address[frameIndex] }; +} + /** * Find the address and library (debugName, breakpadId) for any frame which * was symbolicated to the given filename. @@ -4668,41 +4697,15 @@ export function findAddressProofForFile( profile: Profile, sourceIndex: IndexIntoSourceTable ): AddressProof | null { - const { libs } = profile; const { frameTable, funcTable } = profile.shared; - // Scan for the func manually rather than using `funcTable.source.indexOf`: - // the `source` column only carries a meaningful value for funcs which have - // the `HasSource` flag set, and holds an arbitrary value (usually zero) for - // all other funcs. An `indexOf` would happily match one of those. - let func = -1; - for (let i = 0; i < funcTable.length; i++) { + for (let frameIndex = 0; frameIndex < frameTable.length; frameIndex++) { if ( - (funcTable.flags[i] & FuncFlag.HasSource) !== 0 && - funcTable.source[i] === sourceIndex + _getAddressProvenSource(frameTable, funcTable, frameIndex) === sourceIndex ) { - func = i; - break; + return _getAddressProofForFrame(profile, frameIndex); } } - if (func === -1) { - return null; - } - const frame = frameTable.func.indexOf(func); - if (frame === -1) { - return null; - } - if ((frameTable.flags[frame] & FrameFlag.HasAddress) === 0) { - return null; - } - const address = frameTable.address[frame]; - const libIndex = frameTable.lib[frame]; - const lib = libs[libIndex]; - const { debugName, breakpadId } = lib; - return { - debugName, - breakpadId, - address, - }; + return null; } /** diff --git a/src/test/unit/profile-data.test.ts b/src/test/unit/profile-data.test.ts index 0e83649442..d8256f3af6 100644 --- a/src/test/unit/profile-data.test.ts +++ b/src/test/unit/profile-data.test.ts @@ -1546,6 +1546,25 @@ describe('findAddressProofForFile', function () { ); expect(missingAddressProof).toBeNull(); }); + + it('finds an address for a file whose first frame has none', function () { + const { profile } = getProfileFromTextSamples(` + A[lib:XUL][file:a.cpp] + B[lib:XUL][file:a.cpp][address:20] + `); + const stringTable = StringTable.withBackingArray( + profile.shared.stringArray + ); + const sourceIndex = profile.shared.sources.filename.indexOf( + stringTable.indexForString('a.cpp') + ); + + expect(findAddressProofForFile(profile, sourceIndex)).toEqual({ + debugName: 'XUL', + breakpadId: 'SOMETHING_FAKE', + address: 0x20, + }); + }); }); describe('calculateFunctionSizeLowerBound', function () { From 0e4066c584149a3d4ee8a460f7219027062dff3e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 21/24] Add "open bottom box for function X" support code. The bottom box can currently only be opened for a call node. Add the equivalent lookup for a bare function index, so that views which select functions rather than call nodes can open the bottom box too. --- src/profile-logic/bottom-box.ts | 96 +++++++++++++++++++++++++++++++ src/profile-logic/call-tree.ts | 13 ++++- src/profile-logic/profile-data.ts | 41 +++++++++++++ src/test/store/bottom-box.test.ts | 56 +++++++++++++++++- 4 files changed, 204 insertions(+), 2 deletions(-) diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index 95831f996f..f857ca95a2 100644 --- a/src/profile-logic/bottom-box.ts +++ b/src/profile-logic/bottom-box.ts @@ -8,12 +8,14 @@ import type { Thread, IndexIntoStackTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, BottomBoxInfo, SamplesLikeTable, } from 'firefox-profiler/types'; import type { CallNodeInfo } from './call-node-info'; import { getCallNodeFramePerStack, + getFunctionFramePerStack, getNativeSymbolInfo, getNativeSymbolsForCallNode, getOriginalPositionForFrame, @@ -176,3 +178,97 @@ export function getBottomBoxInfoForStackFrame( highlightedInstructionAddress: instructionAddress, }; } + +/** + * Calculate the BottomBoxInfo for a function, i.e. information about which + * things should be shown in the profiler UI's "bottom box" when a function is + * double-clicked in the function list. + * + * Unlike getBottomBoxInfoForCallNode, this considers all stacks where the + * function appears anywhere (not just as the self function), using the + * innermost (leaf-most) frame when the function appears multiple times in one + * stack due to recursion. + */ +export function getBottomBoxInfoForFunction( + funcIndex: IndexIntoFuncTable, + thread: Thread, + samples: SamplesLikeTable +): BottomBoxInfo { + const { stackTable, frameTable, funcTable, stringTable, nativeSymbols } = + thread; + + const { source: sourceIndex, line: funcLine } = getOriginalPositionForFrame( + null, + funcIndex, + frameTable, + funcTable, + thread.sourceLocationTable + ); + + const funcFramePerStack = getFunctionFramePerStack( + funcIndex, + stackTable, + frameTable + ); + + const nativeSymbolsForFunc = getNativeSymbolsForCallNode( + funcFramePerStack, + frameTable + ); + let initialNativeSymbol = null; + const nativeSymbolTimings = getTotalNativeSymbolTimingsForCallNode( + samples, + funcFramePerStack, + frameTable + ); + const hottestNativeSymbol = mapGetKeyWithMaxValue(nativeSymbolTimings); + if (hottestNativeSymbol !== undefined) { + nativeSymbolsForFunc.add(hottestNativeSymbol); + initialNativeSymbol = hottestNativeSymbol; + } + const nativeSymbolsForFuncArr = [...nativeSymbolsForFunc]; + nativeSymbolsForFuncArr.sort((a, b) => a - b); + if (nativeSymbolsForFuncArr.length !== 0 && initialNativeSymbol === null) { + initialNativeSymbol = nativeSymbolsForFuncArr[0]; + } + + const nativeSymbolInfosForFunc = nativeSymbolsForFuncArr.map( + (nativeSymbolIndex) => + getNativeSymbolInfo( + nativeSymbolIndex, + nativeSymbols, + frameTable, + stringTable + ) + ); + + const lineTimings = getTotalLineTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + funcTable, + funcLine, + thread.sourceLocationTable + ); + const hottestLine = mapGetKeyWithMaxValue(lineTimings); + const addressTimings = getTotalAddressTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + initialNativeSymbol + ); + const hottestInstructionAddress = mapGetKeyWithMaxValue(addressTimings); + + return { + sourceIndex, + nativeSymbols: nativeSymbolInfosForFunc, + initialNativeSymbol: + initialNativeSymbol !== null + ? nativeSymbolsForFuncArr.indexOf(initialNativeSymbol) + : null, + scrollToLineNumber: hottestLine, + scrollToInstructionAddress: hottestInstructionAddress, + highlightedLineNumber: null, + highlightedInstructionAddress: null, + }; +} diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index c4a6360119..f870e0f703 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -39,7 +39,10 @@ import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; import type { SortableColumn } from '../components/shared/TreeView'; -import { getBottomBoxInfoForCallNode } from './bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -652,6 +655,14 @@ export class CallTree { ); } + getBottomBoxInfoForFunction(funcIndex: IndexIntoFuncTable): BottomBoxInfo { + return getBottomBoxInfoForFunction( + funcIndex, + this._thread, + this._previewFilteredCtssSamples + ); + } + /** * Take a IndexIntoCallNodeTable, and compute an inverted path for it. * diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index baed4507e9..01ebf66d3c 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -969,6 +969,47 @@ export function getCallNodeFramePerStackInverted( return callNodeFramePerStack; } +/** + * For each stack, returns the innermost (leaf-most) frame whose function matches + * funcIndex, or -1 if funcIndex doesn't appear in that stack at all. + * + * This is used when double-clicking a function in the function list, to find + * which frame to show in the source and assembly views. When a function appears + * multiple times in a stack (due to recursion), we use the innermost occurrence, + * because that is the one doing the most specific work. + * + * Example: for stack A -> B -> C -> B -> D, asking for func B gives: + * - frame of the B in "C -> B" (the innermost B), not the B in "A -> B" + * + * The algorithm takes advantage of the stack table's ordering (parents before + * children): for each stack, we start with the parent's result and overwrite + * whenever we encounter funcIndex again, so the last write wins (innermost). + */ +export function getFunctionFramePerStack( + funcIndex: IndexIntoFuncTable, + stackTable: StackTable, + frameTable: FrameTable +): Int32Array { + const { frame: frameCol, prefix: prefixCol, length: stackCount } = stackTable; + const funcCol = frameTable.func; + + const funcFramePerStack = new Int32Array(stackCount); + + for (let stackIndex = 0; stackIndex < stackCount; stackIndex++) { + const frame = frameCol[stackIndex]; + if (funcCol[frame] === funcIndex) { + // This stack's own frame matches: it is the innermost so far, overwrite. + funcFramePerStack[stackIndex] = frame; + } else { + // Inherit from parent (or -1 if there is no parent). + const prefix = prefixCol[stackIndex]; + funcFramePerStack[stackIndex] = + prefix !== -1 ? funcFramePerStack[prefix] : -1; + } + } + return funcFramePerStack; +} + /** * Take a samples table, and return an array that contain indexes that point to the * leaf most call node, or null. diff --git a/src/test/store/bottom-box.test.ts b/src/test/store/bottom-box.test.ts index 64037f2faa..7d3ef96ae3 100644 --- a/src/test/store/bottom-box.test.ts +++ b/src/test/store/bottom-box.test.ts @@ -8,7 +8,10 @@ import * as UrlStateSelectors from '../../selectors/url-state'; import * as ProfileSelectors from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; import { emptyAddressTimings } from '../../profile-logic/address-timings'; -import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from '../../profile-logic/bottom-box'; import { changeSelectedCallNode, updateBottomBoxContentsAndMaybeOpen, @@ -372,3 +375,54 @@ describe('bottom box', function () { // - A test with multiple threads: Open the assembly view for a symbol, switch // to a different thread, check timings }); + +describe('getBottomBoxInfoForFunction', function () { + it('uses the innermost frame when a function appears multiple times due to recursion (A->B->A->B)', function () { + // Stack: A[line:20] -> B[line:30] -> A[line:21] -> B[line:31] + // B appears at line 30 (outer) and line 31 (inner/leaf). + // Double-clicking B in the function list should use line 31 (innermost). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + A[file:a.js][line:21] + B[file:b.js][line:31] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('uses the innermost frame when a function appears multiple times due to recursion via another function (A->B->C->B->D)', function () { + // Stack: A[line:20] -> B[line:30] -> C[line:40] -> B[line:31] -> D[line:50] + // B appears at line 30 (outer) and line 31 (inner), with C and D in between. + // Double-clicking B in the function list should use line 31 (innermost B). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + C[file:c.js][line:40] + B[file:b.js][line:31] + D[file:d.js][line:50] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction( + B, + thread, + thread.samples + ); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); +}); From 7892c63c93e749b2ac41cfb79b6e60b5188e6a9d Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 14 Oct 2026 23:29:15 -0400 Subject: [PATCH 22/24] profiler-cli: Limit function annotate --mode asm to the 10 heaviest native symbols. A function inlined in many places can have tens of thousands of native symbols. Each one cost a pass over the whole stack table plus an assembly fetch, so the daemon appeared to hang. --- src/profile-query/function-annotate.ts | 51 ++++++++++++++++++- .../profile-query/function-annotate.test.ts | 33 ++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 src/test/unit/profile-query/function-annotate.test.ts diff --git a/src/profile-query/function-annotate.ts b/src/profile-query/function-annotate.ts index 48c5ce012f..c14765e4ab 100644 --- a/src/profile-query/function-annotate.ts +++ b/src/profile-query/function-annotate.ts @@ -19,6 +19,8 @@ import { import { getNativeSymbolInfo, getNativeSymbolsForFunc, + getFunctionFramePerStack, + getTotalNativeSymbolTimingsForCallNode, findAddressProofForFile, getOriginalPositionForFrame, computeFuncTableFromRawFuncTable, @@ -226,7 +228,40 @@ async function fetchSourceAnnotation( }; } +// Computing address timings and fetching assembly is done per native symbol, +// and costs a pass over the whole stack table each. Functions that are inlined +// in many places can have tens of thousands of native symbols, so only the +// heaviest ones are annotated. +const MAX_ASM_NATIVE_SYMBOLS = 10; + +export function selectHeaviestNativeSymbols( + funcIndex: IndexIntoFuncTable, + nativeSymbolsForFunc: Set, + thread: Thread, + samples: SamplesLikeTable +): Set { + const { stackTable, frameTable } = thread; + const funcFramePerStack = getFunctionFramePerStack( + funcIndex, + stackTable, + frameTable + ); + const weightPerSymbol = getTotalNativeSymbolTimingsForCallNode( + samples, + funcFramePerStack, + frameTable + ); + return new Set( + Array.from(nativeSymbolsForFunc) + .sort( + (a, b) => (weightPerSymbol.get(b) ?? 0) - (weightPerSymbol.get(a) ?? 0) + ) + .slice(0, MAX_ASM_NATIVE_SYMBOLS) + ); +} + async function fetchAsmAnnotations( + funcIndex: IndexIntoFuncTable, functionHandle: string, nativeSymbolsForFunc: Set, thread: Thread, @@ -245,8 +280,21 @@ async function fetchAsmAnnotations( const { stackTable, frameTable, funcTable: threadFuncTable } = thread; const nativeSymbolCount = nativeSymbolsForFunc.size; + let symbolsToAnnotate = nativeSymbolsForFunc; + if (nativeSymbolCount > MAX_ASM_NATIVE_SYMBOLS) { + symbolsToAnnotate = selectHeaviestNativeSymbols( + funcIndex, + nativeSymbolsForFunc, + thread, + samples + ); + warnings.push( + `Function ${functionHandle} has ${nativeSymbolCount} native symbols; showing only the ${MAX_ASM_NATIVE_SYMBOLS} with the most samples.` + ); + } + const results = await Promise.all( - Array.from(nativeSymbolsForFunc).map(async (nsIndex) => { + Array.from(symbolsToAnnotate).map(async (nsIndex) => { const nativeSymbolInfo = getNativeSymbolInfo( nsIndex, thread.nativeSymbols, @@ -408,6 +456,7 @@ export async function functionAnnotate( const asmPromise: Promise = mode === 'asm' || mode === 'all' ? fetchAsmAnnotations( + funcIndex, functionHandle, nativeSymbolsForFunc, thread, diff --git a/src/test/unit/profile-query/function-annotate.test.ts b/src/test/unit/profile-query/function-annotate.test.ts new file mode 100644 index 0000000000..d16fbbd8b7 --- /dev/null +++ b/src/test/unit/profile-query/function-annotate.test.ts @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { selectHeaviestNativeSymbols } from '../../../profile-query/function-annotate'; +import { getNativeSymbolsForFunc } from '../../../profile-logic/profile-data'; +import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; + +it('selects the ten native symbols with the highest sample weights', function () { + const frames = Array.from( + { length: 11 }, + (_, i) => `A[lib:one][sym:s${i}:${i.toString(16)}:1]` + ); + const { derivedThreads, funcNamesDictPerThread } = getProfileFromTextSamples( + [...frames, 'Unrelated'].join(' ') + ); + const [thread] = derivedThreads; + const [{ A }] = funcNamesDictPerThread; + thread.samples.weight = [...frames.map((_, i) => i + 1), 100]; + + const selected = selectHeaviestNativeSymbols( + A, + getNativeSymbolsForFunc(A, thread.frameTable), + thread, + thread.samples + ); + + expect( + [...selected].map((i) => + thread.stringTable.getString(thread.nativeSymbols.name[i]) + ) + ).toEqual(Array.from({ length: 10 }, (_, i) => `s${10 - i}`)); +}); From fe37b882a638fa71c5f2084447462cba9feb2a81 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 21 Sep 2026 18:20:01 -0400 Subject: [PATCH 23/24] Switch to TypeScript 7 (tsgo) and replace ESLint with oxlint TypeScript 7 is the native Go port of the compiler. It ships only the `tsc` binary. Its JavaScript compiler API won't return until 7.1, and typescript-eslint refuses to load without it. So both switches have to happen together. Timings on this machine, from a cold cache: yarn ts 4.83s -> 1.04s yarn lint-js 11.00s -> 1.93s yarn lint 14.03s -> 2.98s The rules in .oxlintrc.json were derived from the effective configuration of the old eslint.config.mjs. Every lint category is switched off and each rule is listed explicitly. Enabling a category would turn on rules that ESLint didn't enforce. Rules that oxlint doesn't implement natively come from the original ESLint plugins, loaded through `jsPlugins` in the same oxlint pass: eslint-plugin-testing-library 20 rules eslint-plugin-jest-dom 11 rules eslint-plugin-react 7 rules, loaded as `react-js` eslint-plugin-jest-formatting padding-around-describe-blocks eslint-plugin-import no-unresolved, for JavaScript files only, loaded as `import-js` oxlint implements consistent-return and dot-notation only as type-aware rules, which can't run on JavaScript files. A small local plugin, scripts/lib/oxlint-eslint-core-rules.mjs, applies ESLint's own versions to .js, .mjs and .cjs files. In TypeScript files, tsc already reports unresolved imports (TS2307). `eslint` stays a devDependency because these plugins import it. Nothing runs the ESLint CLI. These rules are not carried over: - no-invalid-this. In TypeScript files, tsc reports the same problem (TS2683). ESLint's implementation crashes under oxlint, so it doesn't run on JavaScript files. Only one of those uses `this`. - react/no-did-mount-set-state and react/no-did-update-set-state. eslint-plugin-react makes them no-ops for React 16.3 and newer. - no-return-await, which ESLint has deprecated. typescript/return-await in `error-handling-correctness-only` mode replaces it. It only reports an `await` that changes which errors a surrounding try/catch sees. Both linters were run over a corpus of 57 files with one defect each, one per rule. oxlint catches everything ESLint caught, with no false positives. eslint-disable comments for jsPlugins rules are renamed to the names oxlint reports. One jest/no-conditional-expect disable comment is removed: oxlint's version of the rule misses `function () {}` test callbacks, so the comment counted as unused. This is https://github.com/oxc-project/oxc/issues/28054. --- .devcontainer/devcontainer.json | 6 +- .oxlintrc.json | 540 ++++++++++++++++++ CONTRIBUTING.md | 2 +- eslint.config.mjs | 312 ---------- package.json | 12 +- scripts/lib/oxlint-eslint-core-rules.mjs | 17 + .../app/AppLocalizationProvider.tsx | 2 +- .../flame-graph/ConnectedFlameGraph.tsx | 3 +- src/components/flame-graph/FlameGraph.tsx | 2 +- .../shared/ButtonWithPanel/ArrowPanel.tsx | 4 +- src/components/shared/TrackSearchField.tsx | 2 +- src/components/shared/TreeView.tsx | 4 +- src/components/shared/VirtualList.tsx | 4 +- src/test/components/FlameGraph.test.tsx | 2 +- src/test/components/MenuButtons.test.tsx | 10 +- .../components/TransformShortcuts.test.tsx | 5 +- src/test/unit/merge-compare.test.ts | 6 +- src/test/unit/path.test.ts | 1 - src/test/unit/process-profile.test.ts | 4 + yarn.lock | 506 +++++++++++++--- 20 files changed, 1017 insertions(+), 427 deletions(-) create mode 100644 .oxlintrc.json delete mode 100644 eslint.config.mjs create mode 100644 scripts/lib/oxlint-eslint-core-rules.mjs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 14c96ea10c..5f4b7a9d0a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,11 +17,7 @@ "postStartCommand": "FX_PROFILER_HOST=\"0.0.0.0\" yarn start", "customizations": { "vscode": { - "extensions": [ - "dbaeumer.vscode-eslint", - "oxc.oxc-vscode", - "stylelint.vscode-stylelint" - ], + "extensions": ["oxc.oxc-vscode", "stylelint.vscode-stylelint"], "settings": { "editor.formatOnSave": true, "editor.defaultFormatter": "oxc.oxc-vscode" diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..65124fa6a3 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,540 @@ +// Oxlint configuration for the Firefox Profiler. +// +// All lint categories are switched off, so only the rules listed here run. +// +// Rules that oxlint doesn't implement natively come from ESLint plugins, loaded +// through `jsPlugins`. eslint-plugin-react is loaded as `react-js` so that it +// doesn't clash with oxlint's native `react` plugin. jsPlugins support is still +// in alpha, so an oxlint upgrade may require changes here. +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": [ + "src/profile-logic/import/proto/**", + "src/types/libdef/npm/**", + "profiler-cli/dist/**", + "res/**", + "dist/**", + "node-tools-dist/**", + "docs-user/**", + "coverage/**" + ], + "plugins": ["typescript", "react", "import", "jest"], + "categories": { + "correctness": "off", + "suspicious": "off", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "env": { + "browser": true, + "es2024": true, + "node": true + }, + "globals": { + "AVAILABLE_STAGING_LOCALES": "readonly" + }, + "settings": { + "react": { + "version": "19.0" + }, + // Used by import-js/no-unresolved. + "import/resolver": { + "alias": { + "map": [ + ["firefox-profiler", "./src"], + ["firefox-profiler-res", "./res"] + ], + "extensions": [".js", ".ts", ".tsx", ".jpg"] + } + } + }, + "options": { + "typeAware": true, + "reportUnusedDisableDirectives": "error" + }, + "rules": { + "array-callback-return": [ + "error", + { + "allowImplicit": false, + "checkForEach": false, + "allowVoid": false + } + ], + "curly": ["error", "all"], + "default-case": ["error", {}], + "eqeqeq": "error", + "for-direction": "error", + "import/named": "error", + "import/no-duplicates": "error", + "no-alert": "error", + "no-array-constructor": "error", + "no-async-promise-executor": "error", + "no-caller": "error", + "no-case-declarations": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": ["error", "except-parens"], + "no-console": [ + "error", + { + "allow": ["log", "warn", "error"] + } + ], + "no-constant-binary-expression": "error", + "no-constant-condition": [ + "error", + { + "checkLoops": false + } + ], + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-else-if": "error", + "no-duplicate-case": "error", + "no-else-return": [ + "error", + { + "allowElseIf": true + } + ], + "no-empty": [ + "error", + { + "allowEmptyCatch": false + } + ], + "no-empty-character-class": "error", + "no-empty-pattern": [ + "error", + { + "allowObjectPatternsAsParameters": false + } + ], + "no-empty-static-block": "error", + "no-eval": [ + "error", + { + "allowIndirect": false + } + ], + "no-ex-assign": "error", + "no-extend-native": [ + "error", + { + "exceptions": [] + } + ], + "no-extra-bind": "error", + "no-extra-boolean-cast": ["error", {}], + "no-extra-label": "error", + "no-fallthrough": "error", + "no-global-assign": [ + "error", + { + "exceptions": [] + } + ], + "no-implied-eval": "error", + "no-invalid-regexp": ["error", {}], + "no-irregular-whitespace": [ + "error", + { + "skipComments": false, + "skipJSXText": false, + "skipRegExps": false, + "skipStrings": true, + "skipTemplates": false + } + ], + "no-loss-of-precision": "error", + "no-misleading-character-class": [ + "error", + { + "allowEscape": false + } + ], + "no-nested-ternary": "error", + "no-nonoctal-decimal-escape": "error", + "no-prototype-builtins": "error", + "no-regex-spaces": "error", + "no-self-assign": [ + "error", + { + "props": true + } + ], + "no-self-compare": "error", + "no-shadow-restricted-names": [ + "error", + { + "reportGlobalThis": false + } + ], + "no-sparse-arrays": "error", + "no-throw-literal": "error", + "no-unexpected-multiline": "error", + "no-unmodified-loop-condition": "error", + "no-unsafe-finally": "error", + "no-unsafe-optional-chaining": [ + "error", + { + "disallowArithmeticOperators": false + } + ], + "no-unused-expressions": [ + "error", + { + "allowShortCircuit": false, + "allowTaggedTemplates": false, + "allowTernary": false + } + ], + "no-unused-labels": "error", + "no-unused-private-class-members": "error", + // Prefix a name with an underscore to mark it as intentionally unused. + "no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrors": "all", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ], + "no-useless-backreference": "error", + "no-useless-call": "error", + "no-useless-catch": "error", + "no-useless-computed-key": [ + "error", + { + "enforceForClassMembers": true + } + ], + "no-useless-concat": "error", + "no-useless-constructor": "error", + "no-useless-escape": [ + "error", + { + "allowRegexCharacters": [] + } + ], + "no-useless-rename": [ + "error", + { + "ignoreDestructuring": false, + "ignoreImport": false, + "ignoreExport": false + } + ], + "no-useless-return": "error", + "no-var": "error", + "no-void": [ + "error", + { + "allowAsStatement": false + } + ], + "no-with": "error", + "prefer-const": [ + "error", + { + "destructuring": "any", + "ignoreReadBeforeAssign": false + } + ], + "prefer-promise-reject-errors": [ + "error", + { + "allowEmptyReject": false + } + ], + "prefer-rest-params": "error", + "prefer-spread": "error", + "react-js/jsx-no-bind": "error", + "react-js/jsx-no-leaked-render": "error", + "react-js/no-access-state-in-setstate": "error", + "react-js/no-deprecated": "error", + "react-js/no-typos": "error", + "react-js/no-unused-class-component-methods": "error", + "react-js/no-unused-state": "error", + + // react-js/no-unused-prop-types is not enabled: it mistakes functions with + // destructured parameters for function components. + // See https://github.com/yannickcr/eslint-plugin-react/issues/1561 + + "react/button-has-type": "error", + "react/display-name": "error", + "react/jsx-curly-brace-presence": [ + "error", + { + "props": "never", + "children": "never" + } + ], + "react/jsx-key": "error", + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-undef": "error", + "react/no-children-prop": "error", + "react/no-danger": "error", + "react/no-danger-with-children": "error", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "error", + "react/no-is-mounted": "error", + "react/no-redundant-should-component-update": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/no-this-in-sfc": "error", + "react/no-unescaped-entities": "error", + "react/no-unknown-property": "error", + "react/no-will-update-set-state": "error", + "react/require-render-return": "error", + "require-yield": "error", + // Allow @ts-expect-error annotations with descriptions. Don't allow + // @ts-ignore or @ts-nocheck: we want to be notified when the error goes + // away so that the annotation can be removed. @ts-check is allowed even + // without a description. + "typescript/ban-ts-comment": [ + "error", + { + "ts-expect-error": "allow-with-description", + "ts-ignore": true, + "ts-nocheck": true, + "ts-check": false + } + ], + // Use `import type` everywhere we can. + "typescript/consistent-type-imports": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-misused-new": "error", + "typescript/no-namespace": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/triple-slash-reference": "error", + "use-isnan": [ + "error", + { + "enforceForIndexOf": false, + "enforceForSwitchCase": true + } + ], + "valid-typeof": [ + "error", + { + "requireStringLiterals": false + } + ], + + // The following rules are off by default. They are listed to record why + // they stay off. + + // Allow `as any` escape hatches. + "typescript/no-explicit-any": "off", + // The TypeScript FAQ disapproves of this rule. + "typescript/no-empty-object-type": "off", + // TypeScript imports react-jsx into .tsx files for us. + "react/react-in-jsx-scope": "off", + + // The "no-unsafe" rules complain about using "any" freely. + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off", + + // False positives when narrowing return types, see + // https://github.com/typescript-eslint/typescript-eslint/issues/6951 + // (`yarn lint-fix` can make `yarn ts` fail). + "typescript/no-unnecessary-type-assertion": "off", + + // Consider enabling these in the future. + "typescript/await-thenable": "off", + "typescript/no-array-delete": "off", + "typescript/no-base-to-string": "off", + "typescript/no-floating-promises": "off", + "typescript/no-misused-promises": "off", + "typescript/no-redundant-type-constituents": "off", + "typescript/only-throw-error": "off", + "typescript/prefer-promise-reject-errors": "off", + "typescript/require-await": "off", + "typescript/restrict-plus-operands": "off", + "typescript/restrict-template-expressions": "off", + "typescript/unbound-method": "off" + }, + "overrides": [ + // Type-aware rules. These need type information, so they only run on + // TypeScript files. + { + "files": ["**/*.ts", "**/*.tsx"], + "rules": { + "typescript/consistent-return": [ + "error", + { + "treatUndefinedAsUnspecified": false + } + ], + "typescript/dot-notation": [ + "error", + { + "allowKeywords": true, + "allowPattern": "" + } + ], + "typescript/no-duplicate-type-constituents": "error", + "typescript/no-for-in-array": "error", + "typescript/no-implied-eval": "error", + "typescript/no-unsafe-enum-comparison": "error", + "typescript/no-unsafe-unary-minus": "error", + // Only reports an `await` whose presence or absence changes which errors + // a surrounding try/catch sees. + "typescript/return-await": ["error", "error-handling-correctness-only"] + } + }, + // Checks for JavaScript files that TypeScript or type-aware rules provide + // for TypeScript files. oxlint only implements consistent-return and + // dot-notation as type-aware rules, so use ESLint's own implementations. + // For TypeScript files, tsc reports unresolved imports (TS2307). + { + "files": ["**/*.js", "**/*.mjs", "**/*.cjs"], + "rules": { + "eslint-js/consistent-return": [ + "error", + { + "treatUndefinedAsUnspecified": false + } + ], + "eslint-js/dot-notation": [ + "error", + { + "allowKeywords": true + } + ], + "import-js/no-unresolved": "error" + } + }, + // For non-test files, only allow import, no require(). Test files use + // require() for JSON fixtures. + { + "files": ["src/**/*.ts", "src/**/*.tsx"], + "rules": { + "typescript/no-require-imports": "error" + } + }, + { + "files": ["src/test/**/*", "profiler-cli/src/test/**/*"], + "env": { + "jest": true + }, + "rules": { + "jest-dom/prefer-checked": "error", + "jest-dom/prefer-empty": "error", + "jest-dom/prefer-enabled-disabled": "error", + "jest-dom/prefer-focus": "error", + "jest-dom/prefer-in-document": "error", + "jest-dom/prefer-required": "error", + "jest-dom/prefer-to-have-attribute": "error", + "jest-dom/prefer-to-have-class": "error", + "jest-dom/prefer-to-have-style": "error", + "jest-dom/prefer-to-have-text-content": "error", + "jest-dom/prefer-to-have-value": "error", + "jest-formatting/padding-around-describe-blocks": "error", + "jest/expect-expect": "error", + "jest/no-alias-methods": "error", + "jest/no-commented-out-tests": "error", + "jest/no-conditional-expect": "error", + "jest/no-deprecated-functions": "error", + "jest/no-disabled-tests": "error", + "jest/no-done-callback": "error", + "jest/no-export": "error", + "jest/no-focused-tests": "error", + "jest/no-identical-title": "error", + "jest/no-interpolation-in-snapshots": "error", + "jest/no-jasmine-globals": "error", + "jest/no-mocks-import": "error", + "jest/no-standalone-expect": "error", + "jest/no-test-prefixes": "error", + "jest/padding-around-test-blocks": "error", + "jest/valid-describe-callback": "error", + "jest/valid-expect": "error", + "jest/valid-expect-in-promise": "error", + "react-js/jsx-no-bind": "off", + "testing-library/await-async-events": "error", + "testing-library/await-async-queries": "error", + "testing-library/await-async-utils": "error", + "testing-library/no-await-sync-events": "error", + "testing-library/no-await-sync-queries": "error", + "testing-library/no-debugging-utils": "error", + "testing-library/no-dom-import": "error", + "testing-library/no-global-regexp-flag-in-query": "error", + "testing-library/no-manual-cleanup": "error", + "testing-library/no-promise-in-fire-event": "error", + "testing-library/no-render-in-lifecycle": "error", + "testing-library/no-unnecessary-act": "error", + "testing-library/no-wait-for-multiple-assertions": "error", + "testing-library/no-wait-for-side-effects": "error", + "testing-library/no-wait-for-snapshot": "error", + "testing-library/prefer-explicit-assert": [ + "error", + { + "includeFindQueries": false + } + ], + "testing-library/prefer-find-by": "error", + "testing-library/prefer-presence-queries": "error", + "testing-library/prefer-query-by-disappearance": "error", + "testing-library/prefer-screen-queries": "error", + "typescript/no-require-imports": "off", + + // The following rules are off by default. They are listed to record + // why they stay off. + + // TypeScript already checks test titles. + "jest/valid-title": "off", + // A purely stylistic rule. + "testing-library/render-result-naming-convention": "off", + // `container` and direct node access (e.g. firstChild) still have + // legitimate uses in our tests. + "testing-library/no-container": "off", + "testing-library/no-node-access": "off" + } + }, + { + "files": ["__mocks__/**/*"], + "env": { + "jest": true + } + } + ], + "jsPlugins": [ + "eslint-plugin-testing-library", + "eslint-plugin-jest-dom", + "eslint-plugin-jest-formatting", + { + "name": "react-js", + "specifier": "eslint-plugin-react" + }, + { + "name": "import-js", + "specifier": "eslint-plugin-import" + }, + { + "name": "eslint-js", + "specifier": "./scripts/lib/oxlint-eslint-core-rules.mjs" + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efdfad31d1..4ad96b80f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ When working on a new feature and code changes, it's important that things work - Locally when running - `yarn test-all` - Test all the things! - `yarn test` - Run the tests in [`./src/test/`](./src/test/). - - `yarn lint` - Run oxfmt, stylelint, and eslint to check for correct code formatting. + - `yarn lint` - Run oxfmt, stylelint, and oxlint to check for correct code formatting. - `yarn ts` - Check for TypeScript type correctness. - `yarn license-check` - Check the dependencies' licenses. - `git push` and `git commit` diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index d2c4d5e66b..0000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,312 +0,0 @@ -import { defineConfig } from 'eslint/config'; -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import tsParser from '@typescript-eslint/parser'; -import reactPlugin from 'eslint-plugin-react'; -import importPlugin from 'eslint-plugin-import'; -import jestPlugin from 'eslint-plugin-jest'; -import testingLibraryPlugin from 'eslint-plugin-testing-library'; -import jestFormattingPlugin from 'eslint-plugin-jest-formatting'; -import jestDomPlugin from 'eslint-plugin-jest-dom'; -import globals from 'globals'; - -export default defineConfig( - // Global ignores - { - ignores: [ - 'src/profile-logic/import/proto/**', - 'src/types/libdef/npm/**', - 'profiler-cli/dist/**', - 'res/**', - 'dist/**', - 'node-tools-dist/**', - 'docs-user/**', - 'coverage/**', - ], - }, - - // Base JavaScript config - js.configs.recommended, - - // TypeScript config with type checking - ...tseslint.configs.recommendedTypeChecked, - - // React config - reactPlugin.configs.flat.recommended, - - // Custom configuration for all files - { - files: ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.ts', '**/*.tsx'], - languageOptions: { - globals: { - ...globals.browser, - ...globals.es2024, - ...globals.node, - AVAILABLE_STAGING_LOCALES: true, - }, - parser: tsParser, - parserOptions: { - ecmaVersion: 2024, - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - // Note: projectService is enabled for TypeScript files only, below - }, - }, - plugins: { - import: importPlugin, - }, - settings: { - react: { - pragma: 'React', - version: '18.0', - }, - 'import/resolver': { - alias: { - map: [ - ['firefox-profiler', './src'], - ['firefox-profiler-res', './res'], - ], - extensions: ['.js', '.ts', '.tsx', '.jpg'], - }, - }, - }, - rules: { - // Plugin rules: - 'import/no-duplicates': 'error', - 'import/no-unresolved': 'error', - 'import/named': 'error', - 'react/button-has-type': 'error', - 'react/no-access-state-in-setstate': 'error', - 'react/no-danger': 'error', - 'react/no-did-mount-set-state': 'error', - 'react/no-did-update-set-state': 'error', - 'react/no-will-update-set-state': 'error', - 'react/no-redundant-should-component-update': 'error', - 'react/no-unused-class-component-methods': 'error', - 'react/no-this-in-sfc': 'error', - 'react/no-typos': 'error', - // TypeScript provides enough coverage over the prop types. - 'react/prop-types': 'off', - 'react/jsx-curly-brace-presence': [ - 'error', - { props: 'never', children: 'never' }, - ], - // `no-unused-prop-types` is buggy when we use destructuring parameters in - // functions as it misunderstands them as functional components. - // See https://github.com/yannickcr/eslint-plugin-react/issues/1561 - // 'react/no-unused-prop-types': 'error', - 'react/no-unused-state': 'error', - 'react/jsx-no-bind': 'error', - 'react/jsx-no-leaked-render': 'error', - - // overriding recommended rules - 'no-constant-condition': ['error', { checkLoops: false }], - 'no-console': ['error', { allow: ['log', 'warn', 'error'] }], - - // possible errors - 'array-callback-return': 'error', - 'consistent-return': 'error', - curly: 'error', - 'default-case': 'error', - 'dot-notation': 'error', - eqeqeq: 'error', - 'for-direction': 'error', - 'no-alert': 'error', - 'no-caller': 'error', - 'no-eval': 'error', - 'no-extend-native': 'error', - 'no-extra-bind': 'error', - 'no-extra-label': 'error', - 'no-implied-eval': 'error', - 'no-invalid-this': 'error', - 'no-return-await': 'error', - 'no-self-compare': 'error', - 'no-throw-literal': 'error', - 'no-unmodified-loop-condition': 'error', - 'no-useless-call': 'error', - 'no-useless-computed-key': 'error', - 'no-useless-concat': 'error', - 'no-useless-constructor': 'error', - 'no-useless-rename': 'error', - 'no-useless-return': 'error', - 'no-var': 'error', - 'no-void': 'error', - 'no-with': 'error', - 'prefer-const': 'error', - 'prefer-promise-reject-errors': 'error', - 'prefer-rest-params': 'error', - 'prefer-spread': 'error', - 'no-else-return': 'error', - 'no-nested-ternary': 'error', - - '@typescript-eslint/no-unused-vars': [ - 'error', - { - args: 'all', - argsIgnorePattern: '^_', - caughtErrors: 'all', - caughtErrorsIgnorePattern: '^_', - destructuredArrayIgnorePattern: '^_', - varsIgnorePattern: '^_', - ignoreRestSiblings: true, - }, - ], - - // Use `import type` everywhere we can. - '@typescript-eslint/consistent-type-imports': 'error', - // Allow `as any` escape hatches - '@typescript-eslint/no-explicit-any': 'off', - // Disable a rule that the TypeScript FAQ disapproves of - '@typescript-eslint/no-empty-object-type': 'off', - // TypeScript imports react-jsx into .tsx files for us - 'react/react-in-jsx-scope': 'off', - // Allow @ts-expect-error annotations with descriptions. - '@typescript-eslint/ban-ts-comment': [ - 'error', - { - // Allow @ts-expect-error annotations with descriptions. - 'ts-expect-error': 'allow-with-description', - // Don't allow @ts-ignore or @ts-nocheck because we want to be notified - // when the error goes away so we can remove the annotation - use - // @ts-expect-error instead - 'ts-ignore': true, - 'ts-nocheck': true, - 'ts-check': false, // allow even without description - }, - ], - '@typescript-eslint/no-require-imports': 'off', - - // Disable "no-unsafe" checks which complain about using "any" freely. - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-return': 'off', - - // Disable this one due to false positives when narrowing return types, - // see https://github.com/typescript-eslint/typescript-eslint/issues/6951 - // (it can make `yarn ts` fail after `yarn lint-fix`) - '@typescript-eslint/no-unnecessary-type-assertion': 'off', - - // Consider enabling these in the future - '@typescript-eslint/unbound-method': 'off', - '@typescript-eslint/only-throw-error': 'off', - '@typescript-eslint/no-floating-promises': 'off', - '@typescript-eslint/require-await': 'off', - '@typescript-eslint/no-redundant-type-constituents': 'off', - '@typescript-eslint/no-misused-promises': 'off', - '@typescript-eslint/await-thenable': 'off', - '@typescript-eslint/restrict-plus-operands': 'off', - '@typescript-eslint/no-base-to-string': 'off', - '@typescript-eslint/restrict-template-expressions': 'off', - '@typescript-eslint/prefer-promise-reject-errors': 'off', - '@typescript-eslint/no-array-delete': 'off', - }, - linterOptions: { - // This property is specified both here in addition to the command line in - // package.json. - // The reason is that the property only warns but the command line option - // outputs errors, but the property is useful so that we have the information - // directly in editors. - reportUnusedDisableDirectives: true, - }, - }, - - // Enable type-aware lints for TypeScript files. - { - files: ['**/*.ts', '**/*.tsx'], - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - - // Explicitly disable type-aware rules for JS files, to avoid the following error: - // > You have used a rule which requires type information, but don't have - // > parserOptions set to generate type information for this file. - { - files: ['**/*.js', '**/*.mjs', '**/*.cjs'], - ...tseslint.configs.disableTypeChecked, - }, - - // For non-test files, only allow import, no require(). (For test files we - // allow require() because we use it for JSON fixtures.) - { - files: ['src/**/*.ts', 'src/**/*.tsx'], - rules: { - '@typescript-eslint/no-require-imports': 'error', - }, - }, - - // Test files override - { - files: ['src/test/**/*', 'profiler-cli/src/test/**/*'], - languageOptions: { - globals: { - ...globals.jest, - }, - }, - plugins: { - jest: jestPlugin, - 'testing-library': testingLibraryPlugin, - 'jest-formatting': jestFormattingPlugin, - 'jest-dom': jestDomPlugin, - }, - rules: { - // Jest recommended rules - ...jestPlugin.configs.recommended.rules, - - // Testing Library recommended rules - ...testingLibraryPlugin.configs.react.rules, - - // Jest DOM recommended rules - ...jestDomPlugin.configs.recommended.rules, - - 'react/jsx-no-bind': 'off', - // This rule isn't useful because we use TypeScript. - 'jest/valid-title': 'off', - - // Allow require(), for example for importing JSON files. - '@typescript-eslint/no-require-imports': 'off', - - // Adding more errors now - 'testing-library/no-manual-cleanup': 'error', - 'testing-library/no-wait-for-snapshot': 'error', - 'testing-library/prefer-explicit-assert': [ - 'error', - { includeFindQueries: false }, - ], - 'testing-library/prefer-presence-queries': 'error', - - // Disable some rules that are in the "recommended" part. - // This is a purely stylistic rule - 'testing-library/render-result-naming-convention': 'off', - // This disallows using `container`, but this is still useful for us sometimes - 'testing-library/no-container': 'off', - // This disallows using direct Node properties (eg: firstChild), but we have - // legitimate uses: - 'testing-library/no-node-access': 'off', - // Disable until https://github.com/testing-library/eslint-plugin-testing-library/issues/359 - // is fixed. - 'testing-library/await-async-query': 'off', - - // Individual jest-formatting rules so that we format only test and describe blocks - 'jest-formatting/padding-around-describe-blocks': 'error', - 'jest-formatting/padding-around-test-blocks': 'error', - }, - }, - - // __mocks__ directory configuration - { - files: ['__mocks__/**/*'], - languageOptions: { - globals: { - ...globals.jest, - }, - }, - } -); diff --git a/package.json b/package.json index ca4a98199f..83647a04f2 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "publish-cli": "node scripts/publish-profiler-cli.mjs", "lint": "node bin/output-fixing-commands.js run-p lint-js lint-css fmt-check", "lint-fix": "run-p lint-fix-js lint-fix-css fmt", - "lint-js": "node bin/output-fixing-commands.js eslint . --report-unused-disable-directives --cache --cache-strategy content", + "lint-js": "node bin/output-fixing-commands.js oxlint --type-aware", "lint-fix-js": "yarn lint-js --fix", "lint-css": "node bin/output-fixing-commands.js stylelint \"src/**/*.css\" \"res/**/*.css\"", "lint-fix-css": "yarn lint-css --fix", @@ -126,7 +126,6 @@ "@babel/preset-env": "^8.0.7", "@babel/preset-react": "^8.0.1", "@babel/preset-typescript": "^8.0.7", - "@eslint/js": "^9.39.5", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", @@ -139,8 +138,6 @@ "@types/react-dom": "^19.3.0", "@types/redux-logger": "^3.0.6", "@types/tgwf__co2": "^0.14.2", - "@typescript-eslint/eslint-plugin": "^8.71.1", - "@typescript-eslint/parser": "^8.71.1", "alex": "^11.0.1", "babel-jest": "^30.5.2", "babel-plugin-module-resolver": "^5.0.3", @@ -156,14 +153,12 @@ "eslint": "^9.39.5", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jest": "^29.17.0", "eslint-plugin-jest-dom": "^5.10.1", "eslint-plugin-jest-formatting": "^3.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-testing-library": "^7.16.2", "fake-indexeddb": "^6.2.5", "fetch-mock": "^12.6.0", - "globals": "^17.13.0", "husky": "^9.1.7", "jest": "^30.5.2", "jest-environment-jsdom": "^30.5.2", @@ -173,6 +168,8 @@ "npm-run-all2": "^8.0.4", "open": "^11.0.4", "oxfmt": "^0.72.0", + "oxlint": "1.85.0", + "oxlint-tsgolint": "^7.0.2002", "patch-package": "^8.0.1", "postcss": "^8.5.29", "postinstall-postinstall": "^2.1.0", @@ -180,8 +177,7 @@ "stylelint": "^17.16.0", "stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-standard": "^40.0.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.71.1", + "typescript": "7.0.2", "workbox-cli": "^7.4.1", "yargs": "^18.2.0" }, diff --git a/scripts/lib/oxlint-eslint-core-rules.mjs b/scripts/lib/oxlint-eslint-core-rules.mjs new file mode 100644 index 0000000000..0307020bd6 --- /dev/null +++ b/scripts/lib/oxlint-eslint-core-rules.mjs @@ -0,0 +1,17 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// An oxlint JS plugin that exposes some of ESLint's core rules. oxlint only +// implements these as type-aware rules, which can't run on JavaScript files. +// .oxlintrc.json enables them for JavaScript files only. +import { builtinRules } from 'eslint/use-at-your-own-risk'; + +const RULE_NAMES = ['consistent-return', 'dot-notation']; + +export default { + meta: { name: 'eslint-js' }, + rules: Object.fromEntries( + RULE_NAMES.map((name) => [name, builtinRules.get(name)]) + ), +}; diff --git a/src/components/app/AppLocalizationProvider.tsx b/src/components/app/AppLocalizationProvider.tsx index 2b85f1fc97..b40e71f78a 100644 --- a/src/components/app/AppLocalizationProvider.tsx +++ b/src/components/app/AppLocalizationProvider.tsx @@ -244,7 +244,7 @@ export class AppLocalizationProvider extends React.PureComponent< }; // Used by the global togglePseudoStrategy function for console access - // eslint-disable-next-line react/no-unused-class-component-methods + // oxlint-disable-next-line react-js/no-unused-class-component-methods togglePseudoStrategy = (pseudoStrategy: PseudoStrategy) => { this.setState({ pseudoStrategy }); }; diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx index 17d8e1a46f..0946313b55 100644 --- a/src/components/flame-graph/ConnectedFlameGraph.tsx +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -94,7 +94,8 @@ class ConnectedFlameGraphImpl { _flameGraph: React.RefObject = React.createRef(); - // eslint-disable-next-line react/no-unused-class-component-methods -- called via ConnectedFlameGraphHandle ref from FlameGraphViewImpl + // Called via ConnectedFlameGraphHandle ref from FlameGraphViewImpl. + // oxlint-disable-next-line react-js/no-unused-class-component-methods focus() { this._flameGraph.current?.focus(); } diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index a6999b3ad5..f91b111817 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -108,7 +108,7 @@ export class FlameGraph }; /* This method is called from ConnectedFlameGraph. */ - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods focus = () => { if (this._viewport) { this._viewport.focus(); diff --git a/src/components/shared/ButtonWithPanel/ArrowPanel.tsx b/src/components/shared/ButtonWithPanel/ArrowPanel.tsx index 109ac54b84..dfb3b5eb15 100644 --- a/src/components/shared/ButtonWithPanel/ArrowPanel.tsx +++ b/src/components/shared/ButtonWithPanel/ArrowPanel.tsx @@ -36,7 +36,7 @@ export class ArrowPanel extends React.PureComponent { /* These 2 methods are called from other components. /* See https://github.com/firefox-devtools/profiler/issues/1888 and * https://github.com/firefox-devtools/profiler/issues/1641 */ - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods open() { if (this.state.open) { return; @@ -45,7 +45,7 @@ export class ArrowPanel extends React.PureComponent { this.setState({ open: true }); } - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods close() { this.setState((state) => { if (!state.open) { diff --git a/src/components/shared/TrackSearchField.tsx b/src/components/shared/TrackSearchField.tsx index 1519c5e6f4..28afea7f8d 100644 --- a/src/components/shared/TrackSearchField.tsx +++ b/src/components/shared/TrackSearchField.tsx @@ -20,7 +20,7 @@ export class TrackSearchField extends React.PureComponent { }; /* This is called from TrackContextMenu directly */ - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods focus = () => { if (this.searchFieldInput.current) { this.searchFieldInput.current.focus(); diff --git a/src/components/shared/TreeView.tsx b/src/components/shared/TreeView.tsx index 3b49a9cef1..5bc2fb4eb9 100644 --- a/src/components/shared/TreeView.tsx +++ b/src/components/shared/TreeView.tsx @@ -794,7 +794,7 @@ export class TreeView< ); /* This method is used by users of this component. */ - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods scrollSelectionIntoView() { const { selectedNodeId, tree } = this.props; const list = this._list; // this temp variable so that flow knows that it's non-null later @@ -1112,7 +1112,7 @@ export class TreeView< }; /* This method is used by users of this component. */ - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods focus() { if (this._list) { this._list.focus(); diff --git a/src/components/shared/VirtualList.tsx b/src/components/shared/VirtualList.tsx index bd01924cf2..0f6a130efb 100644 --- a/src/components/shared/VirtualList.tsx +++ b/src/components/shared/VirtualList.tsx @@ -380,7 +380,7 @@ export class VirtualList extends React.PureComponent< * * offsetX is how much the horizontal position is offset by fixed columns, if applicable. */ /* This method is used by users of this component. */ - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods scrollItemIntoView( itemIndex: number, itemX: CssPixels, @@ -438,7 +438,7 @@ export class VirtualList extends React.PureComponent< } /* This method is used by users of this component. */ - /* eslint-disable-next-line react/no-unused-class-component-methods */ + // oxlint-disable-next-line react-js/no-unused-class-component-methods focus() { const container = this._container.current; if (container) { diff --git a/src/test/components/FlameGraph.test.tsx b/src/test/components/FlameGraph.test.tsx index b91f50cd39..5e24202a78 100644 --- a/src/test/components/FlameGraph.test.tsx +++ b/src/test/components/FlameGraph.test.tsx @@ -166,7 +166,7 @@ describe('FlameGraph', function () { const valueElement = ensureExists(resourceLabel.nextSibling); // See https://github.com/testing-library/jest-dom/issues/306 - // eslint-disable-next-line jest-dom/prefer-to-have-text-content + // oxlint-disable-next-line jest-dom/prefer-to-have-text-content expect(valueElement.textContent).toBe('libxul.so'); // But also do a good old snapshot. expect(tooltip).toMatchSnapshot(); diff --git a/src/test/components/MenuButtons.test.tsx b/src/test/components/MenuButtons.test.tsx index 6f9dda87c8..5d988af0bb 100644 --- a/src/test/components/MenuButtons.test.tsx +++ b/src/test/components/MenuButtons.test.tsx @@ -615,10 +615,9 @@ describe('app/MenuButtons', function () { screen.getByText(/Buffer capacity/).nextSibling ); - /* This rule needs to be disabled because `renderedCapacity` is a text - * code, and this triggers + /* `renderedCapacity` is a text code, so toHaveTextContent would hit * https://github.com/testing-library/jest-dom/issues/306 */ - /* eslint-disable-next-line jest-dom/prefer-to-have-text-content */ + // oxlint-disable-next-line jest-dom/prefer-to-have-text-content expect(renderedCapacity.textContent).toBe('1GB'); expect(getMetaInfoPanel()).toMatchSnapshot(); }); @@ -636,10 +635,9 @@ describe('app/MenuButtons', function () { screen.getByText(/Device:/).nextSibling ); - /* This rule needs to be disabled because `renderedDevice` is a text - * code, and this triggers + /* `renderedDevice` is a text code, so toHaveTextContent would hit * https://github.com/testing-library/jest-dom/issues/306 */ - /* eslint-disable-next-line jest-dom/prefer-to-have-text-content */ + // oxlint-disable-next-line jest-dom/prefer-to-have-text-content expect(renderedDevice.textContent).toBe('Android Device'); expect(getMetaInfoPanel()).toMatchSnapshot(); }); diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx index aefbcfbd4a..2230dba3c5 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -186,9 +186,7 @@ const pressKeyBuilder = (className: string) => (options: KeyPressOptions) => { fireFullKeyPress(div, options); }; -/* eslint-disable jest/no-standalone-expect */ -// Disable the jest/no-standalone-expect rule because eslint doesn't know that -// these expectations will run in a test block later. +// These expectations run inside a test block later, not at this point. // These actions will be used to generate use cases for each of the supported panels. const actions = { 'a selected node': ( @@ -249,7 +247,6 @@ const actions = { ).not.toBeNull(); }, }; -/* eslint-enable jest/no-standalone-expect */ autoMockCanvasContext(); diff --git a/src/test/unit/merge-compare.test.ts b/src/test/unit/merge-compare.test.ts index c2b7624b53..c48d56c18d 100644 --- a/src/test/unit/merge-compare.test.ts +++ b/src/test/unit/merge-compare.test.ts @@ -82,7 +82,10 @@ describe('mergeProfilesForDiffing function', function () { libName = mergedLibs[libIndex].name; } - /* eslint-disable jest/no-conditional-expect */ + // These conditional expects aren't reported because of an oxlint bug: + // https://github.com/oxc-project/oxc/issues/28054 + // Once it's fixed, wrap this switch in + // oxlint-disable/enable jest/no-conditional-expect comments. switch (funcName) { case 'A': expect(libName).toBeOneOf(['libA', 'libB']); @@ -98,7 +101,6 @@ describe('mergeProfilesForDiffing function', function () { break; default: } - /* eslint-enable */ } }); diff --git a/src/test/unit/path.test.ts b/src/test/unit/path.test.ts index d1885d4e4f..aba5bcae37 100644 --- a/src/test/unit/path.test.ts +++ b/src/test/unit/path.test.ts @@ -89,7 +89,6 @@ describe('PathSet', function () { const resultValues: CallNodePath[] = []; set.forEach(function (value1, value2, thisSet) { // @ts-expect-error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation. - // eslint-disable-next-line no-invalid-this expect(this).toBe(undefined); expect(value1).toBe(value2); expect(thisSet).toBe(set); diff --git a/src/test/unit/process-profile.test.ts b/src/test/unit/process-profile.test.ts index 407ee9368f..0ee0988e35 100644 --- a/src/test/unit/process-profile.test.ts +++ b/src/test/unit/process-profile.test.ts @@ -837,11 +837,15 @@ describe('visualMetrics processing', function () { (name) => shared.stringArray[name] === metricProgressMarkerName ); + // eslint-plugin-jest only applies this rule inside a test callback; + // oxlint also applies it to helpers like this one. + /* oxlint-disable jest/no-conditional-expect */ if (hasProgressMarker) { expect(metricProgressMarker).toBeTruthy(); } else { expect(metricProgressMarker).toBeFalsy(); } + /* oxlint-enable jest/no-conditional-expect */ // Check the visual metric change markers. const metricChangeMarkerName = `${name} Change`; diff --git a/yarn.lock b/yarn.lock index fbe5a24e53..932710e649 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2115,7 +2115,7 @@ dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": +"@eslint-community/regexpp@^4.12.1": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== @@ -2158,7 +2158,7 @@ minimatch "^3.1.5" strip-json-comments "^3.1.1" -"@eslint/js@9.39.5", "@eslint/js@^9.39.5": +"@eslint/js@9.39.5": version "9.39.5" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.5.tgz#6f2fbcff75500d229d535e0a949ae13472c84787" integrity sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A== @@ -2778,6 +2778,131 @@ resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz#e544040c448f59b83793a1d7f6ad9b1111261c39" integrity sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw== +"@oxlint-tsgolint/darwin-arm64@7.0.2004": + version "7.0.2004" + resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2004.tgz#f164eebc5b5689298b61c537c7ec2f69624081fe" + integrity sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ== + +"@oxlint-tsgolint/darwin-x64@7.0.2004": + version "7.0.2004" + resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2004.tgz#201caabbd5913d1b8731e5de3bd39646d9975a86" + integrity sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw== + +"@oxlint-tsgolint/linux-arm64@7.0.2004": + version "7.0.2004" + resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2004.tgz#1a5cd1f5e6009c0069b9f840176bf8fe5e4fd987" + integrity sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw== + +"@oxlint-tsgolint/linux-x64@7.0.2004": + version "7.0.2004" + resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2004.tgz#308944193eee3f86eeecb128dd39fde5e4bfabb5" + integrity sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q== + +"@oxlint-tsgolint/win32-arm64@7.0.2004": + version "7.0.2004" + resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2004.tgz#a068a6291b2544e6b0281ae68f2aa52852d423c0" + integrity sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw== + +"@oxlint-tsgolint/win32-x64@7.0.2004": + version "7.0.2004" + resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2004.tgz#57473c29e2e196d6ff2c44ab3a950fb9739836ff" + integrity sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w== + +"@oxlint/binding-android-arm-eabi@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz#b95305795de282c081492653abada2a46f68c714" + integrity sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg== + +"@oxlint/binding-android-arm64@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz#afe58d09f29ba7a56415fefcf5a55d6baa758d50" + integrity sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA== + +"@oxlint/binding-darwin-arm64@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz#d63f7e3fefa7073edd5a42b0a6ad07a458fbffef" + integrity sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA== + +"@oxlint/binding-darwin-x64@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz#41c75b0181f360b7cc2654e478c95037be780708" + integrity sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw== + +"@oxlint/binding-freebsd-x64@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz#252b099dd4c4c3d5ee7fc53a774d58a37ac6a948" + integrity sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg== + +"@oxlint/binding-linux-arm-gnueabihf@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz#ae16c5b5f55480dba86ce7de377ede00964ea756" + integrity sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw== + +"@oxlint/binding-linux-arm-musleabihf@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz#a903f6043725cc311c81e2c5a1e3383621b0d2d9" + integrity sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g== + +"@oxlint/binding-linux-arm64-gnu@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz#8afee8ea5be0c4edb74d2a25eee1e510c77dbf8e" + integrity sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg== + +"@oxlint/binding-linux-arm64-musl@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz#62cc783fb610f1b0be9328364eec8a07add3e3e7" + integrity sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw== + +"@oxlint/binding-linux-ppc64-gnu@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz#530ec6f185f8bc7f86819b44483e987599ccbcf1" + integrity sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw== + +"@oxlint/binding-linux-riscv64-gnu@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz#35c430e60ef8d8dddafd16f79adc1bf936ddeddb" + integrity sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg== + +"@oxlint/binding-linux-riscv64-musl@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz#426eb8fb43852707dd872bbc0e8415832f9307e8" + integrity sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA== + +"@oxlint/binding-linux-s390x-gnu@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz#f53c7132389e71dc68cad7a319fc0a92abbb1fcf" + integrity sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ== + +"@oxlint/binding-linux-x64-gnu@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz#74769aa504ce7c2571574684ed36387b40c9bfb7" + integrity sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg== + +"@oxlint/binding-linux-x64-musl@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz#0d99815e87e85c51c4ab0e351ddf9b643578e3b6" + integrity sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA== + +"@oxlint/binding-openharmony-arm64@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz#051fc72cd1a103b73d758f3f2eb9d3748ba4aafc" + integrity sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA== + +"@oxlint/binding-win32-arm64-msvc@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz#6bd2fd33ae029685f0f162c8ba5976c3e2059a6b" + integrity sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA== + +"@oxlint/binding-win32-ia32-msvc@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz#d8ab96fd47c575baf78713425680e0533aa2436f" + integrity sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A== + +"@oxlint/binding-win32-x64-msvc@1.85.0": + version "1.85.0" + resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz#d8ab9af17878dfee4f6b57581eb116cb10a9abd6" + integrity sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA== + "@parcel/watcher-android-arm64@2.6.0": version "2.6.0" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz#99aaa3223d43807c9340af439cad7e9b6d26ada6" @@ -3337,7 +3462,7 @@ "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" - integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/mdast@^3.0.0", "@types/mdast@^3.0.3": version "3.0.10" @@ -3463,31 +3588,6 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@8.71.1", "@typescript-eslint/eslint-plugin@^8.71.1": - version "8.71.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz#b37fe4f6b33c707b183ae93421be10d4fe86e275" - integrity sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw== - dependencies: - "@eslint-community/regexpp" "^4.12.2" - "@typescript-eslint/scope-manager" "8.71.1" - "@typescript-eslint/type-utils" "8.71.1" - "@typescript-eslint/utils" "8.71.1" - "@typescript-eslint/visitor-keys" "8.71.1" - ignore "^7.0.5" - natural-compare "^1.4.0" - ts-api-utils "^2.5.0" - -"@typescript-eslint/parser@8.71.1", "@typescript-eslint/parser@^8.71.1": - version "8.71.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.71.1.tgz#aad16e9813272b931caab52393449fa11cc4a269" - integrity sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw== - dependencies: - "@typescript-eslint/scope-manager" "8.71.1" - "@typescript-eslint/types" "8.71.1" - "@typescript-eslint/typescript-estree" "8.71.1" - "@typescript-eslint/visitor-keys" "8.71.1" - debug "^4.4.3" - "@typescript-eslint/project-service@8.71.1": version "8.71.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.71.1.tgz#741743e7b74a7c77cf9c54c173a95dee79648857" @@ -3510,17 +3610,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz#d2a7458bea46736085afdcec3f7bc845ad3b0e56" integrity sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg== -"@typescript-eslint/type-utils@8.71.1": - version "8.71.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz#65906b1015d064887fd8382887a1695c1d67a16d" - integrity sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ== - dependencies: - "@typescript-eslint/types" "8.71.1" - "@typescript-eslint/typescript-estree" "8.71.1" - "@typescript-eslint/utils" "8.71.1" - debug "^4.4.3" - ts-api-utils "^2.5.0" - "@typescript-eslint/types@8.71.1", "@typescript-eslint/types@^8.71.1": version "8.71.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.71.1.tgz#5ac145ce6251e4b51ab8fbecfcc453faad8aa0f3" @@ -3541,7 +3630,7 @@ tinyglobby "^0.2.15" ts-api-utils "^2.5.0" -"@typescript-eslint/utils@8.71.1", "@typescript-eslint/utils@^8.0.0", "@typescript-eslint/utils@^8.56.0": +"@typescript-eslint/utils@^8.56.0": version "8.71.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.71.1.tgz#cb0a9933f9d1705b44c11462e5ba26bf9fdcefeb" integrity sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ== @@ -3559,6 +3648,106 @@ "@typescript-eslint/types" "8.71.1" eslint-visitor-keys "^5.0.0" +"@typescript/typescript-aix-ppc64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz#cdc7ce81d60f1e09034960ddfb1fb880d7a776b6" + integrity sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ== + +"@typescript/typescript-darwin-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz#a55fdfcfa58df58d27db2237cde6a5c1e35a7235" + integrity sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA== + +"@typescript/typescript-darwin-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz#38d1c9172800a91d707bec64d2a370a016634db4" + integrity sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA== + +"@typescript/typescript-freebsd-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz#f1ff8810030b35d2b5be0db6a2dc650460ea94fa" + integrity sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ== + +"@typescript/typescript-freebsd-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz#3d86b03f353c5b1ba95162eb6ce35533bfc294bd" + integrity sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw== + +"@typescript/typescript-linux-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz#d9334d96d6dac6ff85da9c865588948de939e91f" + integrity sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ== + +"@typescript/typescript-linux-arm@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz#ad94b41e1aee2a4dcc6a298c7b67c43345fde32e" + integrity sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ== + +"@typescript/typescript-linux-loong64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz#2965aee4fc873360139d893daafe6397a29138ad" + integrity sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ== + +"@typescript/typescript-linux-mips64el@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz#1a887a311bed3a833f80bfd4a9ed37c271936cf0" + integrity sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA== + +"@typescript/typescript-linux-ppc64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz#8b63c9b2f445b393eb4e43ec21da225dade3577d" + integrity sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA== + +"@typescript/typescript-linux-riscv64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz#b6e8a35c289b3ea97a92a41d461aaeed0d3b36e1" + integrity sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ== + +"@typescript/typescript-linux-s390x@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz#2ef96693be4861f6d17965427e5b009cbbed1a3e" + integrity sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw== + +"@typescript/typescript-linux-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz#73269cb0baba50aea0ca060445a6b88e583f1ce2" + integrity sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A== + +"@typescript/typescript-netbsd-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz#3a3649f97fafa210b4e6e3798c15e06605c8a901" + integrity sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA== + +"@typescript/typescript-netbsd-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz#47ec59491a40c470d2807dc4d2b825528fd979ab" + integrity sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA== + +"@typescript/typescript-openbsd-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz#796be8da0bd989d8a3fb96f2801e38a8365b4baf" + integrity sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ== + +"@typescript/typescript-openbsd-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz#d37fe2a729eb942c076c454ee7f1815faf7d560f" + integrity sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg== + +"@typescript/typescript-sunos-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz#aba8d3464c3565a7044789baba96916bd4ab2c88" + integrity sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g== + +"@typescript/typescript-win32-arm64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz#b9de50a17196383f62620b5f9d0a2f34ad3b60d7" + integrity sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ== + +"@typescript/typescript-win32-x64@7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz#cf3b7b0d6ce5635daca4c8e01c189cdcde47ec3c" + integrity sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g== + "@ungap/structured-clone@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" @@ -3891,7 +4080,7 @@ array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: call-bound "^1.0.3" is-array-buffer "^3.0.5" -array-includes@^3.1.1, array-includes@^3.1.8, array-includes@^3.1.9: +array-includes@^3.1.1, array-includes@^3.1.8: version "3.1.9" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== @@ -3905,6 +4094,20 @@ array-includes@^3.1.1, array-includes@^3.1.8, array-includes@^3.1.9: is-string "^1.1.1" math-intrinsics "^1.1.0" +array-includes@^3.1.9: + version "3.2.0" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.2.0.tgz#18771e6837351aa139c3b58873371d0afa64964a" + integrity sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg== + dependencies: + call-bind "^1.0.9" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.2" + es-object-atoms "^1.1.2" + es-shim-unscopables "^1.1.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + array-iterate@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/array-iterate/-/array-iterate-1.1.4.tgz#add1522e9dd9749bb41152d08b845bd08d6af8b7" @@ -4378,6 +4581,16 @@ call-bind@^1.0.7, call-bind@^1.0.8: get-intrinsic "^1.2.4" set-function-length "^1.2.2" +call-bind@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.9.tgz#39a644700c80bc7d0ca9102fc6d1d43b2fd7eee7" + integrity sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + get-intrinsic "^1.3.0" + set-function-length "^1.2.2" + call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" @@ -5317,6 +5530,66 @@ es-abstract@^1.17.5, es-abstract@^1.23.2, es-abstract@^1.23.3, es-abstract@^1.23 unbox-primitive "^1.1.0" which-typed-array "^1.1.19" +es-abstract@^1.24.2: + version "1.24.2" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.2.tgz#2dbd38c180735ee983f77585140a2706a963ed9a" + integrity sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + es-define-property@^1.0.0, es-define-property@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" @@ -5361,6 +5634,13 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" +es-object-atoms@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.3, es-set-tostringtag@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" @@ -5475,18 +5755,18 @@ eslint-import-resolver-alias@^1.1.2: integrity sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w== eslint-import-resolver-node@^0.3.9: - version "0.3.9" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" - integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== + version "0.3.10" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz#84ce3005abfc300588cf23bbac1aabec1fc6e8c1" + integrity sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ== dependencies: debug "^3.2.7" - is-core-module "^2.13.0" - resolve "^1.22.4" + is-core-module "^2.16.1" + resolve "^2.0.0-next.6" eslint-module-utils@^2.12.1: - version "2.12.1" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" - integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== + version "2.14.0" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz#611f8e1c6ceb29a93eb949e1cc670b8287764819" + integrity sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig== dependencies: debug "^3.2.7" @@ -5527,13 +5807,6 @@ eslint-plugin-jest-formatting@^3.1.0: resolved "https://registry.yarnpkg.com/eslint-plugin-jest-formatting/-/eslint-plugin-jest-formatting-3.1.0.tgz#b26dd5a40f432b642dcc880021a771bb1c93dcd2" integrity sha512-XyysraZ1JSgGbLSDxjj5HzKKh0glgWf+7CkqxbTqb7zEhW7X2WHo5SBQ8cGhnszKN+2Lj3/oevBlHNbHezoc/A== -eslint-plugin-jest@^29.17.0: - version "29.17.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz#513444e5f0b2ed559116f0be02a41e6e22e84f39" - integrity sha512-6jtpU6MhYKbagkES02TupWAUUooJpZtKg94Ax0b8HhUorv3InSTBU8RRtlSBe0LocOcShXOpyec3PHzPDkJCSg== - dependencies: - "@typescript-eslint/utils" "^8.0.0" - eslint-plugin-react@^7.37.5: version "7.37.5" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz#2975511472bdda1b272b34d779335c9b0e877065" @@ -6238,11 +6511,6 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^17.13.0: - version "17.13.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-17.13.0.tgz#ab2134b5ededbf2fbe2831dd0acc36f4f1ddeac5" - integrity sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg== - globalthis@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" @@ -6385,6 +6653,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hast-util-embedded@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/hast-util-embedded/-/hast-util-embedded-2.0.0.tgz#877f4261044854743fc2621f728930ca61c8376f" @@ -6899,6 +7174,13 @@ is-core-module@^2.13.0, is-core-module@^2.16.1, is-core-module@^2.5.0, is-core-m dependencies: hasown "^2.0.2" +is-core-module@^2.16.2: + version "2.17.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.17.0.tgz#75c1c76236c9f7e38ddbfff292e127dc13b21027" + integrity sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA== + dependencies: + hasown "^2.0.4" + is-data-view@^1.0.1, is-data-view@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" @@ -9282,6 +9564,16 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-exports-info@^1.6.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/node-exports-info/-/node-exports-info-1.6.2.tgz#243a3105677d6781cd26e6a72350fd928a5cb46f" + integrity sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag== + dependencies: + array.prototype.flatmap "^1.3.3" + es-errors "^1.3.0" + object.entries "^1.1.9" + semver "^6.3.1" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -9591,6 +9883,43 @@ oxfmt@^0.72.0: "@oxfmt/binding-win32-ia32-msvc" "0.72.0" "@oxfmt/binding-win32-x64-msvc" "0.72.0" +oxlint-tsgolint@^7.0.2002: + version "7.0.2004" + resolved "https://registry.yarnpkg.com/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2004.tgz#fb5fde1541d85ce72cbccf2c10fea055aae0b4fb" + integrity sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ== + optionalDependencies: + "@oxlint-tsgolint/darwin-arm64" "7.0.2004" + "@oxlint-tsgolint/darwin-x64" "7.0.2004" + "@oxlint-tsgolint/linux-arm64" "7.0.2004" + "@oxlint-tsgolint/linux-x64" "7.0.2004" + "@oxlint-tsgolint/win32-arm64" "7.0.2004" + "@oxlint-tsgolint/win32-x64" "7.0.2004" + +oxlint@1.85.0: + version "1.85.0" + resolved "https://registry.yarnpkg.com/oxlint/-/oxlint-1.85.0.tgz#a783acb92bd70986b2586a7457984126daf4e52e" + integrity sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg== + optionalDependencies: + "@oxlint/binding-android-arm-eabi" "1.85.0" + "@oxlint/binding-android-arm64" "1.85.0" + "@oxlint/binding-darwin-arm64" "1.85.0" + "@oxlint/binding-darwin-x64" "1.85.0" + "@oxlint/binding-freebsd-x64" "1.85.0" + "@oxlint/binding-linux-arm-gnueabihf" "1.85.0" + "@oxlint/binding-linux-arm-musleabihf" "1.85.0" + "@oxlint/binding-linux-arm64-gnu" "1.85.0" + "@oxlint/binding-linux-arm64-musl" "1.85.0" + "@oxlint/binding-linux-ppc64-gnu" "1.85.0" + "@oxlint/binding-linux-riscv64-gnu" "1.85.0" + "@oxlint/binding-linux-riscv64-musl" "1.85.0" + "@oxlint/binding-linux-s390x-gnu" "1.85.0" + "@oxlint/binding-linux-x64-gnu" "1.85.0" + "@oxlint/binding-linux-x64-musl" "1.85.0" + "@oxlint/binding-openharmony-arm64" "1.85.0" + "@oxlint/binding-win32-arm64-msvc" "1.85.0" + "@oxlint/binding-win32-ia32-msvc" "1.85.0" + "@oxlint/binding-win32-x64-msvc" "1.85.0" + p-cancelable@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" @@ -10589,7 +10918,7 @@ resolve-path@^1.4.0: http-errors "~1.6.2" path-is-absolute "1.0.1" -resolve@^1.10.0, resolve@^1.22.1, resolve@^1.22.11, resolve@^1.22.4, resolve@^1.22.8: +resolve@^1.10.0, resolve@^1.22.1, resolve@^1.22.11, resolve@^1.22.8: version "1.22.11" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== @@ -10607,6 +10936,18 @@ resolve@^2.0.0-next.5: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^2.0.0-next.6: + version "2.0.0-next.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.7.tgz#ba3b035d4b1ee7c522426eee73cabcb0fd5515dd" + integrity sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ== + dependencies: + es-errors "^1.3.0" + is-core-module "^2.16.2" + node-exports-info "^1.6.0" + object-keys "^1.1.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + responselike@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/responselike/-/responselike-3.0.0.tgz#20decb6c298aff0dbee1c355ca95461d42823626" @@ -11339,7 +11680,7 @@ strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== strip-bom@^4.0.0: version "4.0.0" @@ -11869,20 +12210,31 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== -typescript-eslint@^8.71.1: - version "8.71.1" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.71.1.tgz#dabf06af972d975ea25b27d73e161193ea41075d" - integrity sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ== - dependencies: - "@typescript-eslint/eslint-plugin" "8.71.1" - "@typescript-eslint/parser" "8.71.1" - "@typescript-eslint/typescript-estree" "8.71.1" - "@typescript-eslint/utils" "8.71.1" - -typescript@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" - integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== +typescript@7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-7.0.2.tgz#9ec773d7954a8c182c17cc5bbd575aa28bc51582" + integrity sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA== + optionalDependencies: + "@typescript/typescript-aix-ppc64" "7.0.2" + "@typescript/typescript-darwin-arm64" "7.0.2" + "@typescript/typescript-darwin-x64" "7.0.2" + "@typescript/typescript-freebsd-arm64" "7.0.2" + "@typescript/typescript-freebsd-x64" "7.0.2" + "@typescript/typescript-linux-arm" "7.0.2" + "@typescript/typescript-linux-arm64" "7.0.2" + "@typescript/typescript-linux-loong64" "7.0.2" + "@typescript/typescript-linux-mips64el" "7.0.2" + "@typescript/typescript-linux-ppc64" "7.0.2" + "@typescript/typescript-linux-riscv64" "7.0.2" + "@typescript/typescript-linux-s390x" "7.0.2" + "@typescript/typescript-linux-x64" "7.0.2" + "@typescript/typescript-netbsd-arm64" "7.0.2" + "@typescript/typescript-netbsd-x64" "7.0.2" + "@typescript/typescript-openbsd-arm64" "7.0.2" + "@typescript/typescript-openbsd-x64" "7.0.2" + "@typescript/typescript-sunos-x64" "7.0.2" + "@typescript/typescript-win32-arm64" "7.0.2" + "@typescript/typescript-win32-x64" "7.0.2" typical@^5.0.0, typical@^5.2.0: version "5.2.0" From 7269a79291f08adfdc93de3e5ba16203b4e42fe1 Mon Sep 17 00:00:00 2001 From: fatadel Date: Thu, 22 Oct 2026 18:04:02 +0200 Subject: [PATCH 24/24] Bump profiler-cli version to 0.12.0 --- profiler-cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 7e96729f28..b01cc63e92 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-devtools/profiler-cli", - "version": "0.11.0", + "version": "0.12.0", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "node ../scripts/verify-profiler-cli-build.mjs"