Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/hover/test/browser/hoverService.test.ts
5240 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as assert from 'assert';
7
import { Event } from '../../../../base/common/event.js';
8
import { toDisposable } from '../../../../base/common/lifecycle.js';
9
import { timeout } from '../../../../base/common/async.js';
10
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
11
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
12
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
13
import { IConfigurationService } from '../../../configuration/common/configuration.js';
14
import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js';
15
import { HoverService } from '../../browser/hoverService.js';
16
import { HoverWidget } from '../../browser/hoverWidget.js';
17
import { IContextMenuService } from '../../../contextview/browser/contextView.js';
18
import { IKeybindingService } from '../../../keybinding/common/keybinding.js';
19
import { ILayoutService } from '../../../layout/browser/layoutService.js';
20
import { IAccessibilityService } from '../../../accessibility/common/accessibility.js';
21
import { TestAccessibilityService } from '../../../accessibility/test/common/testAccessibilityService.js';
22
import { mainWindow } from '../../../../base/browser/window.js';
23
import { NoMatchingKb } from '../../../keybinding/common/keybindingResolver.js';
24
import { IMarkdownRendererService } from '../../../markdown/browser/markdownRenderer.js';
25
import type { IHoverWidget } from '../../../../base/browser/ui/hover/hover.js';
26
27
suite('HoverService', () => {
28
const store = ensureNoDisposablesAreLeakedInTestSuite();
29
let hoverService: HoverService;
30
let fixture: HTMLElement;
31
let instantiationService: TestInstantiationService;
32
33
setup(() => {
34
fixture = document.createElement('div');
35
mainWindow.document.body.appendChild(fixture);
36
store.add(toDisposable(() => fixture.remove()));
37
38
instantiationService = store.add(new TestInstantiationService());
39
40
const configurationService = new TestConfigurationService();
41
configurationService.setUserConfiguration('workbench.hover.delay', 0);
42
configurationService.setUserConfiguration('workbench.hover.reducedDelay', 0);
43
instantiationService.stub(IConfigurationService, configurationService);
44
45
instantiationService.stub(IContextMenuService, {
46
onDidShowContextMenu: Event.None
47
});
48
49
instantiationService.stub(IKeybindingService, {
50
mightProducePrintableCharacter() { return false; },
51
softDispatch() { return NoMatchingKb; },
52
resolveKeyboardEvent() {
53
return {
54
getLabel() { return ''; },
55
getAriaLabel() { return ''; },
56
getElectronAccelerator() { return null; },
57
getUserSettingsLabel() { return null; },
58
isWYSIWYG() { return false; },
59
hasMultipleChords() { return false; },
60
getDispatchChords() { return [null]; },
61
getSingleModifierDispatchChords() { return []; },
62
getChords() { return []; }
63
};
64
}
65
});
66
67
instantiationService.stub(ILayoutService, {
68
activeContainer: fixture,
69
mainContainer: fixture,
70
getContainer() { return fixture; },
71
onDidLayoutContainer: Event.None
72
});
73
74
instantiationService.stub(IAccessibilityService, new TestAccessibilityService());
75
76
instantiationService.stub(IMarkdownRendererService, {
77
render() { return { element: document.createElement('div'), dispose() { } }; },
78
setDefaultCodeBlockRenderer() { }
79
});
80
81
hoverService = store.add(instantiationService.createInstance(HoverService));
82
});
83
84
// #region Helper functions
85
86
function createTarget(): HTMLElement {
87
const target = document.createElement('div');
88
target.style.width = '100px';
89
target.style.height = '100px';
90
fixture.appendChild(target);
91
return target;
92
}
93
94
function showHover(content: string, target?: HTMLElement, options?: Partial<Parameters<typeof hoverService.showInstantHover>[0]>): IHoverWidget {
95
const hover = hoverService.showInstantHover({
96
content,
97
target: target ?? createTarget(),
98
...options
99
});
100
assert.ok(hover, `Hover with content "${content}" should be created`);
101
return hover;
102
}
103
104
function asHoverWidget(hover: IHoverWidget): HoverWidget {
105
return hover as HoverWidget;
106
}
107
108
/**
109
* Checks if a hover's DOM node is present in the document.
110
*/
111
function isInDOM(hover: IHoverWidget): boolean {
112
return mainWindow.document.body.contains(asHoverWidget(hover).domNode);
113
}
114
115
/**
116
* Asserts that a hover is in the DOM.
117
*/
118
function assertInDOM(hover: IHoverWidget, message?: string): void {
119
assert.ok(isInDOM(hover), message ?? 'Hover should be in the DOM');
120
}
121
122
/**
123
* Asserts that a hover is NOT in the DOM.
124
*/
125
function assertNotInDOM(hover: IHoverWidget, message?: string): void {
126
assert.ok(!isInDOM(hover), message ?? 'Hover should not be in the DOM');
127
}
128
129
/**
130
* Creates a nested hover by appending a target element inside the parent hover's DOM.
131
*/
132
function createNestedHover(parentHover: IHoverWidget, content: string): IHoverWidget {
133
const nestedTarget = document.createElement('div');
134
asHoverWidget(parentHover).domNode.appendChild(nestedTarget);
135
return showHover(content, nestedTarget);
136
}
137
138
/**
139
* Creates a chain of nested hovers up to the specified depth.
140
* Returns the array of hovers from outermost to innermost.
141
*/
142
function createHoverChain(depth: number): HoverWidget[] {
143
const hovers: HoverWidget[] = [];
144
let currentTarget: HTMLElement = createTarget();
145
146
for (let i = 0; i < depth; i++) {
147
const hover = hoverService.showInstantHover({
148
content: `Hover ${i + 1}`,
149
target: currentTarget
150
});
151
if (!hover) {
152
break;
153
}
154
hovers.push(asHoverWidget(hover));
155
currentTarget = document.createElement('div');
156
asHoverWidget(hover).domNode.appendChild(currentTarget);
157
}
158
159
return hovers;
160
}
161
162
function disposeHovers(hovers: HoverWidget[]): void {
163
for (const h of [...hovers].reverse()) {
164
h?.dispose();
165
}
166
}
167
168
// #endregion
169
170
suite('showInstantHover', () => {
171
test('should not show hover with empty content', () => {
172
const target = createTarget();
173
const hover = hoverService.showInstantHover({
174
content: '',
175
target
176
});
177
178
assert.strictEqual(hover, undefined, 'Hover should not be created for empty content');
179
});
180
181
test('should call onDidShow callback when hover is shown', () => {
182
const target = createTarget();
183
let didShowCalled = false;
184
185
const hover = hoverService.showInstantHover({
186
content: 'Test',
187
target,
188
onDidShow: () => { didShowCalled = true; }
189
});
190
191
assert.ok(didShowCalled, 'onDidShow should be called');
192
assert.ok(hover);
193
assertInDOM(hover, 'Hover should be in DOM after showing');
194
195
hover.dispose();
196
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
197
});
198
199
test('should deduplicate hovers by id', () => {
200
const target = createTarget();
201
202
const hover1 = hoverService.showInstantHover({
203
content: 'Same content',
204
target,
205
id: 'same-id'
206
});
207
208
const hover2 = hoverService.showInstantHover({
209
content: 'Same content',
210
target,
211
id: 'same-id'
212
});
213
214
assert.ok(hover1, 'First hover should be created');
215
assertInDOM(hover1, 'First hover should be in DOM');
216
assert.strictEqual(hover2, undefined, 'Second hover with same id should not be created');
217
218
// Different id should create new hover
219
const hover3 = hoverService.showInstantHover({
220
content: 'Content 3',
221
target,
222
id: 'different-id'
223
});
224
225
assert.ok(hover3, 'Hover with different id should be created');
226
assertInDOM(hover3, 'Third hover should be in DOM');
227
228
hover1?.dispose();
229
hover3?.dispose();
230
});
231
232
test('should apply additional classes to hover DOM', () => {
233
const hover = showHover('Test', undefined, {
234
additionalClasses: ['custom-class-1', 'custom-class-2']
235
});
236
237
const domNode = asHoverWidget(hover).domNode;
238
assertInDOM(hover, 'Hover should be in DOM');
239
assert.ok(domNode.classList.contains('custom-class-1'), 'Should have custom-class-1');
240
assert.ok(domNode.classList.contains('custom-class-2'), 'Should have custom-class-2');
241
242
hover.dispose();
243
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
244
});
245
});
246
247
suite('hideHover', () => {
248
test('should hide non-locked hover', () => {
249
const hover = showHover('Test');
250
assertInDOM(hover, 'Hover should be in DOM initially');
251
252
hoverService.hideHover();
253
254
assert.strictEqual(hover.isDisposed, true, 'Hover should be disposed after hideHover');
255
assertNotInDOM(hover, 'Hover should be removed from DOM after hideHover');
256
});
257
258
test('should not hide locked hover without force flag', () => {
259
const hover = showHover('Test', undefined, {
260
persistence: { sticky: true }
261
});
262
assertInDOM(hover, 'Locked hover should be in DOM');
263
264
hoverService.hideHover();
265
assert.strictEqual(hover.isDisposed, false, 'Locked hover should not be disposed without force');
266
assertInDOM(hover, 'Locked hover should remain in DOM');
267
268
hoverService.hideHover(true);
269
assert.strictEqual(hover.isDisposed, true, 'Locked hover should be disposed with force=true');
270
assertNotInDOM(hover, 'Locked hover should be removed from DOM with force');
271
});
272
});
273
274
suite('nested hovers', () => {
275
test('should keep parent hover visible when nested hover is created', () => {
276
const parentHover = showHover('Parent');
277
assertInDOM(parentHover, 'Parent hover should be in DOM');
278
279
const nestedHover = createNestedHover(parentHover, 'Nested');
280
assertInDOM(nestedHover, 'Nested hover should be in DOM');
281
assertInDOM(parentHover, 'Parent hover should still be in DOM after nested hover created');
282
283
assert.strictEqual(parentHover.isDisposed, false, 'Parent hover should remain visible');
284
assert.strictEqual(nestedHover.isDisposed, false, 'Nested hover should be visible');
285
286
nestedHover.dispose();
287
assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM after dispose');
288
assertInDOM(parentHover, 'Parent hover should remain in DOM after nested is disposed');
289
290
parentHover.dispose();
291
assertNotInDOM(parentHover, 'Parent hover should be removed from DOM after dispose');
292
});
293
294
test('should dispose nested hover when parent is disposed', () => {
295
const parentHover = showHover('Parent');
296
const nestedHover = createNestedHover(parentHover, 'Nested');
297
298
assertInDOM(parentHover, 'Parent hover should be in DOM');
299
assertInDOM(nestedHover, 'Nested hover should be in DOM');
300
301
parentHover.dispose();
302
303
assert.strictEqual(nestedHover.isDisposed, true, 'Nested hover should be disposed when parent is disposed');
304
assertNotInDOM(parentHover, 'Parent hover should be removed from DOM');
305
assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM when parent is disposed');
306
});
307
308
test('should dispose entire hover chain when root is disposed', () => {
309
const hovers = createHoverChain(3);
310
assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');
311
312
// Verify all hovers are in DOM
313
for (let i = 0; i < hovers.length; i++) {
314
assert.ok(mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be in DOM`);
315
}
316
317
// Dispose the root hover
318
hovers[0].dispose();
319
320
// All hovers in the chain should be disposed and removed from DOM
321
for (let i = 0; i < hovers.length; i++) {
322
assert.strictEqual(hovers[i].isDisposed, true, `Hover ${i + 1} should be disposed`);
323
assert.ok(!mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be removed from DOM`);
324
}
325
});
326
327
test('should dispose only nested hovers when middle hover is disposed', () => {
328
const hovers = createHoverChain(3);
329
assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');
330
331
// Verify all hovers are in DOM
332
for (const h of hovers) {
333
assert.ok(mainWindow.document.body.contains(h.domNode), 'All hovers should be in DOM initially');
334
}
335
336
// Dispose the middle hover
337
hovers[1].dispose();
338
339
assert.strictEqual(hovers[0].isDisposed, false, 'Root hover should remain');
340
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Root hover should remain in DOM');
341
342
assert.strictEqual(hovers[1].isDisposed, true, 'Middle hover should be disposed');
343
assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Middle hover should be removed from DOM');
344
345
assert.strictEqual(hovers[2].isDisposed, true, 'Innermost hover should be disposed');
346
assert.ok(!mainWindow.document.body.contains(hovers[2].domNode), 'Innermost hover should be removed from DOM');
347
348
hovers[0].dispose();
349
});
350
351
test('should enforce maximum nesting depth', () => {
352
// Create hovers up to the max depth (3)
353
const hovers = createHoverChain(3);
354
assert.strictEqual(hovers.length, 3, 'Should create exactly 3 hovers (max depth)');
355
356
// Verify all 3 hovers are in DOM
357
for (const h of hovers) {
358
assert.ok(mainWindow.document.body.contains(h.domNode), 'Hover should be in DOM');
359
}
360
361
// Try to create a 4th nested hover - should fail
362
const nestedTarget = document.createElement('div');
363
hovers[2].domNode.appendChild(nestedTarget);
364
const fourthHover = hoverService.showInstantHover({
365
content: 'Hover 4',
366
target: nestedTarget
367
});
368
369
assert.strictEqual(fourthHover, undefined, 'Fourth hover should not be created due to max nesting depth');
370
371
disposeHovers(hovers);
372
});
373
374
test('should allow new hover chain after disposing previous chain', () => {
375
// Create and dispose a chain
376
const firstChain = createHoverChain(3);
377
for (const h of firstChain) {
378
assert.ok(mainWindow.document.body.contains(h.domNode), 'First chain hover should be in DOM');
379
}
380
disposeHovers(firstChain);
381
for (const h of firstChain) {
382
assert.ok(!mainWindow.document.body.contains(h.domNode), 'First chain hover should be removed from DOM');
383
}
384
385
// Should be able to create a new chain
386
const secondChain = createHoverChain(3);
387
assert.strictEqual(secondChain.length, 3, 'Should create new chain after disposing previous');
388
for (const h of secondChain) {
389
assert.ok(mainWindow.document.body.contains(h.domNode), 'Second chain hover should be in DOM');
390
}
391
392
disposeHovers(secondChain);
393
});
394
395
test('hideHover should close innermost hover first', () => {
396
const hovers = createHoverChain(2);
397
398
// Verify both are in DOM
399
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be in DOM');
400
assert.ok(mainWindow.document.body.contains(hovers[1].domNode), 'Inner hover should be in DOM');
401
402
hoverService.hideHover();
403
404
// Innermost hover should be disposed and removed from DOM
405
assert.strictEqual(hovers[1].isDisposed, true, 'Innermost hover should be disposed');
406
assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Innermost hover should be removed from DOM');
407
assert.strictEqual(hovers[0].isDisposed, false, 'Outer hover should remain');
408
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should remain in DOM');
409
410
hoverService.hideHover();
411
412
assert.strictEqual(hovers[0].isDisposed, true, 'Outer hover should be disposed on second call');
413
assert.ok(!mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be removed from DOM');
414
});
415
});
416
417
suite('setupDelayedHover', () => {
418
test('should evaluate function options on mouseover', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
419
const target = createTarget();
420
let callCount = 0;
421
422
const disposable = hoverService.setupDelayedHover(target, () => {
423
callCount++;
424
return { content: `Call ${callCount}` };
425
});
426
427
// First mouseover
428
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
429
assert.strictEqual(callCount, 1, 'Options function should be called on first mouseover');
430
431
await timeout(0);
432
hoverService.hideHover(true);
433
434
// Second mouseover should call function again
435
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
436
assert.strictEqual(callCount, 2, 'Options function should be called on second mouseover');
437
438
await timeout(0);
439
disposable.dispose();
440
hoverService.hideHover(true);
441
}));
442
443
test('should use reduced delay when reducedDelay is true', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
444
const target = createTarget();
445
446
// Configure reducedDelay to 150ms for this test
447
(instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('workbench.hover.reducedDelay', 150);
448
449
const disposable = hoverService.setupDelayedHover(target, { content: 'Reduced delay' }, { reducedDelay: true });
450
451
// Trigger mouseover
452
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
453
454
// Hover should not be visible before delay
455
await timeout(75);
456
const hoversBefore = mainWindow.document.querySelectorAll('.monaco-hover');
457
assert.strictEqual(hoversBefore.length, 0, 'Hover should not be visible before delay completes');
458
459
// Hover should be visible after delay
460
await timeout(150);
461
const hoversAfter = mainWindow.document.querySelectorAll('.monaco-hover');
462
assert.strictEqual(hoversAfter.length, 1, 'Hover should be visible after reduced delay');
463
464
disposable.dispose();
465
hoverService.hideHover(true);
466
}));
467
});
468
469
suite('setupManagedHover', () => {
470
test('should use native title attribute when showNativeHover is true', () => {
471
const target = createTarget();
472
const hover = hoverService.setupManagedHover(
473
{ showHover: () => undefined, delay: 0, showNativeHover: true },
474
target,
475
'Native hover content'
476
);
477
478
assert.strictEqual(target.getAttribute('title'), 'Native hover content');
479
480
hover.dispose();
481
482
assert.strictEqual(target.getAttribute('title'), null, 'Title should be removed on dispose');
483
});
484
485
test('should update content dynamically', async () => {
486
const target = createTarget();
487
const hover = hoverService.setupManagedHover(
488
{ showHover: () => undefined, delay: 0, showNativeHover: true },
489
target,
490
'Initial'
491
);
492
493
assert.strictEqual(target.getAttribute('title'), 'Initial');
494
495
await hover.update('Updated');
496
assert.strictEqual(target.getAttribute('title'), 'Updated');
497
498
await hover.update('Final');
499
assert.strictEqual(target.getAttribute('title'), 'Final');
500
501
hover.dispose();
502
});
503
});
504
505
suite('showDelayedHover', () => {
506
test('should reject hover when current hover is locked and target is outside', () => {
507
const lockedHover = showHover('Locked', undefined, {
508
persistence: { sticky: true }
509
});
510
assertInDOM(lockedHover, 'Locked hover should be in DOM');
511
512
const otherTarget = createTarget();
513
const rejectedHover = hoverService.showDelayedHover({
514
content: 'Should not show',
515
target: otherTarget
516
}, {});
517
518
assert.strictEqual(rejectedHover, undefined, 'Should reject hover when locked hover exists');
519
assertInDOM(lockedHover, 'Locked hover should remain in DOM after rejection');
520
521
lockedHover.dispose();
522
assertNotInDOM(lockedHover, 'Locked hover should be removed from DOM after dispose');
523
});
524
525
test('should use reduced delay when reducedDelay is true', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
526
const target = createTarget();
527
const reducedDelay = 100;
528
529
// Configure reducedDelay setting for this test
530
(instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('workbench.hover.reducedDelay', reducedDelay);
531
532
const hover = hoverService.showDelayedHover({
533
content: 'Reduced delay hover',
534
target
535
}, { reducedDelay: true });
536
537
assert.ok(hover, 'Hover should be created');
538
assertNotInDOM(hover, 'Hover should not be visible immediately');
539
540
// Wait less than reduced delay - hover should still not be visible
541
await timeout(reducedDelay / 2);
542
assertNotInDOM(hover, 'Hover should not be visible before delay completes');
543
544
// Wait for full delay - hover should now be visible
545
await timeout(reducedDelay);
546
assertInDOM(hover, 'Hover should be visible after reduced delay');
547
548
hover.dispose();
549
}));
550
551
test('should use default delay when custom delay is undefined', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
552
const target = createTarget();
553
// Default delay is set to 0 in test setup
554
const hover = hoverService.showDelayedHover({
555
content: 'Default delay hover',
556
target
557
}, {});
558
559
assert.ok(hover, 'Hover should be created');
560
561
// Since default delay is 0 in tests, hover should appear after minimal timeout
562
await timeout(0);
563
assertInDOM(hover, 'Hover should be visible with default delay');
564
565
hover.dispose();
566
}));
567
});
568
569
suite('hover locking', () => {
570
test('isLocked should be settable on hover widget', () => {
571
const hover = showHover('Test');
572
const widget = asHoverWidget(hover);
573
assertInDOM(hover, 'Hover should be in DOM');
574
575
assert.strictEqual(widget.isLocked, false, 'Should not be locked initially');
576
577
widget.isLocked = true;
578
assert.strictEqual(widget.isLocked, true, 'Should be locked after setting');
579
assertInDOM(hover, 'Hover should remain in DOM after locking');
580
581
widget.isLocked = false;
582
assert.strictEqual(widget.isLocked, false, 'Should be unlocked after unsetting');
583
584
hover.dispose();
585
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
586
});
587
588
test('sticky option should set isLocked to true', () => {
589
const hover = showHover('Test', undefined, {
590
persistence: { sticky: true }
591
});
592
assertInDOM(hover, 'Sticky hover should be in DOM');
593
594
assert.strictEqual(asHoverWidget(hover).isLocked, true, 'Should be locked when sticky');
595
596
hover.dispose();
597
assertNotInDOM(hover, 'Sticky hover should be removed from DOM after dispose');
598
});
599
});
600
601
suite('showAndFocusLastHover', () => {
602
test('should recreate last disposed hover', () => {
603
const target = createTarget();
604
const hover = hoverService.showInstantHover({
605
content: 'Remember me',
606
target
607
});
608
assert.ok(hover);
609
assertInDOM(hover, 'Initial hover should be in DOM');
610
611
hover.dispose();
612
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
613
614
// Should recreate the hover - verify a new hover is shown
615
hoverService.showAndFocusLastHover();
616
617
// Verify there is a hover in the DOM (it's a new hover instance)
618
const hoverElements = mainWindow.document.querySelectorAll('.monaco-hover');
619
assert.ok(hoverElements.length > 0, 'A hover should be recreated and in the DOM');
620
621
// Clean up
622
hoverService.hideHover(true);
623
624
// Verify cleanup
625
const remainingHovers = mainWindow.document.querySelectorAll('.monaco-hover');
626
assert.strictEqual(remainingHovers.length, 0, 'No hovers should remain in DOM after cleanup');
627
});
628
});
629
});
630
631