@actiondock/testing 2.0.12 → 2.2.0

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/dist/index.js CHANGED
@@ -1,486 +1,6 @@
1
- // packages/testing/src/index.ts
2
- import { registerTestRuntimeProvider } from "@actiondock/sdk";
3
-
4
- // packages/testing/src/runtime.ts
5
- import {
6
- DefaultExecutionService,
7
- InMemoryEventSink,
8
- RuntimeConfig,
9
- RuntimeStateStore
10
- } from "@actiondock/core";
11
- import {
12
- MemoryLogger
13
- } from "@actiondock/sdk";
14
-
15
- // packages/testing/src/clock.ts
16
- class FakeClock {
17
- currentNow;
18
- currentMonotonic;
19
- nextTimerId = 1;
20
- pendingSleeps = [];
21
- constructor(options = {}) {
22
- if (options.now !== undefined) {
23
- this.currentNow = new Date(options.now).getTime();
24
- } else {
25
- this.currentNow = Date.now();
26
- }
27
- this.currentMonotonic = options.startMonotonic ?? 0;
28
- }
29
- now() {
30
- return new Date(this.currentNow);
31
- }
32
- monotonic() {
33
- return this.currentMonotonic;
34
- }
35
- sleep(ms) {
36
- if (ms <= 0) {
37
- return Promise.resolve();
38
- }
39
- return new Promise((resolve, reject) => {
40
- const targetMonotonic = this.currentMonotonic + ms;
41
- const targetNow = this.currentNow + ms;
42
- this.pendingSleeps.push({
43
- id: this.nextTimerId++,
44
- targetMonotonic,
45
- targetNow,
46
- resolve,
47
- reject
48
- });
49
- this.pendingSleeps.sort((a, b) => a.targetMonotonic - b.targetMonotonic);
50
- });
51
- }
52
- async advance(ms) {
53
- if (ms < 0) {
54
- throw new Error("Cannot advance clock by negative time");
55
- }
56
- if (ms === 0) {
57
- await Promise.resolve();
58
- return;
59
- }
60
- const destinationMonotonic = this.currentMonotonic + ms;
61
- const destinationNow = this.currentNow + ms;
62
- while (this.pendingSleeps.length > 0) {
63
- const nextSleep = this.pendingSleeps[0];
64
- if (nextSleep.targetMonotonic > destinationMonotonic) {
65
- break;
66
- }
67
- this.pendingSleeps.shift();
68
- this.currentMonotonic = nextSleep.targetMonotonic;
69
- this.currentNow = nextSleep.targetNow;
70
- nextSleep.resolve();
71
- await Promise.resolve();
72
- }
73
- this.currentMonotonic = destinationMonotonic;
74
- this.currentNow = destinationNow;
75
- await Promise.resolve();
76
- }
77
- get pendingCount() {
78
- return this.pendingSleeps.length;
79
- }
80
- clear() {
81
- const sleeps = this.pendingSleeps;
82
- this.pendingSleeps = [];
83
- for (const item of sleeps) {
84
- item.reject(new Error("FakeClock timer cancelled"));
85
- }
86
- }
87
- }
88
-
89
- // packages/testing/src/process.ts
90
- class MockProcessExecutor {
91
- mocks = [];
92
- calls = [];
93
- detachedCalls = [];
94
- defaultPid = 10001;
95
- register(matcher, handlerOrResult) {
96
- this.mocks.push({ matcher, handler: handlerOrResult });
97
- return this;
98
- }
99
- async exec(command, args = [], options = {}) {
100
- const startTime = Date.now();
101
- this.calls.push({
102
- command,
103
- args: [...args],
104
- options: { ...options },
105
- timestamp: startTime
106
- });
107
- if (options.signal?.aborted) {
108
- const res = {
109
- ok: false,
110
- exitCode: null,
111
- signal: "SIGTERM",
112
- stdout: "",
113
- stderr: "Process was cancelled by AbortSignal",
114
- raw: new Uint8Array,
115
- timedOut: false,
116
- cancelled: true,
117
- durationMs: 0,
118
- error: {
119
- code: "PROCESS_CANCELLED",
120
- message: "Process was cancelled by AbortSignal"
121
- }
122
- };
123
- if (options.throwOnError) {
124
- throw new Error(res.stderr);
125
- }
126
- return res;
127
- }
128
- const matchedMock = this.findMock(command, args, options);
129
- let resolved;
130
- if (!matchedMock) {
131
- resolved = {
132
- ok: true,
133
- exitCode: 0,
134
- stdout: "",
135
- stderr: ""
136
- };
137
- } else if (typeof matchedMock.handler === "function") {
138
- resolved = await matchedMock.handler(command, args, options);
139
- } else {
140
- resolved = matchedMock.handler;
141
- }
142
- const maybeMock = resolved;
143
- if (typeof maybeMock.delayMs === "number" && maybeMock.delayMs > 0) {
144
- await this.waitDelay(maybeMock.delayMs, options);
145
- }
146
- const timedOut = Boolean(resolved.timedOut);
147
- const cancelled = Boolean(resolved.cancelled || options.signal?.aborted);
148
- const stdout = resolved.stdout ?? "";
149
- const stderr = resolved.stderr ?? (timedOut ? "Process timed out" : cancelled ? "Process cancelled" : "");
150
- const raw = resolved.raw ?? new TextEncoder().encode(stdout);
151
- const exitCode = resolved.exitCode !== undefined ? resolved.exitCode : timedOut || cancelled ? null : resolved.ok === false ? 1 : 0;
152
- const ok = resolved.ok !== undefined ? resolved.ok : exitCode === 0 && !timedOut && !cancelled && !resolved.error;
153
- const durationMs = resolved.durationMs ?? Date.now() - startTime;
154
- let error = resolved.error;
155
- if (!error) {
156
- if (timedOut) {
157
- error = {
158
- code: "PROCESS_TIMEOUT",
159
- message: `Process exceeded timeout of ${options.timeoutMs ?? durationMs}ms`
160
- };
161
- } else if (cancelled) {
162
- error = {
163
- code: "PROCESS_CANCELLED",
164
- message: "Process was cancelled by AbortSignal"
165
- };
166
- } else if (!ok) {
167
- error = {
168
- code: "PROCESS_FAILED",
169
- message: stderr || `Process exited with code ${exitCode}`
170
- };
171
- }
172
- }
173
- const finalResult = {
174
- ok,
175
- exitCode,
176
- signal: resolved.signal,
177
- stdout,
178
- stderr,
179
- raw,
180
- timedOut,
181
- cancelled,
182
- durationMs,
183
- error
184
- };
185
- if (!ok && options.throwOnError) {
186
- throw new Error(stderr || `Process exited with code ${exitCode}`);
187
- }
188
- return finalResult;
189
- }
190
- async spawnDetached(options) {
191
- const startTime = Date.now();
192
- this.detachedCalls.push({
193
- options: { ...options },
194
- timestamp: startTime
195
- });
196
- if (options.signal?.aborted) {
197
- return {
198
- ok: false,
199
- ready: false,
200
- durationMs: 0,
201
- error: {
202
- code: "PROCESS_CANCELLED",
203
- message: "Process was cancelled by AbortSignal"
204
- }
205
- };
206
- }
207
- if (options.probe) {
208
- const fakeResult = {
209
- ok: true,
210
- exitCode: 0,
211
- stdout: "ready",
212
- stderr: "",
213
- raw: new TextEncoder().encode("ready"),
214
- timedOut: false,
215
- cancelled: false,
216
- durationMs: 0
217
- };
218
- const isReady = await options.probe(fakeResult);
219
- return {
220
- ok: isReady,
221
- pid: this.defaultPid++,
222
- ready: isReady,
223
- durationMs: Date.now() - startTime
224
- };
225
- }
226
- return {
227
- ok: true,
228
- pid: this.defaultPid++,
229
- ready: true,
230
- durationMs: Date.now() - startTime
231
- };
232
- }
233
- getCalls(command) {
234
- if (!command) {
235
- return [...this.calls];
236
- }
237
- return this.calls.filter((c) => c.command === command);
238
- }
239
- getLastCall() {
240
- return this.calls[this.calls.length - 1];
241
- }
242
- hasCalled(command) {
243
- return this.calls.some((c) => c.command === command);
244
- }
245
- clearHistory() {
246
- this.calls = [];
247
- this.detachedCalls = [];
248
- }
249
- reset() {
250
- this.mocks = [];
251
- this.calls = [];
252
- this.detachedCalls = [];
253
- }
254
- findMock(command, args, options) {
255
- const fullCommandLine = [command, ...args].join(" ").trim();
256
- for (let i = this.mocks.length - 1;i >= 0; i--) {
257
- const mock = this.mocks[i];
258
- if (typeof mock.matcher === "string") {
259
- if (mock.matcher === command || mock.matcher === fullCommandLine || fullCommandLine.startsWith(mock.matcher)) {
260
- return mock;
261
- }
262
- } else if (mock.matcher instanceof RegExp) {
263
- if (mock.matcher.test(fullCommandLine) || mock.matcher.test(command)) {
264
- return mock;
265
- }
266
- } else if (typeof mock.matcher === "function") {
267
- if (mock.matcher(command, args, options)) {
268
- return mock;
269
- }
270
- }
271
- }
272
- return;
273
- }
274
- async waitDelay(delayMs, options) {
275
- return new Promise((resolve) => {
276
- let timer;
277
- const cleanup = () => {
278
- if (timer)
279
- clearTimeout(timer);
280
- };
281
- if (options.signal) {
282
- options.signal.addEventListener("abort", () => {
283
- cleanup();
284
- resolve();
285
- }, { once: true });
286
- }
287
- timer = setTimeout(() => {
288
- cleanup();
289
- resolve();
290
- }, delayMs);
291
- });
292
- }
293
- }
294
-
295
- // packages/testing/src/storage.ts
296
- import {
297
- SqliteRuntimeStorage
298
- } from "@actiondock/core";
299
-
300
- class MemoryStorage extends SqliteRuntimeStorage {
301
- constructor(options = {}) {
302
- super({
303
- packageId: options.packageId || "test-pkg",
304
- dbPath: ":memory:",
305
- driver: options.driver,
306
- clock: options.clock
307
- });
308
- }
309
- }
310
-
311
- // packages/testing/src/runtime.ts
312
- class ActionRuntimeError extends Error {
313
- code;
314
- details;
315
- cause;
316
- constructor(error) {
317
- super(error.message);
318
- this.name = "ActionRuntimeError";
319
- this.code = error.code;
320
- this.details = error.details;
321
- this.cause = error.cause;
322
- Object.setPrototypeOf(this, ActionRuntimeError.prototype);
323
- }
324
- }
325
-
326
- class TestConfigStore {
327
- runtimeConfig;
328
- storage;
329
- constructor(storage, projectConfig, overrides) {
330
- this.storage = storage;
331
- this.runtimeConfig = new RuntimeConfig(storage, overrides, projectConfig, undefined);
332
- }
333
- get(key, defaultValue) {
334
- return this.runtimeConfig.get(key, defaultValue);
335
- }
336
- has(key) {
337
- return this.runtimeConfig.has(key);
338
- }
339
- set(key, value) {
340
- this.storage.setConfig(key, value);
341
- }
342
- delete(key) {
343
- return this.storage.deleteConfig(key);
344
- }
345
- list() {
346
- return this.storage.listConfig();
347
- }
348
- }
349
-
350
- class TestEventSink extends InMemoryEventSink {
351
- allEvents = [];
352
- sequenceCounter = 0;
353
- nextSequence() {
354
- return this.sequenceCounter++;
355
- }
356
- emit(event) {
357
- this.allEvents.push(event);
358
- super.emit(event);
359
- }
360
- getEvents(runId) {
361
- if (runId) {
362
- return this.allEvents.filter((e) => e.runId === runId);
363
- }
364
- return [...this.allEvents];
365
- }
366
- clearAll() {
367
- this.allEvents = [];
368
- this.sequenceCounter = 0;
369
- }
370
- }
371
- function createTestRuntime(options = {}) {
372
- const packageId = options.packageId || "test-pkg";
373
- const clock = options.clock ?? new FakeClock;
374
- const process = options.process ?? new MockProcessExecutor;
375
- const storage = options.storage ?? new MemoryStorage({
376
- packageId,
377
- clock
378
- });
379
- if (options.config) {
380
- for (const [key, val] of Object.entries(options.config)) {
381
- storage.setConfig(key, val);
382
- }
383
- }
384
- if (options.state) {
385
- for (const [key, val] of Object.entries(options.state)) {
386
- storage.setState("", key, val);
387
- }
388
- }
389
- const events = new TestEventSink;
390
- const memoryLogger = options.logger instanceof MemoryLogger ? options.logger : new MemoryLogger;
391
- const actionsMap = new Map;
392
- if (options.actions) {
393
- if (Array.isArray(options.actions)) {
394
- for (const act of options.actions) {
395
- actionsMap.set(act.id, act);
396
- }
397
- } else if (options.actions instanceof Map) {
398
- for (const [k, v] of options.actions) {
399
- actionsMap.set(k, v);
400
- }
401
- } else if (typeof options.actions === "object") {
402
- for (const [k, v] of Object.entries(options.actions)) {
403
- actionsMap.set(k, v);
404
- }
405
- }
406
- }
407
- const executionService = new DefaultExecutionService({
408
- packageId,
409
- storage,
410
- projectConfig: options.projectConfig,
411
- configOverrides: options.configOverrides,
412
- actions: actionsMap,
413
- process,
414
- clock,
415
- logger: memoryLogger,
416
- eventSink: events
417
- });
418
- const testConfig = new TestConfigStore(storage, options.projectConfig, options.configOverrides);
419
- const testState = new RuntimeStateStore(storage);
420
- const registerAction = (action) => {
421
- executionService.registerAction(action);
422
- };
423
- const getAction = (id) => {
424
- return executionService.getAction(id);
425
- };
426
- const listActions = () => {
427
- return executionService.listActions();
428
- };
429
- const execute = async (action, input = {}, execOptions = {}) => {
430
- if (typeof action !== "string") {
431
- executionService.registerAction(action);
432
- }
433
- const actionRef = typeof action === "string" ? action : action.id;
434
- const ticket = await executionService.start(actionRef, input, {
435
- signal: execOptions.signal,
436
- timeoutMs: execOptions.timeoutMs,
437
- config: execOptions.configOverrides,
438
- parentRunId: execOptions.parentRunId,
439
- rootRunId: execOptions.rootRunId,
440
- maxCallDepth: execOptions.maxCallDepth,
441
- logger: execOptions.logger,
442
- progress: execOptions.progress,
443
- process: execOptions.process || process
444
- });
445
- const result = await ticket.result;
446
- return result;
447
- };
448
- const run = async (action, input = {}) => {
449
- const result = await execute(action, input);
450
- if (!result.ok) {
451
- throw new ActionRuntimeError(result.error);
452
- }
453
- return result.data;
454
- };
455
- return {
456
- config: testConfig,
457
- state: testState,
458
- clock,
459
- process,
460
- events,
461
- logger: memoryLogger,
462
- storage,
463
- executionService,
464
- runner: executionService.runner,
465
- registerAction,
466
- getAction,
467
- listActions,
468
- run,
469
- execute
470
- };
471
- }
472
-
473
- // packages/testing/src/index.ts
474
- function registerTestingAsSdkProvider() {
475
- registerTestRuntimeProvider(createTestRuntime);
476
- }
477
- export {
478
- ActionRuntimeError,
479
- FakeClock,
480
- MemoryStorage,
481
- MockProcessExecutor,
482
- TestConfigStore,
483
- TestEventSink,
484
- createTestRuntime,
485
- registerTestingAsSdkProvider
486
- };
1
+ export * from "./clock.js";
2
+ export * from "./process.js";
3
+ export * from "./storage.js";
4
+ export * from "./runtime.js";
5
+ export * from "./platform.js";
6
+ export * from "./cli.js";
@@ -0,0 +1,48 @@
1
+ import { type EventSink, type FileSystem, type ModuleLoader, type RuntimePlatform, type RuntimeStorage, type StorageFactory } from "@actiondock/core";
2
+ import { FakeClock } from "./clock.js";
3
+ import { MockProcessExecutor } from "./process.js";
4
+ /**
5
+ * 测试平台构建配置选项。
6
+ */
7
+ export interface TestPlatformOptions {
8
+ /** 可选注入的确定性虚拟时钟 */
9
+ clock?: FakeClock;
10
+ /** 可选注入的统一存储实例(若指定则所有 Package 存储均回退至该实例) */
11
+ storage?: RuntimeStorage;
12
+ /** 可选注入的跨 Package 全局存储实例 */
13
+ globalStorage?: RuntimeStorage;
14
+ /** 可选注入的模拟进程执行器 */
15
+ process?: MockProcessExecutor;
16
+ /** 可选注入的执行事件接收器 */
17
+ eventSink?: EventSink;
18
+ /** 可选注入的文件系统抽象驱动 */
19
+ files?: FileSystem;
20
+ /** 可选注入的源码模块加载器 */
21
+ modules?: ModuleLoader;
22
+ }
23
+ /**
24
+ * 测试运行时平台接口契约。
25
+ * 完整实现 RuntimePlatform,并显式暴露测试组件类型。
26
+ */
27
+ export interface TestPlatform extends RuntimePlatform {
28
+ readonly name: "test";
29
+ readonly clock: FakeClock;
30
+ readonly files: FileSystem;
31
+ readonly modules: ModuleLoader;
32
+ readonly process: MockProcessExecutor;
33
+ readonly storage: StorageFactory;
34
+ readonly eventSink: EventSink;
35
+ }
36
+ /**
37
+ * 创建纯内存确定性测试平台实例。
38
+ * 组装测试核心组件:
39
+ * - FakeClock 确定性虚拟时钟
40
+ * - MockProcessExecutor 模拟进程执行器
41
+ * - MemoryStorage 纯内存数据库存储
42
+ * - TestEventSink 确定性事件接收器
43
+ * - DefaultModuleLoader 动态模块加载器
44
+ * - NodeFileSystem 文件系统
45
+ *
46
+ * @param options 测试平台配置选项
47
+ */
48
+ export declare function createTestPlatform(options?: TestPlatformOptions): TestPlatform;
@@ -0,0 +1,61 @@
1
+ import { DefaultModuleLoader, NodeFileSystem, } from "@actiondock/core";
2
+ import { FakeClock } from "./clock.js";
3
+ import { MockProcessExecutor } from "./process.js";
4
+ import { TestEventSink } from "./runtime.js";
5
+ import { MemoryStorage } from "./storage.js";
6
+ /**
7
+ * 创建纯内存确定性测试平台实例。
8
+ * 组装测试核心组件:
9
+ * - FakeClock 确定性虚拟时钟
10
+ * - MockProcessExecutor 模拟进程执行器
11
+ * - MemoryStorage 纯内存数据库存储
12
+ * - TestEventSink 确定性事件接收器
13
+ * - DefaultModuleLoader 动态模块加载器
14
+ * - NodeFileSystem 文件系统
15
+ *
16
+ * @param options 测试平台配置选项
17
+ */
18
+ export function createTestPlatform(options = {}) {
19
+ const clock = options.clock ?? new FakeClock();
20
+ const process = options.process ?? new MockProcessExecutor();
21
+ const eventSink = options.eventSink ?? new TestEventSink();
22
+ const files = options.files ?? new NodeFileSystem();
23
+ const modules = options.modules ?? new DefaultModuleLoader();
24
+ const packageStorages = new Map();
25
+ let globalStorageInstance = options.globalStorage;
26
+ const storageFactory = {
27
+ createStorage(packageId, _opts) {
28
+ if (options.storage) {
29
+ return options.storage;
30
+ }
31
+ let existing = packageStorages.get(packageId);
32
+ if (!existing) {
33
+ existing = new MemoryStorage({
34
+ packageId,
35
+ clock,
36
+ });
37
+ packageStorages.set(packageId, existing);
38
+ }
39
+ return existing;
40
+ },
41
+ createGlobalStorage(_opts) {
42
+ if (globalStorageInstance) {
43
+ return globalStorageInstance;
44
+ }
45
+ globalStorageInstance = new MemoryStorage({
46
+ packageId: "__global__",
47
+ clock,
48
+ });
49
+ return globalStorageInstance;
50
+ },
51
+ };
52
+ return {
53
+ name: "test",
54
+ clock,
55
+ files,
56
+ modules,
57
+ process,
58
+ storage: storageFactory,
59
+ eventSink,
60
+ };
61
+ }
package/dist/process.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ProcessExecutor } from "@actiondock/core";
2
- import type { DetachedProcessOptions, DetachedProcessResult, ProcessExecOptions, ProcessResult, RuntimeError } from "@actiondock/sdk";
2
+ import type { ProcessExecOptions, ProcessResult, RuntimeError } from "@actiondock/sdk";
3
3
  /**
4
4
  * 模拟命令匹配器。
5
5
  */
@@ -49,23 +49,25 @@ export interface ProcessCall {
49
49
  timestamp: number;
50
50
  }
51
51
  /**
52
- * 已记录的后台守护进程调用历史条目。
52
+ * 模拟进程执行器构造选项。
53
53
  */
54
- export interface DetachedProcessCall {
55
- /** 启动参数选项 */
56
- options: DetachedProcessOptions;
57
- /** 调用发生时的时间戳 */
58
- timestamp: number;
54
+ export interface MockProcessExecutorOptions {
55
+ /** 未命中任何模拟规则时是否回退到真实子进程执行(默认 false,未命中即抛错) */
56
+ fallbackToReal?: boolean;
59
57
  }
60
58
  /**
61
59
  * 模拟进程执行器实现。
62
60
  * 遵循 ProcessExecutor 接口契约,支持预设命令响应、跟踪调用历史并模拟超时与取消场景。
61
+ *
62
+ * 默认不回退真实子进程执行:未命中任何模拟规则时抛出明确错误,避免测试中的拼写失误穿透到真实系统命令。
63
+ * 如确需真实回退(例如集成本地 CLI),可显式传入 fallbackToReal: true。
63
64
  */
64
65
  export declare class MockProcessExecutor implements ProcessExecutor {
65
66
  private mocks;
66
67
  calls: ProcessCall[];
67
- detachedCalls: DetachedProcessCall[];
68
68
  defaultPid: number;
69
+ private readonly fallbackToReal;
70
+ constructor(options?: MockProcessExecutorOptions);
69
71
  /**
70
72
  * 注册模拟命令匹配与返回结果。
71
73
  *
@@ -81,12 +83,7 @@ export declare class MockProcessExecutor implements ProcessExecutor {
81
83
  * @param options 执行选项
82
84
  */
83
85
  exec(command: string, args?: string[], options?: ProcessExecOptions): Promise<ProcessResult>;
84
- /**
85
- * 启动模拟脱离父进程的后台进程。
86
- *
87
- * @param options 守护进程启动选项
88
- */
89
- spawnDetached(options: DetachedProcessOptions): Promise<DetachedProcessResult>;
86
+ spawn(command: string, args?: string[], options?: ProcessExecOptions): Promise<ProcessResult>;
90
87
  /**
91
88
  * 获取指定命令的历史调用记录。
92
89
  *
@@ -111,6 +108,10 @@ export declare class MockProcessExecutor implements ProcessExecutor {
111
108
  * 重置所有注册规则与历史记录。
112
109
  */
113
110
  reset(): void;
111
+ /**
112
+ * 渲染已注册匹配器列表,辅助定位拼写失误。
113
+ */
114
+ private describeMatchers;
114
115
  private findMock;
115
116
  private waitDelay;
116
117
  }