Reorganize packages into a modular hierarchy
Move the 18 flat packages/<name> packages into role-grouped dirs: core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are pure containers; each package keeps its @deepseek-ai/dsh-* name. Collapse the per-package tsconfig paths maps (base + typecheck) into one @deepseek-ai/dsh-* wildcard with a candidate per group, and derive the publint list from the hierarchy. Update all depth-coupled globs/configs (workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs, per-package tsconfigs, generators, doc-script scopes, type-equiv manifest) and the cross-package/script relative imports in tests. Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the TypeScript API instead of a regex comment-strip, which corrupted the new wildcard `/*/` path candidates. WIP: doc cross-links and package/RFC docs still to update.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/** Minimal concrete executor: records calls, lets tests drive completions. */
|
||||
class StubExecutor extends BashExecutor {
|
||||
tasks = new Map<string, BashTask>()
|
||||
private owners = new Map<string, string | undefined>()
|
||||
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/stub',
|
||||
timeoutMs: request.timeoutMs ?? 1000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
|
||||
async run(_spec: BashExecSpec): Promise<BashRunResult> {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: 'ok', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashTask {
|
||||
const task: BashTask = {
|
||||
id: `stub-${this.tasks.size + 1}`,
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
done: Promise.resolve(),
|
||||
}
|
||||
this.tasks.set(task.id, task)
|
||||
this.owners.set(task.id, spec.owner)
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: string): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: string): string | undefined {
|
||||
return this.owners.get(id)
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: string): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
return { task, delta: '', lossy: false }
|
||||
}
|
||||
|
||||
kill(id: string): boolean {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
if (task.status !== 'running') return false
|
||||
task.status = 'killed'
|
||||
return true
|
||||
}
|
||||
|
||||
/** Expose the protected notifier for tests. */
|
||||
fire(task: BashTask): void {
|
||||
this.notifyTaskDone(task)
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubExecutor)
|
||||
// ctx.bash resolves to the registered implementation.
|
||||
const bash = ctx.bash as StubExecutor
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
describe('BashExecutor service seam', () => {
|
||||
it('registers as ctx.bash and serves the abstract API', async () => {
|
||||
const { bash } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'sleep 1' }))
|
||||
expect(bash.get(task.id)).toBe(task)
|
||||
expect(bash.list()).toEqual([task])
|
||||
expect(bash.kill(task.id)).toBe(true)
|
||||
expect(bash.kill(task.id)).toBe(false)
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('onTaskDone delivers completions to registered listeners', async () => {
|
||||
const { bash } = await setup()
|
||||
const seen: string[] = []
|
||||
bash.onTaskDone(task => void seen.push(task.id))
|
||||
const task = bash.start(bash.resolve({ command: 'x' }))
|
||||
bash.fire(task)
|
||||
expect(seen).toEqual([task.id])
|
||||
})
|
||||
|
||||
it('onTaskDone disposer unsubscribes the listener', async () => {
|
||||
const { bash } = await setup()
|
||||
const listener = vi.fn()
|
||||
const dispose = bash.onTaskDone(listener)
|
||||
dispose()
|
||||
bash.fire(bash.start(bash.resolve({ command: 'x' })))
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('listeners registered from a fiber are removed on dispose (HMR safety)', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
const listener = vi.fn()
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.bash.onTaskDone(listener)
|
||||
}, { inject: ['bash'] }))
|
||||
bash.fire(bash.start(bash.resolve({ command: 'one' })))
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
await fiber.dispose()
|
||||
bash.fire(bash.start(bash.resolve({ command: 'two' })))
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('silences listeners once the service fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
await inner.plugin(StubExecutor)
|
||||
}, {}))
|
||||
const bash = ctx.bash as StubExecutor
|
||||
const listener = vi.fn()
|
||||
bash.onTaskDone(listener)
|
||||
const task = bash.start(bash.resolve({ command: 'x' }))
|
||||
|
||||
await fiber.dispose()
|
||||
bash.fire(task)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user