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 });