@actiondock/testing 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @actiondock/testing
2
+
3
+ ActionDock 2.0 确定性测试框架与测试运行时。
4
+
5
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D22-green?logo=node.js)](https://nodejs.org/)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue?logo=typescript)](https://www.typescriptlang.org/)
7
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
8
+
9
+ `@actiondock/testing` 为 ActionDock 工具与技能开发者提供确定性、无物理依赖且具备真实 Core 执行语义的单元测试框架。
10
+
11
+ ---
12
+
13
+ ## 核心组件与测试能力
14
+
15
+ ### FakeClock 确定性时钟
16
+
17
+ 解耦真实物理系统时间,彻底消除异步定时器测试中的偶发性失败与等待开销:
18
+
19
+ - 模拟墙上时间 `now` 与单调递增时间戳 `monotonic`。
20
+ - 通过 `advance(ms)` 瞬间推进模拟时间,并以严格确定的顺序依次唤醒挂起的计时器与延迟任务。
21
+ - 支持时间倒流检测与高精度时间戳快照。
22
+
23
+ ### MockProcessExecutor 模拟进程执行器
24
+
25
+ 在沙箱中拦截并伪造所有外部系统命令与子进程调用:
26
+
27
+ - **灵活规则匹配**:通过 `onCommand` 注册匹配器,支持字符串完全匹配、正则表达式匹配或自定义断言谓词函数。
28
+ - **丰富的响应定义**:支持模拟标准输出、标准错误流、非零退出码、二进制字节流以及执行耗时。
29
+ - **异常场景复现**:可直接模拟命令执行超时(`timedOut`)或取消信号阻断(`cancelled`)。
30
+ - **调用历史追踪**:精确记录每次调用的完整入参、工作目录与环境变量,提供便捷的断言追踪支持。
31
+
32
+ ### MemoryStorage 纯内存存储
33
+
34
+ 基于内存 SQLite 驱动构建的无磁盘运行时存储实现:
35
+
36
+ - 具备与生产环境持久化存储完全相同的配置优先级解析规则与事务边界。
37
+ - 完整支持状态数据的命名空间隔离、前缀检索与基于存活时间的自动过期判定。
38
+ - 完整持久化运行历史记录与结构化输入输出快照。
39
+
40
+ ### createTestRuntime 测试运行时工厂
41
+
42
+ 深度复用核心引擎 `ActionRunner` 的测试脚手架:
43
+
44
+ - **真实生命周期校验**:在内存测试中同步执行输入输出 JSON Schema 校验、调用环路死锁检测与超时控制。
45
+ - **双模态执行接口**:支持通过 `run` 直接获取业务数据(失败时抛出带有错误码的异常),或通过 `execute` 获取包含运行标识与元数据的完整信封。
46
+ - **全要素调试访问**:测试运行时直接暴露 `config`、`state`、`clock`、`process`、`events` 与 `storage` 实例,便于在测试用例中注入先验数据并断言副作用。
47
+
48
+ ---
49
+
50
+ ## 快速使用示例
51
+
52
+ ```ts
53
+ import { defineAction } from "@actiondock/sdk";
54
+ import { createTestRuntime, FakeClock, MockProcessExecutor } from "@actiondock/testing";
55
+
56
+ const gitBranchAction = defineAction({
57
+ id: "git.branch",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ remote: { type: "boolean" },
62
+ },
63
+ },
64
+ async run(input, ctx) {
65
+ const res = await ctx.process.exec("git", ["branch"]);
66
+ return { output: res.stdout.trim() };
67
+ },
68
+ });
69
+
70
+ // 初始化模拟执行器与测试运行时
71
+ const processExecutor = new MockProcessExecutor();
72
+ processExecutor.onCommand("git", {
73
+ stdout: "* main\n feature/agent\n",
74
+ });
75
+
76
+ const runtime = createTestRuntime({
77
+ process: processExecutor,
78
+ });
79
+
80
+ // 执行 Action 并断言业务数据
81
+ const result = await runtime.run(gitBranchAction, { remote: false });
82
+ console.log(result.output);
83
+
84
+ // 断言底层命令调用记录
85
+ console.log(processExecutor.getHistory().length === 1);
86
+ ```
87
+
88
+ ---
89
+
90
+ ## 开源协议
91
+
92
+ 本项目采用 Apache-2.0 开源协议。
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@actiondock/testing",
3
+ "version": "2.0.4",
4
+ "description": "ActionDock Test Runtime for testing Actions with real Core execution semantics",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "module": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./src/index.ts",
12
+ "types": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=22.12.0"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test"
28
+ },
29
+ "dependencies": {
30
+ "@actiondock/core": "^2.0.0",
31
+ "@actiondock/sdk": "^2.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "typescript": "^5.7.0"
36
+ },
37
+ "keywords": ["actiondock", "testing", "agent", "actions", "mock"],
38
+ "author": "team4u",
39
+ "license": "Apache-2.0"
40
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,136 @@
1
+ import type { Clock } from "@actiondock/core";
2
+
3
+ /**
4
+ * 待触发的计划计时器项。
5
+ */
6
+ interface ScheduledSleep {
7
+ id: number;
8
+ targetMonotonic: number;
9
+ targetNow: number;
10
+ resolve: () => void;
11
+ reject: (err: unknown) => void;
12
+ }
13
+
14
+ /**
15
+ * 模拟时钟初始化选项。
16
+ */
17
+ export interface FakeClockOptions {
18
+ /** 初始时间戳或日期对象 */
19
+ now?: Date | number | string;
20
+ /** 初始单调时间戳毫秒数 */
21
+ startMonotonic?: number;
22
+ }
23
+
24
+ /**
25
+ * 确定性测试模拟时钟实现。
26
+ * 遵循 Clock 接口契约,支持手动单调推进时间并调度计时器。
27
+ */
28
+ export class FakeClock implements Clock {
29
+ private currentNow: number;
30
+ private currentMonotonic: number;
31
+ private nextTimerId = 1;
32
+ private pendingSleeps: ScheduledSleep[] = [];
33
+
34
+ constructor(options: FakeClockOptions = {}) {
35
+ if (options.now !== undefined) {
36
+ this.currentNow = new Date(options.now).getTime();
37
+ } else {
38
+ this.currentNow = Date.now();
39
+ }
40
+ this.currentMonotonic = options.startMonotonic ?? 0;
41
+ }
42
+
43
+ /**
44
+ * 获取当前模拟墙上时间。
45
+ */
46
+ now(): Date {
47
+ return new Date(this.currentNow);
48
+ }
49
+
50
+ /**
51
+ * 获取当前模拟单调时间戳(毫秒)。
52
+ */
53
+ monotonic(): number {
54
+ return this.currentMonotonic;
55
+ }
56
+
57
+ /**
58
+ * 异步休眠指定毫秒。
59
+ * 等待通过 advance 方法推进时间至目标时刻后完成。
60
+ *
61
+ * @param ms 休眠毫秒数
62
+ */
63
+ sleep(ms: number): Promise<void> {
64
+ if (ms <= 0) {
65
+ return Promise.resolve();
66
+ }
67
+
68
+ return new Promise<void>((resolve, reject) => {
69
+ const targetMonotonic = this.currentMonotonic + ms;
70
+ const targetNow = this.currentNow + ms;
71
+ this.pendingSleeps.push({
72
+ id: this.nextTimerId++,
73
+ targetMonotonic,
74
+ targetNow,
75
+ resolve,
76
+ reject,
77
+ });
78
+ this.pendingSleeps.sort((a, b) => a.targetMonotonic - b.targetMonotonic);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * 手动向前推进指定毫秒时间。
84
+ * 严格按时间戳递增顺序触发并完成所有到期的休眠计时器。
85
+ *
86
+ * @param ms 推进的毫秒数
87
+ */
88
+ async advance(ms: number): Promise<void> {
89
+ if (ms < 0) {
90
+ throw new Error("Cannot advance clock by negative time");
91
+ }
92
+ if (ms === 0) {
93
+ await Promise.resolve();
94
+ return;
95
+ }
96
+
97
+ const destinationMonotonic = this.currentMonotonic + ms;
98
+ const destinationNow = this.currentNow + ms;
99
+
100
+ while (this.pendingSleeps.length > 0) {
101
+ const nextSleep = this.pendingSleeps[0];
102
+ if (nextSleep.targetMonotonic > destinationMonotonic) {
103
+ break;
104
+ }
105
+
106
+ this.pendingSleeps.shift();
107
+ this.currentMonotonic = nextSleep.targetMonotonic;
108
+ this.currentNow = nextSleep.targetNow;
109
+ nextSleep.resolve();
110
+
111
+ await Promise.resolve();
112
+ }
113
+
114
+ this.currentMonotonic = destinationMonotonic;
115
+ this.currentNow = destinationNow;
116
+ await Promise.resolve();
117
+ }
118
+
119
+ /**
120
+ * 获取当前等待中的计时器数量。
121
+ */
122
+ get pendingCount(): number {
123
+ return this.pendingSleeps.length;
124
+ }
125
+
126
+ /**
127
+ * 清除并取消所有等待中的计时器。
128
+ */
129
+ clear(): void {
130
+ const sleeps = this.pendingSleeps;
131
+ this.pendingSleeps = [];
132
+ for (const item of sleeps) {
133
+ item.reject(new Error("FakeClock timer cancelled"));
134
+ }
135
+ }
136
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./clock";
2
+ export * from "./process";
3
+ export * from "./storage";
4
+ export * from "./runtime";
package/src/process.ts ADDED
@@ -0,0 +1,394 @@
1
+ import type { ProcessExecutor } from "@actiondock/core";
2
+ import type {
3
+ DetachedProcessOptions,
4
+ DetachedProcessResult,
5
+ ProcessExecOptions,
6
+ ProcessResult,
7
+ RuntimeError,
8
+ } from "@actiondock/sdk";
9
+
10
+ /**
11
+ * 模拟命令匹配器。
12
+ */
13
+ export type CommandMatcher =
14
+ | string
15
+ | RegExp
16
+ | ((command: string, args: string[], options: ProcessExecOptions) => boolean);
17
+
18
+ /**
19
+ * 模拟进程执行结果选项。
20
+ */
21
+ export interface MockProcessResultOptions {
22
+ /** 命令是否执行成功 */
23
+ ok?: boolean;
24
+ /** 退出状态码 */
25
+ exitCode?: number | null;
26
+ /** 终止信号名称 */
27
+ signal?: string;
28
+ /** 标准输出内容 */
29
+ stdout?: string;
30
+ /** 标准错误内容 */
31
+ stderr?: string;
32
+ /** 原始字节数组输出 */
33
+ raw?: Uint8Array;
34
+ /** 是否标记为超时 */
35
+ timedOut?: boolean;
36
+ /** 是否标记为已取消 */
37
+ cancelled?: boolean;
38
+ /** 执行耗时毫秒数 */
39
+ durationMs?: number;
40
+ /** 运行时结构化错误 */
41
+ error?: RuntimeError;
42
+ /** 模拟执行延迟毫秒数 */
43
+ delayMs?: number;
44
+ }
45
+
46
+ /**
47
+ * 模拟进程处理器函数。
48
+ */
49
+ export type MockProcessHandler = (
50
+ command: string,
51
+ args: string[],
52
+ options: ProcessExecOptions
53
+ ) =>
54
+ | MockProcessResultOptions
55
+ | ProcessResult
56
+ | Promise<MockProcessResultOptions | ProcessResult>;
57
+
58
+ /**
59
+ * 已记录的命令调用历史条目。
60
+ */
61
+ export interface ProcessCall {
62
+ /** 执行命令名称 */
63
+ command: string;
64
+ /** 执行参数列表 */
65
+ args: string[];
66
+ /** 执行选项配置 */
67
+ options: ProcessExecOptions;
68
+ /** 调用发生时的时间戳 */
69
+ timestamp: number;
70
+ }
71
+
72
+ /**
73
+ * 已记录的后台守护进程调用历史条目。
74
+ */
75
+ export interface DetachedProcessCall {
76
+ /** 启动参数选项 */
77
+ options: DetachedProcessOptions;
78
+ /** 调用发生时的时间戳 */
79
+ timestamp: number;
80
+ }
81
+
82
+ interface RegisteredMock {
83
+ matcher: CommandMatcher;
84
+ handler: MockProcessHandler | MockProcessResultOptions;
85
+ }
86
+
87
+ /**
88
+ * 模拟进程执行器实现。
89
+ * 遵循 ProcessExecutor 接口契约,支持预设命令响应、跟踪调用历史并模拟超时与取消场景。
90
+ */
91
+ export class MockProcessExecutor implements ProcessExecutor {
92
+ private mocks: RegisteredMock[] = [];
93
+ public calls: ProcessCall[] = [];
94
+ public detachedCalls: DetachedProcessCall[] = [];
95
+ public defaultPid = 10001;
96
+
97
+ /**
98
+ * 注册模拟命令匹配与返回结果。
99
+ *
100
+ * @param matcher 匹配器(命令字符串、正则表达式或判断函数)
101
+ * @param handlerOrResult 预设执行结果或动态处理函数
102
+ */
103
+ register(
104
+ matcher: CommandMatcher,
105
+ handlerOrResult: MockProcessHandler | MockProcessResultOptions
106
+ ): this {
107
+ this.mocks.push({ matcher, handler: handlerOrResult });
108
+ return this;
109
+ }
110
+
111
+ /**
112
+ * 执行外部命令并返回模拟结果。
113
+ *
114
+ * @param command 执行命令
115
+ * @param args 参数列表
116
+ * @param options 执行选项
117
+ */
118
+ async exec(
119
+ command: string,
120
+ args: string[] = [],
121
+ options: ProcessExecOptions = {}
122
+ ): Promise<ProcessResult> {
123
+ const startTime = Date.now();
124
+ this.calls.push({
125
+ command,
126
+ args: [...args],
127
+ options: { ...options },
128
+ timestamp: startTime,
129
+ });
130
+
131
+ // 检查调用前是否已中断
132
+ if (options.signal?.aborted) {
133
+ const res: ProcessResult = {
134
+ ok: false,
135
+ exitCode: null,
136
+ signal: "SIGTERM",
137
+ stdout: "",
138
+ stderr: "Process was cancelled by AbortSignal",
139
+ raw: new Uint8Array(),
140
+ timedOut: false,
141
+ cancelled: true,
142
+ durationMs: 0,
143
+ error: {
144
+ code: "PROCESS_CANCELLED",
145
+ message: "Process was cancelled by AbortSignal",
146
+ },
147
+ };
148
+ if (options.throwOnError) {
149
+ throw new Error(res.stderr);
150
+ }
151
+ return res;
152
+ }
153
+
154
+ const matchedMock = this.findMock(command, args, options);
155
+ let resolved: MockProcessResultOptions | ProcessResult;
156
+
157
+ if (!matchedMock) {
158
+ resolved = {
159
+ ok: true,
160
+ exitCode: 0,
161
+ stdout: "",
162
+ stderr: "",
163
+ };
164
+ } else if (typeof matchedMock.handler === "function") {
165
+ resolved = await matchedMock.handler(command, args, options);
166
+ } else {
167
+ resolved = matchedMock.handler;
168
+ }
169
+
170
+ // 模拟延时控制
171
+ const maybeMock = resolved as MockProcessResultOptions;
172
+ if (typeof maybeMock.delayMs === "number" && maybeMock.delayMs > 0) {
173
+ await this.waitDelay(maybeMock.delayMs, options);
174
+ }
175
+
176
+ // 组装标准化结果
177
+ const timedOut = Boolean(resolved.timedOut);
178
+ const cancelled = Boolean(resolved.cancelled || options.signal?.aborted);
179
+ const stdout = resolved.stdout ?? "";
180
+ const stderr = resolved.stderr ?? (timedOut ? "Process timed out" : cancelled ? "Process cancelled" : "");
181
+ const raw = resolved.raw ?? new TextEncoder().encode(stdout);
182
+ const exitCode =
183
+ resolved.exitCode !== undefined
184
+ ? resolved.exitCode
185
+ : timedOut || cancelled
186
+ ? null
187
+ : resolved.ok === false
188
+ ? 1
189
+ : 0;
190
+ const ok =
191
+ resolved.ok !== undefined
192
+ ? resolved.ok
193
+ : exitCode === 0 && !timedOut && !cancelled && !resolved.error;
194
+ const durationMs = resolved.durationMs ?? Date.now() - startTime;
195
+
196
+ let error = resolved.error;
197
+ if (!error) {
198
+ if (timedOut) {
199
+ error = {
200
+ code: "PROCESS_TIMEOUT",
201
+ message: `Process exceeded timeout of ${options.timeoutMs ?? durationMs}ms`,
202
+ };
203
+ } else if (cancelled) {
204
+ error = {
205
+ code: "PROCESS_CANCELLED",
206
+ message: "Process was cancelled by AbortSignal",
207
+ };
208
+ } else if (!ok) {
209
+ error = {
210
+ code: "PROCESS_FAILED",
211
+ message: stderr || `Process exited with code ${exitCode}`,
212
+ };
213
+ }
214
+ }
215
+
216
+ const finalResult: ProcessResult = {
217
+ ok,
218
+ exitCode,
219
+ signal: resolved.signal,
220
+ stdout,
221
+ stderr,
222
+ raw,
223
+ timedOut,
224
+ cancelled,
225
+ durationMs,
226
+ error,
227
+ };
228
+
229
+ if (!ok && options.throwOnError) {
230
+ throw new Error(stderr || `Process exited with code ${exitCode}`);
231
+ }
232
+
233
+ return finalResult;
234
+ }
235
+
236
+ /**
237
+ * 启动模拟脱离父进程的后台进程。
238
+ *
239
+ * @param options 守护进程启动选项
240
+ */
241
+ async spawnDetached(
242
+ options: DetachedProcessOptions
243
+ ): Promise<DetachedProcessResult> {
244
+ const startTime = Date.now();
245
+ this.detachedCalls.push({
246
+ options: { ...options },
247
+ timestamp: startTime,
248
+ });
249
+
250
+ if (options.signal?.aborted) {
251
+ return {
252
+ ok: false,
253
+ ready: false,
254
+ durationMs: 0,
255
+ error: {
256
+ code: "PROCESS_CANCELLED",
257
+ message: "Process was cancelled by AbortSignal",
258
+ },
259
+ };
260
+ }
261
+
262
+ if (options.probe) {
263
+ const fakeResult: ProcessResult = {
264
+ ok: true,
265
+ exitCode: 0,
266
+ stdout: "ready",
267
+ stderr: "",
268
+ raw: new TextEncoder().encode("ready"),
269
+ timedOut: false,
270
+ cancelled: false,
271
+ durationMs: 0,
272
+ };
273
+ const isReady = await options.probe(fakeResult);
274
+ return {
275
+ ok: isReady,
276
+ pid: this.defaultPid++,
277
+ ready: isReady,
278
+ durationMs: Date.now() - startTime,
279
+ };
280
+ }
281
+
282
+ return {
283
+ ok: true,
284
+ pid: this.defaultPid++,
285
+ ready: true,
286
+ durationMs: Date.now() - startTime,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * 获取指定命令的历史调用记录。
292
+ *
293
+ * @param command 可选命令筛选
294
+ */
295
+ getCalls(command?: string): ProcessCall[] {
296
+ if (!command) {
297
+ return [...this.calls];
298
+ }
299
+ return this.calls.filter((c) => c.command === command);
300
+ }
301
+
302
+ /**
303
+ * 获取最近一次命令调用记录。
304
+ */
305
+ getLastCall(): ProcessCall | undefined {
306
+ return this.calls[this.calls.length - 1];
307
+ }
308
+
309
+ /**
310
+ * 检查指定命令是否被调用过。
311
+ *
312
+ * @param command 目标命令
313
+ */
314
+ hasCalled(command: string): boolean {
315
+ return this.calls.some((c) => c.command === command);
316
+ }
317
+
318
+ /**
319
+ * 清空历史调用记录。
320
+ */
321
+ clearHistory(): void {
322
+ this.calls = [];
323
+ this.detachedCalls = [];
324
+ }
325
+
326
+ /**
327
+ * 重置所有注册规则与历史记录。
328
+ */
329
+ reset(): void {
330
+ this.mocks = [];
331
+ this.calls = [];
332
+ this.detachedCalls = [];
333
+ }
334
+
335
+ private findMock(
336
+ command: string,
337
+ args: string[],
338
+ options: ProcessExecOptions
339
+ ): RegisteredMock | undefined {
340
+ const fullCommandLine = [command, ...args].join(" ").trim();
341
+
342
+ // 逆序查找,优先匹配最新注册的规则
343
+ for (let i = this.mocks.length - 1; i >= 0; i--) {
344
+ const mock = this.mocks[i];
345
+ if (typeof mock.matcher === "string") {
346
+ if (
347
+ mock.matcher === command ||
348
+ mock.matcher === fullCommandLine ||
349
+ fullCommandLine.startsWith(mock.matcher)
350
+ ) {
351
+ return mock;
352
+ }
353
+ } else if (mock.matcher instanceof RegExp) {
354
+ if (mock.matcher.test(fullCommandLine) || mock.matcher.test(command)) {
355
+ return mock;
356
+ }
357
+ } else if (typeof mock.matcher === "function") {
358
+ if (mock.matcher(command, args, options)) {
359
+ return mock;
360
+ }
361
+ }
362
+ }
363
+ return undefined;
364
+ }
365
+
366
+ private async waitDelay(
367
+ delayMs: number,
368
+ options: ProcessExecOptions
369
+ ): Promise<void> {
370
+ return new Promise<void>((resolve) => {
371
+ let timer: ReturnType<typeof setTimeout> | undefined;
372
+
373
+ const cleanup = () => {
374
+ if (timer) clearTimeout(timer);
375
+ };
376
+
377
+ if (options.signal) {
378
+ options.signal.addEventListener(
379
+ "abort",
380
+ () => {
381
+ cleanup();
382
+ resolve();
383
+ },
384
+ { once: true }
385
+ );
386
+ }
387
+
388
+ timer = setTimeout(() => {
389
+ cleanup();
390
+ resolve();
391
+ }, delayMs);
392
+ });
393
+ }
394
+ }