| 1 | import { createStore } from "/js/AlpineStore.js"; |
| 2 | import { |
| 3 | getNamespacedClient, |
| 4 | createCorrelationId, |
| 5 | validateServerEnvelope, |
| 6 | } from "/js/websocket.js"; |
| 7 | import { store as notificationStore } from "/components/notifications/notification-store.js"; |
| 8 | import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; |
| 9 | import { store as syncStore } from "/components/sync/sync-store.js"; |
| 10 | import { getCurrentUserISOString, getUserTimezone } from "/js/time-utils.js"; |
| 11 | |
| 12 | const MAX_PAYLOAD_BYTES = 50 * 1024 * 1024; |
| 13 | const TOAST_DURATION = 5; |
| 14 | |
| 15 | const websocket = getNamespacedClient("/ws"); |
| 16 | websocket.addHandlers(["ws_dev_test"]); |
| 17 | const stateSocket = websocket; // same /ws namespace client |
| 18 | |
| 19 | function now() { |
| 20 | return getCurrentUserISOString(); |
| 21 | } |
| 22 | |
| 23 | function payloadSize(value) { |
| 24 | try { |
| 25 | return new TextEncoder().encode(JSON.stringify(value ?? null)).length; |
| 26 | } catch (_error) { |
| 27 | return String(value ?? "").length * 2; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | function clientForEventType(eventType) { |
| 32 | if (typeof eventType === "string" && eventType.startsWith("state_")) { |
| 33 | return stateSocket; |
| 34 | } |
| 35 | return websocket; |
| 36 | } |
| 37 | |
| 38 | async function showToast(type, message, title) { |
| 39 | const normalized = (type || "info").toLowerCase(); |
| 40 | switch (normalized) { |
| 41 | case "error": |
| 42 | return notificationStore.addFrontendToastOnly( |
| 43 | "error", |
| 44 | message, |
| 45 | title || "Error", |
| 46 | TOAST_DURATION, |
| 47 | "ws-harness", |
| 48 | 10, |
| 49 | ); |
| 50 | case "success": |
| 51 | return notificationStore.addFrontendToastOnly( |
| 52 | "success", |
| 53 | message, |
| 54 | title || "Success", |
| 55 | TOAST_DURATION, |
| 56 | "ws-harness", |
| 57 | 10, |
| 58 | ); |
| 59 | case "warning": |
| 60 | return notificationStore.addFrontendToastOnly( |
| 61 | "warning", |
| 62 | message, |
| 63 | title || "Warning", |
| 64 | TOAST_DURATION, |
| 65 | "ws-harness", |
| 66 | 10, |
| 67 | ); |
| 68 | case "info": |
| 69 | default: |
| 70 | return notificationStore.addFrontendToastOnly( |
| 71 | "info", |
| 72 | message, |
| 73 | title || "Info", |
| 74 | TOAST_DURATION, |
| 75 | "ws-harness", |
| 76 | 10, |
| 77 | ); |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | function withTimeout(promise, timeoutMs, label) { |
| 82 | const normalizedTimeout = Number(timeoutMs); |
| 83 | if (!Number.isFinite(normalizedTimeout) || normalizedTimeout <= 0) { |
| 84 | return Promise.resolve(promise); |
| 85 | } |
| 86 | return new Promise((resolve, reject) => { |
| 87 | const timer = setTimeout(() => { |
| 88 | reject(new Error(`${label} timed out after ${normalizedTimeout}ms`)); |
| 89 | }, normalizedTimeout); |
| 90 | Promise.resolve(promise).then( |
| 91 | (value) => { |
| 92 | clearTimeout(timer); |
| 93 | resolve(value); |
| 94 | }, |
| 95 | (error) => { |
| 96 | clearTimeout(timer); |
| 97 | reject(error); |
| 98 | }, |
| 99 | ); |
| 100 | }); |
| 101 | } |
| 102 | |
| 103 | const model = { |
| 104 | logs: "", |
| 105 | running: false, |
| 106 | manualRunning: false, |
| 107 | subscriptionCount: 0, |
| 108 | lastAggregated: null, |
| 109 | receivedBroadcasts: [], |
| 110 | isEnabled: false, |
| 111 | _serverRestartHandler: null, |
| 112 | _subscriptionHandlers: null, |
| 113 | _broadcastSeq: 0, |
| 114 | |
| 115 | init() { |
| 116 | this.isEnabled = Boolean(window.runtimeInfo?.isDevelopment); |
| 117 | }, |
| 118 | |
| 119 | onOpen() { |
| 120 | // `init()` is called once when the store is registered; `onOpen()` is called |
| 121 | // every time the component is displayed (modal open). |
| 122 | this.init(); |
| 123 | |
| 124 | if (this.isEnabled) { |
| 125 | this.appendLog("WebSocket tester harness ready."); |
| 126 | if (this._serverRestartHandler) { |
| 127 | websocket.off("server_restart", this._serverRestartHandler); |
| 128 | } |
| 129 | this._serverRestartHandler = (payload) => { |
| 130 | try { |
| 131 | const envelope = validateServerEnvelope(payload); |
| 132 | this.appendLog( |
| 133 | `server_restart received (runtimeId=${envelope.data.runtimeId ?? "unknown"})`, |
| 134 | ); |
| 135 | } catch (error) { |
| 136 | this.appendLog(`server_restart envelope invalid: ${error.message || error}`); |
| 137 | } |
| 138 | }; |
| 139 | websocket |
| 140 | .on("server_restart", this._serverRestartHandler) |
| 141 | .catch((error) => { |
| 142 | this.appendLog(`Failed to subscribe to server_restart: ${error.message || error}`); |
| 143 | }); |
| 144 | } else { |
| 145 | this.appendLog("WebSocket tester harness is available only in development runtime."); |
| 146 | } |
| 147 | }, |
| 148 | |
| 149 | detach() { |
| 150 | if (this._subscriptionHandlers && typeof this._subscriptionHandlers === "object") { |
| 151 | for (const [eventType, handler] of Object.entries(this._subscriptionHandlers)) { |
| 152 | if (typeof handler === "function") { |
| 153 | clientForEventType(eventType).off(eventType, handler); |
| 154 | } |
| 155 | } |
| 156 | this._subscriptionHandlers = null; |
| 157 | } |
| 158 | if (this._serverRestartHandler) { |
| 159 | websocket.off("server_restart", this._serverRestartHandler); |
| 160 | this._serverRestartHandler = null; |
| 161 | } else { |
| 162 | websocket.off("server_restart"); |
| 163 | } |
| 164 | // Legacy cleanup: ensure we do not leave stray tester handlers attached. |
| 165 | websocket.off("ws_tester_broadcast"); |
| 166 | websocket.off("ws_tester_persistence"); |
| 167 | websocket.off("ws_tester_broadcast_demo"); |
| 168 | // NOTE: Do NOT blanket-remove state_push — that nukes syncStore's handler. |
| 169 | // The _subscriptionHandlers loop above already removes tester-specific handlers. |
| 170 | }, |
| 171 | |
| 172 | appendLog(message) { |
| 173 | this.logs += `[${now()}] ${message}\n`; |
| 174 | }, |
| 175 | |
| 176 | clearLog() { |
| 177 | this.logs = ""; |
| 178 | this.appendLog("Log cleared."); |
| 179 | }, |
| 180 | |
| 181 | assertEnabled() { |
| 182 | if (!this.isEnabled) { |
| 183 | throw new Error("WebSocket harness is available only in development runtime."); |
| 184 | } |
| 185 | }, |
| 186 | |
| 187 | async ensureConnected() { |
| 188 | this.assertEnabled(); |
| 189 | if (!websocket.isConnected()) { |
| 190 | this.appendLog("Connecting WebSocket client..."); |
| 191 | await withTimeout(websocket.connect(), 5000, "websocket.connect"); |
| 192 | this.appendLog("Connected to WebSocket server."); |
| 193 | } |
| 194 | }, |
| 195 | |
| 196 | async _toast(type, message, title) { |
| 197 | try { |
| 198 | await showToast(type, message, title); |
| 199 | } catch (error) { |
| 200 | this.appendLog(`Toast failed: ${error.message || error}`); |
| 201 | } |
| 202 | }, |
| 203 | |
| 204 | async runAutomaticSuite() { |
| 205 | this.assertEnabled(); |
| 206 | if (this.running) return; |
| 207 | this.running = true; |
| 208 | this.lastAggregated = null; |
| 209 | this.receivedBroadcasts = []; |
| 210 | this._broadcastSeq = 0; |
| 211 | |
| 212 | const results = []; |
| 213 | |
| 214 | const steps = [ |
| 215 | this.testEmit.bind(this), |
| 216 | this.testRequest.bind(this), |
| 217 | this.testRequestTimeout.bind(this), |
| 218 | this.testSubscriptionPersistence.bind(this), |
| 219 | this.testRequestAll.bind(this), |
| 220 | this.testStateSyncNoPollHealthy.bind(this), |
| 221 | this.testContextSwitchNoLeak.bind(this), |
| 222 | this.testFallbackRecoveryDegraded.bind(this), |
| 223 | this.testResyncTriggersRuntimeEpochAndSeqGap.bind(this), |
| 224 | ]; |
| 225 | |
| 226 | try { |
| 227 | this.appendLog("Starting automatic WebSocket validation suite..."); |
| 228 | await this.ensureConnected(); |
| 229 | |
| 230 | for (const step of steps) { |
| 231 | const result = await step(); |
| 232 | results.push(result); |
| 233 | if (!result.ok) { |
| 234 | await this._toast("warning", `Automatic suite halted: ${result.label} failed`, "WebSocket Harness"); |
| 235 | this.appendLog(`Automatic suite halted on step: ${result.label} (${result.error || 'unknown error'})`); |
| 236 | this.running = false; |
| 237 | return; |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | await this._toast("success", "Automatic WebSocket validation succeeded", "WebSocket Harness"); |
| 242 | this.appendLog("Automatic suite completed successfully."); |
| 243 | } catch (error) { |
| 244 | this.appendLog(`Automatic suite failed: ${error.message || error}`); |
| 245 | await this._toast("error", `Automatic suite failed: ${error.message || error}`, "WebSocket Harness"); |
| 246 | } finally { |
| 247 | this.running = false; |
| 248 | } |
| 249 | }, |
| 250 | |
| 251 | async manualStep(stepFn) { |
| 252 | this.assertEnabled(); |
| 253 | if (this.manualRunning) return; |
| 254 | this.manualRunning = true; |
| 255 | try { |
| 256 | await this.ensureConnected(); |
| 257 | const result = await stepFn(); |
| 258 | this.appendLog( |
| 259 | `${result.ok ? "PASS" : "FAIL"} - ${result.label}${result.error ? `: ${result.error}` : ""}`, |
| 260 | ); |
| 261 | if (result.ok) { |
| 262 | await this._toast("success", `${result.label} succeeded`, "WebSocket Harness"); |
| 263 | } else { |
| 264 | await this._toast("warning", `${result.label} failed: ${result.error}`, "WebSocket Harness"); |
| 265 | } |
| 266 | } catch (error) { |
| 267 | await this._toast("error", `${error.message || error}`, "WebSocket Harness"); |
| 268 | this.appendLog(`Manual step error: ${error.message || error}`); |
| 269 | } finally { |
| 270 | this.manualRunning = false; |
| 271 | } |
| 272 | }, |
| 273 | |
| 274 | async testEmit() { |
| 275 | const label = "Fire-and-forget emit"; |
| 276 | try { |
| 277 | this.appendLog("Testing fire-and-forget emit..."); |
| 278 | await this.ensureSubscribed("ws_tester_broadcast", true); |
| 279 | const emitOptions = { |
| 280 | correlationId: createCorrelationId("harness-emit"), |
| 281 | }; |
| 282 | await websocket.emit( |
| 283 | "ws_tester_emit", |
| 284 | { message: "emit-check", timestamp: now() }, |
| 285 | emitOptions, |
| 286 | ); |
| 287 | const received = await this.waitForEvent( |
| 288 | "ws_tester_broadcast", |
| 289 | (_data, envelope) => |
| 290 | envelope?.data?.message === "emit-check" && |
| 291 | typeof envelope?.handlerId === "string" && |
| 292 | typeof envelope?.eventId === "string" && |
| 293 | typeof envelope?.correlationId === "string" && |
| 294 | typeof envelope?.ts === "string", |
| 295 | ); |
| 296 | this.appendLog("Received broadcast echo with valid envelope metadata."); |
| 297 | return { ok: received, label, error: received ? undefined : "Envelope validation failed" }; |
| 298 | } catch (error) { |
| 299 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 300 | return { ok: false, label, error: error.message || error }; |
| 301 | } |
| 302 | }, |
| 303 | |
| 304 | async testRequest() { |
| 305 | const label = "Request-response"; |
| 306 | try { |
| 307 | this.appendLog("Testing request-response..."); |
| 308 | const requestOptions = { |
| 309 | correlationId: createCorrelationId("harness-request"), |
| 310 | }; |
| 311 | const response = await websocket.request( |
| 312 | "ws_tester_request", |
| 313 | { value: 42 }, |
| 314 | { ...requestOptions }, |
| 315 | ); |
| 316 | const delayedResponse = await websocket.request( |
| 317 | "ws_tester_request_delayed", |
| 318 | { delay_ms: 750 }, |
| 319 | { correlationId: createCorrelationId("harness-request-no-timeout") }, |
| 320 | ); |
| 321 | const first = response.results?.[0]; |
| 322 | const ok = Boolean( |
| 323 | response?.correlationId && |
| 324 | Array.isArray(response.results) && |
| 325 | first?.ok === true && |
| 326 | first?.handlerId && |
| 327 | first?.correlationId === response.correlationId && |
| 328 | first?.data?.echo === 42, |
| 329 | ); |
| 330 | const delayedOk = Boolean( |
| 331 | Array.isArray(delayedResponse.results) && |
| 332 | delayedResponse.results[0]?.ok === true && |
| 333 | delayedResponse.results[0]?.data?.status === "delayed", |
| 334 | ); |
| 335 | this.appendLog(`Request-response result: ${JSON.stringify(response)}`); |
| 336 | this.appendLog(`Request-response (no-timeout) result: ${JSON.stringify(delayedResponse)}`); |
| 337 | return { |
| 338 | ok: ok && delayedOk, |
| 339 | label, |
| 340 | error: ok && delayedOk ? undefined : "Unexpected response payload or default timeout behaviour", |
| 341 | }; |
| 342 | } catch (error) { |
| 343 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 344 | return { ok: false, label, error: error.message || error }; |
| 345 | } |
| 346 | }, |
| 347 | |
| 348 | async testRequestTimeout() { |
| 349 | const label = "Request timeout"; |
| 350 | try { |
| 351 | this.appendLog("Testing request timeout..."); |
| 352 | let threw = false; |
| 353 | try { |
| 354 | const timeoutOptions = { |
| 355 | correlationId: createCorrelationId("harness-timeout"), |
| 356 | }; |
| 357 | await websocket.request( |
| 358 | "ws_tester_request_delayed", |
| 359 | { delay_ms: 2000 }, |
| 360 | { timeoutMs: 500, ...timeoutOptions }, |
| 361 | ); |
| 362 | } catch (error) { |
| 363 | threw = error.message === "Request timeout"; |
| 364 | if (!threw) { |
| 365 | throw error; |
| 366 | } |
| 367 | } |
| 368 | if (threw) { |
| 369 | this.appendLog("Timeout correctly triggered."); |
| 370 | return { ok: true, label }; |
| 371 | } |
| 372 | this.appendLog("Timeout test failed: request resolved unexpectedly."); |
| 373 | return { ok: false, label, error: "Request resolved but should timeout" }; |
| 374 | } catch (error) { |
| 375 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 376 | return { ok: false, label, error: error.message || error }; |
| 377 | } |
| 378 | }, |
| 379 | |
| 380 | async testSubscriptionPersistence() { |
| 381 | const label = "Subscription persistence"; |
| 382 | try { |
| 383 | this.appendLog("Testing subscription persistence across reconnect..."); |
| 384 | await this.ensureSubscribed("ws_tester_persistence", true); |
| 385 | const emitOptions = { |
| 386 | correlationId: createCorrelationId("harness-persistence"), |
| 387 | }; |
| 388 | await websocket.emit("ws_tester_trigger_persistence", { phase: "before" }, emitOptions); |
| 389 | await this.waitForEvent("ws_tester_persistence", (data) => data?.phase === "before"); |
| 390 | this.appendLog("Initial subscription event received."); |
| 391 | |
| 392 | websocket.socket.disconnect(); |
| 393 | this.appendLog("Disconnected socket manually."); |
| 394 | await websocket.connect(); |
| 395 | this.appendLog("Reconnected socket."); |
| 396 | |
| 397 | await websocket.emit( |
| 398 | "ws_tester_trigger_persistence", |
| 399 | { phase: "after" }, |
| 400 | emitOptions, |
| 401 | ); |
| 402 | const received = await this.waitForEvent("ws_tester_persistence", (data) => data?.phase === "after", 2000); |
| 403 | this.appendLog("Post-reconnect event received."); |
| 404 | return { ok: received, label, error: received ? undefined : "Callback not triggered after reconnect" }; |
| 405 | } catch (error) { |
| 406 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 407 | return { ok: false, label, error: error.message || error }; |
| 408 | } |
| 409 | }, |
| 410 | |
| 411 | async testRequestAll() { |
| 412 | const label = "requestAll aggregation"; |
| 413 | try { |
| 414 | this.appendLog("Testing requestAll aggregation..."); |
| 415 | const options = { |
| 416 | correlationId: createCorrelationId("harness-requestAll"), |
| 417 | }; |
| 418 | const response = await websocket.request( |
| 419 | "ws_tester_request_all", |
| 420 | { marker: "aggregate" }, |
| 421 | { timeoutMs: 2000, ...options }, |
| 422 | ); |
| 423 | this.lastAggregated = response; |
| 424 | |
| 425 | const first = response?.results?.[0]; |
| 426 | const aggregated = first?.ok === true ? first?.data?.results : null; |
| 427 | const ok = |
| 428 | Array.isArray(aggregated) && |
| 429 | aggregated.length > 0 && |
| 430 | aggregated.every( |
| 431 | (entry) => |
| 432 | typeof entry?.sid === "string" && |
| 433 | typeof entry?.correlationId === "string" && |
| 434 | Array.isArray(entry.results) && |
| 435 | entry.results.length > 0, |
| 436 | ); |
| 437 | |
| 438 | this.appendLog(`ws_tester_request_all response: ${JSON.stringify(response)}`); |
| 439 | return { ok, label, error: ok ? undefined : "Aggregation payload missing expected metadata" }; |
| 440 | } catch (error) { |
| 441 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 442 | return { ok: false, label, error: error.message || error }; |
| 443 | } |
| 444 | }, |
| 445 | |
| 446 | async testStateSyncNoPollHealthy() { |
| 447 | const label = "State sync (state_request/state_push + no poll when HEALTHY)"; |
| 448 | const originalPoll = globalThis.poll; |
| 449 | let pollCalls = 0; |
| 450 | try { |
| 451 | this.appendLog("Testing state_request/state_push contract and healthy-mode poll suppression..."); |
| 452 | |
| 453 | if (typeof originalPoll === "function") { |
| 454 | globalThis.poll = async (...args) => { |
| 455 | pollCalls += 1; |
| 456 | return await originalPoll(...args); |
| 457 | }; |
| 458 | } |
| 459 | |
| 460 | await this.ensureSubscribed("state_push", true); |
| 461 | this.appendLog("Subscribed to state_push."); |
| 462 | |
| 463 | const timezone = getUserTimezone(); |
| 464 | const response = await stateSocket.request( |
| 465 | "state_request", |
| 466 | { |
| 467 | context: globalThis.getContext ? globalThis.getContext() : null, |
| 468 | log_from: 0, |
| 469 | notifications_from: 0, |
| 470 | timezone, |
| 471 | }, |
| 472 | { timeoutMs: 2000, correlationId: createCorrelationId("harness-state-request") }, |
| 473 | ); |
| 474 | |
| 475 | const first = response?.results?.[0]; |
| 476 | const requestOk = Boolean( |
| 477 | response?.correlationId && |
| 478 | first?.ok === true && |
| 479 | typeof first?.data?.runtime_epoch === "string" && |
| 480 | typeof first?.data?.seq_base === "number", |
| 481 | ); |
| 482 | if (!requestOk) { |
| 483 | this.appendLog(`state_request response invalid: ${JSON.stringify(response)}`); |
| 484 | return { ok: false, label, error: "state_request did not return expected {runtime_epoch, seq_base}" }; |
| 485 | } |
| 486 | this.appendLog("state_request OK."); |
| 487 | |
| 488 | const start = Date.now(); |
| 489 | let pushOk = false; |
| 490 | while (Date.now() - start < 1000) { |
| 491 | const hit = this.receivedBroadcasts.find( |
| 492 | (entry) => |
| 493 | entry.eventType === "state_push" && |
| 494 | typeof entry?.payload?.data?.runtime_epoch === "string" && |
| 495 | typeof entry?.payload?.data?.seq === "number" && |
| 496 | entry?.payload?.data?.snapshot && |
| 497 | typeof entry?.payload?.data?.snapshot === "object" && |
| 498 | Array.isArray(entry?.payload?.data?.snapshot?.contexts) && |
| 499 | Array.isArray(entry?.payload?.data?.snapshot?.tasks) && |
| 500 | Array.isArray(entry?.payload?.data?.snapshot?.notifications), |
| 501 | ); |
| 502 | if (hit) { |
| 503 | pushOk = true; |
| 504 | break; |
| 505 | } |
| 506 | await new Promise((resolve) => setTimeout(resolve, 25)); |
| 507 | } |
| 508 | |
| 509 | if (!pushOk) { |
| 510 | return { ok: false, label, error: "Did not observe state_push within 1s after handshake" }; |
| 511 | } |
| 512 | this.appendLog("state_push observed."); |
| 513 | |
| 514 | // The sync store applies snapshots asynchronously; give it a moment to |
| 515 | // reach HEALTHY before asserting poll suppression. |
| 516 | const startedHealthyWait = Date.now(); |
| 517 | while (Date.now() - startedHealthyWait < 1000 && syncStore.mode !== "HEALTHY") { |
| 518 | await new Promise((resolve) => setTimeout(resolve, 25)); |
| 519 | } |
| 520 | if (syncStore.mode !== "HEALTHY") { |
| 521 | const mode = typeof syncStore.mode === "string" ? syncStore.mode : "missing"; |
| 522 | return { ok: false, label, error: `syncStore did not reach HEALTHY mode (mode=${mode})` }; |
| 523 | } |
| 524 | |
| 525 | // Reset count after the store is HEALTHY; then observe for >1 poll interval. |
| 526 | pollCalls = 0; |
| 527 | await new Promise((resolve) => setTimeout(resolve, 600)); |
| 528 | const noPoll = pollCalls === 0; |
| 529 | if (!noPoll) { |
| 530 | return { ok: false, label, error: `poll() invoked ${pollCalls}x while HEALTHY` }; |
| 531 | } |
| 532 | |
| 533 | return { ok: true, label }; |
| 534 | } catch (error) { |
| 535 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 536 | return { ok: false, label, error: error.message || error }; |
| 537 | } finally { |
| 538 | if (typeof originalPoll === "function") { |
| 539 | globalThis.poll = originalPoll; |
| 540 | } |
| 541 | } |
| 542 | }, |
| 543 | |
| 544 | async testContextSwitchNoLeak() { |
| 545 | const label = "Context switching (state_request updates active context, no stale pushes)"; |
| 546 | const originalContext = typeof globalThis.getContext === "function" ? globalThis.getContext() : null; |
| 547 | try { |
| 548 | this.appendLog("Testing context switching does not leak or keep pushing stale contexts..."); |
| 549 | await this.ensureSubscribed("state_push", true); |
| 550 | |
| 551 | if (!Array.isArray(chatsStore.contexts)) { |
| 552 | return { ok: false, label, error: "chats store not available" }; |
| 553 | } |
| 554 | |
| 555 | const ids = chatsStore.contexts |
| 556 | .map((ctx) => ctx?.id) |
| 557 | .filter((id) => typeof id === "string" && id.length > 0); |
| 558 | const unique = Array.from(new Set(ids)); |
| 559 | if (unique.length < 2) { |
| 560 | return { ok: false, label, error: "Need at least 2 chats to validate switching" }; |
| 561 | } |
| 562 | |
| 563 | const current = typeof originalContext === "string" ? originalContext : null; |
| 564 | let first = unique[0]; |
| 565 | let second = unique[1]; |
| 566 | if (current && unique.includes(current)) { |
| 567 | const alternate = unique.find((id) => id !== current); |
| 568 | if (!alternate) { |
| 569 | return { ok: false, label, error: "Need at least 2 distinct chats to validate switching" }; |
| 570 | } |
| 571 | first = alternate; |
| 572 | second = current; |
| 573 | } |
| 574 | |
| 575 | const switchTo = async (ctxid) => { |
| 576 | if (typeof chatsStore.selectChat === "function") { |
| 577 | await chatsStore.selectChat(ctxid); |
| 578 | return; |
| 579 | } |
| 580 | if (typeof globalThis.setContext === "function") { |
| 581 | globalThis.setContext(ctxid); |
| 582 | return; |
| 583 | } |
| 584 | throw new Error("No chat selection function available"); |
| 585 | }; |
| 586 | |
| 587 | const waitForContextPush = async (ctxid, timeoutMs = 2000) => { |
| 588 | return await this.waitForEvent( |
| 589 | "state_push", |
| 590 | (data) => data?.snapshot?.context === ctxid, |
| 591 | timeoutMs, |
| 592 | ); |
| 593 | }; |
| 594 | |
| 595 | const waitFirst = waitForContextPush(first, 2500); |
| 596 | await switchTo(first); |
| 597 | const gotFirst = await waitFirst; |
| 598 | if (!gotFirst) { |
| 599 | return { ok: false, label, error: "Did not observe state_push for first context after switch" }; |
| 600 | } |
| 601 | |
| 602 | const switchedAt = Date.now(); |
| 603 | const waitSecond = waitForContextPush(second, 2500); |
| 604 | await switchTo(second); |
| 605 | const gotSecond = await waitSecond; |
| 606 | if (!gotSecond) { |
| 607 | return { ok: false, label, error: "Did not observe state_push for second context after switch" }; |
| 608 | } |
| 609 | |
| 610 | // After switching, we should not observe new pushes for the old context. |
| 611 | await new Promise((resolve) => setTimeout(resolve, 300)); |
| 612 | const stale = this.receivedBroadcasts.find((entry) => { |
| 613 | if (entry.eventType !== "state_push") return false; |
| 614 | const timestamp = Date.parse(entry.timestamp); |
| 615 | if (!Number.isFinite(timestamp) || timestamp < switchedAt) return false; |
| 616 | return entry?.payload?.data?.snapshot?.context === first; |
| 617 | }); |
| 618 | if (stale) { |
| 619 | return { ok: false, label, error: "Observed state_push for previous context after switching" }; |
| 620 | } |
| 621 | |
| 622 | return { ok: true, label }; |
| 623 | } catch (error) { |
| 624 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 625 | return { ok: false, label, error: error.message || error }; |
| 626 | } finally { |
| 627 | if (originalContext && typeof originalContext === "string") { |
| 628 | try { |
| 629 | if (typeof chatsStore.selectChat === "function") { |
| 630 | await chatsStore.selectChat(originalContext); |
| 631 | } else if (typeof globalThis.setContext === "function") { |
| 632 | globalThis.setContext(originalContext); |
| 633 | } |
| 634 | } catch (_error) { |
| 635 | // no-op |
| 636 | } |
| 637 | } |
| 638 | } |
| 639 | }, |
| 640 | |
| 641 | async testFallbackRecoveryDegraded() { |
| 642 | const label = "Fallback + recovery (DEGRADED polling, ignore pushes)"; |
| 643 | const originalPoll = globalThis.poll; |
| 644 | const originalRequest = stateSocket.request; |
| 645 | try { |
| 646 | if (typeof syncStore.sendStateRequest !== "function") { |
| 647 | return { ok: false, label, error: "syncStore.sendStateRequest not available" }; |
| 648 | } |
| 649 | |
| 650 | // Ensure we start from a known-good state. |
| 651 | await syncStore.sendStateRequest({ forceFull: true }); |
| 652 | if (syncStore.mode !== "HEALTHY") { |
| 653 | return { ok: false, label, error: `Expected HEALTHY before test, got ${syncStore.mode}` }; |
| 654 | } |
| 655 | |
| 656 | // Stub poll to avoid network side-effects and track calls. |
| 657 | let pollCalls = 0; |
| 658 | globalThis.poll = async () => { |
| 659 | pollCalls += 1; |
| 660 | return { ok: true, updated: false }; |
| 661 | }; |
| 662 | |
| 663 | // Simulate state_request failures to force DEGRADED mode. |
| 664 | stateSocket.request = async (eventType, payload, options) => { |
| 665 | if (eventType === "state_request") { |
| 666 | throw new Error("Request timeout"); |
| 667 | } |
| 668 | return await originalRequest.call(stateSocket, eventType, payload, options); |
| 669 | }; |
| 670 | |
| 671 | let threw = false; |
| 672 | try { |
| 673 | await syncStore.sendStateRequest({ forceFull: true }); |
| 674 | } catch (_error) { |
| 675 | threw = true; |
| 676 | } |
| 677 | if (!threw) { |
| 678 | return { ok: false, label, error: "Expected state_request failure but request succeeded" }; |
| 679 | } |
| 680 | |
| 681 | if (syncStore.mode !== "DEGRADED") { |
| 682 | return { ok: false, label, error: `Expected DEGRADED after failure, got ${syncStore.mode}` }; |
| 683 | } |
| 684 | this.appendLog("Entered DEGRADED mode after simulated state_request failure."); |
| 685 | |
| 686 | // Poll fallback should kick in quickly (1Hz idle); wait long enough for at least one tick. |
| 687 | await new Promise((resolve) => setTimeout(resolve, 1200)); |
| 688 | if (pollCalls < 1) { |
| 689 | return { ok: false, label, error: "poll() was not invoked while DEGRADED" }; |
| 690 | } |
| 691 | |
| 692 | // While DEGRADED, pushes should be ignored (single-writer arbitration). |
| 693 | const lastSeqBefore = typeof syncStore.lastSeq === "number" ? syncStore.lastSeq : 0; |
| 694 | await syncStore._handlePush({ |
| 695 | data: { |
| 696 | runtime_epoch: typeof syncStore.runtimeEpoch === "string" ? syncStore.runtimeEpoch : "test-epoch", |
| 697 | seq: lastSeqBefore + 1, |
| 698 | snapshot: { ignored: true }, |
| 699 | }, |
| 700 | }); |
| 701 | if (syncStore.lastSeq !== lastSeqBefore) { |
| 702 | return { ok: false, label, error: "state_push advanced seq while DEGRADED (should be ignored)" }; |
| 703 | } |
| 704 | this.appendLog("Verified state_push ignored while DEGRADED."); |
| 705 | |
| 706 | // Recover: restore request path and confirm we return to HEALTHY and polling stops. |
| 707 | stateSocket.request = originalRequest; |
| 708 | await syncStore.sendStateRequest({ forceFull: true }); |
| 709 | if (syncStore.mode !== "HEALTHY") { |
| 710 | return { ok: false, label, error: `Expected HEALTHY after recovery, got ${syncStore.mode}` }; |
| 711 | } |
| 712 | |
| 713 | pollCalls = 0; |
| 714 | await new Promise((resolve) => setTimeout(resolve, 600)); |
| 715 | if (pollCalls !== 0) { |
| 716 | return { ok: false, label, error: `poll() invoked ${pollCalls}x after recovery to HEALTHY` }; |
| 717 | } |
| 718 | |
| 719 | return { ok: true, label }; |
| 720 | } catch (error) { |
| 721 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 722 | return { ok: false, label, error: error.message || error }; |
| 723 | } finally { |
| 724 | stateSocket.request = originalRequest; |
| 725 | globalThis.poll = originalPoll; |
| 726 | // Clear dangling retry timers left by simulated failures. |
| 727 | syncStore._clearHandshakeRetry(); |
| 728 | syncStore._handshakeRetryAttempt = 0; |
| 729 | syncStore._handshakeFailureCount = 0; |
| 730 | } |
| 731 | }, |
| 732 | |
| 733 | async testResyncTriggersRuntimeEpochAndSeqGap() { |
| 734 | const label = "Resync triggers (runtime_epoch mismatch + seq gap)"; |
| 735 | if (typeof syncStore._handlePush !== "function") { |
| 736 | return { ok: false, label, error: "syncStore._handlePush not available" }; |
| 737 | } |
| 738 | const originalSendStateRequest = syncStore.sendStateRequest; |
| 739 | const savedMode = syncStore.mode; |
| 740 | const savedEpoch = syncStore.runtimeEpoch; |
| 741 | const savedSeq = syncStore.lastSeq; |
| 742 | const savedNeedsHandshake = syncStore.needsHandshake; |
| 743 | let calls = []; |
| 744 | try { |
| 745 | if (typeof originalSendStateRequest !== "function") { |
| 746 | return { ok: false, label, error: "syncStore.sendStateRequest not available" }; |
| 747 | } |
| 748 | |
| 749 | syncStore.sendStateRequest = async (options = {}) => { |
| 750 | calls.push(options); |
| 751 | }; |
| 752 | |
| 753 | // Case 1: runtime_epoch mismatch should trigger resync. |
| 754 | calls = []; |
| 755 | syncStore.mode = "HEALTHY"; |
| 756 | syncStore.runtimeEpoch = "epoch-a"; |
| 757 | syncStore.lastSeq = 10; |
| 758 | await syncStore._handlePush({ data: { runtime_epoch: "epoch-b", seq: 11 } }); |
| 759 | const runtimeTriggered = calls.length === 1 && calls[0] && calls[0].forceFull === true; |
| 760 | if (!runtimeTriggered) { |
| 761 | return { ok: false, label, error: "runtime_epoch mismatch did not trigger state_request resync" }; |
| 762 | } |
| 763 | if (syncStore.mode !== "HANDSHAKE_PENDING") { |
| 764 | return { ok: false, label, error: "runtime_epoch resync did not set HANDSHAKE_PENDING" }; |
| 765 | } |
| 766 | |
| 767 | // Case 2: seq gap should trigger resync. |
| 768 | calls = []; |
| 769 | syncStore.mode = "HEALTHY"; |
| 770 | syncStore.runtimeEpoch = "epoch-a"; |
| 771 | syncStore.lastSeq = 10; |
| 772 | await syncStore._handlePush({ data: { runtime_epoch: "epoch-a", seq: 12 } }); |
| 773 | const seqTriggered = calls.length === 1 && calls[0] && calls[0].forceFull === true; |
| 774 | if (!seqTriggered) { |
| 775 | return { ok: false, label, error: "seq gap did not trigger state_request resync" }; |
| 776 | } |
| 777 | if (syncStore.mode !== "HANDSHAKE_PENDING") { |
| 778 | return { ok: false, label, error: "seq gap resync did not set HANDSHAKE_PENDING" }; |
| 779 | } |
| 780 | |
| 781 | return { ok: true, label }; |
| 782 | } catch (error) { |
| 783 | this.appendLog(`${label} failed: ${error.message || error}`); |
| 784 | return { ok: false, label, error: error.message || error }; |
| 785 | } finally { |
| 786 | syncStore.sendStateRequest = originalSendStateRequest; |
| 787 | // Restore syncStore state that the test mutated to avoid leaving the |
| 788 | // UI stuck in HANDSHAKE_PENDING after the suite finishes. |
| 789 | syncStore.runtimeEpoch = savedEpoch; |
| 790 | syncStore.lastSeq = savedSeq; |
| 791 | syncStore.needsHandshake = savedNeedsHandshake; |
| 792 | // Re-establish a real handshake to return to HEALTHY. |
| 793 | // Clear any dangling retry state before attempting recovery. |
| 794 | syncStore._clearHandshakeRetry(); |
| 795 | syncStore._handshakeRetryAttempt = 0; |
| 796 | syncStore._handshakeFailureCount = 0; |
| 797 | try { |
| 798 | await syncStore.sendStateRequest({ forceFull: true }); |
| 799 | } catch (_) { |
| 800 | // Best-effort recovery; let the normal retry mechanism take over |
| 801 | // instead of forcing HEALTHY with stale handshake state. |
| 802 | syncStore.needsHandshake = true; |
| 803 | } |
| 804 | } |
| 805 | }, |
| 806 | |
| 807 | async ensureSubscribed(eventType, reset = false) { |
| 808 | if (!this._subscriptionHandlers || typeof this._subscriptionHandlers !== "object") { |
| 809 | this._subscriptionHandlers = {}; |
| 810 | } |
| 811 | |
| 812 | const existing = this._subscriptionHandlers[eventType]; |
| 813 | if (reset && typeof existing === "function") { |
| 814 | clientForEventType(eventType).off(eventType, existing); |
| 815 | delete this._subscriptionHandlers[eventType]; |
| 816 | } else if (!reset && typeof existing === "function") { |
| 817 | return; |
| 818 | } |
| 819 | |
| 820 | const handler = (payload) => { |
| 821 | try { |
| 822 | const envelope = validateServerEnvelope(payload); |
| 823 | if (!Array.isArray(this.receivedBroadcasts)) { |
| 824 | this.receivedBroadcasts = []; |
| 825 | } |
| 826 | this._broadcastSeq = (this._broadcastSeq || 0) + 1; |
| 827 | const id = envelope?.eventId |
| 828 | ? `${eventType}-${envelope.eventId}` |
| 829 | : `${eventType}-${this._broadcastSeq}`; |
| 830 | this.receivedBroadcasts.push({ |
| 831 | id, |
| 832 | eventType, |
| 833 | payload: envelope, |
| 834 | timestamp: now(), |
| 835 | }); |
| 836 | } catch (error) { |
| 837 | this.appendLog(`Received invalid envelope for ${eventType}: ${error.message || error}`); |
| 838 | } |
| 839 | }; |
| 840 | |
| 841 | this._subscriptionHandlers[eventType] = handler; |
| 842 | await clientForEventType(eventType).on(eventType, handler); |
| 843 | }, |
| 844 | |
| 845 | waitForEvent(eventType, predicate, timeout = 1500) { |
| 846 | return new Promise((resolve) => { |
| 847 | const client = clientForEventType(eventType); |
| 848 | let timer; |
| 849 | let done = false; |
| 850 | let handler = null; |
| 851 | |
| 852 | const finish = (ok) => { |
| 853 | if (done) return; |
| 854 | done = true; |
| 855 | if (timer) clearTimeout(timer); |
| 856 | if (typeof handler === "function") { |
| 857 | client.off(eventType, handler); |
| 858 | } |
| 859 | resolve(ok); |
| 860 | }; |
| 861 | |
| 862 | handler = (data) => { |
| 863 | let envelope; |
| 864 | try { |
| 865 | envelope = validateServerEnvelope(data); |
| 866 | } catch (error) { |
| 867 | this.appendLog(`Skipping invalid envelope for ${eventType}: ${error.message || error}`); |
| 868 | return; |
| 869 | } |
| 870 | |
| 871 | if (predicate(envelope.data, envelope)) { |
| 872 | finish(true); |
| 873 | } |
| 874 | }; |
| 875 | |
| 876 | const onPromise = client.on(eventType, handler); |
| 877 | if (onPromise && typeof onPromise.then === "function") { |
| 878 | onPromise.catch((error) => { |
| 879 | this.appendLog(`Failed to subscribe to ${eventType}: ${error.message || error}`); |
| 880 | finish(false); |
| 881 | }); |
| 882 | } |
| 883 | |
| 884 | timer = setTimeout(() => { |
| 885 | finish(false); |
| 886 | }, timeout); |
| 887 | }); |
| 888 | }, |
| 889 | |
| 890 | async runManualEmit() { |
| 891 | await this.manualStep(this.testEmit.bind(this)); |
| 892 | }, |
| 893 | |
| 894 | async runManualRequest() { |
| 895 | await this.manualStep(this.testRequest.bind(this)); |
| 896 | }, |
| 897 | |
| 898 | async runManualRequestTimeout() { |
| 899 | await this.manualStep(this.testRequestTimeout.bind(this)); |
| 900 | }, |
| 901 | |
| 902 | async runManualPersistence() { |
| 903 | await this.manualStep(this.testSubscriptionPersistence.bind(this)); |
| 904 | }, |
| 905 | |
| 906 | async runManualRequestAll() { |
| 907 | await this.manualStep(this.testRequestAll.bind(this)); |
| 908 | }, |
| 909 | |
| 910 | async runManualStateSync() { |
| 911 | await this.manualStep(this.testStateSyncNoPollHealthy.bind(this)); |
| 912 | }, |
| 913 | |
| 914 | async runManualContextSwitch() { |
| 915 | await this.manualStep(this.testContextSwitchNoLeak.bind(this)); |
| 916 | }, |
| 917 | |
| 918 | async runManualFallbackRecovery() { |
| 919 | await this.manualStep(this.testFallbackRecoveryDegraded.bind(this)); |
| 920 | }, |
| 921 | |
| 922 | async runManualResyncTriggers() { |
| 923 | await this.manualStep(this.testResyncTriggersRuntimeEpochAndSeqGap.bind(this)); |
| 924 | }, |
| 925 | |
| 926 | async triggerBroadcastDemo() { |
| 927 | this.assertEnabled(); |
| 928 | try { |
| 929 | await this.ensureConnected(); |
| 930 | await this.ensureSubscribed("ws_tester_broadcast_demo"); |
| 931 | const options = { |
| 932 | correlationId: createCorrelationId("harness-demo"), |
| 933 | }; |
| 934 | await websocket.emit( |
| 935 | "ws_tester_broadcast_demo_trigger", |
| 936 | { requested_at: now() }, |
| 937 | options, |
| 938 | ); |
| 939 | await this._toast("info", "Broadcast demo triggered. Check log output.", "WebSocket Harness"); |
| 940 | } catch (error) { |
| 941 | await this._toast("error", `Broadcast demo failed: ${error.message || error}`, "WebSocket Harness"); |
| 942 | this.appendLog(`Broadcast demo failed: ${error.message || error}`); |
| 943 | } |
| 944 | }, |
| 945 | |
| 946 | payloadSizePreview(input) { |
| 947 | return payloadSize(input); |
| 948 | }, |
| 949 | }; |
| 950 | |
| 951 | const store = createStore("websocketTesterStore", model); |
| 952 | export { store }; |