feat(web): open the local settings file
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */
|
||||
/** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -8,6 +8,8 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
@@ -16,10 +18,11 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
/** The five seats this plugin fills (slot name → expected component). */
|
||||
/** The seats this plugin fills for a loopback browser (slot name → expected component). */
|
||||
const SEATS = [
|
||||
['settings.trigger', TriggerContent],
|
||||
['settings.header', HeaderContent],
|
||||
['settings.action', SettingsDocumentAction],
|
||||
['settings.close', CloseLabel],
|
||||
['settings.section', GeneralSection],
|
||||
['settings.onboarding', WelcomeNotice],
|
||||
@@ -36,6 +39,7 @@ async function bench(isLoopback = true) {
|
||||
ok: true as const,
|
||||
value: {
|
||||
writable: true,
|
||||
documentPath: '/tmp/test-settings.yaml',
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
@@ -47,11 +51,18 @@ async function bench(isLoopback = true) {
|
||||
},
|
||||
},
|
||||
}))
|
||||
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
|
||||
const settingsOpenDocument = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'settings-open' as never,
|
||||
result: { ok: true as const, value: { opened: true as const } },
|
||||
}))
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: settingsDescribe, openDocument: settingsOpenDocument } },
|
||||
isLoopback,
|
||||
} as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe, settingsOpenDocument }
|
||||
}
|
||||
|
||||
/** Declare the shell's four child slots the way ui-settings' entry does. */
|
||||
/** Declare the shell's six child slots the way ui-settings' entry does. */
|
||||
function declare(slots: SlotsService): () => void {
|
||||
return slots.register(
|
||||
{
|
||||
@@ -59,6 +70,7 @@ function declare(slots: SlotsService): () => void {
|
||||
children: {
|
||||
'settings.trigger': { kind: 'single', scope: 'root' },
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.action': { kind: 'list', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
@@ -77,7 +89,7 @@ describe('ui-settings-general apply', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('fills all five seats for declarations before or after apply', async () => {
|
||||
it('fills all six seats for declarations before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -92,6 +104,10 @@ describe('ui-settings-general apply', () => {
|
||||
expect(before.slots.entries('settings.general.item')).toEqual([])
|
||||
const welcome = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
|
||||
const action = before.slots.entries('settings.action')[0]!
|
||||
const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)()
|
||||
expect(actionInjected.controller.store.getSnapshot().status).toBe('idle')
|
||||
expect(actionInjected.useSnapshot).toEqual(expect.any(Function))
|
||||
// Copy rides the standard locale seat: every seat declares the namespace.
|
||||
for (const [name] of SEATS) {
|
||||
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
|
||||
@@ -170,6 +186,7 @@ describe('ui-settings-general apply', () => {
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
||||
expect(b.slots.entries('settings.action')).toEqual([])
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import type { TriggerContentProps } from '../src/client/chrome.tsx'
|
||||
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -54,3 +57,80 @@ describe('GeneralSection', () => {
|
||||
expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsDocumentAction', () => {
|
||||
it('appears only for a file-backed provider and requests its Host-owned document', async () => {
|
||||
const openDocument = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-open' as never,
|
||||
result: { ok: true as const, value: { opened: true as const } },
|
||||
}))
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, documentPath: '/tmp/custom.yaml', namespaces: [] },
|
||||
},
|
||||
})),
|
||||
openDocument,
|
||||
},
|
||||
} as never)
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
const action = await screen.findByRole('button', { name: 'Open configuration file' })
|
||||
fireEvent.click(action)
|
||||
await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
|
||||
})
|
||||
|
||||
it('stays absent when the provider has no local document', async () => {
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
result: { ok: true as const, value: { writable: true, namespaces: [] } },
|
||||
})),
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
|
||||
expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the action available and reports a native-open failure', async () => {
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-action' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: { writable: true, documentPath: '/tmp/settings.yaml', namespaces: [] },
|
||||
},
|
||||
})),
|
||||
openDocument: vi.fn(() => Promise.resolve({
|
||||
rpcId: 'document-open-failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } },
|
||||
})),
|
||||
},
|
||||
} as never)
|
||||
render(<SettingsDocumentAction
|
||||
{...kit}
|
||||
t={t}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
/>)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' }))
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file')
|
||||
expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
|
||||
|
||||
function response(documentPath?: string): RpcResponse<{
|
||||
writable: boolean
|
||||
documentPath?: string
|
||||
namespaces: []
|
||||
}> {
|
||||
return {
|
||||
rpcId: 'settings-document' as never,
|
||||
result: {
|
||||
ok: true,
|
||||
value: { writable: true, ...documentPath === undefined ? {} : { documentPath }, namespaces: [] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function opened(): RpcResponse<{ opened: true }> {
|
||||
return {
|
||||
rpcId: 'settings-open' as never,
|
||||
result: { ok: true, value: { opened: true } },
|
||||
}
|
||||
}
|
||||
|
||||
function describeFailed(message: string): RpcResponse<never> {
|
||||
return {
|
||||
rpcId: 'settings-document-failed' as never,
|
||||
result: { ok: false, error: { code: 'internal', message, details: {} } },
|
||||
}
|
||||
}
|
||||
|
||||
describe('SettingsDocumentStore', () => {
|
||||
it('loads provider metadata and asks the settings domain to open its document', async () => {
|
||||
const describe = vi.fn(() => Promise.resolve(response('/home/test/settings.yaml')))
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({
|
||||
status: 'ready', opening: false, error: null,
|
||||
})
|
||||
await controller.open()
|
||||
expect(openDocument).toHaveBeenCalledWith({})
|
||||
})
|
||||
|
||||
it('marks absent or failed metadata unavailable without opening anything', async () => {
|
||||
const openDocument = vi.fn(() => Promise.resolve(opened()))
|
||||
const absent = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response()), openDocument },
|
||||
} as never)
|
||||
await absent.load()
|
||||
await absent.open()
|
||||
expect(absent.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(openDocument).not.toHaveBeenCalled()
|
||||
|
||||
const failed = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')), openDocument },
|
||||
} as never)
|
||||
await failed.load()
|
||||
expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' })
|
||||
|
||||
const rejected = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument },
|
||||
} as never)
|
||||
await rejected.load()
|
||||
expect(rejected.store.getSnapshot()).toMatchObject({
|
||||
status: 'unavailable', error: 'provider failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses concurrent open gestures and recovers after a failure', async () => {
|
||||
let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void
|
||||
const openDocument = vi.fn(() => new Promise<RpcResponse<{ opened: true }>>((resolve) => { resolveOpen = resolve }))
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: { describe: () => Promise.resolve(response('/tmp/settings.yaml')), openDocument },
|
||||
} as never)
|
||||
await controller.load()
|
||||
const first = controller.open()
|
||||
const second = controller.open()
|
||||
expect(openDocument).toHaveBeenCalledOnce()
|
||||
resolveOpen({
|
||||
rpcId: 'settings-open-failed' as never,
|
||||
result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } },
|
||||
})
|
||||
await Promise.all([first, second])
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready', opening: false, error: 'no default editor',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores stale metadata completions and reports non-Error native failures', async () => {
|
||||
let resolveFirst!: (value: ReturnType<typeof response>) => void
|
||||
const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve })
|
||||
const describe = vi.fn()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockResolvedValueOnce(response('/tmp/current.yaml'))
|
||||
let rejectOpen!: (reason?: unknown) => void
|
||||
const controller = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe,
|
||||
openDocument: () => new Promise((_, reject) => { rejectOpen = reject }),
|
||||
},
|
||||
} as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
resolveFirst(response())
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
const opening = controller.open()
|
||||
rejectOpen('native unavailable')
|
||||
await opening
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
status: 'ready', opening: false, error: 'native unavailable',
|
||||
})
|
||||
|
||||
let rejectFirst!: (error: Error) => void
|
||||
const rejectedFirst = new Promise<ReturnType<typeof response>>((_, reject) => { rejectFirst = reject })
|
||||
const caught = new SettingsDocumentStore({
|
||||
settings: {
|
||||
describe: vi.fn()
|
||||
.mockReturnValueOnce(rejectedFirst)
|
||||
.mockResolvedValueOnce(response('/tmp/current.yaml')),
|
||||
openDocument: vi.fn(),
|
||||
},
|
||||
} as never)
|
||||
const staleRejection = caught.load()
|
||||
await caught.load()
|
||||
rejectFirst(new Error('stale offline'))
|
||||
await staleRejection
|
||||
expect(caught.store.getSnapshot()).toMatchObject({ status: 'ready', error: null })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user