main
js 952 lines 32.5 KB
Raw
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 };