@mark1russell7/service 0.4.0 → 0.5.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.
@@ -1,5 +1,6 @@
1
1
  import { n as Clock } from "./clock-D-38uH_z.js";
2
- import { C as ProcessPort, M as Outcome, S as ExitSignal, j as CheckFn, n as ServiceBuilder } from "./builder-DnfUDW9u.js";
2
+ import { D as Outcome, E as CheckFn, at as ExitSignal, d as TransportImpl, ot as ExitTrigger, r as Lifecycle, ut as ProcessPort } from "./lifecycle-DIj4YGmO.js";
3
+ import { n as ServiceBuilder } from "./builder-B_q-JeW8.js";
3
4
  //#region src/fakes.d.ts
4
5
  /** A fake clock moves only when a test tells it to. */
5
6
  interface FakeClock extends Clock {
@@ -15,17 +16,32 @@ interface FakeClock extends Clock {
15
16
  }
16
17
  /** This function makes a fake clock. Its time starts at `start` milliseconds. */
17
18
  export declare const fakeClock: (start?: number) => FakeClock;
18
- /** A fake process receives signals from a test, and keeps each exit code in place of an exit. */
19
+ /** A fake process receives triggers from a test, and keeps each exit code in place of an exit. */
19
20
  interface FakeProcess extends ProcessPort {
20
21
  /** This method sends a signal to each handler of it. */
21
22
  signal(name: ExitSignal): void;
23
+ /** This method sends a trigger to each handler of it: a signal, or a process trigger, for example `stdin-close`. */
24
+ trigger(name: ExitTrigger): void;
22
25
  /** These are the exit codes, in sequence. A correct runtime exits one time. */
23
26
  readonly exits: readonly number[];
24
27
  /** This count is the number of handlers that are registered. */
25
28
  readonly handlers: number;
29
+ /** These are the triggers that have a handler, in the sequence of their first handler. */
30
+ readonly triggers: readonly ExitTrigger[];
31
+ /** These are the times of the clock of the options at each flush and each exit, when the options give a clock. */
32
+ readonly log: readonly string[];
33
+ /** This count is the number of flushes of the output. */
34
+ readonly flushes: number;
35
+ }
36
+ /** These options shape a fake process. */
37
+ interface FakeProcessOptions {
38
+ /** This function is the flush of the output, for example a wait on a fake clock. Without it, a flush ends at once. */
39
+ readonly flush?: (() => Promise<void> | void) | undefined;
40
+ /** This clock stamps each line of the log. */
41
+ readonly clock?: Clock | undefined;
26
42
  }
27
43
  /** This function makes a fake process. */
28
- export declare const fakeProcess: () => FakeProcess;
44
+ export declare const fakeProcess: (options?: FakeProcessOptions) => FakeProcess;
29
45
  /** This function gives a seeded random number generator (mulberry32), with numbers in [0, 1). */
30
46
  export declare const seeded: (seed: number) => (() => number);
31
47
  //#endregion
@@ -58,6 +74,78 @@ interface ContractOptions {
58
74
  */
59
75
  export declare const contractCases: (declare: () => ServiceBuilder, options?: ContractOptions) => ContractCase[];
60
76
  //#endregion
77
+ //#region src/conformance.d.ts
78
+ /** A held work is one unit of work that stays in flight until the test releases it. */
79
+ interface HeldWork {
80
+ /** This method lets the work end. Its promise ends when the caller of the work has its answer, or its error. */
81
+ release(): Promise<void>;
82
+ /** This flag tells if the work got the cooperative abort of the drain. */
83
+ readonly aborted: boolean;
84
+ }
85
+ /**
86
+ * A transport subject is a transport and the client side of its work. The kit offers work through
87
+ * it, holds work in flight through it, and reads the count of the transport.
88
+ */
89
+ interface TransportSubject {
90
+ readonly impl: TransportImpl;
91
+ /** This lifecycle goes to `start()`. Without it, the kit gives a detached lifecycle. */
92
+ readonly life?: Lifecycle | undefined;
93
+ /** This method offers one unit of new work. It gives true when the transport took it, and false when it refused it. */
94
+ offer(): Promise<boolean>;
95
+ /** This method starts one unit of work that stays in flight until its release. The promise ends when the transport has it. */
96
+ hold(): Promise<HeldWork>;
97
+ /** This method frees what the subject holds outside the transport: its clients and their sockets. */
98
+ dispose?(): Promise<void> | void;
99
+ }
100
+ /**
101
+ * This function gives a detached lifecycle: a lifecycle with no service, for the start of a
102
+ * transport in a test. Its state is `running`, it keeps each connection, and nothing is in flight. Each request
103
+ * and each use of its health throws, because no service declares a route.
104
+ */
105
+ export declare const detachedLifecycle: () => Lifecycle;
106
+ /** A subject factory makes a new subject for each case, on the clock of the case. */
107
+ type SubjectFactory = (clock: Clock) => TransportSubject | Promise<TransportSubject>;
108
+ /** A conformance record is one measurement of a case, for the raw records of a run in CI. */
109
+ interface ConformanceRecord {
110
+ readonly case: string;
111
+ readonly what: string;
112
+ /** This time is the limit of the contract, in milliseconds. */
113
+ readonly expected: number;
114
+ /** This time is what the case measured, in milliseconds of the real clock. */
115
+ readonly measured: number;
116
+ }
117
+ /** These options tune the conformance kit. */
118
+ interface ConformanceOptions {
119
+ /** This name goes before the name of each case. */
120
+ readonly name?: string | undefined;
121
+ /** This clock moves the cases: `fake` gives exact and fast cases, and `real` measures the timing. The default is `fake`. */
122
+ readonly clock?: "fake" | "real" | undefined;
123
+ /** This time is the cap of each drain of the cases, in milliseconds. The default is 1000. */
124
+ readonly cap?: number | undefined;
125
+ /** This time is the most that a close can take, in milliseconds of the real clock. The default is 1000. */
126
+ readonly closeBound?: number | undefined;
127
+ /** This time is the most that an event can take to reach the transport, in real milliseconds. The default is 100. */
128
+ readonly slack?: number | undefined;
129
+ /** False tells that the work of the transport cannot see the cooperative abort. The default is true. */
130
+ readonly cooperative?: boolean | undefined;
131
+ /** This count is the number of works that the count case holds. The default is 3. */
132
+ readonly holds?: number | undefined;
133
+ /** This function receives each measurement, for the raw records of a run. */
134
+ readonly record?: ((record: ConformanceRecord) => void) | undefined;
135
+ }
136
+ /**
137
+ * This function generates the conformance cases of a transport (S6 A9). `make` gives a new subject
138
+ * for each case. Each case throws on a failure, and its message names the part of the contract.
139
+ * - The start: nothing is in flight, and the transport takes work.
140
+ * - The count: it follows each work from its events, with no move of the clock.
141
+ * - `stopIntake` refuses new work, keeps the work in flight, and a second stop has no effect.
142
+ * - `drain` ends at once with no work, ends with the last work, and holds to its deadline and not
143
+ * past it. It gives the cooperative abort to the work, and it does not throw.
144
+ * - `close` ends the work in flight in its bound, and a second close has no effect.
145
+ * - The stages in their sequence, as the lifecycle does them.
146
+ */
147
+ export declare const transportConformance: (make: SubjectFactory, options?: ConformanceOptions) => ContractCase[];
148
+ //#endregion
61
149
  //#region src/testing.d.ts
62
150
  /**
63
151
  * A behavior is what a scripted check does when the runtime starts it:
@@ -99,5 +187,5 @@ export interface QuietServer {
99
187
  */
100
188
  export declare const quietServer: () => Promise<QuietServer>;
101
189
  //#endregion
102
- export type { ContractCase, ContractOptions, FakeClock, FakeProcess };
190
+ export type { ConformanceOptions, ConformanceRecord, ContractCase, ContractOptions, FakeClock, FakeProcess, FakeProcessOptions, HeldWork, SubjectFactory, TransportSubject };
103
191
  //# sourceMappingURL=testing.d.ts.map
package/build/testing.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { realClock } from "./clock.js";
1
2
  import { a as recoverSpan, r as failSpan } from "./timing-DGRi3XCI.js";
2
3
  import { valueIn } from "@mark1russell7/quantity";
3
4
  import { createServer } from "node:net";
@@ -54,24 +55,38 @@ const fakeClock = (start = 0) => {
54
55
  };
55
56
  };
56
57
  /** This function makes a fake process. */
57
- const fakeProcess = () => {
58
+ const fakeProcess = (options = {}) => {
58
59
  const handlers = /* @__PURE__ */ new Map();
59
60
  const exits = [];
61
+ const log = [];
62
+ let flushes = 0;
63
+ const stamp = (what) => {
64
+ log.push(options.clock === void 0 ? what : `${options.clock.now()} ${what}`);
65
+ };
66
+ const send = (name) => {
67
+ for (const handler of [...handlers.get(name) ?? []]) handler();
68
+ };
60
69
  return {
61
- on(signal, handler) {
62
- const set = handlers.get(signal) ?? /* @__PURE__ */ new Set();
70
+ on(trigger, handler) {
71
+ const set = handlers.get(trigger) ?? /* @__PURE__ */ new Set();
63
72
  set.add(handler);
64
- handlers.set(signal, set);
73
+ handlers.set(trigger, set);
65
74
  return () => {
66
75
  set.delete(handler);
67
76
  };
68
77
  },
78
+ async flush() {
79
+ stamp("flush");
80
+ await options.flush?.();
81
+ flushes += 1;
82
+ stamp("flushed");
83
+ },
69
84
  exit(code) {
85
+ stamp(`exit ${code}`);
70
86
  exits.push(code);
71
87
  },
72
- signal(name) {
73
- for (const handler of [...handlers.get(name) ?? []]) handler();
74
- },
88
+ signal: send,
89
+ trigger: send,
75
90
  get exits() {
76
91
  return [...exits];
77
92
  },
@@ -79,6 +94,15 @@ const fakeProcess = () => {
79
94
  let count = 0;
80
95
  for (const set of handlers.values()) count += set.size;
81
96
  return count;
97
+ },
98
+ get triggers() {
99
+ return [...handlers.keys()];
100
+ },
101
+ get log() {
102
+ return [...log];
103
+ },
104
+ get flushes() {
105
+ return flushes;
82
106
  }
83
107
  };
84
108
  };
@@ -273,6 +297,276 @@ const contractCases = (declare, options = {}) => {
273
297
  return cases;
274
298
  };
275
299
  //#endregion
300
+ //#region src/conformance.ts
301
+ /**
302
+ * This function gives a detached lifecycle: a lifecycle with no service, for the start of a
303
+ * transport in a test. Its state is `running`, it keeps each connection, and nothing is in flight. Each request
304
+ * and each use of its health throws, because no service declares a route.
305
+ */
306
+ const detachedLifecycle = () => {
307
+ const none = () => {
308
+ throw new Error("a detached lifecycle has no service: give the transport the lifecycle of a service");
309
+ };
310
+ return {
311
+ health: {
312
+ verdict: none,
313
+ probe: none,
314
+ subscribe: none,
315
+ forceFail: none,
316
+ drain: none,
317
+ stop: none
318
+ },
319
+ state: "running",
320
+ exitCode: null,
321
+ errors: [],
322
+ started: Promise.resolve(),
323
+ done: new Promise(() => {}),
324
+ signal: new AbortController().signal,
325
+ shutdown: none,
326
+ request: none,
327
+ inFlight: () => 0,
328
+ keepAlive: () => true
329
+ };
330
+ };
331
+ const realNow = () => performance.now();
332
+ const turn = () => new Promise((resolve) => setTimeout(resolve, 0));
333
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
334
+ /** This function waits on the real clock, until the test is true or the bound ends. It gives the test at the end. */
335
+ const within = async (bound, test) => {
336
+ const end = realNow() + bound;
337
+ for (;;) {
338
+ if (test()) return true;
339
+ if (realNow() > end) return test();
340
+ await turn();
341
+ }
342
+ };
343
+ /** This function lets the events that are ready reach the transport, with no move of the clock of the case. */
344
+ const quiet = async () => {
345
+ for (let at = 0; at < 8; at += 1) await turn();
346
+ };
347
+ /** This function uses a method of the transport, and gives a throw of the method as a rejection. */
348
+ const attempt = (call) => {
349
+ try {
350
+ return Promise.resolve(call());
351
+ } catch (error) {
352
+ return Promise.reject(error);
353
+ }
354
+ };
355
+ const track = (promise) => {
356
+ const state = {
357
+ settled: false,
358
+ error: void 0,
359
+ failed: false
360
+ };
361
+ promise.then(() => {
362
+ state.settled = true;
363
+ }, (error) => {
364
+ state.settled = true;
365
+ state.failed = true;
366
+ state.error = error;
367
+ });
368
+ return state;
369
+ };
370
+ /**
371
+ * This function generates the conformance cases of a transport (S6 A9). `make` gives a new subject
372
+ * for each case. Each case throws on a failure, and its message names the part of the contract.
373
+ * - The start: nothing is in flight, and the transport takes work.
374
+ * - The count: it follows each work from its events, with no move of the clock.
375
+ * - `stopIntake` refuses new work, keeps the work in flight, and a second stop has no effect.
376
+ * - `drain` ends at once with no work, ends with the last work, and holds to its deadline and not
377
+ * past it. It gives the cooperative abort to the work, and it does not throw.
378
+ * - `close` ends the work in flight in its bound, and a second close has no effect.
379
+ * - The stages in their sequence, as the lifecycle does them.
380
+ */
381
+ const transportConformance = (make, options = {}) => {
382
+ const name = options.name ?? "transport";
383
+ const fake = (options.clock ?? "fake") === "fake";
384
+ const cap = options.cap ?? 1e3;
385
+ const closeBound = options.closeBound ?? 1e3;
386
+ const slack = options.slack ?? 100;
387
+ const cooperative = options.cooperative ?? true;
388
+ const holds = options.holds ?? 3;
389
+ const timeOf = () => {
390
+ if (!fake) return {
391
+ clock: realClock,
392
+ pass: sleep
393
+ };
394
+ const clock = fakeClock();
395
+ return {
396
+ clock,
397
+ pass: (ms) => clock.advance(ms)
398
+ };
399
+ };
400
+ const check = (ok, what) => {
401
+ if (!ok) throw new Error(`${name}: ${what}`);
402
+ };
403
+ const each = (title, body) => ({
404
+ name: `${name}: ${title}`,
405
+ async run() {
406
+ const time = timeOf();
407
+ const subject = await make(time.clock);
408
+ try {
409
+ await subject.impl.start(new AbortController().signal, subject.life ?? detachedLifecycle());
410
+ await body(subject, time, title);
411
+ } finally {
412
+ try {
413
+ await subject.impl.close();
414
+ } finally {
415
+ await subject.dispose?.();
416
+ }
417
+ }
418
+ }
419
+ });
420
+ const count = (subject, expected, what) => within(slack, () => subject.impl.inFlight() === expected).then((ok) => check(ok, `${what}: inFlight() gives ${subject.impl.inFlight()}, not ${expected}`));
421
+ const closes = async (subject, title) => {
422
+ const began = realNow();
423
+ const closed = track(attempt(() => subject.impl.close()));
424
+ const inBound = await within(closeBound, () => closed.settled);
425
+ const took = realNow() - began;
426
+ options.record?.({
427
+ case: title,
428
+ what: "close",
429
+ expected: closeBound,
430
+ measured: took
431
+ });
432
+ check(inBound, `close did not end in its bound of ${closeBound} ms`);
433
+ check(!closed.failed, `close threw: ${String(closed.error)}`);
434
+ };
435
+ return [
436
+ each("nothing is in flight after the start, and it takes new work", async (subject) => {
437
+ check(subject.impl.inFlight() === 0, `inFlight() gives ${subject.impl.inFlight()} after the start, not 0`);
438
+ check(await subject.offer(), "the transport refused new work after its start");
439
+ await count(subject, 0, "the work ended");
440
+ }),
441
+ each("the count follows each work in flight from its events, with no move of the clock", async (subject) => {
442
+ const held = [];
443
+ for (let at = 1; at <= holds; at += 1) {
444
+ held.push(await subject.hold());
445
+ await count(subject, at, `${at} works are in flight`);
446
+ }
447
+ for (let at = holds - 1; at >= 0; at -= 1) {
448
+ await held[at].release();
449
+ await count(subject, at, `${at} works are in flight after a release`);
450
+ }
451
+ }),
452
+ each("stopIntake refuses new work, keeps the work in flight, and a second stop has no effect", async (subject) => {
453
+ const work = await subject.hold();
454
+ await subject.impl.stopIntake();
455
+ check(!await subject.offer(), "the transport took new work after stopIntake");
456
+ await count(subject, 1, "stopIntake cut the work in flight");
457
+ await subject.impl.stopIntake();
458
+ check(!await subject.offer(), "the transport took new work after a second stopIntake");
459
+ await work.release();
460
+ await count(subject, 0, "the work in flight ended after stopIntake");
461
+ }),
462
+ each("drain ends at once when nothing is in flight", async (subject, time) => {
463
+ await subject.impl.stopIntake();
464
+ const drained = track(attempt(() => subject.impl.drain(time.clock.now() + cap, new AbortController().signal)));
465
+ check(await within(slack, () => drained.settled), "drain did not end with nothing in flight");
466
+ check(!drained.failed, `drain threw: ${String(drained.error)}`);
467
+ }),
468
+ each("drain ends when the last work ends, before its deadline", async (subject, time) => {
469
+ const work = await subject.hold();
470
+ await subject.impl.stopIntake();
471
+ const deadline = time.clock.now() + cap;
472
+ const drained = track(attempt(() => subject.impl.drain(deadline, new AbortController().signal)));
473
+ await time.pass(cap / 4);
474
+ await quiet();
475
+ check(!drained.settled, "drain ended while work was in flight, before its deadline");
476
+ await work.release();
477
+ check(await within(slack, () => drained.settled), "drain did not end when the last work ended");
478
+ check(!drained.failed, `drain threw: ${String(drained.error)}`);
479
+ check(time.clock.now() < deadline, "drain waited until its deadline after the last work ended");
480
+ }),
481
+ each("drain holds to its deadline and not past it, and does not throw", async (subject, time, title) => {
482
+ await subject.hold();
483
+ await subject.impl.stopIntake();
484
+ const began = realNow();
485
+ const deadline = time.clock.now() + cap;
486
+ const drained = track(attempt(() => subject.impl.drain(deadline, new AbortController().signal)));
487
+ if (fake) {
488
+ await time.pass(cap - 1);
489
+ await quiet();
490
+ check(!drained.settled, "drain ended before its deadline, with work in flight");
491
+ await time.pass(1);
492
+ check(await within(slack, () => drained.settled), "drain did not end at its deadline");
493
+ } else {
494
+ const ended = await within(cap + slack, () => drained.settled);
495
+ const took = realNow() - began;
496
+ options.record?.({
497
+ case: title,
498
+ what: "drain to its deadline",
499
+ expected: cap,
500
+ measured: took
501
+ });
502
+ check(ended, `drain did not end in ${cap + slack} ms, with a deadline at ${cap} ms`);
503
+ check(took >= cap - slack, `drain ended after ${Math.round(took)} ms, before its deadline at ${cap} ms`);
504
+ }
505
+ check(!drained.failed, `drain threw at its deadline: ${String(drained.error)}`);
506
+ }),
507
+ ...cooperative ? [each("drain gives the cooperative abort to the work in flight", async (subject, time) => {
508
+ const work = await subject.hold();
509
+ await subject.impl.stopIntake();
510
+ const abort = new AbortController();
511
+ const drained = track(attempt(() => subject.impl.drain(time.clock.now() + cap, abort.signal)));
512
+ await time.pass(cap / 2);
513
+ await quiet();
514
+ check(!work.aborted, "the work got an abort before the signal of the drain aborted");
515
+ abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the conformance kit"));
516
+ check(await within(slack, () => work.aborted), "the work did not get the cooperative abort");
517
+ await time.pass(cap / 2);
518
+ check(await within(slack, () => drained.settled), "drain did not end at its deadline after the cooperative abort");
519
+ check(!drained.failed, `drain threw after the cooperative abort: ${String(drained.error)}`);
520
+ })] : [],
521
+ each("drain with an aborted signal and a deadline in the past ends at once", async (subject, time) => {
522
+ const work = await subject.hold();
523
+ await subject.impl.stopIntake();
524
+ const abort = new AbortController();
525
+ abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the conformance kit"));
526
+ const drained = track(attempt(() => subject.impl.drain(time.clock.now() - 1, abort.signal)));
527
+ check(await within(slack, () => drained.settled), "drain did not end at once with its deadline in the past");
528
+ check(!drained.failed, `drain threw: ${String(drained.error)}`);
529
+ if (cooperative) check(await within(slack, () => work.aborted), "the work did not get an abort that came before the drain");
530
+ }),
531
+ each("close ends the work in flight, in its bound", async (subject, _time, title) => {
532
+ for (let at = 0; at < holds; at += 1) await subject.hold();
533
+ await count(subject, holds, "the works are in flight before the close");
534
+ await closes(subject, title);
535
+ await count(subject, 0, "close left work in flight");
536
+ }),
537
+ each("close is idempotent: a second close has no effect", async (subject, _time, title) => {
538
+ await subject.hold();
539
+ const first = track(attempt(() => subject.impl.close()));
540
+ const second = track(attempt(() => subject.impl.close()));
541
+ check(await within(closeBound, () => first.settled && second.settled), "two closes did not end in the bound");
542
+ check(!first.failed && !second.failed, `a close threw: ${String(first.error ?? second.error)}`);
543
+ await closes(subject, title);
544
+ await count(subject, 0, "the closes left work in flight");
545
+ }),
546
+ each("close right after the start ends in its bound", async (subject, _time, title) => {
547
+ await closes(subject, title);
548
+ check(subject.impl.inFlight() === 0, "inFlight() is not 0 after a close with no work");
549
+ }),
550
+ each("the stages in their sequence: stop intake, drain with its cooperative abort, close", async (subject, time, title) => {
551
+ const short = await subject.hold();
552
+ const long = await subject.hold();
553
+ await subject.impl.stopIntake();
554
+ check(!await subject.offer(), "the transport took new work after stopIntake");
555
+ const abort = new AbortController();
556
+ const drained = track(attempt(() => subject.impl.drain(time.clock.now() + cap, abort.signal)));
557
+ await short.release();
558
+ await count(subject, 1, "one work ended in the drain");
559
+ await time.pass(cap / 2);
560
+ abort.abort(/* @__PURE__ */ new Error("the cooperative abort of the conformance kit"));
561
+ if (cooperative) check(await within(slack, () => long.aborted), "the long work did not get the cooperative abort");
562
+ await time.pass(cap / 2);
563
+ check(await within(slack, () => drained.settled), "drain did not end at its deadline");
564
+ await closes(subject, title);
565
+ await count(subject, 0, "close left work in flight");
566
+ })
567
+ ];
568
+ };
569
+ //#endregion
276
570
  //#region src/testing.ts
277
571
  /** This function makes a scripted check with a behavior at the start. */
278
572
  const scriptedCheck = (behavior = "ok") => {
@@ -344,6 +638,6 @@ const quietServer = async () => {
344
638
  };
345
639
  };
346
640
  //#endregion
347
- export { contractCases, fakeClock, fakeProcess, quietServer, scriptedCheck, seeded };
641
+ export { contractCases, detachedLifecycle, fakeClock, fakeProcess, quietServer, scriptedCheck, seeded, transportConformance };
348
642
 
349
643
  //# sourceMappingURL=testing.js.map