DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).
Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
addressed by the plugin row's Loader entry id and read from the
module's exported Config. The preferences are merged into Config and
marked volatile — which requires DSH's own schemastery, because only
that build wraps a volatile field in the cosmokit reference the
loader's volatile commit walks. With the public package a settings
write reports success and revision 1 while the effective value never
changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
mode is render-time, not load-time. Five call sites that relied on the
old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
boundary injection now writes plugin:dsh-better-sidebar, the same kind
DSH's own v3->v4 migration produces, so historical threads survive.
Tool results moved to role 'tool' with top-level content/isError; all
four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
and the on-demand subagent-catalog observation API. The Tasks page
reads projectionsBySession and a new jobs.list route.
Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
spreadsheet worker, zoom, per-directory refresh); editor.canOpen
refuses those 26 extensions. Markdown, HTML (with its sandbox
switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
host, fixing a regression from the 0.1.6 round: the fallback pointed
at a 'browser' type the plugin no longer registers, so http links in
its own markdown did nothing.
Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
refreshSubagents do not exist on the current baseline, so the
topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
primitives call at module-evaluation time; and the e2e token exchange
needs Playwright's request stack, not Node's fetch.
128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
/**
|
|
* Directory-watch spec: the file tree's live refresh rests on this module, so
|
|
* the tests use real directories and the real `fs.watch` rather than a stub —
|
|
* the debounce, the handle accounting and the error path are the behaviour
|
|
* being pinned.
|
|
*
|
|
* Timing note: `fs.watch` delivery is neither prompt nor one-to-one with the
|
|
* writes that caused it (macOS FSEvents batches on its own schedule, and a
|
|
* loaded machine stretches it further). The assertions below therefore pin
|
|
* the PROPERTIES the tree depends on — a change is reported, a burst is
|
|
* folded, an unwatched directory stays silent — instead of an exact event
|
|
* count, and every wait is given a budget far above the module's 150 ms
|
|
* debounce.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { createDirectoryWatchers, type DirectoryWatchEvent } from '../src/fs-watch.ts'
|
|
|
|
/** Generous delivery budget: the module debounces at 150 ms, the OS adds its own latency. */
|
|
const DELIVERY_TIMEOUT_MS = 15_000
|
|
/** Long enough for a second (tail) delivery to have surfaced, so "folded" means something. */
|
|
const SETTLE_MS = 1_000
|
|
|
|
/** One scratch tree with a directory to watch. */
|
|
const withScratch = (run: (dir: string) => Promise<void> | void): Promise<void> => {
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-watch-'))
|
|
const dir = join(root, 'watched')
|
|
mkdirSync(dir)
|
|
return Promise.resolve(run(dir)).finally(() => { rmSync(root, { recursive: true, force: true }) })
|
|
}
|
|
|
|
/** Sleep for a fixed window. */
|
|
const settle = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
describe('directory watchers', () => {
|
|
it('folds a burst of changes into a single notice', async () => {
|
|
await withScratch(async (dir) => {
|
|
const events: DirectoryWatchEvent[] = []
|
|
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
|
|
try {
|
|
expect(watchers.add(dir)).toBe(true)
|
|
// One save is several filesystem events; the tree re-lists the whole
|
|
// directory either way, so they must collapse. Twenty writes make the
|
|
// fold unambiguous even if the OS delivers one late tail batch.
|
|
for (let index = 0; index < 20; index++) writeFileSync(join(dir, `f${index}.txt`), 'x')
|
|
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
|
|
await settle(SETTLE_MS)
|
|
expect(events.length).toBeLessThanOrEqual(2)
|
|
for (const event of events) expect(event).toEqual({ dir })
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('stops reporting once a directory is unwatched or closed', async () => {
|
|
await withScratch(async (dir) => {
|
|
const events: DirectoryWatchEvent[] = []
|
|
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
|
|
watchers.add(dir)
|
|
watchers.remove(dir)
|
|
// Removing twice is a no-op, never a throw.
|
|
watchers.remove(dir)
|
|
writeFileSync(join(dir, 'a.txt'), 'x')
|
|
// The waiting budget matches the positive case: a silent assertion is
|
|
// only meaningful if an event had ample time to arrive.
|
|
await settle(SETTLE_MS)
|
|
expect(events).toEqual([])
|
|
// Closing the whole set is idempotent too.
|
|
watchers.close()
|
|
watchers.close()
|
|
})
|
|
})
|
|
|
|
it('treats a repeat add as already-watched', async () => {
|
|
await withScratch(async (dir) => {
|
|
const events: DirectoryWatchEvent[] = []
|
|
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
|
|
try {
|
|
expect(watchers.add(dir)).toBe(true)
|
|
// A second add must not install a second watcher — that would double
|
|
// every notice for the same directory.
|
|
expect(watchers.add(dir)).toBe(true)
|
|
writeFileSync(join(dir, 'a.txt'), 'x')
|
|
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
|
|
await settle(SETTLE_MS)
|
|
expect(events.length).toBeLessThanOrEqual(2)
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('caps the handles one connection may hold', async () => {
|
|
await withScratch(async (dir) => {
|
|
const root = join(dir, '..')
|
|
const watchers = createDirectoryWatchers(() => {}, () => {})
|
|
try {
|
|
// The cap is 64: the first 64 directories are watched, the rest are
|
|
// refused rather than throwing, and a refusal evicts nothing.
|
|
let accepted = 0
|
|
for (let index = 0; index < 70; index++) {
|
|
const child = join(root, `child-${index}`)
|
|
mkdirSync(child)
|
|
if (watchers.add(child)) accepted += 1
|
|
}
|
|
expect(accepted).toBe(64)
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('reports an unwatchable directory instead of throwing', async () => {
|
|
const failures: string[] = []
|
|
const watchers = createDirectoryWatchers(() => {}, (dir) => { failures.push(dir) })
|
|
try {
|
|
const missing = join(tmpdir(), 'dsh-sidebar-watch-does-not-exist-42')
|
|
expect(watchers.add(missing)).toBe(false)
|
|
expect(failures).toEqual([missing])
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|