@@ -3,20 +3,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
33vi . mock ( 'electron' , ( ) => import ( '@/test/electron-mock' ) )
44
55import { sleep } from '@sim/utils/helpers'
6- import type { BrowserWindow , WebContents } from 'electron'
7- import { FillCoordinator } from '@/main/browser-credentials/fill'
6+ import { BrowserWindow , type WebContents } from 'electron'
7+ import { FillCoordinator , type FillCoordinatorDeps } from '@/main/browser-credentials/fill'
88import type { CredentialPicker } from '@/main/browser-credentials/picker'
99import type { CredentialVault } from '@/main/browser-credentials/vault'
1010import type { CredentialFormReport } from '@/shared/browser-credentials'
1111
1212const ORIGIN = 'https://example.com'
1313const SCOPE = 'chat-a'
14- const WINDOW = {
15- getContentBounds : ( ) => ( { x : 0 , y : 0 } ) ,
14+ const WINDOW = Object . assign ( new BrowserWindow ( ) , {
15+ getContentBounds : ( ) => ( { x : 0 , y : 0 , width : 1200 , height : 800 } ) ,
1616 isDestroyed : ( ) => false ,
17- } as BrowserWindow
17+ isVisible : ( ) => true ,
18+ isMinimized : ( ) => false ,
19+ isFocused : ( ) => true ,
20+ focus : vi . fn ( ) ,
21+ } )
1822
19- const { pickerOptions } = vi . hoisted ( ( ) => ( { pickerOptions : vi . fn ( ) } ) )
23+ const { pickerOptions, pickerFocus } = vi . hoisted ( ( ) => ( {
24+ pickerOptions : vi . fn ( ) ,
25+ pickerFocus : vi . fn ( ) ,
26+ } ) )
2027vi . mock ( '@/main/browser-credentials/picker' , ( ) => ( {
2128 CredentialPicker : class {
2229 constructor ( private readonly options : ConstructorParameters < typeof CredentialPicker > [ 0 ] ) {
@@ -26,7 +33,9 @@ vi.mock('@/main/browser-credentials/picker', () => ({
2633 this . options . closed ( )
2734 }
2835 position ( ) { }
29- focus ( ) { }
36+ focus ( ) {
37+ pickerFocus ( )
38+ }
3039 } ,
3140} ) )
3241
@@ -35,6 +44,7 @@ function fakeContents(url = `${ORIGIN}/login`) {
3544 getURL : vi . fn ( ( ) => url ) ,
3645 isDestroyed : vi . fn ( ( ) => false ) ,
3746 send : vi . fn ( ) ,
47+ focus : vi . fn ( ) ,
3848 }
3949}
4050
@@ -58,7 +68,19 @@ function fakeVault(overrides: Partial<Record<string, unknown>> = {}) {
5868
5969type Contents = ReturnType < typeof fakeContents >
6070
61- function setup ( contents : Contents = fakeContents ( ) , vault = fakeVault ( ) ) {
71+ function deferred < T > ( ) {
72+ let resolve ! : ( value : T ) => void
73+ const promise = new Promise < T > ( ( done ) => {
74+ resolve = done
75+ } )
76+ return { promise, resolve }
77+ }
78+
79+ function setup (
80+ contents : Contents = fakeContents ( ) ,
81+ vault = fakeVault ( ) ,
82+ pickerHost ?: FillCoordinatorDeps [ 'pickerHost' ]
83+ ) {
6284 const onAvailabilityChanged = vi . fn ( )
6385 let active : Contents | null = contents
6486 let activeScope = SCOPE
@@ -70,6 +92,7 @@ function setup(contents: Contents = fakeContents(), vault = fakeVault()) {
7092 ! scopeId || scopeId === activeScope ? ( active as unknown as WebContents | null ) : null ,
7193 scopeOwnsContents : ( scopeId , candidate ) => contentsScopes . get ( candidate ) === scopeId ,
7294 onAvailabilityChanged,
95+ pickerHost,
7396 } )
7497 return {
7598 coordinator,
@@ -124,6 +147,7 @@ async function settle(): Promise<void> {
124147
125148beforeEach ( ( ) => {
126149 pickerOptions . mockClear ( )
150+ pickerFocus . mockClear ( )
127151} )
128152
129153describe ( 'fill availability' , ( ) => {
@@ -259,6 +283,91 @@ describe('fill availability', () => {
259283} )
260284
261285describe ( 'credential chooser' , ( ) => {
286+ it . each ( [ 'hidden' , 'minimized' , 'unfocused' ] ) (
287+ 'does not open when the parent becomes %s during a metadata lookup' ,
288+ async ( state ) => {
289+ const context = setup ( )
290+ context . coordinator . noteFormState (
291+ context . contents as unknown as WebContents ,
292+ loginFormState ( )
293+ )
294+ const matches = await context . vault . listForOrigin ( )
295+ const pending = deferred < typeof matches > ( )
296+ context . vault . listForOrigin . mockReturnValueOnce ( pending . promise )
297+ let available = true
298+ const window = Object . assign ( new BrowserWindow ( ) , {
299+ ...WINDOW ,
300+ isVisible : ( ) => state !== 'hidden' || available ,
301+ isMinimized : ( ) => state === 'minimized' && ! available ,
302+ isFocused : ( ) => state !== 'unfocused' || available ,
303+ } )
304+ const opened = context . coordinator . showChooser ( window , { x : 0 , y : 0 } )
305+ available = false
306+ pending . resolve ( matches )
307+ await expect ( opened ) . resolves . toBe ( false )
308+ expect ( pickerOptions ) . not . toHaveBeenCalled ( )
309+ }
310+ )
311+
312+ it . each ( [ 'disappears' , 'moves' ] ) (
313+ 'rechecks the field host when it %s during a metadata lookup' ,
314+ async ( change ) => {
315+ const anchor = { x : 10 , y : 20 , width : 200 , height : 30 }
316+ const pickerHost = vi . fn < NonNullable < FillCoordinatorDeps [ 'pickerHost' ] > > ( ( ) => ( {
317+ window : WINDOW ,
318+ anchor,
319+ } ) )
320+ const context = setup ( fakeContents ( ) , fakeVault ( ) , pickerHost )
321+ context . coordinator . noteFormState (
322+ context . contents as unknown as WebContents ,
323+ loginFormState ( { bounds : anchor } )
324+ )
325+ const matches = await context . vault . listForOrigin ( )
326+ const pending = deferred < typeof matches > ( )
327+ context . vault . listForOrigin . mockReturnValueOnce ( pending . promise )
328+ const opened = context . coordinator . showChooser ( WINDOW , { x : 0 , y : 0 } )
329+ const moved = { ...anchor , x : 90 , y : 100 }
330+ pickerHost . mockReturnValue ( change === 'disappears' ? null : { window : WINDOW , anchor : moved } )
331+ pending . resolve ( matches )
332+ await expect ( opened ) . resolves . toBe ( change === 'moves' )
333+ if ( change === 'disappears' ) expect ( pickerOptions ) . not . toHaveBeenCalled ( )
334+ else expect ( pickerOptions ) . toHaveBeenCalledWith ( expect . objectContaining ( { anchor : moved } ) )
335+ }
336+ )
337+
338+ it . each ( [ 'navigation' , 'tab change' ] ) ( 'does not restore focus after %s' , async ( change ) => {
339+ const context = setup ( )
340+ await openChooser ( context )
341+ const options = pickerOptions . mock . calls . at ( - 1 ) ! [ 0 ] as ConstructorParameters <
342+ typeof CredentialPicker
343+ > [ 0 ]
344+ if ( change === 'navigation' ) {
345+ context . coordinator . noteNavigation ( context . contents as unknown as WebContents )
346+ } else context . setActive ( fakeContents ( ) )
347+ options . restoreFocus ( )
348+ expect ( context . contents . focus ) . not . toHaveBeenCalled ( )
349+ } )
350+
351+ it ( 'does not let a superseded keyboard request focus a newer picker' , async ( ) => {
352+ const anchor = { x : 0 , y : 0 , width : 200 , height : 30 }
353+ const context = setup ( fakeContents ( ) , fakeVault ( ) , ( ) => ( { window : WINDOW , anchor } ) )
354+ const contents = context . contents as unknown as WebContents
355+ context . coordinator . noteFormState ( contents , loginFormState ( { bounds : anchor } ) )
356+ const matches = await context . vault . listForOrigin ( )
357+ const pending = deferred < typeof matches > ( )
358+ context . vault . listForOrigin . mockReturnValueOnce ( pending . promise )
359+ const first = context . coordinator . requestPicker ( contents , 'focus' )
360+ context . coordinator . noteFormState (
361+ contents ,
362+ loginFormState ( { bounds : anchor , targetId : 'next' } )
363+ )
364+ await context . coordinator . requestPicker ( contents , 'open' )
365+ expect ( pickerOptions ) . toHaveBeenCalledOnce ( )
366+ pending . resolve ( matches )
367+ await first
368+ expect ( pickerFocus ) . not . toHaveBeenCalled ( )
369+ } )
370+
262371 it ( 'lists usernames without reading any password' , async ( ) => {
263372 const context = setup ( )
264373 const template = await openChooser ( context )
0 commit comments