diff --git a/package.json b/package.json index 1c082839f2..5dddda1e35 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", "reselect": "^4.1.8", + "smol-toml": "^1.6.1", "valibot": "^1.4.0", "workbox-window": "^7.4.1" }, diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts index 49c11a55f8..a69ab64eef 100644 --- a/src/node-tools/profiler-edit.ts +++ b/src/node-tools/profiler-edit.ts @@ -8,6 +8,7 @@ import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-lo import { computeCompactedProfile } from 'firefox-profiler/profile-logic/profile-compacting'; import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; import { compress } from 'firefox-profiler/utils/gz'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; import { SymbolStore } from 'firefox-profiler/profile-logic/symbol-store'; import { symbolicateProfile, @@ -21,6 +22,10 @@ import { } from 'firefox-profiler/profile-logic/wasm-symbolication'; import type { Profile } from 'firefox-profiler/types/profile'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { + parseLabelToml, + resolveAllLabels, +} from 'firefox-profiler/utils/label-templates'; /** * A CLI tool for editing profiles. @@ -38,6 +43,9 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; * node node-tools-dist/profiler-edit.js -i input.json.gz -o out.json.gz \ * --symbolicate-wasm http://host/a.wasm=./a-unstripped.wasm \ * --symbolicate-wasm http://host/b.wasm=./b-unstripped.wasm + * + * node node-tools-dist/profiler-edit.js --from-hash w1spyw917hg... -o out.json.gz \ + * --insert-label-frames known-functions.toml */ type ProfileSource = @@ -61,6 +69,7 @@ export interface CliOptions { output: string; symbolicateWithServer?: string; symbolicateWasm: WasmSymbolicationCliSpec[]; + insertLabelFrames?: string; } function loadWasmSymbolicationSpecs( @@ -77,6 +86,34 @@ function loadWasmSymbolicationSpecs( }); } +/** + * Reconstruct the func-name strings used by insertStackLabels' prefix matcher + * (mirrors getLabelIndexForFunc in insert-stack-labels.ts), so auto-discovery + * sees the same strings the labeler will compare against. + */ +function collectFuncNames(profile: Profile): string[] { + const { funcTable, sources, stringArray } = profile.shared; + const result: string[] = []; + for (let i = 0; i < funcTable.length; i++) { + let name = stringArray[funcTable.name[i]]; + const sourceIndex = funcTable.source[i]; + if (sourceIndex !== null) { + const filename = stringArray[sources.filename[sourceIndex]]; + const line = funcTable.lineNumber[i]; + const col = funcTable.columnNumber[i]; + if (line !== null && col !== null) { + name += ` (${filename}:${line}:${col})`; + } else if (line !== null) { + name += ` (${filename}:${line})`; + } else { + name += ` (${filename})`; + } + } + result.push(name); + } + return result; +} + async function loadProfile(source: ProfileSource): Promise { switch (source.type) { case 'FILE': { @@ -129,7 +166,7 @@ async function loadProfile(source: ProfileSource): Promise { } export async function run(options: CliOptions) { - const profile = await loadProfile(options.input); + let profile = await loadProfile(options.input); if (options.symbolicateWithServer !== undefined) { const server = options.symbolicateWithServer; @@ -183,6 +220,15 @@ export async function run(options: CliOptions) { loadWasmSymbolicationSpecs(options.symbolicateWasm) ); + if (options.insertLabelFrames !== undefined) { + console.log('Inserting label frames...'); + const tomlText = fs.readFileSync(options.insertLabelFrames, 'utf8'); + const parsed = parseLabelToml(tomlText); + const funcNames = collectFuncNames(profile); + const labels = resolveAllLabels(parsed, funcNames); + profile = insertStackLabels(profile, labels); + } + const { profile: compactedProfile } = computeCompactedProfile(profile); console.log(`Saving profile to ${options.output}`); @@ -243,7 +289,8 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { ) .argParser(collectWasm) .default([] as WasmSymbolicationCliSpec[]) - ); + ) + .option('--insert-label-frames ', 'TOML file with label definitions'); program.parse(processArgv); const opts = program.opts(); @@ -290,6 +337,11 @@ export function makeOptionsFromArgv(processArgv: string[]): CliOptions { ? opts.symbolicateWithServer : undefined, symbolicateWasm: opts.symbolicateWasm, + insertLabelFrames: + typeof opts.insertLabelFrames === 'string' && + opts.insertLabelFrames !== '' + ? opts.insertLabelFrames + : undefined, }; } diff --git a/src/profile-logic/insert-stack-labels.ts b/src/profile-logic/insert-stack-labels.ts new file mode 100644 index 0000000000..6133398bdd --- /dev/null +++ b/src/profile-logic/insert-stack-labels.ts @@ -0,0 +1,289 @@ +/* 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 type { + IndexIntoFrameTable, + IndexIntoStackTable, + RawStackTable, + IndexIntoFuncTable, + Profile, + Category, +} from '../types/profile'; +import { + shallowCloneFrameTable, + shallowCloneFuncTable, +} from 'firefox-profiler/profile-logic/data-structures'; +import { StringTable } from 'firefox-profiler/utils/string-table'; +import { updateRawThreadStacks } from 'firefox-profiler/profile-logic/profile-data'; + +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +/** + * Takes a profile and creates one which contains "stack labels". + * + * ## Example + * + * Before: + * + * ``` + * - BaselineIC: Call.CallNative + * - mozilla::dom::Element_Binding::getBoundingClientRect <- matches a funcPrefix + * - nsIContent::GetPrimaryFrame + * - mozilla::PresShell::DoFlushLayout + * - mozilla::PresShell::ProcessReflowCommands <- matches a funcPrefix + * ``` + * + * After: + * + * ``` + * - BaselineIC: Call.CallNative + * - Element.getBoundingClientRect <== NEW + * - mozilla::dom::Element_Binding::getBoundingClientRect + * - nsIContent::GetPrimaryFrame + * - mozilla::PresShell::DoFlushLayout + * - Update layout <== NEW + * - mozilla::PresShell::ProcessReflowCommands + * ``` + * + * The label frames are inserted as new parent stack nodes for the matched + * stack node. The caller supplies the list of labels and their matchers + * (as function name prefixes). + * + * ## No "duplicate labels" + * + * This implementation avoids duplicate labels. This is best explained with + * an example. Let "Label A" apply to prefix "a" and "Label B" apply to prefix "b". + * + * Input: + * + * ``` + * - a1 + * - a2 + * - b1 + * - a2 + * ``` + * + * Then we get: + * + * ``` + * - Label A + * - a1 + * - a2 + * - Label B + * - b1 + * - Label A + * - a2 + * ``` + * + * Notably there is no extra "Label A" frame between a1 and a2, even though a2 + * also matches. We avoid it in order to keep the tree simple; and in the JS-only + * call tree, the samples at a1,a2 are already accounted to the Label A node which + * is all we wanted to achieve. + */ +export function insertStackLabels( + profile: Profile, + labelDescriptions: LabelDescription[] +): Profile { + const labelCategoryIndex = profile.meta.categories!.length; + + const newCategories: Category[] = [ + ...profile.meta.categories!, + { + name: 'Label', + color: 'blue', + subcategories: ['Other'], + }, + ]; + + const { + funcTable: oldFuncTable, + frameTable: oldFrameTable, + stackTable: oldStackTable, + sources, + stringArray, + } = profile.shared; + const frameTable = shallowCloneFrameTable(oldFrameTable); + const funcTable = shallowCloneFuncTable(oldFuncTable); + const stringTable = StringTable.withBackingArray(stringArray); + + const rootLabelName = 'Root (unaccounted / catch-all)'; + const rootLabelFrameIndex = frameTable.length; + + const labelFramesStartIndex = rootLabelFrameIndex + 1; + const allLabelNames = [ + rootLabelName, + ...labelDescriptions.map((label) => label.name), + ]; + + // First, add the label frames and funcs to the frameTable + funcTable. + for (let i = 0; i < allLabelNames.length; i++) { + const labelName = allLabelNames[i]; + const funcIndex = funcTable.length++; + funcTable.name[funcIndex] = stringTable.indexForString(labelName); + funcTable.resource[funcIndex] = -1; + funcTable.source[funcIndex] = null; + funcTable.lineNumber[funcIndex] = null; + funcTable.columnNumber[funcIndex] = null; + funcTable.isJS[funcIndex] = false; + funcTable.relevantForJS[funcIndex] = true; + + const frameIndex = frameTable.length++; + frameTable.func[frameIndex] = funcIndex; + frameTable.category[frameIndex] = labelCategoryIndex; + frameTable.subcategory[frameIndex] = 0; + frameTable.nativeSymbol[frameIndex] = null; + frameTable.address[frameIndex] = 0; + frameTable.inlineDepth[frameIndex] = 0; + frameTable.line[frameIndex] = null; + frameTable.column[frameIndex] = null; + frameTable.innerWindowID[frameIndex] = null; + } + + // Run the function name against the substring matchers and return the first + // match. + function getLabelIndexForFunc(funcIndex: IndexIntoFuncTable): number | null { + let nameString = stringArray[funcTable.name[funcIndex]]; + + // Include the filename (in brackets), if present. This allows matchers + // like `onStateChange (chrome://browser/content/tabbrowser/` + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex !== null) { + const filenameString = stringArray[sources.filename[sourceIndex]]; + nameString += ` (${filenameString})`; + } + + // Check against every funcPrefix of every label. Return the first match. + for ( + let labelIndex = 0; + labelIndex < labelDescriptions.length; + labelIndex++ + ) { + const labelDescription = labelDescriptions[labelIndex]; + for ( + let prefixIndex = 0; + prefixIndex < labelDescription.funcPrefixes.length; + prefixIndex++ + ) { + const funcNamePrefix = labelDescription.funcPrefixes[prefixIndex]; + if (nameString.startsWith(funcNamePrefix)) { + return labelIndex; + } + } + } + return null; + } + + // Compute the label frame index (if any) for every func. + const funcIndexToLabelFrameIndex = new Array(funcTable.length); + for (let funcIndex = 0; funcIndex < funcTable.length; funcIndex++) { + const labelIndex = getLabelIndexForFunc(funcIndex); + const labelFrameIndex = + labelIndex !== null ? labelFramesStartIndex + labelIndex : null; + funcIndexToLabelFrameIndex[funcIndex] = labelFrameIndex; + } + + // Now compute where in the stack table we need labels. + const labelFrameIndexToInsertAtStack = new Array( + oldStackTable.length + ); + const inheritedLabelFrameIndexAtStack = new Array( + oldStackTable.length + ); + let stacksToInsertCount = 0; + for (let stackIndex = 0; stackIndex < oldStackTable.length; stackIndex++) { + const parentStackIndex = oldStackTable.prefix[stackIndex]; + const inheritedLabelFrameIndex = + parentStackIndex !== null + ? inheritedLabelFrameIndexAtStack[parentStackIndex] + : null; + const frameIndex = oldStackTable.frame[stackIndex]; + const funcIndex = oldFrameTable.func[frameIndex]; + const labelFrameIndex = funcIndexToLabelFrameIndex[funcIndex]; + if ( + labelFrameIndex !== null && + labelFrameIndex !== inheritedLabelFrameIndex + ) { + labelFrameIndexToInsertAtStack[stackIndex] = labelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = labelFrameIndex; + stacksToInsertCount++; + } else if ( + funcTable.isJS[funcIndex] || + funcTable.relevantForJS[funcIndex] + ) { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = null; + } else if (parentStackIndex === null) { + labelFrameIndexToInsertAtStack[stackIndex] = rootLabelFrameIndex; + inheritedLabelFrameIndexAtStack[stackIndex] = rootLabelFrameIndex; + stacksToInsertCount++; + } else { + labelFrameIndexToInsertAtStack[stackIndex] = null; + inheritedLabelFrameIndexAtStack[stackIndex] = inheritedLabelFrameIndex; + } + } + + // Now compute the new stack table. + const newStackCount = oldStackTable.length + stacksToInsertCount; + const newPrefixCol = new Array(newStackCount); + const newFrameCol = new Array(newStackCount); + const oldStackToNewStackPlusOne = new Int32Array(oldStackTable.length); + let nextNewStackIndex = 0; + for ( + let oldStackIndex = 0; + oldStackIndex < oldStackTable.length; + oldStackIndex++ + ) { + const labelFrameIndexToInsert = + labelFrameIndexToInsertAtStack[oldStackIndex]; + const oldPrefix = oldStackTable.prefix[oldStackIndex]; + let newPrefix = + oldPrefix !== null ? oldStackToNewStackPlusOne[oldPrefix] - 1 : null; + const frameIndex = oldStackTable.frame[oldStackIndex]; + if (labelFrameIndexToInsert !== null) { + const insertedStackIndex = nextNewStackIndex++; + newPrefixCol[insertedStackIndex] = newPrefix; + newFrameCol[insertedStackIndex] = labelFrameIndexToInsert; + newPrefix = insertedStackIndex; + } + const newStackIndex = nextNewStackIndex++; + newPrefixCol[newStackIndex] = newPrefix; + newFrameCol[newStackIndex] = frameIndex; + oldStackToNewStackPlusOne[oldStackIndex] = newStackIndex + 1; + } + + if (nextNewStackIndex !== newStackCount) { + console.error('Unexpected new stack count!', { + nextNewStackIndex, + newStackCount, + stacksToInsertCount, + }); + } + + const stackTable: RawStackTable = { + prefix: newPrefixCol, + frame: newFrameCol, + length: newStackCount, + }; + + const newShared = { ...profile.shared, stackTable, frameTable, funcTable }; + const newThreads = updateRawThreadStacks(profile.threads, (oldStack) => + oldStack !== null ? oldStackToNewStackPlusOne[oldStack] - 1 : null + ); + const newMeta = { + ...profile.meta, + categories: newCategories, + }; + + const newProfile: Profile = { + ...profile, + meta: newMeta, + shared: newShared, + threads: newThreads, + }; + + return newProfile; +} diff --git a/src/test/unit/insert-stack-labels.test.ts b/src/test/unit/insert-stack-labels.test.ts new file mode 100644 index 0000000000..eb76415e64 --- /dev/null +++ b/src/test/unit/insert-stack-labels.test.ts @@ -0,0 +1,158 @@ +/* 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 { insertStackLabels } from '../../profile-logic/insert-stack-labels'; +import { callTreeFromProfile, formatTree } from '../fixtures/utils'; + +describe('insertStackLabels', function () { + it('inserts a label before the first frame matching a label', function () { + const { profile } = getProfileFromTextSamples(` + A + B + mozilla::layout::Reflow + mozilla::layout::FrameReflow + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['mozilla::layout::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - B (total: 1, self: —)', + ' - Layout (total: 1, self: —)', + ' - mozilla::layout::Reflow (total: 1, self: —)', + ' - mozilla::layout::FrameReflow (total: 1, self: 1)', + ]); + }); + + it('does not repeat the label for consecutive frames in the same label', function () { + const { profile } = getProfileFromTextSamples(` + A + gfx::Composite + gfx::LayerManager::Render + gfx::DrawQuad + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: —)', + ' - gfx::LayerManager::Render (total: 1, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ]); + }); + + it('does not re-insert the label when re-entering a label after an unmatched frame', function () { + // A non-JS, non-label frame (helper) propagates the inherited label context, + // so gfx::DrawQuad is still considered inside the Graphics label and no new + // label is inserted. + const { profile } = getProfileFromTextSamples(` + gfx::Composite + helper + gfx::DrawQuad + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: —)', + ' - helper (total: 1, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ]); + }); + + it('inserts the right label when multiple labels are configured', function () { + const { profile } = getProfileFromTextSamples(` + A A + layout::Reflow gfx::Composite + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 2, self: —)', + ' - A (total: 2, self: —)', + ' - Layout (total: 1, self: —)', + ' - layout::Reflow (total: 1, self: 1)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: 1)', + ]); + }); + + it('inserts a new label when switching from one label to another', function () { + const { profile } = getProfileFromTextSamples(` + layout::Reflow + gfx::Composite + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Layout (total: 1, self: —)', + ' - layout::Reflow (total: 1, self: —)', + ' - Graphics (total: 1, self: —)', + ' - gfx::Composite (total: 1, self: 1)', + ]); + }); + + it('wraps unmatched root frames in a root label', function () { + const { profile } = getProfileFromTextSamples(` + A + B + C + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Layout', funcPrefixes: ['layout::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - B (total: 1, self: —)', + ' - C (total: 1, self: 1)', + ]); + }); + + it('handles multiple samples sharing common prefixes correctly', function () { + const { profile } = getProfileFromTextSamples(` + A A A + B B B + gfx::Composite gfx::Composite C + gfx::DrawQuad gfx::LayerManager::Render + `); + + const labeled = insertStackLabels(profile, [ + { name: 'Graphics', funcPrefixes: ['gfx::'] }, + ]); + + expect(formatTree(callTreeFromProfile(labeled))).toEqual([ + '- Root (unaccounted / catch-all) (total: 3, self: —)', + ' - A (total: 3, self: —)', + ' - B (total: 3, self: —)', + ' - Graphics (total: 2, self: —)', + ' - gfx::Composite (total: 2, self: —)', + ' - gfx::DrawQuad (total: 1, self: 1)', + ' - gfx::LayerManager::Render (total: 1, self: 1)', + ' - C (total: 1, self: 1)', + ]); + }); +}); diff --git a/src/test/unit/label-templates.test.ts b/src/test/unit/label-templates.test.ts new file mode 100644 index 0000000000..c71efcc77f --- /dev/null +++ b/src/test/unit/label-templates.test.ts @@ -0,0 +1,810 @@ +/* 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 { + applyModifier, + expandPattern, + reverseModifier, + reverseBlinkSnake, + compilePatternToRegex, + discoverAutoLabels, + resolveAllLabels, + parseLabelToml, +} from 'firefox-profiler/utils/label-templates'; +import type { AutoLabel } from 'firefox-profiler/utils/label-templates'; +import { insertStackLabels } from 'firefox-profiler/profile-logic/insert-stack-labels'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { callTreeFromProfile, formatTree } from '../fixtures/utils'; + +// --------------------------------------------------------------------------- +// applyModifier +// --------------------------------------------------------------------------- + +describe('applyModifier', function () { + describe(':pascal', function () { + it('uppercases the first letter of a camelCase name', function () { + expect(applyModifier('querySelector', 'pascal')).toBe('QuerySelector'); + }); + + it('handles multi-word camelCase', function () { + expect(applyModifier('addEventListener', 'pascal')).toBe( + 'AddEventListener' + ); + }); + + it('preserves interior uppercase (innerHTML)', function () { + expect(applyModifier('innerHTML', 'pascal')).toBe('InnerHTML'); + }); + + it('handles a single lowercase letter', function () { + expect(applyModifier('fill', 'pascal')).toBe('Fill'); + }); + }); + + describe(':blink_snake', function () { + it('lowercases a simple PascalCase word', function () { + expect(applyModifier('Element', 'blink_snake')).toBe('element'); + }); + + it('handles HTML* class names', function () { + expect(applyModifier('HTMLInputElement', 'blink_snake')).toBe( + 'html_input_element' + ); + }); + + it('handles DOM* class names', function () { + expect(applyModifier('DOMTokenList', 'blink_snake')).toBe( + 'dom_token_list' + ); + }); + + it('handles CSS* class names', function () { + expect(applyModifier('CSSStyleSheet', 'blink_snake')).toBe( + 'css_style_sheet' + ); + }); + + it('handles compound PascalCase names', function () { + expect(applyModifier('DocumentFragment', 'blink_snake')).toBe( + 'document_fragment' + ); + expect(applyModifier('EventTarget', 'blink_snake')).toBe('event_target'); + expect(applyModifier('ShadowRoot', 'blink_snake')).toBe('shadow_root'); + }); + + it('handles all-uppercase acronyms', function () { + expect(applyModifier('URL', 'blink_snake')).toBe('url'); + expect(applyModifier('DOMParser', 'blink_snake')).toBe('dom_parser'); + }); + + it('keeps a mixed-case special token together when listed', function () { + expect( + applyModifier('WebGLRenderingContext', 'blink_snake', ['WebGL']) + ).toBe('webgl_rendering_context'); + expect(applyModifier('XPathEvaluator', 'blink_snake', ['XPath'])).toBe( + 'xpath_evaluator' + ); + }); + + it('keeps a digit-bearing special token together when listed', function () { + expect( + applyModifier('WebGL2RenderingContext', 'blink_snake', ['WebGL2']) + ).toBe('webgl2_rendering_context'); + }); + + it('prefers the longer of two prefix-overlapping special tokens', function () { + // WebGL2 must match before WebGL, regardless of list order. + expect( + applyModifier('WebGL2RenderingContext', 'blink_snake', [ + 'WebGL', + 'WebGL2', + ]) + ).toBe('webgl2_rendering_context'); + expect( + applyModifier('WebGLRenderingContext', 'blink_snake', [ + 'WebGL', + 'WebGL2', + ]) + ).toBe('webgl_rendering_context'); + }); + }); + + it('returns the value unchanged when no modifier is given', function () { + expect(applyModifier('querySelector', undefined)).toBe('querySelector'); + expect(applyModifier('Element', undefined)).toBe('Element'); + }); + + it('throws on an unknown modifier', function () { + expect(() => applyModifier('foo', 'upper')).toThrow( + 'Unknown template modifier: upper' + ); + }); +}); + +// --------------------------------------------------------------------------- +// expandPattern +// --------------------------------------------------------------------------- + +describe('expandPattern', function () { + it('substitutes a plain variable', function () { + expect( + expandPattern('mozilla::dom::{Class}_Binding::{method}(', { + Class: 'Element', + method: 'querySelector', + }) + ).toBe('mozilla::dom::Element_Binding::querySelector('); + }); + + it('applies :pascal to the variable value', function () { + expect( + expandPattern('v8_{Class:blink_snake}::{method:pascal}Operation', { + Class: 'Element', + method: 'querySelector', + }) + ).toBe('v8_element::QuerySelectorOperation'); + }); + + it('applies :blink_snake to the variable value', function () { + expect( + expandPattern('v8_{Class:blink_snake}::Callback', { + Class: 'HTMLInputElement', + }) + ).toBe('v8_html_input_element::Callback'); + }); + + it('leaves unrelated text intact', function () { + expect(expandPattern('no_vars_here', {})).toBe('no_vars_here'); + }); + + it('throws when a referenced variable is not provided', function () { + expect(() => + expandPattern('{Class}_Binding::{method}(', { Class: 'Element' }) + ).toThrow('Template variable "method" not provided'); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: auto_labels → insertStackLabels → formatTree +// +// resolveAllLabels-driven coverage for each engine's func-name shape. Each +// test seeds funcs from one engine and confirms auto-discovery synthesizes +// a label whose forward-expanded prefixes match across all engines. +// --------------------------------------------------------------------------- + +const DOM_OPERATION: AutoLabel = { + label: '{Class}.{method}', + patterns: [ + 'mozilla::dom::{Class}_Binding::{method}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation', + 'WebCore::js{Class}PrototypeFunction_{method}', + ], +}; + +const CANVAS2D_OPERATION: AutoLabel = { + label: 'CanvasRenderingContext2D.{method}', + patterns: [ + 'mozilla::dom::CanvasRenderingContext2D_Binding::{method}(', + 'mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::{method}(', + "blink::`anonymous namespace'::v8_canvas_rendering_context_2d::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_canvas_rendering_context_2d::{method:pascal}Operation', + 'blink::Canvas2DRecorderContext::{method}(', + 'WebCore::jsCanvasRenderingContext2DPrototypeFunction_{method}(', + ], +}; + +describe('auto_labels integration with insertStackLabels', function () { + it('matches Gecko-style function names generated from a dom_operation template', function () { + const { profile } = getProfileFromTextSamples(` + A + mozilla::dom::Element_Binding::querySelector(elem) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::querySelector(elem)'] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::querySelector(elem) (total: 1, self: 1)', + ]); + }); + + it('matches both anonymous-namespace forms for Blink', function () { + const { profile } = getProfileFromTextSamples(` + blink::\`anonymous namespace'::v8_element::QuerySelectorOperation + blink::(anonymous namespace)::v8_element::QuerySelectorOperation + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + [ + "blink::`anonymous namespace'::v8_element::QuerySelectorOperation", + 'blink::(anonymous namespace)::v8_element::QuerySelectorOperation', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Element.querySelector (total: 1, self: —)', + " - blink::`anonymous namespace'::v8_element::QuerySelectorOperation (total: 1, self: —)", + ' - blink::(anonymous namespace)::v8_element::QuerySelectorOperation (total: 1, self: 1)', + ]); + }); + + it('matches WebKit-style function names', function () { + const { profile } = getProfileFromTextSamples(` + A + WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [DOM_OPERATION], + blinkSpecialTokens: [], + }, + [ + 'WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*)', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*) (total: 1, self: 1)', + ]); + }); + + it('canvas2d_operation matches Gecko, Offscreen, Blink, and WebKit variants', function () { + const { profile } = getProfileFromTextSamples(` + mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args) + mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args) + blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation + WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx) + `); + + const labels = resolveAllLabels( + { + labels: [], + autoLabels: [CANVAS2D_OPERATION], + blinkSpecialTokens: [], + }, + [ + 'mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args)', + 'mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args)', + 'blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation', + 'WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx)', + ] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- CanvasRenderingContext2D.fillRect (total: 1, self: —)', + ' - mozilla::dom::CanvasRenderingContext2D_Binding::fillRect(args) (total: 1, self: —)', + ' - mozilla::dom::OffscreenCanvasRenderingContext2D_Binding::fillRect(args) (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_canvas_rendering_context_2d::FillRectOperation (total: 1, self: —)', + ' - WebCore::jsCanvasRenderingContext2DPrototypeFunction_fillRect(ctx) (total: 1, self: 1)', + ]); + }); + + it('merges an explicit `[[labels]]` entry with the auto-discovered prefixes when names collide', function () { + // The blink::bindings::PerformAttributeSetCEReactionsReflectTypeString + // frame is the generic className-setter runtime — auto-discovery can't + // synthesize it from any template. The explicit `[[labels]]` block adds + // it on top of the auto-discovered "set Element.className" prefixes. + const { profile } = getProfileFromTextSamples(` + blink::bindings::PerformAttributeSetCEReactionsReflectTypeString + mozilla::dom::Element_Binding::set_className(args) + `); + + const DOM_SETTER: AutoLabel = { + label: 'set {Class}.{prop}', + patterns: ['mozilla::dom::{Class}_Binding::set_{prop}('], + }; + + const labels = resolveAllLabels( + { + labels: [ + { + name: 'set Element.className', + funcPrefixes: [ + 'blink::bindings::PerformAttributeSetCEReactionsReflectTypeString', + ], + }, + ], + autoLabels: [DOM_SETTER], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_className(args)'] + ); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- set Element.className (total: 1, self: —)', + ' - blink::bindings::PerformAttributeSetCEReactionsReflectTypeString (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::set_className(args) (total: 1, self: 1)', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// :blink_snake digit-boundary handling +// --------------------------------------------------------------------------- + +describe(':blink_snake digit handling', function () { + it('separates trailing digit acronyms (Context2D)', function () { + expect(applyModifier('CanvasRenderingContext2D', 'blink_snake')).toBe( + 'canvas_rendering_context_2d' + ); + }); + + it('keeps a digit-then-uppercase acronym joined when no lowercase follows', function () { + expect(applyModifier('Canvas2D', 'blink_snake')).toBe('canvas_2d'); + }); + + it('splits a digit-then-uppercase pair when followed by lowercase', function () { + expect(applyModifier('WebGL2RenderingContext', 'blink_snake')).toBe( + 'web_gl_2_rendering_context' + ); + }); +}); + +// --------------------------------------------------------------------------- +// reverseSnake / reverseModifier +// --------------------------------------------------------------------------- + +describe('reverseBlinkSnake', function () { + it('reverses a single Pascal word', function () { + expect(reverseBlinkSnake('element')).toBe('Element'); + }); + + it('reverses a multi-word PascalCase name', function () { + expect(reverseBlinkSnake('document_fragment')).toBe('DocumentFragment'); + expect(reverseBlinkSnake('event_target')).toBe('EventTarget'); + }); + + it('uses the special-tokens list to recover all-uppercase fragments', function () { + expect(reverseBlinkSnake('html_element', ['HTML'])).toBe('HTMLElement'); + expect(reverseBlinkSnake('html_image_element', ['HTML'])).toBe( + 'HTMLImageElement' + ); + expect(reverseBlinkSnake('css_style_sheet', ['CSS'])).toBe('CSSStyleSheet'); + expect(reverseBlinkSnake('xml_http_request', ['XML'])).toBe( + 'XMLHttpRequest' + ); + }); + + it('uses the special-tokens list to recover mixed-case fragments', function () { + expect(reverseBlinkSnake('webgl_rendering_context', ['WebGL'])).toBe( + 'WebGLRenderingContext' + ); + expect(reverseBlinkSnake('webgl2_rendering_context', ['WebGL2'])).toBe( + 'WebGL2RenderingContext' + ); + expect(reverseBlinkSnake('xpath_evaluator', ['XPath'])).toBe( + 'XPathEvaluator' + ); + }); + + it('without a special-tokens list, falls back to first-letter capitalization', function () { + // This is the lossy case: HtmlElement and HTMLElement both snake to the + // same string, and without special tokens we can't recover the original. + expect(reverseBlinkSnake('html_element')).toBe('HtmlElement'); + }); + + it('handles trailing digit-bearing tokens', function () { + expect(reverseBlinkSnake('canvas_rendering_context_2d', ['2D'])).toBe( + 'CanvasRenderingContext2D' + ); + expect(reverseBlinkSnake('canvas_2d', ['2D'])).toBe('Canvas2D'); + }); + + it('handles multiple tokens separated by Pascal words', function () { + // The special-tokens list applies independently at each segment; here + // HTML is recovered, then `dom` becomes `Dom` (no DOM in the list), then + // URL is recovered. (Two adjacent special tokens cannot be expressed in + // snake form — `HTMLDOMParser` snakes to `htmldom_parser`, not + // `html_dom_parser`.) + expect(reverseBlinkSnake('html_dom_event_url', ['HTML', 'URL'])).toBe( + 'HTMLDomEventURL' + ); + }); + + it('handles a trailing special token with no follow-up segment', function () { + expect(reverseBlinkSnake('parse_url', ['URL'])).toBe('ParseURL'); + }); + + it('does case-insensitive matching against the special-tokens list', function () { + expect(reverseBlinkSnake('html_element', ['html'])).toBe('htmlElement'); + }); +}); + +describe('reverseModifier', function () { + it('reverses :pascal by lowercasing the first letter', function () { + expect(reverseModifier('QuerySelector', 'pascal')).toBe('querySelector'); + expect(reverseModifier('Fill', 'pascal')).toBe('fill'); + }); + + it('reverses :blink_snake using the special-tokens list', function () { + expect(reverseModifier('html_input_element', 'blink_snake', ['HTML'])).toBe( + 'HTMLInputElement' + ); + }); + + it('returns the value unchanged when no modifier is given', function () { + expect(reverseModifier('Element', undefined)).toBe('Element'); + }); + + it('throws on an unknown modifier', function () { + expect(() => reverseModifier('foo', 'upper')).toThrow( + 'Unknown template modifier: upper' + ); + }); +}); + +// --------------------------------------------------------------------------- +// compilePatternToRegex +// --------------------------------------------------------------------------- + +describe('compilePatternToRegex', function () { + it('compiles a Mozilla-style operation pattern', function () { + const { regex, vars } = compilePatternToRegex( + 'mozilla::dom::{Class}_Binding::{method}(' + ); + expect(vars).toEqual([ + { name: 'Class', modifier: undefined }, + { name: 'method', modifier: undefined }, + ]); + const m = 'mozilla::dom::Element_Binding::querySelector(args)'.match(regex); + expect(m).not.toBeNull(); + expect(m![1]).toBe('Element'); + expect(m![2]).toBe('querySelector'); + }); + + it('compiles a Blink-style pattern with :blink_snake and :pascal', function () { + const { regex, vars } = compilePatternToRegex( + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation' + ); + expect(vars).toEqual([ + { name: 'Class', modifier: 'blink_snake' }, + { name: 'method', modifier: 'pascal' }, + ]); + const m = + 'blink::(anonymous namespace)::v8_html_image_element::SetSrcOperation'.match( + regex + ); + expect(m).not.toBeNull(); + expect(m![1]).toBe('html_image_element'); + expect(m![2]).toBe('SetSrc'); + }); + + it('compiles a WebKit-style pattern', function () { + const { regex } = compilePatternToRegex( + 'WebCore::js{Class}PrototypeFunction_{method}' + ); + const m = 'WebCore::jsElementPrototypeFunction_querySelector(args)'.match( + regex + ); + expect(m).not.toBeNull(); + expect(m![1]).toBe('Element'); + expect(m![2]).toBe('querySelector'); + }); + + it('refuses to match when the literal text does not appear', function () { + const { regex } = compilePatternToRegex( + 'mozilla::dom::{Class}_Binding::{method}(' + ); + expect('mozilla::dom::Element_Other::foo('.match(regex)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// discoverAutoLabels / resolveAllLabels +// --------------------------------------------------------------------------- + +const DOM_OPERATION_AUTO: AutoLabel = { + label: '{Class}.{method}', + patterns: [ + 'mozilla::dom::{Class}_Binding::{method}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{method:pascal}Operation", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation', + 'WebCore::js{Class}PrototypeFunction_{method}', + ], +}; + +const DOM_SETTER_AUTO: AutoLabel = { + label: 'set {Class}.{prop}', + patterns: [ + 'mozilla::dom::{Class}_Binding::set_{prop}(', + "blink::`anonymous namespace'::v8_{Class:blink_snake}::{prop:pascal}AttributeSetCallback", + 'blink::(anonymous namespace)::v8_{Class:blink_snake}::{prop:pascal}AttributeSetCallback', + 'WebCore::setJS{Class}_{prop}(', + ], +}; + +describe('discoverAutoLabels', function () { + it('discovers a Mozilla-style operation', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::querySelector(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + expect(labels[0].funcPrefixes).toContain( + 'mozilla::dom::Element_Binding::querySelector(' + ); + expect(labels[0].funcPrefixes).toContain( + 'WebCore::jsElementPrototypeFunction_querySelector' + ); + expect(labels[0].funcPrefixes).toContain( + 'blink::(anonymous namespace)::v8_element::QuerySelectorOperation' + ); + }); + + it('discovers a Blink-style operation, recovering Class via the special-tokens list', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: ['HTML'], + }, + ['blink::(anonymous namespace)::v8_html_image_element::ClickOperation'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('HTMLImageElement.click'); + // forward-expanded prefixes for all engines: + expect(labels[0].funcPrefixes).toEqual([ + 'mozilla::dom::HTMLImageElement_Binding::click(', + "blink::`anonymous namespace'::v8_html_image_element::ClickOperation", + 'blink::(anonymous namespace)::v8_html_image_element::ClickOperation', + 'WebCore::jsHTMLImageElementPrototypeFunction_click', + ]); + }); + + it('dedupes when the same (Class, method) is observed in multiple engine forms', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'WebCore::jsElementPrototypeFunction_querySelector(JSC::JSGlobalObject*)', + ] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('Element.querySelector'); + }); + + it('discovers a setter using a separate auto_labels entry', function () { + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_SETTER_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_id(args)'] + ); + expect(labels).toHaveLength(1); + expect(labels[0].name).toBe('set Element.id'); + }); + + it('rejects matches whose round-trip does not agree with the observed name', function () { + // funcName has Class part that is NOT a valid PascalCase identifier under + // our regex (starts lowercase), so there should be no match. + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::element_Binding::querySelector(args)'] + ); + expect(labels).toHaveLength(0); + }); + + it('does not match a binding setter as a dom_operation method', function () { + // `{method}` (camelCase, no modifier) must not swallow the `set_` of + // `set_innerHTML`, otherwise dom_operation would synthesize a stray + // "Element.set_innerHTML" label alongside dom_setter's "set Element.innerHTML". + const labels = discoverAutoLabels( + { + labels: [], + autoLabels: [DOM_OPERATION_AUTO, DOM_SETTER_AUTO], + blinkSpecialTokens: [], + }, + ['mozilla::dom::Element_Binding::set_innerHTML(args)'] + ); + expect(labels.map((l) => l.name)).toEqual(['set Element.innerHTML']); + }); +}); + +describe('resolveAllLabels', function () { + it('merges an explicit `[[labels]]` block into the auto-discovered prefixes when names collide', function () { + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["HTML"] + +[[labels]] +name = "Element.querySelector" +funcPrefixes = ["extra::prefix"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "WebCore::js{Class}PrototypeFunction_{method}", +] +`); + const labels = resolveAllLabels(parsed, [ + 'mozilla::dom::Element_Binding::querySelector(args)', + 'mozilla::dom::Element_Binding::getAttribute(args)', + ]); + + const byName = new Map(labels.map((l) => [l.name, l])); + // querySelector: auto-discovered prefixes plus the extra one from [[labels]] + expect(byName.get('Element.querySelector')!.funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::querySelector(', + 'WebCore::jsElementPrototypeFunction_querySelector', + 'extra::prefix', + ]); + // getAttribute: pure auto-discovered, no explicit override + expect(byName.get('Element.getAttribute')!.funcPrefixes).toEqual([ + 'mozilla::dom::Element_Binding::getAttribute(', + 'WebCore::jsElementPrototypeFunction_getAttribute', + ]); + }); + + it('keeps an explicit-only label whose name does not collide with any auto-discovered one', function () { + const parsed = parseLabelToml(` +[[labels]] +name = "GC" +funcPrefixes = ["js::gc::GCRuntime::collect("] +`); + const labels = resolveAllLabels(parsed, []); + expect(labels).toEqual([ + { name: 'GC', funcPrefixes: ['js::gc::GCRuntime::collect('] }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// End-to-end auto-discovery → insertStackLabels +// --------------------------------------------------------------------------- + +describe('auto_labels end-to-end', function () { + it('synthesizes a label from an observed Mozilla func and inserts it', function () { + const { profile } = getProfileFromTextSamples(` + A + mozilla::dom::Element_Binding::querySelector(args) + `); + + const parsed = parseLabelToml(` +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - Element.querySelector (total: 1, self: —)', + ' - mozilla::dom::Element_Binding::querySelector(args) (total: 1, self: 1)', + ]); + }); + + it('synthesizes a label for HTMLImageElement from a Blink-only profile using the special-tokens list', function () { + const { profile } = getProfileFromTextSamples(` + A + blink::(anonymous namespace)::v8_html_image_element::ClickOperation + `); + + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["HTML"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - HTMLImageElement.click (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_html_image_element::ClickOperation (total: 1, self: 1)', + ]); + }); + + it('synthesizes a label for WebGL2RenderingContext using a mixed-case special token', function () { + // Blink's NameStyleConverter snakes `WebGL2RenderingContext` to + // `webgl2_rendering_context` (one token `WebGL2`), not + // `web_gl_2_rendering_context`. With `WebGL2` in the special-tokens list + // the recovery agrees with how Blink actually spells the binding name. + const { profile } = getProfileFromTextSamples(` + A + blink::(anonymous namespace)::v8_webgl2_rendering_context::DrawArraysOperation + `); + + const parsed = parseLabelToml(` +[global] +blink_special_tokens = ["WebGL2"] + +[[auto_labels]] +label = "{Class}.{method}" +patterns = [ + "mozilla::dom::{Class}_Binding::{method}(", + "blink::(anonymous namespace)::v8_{Class:blink_snake}::{method:pascal}Operation", +] +`); + + const funcNames: string[] = []; + for (let i = 0; i < profile.shared.funcTable.length; i++) { + funcNames.push( + profile.shared.stringArray[profile.shared.funcTable.name[i]] + ); + } + const labels = resolveAllLabels(parsed, funcNames); + + expect( + formatTree(callTreeFromProfile(insertStackLabels(profile, labels))) + ).toEqual([ + '- Unaccounted (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - WebGL2RenderingContext.drawArrays (total: 1, self: —)', + ' - blink::(anonymous namespace)::v8_webgl2_rendering_context::DrawArraysOperation (total: 1, self: 1)', + ]); + }); +}); diff --git a/src/test/unit/profiler-edit.test.ts b/src/test/unit/profiler-edit.test.ts index 82205160fa..20a83c0dad 100644 --- a/src/test/unit/profiler-edit.test.ts +++ b/src/test/unit/profiler-edit.test.ts @@ -20,6 +20,7 @@ describe('makeOptionsFromArgv', function () { }); expect(options.output).toEqual('/path/to/output.json'); expect(options.symbolicateWithServer).toBeUndefined(); + expect(options.insertLabelFrames).toBeUndefined(); }); it('recognizes -i with an http URL', function () { @@ -113,6 +114,19 @@ describe('makeOptionsFromArgv', function () { expect(options.symbolicateWithServer).toEqual('http://localhost:8001/'); }); + it('recognizes optional --insert-label-frames', function () { + const options = makeOptionsFromArgv([ + ...commonArgs, + '-i', + '/path/to/profile.json', + '-o', + '/path/to/output.json', + '--insert-label-frames', + '/path/to/labels.toml', + ]); + expect(options.insertLabelFrames).toEqual('/path/to/labels.toml'); + }); + it('throws when no input is provided', function () { expect(() => makeOptionsFromArgv([...commonArgs, '-o', '/path/to/output.json']) diff --git a/src/utils/label-templates.ts b/src/utils/label-templates.ts new file mode 100644 index 0000000000..342dabac5b --- /dev/null +++ b/src/utils/label-templates.ts @@ -0,0 +1,426 @@ +/* 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/. */ + +// --------------------------------------------------------------------------- +// Engine coupling — read this before adding modifiers +// +// Blink derives V8 binding func names from IDL by snake_case-ing the +// interface name (e.g. `HTMLCanvasElement` → `v8_html_canvas_element::...`). +// To match those frames in profiles we have to mirror Blink's exact +// snake_case algorithm — see Blink's `NameStyleConverter` +// (third_party/blink/renderer/build/scripts/blinkbuild/name_style_converter.py). +// That coupling is encoded by the `:blink_snake` modifier and its +// companion `blink_special_tokens` list in the TOML's `[global]` section, +// not by a generic `:snake` — because there is no engine-neutral snake +// convention this translation could appeal to. WebKit and Mozilla bindings +// keep PascalCase in their generated symbols, so they don't need an +// equivalent. If a future engine needs its own convention, add a new +// modifier (e.g. `:webkit_snake`) rather than overloading this one. +// --------------------------------------------------------------------------- + +import { parse as parseToml } from 'smol-toml'; + +/** + * A template-driven label produced by auto-discovery. `label` is the name + * template (e.g. "set {Class}.{prop}"), `patterns` are the per-engine + * funcPrefix templates whose vars are recovered from observed func names. + */ +export type AutoLabel = { + label: string; + patterns: string[]; +}; + +export type LabelConfig = { + name: string; + funcPrefixes?: string[]; +}; + +export type LabelDescription = { + name: string; + funcPrefixes: string[]; +}; + +export type ParsedLabelToml = { + labels: LabelConfig[]; + autoLabels: AutoLabel[]; + blinkSpecialTokens: string[]; +}; + +// --------------------------------------------------------------------------- +// Blink-style tokenization +// --------------------------------------------------------------------------- + +const BLINK_TOKEN_PATTERNS = [ + '[A-Z]?[a-z]+', + '[A-Z]+(?![a-z])', + '[0-9][Dd](?![a-z])', + '[0-9]+', +]; + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function buildBlinkTokenRegex(blinkSpecialTokens: string[]): RegExp { + // Sort by length desc so longer entries that share a prefix with shorter + // ones (e.g. `WebGL2` vs `WebGL`) match first. JS regex alternation picks + // the leftmost successful alternative, not the longest match. + const sorted = [...blinkSpecialTokens].sort((a, b) => b.length - a.length); + const escaped = sorted.map(escapeRegex); + return new RegExp([...escaped, ...BLINK_TOKEN_PATTERNS].join('|'), 'g'); +} + +function buildBlinkLeadingNumberTokenRegex( + blinkSpecialTokens: string[] +): RegExp | null { + const withNumbers = blinkSpecialTokens.filter((t) => /[0-9]/.test(t)); + if (withNumbers.length === 0) { + return null; + } + const sorted = [...withNumbers].sort((a, b) => b.length - a.length); + return new RegExp('^(' + sorted.map(escapeRegex).join('|') + ')', 'i'); +} + +/** + * Tokenize a name following Blink's NameStyleConverter. Special tokens + * (e.g. `HTML`, `WebGL2`, `XPath`, `2D`) are matched first as single units; + * otherwise the default patterns capture camelCase boundaries and runs of + * capitals. + */ +export function tokenizeBlinkName( + name: string, + blinkSpecialTokens: string[] +): string[] { + const tokens: string[] = []; + let remaining = name; + + // Case-insensitive leading match for digit-bearing special tokens. Lets us + // tokenize lowerCamelCase like `webgl2RenderingContext` (where the leading + // special token has been lowercased). + const leadingRe = buildBlinkLeadingNumberTokenRegex(blinkSpecialTokens); + if (leadingRe !== null) { + const m = remaining.match(leadingRe); + if (m !== null) { + tokens.push(m[1]); + remaining = remaining.slice(m[1].length); + } + } + + const tokenRe = buildBlinkTokenRegex(blinkSpecialTokens); + for (const m of remaining.matchAll(tokenRe)) { + tokens.push(m[0]); + } + + return tokens; +} + +// --------------------------------------------------------------------------- +// Forward modifiers +// --------------------------------------------------------------------------- + +export function applyModifier( + value: string, + modifier: string | undefined, + blinkSpecialTokens: string[] = [] +): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toUpperCase() + value.slice(1); + case 'blink_snake': + return tokenizeBlinkName(value, blinkSpecialTokens) + .map((t) => t.toLowerCase()) + .join('_'); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +export function expandPattern( + pattern: string, + vars: Record, + blinkSpecialTokens: string[] = [] +): string { + return pattern.replace( + /\{(\w+)(?::(\w+))?\}/g, + (_match, name: string, modifier: string | undefined) => { + if (!(name in vars)) { + throw new Error(`Template variable "${name}" not provided`); + } + return applyModifier(vars[name], modifier, blinkSpecialTokens); + } + ); +} + +// --------------------------------------------------------------------------- +// Reverse modifiers +// --------------------------------------------------------------------------- + +function capitalizeFirstAlpha(seg: string): string { + const idx = seg.search(/[A-Za-z]/); + if (idx < 0) { + return seg; + } + return seg.slice(0, idx) + seg.charAt(idx).toUpperCase() + seg.slice(idx + 1); +} + +/** + * Reverse a `:blink_snake`-formed string back to its PascalCase original. + * + * Lowercasing during `:blink_snake` is one-way: `html_element` could come + * from either `HtmlElement` or `HTMLElement`. The caller supplies + * `blinkSpecialTokens` — canonical-cased fragments expected in the + * original — to disambiguate. + * + * Blink's `to_upper_camel_case` only consults SPECIAL_TOKENS for the first + * token, relying on tokenization to preserve the casing of subsequent + * tokens. We extend that to every position because by the time a name has + * been emitted as snake_case in a V8 binding func name, the tokenization is + * already gone. + */ +export function reverseBlinkSnake( + value: string, + blinkSpecialTokens: string[] = [] +): string { + const tokenByLower = new Map(); + for (const t of blinkSpecialTokens) { + tokenByLower.set(t.toLowerCase(), t); + } + return value + .split('_') + .map((seg) => tokenByLower.get(seg) ?? capitalizeFirstAlpha(seg)) + .join(''); +} + +export function reverseModifier( + value: string, + modifier: string | undefined, + blinkSpecialTokens: string[] = [] +): string { + switch (modifier) { + case 'pascal': + return value.charAt(0).toLowerCase() + value.slice(1); + case 'blink_snake': + return reverseBlinkSnake(value, blinkSpecialTokens); + case undefined: + return value; + default: + throw new Error(`Unknown template modifier: ${modifier}`); + } +} + +// --------------------------------------------------------------------------- +// Pattern → regex compilation (used for auto-discovery) +// --------------------------------------------------------------------------- + +const VAR_PATTERN_RE = /\{(\w+)(?::(\w+))?\}/g; + +function regexCharClassForVar( + name: string, + modifier: string | undefined +): string { + if (modifier === 'blink_snake') { + // snake_case identifier: starts with lowercase letter or digit, may + // contain `_`-separated alnum runs. + return '[a-z][a-z0-9]*(?:_[a-z0-9]+)*'; + } + // No modifier or :pascal — matches the case-style expected at the + // expansion site. PascalCase if the var name starts with uppercase, + // camelCase otherwise. `:pascal` always emits a PascalCase result. + // Underscores are excluded from camelCase: DOM method/property names + // are camelCase, and allowing `_` would let `{method}` swallow the + // `set_`/`get_` prefix of binding setters/getters (matching + // `mozilla::dom::Element_Binding::set_innerHTML(` as method= + // `set_innerHTML` instead of leaving it for the dom_setter template). + if (modifier === 'pascal' || /^[A-Z]/.test(name)) { + return '[A-Z][A-Za-z0-9]*'; + } + return '[a-z][A-Za-z0-9]*'; +} + +export function compilePatternToRegex(pattern: string): { + regex: RegExp; + vars: Array<{ name: string; modifier: string | undefined }>; +} { + const vars: Array<{ name: string; modifier: string | undefined }> = []; + let regexStr = ''; + let lastIndex = 0; + for (const m of pattern.matchAll(VAR_PATTERN_RE)) { + regexStr += escapeRegex(pattern.slice(lastIndex, m.index)); + const name = m[1]; + const modifier = m[2] ?? undefined; + regexStr += '(' + regexCharClassForVar(name, modifier) + ')'; + vars.push({ name, modifier }); + lastIndex = m.index! + m[0].length; + } + regexStr += escapeRegex(pattern.slice(lastIndex)); + return { regex: new RegExp('^' + regexStr), vars }; +} + +// --------------------------------------------------------------------------- +// Auto-discovery +// --------------------------------------------------------------------------- + +type CompiledAutoEntry = { + auto: AutoLabel; + patterns: Array<{ + pattern: string; + regex: RegExp; + vars: Array<{ name: string; modifier: string | undefined }>; + }>; +}; + +function compileAutoLabels(parsed: ParsedLabelToml): CompiledAutoEntry[] { + return parsed.autoLabels.map((auto) => { + const patterns = auto.patterns.map((pattern) => { + const { regex, vars } = compilePatternToRegex(pattern); + return { pattern, regex, vars }; + }); + return { auto, patterns }; + }); +} + +/** + * Walk `funcNames` and synthesize a label entry for each unique + * (auto-label, recovered-vars) tuple matched by an `[[auto_labels]]` entry. + * Each entry's funcPrefixes is the forward-expansion of every pattern in + * the entry, so the label still matches across engines even if only + * one engine's funcs were observed. + */ +export function discoverAutoLabels( + parsed: ParsedLabelToml, + funcNames: Iterable +): LabelDescription[] { + const compiled = compileAutoLabels(parsed); + if (compiled.length === 0) { + return []; + } + + type Discovered = { + auto: AutoLabel; + vars: Record; + labelName: string; + }; + const discovered = new Map(); + + for (const funcName of funcNames) { + for (const { auto, patterns } of compiled) { + for (const c of patterns) { + const m = funcName.match(c.regex); + if (!m) { + continue; + } + + const vars: Record = {}; + let recoverFailed = false; + for (let i = 0; i < c.vars.length; i++) { + const { name, modifier } = c.vars[i]; + try { + vars[name] = reverseModifier( + m[i + 1], + modifier, + parsed.blinkSpecialTokens + ); + } catch { + recoverFailed = true; + break; + } + } + if (recoverFailed) { + continue; + } + + // Round-trip verification: forward-expand the same pattern with the + // recovered vars and confirm it's a prefix of the observed func name. + // Filters out cases where the reverse modifier produced something the + // forward modifier doesn't agree with (e.g. special-token mismatch). + let roundTrip: string; + try { + roundTrip = expandPattern(c.pattern, vars, parsed.blinkSpecialTokens); + } catch { + continue; + } + if (!funcName.startsWith(roundTrip)) { + continue; + } + + let labelName: string; + try { + labelName = expandPattern( + auto.label, + vars, + parsed.blinkSpecialTokens + ); + } catch { + continue; + } + + const key = auto.label + '\0' + labelName; + if (!discovered.has(key)) { + discovered.set(key, { auto, vars, labelName }); + } + break; // first matching pattern wins for this (auto, funcName) + } + } + } + + const result: LabelDescription[] = []; + for (const d of discovered.values()) { + const funcPrefixes = d.auto.patterns.map((p) => + expandPattern(p, d.vars, parsed.blinkSpecialTokens) + ); + result.push({ name: d.labelName, funcPrefixes }); + } + return result; +} + +// --------------------------------------------------------------------------- +// TOML entry points +// --------------------------------------------------------------------------- + +export function parseLabelToml(tomlText: string): ParsedLabelToml { + const data = parseToml(tomlText) as unknown as { + global?: { blink_special_tokens?: string[] }; + labels?: LabelConfig[]; + auto_labels?: AutoLabel[]; + }; + return { + labels: data.labels ?? [], + autoLabels: data.auto_labels ?? [], + blinkSpecialTokens: data.global?.blink_special_tokens ?? [], + }; +} + +/** + * Resolve `[[auto_labels]]` against `funcNames`, then merge in `[[labels]]`. + * On a name collision between an auto-discovered label and an explicit one, + * funcPrefixes are concatenated (auto first, explicit second) — explicit + * labels can extend an auto-discovered label with extra prefixes the + * template can't synthesize. + */ +export function resolveAllLabels( + parsed: ParsedLabelToml, + funcNames: Iterable +): LabelDescription[] { + const auto = discoverAutoLabels(parsed, funcNames); + + const byName = new Map(); + for (const l of auto) { + byName.set(l.name, l); + } + for (const l of parsed.labels) { + const extras = l.funcPrefixes ?? []; + const existing = byName.get(l.name); + if (existing) { + byName.set(l.name, { + name: l.name, + funcPrefixes: [...existing.funcPrefixes, ...extras], + }); + } else { + byName.set(l.name, { name: l.name, funcPrefixes: extras }); + } + } + return [...byName.values()]; +} diff --git a/yarn.lock b/yarn.lock index d55c597ecf..9836914f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10207,6 +10207,11 @@ 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.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.1.tgz#4fceb5f7c4b86c2544024ef686e12ff0983465be" + integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"