Files
apes-sidebar/tests/fs-watch.spec.ts
T
Menghuan1918 6586e3e2d3 feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews
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.
2026-09-22 17:09:08 +08:00

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()
}
})
})