test: remove dsh-* temp dirs created by unit tests at teardown

Spec files that create /tmp/dsh-* directories via mkdtemp now track and
delete them in afterEach/afterAll; module-scope fixture dirs (executor
spill dirs) are removed in afterAll. The file list came from the
observed-residue inventory on the self-hosted CI host: only specs whose
dirs actually accumulated were leak sources (issue #3134), superseding
the kept-but-unmerged CI sweep branch per the #3233 review decision.

Product per-process spill roots (dsh-subprocess-local spawn,
dsh-spill-local store) register a process-exit handler that removes the
memoized dir, so processes that used the spawn/spill path clean up on
normal exit. A SIGKILLed process cannot run in-process teardown; the
machine-side timer remains the backstop for that path.

Agent Note: .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
This commit is contained in:
Chinesezjc committed 2026-09-03 16:19:18 +08:00
1 parent 9fe2112a63
commit 0364343a7e
39 files changed
+461 -73

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
2026-08-28-test-temp-dir-self-cleanup.md: e8e0c5e2db716ff9a63ef55ae1af07a66eaf28a0
2026-08-28-test-temp-dir-self-cleanup.zh.md: cdc0dfa2b6a00fc11703743c51326238bf644fb1
@@ -0,0 +1,39 @@
# Agent Note: Unit tests remove the dsh-* temp dirs they create
Status: implemented
English | [中文](2026-08-28-test-temp-dir-self-cleanup.zh.md)
## Problem
Test processes create `/tmp/dsh-*` directories with `mkdtemp(join(tmpdir(), 'dsh-*'))` and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one `/tmp`) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side `dsh-tmp-sweep` timer and the CI lane sweep (kept, unmerged, on branch `fix/ci-tmp-residue-cleanup`) remove residue after the fact but leave the producing defect in place. Human review of #3233 (2026-08-28) rejected the sweep: unit tests must clean up the directories they create instead.
## Decision
Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning test's teardown:
- Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point.
- Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test.
- The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged.
- Product per-process spill roots (`privateSpillDir` in `dsh-subprocess-local/spawn`, `privateRoot` in `dsh-spill-local/store`) register a `process.once('exit')` handler that removes the memoized directory, so every process that used the spawn/spill path cleans up on normal exit.
## Verification
- Targeted local run of every changed spec (32 files, 700 tests) passed, including the suites that exercise the changed product sources.
- CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host.
## Alternatives considered
### Keep the sweep-only approach (rejected in review)
Sweep steps and timers delete residue after it exists; they do not stop local runs from accumulating, and a machine sweep cannot distinguish a dead run's residue from a live one's. The reviewer decision was per-test cleanup, implemented here for the normal-exit path.
### Introduce a shared temp-dir helper package
Not chosen: the files that leak each create roots through their own small helpers, and tracking them at those helpers is a per-file one-point change. A new test-support package would add a dependency without reducing the per-file audit.
## Consequences
- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown, and per-process spill roots are removed when their process exits normally.
- Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path.
- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned per-process roots are covered by the exit handler in the process that created them.
@@ -0,0 +1,39 @@
# Agent Note:单测删除自己创建的 dsh-* 临时目录
Status: implemented
[English](2026-08-28-test-temp-dir-self-cleanup.md) | 中文
## Problem
测试进程用 `mkdtemp(join(tmpdir(), 'dsh-*'))` 创建 `/tmp/dsh-*` 目录后不清理。在自托管 Linux CI 主机上(32 个 runner 实例共享一个 `/tmp`),残留两次耗尽根分区 inode(issue #3134,2026-08-13 与 2026-08-26)。机器侧 `dsh-tmp-sweep` timer 与 CI lane sweep(保留未合并,在分支 `fix/ci-tmp-residue-cleanup` 上)都是事后删除残留,未修掉产生残留的缺陷本体。人类 review #3233(2026-08-28)否决了 sweep:单测应改为自己清理创建的目录。
## Decision
为 spec 文件创建的每个 `dsh-*` 临时目录补上删除路径,挂在所属测试的 teardown 上:
- 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。
- 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。
- 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。
- 产品侧每进程 spill root(`dsh-subprocess-local/spawn` 的 `privateSpillDir`、`dsh-spill-local/store` 的 `privateRoot`)注册 `process.once('exit')` handler,在进程正常退出时删除记忆化的目录——凡走过 spawn/spill 路径的进程都会在正常结束时清理。
## Verification
- 本地定向跑过全部改动 spec(32 个文件、700 个测试)通过,含直接使用改动后产品源码的套件。
- CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。
## Alternatives considered
### 保留纯 sweep 方案(review 否决)
Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机器 sweep 也区分不了已死 run 的残留与存活 run 的目录。review 的决定是逐测试清理,本实现覆盖正常结束路径。
### 引入共享临时目录 helper 包
未选:泄漏文件各自通过自己的小 helper 创建 root,在那些 helper 处登记是每个文件单点改动;新增 test-support 包只会增加依赖,不减少逐文件审计量。
## Consequences
- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;每进程 spill root 在其进程正常退出时删除。
- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。
- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有每进程 root 由创建它的进程的 exit handler 覆盖。
@@ -76,6 +76,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
afterAll(async () => {
await browser?.close()
await scaffold?.close()
await rm(userRoot, { recursive: true, force: true })
})
it('offers the roster with copy as the only way to create', async () => {
+2 -1
View File
@@ -9,7 +9,7 @@
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { fileURLToPath } from 'node:url'
import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -232,6 +232,7 @@ describe('web e2e: agent-preset selection', () => {
afterAll(async () => {
await browser?.close()
await scaffold?.close()
await rm(presetRoot, { recursive: true, force: true })
})
it('offers the chip on the new-session screen, beside the workspace picker', async () => {
@@ -1,4 +1,4 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
@@ -22,8 +22,12 @@ import { installSessionReadTestServices, testSessionPersistence } from './test-r
const roots: Context[] = []
/** Session cwd roots created per test, removed after their context settles. */
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> {
@@ -297,6 +301,7 @@ describe('ApiSession create or adoption', () => {
it('shares one in-flight creation between concurrent callers', async () => {
const { ctx, agents } = await harness()
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-'))
tempDirs.push(cwd)
const meta = header('concurrent-create', cwd)
const created = unpublishedAgent(ctx, meta)
let release!: () => void
@@ -317,6 +322,7 @@ describe('ApiSession create or adoption', () => {
it('accepts a raced ordinary creation and rejects a raced attached child', async () => {
const ordinary = await harness()
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-'))
tempDirs.push(cwd)
const ordinaryMeta = header('create-race', cwd)
const winner = agent(ordinary.ctx, ordinaryMeta)
vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
@@ -328,6 +334,7 @@ describe('ApiSession create or adoption', () => {
const child = await harness()
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-'))
tempDirs.push(childCwd)
const childId = SessionId('create-child-race')
vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => {
child.ctx.sessions.create(childId, {
@@ -342,6 +349,7 @@ describe('ApiSession create or adoption', () => {
it('validates ownership and cwd on the Agent returned by creation', async () => {
const child = await harness()
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-'))
tempDirs.push(childCwd)
const childMeta = {
...header('returned-child', childCwd),
parentSession: SessionId('parent'),
@@ -357,6 +365,7 @@ describe('ApiSession create or adoption', () => {
const wrong = await harness()
const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-'))
tempDirs.push(requestedCwd)
const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other'))
vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({
agent: wrongAgent,
@@ -437,6 +446,7 @@ describe('ApiSession create or adoption', () => {
it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => {
const { agents } = await harness()
const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
tempDirs.push(parent)
const file = join(parent, 'file')
writeFileSync(file, 'not a directory')
await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))
@@ -1,6 +1,6 @@
/** Session creation and adoption rules for Agent preset identity. */
import { mkdtempSync, realpathSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
@@ -10,9 +10,17 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { createSessionTestRemote } from './test-remote.ts'
/** Booted contexts and their temp roots, torn down after each test. */
const contexts: Context[] = []
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function stubAgent(session: Session): Agent {
return { id: session.id, session, status: 'idle' } as unknown as Agent
}
@@ -42,7 +50,9 @@ function roster(ids: readonly string[]): unknown {
async function harness(presets?: readonly string[]) {
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-')))
tempDirs.push(cwd)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (presets !== undefined) {
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -22,8 +22,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
const roots: Context[] = []
/** Workspace roots created per test, removed after their context settles. */
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
interface Deferred<T> {
@@ -39,6 +43,7 @@ function deferred<T>(): Deferred<T> {
async function harness() {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-')))
tempDirs.push(root)
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
+12 -3
View File
@@ -1,8 +1,8 @@
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
@@ -13,7 +13,16 @@ import {
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
const tempRoots: string[] = []
afterAll(() => {
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
const tmp = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
tempRoots.push(dir)
return dir
}
describe('resolveConfigPath', () => {
it('resolves relative to the given cwd outside replay mode', () => {
@@ -7,18 +7,27 @@
* shared overlay whose row exists only on another surface.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
const tempRoots: string[] = []
afterAll(() => {
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
const tmp = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
tempRoots.push(dir)
return dir
}
function writeBase(dir: string): string {
const base = join(dir, 'base.yml')
@@ -4,10 +4,10 @@
* previous generation has been retained or restored.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Include } from '@deepseek-ai/cordis-plugin-include'
import { boot } from '../src/index.ts'
@@ -16,6 +16,11 @@ const NAME = 'dsh-test-bin'
const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
const tempRoots: string[] = []
afterAll(() => {
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
interface TreeFixture {
ctx: Context
dir: string
@@ -24,6 +29,7 @@ interface TreeFixture {
async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
tempRoots.push(dir)
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
writeFileSync(join(dir, 'cordis.yml'), configBody)
@@ -282,6 +288,7 @@ describe('loader tree replacement', () => {
describe('include refresh with overlay patches', () => {
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
tempRoots.push(dir)
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
@@ -347,6 +354,7 @@ describe('include patches layered over one base', () => {
// must therefore be able to reach a row an earlier layer inserted, or
// bundle-only rows would be invisible to the user's patch layer.
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
tempRoots.push(dir)
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
writeFileSync(join(dir, 'cordis.yml'), [
@@ -7,7 +7,10 @@ import { Context } from '@deepseek-ai/cordis'
import Hmr from '@deepseek-ai/cordis-plugin-hmr'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
/** Every per-test tree root, removed once the booted watcher has been disposed. */
const hmrRoots: string[] = []
async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
const ctx = new Context()
@@ -32,6 +35,10 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
}
describe('HMR exact config paths', () => {
afterEach(() => {
for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
const alias = `${target}-alias`
@@ -85,6 +92,7 @@ describe('HMR exact config paths', () => {
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
hmrRoots.push(dir)
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(dir)
const observed: string[] = []
@@ -111,6 +119,7 @@ describe('HMR exact config paths', () => {
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
hmrRoots.push(root)
const dir = join(root, 'later')
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(root)
@@ -129,6 +138,7 @@ describe('HMR exact config paths', () => {
it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
hmrRoots.push(dir)
const filename = join(dir, 'plugins.yml')
writeFileSync(filename, 'one')
const ctx = await bootHmr(dir)
@@ -170,6 +180,7 @@ describe('HMR exact config paths', () => {
it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
hmrRoots.push(dir)
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(dir)
const failure = Promise.withResolvers<{ filename: string; error: Error }>()
+11 -2
View File
@@ -11,7 +11,7 @@ import {
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
import { describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it } from 'vitest'
import {
composeEntries,
healProfilesModuleFallback,
@@ -26,7 +26,16 @@ import {
type Profile,
} from '../src/index.ts'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
const tempRoots: string[] = []
afterAll(() => {
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
const tmp = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-'))
tempRoots.push(dir)
return dir
}
/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
function stageInstallation(
@@ -4,11 +4,11 @@
* a real Loader tree, kept live through transactional HMR.
*/
import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterAll, afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Hmr from '@deepseek-ai/cordis-plugin-hmr'
import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -23,7 +23,16 @@ import {
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
const tempRoots: string[] = []
afterAll(() => {
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
const tmp = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
tempRoots.push(dir)
return dir
}
async function eventually(test: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 10_000
+6 -1
View File
@@ -4,7 +4,7 @@
* active, then resolves that row's config against its injection-ready context.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { EventEmitter } from 'node:events'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -34,6 +34,9 @@ interface Fixture {
const disposers: (() => Promise<void>)[] = []
/** Fixture tree roots, removed after their booted tree has been disposed. */
const tempDirs: string[] = []
const readyApp: AppReady = {
onReady(listener) {
listener()
@@ -59,6 +62,7 @@ function controlledAppReady(): { service: AppReady; commit(): void } {
afterEach(async () => {
for (const dispose of disposers.splice(0)) await dispose()
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
internals.stdin = process.stdin
internals.stdout = process.stdout
internals.stderr = process.stderr
@@ -103,6 +107,7 @@ async function bootFixture(
options: { objectInject?: boolean; withoutProvider?: boolean } = {},
): Promise<Fixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
tempDirs.push(dir)
const observed: Observed = { exits: [], out: '' }
writeFileSync(join(dir, 'reader.mjs'), `
export const name = 'reader'
@@ -4,7 +4,7 @@
* the consumer pending.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -24,8 +24,12 @@ interface Observed {
const disposers: (() => Promise<void>)[] = []
/** Fixture tree roots, removed after their booted tree has been disposed. */
const tempDirs: string[] = []
afterEach(async () => {
for (const dispose of disposers.splice(0)) await dispose()
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
internals.stdout = process.stdout
internals.stderr = process.stderr
})
@@ -37,6 +41,7 @@ afterEach(async () => {
*/
async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-'))
tempDirs.push(dir)
const observed: Observed = { exits: [], out: '' }
writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n')
// Loader imports through Node's resolver, so this fixture delegates to the
@@ -3,7 +3,7 @@
* releases a consumer whose config reads `ctx.webStartup` directly.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -23,8 +23,12 @@ interface Observed {
const disposers: (() => Promise<void>)[] = []
/** Fixture tree roots, removed after their booted tree has been disposed. */
const tempDirs: string[] = []
afterEach(async () => {
for (const dispose of disposers.splice(0)) await dispose()
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
internals.stdout = process.stdout
internals.stderr = process.stderr
})
@@ -39,6 +43,7 @@ async function bootProvider(args: string[]): Promise<{
observed: Observed
}> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-'))
tempDirs.push(dir)
const observed: Observed = { exits: [], out: '' }
writeFileSync(join(dir, 'reader.mjs'), `
export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config }
@@ -1,7 +1,7 @@
import { mkdtemp } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/file-store.ts'
@@ -31,6 +31,12 @@ const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' }
const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 }
const NOW = 1_700_000_000_000
/** Every temp store root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
function requestUrl(input: string | URL | Request): string {
if (typeof input === 'string') return input
return input instanceof URL ? input.href : input.url
@@ -64,6 +70,7 @@ function uploadFetch(now: () => number = () => NOW) {
describe('DeepSeekFileStore', () => {
it('singleflights the first upload and reuses the durable mapping across store instances', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const remote = uploadFetch()
const first = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl })
@@ -84,6 +91,7 @@ describe('DeepSeekFileStore', () => {
it('keeps a shared upload alive while another waiter remains', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
let complete: ((response: Response) => void) | undefined
let uploadSignal: AbortSignal | undefined
@@ -123,6 +131,7 @@ describe('DeepSeekFileStore', () => {
it('aborts the shared upload after its only waiter cancels', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
let uploadSignal: AbortSignal | undefined
const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
@@ -149,6 +158,7 @@ describe('DeepSeekFileStore', () => {
it('normalizes a non-Error cancellation reason', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => (
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
@@ -176,6 +186,7 @@ describe('DeepSeekFileStore', () => {
it('starts a fresh upload while the cancelled transport is settling', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
let requests = 0
const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
requests += 1
@@ -222,6 +233,7 @@ describe('DeepSeekFileStore', () => {
it('does not persist an upload whose response is missing and retries on the next request', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const good = uploadFetch()
let first = true
@@ -242,6 +254,7 @@ describe('DeepSeekFileStore', () => {
it('rejects an upload response whose byte count differs from the request version', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000,
filename: 'dsh-wrong.png', purpose: 'user_data',
@@ -262,6 +275,7 @@ describe('DeepSeekFileStore', () => {
['image/gif', 'gif'],
] as const)('uses the %s filename extension for uploads', async (mediaType, extension) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const remote = uploadFetch()
const store = new DeepSeekFileStore({
index: new DeepSeekUploadIndex(join(dir, `${extension}.json`)),
@@ -279,6 +293,7 @@ describe('DeepSeekFileStore', () => {
it('normalizes a non-Error failure from the durable upload index', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
vi.spyOn(index, 'get').mockRejectedValue('index unavailable')
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
@@ -291,6 +306,7 @@ describe('DeepSeekFileStore', () => {
it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
let now = NOW
const remote = uploadFetch(() => now)
@@ -311,6 +327,7 @@ describe('DeepSeekFileStore', () => {
it('releases an indexed file through DELETE and removes only that mapping', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const remote = uploadFetch()
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl })
@@ -323,6 +340,7 @@ describe('DeepSeekFileStore', () => {
it('removes a losing upload and keeps the winning durable mapping when duplicate cleanup fails', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
vi.spyOn(index, 'commit').mockResolvedValue({
accepted: false,
@@ -352,6 +370,7 @@ describe('DeepSeekFileStore', () => {
it('reclaims one owned file after quota rejection and retries the upload once', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
let uploads = 0
const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
if (init?.method === 'POST') {
@@ -394,6 +413,7 @@ describe('DeepSeekFileStore', () => {
it('preserves a quota error when no harness-owned file can be reclaimed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({
error: { message: 'file count quota exceeded', code: 'file_quota' },
@@ -418,6 +438,7 @@ describe('DeepSeekFileStore', () => {
it('finishes pagination before deleting cursor files during quota recovery', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const deleted = new Set<string>()
const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const target = new URL(requestUrl(input))
@@ -456,6 +477,7 @@ describe('DeepSeekFileStore', () => {
it('stops pagination when a page omits or repeats its cursor', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
for (const mode of ['missing', 'repeated'] as const) {
let page = 0
const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
@@ -482,6 +504,7 @@ describe('DeepSeekFileStore', () => {
it('releases every batch and clears the scoped upload index', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
const reclaim = vi.spyOn(store, 'reclaimOldestOwned')
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import { DeepSeekFileId } from '../src/file-id.ts'
import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
@@ -9,6 +9,12 @@ import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`)
const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`)
/** Every temp index root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
describe('DeepSeekUploadIndex', () => {
it('normalizes trailing endpoint slashes in the credential scope', () => {
expect(deepSeekFileScope('https://api.deepseek.com///', 'key'))
@@ -17,6 +23,7 @@ describe('DeepSeekUploadIndex', () => {
it('isolates API-key namespaces and reuses only records above the refresh margin', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const first = deepSeekFileScope('https://api.deepseek.com', 'first-key')
const second = deepSeekFileScope('https://api.deepseek.com', 'second-key')
@@ -38,6 +45,7 @@ describe('DeepSeekUploadIndex', () => {
it('keeps a reusable cross-process winner and removes only an exact generation', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
const first = {
@@ -56,6 +64,7 @@ describe('DeepSeekUploadIndex', () => {
it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const path = join(dir, 'index.json')
await writeFile(path, '{bad', 'utf8')
const index = new DeepSeekUploadIndex(path)
@@ -128,6 +137,7 @@ describe('DeepSeekUploadIndex', () => {
})}]}`,
])('treats an invalid persisted index as empty %#', async (text) => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const path = join(dir, 'index.json')
await writeFile(path, text, 'utf8')
const index = new DeepSeekUploadIndex(path)
@@ -138,6 +148,7 @@ describe('DeepSeekUploadIndex', () => {
it('rejects duplicate persisted mappings as a corrupt cache', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const path = join(dir, 'index.json')
const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
const record = {
@@ -151,6 +162,7 @@ describe('DeepSeekUploadIndex', () => {
it('drops expired records on commit and clears only the selected namespace', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
const first = deepSeekFileScope('https://api.deepseek.com', 'first')
const second = deepSeekFileScope('https://api.deepseek.com', 'second')
@@ -171,6 +183,7 @@ describe('DeepSeekUploadIndex', () => {
it('propagates non-cache filesystem read failures', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
roots.push(dir)
const path = join(dir, 'directory')
await mkdir(path)
const index = new DeepSeekUploadIndex(path)
@@ -6,7 +6,7 @@
* stays read-only.
*/
import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import AgentPresets, {
COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config,
} from '@deepseek-ai/dsh-agent-presets'
@@ -23,6 +23,12 @@ import AgentPresets, {
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
/** Every temp root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
let ctx: Context
let userRoot: string
@@ -49,6 +55,7 @@ async function seedPreset(
beforeEach(async () => {
userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-'))
roots.push(userRoot)
ctx = new Context()
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
await ctx.plugin(Loader)
@@ -201,6 +208,7 @@ describe('deleting a preset', () => {
describe('a deployment with more than one user root', () => {
it('refuses to delete a preset the writable root does not own', async () => {
const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-'))
roots.push(second)
await seedPreset(second, 'elsewhere')
const layered = new Context()
layered.baseUrl = pathToFileURL(FIXTURES).href + '/'
@@ -246,7 +254,9 @@ describe('a deployment with no writable root', () => {
describe('a user root that does not exist yet', () => {
it('is created by the first copy', async () => {
const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset')
const absentRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-absent-'))
roots.push(absentRoot)
const absent = join(absentRoot, 'nested', 'preset')
const fresh = new Context()
fresh.baseUrl = pathToFileURL(FIXTURES).href + '/'
await fresh.plugin(Loader)
@@ -5,7 +5,7 @@
* read reported broken by reason instead of dropped.
*/
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -33,6 +33,9 @@ const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n'
const contexts: Context[] = []
/** Every temp root created by this file, removed after its contexts settle. */
const roots: string[] = []
/** A Loader-context evaluator over an empty scope, enough for literal gates. */
const evaluateExpression = (expression: string): unknown => evaluate({}, expression)
/** An evaluator that refuses every expression, leaving rows conditional. */
@@ -41,6 +44,7 @@ const refuseExpression = (): never => { throw new Error('no loader context') }
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function harness(roster: Config): Promise<Context> {
@@ -63,6 +67,7 @@ async function harness(roster: Config): Promise<Context> {
describe('fileComposition', () => {
it('flattens groups and keeps refused expressions conditional', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
roots.push(dir)
const path = join(dir, COMPOSITION_FILE)
await writeFile(path, [
'- id: alpha',
@@ -124,6 +129,7 @@ describe('fileComposition', () => {
it('evaluates decidable gates the way a mount would', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
roots.push(dir)
const path = join(dir, COMPOSITION_FILE)
await writeFile(path, [
'- id: off',
@@ -144,6 +150,7 @@ describe('fileComposition', () => {
it('answers broken for a file that stopped reading as a composition', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
roots.push(dir)
const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression)
expect(missing).toHaveProperty('broken')
@@ -201,6 +208,7 @@ describe('mountedCompositionRows', () => {
describe('AgentPresets.compositionInventory', () => {
it('reads unmounted presets from their files, marking the default and metadata', async () => {
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-'))
roots.push(userRoot)
await mkdir(join(userRoot, 'documented'))
await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [
VALID.trimEnd(),
@@ -287,6 +295,7 @@ describe('AgentPresets.compositionInventory', () => {
it('prefers the standing mount over a file that broke after mounting', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-composition-volatile-'))
roots.push(root)
await mkdir(join(root, 'volatile'))
const plugin = join(FIXTURES, 'plugins', 'contribute.js')
await writeFile(
@@ -344,6 +353,7 @@ describe('AgentPresets.compositionInventory', () => {
it('keeps a broken preset on the inventory with its discovery reason', async () => {
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-'))
roots.push(userRoot)
await mkdir(join(userRoot, 'damaged'))
const ctx = await harness({
default: 'minimal',
@@ -1,8 +1,8 @@
import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
const fsHarness = vi.hoisted(() => ({
@@ -31,6 +31,12 @@ const HARNESS = new URL('.', import.meta.url).href
const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
/** Every temp root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
beforeEach(() => {
fsHarness.nextReadError = undefined
})
@@ -38,6 +44,7 @@ beforeEach(() => {
describe('display order', () => {
it('puts declared order first, then everything else by id', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-order-'))
roots.push(root)
for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) {
await mkdir(join(root, id), { recursive: true })
await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
@@ -57,6 +64,7 @@ describe('display order', () => {
it('breaks a tie between equal declared orders by id', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-'))
roots.push(root)
for (const id of ['yankee', 'alpha']) {
await mkdir(join(root, id), { recursive: true })
await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
@@ -94,6 +102,7 @@ describe('preset discovery', () => {
it('skips a directory whose name no preset id could ever claim', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-'))
roots.push(root)
await mkdir(join(root, '.hidden'))
await mkdir(join(root, 'Has_Caps'))
await mkdir(join(root, 'usable'))
@@ -129,6 +138,7 @@ describe('preset discovery', () => {
it('ignores a plain file sitting beside the preset directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
roots.push(root)
await writeFile(join(root, 'stray.yml'), '- id: x\n')
await mkdir(join(root, 'real'))
await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n')
@@ -140,6 +150,7 @@ describe('preset discovery', () => {
it('reports a root it cannot read rather than treating it as empty', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
roots.push(root)
const notADirectory = join(root, 'file-as-root')
await writeFile(notADirectory, 'not a directory\n')
@@ -168,6 +179,7 @@ describe('composition health', () => {
*/
async function scanned(composition: string): Promise<string | undefined> {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-'))
roots.push(root)
await mkdir(join(root, 'probe'))
await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
@@ -208,6 +220,7 @@ describe('composition health', () => {
it('reports a composition that stats but cannot be read', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-'))
roots.push(root)
await mkdir(join(root, 'sealed'))
const path = join(root, 'sealed', COMPOSITION_FILE)
await writeFile(path, '[]\n')
@@ -235,6 +248,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
/** One directory under a fresh root holding `composition`, scanned. */
async function scanned(composition: string): Promise<string | undefined> {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-resolve-'))
roots.push(root)
await mkdir(join(root, 'probe'))
await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
@@ -272,6 +286,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
it('resolves a preset-relative row against the preset\'s own directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-relative-'))
roots.push(root)
await mkdir(join(root, 'probe'))
await writeFile(join(root, 'probe', 'own-plugin.mjs'), 'export function apply() {}\n')
await writeFile(join(root, 'probe', COMPOSITION_FILE), '- id: own\n name: ./own-plugin.mjs\n- id: gone\n name: ./deleted.mjs\n')
@@ -311,6 +326,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
// The fast path, and the one that has to answer alone: this package has a
// directory and nothing to import, so a resolver would reject it.
const home = await mkdtemp(join(tmpdir(), 'dsh-presets-installed-'))
roots.push(home)
await mkdir(join(home, 'node_modules', '@scope', 'pkg'), { recursive: true })
await writeFile(join(home, 'node_modules', '@scope', 'pkg', 'package.json'), '{"name":"@scope/pkg"}\n')
await mkdir(join(home, 'presets', 'probe'), { recursive: true })
@@ -326,6 +342,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
// What a stale profile install leaves behind: the name is still in
// `node_modules`, pointing at a checkout that is gone.
const home = await mkdtemp(join(tmpdir(), 'dsh-presets-dangling-'))
roots.push(home)
await mkdir(join(home, 'node_modules', '@scope'), { recursive: true })
await symlink(join(home, 'deleted-checkout'), join(home, 'node_modules', '@scope', 'pkg'))
await mkdir(join(home, 'presets', 'probe'), { recursive: true })
@@ -6,15 +6,22 @@
* from the file a user can write.
*/
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts'
/** Every temp preset directory created by this file, removed after each test. */
const tempDirs: string[] = []
afterEach(async () => {
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
})
/** A preset directory holding exactly the given metadata text. */
async function presetDir(content?: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-'))
tempDirs.push(dir)
await mkdir(dir, { recursive: true })
if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content)
return dir
@@ -13,7 +13,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentPresets, {
COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
} from '@deepseek-ai/dsh-agent-presets'
@@ -86,6 +86,13 @@ function rootResolves(ctx: Context, name: string): boolean {
}
let ctx: Context
/** Every temp preset root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
beforeEach(async () => {
ctx = await harness()
})
@@ -93,6 +100,7 @@ beforeEach(async () => {
describe('composing an agent from a preset', () => {
it('hands an absolute plugin path to Node as a file URL', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-'))
roots.push(root)
const presetDir = join(root, 'absolute')
const plugin = join(FIXTURES, 'plugins', 'contribute.js')
await mkdir(presetDir)
@@ -360,6 +368,7 @@ describe('composing from a broken preset', () => {
/** A roster whose only user preset carries `composition`. */
async function rosterWith(composition: string): Promise<Context> {
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
roots.push(root)
await mkdir(join(root, 'damaged'))
await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false })
@@ -428,6 +437,7 @@ describe('the preset file is an input, never a persistence target', () => {
// committed fixture would be mutated by the very run that proves the bug
// and every later run would compare against the damaged file and pass.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-'))
roots.push(root)
const dir = join(root, 'self-disposing')
await mkdir(dir)
const path = join(dir, COMPOSITION_FILE)
@@ -625,6 +635,7 @@ describe('replacing a composition', () => {
// A preset root this test owns, so removing the composition mid-flight
// cannot disturb the shipped fixtures.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
roots.push(root)
const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', `- id: nope\n name: ${join(FIXTURES, 'plugins', 'throws.js')}\n config:\n message: refuses\n`]]
for (const [id, body] of seeded) {
await mkdir(join(root, id))
@@ -679,6 +690,7 @@ describe('editing a composition file', () => {
*/
async function editable(id: string): Promise<{ scoped: Context; path: string }> {
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-'))
roots.push(root)
await mkdir(join(root, id))
const path = join(root, id, COMPOSITION_FILE)
await writeFile(path, rowFor('before'))
@@ -4,7 +4,7 @@
* which is the only one of the three that mutates an agent.
*/
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -34,7 +34,13 @@ const ROOTS = [
// temp preset directory these tests seed would report the composition broken.
const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n'
afterEach(() => vi.restoreAllMocks())
/** Every temp preset root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function remoteFailure(operation: Promise<unknown>): Promise<RemoteFailure> {
try {
@@ -97,6 +103,7 @@ const recordedPreset = (agent: Agent): unknown =>
describe('the roster a client reads', () => {
it('projects path-free rows, marking the default and carrying published metadata', async () => {
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
roots.push(userRoot)
await mkdir(join(userRoot, 'documented'), { recursive: true })
await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
@@ -122,6 +129,7 @@ describe('the roster a client reads', () => {
it('keeps a broken preset on the roster with its reason', async () => {
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
roots.push(userRoot)
await mkdir(join(userRoot, 'damaged'), { recursive: true })
const ctx = await harness({
default: 'standard',
@@ -174,6 +182,7 @@ describe('reading one composition', () => {
it('carries the display metadata a preset published', async () => {
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
roots.push(userRoot)
await mkdir(join(userRoot, 'documented'), { recursive: true })
await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
@@ -241,6 +250,7 @@ describe('authoring over Remote', () => {
it('copies and deletes through the Remote adapters', async () => {
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
roots.push(userRoot)
const ctx = await harness({
default: 'standard',
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
@@ -432,6 +442,7 @@ describe('switching one session\'s composition', () => {
it('reports an unusable composition with its discovery reason', async () => {
const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
roots.push(userRoot)
await mkdir(join(userRoot, 'damaged'), { recursive: true })
const ctx = await harness({
default: 'standard',
@@ -4,7 +4,7 @@
* so a person can change which preset new sessions get without a restart.
*/
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -19,13 +19,19 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }]
const NS = SETTINGS_NAMESPACE
/** Every temp root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
/**
* A composition with a real file-backed settings provider. `settingsFiber` is
* the provider's own handle, so a test can take it away the way a reload does.
@@ -34,6 +40,7 @@ async function harness(
extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [],
): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> {
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-'))
roots.push(home)
const settingsFile = join(home, 'settings.yaml')
await writeFile(settingsFile, '{}\n')
@@ -119,6 +126,7 @@ describe('the default preset as a user setting', () => {
it('clears a user default it has just deleted', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-'))
roots.push(root)
await mkdir(join(root, 'mine'))
await writeFile(
join(root, 'mine', COMPOSITION_FILE),
@@ -9,7 +9,7 @@
* suite: the derived writable root is resolved in the constructor.
*/
import { mkdtemp, readFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -24,16 +24,19 @@ import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const SYSTEM_ROOT = join(FIXTURES, 'system')
let home: string
let previousHome: string | undefined
beforeEach(async () => {
previousHome = process.env.DSH_HOME
process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-'))
home = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-'))
process.env.DSH_HOME = home
})
afterEach(() => {
afterEach(async () => {
if (previousHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousHome
await rm(home, { recursive: true, force: true })
})
/** Boot a roster with the shipped root left to the plugin's default. */
@@ -10,7 +10,7 @@
* temporary home, or it would reach the developer's real one.
*/
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -31,15 +31,20 @@ const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\
let home: string
let previousHome: string | undefined
/** Extra per-test roots, removed with the home directory. */
const explicitRoots: string[] = []
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-'))
previousHome = process.env.DSH_HOME
process.env.DSH_HOME = home
})
afterEach(() => {
afterEach(async () => {
if (previousHome === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previousHome
await rm(home, { recursive: true, force: true })
for (const root of explicitRoots.splice(0)) await rm(root, { recursive: true, force: true })
})
/** Boot a roster over the fixture system root, with the derived root left to the plugin. */
@@ -120,6 +125,7 @@ describe('the harness-home preset root', () => {
it('yields to a configured user root for authoring, which writableRoot takes first', async () => {
const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-'))
explicitRoots.push(explicit)
const ctx = await roster({
roots: [
{ path: SYSTEM_ROOT, trust: 'system' as const },
@@ -7,10 +7,10 @@
* are all exercised through the real `confine()` path.
*/
import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
@@ -24,6 +24,12 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
/** Every temp dir created by this file (fake launchers and runner entries), removed after each test. */
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) {
const ctx = new Context()
await ctx.plugin(LocalSandboxProvider, config)
@@ -39,12 +45,15 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter
* `sandbox-windows-acl/lib/runner.js`.
*/
function absentRunnerEntry(): string {
return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js')
const dir = mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-'))
tempDirs.push(dir)
return join(dir, 'runner.js')
}
/** Write an executable fake `landlock-run` that answers `--probe` with `report`. */
function fakeLauncher(report = 'landlock: fully enforced'): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
return launcher
@@ -53,6 +62,7 @@ function fakeLauncher(report = 'landlock: fully enforced'): string {
/** Write an executable fake `sandbox-exec` that exits `status` for any invocation. */
function fakeSeatbeltExec(status: number): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-seatbelt-'))
tempDirs.push(dir)
const exec = join(dir, 'sandbox-exec')
writeFileSync(exec, `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 })
return exec
@@ -316,6 +326,7 @@ describe('the default landlock probe (launcher CLI contract)', () => {
it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
@@ -337,6 +348,7 @@ describe('probeTimeoutMs config', () => {
// spawnSync blocks the worker and fork/exec latency inflates wall-clock)
// cannot flip either verdict; the vitest timeout clears the patient budget.
const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
@@ -440,6 +452,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
it('prefers the built lib/runner.js entry when the resolved file exists', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-'))
tempDirs.push(dir)
const builtEntry = join(dir, 'runner.js')
writeFileSync(builtEntry, '')
const { sandbox } = await setup({}, {
+15 -8
View File
@@ -4,16 +4,22 @@
* deriving from `writableRoots` — cannot drift.
*/
import { realpathSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
/** Every temp root created by this file, removed after each test. */
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('canonicalPath', () => {
it('resolves symlinks (an existing path realpaths)', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
roots.push(dir)
expect(canonicalPath(dir)).toBe(realpathSync.native(dir))
})
@@ -29,11 +35,12 @@ describe('writableRoots', () => {
it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
expect(roots).toContain(realpathSync.native(ws))
expect(roots).toContain(canonicalPath('/tmp'))
expect(roots).toContain(realpathSync.native(tmpdir()))
roots.push(ws)
const writable = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
expect(writable).toContain(realpathSync.native(ws))
expect(writable).toContain(canonicalPath('/tmp'))
expect(writable).toContain(realpathSync.native(tmpdir()))
// Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
expect(new Set(roots).size).toBe(roots.length)
expect(new Set(writable).size).toBe(writable.length)
})
})
@@ -1,7 +1,7 @@
import { mkdtempSync } from 'node:fs'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -10,6 +10,10 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
afterAll(() => {
rmSync(spillDir, { recursive: true, force: true })
})
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
const ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
@@ -9,11 +9,11 @@
* writes CRLF on Windows, so exact text assertions normalize line endings.
*/
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { afterAll, afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -24,6 +24,16 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
afterAll(() => {
rmSync(spillDir, { recursive: true, force: true })
})
/** Per-test temp dirs, removed after each test. */
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
// The probe follows the executor's own resolution (Program Files installs on
// Windows are found even when bare `pwsh` is not on PATH).
const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
@@ -114,6 +124,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
it('returns the first EXISTING win32 candidate, else pwsh', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-'))
tempDirs.push(dir)
const store = join(dir, 'store')
mkdirSync(store, { recursive: true })
writeFileSync(join(store, 'pwsh.exe'), '')
@@ -131,6 +142,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
// Store app execution aliases stat as EACCES but lstat as a link; a
// dangling symlink reproduces that split on every platform.
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-'))
tempDirs.push(dir)
const store = join(dir, 'store')
mkdirSync(store, { recursive: true })
const link = join(store, 'pwsh.exe')
@@ -141,6 +153,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
it('skips a directory candidate and falls through to the PATH-resolution default', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-'))
tempDirs.push(dir)
const store = join(dir, 'store')
mkdirSync(join(store, 'pwsh.exe'), { recursive: true })
expect(resolvePwshPath(undefined, {
@@ -201,6 +214,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
it('uses config cwd, overridable per call', async () => {
const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))
tempDirs.push(first, second)
const { bash } = await setup({ cwd: first })
const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true)
@@ -3,7 +3,7 @@ import type { Stats } from 'node:fs'
import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SkillRegistry from '@deepseek-ai/dsh-skill'
@@ -91,8 +91,16 @@ vi.mock('chokidar', () => ({
const SkillFileSystem = await import('../src/index.ts')
/** Every temp dir created by this file, removed after each test. */
const tempDirs: string[] = []
afterEach(async () => {
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
})
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
tempDirs.push(dir)
return dir
}
async function writeSkill(root: string, name: string): Promise<void> {
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -7,8 +7,16 @@ import SkillRegistry from '@deepseek-ai/dsh-skill'
import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import * as SkillFileSystem from '../src/index.ts'
/** Every temp dir created by this file, removed after each test. */
const tempDirs: string[] = []
afterEach(async () => {
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
})
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
tempDirs.push(dir)
return dir
}
async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { mkdir, writeFile } from 'node:fs/promises'
import { afterEach, describe, expect, it } from 'vitest'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from '@deepseek-ai/cordis'
@@ -15,8 +15,16 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
const testToolSignal = new AbortController().signal
/** Every temp dir created by this file, removed after each test. */
const tempDirs: string[] = []
afterEach(async () => {
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
})
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
tempDirs.push(dir)
return dir
}
async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {
+8 -1
View File
@@ -7,7 +7,7 @@
*/
import { createHash, randomBytes } from 'node:crypto'
import { mkdtempSync } from 'node:fs'
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdir, open } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -38,6 +38,13 @@ export function privateRoot(): string {
return defaultRoot
}
// The spill root is private to this process, so it is removed when the process
// ends normally; a process killed with SIGKILL cannot run this, and its
// residue is left to OS temp hygiene.
process.once('exit', () => {
if (defaultRoot !== undefined) rmSync(defaultRoot, { recursive: true, force: true })
})
// Spill keeps its empty-name policy local so storage backends stay decoupled.
/* jscpd:ignore-start */
/**
@@ -10,7 +10,7 @@
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
import type { Readable } from 'node:stream'
import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
import { closeSync, mkdtempSync, openSync, rmSync, unlinkSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleepMs } from 'node:timers/promises'
@@ -91,6 +91,13 @@ function privateSpillDir(): string {
return defaultSpillDir
}
// The spill directory is private to this process, so it is removed when the
// process ends normally; a process killed with SIGKILL cannot run this, and
// its residue is left to OS temp hygiene.
process.once('exit', () => {
if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true })
})
/**
* Collects one stream with a bounded in-memory tail. With a spill cap, on
* first overflow a spill file is created and every chunk (including those
@@ -1,7 +1,7 @@
import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import {
childEnv,
killGroup,
@@ -82,6 +82,10 @@ vi.mock('node:fs', async (importOriginal) => {
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
afterAll(() => {
rmSync(spillDir, { recursive: true, force: true })
})
type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
stdoutMaxBytes?: number
stderrMaxBytes?: number
+10 -3
View File
@@ -1,4 +1,4 @@
import { access, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -22,7 +22,12 @@ import {
const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
afterEach(() => vi.restoreAllMocks())
/** Every temporary root created by this file, removed after each test. */
const roots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
async function writeBlob(command: CoverageCommand): Promise<void> {
if (command.blobPath === undefined) return
@@ -31,7 +36,9 @@ async function writeBlob(command: CoverageCommand): Promise<void> {
}
async function temporaryRoot(): Promise<string> {
return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
const root = await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-'))
roots.push(root)
return root
}
/** Write a Vitest results cache under a temporary root. */