Skip to content

Commit 75e7f78

Browse files
committed
chore: Prevent accumulation of mousedown listener in search widget
1 parent a234436 commit 75e7f78

File tree

2 files changed

+284
-54
lines changed

2 files changed

+284
-54
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
const fs = require("fs");
2+
const path = require("path");
3+
const vm = require("vm");
4+
5+
describe("Search Widget Listener Fix", () => {
6+
let activity;
7+
let Activity;
8+
let sandbox;
9+
10+
beforeAll(() => {
11+
const activityPath = path.resolve(__dirname, "../activity.js");
12+
let code = fs.readFileSync(activityPath, "utf8");
13+
14+
// Strip the trailing instantiation
15+
const splitPoint = code.indexOf("const activity = new Activity();");
16+
if (splitPoint !== -1) {
17+
code = code.substring(0, splitPoint);
18+
}
19+
20+
const elementCache = {};
21+
const jQueryMock = jest.fn(() => ({
22+
data: jest.fn(() => false),
23+
autocomplete: jest.fn(),
24+
on: jest.fn(),
25+
off: jest.fn()
26+
}));
27+
jQueryMock.fn = jQueryMock;
28+
29+
sandbox = {
30+
window: global.window,
31+
jQuery: jQueryMock,
32+
$: jQueryMock,
33+
document: {
34+
getElementById: jest.fn(id => {
35+
if (elementCache[id]) return elementCache[id];
36+
37+
let el;
38+
if (id === "search") {
39+
el = {
40+
style: { visibility: "hidden" },
41+
contains: jest.fn(target => target === el),
42+
focus: jest.fn(),
43+
addEventListener: jest.fn(),
44+
value: ""
45+
};
46+
} else if (id === "ui-id-1") {
47+
el = {
48+
style: { display: "none" },
49+
contains: jest.fn(target => target === el),
50+
addEventListener: jest.fn()
51+
};
52+
} else if (id === "myCanvas") {
53+
el = { addEventListener: jest.fn() };
54+
} else {
55+
el = {
56+
style: { visibility: "hidden", display: "none" },
57+
addEventListener: jest.fn(),
58+
appendChild: jest.fn(),
59+
contains: jest.fn(target => target === el)
60+
};
61+
}
62+
elementCache[id] = el;
63+
return el;
64+
}),
65+
getElementsByTagName: jest.fn(name => {
66+
if (name === "tr") {
67+
const tr = { contains: jest.fn(() => false) };
68+
return [{}, {}, tr];
69+
}
70+
return [];
71+
}),
72+
addEventListener: jest.fn(),
73+
removeEventListener: jest.fn(),
74+
body: { appendChild: jest.fn() },
75+
querySelector: jest.fn(() => null)
76+
},
77+
console: { log: jest.fn(), debug: jest.fn(), error: jest.fn() },
78+
_: key => key,
79+
define: jest.fn(),
80+
require: jest.fn(),
81+
setTimeout: jest.fn(cb => cb()),
82+
setInterval: jest.fn(),
83+
requestAnimationFrame: jest.fn(),
84+
cancelAnimationFrame: jest.fn(),
85+
createjs: {
86+
Stage: class { on() { } removeAllEventListeners() { } addChild() { } getBounds() { return { x: 0, y: 0, width: 0, height: 0 }; } },
87+
Container: class { addChild() { } on() { } cache() { } getBounds() { return { x: 0, y: 0, width: 0, height: 0 }; } },
88+
Text: class { },
89+
Bitmap: class { },
90+
Ticker: { framerate: 60 }
91+
},
92+
Image: class { set src(val) { if (this.onload) this.onload(); } },
93+
docByClass: jest.fn(() => []),
94+
docById: jest.fn(id => sandbox.document.getElementById(id)),
95+
base64Encode: jest.fn(),
96+
MSGBLOCK: "",
97+
ERRORARTWORK: [],
98+
LEADING: 0,
99+
MYDEFINES: [],
100+
_THIS_IS_MUSIC_BLOCKS_: true,
101+
102+
Turtles: class { constructor() { this.running = () => false; } },
103+
Palettes: class { constructor() { this.getSearchPos = () => [0, 0]; } },
104+
Blocks: class { constructor() { this.protoBlockDict = {}; } },
105+
Logo: class { },
106+
LanguageBox: class { },
107+
ThemeBox: class { updateThemeIcon() { } },
108+
SaveInterface: class { },
109+
StatsWindow: class { },
110+
Trashcan: class { },
111+
PasteBox: class { },
112+
HelpWidget: class { },
113+
};
114+
115+
code = "const localStorage = this.localStorage;\n" +
116+
"const jQuery = this.jQuery;\n" +
117+
"const $ = this.$;\n" + code;
118+
code += "\n this.Activity = Activity;";
119+
120+
vm.createContext(sandbox);
121+
vm.runInContext(code, sandbox);
122+
Activity = sandbox.Activity;
123+
});
124+
125+
beforeEach(() => {
126+
// Suppress constructor noise
127+
Activity.prototype.prepSearchWidget = jest.fn();
128+
Activity.prototype._create2Ddrag = jest.fn();
129+
Activity.prototype._createDrag = jest.fn();
130+
Activity.prototype._createGrid = jest.fn();
131+
Activity.prototype._createMsgContainer = jest.fn();
132+
Activity.prototype._createErrorContainers = jest.fn();
133+
Activity.prototype._initIdleWatcher = jest.fn();
134+
135+
activity = new Activity();
136+
activity.turtleBlocksScale = 1;
137+
138+
// Fresh mocks for each instance
139+
activity.addEventListener = jest.fn(function (target, type, listener) {
140+
if (target && target.addEventListener) {
141+
target.addEventListener(type, listener);
142+
}
143+
if (!this._listeners) this._listeners = [];
144+
this._listeners.push({ target, type, listener });
145+
});
146+
activity.removeEventListener = jest.fn(function (target, type, listener) {
147+
if (target && target.removeEventListener) {
148+
target.removeEventListener(type, listener);
149+
}
150+
});
151+
152+
// Re-inject mocks that might be overwritten or needed
153+
activity.searchWidget = sandbox.document.getElementById("search");
154+
activity.palettes = new sandbox.Palettes();
155+
activity.doSearch = jest.fn();
156+
157+
sandbox.document.addEventListener.mockClear();
158+
sandbox.document.removeEventListener.mockClear();
159+
});
160+
161+
test("showSearchWidget should add a mousedown listener", () => {
162+
try {
163+
activity.showSearchWidget();
164+
} catch (e) {
165+
console.error("showSearchWidget crash:", e);
166+
throw e;
167+
}
168+
169+
const addCalls = activity.addEventListener.mock.calls.filter(c => c[1] === "mousedown");
170+
expect(addCalls.length).toBe(1);
171+
});
172+
173+
test("showSearchWidget should NOT accumulate listeners when toggled", () => {
174+
activity.showSearchWidget(); // Toggles ON
175+
expect(activity.addEventListener.mock.calls.filter(c => c[1] === "mousedown").length).toBe(1);
176+
177+
activity.showSearchWidget(); // Toggles OFF
178+
179+
expect(activity.removeEventListener.mock.calls.filter(c => c[1] === "mousedown").length).toBe(1);
180+
// addEventListener should NOT have been called again during toggle OFF
181+
expect(activity.addEventListener.mock.calls.filter(c => c[1] === "mousedown").length).toBe(1);
182+
});
183+
184+
test("hideSearchWidget should remove the listener via wrapper", () => {
185+
activity.showSearchWidget();
186+
187+
// Capture the listener added
188+
const listener = activity.addEventListener.mock.calls.find(c => c[1] === "mousedown")[2];
189+
190+
activity.hideSearchWidget();
191+
192+
expect(activity.removeEventListener).toHaveBeenCalledWith(sandbox.document, "mousedown", listener);
193+
});
194+
195+
test("clicking inside search should NOT remove listener", () => {
196+
activity.showSearchWidget();
197+
const listener = activity.addEventListener.mock.calls.find(c => c[1] === "mousedown")[2];
198+
199+
const searchElem = activity.searchWidget;
200+
searchElem.style.visibility = "visible";
201+
searchElem.contains.mockReturnValue(true);
202+
203+
listener({ target: searchElem });
204+
205+
// Verify removeEventListener was NOT called for this listener
206+
const removeCalls = activity.removeEventListener.mock.calls.filter(c => c[2] === listener);
207+
expect(removeCalls.length).toBe(0);
208+
});
209+
210+
test("clicking outside search SHOULD remove listener and hide widget", () => {
211+
activity.showSearchWidget();
212+
const listener = activity.addEventListener.mock.calls.find(c => c[1] === "mousedown")[2];
213+
const hideSpy = jest.spyOn(activity, "hideSearchWidget");
214+
215+
// Clicked outside
216+
listener({ target: {} });
217+
218+
expect(hideSpy).toHaveBeenCalled();
219+
expect(activity.removeEventListener).toHaveBeenCalledWith(sandbox.document, "mousedown", listener);
220+
hideSpy.mockRestore();
221+
});
222+
});

0 commit comments

Comments
 (0)