@noego/testing 0.1.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.cjs +721 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +262 -0
- package/dist/index.d.ts +262 -0
- package/dist/index.js +676 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
// src/time/manual_clock.ts
|
|
2
|
+
import { Clock, Instant } from "@noego/runtime";
|
|
3
|
+
var ManualClock = class _ManualClock extends Clock {
|
|
4
|
+
static defaultStart = Instant.ofEpochMilliseconds(Date.UTC(2020, 0, 1));
|
|
5
|
+
current;
|
|
6
|
+
constructor(start = _ManualClock.defaultStart) {
|
|
7
|
+
super();
|
|
8
|
+
this.current = start;
|
|
9
|
+
}
|
|
10
|
+
now() {
|
|
11
|
+
return this.current;
|
|
12
|
+
}
|
|
13
|
+
set(instant) {
|
|
14
|
+
if (instant.isBefore(this.current)) {
|
|
15
|
+
throw new RangeError(`ManualClock cannot move backwards: ${this.current.toString()} -> ${instant.toString()}`);
|
|
16
|
+
}
|
|
17
|
+
this.current = instant;
|
|
18
|
+
}
|
|
19
|
+
advanceBy(duration) {
|
|
20
|
+
this.current = this.current.plus(duration);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// src/time/manual_scheduler.ts
|
|
25
|
+
import { Scheduler, brandId } from "@noego/runtime";
|
|
26
|
+
var ManualScheduler = class extends Scheduler {
|
|
27
|
+
constructor(clock) {
|
|
28
|
+
super();
|
|
29
|
+
this.clock = clock;
|
|
30
|
+
}
|
|
31
|
+
clock;
|
|
32
|
+
tasks = [];
|
|
33
|
+
nextSequence = 0;
|
|
34
|
+
nextId = 0;
|
|
35
|
+
schedule(input) {
|
|
36
|
+
const id = brandId(`task-${++this.nextId}`, "ScheduledTask");
|
|
37
|
+
const internal = {
|
|
38
|
+
id,
|
|
39
|
+
label: input.label,
|
|
40
|
+
deadline: this.clock.now().plus(input.delay),
|
|
41
|
+
sequence: this.nextSequence++,
|
|
42
|
+
task: input.task,
|
|
43
|
+
cancelled: false
|
|
44
|
+
};
|
|
45
|
+
this.tasks.push(internal);
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
cancel: () => {
|
|
49
|
+
internal.cancelled = true;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
pending() {
|
|
54
|
+
return this.live().sort(compareTasks).map(({ id, label, deadline }) => ({ id, label, deadline }));
|
|
55
|
+
}
|
|
56
|
+
/** Advance the clock, running every task due inside the window in order. */
|
|
57
|
+
async advanceBy(duration) {
|
|
58
|
+
const target = this.clock.now().plus(duration);
|
|
59
|
+
for (; ; ) {
|
|
60
|
+
const next = this.nextDue(target);
|
|
61
|
+
if (next === null) break;
|
|
62
|
+
if (next.deadline.isAfter(this.clock.now())) {
|
|
63
|
+
this.clock.set(next.deadline);
|
|
64
|
+
}
|
|
65
|
+
await this.execute(next);
|
|
66
|
+
}
|
|
67
|
+
if (target.isAfter(this.clock.now())) {
|
|
68
|
+
this.clock.set(target);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Advance to the earliest pending deadline and run exactly that task. */
|
|
72
|
+
async runNext() {
|
|
73
|
+
const next = this.live().sort(compareTasks)[0];
|
|
74
|
+
if (next === void 0) {
|
|
75
|
+
throw new Error("ManualScheduler.runNext: no pending tasks");
|
|
76
|
+
}
|
|
77
|
+
if (next.deadline.isAfter(this.clock.now())) {
|
|
78
|
+
this.clock.set(next.deadline);
|
|
79
|
+
}
|
|
80
|
+
await this.execute(next);
|
|
81
|
+
}
|
|
82
|
+
/** Run tasks (advancing time as needed) until none remain. */
|
|
83
|
+
async runUntilIdle(options) {
|
|
84
|
+
const maxTasks = options?.maxTasks ?? 1e4;
|
|
85
|
+
let executed = 0;
|
|
86
|
+
while (this.live().length > 0) {
|
|
87
|
+
if (executed >= maxTasks) {
|
|
88
|
+
const labels = this.pending().slice(0, 5).map((t) => t.label).join(", ");
|
|
89
|
+
throw new Error(
|
|
90
|
+
`ManualScheduler.runUntilIdle: still not idle after ${maxTasks} tasks \u2014 probable infinite reschedule loop (next: ${labels})`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
await this.runNext();
|
|
94
|
+
executed += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Teardown assertion: fails when pending tasks remain, unless allowed. */
|
|
98
|
+
assertNoPendingTasks(options) {
|
|
99
|
+
if (options?.allow === true) return;
|
|
100
|
+
const remaining = this.pending();
|
|
101
|
+
if (remaining.length > 0) {
|
|
102
|
+
const labels = remaining.map((t) => `${t.label} @ ${t.deadline.toString()}`).join("; ");
|
|
103
|
+
throw new Error(`ManualScheduler: ${remaining.length} pending task(s) at teardown: ${labels}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
live() {
|
|
107
|
+
return this.tasks.filter((t) => !t.cancelled);
|
|
108
|
+
}
|
|
109
|
+
nextDue(target) {
|
|
110
|
+
const candidates = this.live().filter((t) => !t.deadline.isAfter(target)).sort(compareTasks);
|
|
111
|
+
return candidates[0] ?? null;
|
|
112
|
+
}
|
|
113
|
+
async execute(task) {
|
|
114
|
+
this.remove(task);
|
|
115
|
+
await task.task();
|
|
116
|
+
}
|
|
117
|
+
remove(task) {
|
|
118
|
+
const index = this.tasks.indexOf(task);
|
|
119
|
+
this.tasks.splice(index, 1);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
function compareTasks(a, b) {
|
|
123
|
+
const byDeadline = a.deadline.compareTo(b.deadline);
|
|
124
|
+
return byDeadline !== 0 ? byDeadline : a.sequence - b.sequence;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/identity/sequence_id_generator.ts
|
|
128
|
+
import { IdGenerator, brandId as brandId2 } from "@noego/runtime";
|
|
129
|
+
var SequenceIdGenerator = class extends IdGenerator {
|
|
130
|
+
counters = /* @__PURE__ */ new Map();
|
|
131
|
+
next(brand) {
|
|
132
|
+
const current = (this.counters.get(brand) ?? 0) + 1;
|
|
133
|
+
this.counters.set(brand, current);
|
|
134
|
+
return brandId2(`${brand.toLowerCase()}-${current}`, brand);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// src/identity/seeded_random_source.ts
|
|
139
|
+
import { RandomSource } from "@noego/runtime";
|
|
140
|
+
var SeededRandomSource = class extends RandomSource {
|
|
141
|
+
state;
|
|
142
|
+
constructor(seed = 1) {
|
|
143
|
+
super();
|
|
144
|
+
if (!Number.isInteger(seed)) {
|
|
145
|
+
throw new RangeError(`SeededRandomSource seed must be an integer, got ${seed}`);
|
|
146
|
+
}
|
|
147
|
+
this.state = seed >>> 0;
|
|
148
|
+
}
|
|
149
|
+
nextUint32() {
|
|
150
|
+
this.state = this.state + 1831565813 >>> 0;
|
|
151
|
+
let t = this.state;
|
|
152
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
153
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
154
|
+
return (t ^ t >>> 14) >>> 0;
|
|
155
|
+
}
|
|
156
|
+
bytes(length) {
|
|
157
|
+
if (!Number.isInteger(length) || length < 0) {
|
|
158
|
+
throw new RangeError(`bytes length must be a non-negative integer, got ${length}`);
|
|
159
|
+
}
|
|
160
|
+
const out = new Uint8Array(length);
|
|
161
|
+
for (let i = 0; i < length; i += 1) {
|
|
162
|
+
out[i] = this.nextUint32() & 255;
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
integer(minInclusive, maxExclusive) {
|
|
167
|
+
if (maxExclusive <= minInclusive) {
|
|
168
|
+
throw new RangeError(`integer range is empty: [${minInclusive}, ${maxExclusive})`);
|
|
169
|
+
}
|
|
170
|
+
const span = maxExclusive - minInclusive;
|
|
171
|
+
return minInclusive + this.nextUint32() % span;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// src/network/scripted_fetch_client.ts
|
|
176
|
+
import { FetchClient, FetchError } from "@noego/runtime";
|
|
177
|
+
var ScriptedFetchClient = class extends FetchClient {
|
|
178
|
+
script;
|
|
179
|
+
cursor = 0;
|
|
180
|
+
executed = [];
|
|
181
|
+
constructor(script) {
|
|
182
|
+
super();
|
|
183
|
+
this.script = [...script];
|
|
184
|
+
}
|
|
185
|
+
execute(request) {
|
|
186
|
+
const entry = this.script[this.cursor];
|
|
187
|
+
if (entry === void 0) {
|
|
188
|
+
return Promise.reject(
|
|
189
|
+
new Error(`ScriptedFetchClient: unexpected call #${this.cursor + 1} (${request.method} ${request.url}); script is exhausted`)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (!entry.matches(request)) {
|
|
193
|
+
return Promise.reject(
|
|
194
|
+
new Error(`ScriptedFetchClient: call #${this.cursor + 1} (${request.method} ${request.url}) does not match script entry "${entry.describe}"`)
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
this.cursor += 1;
|
|
198
|
+
this.executed.push(request);
|
|
199
|
+
const outcome = entry.respond(request);
|
|
200
|
+
return outcome instanceof FetchError ? Promise.reject(outcome) : Promise.resolve(outcome);
|
|
201
|
+
}
|
|
202
|
+
/** Teardown assertion: every scripted call must have been consumed. */
|
|
203
|
+
assertScriptConsumed() {
|
|
204
|
+
if (this.cursor < this.script.length) {
|
|
205
|
+
const remaining = this.script.slice(this.cursor).map((e) => e.describe).join(", ");
|
|
206
|
+
throw new Error(`ScriptedFetchClient: ${this.script.length - this.cursor} unconsumed script entr(ies): ${remaining}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// src/process/scripted_process_runner.ts
|
|
212
|
+
import { ProcessRunner } from "@noego/runtime";
|
|
213
|
+
var ScriptedProcessRunner = class extends ProcessRunner {
|
|
214
|
+
script;
|
|
215
|
+
cursor = 0;
|
|
216
|
+
openHandles = /* @__PURE__ */ new Set();
|
|
217
|
+
constructor(script) {
|
|
218
|
+
super();
|
|
219
|
+
this.script = [...script];
|
|
220
|
+
}
|
|
221
|
+
consume(command, kind) {
|
|
222
|
+
const entry = this.script[this.cursor];
|
|
223
|
+
if (entry === void 0) {
|
|
224
|
+
throw new Error(`ScriptedProcessRunner: unexpected ${kind} of "${command.executable}"; script is exhausted`);
|
|
225
|
+
}
|
|
226
|
+
if (!entry.matches(command)) {
|
|
227
|
+
throw new Error(`ScriptedProcessRunner: ${kind} of "${command.executable}" does not match script entry "${entry.describe}"`);
|
|
228
|
+
}
|
|
229
|
+
this.cursor += 1;
|
|
230
|
+
return entry;
|
|
231
|
+
}
|
|
232
|
+
run(command) {
|
|
233
|
+
try {
|
|
234
|
+
const entry = this.consume(command, "run");
|
|
235
|
+
return Promise.resolve(foldEvents(entry.events));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
return Promise.reject(error);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
spawn(command) {
|
|
241
|
+
const entry = this.consume(command, "spawn");
|
|
242
|
+
const handle = new ScriptedProcessHandle(command.label, entry.events, () => {
|
|
243
|
+
this.openHandles.delete(handle);
|
|
244
|
+
});
|
|
245
|
+
this.openHandles.add(handle);
|
|
246
|
+
return handle;
|
|
247
|
+
}
|
|
248
|
+
/** Teardown assertion: no spawned process may remain open. */
|
|
249
|
+
assertNoOpenHandles() {
|
|
250
|
+
if (this.openHandles.size > 0) {
|
|
251
|
+
const labels = [...this.openHandles].map((h) => h.label).join(", ");
|
|
252
|
+
throw new Error(`ScriptedProcessRunner: ${this.openHandles.size} open process handle(s) at teardown: ${labels}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
var ScriptedProcessHandle = class {
|
|
257
|
+
constructor(label, events, onClosed) {
|
|
258
|
+
this.label = label;
|
|
259
|
+
this.events = events;
|
|
260
|
+
this.onClosed = onClosed;
|
|
261
|
+
this.resultPromise = new Promise((resolve) => {
|
|
262
|
+
this.resolveResult = resolve;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
label;
|
|
266
|
+
events;
|
|
267
|
+
onClosed;
|
|
268
|
+
listeners = [];
|
|
269
|
+
resultPromise;
|
|
270
|
+
resolveResult;
|
|
271
|
+
flushed = false;
|
|
272
|
+
killed = false;
|
|
273
|
+
onEvent(listener) {
|
|
274
|
+
this.listeners.push(listener);
|
|
275
|
+
}
|
|
276
|
+
/** Deliver all scripted events now (deterministic, test-controlled). */
|
|
277
|
+
flush() {
|
|
278
|
+
if (this.flushed || this.killed) return;
|
|
279
|
+
this.flushed = true;
|
|
280
|
+
for (const event of this.events) {
|
|
281
|
+
for (const listener of this.listeners) listener(event);
|
|
282
|
+
if (event.kind === "exit") {
|
|
283
|
+
this.resolveResult(foldEvents(this.events));
|
|
284
|
+
this.onClosed();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
kill(signal = "SIGTERM") {
|
|
289
|
+
if (this.flushed || this.killed) return;
|
|
290
|
+
this.killed = true;
|
|
291
|
+
const exit = { kind: "exit", exitCode: null, signal };
|
|
292
|
+
for (const listener of this.listeners) listener(exit);
|
|
293
|
+
this.resolveResult({ exitCode: null, signal, stdout: "", stderr: "" });
|
|
294
|
+
this.onClosed();
|
|
295
|
+
}
|
|
296
|
+
wait() {
|
|
297
|
+
return this.resultPromise;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
function foldEvents(events) {
|
|
301
|
+
let stdout = "";
|
|
302
|
+
let stderr = "";
|
|
303
|
+
let exitCode = null;
|
|
304
|
+
let signal = null;
|
|
305
|
+
for (const event of events) {
|
|
306
|
+
if (event.kind === "stdout") stdout += event.chunk;
|
|
307
|
+
else if (event.kind === "stderr") stderr += event.chunk;
|
|
308
|
+
else {
|
|
309
|
+
exitCode = event.exitCode;
|
|
310
|
+
signal = event.signal;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { exitCode, signal, stdout, stderr };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/storage/memory_key_value_store.ts
|
|
317
|
+
import { KeyValueStore } from "@noego/runtime";
|
|
318
|
+
var MemoryKeyValueStore = class extends KeyValueStore {
|
|
319
|
+
constructor(clock) {
|
|
320
|
+
super();
|
|
321
|
+
this.clock = clock;
|
|
322
|
+
}
|
|
323
|
+
clock;
|
|
324
|
+
entries = /* @__PURE__ */ new Map();
|
|
325
|
+
get(key, codec) {
|
|
326
|
+
const entry = this.entries.get(key);
|
|
327
|
+
if (entry === void 0) return Promise.resolve(null);
|
|
328
|
+
if (entry.expiresAt !== null && !this.clock.now().isBefore(entry.expiresAt)) {
|
|
329
|
+
this.entries.delete(key);
|
|
330
|
+
return Promise.resolve(null);
|
|
331
|
+
}
|
|
332
|
+
return Promise.resolve(codec.decode(entry.encoded));
|
|
333
|
+
}
|
|
334
|
+
put(key, value, codec, options) {
|
|
335
|
+
const expiresAt = options?.timeToLive !== void 0 ? this.clock.now().plus(options.timeToLive) : null;
|
|
336
|
+
this.entries.set(key, { encoded: codec.encode(value), expiresAt });
|
|
337
|
+
return Promise.resolve();
|
|
338
|
+
}
|
|
339
|
+
delete(key) {
|
|
340
|
+
this.entries.delete(key);
|
|
341
|
+
return Promise.resolve();
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// src/storage/memory_object_store.ts
|
|
346
|
+
import { ObjectStore, brandId as brandId3 } from "@noego/runtime";
|
|
347
|
+
var MemoryObjectStore = class extends ObjectStore {
|
|
348
|
+
objects = /* @__PURE__ */ new Map();
|
|
349
|
+
nextVersion = 0;
|
|
350
|
+
read(key) {
|
|
351
|
+
return Promise.resolve(this.objects.get(key) ?? null);
|
|
352
|
+
}
|
|
353
|
+
write(key, object) {
|
|
354
|
+
const version = brandId3(`v${++this.nextVersion}`, "ObjectVersion");
|
|
355
|
+
this.objects.set(key, {
|
|
356
|
+
bytes: object.bytes.slice(),
|
|
357
|
+
contentType: object.contentType,
|
|
358
|
+
metadata: object.metadata !== void 0 ? new Map(object.metadata) : void 0,
|
|
359
|
+
version
|
|
360
|
+
});
|
|
361
|
+
return Promise.resolve(version);
|
|
362
|
+
}
|
|
363
|
+
delete(key) {
|
|
364
|
+
this.objects.delete(key);
|
|
365
|
+
return Promise.resolve();
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// src/events/recording_event_bus.ts
|
|
370
|
+
import { EventBus } from "@noego/runtime";
|
|
371
|
+
var RecordingEventBus = class extends EventBus {
|
|
372
|
+
constructor(mode = "immediate") {
|
|
373
|
+
super();
|
|
374
|
+
this.mode = mode;
|
|
375
|
+
}
|
|
376
|
+
mode;
|
|
377
|
+
published = [];
|
|
378
|
+
handlers = /* @__PURE__ */ new Set();
|
|
379
|
+
queue = [];
|
|
380
|
+
async publish(event) {
|
|
381
|
+
this.published.push(event);
|
|
382
|
+
if (this.mode === "queued") {
|
|
383
|
+
this.queue.push(event);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
await this.deliver(event);
|
|
387
|
+
}
|
|
388
|
+
subscribe(handler) {
|
|
389
|
+
this.handlers.add(handler);
|
|
390
|
+
const bus = this;
|
|
391
|
+
const subscription = {
|
|
392
|
+
active: true,
|
|
393
|
+
unsubscribe() {
|
|
394
|
+
if (!subscription.active) return;
|
|
395
|
+
subscription.active = false;
|
|
396
|
+
bus.handlers.delete(handler);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
return subscription;
|
|
400
|
+
}
|
|
401
|
+
/** Deliver buffered events (queued mode) in publication order. */
|
|
402
|
+
async deliverQueued() {
|
|
403
|
+
while (this.queue.length > 0) {
|
|
404
|
+
const event = this.queue.shift();
|
|
405
|
+
await this.deliver(event);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
get activeSubscriptionCount() {
|
|
409
|
+
return this.handlers.size;
|
|
410
|
+
}
|
|
411
|
+
assertNoActiveSubscriptions() {
|
|
412
|
+
if (this.handlers.size > 0) {
|
|
413
|
+
throw new Error(`RecordingEventBus: ${this.handlers.size} active subscription(s) at teardown`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async deliver(event) {
|
|
417
|
+
for (const handler of [...this.handlers]) {
|
|
418
|
+
await handler(event);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/observability/recording_sinks.ts
|
|
424
|
+
import { LogSink, TelemetrySink, TraceSink } from "@noego/runtime";
|
|
425
|
+
var RecordingLogSink = class extends LogSink {
|
|
426
|
+
envelopes = [];
|
|
427
|
+
emit(envelope) {
|
|
428
|
+
this.envelopes.push(envelope);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
var RecordingTraceSink = class extends TraceSink {
|
|
432
|
+
envelopes = [];
|
|
433
|
+
emit(envelope) {
|
|
434
|
+
this.envelopes.push(envelope);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
var RecordingTelemetrySink = class extends TelemetrySink {
|
|
438
|
+
envelopes = [];
|
|
439
|
+
emit(envelope) {
|
|
440
|
+
this.envelopes.push(envelope);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
var NoopLogSink = class extends LogSink {
|
|
444
|
+
emit(_envelope) {
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// src/contracts/clock.contract.ts
|
|
449
|
+
function runClockContract(name, setup) {
|
|
450
|
+
describe(`Clock contract: ${name}`, () => {
|
|
451
|
+
it("returns a monotonically non-decreasing instant across consecutive reads", () => {
|
|
452
|
+
const { clock } = setup();
|
|
453
|
+
const first = clock.now();
|
|
454
|
+
const second = clock.now();
|
|
455
|
+
expect(second.isBefore(first)).toBe(false);
|
|
456
|
+
});
|
|
457
|
+
it("nowMs agrees with now()", () => {
|
|
458
|
+
const { clock } = setup();
|
|
459
|
+
const instant = clock.now();
|
|
460
|
+
const ms = clock.nowMs();
|
|
461
|
+
expect(ms).toBeGreaterThanOrEqual(instant.epochMilliseconds);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// src/contracts/key_value_store.contract.ts
|
|
467
|
+
import { Duration as Duration3, jsonCodec, storageKey } from "@noego/runtime";
|
|
468
|
+
function runKeyValueStoreContract(name, setup) {
|
|
469
|
+
const codec = jsonCodec();
|
|
470
|
+
describe(`KeyValueStore contract: ${name}`, () => {
|
|
471
|
+
it("returns null for a missing key", async () => {
|
|
472
|
+
const { store } = setup();
|
|
473
|
+
await expect(store.get(storageKey("missing"), codec)).resolves.toBeNull();
|
|
474
|
+
});
|
|
475
|
+
it("round-trips a stored value", async () => {
|
|
476
|
+
const { store } = setup();
|
|
477
|
+
const key = storageKey("k1");
|
|
478
|
+
await store.put(key, { n: 42 }, codec);
|
|
479
|
+
await expect(store.get(key, codec)).resolves.toEqual({ n: 42 });
|
|
480
|
+
});
|
|
481
|
+
it("overwrites an existing value", async () => {
|
|
482
|
+
const { store } = setup();
|
|
483
|
+
const key = storageKey("k1");
|
|
484
|
+
await store.put(key, { n: 1 }, codec);
|
|
485
|
+
await store.put(key, { n: 2 }, codec);
|
|
486
|
+
await expect(store.get(key, codec)).resolves.toEqual({ n: 2 });
|
|
487
|
+
});
|
|
488
|
+
it("deletes a value and tolerates deleting a missing key", async () => {
|
|
489
|
+
const { store } = setup();
|
|
490
|
+
const key = storageKey("k1");
|
|
491
|
+
await store.put(key, { n: 1 }, codec);
|
|
492
|
+
await store.delete(key);
|
|
493
|
+
await expect(store.get(key, codec)).resolves.toBeNull();
|
|
494
|
+
await expect(store.delete(storageKey("missing"))).resolves.toBeUndefined();
|
|
495
|
+
});
|
|
496
|
+
it("expires values after their TTL when TTL is supported", async () => {
|
|
497
|
+
const context = setup();
|
|
498
|
+
if (!context.supportsTtl) return;
|
|
499
|
+
const key = storageKey("expiring");
|
|
500
|
+
await context.store.put(key, { n: 7 }, codec, { timeToLive: Duration3.ofSeconds(30) });
|
|
501
|
+
await expect(context.store.get(key, codec)).resolves.toEqual({ n: 7 });
|
|
502
|
+
await context.advanceBy(Duration3.ofSeconds(31));
|
|
503
|
+
await expect(context.store.get(key, codec)).resolves.toBeNull();
|
|
504
|
+
});
|
|
505
|
+
it("keeps values without a TTL alive as time passes", async () => {
|
|
506
|
+
const context = setup();
|
|
507
|
+
const key = storageKey("durable");
|
|
508
|
+
await context.store.put(key, { n: 9 }, codec);
|
|
509
|
+
await context.advanceBy(Duration3.ofMinutes(60));
|
|
510
|
+
await expect(context.store.get(key, codec)).resolves.toEqual({ n: 9 });
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/contracts/object_store.contract.ts
|
|
516
|
+
import { objectKey } from "@noego/runtime";
|
|
517
|
+
function runObjectStoreContract(name, setup) {
|
|
518
|
+
const bytes = (...values) => new Uint8Array(values);
|
|
519
|
+
describe(`ObjectStore contract: ${name}`, () => {
|
|
520
|
+
it("returns null for a missing object", async () => {
|
|
521
|
+
const { store } = setup();
|
|
522
|
+
await expect(store.read(objectKey("missing"))).resolves.toBeNull();
|
|
523
|
+
});
|
|
524
|
+
it("round-trips bytes, content type, and metadata", async () => {
|
|
525
|
+
const { store } = setup();
|
|
526
|
+
const key = objectKey("docs/a.bin");
|
|
527
|
+
await store.write(key, {
|
|
528
|
+
bytes: bytes(1, 2, 3),
|
|
529
|
+
contentType: "application/octet-stream",
|
|
530
|
+
metadata: /* @__PURE__ */ new Map([["owner", "alice"]])
|
|
531
|
+
});
|
|
532
|
+
const stored = await store.read(key);
|
|
533
|
+
expect(stored).not.toBeNull();
|
|
534
|
+
expect([...stored.bytes]).toEqual([1, 2, 3]);
|
|
535
|
+
expect(stored.contentType).toBe("application/octet-stream");
|
|
536
|
+
expect(stored.metadata?.get("owner")).toBe("alice");
|
|
537
|
+
});
|
|
538
|
+
it("issues a new version on every write", async () => {
|
|
539
|
+
const { store } = setup();
|
|
540
|
+
const key = objectKey("docs/a.bin");
|
|
541
|
+
const v1 = await store.write(key, { bytes: bytes(1), contentType: "text/plain" });
|
|
542
|
+
const v2 = await store.write(key, { bytes: bytes(2), contentType: "text/plain" });
|
|
543
|
+
expect(v1).not.toBe(v2);
|
|
544
|
+
const stored = await store.read(key);
|
|
545
|
+
expect(stored.version).toBe(v2);
|
|
546
|
+
expect([...stored.bytes]).toEqual([2]);
|
|
547
|
+
});
|
|
548
|
+
it("deletes an object and tolerates deleting a missing key", async () => {
|
|
549
|
+
const { store } = setup();
|
|
550
|
+
const key = objectKey("docs/a.bin");
|
|
551
|
+
await store.write(key, { bytes: bytes(1), contentType: "text/plain" });
|
|
552
|
+
await store.delete(key);
|
|
553
|
+
await expect(store.read(key)).resolves.toBeNull();
|
|
554
|
+
await expect(store.delete(objectKey("missing"))).resolves.toBeUndefined();
|
|
555
|
+
});
|
|
556
|
+
it("does not expose caller mutations of written bytes", async () => {
|
|
557
|
+
const { store } = setup();
|
|
558
|
+
const key = objectKey("docs/mutable.bin");
|
|
559
|
+
const input = bytes(9);
|
|
560
|
+
await store.write(key, { bytes: input, contentType: "text/plain" });
|
|
561
|
+
input[0] = 0;
|
|
562
|
+
const stored = await store.read(key);
|
|
563
|
+
expect([...stored.bytes]).toEqual([9]);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/contracts/event_bus.contract.ts
|
|
569
|
+
var testEvent = (value) => ({ type: "test-event", value });
|
|
570
|
+
function runEventBusContract(name, setup) {
|
|
571
|
+
describe(`EventBus contract: ${name}`, () => {
|
|
572
|
+
it("delivers published events to subscribers in order", async () => {
|
|
573
|
+
const { bus, deliver } = setup();
|
|
574
|
+
const seen = [];
|
|
575
|
+
const subscription = bus.subscribe((event) => {
|
|
576
|
+
seen.push(event.value);
|
|
577
|
+
});
|
|
578
|
+
await bus.publish(testEvent(1));
|
|
579
|
+
await bus.publish(testEvent(2));
|
|
580
|
+
await deliver();
|
|
581
|
+
expect(seen).toEqual([1, 2]);
|
|
582
|
+
subscription.unsubscribe();
|
|
583
|
+
});
|
|
584
|
+
it("stops delivering after unsubscribe", async () => {
|
|
585
|
+
const { bus, deliver } = setup();
|
|
586
|
+
const seen = [];
|
|
587
|
+
const subscription = bus.subscribe((event) => {
|
|
588
|
+
seen.push(event.value);
|
|
589
|
+
});
|
|
590
|
+
await bus.publish(testEvent(1));
|
|
591
|
+
await deliver();
|
|
592
|
+
subscription.unsubscribe();
|
|
593
|
+
await bus.publish(testEvent(2));
|
|
594
|
+
await deliver();
|
|
595
|
+
expect(seen).toEqual([1]);
|
|
596
|
+
});
|
|
597
|
+
it("supports multiple subscribers", async () => {
|
|
598
|
+
const { bus, deliver } = setup();
|
|
599
|
+
const a = [];
|
|
600
|
+
const b = [];
|
|
601
|
+
const sa = bus.subscribe((event) => {
|
|
602
|
+
a.push(event.value);
|
|
603
|
+
});
|
|
604
|
+
const sb = bus.subscribe((event) => {
|
|
605
|
+
b.push(event.value);
|
|
606
|
+
});
|
|
607
|
+
await bus.publish(testEvent(5));
|
|
608
|
+
await deliver();
|
|
609
|
+
expect(a).toEqual([5]);
|
|
610
|
+
expect(b).toEqual([5]);
|
|
611
|
+
sa.unsubscribe();
|
|
612
|
+
sb.unsubscribe();
|
|
613
|
+
});
|
|
614
|
+
it("marks subscriptions inactive after unsubscribe and tolerates double unsubscribe", async () => {
|
|
615
|
+
const { bus, deliver } = setup();
|
|
616
|
+
const seen = [];
|
|
617
|
+
const subscription = bus.subscribe((event) => {
|
|
618
|
+
seen.push(event.value);
|
|
619
|
+
});
|
|
620
|
+
await bus.publish(testEvent(1));
|
|
621
|
+
await deliver();
|
|
622
|
+
expect(seen).toEqual([1]);
|
|
623
|
+
expect(subscription.active).toBe(true);
|
|
624
|
+
subscription.unsubscribe();
|
|
625
|
+
expect(subscription.active).toBe(false);
|
|
626
|
+
subscription.unsubscribe();
|
|
627
|
+
expect(subscription.active).toBe(false);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/leak/leak_detector.ts
|
|
633
|
+
var LeakDetector = class {
|
|
634
|
+
checks = [];
|
|
635
|
+
register(check) {
|
|
636
|
+
this.checks.push(check);
|
|
637
|
+
}
|
|
638
|
+
findLeaks() {
|
|
639
|
+
const leaks = [];
|
|
640
|
+
for (const { name, check } of this.checks) {
|
|
641
|
+
for (const description of check()) {
|
|
642
|
+
leaks.push(`[${name}] ${description}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return leaks;
|
|
646
|
+
}
|
|
647
|
+
assertNoLeaks() {
|
|
648
|
+
const leaks = this.findLeaks();
|
|
649
|
+
if (leaks.length > 0) {
|
|
650
|
+
throw new Error(`LeakDetector: ${leaks.length} leaked resource(s):
|
|
651
|
+
${leaks.join("\n")}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
export {
|
|
656
|
+
LeakDetector,
|
|
657
|
+
ManualClock,
|
|
658
|
+
ManualScheduler,
|
|
659
|
+
MemoryKeyValueStore,
|
|
660
|
+
MemoryObjectStore,
|
|
661
|
+
NoopLogSink,
|
|
662
|
+
RecordingEventBus,
|
|
663
|
+
RecordingLogSink,
|
|
664
|
+
RecordingTelemetrySink,
|
|
665
|
+
RecordingTraceSink,
|
|
666
|
+
ScriptedFetchClient,
|
|
667
|
+
ScriptedProcessRunner,
|
|
668
|
+
SeededRandomSource,
|
|
669
|
+
SequenceIdGenerator,
|
|
670
|
+
runClockContract,
|
|
671
|
+
runEventBusContract,
|
|
672
|
+
runKeyValueStoreContract,
|
|
673
|
+
runObjectStoreContract,
|
|
674
|
+
testEvent
|
|
675
|
+
};
|
|
676
|
+
//# sourceMappingURL=index.js.map
|