@actiondock/testing 2.0.11 → 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/README.md +45 -47
- package/dist/cli.d.ts +35 -0
- package/dist/cli.js +151 -0
- package/dist/clock.d.ts +54 -0
- package/dist/clock.js +108 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/platform.d.ts +48 -0
- package/dist/platform.js +61 -0
- package/dist/process.d.ts +117 -0
- package/dist/process.js +280 -0
- package/dist/runtime.d.ts +208 -0
- package/dist/runtime.js +426 -0
- package/dist/storage.d.ts +20 -0
- package/dist/storage.js +16 -0
- package/package.json +13 -12
- package/src/clock.ts +0 -136
- package/src/index.ts +0 -4
- package/src/process.ts +0 -394
- package/src/runtime.ts +0 -380
- package/src/storage.ts +0 -37
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { DefaultExecutionService, InMemoryEventSink, RuntimeConfig, RuntimeStateStore, SystemClock, normalizeActionCollection, } from "@actiondock/core";
|
|
2
|
+
import { FakeClock } from "./clock.js";
|
|
3
|
+
import { MockProcessExecutor } from "./process.js";
|
|
4
|
+
import { MemoryStorage } from "./storage.js";
|
|
5
|
+
/**
|
|
6
|
+
* 基于内存 Map 的只读/可写配置实现,专供单元测试使用。
|
|
7
|
+
*/
|
|
8
|
+
export class MemoryConfig {
|
|
9
|
+
store;
|
|
10
|
+
constructor(initial = {}) {
|
|
11
|
+
this.store = new Map(Object.entries(initial));
|
|
12
|
+
}
|
|
13
|
+
get(key, defaultValue) {
|
|
14
|
+
if (this.store.has(key)) {
|
|
15
|
+
return this.store.get(key);
|
|
16
|
+
}
|
|
17
|
+
return defaultValue;
|
|
18
|
+
}
|
|
19
|
+
has(key) {
|
|
20
|
+
return this.store.has(key);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 在测试期间动态更新或插入配置值。
|
|
24
|
+
* @param key 配置键名
|
|
25
|
+
* @param value 配置值
|
|
26
|
+
*/
|
|
27
|
+
set(key, value) {
|
|
28
|
+
this.store.set(key, value);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 删除指定配置项。
|
|
32
|
+
* @param key 配置键名
|
|
33
|
+
*/
|
|
34
|
+
delete(key) {
|
|
35
|
+
return this.store.delete(key);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 列出所有已存储配置项。
|
|
39
|
+
*/
|
|
40
|
+
list() {
|
|
41
|
+
return Object.fromEntries(this.store.entries());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// 状态键编解码能力单一事实源位于 @actiondock/sdk,此处 re-export 维持既有导入路径兼容并供本模块内部复用
|
|
45
|
+
import { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment, } from "@actiondock/sdk";
|
|
46
|
+
export { decodeStateKey, encodeStateKey, escapeStateSegment, unescapeStateSegment };
|
|
47
|
+
/**
|
|
48
|
+
* 基于内存 Map 的状态存储实现,支持命名空间隔离与 TTL 自动失效,专供单元测试使用。
|
|
49
|
+
*/
|
|
50
|
+
export class MemoryStateStore {
|
|
51
|
+
store;
|
|
52
|
+
namespace;
|
|
53
|
+
clock;
|
|
54
|
+
constructor(store, namespace = "", clock) {
|
|
55
|
+
this.store = store || new Map();
|
|
56
|
+
this.namespace = namespace;
|
|
57
|
+
this.clock = clock ?? new SystemClock();
|
|
58
|
+
}
|
|
59
|
+
/** 获取注入时钟的当前时间戳(毫秒),TTL 过期判定单一事实入口 */
|
|
60
|
+
nowMs() {
|
|
61
|
+
return this.clock.now().getTime();
|
|
62
|
+
}
|
|
63
|
+
qualify(key) {
|
|
64
|
+
return encodeStateKey(this.namespace, key);
|
|
65
|
+
}
|
|
66
|
+
extractEntry(raw) {
|
|
67
|
+
if (raw !== null &&
|
|
68
|
+
typeof raw === "object" &&
|
|
69
|
+
("__actiondock_entry__" in raw ||
|
|
70
|
+
"expiresAt" in raw)) {
|
|
71
|
+
return raw;
|
|
72
|
+
}
|
|
73
|
+
return { value: raw };
|
|
74
|
+
}
|
|
75
|
+
async get(key) {
|
|
76
|
+
const qKey = this.qualify(key);
|
|
77
|
+
const raw = this.store.get(qKey);
|
|
78
|
+
if (raw === undefined)
|
|
79
|
+
return undefined;
|
|
80
|
+
const entry = this.extractEntry(raw);
|
|
81
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= this.nowMs()) {
|
|
82
|
+
this.store.delete(qKey);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return (entry.value !== undefined ? structuredClone(entry.value) : undefined);
|
|
86
|
+
}
|
|
87
|
+
async set(key, value, ttl) {
|
|
88
|
+
const qKey = this.qualify(key);
|
|
89
|
+
const expiresAt = typeof ttl === "number" && ttl > 0 ? this.nowMs() + ttl * 1000 : undefined;
|
|
90
|
+
const entry = {
|
|
91
|
+
value: structuredClone(value),
|
|
92
|
+
expiresAt,
|
|
93
|
+
};
|
|
94
|
+
entry.__actiondock_entry__ = true;
|
|
95
|
+
this.store.set(qKey, entry);
|
|
96
|
+
}
|
|
97
|
+
async delete(key) {
|
|
98
|
+
const qKey = this.qualify(key);
|
|
99
|
+
return this.store.delete(qKey);
|
|
100
|
+
}
|
|
101
|
+
async clear(prefix = "") {
|
|
102
|
+
const keysToDelete = await this.keys(prefix);
|
|
103
|
+
let count = 0;
|
|
104
|
+
for (const k of keysToDelete) {
|
|
105
|
+
if (this.store.delete(this.qualify(k))) {
|
|
106
|
+
count++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
async keys(prefix = "") {
|
|
112
|
+
const now = this.nowMs();
|
|
113
|
+
const result = [];
|
|
114
|
+
for (const [k, raw] of this.store.entries()) {
|
|
115
|
+
let decoded;
|
|
116
|
+
try {
|
|
117
|
+
decoded = decodeStateKey(k);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (decoded.namespace === this.namespace) {
|
|
123
|
+
if (prefix && !decoded.key.startsWith(prefix)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const entry = this.extractEntry(raw);
|
|
127
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= now) {
|
|
128
|
+
this.store.delete(k);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
result.push(decoded.key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
scope(namespace) {
|
|
137
|
+
const nextNs = this.namespace
|
|
138
|
+
? `${this.namespace}:${namespace}`
|
|
139
|
+
: namespace;
|
|
140
|
+
return new MemoryStateStore(this.store, nextNs, this.clock);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* 内存日志记录器实现,将所有日志记录在数组中以便在测试断言中检索。
|
|
145
|
+
*/
|
|
146
|
+
export class MemoryLogger {
|
|
147
|
+
logs = [];
|
|
148
|
+
debug(message, data) {
|
|
149
|
+
this.logs.push({ level: "debug", message, data });
|
|
150
|
+
}
|
|
151
|
+
info(message, data) {
|
|
152
|
+
this.logs.push({ level: "info", message, data });
|
|
153
|
+
}
|
|
154
|
+
warn(message, data) {
|
|
155
|
+
this.logs.push({ level: "warn", message, data });
|
|
156
|
+
}
|
|
157
|
+
error(message, data) {
|
|
158
|
+
this.logs.push({ level: "error", message, data });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 规范化运行时错误类单一事实源位于 @actiondock/sdk,此处 re-export 维持既有导入路径兼容并供本模块内部复用
|
|
162
|
+
import { ActionRuntimeError } from "@actiondock/sdk";
|
|
163
|
+
export { ActionRuntimeError };
|
|
164
|
+
/**
|
|
165
|
+
* 测试配置管理器实现。
|
|
166
|
+
*/
|
|
167
|
+
export class TestConfigStore {
|
|
168
|
+
runtimeConfig;
|
|
169
|
+
storage;
|
|
170
|
+
constructor(storage, projectConfig, overrides) {
|
|
171
|
+
this.storage = storage;
|
|
172
|
+
this.runtimeConfig = new RuntimeConfig(storage, overrides, projectConfig, undefined);
|
|
173
|
+
}
|
|
174
|
+
get(key, defaultValue) {
|
|
175
|
+
return this.runtimeConfig.get(key, defaultValue);
|
|
176
|
+
}
|
|
177
|
+
has(key) {
|
|
178
|
+
return this.runtimeConfig.has(key);
|
|
179
|
+
}
|
|
180
|
+
set(key, value) {
|
|
181
|
+
this.storage.setConfig(key, value);
|
|
182
|
+
}
|
|
183
|
+
delete(key) {
|
|
184
|
+
const res = this.storage.deleteConfig(key);
|
|
185
|
+
return typeof res === "boolean" ? res : true;
|
|
186
|
+
}
|
|
187
|
+
list() {
|
|
188
|
+
return this.storage.listConfig();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 测试事件接收器实现。
|
|
193
|
+
* 记录执行期间产生的所有事件并支持历史检索。
|
|
194
|
+
*/
|
|
195
|
+
export class TestEventSink extends InMemoryEventSink {
|
|
196
|
+
allEvents = [];
|
|
197
|
+
sequenceCounter = 0;
|
|
198
|
+
/** 获取下一个单调自增序号 */
|
|
199
|
+
nextSequence() {
|
|
200
|
+
return this.sequenceCounter++;
|
|
201
|
+
}
|
|
202
|
+
emit(event) {
|
|
203
|
+
this.allEvents.push(event);
|
|
204
|
+
super.emit(event);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* 检索历史事件列表。
|
|
208
|
+
*
|
|
209
|
+
* @param runId 可选运行标识筛选
|
|
210
|
+
*/
|
|
211
|
+
getEvents(runId) {
|
|
212
|
+
if (runId) {
|
|
213
|
+
return this.allEvents.filter((e) => e.runId === runId);
|
|
214
|
+
}
|
|
215
|
+
return [...this.allEvents];
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 清理所有捕获的事件记录。
|
|
219
|
+
*/
|
|
220
|
+
clearAll() {
|
|
221
|
+
this.allEvents = [];
|
|
222
|
+
this.sequenceCounter = 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const anonymousTestActions = new WeakMap();
|
|
226
|
+
let anonymousTestActionCounter = 0;
|
|
227
|
+
/** 测试运行时内部持有的匿名 Action 下一次可用序号 */
|
|
228
|
+
function nextAnonymousTestActionId() {
|
|
229
|
+
anonymousTestActionCounter++;
|
|
230
|
+
return `test-action-${anonymousTestActionCounter}`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 将任意形态的 actions 输入归一化为统一映射表。
|
|
234
|
+
*
|
|
235
|
+
* 直接复用 core 的 normalizeActionCollection 作为归一化单一事实源,
|
|
236
|
+
* 避免在 testing 包内重新实现三形态分支造成逻辑拷贝;
|
|
237
|
+
* 此处无需 actionSpecs 产物,仅取 actionsMap,并额外为不含包前缀的标识补充限定别名。
|
|
238
|
+
*
|
|
239
|
+
* @param rawActions 三形态之一的 Action 集合输入
|
|
240
|
+
* @param packageId 当前 Package 标识,用于补充限定别名
|
|
241
|
+
*/
|
|
242
|
+
function normalizeTestActions(rawActions, packageId) {
|
|
243
|
+
const { actionsMap } = normalizeActionCollection(rawActions);
|
|
244
|
+
for (const [id, act] of [...actionsMap]) {
|
|
245
|
+
if (!id.includes("/")) {
|
|
246
|
+
actionsMap.set(`${packageId}/${id}`, act);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return actionsMap;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 测试运行时 Action 注册表。
|
|
253
|
+
* 统一持有本地 actionsMap 与 executionService 双份注册状态,收敛注册、检索与列举入口。
|
|
254
|
+
*/
|
|
255
|
+
class TestActionRegistry {
|
|
256
|
+
actionsMap;
|
|
257
|
+
executionService;
|
|
258
|
+
packageId;
|
|
259
|
+
constructor(actionsMap, executionService, packageId) {
|
|
260
|
+
this.actionsMap = actionsMap;
|
|
261
|
+
this.executionService = executionService;
|
|
262
|
+
this.packageId = packageId;
|
|
263
|
+
}
|
|
264
|
+
/** 双签名注册:字符串标识与定义,或携带 id 的定义对象 */
|
|
265
|
+
register(idOrAction, maybeAction) {
|
|
266
|
+
if (typeof idOrAction === "string") {
|
|
267
|
+
if (!maybeAction) {
|
|
268
|
+
throw new Error(`registerAction(id, action) 调用缺少 action 定义:id=${idOrAction}`);
|
|
269
|
+
}
|
|
270
|
+
this.executionService.registerAction(idOrAction, maybeAction);
|
|
271
|
+
this.actionsMap.set(idOrAction, maybeAction);
|
|
272
|
+
if (!idOrAction.includes("/")) {
|
|
273
|
+
this.actionsMap.set(`${this.packageId}/${idOrAction}`, maybeAction);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const actObj = idOrAction;
|
|
278
|
+
const id = actObj.id;
|
|
279
|
+
const act = actObj.action ?? (typeof actObj.run === "function" ? idOrAction : undefined);
|
|
280
|
+
if (id && act) {
|
|
281
|
+
this.actionsMap.set(id, act);
|
|
282
|
+
if (!id.includes("/")) {
|
|
283
|
+
this.actionsMap.set(`${this.packageId}/${id}`, act);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
this.executionService.registerAction(idOrAction);
|
|
287
|
+
}
|
|
288
|
+
/** 按标识检索已注册定义 */
|
|
289
|
+
get(id) {
|
|
290
|
+
return this.executionService.getAction(id);
|
|
291
|
+
}
|
|
292
|
+
/** 列出已注册的全部定义 */
|
|
293
|
+
list() {
|
|
294
|
+
return this.executionService.listActions();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* 解析 execute 入参的 Action 引用。
|
|
299
|
+
*
|
|
300
|
+
* 字符串直接作为引用;对象则依次尝试自身 id、注册表反查、匿名 WeakMap 映射,
|
|
301
|
+
* 并在首次出现时完成注册。全程不修改调用方传入的对象。
|
|
302
|
+
*
|
|
303
|
+
* @param action Action 定义或已注册标识
|
|
304
|
+
* @param registry 测试运行时 Action 注册表
|
|
305
|
+
* @returns 可用于 executionService 启动执行的引用标识
|
|
306
|
+
*/
|
|
307
|
+
function resolveActionRef(action, registry) {
|
|
308
|
+
if (typeof action === "string") {
|
|
309
|
+
return action;
|
|
310
|
+
}
|
|
311
|
+
const candidateId = action.id;
|
|
312
|
+
if (candidateId) {
|
|
313
|
+
registry.executionService.registerAction(candidateId, action);
|
|
314
|
+
registry.actionsMap.set(candidateId, action);
|
|
315
|
+
return candidateId;
|
|
316
|
+
}
|
|
317
|
+
// 反查注册表:同一对象已注册时复用既有标识
|
|
318
|
+
for (const [registeredId, registeredAct] of registry.actionsMap) {
|
|
319
|
+
if (registeredAct === action) {
|
|
320
|
+
return registeredId;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// 匿名对象首次出现:仅记录 WeakMap 映射,不写入调用方对象
|
|
324
|
+
let anonId = anonymousTestActions.get(action);
|
|
325
|
+
if (!anonId) {
|
|
326
|
+
anonId = nextAnonymousTestActionId();
|
|
327
|
+
anonymousTestActions.set(action, anonId);
|
|
328
|
+
}
|
|
329
|
+
registry.executionService.registerAction(anonId, action);
|
|
330
|
+
registry.actionsMap.set(anonId, action);
|
|
331
|
+
return anonId;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 创建全功能测试运行时实例。
|
|
335
|
+
* 基于统一 ExecutionService 协调执行全生命周期,并暴露配置、状态、时钟、进程与事件等调试接口。
|
|
336
|
+
*
|
|
337
|
+
* @param options 测试运行时选项
|
|
338
|
+
*/
|
|
339
|
+
export function createTestRuntime(options = {}) {
|
|
340
|
+
const packageId = options.packageId || "my-pkg";
|
|
341
|
+
const clock = options.clock ??
|
|
342
|
+
(options.platform?.clock instanceof FakeClock ? options.platform.clock : new FakeClock());
|
|
343
|
+
const process = options.process ??
|
|
344
|
+
(options.platform?.process instanceof MockProcessExecutor
|
|
345
|
+
? options.platform.process
|
|
346
|
+
: new MockProcessExecutor());
|
|
347
|
+
const storage = options.storage ??
|
|
348
|
+
(options.platform?.storage
|
|
349
|
+
? options.platform.storage.createStorage(packageId)
|
|
350
|
+
: new MemoryStorage({
|
|
351
|
+
packageId,
|
|
352
|
+
clock,
|
|
353
|
+
}));
|
|
354
|
+
// 初始化配置数据
|
|
355
|
+
if (options.config) {
|
|
356
|
+
for (const [key, val] of Object.entries(options.config)) {
|
|
357
|
+
storage.setConfig(key, val);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// 初始化状态数据
|
|
361
|
+
if (options.state) {
|
|
362
|
+
for (const [key, val] of Object.entries(options.state)) {
|
|
363
|
+
storage.setState("", key, val);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const events = new TestEventSink();
|
|
367
|
+
const memoryLogger = options.logger instanceof MemoryLogger ? options.logger : new MemoryLogger();
|
|
368
|
+
const actionsMap = normalizeTestActions(options.actions, packageId);
|
|
369
|
+
const executionService = new DefaultExecutionService({
|
|
370
|
+
packageId,
|
|
371
|
+
storage,
|
|
372
|
+
projectConfig: options.projectConfig,
|
|
373
|
+
configOverrides: options.configOverrides,
|
|
374
|
+
actions: actionsMap,
|
|
375
|
+
process,
|
|
376
|
+
clock,
|
|
377
|
+
logger: memoryLogger,
|
|
378
|
+
eventSink: events,
|
|
379
|
+
platform: options.platform,
|
|
380
|
+
});
|
|
381
|
+
const registry = new TestActionRegistry(actionsMap, executionService, packageId);
|
|
382
|
+
const testConfig = new TestConfigStore(storage, options.projectConfig, options.configOverrides);
|
|
383
|
+
const testState = new RuntimeStateStore(storage);
|
|
384
|
+
const registerAction = (idOrAction, maybeAction) => {
|
|
385
|
+
registry.register(idOrAction, maybeAction);
|
|
386
|
+
};
|
|
387
|
+
const execute = async (action, input = {}, execOptions = {}) => {
|
|
388
|
+
const actionRef = resolveActionRef(action, registry);
|
|
389
|
+
const ticket = await executionService.start(actionRef, input, {
|
|
390
|
+
signal: execOptions.signal,
|
|
391
|
+
timeoutMs: execOptions.timeoutMs,
|
|
392
|
+
config: execOptions.configOverrides,
|
|
393
|
+
parentRunId: execOptions.parentRunId,
|
|
394
|
+
rootRunId: execOptions.rootRunId,
|
|
395
|
+
maxCallDepth: execOptions.maxCallDepth,
|
|
396
|
+
logger: execOptions.logger,
|
|
397
|
+
progress: execOptions.progress,
|
|
398
|
+
process: execOptions.process || process,
|
|
399
|
+
});
|
|
400
|
+
const result = (await ticket.result);
|
|
401
|
+
return result;
|
|
402
|
+
};
|
|
403
|
+
const run = async (action, input = {}) => {
|
|
404
|
+
const result = await execute(action, input);
|
|
405
|
+
if (!result.ok) {
|
|
406
|
+
throw new ActionRuntimeError(result.error);
|
|
407
|
+
}
|
|
408
|
+
return result.data;
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
config: testConfig,
|
|
412
|
+
state: testState,
|
|
413
|
+
clock,
|
|
414
|
+
process,
|
|
415
|
+
events,
|
|
416
|
+
logger: memoryLogger,
|
|
417
|
+
storage,
|
|
418
|
+
executionService,
|
|
419
|
+
runner: executionService.runner,
|
|
420
|
+
registerAction,
|
|
421
|
+
getAction: (id) => registry.get(id),
|
|
422
|
+
listActions: () => registry.list(),
|
|
423
|
+
run,
|
|
424
|
+
execute,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Clock, SqliteRuntimeStorage, type SqliteDriver } from "@actiondock/core";
|
|
2
|
+
/**
|
|
3
|
+
* 内存运行时存储初始化选项。
|
|
4
|
+
*/
|
|
5
|
+
export interface MemoryStorageOptions {
|
|
6
|
+
/** 绑定的 Package 标识,默认为 test-pkg */
|
|
7
|
+
packageId?: string;
|
|
8
|
+
/** 可选注入的时间提供器,便于与模拟时钟联动 */
|
|
9
|
+
clock?: Clock;
|
|
10
|
+
/** 可选显式注入的底层 SQLite 驱动 */
|
|
11
|
+
driver?: SqliteDriver;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 统一内存运行时存储实现。
|
|
15
|
+
* 基于 SqliteRuntimeStorage 构建,默认使用 :memory: 内存数据库并对接虚拟时钟,
|
|
16
|
+
* 确保与生产环境具备完全相同的配置优先级、状态过期契约与运行终态行为。
|
|
17
|
+
*/
|
|
18
|
+
export declare class MemoryStorage extends SqliteRuntimeStorage {
|
|
19
|
+
constructor(options?: MemoryStorageOptions);
|
|
20
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SqliteRuntimeStorage, } from "@actiondock/core";
|
|
2
|
+
/**
|
|
3
|
+
* 统一内存运行时存储实现。
|
|
4
|
+
* 基于 SqliteRuntimeStorage 构建,默认使用 :memory: 内存数据库并对接虚拟时钟,
|
|
5
|
+
* 确保与生产环境具备完全相同的配置优先级、状态过期契约与运行终态行为。
|
|
6
|
+
*/
|
|
7
|
+
export class MemoryStorage extends SqliteRuntimeStorage {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
super({
|
|
10
|
+
packageId: options.packageId || "test-pkg",
|
|
11
|
+
dbPath: ":memory:",
|
|
12
|
+
driver: options.driver,
|
|
13
|
+
clock: options.clock,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,37 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actiondock/testing",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "ActionDock Test Runtime for testing Actions with real Core execution semantics",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
7
|
-
"module": "./
|
|
8
|
-
"types": "./
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
-
"
|
|
12
|
-
"
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
13
14
|
}
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
|
-
"
|
|
17
|
+
"dist",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
21
|
+
"node": ">=24.12.0"
|
|
21
22
|
},
|
|
22
23
|
"publishConfig": {
|
|
23
24
|
"access": "public",
|
|
24
25
|
"registry": "https://registry.npmjs.org/"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
27
|
-
"
|
|
28
|
+
"build": "node ../../scripts/build.ts",
|
|
29
|
+
"test": "node ../../scripts/run-tests.ts"
|
|
28
30
|
},
|
|
29
31
|
"dependencies": {
|
|
30
|
-
"@actiondock/core": "^2.0
|
|
31
|
-
"@actiondock/sdk": "^2.0
|
|
32
|
+
"@actiondock/core": "^2.2.0",
|
|
33
|
+
"@actiondock/sdk": "^2.2.0"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
34
|
-
"@types/bun": "latest",
|
|
35
36
|
"typescript": "^5.7.0"
|
|
36
37
|
},
|
|
37
38
|
"keywords": [
|
package/src/clock.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
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
DELETED