Merge pull request #6184 from deepseek-harness/fix/shared-account-avatar

fix(account): 共享头像加载结果并在打开设置时重试
This commit is contained in:
lsdsjy authored and GitHub committed 2026-10-14 15:47:50 +08:00
commit 328584875a
21 files changed
+430 -55

No files matched your search

+74 -5
View File
@@ -8,14 +8,15 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { saveFailureShot } from './support.ts'
const OVERLAY_TEMPLATE = fileURLToPath(new URL('./fixtures/account-login/cordis.patch.yml', import.meta.url))
const MODE = webSnapshotMode()
const TOKEN = 'dsh_web_login_test'
const CODE = 'web-login-code'
const AVATAR_EXPECTED = fileURLToPath(new URL('./expected/account-login/avatar-retry.expected.md', import.meta.url))
/** Loopback Platform double: authorization handoff, code exchange, and the signed-in profile reads. */
async function mockPlatform() {
@@ -23,6 +24,7 @@ async function mockPlatform() {
const pages: string[] = []
let state: string | undefined
let redirectUri: string | undefined
let avatarRequests = 0
const server: Server = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
const reply = (payload: unknown): void => {
@@ -64,10 +66,17 @@ async function mockPlatform() {
})
return
}
if (url.pathname === '/avatar.svg') {
avatarRequests++
if (avatarRequests < 3) { res.writeHead(503, { 'cache-control': 'no-store' }).end(); return }
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'max-age=3600' })
.end('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="16" fill="blue"/></svg>')
return
}
if (req.headers['x-dsh-auth-token'] !== TOKEN) { res.writeHead(401).end(); return }
if (url.pathname === '/auth-api/v0/users/current') {
// `email` is required by the provider's profile schema even when the account has none.
reply({ id: 'web-user', email: '', id_profile: { name: 'Web User', picture: null } })
reply({ id: 'web-user', email: '', id_profile: { name: 'Web User', picture: `${origin}/avatar.svg` } })
return
}
if (url.pathname === '/api/v0/users/get_user_summary') {
@@ -82,7 +91,7 @@ async function mockPlatform() {
if (address === null || typeof address === 'string') throw new Error('account login: missing mock listener')
const origin = `http://127.0.0.1:${String(address.port)}`
return {
origin, inits, pages,
origin, inits, pages, avatarRequests: () => avatarRequests,
close: () => new Promise<void>((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }),
}
}
@@ -109,8 +118,9 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain
if (root !== undefined) await rm(root, { recursive: true, force: true })
})
it('signs in through a preopened themed tab and reports the Web login source', async () => {
it('signs in through a themed tab and recovers both avatars on a later Settings entry', async () => {
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: 'en-US', colorScheme: 'dark' })
onTestFinished(() => context.close())
const page = await context.newPage()
const console = watchConsole(page)
onTestFailed(() => saveFailureShot(page, 'web-e2e-account-login'))
@@ -130,6 +140,65 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek account sign-in in a plain
const [authorize, authorized] = platform.pages.map(href => new URL(href))
expect(authorize?.searchParams.get('theme')).toBe('dark')
expect(authorized?.searchParams.get('login_source')).toBe('web')
await expect.poll(platform.avatarRequests).toBe(1)
await menu.locator('[data-avatar-status="failed"]').waitFor()
expect(await menu.locator('img').count()).toBe(0)
const states: string[] = []
const capture = async (stage: string): Promise<void> => {
const sidebar = menu.locator('[data-avatar-status]')
const account = page.getByRole('region', { name: 'Account', exact: true })
const mounted = await account.count() > 0
const settingsStatus = mounted
? await account.locator('[data-avatar-status]').getAttribute('data-avatar-status') : 'unmounted'
states.push(`${stage}: sidebar=${await sidebar.getAttribute('data-avatar-status')}, settings=${settingsStatus}, `
+ `sidebarImages=${await menu.locator('img').count()}, settingsImages=${mounted ? await account.locator('img').count() : 0}, `
+ `requests=${platform.avatarRequests()}`)
}
await capture('Initial load')
await menu.click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
const account = settings.getByRole('region', { name: 'Account', exact: true })
await expect.poll(platform.avatarRequests).toBe(2)
await account.locator('[data-avatar-status="failed"]').waitFor()
expect(await menu.locator('img').count()).toBe(0)
expect(await account.locator('img').count()).toBe(0)
await capture('First Settings entry')
await settings.getByRole('button', { name: 'Close', exact: true }).click()
await settings.waitFor({ state: 'detached' })
await menu.click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
await expect.poll(() => menu.locator('img').count()).toBe(1)
await expect.poll(() => account.locator('img').count()).toBe(1)
for (const image of [menu.locator('img'), account.locator('img')]) {
await expect.poll(() => image.evaluate(element =>
element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0)).toBe(true)
expect(await image.getAttribute('src')).toBe(`${platform.origin}/avatar.svg`)
}
await capture('Second Settings entry')
await settings.getByRole('button', { name: 'Close', exact: true }).click()
await settings.waitFor({ state: 'detached' })
await menu.click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
await account.waitFor()
expect(await menu.locator('img').count()).toBe(1)
expect(await account.locator('img').count()).toBe(1)
expect(platform.avatarRequests()).toBe(3)
await capture('Third Settings entry')
await menu.locator('img').dispatchEvent('error')
await menu.locator('[data-avatar-status="failed"]').waitFor()
await account.locator('[data-avatar-status="failed"]').waitFor()
expect(await menu.locator('img').count()).toBe(0)
expect(await account.locator('img').count()).toBe(0)
await capture('Rendered image failure')
await settings.getByRole('button', { name: 'Close', exact: true }).click()
await settings.waitFor({ state: 'detached' })
await menu.click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
await expect.poll(() => menu.locator('img').count()).toBe(1)
await expect.poll(() => account.locator('img').count()).toBe(1)
await capture('Settings entry after rendered failure')
await compareOrRefreshGolden(AVATAR_EXPECTED, states.join('\n'), MODE)
expect(console.pageErrors).toEqual([])
await context.close()
}, 90_000)
@@ -0,0 +1,6 @@
Initial load: sidebar=failed, settings=unmounted, sidebarImages=0, settingsImages=0, requests=1
First Settings entry: sidebar=failed, settings=failed, sidebarImages=0, settingsImages=0, requests=2
Second Settings entry: sidebar=ready, settings=ready, sidebarImages=1, settingsImages=1, requests=3
Third Settings entry: sidebar=ready, settings=ready, sidebarImages=1, settingsImages=1, requests=3
Rendered image failure: sidebar=failed, settings=failed, sidebarImages=0, settingsImages=0, requests=3
Settings entry after rendered failure: sidebar=ready, settings=ready, sidebarImages=1, settingsImages=1, requests=3
@@ -15,8 +15,8 @@
en: c26f81e8adf0128b
zh: e11ab371f51f66bc
/deepseek-ai-dsh-client-ui-settings-account/use-this-package:
en: 8fabc9c4fb214857
zh: a5d4c085f4812e75
en: a0e4b5bbef33ca6d
zh: 697978822aeddf6e
/deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding:
en: c1c4db71fd09ca13
zh: a40c0a01e9951945
@@ -30,7 +30,7 @@ A new account-model sign-in-required event displays “Model unavailable. Please
Desktop and plain Web clients both mount the sidebar account launcher, Account settings, the sign-in step of model onboarding, and the account-state subscription. Desktop main opens the authorization page in the system browser. A plain Web client, identified by the absent Desktop preload bridge, sends `login_source` web with its own origin as the callback origin, preopens a tab during the sign-in click, navigates it to the authorization URL with the same `theme` parameter once its attempt receives one, and closes it on failure, expiry, or cancellation; a blocked popup leaves the copy-link route. Embedded Platform pages, Desktop onboarding, and the native device reader stay Desktop-only, so Web Platform links open new tabs.
The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively, with the account icon as fallback when the URL is absent or the image fails to load. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph.
The sidebar and Account settings display circular profile images with diameters of 24px and 32px respectively. Both observe one browser image load and show the account icon while no URL is available, loading is pending, or loading failed. A rendered image failure updates the shared result, so both surfaces return to the icon and remain eligible for retry; reports from a superseded picture attempt are ignored. Each Settings entry, or return from a Settings or quota-notice top-up page, retries a failed image once even when its URL is unchanged; pending and successful loads are reused, and success updates both surfaces. A changed picture URL starts a new load; account-state replacement, sign-out, and plugin unload discard the previous picture and its callbacks. The collapsed sidebar centers the avatar in a 36 × 36 px button. Before sign-in the sidebar launcher shows a More row instead, leading with the ellipsis glyph.
The section registers through settings.section and uses the account Remote namespace. The stream survives carrier reconnects through the shared Remote supervisor. The page renders feature-owned English and Chinese copy and keeps API keys separate from account state.
@@ -30,7 +30,7 @@ kind: "package-reference"
Desktop 与普通 Web 客户端都挂载侧边栏账号菜单、账号设置、模型引导中的登录步骤,并订阅账号状态。Desktop 主进程在系统浏览器中打开授权页面。普通 Web 客户端(以缺少 Desktop preload 桥接识别)以 `login_source` web 发起登录,并以自身 origin 作为回调 origin;它在点击登录时预先打开一个标签页,本次尝试获得授权 URL 后带上相同的 `theme` 参数导航该标签页,并在失败、过期或取消时关闭它;弹窗被拦截时仍可使用复制链接。内嵌 Platform 页面、Desktop 引导和原生设备信息读取器仅在 Desktop 中提供,因此 Web 中的 Platform 链接在新标签页打开。
侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px;头像 URL 缺失或图片加载失败时使用账号图标兜底。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。
侧边栏和账号设置以正圆图片显示账号头像,直径分别为 24px 和 32px。两处共享一次浏览器图片加载,URL 缺失、加载中或加载失败时使用账号图标兜底。渲染图片失败时更新共享结果,两处一起恢复为图标,并保留重试机会;已被替换的图片加载尝试上报的失败会被忽略。每次进入设置,或从设置及余额不足提示打开的充值页面返回时,都会重试一次失败的图片,即使 URL 未变化;加载中和已成功的图片会复用,成功后两处一起展示。头像 URL 变化时开始新加载;账号状态替换、退出登录和插件卸载会丢弃旧头像及其回调。折叠侧边栏将头像居中放置在 36 × 36 px 的按钮内。登录前侧边栏入口改为「更多」行,以三点图标开头。
该页面通过 settings.section 注册,并使用 account Remote 命名空间。共享 Remote 管理器负责连接恢复后的流重连。页面使用功能自有的中英文文案,将 API Key 与账号状态分开。
@@ -1,16 +1,19 @@
/** Shared account picture with an icon fallback for missing or unavailable images. */
import { useState } from 'react'
import { IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AccountAvatarSnapshot } from './account-avatar.ts'
import css from './AccountAvatar.module.css'
/**
* Render a decorative avatar next to the account identity.
* @param props - profile picture URL; absent while signed out or loading.
* @param props - shared browser load outcome and rendered-image failure reporter; absent data uses the default icon.
* @returns picture or the default account icon.
*/
export function AccountAvatar({ url }: { url?: string | null | undefined }) {
const [failedUrl, setFailedUrl] = useState<string>()
return url && url !== failedUrl
? <img className={css.image} src={url} alt="" referrerPolicy="no-referrer" onError={() => { setFailedUrl(url) }} />
export function AccountAvatar({ avatar, avatarFailed }: {
avatar: AccountAvatarSnapshot | undefined
avatarFailed: (snapshot: AccountAvatarSnapshot) => void
}) {
return avatar?.status === 'ready'
? <img className={css.image} src={avatar.url} alt="" referrerPolicy="no-referrer"
onError={() => { avatarFailed(avatar) }} />
: <IconUserOutlineMedium size={16} />
}
@@ -22,7 +22,7 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'
export function AccountMenu({
subscribeSessionExpired, subscribeModelSignInRequired, wide, settingsShortcut, openSettings, openOnboarding, settingsOpen,
useAccount, useTheme, signOut, hasRunningAccountTasks, refreshAccount, bonusNoticeShown, bonusNoticeDismissed,
contactUs, showLogin, start, cancel, t,
contactUs, showLogin, start, cancel, avatarFailed, t,
}: AccountMenuProps) {
const anchor = useRef<HTMLDivElement>(null)
// The launcher outlives the panel, so a false-to-true edge is one Settings entry:
@@ -65,7 +65,9 @@ export function AccountMenu({
anchor={<button ref={trigger} type="button" className={css.trigger} data-collapsed={!wide} data-signed-out={!signedIn} aria-label={t('menu')}
aria-haspopup="menu" aria-expanded={open} onClick={() => { setOpen(value => !value) }}>
{signedIn
? <span className={css.avatar}><AccountAvatar url={profile?.status === 'ready' ? profile.value.avatarUrl : null} /></span>
? <span className={css.avatar} data-avatar-status={account.avatar?.status}>
<AccountAvatar avatar={account.avatar} avatarFailed={avatarFailed} />
</span>
: <IconEllipsisOutlineMedium size={14} />}
{wide && <span className={css.label}>{signedIn ? label : t('more')}</span>}
</button>}
@@ -8,6 +8,7 @@ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { PlatformPage, PlatformPages } from './platform-pages.ts'
import { formatBalance } from './formatBalance.ts'
import { AccountAvatar } from './AccountAvatar.tsx'
import type { AccountAvatarSnapshot } from './account-avatar.ts'
import { authorizeUrlWithTheme } from './authorize-url.ts'
import type { BonusNotice } from './bonus-notices.ts'
import css from './AccountSection.module.css'
@@ -21,6 +22,8 @@ export interface AccountSnapshot {
view: AccountView | undefined
/** Sanitized profile and balance query outcomes, absent while loading. */
details: Partial<AccountDetails> | undefined
/** Shared picture load; absent until a profile supplies a picture or after sign-out. */
avatar: AccountAvatarSnapshot | undefined
/** Whether the state stream failed. */
failed: boolean
/** Explicitly opened account dialog outside onboarding. */
@@ -37,6 +40,8 @@ export interface AccountSnapshot {
/** Host operations injected into the Cordis-free account component. */
export interface AccountSectionInjected {
/** @param snapshot - picture attempt whose rendered image failed; superseded attempts are ignored. */
avatarFailed: (snapshot: AccountAvatarSnapshot) => void
/** Subscribe to live credential-expiry notifications.
* @param listener - callback after the current credential is removed.
* @returns listener cleanup.
@@ -57,9 +62,9 @@ export interface AccountSectionInjected {
theme: HostObservable<ThemeSnapshot>
}
/**
* Read the balance, bonus wallets and unnotified bonus once. The Settings launcher
* Retry a failed avatar and read the profile, balance, bonus wallets and unnotified bonus once. The Settings launcher
* calls it on each entry, and a page opener on return from top-up.
* @returns after both reads settle.
* @returns after the account queries settle; image loading completes independently.
*/
refreshAccount: () => Promise<void>
/** Open the external support questionnaire with the account, build and environment sampled by this click. */
@@ -85,8 +90,8 @@ export interface AccountSectionInjected {
export type AccountSectionProps =
PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>
/** @param props - localized actions, account subscription, and the shared Platform page channel. @returns account settings UI. */
export function AccountSection({ t, useAccount, useTheme, start, cancel, openPlatformPage }: AccountSectionProps) {
const { view: state, details, failed: streamFailed } = useAccount(value => value)
export function AccountSection({ t, useAccount, useTheme, start, cancel, openPlatformPage, avatarFailed }: AccountSectionProps) {
const { view: state, details, avatar, failed: streamFailed } = useAccount(value => value)
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
// The shared host owns the native view; this page holds only its own request,
// and only while it is the latest owner.
@@ -159,7 +164,9 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, openPla
<section className={css.section} aria-label={t('nav')}>
<div className={css.card}>
<div className={css.identity}>
<span className={css.avatar}><AccountAvatar url={signedIn ? profile?.avatarUrl : null} /></span>
<span className={css.avatar} data-avatar-status={signedIn ? avatar?.status : undefined}>
<AccountAvatar avatar={signedIn ? avatar : undefined} avatarFailed={avatarFailed} />
</span>
<div className={css.identityCopy}>
<span className={css.name}>{signedIn ? profile?.name ?? t('signedIn') : t('signedOut')}</span>
<span className={css.status} role="status">{status}</span>
@@ -0,0 +1,92 @@
/** One account picture load shared by the sidebar and Settings for this plugin lifetime. */
/** Browser image outcome for the latest profile picture URL. */
export interface AccountAvatarSnapshot {
/** Profile picture whose browser load produced this state. */
url: string
/** Both account surfaces show the picture only after its shared load succeeds. */
status: 'loading' | 'ready' | 'failed'
}
/** Owns the browser image and discards callbacks after URL replacement or plugin unload. */
export class AccountAvatarLoader {
private snapshot: AccountAvatarSnapshot | undefined
private image: HTMLImageElement | undefined
private disposed = false
/** @param publish - publishes picture changes through the account snapshot. */
constructor(private readonly publish: (snapshot: AccountAvatarSnapshot | undefined) => void) {}
/**
* Load a changed URL once; an absent or empty URL clears the picture.
* @param url - latest successful profile's picture, absent when no picture is available.
*/
setUrl(url: string | null | undefined): void {
if (this.disposed) return
const source = url || undefined
if (this.snapshot?.url === source) return
if (source === undefined) {
this.detach()
this.update(undefined)
}
else this.load(source)
}
/** Retry a failed picture once; successful and pending loads remain shared. */
retry(): void {
if (!this.disposed && this.snapshot?.status === 'failed') this.load(this.snapshot.url)
}
/**
* Return both surfaces to fallback when a rendered picture fails.
* @param snapshot - picture attempt rendered by the failing element; superseded attempts are ignored.
*/
failed(snapshot: AccountAvatarSnapshot): void {
if (this.disposed || this.snapshot !== snapshot || snapshot.status !== 'ready') return
this.detach()
this.update({ url: snapshot.url, status: 'failed' })
}
/** Release the picture without publishing; the owner publishes an empty avatar with the replacement account frame. */
reset(): void {
this.detach()
this.snapshot = undefined
}
/** Release the browser image and prevent further loads or publications. */
dispose(): void {
this.disposed = true
this.reset()
}
private load(url: string): void {
this.detach()
const image = new Image()
this.image = image
image.referrerPolicy = 'no-referrer'
const settle = (status: 'ready' | 'failed'): void => {
if (this.image !== image) return
image.onload = null
image.onerror = null
this.update({ url, status })
}
image.onload = () => { settle('ready') }
image.onerror = () => { settle('failed') }
this.update({ url, status: 'loading' })
if (this.image === image) image.src = url
}
private update(snapshot: AccountAvatarSnapshot | undefined): void {
this.snapshot = snapshot
this.publish(snapshot)
}
private detach(): void {
const image = this.image
this.image = undefined
if (image === undefined) return
image.onload = null
image.onerror = null
image.removeAttribute('src')
}
}
@@ -30,6 +30,7 @@ import { DesktopOnboardingController } from './onboarding-state.ts'
import { DesktopOnboardingEntry, type DesktopOnboardingInjected } from './DesktopOnboardingEntry.tsx'
import { readOnboardingApiKeyPresence } from './onboarding-credentials.ts'
import { refreshAfterReturn } from './account-refresh.ts'
import { AccountAvatarLoader } from './account-avatar.ts'
export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx'
export type { AccountMenuProps } from './AccountMenu.tsx'
export type { AccountSnapshot } from './AccountSection.tsx'
@@ -49,13 +50,15 @@ export function apply(ctx: Context): void {
const page = globalThis as Partial<Record<typeof CONTACT_CONFIG_GLOBAL, unknown>>
const config = ContactConfig(page[CONTACT_CONFIG_GLOBAL] ?? {})
let snapshot: AccountSnapshot = {
view: undefined, details: undefined, failed: false, loginVisible: false, desktopOnboardingVisible: desktop,
view: undefined, details: undefined, avatar: undefined, failed: false, loginVisible: false, desktopOnboardingVisible: desktop,
}
// Desktop main opens the authorization page natively; a plain browser opens it in a tab it owns.
const browser = new BrowserLogin(url => authorizeUrlWithTheme(url, ctx.theme.getTheme().active.colorScheme))
ctx.effect(() => () => { browser.dispose() }, 'account: pending browser window')
const listeners = new Set<() => void>()
const publish = (value: AccountSnapshot) => { snapshot = value; for (const listener of listeners) listener() }
const avatar = new AccountAvatarLoader((value) => { publish({ ...snapshot, avatar: value }) })
ctx.effect(() => () => { avatar.dispose() }, 'account: profile picture lifetime')
/** @returns the client identity for one account call, read at call time so it carries the language and zone in effect then. */
const client = () => accountClientMetadata(ctx.locale.getSnapshot().active, process.env.DSH_CLIENT_VERSION)
// The browser half of the notice lifecycle: reads when the account becomes
@@ -100,6 +103,7 @@ export function apply(ctx: Context): void {
read('profile', async () => {
const result = await ctx.remote.account.getProfile(client())
if (!result.ok) throw new Error('account profile failed')
if (generation === revision && result.value?.status === 'ready') avatar.setUrl(result.value.value.avatarUrl)
return result.value
}),
read('balance', async () => {
@@ -122,10 +126,11 @@ export function apply(ctx: Context): void {
for await (const frame of stream) {
revision++
refreshing = undefined
avatar.reset()
const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded'
&& snapshot.view?.attempt?.phase !== 'succeeded'
const { notice, ...previous } = snapshot
publish({ ...previous, view: frame.value, details: undefined, failed: false,
publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false,
...(frame.value.status === 'credential-stored' && notice ? { notice } : {}) })
browser.update(frame.value)
frame.accept()
@@ -151,7 +156,10 @@ export function apply(ctx: Context): void {
// One account refresh: the recharge/bonus wallet and the unnotified-bonus read,
// whichever surface asks — a Settings entry, or returning from top-up. Both
// reads are independent, and a concurrent refresh shares the in-flight request.
const refreshAccount = async (): Promise<void> => { await Promise.all([refresh(), notices.refresh()]) }
const refreshAccount = async (): Promise<void> => {
avatar.retry()
await Promise.all([refresh(), notices.refresh()])
}
/**
* Narrow the shared channel to one surface's post-return read: only a viewer
* returning from top-up re-reads, so a superseded page or a plugin teardown
@@ -165,6 +173,7 @@ export function apply(ctx: Context): void {
if (reason === 'returned' && page === 'top-up') void refreshAfterReturn(refreshing, reRead)
})
const operations: AccountSectionInjected = {
avatarFailed: (value) => { avatar.failed(value) },
subscribeSessionExpired: listener => ctx.remote.$on('deepseek-account/session-expired', listener),
subscribeModelSignInRequired: listener => ctx.remote.$on('deepseek-account/model-sign-in-required', listener),
...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) },
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { AccountAvatarLoader, type AccountAvatarSnapshot } from '../src/client/account-avatar.ts'
afterEach(() => { vi.unstubAllGlobals() })
function imagesOf() {
const images: HTMLImageElement[] = []
vi.stubGlobal('Image', function () {
const image = document.createElement('img')
images.push(image)
return image
})
const states: Array<AccountAvatarSnapshot | undefined> = []
const loader = new AccountAvatarLoader((state) => { states.push(state) })
onTestFinished(() => { loader.dispose() })
return { images, states, loader }
}
it('shares successful and pending loads, and retries each failed load only on request', async () => {
const { images, states, loader } = imagesOf()
loader.retry()
loader.setUrl('https://example.test/avatar.png')
expect(images[0]!.referrerPolicy).toBe('no-referrer')
expect(images[0]!.src).toBe('https://example.test/avatar.png')
loader.setUrl('https://example.test/avatar.png')
loader.retry()
expect(images).toHaveLength(1)
images[0]!.dispatchEvent(new Event('error'))
loader.setUrl('https://example.test/avatar.png')
expect(images).toHaveLength(1)
loader.retry()
images[1]!.dispatchEvent(new Event('error'))
loader.retry()
images[2]!.dispatchEvent(new Event('load'))
loader.retry()
loader.setUrl('https://example.test/avatar.png')
expect(images).toHaveLength(3)
await expect(`${JSON.stringify(states, null, 2)}\n`).toMatchFileSnapshot('./expected/avatar-retry.txt')
loader.setUrl(null)
expect(states.at(-1)).toBeUndefined()
expect(images[2]!.getAttribute('src')).toBeNull()
loader.setUrl('')
expect(images).toHaveLength(3)
})
it('discards replaced and disposed image callbacks, including the same URL after sign-out', () => {
const { images, states, loader } = imagesOf()
loader.setUrl('https://example.test/old.png')
const oldLoad = images[0]!.onload!
const oldError = images[0]!.onerror!
loader.setUrl('https://example.test/new.png')
expect(images[0]!.onload).toBeNull()
expect(images[0]!.onerror).toBeNull()
oldLoad.call(images[0]!, new Event('load'))
oldError.call(images[0]!, new Event('error'))
expect(states.at(-1)).toEqual({ url: 'https://example.test/new.png', status: 'loading' })
loader.setUrl(undefined)
loader.setUrl('https://example.test/new.png')
expect(images).toHaveLength(3)
const lateLoad = images[2]!.onload!
const lateError = images[2]!.onerror!
loader.dispose()
const published = states.length
lateLoad.call(images[2]!, new Event('load'))
lateError.call(images[2]!, new Event('error'))
loader.setUrl('https://example.test/after-disposal.png')
loader.retry()
loader.dispose()
expect(states).toHaveLength(published)
expect(images).toHaveLength(3)
})
it('does not start a load when publication releases its owner', () => {
const image = document.createElement('img')
vi.stubGlobal('Image', function () { return image })
const loader = new AccountAvatarLoader(() => { loader.dispose() })
onTestFinished(() => { loader.dispose() })
loader.setUrl('https://example.test/avatar.png')
expect(image.getAttribute('src')).toBeNull()
expect(image.onload).toBeNull()
expect(image.onerror).toBeNull()
})
it('shares rendered-image failures and ignores reports from a superseded picture attempt', () => {
const { images, states, loader } = imagesOf()
loader.setUrl('https://example.test/avatar.png')
const loading = states.at(-1)!
loader.failed(loading)
expect(states.at(-1)).toBe(loading)
images[0]!.dispatchEvent(new Event('load'))
const ready = states.at(-1)!
loader.failed(ready)
expect(states.at(-1)).toEqual({ url: ready.url, status: 'failed' })
loader.retry()
images[1]!.dispatchEvent(new Event('load'))
const successor = states.at(-1)!
loader.failed(ready)
expect(states.at(-1)).toBe(successor)
loader.dispose()
loader.failed(successor)
expect(states.at(-1)).toBe(successor)
})
it('resets an account picture without publishing an intermediate account snapshot', () => {
const { images, states, loader } = imagesOf()
loader.setUrl('https://example.test/avatar.png')
const lateLoad = images[0]!.onload!
loader.reset()
lateLoad.call(images[0]!, new Event('load'))
expect(states).toHaveLength(1)
loader.setUrl('https://example.test/avatar.png')
expect(images).toHaveLength(2)
})
@@ -14,6 +14,7 @@ import type { AccountMenuProps } from '../src/client/AccountMenu.tsx'
import type {} from '../src/client/index.ts'
import { en, zh, type AccountKey } from '../src/client/locales.ts'
import css from '../src/client/AccountSection.module.css'
import type { AccountAvatarSnapshot } from '../src/client/account-avatar.ts'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
@@ -23,12 +24,13 @@ const themeOf = (colorScheme: 'light' | 'dark'): ThemeSnapshot => ({
active: { id: colorScheme, colorScheme, tokens: {} }, themes: [], revision: 0,
})
function operationsOf(state: Omit<AccountView, 'links'>, details?: Partial<AccountDetails>, pages?: PlatformPages): AccountSectionInjected {
function operationsOf(state: Omit<AccountView, 'links'>, details?: Partial<AccountDetails>, pages?: PlatformPages,
avatar?: AccountAvatarSnapshot): AccountSectionInjected {
return {
...pages === undefined ? {} : { openPlatformPage: pages.open },
hooks: {
account: {
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, failed: false }),
getSnapshot: () => ({ view: { ...state, links: { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' } }, details, avatar, failed: false }),
subscribe: () => () => {},
},
theme: { getSnapshot: () => themeOf('light'), subscribe: () => () => {} },
@@ -37,13 +39,15 @@ function operationsOf(state: Omit<AccountView, 'links'>, details?: Partial<Accou
contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(),
bonusNoticeShown: vi.fn(), bonusNoticeDismissed: vi.fn(),
refreshAccount: vi.fn(() => Promise.resolve()),
avatarFailed: vi.fn(),
start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()),
}
}
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>, platform?: PlatformBridge) {
function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh = en, details?: Partial<AccountDetails>,
platform?: PlatformBridge, avatar?: AccountAvatarSnapshot) {
const pages = platform === undefined ? undefined : createPlatformPages()
const operations = operationsOf(state, details, pages)
const operations = operationsOf(state, details, pages, avatar)
// AccountSection consumes no global hooks; the slot supplies them in the application.
const globals = {} as GlobalStandardProps
render(<>
@@ -202,7 +206,7 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async (
cleanup()
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
const links = { usageUrl: 'http://localhost:8081/usage', topUpUrl: 'http://localhost:8081/top_up' }
let snapshot: AccountSnapshot = { view: { status: 'signed-out', attempt: null, links }, details: undefined, failed: false }
let snapshot: AccountSnapshot = { view: { status: 'signed-out', attempt: null, links }, details: undefined, avatar: undefined, failed: false }
// The plugin's start publishes the failure through the account snapshot, then rejects.
const start = vi.fn((): Promise<void> => {
snapshot = { ...snapshot, loginVisible: true, loginFailed: true }
@@ -401,10 +405,11 @@ it.each([
expect(screen.getByRole('button', { name: en.menu }).textContent).toBe(expected)
})
it('shows the profile image in settings and the sidebar, with independent load-error fallbacks', async () => {
it('shows the same shared picture outcome in settings and the sidebar', async () => {
const avatar: AccountAvatarSnapshot = { status: 'ready', url: 'https://example.test/avatar.png' }
const operations = mount({ status: 'credential-stored', attempt: null }, en, {
profile: { status: 'ready', value: { id: null, name: 'User', contact: null, avatarUrl: 'https://example.test/avatar.png' } },
})
}, undefined, avatar)
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} settingsOpen={false}
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
@@ -412,26 +417,24 @@ it('shows the profile image in settings and the sidebar, with independent load-e
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
const images = document.querySelectorAll('img')
expect(images).toHaveLength(2)
for (const image of images) {
expect(image.getAttribute('src')).toBe('https://example.test/avatar.png')
const parent = image.parentElement!
fireEvent.error(image)
expect(parent.querySelector('img')).toBeNull()
expect(parent.querySelector('svg')).not.toBeNull()
}
for (const image of images) expect(image.getAttribute('src')).toBe('https://example.test/avatar.png')
for (const image of images) fireEvent.error(image)
expect(operations.avatarFailed).toHaveBeenCalledTimes(2)
expect(operations.avatarFailed).toHaveBeenCalledWith(avatar)
})
it('retries a changed avatar URL after an image fails', async () => {
it.each(['loading', 'failed'] as const)('shows fallback until the shared avatar is ready (%s)', async (status) => {
const { AccountAvatar } = await import('../src/client/AccountAvatar.tsx')
const view = render(<AccountAvatar />)
const avatarFailed = vi.fn()
const view = render(<AccountAvatar avatar={undefined} avatarFailed={avatarFailed} />)
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
view.rerender(<AccountAvatar url="https://example.test/old.png" />)
fireEvent.error(view.container.querySelector('img')!)
view.rerender(<AccountAvatar avatar={{ url: 'https://example.test/avatar.png', status }} avatarFailed={avatarFailed} />)
expect(view.container.querySelector('img')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
view.rerender(<AccountAvatar url="https://example.test/new.png" />)
expect(view.container.querySelector('img')!.getAttribute('src')).toBe('https://example.test/new.png')
view.rerender(<AccountAvatar url={null} />)
view.rerender(<AccountAvatar avatar={{ url: 'https://example.test/avatar.png', status: 'ready' }} avatarFailed={avatarFailed} />)
expect(view.container.querySelector('img')!.getAttribute('src')).toBe('https://example.test/avatar.png')
view.rerender(<AccountAvatar avatar={undefined} avatarFailed={avatarFailed} />)
expect(view.container.querySelector('img')).toBeNull()
})
@@ -556,7 +559,7 @@ it('opens the embedded Platform page from a failed balance row on Desktop', asyn
it('reports a rejected settings login and disables login while initial state is unavailable', async () => {
const operations = mount({ status: 'signed-out', attempt: null })
cleanup()
let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: true }
let snapshot: AccountSnapshot = { view: undefined, details: undefined, avatar: undefined, failed: true }
const props = { ...({} as GlobalStandardProps), ...operations,
start: vi.fn(async () => { throw new Error('unavailable') }),
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
@@ -207,6 +207,48 @@ it('reports the account sampled by the click when a native read outlasts a sign-
expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.get('prefill_uid')).toBe('account-user')
}, 60_000)
it('publishes one avatar load and retries it through the shared Settings refresh action', async ({ start }) => {
const images: HTMLImageElement[] = []
vi.stubGlobal('Image', function () {
const image = document.createElement('img')
images.push(image)
return image
})
const c = await start()
const actions = operations(c)
const picture: AccountDetails['profile'] = {
status: 'ready', value: { id: 'account-user' as AccountUserId, name: 'User', contact: null, avatarUrl: 'https://example.test/avatar.png' },
}
c.mock.remote.account.getProfile.mockResolvedValue(ok(picture))
c.mock.streams.push('account/watch', stored)
await vi.waitFor(() => { expect(images).toHaveLength(1) })
images[0]!.dispatchEvent(new Event('error'))
expect(actions.hooks.account.getSnapshot().avatar?.status).toBe('failed')
await actions.refreshAccount()
await actions.refreshAccount()
expect(images).toHaveLength(2)
images[1]!.dispatchEvent(new Event('error'))
await actions.refreshAccount()
expect(images).toHaveLength(3)
images[2]!.dispatchEvent(new Event('load'))
expect(actions.hooks.account.getSnapshot().avatar).toEqual({ url: picture.value.avatarUrl, status: 'ready' })
await actions.refreshAccount()
expect(images).toHaveLength(3)
const ready = actions.hooks.account.getSnapshot().avatar!
actions.avatarFailed(ready)
expect(actions.hooks.account.getSnapshot().avatar?.status).toBe('failed')
await actions.refreshAccount()
expect(images).toHaveLength(4)
images[3]!.dispatchEvent(new Event('load'))
const successor = actions.hooks.account.getSnapshot().avatar!
actions.avatarFailed(ready)
expect(actions.hooks.account.getSnapshot().avatar).toBe(successor)
c.mock.streams.push('account/watch', view)
await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(view) })
expect(actions.hooks.account.getSnapshot().avatar).toBeUndefined()
expect(images[3]!.getAttribute('src')).toBeNull()
}, 60_000)
it('keeps successful account refreshes silent and reports only failed reads', async ({ start }) => {
vi.stubGlobal('dshDesktop', {})
const output = vi.spyOn(console, 'error').mockImplementation(() => undefined)
@@ -48,7 +48,7 @@ function mount(initiallyDone = false, initiallyLoading = false) {
}
let account: AccountSnapshot = {
view: { status: 'credential-stored', attempt: null, links: { usageUrl: 'https://example.com/usage', topUpUrl: 'https://example.com/top_up' } },
failed: false, details: undefined,
failed: false, details: undefined, avatar: undefined,
}
// This entry consumes only its injected selectors; the shell owns the global hooks.
const globals = {} as GlobalStandardProps
@@ -36,7 +36,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal
return saved
}}
account={{ view: { status: 'credential-stored', attempt: null, links: { usageUrl: 'https://example.com/usage', topUpUrl: 'https://example.com/top_up' } },
failed: false, details: balance === 'loading' ? {} : { balance: balance === 'failed' ? { status: 'failed' } : { status: 'ready', bonusWallets: balance === 'bonus' ? [{ currency: 'CNY', balance: '1' }] : [], value: [{ currency: 'CNY', balance: balance === 'zero' || balance === 'bonus' ? '0E-16' : '12.34' }] } } }}
avatar: undefined, failed: false, details: balance === 'loading' ? {} : { balance: balance === 'failed' ? { status: 'failed' } : { status: 'ready', bonusWallets: balance === 'bonus' ? [{ currency: 'CNY', balance: '1' }] : [], value: [{ currency: 'CNY', balance: balance === 'zero' || balance === 'bonus' ? '0E-16' : '12.34' }] } } }}
openPlatformPage={openPlatformPage} />
}
render(<App />)
@@ -0,0 +1,26 @@
[
{
"url": "https://example.test/avatar.png",
"status": "loading"
},
{
"url": "https://example.test/avatar.png",
"status": "failed"
},
{
"url": "https://example.test/avatar.png",
"status": "loading"
},
{
"url": "https://example.test/avatar.png",
"status": "failed"
},
{
"url": "https://example.test/avatar.png",
"status": "loading"
},
{
"url": "https://example.test/avatar.png",
"status": "ready"
}
]
@@ -17,7 +17,7 @@ afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await
function accountState(status?: 'credential-stored' | 'signed-out'): AccountSnapshot {
return {
view: status === undefined ? undefined : { status, attempt: null, links: { usageUrl: 'https://platform.example/usage', topUpUrl: 'https://platform.example/top_up' } },
details: undefined, failed: false,
details: undefined, avatar: undefined, failed: false,
}
}
@@ -17,7 +17,7 @@ const theme: ThemeSnapshot = {
}
it('claims onboarding while loading, offers API key fallback, and completes after login', () => {
let account: AccountSnapshot = { view: undefined, details: undefined, failed: false }
let account: AccountSnapshot = { view: undefined, details: undefined, avatar: undefined, failed: false }
const unused = (): never => { throw new Error('AccountOnboarding does not consume global hooks') }
const globals: GlobalStandardProps = {
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused,
@@ -33,6 +33,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte
start: vi.fn(async () => {}), cancel: vi.fn(async () => {}), signOut: vi.fn(async () => {}),
contactUs: vi.fn(), t: makeTranslate(en),
refreshAccount: vi.fn(async () => {}), bonusNoticeShown: vi.fn(), bonusNoticeDismissed: vi.fn(),
avatarFailed: vi.fn(),
}
const view = render(<AccountOnboarding {...props} />)
expect(props.setOnboarding).toHaveBeenCalledExactlyOnceWith(true)
@@ -64,6 +64,7 @@ function sectionOperations(account: AccountSnapshot, pages: PlatformPages): Acco
},
openPlatformPage: pages.open,
refreshAccount: async () => {},
avatarFailed: () => {},
bonusNoticeShown: () => {}, bonusNoticeDismissed: () => {},
hasRunningAccountTasks: async () => false,
contactUs: () => {}, showLogin: () => {}, setOnboarding: () => {},
@@ -119,7 +120,7 @@ async function bench() {
runtime.ctx.uiSession.provide = (next) => { descriptor ??= next; return provide(next) }
await runtime.mount({ inject: [...injectChat], apply: applyChat })
if (descriptor === undefined) throw new Error('ui-chat did not provide its standard source')
const accountSnapshot = createSnapshotStore<AccountSnapshot>({ view: storedView, details: undefined, failed: false })
const accountSnapshot = createSnapshotStore<AccountSnapshot>({ view: storedView, details: undefined, avatar: undefined, failed: false })
const account = {
getSnapshot: () => accountSnapshot.getSnapshot(),
subscribe: (listener: () => void) => accountSnapshot.subscribe(listener),
@@ -366,7 +367,7 @@ describe('shared Platform page ownership', () => {
await act(async () => { screen.getByRole('button', { name: en.quotaTopUp }).click() })
expect(b.platform.open).toHaveBeenCalledOnce()
act(() => { b.account.set({ view: { ...storedView, status: 'signed-out' }, details: undefined, failed: false }) })
act(() => { b.account.set({ view: { ...storedView, status: 'signed-out' }, details: undefined, avatar: undefined, failed: false }) })
expect(b.platform.close).toHaveBeenCalledOnce()
expect(screen.queryByRole('button', { name: en.backToHarness })).toBeNull()
@@ -26,7 +26,7 @@ const noticeMessage = 'Request quota exhausted.'
function snapshot(status?: 'signed-out' | 'credential-stored'): AccountSnapshot {
const view: AccountView = { status: status ?? 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } }
return { view: status === undefined ? undefined : view, details: undefined, failed: false }
return { view: status === undefined ? undefined : view, details: undefined, avatar: undefined, failed: false }
}
/** The live page request channel with its acquisition order retained for ordering assertions. */
@@ -14,7 +14,7 @@ function dialogProps(attempt: AccountView['attempt'], copy: typeof en | typeof z
const close = vi.fn()
const useApiKey = vi.fn()
return { start, cancel, close, useApiKey, colorScheme, t: (key: keyof typeof en) => copy[key],
account: { view: { status: 'signed-out' as const, attempt, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }, details: undefined, failed: false } }
account: { view: { status: 'signed-out' as const, attempt, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }, details: undefined, avatar: undefined, failed: false } }
}
function mount(attempt: AccountView['attempt'], copy: typeof en | typeof zh = en, colorScheme: 'light' | 'dark' = 'dark') {
const props = dialogProps(attempt, copy, colorScheme)