Recently Written · git

bd-plugin-profiler

BetterDiscord plugin that measures the main-thread time of each plugin

git clone https://github.com/equwal/bd-plugin-profiler

Log | Files | Refs


test/hooks.test.js (8216 bytes)

1 "use strict";
2 const test = require("node:test");
3 const assert = require("node:assert/strict");
4 const {loadBdfdbSuppress} = require("./helpers/installed.js");
5 const {
6     Recorder, pluginFilesOnStack, ownerFile, hookTimers, hookObservers, hookInstance, hookSuppress, callerNames, ORIGINAL
7 } = require("../00PluginProfiler.plugin.js").internals;
8 
9 const OWN_FILE = "00PluginProfiler.plugin.js";
10 
11 /** Makes a function whose code has the source URL that BetterDiscord gives to a plugin file. */
12 function inPluginFile(url, param, body) {
13     return new Function(param, `${body}\n//# sourceURL=${url}`);
14 }
15 
16 function setup() {
17     const clock = {t: 0};
18     const recorder = new Recorder(() => clock.t);
19     const entry = name => recorder.owners.get(name);
20     return {clock, recorder, entry, undo: []};
21 }
22 
23 test("the stack gives the plugin file, and a library gives way to the plugin that called it", () => {
24     const library = inPluginFile("betterdiscord://betterdiscord/plugins/0BDFDB.plugin.js", "callback", "return function libraryHelper() { return callback(); };")(pluginFilesOnStack);
25     const plugin = inPluginFile("betterdiscord://betterdiscord/plugins/FriendNotifications.plugin.js", "library", "return function pluginWork() { return library(); };")(library);
26     const files = plugin();
27     assert.deepEqual(files, ["0BDFDB.plugin.js", "FriendNotifications.plugin.js"]);
28     assert.equal(ownerFile(files, OWN_FILE), "FriendNotifications.plugin.js");
29     assert.equal(ownerFile(["0BDFDB.plugin.js"], OWN_FILE), "0BDFDB.plugin.js");
30     assert.equal(ownerFile([OWN_FILE, OWN_FILE], OWN_FILE), null);
31     assert.equal(ownerFile([], OWN_FILE), null);
32 });
33 
34 test("old BetterDiscord source URLs also match", () => {
35     const plugin = inPluginFile("betterdiscord://plugins/Old.plugin.js", "capture", "return function oldWork() { return capture(); };")(pluginFilesOnStack);
36     assert.deepEqual(plugin(), ["Old.plugin.js"]);
37 });
38 
39 test("the stack capture puts back Error.prepareStackTrace and Error.stackTraceLimit", () => {
40     const prepare = () => "custom";
41     const savedPrepare = Error.prepareStackTrace;
42     const savedLimit = Error.stackTraceLimit;
43     try {
44         Error.prepareStackTrace = prepare;
45         Error.stackTraceLimit = 7;
46         pluginFilesOnStack();
47         assert.equal(Error.prepareStackTrace, prepare);
48         assert.equal(Error.stackTraceLimit, 7);
49         assert.equal(new Error("x").stack, "custom");
50     }
51     finally {
52         Error.prepareStackTrace = savedPrepare;
53         Error.stackTraceLimit = savedLimit;
54     }
55 });
56 
57 test("timer callbacks count for the plugin that made the timer", () => {
58     const {clock, recorder, entry, undo} = setup();
59     const calls = [];
60     const setTimeout = (handler, ms, ...args) => { calls.push({handler, ms, args}); return 7; };
61     const setInterval = (handler, ms) => { calls.push({handler, ms}); return 8; };
62     const scope = {setTimeout, setInterval};
63     let owner = "FriendNotifications";
64     hookTimers(scope, recorder, () => owner, undo);
65 
66     assert.equal(scope.setTimeout(() => { clock.t += 4; return "done"; }, 50, "x"), 7);
67     assert.deepEqual([calls[0].ms, calls[0].args], [50, ["x"]]);
68     assert.equal(calls[0].handler(), "done");
69     assert.equal(entry("FriendNotifications").ms, 4);
70     assert.equal(entry("FriendNotifications").kinds.get("timer"), 4);
71     assert.ok(entry("PluginProfiler").kinds.has("stack capture"));
72 
73     // A string handler and a handler that no plugin owns pass through unchanged.
74     scope.setInterval("code()", 5);
75     assert.equal(calls[1].handler, "code()");
76     owner = null;
77     const discordHandler = () => {};
78     scope.setTimeout(discordHandler, 1);
79     assert.equal(calls[2].handler, discordHandler);
80 
81     while (undo.length) undo.pop()();
82     assert.equal(scope.setTimeout, setTimeout);
83     assert.equal(scope.setInterval, setInterval);
84 });
85 
86 test("observer callbacks count for the plugin that made the observer", () => {
87     const {clock, recorder, entry, undo} = setup();
88     class FakeObserver {
89         constructor(callback, options) { this.callback = callback; this.options = options; }
90     }
91     const scope = {MutationObserver: FakeObserver, ResizeObserver: FakeObserver};
92     hookObservers(scope, recorder, () => "SpellCheck", undo);
93     const observer = new scope.MutationObserver(() => { clock.t += 6; }, {subtree: true});
94     assert.ok(observer instanceof FakeObserver);
95     assert.equal(scope.MutationObserver.name, "MutationObserver");
96     assert.deepEqual(observer.options, {subtree: true});
97     observer.callback();
98     assert.equal(entry("SpellCheck").kinds.get("mutation observer"), 6);
99     while (undo.length) undo.pop()();
100     assert.equal(scope.MutationObserver, FakeObserver);
101     assert.equal(scope.ResizeObserver, FakeObserver);
102 });
103 
104 test("plugin methods that BetterDiscord and BDFDB call by name count for the plugin", () => {
105     const {clock, recorder, entry, undo} = setup();
106     class Base {
107         processMessageToolbar(e) { clock.t += 2; return e + 1; }
108         start() {}
109     }
110     class Plugin extends Base {
111         observer() { clock.t += 3; }
112     }
113     const instance = new Plugin();
114     const ownSwitch = function () { clock.t += 1; return this; };
115     instance.onSwitch = ownSwitch;
116     hookInstance(recorder, instance, "SpellCheck", undo);
117     const undoCount = undo.length;
118     hookInstance(recorder, instance, "SpellCheck", undo);
119     assert.equal(undo.length, undoCount, "a second scan adds no hooks");
120 
121     assert.equal(instance.processMessageToolbar(1), 2);
122     instance.observer();
123     assert.equal(instance.onSwitch(), instance);
124     assert.equal(instance.start, Base.prototype.start);
125     const kinds = Object.fromEntries(entry("SpellCheck").kinds);
126     assert.deepEqual(kinds, {"BDFDB process": 2, observer: 3, onSwitch: 1});
127 
128     while (undo.length) undo.pop()();
129     assert.equal(Object.hasOwn(instance, "processMessageToolbar"), false);
130     assert.equal(Object.hasOwn(instance, "observer"), false);
131     assert.equal(instance.onSwitch, ownSwitch);
132 });
133 
134 test("BDFDB callbacks count for each plugin, and the original Discord call counts for no plugin", () => {
135     const {clock, recorder, entry, undo} = setup();
136     const logged = [];
137     const originalSuppress = loadBdfdbSuppress({error: (...args) => logged.push(args)});
138     const bdfdb = {TimeUtils: {suppress: originalSuppress}};
139     const names = {spellcheck: "SpellCheck"};
140     assert.equal(hookSuppress(recorder, bdfdb, name => names[name.toLowerCase()] || name, undo), true);
141     assert.equal(hookSuppress(recorder, bdfdb, name => name, undo), false, "no second hook");
142 
143     // The same loop shape as BDFDB.PatchUtils.patch: one BetterDiscord patch calls all plugin callbacks.
144     const callbacks = [
145         Object.assign(() => { clock.t += 9; }, {pluginName: "spellcheck"}),
146         Object.assign(() => { clock.t += 4; throw new Error("plugin bug"); }, {pluginName: "Translator"})
147     ];
148     const dispatcher = () => {
149         clock.t += 1;
150         for (const callback of callbacks) bdfdb.TimeUtils.suppress(callback, "after callback", {name: callback.pluginName})({});
151         bdfdb.TimeUtils.suppress(() => { clock.t += 50; }, "originalMethod", {name: "Discord"})();
152     };
153     recorder.run("BDFDB", "patch after", dispatcher, null, []);
154     assert.equal(entry("SpellCheck").ms, 9);
155     assert.equal(entry("Translator").ms, 4);
156     assert.equal(entry("BDFDB").ms, 1);
157     assert.equal(recorder.unattributedMs, 50);
158     assert.equal(logged.length, 1, "BDFDB still logs the plugin error");
159 
160     const plain = bdfdb.TimeUtils.suppress(() => 3, "no config");
161     assert.equal(plain[ORIGINAL], undefined);
162     assert.equal(plain(), 3);
163     while (undo.length) undo.pop()();
164     assert.equal(bdfdb.TimeUtils.suppress, originalSuppress);
165 });
166 
167 test("caller names cover the forms that plugins use", () => {
168     const names = callerNames({
169         name: "Hide Channels", id: "Hide Channels", filename: "HideChannels.plugin.js",
170         instance: {getName: () => "HideChannelsPlugin"}
171     });
172     assert.deepEqual(names.sort(), ["Hide Channels", "HideChannels", "HideChannelsPlugin"]);
173     assert.deepEqual(callerNames({name: "X", instance: {getName() { throw new Error("broken"); }}}), ["X"]);
174 });