@actiondock/testing 2.0.12 → 2.2.1

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.
@@ -0,0 +1,280 @@
1
+ import { execCli } from "./cli.js";
2
+ /**
3
+ * 模拟进程执行器实现。
4
+ * 遵循 ProcessExecutor 接口契约,支持预设命令响应、跟踪调用历史并模拟超时与取消场景。
5
+ *
6
+ * 默认不回退真实子进程执行:未命中任何模拟规则时抛出明确错误,避免测试中的拼写失误穿透到真实系统命令。
7
+ * 如确需真实回退(例如集成本地 CLI),可显式传入 fallbackToReal: true。
8
+ */
9
+ export class MockProcessExecutor {
10
+ mocks = [];
11
+ calls = [];
12
+ defaultPid = 10001;
13
+ fallbackToReal;
14
+ constructor(options = {}) {
15
+ this.fallbackToReal = options.fallbackToReal ?? false;
16
+ }
17
+ /**
18
+ * 注册模拟命令匹配与返回结果。
19
+ *
20
+ * @param matcher 匹配器(命令字符串、正则表达式或判断函数)
21
+ * @param handlerOrResult 预设执行结果或动态处理函数
22
+ */
23
+ register(matcher, handlerOrResult) {
24
+ this.mocks.push({ matcher, handler: handlerOrResult });
25
+ return this;
26
+ }
27
+ /**
28
+ * 执行外部命令并返回模拟结果。
29
+ *
30
+ * @param command 执行命令
31
+ * @param args 参数列表
32
+ * @param options 执行选项
33
+ */
34
+ async exec(command, args = [], options = {}) {
35
+ const startTime = Date.now();
36
+ this.calls.push({
37
+ command,
38
+ args: [...args],
39
+ options: { ...options },
40
+ timestamp: startTime,
41
+ });
42
+ // 检查调用前是否已中断
43
+ if (options.signal?.aborted) {
44
+ const res = {
45
+ ok: false,
46
+ exitCode: -1,
47
+ signal: "SIGTERM",
48
+ stdout: "",
49
+ stderr: "Command aborted before execution by signal",
50
+ raw: new Uint8Array(),
51
+ timedOut: false,
52
+ cancelled: true,
53
+ durationMs: 0,
54
+ error: {
55
+ code: "PROCESS_CANCELLED",
56
+ message: "Process was cancelled by AbortSignal",
57
+ },
58
+ };
59
+ if (options.throwOnError) {
60
+ throw new Error(res.stderr);
61
+ }
62
+ return res;
63
+ }
64
+ const matchedMock = this.findMock(command, args, options);
65
+ const fullCommandLine = [command, ...args].join(" ").trim();
66
+ let resolved;
67
+ if (!matchedMock) {
68
+ if (!this.fallbackToReal) {
69
+ throw new Error(`MockProcessExecutor: 未命中任何模拟规则,且未开启 fallbackToReal,拒绝执行真实命令: ${fullCommandLine}\n已注册匹配器列表:\n${this.describeMatchers()}`);
70
+ }
71
+ try {
72
+ const cliRes = await execCli(command, args, {
73
+ cwd: options.cwd,
74
+ env: options.env,
75
+ signal: options.signal,
76
+ timeout: options.timeoutMs,
77
+ input: options.input,
78
+ encoding: options.encoding,
79
+ });
80
+ resolved = {
81
+ ok: cliRes.ok,
82
+ exitCode: cliRes.exitCode,
83
+ stdout: cliRes.stdout,
84
+ stderr: cliRes.stderr,
85
+ raw: cliRes.raw,
86
+ timedOut: cliRes.timedOut,
87
+ durationMs: cliRes.durationMs,
88
+ };
89
+ }
90
+ catch (err) {
91
+ resolved = {
92
+ ok: false,
93
+ exitCode: -1,
94
+ stdout: "",
95
+ stderr: err?.message || String(err),
96
+ raw: new Uint8Array(),
97
+ durationMs: Date.now() - startTime,
98
+ };
99
+ }
100
+ }
101
+ else if (typeof matchedMock.handler === "function") {
102
+ resolved = await matchedMock.handler(command, args, options);
103
+ }
104
+ else {
105
+ resolved = matchedMock.handler;
106
+ }
107
+ // 模拟延时控制
108
+ const maybeMock = resolved;
109
+ if (typeof maybeMock.delayMs === "number" && maybeMock.delayMs > 0) {
110
+ await this.waitDelay(maybeMock.delayMs, options);
111
+ }
112
+ // 组装标准化结果
113
+ const timedOut = Boolean(resolved.timedOut);
114
+ const cancelled = Boolean(resolved.cancelled || options.signal?.aborted);
115
+ const stdout = resolved.stdout ?? "";
116
+ const stderr = resolved.stderr ?? (timedOut ? "Process timed out" : cancelled ? "Process cancelled" : "");
117
+ const raw = resolved.raw ?? new TextEncoder().encode(stdout);
118
+ const exitCode = resolved.exitCode !== undefined
119
+ ? resolved.exitCode
120
+ : timedOut || cancelled
121
+ ? null
122
+ : resolved.ok === false
123
+ ? 1
124
+ : 0;
125
+ const ok = resolved.ok !== undefined
126
+ ? resolved.ok
127
+ : exitCode === 0 && !timedOut && !cancelled && !resolved.error;
128
+ const durationMs = resolved.durationMs ?? Date.now() - startTime;
129
+ let error = resolved.error;
130
+ if (!error) {
131
+ if (timedOut) {
132
+ error = {
133
+ code: "PROCESS_TIMEOUT",
134
+ message: `Process exceeded timeout of ${options.timeoutMs ?? durationMs}ms`,
135
+ };
136
+ }
137
+ else if (cancelled) {
138
+ error = {
139
+ code: "PROCESS_CANCELLED",
140
+ message: "Process was cancelled by AbortSignal",
141
+ };
142
+ }
143
+ else if (!ok) {
144
+ error = {
145
+ code: "PROCESS_FAILED",
146
+ message: stderr || `Process exited with code ${exitCode}`,
147
+ };
148
+ }
149
+ }
150
+ const finalResult = {
151
+ ok,
152
+ exitCode,
153
+ signal: resolved.signal,
154
+ stdout,
155
+ stderr,
156
+ raw,
157
+ timedOut,
158
+ cancelled,
159
+ durationMs,
160
+ error,
161
+ };
162
+ if (!ok && options.throwOnError) {
163
+ throw new Error(stderr || `Process exited with code ${exitCode}`);
164
+ }
165
+ return finalResult;
166
+ }
167
+ async spawn(command, args = [], options = {}) {
168
+ return this.exec(command, args, options);
169
+ }
170
+ /**
171
+ * 获取指定命令的历史调用记录。
172
+ *
173
+ * @param command 可选命令筛选
174
+ */
175
+ getCalls(command) {
176
+ if (!command) {
177
+ return [...this.calls];
178
+ }
179
+ return this.calls.filter((c) => c.command === command);
180
+ }
181
+ /**
182
+ * 获取最近一次命令调用记录。
183
+ */
184
+ getLastCall() {
185
+ return this.calls[this.calls.length - 1];
186
+ }
187
+ /**
188
+ * 检查指定命令是否被调用过。
189
+ *
190
+ * @param command 目标命令
191
+ */
192
+ hasCalled(command) {
193
+ return this.calls.some((c) => c.command === command);
194
+ }
195
+ /**
196
+ * 清空历史调用记录。
197
+ */
198
+ clearHistory() {
199
+ this.calls = [];
200
+ }
201
+ /**
202
+ * 重置所有注册规则与历史记录。
203
+ */
204
+ reset() {
205
+ this.mocks = [];
206
+ this.calls = [];
207
+ }
208
+ /**
209
+ * 渲染已注册匹配器列表,辅助定位拼写失误。
210
+ */
211
+ describeMatchers() {
212
+ if (this.mocks.length === 0) {
213
+ return "(无任何已注册匹配器)";
214
+ }
215
+ return this.mocks
216
+ .map((m) => {
217
+ const desc = typeof m.matcher === "string"
218
+ ? `"${m.matcher}"`
219
+ : m.matcher instanceof RegExp
220
+ ? `/${m.matcher.source}/${m.matcher.flags}`
221
+ : "[Function]";
222
+ return `- ${desc}`;
223
+ })
224
+ .join("\n");
225
+ }
226
+ findMock(command, args, options) {
227
+ const fullCommandLine = [command, ...args].join(" ").trim();
228
+ // 逆序查找,优先匹配最新注册的规则;字符串匹配器仅支持命令名精确匹配与全命令行精确匹配
229
+ for (let i = this.mocks.length - 1; i >= 0; i--) {
230
+ const mock = this.mocks[i];
231
+ if (typeof mock.matcher === "string") {
232
+ if (mock.matcher === command || mock.matcher === fullCommandLine) {
233
+ return mock;
234
+ }
235
+ }
236
+ else if (mock.matcher instanceof RegExp) {
237
+ if (mock.matcher.test(fullCommandLine) || mock.matcher.test(command)) {
238
+ return mock;
239
+ }
240
+ }
241
+ else if (typeof mock.matcher === "function") {
242
+ if (mock.matcher(command, args, options)) {
243
+ return mock;
244
+ }
245
+ }
246
+ }
247
+ return undefined;
248
+ }
249
+ async waitDelay(delayMs, options) {
250
+ return new Promise((resolve) => {
251
+ let timer;
252
+ let onAbort;
253
+ const cleanup = () => {
254
+ if (timer) {
255
+ clearTimeout(timer);
256
+ timer = undefined;
257
+ }
258
+ if (options.signal && onAbort) {
259
+ options.signal.removeEventListener("abort", onAbort);
260
+ onAbort = undefined;
261
+ }
262
+ };
263
+ if (options.signal) {
264
+ if (options.signal.aborted) {
265
+ resolve();
266
+ return;
267
+ }
268
+ onAbort = () => {
269
+ cleanup();
270
+ resolve();
271
+ };
272
+ options.signal.addEventListener("abort", onAbort, { once: true });
273
+ }
274
+ timer = setTimeout(() => {
275
+ cleanup();
276
+ resolve();
277
+ }, delayMs);
278
+ });
279
+ }
280
+ }
package/dist/runtime.d.ts CHANGED
@@ -1,18 +1,78 @@
1
- import { ActionRunner, type ExecutionService, type ExecutionStartOptions, InMemoryEventSink, type ProjectConfig } from "@actiondock/core";
2
- import { type ActionDefinition, type Config, type ExecutionEvent, type ExecutionResult, type Logger, MemoryLogger, type RuntimeError, type StateStore } from "@actiondock/sdk";
3
- import { FakeClock } from "./clock";
4
- import { MockProcessExecutor } from "./process";
5
- import { MemoryStorage } from "./storage";
1
+ import { ActionRunner, type ExecutionService, type ExecutionStartOptions, InMemoryEventSink, type ProjectConfig, type RuntimePlatform } from "@actiondock/core";
2
+ import type { Clock } from "@actiondock/core";
3
+ import type { ActionDefinition, Config, ExecutionEvent, ExecutionResult, Logger, StateStore } from "@actiondock/sdk";
4
+ import { FakeClock } from "./clock.js";
5
+ import { MockProcessExecutor } from "./process.js";
6
+ import { MemoryStorage } from "./storage.js";
6
7
  /**
7
- * 规范化运行时错误异常类。
8
- * 当 run 方法执行失败时抛出,完整实现 RuntimeError 契约。
8
+ * 基于内存 Map 的只读/可写配置实现,专供单元测试使用。
9
9
  */
10
- export declare class ActionRuntimeError extends Error implements RuntimeError {
11
- code: string;
12
- details?: unknown;
13
- cause?: unknown;
14
- constructor(error: RuntimeError);
10
+ export declare class MemoryConfig implements Config {
11
+ private store;
12
+ constructor(initial?: Record<string, unknown>);
13
+ get<T = unknown>(key: string): T | undefined;
14
+ get<T = unknown>(key: string, defaultValue: T): T;
15
+ has(key: string): boolean;
16
+ /**
17
+ * 在测试期间动态更新或插入配置值。
18
+ * @param key 配置键名
19
+ * @param value 配置值
20
+ */
21
+ set(key: string, value: unknown): void;
22
+ /**
23
+ * 删除指定配置项。
24
+ * @param key 配置键名
25
+ */
26
+ delete(key: string): boolean;
27
+ /**
28
+ * 列出所有已存储配置项。
29
+ */
30
+ list(): Record<string, unknown>;
31
+ }
32
+ /**
33
+ * 内存状态条目结构体,包含数据值与可选的过期时间戳。
34
+ */
35
+ export interface MemoryStateEntry {
36
+ value: unknown;
37
+ expiresAt?: number;
38
+ }
39
+ import { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment } from "@actiondock/sdk";
40
+ export { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment };
41
+ /**
42
+ * 基于内存 Map 的状态存储实现,支持命名空间隔离与 TTL 自动失效,专供单元测试使用。
43
+ */
44
+ export declare class MemoryStateStore implements StateStore {
45
+ private store;
46
+ private namespace;
47
+ private clock;
48
+ constructor(store?: Map<string, any>, namespace?: string, clock?: Clock);
49
+ /** 获取注入时钟的当前时间戳(毫秒),TTL 过期判定单一事实入口 */
50
+ private nowMs;
51
+ private qualify;
52
+ private extractEntry;
53
+ get<T = unknown>(key: string): Promise<T | undefined>;
54
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<void>;
55
+ delete(key: string): Promise<boolean>;
56
+ clear(prefix?: string): Promise<number>;
57
+ keys(prefix?: string): Promise<string[]>;
58
+ scope(namespace: string): StateStore;
59
+ }
60
+ /**
61
+ * 内存日志记录器实现,将所有日志记录在数组中以便在测试断言中检索。
62
+ */
63
+ export declare class MemoryLogger implements Logger {
64
+ logs: Array<{
65
+ level: string;
66
+ message: string;
67
+ data?: unknown;
68
+ }>;
69
+ debug(message: string, data?: unknown): void;
70
+ info(message: string, data?: unknown): void;
71
+ warn(message: string, data?: unknown): void;
72
+ error(message: string, data?: unknown): void;
15
73
  }
74
+ import { ActionRuntimeError } from "@actiondock/sdk";
75
+ export { ActionRuntimeError };
16
76
  /**
17
77
  * 带有写入和调试能力的配置接口。
18
78
  */
@@ -82,7 +142,14 @@ export interface TestRuntimeOptions {
82
142
  /** 项目静态配置元数据 */
83
143
  projectConfig?: ProjectConfig;
84
144
  /** 预注册的 Action 动作列表 */
85
- actions?: ActionDefinition[] | Record<string, ActionDefinition> | Map<string, ActionDefinition>;
145
+ actions?: Array<{
146
+ id: string;
147
+ action: ActionDefinition;
148
+ } | (ActionDefinition & {
149
+ id: string;
150
+ })> | Record<string, ActionDefinition> | Map<string, ActionDefinition>;
151
+ /** 可选注入的标准运行时平台实例 */
152
+ platform?: RuntimePlatform;
86
153
  }
87
154
  /**
88
155
  * 测试运行时接口。
@@ -107,7 +174,11 @@ export interface TestRuntime {
107
174
  /** 核心执行器引擎(向后兼容保留) */
108
175
  runner: ActionRunner;
109
176
  /** 注册 Action 动作定义 */
110
- registerAction(action: ActionDefinition): void;
177
+ registerAction(id: string, action: ActionDefinition): void;
178
+ registerAction(action: ({
179
+ id: string;
180
+ action?: ActionDefinition;
181
+ } & Partial<ActionDefinition>) | ActionDefinition): void;
111
182
  /** 获取已注册的 Action 动作定义 */
112
183
  getAction(id: string): ActionDefinition | undefined;
113
184
  /** 列出已注册的所有 Action 动作定义 */