Merge pull request #3513 from deepseek-harness/fix/test-tmp-teardown-self-clean

test: remove dsh-* temp dirs created by unit tests at teardown
This commit is contained in:
Chinesezjc authored and GitHub committed 2026-09-04 12:11:53 +08:00
commit bee0c13f28
45 files changed
+512 -93

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: 175fe82b0ad01c2afda3abb5f66ff455045a3cd6
2026-08-28-test-temp-dir-self-cleanup.zh.md: c2fcd07d1619d1556ac99b336f27b89a5f46e426
@@ -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 cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit when it holds no completed spill file — completed spill files are retained as full-output recovery artifacts until an external cleanup, so only directories that never spilled (the dominant residue shape on the CI host: 92% of sampled `dsh-subprocess-*` dirs are empty) are removed. The removal is best-effort (ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference.
## Verification
- Targeted local runs of every changed unit spec passed (the 36 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane.
- 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; a `dsh-subprocess-local` per-process spill directory holding no completed spill file is removed at a JavaScript-observable process exit.
- 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 spill dirs holding spill files, and `dsh-spill-local`'s default root, keep their files per the existing retention policy and are cleaned by that package's own sweep.
@@ -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)确认在正常结束路径上本来干净,不改。
- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时,仅当目录**未持有任何已完成的 spill 文件**才删除——已完成的 spill 文件作为完整输出恢复产物保留到外部清理,因此只有从未 spill 过的目录(CI 主机残留的主流形态:抽样 `dsh-subprocess-*` 目录 92% 为空)会被删除。删除是 best-effort(ENOENT/ENOTEMPTY/EBUSY/EPERM 不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。
## Verification
- 本地定向跑过全部改动单测 spec 通过(36 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。
- 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 删除;`dsh-subprocess-local` 未持有任何已完成 spill 文件的每进程 spill 目录在 JavaScript 可观察的进程退出时删除。
- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。
- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与 `dsh-spill-local` 的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。
@@ -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 }
@@ -100,9 +100,14 @@ describe('workspace-write containment', () => {
})
it('a write to the platform temp area lands (parity with the bash runner grant)', async () => {
const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt')
await fs.writeText(await target(path), 'temp')
expect(await readFile(path, 'utf8')).toBe('temp')
const dir = await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-'))
try {
const path = join(dir, 'temp.txt')
await fs.writeText(await target(path), 'temp')
expect(await readFile(path, 'utf8')).toBe('temp')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
it('an absolute path outside the workspace is denied, no file created', async () => {
@@ -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)
@@ -8,7 +8,7 @@
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -23,6 +23,10 @@ import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
afterAll(() => {
rmSync(spillDir, { recursive: true, force: true })
})
/** One recorded provider call: the argv handed over and the policy it rode with. */
interface ConfineCall {
argv: string[]
@@ -518,12 +522,17 @@ describe('result facts', () => {
it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
const { bash } = await setup()
const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
mkdirSync(lockedDir)
chmodSync(lockedDir, 0o555)
const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
const deniedRoot = mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-'))
try {
const lockedDir = join(deniedRoot, 'locked')
mkdirSync(lockedDir)
chmodSync(lockedDir, 0o555)
const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
} finally {
rmSync(deniedRoot, { recursive: true, force: true })
}
})
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
@@ -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)
+6 -2
View File
@@ -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, vi } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
@@ -29,6 +29,10 @@ const testToolSignal = new AbortController().signal
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
afterAll(() => {
rmSync(spillDir, { recursive: true, force: true })
})
/** Foreground-only harness: no job runtime (backgrounding fails loud here). */
async function setup() {
const ctx = new Context()
+10 -2
View File
@@ -10,9 +10,9 @@
* is pinned separately in integration.spec.ts.
*/
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { mkdtempSync, realpathSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
@@ -38,6 +38,12 @@ import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts'
const testToolSignal = new AbortController().signal
/** Per-test temp dirs (session cwd/home fixtures), removed after each test. */
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
/**
* A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
* returns the armed foreground script, `start()` returns the armed background
@@ -388,6 +394,7 @@ describe('argument validation', () => {
describe('execution through the bash seam', () => {
it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
tempDirs.push(dshHome)
const { ctx, bash } = await setup({}, dshHome)
bash.handler = () => runResult('hi\n')
const agent = registerFakeAgent(ctx, 'session-1')
@@ -538,6 +545,7 @@ describe('per-call sandbox policy resolution', () => {
it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => {
const { ctx, bash } = await setupSandboxed()
const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-'))
tempDirs.push(sessionCwd)
const agent = registerFakeAgent(ctx, 'policy-session')
Object.assign(agent.session.header, { cwd: sessionCwd })
const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent)
@@ -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'
@@ -17,8 +17,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> {
@@ -2,5 +2,5 @@
# 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 packages/subprocess/subprocess-local/README.md
README.md: cacd900729237d60f1bfeab9723ed83be3d9f3aa
README.zh.md: 559ec64cc3cb0d416934c821090daef3df999c58
README.md: d8e44d160a01fa2f394aaf781cf90728b0506c77
README.zh.md: 4a3fed4fa8525be1428a61c8bdc1c3fb8497f263
@@ -50,7 +50,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina
### Shutdown behavior
Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. The same exit removes the private per-process spill directory when it holds no completed spill file (completed spill files are retained as full-output recovery artifacts until an external cleanup). Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
### What can go wrong
@@ -127,7 +127,7 @@ These limits define when the provider is a poor fit or needs special operational
- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor.
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner.
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work.
- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill directory) accumulate under the OS tmpdir until something external cleans them.
- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit only when it holds no completed spill file.
<a id="dev-note"></a>
### Dev Note
@@ -50,7 +50,7 @@ kind: "package-reference"
### 关闭行为
正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。
正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。同一退出阶段会删除每进程私有 spill 目录(仅当其未持有任何已完成的 spill 文件时;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理)。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。
### 可能出错的地方
@@ -127,7 +127,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
- **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。
- **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
- **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件(以及每进程私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理。
- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录仅在未持有任何已完成 spill 文件时于 JavaScript 可观察的退出阶段删除。
<a id="dev-note"></a>
### 开发备注
@@ -10,7 +10,7 @@
import { type ChildProcess, type SpawnOptions, 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, rmdirSync, unlinkSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleepMs } from 'node:timers/promises'
@@ -92,13 +92,27 @@ let defaultSpillDir: string | undefined
/**
* The default spill location: a private (0700) per-process directory under
* the OS tmpdir, created lazily. Predictable world-readable paths would let
* other local users read command output or pre-create symlinks.
* other local users read command output or pre-create symlinks. At a
* JavaScript-observable process exit the directory is removed only when it
* holds no completed spill file (spill files are retained as full-output
* recovery artifacts until an external cleanup).
*/
function privateSpillDir(): string {
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
return defaultSpillDir
}
// The per-process spill directory is removed at process exit when it holds no
// completed spill file: a directory that never spilled is empty and is safe to
// remove, while a directory holding completed spill files keeps them (their
// content is retained until an external cleanup). A SIGKILLed process cannot
// run this at all; its residue is left to OS temp hygiene.
/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
process.once('exit', () => {
if (defaultSpillDir === undefined) return
try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ }
})
/**
* 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,8 +1,8 @@
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process'
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,
@@ -88,6 +88,14 @@ vi.mock('node:fs', async (importOriginal) => {
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
/** The per-process default spill dir captured by the default-spill test. */
let defaultSpillDir: string | undefined
afterAll(() => {
rmSync(spillDir, { recursive: true, force: true })
if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true })
})
type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
stdoutMaxBytes?: number
stderrMaxBytes?: number
@@ -1127,6 +1135,7 @@ describe('environment and spill-file hardening', () => {
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
))
const dir = dirname(result.stdout.spillPath!)
defaultSpillDir = dir
expect(dir).toMatch(/dsh-subprocess-/)
const mode = statSync(dir).mode & 0o777
expect(mode).toBe(0o700)
+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. */