mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import z from 'schemastery'
|
|
import { chmod, lstat, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import { SettingsLocal, resolveSpec } 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),
|
|
})
|
|
|
|
const cleanups: Array<() => Promise<void>> = []
|
|
|
|
afterEach(async () => {
|
|
while (cleanups.length > 0) await cleanups.pop()!()
|
|
})
|
|
|
|
async function tempDir(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-settings-local-'))
|
|
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
|
return dir
|
|
}
|
|
|
|
async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Promise<Context> {
|
|
const ctx = new Context()
|
|
const fiber = ctx.plugin(SettingsLocal, config)
|
|
cleanups.push(async () => { await fiber.dispose() })
|
|
await fiber
|
|
return ctx
|
|
}
|
|
|
|
describe('resolveSpec', () => {
|
|
it('defaults watch and debounce when construction bypasses schema normalization', () => {
|
|
const spec = resolveSpec({ path: '/tmp/anywhere/settings.yaml' })
|
|
expect(spec.watch).toBe(true)
|
|
expect(spec.debounceMs).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('boot and reads', () => {
|
|
it('resolves defaults over an absent file and reports writable', async () => {
|
|
const dir = await tempDir()
|
|
const ctx = await boot({ path: join(dir, 'settings.yaml'), watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
|
|
base: { fontSize: 16 },
|
|
})
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 16 })
|
|
expect(ctx.settings.writable).toBe(true)
|
|
})
|
|
|
|
it('reads sections from an existing yaml document', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, 'ui-theme:\n theme: light\n')
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
|
})
|
|
|
|
it('reads sections from a json document', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.json')
|
|
await writeFile(path, JSON.stringify({ 'ui-theme': { fontSize: 18 } }))
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 18 })
|
|
})
|
|
|
|
it('defaults the file location under the configured harness home', async () => {
|
|
const dir = await tempDir()
|
|
const ctx = await boot({ dshHome: dir, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await scope.update({ theme: 'light' })
|
|
const written = await readFile(join(dir, 'settings.yaml'), 'utf8')
|
|
expect(written).toContain('theme: light')
|
|
})
|
|
|
|
it('reads an empty yaml document as no sections', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, '')
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
|
})
|
|
|
|
it('reads an empty json document as no sections', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.json')
|
|
await writeFile(path, '')
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
|
})
|
|
|
|
it('fails loud at boot when the document exists but is unreadable', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, 'ui-theme:\n theme: light\n')
|
|
await chmod(path, 0o000)
|
|
cleanups.push(() => chmod(path, 0o600))
|
|
await expect(boot({ path, watch: false })).rejects.toThrow(/EACCES|permission/i)
|
|
})
|
|
|
|
it('fails loud on an unsupported extension', async () => {
|
|
const dir = await tempDir()
|
|
await expect(boot({ path: join(dir, 'settings.toml'), watch: false }))
|
|
.rejects.toThrow(/not supported/)
|
|
})
|
|
|
|
it('fails loud at boot on unparsable yaml', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, 'ui-theme: [unclosed\n')
|
|
await expect(boot({ path, watch: false })).rejects.toThrow()
|
|
})
|
|
|
|
it('fails loud at boot when the root is not a map of sections', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, '- just\n- a list\n')
|
|
await expect(boot({ path, watch: false })).rejects.toThrow(/map of namespace sections/)
|
|
})
|
|
})
|
|
|
|
describe('persist', () => {
|
|
it('writes the merged section, creating the file with owner-only permissions', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await scope.update({ theme: 'light' })
|
|
|
|
const written = await readFile(path, 'utf8')
|
|
expect(written).toContain('theme: light')
|
|
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
|
// Atomic replace leaves no temp artifact behind.
|
|
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
|
|
})
|
|
|
|
it('serializes cross-namespace writes into one on-disk document', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
const ctx = await boot({ path, watch: false })
|
|
const alpha = ctx.settings.register(settingsNamespace('alpha'), ThemeSchema)
|
|
const beta = ctx.settings.register(settingsNamespace('beta'), ThemeSchema)
|
|
await Promise.all([
|
|
alpha.update({ theme: 'light' }),
|
|
beta.update({ fontSize: 20 }),
|
|
])
|
|
const text = await readFile(path, 'utf8')
|
|
expect(text).toContain('alpha:')
|
|
expect(text).toContain('beta:')
|
|
expect(alpha.get().theme).toBe('light')
|
|
expect(beta.get().fontSize).toBe(20)
|
|
})
|
|
|
|
it('never follows a planted symlink at a temp path and never leaves the document a symlink', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
const victim = join(dir, 'victim.txt')
|
|
await writeFile(victim, 'precious')
|
|
// A hostile sibling plants the historic fixed temp name as a symlink.
|
|
await symlink(victim, `${path}.tmp`)
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await scope.update({ theme: 'light' })
|
|
|
|
expect(await readFile(victim, 'utf8')).toBe('precious')
|
|
expect((await lstat(path)).isSymbolicLink()).toBe(false)
|
|
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
|
expect(await readFile(path, 'utf8')).toContain('theme: light')
|
|
})
|
|
|
|
it('preserves comments and unregistered sections across updates', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, [
|
|
'# personal settings',
|
|
'ui-theme:',
|
|
' theme: light',
|
|
'# owned by a plugin that is not loaded right now',
|
|
'future-plugin:',
|
|
' keep: me',
|
|
'',
|
|
].join('\n'))
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await scope.update({ fontSize: 18 })
|
|
|
|
const written = await readFile(path, 'utf8')
|
|
expect(written).toContain('# personal settings')
|
|
expect(written).toContain('# owned by a plugin that is not loaded right now')
|
|
expect(written).toContain('keep: me')
|
|
expect(written).toContain('fontSize: 18')
|
|
expect(written).toContain('theme: light')
|
|
})
|
|
|
|
it('creates a json document from scratch', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.json')
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await scope.update({ theme: 'light' })
|
|
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
|
expect(written).toEqual({ 'ui-theme': { theme: 'light' } })
|
|
})
|
|
|
|
it('rejects and leaves no temp residue when the directory turns unwritable', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, 'ui-theme:\n theme: light\n')
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await chmod(dir, 0o500)
|
|
cleanups.push(() => chmod(dir, 0o700))
|
|
await expect(scope.update({ theme: 'dark' })).rejects.toThrow()
|
|
await chmod(dir, 0o700)
|
|
expect((await readdir(dir)).sort()).toEqual(['settings.yaml'])
|
|
expect(scope.get().theme).toBe('light')
|
|
// The failed persist must not poison the document write chain.
|
|
await scope.update({ theme: 'dark' })
|
|
expect(scope.get().theme).toBe('dark')
|
|
})
|
|
|
|
it('round-trips a json document', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.json')
|
|
await writeFile(path, JSON.stringify({ other: { keep: true } }, null, 2))
|
|
const ctx = await boot({ path, watch: false })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await scope.update({ theme: 'light' })
|
|
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
|
|
expect(written).toEqual({ other: { keep: true }, 'ui-theme': { theme: 'light' } })
|
|
})
|
|
})
|
|
|
|
describe('watch', () => {
|
|
it('publishes an external edit to registered scopes', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, 'ui-theme:\n theme: light\n')
|
|
const ctx = await boot({ path, debounceMs: 10 })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
expect(scope.get().theme).toBe('light')
|
|
|
|
await writeFile(path, 'ui-theme:\n theme: dark\n fontSize: 20\n')
|
|
await vi.waitFor(() => {
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 20 })
|
|
}, { timeout: 5000 })
|
|
})
|
|
|
|
it('keeps the last good document over an invalid edit, then recovers', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, 'ui-theme:\n theme: light\n')
|
|
const ctx = await boot({ path, debounceMs: 10 })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
|
await writeFile(path, 'ui-theme: [unclosed\n')
|
|
// The bad edit must never take the live tree down or reset the value.
|
|
await new Promise(resolve => setTimeout(resolve, 300))
|
|
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
|
|
|
await writeFile(path, 'ui-theme:\n theme: dark\n')
|
|
await vi.waitFor(() => {
|
|
expect(scope.get().theme).toBe('dark')
|
|
}, { timeout: 5000 })
|
|
})
|
|
|
|
it('treats file removal as an empty document', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
await writeFile(path, 'ui-theme:\n theme: light\n')
|
|
const ctx = await boot({ path, debounceMs: 10 })
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
|
|
await rm(path)
|
|
await vi.waitFor(() => {
|
|
expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
|
|
}, { timeout: 5000 })
|
|
})
|
|
|
|
it('does not republish its own persisted write', async () => {
|
|
const dir = await tempDir()
|
|
const path = join(dir, 'settings.yaml')
|
|
const ctx = await boot({ path, debounceMs: 10 })
|
|
const events: unknown[] = []
|
|
ctx.on('settings/updated', (ns, _next, _prev, source) => {
|
|
events.push({ ns, source })
|
|
})
|
|
const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
|
|
await scope.update({ theme: 'light' })
|
|
await new Promise(resolve => setTimeout(resolve, 300))
|
|
expect(events).toEqual([{ ns: 'ui-theme', source: 'update' }])
|
|
})
|
|
})
|