Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e

# Conflicts:
#	docs/subsystems/tasks.i18n.yaml
#	docs/subsystems/tasks.md
#	docs/subsystems/tasks.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/tasks/tasks-local/src/index.ts
#	packages/tasks/tasks/README.i18n.yaml
#	packages/tasks/tasks/README.md
#	packages/tasks/tasks/README.zh.md
#	packages/tasks/tasks/src/index.ts
This commit is contained in:
Yichen Jiang
2026-08-11 11:57:33 +08:00
2697 changed files with 39978 additions and 18500 deletions

View File

@@ -1,13 +1,14 @@
/** ui-theme apply wiring: service provision, settings dictionaries riding the
* locale service, declaration-aware Appearance row registration, snapshot
* projection into the row store, and HMR collapse recovery. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
@@ -17,12 +18,45 @@ usePinnedBrowserLanguages('zh-CN')
const SLOT = 'settings.general.item'
async function bench() {
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => { resolve = done })
return { promise, resolve }
}
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
let preference = 'system'
const namespace = () => ({
ns: THEME_SETTINGS_NAMESPACE,
schema: ThemeSettingsSchema.toJSON(),
value: { preference },
applies: 'live' as const,
secrets: [],
revision: 0,
})
const describe = vi.fn(() => Promise.resolve({
rpcId: 'theme-describe' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
},
}))
const mutate = vi.fn((request: { ops: { value: string }[] }) => {
preference = request.ops[0]!.value
return Promise.resolve({
rpcId: 'theme-mutate' as never,
result: { ok: true as const, value: namespace() },
})
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
return {
ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate,
setHostPreference: (next: string) => { preference = next },
}
}
/** Stand in for the settings shell: declare the General item slot from root. */
@@ -45,7 +79,7 @@ function faceOf(slots: SlotsService) {
describe('ui-theme apply', () => {
it('declares the slot and locale services', () => {
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => {
@@ -84,6 +118,57 @@ describe('ui-theme apply', () => {
face.setTheme('system')
expect(theme.getTheme().preference).toBe('system')
expect(instance.getSnapshot().preference).toBe('system')
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
})
it('loads Host settings at boot, refreshes its namespace, and keeps remote browsers process-local', async () => {
const b = await bench()
b.setHostPreference('dark')
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const theme = b.ctx.get('theme') as ThemeService
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
b.ctx.emit('settings/changed', 'unrelated')
expect(b.describe).toHaveBeenCalledOnce()
b.setHostPreference('light')
b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE)
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
b.setHostPreference('dark')
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
const remote = await bench(false)
declareItems(remote.slots)
await remote.ctx.plugin({ inject: [...inject], apply }).await()
const remoteTheme = remote.ctx.get('theme') as ThemeService
remoteTheme.setTheme('dark')
await Promise.resolve()
expect(remote.describe).not.toHaveBeenCalled()
expect(remote.mutate).not.toHaveBeenCalled()
})
it('activates before a slow initial settings read and converges when it settles', async () => {
const b = await bench()
b.setHostPreference('dark')
const describe = b.describe.getMockImplementation()!
const pending = deferred<Awaited<ReturnType<typeof describe>>>()
b.describe.mockImplementationOnce(() => pending.promise)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const theme = b.ctx.get('theme') as ThemeService
expect(theme.getTheme().preference).toBe('system')
pending.resolve(await describe())
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
await fiber.dispose()
})
it('ignores an invalid preference crossing the settings wire', async () => {
const b = await bench()
b.setHostPreference('sepia')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const theme = b.ctx.get('theme') as ThemeService
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
expect(theme.getTheme().preference).toBe('system')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {

View File

@@ -0,0 +1,30 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
} from '@deepseek-ai/dsh-client-ui-theme'
class MemorySettings extends Settings {
readonly writable = true
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
return Promise.resolve()
}
}
describe('ui-theme host', () => {
it('registers, validates, and disposes the durable theme namespace with its fiber', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
const fiber = ctx.plugin({ apply })
await fiber.await()
const ns = settingsNamespace(THEME_SETTINGS_NAMESPACE)
expect(ctx.settings.get(ns)).toEqual({ preference: DEFAULT_PREFERENCE })
await ctx.settings.update(ns, { preference: 'dark' })
expect(ctx.settings.get(ns)).toEqual({ preference: 'dark' })
await expect(ctx.settings.update(ns, { preference: 'sepia' })).rejects.toThrow()
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
})

View File

@@ -1,10 +1,10 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context } from '@deepseek-ai/cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme'
import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client'
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -15,18 +15,25 @@ describe('invariant companion', () => {
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
it('node-half waits for an optional settings provider', () => {
nodeApply(new Context())
expect(true).toBe(true)
})
it('client apply provides ctx.theme over the slots/locale edges', async () => {
// The feature registers its own Appearance settings row with localized
// copy, hence the slots + locale edges.
expect(inject).toEqual(['slots', 'locale'])
expect(inject).toEqual(['slots', 'locale', 'connection'])
const ctx = new Context()
new SlotsService(ctx)
await ctx.plugin({ inject: ['slots'], apply: localeApply }).await()
ctx.provide('connection', {
api: { settings: { describe: () => Promise.resolve({
rpcId: 'theme-invariant' as never,
result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } },
}) } },
isLoopback: true,
} as never)
await ctx.plugin({ inject: localeInject, apply: localeApply }).await()
await ctx.plugin({ inject, apply: clientApply }).await()
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
})

View File

@@ -1,21 +1,23 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => {
const make = (host = stubSettingsScope<ThemeSettings>()): {
ctx: Context
theme: ThemeService
events: ThemeSnapshot[]
host: StubSettingsScope<ThemeSettings>
} => {
const ctx = new Context()
const events: ThemeSnapshot[] = []
ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
return { ctx, theme: new ThemeService(ctx), events }
return { ctx, theme: new ThemeService(ctx, host.scope), events, host }
}
describe('ThemeService', () => {
beforeEach(() => {
localStorage.clear()
})
it('defaults to the system preference resolved against prefers-color-scheme', () => {
const { theme } = make()
const snapshot = theme.getTheme()
@@ -26,12 +28,12 @@ describe('ThemeService', () => {
expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark'])
})
it('setTheme switches, persists, republishes, and keeps DOM untouched', () => {
const { theme, events } = make()
it('setTheme switches, writes through the scope, republishes, and keeps DOM untouched', () => {
const { theme, events, host } = make()
theme.setTheme('dark')
expect(theme.getTheme().preference).toBe('dark')
expect(theme.getTheme().active.colorScheme).toBe('dark')
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
expect(host.set).toHaveBeenCalledWith('preference', 'dark')
expect(events).toHaveLength(1)
expect(events[0]).toBe(theme.getTheme())
// The service never touches presentation state.
@@ -39,13 +41,24 @@ describe('ThemeService', () => {
// Same-value set is a no-op (no extra event).
theme.setTheme('dark')
expect(events).toHaveLength(1)
expect(host.set).toHaveBeenCalledOnce()
})
it('restores a persisted preference and falls back on garbage', () => {
localStorage.setItem(STORAGE_KEY, 'dark')
expect(make().theme.getTheme().preference).toBe('dark')
localStorage.setItem(STORAGE_KEY, 'sepia')
expect(make().theme.getTheme().preference).toBe('system')
it('adopts a published Host section without writing it back', () => {
const { theme, events, host } = make()
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
expect(theme.getTheme().preference).toBe('dark')
expect(events).toHaveLength(1)
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { preference: 'dark' }, revision: 2 })
expect(events).toHaveLength(1)
})
it('adopts a section already standing at construction', () => {
const host = stubSettingsScope<ThemeSettings>()
host.publish({ status: 'ready', value: { preference: 'dark' }, revision: 1, writable: true })
const { theme } = make(host)
expect(theme.getTheme().preference).toBe('dark')
})
it('throws on unknown setTheme ids, duplicate registration, and the system id', () => {
@@ -56,7 +69,7 @@ describe('ThemeService', () => {
})
it('registered themes join the snapshot; disposing the active one resets to default', () => {
const { theme, events } = make()
const { theme, events, host } = make()
const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } })
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia'])
theme.setTheme('sepia')
@@ -64,7 +77,9 @@ describe('ThemeService', () => {
dispose()
expect(theme.getTheme().preference).toBe('system')
expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark'])
expect(localStorage.getItem(STORAGE_KEY)).toBe('system')
// Custom ids are in-process extension themes; only the built-in product
// preferences cross the Host settings schema.
expect(host.set).not.toHaveBeenCalled()
// register + set + dispose = three publishes; disposer is idempotent.
expect(events.length).toBe(3)
dispose()
@@ -88,16 +103,11 @@ describe('ThemeService', () => {
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
})
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
vi.stubGlobal('localStorage', undefined)
try {
const { theme } = make()
expect(theme.getTheme().preference).toBe('system')
theme.setTheme('dark')
expect(theme.getTheme().preference).toBe('dark')
} finally {
vi.unstubAllGlobals()
}
it('context dispose releases the scope subscription', async () => {
const { ctx, host } = make()
expect(host.listenerCount()).toBe(1)
await ctx.fiber.dispose()
expect(host.listenerCount()).toBe(0)
})
describe('prefers-color-scheme resolution (stubbed matchMedia)', () => {