mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(client): gate every mic re-enable path on the user's mute state
Six separate paths republished the microphone without consulting whether
the user had muted themselves: the audio-device fallback, selecting the
"Default" input, un-deafening, retryMicPermission, a stale PTT ownership
latch, and auto-reconnect's restoreLocalVoiceState. Each one produced a
hot mic while every remote UI still showed the user as muted.
These were six findings but one missing guard. Adds isMicPolicyGated()
(localMuted || localDeafened || localServerMuted || pttGated) and routes
the device-switch cycle, applyMicMuteState's unmute branch and
retryMicPermission through it, which also covers setDeafened(false) --
a call site no finding named.
Also extracts reconnectSuperseded() so all five supersession checkpoints
in the auto-reconnect loop carry the state-type check that only the
give-up path had, and clears the PTT gate on stopPtt and on ptt-error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop camera/screenshare publishing after the user turns it off
enableCamera and enableScreenshare set the store flag before awaiting
getUserMedia/getDisplayMedia, so clicking off during the OS picker left
the track publishing to the SFU while the UI showed it off, with no stop
affordance. Adds one shared generation guard: disable bumps, enable
captures before the await and discards the track if it changed.
Also in this area:
- a server refusal of voice_screenshare (or a non-VIDEO_LIMIT refusal of
voice_camera) never rolled back the published track; the dispatcher now
correlates the error by envelope id rather than blanket-rolling-back.
- a full-ready resync left every loaded channel with a permanent hole in
its history, because that tier never replays chat_message frames.
Loaded windows are now invalidated on a resync (pending and failed rows
carry through) and the active channel refetched.
- CHANNEL_FULL while joining left voiceStatus stuck; DM mirror rows kept
phantom entries and stale unread counts across a resync; addMessage and
setAroundMessages dropped offline/failed optimistic rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor
setupKeyExchange unconditionally wrote the server's key-holder value
captured at join, clobbering a handleParticipantLeft promotion that
landed during its pre-publish awaits. The joiner then waited for an offer
only it could send, timed out, and was ejected from voice. The write now
preserves an existing promotion; it sits after the existing
session-generation check, and clearState bumps that generation and resets
the flag synchronously, so stale state cannot survive a teardown.
Enhanced Noise Suppression silently disabled the input-volume slider and
the VAD gate: livekit-client's setProcessor() does its own internal
replaceTrack(processedTrack) after awaiting addModule and a fetch, so it
landed after ours and wired the sender straight to the raw mic. The
pipeline now sources from the processed track and re-runs after
attaching, so our replaceTrack wins.
Also scopes the voice identity keypair by host AND user id so two
accounts sharing one OS profile stop sharing an identity keypair, guards
peer-key and TOFU writes against a clearState during their IPC awaits,
and seeds VideoGrid tiles from the persisted per-user volume.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): drop the previous server's bearer token on a host switch
api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.
Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.
Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): guard the quick-switcher against a double-open
openQuickSwitch assigned its instance only after awaiting the profile
load, so a second click during that window mounted a second overlay and
orphaned the first. Every close affordance destroys only the tracked
instance, leaving a body-mounted position:fixed backdrop that blocks all
input until the app is reloaded. Adds the same `opening` flag the sibling
overlay controllers already use; audited every other opener in these
files and found no second instance of the race.
Also: loadOlderMessages and loadMessages now discard a response whose
window was replaced mid-fetch by a same-channel jump; the ArrowUp
edit-last-message scan skips unsent rows, matching the visual affordance;
unpinning from the pinned panel writes the store row; the pinned panel
forwards the channel it captured at open time rather than reading the
active one at click time; the reaction picker closes on channel teardown;
a non-voice channel switch dismisses the video grid; and destroy() closes
the settings overlay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the status-picker stylesheet and a dozen UI defects
The .status-picker rules targeted a root element the component never
toggles, leaving the popup's own chrome unstyled and the root
display:none. Repointed at .status-picker-dropdown and dropped the dead
rules.
Component and store fixes, all test-first: the upload preview bar never
became visible so upload errors were invisible; replying while editing
left the edit text in the textarea; MessageList's load-older latch keyed
off a raw count so a live tail append refired the fetch; drag-reorder
renumbered channels into a 0..n-1 range instead of reusing the group's
own position slots; DM avatars bypassed the authenticated fetch path;
the member-list moderation gate read a mount-time role snapshot; mention
autocomplete offered usernames the mention grammar cannot express;
notifications titled DMs as "#channel"; the update-notifier catch
dereferenced a null banner; and the channel context menu leaked its node
on teardown.
Also resets authStore in member-list.test.ts's shared reset helper: one
test was leaving role="admin" set for every test after it, unnoticed
because no gate read authStore for role until now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): adopt the legacy identity key instead of re-minting one
Scoping the identity keypair by host and user id changed the keyring
account name, so every existing install would have found nothing at the
new account and generated a fresh identity key. Every peer who had
already pinned the old one would then see a TOFU mismatch, which raises
the re-pin modal telling the user to verify the safety number
out-of-band -- a MITM alarm fired at the whole alpha population at once,
which teaches people to click through the one warning meant to matter.
When the scoped account is empty, the legacy host-only account is now
adopted: saved under the scoped name, then the legacy account deleted.
Save happens before delete so a partial failure leaves the legacy key in
place for the next launch rather than stranding the user with neither.
A corrupt legacy blob falls through to fresh generation without throwing.
A second account on the same host still mints its own distinct keypair,
which was the point of the scoping fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): surface server errors that had no dedicated handler
The WebSocket error handler bannered only RATE_LIMITED and FORBIDDEN, so
every other code that reached the fallthrough was dropped in silence --
a rejected chat_edit reported nothing at all while the optimistic
"Message edited" toast still fired. Every specific branch above already
returns, so the fallthrough sees only genuinely unhandled codes; it now
banners all of them.
Also:
- reattachToPresent cleared the detached flag eagerly, so a failed tail
refetch let a live broadcast splice onto the stale around-window with a
silent gap. The flag now survives until setMessages lands the tail.
- a mixed-case host and its lowercase-normalized URL form resolved to
different cert-store pin keys; tofu::cert_store_key and ws.ts's
normalizeHostForCertCompare both lowercase now. attachments.ts already
did the right thing and is unchanged.
- clearAuth left the channels store populated for the next login.
- capabilities/default.json was missing
core:window:allow-request-user-attention.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): tear down video tiles, focus and the lightbox on leave
Four defects an earlier pass could not finish because each spanned two
files:
- closeVideoGrid only hid the grid, so remote video tiles survived a
channel leave and reappeared on the next join. VideoGrid grew a
clearStreams(), called from the real-leave branch of checkVideoMode
(not the reconnect branch).
- the grid kept its focused-tile state across a close; setFocusedTile now
accepts null and closeVideoGrid clears it.
- the per-user volume preference key had no host component, so volumes
set on one server applied to a different user with the same id on
another. Scoped via setAudioVolumeHost, mirroring channel-mutes.
- the media lightbox stayed mounted after MainPage.destroy().
Also repairs tests/unit/audio-elements.test.ts, which was missing an
afterEach import and failing to compile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): close eight defects a review found in this batch
Three of these are incomplete fixes from earlier commits on this branch --
the diagnosis landed, the cure stopped short.
- main.ts kept a hand-copied normalizeHostForCertCompare that never got the
.toLowerCase() its ws.ts original and tofu::cert_store_key both have. Since
the Rust side always emits the lowercased host and a profile stores it
verbatim, any uppercase in the hostname broke all three guards -- worst of
them the mismatch modal's onReject, which then skipped disconnect/clearAuth
and left the user connected to the server whose certificate they had just
refused. ws.ts now exports the one implementation and the copy is gone.
- the status-picker stylesheet repair repointed the root and deleted the old
.status-option rules without adding replacements under the names the
component emits, so the trigger dot -- a bare div whose only style is an
inline background -- stayed 0x0, invisible and unclickable. The picker still
could not be opened.
- ungateMic's re-open branch was unreachable in the one scenario its comment
described: a PTT release routes through setMuted(true), so localMuted is
always true there. It now takes the pttOwnsMute latch read *before* each
call site resets it; reading the module flag from inside would always see
false and move the bug rather than fix it.
The rest:
- dispatcher.ts statically imported @lib/screenShare, which has value imports
from livekit-client -- dragging ~1.3 MB into the entry chunk that the file's
own comment says is deliberately kept out of it. Now lazy, like every other
voice call site here.
- replay detection compared payload.timestamp (server clock) against
Date.now() (client clock). A self-hosted server without NTP made every live
message after a reconnect look like a replay, silently killing notifications
for the whole drift window. Both sides are now in server time via an
observed skew estimate; latency biases it toward treat-as-live, which is the
side that costs a duplicate rather than a dropped notification.
- identity.ts and livekitE2EE.ts each derived the keyring scope with `?? 0`.
A missing user id would have adopted-and-deleted the real legacy key into a
bogus host:0 account, then minted a second keypair under host:<realId> --
published key and signing key permanently disagreeing, which is a false MITM
warning for every peer. Unreachable today, irreversible if reached.
- per-user volumes were scoped by host with a legacy fallback that only fired
when currentHost was null, which MainPage never leaves it as -- so every
saved volume silently read as the default on upgrade. Reads now fall through
to the unscoped key once and persist under the scoped one.
- a post-resync invalidate ran unconditionally while its refetch was guarded,
so a missing getMessages left every window dropped with nothing to reload it.
A ninth finding -- that the DM reconcile could strand activeChannelId -- was
checked and rejected: the block 40 lines above already clears it whenever the
id is absent from both channels and dm_channels.
Two test-suite notes: livekit-session's announce-signing test was joining
voice with no authenticated user, which production does not permit, so it now
sets one (below PEER_ID, leaving key-holder election unchanged) and clears it
after. status-picker-userbar reads app.css from disk rather than `?raw`, which
vitest stubs to an empty string for stylesheets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): repair the e2e cert test and four defects found verifying it
The e2e suite caught one behavioural divergence from this branch, and
hand-verifying the hunt's flagged-but-unchecked items turned up four more
defects.
E2E:
- cert-tofu's "disconnect on mismatch returns to the connect page" emitted the
mismatch for myserver.example:8443 while the session was authenticated
against localhost:8443, so it asserted the pre-fix behaviour: a certificate
rotating on ANY unrelated saved profile logs you out of the server you are
using. That is the bug 8917c28 deliberately fixed. The test now emits for the
live host, and a new sibling pins the guard itself -- a mismatch for another
host must leave the session alone. Verified by defeating the guard: only the
new test goes red, which is why the old one never noticed the change.
Defects found verifying the ledger's open items:
- logging out fired delete_credential fire-and-forget and then navigated to the
connect page, whose auto-login immediately read the same account back. Since
B4-3 moved the credential commands to #[tauri::command(async)] they no longer
serialize on the IPC thread, so a read that wins that race signs the user
straight back into the server they just left. Two fixes, because the race and
the intent are separate problems: a CREDENTIAL_LOCK mutex restores the
one-operation-at-a-time property that also keeps secret_store::set's
read-modify-write atomic, and the connect page now skips auto-login once
after a logout that removed the credential -- mirroring the quick-switch
sessionStorage idiom already in that file. A server_shutdown logout keeps its
credential and deliberately does not set the flag, so restart auto-login
still works. e2e-pinned: with the suppression defeated, the user is visibly
back in the app after clicking Log Out.
- a post-resync history refetch that REJECTED left the active channel's window
already invalidated but never marked errored, so MessageList fell into its
"no messages yet" welcome branch -- rendering a failed reload as a genuinely
empty channel, with no Retry, until the user navigated away and back. Now
calls setChannelLoadError, reusing MessageController's existing plumbing.
- an invite deep link arriving during the connected overlay's 800ms ready
countdown hit a gate that assumed isAuthenticated implies the router is on
"main". It is not: clearAuth() ran without the teardown that only the
authStore subscriber performs (and only while on "main"), so the overlay's
timer then mounted MainPage over a nulled-out auth state, and the invite was
dropped. Gated on the real invariant and the in-flight session is now torn
down explicitly.
- channel mutes carried the same dead legacy-preference fallback that per-user
volumes had -- guarded on currentHost === null, which MainPage never leaves
it as -- so every saved mute was silently discarded on upgrade. Mutes are a
list, where an empty saved value is real data, so this needed a presence
probe rather than the volume fix's sentinel.
Also extends the e2e Tauri mock with storedSettings/storedCredential seeds so
auto-login paths are exercisable at all.
Verified clean: 4800 vitest, 293 Playwright, 97 cargo, tsc, tsc -p e2e, eslint,
prettier, clippy -D warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop Tab escaping every modal, and a duplicate row after a resync
Two bugs left open by the previous round.
The "flaky" a11y focus-trap test was not flaky -- it was a real accessibility
defect surfacing nondeterministically. FOCUSABLE_SELECTOR is structural and
says nothing about visibility, but this codebase hides controls with inline
`style.display = "none"` (MemberPickerModal's group-name field and confirm
button both start hidden). So focusDialog() picked a display:none input as the
dialog's first focusable and called .focus() on it -- which browsers silently
refuse -- and focus never entered the dialog at all. trapFocus() then computed
first/last as those same hidden elements, so neither Tab branch ever matched
document.activeElement, preventDefault() never fired, and Tab fell through to
the browser's native order and walked straight out of the dialog. Whether the
test noticed depended on how much async sidebar content happened to be
focusable at that moment, which is what made it look intermittent.
Fixed in the shared helper rather than in the one modal that exposed it: about
forty call sites hide controls the same way, so every factory modal had the
same hole. trapFocus and focusDialog now filter out inline-hidden elements.
Reproduced first at 3/10 failures under --repeat-each; 10/10 after, and 20/20
at --workers=4. Note the check reads inline styles only -- an element hidden by
a CSS class would still slip through, which no current call site does.
Second: a message the server persisted but whose chat_send_ok ack was lost to
the same disconnect that forced a resync was displayed twice. The optimistic
row keeps id 0 until confirmSend stamps it, so setMessages' id-based carry-over
could never collide it with the real row, while addMessage had solved exactly
this for the live path by matching on content. Extracted that predicate as
isUnreconciledEcho and used it in both, so the two cannot drift apart.
The dangerous direction here is over-merging, not under-merging: collapsing two
genuinely distinct sends of the same text loses a real message. Three things
bound it -- only rows still awaiting reconciliation qualify (pending, or failed
for OFFLINE specifically, since a SLOW_MODE rejection is never broadcast and
eating that row would kill a live retry draft), author and content must both
match, and each snapshot row is consumed at most once, so N identical pending
sends pair off against N identical real rows instead of collapsing onto one.
Both directions are tested.
Verified: 4804 vitest, 293 Playwright with zero flaky, tsc, tsc -p e2e, eslint,
prettier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
/**
|
|
* Tests for src/components/channel-sidebar/volume-menu.ts (was 77.7% statements
|
|
* / 50% functions, no test file — every event handler was unexercised).
|
|
*
|
|
* The menu is transient DOM appended to document.body with two AbortControllers
|
|
* governing its teardown, so the interesting failures are leaks: a menu that
|
|
* outlives its sidebar, or a dismiss listener that survives its menu.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const setUserVolume = vi.fn();
|
|
const getUserVolume = vi.fn();
|
|
|
|
vi.mock("@lib/livekitSession", () => ({
|
|
setUserVolume: (...args: unknown[]) => setUserVolume(...args) as unknown,
|
|
getUserVolume: (...args: unknown[]) => getUserVolume(...args) as unknown,
|
|
}));
|
|
|
|
const { showUserVolumeMenu } = await import("@components/channel-sidebar/volume-menu");
|
|
|
|
function menuEl(): HTMLElement | null {
|
|
return document.querySelector(".user-vol-menu");
|
|
}
|
|
|
|
function sliderEl(): HTMLInputElement | null {
|
|
return document.querySelector<HTMLInputElement>(".user-vol-menu input[type=range]");
|
|
}
|
|
|
|
function itemTexts(): string[] {
|
|
return [...document.querySelectorAll(".user-vol-menu .context-menu-item")].map(
|
|
(el) => el.textContent ?? "",
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
setUserVolume.mockReset();
|
|
getUserVolume.mockReset().mockReturnValue(100);
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
// ── rendering ──────────────────────────────────────────────────────────────
|
|
|
|
describe("showUserVolumeMenu rendering", () => {
|
|
it("renders the username, the current volume and a 0-200 slider", () => {
|
|
getUserVolume.mockReturnValue(140);
|
|
|
|
showUserVolumeMenu(7, "alice", 10, 20, new AbortController().signal);
|
|
|
|
expect(menuEl()).not.toBeNull();
|
|
expect(itemTexts()).toContain("alice");
|
|
expect(itemTexts()).toContain("User Volume: 140%");
|
|
|
|
const slider = sliderEl();
|
|
expect(slider?.value).toBe("140");
|
|
expect(slider?.min).toBe("0");
|
|
expect(slider?.max).toBe("200");
|
|
});
|
|
|
|
it("positions the menu at the supplied coordinates", () => {
|
|
showUserVolumeMenu(7, "alice", 123, 456, new AbortController().signal);
|
|
|
|
expect(menuEl()?.style.left).toBe("123px");
|
|
expect(menuEl()?.style.top).toBe("456px");
|
|
});
|
|
|
|
it("reads the current volume for the requested user", () => {
|
|
showUserVolumeMenu(42, "bob", 0, 0, new AbortController().signal);
|
|
|
|
expect(getUserVolume).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
it("renders a volume of 0 rather than treating it as absent", () => {
|
|
getUserVolume.mockReturnValue(0);
|
|
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
|
|
expect(itemTexts()).toContain("User Volume: 0%");
|
|
expect(sliderEl()?.value).toBe("0");
|
|
});
|
|
});
|
|
|
|
// ── slider ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("volume slider", () => {
|
|
it("applies the new volume and updates both labels", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
const slider = sliderEl();
|
|
if (slider === null) throw new Error("no slider rendered");
|
|
|
|
slider.value = "55";
|
|
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(setUserVolume).toHaveBeenCalledWith(7, 55);
|
|
expect(itemTexts()).toContain("User Volume: 55%");
|
|
expect(document.querySelector(".slider-val")?.textContent).toBe("55%");
|
|
});
|
|
|
|
it("supports boosting above 100%", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
const slider = sliderEl();
|
|
if (slider === null) throw new Error("no slider rendered");
|
|
|
|
slider.value = "200";
|
|
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(setUserVolume).toHaveBeenCalledWith(7, 200);
|
|
});
|
|
|
|
it("supports muting to 0%", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
const slider = sliderEl();
|
|
if (slider === null) throw new Error("no slider rendered");
|
|
|
|
slider.value = "0";
|
|
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(setUserVolume).toHaveBeenCalledWith(7, 0);
|
|
expect(itemTexts()).toContain("User Volume: 0%");
|
|
});
|
|
});
|
|
|
|
// ── reset ──────────────────────────────────────────────────────────────────
|
|
|
|
describe("reset button", () => {
|
|
it("restores 100% in the store, the slider and both labels", () => {
|
|
getUserVolume.mockReturnValue(30);
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
|
|
const reset = [
|
|
...document.querySelectorAll<HTMLElement>(".user-vol-menu .context-menu-item"),
|
|
].find((el) => el.textContent === "Reset Volume");
|
|
reset?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
expect(setUserVolume).toHaveBeenCalledWith(7, 100);
|
|
expect(sliderEl()?.value).toBe("100");
|
|
expect(itemTexts()).toContain("User Volume: 100%");
|
|
expect(document.querySelector(".slider-val")?.textContent).toBe("100%");
|
|
});
|
|
});
|
|
|
|
// ── dismissal and teardown ─────────────────────────────────────────────────
|
|
|
|
describe("dismissal", () => {
|
|
it("closes on a mousedown outside the menu", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
vi.runAllTimers(); // the outside-click listener is attached on a macrotask
|
|
|
|
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
|
|
expect(menuEl()).toBeNull();
|
|
});
|
|
|
|
it("stays open on a mousedown inside the menu", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
vi.runAllTimers();
|
|
|
|
sliderEl()?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
|
|
expect(menuEl()).not.toBeNull();
|
|
});
|
|
|
|
it("ignores clicks landing before the listener is attached", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
|
|
// The setTimeout(0) exists so the right-click that opened the menu does not
|
|
// immediately close it again.
|
|
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
|
|
expect(menuEl()).not.toBeNull();
|
|
});
|
|
|
|
it("removes the menu when the parent component aborts", () => {
|
|
const ac = new AbortController();
|
|
showUserVolumeMenu(7, "alice", 0, 0, ac.signal);
|
|
vi.runAllTimers();
|
|
|
|
ac.abort();
|
|
|
|
expect(menuEl()).toBeNull();
|
|
});
|
|
|
|
it("[B3-4] ties the parent-signal abort bridge to the menu's own dismiss signal, so it does not outlive a dismissed menu", () => {
|
|
// Without a { signal } option, this bridge listener (and the closure
|
|
// retaining a detached .user-vol-menu subtree) survives every future
|
|
// right-click for the parent's entire lifetime — the outside-click and
|
|
// replace-on-reopen dismiss paths remove the menu but cannot remove this
|
|
// listener, since it is registered directly on the caller's long-lived
|
|
// signal. Mirrors context-menu.ts's `{ signal: menuAc.signal }` pattern.
|
|
const parentAc = new AbortController();
|
|
const addSpy = vi.spyOn(parentAc.signal, "addEventListener");
|
|
|
|
showUserVolumeMenu(7, "alice", 0, 0, parentAc.signal);
|
|
|
|
expect(addSpy).toHaveBeenCalledTimes(1);
|
|
const [eventName, , options] = addSpy.mock.calls[0]!;
|
|
expect(eventName).toBe("abort");
|
|
expect(options).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
|
});
|
|
|
|
it("does not re-attach the dismiss listener when aborted before the timer fires", () => {
|
|
const ac = new AbortController();
|
|
showUserVolumeMenu(7, "alice", 0, 0, ac.signal);
|
|
|
|
ac.abort();
|
|
// The scheduled callback checks the dismiss signal first, so no listener is
|
|
// registered against an already-removed menu.
|
|
expect(() => {
|
|
vi.runAllTimers();
|
|
}).not.toThrow();
|
|
expect(menuEl()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("re-opening", () => {
|
|
it("replaces any menu already on screen", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
vi.runAllTimers();
|
|
|
|
showUserVolumeMenu(9, "bob", 50, 60, new AbortController().signal);
|
|
|
|
expect(document.querySelectorAll(".user-vol-menu")).toHaveLength(1);
|
|
expect(itemTexts()).toContain("bob");
|
|
expect(itemTexts()).not.toContain("alice");
|
|
});
|
|
|
|
it("the replaced menu's dismiss listener no longer closes the new menu", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
vi.runAllTimers(); // first menu's outside-click listener is live
|
|
|
|
showUserVolumeMenu(9, "bob", 0, 0, new AbortController().signal);
|
|
// Deliberately do NOT run timers: only the stale listener from the first
|
|
// menu is attached. It was aborted on replace, so this click must not close
|
|
// the freshly opened menu.
|
|
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
|
|
expect(menuEl()).not.toBeNull();
|
|
expect(itemTexts()).toContain("bob");
|
|
});
|
|
|
|
it("the new menu operates on the new user", () => {
|
|
showUserVolumeMenu(7, "alice", 0, 0, new AbortController().signal);
|
|
showUserVolumeMenu(9, "bob", 0, 0, new AbortController().signal);
|
|
const slider = sliderEl();
|
|
if (slider === null) throw new Error("no slider rendered");
|
|
|
|
slider.value = "20";
|
|
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
|
expect(setUserVolume).toHaveBeenCalledWith(9, 20);
|
|
expect(setUserVolume).not.toHaveBeenCalledWith(7, 20);
|
|
});
|
|
});
|