Skip to content

Commit 2ea36a2

Browse files
committed
Merge remote-tracking branch 'origin/feat/mothership-revamp' into feat/mothership-revamp
# Conflicts: # apps/sim/lib/core/config/deployment-shape.test.ts
2 parents 19e8407 + 749545d commit 2ea36a2

7 files changed

Lines changed: 306 additions & 46 deletions

File tree

‎apps/desktop/e2e/password-autofill.spec.ts‎

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,21 +122,21 @@ test.describe('saved password autofill', () => {
122122
)
123123
})
124124

125-
async function seed(count: number) {
125+
async function seed(count: number, prefix = 'account') {
126126
const ciphertext = await app.evaluate(
127-
({ safeStorage }, { site, count }) => {
127+
({ safeStorage }, { site, count, prefix }) => {
128128
const records = Array.from({ length: count }, (_, index) => ({
129129
id: `account-${index}`,
130130
origin: site,
131-
username: `account${index + 1}@example.test`,
131+
username: `${prefix}${index + 1}@example.test`,
132132
password: `fixture-secret-${index}`,
133133
createdAt: '2026-01-01T00:00:00.000Z',
134134
updatedAt: '2026-01-01T00:00:00.000Z',
135135
source: 'manual',
136136
}))
137137
return safeStorage.encryptString(JSON.stringify(records)).toString('base64')
138138
},
139-
{ site, count }
139+
{ site, count, prefix }
140140
)
141141
writeFileSync(
142142
join(userData, 'browser-credentials.json'),
@@ -319,13 +319,65 @@ test.describe('saved password autofill', () => {
319319
.toBe(false)
320320
})
321321

322+
for (const count of [1, 3]) {
323+
test(`keeps full account tooltips inside a ${count}-account picker`, async () => {
324+
const prefix = 'person.with.a.long.name.and.department.for.signin'
325+
await seed(count, prefix)
326+
await clickField()
327+
const menu = await picker()
328+
await menu.getByRole('menuitem').first().hover()
329+
const tooltip = menu.locator('body > [data-native-surface-overlay][aria-hidden="true"]')
330+
await expect(tooltip).toBeVisible()
331+
await expect(tooltip).toHaveText(`${prefix}1@example.test`)
332+
await expect
333+
.poll(() =>
334+
tooltip.evaluate((element) => {
335+
const rect = element.getBoundingClientRect()
336+
return (
337+
rect.left >= 0 &&
338+
rect.top >= 0 &&
339+
rect.right <= innerWidth &&
340+
rect.bottom <= innerHeight
341+
)
342+
})
343+
)
344+
.toBe(true)
345+
if (SCREENSHOTS) {
346+
await menu.screenshot({ path: join(SCREENSHOTS, `password-picker-long-${count}.png`) })
347+
}
348+
await app.evaluate(({ BrowserWindow }) => {
349+
BrowserWindow.getAllWindows()
350+
.find((window) => window.webContents.getURL().includes('credential-picker.html'))!
351+
.setContentSize(800, 600)
352+
})
353+
await menu
354+
.getByRole('menuitem')
355+
.first()
356+
.hover({ position: { x: 100, y: 14 } })
357+
await expect
358+
.poll(() =>
359+
tooltip.evaluate((element) => {
360+
const rect = element.getBoundingClientRect()
361+
return (
362+
rect.left >= 100 &&
363+
rect.top >= 0 &&
364+
rect.right <= innerWidth &&
365+
rect.bottom <= innerHeight
366+
)
367+
})
368+
)
369+
.toBe(true)
370+
})
371+
}
372+
322373
test('dismisses an inactive picker on outside input and hides it when its page disappears', async () => {
323374
await clickField()
324375
await picker()
325376
await host.getByRole('button', { name: 'Outside the browser' }).click()
326377
await expect
327378
.poll(() => app.windows().some((page) => page.url().includes('credential-picker.html')))
328379
.toBe(false)
380+
await expect(host.getByRole('button', { name: 'Outside the browser' })).toBeFocused()
329381
await clickField()
330382
await picker()
331383
await host.evaluate((scope) => {
@@ -339,6 +391,45 @@ test.describe('saved password autofill', () => {
339391
expect(await pageScript('document.getElementById("pass").value')).toBe('')
340392
})
341393

394+
test('returns keyboard focus to the login after dismissal and selection', async () => {
395+
await clickField()
396+
await picker()
397+
await app.evaluate(({ webContents }, site) => {
398+
const page = webContents
399+
.getAllWebContents()
400+
.find((contents) => contents.getURL().startsWith(`${site}/login`))!
401+
page.sendInputEvent({ type: 'keyDown', keyCode: 'Down' })
402+
page.sendInputEvent({ type: 'keyUp', keyCode: 'Down' })
403+
}, site)
404+
await expect
405+
.poll(() =>
406+
app.evaluate(({ BrowserWindow }) =>
407+
BrowserWindow.getFocusedWindow()?.webContents.getURL().includes('credential-picker.html')
408+
)
409+
)
410+
.toBe(true)
411+
await pickerKey('Escape')
412+
await expect
413+
.poll(() =>
414+
app.evaluate(({ BrowserWindow, webContents }) => ({
415+
window: BrowserWindow.getFocusedWindow()?.webContents.getURL(),
416+
page: webContents.getFocusedWebContents()?.getURL(),
417+
}))
418+
)
419+
.toEqual({ window: host.url(), page: `${site}/login` })
420+
await app.evaluate(({ webContents }) => {
421+
const page = webContents.getFocusedWebContents()!
422+
page.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
423+
page.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
424+
})
425+
await expect.poll(() => pageScript('document.activeElement.id')).toBe('pass')
426+
const menu = await picker()
427+
await menu.getByRole('menuitem').first().click()
428+
await expect
429+
.poll(() => app.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL()))
430+
.toBe(`${site}/login`)
431+
})
432+
342433
test('rejects replaced fields and excludes account creation', async () => {
343434
await clickField()
344435
await picker()
@@ -354,11 +445,18 @@ test.describe('saved password autofill', () => {
354445
})
355446
test('offers a focused manual chooser with one account', async () => {
356447
await seed(1)
448+
await host.evaluate(async (scope) => {
449+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
450+
await api.browserAgent.capturePanelSnapshot(scope)
451+
if (!(await api.browserAgent.setPanelOccluded(true, scope))) {
452+
throw new Error('Could not open the toolbar overlay')
453+
}
454+
}, SCOPE)
357455
await host.evaluate((scope) => {
358-
document.getElementById('outside')!.onclick = () => {
359-
void (
360-
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
361-
).simDesktop.browserCredentials.showChooser({ x: 20, y: 80 }, scope)
456+
document.getElementById('outside')!.onclick = async () => {
457+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
458+
await api.browserAgent.setPanelOccluded(false, scope)
459+
await api.browserCredentials.showChooser({ x: 20, y: 80 }, scope)
362460
}
363461
}, SCOPE)
364462
await host.getByRole('button', { name: 'Outside the browser' }).click()

‎apps/desktop/src/main/browser-credentials/fill.test.ts‎

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
33
vi.mock('electron', () => import('@/test/electron-mock'))
44

55
import { 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'
88
import type { CredentialPicker } from '@/main/browser-credentials/picker'
99
import type { CredentialVault } from '@/main/browser-credentials/vault'
1010
import type { CredentialFormReport } from '@/shared/browser-credentials'
1111

1212
const ORIGIN = 'https://example.com'
1313
const 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+
}))
2027
vi.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

5969
type 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

125148
beforeEach(() => {
126149
pickerOptions.mockClear()
150+
pickerFocus.mockClear()
127151
})
128152

129153
describe('fill availability', () => {
@@ -259,6 +283,91 @@ describe('fill availability', () => {
259283
})
260284

261285
describe('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

Comments
 (0)