fix(stage-tamagotchi): allow sign-in on the first attempt (#2482)
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { effectScope, nextTick, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import { useOnboardingAuthentication } from './use-onboarding-authentication'
|
||||||
|
|
||||||
|
describe('useOnboardingAuthentication', () => {
|
||||||
|
it('keeps the initiating window open until the first sign-in completes', async () => {
|
||||||
|
const isAuthenticated = shallowRef(false)
|
||||||
|
const needsLogin = shallowRef(false)
|
||||||
|
const closeRequestId = shallowRef(0)
|
||||||
|
const startLogin = vi.fn<() => Promise<void>>().mockResolvedValue()
|
||||||
|
const closeWindow = vi.fn<() => Promise<void>>().mockResolvedValue()
|
||||||
|
const scope = effectScope()
|
||||||
|
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The onboarding window closed as soon as the main process opened the browser.
|
||||||
|
// The main process later sent the token callback to that closed renderer, so the first sign-in was lost.
|
||||||
|
// The window must stay open until synchronized authentication state confirms the completed sign-in.
|
||||||
|
scope.run(() => useOnboardingAuthentication({
|
||||||
|
closeRequestId,
|
||||||
|
closeWindow,
|
||||||
|
isAuthenticated,
|
||||||
|
needsLogin,
|
||||||
|
onCloseError: vi.fn(),
|
||||||
|
startLogin,
|
||||||
|
}))
|
||||||
|
|
||||||
|
needsLogin.value = true
|
||||||
|
await nextTick()
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(startLogin).toHaveBeenCalledTimes(1)
|
||||||
|
expect(needsLogin.value).toBe(false)
|
||||||
|
expect(closeWindow).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
isAuthenticated.value = true
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(closeWindow).toHaveBeenCalledTimes(1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes when another renderer publishes a close request', async () => {
|
||||||
|
const closeRequestId = shallowRef(0)
|
||||||
|
const closeWindow = vi.fn<() => Promise<void>>().mockResolvedValue()
|
||||||
|
const scope = effectScope()
|
||||||
|
|
||||||
|
scope.run(() => useOnboardingAuthentication({
|
||||||
|
closeRequestId,
|
||||||
|
closeWindow,
|
||||||
|
isAuthenticated: shallowRef(false),
|
||||||
|
needsLogin: shallowRef(false),
|
||||||
|
onCloseError: vi.fn(),
|
||||||
|
startLogin: vi.fn<() => Promise<void>>().mockResolvedValue(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
closeRequestId.value += 1
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(closeWindow).toHaveBeenCalledTimes(1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a close retry after Electron rejects the first request', async () => {
|
||||||
|
const closeWindow = vi.fn<() => Promise<void>>()
|
||||||
|
.mockRejectedValueOnce(new Error('window unavailable'))
|
||||||
|
.mockResolvedValue()
|
||||||
|
const onCloseError = vi.fn()
|
||||||
|
const scope = effectScope()
|
||||||
|
const controls = scope.run(() => useOnboardingAuthentication({
|
||||||
|
closeRequestId: shallowRef(0),
|
||||||
|
closeWindow,
|
||||||
|
isAuthenticated: shallowRef(false),
|
||||||
|
needsLogin: shallowRef(false),
|
||||||
|
onCloseError,
|
||||||
|
startLogin: vi.fn<() => Promise<void>>().mockResolvedValue(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
await controls!.closeOnboardingWindow()
|
||||||
|
await controls!.closeOnboardingWindow()
|
||||||
|
|
||||||
|
expect(closeWindow).toHaveBeenCalledTimes(2)
|
||||||
|
expect(onCloseError).toHaveBeenCalledTimes(1)
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
|
interface UseOnboardingAuthenticationOptions {
|
||||||
|
closeRequestId: Readonly<Ref<number>>
|
||||||
|
closeWindow: () => Promise<unknown>
|
||||||
|
isAuthenticated: Readonly<Ref<boolean>>
|
||||||
|
needsLogin: Ref<boolean>
|
||||||
|
onCloseError: (error: unknown) => void
|
||||||
|
startLogin: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingAuthenticationControls {
|
||||||
|
closeOnboardingWindow: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates sign-in and window closure for the standalone onboarding renderer.
|
||||||
|
*
|
||||||
|
* The renderer that starts the external sign-in remains alive until synchronized
|
||||||
|
* authentication state confirms completion. Close requests are deduplicated while
|
||||||
|
* the Electron close operation is in flight.
|
||||||
|
*/
|
||||||
|
export function useOnboardingAuthentication(options: UseOnboardingAuthenticationOptions): OnboardingAuthenticationControls {
|
||||||
|
let closing = false
|
||||||
|
|
||||||
|
/** Closes the onboarding window once and permits a retry after a failed close. */
|
||||||
|
async function closeOnboardingWindow(): Promise<void> {
|
||||||
|
if (closing)
|
||||||
|
return
|
||||||
|
|
||||||
|
closing = true
|
||||||
|
try {
|
||||||
|
await options.closeWindow()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
closing = false
|
||||||
|
options.onCloseError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shared action publishes a close request from the renderer that finishes
|
||||||
|
// authentication. This renderer remains the sole owner of the Electron close
|
||||||
|
// side effect. The auth check also handles a window mounted after the request.
|
||||||
|
watch([options.isAuthenticated, options.closeRequestId], ([authenticated, requestId], previous) => {
|
||||||
|
const previousRequestId = previous?.[1]
|
||||||
|
if (authenticated || (previousRequestId !== undefined && requestId !== previousRequestId))
|
||||||
|
void closeOnboardingWindow()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// The onboarding window is a separate Electron renderer with its own Pinia
|
||||||
|
// instance. It must initiate login itself and stay alive for the token callback.
|
||||||
|
watch(options.needsLogin, async (needsLogin) => {
|
||||||
|
if (!needsLogin || options.isAuthenticated.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
await options.startLogin()
|
||||||
|
options.needsLogin.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return { closeOnboardingWindow }
|
||||||
|
}
|
||||||
@@ -6,9 +6,10 @@ import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
|||||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||||
import { useTheme } from '@proj-airi/ui'
|
import { useTheme } from '@proj-airi/ui'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { electronAuthStartLogin, electronOnboardingClose } from '../../shared/eventa'
|
import { electronAuthStartLogin, electronOnboardingClose } from '../../shared/eventa'
|
||||||
|
import { useOnboardingAuthentication } from '../composables/use-onboarding-authentication'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { needsLogin, isAuthenticated } = storeToRefs(authStore)
|
const { needsLogin, isAuthenticated } = storeToRefs(authStore)
|
||||||
@@ -17,40 +18,13 @@ const { closeRequestId } = storeToRefs(onboardingStore)
|
|||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const startLogin = useElectronEventaInvoke(electronAuthStartLogin)
|
const startLogin = useElectronEventaInvoke(electronAuthStartLogin)
|
||||||
const closeWindow = useElectronEventaInvoke(electronOnboardingClose)
|
const closeWindow = useElectronEventaInvoke(electronOnboardingClose)
|
||||||
let closing = false
|
const { closeOnboardingWindow } = useOnboardingAuthentication({
|
||||||
|
closeRequestId,
|
||||||
async function closeOnboardingWindow() {
|
closeWindow,
|
||||||
if (closing)
|
isAuthenticated,
|
||||||
return
|
needsLogin,
|
||||||
|
onCloseError: error => console.error('[Onboarding] Failed to close the onboarding window.', error),
|
||||||
closing = true
|
startLogin,
|
||||||
try {
|
|
||||||
await closeWindow()
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
closing = false
|
|
||||||
console.error('[Onboarding] Failed to close the onboarding window.', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The shared action publishes a close request from the renderer that finishes
|
|
||||||
// authentication. This renderer remains the sole owner of the Electron close
|
|
||||||
// side effect. The auth check also handles a window mounted after the request.
|
|
||||||
watch([isAuthenticated, closeRequestId], ([authenticated, requestId], previous) => {
|
|
||||||
const previousRequestId = previous?.[1]
|
|
||||||
if (authenticated || (previousRequestId !== undefined && requestId !== previousRequestId))
|
|
||||||
void closeOnboardingWindow()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// The onboarding window is a separate Electron process with its own Pinia instance.
|
|
||||||
// When step-welcome sets needsLogin=true, we must invoke the IPC login from here
|
|
||||||
// since the controls-island watcher only exists in the main window.
|
|
||||||
watch(needsLogin, async (val) => {
|
|
||||||
if (val && !isAuthenticated.value) {
|
|
||||||
await startLogin()
|
|
||||||
needsLogin.value = false
|
|
||||||
await closeOnboardingWindow()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white')
|
const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white')
|
||||||
|
|||||||
Reference in New Issue
Block a user