Path: blob/main/src/vs/platform/hover/test/browser/hoverService.test.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as assert from 'assert';6import { Event } from '../../../../base/common/event.js';7import { toDisposable } from '../../../../base/common/lifecycle.js';8import { timeout } from '../../../../base/common/async.js';9import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';10import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';11import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';12import { IConfigurationService } from '../../../configuration/common/configuration.js';13import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js';14import { HoverService } from '../../browser/hoverService.js';15import { HoverWidget } from '../../browser/hoverWidget.js';16import { IContextMenuService } from '../../../contextview/browser/contextView.js';17import { IKeybindingService } from '../../../keybinding/common/keybinding.js';18import { ILayoutService } from '../../../layout/browser/layoutService.js';19import { IAccessibilityService } from '../../../accessibility/common/accessibility.js';20import { TestAccessibilityService } from '../../../accessibility/test/common/testAccessibilityService.js';21import { mainWindow } from '../../../../base/browser/window.js';22import { NoMatchingKb } from '../../../keybinding/common/keybindingResolver.js';23import { IMarkdownRendererService } from '../../../markdown/browser/markdownRenderer.js';24import type { IHoverWidget } from '../../../../base/browser/ui/hover/hover.js';2526suite('HoverService', () => {27const store = ensureNoDisposablesAreLeakedInTestSuite();28let hoverService: HoverService;29let fixture: HTMLElement;30let instantiationService: TestInstantiationService;3132setup(() => {33fixture = document.createElement('div');34mainWindow.document.body.appendChild(fixture);35store.add(toDisposable(() => fixture.remove()));3637instantiationService = store.add(new TestInstantiationService());3839const configurationService = new TestConfigurationService();40configurationService.setUserConfiguration('workbench.hover.delay', 0);41configurationService.setUserConfiguration('workbench.hover.reducedDelay', 0);42instantiationService.stub(IConfigurationService, configurationService);4344instantiationService.stub(IContextMenuService, {45onDidShowContextMenu: Event.None46});4748instantiationService.stub(IKeybindingService, {49mightProducePrintableCharacter() { return false; },50softDispatch() { return NoMatchingKb; },51resolveKeyboardEvent() {52return {53getLabel() { return ''; },54getAriaLabel() { return ''; },55getElectronAccelerator() { return null; },56getUserSettingsLabel() { return null; },57isWYSIWYG() { return false; },58hasMultipleChords() { return false; },59getDispatchChords() { return [null]; },60getSingleModifierDispatchChords() { return []; },61getChords() { return []; }62};63}64});6566instantiationService.stub(ILayoutService, {67activeContainer: fixture,68mainContainer: fixture,69getContainer() { return fixture; },70onDidLayoutContainer: Event.None71});7273instantiationService.stub(IAccessibilityService, new TestAccessibilityService());7475instantiationService.stub(IMarkdownRendererService, {76render() { return { element: document.createElement('div'), dispose() { } }; },77setDefaultCodeBlockRenderer() { }78});7980hoverService = store.add(instantiationService.createInstance(HoverService));81});8283// #region Helper functions8485function createTarget(): HTMLElement {86const target = document.createElement('div');87target.style.width = '100px';88target.style.height = '100px';89fixture.appendChild(target);90return target;91}9293function showHover(content: string, target?: HTMLElement, options?: Partial<Parameters<typeof hoverService.showInstantHover>[0]>): IHoverWidget {94const hover = hoverService.showInstantHover({95content,96target: target ?? createTarget(),97...options98});99assert.ok(hover, `Hover with content "${content}" should be created`);100return hover;101}102103function asHoverWidget(hover: IHoverWidget): HoverWidget {104return hover as HoverWidget;105}106107/**108* Checks if a hover's DOM node is present in the document.109*/110function isInDOM(hover: IHoverWidget): boolean {111return mainWindow.document.body.contains(asHoverWidget(hover).domNode);112}113114/**115* Asserts that a hover is in the DOM.116*/117function assertInDOM(hover: IHoverWidget, message?: string): void {118assert.ok(isInDOM(hover), message ?? 'Hover should be in the DOM');119}120121/**122* Asserts that a hover is NOT in the DOM.123*/124function assertNotInDOM(hover: IHoverWidget, message?: string): void {125assert.ok(!isInDOM(hover), message ?? 'Hover should not be in the DOM');126}127128/**129* Creates a nested hover by appending a target element inside the parent hover's DOM.130*/131function createNestedHover(parentHover: IHoverWidget, content: string): IHoverWidget {132const nestedTarget = document.createElement('div');133asHoverWidget(parentHover).domNode.appendChild(nestedTarget);134return showHover(content, nestedTarget);135}136137/**138* Creates a chain of nested hovers up to the specified depth.139* Returns the array of hovers from outermost to innermost.140*/141function createHoverChain(depth: number): HoverWidget[] {142const hovers: HoverWidget[] = [];143let currentTarget: HTMLElement = createTarget();144145for (let i = 0; i < depth; i++) {146const hover = hoverService.showInstantHover({147content: `Hover ${i + 1}`,148target: currentTarget149});150if (!hover) {151break;152}153hovers.push(asHoverWidget(hover));154currentTarget = document.createElement('div');155asHoverWidget(hover).domNode.appendChild(currentTarget);156}157158return hovers;159}160161function disposeHovers(hovers: HoverWidget[]): void {162for (const h of [...hovers].reverse()) {163h?.dispose();164}165}166167// #endregion168169suite('showInstantHover', () => {170test('should not show hover with empty content', () => {171const target = createTarget();172const hover = hoverService.showInstantHover({173content: '',174target175});176177assert.strictEqual(hover, undefined, 'Hover should not be created for empty content');178});179180test('should call onDidShow callback when hover is shown', () => {181const target = createTarget();182let didShowCalled = false;183184const hover = hoverService.showInstantHover({185content: 'Test',186target,187onDidShow: () => { didShowCalled = true; }188});189190assert.ok(didShowCalled, 'onDidShow should be called');191assert.ok(hover);192assertInDOM(hover, 'Hover should be in DOM after showing');193194hover.dispose();195assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');196});197198test('should deduplicate hovers by id', () => {199const target = createTarget();200201const hover1 = hoverService.showInstantHover({202content: 'Same content',203target,204id: 'same-id'205});206207const hover2 = hoverService.showInstantHover({208content: 'Same content',209target,210id: 'same-id'211});212213assert.ok(hover1, 'First hover should be created');214assertInDOM(hover1, 'First hover should be in DOM');215assert.strictEqual(hover2, undefined, 'Second hover with same id should not be created');216217// Different id should create new hover218const hover3 = hoverService.showInstantHover({219content: 'Content 3',220target,221id: 'different-id'222});223224assert.ok(hover3, 'Hover with different id should be created');225assertInDOM(hover3, 'Third hover should be in DOM');226227hover1?.dispose();228hover3?.dispose();229});230231test('should apply additional classes to hover DOM', () => {232const hover = showHover('Test', undefined, {233additionalClasses: ['custom-class-1', 'custom-class-2']234});235236const domNode = asHoverWidget(hover).domNode;237assertInDOM(hover, 'Hover should be in DOM');238assert.ok(domNode.classList.contains('custom-class-1'), 'Should have custom-class-1');239assert.ok(domNode.classList.contains('custom-class-2'), 'Should have custom-class-2');240241hover.dispose();242assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');243});244});245246suite('hideHover', () => {247test('should hide non-locked hover', () => {248const hover = showHover('Test');249assertInDOM(hover, 'Hover should be in DOM initially');250251hoverService.hideHover();252253assert.strictEqual(hover.isDisposed, true, 'Hover should be disposed after hideHover');254assertNotInDOM(hover, 'Hover should be removed from DOM after hideHover');255});256257test('should not hide locked hover without force flag', () => {258const hover = showHover('Test', undefined, {259persistence: { sticky: true }260});261assertInDOM(hover, 'Locked hover should be in DOM');262263hoverService.hideHover();264assert.strictEqual(hover.isDisposed, false, 'Locked hover should not be disposed without force');265assertInDOM(hover, 'Locked hover should remain in DOM');266267hoverService.hideHover(true);268assert.strictEqual(hover.isDisposed, true, 'Locked hover should be disposed with force=true');269assertNotInDOM(hover, 'Locked hover should be removed from DOM with force');270});271});272273suite('nested hovers', () => {274test('should keep parent hover visible when nested hover is created', () => {275const parentHover = showHover('Parent');276assertInDOM(parentHover, 'Parent hover should be in DOM');277278const nestedHover = createNestedHover(parentHover, 'Nested');279assertInDOM(nestedHover, 'Nested hover should be in DOM');280assertInDOM(parentHover, 'Parent hover should still be in DOM after nested hover created');281282assert.strictEqual(parentHover.isDisposed, false, 'Parent hover should remain visible');283assert.strictEqual(nestedHover.isDisposed, false, 'Nested hover should be visible');284285nestedHover.dispose();286assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM after dispose');287assertInDOM(parentHover, 'Parent hover should remain in DOM after nested is disposed');288289parentHover.dispose();290assertNotInDOM(parentHover, 'Parent hover should be removed from DOM after dispose');291});292293test('should dispose nested hover when parent is disposed', () => {294const parentHover = showHover('Parent');295const nestedHover = createNestedHover(parentHover, 'Nested');296297assertInDOM(parentHover, 'Parent hover should be in DOM');298assertInDOM(nestedHover, 'Nested hover should be in DOM');299300parentHover.dispose();301302assert.strictEqual(nestedHover.isDisposed, true, 'Nested hover should be disposed when parent is disposed');303assertNotInDOM(parentHover, 'Parent hover should be removed from DOM');304assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM when parent is disposed');305});306307test('should dispose entire hover chain when root is disposed', () => {308const hovers = createHoverChain(3);309assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');310311// Verify all hovers are in DOM312for (let i = 0; i < hovers.length; i++) {313assert.ok(mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be in DOM`);314}315316// Dispose the root hover317hovers[0].dispose();318319// All hovers in the chain should be disposed and removed from DOM320for (let i = 0; i < hovers.length; i++) {321assert.strictEqual(hovers[i].isDisposed, true, `Hover ${i + 1} should be disposed`);322assert.ok(!mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be removed from DOM`);323}324});325326test('should dispose only nested hovers when middle hover is disposed', () => {327const hovers = createHoverChain(3);328assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');329330// Verify all hovers are in DOM331for (const h of hovers) {332assert.ok(mainWindow.document.body.contains(h.domNode), 'All hovers should be in DOM initially');333}334335// Dispose the middle hover336hovers[1].dispose();337338assert.strictEqual(hovers[0].isDisposed, false, 'Root hover should remain');339assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Root hover should remain in DOM');340341assert.strictEqual(hovers[1].isDisposed, true, 'Middle hover should be disposed');342assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Middle hover should be removed from DOM');343344assert.strictEqual(hovers[2].isDisposed, true, 'Innermost hover should be disposed');345assert.ok(!mainWindow.document.body.contains(hovers[2].domNode), 'Innermost hover should be removed from DOM');346347hovers[0].dispose();348});349350test('should enforce maximum nesting depth', () => {351// Create hovers up to the max depth (3)352const hovers = createHoverChain(3);353assert.strictEqual(hovers.length, 3, 'Should create exactly 3 hovers (max depth)');354355// Verify all 3 hovers are in DOM356for (const h of hovers) {357assert.ok(mainWindow.document.body.contains(h.domNode), 'Hover should be in DOM');358}359360// Try to create a 4th nested hover - should fail361const nestedTarget = document.createElement('div');362hovers[2].domNode.appendChild(nestedTarget);363const fourthHover = hoverService.showInstantHover({364content: 'Hover 4',365target: nestedTarget366});367368assert.strictEqual(fourthHover, undefined, 'Fourth hover should not be created due to max nesting depth');369370disposeHovers(hovers);371});372373test('should allow new hover chain after disposing previous chain', () => {374// Create and dispose a chain375const firstChain = createHoverChain(3);376for (const h of firstChain) {377assert.ok(mainWindow.document.body.contains(h.domNode), 'First chain hover should be in DOM');378}379disposeHovers(firstChain);380for (const h of firstChain) {381assert.ok(!mainWindow.document.body.contains(h.domNode), 'First chain hover should be removed from DOM');382}383384// Should be able to create a new chain385const secondChain = createHoverChain(3);386assert.strictEqual(secondChain.length, 3, 'Should create new chain after disposing previous');387for (const h of secondChain) {388assert.ok(mainWindow.document.body.contains(h.domNode), 'Second chain hover should be in DOM');389}390391disposeHovers(secondChain);392});393394test('hideHover should close innermost hover first', () => {395const hovers = createHoverChain(2);396397// Verify both are in DOM398assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be in DOM');399assert.ok(mainWindow.document.body.contains(hovers[1].domNode), 'Inner hover should be in DOM');400401hoverService.hideHover();402403// Innermost hover should be disposed and removed from DOM404assert.strictEqual(hovers[1].isDisposed, true, 'Innermost hover should be disposed');405assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Innermost hover should be removed from DOM');406assert.strictEqual(hovers[0].isDisposed, false, 'Outer hover should remain');407assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should remain in DOM');408409hoverService.hideHover();410411assert.strictEqual(hovers[0].isDisposed, true, 'Outer hover should be disposed on second call');412assert.ok(!mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be removed from DOM');413});414});415416suite('setupDelayedHover', () => {417test('should evaluate function options on mouseover', () => runWithFakedTimers({ useFakeTimers: true }, async () => {418const target = createTarget();419let callCount = 0;420421const disposable = hoverService.setupDelayedHover(target, () => {422callCount++;423return { content: `Call ${callCount}` };424});425426// First mouseover427target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));428assert.strictEqual(callCount, 1, 'Options function should be called on first mouseover');429430await timeout(0);431hoverService.hideHover(true);432433// Second mouseover should call function again434target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));435assert.strictEqual(callCount, 2, 'Options function should be called on second mouseover');436437await timeout(0);438disposable.dispose();439hoverService.hideHover(true);440}));441442test('should use reduced delay when reducedDelay is true', () => runWithFakedTimers({ useFakeTimers: true }, async () => {443const target = createTarget();444445// Configure reducedDelay to 150ms for this test446(instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('workbench.hover.reducedDelay', 150);447448const disposable = hoverService.setupDelayedHover(target, { content: 'Reduced delay' }, { reducedDelay: true });449450// Trigger mouseover451target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));452453// Hover should not be visible before delay454await timeout(75);455const hoversBefore = mainWindow.document.querySelectorAll('.monaco-hover');456assert.strictEqual(hoversBefore.length, 0, 'Hover should not be visible before delay completes');457458// Hover should be visible after delay459await timeout(150);460const hoversAfter = mainWindow.document.querySelectorAll('.monaco-hover');461assert.strictEqual(hoversAfter.length, 1, 'Hover should be visible after reduced delay');462463disposable.dispose();464hoverService.hideHover(true);465}));466});467468suite('setupManagedHover', () => {469test('should use native title attribute when showNativeHover is true', () => {470const target = createTarget();471const hover = hoverService.setupManagedHover(472{ showHover: () => undefined, delay: 0, showNativeHover: true },473target,474'Native hover content'475);476477assert.strictEqual(target.getAttribute('title'), 'Native hover content');478479hover.dispose();480481assert.strictEqual(target.getAttribute('title'), null, 'Title should be removed on dispose');482});483484test('should update content dynamically', async () => {485const target = createTarget();486const hover = hoverService.setupManagedHover(487{ showHover: () => undefined, delay: 0, showNativeHover: true },488target,489'Initial'490);491492assert.strictEqual(target.getAttribute('title'), 'Initial');493494await hover.update('Updated');495assert.strictEqual(target.getAttribute('title'), 'Updated');496497await hover.update('Final');498assert.strictEqual(target.getAttribute('title'), 'Final');499500hover.dispose();501});502});503504suite('showDelayedHover', () => {505test('should reject hover when current hover is locked and target is outside', () => {506const lockedHover = showHover('Locked', undefined, {507persistence: { sticky: true }508});509assertInDOM(lockedHover, 'Locked hover should be in DOM');510511const otherTarget = createTarget();512const rejectedHover = hoverService.showDelayedHover({513content: 'Should not show',514target: otherTarget515}, {});516517assert.strictEqual(rejectedHover, undefined, 'Should reject hover when locked hover exists');518assertInDOM(lockedHover, 'Locked hover should remain in DOM after rejection');519520lockedHover.dispose();521assertNotInDOM(lockedHover, 'Locked hover should be removed from DOM after dispose');522});523524test('should use reduced delay when reducedDelay is true', () => runWithFakedTimers({ useFakeTimers: true }, async () => {525const target = createTarget();526const reducedDelay = 100;527528// Configure reducedDelay setting for this test529(instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('workbench.hover.reducedDelay', reducedDelay);530531const hover = hoverService.showDelayedHover({532content: 'Reduced delay hover',533target534}, { reducedDelay: true });535536assert.ok(hover, 'Hover should be created');537assertNotInDOM(hover, 'Hover should not be visible immediately');538539// Wait less than reduced delay - hover should still not be visible540await timeout(reducedDelay / 2);541assertNotInDOM(hover, 'Hover should not be visible before delay completes');542543// Wait for full delay - hover should now be visible544await timeout(reducedDelay);545assertInDOM(hover, 'Hover should be visible after reduced delay');546547hover.dispose();548}));549550test('should use default delay when custom delay is undefined', () => runWithFakedTimers({ useFakeTimers: true }, async () => {551const target = createTarget();552// Default delay is set to 0 in test setup553const hover = hoverService.showDelayedHover({554content: 'Default delay hover',555target556}, {});557558assert.ok(hover, 'Hover should be created');559560// Since default delay is 0 in tests, hover should appear after minimal timeout561await timeout(0);562assertInDOM(hover, 'Hover should be visible with default delay');563564hover.dispose();565}));566});567568suite('hover locking', () => {569test('isLocked should be settable on hover widget', () => {570const hover = showHover('Test');571const widget = asHoverWidget(hover);572assertInDOM(hover, 'Hover should be in DOM');573574assert.strictEqual(widget.isLocked, false, 'Should not be locked initially');575576widget.isLocked = true;577assert.strictEqual(widget.isLocked, true, 'Should be locked after setting');578assertInDOM(hover, 'Hover should remain in DOM after locking');579580widget.isLocked = false;581assert.strictEqual(widget.isLocked, false, 'Should be unlocked after unsetting');582583hover.dispose();584assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');585});586587test('sticky option should set isLocked to true', () => {588const hover = showHover('Test', undefined, {589persistence: { sticky: true }590});591assertInDOM(hover, 'Sticky hover should be in DOM');592593assert.strictEqual(asHoverWidget(hover).isLocked, true, 'Should be locked when sticky');594595hover.dispose();596assertNotInDOM(hover, 'Sticky hover should be removed from DOM after dispose');597});598});599600suite('showAndFocusLastHover', () => {601test('should recreate last disposed hover', () => {602const target = createTarget();603const hover = hoverService.showInstantHover({604content: 'Remember me',605target606});607assert.ok(hover);608assertInDOM(hover, 'Initial hover should be in DOM');609610hover.dispose();611assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');612613// Should recreate the hover - verify a new hover is shown614hoverService.showAndFocusLastHover();615616// Verify there is a hover in the DOM (it's a new hover instance)617const hoverElements = mainWindow.document.querySelectorAll('.monaco-hover');618assert.ok(hoverElements.length > 0, 'A hover should be recreated and in the DOM');619620// Clean up621hoverService.hideHover(true);622623// Verify cleanup624const remainingHovers = mainWindow.document.querySelectorAll('.monaco-hover');625assert.strictEqual(remainingHovers.length, 0, 'No hovers should remain in DOM after cleanup');626});627});628});629630631