refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
6
packages/test-support/loader-smoke/README.i18n.yaml
Normal file
6
packages/test-support/loader-smoke/README.i18n.yaml
Normal file
@@ -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 packages/test-support/loader-smoke/README.md
|
||||
README.md: e5a33beb95f4e5940364cf309c8ea5fea60686f1
|
||||
README.zh.md: 32c7af5bef050d6d46e91783468bdd0aa66e5ca0
|
||||
25
packages/test-support/loader-smoke/README.md
Normal file
25
packages/test-support/loader-smoke/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# `@deepseek-ai/dsh-loader-smoke`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared subprocess harness for tests that boot an app and `cordis.yml` through the Cordis Loader. `resolveExampleLaunch` selects local `src` mode (tsx and root tsconfig paths) or CI `lib` mode (plain Node and package exports) from an explicit mode or `DSH_EXAMPLE_MODE`.
|
||||
|
||||
`runLoaderSmoke` accepts bin and config paths, optional complete bin arguments, environment overrides, stdin, pre-run setup, and pre-cleanup inspection. It owns the isolated cwd, DSH homes, diagnostics, deadline, termination, EOF, and cleanup; it returns both streams after a zero exit and rejects with both streams on failure.
|
||||
|
||||
`runFixtureTurn` drives one task through exactly one configured root agent, forwards canonical events after that task reaches the durable inbox, flushes the session, and returns the final assistant text plus accumulated usage. Example-local drivers retain configuration, rendering, and assertion ownership.
|
||||
|
||||
This is support-tier test infrastructure, not product API.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the test harness submits only the consuming test's ordinary user task and delegates prompt and tool composition to the loaded tree.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None beyond the loaded tree; the helper neither changes the request prefix nor retains state across runs.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`.
|
||||
- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget.
|
||||
- **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup.
|
||||
25
packages/test-support/loader-smoke/README.zh.md
Normal file
25
packages/test-support/loader-smoke/README.zh.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# `@deepseek-ai/dsh-loader-smoke`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用于测试通过 Cordis Loader 启动应用和 `cordis.yml` 的共享子进程 harness。`resolveExampleLaunch` 选择本地 `src` mode(tsx 和根 tsconfig 路径)或 CI `lib` mode(普通 Node 和包导出);选择依据为显式 mode 或 `DSH_EXAMPLE_MODE`。
|
||||
|
||||
`runLoaderSmoke` 接受可执行文件路径和配置路径、可选的完整可执行文件参数、环境变量覆盖、标准输入、运行前准备和清理前检查。它负责隔离工作目录、DSH 主目录、诊断、截止时间、终止、EOF 和清理;进程以零状态退出后返回两个流,失败时则返回拒绝并附带两个流。
|
||||
|
||||
`runFixtureTurn` 通过恰好一个已配置的根 agent(智能体)驱动一项任务,在该任务进入持久收件箱后转发规范事件,刷写会话,并返回最终 assistant 文本和累计用量。示例本地 driver 继续负责配置、渲染和断言。
|
||||
|
||||
这是支持层测试基础设施,而非产品 API。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为测试 harness 仅提交调用方测试的普通用户任务,并将提示词和工具组装交由已加载的树负责。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
除已加载树本身的影响外,无其他影响;该 helper 既不更改请求前缀,也不跨运行保留状态。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **构建模式需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。
|
||||
- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。
|
||||
- **超时只终止直接子进程**:有故障的 fixture(测试前置数据)spawn 的进程树可能比冒烟测试存活更久,需要外部清理。
|
||||
52
packages/test-support/loader-smoke/package.json
Normal file
52
packages/test-support/loader-smoke/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-loader-smoke",
|
||||
"description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/test-support/loader-smoke"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"execa": "^10.0.0",
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
100
packages/test-support/loader-smoke/src/agent-turn.ts
Normal file
100
packages/test-support/loader-smoke/src/agent-turn.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Test-only direct-agent turn driver shared by assembled Loader fixtures.
|
||||
* @module @deepseek-ai/dsh-loader-smoke/agent-turn
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Result envelope consumed only by snapshot and composition tests. */
|
||||
export interface FixtureTurnResult {
|
||||
readonly type: 'result'
|
||||
readonly sessionId: string
|
||||
readonly output: string
|
||||
readonly usage?: TokenUsage
|
||||
}
|
||||
|
||||
/** Options for one fixture turn against exactly one configured root agent. */
|
||||
export interface FixtureTurnOptions {
|
||||
readonly task: string
|
||||
readonly onEvent?: (sessionId: string, event: SessionEvent) => void
|
||||
}
|
||||
|
||||
function addUsage(total: TokenUsage | undefined, step: TokenUsage): TokenUsage {
|
||||
const next: TokenUsage = {
|
||||
inputTokens: (total?.inputTokens ?? 0) + step.inputTokens,
|
||||
outputTokens: (total?.outputTokens ?? 0) + step.outputTokens,
|
||||
}
|
||||
for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'] as const) {
|
||||
if (total?.[key] !== undefined || step[key] !== undefined) next[key] = (total?.[key] ?? 0) + (step[key] ?? 0)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string | undefined {
|
||||
const blocks = event.data.message.content.filter(block => block.type === 'text')
|
||||
return blocks.length === 0 ? undefined : blocks.map(block => block.text).join('')
|
||||
}
|
||||
|
||||
function onlyRootAgent(ctx: Context): Agent {
|
||||
const agents = ctx.get('agents')?.roots() ?? []
|
||||
const [agent] = agents
|
||||
if (agent === undefined || agents.length !== 1) {
|
||||
throw new Error(`fixture turn requires exactly one top-level agent, found ${agents.length}`)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one task from its durable inbox receipt through whole-agent idle.
|
||||
* @param ctx - settled Loader context with exactly one configured root agent.
|
||||
* @param options - task and optional canonical-event observer.
|
||||
* @returns the final assistant text and accumulated model usage.
|
||||
*/
|
||||
export async function runFixtureTurn(ctx: Context, options: FixtureTurnOptions): Promise<FixtureTurnResult> {
|
||||
const agent = onlyRootAgent(ctx)
|
||||
await agent.whenIdle()
|
||||
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: options.task }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
let received = false
|
||||
let output = ''
|
||||
const usageByStep = new Map<string, TokenUsage>()
|
||||
const disposeListener = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (!received) {
|
||||
if (event.type !== 'agent/inbox/spliced'
|
||||
|| !event.data.inserted.some(inserted => inserted.id === message.id)) return
|
||||
received = true
|
||||
}
|
||||
options.onEvent?.(session.id, event)
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
||||
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
|
||||
}
|
||||
if (event.type === 'assistant/message') {
|
||||
output = assistantText(event) ?? output
|
||||
if (event.data.usage !== undefined) {
|
||||
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
agent.followup(message)
|
||||
await agent.whenIdle()
|
||||
} finally {
|
||||
disposeListener()
|
||||
}
|
||||
await ctx.sessions.flush(agent.session)
|
||||
const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined)
|
||||
return {
|
||||
type: 'result',
|
||||
sessionId: agent.session.id,
|
||||
output,
|
||||
...usage === undefined ? {} : { usage },
|
||||
}
|
||||
}
|
||||
212
packages/test-support/loader-smoke/src/index.ts
Normal file
212
packages/test-support/loader-smoke/src/index.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Shared subprocess harness for keyless example smokes that boot a real
|
||||
* `cordis.yml` through an app bin and Cordis Loader.
|
||||
*
|
||||
* It also owns the mode-aware launch resolver every example subprocess harness shares
|
||||
* ({@link resolveExampleLaunch}): booting an example bin from TypeScript source under `tsx` (the
|
||||
* zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths`
|
||||
* map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an
|
||||
* installed consumer does, while Node type-strips relative example-local TypeScript plugins).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-loader-smoke
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { execa } from 'execa'
|
||||
|
||||
export {
|
||||
runFixtureTurn,
|
||||
type FixtureTurnOptions,
|
||||
type FixtureTurnResult,
|
||||
} from './agent-turn.ts'
|
||||
|
||||
const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
|
||||
export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
|
||||
|
||||
/** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */
|
||||
export type ExampleMode = 'src' | 'lib'
|
||||
|
||||
/** Environment variable selecting the mode; CI sets it to `lib`, dev leaves it unset (`src`). */
|
||||
export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE'
|
||||
|
||||
/**
|
||||
* Parse an {@link ExampleMode} from a raw string, defaulting to `src` when absent so an unset
|
||||
* environment reproduces the dev/tsx behavior. Throws on any other value rather than silently
|
||||
* falling back, so a typo in a gate's env fails loud.
|
||||
* @param raw - the raw value; defaults to `process.env.DSH_EXAMPLE_MODE`.
|
||||
* @returns the validated mode.
|
||||
*/
|
||||
export function resolveExampleMode(raw: string | undefined = process.env[EXAMPLE_MODE_ENV]): ExampleMode {
|
||||
switch (raw) {
|
||||
case undefined:
|
||||
case '':
|
||||
case 'src':
|
||||
return 'src'
|
||||
case 'lib':
|
||||
return 'lib'
|
||||
default:
|
||||
throw new Error(`${EXAMPLE_MODE_ENV} must be 'src' or 'lib', got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs to {@link resolveExampleLaunch}. */
|
||||
export interface ExampleLaunchOptions {
|
||||
/** Absolute path to the example bin's TypeScript source entry (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
|
||||
readonly srcBin: string
|
||||
/** Explicit plain-Node entry for `lib` mode; test fixtures may point this at Node-type-strippable TypeScript. */
|
||||
readonly libBin?: string | undefined
|
||||
/** Arguments passed after the bin — the config, positional (`[configPath]`) or flagged (`['--config', configPath]`). */
|
||||
readonly configArgs?: readonly string[]
|
||||
/** The mode to launch in; defaults to {@link resolveExampleMode} of the environment. */
|
||||
readonly mode?: ExampleMode
|
||||
/** Absolute repo tsconfig whose `paths` map resolves unbuilt workspace imports. Required in `src` mode, ignored in `lib`. */
|
||||
readonly tsconfigPath?: string
|
||||
/** Extra environment entries the mode-specific ones layer over; the caller then merges the result over `process.env`. */
|
||||
readonly env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
/** The resolved spawn: `spawn(command, args, { env: { ...process.env, ...env } })`. */
|
||||
export interface ExampleLaunch {
|
||||
/** The executable to spawn — always the current Node binary. */
|
||||
readonly command: string
|
||||
/** Node flags, the resolved bin, then the caller's `configArgs`. */
|
||||
readonly args: string[]
|
||||
/** Mode-specific environment (`TSX_TSCONFIG_PATH` in `src`, nothing added in `lib`) layered over the caller's `env`. */
|
||||
readonly env: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
/** Derive the built-lib bin (`<pkg>/lib/<name>.js`) from a source bin (`<pkg>/src/<name>.ts`). */
|
||||
function toLibBin(srcBin: string): string {
|
||||
const markerLength = '/src/'.length
|
||||
const cut = Math.max(srcBin.lastIndexOf('/src/'), srcBin.lastIndexOf('\\src\\'))
|
||||
if (cut === -1) {
|
||||
throw new Error(`resolveExampleLaunch: expected a "/src/" segment or Windows equivalent in bin path ${JSON.stringify(srcBin)}.`)
|
||||
}
|
||||
const separator = srcBin.slice(cut, cut + 1)
|
||||
const tail = srcBin.slice(cut + markerLength).replace(/\.ts$/, '.js')
|
||||
return `${srcBin.slice(0, cut)}${separator}lib${separator}${tail}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how to spawn an example bin in the selected mode.
|
||||
*
|
||||
* `src` yields `node --import <tsx> <srcBin> <configArgs>` with `TSX_TSCONFIG_PATH` set so the
|
||||
* tsconfig `paths` map resolves workspace imports to source. `lib` yields
|
||||
* `node <libBin> <configArgs>` under plain Node with no tsx and no paths map, so
|
||||
* bare package plugins resolve through real package `exports` into built `lib/`; relative example-local
|
||||
* TypeScript plugins remain source files loaded through Node's built-in type stripping. Bare resolution
|
||||
* requires the config to live below a workspace that declares its `cordis.yml` package dependencies.
|
||||
*
|
||||
* @param options - the source bin, config arguments, mode, and environment.
|
||||
* @returns the command, argument vector, and mode-specific environment to spawn with.
|
||||
*/
|
||||
export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaunch {
|
||||
const mode = options.mode ?? resolveExampleMode()
|
||||
const configArgs = options.configArgs ?? []
|
||||
const env: NodeJS.ProcessEnv = { ...options.env }
|
||||
|
||||
if (mode === 'src') {
|
||||
if (options.tsconfigPath === undefined) {
|
||||
throw new Error("resolveExampleLaunch: 'src' mode needs tsconfigPath for the workspace paths map.")
|
||||
}
|
||||
const tsxLoader = import.meta.resolve('tsx')
|
||||
env.TSX_TSCONFIG_PATH = options.tsconfigPath
|
||||
return { command: process.execPath, args: ['--import', tsxLoader, options.srcBin, ...configArgs], env }
|
||||
}
|
||||
|
||||
return { command: process.execPath, args: [options.libBin ?? toLibBin(options.srcBin), ...configArgs], env }
|
||||
}
|
||||
|
||||
/** Inputs that vary between real-Loader example smokes. */
|
||||
export interface LoaderSmokeOptions {
|
||||
/** Human-readable example name used in failure diagnostics. */
|
||||
readonly label: string
|
||||
/** Prefix for the isolated temporary process cwd. */
|
||||
readonly tempDirPrefix: string
|
||||
/** Absolute app-bin source path (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
|
||||
readonly binScript: string
|
||||
/** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */
|
||||
readonly libBinScript?: string | undefined
|
||||
/** Absolute real Loader config path, passed as the sole bin argument by default. */
|
||||
readonly configPath: string
|
||||
/** Complete argv after the bin path; overrides the default `[configPath]`. */
|
||||
readonly binArgs?: readonly string[]
|
||||
/** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */
|
||||
readonly tsconfigPath: string
|
||||
/** Boot from source via tsx (`src`) or built lib via plain Node (`lib`); defaults to the environment's mode. */
|
||||
readonly mode?: ExampleMode
|
||||
/** Environment overrides layered over the parent and isolated DSH homes. */
|
||||
readonly env?: Readonly<NodeJS.ProcessEnv>
|
||||
/** Process deadline override for harness tests. */
|
||||
readonly processTimeoutMs?: number
|
||||
/** Optional world-state setup run in the isolated cwd before process start. */
|
||||
readonly prepare?: (cwd: string) => Promise<void> | void
|
||||
/** Optional world-state assertion run in the isolated cwd before cleanup. */
|
||||
readonly inspect?: (cwd: string) => Promise<void> | void
|
||||
/**
|
||||
* Exact process exit code this smoke expects; defaults to `0`. Scenarios
|
||||
* pinning a designed failure surface (a one-shot turn ending in an error
|
||||
* result) declare its nonzero exit here, and a run that exits any other
|
||||
* way — including succeeding — still fails the smoke.
|
||||
*/
|
||||
readonly expectedExitCode?: number
|
||||
}
|
||||
|
||||
/** Captured output from a Loader smoke that exited successfully. */
|
||||
export interface LoaderSmokeResult {
|
||||
/** Complete stdout after clean exit. */
|
||||
readonly stdout: string
|
||||
/** Complete stderr after clean exit. */
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot one real Loader tree from an isolated cwd, close stdin immediately, and
|
||||
* await a clean exit. The helper owns process kill and temp-directory cleanup on
|
||||
* every outcome, and picks src/lib via {@link resolveExampleLaunch}.
|
||||
* @param options - example paths, mode, environment, and diagnostic identity.
|
||||
* @returns captured stdout and stderr after a zero exit.
|
||||
*/
|
||||
export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix))
|
||||
const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
|
||||
try {
|
||||
await options.prepare?.(cwd)
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: options.binScript,
|
||||
libBin: options.libBinScript,
|
||||
configArgs: options.binArgs ?? [options.configPath],
|
||||
...options.mode !== undefined ? { mode: options.mode } : {},
|
||||
tsconfigPath: options.tsconfigPath,
|
||||
env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env },
|
||||
})
|
||||
// `input: ''` writes nothing and closes stdin — the fixture-visible
|
||||
// stdin-close contract. `reject: false` folds spawn errors, the SIGKILL
|
||||
// deadline, and nonzero exits into independent result fields, so the
|
||||
// diagnostics below embed both streams on every failure.
|
||||
const result = await execa(launch.command, launch.args, {
|
||||
cwd,
|
||||
env: launch.env,
|
||||
input: '',
|
||||
timeout: processTimeoutMs,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
const expectedExitCode = options.expectedExitCode ?? 0
|
||||
if (result.exitCode !== expectedExitCode) {
|
||||
throw new Error(`${options.label} exited ${String(result.exitCode)} (expected ${expectedExitCode}). stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
await options.inspect?.(cwd)
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
30
packages/test-support/loader-smoke/src/invariant.ts
Normal file
30
packages/test-support/loader-smoke/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-loader-smoke`.
|
||||
* @module @deepseek-ai/dsh-loader-smoke/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'loader-smoke-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this test-support package owns no production event stream or mutable data;
|
||||
* consuming test suites exercise its behavior.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
160
packages/test-support/loader-smoke/tests/agent-turn.spec.ts
Normal file
160
packages/test-support/loader-smoke/tests/agent-turn.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runFixtureTurn } from '../src/agent-turn.ts'
|
||||
|
||||
type Listener = (session: unknown, event: SessionEvent) => void
|
||||
|
||||
const event = (value: object): SessionEvent => value as unknown as SessionEvent
|
||||
|
||||
function turnHarness(): {
|
||||
readonly ctx: Context
|
||||
readonly session: { readonly id: string }
|
||||
readonly foreignSession: object
|
||||
readonly emit: (session: unknown, value: object) => void
|
||||
readonly setFollowup: (callback: (message: { readonly id: unknown }) => void) => void
|
||||
readonly whenIdle: ReturnType<typeof vi.fn>
|
||||
readonly disposeListener: ReturnType<typeof vi.fn>
|
||||
readonly flush: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const session = { id: 'fixture-session' }
|
||||
const foreignSession = {}
|
||||
let listener: Listener | undefined
|
||||
let followup = (_message: { readonly id: unknown }): void => {}
|
||||
const whenIdle = vi.fn(async () => {})
|
||||
const disposeListener = vi.fn()
|
||||
const flush = vi.fn(async () => {})
|
||||
const agent = {
|
||||
session,
|
||||
whenIdle,
|
||||
followup: vi.fn((message: { readonly id: unknown }) => { followup(message) }),
|
||||
}
|
||||
const ctx = {
|
||||
get: (name: string) => name === 'agents' ? { roots: () => [agent] } : undefined,
|
||||
on: (_name: string, callback: Listener) => {
|
||||
listener = callback
|
||||
return disposeListener
|
||||
},
|
||||
sessions: { flush },
|
||||
} as unknown as Context
|
||||
return {
|
||||
ctx,
|
||||
session,
|
||||
foreignSession,
|
||||
emit: (target, value) => { listener?.(target, event(value)) },
|
||||
setFollowup: (callback) => { followup = callback },
|
||||
whenIdle,
|
||||
disposeListener,
|
||||
flush,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runFixtureTurn', () => {
|
||||
it.each([
|
||||
['no agent registry', undefined, 0],
|
||||
['multiple roots', { roots: () => [{}, {}] }, 2],
|
||||
])('rejects %s', async (_label, registry, count) => {
|
||||
const ctx = { get: () => registry } as unknown as Context
|
||||
await expect(runFixtureTurn(ctx, { task: 'ignored' }))
|
||||
.rejects.toThrow(`fixture turn requires exactly one top-level agent, found ${count}`)
|
||||
})
|
||||
|
||||
it('observes only the owned interval and returns its final text and deduplicated usage', async () => {
|
||||
const harness = turnHarness()
|
||||
const observed: SessionEvent[] = []
|
||||
harness.setFollowup((message) => {
|
||||
harness.emit(harness.foreignSession, {
|
||||
type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [] } },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'agent/inbox/spliced', seq: 1, time: 1, data: { inserted: [{ id: 'other' }] },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'agent/inbox/spliced', seq: 2, time: 2, data: { inserted: [message] },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/chunk', seq: 3, time: 3,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', text: 'partial' } },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/chunk', seq: 4, time: 4,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 2, outputTokens: 3, reasoningTokens: 1 } },
|
||||
},
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/message', seq: 5, time: 5,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: { content: [{ type: 'text', text: 'final answer' }] },
|
||||
usage: { inputTokens: 4, outputTokens: 5, cacheReadTokens: 6 },
|
||||
},
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/chunk', seq: 6, time: 6,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, cacheWriteTokens: 7, reasoningTokens: 2 } },
|
||||
},
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/message', seq: 7, time: 7,
|
||||
data: { turn: 1, step: 2, message: { content: [{ type: 'tool-call' }] } },
|
||||
})
|
||||
harness.emit(harness.foreignSession, {
|
||||
type: 'assistant/message', seq: 8, time: 8, data: { message: { content: [] } },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(runFixtureTurn(harness.ctx, {
|
||||
task: 'prove the fixture',
|
||||
onEvent: (_sessionId, current) => { observed.push(current) },
|
||||
})).resolves.toEqual({
|
||||
type: 'result',
|
||||
sessionId: 'fixture-session',
|
||||
output: 'final answer',
|
||||
usage: {
|
||||
inputTokens: 5,
|
||||
outputTokens: 7,
|
||||
cacheReadTokens: 6,
|
||||
cacheWriteTokens: 7,
|
||||
reasoningTokens: 2,
|
||||
},
|
||||
})
|
||||
expect(observed.map(current => current.seq)).toEqual([2, 3, 4, 5, 6, 7])
|
||||
expect(harness.whenIdle).toHaveBeenCalledTimes(2)
|
||||
expect(harness.flush).toHaveBeenCalledWith(harness.session)
|
||||
expect(harness.disposeListener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('omits usage when the interval records none', async () => {
|
||||
const harness = turnHarness()
|
||||
harness.setFollowup((message) => {
|
||||
harness.emit(harness.session, {
|
||||
type: 'agent/inbox/spliced', seq: 0, time: 0, data: { inserted: [message] },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(runFixtureTurn(harness.ctx, { task: 'no model step' })).resolves.toEqual({
|
||||
type: 'result',
|
||||
sessionId: 'fixture-session',
|
||||
output: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('always removes its listener when the turn fails', async () => {
|
||||
const harness = turnHarness()
|
||||
harness.whenIdle.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('turn failed'))
|
||||
|
||||
await expect(runFixtureTurn(harness.ctx, { task: 'fail' })).rejects.toThrow('turn failed')
|
||||
expect(harness.disposeListener).toHaveBeenCalledOnce()
|
||||
expect(harness.flush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
105
packages/test-support/loader-smoke/tests/example-launch.spec.ts
Normal file
105
packages/test-support/loader-smoke/tests/example-launch.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EXAMPLE_MODE_ENV,
|
||||
resolveExampleLaunch,
|
||||
resolveExampleMode,
|
||||
} from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const SRC_BIN = '/repo/packages/examples/acp-demo/src/bin.ts'
|
||||
const TSCONFIG = '/repo/tsconfig.json'
|
||||
|
||||
const originalMode = process.env[EXAMPLE_MODE_ENV]
|
||||
afterEach(() => {
|
||||
if (originalMode === undefined) Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV)
|
||||
else process.env[EXAMPLE_MODE_ENV] = originalMode
|
||||
})
|
||||
|
||||
describe('resolveExampleMode', () => {
|
||||
it('defaults absent/empty/src to src', () => {
|
||||
Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV)
|
||||
expect(resolveExampleMode()).toBe('src')
|
||||
expect(resolveExampleMode('')).toBe('src')
|
||||
expect(resolveExampleMode('src')).toBe('src')
|
||||
})
|
||||
|
||||
it('accepts lib', () => {
|
||||
expect(resolveExampleMode('lib')).toBe('lib')
|
||||
})
|
||||
|
||||
it('throws on any other value', () => {
|
||||
expect(() => resolveExampleMode('prod')).toThrow(/must be 'src' or 'lib'/)
|
||||
})
|
||||
|
||||
it('reads the environment when no argument is given', () => {
|
||||
process.env[EXAMPLE_MODE_ENV] = 'lib'
|
||||
expect(resolveExampleMode()).toBe('lib')
|
||||
Reflect.deleteProperty(process.env, EXAMPLE_MODE_ENV)
|
||||
expect(resolveExampleMode()).toBe('src')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveExampleLaunch', () => {
|
||||
it('src mode: --import tsx on the source bin with the tsconfig paths env', () => {
|
||||
const { command, args, env } = resolveExampleLaunch({
|
||||
srcBin: SRC_BIN,
|
||||
configArgs: ['./cordis.yml'],
|
||||
mode: 'src',
|
||||
tsconfigPath: TSCONFIG,
|
||||
})
|
||||
expect(command).toBe(process.execPath)
|
||||
expect(args).toContain('--import')
|
||||
expect(args).toContain(SRC_BIN)
|
||||
expect(args[args.length - 1]).toBe('./cordis.yml')
|
||||
expect(env.TSX_TSCONFIG_PATH).toBe(TSCONFIG)
|
||||
})
|
||||
|
||||
it('src mode: throws without a tsconfig path', () => {
|
||||
expect(() => resolveExampleLaunch({ srcBin: SRC_BIN, mode: 'src' })).toThrow(/needs tsconfigPath/)
|
||||
})
|
||||
|
||||
it('lib mode: plain node on the derived lib bin, no tsx and no paths env', () => {
|
||||
const { args, env } = resolveExampleLaunch({
|
||||
srcBin: SRC_BIN,
|
||||
configArgs: ['--config', './cordis.yml'],
|
||||
mode: 'lib',
|
||||
env: { DSH_HOME: '/tmp/home' },
|
||||
})
|
||||
expect(args).not.toContain('--import')
|
||||
expect(args).toContain('/repo/packages/examples/acp-demo/lib/bin.js')
|
||||
expect(args.slice(-2)).toEqual(['--config', './cordis.yml'])
|
||||
expect(env.TSX_TSCONFIG_PATH).toBeUndefined()
|
||||
expect(env.DSH_HOME).toBe('/tmp/home')
|
||||
})
|
||||
|
||||
it('lib mode: uses an explicit plain-Node bin when provided', () => {
|
||||
const fixture = '/repo/fixture.ts'
|
||||
const { args } = resolveExampleLaunch({ srcBin: fixture, libBin: fixture, mode: 'lib' })
|
||||
expect(args).toContain(fixture)
|
||||
})
|
||||
|
||||
it('lib mode: rewrites only the last /src/ segment', () => {
|
||||
const { args } = resolveExampleLaunch({
|
||||
srcBin: '/repo/src/packages/examples/acp-demo/src/bin.ts',
|
||||
mode: 'lib',
|
||||
})
|
||||
expect(args).toContain('/repo/src/packages/examples/acp-demo/lib/bin.js')
|
||||
})
|
||||
|
||||
it('lib mode: derives the built bin from a Windows source path', () => {
|
||||
const { args } = resolveExampleLaunch({
|
||||
srcBin: String.raw`D:\repo\src\packages\examples\acp-demo\src\bin.ts`,
|
||||
mode: 'lib',
|
||||
})
|
||||
expect(args).toContain(String.raw`D:\repo\src\packages\examples\acp-demo\lib\bin.js`)
|
||||
})
|
||||
|
||||
it('lib mode: throws when the bin has no /src/ segment', () => {
|
||||
expect(() => resolveExampleLaunch({ srcBin: '/repo/lib/bin.js', mode: 'lib' })).toThrow(/"\/src\/" segment/)
|
||||
})
|
||||
|
||||
it('defaults the mode from the environment', () => {
|
||||
process.env[EXAMPLE_MODE_ENV] = 'lib'
|
||||
const { args } = resolveExampleLaunch({ srcBin: SRC_BIN })
|
||||
expect(args).toContain('/repo/packages/examples/acp-demo/lib/bin.js')
|
||||
})
|
||||
})
|
||||
4
packages/test-support/loader-smoke/tests/fixtures/fail.ts
vendored
Normal file
4
packages/test-support/loader-smoke/tests/fixtures/fail.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Non-zero subprocess fixture for the Loader-smoke harness. */
|
||||
|
||||
console.error('fixture failed')
|
||||
process.exitCode = 7
|
||||
4
packages/test-support/loader-smoke/tests/fixtures/hang.ts
vendored
Normal file
4
packages/test-support/loader-smoke/tests/fixtures/hang.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Deadline subprocess fixture for the Loader-smoke harness. */
|
||||
|
||||
console.log('fixture hanging')
|
||||
setInterval(() => {}, 1_000)
|
||||
17
packages/test-support/loader-smoke/tests/fixtures/success.ts
vendored
Normal file
17
packages/test-support/loader-smoke/tests/fixtures/success.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Successful subprocess fixture for the Loader-smoke harness. */
|
||||
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdin.on('data', (chunk: string) => { input += chunk })
|
||||
process.stdin.on('end', () => {
|
||||
console.log(JSON.stringify({
|
||||
configPath: process.argv[2],
|
||||
args: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
dshHome: process.env.DSH_HOME,
|
||||
agentsHome: process.env.DSH_AGENTS_HOME,
|
||||
marker: process.env.LOADER_SMOKE_MARKER,
|
||||
input,
|
||||
}))
|
||||
console.error('fixture stderr')
|
||||
})
|
||||
117
packages/test-support/loader-smoke/tests/loader-smoke.spec.ts
Normal file
117
packages/test-support/loader-smoke/tests/loader-smoke.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const configPath = '/tmp/fixture.cordis.yml'
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url))
|
||||
// macOS realpaths temp dirs into /private; TMPDIR may live under /var or /tmp.
|
||||
const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/(?:var|tmp)\/)/, '')
|
||||
|
||||
describe('runLoaderSmoke', () => {
|
||||
it('isolates the process, closes stdin, captures output, and removes the cwd', async () => {
|
||||
const result = await runLoaderSmoke({
|
||||
label: 'success fixture',
|
||||
tempDirPrefix: 'loader-smoke-success-',
|
||||
binScript: fixture('success'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
mode: 'src',
|
||||
env: { LOADER_SMOKE_MARKER: 'present' },
|
||||
})
|
||||
const output = JSON.parse(result.stdout) as {
|
||||
configPath: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
dshHome: string
|
||||
agentsHome: string
|
||||
marker: string
|
||||
input: string
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
configPath,
|
||||
args: [configPath],
|
||||
marker: 'present',
|
||||
input: '',
|
||||
})
|
||||
expect(canonicalTempPath(output.dshHome)).toBe(canonicalTempPath(join(output.cwd, '.dsh')))
|
||||
expect(canonicalTempPath(output.agentsHome)).toBe(canonicalTempPath(join(output.cwd, '.agents')))
|
||||
expect(result.stderr).toContain('fixture stderr')
|
||||
expect(existsSync(output.cwd)).toBe(false)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('passes an arbitrary bin argv and inspects world state before cleanup', async () => {
|
||||
let inspected = ''
|
||||
let marker = ''
|
||||
const result = await runLoaderSmoke({
|
||||
label: 'argv fixture',
|
||||
tempDirPrefix: 'loader-smoke-argv-',
|
||||
binScript: fixture('success'),
|
||||
libBinScript: fixture('success'),
|
||||
configPath,
|
||||
binArgs: ['--config', configPath, '--output-format', 'json', 'task with spaces'],
|
||||
tsconfigPath,
|
||||
prepare: cwd => writeFile(join(cwd, 'marker.txt'), 'prepared'),
|
||||
inspect: async (cwd) => {
|
||||
inspected = cwd
|
||||
marker = await readFile(join(cwd, 'marker.txt'), 'utf8')
|
||||
},
|
||||
})
|
||||
const output = JSON.parse(result.stdout) as { args: string[]; cwd: string }
|
||||
expect(output.args).toEqual(['--config', configPath, '--output-format', 'json', 'task with spaces'])
|
||||
expect(canonicalTempPath(inspected)).toBe(canonicalTempPath(output.cwd))
|
||||
expect(marker).toBe('prepared')
|
||||
expect(existsSync(inspected)).toBe(false)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('rejects a non-zero exit with captured diagnostics', async () => {
|
||||
await expect(runLoaderSmoke({
|
||||
label: 'failure fixture',
|
||||
tempDirPrefix: 'loader-smoke-fail-',
|
||||
binScript: fixture('fail'),
|
||||
libBinScript: fixture('fail'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
})).rejects.toThrow('failure fixture exited 7 (expected 0). stdout:\n\nstderr:\nfixture failed')
|
||||
})
|
||||
|
||||
it('accepts a declared expected failure exit and rejects any other outcome', async () => {
|
||||
// A scenario pinning a designed failure surface declares its exit code…
|
||||
const declared = await runLoaderSmoke({
|
||||
label: 'declared failure fixture',
|
||||
tempDirPrefix: 'loader-smoke-declared-fail-',
|
||||
binScript: fixture('fail'),
|
||||
libBinScript: fixture('fail'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
expectedExitCode: 7,
|
||||
})
|
||||
expect(declared.stderr).toBe('fixture failed\n')
|
||||
|
||||
// …and a run that succeeds instead still fails the smoke.
|
||||
await expect(runLoaderSmoke({
|
||||
label: 'unexpectedly clean fixture',
|
||||
tempDirPrefix: 'loader-smoke-clean-',
|
||||
binScript: fixture('success'),
|
||||
libBinScript: fixture('success'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
expectedExitCode: 7,
|
||||
})).rejects.toThrow(/exited 0 \(expected 7\)/)
|
||||
})
|
||||
|
||||
it('kills a process at its deadline and reports captured output', async () => {
|
||||
await expect(runLoaderSmoke({
|
||||
label: 'hanging fixture',
|
||||
tempDirPrefix: 'loader-smoke-hang-',
|
||||
binScript: fixture('hang'),
|
||||
libBinScript: fixture('hang'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
processTimeoutMs: 100,
|
||||
})).rejects.toThrow('hanging fixture did not exit within 0.1s.')
|
||||
})
|
||||
})
|
||||
24
packages/test-support/loader-smoke/tsconfig.json
Normal file
24
packages/test-support/loader-smoke/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user