@@ -26,61 +26,92 @@ import { OpenerService } from './opener-service';
2626
2727disableJSDOM ( ) ;
2828
29+ /* eslint-disable @typescript-eslint/no-explicit-any */
30+
2931describe ( 'HoverService' , ( ) => {
3032 let container : Container ;
3133 let hoverService : HoverService ;
34+ let originalMatches : ( selectors : string ) => boolean ;
3235
3336 before ( ( ) => {
3437 disableJSDOM = enableJSDOM ( ) ;
3538 // The hover service positions its host after waiting for an animation frame.
3639 // JSDOM (without pretendToBeVisual) does not provide requestAnimationFrame.
37- // eslint-disable-next-line @typescript-eslint/no-explicit-any
3840 ( global as any ) . requestAnimationFrame = ( cb : FrameRequestCallback ) => setTimeout ( cb , 0 ) ;
41+ // JSDOM implements neither the Popover API (showPopover/hidePopover) nor the
42+ // `:popover-open` pseudo-class: stub them, tracking the open state in an attribute.
43+ const elementPrototype = window . HTMLElement . prototype as any ;
44+ elementPrototype . showPopover = function ( this : HTMLElement ) : void { this . setAttribute ( 'data-test-popover-open' , 'true' ) ; } ;
45+ elementPrototype . hidePopover = function ( this : HTMLElement ) : void { this . removeAttribute ( 'data-test-popover-open' ) ; } ;
46+ originalMatches = elementPrototype . matches ;
47+ elementPrototype . matches = function ( this : HTMLElement , selectors : string ) : boolean {
48+ return selectors === ':popover-open' ? this . hasAttribute ( 'data-test-popover-open' ) : originalMatches . call ( this , selectors ) ;
49+ } ;
3950 } ) ;
4051
4152 after ( ( ) => {
42- // eslint-disable-next-line @typescript-eslint/no-explicit-any
53+ const elementPrototype = window . HTMLElement . prototype as any ;
54+ delete elementPrototype . showPopover ;
55+ delete elementPrototype . hidePopover ;
56+ elementPrototype . matches = originalMatches ;
4357 delete ( global as any ) . requestAnimationFrame ;
4458 disableJSDOM ( ) ;
4559 } ) ;
4660
4761 beforeEach ( ( ) => {
4862 container = new Container ( ) ;
4963 container . bind ( HoverService ) . toSelf ( ) . inSingletonScope ( ) ;
50- // eslint-disable-next-line @typescript-eslint/no-explicit-any
5164 container . bind ( PreferenceService ) . toConstantValue ( { get : ( ) => 0 } as any ) ;
52- // eslint-disable-next-line @typescript-eslint/no-explicit-any
5365 container . bind ( CoreMarkdownRenderer ) . toConstantValue ( { render : ( ) => ( { element : document . createElement ( 'div' ) , dispose : ( ) => { } } ) } as any ) ;
54- // eslint-disable-next-line @typescript-eslint/no-explicit-any
5566 container . bind ( OpenerService ) . toConstantValue ( { } as any ) ;
5667 hoverService = container . get ( HoverService ) ;
57- stubPopoverApi ( hoverService ) ;
5868 } ) ;
5969
6070 afterEach ( ( ) => {
6171 hoverService . cancelHover ( ) ;
6272 } ) ;
6373
64- /**
65- * JSDOM implements neither the Popover API (showPopover/hidePopover) nor the
66- * `:popover-open` pseudo-class, so stub them on the service's host element.
67- */
68- function stubPopoverApi ( service : HoverService ) : void {
69- // eslint-disable-next-line @typescript-eslint/no-explicit-any
70- const host : HTMLElement = ( service as any ) . hoverHost ;
71- // eslint-disable-next-line @typescript-eslint/no-explicit-any
72- ( host as any ) . showPopover = ( ) => { } ;
73- // eslint-disable-next-line @typescript-eslint/no-explicit-any
74- ( host as any ) . hidePopover = ( ) => { } ;
75- const originalMatches = host . matches . bind ( host ) ;
76- host . matches = ( selectors : string ) => selectors === ':popover-open' ? false : originalMatches ( selectors ) ;
77- }
78-
7974 function waitForHover ( ) : Promise < void > {
8075 // hover delay (0ms timeout) + animation frame (0ms timeout stub)
8176 return new Promise ( resolve => setTimeout ( resolve , 20 ) ) ;
8277 }
8378
79+ interface FakeSecondaryWindow {
80+ secondaryDocument : Document ;
81+ fireEvent ( type : string ) : void ;
82+ }
83+
84+ /**
85+ * Creates a document simulating one hosted in a secondary window: unlike a document from
86+ * `createHTMLDocument`, it has a `defaultView` window on which the hover service can listen
87+ * for the window going away.
88+ */
89+ function createSecondaryWindowDocument ( options ?: { closed ?: boolean } ) : FakeSecondaryWindow {
90+ const secondaryDocument = document . implementation . createHTMLDocument ( 'secondary window' ) ;
91+ const listeners = new Map < string , EventListener [ ] > ( ) ;
92+ const fakeWindow = {
93+ closed : options ?. closed ?? false ,
94+ requestAnimationFrame : ( cb : FrameRequestCallback ) => setTimeout ( cb , 0 ) ,
95+ addEventListener : ( type : string , listener : EventListener ) => {
96+ const forType = listeners . get ( type ) ?? [ ] ;
97+ forType . push ( listener ) ;
98+ listeners . set ( type , forType ) ;
99+ } ,
100+ removeEventListener : ( type : string , listener : EventListener ) => {
101+ const forType = listeners . get ( type ) ;
102+ const index = forType ?. indexOf ( listener ) ?? - 1 ;
103+ if ( forType && index > - 1 ) {
104+ forType . splice ( index , 1 ) ;
105+ }
106+ }
107+ } ;
108+ Object . defineProperty ( secondaryDocument , 'defaultView' , { value : fakeWindow , configurable : true } ) ;
109+ return {
110+ secondaryDocument,
111+ fireEvent : type => [ ...( listeners . get ( type ) ?? [ ] ) ] . forEach ( listener => listener ( { type } as Event ) )
112+ } ;
113+ }
114+
84115 it ( 'renders the hover in the document of the target element' , async ( ) => {
85116 const target = document . createElement ( 'div' ) ;
86117 document . body . appendChild ( target ) ;
@@ -91,7 +122,7 @@ describe('HoverService', () => {
91122 } ) ;
92123
93124 it ( 'renders the hover in a secondary window document if the target lives there' , async ( ) => {
94- const secondaryDocument = document . implementation . createHTMLDocument ( 'secondary window' ) ;
125+ const { secondaryDocument } = createSecondaryWindowDocument ( ) ;
95126 const target = secondaryDocument . createElement ( 'div' ) ;
96127 secondaryDocument . body . appendChild ( target ) ;
97128 hoverService . requestHover ( { content : 'secondary window hover' , target, position : 'right' , skipHoverDelay : true } ) ;
@@ -101,6 +132,136 @@ describe('HoverService', () => {
101132 target . remove ( ) ;
102133 } ) ;
103134
135+ it ( 'creates the hover host in the document of the target instead of adopting it across documents' , async ( ) => {
136+ const mainTarget = document . createElement ( 'div' ) ;
137+ document . body . appendChild ( mainTarget ) ;
138+ hoverService . requestHover ( { content : 'main' , target : mainTarget , position : 'right' , skipHoverDelay : true } ) ;
139+ await waitForHover ( ) ;
140+ const mainHost = document . querySelector ( '.theia-hover' ) ;
141+ expect ( mainHost , 'hover should be in the main document' ) . to . exist ;
142+
143+ const { secondaryDocument } = createSecondaryWindowDocument ( ) ;
144+ const secondaryTarget = secondaryDocument . createElement ( 'div' ) ;
145+ secondaryDocument . body . appendChild ( secondaryTarget ) ;
146+ hoverService . requestHover ( { content : 'secondary' , target : secondaryTarget , position : 'right' , skipHoverDelay : true } ) ;
147+ await waitForHover ( ) ;
148+ const secondaryHost = secondaryDocument . querySelector ( '.theia-hover' ) ;
149+ expect ( secondaryHost , 'hover should be in the secondary document' ) . to . exist ;
150+ // moving a host into another document would make it outlive its window; a host must be
151+ // created in the document it is shown in
152+ expect ( secondaryHost , 'the secondary host must not be the adopted main host' ) . to . not . equal ( mainHost ) ;
153+ expect ( secondaryHost ! . ownerDocument ) . to . equal ( secondaryDocument ) ;
154+ mainTarget . remove ( ) ;
155+ secondaryTarget . remove ( ) ;
156+ } ) ;
157+
158+ it ( 'cancels the hover when the window hosting it is closed' , async ( ) => {
159+ const { secondaryDocument, fireEvent } = createSecondaryWindowDocument ( ) ;
160+ const target = secondaryDocument . createElement ( 'div' ) ;
161+ secondaryDocument . body . appendChild ( target ) ;
162+ hoverService . requestHover ( { content : 'secondary window hover' , target, position : 'right' , skipHoverDelay : true } ) ;
163+ await waitForHover ( ) ;
164+ expect ( secondaryDocument . querySelector ( '.theia-hover' ) , 'hover should be in the secondary document' ) . to . exist ;
165+
166+ fireEvent ( 'pagehide' ) ;
167+ expect ( secondaryDocument . querySelector ( '.theia-hover' ) , 'hover should be removed when its window closes' ) . to . not . exist ;
168+
169+ // hovers in the main window must keep working afterwards
170+ const mainTarget = document . createElement ( 'div' ) ;
171+ document . body . appendChild ( mainTarget ) ;
172+ hoverService . requestHover ( { content : 'after window close' , target : mainTarget , position : 'right' , skipHoverDelay : true } ) ;
173+ await waitForHover ( ) ;
174+ expect ( document . querySelector ( '.theia-hover' ) , 'hover should be rendered in the main document afterwards' ) . to . exist ;
175+ target . remove ( ) ;
176+ mainTarget . remove ( ) ;
177+ } ) ;
178+
179+ it ( 'does not render a hover for a target in an already closed window' , async ( ) => {
180+ const { secondaryDocument } = createSecondaryWindowDocument ( { closed : true } ) ;
181+ const target = secondaryDocument . createElement ( 'div' ) ;
182+ secondaryDocument . body . appendChild ( target ) ;
183+ hoverService . requestHover ( { content : 'closed window hover' , target, position : 'right' , skipHoverDelay : true } ) ;
184+ await waitForHover ( ) ;
185+ expect ( secondaryDocument . querySelector ( '.theia-hover' ) , 'no hover should be rendered in a closed window' ) . to . not . exist ;
186+ expect ( document . querySelector ( '.theia-hover' ) , 'no hover should be rendered in the main document either' ) . to . not . exist ;
187+ target . remove ( ) ;
188+ } ) ;
189+
190+ it ( 'keeps the hover host hidden until it has been positioned' , async ( ) => {
191+ // the host is appended (and the popover shown) at (0, 0) first and only positioned after an
192+ // animation frame; it must not be hittable in the meantime: a visible popover at (0, 0) can
193+ // cover the target, kick it out of the hover chain and retrigger mouseenter hovers in an
194+ // endless show/hide loop (e.g. for tabs at the top-left corner of a secondary window)
195+ const target = document . createElement ( 'div' ) ;
196+ document . body . appendChild ( target ) ;
197+ const rendering = ( hoverService as any ) . renderHover ( { content : 'positioning' , target, position : 'right' } ) as Promise < void > ;
198+ const host = document . querySelector ( '.theia-hover' ) as HTMLElement ;
199+ expect ( host , 'hover should be appended synchronously' ) . to . exist ;
200+ expect ( host . style . visibility , 'hover must not be visible before it has been positioned' ) . to . equal ( 'hidden' ) ;
201+ await rendering ;
202+ expect ( host . style . visibility , 'hover should be visible once positioned' ) . to . equal ( 'visible' ) ;
203+ target . remove ( ) ;
204+ } ) ;
205+
206+ it ( 'does not reveal a hover that was superseded while waiting to be positioned' , async ( ) => {
207+ const target = document . createElement ( 'div' ) ;
208+ document . body . appendChild ( target ) ;
209+ const service = hoverService as any ;
210+ const first = service . renderHover ( { content : 'first' , target, position : 'right' } ) as Promise < void > ;
211+ const second = service . renderHover ( { content : 'second' , target, position : 'right' } ) as Promise < void > ;
212+ await first ;
213+ const host = document . querySelector ( '.theia-hover' ) as HTMLElement ;
214+ expect ( host . style . visibility , 'the superseded render must not reveal the host' ) . to . equal ( 'hidden' ) ;
215+ await second ;
216+ expect ( host . style . visibility , 'the latest render reveals the host' ) . to . equal ( 'visible' ) ;
217+ target . remove ( ) ;
218+ } ) ;
219+
220+ it ( 'does not leak css classes from a hover that was superseded while waiting to be positioned' , async ( ) => {
221+ const service = hoverService as any ;
222+ // keep the first render stuck waiting for its animation frame so that a second hover supersedes it mid-render
223+ const originalAnimationFrame = service . hostAnimationFrame . bind ( service ) ;
224+ let releaseFirst : ( ) => void ;
225+ let animationFrameCalls = 0 ;
226+ service . hostAnimationFrame = ( element : HTMLElement ) => ++ animationFrameCalls === 1
227+ ? new Promise < void > ( resolve => { releaseFirst = resolve ; } )
228+ : originalAnimationFrame ( element ) ;
229+ const target = document . createElement ( 'div' ) ;
230+ document . body . appendChild ( target ) ;
231+ hoverService . requestHover ( { content : 'first' , target, position : 'right' , skipHoverDelay : true , cssClasses : [ 'first-hover-class' ] } ) ;
232+ await waitForHover ( ) ;
233+ hoverService . requestHover ( { content : 'second' , target, position : 'right' , skipHoverDelay : true } ) ;
234+ await waitForHover ( ) ;
235+ releaseFirst ! ( ) ; // let the superseded render finish
236+ await waitForHover ( ) ;
237+ const host = document . querySelector ( '.theia-hover' ) ;
238+ expect ( host , 'second hover should be rendered' ) . to . exist ;
239+ expect ( host ! . classList . contains ( 'first-hover-class' ) , 'the superseded hover must not leak its css classes' ) . to . equal ( false ) ;
240+ target . remove ( ) ;
241+ } ) ;
242+
243+ it ( 'recovers if the open hover can no longer be hidden' , async ( ) => {
244+ // simulate a hover whose document is no longer fully active, e.g. because the secondary
245+ // window hosting it was closed: hidePopover throws and must not break subsequent hovers
246+ const target = document . createElement ( 'div' ) ;
247+ document . body . appendChild ( target ) ;
248+ hoverService . requestHover ( { content : 'first' , target, position : 'right' , skipHoverDelay : true } ) ;
249+ await waitForHover ( ) ;
250+ const host = document . querySelector ( '.theia-hover' ) as HTMLElement ;
251+ expect ( host , 'first hover should be rendered' ) . to . exist ;
252+ ( host as any ) . hidePopover = ( ) => { throw new Error ( 'InvalidStateError: not fully active' ) ; } ;
253+
254+ const secondTarget = document . createElement ( 'div' ) ;
255+ document . body . appendChild ( secondTarget ) ;
256+ hoverService . requestHover ( { content : 'second' , target : secondTarget , position : 'right' , skipHoverDelay : true } ) ;
257+ await waitForHover ( ) ;
258+ const secondHost = document . querySelector ( '.theia-hover' ) ;
259+ expect ( secondHost , 'hover should be rendered again in the main document' ) . to . exist ;
260+ expect ( secondHost ! . textContent ) . to . equal ( 'second' ) ;
261+ target . remove ( ) ;
262+ secondTarget . remove ( ) ;
263+ } ) ;
264+
104265 describe ( 'position fallback' , ( ) => {
105266 // simulated window: 400px wide, 600px high
106267 const windowWidth = 400 ;
@@ -115,7 +276,6 @@ describe('HoverService', () => {
115276 beforeEach ( ( ) => {
116277 target = document . createElement ( 'div' ) ;
117278 document . body . appendChild ( target ) ;
118- // eslint-disable-next-line @typescript-eslint/no-explicit-any
119279 const host : HTMLElement = ( hoverService as any ) . hoverHost ;
120280 host . getBoundingClientRect = ( ) => rect ( 0 , 0 , 300 , 50 ) ;
121281 originalBodyRect = document . body . getBoundingClientRect . bind ( document . body ) ;
@@ -125,13 +285,11 @@ describe('HoverService', () => {
125285
126286 afterEach ( ( ) => {
127287 document . body . getBoundingClientRect = originalBodyRect ;
128- // eslint-disable-next-line @typescript-eslint/no-explicit-any
129288 delete ( document . documentElement as any ) . scrollHeight ;
130289 target . remove ( ) ;
131290 } ) ;
132291
133292 function setHostPosition ( position : 'left' | 'right' | 'top' | 'bottom' ) : string {
134- // eslint-disable-next-line @typescript-eslint/no-explicit-any
135293 const service = hoverService as any ;
136294 return service . setHostPosition ( target , service . hoverHost , position ) ;
137295 }
@@ -152,28 +310,10 @@ describe('HoverService', () => {
152310 } ) ;
153311
154312 it ( 'keeps the requested direction when the perpendicular direction does not fit either' , ( ) => {
155- // eslint-disable-next-line @typescript-eslint/no-explicit-any
156313 const host : HTMLElement = ( hoverService as any ) . hoverHost ;
157314 host . getBoundingClientRect = ( ) => rect ( 0 , 0 , 300 , windowHeight ) ; // hover as tall as the window
158315 target . getBoundingClientRect = ( ) => rect ( 0 , 100 , windowWidth , 20 ) ;
159316 expect ( setHostPosition ( 'left' ) ) . to . equal ( 'right' ) ;
160317 } ) ;
161318 } ) ;
162-
163- it ( 'recovers if the document hosting an open hover is no longer active' , async ( ) => {
164- // simulate a hover host left popover-open in a closed secondary window's document:
165- // hidePopover then throws 'InvalidStateError' and must not break subsequent hovers
166- // eslint-disable-next-line @typescript-eslint/no-explicit-any
167- const host : HTMLElement = ( hoverService as any ) . hoverHost ;
168- host . matches = ( selectors : string ) => selectors === ':popover-open' ;
169- // eslint-disable-next-line @typescript-eslint/no-explicit-any
170- ( host as any ) . hidePopover = ( ) => { throw new Error ( 'InvalidStateError: not fully active' ) ; } ;
171- const target = document . createElement ( 'div' ) ;
172- document . body . appendChild ( target ) ;
173- hoverService . requestHover ( { content : 'after window close' , target, position : 'right' , skipHoverDelay : true } ) ;
174- stubPopoverApi ( hoverService ) ; // restore working popover stubs for the new hover
175- await waitForHover ( ) ;
176- expect ( document . querySelector ( '.theia-hover' ) , 'hover should be rendered again in the main document' ) . to . exist ;
177- target . remove ( ) ;
178- } ) ;
179319} ) ;
0 commit comments