fix(client): batch of 15 client correctness fixes (#1367)

* fix(client): 1 defect(s) (OC-0078)

* fix(client): 1 defect(s) (OC-0147)

* fix(client): 1 defect(s) (OC-0141)

* fix(voice): 1 defect(s) (OC-0125)

* fix(client): 1 defect(s) (OC-0138)

* fix(client): 1 defect(s) (OC-0136)

* fix(client): 1 defect(s) (OC-0121)

* fix(voice): 1 defect(s) (OC-0132)

* fix(client): 1 defect(s) (OC-0057)

* fix(client): 1 defect(s) (OC-0122)

* fix(client): 1 defect(s) (OC-0130)

* fix(client): 1 defect(s) (OC-0060)

* fix(client): 1 defect(s) (OC-0123)

* fix(client): 1 defect(s) (OC-0124)

* fix(ws): 1 defect(s) (OC-0056)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-14 08:11:16 +02:00
committed by GitHub
co-authored by Claude
parent b1fb56511d
commit c3837fa32c
30 changed files with 1060 additions and 46 deletions
@@ -657,6 +657,13 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
let renderAllRunning = false;
let renderAllCount = 0;
let renderAllResetTimer = 0;
// Set when the rapid-fire breaker below drops a renderAll() call on the
// floor. The store change that triggered the dropped call is still live —
// without this, the DOM is left showing pre-burst state until some later,
// unrelated store event happens to call renderAll() again. The 2s reset
// timeout checks this flag and issues one final renderAll() so the burst's
// last state always makes it to the screen.
let renderAllSuppressed = false;
function renderAll(): void {
if (root === null) return;
@@ -667,12 +674,18 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
renderAllCount++;
if (renderAllCount > 20) {
log.error("[MessageList] renderAll called >20 times in 2s — breaking loop");
renderAllSuppressed = true;
return;
}
if (renderAllResetTimer === 0) {
renderAllResetTimer = window.setTimeout(() => {
renderAllCount = 0;
renderAllResetTimer = 0;
if (renderAllSuppressed) {
// Render the burst's final state once, now that it's over.
renderAllSuppressed = false;
renderAll();
}
}, 2000);
}
@@ -93,15 +93,6 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
appendChildren(item, header, content);
item.addEventListener(
"click",
() => {
options.onSelectResult(r);
options.onClose();
},
{ signal },
);
resultsDiv.appendChild(item);
}
}
@@ -200,6 +191,26 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
}
}
// Single delegated listener for the results container, registered once at
// mount time. renderResults() re-creates row elements on every search and
// on every arrow-key navigation, so binding a listener directly to each row
// would re-register (and never release) one abort algorithm per discarded
// row for the lifetime of the overlay.
function handleResultsClick(e: MouseEvent): void {
const target = e.target;
if (!(target instanceof Element)) return;
const row = target.closest(".search-result-item");
if (!row) return;
const testId = row.getAttribute("data-testid");
if (!testId) return;
const idx = Number(testId.slice("search-result-".length));
const r = results[idx];
if (r !== undefined) {
options.onSelectResult(r);
options.onClose();
}
}
function mount(container: Element): void {
root = createElement("div", {
class: "search-overlay open",
@@ -234,6 +245,7 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
input.addEventListener("input", handleInput, { signal });
input.addEventListener("keydown", handleKeydown, { signal });
root.addEventListener("click", handleBackdropClick, { signal });
resultsDiv.addEventListener("click", handleResultsClick, { signal });
requestAnimationFrame(() => input.focus());
}
@@ -238,9 +238,15 @@ function buildAutostartRow(signal: AbortSignal): HTMLDivElement {
// Starts off; corrected to the real OS state once the plugin answers.
let enabled = false;
// Set as soon as the user interacts with the toggle. Guards the init
// read-back below so a slow `isEnabled()` resolving after the user has
// already flipped the switch can't clobber their change with a stale
// value (see OC-0141).
let touched = false;
const toggle = createToggle(false, {
signal,
onChange: (nowOn) => {
touched = true;
void (async () => {
try {
const { enable, disable } = await import("@tauri-apps/plugin-autostart");
@@ -261,7 +267,12 @@ function buildAutostartRow(signal: AbortSignal): HTMLDivElement {
void (async () => {
try {
const { isEnabled } = await import("@tauri-apps/plugin-autostart");
enabled = await isEnabled();
const initialEnabled = await isEnabled();
// If the user already toggled this before the read-back resolved,
// their change (and whatever it settles to) wins — don't overwrite it
// with the value read before that change was applied.
if (touched) return;
enabled = initialEnabled;
toggle.classList.toggle("on", enabled);
toggle.setAttribute("aria-checked", String(enabled));
} catch {
@@ -487,9 +487,11 @@ function buildVoiceAudioTabInner(
startCameraPreview(savedVideoDevice);
}
signal.addEventListener("abort", () => {
stopCameraPreview();
});
// Camera teardown on overlay close is already covered by the factory's
// single signal.addEventListener("abort", cleanupMic) — registering here
// too would add one more permanent listener (and retain this build's DOM
// subtree via closure) every time the tab is rebuilt, since `signal` is
// shared for the whole overlay lifetime, not per-build.
// Start mic level monitoring for visual feedback
void (async () => {
+12 -1
View File
@@ -79,7 +79,18 @@ const log = createLogger("api");
export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: OnUnauthorized) {
// oxlint-disable-next-line consistent-function-scoping -- co-located with createApiClient for encapsulation
function isValidHost(host: string): boolean {
return /^[\w.-]+(:\d+)?$/.test(host) && host.length <= 253;
if (host.length > 253) return false;
// Bracketed IPv6 literal ("[::1]" or "[::1]:8443") — same convention as
// livekitSession.ts's ensureLiveKitProxy and http_proxy.rs /
// livekit_proxy.rs's validate_remote_host + parse_server_name.
if (/^\[[0-9A-Fa-f:.]+\](:\d+)?$/.test(host)) return true;
// Bare (unbracketed) IPv6 literal, e.g. "2001:db8::1" or "::1". More than
// one colon means the whole string is the address — a single colon is
// reserved for the host:port separator below, matching how
// ensureLiveKitProxy tells "[::1]:port" apart from "host:port".
if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;
// DNS name or IPv4 literal, optionally with a port.
return /^[\w.-]+(:\d+)?$/.test(host);
}
let config = { ...initialConfig };
@@ -5,17 +5,34 @@
*/
import { setActiveChannel, clearUnread, channelsStore } from "@stores/channels.store";
import { clearDmUnread } from "@stores/dm.store";
import { clearDmUnread, dmStore, dmDisplayName } from "@stores/dm.store";
// lib -> pages import: addDmToChannelsStore is the only place that
// synthesizes a DM's channelsStore mirror row. dispatcher.ts already crosses
// this same boundary for exactly this reason (see its DM_CHANNEL_CLOSE
// handler) — a DM that `ready` reported in dmStore but that the user has not
// yet opened this session has no mirror row until one of these two call
// sites creates it.
import { addDmToChannelsStore } from "@pages/main-page/SidebarDmHelpers";
/**
* Activate `channelId`, clearing its unread and mention badges.
*
* No-op for an id the channel store does not know: the caller resolved a name
* that no longer exists, and blanking the active channel would be worse than
* staying put.
* A channel absent from channelsStore is not necessarily invisible to the
* user: a DM's row there is only synthesized on open (addDmToChannelsStore),
* while dmStore carries every DM the user is a member of from the moment
* `ready` lands. Fall back to dmStore and synthesize the mirror row so a
* jump (permalink, search hit, pinned, reply) into a DM the user has not
* clicked yet this session still lands, instead of degrading as if the
* channel did not exist. True no-op only when neither store has it: the
* caller resolved an id that no longer exists, and blanking the active
* channel would be worse than staying put.
*/
export function navigateToChannel(channelId: number): void {
if (!channelsStore.getState().channels.has(channelId)) return;
if (!channelsStore.getState().channels.has(channelId)) {
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
if (dm === undefined) return;
addDmToChannelsStore(dm);
}
setActiveChannel(channelId);
clearUnread(channelId);
// findChannelById does not filter out DM mirrors, so a jump (permalink,
@@ -32,10 +49,19 @@ export function navigateToChannel(channelId: number): void {
* than a name (message permalinks). Returns null when the channel is not in
* this user's channel list — a permalink to somewhere they cannot see must
* degrade quietly, not render a chip that goes nowhere.
*
* Falls back to dmStore when channelsStore has no row: a DM's channelsStore
* mirror is synthesized only on open (addDmToChannelsStore), but dmStore
* already knows every DM the user belongs to from `ready`. Without this, a
* jump into a DM never opened this session reads as "not visible" even
* though the user is a member and the server will happily serve its
* messages.
*/
export function findChannelById(channelId: number): { id: number; name: string } | null {
const ch = channelsStore.getState().channels.get(channelId);
return ch === undefined ? null : { id: ch.id, name: ch.name };
if (ch !== undefined) return { id: ch.id, name: ch.name };
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
return dm === undefined ? null : { id: dm.channelId, name: dmDisplayName(dm) };
}
/**
+12 -2
View File
@@ -163,11 +163,17 @@ export function createConnectionStatsPoller(getRoom: () => Room | null): Connect
listeners.forEach((cb) => cb(current));
// Debounced quality change notification (prevents toast spam on flapping)
// Debounced quality change notification (prevents toast spam on flapping).
// Only arm the timer when none is already pending — POLL_INTERVAL_MS (2000)
// is shorter than QUALITY_DEBOUNCE_MS (3000), so re-arming on every poll
// that still disagrees with lastQuality would push the deadline out
// forever and the timer would never fire. If quality returns to
// lastQuality before the timer elapses, cancel it instead.
const newQuality = current.quality;
if (newQuality !== lastQuality) {
if (qualityDebounceTimer !== null) clearTimeout(qualityDebounceTimer);
if (qualityDebounceTimer === null) {
qualityDebounceTimer = setTimeout(() => {
qualityDebounceTimer = null;
if (current.quality !== lastQuality) {
const prevQuality = lastQuality;
lastQuality = current.quality;
@@ -175,6 +181,10 @@ export function createConnectionStatsPoller(getRoom: () => Room | null): Connect
}
}, QUALITY_DEBOUNCE_MS);
}
} else if (qualityDebounceTimer !== null) {
clearTimeout(qualityDebounceTimer);
qualityDebounceTimer = null;
}
}
function start(): void {
+35 -6
View File
@@ -23,6 +23,12 @@ export interface ContextMenuOptions {
readonly className?: string;
}
// Tracks each open menu's per-invocation dismiss controller, so a menu swept
// away by a same-class reopen (see below) can release its own teardown
// listener on the caller's signal instead of leaving it pinned until the
// caller's signal eventually aborts.
const dismissControllers = new WeakMap<Element, AbortController>();
/**
* Show a context menu at the given coordinates.
* Automatically removes any existing menu with the same className.
@@ -32,13 +38,21 @@ export function showContextMenu(opts: ContextMenuOptions): void {
const { x, y, items, signal, className } = opts;
const menuClass = className ?? "context-menu";
// Remove any existing context menu with same class
document.querySelectorAll(`.${menuClass}`).forEach((el) => el.remove());
// Remove any existing context menu with same class, releasing its dismiss
// controller so its teardown listener on the caller's signal is dropped
// now rather than lingering until the caller itself is destroyed.
document.querySelectorAll(`.${menuClass}`).forEach((el) => {
dismissControllers.get(el)?.abort();
el.remove();
});
const menu = createElement("div", { class: `context-menu ${menuClass}` });
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
const dismissAc = new AbortController();
dismissControllers.set(menu, dismissAc);
let hasSeparator = false;
for (const item of items) {
if (hasSeparator && item.danger) {
@@ -69,7 +83,6 @@ export function showContextMenu(opts: ContextMenuOptions): void {
document.body.appendChild(menu);
// Close on click outside (deferred so the opening click doesn't immediately close)
const dismissAc = new AbortController();
setTimeout(() => {
if (dismissAc.signal.aborted) return;
document.addEventListener(
@@ -84,9 +97,25 @@ export function showContextMenu(opts: ContextMenuOptions): void {
);
}, 0);
// Clean up if parent component is destroyed
signal.addEventListener("abort", () => {
// Clean up if parent component is destroyed. If the caller's signal is
// already aborted, "abort" already fired and would never reach a listener
// added now, so tear down immediately instead of registering one. When it
// isn't, tie the listener's own lifetime to dismissAc: once the menu is
// dismissed some other way (item click, outside click), dismissAc aborts
// and this listener is dropped from the caller's signal instead of
// lingering — with its closure over `menu` — for the rest of the caller's
// lifetime.
if (signal.aborted) {
menu.remove();
dismissAc.abort();
});
} else {
signal.addEventListener(
"abort",
() => {
menu.remove();
dismissAc.abort();
},
{ once: true, signal: dismissAc.signal },
);
}
}
@@ -55,6 +55,7 @@ import {
setDmChannels,
addDmChannel,
closeDmLocally,
clearDmUnread,
updateDmLastMessage,
updateDmLastMessagePreview,
incrementDmMention,
@@ -447,6 +448,14 @@ export function wireDispatcher(
// activating it lands on an id ChannelController can't resolve and
// blanks the chat area with no way to recover.
addDmToChannelsStore(remaining[0]!);
// A DM's unread badge lives in dmStore, not the channelsStore
// mirror — setActiveChannel only zeroes the latter. Every other
// "open this DM" path (selectDmConversation, navigateToChannel,
// markChannelRead) pairs activation with clearDmUnread for exactly
// this reason; without it here the badge on the DM we're about to
// treat as active survives forever (new messages take the
// isDmActive branch below and never increment it back).
clearDmUnread(remaining[0]!.channelId);
setActiveChannel(remaining[0]!.channelId);
return;
}
@@ -67,14 +67,12 @@ function freezeImage(img: HTMLImageElement, entry: MediaEntry): void {
}
entry.isPlaying = false;
if (img.src === entry.originalSrc) {
if (entry.frozenSrc === null) {
entry.frozenSrc = captureStaticFrame(img);
}
if (entry.frozenSrc !== null) {
if (entry.frozenSrc !== null && img.src !== entry.frozenSrc) {
img.src = entry.frozenSrc;
}
}
updateButton(entry);
}
+24 -2
View File
@@ -103,6 +103,21 @@ function isValidStoredData(data: unknown): data is StoredData {
);
}
/**
* Validates only the persistence envelope shape (schema version + a
* profiles array), without requiring every individual profile inside it to
* be well-formed. Used to tell "nothing/garbage was stored" apart from "a
* valid envelope containing some malformed entries" — the latter should
* have only the bad entries dropped, not the whole envelope discarded.
*/
function isValidStoredEnvelope(
data: unknown,
): data is { schemaVersion: number; profiles: unknown[] } {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return typeof obj.schemaVersion === "number" && Array.isArray(obj.profiles);
}
// ---------------------------------------------------------------------------
// Default Tauri persistence backend
// ---------------------------------------------------------------------------
@@ -114,8 +129,15 @@ export function createTauriBackend(): PersistenceBackend {
const settings = await invoke<Record<string, unknown>>("get_settings");
const raw = settings[STORAGE_KEY];
if (raw === undefined || raw === null) return null;
if (isValidStoredData(raw)) return raw;
return null;
if (!isValidStoredEnvelope(raw)) return null;
// The envelope itself is well-formed; salvage whichever individual
// profiles are valid rather than discarding the entire stored list
// because one entry is malformed (see OC-0060). Mirrors the per-item
// tolerance importProfiles() already has.
return {
schemaVersion: raw.schemaVersion,
profiles: raw.profiles.filter(isValidProfileShape),
};
},
async save(data: StoredData): Promise<void> {
const { invoke } = await import("@tauri-apps/api/core");
+24 -1
View File
@@ -95,6 +95,15 @@ function cancelPendingMarkAll(): void {
pendingMarkAll = [];
}
/** Sum of a channel or DM's unread + mention counts, from whichever store
* knows it. 0 for a channel this client does not (or no longer) know. */
function unreadTotal(channelId: number): number {
const ch = channelsStore.getState().channels.get(channelId);
if (ch !== undefined) return ch.unreadCount + ch.mentionCount;
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
return dm !== undefined ? dm.unreadCount + dm.mentionCount : 0;
}
/**
* Mark every unread channel and DM read. Returns how many were marked, so the
* caller can stay silent when there was nothing to do.
@@ -103,6 +112,15 @@ function cancelPendingMarkAll(): void {
* apart — see the budget note above. Each channel's local badge is cleared at
* the moment its own frame actually goes out, not up front, so a channel
* whose send hasn't fired yet still shows unread rather than lying about it.
*
* The deferred tail snapshots each channel's unread+mention total at click
* time and skips its send if that total has grown by the time the timer
* fires: a message that arrives during the pacing window postdates the click
* and was never seen, so marking it read would silently wipe a genuinely-new
* badge (and tell the server the user has read a message they never saw).
* The synchronous first burst has no such window — nothing can arrive between
* scheduling and firing in the same tick — so it stays unconditional, as does
* every other caller of `markChannelRead`.
*/
export function markAllRead(): number {
// A second click supersedes the first: its own `unreadChannelIds()` already
@@ -115,7 +133,12 @@ export function markAllRead(): number {
if (delay === 0) {
markChannelRead(id);
} else {
pendingMarkAll.push(setTimeout(() => markChannelRead(id), delay));
const snapshot = unreadTotal(id);
pendingMarkAll.push(
setTimeout(() => {
if (unreadTotal(id) <= snapshot) markChannelRead(id);
}, delay),
);
}
}
return ids.length;
@@ -247,6 +247,7 @@ function hidePreview(row: HTMLElement): void {
const state = previewTimers.get(row);
if (state !== undefined) {
clearTimeout(state.debounce);
clearTimeout(state.animation);
if (state.trackCleanup !== null) {
state.trackCleanup();
state.trackCleanup = null;
+23
View File
@@ -483,6 +483,10 @@ export function createWsClient() {
async function connect(cfg: WsClientConfig): Promise<void> {
wsGeneration++;
// Captured so a disconnect() landing mid-await (this function has three
// await points below) can be detected on resume — disconnect() bumps
// wsGeneration too, so a mismatch here means this attempt was cancelled.
const gen = wsGeneration;
config = cfg;
intentionalClose = false;
// Belt-and-braces: a fresh connect (even one not routed through
@@ -494,6 +498,11 @@ export function createWsClient() {
setState("connecting");
await ensureTauriApis();
if (gen !== wsGeneration) {
// A disconnect() (or a newer connect()) landed while we were
// suspended here — this attempt is cancelled, do not proceed.
return;
}
if (tauriInvoke === null) {
log.error("Tauri APIs not available, cannot connect WebSocket");
setState("disconnected");
@@ -510,6 +519,14 @@ export function createWsClient() {
// Set up event listeners before connecting
cleanupEventListeners();
await setupEventListeners();
if (gen !== wsGeneration) {
// Cancelled while awaiting the Tauri IPC round trips inside
// setupEventListeners(). Tear down the listeners this (now-stale)
// attempt just registered instead of leaving them until the next
// connect() happens to clean them up.
cleanupEventListeners();
return;
}
try {
await tauriInvoke("ws_connect", { url: wsUrl });
@@ -583,6 +600,12 @@ export function createWsClient() {
}
function disconnect(): void {
// Invalidate any connect() suspended mid-await (e.g. cancelled
// auto-login, logout racing a fresh connect) so it notices on resume
// instead of finishing setup and opening the very socket this teardown
// was meant to prevent. See setupEventListeners()'s tauriListen guards
// and connect()'s own gen checks.
wsGeneration++;
intentionalClose = true;
log.info("WebSocket disconnecting (intentional)", { host: config?.host ?? "unknown" });
certMismatchBlock = false;
+8
View File
@@ -666,6 +666,14 @@
bottom: -1px;
right: -1px;
}
/* UserBar.ts toggles this whenever canSetStatus() is false (socket down, or
no ws send path at all) -- without it the dot, dropdown options and
custom-status input all stay fully interactive, so anything picked or
typed while disconnected is silently dropped instead of refused. */
.user-bar .ub-status-picker-wrap.ub-status-picker--disabled {
pointer-events: none;
opacity: 0.5;
}
.user-bar .ub-info {
flex: 1;
min-width: 0;
@@ -9,6 +9,9 @@ const {
mockClearEmbedCaches,
mockClearMediaCaches,
deleteDbState,
mockIsEnabled,
mockEnable,
mockDisable,
} = vi.hoisted(() => ({
mockReadDir: vi.fn().mockResolvedValue([]),
mockRemove: vi.fn().mockResolvedValue(undefined),
@@ -26,6 +29,9 @@ const {
| "blocked-double"
| "success-then-blocked",
},
mockIsEnabled: vi.fn().mockResolvedValue(false),
mockEnable: vi.fn().mockResolvedValue(undefined),
mockDisable: vi.fn().mockResolvedValue(undefined),
}));
// Mock Tauri APIs
@@ -43,6 +49,11 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
vi.mock("@tauri-apps/plugin-process", () => ({
relaunch: mockRelaunch,
}));
vi.mock("@tauri-apps/plugin-autostart", () => ({
isEnabled: mockIsEnabled,
enable: mockEnable,
disable: mockDisable,
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
@@ -731,3 +742,93 @@ describe("AdvancedTab — Toggles & Structure", () => {
expect(invoke).toHaveBeenCalledWith("open_devtools");
});
});
describe("AdvancedTab — Launch on Login (autostart)", () => {
let container: HTMLDivElement;
const ac = new AbortController();
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockIsEnabled.mockReset().mockResolvedValue(false);
mockEnable.mockReset().mockResolvedValue(undefined);
mockDisable.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
container.remove();
});
function getAutostartToggle(): HTMLElement {
const section = buildAdvancedTab(ac.signal);
container.appendChild(section);
const rows = Array.from(container.querySelectorAll(".setting-row"));
const row = rows.find(
(r) => r.querySelector(".setting-label")?.textContent === "Launch on Login",
);
expect(row).toBeDefined();
return row!.querySelector(".toggle") as HTMLElement;
}
async function tick(times = 1): Promise<void> {
for (let i = 0; i < times; i++) {
await new Promise((r) => setTimeout(r, 0));
}
}
it("keeps the toggle ON when the user enables autostart before the init read-back resolves", async () => {
let resolveIsEnabled!: (v: boolean) => void;
const isEnabledPromise = new Promise<boolean>((r) => {
resolveIsEnabled = r;
});
mockIsEnabled.mockReturnValueOnce(isEnabledPromise);
const toggle = getAutostartToggle();
// Let the init IIFE's dynamic import resolve and its isEnabled() call get
// issued, but don't resolve it yet — this is the window the finding
// describes.
await tick(2);
// User clicks during that window: turns autostart ON.
toggle.click();
expect(toggle.classList.contains("on")).toBe(true);
// Let the click's own async chain (dynamic import + enable()) fully
// resolve, so the OS-level write really has completed.
await tick(3);
expect(mockEnable).toHaveBeenCalledTimes(1);
// Now the stale init read finally resolves with the pre-click value.
resolveIsEnabled(false);
await tick(3);
// The click already turned autostart on for real — the late, stale read
// must not clobber that visual/state.
expect(toggle.classList.contains("on")).toBe(true);
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("still applies a fresh init read-back when the user hasn't touched the toggle", async () => {
mockIsEnabled.mockResolvedValueOnce(true);
const toggle = getAutostartToggle();
await tick(3);
expect(toggle.classList.contains("on")).toBe(true);
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("still reverts the toggle when the OS write itself fails", async () => {
mockEnable.mockRejectedValueOnce(new Error("not permitted"));
const toggle = getAutostartToggle();
await tick(3);
toggle.click();
await tick(3);
expect(toggle.classList.contains("on")).toBe(false);
expect(toggle.getAttribute("aria-checked")).toBe("false");
});
});
@@ -345,6 +345,36 @@ describe("API Client", () => {
const headers = fetchCallOpts().headers as Record<string, string>;
expect(headers["Authorization"]).toBe("Bearer fresh-token");
});
// OC-0136: every other layer of this client (livekitSession.ts's
// ensureLiveKitProxy, http_proxy.rs / livekit_proxy.rs's
// validate_remote_host + parse_server_name) deliberately accepts IPv6
// literals, bracketed or bare. setConfig's host gate must not be the one
// place that refuses — otherwise login/register/auto-login to an IPv6
// server throws "Invalid host format" even though the health check
// (which tunnels through the same Rust proxy) already reported it
// reachable.
describe("host validation accepts IPv6 literals", () => {
it("accepts a bracketed IPv6 literal with a port", () => {
expect(() => api.setConfig({ host: "[::1]:8443" })).not.toThrow();
});
it("accepts a bracketed IPv6 literal without a port", () => {
expect(() => api.setConfig({ host: "[fd00::1]" })).not.toThrow();
});
it("accepts a bare (unbracketed) IPv6 literal", () => {
expect(() => api.setConfig({ host: "2001:db8::1" })).not.toThrow();
});
it("still rejects hosts with disallowed characters", () => {
expect(() => api.setConfig({ host: "evil host name" })).toThrow("Invalid host format");
});
it("still rejects hosts that could inject headers", () => {
expect(() => api.setConfig({ host: "evil\r\nhost:8443" })).toThrow("Invalid host format");
});
});
});
describe("user endpoints", () => {
@@ -103,6 +103,29 @@ describe("channel-navigation", () => {
expect(dmStore.getState().channels.find((c) => c.channelId === 50)?.unreadCount).toBe(3);
});
// OC-0121: a DM present in dmStore from `ready` but never opened this
// session (so never selected via selectDmConversation) has no mirror row
// in channelsStore yet. A jump affordance (permalink, search hit, pinned,
// reply) must still be able to activate it instead of silently no-op'ing.
it("activates a DM known only to dmStore by synthesizing its channelsStore mirror", () => {
setDmChannels([
makeDm({
channelId: 50,
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
unreadCount: 3,
mentionCount: 1,
}),
]);
navigateToChannel(50);
expect(channelsStore.getState().activeChannelId).toBe(50);
expect(channelsStore.getState().channels.get(50)?.name).toBe("bob");
const dm = dmStore.getState().channels.find((c) => c.channelId === 50);
expect(dm?.unreadCount).toBe(0);
expect(dm?.mentionCount).toBe(0);
});
});
describe("findChannelById", () => {
@@ -118,6 +141,21 @@ describe("channel-navigation", () => {
it("returns null for an unknown id", () => {
expect(findChannelById(999)).toBeNull();
});
// OC-0121: findChannelById gated on channelsStore alone, so a DM whose
// mirror row has not been synthesized yet (never opened this session)
// read as "not visible" even though the user is a member — the first
// check in MessageJump.jumpTo rejects it before getMessagesAround is
// ever called.
it("resolves a DM known only to dmStore, not yet mirrored into channelsStore", () => {
setDmChannels([
makeDm({
channelId: 50,
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
}),
]);
expect(findChannelById(50)).toEqual({ id: 50, name: "bob" });
});
});
describe("findChannelByName", () => {
@@ -361,6 +361,66 @@ describe("createConnectionStatsPoller", () => {
expect(qualityCb).toHaveBeenCalledWith("bad", "excellent");
});
it("fires quality change callback once the debounce elapses, even though the room stays active and every 2s poll keeps re-reporting the changed quality (OC-0132)", async () => {
// Regression test for OC-0132: POLL_INTERVAL_MS (2000) < QUALITY_DEBOUNCE_MS (3000)
// means that while the room stays active and quality remains different from
// lastQuality, every poll must NOT keep clearing/rescheduling the debounce
// timer — otherwise the timer can never reach its 3s deadline and the
// quality-change notification never fires.
let currentRtt = 0.01;
const room = {
engine: {
pcManager: {
publisher: {
pc: {
getStats: vi.fn().mockImplementation(() => {
const report = new Map();
report.set("cp1", {
type: "candidate-pair",
currentRoundTripTime: currentRtt,
bytesSent: 0,
bytesReceived: 0,
});
return Promise.resolve(report);
}),
},
},
},
},
};
const qualityCb = vi.fn();
poller = createConnectionStatsPoller(() => room as any);
poller.onQualityChanged(qualityCb);
poller.start();
// t=2000: first poll establishes "excellent" baseline.
await vi.advanceTimersByTimeAsync(2100);
expect(qualityCb).not.toHaveBeenCalled();
// Degrade — the room stays active, so every subsequent 2s poll will keep
// observing "bad" (different from lastQuality "excellent") for as long as
// the connection stays degraded.
currentRtt = 0.5;
// t=4000: quality first observed as "bad" -> debounce timer armed for t=7000.
await vi.advanceTimersByTimeAsync(2100);
expect(qualityCb).not.toHaveBeenCalled();
// t=6000: quality is STILL "bad" (still != lastQuality). Under the bug this
// poll clears the pending timer and reschedules a fresh 3s one, pushing the
// deadline out indefinitely for as long as the connection stays bad.
await vi.advanceTimersByTimeAsync(2100);
expect(qualityCb).not.toHaveBeenCalled();
// t=8000: the original debounce deadline (t=7000) has now passed. The room
// never went inactive and never stopped reporting "bad" in between, so the
// only way this fires is if same-quality polls stopped resetting the timer.
await vi.advanceTimersByTimeAsync(2100);
expect(qualityCb).toHaveBeenCalledTimes(1);
expect(qualityCb).toHaveBeenCalledWith("bad", "excellent");
});
it("unsubscribed onUpdate callback is not called", async () => {
const room = createMockRoom([
{
@@ -315,4 +315,116 @@ describe("showContextMenu", () => {
const menu = document.body.querySelector(".context-menu");
expect(menu).not.toBeNull();
});
describe("OC-0057: abort-listener teardown on the caller signal", () => {
it("does not re-invoke menu cleanup off the caller signal after the menu was already dismissed", () => {
showContextMenu({
x: 0,
y: 0,
items: [{ label: "Action", onClick: vi.fn() }],
signal: ac.signal,
className: "oc0057-menu-a",
});
const menu = document.querySelector(".oc0057-menu-a") as HTMLElement;
expect(menu).not.toBeNull();
// Dismiss the menu through the normal item-click path (NOT via ac.abort()).
const item = menu.querySelector(".context-menu-item") as HTMLElement;
const removeSpy = vi.spyOn(menu, "remove");
item.click();
expect(removeSpy).toHaveBeenCalledTimes(1);
// The component that owns `ac` is destroyed sometime later. A correctly
// torn-down showContextMenu invocation must have released its "abort"
// listener on `ac.signal` when the menu was dismissed above, so this
// must NOT invoke the stale closure's menu.remove() a second time.
ac.abort();
expect(removeSpy).toHaveBeenCalledTimes(1);
});
it("does not accumulate a live abort listener on the caller signal per invocation", () => {
// Open and dismiss (via item click) several menus on the same
// long-lived caller signal, as DmSidebar does across repeated
// right-clicks without the sidebar being rebuilt.
const menus: HTMLElement[] = [];
for (let i = 0; i < 3; i++) {
showContextMenu({
x: 0,
y: 0,
items: [{ label: "Action", onClick: vi.fn() }],
signal: ac.signal,
className: "oc0057-menu-b",
});
const menu = document.querySelector(".oc0057-menu-b") as HTMLElement;
menus.push(menu);
const item = menu.querySelector(".context-menu-item") as HTMLElement;
item.click();
}
const removeSpies = menus.map((m) => vi.spyOn(m, "remove"));
// Simulate the parent component finally being destroyed.
ac.abort();
// None of the already-dismissed menus' remove() should fire again —
// each invocation's abort listener should have been released when that
// specific menu was dismissed, not held until component teardown.
for (const spy of removeSpies) {
expect(spy).not.toHaveBeenCalled();
}
});
it("cleans up immediately when the signal is already aborted before the menu is shown", () => {
ac.abort();
showContextMenu({
x: 0,
y: 0,
items: [{ label: "Action", onClick: vi.fn() }],
signal: ac.signal,
className: "oc0057-menu-c",
});
// An already-aborted parent signal means the menu must never be left
// dangling in the DOM — "abort" already fired before we could listen
// for it, so the code must check signal.aborted explicitly.
expect(document.querySelector(".oc0057-menu-c")).toBeNull();
});
it("releases the old menu's abort listener when it is swept away by a same-class reopen", () => {
showContextMenu({
x: 0,
y: 0,
items: [{ label: "First", onClick: vi.fn() }],
signal: ac.signal,
className: "oc0057-menu-d",
});
const firstMenu = document.querySelector(".oc0057-menu-d") as HTMLElement;
expect(firstMenu).not.toBeNull();
const removeSpy = vi.spyOn(firstMenu, "remove");
// Reopening with the same className sweeps the first menu out via the
// querySelectorAll(...).remove() path at the top of the function, not
// via item click or outside click.
showContextMenu({
x: 10,
y: 10,
items: [{ label: "Second", onClick: vi.fn() }],
signal: ac.signal,
className: "oc0057-menu-d",
});
expect(removeSpy).toHaveBeenCalledTimes(1);
// The parent component is destroyed later. The swept-away first menu's
// abort listener must have been released at sweep time, not left
// pinned on `ac.signal` until now.
ac.abort();
expect(removeSpy).toHaveBeenCalledTimes(1);
});
});
});
@@ -2965,6 +2965,53 @@ describe("WS Dispatcher", () => {
expect(channelsStore.getState().activeChannelId).toBe(60);
});
// setActiveChannel only zeroes the channelsStore mirror row's counts; a
// DM's badge lives in dmStore (SidebarDmSection reads dmStore.unreadCount,
// not the channelsStore mirror). Every other "open this DM" path
// (selectDmConversation, navigateToChannel, markChannelRead) pairs
// activation with clearDmUnread for exactly this reason — the
// dm_channel_close fallback must too, or the badge on the DM it just
// activated survives forever (it's now "active", so new messages take the
// isDmActive branch and never increment it back).
it("clears the dmStore unread badge on the DM it falls back to activating", () => {
dmStore.setState(() => ({
channels: [
{
channelId: 50,
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
participants: [],
name: "",
isGroup: false,
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
mentionCount: 0,
},
{
channelId: 60,
recipient: { id: 11, username: "carl", avatar: "", status: "online" },
participants: [],
name: "",
isGroup: false,
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 3,
mentionCount: 1,
},
],
}));
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 }));
mock.dispatch("dm_channel_close", { channel_id: 50 });
expect(channelsStore.getState().activeChannelId).toBe(60);
const dm = dmStore.getState().channels.find((c) => c.channelId === 60);
expect(dm?.unreadCount).toBe(0);
expect(dm?.mentionCount).toBe(0);
});
// The channelsStore mirror row for a DM is only ever synthesized by
// addDmToChannelsStore (on open, via selectDmConversation) — a DM present
// in dmStore from `ready` but never opened this session has none. Without
@@ -152,6 +152,26 @@ describe("media-visibility", () => {
cleanup();
});
it("freezes a GIF whose src the DOM normalizes away from the raw originalSrc (OC-0130)", () => {
const cleanup = setupCanvasMocks();
// Mixed-case host: the <img> element's src getter returns this
// lowercased per the WHATWG URL spec, so it no longer string-matches
// the raw originalSrc the caller passed in.
const raw = "https://EXAMPLE.com/anim.gif";
const img = createFakeImg(raw);
const wrap = createWrapper();
observeMedia(img, raw, wrap);
// Sanity check: the DOM really did normalize it away from `raw`.
expect(img.src).toBe("https://example.com/anim.gif");
expect(img.src).not.toBe(raw);
const btn = wrap.querySelector(".gif-play-btn") as HTMLButtonElement;
btn.click();
expect(img.src).toBe("data:image/png;base64,frozen");
expect(wrap.classList.contains("gif-paused")).toBe(true);
cleanup();
});
it("pause button click freezes immediately", () => {
const cleanup = setupCanvasMocks();
const img = createFakeImg("https://example.com/cat.gif");
@@ -678,4 +678,46 @@ describe("MessageList", () => {
expect(row1!.textContent).toContain("Edited");
});
});
describe("renderAll rapid-fire breaker", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("renders the final state once the 2s burst window resets, instead of staying stuck at the pre-trip state", () => {
setMessages(1, [makeMessage({ id: 1, content: "v0" })]);
msgList.mount(container); // 1st renderAll call, starts the 2s window
// Fire 25 non-append updates (edits) back-to-back, well inside the 2s
// window. tryAppendMessages() returns false for every one of these
// (same-length array, content changed) so each forces a renderAll().
// Combined with the mount's call, this is 26 renderAll invocations —
// calls 21+ trip the >20-in-2s breaker and must return without
// rendering.
for (let i = 1; i <= 25; i++) {
setMessages(1, [makeMessage({ id: 1, content: `v${i}` })]);
messagesStore.flush();
}
const rowDuringBurst = container.querySelector("[data-testid='message-1']");
expect(rowDuringBurst).not.toBeNull();
// The breaker tripped partway through, so the DOM is stuck behind the
// final store state (still showing an earlier version, not "v25").
expect(rowDuringBurst!.textContent).not.toContain("v25");
// Let the 2s reset window elapse with no further store updates.
vi.advanceTimersByTime(2100);
// Once the burst is over, the list must reflect the final state that
// triggered the last suppressed renderAll — not stay frozen on
// whatever rendered right before the breaker tripped.
const rowAfterReset = container.querySelector("[data-testid='message-1']");
expect(rowAfterReset).not.toBeNull();
expect(rowAfterReset!.textContent).toContain("v25");
});
});
});
@@ -789,5 +789,107 @@ describe("ProfileManager", () => {
expect(result).toBeNull();
});
it("load salvages the valid profiles when one stored entry is malformed (OC-0060)", async () => {
const storedData = {
schemaVersion: 1,
profiles: [
{
id: "good-1",
name: "Good One",
host: "good1.example.com:443",
username: "user1",
color: "#111111",
autoConnect: false,
rememberPassword: false,
lastConnected: null,
},
{
// Malformed: written by a build predating the color field / partial write.
id: "bad-1",
name: "",
host: "bad1.example.com:443",
username: "user2",
autoConnect: false,
rememberPassword: false,
lastConnected: null,
},
{
id: "good-2",
name: "Good Two",
host: "good2.example.com:443",
username: "user3",
color: "#222222",
autoConnect: false,
rememberPassword: false,
lastConnected: null,
},
],
};
mockInvoke.mockResolvedValueOnce({ "owncord:profiles": storedData });
const backend = createTauriBackend();
const result = await backend.load();
// A single corrupt entry must not null out the whole store — only the
// bad entry should be dropped, salvaging the other two valid profiles.
expect(result).not.toBeNull();
expect(result!.profiles.map((p) => p.id)).toEqual(["good-1", "good-2"]);
});
});
// ── OC-0060: one bad stored profile must not evict the good ones ──
describe("OC-0060 malformed profile salvage (end-to-end)", () => {
beforeEach(() => {
mockInvoke.mockReset();
});
it("does not let a single malformed stored profile wipe out the rest on the next save", async () => {
const validProfiles = Array.from({ length: 5 }, (_, i) => ({
id: `orig-${i}`,
name: `Original ${i}`,
host: `orig${i}.example.com:443`,
username: `user${i}`,
color: "#abcdef",
autoConnect: false,
rememberPassword: false,
lastConnected: null,
}));
// Corrupt one entry the way a hand-edit / partial write would: empty name.
const corrupted = [...validProfiles.slice(0, 4), { ...validProfiles[4], name: "" }];
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === "get_settings") {
return Promise.resolve({
"owncord:profiles": { schemaVersion: 1, profiles: corrupted },
});
}
if (cmd === "save_settings") {
return Promise.resolve(undefined);
}
return Promise.resolve(undefined);
});
const backend = createTauriBackend();
const m = createProfileManager(backend, mockFetch as unknown as FetchFn);
await m.loadProfiles();
// The four well-formed originals must have survived the load.
expect(m.getAll()).toHaveLength(4);
m.addProfile(sampleData);
await m.saveProfiles();
const saveCall = mockInvoke.mock.calls.find(([cmd]) => cmd === "save_settings");
expect(saveCall).toBeDefined();
const savedValue = saveCall![1] as { value: { profiles: ServerProfile[] } };
// The persisted set must still contain the four originals plus the new
// profile — not just the newly added one.
expect(savedValue.value.profiles).toHaveLength(5);
expect(savedValue.value.profiles.map((p) => p.id)).toEqual(
expect.arrayContaining(["orig-0", "orig-1", "orig-2", "orig-3"]),
);
});
});
});
@@ -12,7 +12,7 @@ import {
setMarkReadSender,
unreadChannelIds,
} from "@lib/read-state";
import { channelsStore, setChannels } from "@stores/channels.store";
import { channelsStore, setChannels, incrementUnread } from "@stores/channels.store";
import { dmStore, setDmChannels } from "@stores/dm.store";
import type { ReadyChannel } from "@lib/types";
import type { DmChannel } from "@stores/dm.store";
@@ -211,6 +211,44 @@ describe("unreadChannelIds / markAllRead", () => {
}
});
// OC-0123: a message that arrives in a channel while its own send is still
// sitting in the paced tail must not be wiped out by that stale click. The
// click predates the message; marking it read would silently swallow a
// genuinely-new unread the user has not seen.
it("does not mark read a channel that got a new message during the pacing window", () => {
vi.useFakeTimers();
try {
setChannels([
channel(1, 1),
channel(2, 1),
channel(3, 1),
channel(4, 1),
channel(5, 1),
channel(6, 1),
]);
expect(markAllRead()).toBe(6);
const firstBurst = new Set(sent);
const deferredId = [1, 2, 3, 4, 5, 6].find((id) => !firstBurst.has(id))!;
// A new message lands in the deferred channel before its paced send fires
// — exactly what the dispatcher's chat_message handler does.
incrementUnread(deferredId);
expect(channelsStore.getState().channels.get(deferredId)?.unreadCount).toBe(2);
vi.advanceTimersByTime(2000);
// The paced send must skip this channel: the message postdates the click,
// so neither the local badge nor the server's read state should advance
// past it.
expect(sent).not.toContain(deferredId);
expect(hasUnread(deferredId)).toBe(true);
expect(channelsStore.getState().channels.get(deferredId)?.unreadCount).toBe(2);
} finally {
vi.useRealTimers();
}
});
it("supersedes an in-flight burst rather than double-sending it", () => {
vi.useFakeTimers();
try {
@@ -294,6 +294,61 @@ describe("createSearchOverlay", () => {
overlay.destroy?.();
});
it("does not attach a new click listener to each row on every re-render (OC-0147)", async () => {
// Per-row click listeners registered on the component-lifetime AbortSignal
// never get cleaned up when a row is discarded by a re-render — only
// destroy() aborts that signal. Re-renders triggered by ArrowDown/ArrowUp
// (which don't create new rows via a fresh search) must not register any
// additional listeners directly on ".search-result-item" elements; the
// fix delegates a single listener onto the results container instead.
const results = [
makeResult({ message_id: 1 }),
makeResult({ message_id: 2 }),
makeResult({ message_id: 3 }),
];
const onSearch = vi.fn().mockResolvedValue(results);
const opts = makeOptions({ onSearch });
const overlay = createSearchOverlay(opts);
overlay.mount(container);
const input = container.querySelector(".search-overlay-input") as HTMLInputElement;
input.value = "test";
input.dispatchEvent(new Event("input"));
await vi.advanceTimersByTimeAsync(300);
expect(container.querySelectorAll(".search-result-item")).toHaveLength(3);
// Only start counting after the initial render so we isolate what the
// subsequent re-renders (via arrow-key navigation) register. vi.spyOn's
// mock.instances isn't reliably typed/populated for non-constructor
// methods, so track `this` via a manual monkey-patch instead.
const perRowClickRegistrations: Element[] = [];
const originalAddEventListener = Element.prototype.addEventListener;
Element.prototype.addEventListener = function (
this: Element,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
if (type === "click" && this.classList.contains("search-result-item")) {
perRowClickRegistrations.push(this);
}
originalAddEventListener.call(this, type, listener, options);
};
try {
for (let i = 0; i < 5; i++) {
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
}
} finally {
Element.prototype.addEventListener = originalAddEventListener;
}
expect(perRowClickRegistrations).toHaveLength(0);
overlay.destroy?.();
});
it("destroy removes overlay from DOM", () => {
const opts = makeOptions();
const overlay = createSearchOverlay(opts);
@@ -222,4 +222,23 @@ describe("StatusPicker wired to UserBar", () => {
);
}
});
// UserBar's updatePickerDisabled toggles ub-status-picker--disabled on the
// wrap element whenever canSetStatus() is false, but that class has no
// effect at all unless app.css actually makes it inert -- otherwise the
// dropdown and its custom-status input stay fully clickable while the
// socket is down, and anything typed there is silently dropped (never sent
// now, never re-sent on reconnect since restoreSavedPresence only re-sends
// `status`, not `custom_status`).
it("ub-status-picker--disabled actually disables the picker in app.css", () => {
const css = readFileSync(join(process.cwd(), "src/styles/app.css"), "utf8");
const match = /\.ub-status-picker--disabled\s*\{([^}]*)\}/.exec(css);
expect(
match,
"expected a `.ub-status-picker--disabled { ... }` rule in app.css",
).not.toBeNull();
expect(match![1], "disabled state must reject pointer input").toMatch(
/pointer-events\s*:\s*none/,
);
});
});
@@ -289,6 +289,66 @@ describe("streamPreview", () => {
expect(getPreview(row)).toBeNull();
});
// OC-0124: hidePreview overwrites state.animation without clearing the
// timer already sitting there. When stopPreviewDelayed's 150ms grace timer
// (T1) is still pending and something else (scroll/focusout) calls
// hidePreview directly, T1 survives uncancelled, fires later, and calls
// hidePreview a second time — orphaning that call's own 200ms removal
// timer in turn. One of those orphaned removal timers eventually runs
// `previewTimers.delete(row)` against whatever state a *later* hover
// installed, deleting it before its debounce ever fires. showPreview then
// finds no state to store trackCleanup on, so the ended/mute listeners it
// just registered on the live MediaStreamTrack become permanently
// unreachable by hidePreview, clearPreviewState, and the abort handler.
it("does not leak track listeners when hidePreview interrupts a pending stopPreviewDelayed timer (OC-0124)", () => {
const stream = createMockMediaStream();
const track = stream.getVideoTracks()[0]!;
const removeSpy = vi.spyOn(track, "removeEventListener");
mockGetRemoteVideoStream.mockReturnValue(stream);
const row = createRow(42);
attachStreamPreview(row, 42, "Alice", false, true, ac.signal);
// t=0: hover -> debounce armed.
row.dispatchEvent(new MouseEvent("mouseenter"));
// t=300: debounce fires -> preview shown, ended/mute listeners #1 registered.
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
// t=400: mouse leaves -> stopPreviewDelayed arms its 150ms grace timer.
vi.advanceTimersByTime(100);
row.dispatchEvent(new MouseEvent("mouseleave"));
// t=450: a second, independent trigger (e.g. focusout from a Tab, or a
// scroll-collapse) calls hidePreview directly while the grace timer from
// t=400 is still pending. This must cancel that timer, not just
// overwrite the handle to it.
vi.advanceTimersByTime(50);
row.dispatchEvent(new FocusEvent("focusout"));
// trackCleanup #1 runs synchronously inside this hidePreview call.
expect(removeSpy).toHaveBeenCalledTimes(2); // "ended" + "mute" for listener #1
// t=650: the removal timer armed by the t=450 hidePreview call fires and
// tears down the (now empty) preview + state.
vi.advanceTimersByTime(200);
expect(getPreview(row)).toBeNull();
// t=660: user hovers again -> a fresh debounce/state is installed.
vi.advanceTimersByTime(10);
row.dispatchEvent(new MouseEvent("mouseenter"));
// t=960: the new debounce fires and showPreview runs again, registering
// ended/mute listeners #2 on the same track and trying to store
// trackCleanup on the freshly-installed state.
vi.advanceTimersByTime(300);
expect(getPreview(row)).not.toBeNull();
// Close the second preview. If the fresh state survived intact,
// trackCleanup #2 fires here, removing listener set #2 as well.
row.dispatchEvent(new FocusEvent("focusout"));
expect(removeSpy).toHaveBeenCalledTimes(4); // "ended" + "mute" for BOTH listener sets
});
// Abort-listener accumulation (leak fix)
it("registers only one abort listener per signal, not one per attach call", () => {
mockGetRemoteVideoStream.mockReturnValue(null);
@@ -900,4 +900,30 @@ describe("VoiceAudioTab UI structure", () => {
expect(() => tab.build()).not.toThrow();
ac.abort();
});
it("does not register a new permanent abort listener on every rebuild (OC-0125)", () => {
// Simulates the settings overlay staying open for a session while the
// user re-opens the Voice & Audio tab (e.g. hide()/show() cycles):
// SettingsOverlay's single AbortController lives for the whole session,
// and build() is called again each time the tab is (re)rendered.
stubNavigator();
const ac = new AbortController();
const addEventListenerSpy = vi.spyOn(ac.signal, "addEventListener");
const abortListenerCount = (): number =>
addEventListenerSpy.mock.calls.filter(([type]) => type === "abort").length;
const tab = createVoiceAudioTab(ac.signal);
const afterCreate = abortListenerCount();
tab.build();
tab.build();
tab.build();
// Rebuilding the tab three times must not add three more permanent
// "abort" listeners to the overlay-lifetime signal — only the single
// listener the factory registers once at creation should exist.
expect(abortListenerCount()).toBe(afterCreate);
ac.abort();
});
});
@@ -595,6 +595,72 @@ describe("wsGeneration stale listener guard", () => {
});
});
describe("disconnect() cancelling an in-flight connect()", () => {
let client: ReturnType<typeof createWsClient>;
let originalMockListenImpl: (typeof mockListen)["getMockImplementation"] extends () => infer R
? R
: never;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
originalMockListenImpl = mockListen.getMockImplementation()!;
mockListen.mockClear();
eventHandlers.clear();
client = createWsClient();
});
afterEach(() => {
mockListen.mockImplementation(originalMockListenImpl!);
client.disconnect();
vi.useRealTimers();
});
// Mirrors main.ts's onAutoLoginCancel: by the time the Cancel button is
// clickable, wirePostAuth has already called ws.connect() and connect()
// is suspended mid-await (setupEventListeners' tauriListen round trips).
// disconnect() runs synchronously while that await is pending, then the
// suspended connect() resumes.
it("prevents ws_connect from being invoked after disconnect() runs mid-connect()", async () => {
let releaseListen: (() => void) | null = null;
mockListen.mockImplementation(
async (event: string, handler: (e: { payload: unknown }) => void) => {
if (event === "ws-message" && releaseListen === null) {
// Pause connect() here, mimicking the Cancel click landing while
// connect() is still awaiting its Tauri IPC round trips.
await new Promise<void>((resolve) => {
releaseListen = resolve;
});
}
return originalMockListenImpl!(event, handler);
},
);
client.connect({ host: "localhost:8443", token: "t" });
// Let connect() run past ensureTauriApis()/cleanupEventListeners() and
// into the paused first tauriListen("ws-message", ...) call.
await vi.advanceTimersByTimeAsync(10);
expect(releaseListen).not.toBeNull();
// Cancel arrives while connect() is suspended mid-await.
client.disconnect();
expect(client.getState()).toBe("disconnected");
// Resume the suspended connect() — it must notice the cancellation and
// bail out instead of completing setupEventListeners() and invoking
// ws_connect.
releaseListen!();
await vi.advanceTimersByTimeAsync(10);
const wsConnectCalls = mockInvoke.mock.calls.filter((c) => c[0] === "ws_connect");
expect(wsConnectCalls).toHaveLength(0);
// The cancelled attempt must not have flipped the state back out of
// "disconnected" (e.g. to "authenticating"/"reconnecting").
expect(client.getState()).toBe("disconnected");
});
});
describe("heartbeat proxyOpen guard", () => {
let client: ReturnType<typeof createWsClient>;