diff --git a/Package.swift b/Package.swift index c604b81..4037add 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,17 @@ let messengerPlugin: Target = .target( resources: [.process("Resources")] ) +let profilerPlugin: Target = .target( + name: "PlayerUIDevToolsProfilerDevtoolsPlugin", + dependencies: [ + playerUIDependency, + playerUISwiftUIDependency + ], + path: "devtools/plugins/profiler/ios", + exclude: excluded, + resources: [.process("Resources")] +) + // --- END DECLARATIONS --- // This is the Package.swift for our SPM release. @@ -42,11 +53,15 @@ let package = Package( .library( name: messengerPlugin.name, targets: [messengerPlugin.name] + ), + .library( + name: profilerPlugin.name, + targets: [profilerPlugin.name] ) ], dependencies: [ .package(url: "https://github.com/player-ui/playerui-swift-package.git", from: "0.11.2"), .package(url:"https://github.com/chiragramani/SwiftFlipper.git", from: "0.1.0"), ], - targets: [messengerPlugin] + targets: [messengerPlugin, profilerPlugin] ) diff --git a/devtools/plugin/core/src/helpers/getNowTime.ts b/devtools/plugin/core/src/helpers/getNowTime.ts new file mode 100644 index 0000000..fd3192d --- /dev/null +++ b/devtools/plugin/core/src/helpers/getNowTime.ts @@ -0,0 +1,3 @@ +export const getNowTime = globalThis.performance + ? () => globalThis.performance.now() + : () => Date.now(); diff --git a/devtools/plugin/core/src/helpers/index.ts b/devtools/plugin/core/src/helpers/index.ts index 13ceb14..18dd223 100644 --- a/devtools/plugin/core/src/helpers/index.ts +++ b/devtools/plugin/core/src/helpers/index.ts @@ -1,2 +1,3 @@ export { generateUUID } from "./uuid"; export { genDataChangeTransaction } from "./genDataChangeTransaction"; +export { getNowTime } from "./getNowTime"; diff --git a/devtools/plugin/core/src/helpers/uuid.ts b/devtools/plugin/core/src/helpers/uuid.ts index 6a6bf3e..3c0419f 100644 --- a/devtools/plugin/core/src/helpers/uuid.ts +++ b/devtools/plugin/core/src/helpers/uuid.ts @@ -1,12 +1,10 @@ +import { getNowTime } from "./getNowTime"; + // TODO: Either polyfill crypto or use this (pulled from SO) export function generateUUID(): string { // Public Domain/MIT let d = new Date().getTime(); //Timestamp - let d2 = - (typeof performance !== "undefined" && - performance.now && - performance.now() * 1000) || - 0; //Time in microseconds since page-load or 0 if unsupported + let d2 = getNowTime() * 1000; return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { let r = Math.random() * 16; //random number between 0 and 16 if (d > 0) { diff --git a/devtools/plugins/profiler/core/BUILD b/devtools/plugins/profiler/core/BUILD new file mode 100644 index 0000000..8e6b237 --- /dev/null +++ b/devtools/plugins/profiler/core/BUILD @@ -0,0 +1,28 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@rules_player//javascript:defs.bzl", "js_pipeline") +load("//helpers:defs.bzl", "NATIVE_BUILD_DEPS", "tsup_config", "vitest_config") + +npm_link_all_packages(name = "node_modules") + +tsup_config(name = "tsup_config") + +vitest_config(name = "vitest_config") + +js_pipeline( + package_name = "@player-devtools/profiler-plugin", + build_deps = NATIVE_BUILD_DEPS, + native_bundle = "ProfilerDevtoolsPlugin", + deps = [ + ":node_modules/@player-devtools/messenger", + ":node_modules/@player-devtools/plugin", + ":node_modules/@player-devtools/types", + "//:node_modules/@devtools-ui/plugin", + "//:node_modules/@player-ui/player", + "//:node_modules/@types/uuid", + "//:node_modules/dequal", + "//:node_modules/dset", + "//:node_modules/immer", + "//:node_modules/uuid", + "//:node_modules/tapable-ts", + ], +) diff --git a/devtools/plugins/profiler/core/package.json b/devtools/plugins/profiler/core/package.json new file mode 100644 index 0000000..067a1d1 --- /dev/null +++ b/devtools/plugins/profiler/core/package.json @@ -0,0 +1,10 @@ +{ + "name": "@player-devtools/profiler-plugin", + "version": "0.0.0-PLACEHOLDER", + "main": "src/index.ts", + "dependencies": { + "@player-devtools/messenger": "workspace:*", + "@player-devtools/plugin": "workspace:*", + "@player-devtools/types": "workspace:*" + } +} diff --git a/devtools/plugins/profiler/core/src/__tests__/__snapshots__/plugin.test.ts.snap b/devtools/plugins/profiler/core/src/__tests__/__snapshots__/plugin.test.ts.snap new file mode 100644 index 0000000..a362c0c --- /dev/null +++ b/devtools/plugins/profiler/core/src/__tests__/__snapshots__/plugin.test.ts.snap @@ -0,0 +1,1484 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Plugin > should profile player hooks when navigating through a flow 1`] = ` +{ + "displayFlameGraph": true, + "profiling": false, + "rawNodes": [ + { + "children": [], + "endTime": 2490.2999999999997, + "name": "state", + "startTime": 2490.2, + "tooltip": "state, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2490.5999999999995, + "name": "resolveFlowContent", + "startTime": 2490.4999999999995, + "tooltip": "resolveFlowContent, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2490.899999999999, + "name": "onStart", + "startTime": 2490.7999999999993, + "tooltip": "onStart, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2491.199999999999, + "name": "flowController", + "startTime": 2491.099999999999, + "tooltip": "flowController, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2491.4999999999986, + "name": "bindingParser", + "startTime": 2491.3999999999987, + "tooltip": "bindingParser, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2491.7999999999984, + "name": "schema", + "startTime": 2491.6999999999985, + "tooltip": "schema, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2492.099999999998, + "name": "validationController", + "startTime": 2491.999999999998, + "tooltip": "validationController, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2492.399999999998, + "name": "expressionEvaluator", + "startTime": 2492.299999999998, + "tooltip": "expressionEvaluator, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2492.6999999999975, + "name": "dataController", + "startTime": 2492.5999999999976, + "tooltip": "dataController, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2492.9999999999973, + "name": "viewController", + "startTime": 2492.8999999999974, + "tooltip": "viewController, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2493.299999999997, + "name": "state", + "startTime": 2493.199999999997, + "tooltip": "state, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2493.5999999999967, + "name": "flow", + "startTime": 2493.499999999997, + "tooltip": "flow, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2493.8999999999965, + "name": "beforeStart", + "startTime": 2493.7999999999965, + "tooltip": "beforeStart, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2494.199999999996, + "name": "resolveTransitionNode", + "startTime": 2494.0999999999963, + "tooltip": "resolveTransitionNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [ + { + "children": [], + "endTime": 2494.599999999996, + "name": "resolveView", + "startTime": 2494.499999999996, + "tooltip": "resolveView, 0.1000 (ms)", + "value": 100, + }, + { + "children": [ + { + "children": [], + "endTime": 2494.9999999999955, + "name": "onTemplatePluginCreated", + "startTime": 2494.8999999999955, + "tooltip": "onTemplatePluginCreated, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2495.1999999999953, + "name": "view", + "startTime": 2494.7999999999956, + "tooltip": "view, 0.4000 (ms)", + "value": 400, + }, + { + "children": [], + "endTime": 2495.499999999995, + "name": "templatePlugin", + "startTime": 2495.399999999995, + "tooltip": "templatePlugin, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2495.7999999999947, + "name": "parser", + "startTime": 2495.699999999995, + "tooltip": "parser, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2496.0999999999945, + "name": "parseNode", + "startTime": 2495.9999999999945, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2496.399999999994, + "name": "onParseObject", + "startTime": 2496.2999999999943, + "tooltip": "onParseObject, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2496.699999999994, + "name": "parseNode", + "startTime": 2496.599999999994, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2496.9999999999936, + "name": "parseNode", + "startTime": 2496.8999999999937, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2497.2999999999934, + "name": "parseNode", + "startTime": 2497.1999999999935, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2497.599999999993, + "name": "onCreateASTNode", + "startTime": 2497.499999999993, + "tooltip": "onCreateASTNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2497.899999999993, + "name": "resolver", + "startTime": 2497.799999999993, + "tooltip": "resolver, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2498.1999999999925, + "name": "beforeUpdate", + "startTime": 2498.0999999999926, + "tooltip": "beforeUpdate, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2498.4999999999923, + "name": "resolveOptions", + "startTime": 2498.3999999999924, + "tooltip": "resolveOptions, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2498.799999999992, + "name": "skipResolve", + "startTime": 2498.699999999992, + "tooltip": "skipResolve, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2499.0999999999917, + "name": "beforeResolve", + "startTime": 2498.999999999992, + "tooltip": "beforeResolve, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2499.3999999999915, + "name": "resolve", + "startTime": 2499.2999999999915, + "tooltip": "resolve, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2499.699999999991, + "name": "afterResolve", + "startTime": 2499.5999999999913, + "tooltip": "afterResolve, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2499.999999999991, + "name": "afterNodeUpdate", + "startTime": 2499.899999999991, + "tooltip": "afterNodeUpdate, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2500.2999999999906, + "name": "afterUpdate", + "startTime": 2500.1999999999907, + "tooltip": "afterUpdate, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2500.5999999999904, + "name": "onUpdate", + "startTime": 2500.4999999999905, + "tooltip": "onUpdate, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2500.79999999999, + "name": "transition", + "startTime": 2494.399999999996, + "tooltip": "transition, 6.4000 (ms)", + "value": 6400, + }, + { + "children": [], + "endTime": 2501.09999999999, + "name": "afterTransition", + "startTime": 2500.99999999999, + "tooltip": "afterTransition, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2501.3999999999896, + "name": "skipTransition", + "startTime": 2501.2999999999897, + "tooltip": "skipTransition, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2501.6999999999894, + "name": "beforeTransition", + "startTime": 2501.5999999999894, + "tooltip": "beforeTransition, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2501.999999999989, + "name": "resolveTransitionNode", + "startTime": 2501.899999999989, + "tooltip": "resolveTransitionNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2502.299999999989, + "name": "transition", + "startTime": 2502.199999999989, + "tooltip": "transition, 0.1000 (ms)", + "value": 100, + }, + { + "children": [ + { + "children": [], + "endTime": 2502.6999999999884, + "name": "resolveOptions", + "startTime": 2502.5999999999885, + "tooltip": "resolveOptions, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2502.999999999988, + "name": "beforeEvaluate", + "startTime": 2502.8999999999883, + "tooltip": "beforeEvaluate, 0.1000 (ms)", + "value": 100, + }, + { + "children": [ + { + "children": [], + "endTime": 2503.399999999988, + "name": "resolve", + "startTime": 2503.299999999988, + "tooltip": "resolve, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2503.6999999999875, + "name": "skipOptimization", + "startTime": 2503.5999999999876, + "tooltip": "skipOptimization, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2503.9999999999873, + "name": "resolveDataStages", + "startTime": 2503.8999999999874, + "tooltip": "resolveDataStages, 0.1000 (ms)", + "value": 100, + }, + { + "children": [ + { + "children": [], + "endTime": 2504.399999999987, + "name": "resolveTypeForBinding", + "startTime": 2504.299999999987, + "tooltip": "resolveTypeForBinding, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2504.5999999999867, + "name": "resolveDefaultValue", + "startTime": 2504.199999999987, + "tooltip": "resolveDefaultValue, 0.4000 (ms)", + "value": 400, + }, + { + "children": [], + "endTime": 2504.8999999999864, + "name": "onGet", + "startTime": 2504.7999999999865, + "tooltip": "onGet, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2505.199999999986, + "name": "onSet", + "startTime": 2505.0999999999863, + "tooltip": "onSet, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2505.499999999986, + "name": "onUpdate", + "startTime": 2505.399999999986, + "tooltip": "onUpdate, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2505.6999999999857, + "name": "resolve", + "startTime": 2503.199999999988, + "tooltip": "resolve, 2.5000 (ms)", + "value": 2500, + }, + { + "children": [], + "endTime": 2505.9999999999854, + "name": "skipTransition", + "startTime": 2505.8999999999855, + "tooltip": "skipTransition, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2506.299999999985, + "name": "beforeTransition", + "startTime": 2506.1999999999853, + "tooltip": "beforeTransition, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2506.599999999985, + "name": "resolveTransitionNode", + "startTime": 2506.499999999985, + "tooltip": "resolveTransitionNode, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2506.8999999999846, + "name": "transition", + "startTime": 2506.7999999999847, + "tooltip": "transition, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2507.1999999999844, + "name": "afterTransition", + "startTime": 2507.0999999999844, + "tooltip": "afterTransition, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2507.399999999984, + "name": "afterTransition", + "startTime": 2502.4999999999886, + "tooltip": "afterTransition, 4.9000 (ms)", + "value": 4900, + }, + { + "children": [], + "endTime": 2507.699999999984, + "name": "onGet", + "startTime": 2507.599999999984, + "tooltip": "onGet, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2507.9999999999836, + "name": "serialize", + "startTime": 2507.8999999999837, + "tooltip": "serialize, 0.1000 (ms)", + "value": 100, + }, + { + "children": [], + "endTime": 2508.2999999999834, + "name": "state", + "startTime": 2508.1999999999834, + "tooltip": "state, 0.1000 (ms)", + "value": 100, + }, + ], + "rootNode": { + "children": [ + { + "children": [], + "endTime": 2490.2999999999997, + "name": "state", + "startTime": 2490.2, + "tooltip": "state, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2490.5999999999995, + "name": "resolveFlowContent", + "startTime": 2490.4999999999995, + "tooltip": "resolveFlowContent, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2490.899999999999, + "name": "onStart", + "startTime": 2490.7999999999993, + "tooltip": "onStart, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2491.199999999999, + "name": "flowController", + "startTime": 2491.099999999999, + "tooltip": "flowController, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2491.4999999999986, + "name": "bindingParser", + "startTime": 2491.3999999999987, + "tooltip": "bindingParser, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2491.7999999999984, + "name": "schema", + "startTime": 2491.6999999999985, + "tooltip": "schema, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2492.099999999998, + "name": "validationController", + "startTime": 2491.999999999998, + "tooltip": "validationController, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2492.399999999998, + "name": "expressionEvaluator", + "startTime": 2492.299999999998, + "tooltip": "expressionEvaluator, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2492.6999999999975, + "name": "dataController", + "startTime": 2492.5999999999976, + "tooltip": "dataController, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2492.9999999999973, + "name": "viewController", + "startTime": 2492.8999999999974, + "tooltip": "viewController, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2493.299999999997, + "name": "state", + "startTime": 2493.199999999997, + "tooltip": "state, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2493.5999999999967, + "name": "flow", + "startTime": 2493.499999999997, + "tooltip": "flow, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2493.8999999999965, + "name": "beforeStart", + "startTime": 2493.7999999999965, + "tooltip": "beforeStart, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2494.199999999996, + "name": "resolveTransitionNode", + "startTime": 2494.0999999999963, + "tooltip": "resolveTransitionNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [ + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 100, + }, + { + "children": [], + "endTime": 2494.599999999996, + "name": "resolveView", + "startTime": 2494.499999999996, + "tooltip": "resolveView, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [ + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 100, + }, + { + "children": [], + "endTime": 2494.9999999999955, + "name": "onTemplatePluginCreated", + "startTime": 2494.8999999999955, + "tooltip": "onTemplatePluginCreated, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2495.1999999999953, + "name": "view", + "startTime": 2494.7999999999956, + "tooltip": "view, 0.4000 (ms)", + "value": 400, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2495.499999999995, + "name": "templatePlugin", + "startTime": 2495.399999999995, + "tooltip": "templatePlugin, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2495.7999999999947, + "name": "parser", + "startTime": 2495.699999999995, + "tooltip": "parser, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2496.0999999999945, + "name": "parseNode", + "startTime": 2495.9999999999945, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2496.399999999994, + "name": "onParseObject", + "startTime": 2496.2999999999943, + "tooltip": "onParseObject, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2496.699999999994, + "name": "parseNode", + "startTime": 2496.599999999994, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2496.9999999999936, + "name": "parseNode", + "startTime": 2496.8999999999937, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2497.2999999999934, + "name": "parseNode", + "startTime": 2497.1999999999935, + "tooltip": "parseNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2497.599999999993, + "name": "onCreateASTNode", + "startTime": 2497.499999999993, + "tooltip": "onCreateASTNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2497.899999999993, + "name": "resolver", + "startTime": 2497.799999999993, + "tooltip": "resolver, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2498.1999999999925, + "name": "beforeUpdate", + "startTime": 2498.0999999999926, + "tooltip": "beforeUpdate, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2498.4999999999923, + "name": "resolveOptions", + "startTime": 2498.3999999999924, + "tooltip": "resolveOptions, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2498.799999999992, + "name": "skipResolve", + "startTime": 2498.699999999992, + "tooltip": "skipResolve, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2499.0999999999917, + "name": "beforeResolve", + "startTime": 2498.999999999992, + "tooltip": "beforeResolve, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2499.3999999999915, + "name": "resolve", + "startTime": 2499.2999999999915, + "tooltip": "resolve, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2499.699999999991, + "name": "afterResolve", + "startTime": 2499.5999999999913, + "tooltip": "afterResolve, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2499.999999999991, + "name": "afterNodeUpdate", + "startTime": 2499.899999999991, + "tooltip": "afterNodeUpdate, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2500.2999999999906, + "name": "afterUpdate", + "startTime": 2500.1999999999907, + "tooltip": "afterUpdate, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2500.5999999999904, + "name": "onUpdate", + "startTime": 2500.4999999999905, + "tooltip": "onUpdate, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2500.79999999999, + "name": "transition", + "startTime": 2494.399999999996, + "tooltip": "transition, 6.4000 (ms)", + "value": 6400, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2501.09999999999, + "name": "afterTransition", + "startTime": 2500.99999999999, + "tooltip": "afterTransition, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2501.3999999999896, + "name": "skipTransition", + "startTime": 2501.2999999999897, + "tooltip": "skipTransition, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2501.6999999999894, + "name": "beforeTransition", + "startTime": 2501.5999999999894, + "tooltip": "beforeTransition, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2501.999999999989, + "name": "resolveTransitionNode", + "startTime": 2501.899999999989, + "tooltip": "resolveTransitionNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2502.299999999989, + "name": "transition", + "startTime": 2502.199999999989, + "tooltip": "transition, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [ + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 100, + }, + { + "children": [], + "endTime": 2502.6999999999884, + "name": "resolveOptions", + "startTime": 2502.5999999999885, + "tooltip": "resolveOptions, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2502.999999999988, + "name": "beforeEvaluate", + "startTime": 2502.8999999999883, + "tooltip": "beforeEvaluate, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [ + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 100, + }, + { + "children": [], + "endTime": 2503.399999999988, + "name": "resolve", + "startTime": 2503.299999999988, + "tooltip": "resolve, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2503.6999999999875, + "name": "skipOptimization", + "startTime": 2503.5999999999876, + "tooltip": "skipOptimization, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2503.9999999999873, + "name": "resolveDataStages", + "startTime": 2503.8999999999874, + "tooltip": "resolveDataStages, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [ + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 100, + }, + { + "children": [], + "endTime": 2504.399999999987, + "name": "resolveTypeForBinding", + "startTime": 2504.299999999987, + "tooltip": "resolveTypeForBinding, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2504.5999999999867, + "name": "resolveDefaultValue", + "startTime": 2504.199999999987, + "tooltip": "resolveDefaultValue, 0.4000 (ms)", + "value": 400, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2504.8999999999864, + "name": "onGet", + "startTime": 2504.7999999999865, + "tooltip": "onGet, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2505.199999999986, + "name": "onSet", + "startTime": 2505.0999999999863, + "tooltip": "onSet, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2505.499999999986, + "name": "onUpdate", + "startTime": 2505.399999999986, + "tooltip": "onUpdate, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2505.6999999999857, + "name": "resolve", + "startTime": 2503.199999999988, + "tooltip": "resolve, 2.5000 (ms)", + "value": 2500, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2505.9999999999854, + "name": "skipTransition", + "startTime": 2505.8999999999855, + "tooltip": "skipTransition, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2506.299999999985, + "name": "beforeTransition", + "startTime": 2506.1999999999853, + "tooltip": "beforeTransition, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2506.599999999985, + "name": "resolveTransitionNode", + "startTime": 2506.499999999985, + "tooltip": "resolveTransitionNode, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2506.8999999999846, + "name": "transition", + "startTime": 2506.7999999999847, + "tooltip": "transition, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2507.1999999999844, + "name": "afterTransition", + "startTime": 2507.0999999999844, + "tooltip": "afterTransition, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2507.399999999984, + "name": "afterTransition", + "startTime": 2502.4999999999886, + "tooltip": "afterTransition, 4.9000 (ms)", + "value": 4900, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2507.699999999984, + "name": "onGet", + "startTime": 2507.599999999984, + "tooltip": "onGet, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2507.9999999999836, + "name": "serialize", + "startTime": 2507.8999999999837, + "tooltip": "serialize, 0.1000 (ms)", + "value": 100, + }, + { + "backgroundColor": "#000000", + "children": [], + "color": "#000000", + "name": "(work)", + "tooltip": "Placeholder time between hooks", + "value": 200, + }, + { + "children": [], + "endTime": 2508.2999999999834, + "name": "state", + "startTime": 2508.1999999999834, + "tooltip": "state, 0.1000 (ms)", + "value": 100, + }, + ], + "endTime": 2508.2999999999834, + "name": "root", + "startTime": 2490.2, + "value": 18100, + }, +} +`; diff --git a/devtools/plugins/profiler/core/src/__tests__/addProfilerInterceptorsToHooks.test.ts b/devtools/plugins/profiler/core/src/__tests__/addProfilerInterceptorsToHooks.test.ts new file mode 100644 index 0000000..bce09ac --- /dev/null +++ b/devtools/plugins/profiler/core/src/__tests__/addProfilerInterceptorsToHooks.test.ts @@ -0,0 +1,60 @@ +import { SyncHook } from "tapable-ts"; +import { describe, expect, test } from "vitest"; +import { addProfilerInterceptorsToHooks } from "../addProfilerInterceptorsToHooks"; +import { Profiler } from "../helpers"; + +describe("addProfilerInterceptorsToHooks", () => { + /** + * When a parent hook's call interceptor fires, it recursively calls + * addProfilerInterceptorsToHooks on args[0] to discover nested hooks. + * If the parent hook fires again with the same child object, the intercepted + * WeakSet prevents a second interceptor from being added — so the child hook + * always fires exactly once per call regardless of how many times the parent + * hook has fired. + */ + test("re-intercepting the same child object on repeated parent calls does not duplicate timers", () => { + const profilerInstance = new Profiler(); + profilerInstance.start(); + + // Child object whose hooks get discovered lazily via the parent's call arg + const childObj = { + hooks: { + afterTransition: new SyncHook<[]>(), + }, + }; + + // Parent hook that passes childObj as its argument (mirrors Player's "flow" hook + // passing a FlowInstance, which carries its own hooks like afterTransition) + const parentObj = { + hooks: { + flow: new SyncHook<[typeof childObj]>(), + }, + }; + + addProfilerInterceptorsToHooks(parentObj, profilerInstance); + + // First parent call: wires one interceptor onto childObj.hooks.afterTransition + parentObj.hooks.flow.call(childObj); + childObj.hooks.afterTransition.call(); + + const snapAfterFirst = profilerInstance.getSnapshot(); + expect(snapAfterFirst.rootNodes).toHaveLength(2); + expect(snapAfterFirst.rootNodes[0]!.name).toBe("flow"); + expect(snapAfterFirst.rootNodes[1]!.name).toBe("afterTransition"); + expect(snapAfterFirst.rootNodes[1]!.children).toHaveLength(0); + + profilerInstance.start(); + + // Second parent call with the same childObj: the WeakSet guard prevents a second + // interceptor from being added to afterTransition — it fires exactly once. + parentObj.hooks.flow.call(childObj); + childObj.hooks.afterTransition.call(); + + const snapAfterSecond = profilerInstance.getSnapshot(); + const afterTransitionNode = snapAfterSecond.rootNodes.find( + (n) => n.name === "afterTransition", + ); + expect(afterTransitionNode).toBeDefined(); + expect(afterTransitionNode!.children).toHaveLength(0); + }); +}); diff --git a/devtools/plugins/profiler/core/src/__tests__/plugin.test.ts b/devtools/plugins/profiler/core/src/__tests__/plugin.test.ts new file mode 100644 index 0000000..e618123 --- /dev/null +++ b/devtools/plugins/profiler/core/src/__tests__/plugin.test.ts @@ -0,0 +1,168 @@ +import { Flow, InProgressState, Player } from "@player-ui/player"; +import { describe, expect, test, vi } from "vitest"; +import { ProfilerDevtoolsPlugin } from "../plugin"; + +let count = 2490.0; +vi.mock("@player-devtools/plugin", async () => { + const actual = await vi.importActual("@player-devtools/plugin"); + return { + ...actual, + getNowTime: vi.fn(() => { + count += 0.1; + return count; + }), + }; +}); + +describe("Plugin", () => { + // This test is being used to setup a baseline snapshot of perf on a basic player flow. + test("should profile player hooks when navigating through a flow", async () => { + const profilerPlugin = new ProfilerDevtoolsPlugin({ + handler: { + checkIfDevtoolsIsActive: () => true, + processInteraction: () => {}, + }, + playerID: "ID", + }); + + const player = new Player({ plugins: [profilerPlugin] }); + + // This flow is used to navigate through common player steps. + const flow: Flow = { + id: "flow", + views: [ + { + id: "view1", + type: "foo", + value: "bar", + }, + ], + navigation: { + BEGIN: "FLOW_1", + FLOW_1: { + startState: "VIEW_1", + VIEW_1: { + state_type: "VIEW", + ref: "view1", + transitions: { + "*": "ACTION_1", + }, + }, + ACTION_1: { + state_type: "ACTION", + exp: "{{a}} = 1", + transitions: { + "*": "END_DONE", + }, + }, + END_DONE: { + state_type: "END", + outcome: "done", + }, + }, + }, + }; + const playerPromise = player.start(flow); + + // Wait for first view update to complete. + await vi.waitFor(() => { + const playerState = player.getState(); + expect(playerState.status).toBe("in-progress"); + expect( + (playerState as InProgressState).controllers.view.currentView + ?.lastUpdate, + ).toBeDefined(); + }); + + // Live update: profiling is active + const liveData = + profilerPlugin.store.getState().plugins["player-ui-profiler-plugin"]?.flow + .data; + expect(liveData?.profiling).toBe(true); + + // Transition to action state + (player.getState() as InProgressState).controllers.flow.transition("go"); + + // Wait for action state to transition to end state and complete + await playerPromise; + profilerPlugin.processInteraction({ + payload: { + type: "stop-profiling", + }, + type: "PLAYER_DEVTOOLS_PLUGIN_INTERACTION", + }); + + const storeState = profilerPlugin.store.getState(); + expect( + storeState.plugins["player-ui-profiler-plugin"]?.flow.data, + ).toMatchSnapshot(); + }); + + test("stop-profiling marks profiling complete; start-profiling resets state", async () => { + const profilerPlugin = new ProfilerDevtoolsPlugin({ + handler: { + checkIfDevtoolsIsActive: () => true, + processInteraction: () => {}, + }, + playerID: "ID", + }); + + const player = new Player({ plugins: [profilerPlugin] }); + + const flow: Flow = { + id: "flow2", + views: [{ id: "view1", type: "foo" }], + navigation: { + BEGIN: "FLOW_1", + FLOW_1: { + startState: "VIEW_1", + VIEW_1: { + state_type: "VIEW", + ref: "view1", + transitions: { "*": "END_DONE" }, + }, + END_DONE: { state_type: "END", outcome: "done" }, + }, + }, + }; + + const playerPromise = player.start(flow); + + // Wait for the view to render so hooks have fired + await vi.waitFor(() => { + const playerState = player.getState(); + expect(playerState.status).toBe("in-progress"); + expect( + (playerState as InProgressState).controllers.view.currentView + ?.lastUpdate, + ).toBeDefined(); + }); + + profilerPlugin.processInteraction({ + payload: { type: "stop-profiling" }, + type: "PLAYER_DEVTOOLS_PLUGIN_INTERACTION", + }); + + const dataAfterStop = + profilerPlugin.store.getState().plugins["player-ui-profiler-plugin"]?.flow + .data; + expect(dataAfterStop?.profiling).toBe(false); + expect(dataAfterStop?.displayFlameGraph).toBe(true); + + // Restart — state should flip back to active profiling + profilerPlugin.processInteraction({ + payload: { type: "start-profiling" }, + type: "PLAYER_DEVTOOLS_PLUGIN_INTERACTION", + }); + + const dataAfterRestart = + profilerPlugin.store.getState().plugins["player-ui-profiler-plugin"]?.flow + .data; + expect(dataAfterRestart?.profiling).toBe(true); + expect(dataAfterRestart?.displayFlameGraph).toBe(false); + + // Clean up + (player.getState() as InProgressState).controllers.flow.transition("go"); + await playerPromise; + }); +}); diff --git a/devtools/plugins/profiler/core/src/addProfilerInterceptorsToHooks.ts b/devtools/plugins/profiler/core/src/addProfilerInterceptorsToHooks.ts new file mode 100644 index 0000000..c23c6f3 --- /dev/null +++ b/devtools/plugins/profiler/core/src/addProfilerInterceptorsToHooks.ts @@ -0,0 +1,107 @@ +import { + AsyncParallelBailHook, + AsyncParallelHook, + AsyncSeriesBailHook, + AsyncSeriesHook, + AsyncSeriesLoopHook, + AsyncSeriesWaterfallHook, + SyncBailHook, + SyncHook, + SyncLoopHook, + SyncWaterfallHook, +} from "tapable-ts"; +import { Profiler } from "./helpers"; + +/* Paths to hooks to ignore. + * Currently ignoring "view" hook on player since it acts as a shortcut to the viewController's view hook. Including it would duplicate a lot of profiling work. + */ +const IGNORED_PATHS = [["view"]]; + +// Would love to just check if things are `Hook` but tapable-ts doesn't export the base class ;-; +type AnyHook = + | AsyncParallelBailHook + | AsyncParallelHook + | AsyncSeriesBailHook + | AsyncSeriesHook + | AsyncSeriesLoopHook + | AsyncSeriesWaterfallHook + | SyncBailHook + | SyncHook + | SyncLoopHook + | SyncWaterfallHook; + +// Note: cannot use instanceof to check against the hook classes due to how JS is loaded in swift and kotlin +const isAnyHook = (obj: unknown): obj is AnyHook => { + return ( + isRecordType(obj) && + "intercept" in obj && + typeof obj.intercept === "function" + ); +}; + +/** Recursively add profiler interceptors to each hook in the "hooks" property of obj. */ +export const addProfilerInterceptorsToHooks = ( + obj: unknown, + profiler: Profiler, + currentPath: string[] = [], + intercepted: WeakSet = new WeakSet(), +): void => { + if (!hasHooks(obj)) { + return; + } + + Object.entries(obj.hooks).forEach(([key, value]) => { + const nextPath = [...currentPath, key]; + if ( + !isAnyHook(value) || + IGNORED_PATHS.some((path) => isMatchingPaths(path, nextPath)) || + intercepted.has(value) + ) { + return; + } + + intercepted.add(value); + + value.intercept({ + call: (...args) => { + if (args.length > 0) { + addProfilerInterceptorsToHooks( + args[0], + profiler, + nextPath, + intercepted, + ); + } + + profiler.startTimer(key); + }, + done: () => { + profiler.endTimer({ hookName: key }); + }, + result: () => { + profiler.endTimer({ hookName: key }); + }, + error: () => { + profiler.endTimer({ hookName: key }); + }, + }); + }); +}; + +// TODO: Move all these where they should be +export const isMatchingPaths = (path1: string[], path2: string[]): boolean => { + if (path1.length !== path2.length) return false; + + return path1.every((val, idx) => val === path2[idx]); +}; + +export type ObjectWithHooks = { + hooks: Record; +}; + +export const isRecordType = (obj: unknown): obj is Record => + typeof obj === "object" && obj !== null && !Array.isArray(obj); + +export const hasHooks = (obj: unknown): obj is ObjectWithHooks => { + return isRecordType(obj) && "hooks" in obj && isRecordType(obj.hooks); +}; diff --git a/devtools/plugins/profiler/core/src/constants.ts b/devtools/plugins/profiler/core/src/constants.ts new file mode 100644 index 0000000..7691215 --- /dev/null +++ b/devtools/plugins/profiler/core/src/constants.ts @@ -0,0 +1,26 @@ +import type { PluginData } from "@player-devtools/types"; + +export const PLUGIN_ID = "player-ui-profiler-plugin"; + +export const PLUGIN_NAME = "Player UI Profiler"; + +export const PLUGIN_DESCRIPTION = "Standard Player UI Profiler"; + +export const PLUGIN_VERSION = "__VERSION__"; + +export const VIEWS_IDS = { + PROFILER: "Profiler", +}; + +export const INTERACTIONS = { + START_PROFILING: "start-profiling", + STOP_PROFILING: "stop-profiling", + RESET_PROFILING: "reset-profiling", +}; + +export const BASE_PLUGIN_DATA: Omit = { + id: PLUGIN_ID, + name: PLUGIN_NAME, + description: PLUGIN_DESCRIPTION, + version: PLUGIN_VERSION, +}; diff --git a/devtools/plugins/profiler/core/src/helpers/__tests__/profiler.test.ts b/devtools/plugins/profiler/core/src/helpers/__tests__/profiler.test.ts new file mode 100644 index 0000000..31c00f9 --- /dev/null +++ b/devtools/plugins/profiler/core/src/helpers/__tests__/profiler.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, test, vi } from "vitest"; +import { Profiler } from "../profiler"; + +let count = 2490.0; +vi.mock("@player-devtools/plugin", async () => { + const actual = await vi.importActual("@player-devtools/plugin"); + return { + ...actual, + getNowTime: vi.fn(() => { + count += 0.1; + return count; + }), + }; +}); + +describe("Profiler", () => { + test("sequential top-level timers each become a separate rootNodes entry", () => { + const p = new Profiler(); + + p.start(); + + p.startTimer("hookA"); + p.endTimer({ hookName: "hookA" }); + + p.startTimer("hookB"); + p.endTimer({ hookName: "hookB" }); + + const { rootNodes } = p.stopProfiler(); + + expect(rootNodes).toHaveLength(2); + expect(rootNodes[0]!.name).toBe("hookA"); + expect(rootNodes[1]!.name).toBe("hookB"); + }); + + test("nested timers become children of the outer timer", () => { + const p = new Profiler(); + + p.start(); + + p.startTimer("outer"); + p.startTimer("inner1"); + p.endTimer({ hookName: "inner1" }); + p.startTimer("inner2"); + p.endTimer({ hookName: "inner2" }); + p.endTimer({ hookName: "outer" }); + + const { rootNodes } = p.stopProfiler(); + + expect(rootNodes).toHaveLength(1); + expect(rootNodes[0]!.name).toBe("outer"); + expect(rootNodes[0]!.children).toHaveLength(2); + expect(rootNodes[0]!.children[0]!.name).toBe("inner1"); + expect(rootNodes[0]!.children[1]!.name).toBe("inner2"); + }); + + test("endTimer for unknown name warns and does nothing", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const p = new Profiler(); + + p.start(); + p.startTimer("hookA"); + p.endTimer({ hookName: "unknown" }); + + const { rootNodes } = p.stopProfiler(); + + // hookA is still on the stack — not finalized, so rootNodes has it but without endTime + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("'unknown' not found in stack"), + ); + // hookA was started but never ended — it's still a root node (was pushed to rootNodes on start) + expect(rootNodes[0]!.name).toBe("hookA"); + expect(rootNodes[0]!.endTime).toBeUndefined(); + + warnSpy.mockRestore(); + }); + + test("endTimer for buried name pops and warns about intermediate timers", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const p = new Profiler(); + + p.start(); + + p.startTimer("outer"); + p.startTimer("middle"); + p.startTimer("inner"); + // End "outer" without ending "inner" or "middle" first + p.endTimer({ hookName: "outer" }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("popping 'inner'"), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("popping 'middle'"), + ); + + const { rootNodes } = p.stopProfiler(); + + // All three should be finalized + expect(rootNodes).toHaveLength(1); + expect(rootNodes[0]!.name).toBe("outer"); + + warnSpy.mockRestore(); + }); + + test("start() resets all state", () => { + const p = new Profiler(); + + p.start(); + p.startTimer("hookA"); + p.endTimer({ hookName: "hookA" }); + + p.start(); + const { rootNodes } = p.stopProfiler(); + + expect(rootNodes).toHaveLength(0); + }); + + test("calls onUpdate on start(), endTimer(), and clear(), not on startTimer()", () => { + const onUpdate = vi.fn(); + const p = new Profiler(onUpdate); + + expect(onUpdate.mock.calls.length).toBe(0); + + p.start(); + expect(onUpdate.mock.calls.length).toBe(1); + + p.startTimer("hookA"); + expect(onUpdate.mock.calls.length).toBe(1); + + p.endTimer({ hookName: "hookA" }); + expect(onUpdate.mock.calls.length).toBe(2); + + p.startTimer("hookB"); + p.endTimer({ hookName: "hookB" }); + expect(onUpdate.mock.calls.length).toBe(3); + + p.startTimer("hookC"); + p.clear(); + expect(onUpdate.mock.calls.length).toBe(4); + }); + + test("getSnapshot returns a deep clone of rootNodes", () => { + const p = new Profiler(); + + p.start(); + + p.startTimer("slow"); + p.endTimer({ hookName: "slow" }); + + p.startTimer("fast"); + p.endTimer({ hookName: "fast" }); + + const snap = p.getSnapshot(); + + expect(snap.rootNodes).toHaveLength(2); + + // Snapshot is a clone — adding to live tree doesn't affect it + p.startTimer("extra"); + p.endTimer({ hookName: "extra" }); + expect(snap.rootNodes).toHaveLength(2); + }); + + test("getSnapshot sets endTime and value on in-flight nodes using current time", () => { + const p = new Profiler(); + + p.start(); + + // Finish one node so we have a reference + p.startTimer("finished"); + p.endTimer({ hookName: "finished" }); + + // Leave this one in-flight + p.startTimer("inflight"); + + const snap = p.getSnapshot(); + + const finished = snap.rootNodes.find((n) => n.name === "finished"); + const inflight = snap.rootNodes.find((n) => n.name === "inflight"); + + // Finished node is unchanged + expect(finished!.endTime).toBeDefined(); + expect(finished!.value).toBeGreaterThan(0); + + // In-flight node gets a synthetic endTime and value based on snapshot time + expect(inflight!.endTime).toBeDefined(); + expect(inflight!.value).toBeGreaterThan(0); + + // Live node is not mutated + const p2 = new Profiler(); + p2.start(); + p2.startTimer("live"); + const liveBefore = p2.getSnapshot().rootNodes[0]!; + expect(liveBefore.endTime).toBeDefined(); // snapshot sets it + // but the original node in the stack must remain without endTime + // (verified indirectly: calling endTimer still works normally) + p2.endTimer({ hookName: "live" }); + const liveAfter = p2.getSnapshot().rootNodes[0]!; + expect(liveAfter.endTime).toBeDefined(); + expect(liveAfter.value).toBeGreaterThan(0); + }); + + describe("clear()", () => { + test("clears rootNodes but leaves in-progress timers on the stack", () => { + const p = new Profiler(); + p.start(); + + p.startTimer("finished"); + p.endTimer({ hookName: "finished" }); + + p.startTimer("inflight"); + + p.clear(); + + const { rootNodes } = p.stopProfiler(); + // rootNodes from before the clear are gone; the in-flight timer produces one new root + expect(rootNodes.find((n) => n.name === "finished")).toBeUndefined(); + expect(rootNodes.find((n) => n.name === "inflight")).toBeDefined(); + }); + + test("resets startTime of in-progress timers to current time", () => { + const p = new Profiler(); + p.start(); + + p.startTimer("inflight"); + const beforeClear = p.getSnapshot().rootNodes[0]!.startTime!; + + p.clear(); + + const afterClear = p.getSnapshot().rootNodes[0]!.startTime!; + expect(afterClear).toBeGreaterThan(beforeClear); + }); + + test("fires onUpdate", () => { + const onUpdate = vi.fn(); + const p = new Profiler(onUpdate); + p.start(); + p.startTimer("hookA"); + onUpdate.mockClear(); + + p.clear(); + + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + + test("endTimer still works normally for in-progress timers after clear", () => { + const p = new Profiler(); + p.start(); + + p.startTimer("outer"); + p.startTimer("inner"); + + p.clear(); + + p.endTimer({ hookName: "inner" }); + p.endTimer({ hookName: "outer" }); + + const { rootNodes } = p.stopProfiler(); + expect(rootNodes).toHaveLength(1); + expect(rootNodes[0]!.name).toBe("outer"); + expect(rootNodes[0]!.children).toHaveLength(1); + }); + }); + + test("stopProfiler returns the full rootNodes forest", () => { + const p = new Profiler(); + + p.start(); + + p.startTimer("a"); + p.startTimer("a.child"); + p.endTimer({ hookName: "a.child" }); + p.endTimer({ hookName: "a" }); + + p.startTimer("b"); + p.endTimer({ hookName: "b" }); + + const { rootNodes } = p.stopProfiler(); + + expect(rootNodes).toHaveLength(2); + expect(rootNodes[0]!.children).toHaveLength(1); + }); +}); diff --git a/devtools/plugins/profiler/core/src/helpers/__tests__/transformProfilerData.test.ts b/devtools/plugins/profiler/core/src/helpers/__tests__/transformProfilerData.test.ts new file mode 100644 index 0000000..24908ce --- /dev/null +++ b/devtools/plugins/profiler/core/src/helpers/__tests__/transformProfilerData.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "vitest"; +import { transformProfilerData } from "../transformProfilerData"; +import type { ProfilerNode } from "../../types"; + +const node = ( + name: string, + startTime: number, + endTime: number, + children: ProfilerNode[] = [], +): ProfilerNode => ({ + name, + startTime, + endTime, + value: Math.ceil((endTime - startTime) * 1000), + children, +}); + +describe("transformProfilerData", () => { + test("returns root with transformed children, preserving root fields", () => { + const root = node("root", 0, 1, [node("a", 0, 1)]); + const result = transformProfilerData(root); + + expect(result.name).toBe("root"); + expect(result.startTime).toBe(0); + expect(result.endTime).toBe(1); + }); + + test("nodes with no gap between them produce no spacers", () => { + const root = node("root", 0, 2, [node("a", 0, 1), node("b", 1, 2)]); + const { children } = transformProfilerData(root); + + expect(children).toHaveLength(2); + expect(children[0]!.name).toBe("a"); + expect(children[1]!.name).toBe("b"); + }); + + test("gap before first child inserts a spacer", () => { + const root = node("root", 0, 2, [node("a", 1, 2)]); + const { children } = transformProfilerData(root); + + expect(children).toHaveLength(2); + expect(children[0]!.name).toBe("(work)"); + expect(children[0]!.value).toBe(1000); + expect(children[1]!.name).toBe("a"); + }); + + test("gap between children inserts a spacer between them", () => { + const root = node("root", 0, 3, [node("a", 0, 1), node("b", 2, 3)]); + const { children } = transformProfilerData(root); + + expect(children).toHaveLength(3); + expect(children[0]!.name).toBe("a"); + expect(children[1]!.name).toBe("(work)"); + expect(children[1]!.value).toBe(1000); + expect(children[2]!.name).toBe("b"); + }); + + test("spacer value is gap duration in milliseconds (ceil)", () => { + // gap of 0.0015 seconds → ceil(0.0015 * 1000) = ceil(1.5) = 2 + const root = node("root", 0, 2, [node("a", 0.0015, 2)]); + const { children } = transformProfilerData(root); + + expect(children[0]!.name).toBe("(work)"); + expect(children[0]!.value).toBe(2); + }); + + test("spacer has correct styling fields", () => { + const root = node("root", 0, 2, [node("a", 1, 2)]); + const { children } = transformProfilerData(root); + const spacer = children[0]!; + + expect(spacer.backgroundColor).toBe("#000000"); + expect(spacer.color).toBe("#000000"); + expect(spacer.tooltip).toBe("Placeholder time between hooks"); + expect(spacer.children).toHaveLength(0); + }); + + test("nodes missing startTime, endTime, or value are filtered out", () => { + const root: ProfilerNode = { + name: "root", + startTime: 0, + endTime: 2, + value: 2000, + children: [ + { name: "no-start", endTime: 1, value: 1000, children: [] }, + { name: "no-end", startTime: 0, value: 1000, children: [] }, + { name: "no-value", startTime: 0, endTime: 1, children: [] }, + node("valid", 0, 1), + ], + }; + + const { children } = transformProfilerData(root); + expect(children).toHaveLength(1); + expect(children[0]!.name).toBe("valid"); + }); + + test("nodes with value of zero are filtered out", () => { + const root: ProfilerNode = { + name: "root", + startTime: 0, + endTime: 2, + value: 2000, + children: [ + { + name: "zero-value", + startTime: 0, + endTime: 0, + value: 0, + children: [], + }, + node("valid", 0, 1), + ], + }; + + const { children } = transformProfilerData(root); + expect(children).toHaveLength(1); + expect(children[0]!.name).toBe("valid"); + }); + + test("root with no children returns empty children array", () => { + const root = node("root", 0, 1); + const { children } = transformProfilerData(root); + expect(children).toHaveLength(0); + }); + + test("nested children are recursively transformed", () => { + const inner = node("inner", 1.5, 2, []); + const outer = node("outer", 0, 2, [inner]); + const root = node("root", 0, 2, [outer]); + + const result = transformProfilerData(root); + const outerResult = result.children[0]!; + + // gap before inner inside outer → spacer inserted + expect(outerResult.children[0]!.name).toBe("(work)"); + expect(outerResult.children[1]!.name).toBe("inner"); + }); + + test("multiple gaps each produce their own spacer", () => { + const root = node("root", 0, 6, [node("a", 1, 2), node("b", 4, 5)]); + + const { children } = transformProfilerData(root); + + // spacer(0→1), a, spacer(2→4), b + expect(children).toHaveLength(4); + expect(children[0]!.name).toBe("(work)"); + expect(children[1]!.name).toBe("a"); + expect(children[2]!.name).toBe("(work)"); + expect(children[3]!.name).toBe("b"); + }); +}); diff --git a/devtools/plugins/profiler/core/src/helpers/index.ts b/devtools/plugins/profiler/core/src/helpers/index.ts new file mode 100644 index 0000000..d1c5bbb --- /dev/null +++ b/devtools/plugins/profiler/core/src/helpers/index.ts @@ -0,0 +1,2 @@ +export * from "./profiler"; +export * from "./transformProfilerData"; diff --git a/devtools/plugins/profiler/core/src/helpers/profiler.ts b/devtools/plugins/profiler/core/src/helpers/profiler.ts new file mode 100644 index 0000000..9b8746d --- /dev/null +++ b/devtools/plugins/profiler/core/src/helpers/profiler.ts @@ -0,0 +1,114 @@ +import type { ProfilerNode } from "../types"; +import { getNowTime } from "@player-devtools/plugin"; + +export class Profiler { + private rootNodes: ProfilerNode[] = []; + private stack: ProfilerNode[] = []; + + constructor(private readonly onUpdate?: () => void) {} + + start(): void { + this.rootNodes = []; + this.stack = []; + this.onUpdate?.(); + } + + clear(): void { + const now = getNowTime(); + // Reset each in-progress node: fresh startTime, no accumulated children + for (const node of this.stack) { + node.startTime = now; + node.children = node.children.filter((x) => x.endTime === undefined); + } + // Re-wire parent→child links through the stack chain + for (let i = 1; i < this.stack.length; i++) { + this.stack[i - 1]!.children = [this.stack[i]!]; + } + // rootNodes keeps only the outermost in-progress node (if any) + this.rootNodes = this.stack.slice(0, 1); + this.onUpdate?.(); + } + + private cloneNode(node: ProfilerNode, snapshotTime?: number): ProfilerNode { + const children = node.children.map((c) => this.cloneNode(c, snapshotTime)); + const endTime = + node.endTime ?? (snapshotTime !== undefined ? snapshotTime : undefined); + const value = + node.value ?? + (endTime !== undefined && node.startTime !== undefined + ? Math.ceil((endTime - node.startTime) * 1000) + : children.reduce((prev, current) => prev + (current?.value ?? 0), 0)); + + return { + ...node, + endTime, + value, + children, + }; + } + + getSnapshot(): { rootNodes: ProfilerNode[] } { + const now = getNowTime(); + return { + rootNodes: this.rootNodes.map((n) => this.cloneNode(n, now)), + }; + } + + startTimer(hookName: string): void { + const node: ProfilerNode = { + name: hookName, + startTime: getNowTime(), + children: [], + }; + + if (this.stack.length > 0) { + this.stack[this.stack.length - 1]!.children.push(node); + } else { + this.rootNodes.push(node); + } + + this.stack.push(node); + } + + private finalizeNode(node: ProfilerNode, endTime: number): void { + const duration = + node.startTime !== undefined ? endTime - node.startTime : 0.01; + node.endTime = endTime; + node.value = Math.ceil(duration * 1000); + node.tooltip = `${node.name}, ${duration.toFixed(4)} (ms)`; + } + + endTimer({ hookName }: { hookName: string }): void { + const idx = [...this.stack].reverse().findIndex((n) => n.name === hookName); + + if (idx === -1) { + console.warn(`endTimer: '${hookName}' not found in stack, ignoring`); + return; + } + + // stack index of the target (reverse idx → forward idx) + const targetIdx = this.stack.length - 1 - idx; + const endTime = getNowTime(); + + // Pop and finalize everything above the target, from top down + for (let i = this.stack.length - 1; i > targetIdx; i--) { + const orphan = this.stack[i]!; + console.warn( + `endTimer: popping '${orphan.name}' — timer was never explicitly ended`, + ); + this.finalizeNode(orphan, endTime); + } + + // Finalize the target + this.finalizeNode(this.stack[targetIdx]!, endTime); + + // Truncate stack + this.stack.length = targetIdx; + + this.onUpdate?.(); + } + + stopProfiler(): { rootNodes: ProfilerNode[] } { + return { rootNodes: [...this.rootNodes] }; + } +} diff --git a/devtools/plugins/profiler/core/src/helpers/transformProfilerData.ts b/devtools/plugins/profiler/core/src/helpers/transformProfilerData.ts new file mode 100644 index 0000000..ab8f7d8 --- /dev/null +++ b/devtools/plugins/profiler/core/src/helpers/transformProfilerData.ts @@ -0,0 +1,51 @@ +import type { ProfilerNode } from "../types"; + +const createSpacer = (gap: number) => ({ + name: "(work)", + value: Math.ceil(gap * 1000), + children: [], + backgroundColor: "#000000", + color: "#000000", + tooltip: "Placeholder time between hooks", +}); + +export const transformProfilerData = (root: ProfilerNode): ProfilerNode => { + return { + ...root, + children: transformProfilerDataHelper(root.children, root.startTime), + }; +}; + +const transformProfilerDataHelper = ( + nodes: ProfilerNode[], + parentStart: number = 0, +): ProfilerNode[] => { + const merged: ProfilerNode[] = []; + let cursor = parentStart; + + for (const node of nodes) { + if ( + node.startTime === undefined || + node.endTime === undefined || + node.value === undefined || + node.value <= 0 + ) { + continue; + } + + const next = { + ...node, + children: transformProfilerDataHelper(node.children, node.startTime), + }; + + const gap = node.startTime - cursor; + if (gap > 0) { + merged.push(createSpacer(gap)); + } + cursor = node.endTime; + + merged.push(next); + } + + return merged; +}; diff --git a/devtools/plugins/profiler/core/src/index.ts b/devtools/plugins/profiler/core/src/index.ts new file mode 100644 index 0000000..39b9a61 --- /dev/null +++ b/devtools/plugins/profiler/core/src/index.ts @@ -0,0 +1 @@ +export * from "./plugin"; diff --git a/devtools/plugins/profiler/core/src/plugin-flow.json b/devtools/plugins/profiler/core/src/plugin-flow.json new file mode 100755 index 0000000..87edd1b --- /dev/null +++ b/devtools/plugins/profiler/core/src/plugin-flow.json @@ -0,0 +1,227 @@ +{ + "id": "player-ui-profiler-devtools-plugin", + "views": [ + { + "id": "Profile", + "type": "stacked-view", + "header": { + "asset": { + "id": "Profile-header", + "type": "navigation", + "values": [ + { + "asset": { + "id": "Profile-header-values-0", + "type": "action", + "value": "Profile", + "label": { + "asset": { + "id": "Profile-header-values-0-label", + "type": "text", + "value": "Flame Graph" + } + } + } + }, + { + "asset": { + "id": "Profile-header-values-1", + "type": "action", + "value": "Raw", + "label": { + "asset": { + "id": "Profile-header-values-1-label", + "type": "text", + "value": "Raw" + } + } + } + } + ] + } + }, + "main": { + "asset": { + "id": "Profile-main", + "type": "flame-graph", + "binding": "rootNode", + "width": "@[{{rootNode.value}} / 200]@", + "label": { + "asset": { + "id": "Profile-main-label", + "type": "text", + "value": "Profile" + } + } + } + }, + "footer": { + "asset": { + "id": "Profile-footer", + "type": "collection", + "values": [ + { + "asset": { + "id": "Profile-footer-action", + "type": "action", + "exp": "conditional({{profiling}} === true, publish('stop-profiling'), publish('start-profiling'))", + "label": { + "asset": { + "id": "Profile-footer-action-label", + "type": "text", + "value": "@[conditional({{profiling}} === true, 'Stop', 'Start')]@" + } + } + } + }, + { + "asset": { + "id": "Profile-footer-clear", + "type": "action", + "exp": "publish('reset-profiling')", + "label": { + "asset": { + "id": "Profile-footer-clear-label", + "type": "text", + "value": "Reset" + } + } + } + } + ] + } + } + }, + { + "id": "Raw", + "type": "stacked-view", + "header": { + "asset": { + "id": "Raw-header", + "type": "navigation", + "values": [ + { + "asset": { + "id": "Raw-header-values-0", + "type": "action", + "value": "Profile", + "label": { + "asset": { + "id": "Raw-header-values-0-label", + "type": "text", + "value": "Flame Graph" + } + } + } + }, + { + "asset": { + "id": "Raw-header-values-1", + "type": "action", + "value": "Raw", + "label": { + "asset": { + "id": "Raw-header-values-1-label", + "type": "text", + "value": "Raw" + } + } + } + } + ] + } + }, + "main": { + "asset": { + "id": "Raw-main", + "type": "object-inspector", + "binding": "rawNodes" + } + }, + "footer": { + "asset": { + "id": "Raw-footer", + "type": "collection", + "values": [ + { + "asset": { + "id": "Raw-footer-action", + "type": "action", + "exp": "conditional({{profiling}} === true, publish('stop-profiling'), publish('start-profiling'))", + "label": { + "asset": { + "id": "Raw-footer-action-label", + "type": "text", + "value": "@[conditional({{profiling}} === true, 'Stop', 'Start')]@" + } + } + } + }, + { + "asset": { + "id": "Raw-footer-clear", + "type": "action", + "exp": "publish('clear-profiling')", + "label": { + "asset": { + "id": "Raw-footer-clear-label", + "type": "text", + "value": "Clear" + } + } + } + } + ] + } + } + } + ], + "navigation": { + "BEGIN": "Plugin", + "Plugin": { + "startState": "PROFILE", + "PROFILE": { + "state_type": "VIEW", + "ref": "Profile", + "transitions": { + "Profile": "PROFILE", + "Raw": "RAW" + } + }, + "RAW": { + "state_type": "VIEW", + "ref": "Raw", + "transitions": { + "Profile": "PROFILE", + "Raw": "RAW" + } + } + } + }, + "schema": { + "ROOT": { + "profiling": { + "type": "BooleanType" + }, + "displayFlameGraph": { + "type": "BooleanType" + }, + "rootNodes": { + "type": "RecordType", + "isArray": true + }, + "rawNodes": { + "type": "RecordType", + "isArray": true + } + } + }, + "data": { + "profiling": false, + "displayFlameGraph": false, + "rootNode": { + "value": 0 + }, + "rawNodes": [] + } +} diff --git a/devtools/plugins/profiler/core/src/plugin.ts b/devtools/plugins/profiler/core/src/plugin.ts new file mode 100644 index 0000000..d64119c --- /dev/null +++ b/devtools/plugins/profiler/core/src/plugin.ts @@ -0,0 +1,172 @@ +import { + DevtoolsPlugin, + genDataChangeTransaction, + type DevtoolsPluginOptions, +} from "@player-devtools/plugin"; +import type { + DevtoolsPluginInteractionEvent, + PluginData, +} from "@player-devtools/types"; +import type { Flow, Player } from "@player-ui/player"; +import { dset } from "dset/merge"; +import { produce } from "immer"; +import { BASE_PLUGIN_DATA, INTERACTIONS } from "./constants"; +import { Profiler, transformProfilerData } from "./helpers"; +import type { ProfilerNode } from "./types"; +import { addProfilerInterceptorsToHooks } from "./addProfilerInterceptorsToHooks"; +import flow from "./plugin-flow.json"; + +const wrapInRoot = (nodes: ProfilerNode[]): ProfilerNode => { + const startTime = + nodes.reduce( + (min, n) => + n.startTime !== undefined && (min === undefined || n.startTime < min) + ? n.startTime + : min, + undefined, + ) ?? 0; + const endTime = + nodes.reduce( + (max, n) => + n.endTime !== undefined && (max === undefined || n.endTime > max) + ? n.endTime + : max, + undefined, + ) ?? startTime; + return { + name: "root", + startTime, + endTime, + value: Math.ceil((endTime - startTime) * 1000), + children: nodes, + }; +}; + +const pluginData: PluginData = { + ...BASE_PLUGIN_DATA, + flow: flow as Flow, +}; + +const pluginID = pluginData.id; + +export class ProfilerDevtoolsPlugin extends DevtoolsPlugin { + name = "ProfilerDevtoolsPlugin"; + + private readonly profilerObj: Profiler; + + private readonly interactionMap: Map void> = new Map([ + [INTERACTIONS.START_PROFILING, () => this.startProfiler()], + [INTERACTIONS.STOP_PROFILING, () => this.stopProfiler()], + [INTERACTIONS.RESET_PROFILING, () => this.clearProfiler()], + ]); + + constructor(options: Omit) { + super({ + ...options, + pluginData, + }); + + this.profilerObj = new Profiler(() => { + const { rootNodes } = this.profilerObj.getSnapshot(); + const newState = this.produceState( + [ + ["plugins", pluginID, "flow", "data", "rootNode"], + transformProfilerData(wrapInRoot(rootNodes)), + ], + [["plugins", pluginID, "flow", "data", "rawNodes"], rootNodes], + ); + this.store.dispatch( + genDataChangeTransaction({ + playerID: this.playerID, + data: newState.plugins[pluginID]?.flow.data, + pluginID, + }), + ); + }); + } + + private startProfiler(): void { + this.profilerObj.start(); + const newState = produce(this.store.getState(), (draft) => { + dset(draft, ["plugins", pluginID, "flow", "data", "profiling"], true); + dset( + draft, + ["plugins", pluginID, "flow", "data", "displayFlameGraph"], + false, + ); + }); + this.store.dispatch( + genDataChangeTransaction({ + playerID: this.playerID, + data: newState.plugins[pluginID]?.flow.data, + pluginID, + }), + ); + } + + private clearProfiler(): void { + this.profilerObj.clear(); + } + + private stopProfiler(): ReturnType { + const result = this.profilerObj.stopProfiler(); + const { rootNodes } = result; + const newState = this.produceState( + [ + ["plugins", pluginID, "flow", "data", "rootNode"], + transformProfilerData(wrapInRoot(rootNodes)), + ], + [["plugins", pluginID, "flow", "data", "rawNodes"], rootNodes], + [["plugins", pluginID, "flow", "data", "profiling"], false], + [["plugins", pluginID, "flow", "data", "displayFlameGraph"], true], + ); + this.store.dispatch( + genDataChangeTransaction({ + playerID: this.playerID, + data: newState.plugins[pluginID]?.flow.data, + pluginID, + }), + ); + return result; + } + + apply(player: Player): void { + if (!this.checkIfDevtoolsIsActive()) { + return; + } + + super.apply(player); + + // Hook once for the lifetime of this Player instance + addProfilerInterceptorsToHooks(player, this.profilerObj); + + // Start profiling and dispatch initial state + this.profilerObj.start(); + const initialState = produce(this.store.getState(), (draft) => { + dset(draft, ["plugins", pluginID, "flow", "data", "profiling"], true); + dset( + draft, + ["plugins", pluginID, "flow", "data", "displayFlameGraph"], + false, + ); + }); + + this.store.dispatch( + genDataChangeTransaction({ + playerID: this.playerID, + data: initialState.plugins[pluginID]?.flow.data, + pluginID, + }), + ); + } + + processInteraction(interaction: DevtoolsPluginInteractionEvent): void { + super.processInteraction(interaction); + + const { + payload: { type }, + } = interaction; + + this.interactionMap.get(type)?.(); + } +} diff --git a/devtools/plugins/profiler/core/src/types.ts b/devtools/plugins/profiler/core/src/types.ts new file mode 100644 index 0000000..add8b4b --- /dev/null +++ b/devtools/plugins/profiler/core/src/types.ts @@ -0,0 +1,16 @@ +export type ProfilerNode = { + /** hook name */ + name: string; + /* startTime of the hook */ + startTime?: number; + /** endTime of the hook */ + endTime?: number; + /** duration casted to a positive integer (multiplied by 1000) */ + value?: number; + /** tooltip to be shown on hover */ + tooltip?: string; + /** subhook profiler nodes */ + children: ProfilerNode[]; + backgroundColor?: string; + color?: string; +}; diff --git a/devtools/plugins/profiler/ios/BUILD b/devtools/plugins/profiler/ios/BUILD new file mode 100644 index 0000000..47e18b4 --- /dev/null +++ b/devtools/plugins/profiler/ios/BUILD @@ -0,0 +1,10 @@ +load("//helpers:ios.bzl", "ios_library") + +ios_library( + name = "BaseProfilerDevtoolsPlugin", + resources = ["//devtools/plugins/profiler/core:core_native_bundle"], + deps = [ + "//devtools/plugin/ios:PlayerUIDevtoolsPlugins", + "//devtools/utils/swiftui:PlayerUIDevtoolsUtilsSwiftUI", + ], +) diff --git a/devtools/plugins/profiler/ios/Sources/BaseProfilerDevtoolsPlugin.swift b/devtools/plugins/profiler/ios/Sources/BaseProfilerDevtoolsPlugin.swift new file mode 100644 index 0000000..af19a00 --- /dev/null +++ b/devtools/plugins/profiler/ios/Sources/BaseProfilerDevtoolsPlugin.swift @@ -0,0 +1,61 @@ +import PlayerUI +import PlayerUILogger +import Foundation +import PlayerUIDevtoolsPlugins +import PlayerUIDevtoolsMessenger +import PlayerUIDevtoolsTypes +import JavaScriptCore +import PlayerUIDevtoolsUtilsSwiftUI + +open class BaseProfilerDevtoolsPlugin: JSBasePlugin, BaseDevtoolsPlugin { + /// Matches `bundle_name` / `ios_library(name=...)` in `devtools/plugins/profiler/ios/BUILD` (the `apple_resource_bundle` base name). + private static let pluginResourceBundleName = "ProfilerDevtoolsPlugin" + + private let _playerID: String + // This is a var so a different handler can be provided for testing + var handler: DevtoolsHandler = Handler() + + public init(playerID: String) { + self._playerID = playerID + super.init( + fileName: "ProfilerDevtoolsPlugin.native", + pluginName: "ProfilerDevtoolsPlugin.ProfilerDevtoolsPlugin" + ) + } + + public final override func getUrlForFile(fileName: String) -> URL? { + if let url = Bundle.module.url(forResource: fileName, withExtension: "js") { + return url + } + if let bundleURL = Bundle.main.url( + forResource: Self.pluginResourceBundleName, + withExtension: "bundle" + ), + let pluginBundle = Bundle(url: bundleURL), + let url = pluginBundle.url(forResource: fileName, withExtension: "js") { + return url + } + return Bundle.main.url(forResource: fileName, withExtension: "js") + } + + public override func getArguments() -> [Any] { + guard let context else { return [] } + // TODO: replace with proper polyfill plugin after https://github.com/player-ui/player/issues/773 + context.polyfill() + + // PluginData is nil. The core profiler plugin provides its own plugin data + let options = DevtoolsPluginOptions(in: context , playerID: _playerID, handler: handler) + return [options.jsCompatible] + } + + /// This will process messages. The core plugin augments this handler with some logging and metadata + struct Handler: DevtoolsHandler { + var isActive = true + + // This plugin has no extra steps for processInteraction beyond the core impl. + func processInteraction(interaction: PlayerUIDevtoolsTypes.Message) {} + + // This plugin has no extra steps for log beyond the core impl. + func log(message: String) {} + } +} diff --git a/devtools/plugins/profiler/ios/Tests/BaseProfilerDevtoolsPluginTests.swift b/devtools/plugins/profiler/ios/Tests/BaseProfilerDevtoolsPluginTests.swift new file mode 100644 index 0000000..55a9868 --- /dev/null +++ b/devtools/plugins/profiler/ios/Tests/BaseProfilerDevtoolsPluginTests.swift @@ -0,0 +1,149 @@ +import XCTest +import JavaScriptCore +import PlayerUI +import PlayerUIDevtoolsPlugins +import PlayerUIDevtoolsTypes +@testable import PlayerUIDevtoolsBaseProfilerDevtoolsPlugin + +final class BaseProfilerDevtoolsPluginTests: XCTestCase { + + var context: JSContext! + var testHandler: TestHandler! + var plugin: BaseProfilerDevtoolsPlugin! + let testPlayerID = "test-player-123" + + override func setUp() { + super.setUp() + context = JSContext() + testHandler = TestHandler() + plugin = BaseProfilerDevtoolsPlugin(playerID: testPlayerID) + plugin.handler = testHandler + + // Actually load the JS plugin + plugin.context = context + plugin.setup(context: context) + } + + override func tearDown() { + plugin = nil + super.tearDown() + } + + // MARK: - Initialization Tests + + func testInitializationWithVariousPlayerIDs() { + let testCases = [ + "player-1", + "", + "player-@#$%^&*()", + "player-🎮-🎯", + String(repeating: "a", count: 1000) + ] + + for playerID in testCases { + let testPlugin = BaseProfilerDevtoolsPlugin(playerID: playerID) + XCTAssertNotNil(testPlugin) + } + } + + // MARK: - Arguments Tests + + func testGetArgumentsStructure() { + let arguments = plugin.getArguments() + XCTAssertEqual(arguments.count, 1) + + guard let jsCompatibleDict = arguments.first as? [String: Any] else { + XCTFail("First argument should be a dictionary") + return + } + + // Verify playerID + XCTAssertEqual(jsCompatibleDict["playerID"] as? String, testPlayerID) + + // Verify handler structure + guard let handler = jsCompatibleDict["handler"] as? [String: Any] else { + XCTFail("Handler should be a dictionary") + return + } + + XCTAssertTrue(handler.keys.contains("checkIfDevtoolsIsActive")) + XCTAssertTrue(handler.keys.contains("processInteraction")) + } + + // MARK: - Handler Functionality Tests + + func testJSCompatibleHandlerHasCorrectProperties() { + let arguments = plugin.getArguments() + guard let jsCompatibleDict = arguments.first as? [String: Any], + let handler = jsCompatibleDict["handler"] as? [String: Any] else { + XCTFail("Handler should be a dictionary") + return + } + + // Verify handler has exactly the required methods + XCTAssertEqual(handler.keys.count, 2) + XCTAssertTrue(handler.keys.contains("checkIfDevtoolsIsActive")) + XCTAssertTrue(handler.keys.contains("processInteraction")) + } + + func testJSCompatibleIsActive() { + let arguments = plugin.getArguments() + guard let jsCompatibleDict = arguments.first as? [String: Any], + let handler = jsCompatibleDict["handler"] as? [String: Any], + let checkIfActiveFn = handler["checkIfDevtoolsIsActive"] as? JSValue + else { + XCTFail("Handler should be a dictionary") + return + } + XCTAssert(checkIfActiveFn.call(withArguments: []).toBool()) + } + + func testJSCompatibleProcessInteraction() { + let arguments = plugin.getArguments() + guard let jsCompatibleDict = arguments.first as? [String: Any], + let handler = jsCompatibleDict["handler"] as? [String: Any], + let processInteractionFn = handler["processInteraction"] as? JSValue + else { + XCTFail("Handler should be a dictionary") + return + } + + let interaction: Message = ["type": "PLAYER_DEVTOOLS_PLUGIN_INTERACTION", "data": "test-data"] + processInteractionFn.call(withArguments: [interaction]) + XCTAssertEqual(testHandler.interactionsProcessed, 1) + } + + func testIsActiveReturnsTrue() { + XCTAssert(plugin.isActive) + } + + func testProcessInteractionWithVariousMessages() { + let testMessages: [Message] = [ + ["type": "PLAYER_DEVTOOLS_PLUGIN_INTERACTION", "data": "test-data"], + ["type": "PLAYER_DEVTOOLS_PLUGIN_INTERACTION", "data": ["nested": "value", "array": [1, 2, 3]]], + ["type": "PLAYER_DEVTOOLS_PLUGIN_INTERACTION", "data": NSNull()] + ] + + for message in testMessages { + testHandler.processInteraction(interaction: message) + } + + XCTAssertEqual(testHandler.interactionsProcessed, testMessages.count) + } + + // MARK: - Plugin Configuration Tests + + func testPluginConfiguration() { + XCTAssertEqual(plugin.pluginName, "ProfilerDevtoolsPlugin.ProfilerDevtoolsPlugin") + XCTAssertEqual(plugin.fileName, "ProfilerDevtoolsPlugin.native") + } +} + +class TestHandler: DevtoolsHandler { + var interactionsProcessed = 0 + var isActive = true + + func processInteraction(interaction: PlayerUIDevtoolsTypes.Message) { + interactionsProcessed += 1 + } +} diff --git a/devtools/plugins/profiler/jvm/BUILD b/devtools/plugins/profiler/jvm/BUILD new file mode 100644 index 0000000..db9ee47 --- /dev/null +++ b/devtools/plugins/profiler/jvm/BUILD @@ -0,0 +1,26 @@ +load("@rules_jvm_external//:defs.bzl", "artifact") +load("//helpers:jvm.bzl", "kt_jvm") + +main_exports = [ + "//devtools/plugin/jvm:plugin", +] + +main_deps = main_exports + [] + +main_resources = [ + "//devtools/plugins/profiler/core:core_native_bundle", +] + +test_deps = [ + artifact("com.intuit.playerui:testutils"), + artifact("com.intuit.playerui:j2v8-all"), +] + +kt_jvm( + name = "profiler-plugin", + group = "com.intuit.playerui.plugins.devtools.profiler", + main_deps = main_deps, + main_exports = main_exports, + main_resources = main_resources, + test_deps = test_deps +) diff --git a/devtools/plugins/profiler/jvm/src/main/kotlin/com/intuit/playerui/plugins/devtools/profiler/ProfilerDevtoolsPlugin.kt b/devtools/plugins/profiler/jvm/src/main/kotlin/com/intuit/playerui/plugins/devtools/profiler/ProfilerDevtoolsPlugin.kt new file mode 100644 index 0000000..5bd85a0 --- /dev/null +++ b/devtools/plugins/profiler/jvm/src/main/kotlin/com/intuit/playerui/plugins/devtools/profiler/ProfilerDevtoolsPlugin.kt @@ -0,0 +1,49 @@ +package com.intuit.playerui.plugins.devtools.profiler + +import com.intuit.playerui.core.bridge.Node +import com.intuit.playerui.core.bridge.runtime.Runtime +import com.intuit.playerui.core.bridge.runtime.add +import com.intuit.playerui.core.bridge.serialization.serializers.NodeWrapperSerializer +import com.intuit.playerui.core.player.PlayerException +import com.intuit.playerui.core.plugins.RuntimePlugin +import com.intuit.playerui.devtools.DevtoolsHandler +import com.intuit.playerui.devtools.DevtoolsPlugin +import com.intuit.playerui.devtools.ModuleLoader +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import java.util.concurrent.atomic.AtomicInteger + +@Serializable(with = ProfilerDevtoolsPlugin.Serializer::class) +public class ProfilerDevtoolsPlugin( + node: Node, +) : DevtoolsPlugin(node) { + @Serializable + public data class Options( + public val playerID: String, + public val handler: DevtoolsHandler, + ) + + public companion object Module : RuntimePlugin by ModuleLoader( + ProfilerDevtoolsPlugin.NAME, + ProfilerDevtoolsPlugin.BUNDLED_SOURCE_PATH, + ) { + private val count = AtomicInteger(0) + + public fun Runtime<*>.ProfilerDevtoolsPlugin(options: Options): ProfilerDevtoolsPlugin { + runtime.execute("class WeakRef { value = null; constructor(value) { this.value = value }; deref() { return this.value } }") + + val argsKey = "profilerDevtoolsPluginArgs_${count.getAndIncrement()}" + runtime.add(argsKey, options) + apply(this) + val instance = + runtime.execute("(new $NAME.$NAME($argsKey))") as? Node + ?: throw PlayerException("Could not instantiate ProfilerDevtoolsPlugin") + return ProfilerDevtoolsPlugin(instance) + } + + private const val NAME = "ProfilerDevtoolsPlugin" + private const val BUNDLED_SOURCE_PATH = "devtools/plugins/profiler/core/dist/$NAME.native.js" + } + + internal object Serializer : KSerializer by NodeWrapperSerializer(::ProfilerDevtoolsPlugin) +} diff --git a/devtools/plugins/profiler/jvm/src/test/kotlin/com/intuit/playerui/plugins/devtools/profiler/ProfilerDevtoolsPluginTest.kt b/devtools/plugins/profiler/jvm/src/test/kotlin/com/intuit/playerui/plugins/devtools/profiler/ProfilerDevtoolsPluginTest.kt new file mode 100644 index 0000000..0a41565 --- /dev/null +++ b/devtools/plugins/profiler/jvm/src/test/kotlin/com/intuit/playerui/plugins/devtools/profiler/ProfilerDevtoolsPluginTest.kt @@ -0,0 +1,77 @@ +package com.intuit.playerui.plugins.devtools.profiler + +import com.intuit.playerui.devtools.DevtoolsHandler +import com.intuit.playerui.devtools.DevtoolsPluginInteractionEvent +import com.intuit.playerui.plugins.devtools.profiler.ProfilerDevtoolsPlugin.Module.ProfilerDevtoolsPlugin +import com.intuit.playerui.utils.test.RuntimeTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ProfilerDevtoolsPluginTest : + RuntimeTest(), + DevtoolsHandler { + private val interactions = mutableListOf() + + override fun processInteraction(interaction: DevtoolsPluginInteractionEvent) { + interactions.add(interaction) + } + + override fun checkIfDevtoolsIsActive(): Boolean = true + + private fun plugin(id: String = "test") = runtime.ProfilerDevtoolsPlugin(ProfilerDevtoolsPlugin.Options(id, this)) + + // MARK: - Initialization + + @Test fun `plugin is active when handler reports active`() { + assertTrue(plugin().checkIfDevtoolsIsActive()) + } + + @Test fun `playerID matches the id passed to options`() { + val plugin = plugin("my-player") + assertEquals("my-player", plugin.playerID) + } + + @Test fun `pluginID is the profiler plugin id`() { + val plugin = plugin() + assertEquals("player-ui-profiler-plugin", plugin.pluginID) + } + + @Test fun `store is accessible after construction`() { + val plugin = plugin() + assertNotNull(plugin.store.getState().node) + } + + // MARK: - Interactions + + @Test fun `start-profiling interaction is handled without throwing`() { + val plugin = plugin() + plugin.processInteraction(interactionEvent("start-profiling")) + } + + @Test fun `stop-profiling interaction is handled without throwing`() { + val plugin = plugin() + plugin.processInteraction(interactionEvent("stop-profiling")) + } + + @Test fun `reset-profiling interaction is handled without throwing`() { + val plugin = plugin() + plugin.processInteraction(interactionEvent("reset-profiling")) + } + + @Test fun `unknown interaction type is handled without throwing`() { + val plugin = plugin() + plugin.processInteraction(interactionEvent("unknown-interaction")) + } +} + +private fun interactionEvent(type: String) = + DevtoolsPluginInteractionEvent( + payload = DevtoolsPluginInteractionEvent.Payload(type = type, payload = ""), + id = 0, + timestamp = 0, + sender = "test", + context = kotlinx.serialization.json.JsonPrimitive("player"), + tag = false, + ) diff --git a/devtools/plugins/profiler/react/BUILD b/devtools/plugins/profiler/react/BUILD new file mode 100644 index 0000000..5f89d03 --- /dev/null +++ b/devtools/plugins/profiler/react/BUILD @@ -0,0 +1,25 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@rules_player//javascript:defs.bzl", "js_pipeline") +load("//helpers:defs.bzl", "tsup_config", "vitest_config") + +npm_link_all_packages(name = "node_modules") + +tsup_config(name = "tsup_config") + +vitest_config(name = "vitest_config") + +js_pipeline( + package_name = "@player-devtools/profiler-plugin-react", + deps = [ + ":node_modules/@player-devtools/plugin-react", + ":node_modules/@player-devtools/profiler-plugin", + ":node_modules/@player-devtools/types", + "//:node_modules/@devtools-ui/plugin", + "//:node_modules/@player-ui/react", + "//:node_modules/@types/uuid", + "//:node_modules/dequal", + "//:node_modules/dset", + "//:node_modules/immer", + "//:node_modules/uuid", + ], +) \ No newline at end of file diff --git a/devtools/plugins/profiler/react/package.json b/devtools/plugins/profiler/react/package.json new file mode 100644 index 0000000..a99233e --- /dev/null +++ b/devtools/plugins/profiler/react/package.json @@ -0,0 +1,10 @@ +{ + "name": "@player-devtools/profiler-plugin-react", + "version": "0.0.0-PLACEHOLDER", + "main": "src/index.tsx", + "dependencies": { + "@player-devtools/profiler-plugin": "workspace:*", + "@player-devtools/plugin-react": "workspace:*", + "@player-devtools/types": "workspace:*" + } +} diff --git a/devtools/plugins/profiler/react/src/index.ts b/devtools/plugins/profiler/react/src/index.ts new file mode 100644 index 0000000..9e986f4 --- /dev/null +++ b/devtools/plugins/profiler/react/src/index.ts @@ -0,0 +1,17 @@ +import { ReactDevtoolsPlugin } from "@player-devtools/plugin-react"; +import { ProfilerDevtoolsPlugin } from "@player-devtools/profiler-plugin"; + +export class ProfilerReactDevtoolsPlugin extends ReactDevtoolsPlugin { + name = "ProfilerReactDevtoolsPlugin"; + + corePlugin: ProfilerDevtoolsPlugin; + + constructor(id?: string) { + super(); + + this.corePlugin = new ProfilerDevtoolsPlugin({ + playerID: id ?? "default-id", + handler: this, + }); + } +} diff --git a/devtools/plugins/profiler/swiftui/BUILD b/devtools/plugins/profiler/swiftui/BUILD new file mode 100644 index 0000000..90fc358 --- /dev/null +++ b/devtools/plugins/profiler/swiftui/BUILD @@ -0,0 +1,11 @@ +load("//helpers:ios.bzl", "swiftui_plugin") + +swiftui_plugin( + name = "ProfilerPlugin", + deps = [ + "@swiftpkg_swiftflipper//:SwiftFlipper", + "//devtools/plugin/swiftui:PlayerUIDevtoolsSwiftUIPlugins", + "//devtools/plugins/profiler/ios:PlayerUIDevtoolsBaseProfilerDevtoolsPlugin" + ], + test_deps = [] +) diff --git a/devtools/plugins/profiler/swiftui/Sources/ProfilerDevtoolsPlugin.swift b/devtools/plugins/profiler/swiftui/Sources/ProfilerDevtoolsPlugin.swift new file mode 100644 index 0000000..2cb46e3 --- /dev/null +++ b/devtools/plugins/profiler/swiftui/Sources/ProfilerDevtoolsPlugin.swift @@ -0,0 +1,40 @@ +import Foundation +import PlayerUIDevtoolsMessenger +import PlayerUIDevtoolsPlugins +import PlayerUIDevtoolsSwiftUIPlugins +import PlayerUIDevtoolsBaseProfilerDevtoolsPlugin + +/// A Player Plugin that provides DevTools capabilities via Flipper. +/// This is entirely just a wrapper around the JSBasePlugin +public class ProfilerDevtoolsPlugin: BaseProfilerDevtoolsPlugin, DevtoolsPlugin { + /// Our connection to the flipper server + public let flipperPlugin: DevtoolsFlipperPlugin + /// Keep a reference so the messenger doesn't get garbage collected and destroyed + public var messenger: Messenger? + /// The IDs of all registered listeners associated with this plugin + public var listeners: [UUID] = [] + + public init(id: String, flipperPlugin: DevtoolsFlipperPlugin) { + self.flipperPlugin = flipperPlugin + super.init(playerID: id) + } + + /* Let flipper know that this plugin is going away. Deregister the listeners we + attached to the DevtoolsFlipperPlugin. + + Deinits will NOT run when the app is terminated. But if the app is terminated, + flipper will gracefully handle the abrupt, implicit disconnect, and deregistering + the listeners won't matter anymore since they won't be called if the app is dead. */ + deinit { + // If you make your own DevtoolsPlugin, you will need to implement your own + // deinit, exactly like this. The DevtoolsPlugin protocol cannot provide a deinit, + // unfortunately. + if let messenger { + messenger.destroy() + } else { + print("[DEBUG] Could not destroy messenger. Messenger already no longer exists.") + } + listeners.forEach { flipperPlugin.removeListener(id: $0) } + print("[DEBUG] ProfilerDevtoolsPlugin deinited") + } +} diff --git a/devtools/plugins/profiler/swiftui/ViewInspector/ProfilerDevtoolsPluginTests.swift b/devtools/plugins/profiler/swiftui/ViewInspector/ProfilerDevtoolsPluginTests.swift new file mode 100644 index 0000000..d25a19f --- /dev/null +++ b/devtools/plugins/profiler/swiftui/ViewInspector/ProfilerDevtoolsPluginTests.swift @@ -0,0 +1,138 @@ +import XCTest +import PlayerUI +import JavaScriptCore +import PlayerUIDevtoolsTypes +import PlayerUIDevtoolsPlugins +import PlayerUIDevtoolsBaseProfilerDevtoolsPlugin +@testable import PlayerUIDevtoolsProfilerPlugin + +// MARK: - ProfilerDevtoolsPluginTests +final class ProfilerDevtoolsPluginTests: XCTestCase { + + func testPluginNameIsProfilerDevtoolsPlugin() { + let plugin = ProfilerDevtoolsPlugin(id: "test-id") + XCTAssertEqual(plugin.pluginName, "ProfilerDevtoolsPlugin.ProfilerDevtoolsPlugin") + } + + // MARK: - Initialization Tests + + func testInitializationWithValidID() { + let testID = "player-123" + let plugin = ProfilerDevtoolsPlugin(id: testID) + XCTAssertNotNil(plugin) + } + + func testInitializationWithEmptyID() { + let plugin = ProfilerDevtoolsPlugin(id: "") + XCTAssertNotNil(plugin) + } + + func testInitializationWithSpecialCharactersInID() { + let testID = "player-@#$%^&*()" + let plugin = ProfilerDevtoolsPlugin(id: testID) + XCTAssertNotNil(plugin) + } + + func testInitializationWithLongID() { + let testID = String(repeating: "a", count: 1000) + let plugin = ProfilerDevtoolsPlugin(id: testID) + XCTAssertNotNil(plugin) + } + + // MARK: - Flipper Plugin Tests + + func testFlipperPluginIsInitialized() { + let plugin = ProfilerDevtoolsPlugin(id: "test-id") + XCTAssertNotNil(plugin.flipperPlugin) + } + + func testFlipperPluginIDIsCorrect() { + let plugin = ProfilerDevtoolsPlugin(id: "test-id") + XCTAssertEqual(plugin.flipperPlugin.id, "player-ui-devtools") + } + + func testFlipperPluginDoesNotRunInBackground() { + let plugin = ProfilerDevtoolsPlugin(id: "test-id") + XCTAssertFalse(plugin.flipperPlugin.runInBackground) + } + + // MARK: - Multiple Instance Tests + + func testMultiplePluginInstancesWithDifferentIDs() { + let plugin1 = ProfilerDevtoolsPlugin(id: "player-1") + let plugin2 = ProfilerDevtoolsPlugin(id: "player-2") + + XCTAssertNotNil(plugin1) + XCTAssertNotNil(plugin2) + XCTAssertFalse(plugin1 === plugin2) + } + + func testEachPluginInstanceHasOwnFlipperPlugin() { + let plugin1 = ProfilerDevtoolsPlugin(id: "player-1") + let plugin2 = ProfilerDevtoolsPlugin(id: "player-2") + + XCTAssertNotEqual(ObjectIdentifier(plugin1.flipperPlugin), ObjectIdentifier(plugin2.flipperPlugin)) + } + + // MARK: - Plugin Name Tests + + func testPluginNameConsistency() { + let plugin1 = ProfilerDevtoolsPlugin(id: "id-1") + let plugin2 = ProfilerDevtoolsPlugin(id: "id-2") + + XCTAssertEqual(plugin1.pluginName, plugin2.pluginName) + XCTAssertEqual(plugin1.pluginName, "ProfilerDevtoolsPlugin.ProfilerDevtoolsPlugin") + } + + // MARK: - Flipper Plugin Configuration Tests + + func testFlipperPluginHasCorrectConfiguration() { + let plugin = ProfilerDevtoolsPlugin(id: "test-id") + + XCTAssertEqual(plugin.flipperPlugin.id, "player-ui-devtools") + XCTAssertFalse(plugin.flipperPlugin.runInBackground) + } + + func testMultiplePluginsHaveIndependentFlipperPlugins() { + let plugin1 = ProfilerDevtoolsPlugin(id: "player-1") + let plugin2 = ProfilerDevtoolsPlugin(id: "player-2") + + let id1 = ObjectIdentifier(plugin1.flipperPlugin) + let id2 = ObjectIdentifier(plugin2.flipperPlugin) + + XCTAssertNotEqual(id1, id2) + } + + // MARK: - Deinit Tests + + func testDeinitRemovesListeners() { + let flipperPlugin = DevtoolsFlipperPlugin() + var plugin: ProfilerDevtoolsPlugin? = ProfilerDevtoolsPlugin(id: "test-id", flipperPlugin: flipperPlugin) + + let listenerID = flipperPlugin.addListener { _ in } + plugin?.listeners.append(listenerID) + + XCTAssertEqual(flipperPlugin.listeners.count, 1) + + plugin = nil + + XCTAssertEqual(flipperPlugin.listeners.count, 0) + } + + func testDeinitDestroysMessenger() { + let flipperPlugin = DevtoolsFlipperPlugin() + var plugin: ProfilerDevtoolsPlugin? = ProfilerDevtoolsPlugin(id: "test-id", flipperPlugin: flipperPlugin) + + plugin = nil + + if let jsException = plugin?.context?.exception { + XCTFail("Destroy failed") + } + } +} + +extension ProfilerDevtoolsPlugin { + convenience init(id: String) { + self.init(id: id, flipperPlugin: .init()) + } +} diff --git a/ios/BUILD b/ios/BUILD index 1d6db10..dbb87ed 100644 --- a/ios/BUILD +++ b/ios/BUILD @@ -18,5 +18,7 @@ xcodeproj( "//devtools/plugin/swiftui:PlayerUIDevtoolsSwiftUIPluginsViewInspectorTests", "//devtools/plugins/basic/ios:PlayerUIDevtoolsBaseBasicDevtoolsPluginTests", "//devtools/plugins/basic/swiftui:PlayerUIDevtoolsBasicPluginViewInspectorTests", + "//devtools/plugins/profiler/ios:PlayerUIDevtoolsBaseProfilerDevtoolsPluginTests", + "//devtools/plugins/profiler/swiftui:PlayerUIDevtoolsProfilerPluginViewInspectorTests", ], ) \ No newline at end of file diff --git a/ios/demo/BUILD b/ios/demo/BUILD index 0eadd76..5002506 100644 --- a/ios/demo/BUILD +++ b/ios/demo/BUILD @@ -20,6 +20,7 @@ swift_library( # These are the components under development. "//devtools/plugin/ios:PlayerUIDevtoolsPlugins", "//devtools/plugins/basic/swiftui:PlayerUIDevtoolsBasicPlugin", + "//devtools/plugins/profiler/swiftui:PlayerUIDevtoolsProfilerPlugin", ] ) diff --git a/ios/demo/Sources/DemoApp.swift b/ios/demo/Sources/DemoApp.swift index 868b389..52f2ac2 100644 --- a/ios/demo/Sources/DemoApp.swift +++ b/ios/demo/Sources/DemoApp.swift @@ -7,6 +7,7 @@ import SwiftFlipper import PlayerUIDevtoolsPlugins import PlayerUIReferenceAssets import PlayerUIDevtoolsBasicPlugin +import PlayerUIDevtoolsProfilerPlugin @main struct BazelApp: App { @@ -95,7 +96,8 @@ class DemoViewModel: ObservableObject { defaultPlugins = [ ReferenceAssetsPlugin(), PrintLoggerPlugin(level: .debug), - BasicDevtoolsPlugin(id: devtoolsPluginID, flipperPlugin: flipperPlugin) + BasicDevtoolsPlugin(id: devtoolsPluginID, flipperPlugin: flipperPlugin), + ProfilerDevtoolsPlugin(id: devtoolsPluginID, flipperPlugin: flipperPlugin) ] flipperClient.addPlugin(flipperPlugin) @@ -114,7 +116,8 @@ class DemoViewModel: ObservableObject { defaultPlugins = [ ReferenceAssetsPlugin(), PrintLoggerPlugin(level: .debug), - BasicDevtoolsPlugin(id: devtoolsPluginID, flipperPlugin: flipperPlugin) + BasicDevtoolsPlugin(id: devtoolsPluginID, flipperPlugin: flipperPlugin), + ProfilerDevtoolsPlugin(id: devtoolsPluginID, flipperPlugin: flipperPlugin) ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0b86a5..4e862d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -408,6 +408,30 @@ importers: specifier: workspace:* version: link:../../../types/core + devtools/plugins/profiler/core: + dependencies: + '@player-devtools/messenger': + specifier: workspace:* + version: link:../../../messenger/core + '@player-devtools/plugin': + specifier: workspace:* + version: link:../../../plugin/core + '@player-devtools/types': + specifier: workspace:* + version: link:../../../types/core + + devtools/plugins/profiler/react: + dependencies: + '@player-devtools/plugin-react': + specifier: workspace:* + version: link:../../../plugin/react + '@player-devtools/profiler-plugin': + specifier: workspace:* + version: link:../core + '@player-devtools/types': + specifier: workspace:* + version: link:../../../types/core + devtools/types/core: {} devtools/utils/ios/Resources/core: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e5403ec..c0d7e77 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,8 @@ packages: - devtools/plugin/react - devtools/plugins/basic/core - devtools/plugins/basic/react + - devtools/plugins/profiler/core + - devtools/plugins/profiler/react - devtools/messenger/core - devtools/utils/ios/Resources/core - mocks/