Files
deepseek-harness/packages/settings/settings-local/tests/loader-composition.spec.ts
T
Yichen Jiang 1010291fe6 fix(settings): close cross-namespace, dispatch, and lifecycle races from second review
Confirmed and fixed, each with a regression test that failed first:

- Concurrent writes to different namespaces lost whole sections on disk
  (each persist rendered the full document from a stale text): the local
  provider serializes render->write->rename->text-commit on one internal
  persist chain shared by every namespace queue.
- One throwing settings/updated listener starved the rest (cordis emit
  stops at the first throw): commit fans out per listener via
  events.dispatch, contains individual failures, and rethrows the first
  INVARIANT-coded error only after every listener ran.
- Write queues ignored fiber/service lifecycle: the base init now
  registers a teardown that refuses new writes and drains queued chains;
  queued tasks re-verify service liveness and namespace ownership before
  running and again before committing, so a registrant disposed
  mid-flight is never notified and a disposed service never commits.
- Async watcher invocations could interleave (a slow stale call applied
  last): each watcher carries a serialized invocation chain — one call
  at a time, in commit order; JSDoc/doc pages state the async timing.
- update/replace borrowed the caller's object until the queued task ran:
  inputs are structured-clone snapshotted at call time; non-cloneable
  plain objects reject with a typed error.
- Composition guard now proves the documented fallback: the consumer
  uses the optional scoped-inject shape and boots both with the settings
  entry (hot publish) and without it (entry-config resolution, no scope).
- core-data-structures index: settings.md row added to the sub-page
  table in core.md/core.zh.md.

Both packages hold per-file 100% coverage across repeated runs.
2026-07-29 10:19:33 +08:00

146 lines
5.1 KiB
TypeScript

/**
* Real-composition guard: the provider and a consumer plugin boot from a
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml hot-publishes into the consumer's scope, and the same
* consumer booted WITHOUT a settings entry keeps its entry-config resolution —
* the documented optional-inject fallback.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import z from 'schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import SettingsLocal from '../src/index.ts'
interface ThemeConfig {
theme: 'dark' | 'light'
fontSize: number
}
const ThemeSchema: z<ThemeConfig> = z.object({
theme: z.union(['dark', 'light']).default('dark'),
fontSize: z.number().default(14),
})
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
interface ConsumerState {
scope: SettingsScope<ThemeConfig> | undefined
seen: ThemeConfig[]
/** What the consumer is actually running with, settings or not. */
applied: ThemeConfig | undefined
}
async function loadComposition(
options?: { withSettings?: boolean },
): Promise<{ ctx: Context; state: ConsumerState; settingsPath: string }> {
const withSettings = options?.withSettings ?? true
root = await mkdtemp(join(tmpdir(), 'dsh-settings-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, 'ui-theme:\n theme: light\n')
const state: ConsumerState = { scope: undefined, seen: [], applied: undefined }
const consumer = {
name: 'settings-consumer',
apply: (ctx: Context) => {
// The documented consumer shape: no hard dependency — entry config alone
// is the running state, and the scoped inject overlays the user layer
// only while a settings service exists.
const base: Partial<ThemeConfig> = { fontSize: 16 }
state.applied = ThemeSchema(base as ThemeConfig)
ctx.inject(['settings'], (child: Context) => {
const scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema, { base })
state.scope = scope
state.applied = scope.get()
scope.watch((next) => {
state.seen.push(next)
state.applied = next
})
})
},
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
...withSettings
? [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
]
: [],
'- id: consumer',
' name: test-settings-consumer',
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-settings-local', SettingsLocal],
['test-settings-consumer', consumer],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, state, settingsPath }
}
describe('settings-local real composition', () => {
it('boots from cordis.yml and hot-publishes an external settings edit', async () => {
const { ctx, state, settingsPath } = await loadComposition()
// Composition resolution: user layer over the consumer's composition base.
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'light', fontSize: 16 })
})
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual(['ui-theme'])
await writeFile(settingsPath, 'ui-theme:\n theme: dark\n fontSize: 20\n')
await vi.waitFor(() => {
expect(state.scope!.get()).toEqual({ theme: 'dark', fontSize: 20 })
}, { timeout: 5000 })
expect(state.seen.at(-1)).toEqual({ theme: 'dark', fontSize: 20 })
})
it('boots the same consumer without a settings entry and keeps entry-config resolution', async () => {
const { ctx, state } = await loadComposition({ withSettings: false })
// No settings service anywhere in the composition…
expect(ctx.get('settings')).toBeUndefined()
// …so the consumer runs on schema defaults plus its composition base, and
// never receives a scope.
expect(state.applied).toEqual({ theme: 'dark', fontSize: 16 })
expect(state.scope).toBeUndefined()
expect(state.seen).toEqual([])
})
})