monitor.test.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import assert from "node:assert/strict";
  2. import test from "node:test";
  3. import { setTimeout as delay } from "node:timers/promises";
  4. import { AppServerClient } from "../src/app-server-client.js";
  5. import { ThreadMonitor } from "../src/monitor.js";
  6. import { FakeAppServer, rawThread, successfulHandshake } from "./fake-app-server.js";
  7. async function until(predicate: () => boolean, timeoutMs = 500): Promise<void> {
  8. const deadline = Date.now() + timeoutMs;
  9. while (!predicate() && Date.now() < deadline) await delay(1);
  10. assert.equal(predicate(), true, "condition was not reached before timeout");
  11. }
  12. function appClient(server: FakeAppServer, timeoutMs = 50): AppServerClient {
  13. return new AppServerClient({
  14. url: "ws://127.0.0.1:4500",
  15. requestTimeoutMs: timeoutMs,
  16. connectTimeoutMs: timeoutMs,
  17. webSocketFactory: server.factory,
  18. });
  19. }
  20. function inventoryServer(id: string, status = "notLoaded"): FakeAppServer {
  21. return new FakeAppServer(successfulHandshake((request, server) => {
  22. if (request.method === "thread/list") {
  23. server.respond(request, {
  24. data: [rawThread(id, { status: { type: status } })],
  25. nextCursor: null,
  26. backwardsCursor: null,
  27. });
  28. } else if (request.method === "thread/loaded/list") {
  29. server.respond(request, { data: status === "notLoaded" ? [] : [id], nextCursor: null });
  30. }
  31. }));
  32. }
  33. test("startup snapshot stays unavailable until both inventories complete", async () => {
  34. let loadedRequest: Record<string, unknown> | null = null;
  35. const server = new FakeAppServer(successfulHandshake((request, fake) => {
  36. if (request.method === "thread/list") {
  37. fake.respond(request, { data: [rawThread("a")], nextCursor: null, backwardsCursor: null });
  38. } else if (request.method === "thread/loaded/list") {
  39. loadedRequest = request;
  40. }
  41. }));
  42. const monitor = new ThreadMonitor({ clientFactory: () => appClient(server), reconnectBaseMs: 1 });
  43. monitor.start();
  44. await until(() => loadedRequest !== null);
  45. assert.equal(monitor.snapshot().snapshotAvailable, false);
  46. server.respond(loadedRequest as unknown as Record<string, unknown>, { data: [], nextCursor: null });
  47. assert.deepEqual((await monitor.waitUntilSynchronized()).threads.map((thread) => thread.id), ["a"]);
  48. await monitor.stop();
  49. });
  50. test("startup failure retries and recovers with a current snapshot", async () => {
  51. const unavailable = new FakeAppServer(() => undefined, false);
  52. const recovered = inventoryServer("recovered", "idle");
  53. const servers = [unavailable, recovered];
  54. const monitor = new ThreadMonitor({
  55. clientFactory: () => appClient(servers.shift() ?? recovered, 10),
  56. reconnectBaseMs: 1,
  57. reconnectMaxMs: 2,
  58. sleep: async () => undefined,
  59. });
  60. monitor.start();
  61. const snapshot = await monitor.waitUntilSynchronized(500);
  62. assert.equal(snapshot.freshness, "current");
  63. assert.equal(snapshot.threads[0]?.id, "recovered");
  64. await monitor.stop();
  65. });
  66. test("disconnect retains stale cache and reconnect atomically replaces it", async () => {
  67. const first = inventoryServer("old", "idle");
  68. const second = inventoryServer("new", "active");
  69. const servers = [first, second];
  70. let releaseBackoff: (() => void) | null = null;
  71. const backoff = new Promise<void>((resolve) => { releaseBackoff = resolve; });
  72. let slept = false;
  73. const monitor = new ThreadMonitor({
  74. clientFactory: () => appClient(servers.shift() ?? second),
  75. reconnectBaseMs: 1,
  76. sleep: async () => {
  77. if (!slept) {
  78. slept = true;
  79. await backoff;
  80. }
  81. },
  82. });
  83. monitor.start();
  84. await monitor.waitUntilSynchronized();
  85. first.disconnect();
  86. await until(() => monitor.snapshot().connectionState === "disconnected");
  87. assert.equal(monitor.snapshot().freshness, "stale");
  88. assert.deepEqual(monitor.snapshot().threads.map((thread) => thread.id), ["old"]);
  89. (releaseBackoff as unknown as () => void)();
  90. await until(() => monitor.snapshot().freshness === "current"
  91. && monitor.snapshot().threads[0]?.id === "new");
  92. assert.deepEqual(monitor.snapshot().threads.map((thread) => thread.id), ["new"]);
  93. await monitor.stop();
  94. });
  95. test("bounded wait refresh detects a missed status notification", async () => {
  96. let status = "idle";
  97. let listCalls = 0;
  98. const server = new FakeAppServer(successfulHandshake((request, fake) => {
  99. if (request.method === "thread/list") {
  100. listCalls += 1;
  101. fake.respond(request, {
  102. data: [rawThread("a", { status: { type: status } })],
  103. nextCursor: null,
  104. backwardsCursor: null,
  105. });
  106. } else if (request.method === "thread/loaded/list") {
  107. fake.respond(request, { data: ["a"], nextCursor: null });
  108. }
  109. }));
  110. const monitor = new ThreadMonitor({
  111. clientFactory: () => appClient(server),
  112. waitRefreshIntervalMs: 5,
  113. refreshTimeoutMs: 50,
  114. });
  115. monitor.start();
  116. await monitor.waitUntilSynchronized();
  117. const revision = monitor.snapshot().revision;
  118. status = "active";
  119. const result = await monitor.waitForChange(revision, 100);
  120. assert.equal(result.timedOut, false);
  121. assert.equal(monitor.snapshot().threads[0]?.status, "active");
  122. assert.equal(listCalls, 2);
  123. await monitor.stop();
  124. });