fix(web): address settings document review

This commit is contained in:
Yichen Jiang
2026-08-04 17:31:36 +08:00
parent e31b7221e7
commit 4f717f2da7
42 changed files with 219 additions and 115 deletions

View File

@@ -2390,7 +2390,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// editor; real schema-driven forms ride the HTTP transport.
describe: request => ok(request, {
writable: true,
documentPath: `${FIXTURE_HOME}/settings.yaml`,
hasDocument: true,
namespaces: [{
ns: 'llm-deepseek',
schema: {},
@@ -2400,6 +2400,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
revision: 0,
}],
}),
// Native opens are deterministic no-op successes in this fixture, as is host.openPath.
openDocument: request => ok(request, { opened: true as const }),
update: request => err(request, {
code: 'settings-rejected',

View File

@@ -181,7 +181,7 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

View File

@@ -216,7 +216,7 @@ export class FakeApiClient implements IApiClient {
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, namespaces: [] }))),
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),

View File

@@ -104,7 +104,7 @@ function scriptedFace(overrides: {
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
},
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))),
update,
replace,
mutate,
@@ -541,6 +541,7 @@ describe('ModelsSection', () => {
const { face } = await mountSection()
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
writable: false,
hasDocument: false,
namespaces: wireNamespaces(),
})))
const controller = new ModelsSettingsStore(face as unknown as WireFace)

View File

@@ -51,7 +51,7 @@ function api(overrides: {
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
},
settings: {
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: NAMESPACES }))),
update: () => Promise.resolve(fail('unused')),
replace: () => Promise.resolve(fail('unused')),
},
@@ -135,6 +135,7 @@ describe('ModelsSettingsStore', () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
...NAMESPACES[0],
secrets: [
@@ -195,6 +196,7 @@ describe('edge joins', () => {
const { face } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{
ns: 'llm-pi-ai',
schema: {},
@@ -221,6 +223,7 @@ describe('edge joins', () => {
const { face, seenRefs } = api({
describeSettings: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [], revision: 0 }] as never,
})),
providers: () => Promise.resolve(ok({

View File

@@ -48,7 +48,7 @@ async function bench() {
settings: {
describe: () => Promise.resolve({
rpcId: 'describe',
result: { ok: true as const, value: { writable: true, namespaces: [] } },
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
}),
mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
},

View File

@@ -60,7 +60,7 @@ describe('PermissionRow', () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
})
@@ -87,7 +87,7 @@ describe('PermissionRow', () => {
const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
const controller = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate,
} as never,
})
@@ -111,7 +111,7 @@ describe('PermissionRow', () => {
it('hides an unavailable namespace and disables a read-only provider', async () => {
const absent = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
mutate: vi.fn(),
} as never,
})
@@ -121,7 +121,7 @@ describe('PermissionRow', () => {
const readonly = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })),
mutate: vi.fn(),
} as never,
})
@@ -148,7 +148,7 @@ describe('PermissionRow', () => {
})
mount(controller)
expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
describe.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
const button = await screen.findByRole('button', { name: 'Read Only' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))

View File

@@ -88,6 +88,7 @@ describe('permission settings store', () => {
it('loads and writes defaultPreset with optimistic concurrency', async () => {
const describe = vi.fn(() => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [view('read-only', 4)],
})))
const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5))))
@@ -115,7 +116,7 @@ describe('permission settings store', () => {
})
it('hides the row when the namespace is absent and contains write failures', async () => {
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] })))
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })))
const controller = new PermissionSettingsController({
settings: { describe, mutate: vi.fn() } as never,
})
@@ -124,7 +125,7 @@ describe('permission settings store', () => {
const failing = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => Promise.resolve({
rpcId: 'test',
result: {
@@ -146,14 +147,14 @@ describe('permission settings store', () => {
}>>>()
const describe = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] }))
.mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] }))
const mutate = vi.fn()
const controller = new PermissionSettingsController({
settings: { describe, mutate } as never,
})
const stale = controller.load()
await controller.load()
first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] }))
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('workspace-write', 1)] }))
await stale
expect(controller.store.getSnapshot()).toMatchObject({
currentValue: 'read-only',
@@ -200,7 +201,7 @@ describe('permission settings store', () => {
expect(describe).not.toHaveBeenCalled()
const loading = idle.load()
idle.dispose()
read.resolve(ok({ writable: true, namespaces: [view('read-only')] }))
read.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
await loading
expect(idle.store.getSnapshot().status).toBe('loading')
@@ -220,6 +221,7 @@ describe('permission settings store', () => {
const mutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const activeDescribe = vi.fn(() => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [view('read-only')],
})))
const active = new PermissionSettingsController({
@@ -240,7 +242,7 @@ describe('permission settings store', () => {
const rejectedMutation = Promise.withResolvers<ReturnType<typeof ok<SettingsNamespaceView>>>()
const disposedWrite = new PermissionSettingsController({
settings: {
describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })),
describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })),
mutate: () => rejectedMutation.promise,
} as never,
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
README.md: d9aeaa37b3953f586821871118b009abd72b896d
README.zh.md: 96666e2d4d2087ca9579a1bcdd6a00ccfdaaa90d
README.md: 29e48d193d24644f37d219b4df44a8fedf062e53
README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
A loopback browser loads the provider's optional `documentPath` through `settings.describe` and renders **Open configuration file** only when the Host confirms one local file. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Remote browsers never register the action and never issue the privileged settings read.
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.

View File

@@ -4,7 +4,7 @@
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
回环浏览器通过 `settings.describe` 加载提供方可选`documentPath`,且只有在 Host 确认存在一个本地文时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求Host 会再次解析提供方路径、在文档缺失时将其创建出来并交给原生文本编辑器macOS 上使用 `open -t`绕过浏览器文件关联Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求Host 会再次解析提供方路径、在文档缺失时将其创建出来并交给原生文本编辑器macOS 上使用 `open -t`绕过浏览器文件关联Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。

View File

@@ -21,7 +21,7 @@ export type SettingsDocumentActionProps =
PropsRuntime<'settings.action'> & PropsLocale<'settings'> & SettingsDocumentActionInjected
/**
* Render the open-document action only after Host metadata confirms a local file.
* Render the open-document action only after Host metadata confirms document availability.
* @param props - header owner props, localized copy, and injected document state.
* @returns the action, or null while unavailable or unresolved.
*/
@@ -29,8 +29,8 @@ export function SettingsDocumentAction({ controller, useSnapshot, t }: SettingsD
const state = useSnapshot(snapshot => snapshot)
useEffect(() => {
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
void controller.load()
}, [controller])
if (state.status !== 'ready') return null

View File

@@ -17,7 +17,7 @@ import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
import { SettingsDocumentStore } from './settings-document-store.ts'
import { refreshDocumentIfLoaded, SettingsDocumentStore } from './settings-document-store.ts'
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
import { WelcomeNotice } from './WelcomeNotice.tsx'
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
@@ -90,10 +90,13 @@ export function apply(ctx: ClientContext): void {
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
ctx.on('connection/reset', () => {
refresh()
refreshDocumentIfLoaded(documentController)
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-general: welcome invalidations')
}, 'ui-settings-general: metadata invalidations')
ctx.effect(() => {
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))

View File

@@ -5,7 +5,7 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
/** Browser state of the Host-owned settings document. */
export interface SettingsDocumentState {
/** Metadata-loading phase; unavailable means the provider has no local file or the read failed. */
/** Metadata-loading phase; unavailable means the provider has no local document or the read failed. */
status: 'idle' | 'loading' | 'ready' | 'unavailable'
/** Whether one native-open request is in flight. */
opening: boolean
@@ -32,7 +32,7 @@ export class SettingsDocumentStore {
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/**
* Load the current provider's optional local document path.
* Load whether the current provider owns a local document.
* @returns after the latest metadata response updates the store.
*/
async load(): Promise<void> {
@@ -52,7 +52,7 @@ export class SettingsDocumentStore {
return
}
this.store.update((state) => {
state.status = result.value.documentPath === undefined ? 'unavailable' : 'ready'
state.status = result.value.hasDocument ? 'ready' : 'unavailable'
state.error = null
})
} catch (error) {
@@ -85,3 +85,12 @@ export class SettingsDocumentStore {
}
}
}
/**
* Refresh document availability after reconnect only when a surface has already requested it.
* @param controller - optional loopback document state owner.
*/
export function refreshDocumentIfLoaded(controller: SettingsDocumentStore | undefined): void {
if (controller === undefined || controller.store.getSnapshot().status === 'idle') return
void controller.load()
}

View File

@@ -39,7 +39,7 @@ async function bench(isLoopback = true) {
ok: true as const,
value: {
writable: true,
documentPath: '/tmp/test-settings.yaml',
hasDocument: true,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},
@@ -175,10 +175,25 @@ describe('ui-settings-general apply', () => {
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
})
it('refreshes loaded document availability on reconnect without reading it eagerly', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.action')[0]!
const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)()
b.ctx.emit('connection/reset')
expect(b.settingsDescribe).not.toHaveBeenCalled()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
})
it('keeps remote welcome acknowledgement process-local', async () => {
const b = await bench(false)
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = b.slots.entries('settings.onboarding')[0]!
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
@@ -187,6 +202,8 @@ describe('ui-settings-general apply', () => {
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
expect(b.settingsDescribe).not.toHaveBeenCalled()
expect(b.slots.entries('settings.action')).toEqual([])
await fiber.dispose()
for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([])
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {

View File

@@ -70,7 +70,7 @@ describe('SettingsDocumentAction', () => {
rpcId: 'document-action' as never,
result: {
ok: true as const,
value: { writable: true, documentPath: '/tmp/custom.yaml', namespaces: [] },
value: { writable: true, hasDocument: true, namespaces: [] },
},
})),
openDocument,
@@ -87,17 +87,23 @@ describe('SettingsDocumentAction', () => {
await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
})
it('stays absent when the provider has no local document', async () => {
it('stays absent without a document and retries availability after remount', async () => {
const describe = vi.fn()
.mockResolvedValueOnce({
rpcId: 'document-action-absent' as never,
result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
})
.mockResolvedValueOnce({
rpcId: 'document-action-ready' as never,
result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
})
const controller = new SettingsDocumentStore({
settings: {
describe: vi.fn(() => Promise.resolve({
rpcId: 'document-action' as never,
result: { ok: true as const, value: { writable: true, namespaces: [] } },
})),
describe,
openDocument: vi.fn(),
},
} as never)
render(<SettingsDocumentAction
const first = render(<SettingsDocumentAction
{...kit}
t={t}
controller={controller}
@@ -105,6 +111,15 @@ describe('SettingsDocumentAction', () => {
/>)
await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull()
first.unmount()
render(<SettingsDocumentAction
{...kit}
t={t}
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
/>)
expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy()
expect(describe).toHaveBeenCalledTimes(2)
})
it('keeps the action available and reports a native-open failure', async () => {
@@ -114,7 +129,7 @@ describe('SettingsDocumentAction', () => {
rpcId: 'document-action' as never,
result: {
ok: true as const,
value: { writable: true, documentPath: '/tmp/settings.yaml', namespaces: [] },
value: { writable: true, hasDocument: true, namespaces: [] },
},
})),
openDocument: vi.fn(() => Promise.resolve({

View File

@@ -2,16 +2,16 @@ 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<{
function response(hasDocument = false): RpcResponse<{
writable: boolean
documentPath?: string
hasDocument: boolean
namespaces: []
}> {
return {
rpcId: 'settings-document' as never,
result: {
ok: true,
value: { writable: true, ...documentPath === undefined ? {} : { documentPath }, namespaces: [] },
value: { writable: true, hasDocument, namespaces: [] },
},
}
}
@@ -32,7 +32,7 @@ function describeFailed(message: string): RpcResponse<never> {
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 describe = vi.fn(() => Promise.resolve(response(true)))
const openDocument = vi.fn(() => Promise.resolve(opened()))
const controller = new SettingsDocumentStore({ settings: { describe, openDocument } } as never)
await controller.load()
@@ -72,7 +72,7 @@ describe('SettingsDocumentStore', () => {
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 },
settings: { describe: () => Promise.resolve(response(true)), openDocument },
} as never)
await controller.load()
const first = controller.open()
@@ -93,7 +93,7 @@ describe('SettingsDocumentStore', () => {
const first = new Promise<ReturnType<typeof response>>((resolve) => { resolveFirst = resolve })
const describe = vi.fn()
.mockReturnValueOnce(first)
.mockResolvedValueOnce(response('/tmp/current.yaml'))
.mockResolvedValueOnce(response(true))
let rejectOpen!: (reason?: unknown) => void
const controller = new SettingsDocumentStore({
settings: {
@@ -119,7 +119,7 @@ describe('SettingsDocumentStore', () => {
settings: {
describe: vi.fn()
.mockReturnValueOnce(rejectedFirst)
.mockResolvedValueOnce(response('/tmp/current.yaml')),
.mockResolvedValueOnce(response(true)),
openDocument: vi.fn(),
},
} as never)

View File

@@ -23,6 +23,7 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
settings: {
describe: () => Promise.resolve(response({
writable: true,
hasDocument: false,
namespaces: [{
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
schema: {},

View File

@@ -53,7 +53,7 @@ describe('WelcomeNoticeStore', () => {
] as const) {
const api = {
settings: {
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
describe: vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(version)] }))),
},
}
const controller = new WelcomeNoticeStore(api as never)
@@ -101,7 +101,7 @@ describe('WelcomeNoticeStore', () => {
rpcId: 'failed' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
}),
() => Promise.resolve(ok({ writable: true, namespaces: [] })),
() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })),
]) {
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
await controller.load()
@@ -112,6 +112,7 @@ describe('WelcomeNoticeStore', () => {
const controller = new WelcomeNoticeStore({
settings: { describe: () => Promise.resolve(ok({
writable: true,
hasDocument: false,
namespaces: [{ ...namespace(), value }],
})) },
} as never)
@@ -133,18 +134,20 @@ describe('WelcomeNoticeStore', () => {
const first = deferred<ReturnType<typeof ok>>()
const describe = vi.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
const stale = controller.load()
await controller.load()
first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
first.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
await stale
expect(controller.store.getSnapshot().acknowledged).toBe(false)
const failed = deferred<ReturnType<typeof ok>>()
describe
.mockImplementationOnce(() => failed.promise)
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
.mockImplementationOnce(() => Promise.resolve(ok({
writable: true, hasDocument: false, namespaces: [namespace(WELCOME_NOTICE_VERSION)],
})))
const staleFailure = controller.load()
await controller.load()
failed.reject('stale failure')
@@ -154,7 +157,7 @@ describe('WelcomeNoticeStore', () => {
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
const write = deferred<ReturnType<typeof ok>>()
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [namespace()] })))
const controller = new WelcomeNoticeStore({
settings: { mutate: () => write.promise, describe },
} as never)