mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.
Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.
* fix(ws): 1 defect(s) (OC-0210)
* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed
Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.
Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.
OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.
* docs: record the dm_channel_open emission on 1:1 DM creation
POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.
The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.
---------
Co-authored-by: Claude <noreply@anthropic.com>
970 lines
34 KiB
TypeScript
970 lines
34 KiB
TypeScript
/**
|
|
* MainPage's activeChannelId subscriber (finding: a deleted/closed active
|
|
* channel left its message list and composer mounted, because the
|
|
* subscriber had no else branch to tear them down).
|
|
*
|
|
* MainPage.ts is excluded from unit coverage (vitest.config.ts) — it has
|
|
* lots of heavy child components (SidebarArea, ChatArea, ChannelController)
|
|
* that are extracted specifically to be independently testable, so those are
|
|
* mocked out here and only the wiring under test (the store subscription) is
|
|
* exercised for real.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("@lib/logger", () => ({
|
|
createLogger: () => ({
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
const { capturedOnRemoteVideo } = vi.hoisted(() => ({
|
|
capturedOnRemoteVideo: {
|
|
current: null as null | ((userId: number, stream: MediaStream, isScreenshare: boolean) => void),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@lib/livekitSession", () => ({
|
|
cleanupAll: vi.fn(),
|
|
setOnRemoteVideo: vi.fn(
|
|
(cb: (userId: number, stream: MediaStream, isScreenshare: boolean) => void) => {
|
|
capturedOnRemoteVideo.current = cb;
|
|
},
|
|
),
|
|
setOnRemoteVideoRemoved: vi.fn(),
|
|
clearOnRemoteVideo: vi.fn(),
|
|
setWsClient: vi.fn(),
|
|
setServerHost: vi.fn(),
|
|
setOnError: vi.fn(),
|
|
leaveVoice: vi.fn(),
|
|
setMuted: vi.fn(),
|
|
setDeafened: vi.fn(),
|
|
enableCamera: vi.fn().mockResolvedValue(undefined),
|
|
disableCamera: vi.fn().mockResolvedValue(undefined),
|
|
enableScreenshare: vi.fn().mockResolvedValue(undefined),
|
|
disableScreenshare: vi.fn().mockResolvedValue(undefined),
|
|
getLocalCameraStream: vi.fn(() => null),
|
|
getLocalScreenshareStream: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock("@lib/notifications", () => ({
|
|
startRingChime: vi.fn(),
|
|
stopRingChime: vi.fn(),
|
|
}));
|
|
|
|
const { mockSetAudioVolumeHost } = vi.hoisted(() => ({
|
|
mockSetAudioVolumeHost: vi.fn(),
|
|
}));
|
|
|
|
// Real audioElements.ts pulls in livekit-client (unmocked elsewhere in this
|
|
// file's graph) purely to hold the AudioElements class this page never
|
|
// touches directly — mock out the one export MainPage actually calls.
|
|
vi.mock("@lib/audioElements", () => ({
|
|
setAudioVolumeHost: mockSetAudioVolumeHost,
|
|
}));
|
|
|
|
const { capturedAutoIdleOptions } = vi.hoisted(() => ({
|
|
capturedAutoIdleOptions: {
|
|
current: null as null | { onStatusChange: (status: string) => void },
|
|
},
|
|
}));
|
|
|
|
vi.mock("@lib/autoIdle", () => ({
|
|
startAutoIdle: (options: { onStatusChange: (status: string) => void }) => {
|
|
capturedAutoIdleOptions.current = options;
|
|
return { destroy: vi.fn() };
|
|
},
|
|
}));
|
|
|
|
const {
|
|
mockMountChannel,
|
|
mockDestroyChannel,
|
|
mockCreateChannelController,
|
|
mockCreateSidebarArea,
|
|
mockCreateChatArea,
|
|
capturedChatAreaRef,
|
|
} = vi.hoisted(() => ({
|
|
mockMountChannel: vi.fn(),
|
|
mockDestroyChannel: vi.fn(),
|
|
mockCreateChannelController: vi.fn(),
|
|
mockCreateSidebarArea: vi.fn(),
|
|
mockCreateChatArea: vi.fn(),
|
|
// The ChatArea mock below builds fresh DOM elements per call (a plain
|
|
// return value can't be read back from a vi.fn() call site) — this is how
|
|
// tests get at the actual slots/dmProfileSlot MainPage is wiring against.
|
|
capturedChatAreaRef: {
|
|
current: null as null | {
|
|
slots: {
|
|
messagesSlot: HTMLDivElement;
|
|
typingSlot: HTMLDivElement;
|
|
inputSlot: HTMLDivElement;
|
|
videoGridSlot: HTMLDivElement;
|
|
};
|
|
dmProfileSlot: HTMLDivElement;
|
|
videoGrid: {
|
|
addStream: ReturnType<typeof vi.fn>;
|
|
removeStream: ReturnType<typeof vi.fn>;
|
|
clearStreams: ReturnType<typeof vi.fn>;
|
|
hasStreams: ReturnType<typeof vi.fn>;
|
|
setFocusedTile: ReturnType<typeof vi.fn>;
|
|
getFocusedTileId: ReturnType<typeof vi.fn>;
|
|
setLabel: ReturnType<typeof vi.fn>;
|
|
};
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/pages/main-page/ChannelController", () => ({
|
|
createChannelController: (...args: unknown[]) => {
|
|
mockCreateChannelController(...args);
|
|
return {
|
|
mountChannel: mockMountChannel,
|
|
destroyChannel: mockDestroyChannel,
|
|
openFilePicker: vi.fn(),
|
|
currentChannelId: 0,
|
|
messageList: null,
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/pages/main-page/SidebarArea", () => ({
|
|
createSidebarArea: (...args: unknown[]) => {
|
|
mockCreateSidebarArea(...args);
|
|
return {
|
|
sidebarWrapper: document.createElement("div"),
|
|
children: [],
|
|
unsubscribers: [],
|
|
openQuickSwitch: vi.fn(),
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../src/pages/main-page/ChatArea", () => ({
|
|
createChatArea: (...args: unknown[]) => {
|
|
mockCreateChatArea(...args);
|
|
const slots = {
|
|
messagesSlot: document.createElement("div"),
|
|
typingSlot: document.createElement("div"),
|
|
inputSlot: document.createElement("div"),
|
|
videoGridSlot: document.createElement("div"),
|
|
};
|
|
const dmProfileSlot = document.createElement("div");
|
|
const videoGrid = {
|
|
addStream: vi.fn(),
|
|
removeStream: vi.fn(),
|
|
clearStreams: vi.fn(),
|
|
hasStreams: vi.fn(() => false),
|
|
setFocusedTile: vi.fn(),
|
|
getFocusedTileId: vi.fn(() => null),
|
|
setLabel: vi.fn(),
|
|
mount: vi.fn(),
|
|
destroy: vi.fn(),
|
|
};
|
|
capturedChatAreaRef.current = { slots, dmProfileSlot, videoGrid };
|
|
return {
|
|
chatArea: document.createElement("div"),
|
|
slots,
|
|
videoGrid,
|
|
chatHeaderName: document.createElement("span"),
|
|
chatHeaderRefs: {
|
|
hashEl: document.createElement("span"),
|
|
nameEl: document.createElement("span"),
|
|
topicEl: document.createElement("span"),
|
|
callBtn: document.createElement("button"),
|
|
},
|
|
searchCtrl: { open: vi.fn(), cleanup: vi.fn() },
|
|
dmProfileSlot,
|
|
children: [],
|
|
unsubscribers: [],
|
|
};
|
|
},
|
|
}));
|
|
|
|
import { createMainPage } from "../../src/pages/MainPage";
|
|
import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store";
|
|
import { authStore } from "../../src/stores/auth.store";
|
|
import { uiStore } from "../../src/stores/ui.store";
|
|
import { voiceStore, updateVoiceUserProfile } from "../../src/stores/voice.store";
|
|
import { dmStore } from "../../src/stores/dm.store";
|
|
import { membersStore, updateMemberProfile } from "../../src/stores/members.store";
|
|
import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws";
|
|
import type { ApiClient } from "../../src/lib/api";
|
|
import type { ServerMessage } from "../../src/lib/types";
|
|
import { openImageLightbox } from "../../src/components/message-list/media";
|
|
import { saveUserStatus } from "../../src/lib/userStatus";
|
|
|
|
function resetStores(): void {
|
|
channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] }));
|
|
authStore.setState(() => ({
|
|
token: "t",
|
|
user: { id: 1, username: "alice", avatar: null, role: "member" },
|
|
serverName: null,
|
|
motd: null,
|
|
isAuthenticated: true,
|
|
}));
|
|
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected", settingsOpen: false }));
|
|
voiceStore.setState(() => ({
|
|
currentChannelId: null,
|
|
voiceUsers: new Map(),
|
|
voiceConfigs: new Map(),
|
|
localMuted: false,
|
|
localDeafened: false,
|
|
localCamera: false,
|
|
localScreenshare: false,
|
|
joinedAt: null,
|
|
listenOnly: false,
|
|
voiceStatus: "idle",
|
|
}));
|
|
dmStore.setState(() => ({ channels: [] }));
|
|
membersStore.setState(() => ({ members: new Map(), typingUsers: new Map(), roleRevision: 0 }));
|
|
}
|
|
|
|
type FakeWsClient = WsClient & {
|
|
/** Test-only: drive a registered `ws.on(type, ...)` listener directly. */
|
|
emit: (type: ServerMessage["type"], payload: unknown) => void;
|
|
};
|
|
|
|
function fakeWs(): FakeWsClient {
|
|
const listeners = new Map<string, Set<WsListener<ServerMessage["type"]>>>();
|
|
return {
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
send: vi.fn(() => "id"),
|
|
on<T extends ServerMessage["type"]>(type: T, listener: WsListener<T>) {
|
|
if (!listeners.has(type)) listeners.set(type, new Set());
|
|
listeners.get(type)!.add(listener as unknown as WsListener<ServerMessage["type"]>);
|
|
return () => {
|
|
listeners.get(type)?.delete(listener as unknown as WsListener<ServerMessage["type"]>);
|
|
};
|
|
},
|
|
emit(type: ServerMessage["type"], payload: unknown) {
|
|
for (const listener of listeners.get(type) ?? []) {
|
|
(listener as (p: unknown, id?: string) => void)(payload);
|
|
}
|
|
},
|
|
onStateChange: vi.fn(() => () => {}),
|
|
onSendFailure: vi.fn(() => () => {}),
|
|
onCertMismatch: vi.fn(() => () => {}),
|
|
onCertFirstUse: vi.fn(() => () => {}),
|
|
startCertListener: vi.fn(async () => {}),
|
|
acceptCertFingerprint: vi.fn(async () => {}),
|
|
getState: vi.fn(() => "connected" as ConnectionState),
|
|
_getWs: vi.fn(() => null),
|
|
};
|
|
}
|
|
|
|
function fakeApi(): ApiClient {
|
|
return {
|
|
getConfig: () => ({ host: "" }),
|
|
getReactionUsers: vi.fn(async () => ({ users: [] })),
|
|
} as unknown as ApiClient;
|
|
}
|
|
|
|
function textChannel(id: number, name: string, position = 0) {
|
|
return {
|
|
id,
|
|
name,
|
|
type: "text" as const,
|
|
category: null,
|
|
position,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
lastMessageId: null,
|
|
canSend: true,
|
|
topic: "",
|
|
slowMode: 0,
|
|
nsfw: false,
|
|
voiceMaxUsers: 0,
|
|
voiceMaxVideo: 0,
|
|
};
|
|
}
|
|
|
|
describe("MainPage — activeChannelId subscriber", () => {
|
|
let container: HTMLDivElement;
|
|
let page: ReturnType<typeof createMainPage>;
|
|
|
|
beforeEach(() => {
|
|
resetStores();
|
|
mockMountChannel.mockClear();
|
|
mockDestroyChannel.mockClear();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
page?.destroy?.();
|
|
container.remove();
|
|
});
|
|
|
|
it("mounts the channel when an active channel is set", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(1, textChannel(1, "general"));
|
|
return { ...prev, channels: ch, activeChannelId: 1 };
|
|
});
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
expect(mockMountChannel).toHaveBeenCalledWith(1, "general", "text");
|
|
});
|
|
|
|
it("destroys the mounted channel when the active channel is cleared (deleted/closed while offline)", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(1, textChannel(1, "general"));
|
|
return { ...prev, channels: ch, activeChannelId: 1 };
|
|
});
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
expect(mockMountChannel).toHaveBeenCalledWith(1, "general", "text");
|
|
|
|
// The channel is gone and nothing replaces it as active — this is what
|
|
// dispatcher.ts's ready handler now does when the previously-active
|
|
// channel is absent from a fresh snapshot.
|
|
setActiveChannel(null);
|
|
channelsStore.flush();
|
|
|
|
expect(mockDestroyChannel).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("mounts the newly active channel when switching between two channels", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(1, textChannel(1, "general"));
|
|
ch.set(2, textChannel(2, "random", 1));
|
|
return { ...prev, channels: ch, activeChannelId: 1 };
|
|
});
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
setActiveChannel(2);
|
|
channelsStore.flush();
|
|
|
|
expect(mockMountChannel).toHaveBeenCalledWith(2, "random", "text");
|
|
// Switching to a real channel remounts rather than destroying.
|
|
expect(mockDestroyChannel).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function dmChannel(id: number, name: string, position = 0) {
|
|
return { ...textChannel(id, name, position), type: "dm" as const };
|
|
}
|
|
|
|
describe("MainPage — video grid, DM profile panel, calls, settings", () => {
|
|
let container: HTMLDivElement;
|
|
let page: ReturnType<typeof createMainPage>;
|
|
|
|
beforeEach(() => {
|
|
resetStores();
|
|
mockMountChannel.mockClear();
|
|
mockDestroyChannel.mockClear();
|
|
mockCreateChatArea.mockClear();
|
|
capturedChatAreaRef.current = null;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
page?.destroy?.();
|
|
container.remove();
|
|
});
|
|
|
|
it("dismisses the video grid when switching to a non-voice channel (dm/announcement), not just 'text'", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(1, textChannel(1, "general"));
|
|
ch.set(2, dmChannel(2, "dm-with-bob"));
|
|
return { ...prev, channels: ch, activeChannelId: 1 };
|
|
});
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
// Put the grid into video mode via a local camera start while in voice
|
|
// (the real, unmocked VideoModeController drives this off voiceStore).
|
|
voiceStore.setState((prev) => ({
|
|
...prev,
|
|
currentChannelId: 9,
|
|
voiceUsers: new Map([[9, new Map()]]),
|
|
localCamera: true,
|
|
}));
|
|
voiceStore.flush();
|
|
|
|
expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe("none");
|
|
|
|
// Switch to a DM while the grid is up — a dm channel mounts a chat
|
|
// surface just like text/announcement do, so the grid must not survive
|
|
// the switch and hide it behind an unrelated video grid.
|
|
setActiveChannel(2);
|
|
channelsStore.flush();
|
|
|
|
expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe("");
|
|
});
|
|
|
|
it("does not clear a just-added remote video tile when a voice-channel switch left the camera/screenshare signature unchanged (OC-0207)", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(1, textChannel(1, "general"));
|
|
return { ...prev, channels: ch, activeChannelId: 1 };
|
|
});
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
// Alice joins voice channel A (9). Someone's camera briefly toggles the
|
|
// signature so the real VideoModeController actually runs checkVideoMode
|
|
// against channel 9 and latches its lastChannelId there — mirroring
|
|
// "Alice is already in voice channel A" from the finding's repro.
|
|
voiceStore.setState((prev) => ({
|
|
...prev,
|
|
currentChannelId: 9,
|
|
voiceUsers: new Map([
|
|
[
|
|
9,
|
|
new Map([
|
|
[
|
|
100,
|
|
{
|
|
userId: 100,
|
|
username: "carl",
|
|
muted: false,
|
|
deafened: false,
|
|
speaking: false,
|
|
camera: true,
|
|
screenshare: false,
|
|
},
|
|
],
|
|
]),
|
|
],
|
|
]),
|
|
}));
|
|
voiceStore.flush();
|
|
voiceStore.setState((prev) => ({
|
|
...prev,
|
|
voiceUsers: new Map([
|
|
[
|
|
9,
|
|
new Map([
|
|
[
|
|
100,
|
|
{
|
|
userId: 100,
|
|
username: "carl",
|
|
muted: false,
|
|
deafened: false,
|
|
speaking: false,
|
|
camera: false,
|
|
screenshare: false,
|
|
},
|
|
],
|
|
]),
|
|
],
|
|
]),
|
|
}));
|
|
voiceStore.flush();
|
|
|
|
// Alice switches to voice channel B (10). Nobody in B has camera or
|
|
// screenshare on either, so MainPage's camera/screenshare signature does
|
|
// not change across the switch — the blind spot the finding describes.
|
|
voiceStore.setState((prev) => ({
|
|
...prev,
|
|
currentChannelId: 10,
|
|
voiceUsers: new Map([[10, new Map()]]),
|
|
}));
|
|
voiceStore.flush();
|
|
|
|
// The switch itself may legitimately clear stale tiles from channel A
|
|
// (that is the correct, eager fix) — what matters for this finding is
|
|
// that nothing clears the grid again *after* the new tile is added.
|
|
const videoGrid = capturedChatAreaRef.current!.videoGrid;
|
|
videoGrid.clearStreams.mockClear();
|
|
|
|
// Bob's screenshare track arrives via LiveKit in channel B ahead of the
|
|
// server's voice_state broadcast (the documented TrackSubscribed race).
|
|
const fakeStream = {} as MediaStream;
|
|
capturedOnRemoteVideo.current!(200, fakeStream, true);
|
|
|
|
expect(videoGrid.addStream).toHaveBeenCalled();
|
|
// The bug: VideoModeController's lastChannelId is still stuck on channel
|
|
// A (9) because the switch to B (10) never changed the signature, so the
|
|
// checkVideoMode() call right after addStream sees a "channel change"
|
|
// that isn't one and wipes the tile it was just given.
|
|
expect(videoGrid.clearStreams).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("relabels a remote video tile with the member's display name, not the raw username, and keeps it in sync with a mid-call rename (OC-0227)", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(1, textChannel(1, "general"));
|
|
return { ...prev, channels: ch, activeChannelId: 1 };
|
|
});
|
|
membersStore.setState(() => ({
|
|
members: new Map([
|
|
[
|
|
200,
|
|
{
|
|
id: 200,
|
|
username: "bob_1994",
|
|
avatar: null,
|
|
role: "member",
|
|
status: "online" as const,
|
|
displayName: "Bee",
|
|
},
|
|
],
|
|
]),
|
|
typingUsers: new Map(),
|
|
roleRevision: 0,
|
|
}));
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
voiceStore.setState((prev) => ({
|
|
...prev,
|
|
currentChannelId: 9,
|
|
voiceUsers: new Map([
|
|
[
|
|
9,
|
|
new Map([
|
|
[
|
|
200,
|
|
{
|
|
userId: 200,
|
|
username: "bob_1994",
|
|
muted: false,
|
|
deafened: false,
|
|
speaking: false,
|
|
camera: true,
|
|
screenshare: false,
|
|
},
|
|
],
|
|
]),
|
|
],
|
|
]),
|
|
}));
|
|
voiceStore.flush();
|
|
|
|
const fakeStream = {} as MediaStream;
|
|
capturedOnRemoteVideo.current!(200, fakeStream, false);
|
|
|
|
const videoGrid = capturedChatAreaRef.current!.videoGrid;
|
|
// The tile must show the same identity the voice roster and every other
|
|
// surface show for user 200 — the nickname "Bee" — not the raw username
|
|
// "bob_1994" (ChannelSidebar.ts's memberDisplayName idiom).
|
|
expect(videoGrid.addStream).toHaveBeenCalledWith(
|
|
200,
|
|
"Bee",
|
|
fakeStream,
|
|
expect.objectContaining({ isSelf: false }),
|
|
);
|
|
|
|
// Bob renames himself mid-call (Settings -> Account). The USER_UPDATE
|
|
// fan-out updates both membersStore and voiceStore's frozen username
|
|
// copy, exactly like dispatcher.ts's USER_UPDATE handler does.
|
|
updateMemberProfile(200, { username: "bob_1994", avatar: null, displayName: "Robert" });
|
|
updateVoiceUserProfile(200, { username: "bob_1994" });
|
|
voiceStore.flush();
|
|
|
|
// The already-open tile must pick up the new name without the tile
|
|
// being torn down and re-created (no new addStream call for tile 200).
|
|
expect(videoGrid.setLabel).toHaveBeenCalledWith(200, "Robert");
|
|
});
|
|
|
|
it("does not open the 1:1 profile panel for a group DM header click", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(50, dmChannel(50, "Group Chat"));
|
|
return { ...prev, channels: ch, activeChannelId: 50 };
|
|
});
|
|
dmStore.setState(() => ({
|
|
channels: [
|
|
{
|
|
channelId: 50,
|
|
recipient: { id: 10, username: "alice", avatar: "", status: "online" },
|
|
participants: [
|
|
{ id: 10, username: "alice", avatar: "", status: "online" },
|
|
{ id: 11, username: "bob", avatar: "", status: "online" },
|
|
],
|
|
name: "Group Chat",
|
|
isGroup: true,
|
|
lastMessageId: null,
|
|
lastMessage: "",
|
|
lastMessageAt: "",
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
},
|
|
],
|
|
}));
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
|
|
chatAreaOpts.onToggleDmProfile();
|
|
|
|
// dm.store.ts documents .recipient as "the first of participants" for a
|
|
// group, with the explicit instruction that group-correct code reads
|
|
// .participants instead — a 1:1 panel built from .recipient would show
|
|
// one arbitrary member's identity as if it were the whole conversation.
|
|
expect(
|
|
capturedChatAreaRef.current!.dmProfileSlot.querySelector(
|
|
'[data-testid="dm-profile-sidebar"]',
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("does not ring or announce 'Calling…' when starting a call while the socket is reconnecting", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(50, dmChannel(50, "dm-alice"));
|
|
return { ...prev, channels: ch, activeChannelId: 50 };
|
|
});
|
|
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
|
|
|
|
const ws = fakeWs();
|
|
page = createMainPage({ ws, api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
|
|
chatAreaOpts.onStartCall();
|
|
|
|
// onVoiceJoin already silently refuses to join while the socket is down
|
|
// (VoiceCallbacks.ts's socketLive() guard) — startCall must not still
|
|
// ring and tell the user "Calling…" over a call nobody can hear.
|
|
expect(ws.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "call_ring" }));
|
|
});
|
|
|
|
it("keeps an incoming ring alive when Accept is clicked while the socket is reconnecting", () => {
|
|
const ws = fakeWs();
|
|
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
|
|
|
page = createMainPage({ ws, api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
|
|
|
|
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
|
|
expect(banner.style.display).not.toBe("none");
|
|
|
|
// The socket drops mid-ring.
|
|
uiStore.setState((prev) => ({ ...prev, connectionStatus: "reconnecting" }));
|
|
|
|
const acceptBtn = document.querySelector('[data-testid="incoming-call-accept"]') as HTMLElement;
|
|
acceptBtn.click();
|
|
|
|
// Accepting while reconnecting must not silently consume the ring (the
|
|
// banner clearing means ringCtrl.accept() ran and nothing rejoins) — the
|
|
// ring has to survive so the user can accept again once reconnected.
|
|
expect(banner.style.display).not.toBe("none");
|
|
});
|
|
|
|
it("does not cancel an incoming ring when a fellow group-DM callee declines, only when the actual ringer does (OC-0114)", () => {
|
|
const ws = fakeWs();
|
|
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
|
|
|
page = createMainPage({ ws, api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
// Alice (10) rings a group DM; this client is a third participant.
|
|
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
|
|
|
|
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
|
|
expect(banner.style.display).not.toBe("none");
|
|
|
|
// Bob (11), a different callee in the same group DM, declines. The
|
|
// server addresses call_declined to every other participant (not just
|
|
// the caller — it holds no call state to target with), so this client
|
|
// receives it too, but it must not silence a ring it is still deciding
|
|
// on: Bob declining is not Alice hanging up.
|
|
ws.emit("call_declined", { channel_id: 50, from_user: 11, username: "bob" });
|
|
|
|
expect(banner.style.display).not.toBe("none");
|
|
|
|
// The actual ringer's own call_declined (e.g. a glare decline) still
|
|
// cancels it.
|
|
ws.emit("call_declined", { channel_id: 50, from_user: 10, username: "alice" });
|
|
|
|
expect(banner.style.display).toBe("none");
|
|
});
|
|
|
|
it("does not cancel an incoming ring on a voice_leave for a different channel from the ringer (OC-0011)", () => {
|
|
const ws = fakeWs();
|
|
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
|
|
|
page = createMainPage({ ws, api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
// Alice (10) rings this client's DM (channel 50).
|
|
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
|
|
|
|
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
|
|
expect(banner.style.display).not.toBe("none");
|
|
|
|
// Alice also happens to be sitting in an unrelated server voice channel
|
|
// (99) and leaves it. Same user, wrong channel: this must not silence
|
|
// the DM ring — only a voice_leave for the ring's own channel (50) may.
|
|
ws.emit("voice_leave", { channel_id: 99, user_id: 10 });
|
|
|
|
expect(banner.style.display).not.toBe("none");
|
|
|
|
// Alice leaving the ring's own channel (the DM she was calling from)
|
|
// still cancels it.
|
|
ws.emit("voice_leave", { channel_id: 50, user_id: 10 });
|
|
|
|
expect(banner.style.display).toBe("none");
|
|
});
|
|
|
|
it("does not cancel a group-DM ring when the ringer leaves voice but another callee is still in the call (OC-0235)", () => {
|
|
const ws = fakeWs();
|
|
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
|
|
|
|
page = createMainPage({ ws, api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
// Alice (10) rings a group DM (channel 50); this client is a third
|
|
// participant (C).
|
|
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
|
|
|
|
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
|
|
expect(banner.style.display).not.toBe("none");
|
|
|
|
// Bob (11) already accepted and is sitting in the DM's voice channel.
|
|
// The dispatcher's own voice_leave handler may already have removed
|
|
// Alice from the roster by the time this fires (order-independent), so
|
|
// her entry is absent here too — only Bob remains.
|
|
voiceStore.setState((prev) => ({
|
|
...prev,
|
|
voiceUsers: new Map([
|
|
[
|
|
50,
|
|
new Map([
|
|
[
|
|
11,
|
|
{
|
|
userId: 11,
|
|
username: "bob",
|
|
muted: false,
|
|
deafened: false,
|
|
speaking: false,
|
|
camera: false,
|
|
screenshare: false,
|
|
},
|
|
],
|
|
]),
|
|
],
|
|
]),
|
|
}));
|
|
voiceStore.flush();
|
|
|
|
// Alice, the ringer, hangs up. The call is still live — Bob is in it —
|
|
// so this client's own one-click Accept must not disappear.
|
|
ws.emit("voice_leave", { channel_id: 50, user_id: 10 });
|
|
|
|
expect(banner.style.display).not.toBe("none");
|
|
});
|
|
|
|
it("clears settingsOpen on destroy so the next page (e.g. ConnectPage after logout) doesn't inherit a stale open overlay", () => {
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
uiStore.setState((prev) => ({ ...prev, settingsOpen: true }));
|
|
|
|
page.destroy?.();
|
|
|
|
expect(uiStore.getState().settingsOpen).toBe(false);
|
|
});
|
|
|
|
it("scopes per-user volume prefs to the connected host, like channel mutes and the NSFW gate (B3-6)", () => {
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
expect(mockSetAudioVolumeHost).toHaveBeenCalledWith("");
|
|
});
|
|
|
|
it("closes an open image lightbox on destroy so it doesn't survive onto the next page (B6-15)", () => {
|
|
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
openImageLightbox("https://example.com/pic.png", "pic");
|
|
expect(document.body.querySelector(".image-lightbox")).not.toBeNull();
|
|
|
|
page.destroy?.();
|
|
|
|
expect(document.body.querySelector(".image-lightbox")).toBeNull();
|
|
});
|
|
|
|
it("scopes DM profile notes to the connected host, like channel mutes and the NSFW gate (OC-0143)", () => {
|
|
channelsStore.setState((prev) => {
|
|
const ch = new Map(prev.channels);
|
|
ch.set(60, dmChannel(60, "dm-carol"));
|
|
return { ...prev, channels: ch, activeChannelId: 60 };
|
|
});
|
|
dmStore.setState(() => ({
|
|
channels: [
|
|
{
|
|
channelId: 60,
|
|
recipient: { id: 5, username: "carol", avatar: "", status: "online" },
|
|
participants: [{ id: 5, username: "carol", avatar: "", status: "online" }],
|
|
name: "carol",
|
|
isGroup: false,
|
|
lastMessageId: null,
|
|
lastMessage: "",
|
|
lastMessageAt: "",
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
},
|
|
],
|
|
}));
|
|
|
|
const hostedApi = {
|
|
getConfig: () => ({ host: "chat.example.com" }),
|
|
getReactionUsers: vi.fn(async () => ({ users: [] })),
|
|
} as unknown as ApiClient;
|
|
|
|
page = createMainPage({ ws: fakeWs(), api: hostedApi });
|
|
page.mount(container);
|
|
|
|
const chatAreaOpts = mockCreateChatArea.mock.calls[0]![0];
|
|
chatAreaOpts.onToggleDmProfile();
|
|
|
|
const noteEl = capturedChatAreaRef.current!.dmProfileSlot.querySelector(
|
|
'[data-testid="dps-note"]',
|
|
) as HTMLTextAreaElement;
|
|
noteEl.value = "owes me money";
|
|
noteEl.dispatchEvent(new Event("input"));
|
|
|
|
try {
|
|
// Server A's note about user 5 must land under a key scoped to server A
|
|
// — the same host scoping already applied to channel mutes, the NSFW
|
|
// gate and per-user volume (setChannelMutesHost/setNsfwGateHost/
|
|
// setAudioVolumeHost, all called with apiConfig.host above).
|
|
expect(localStorage.getItem("owncord:dm-note:chat.example.com:5")).toBe("owes me money");
|
|
// And it must not have gone to the legacy unscoped key, which server
|
|
// B's unrelated user 5 would also read from.
|
|
expect(localStorage.getItem("owncord:dm-note:5")).toBeNull();
|
|
} finally {
|
|
localStorage.removeItem("owncord:dm-note:chat.example.com:5");
|
|
localStorage.removeItem("owncord:dm-note:5");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MainPage — presence", () => {
|
|
let container: HTMLDivElement;
|
|
let page: ReturnType<typeof createMainPage>;
|
|
|
|
beforeEach(() => {
|
|
resetStores();
|
|
// Match the client's own idea of the signed-in user's status to the
|
|
// freshly-reset (default "online") saved preference, so the mount-time
|
|
// restoreSavedPresence() call (MainPage.ts:340) is the no-op it
|
|
// documents itself as being, and doesn't spend the single presence
|
|
// token before this test gets to exercise applyPresence directly.
|
|
authStore.setState((prev) => ({
|
|
...prev,
|
|
user: prev.user === null ? null : { ...prev.user, status: "online" },
|
|
}));
|
|
capturedAutoIdleOptions.current = null;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
page?.destroy?.();
|
|
container.remove();
|
|
vi.useRealTimers();
|
|
localStorage.removeItem("owncord:settings:userStatus");
|
|
localStorage.removeItem("owncord:settings:userStatusOrigin");
|
|
});
|
|
|
|
it("retries the return-to-online presence_update once the limiter's window reopens instead of dropping it forever (OC-0111)", () => {
|
|
const ws = fakeWs();
|
|
page = createMainPage({ ws, api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
const onStatusChange = capturedAutoIdleOptions.current!.onStatusChange;
|
|
|
|
// Auto-idle fires idle after ten quiet minutes; this consumes the single
|
|
// presence token (1 per 10s, rate-limiter.ts).
|
|
saveUserStatus("idle", "auto");
|
|
onStatusChange("idle");
|
|
expect(ws.send).toHaveBeenCalledWith({
|
|
type: "presence_update",
|
|
payload: { status: "idle" },
|
|
});
|
|
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
|
|
|
// The very first mouse event after that flips back to online milliseconds
|
|
// later (autoIdle.ts's unthrottled return-to-activity path) — the token
|
|
// is still gone, so the frame cannot go out immediately.
|
|
saveUserStatus("online", "manual");
|
|
onStatusChange("online");
|
|
expect(ws.send).not.toHaveBeenCalled();
|
|
|
|
// Once the limiter's 10s window reopens, the deferred "online" frame must
|
|
// still go out — without a retry the server and every other client stay
|
|
// stuck on "idle" forever with no further trigger to correct it.
|
|
vi.advanceTimersByTime(10_000);
|
|
|
|
expect(ws.send).toHaveBeenCalledWith({
|
|
type: "presence_update",
|
|
payload: { status: "online" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("MainPage — server restart banner", () => {
|
|
let container: HTMLDivElement;
|
|
let page: ReturnType<typeof createMainPage>;
|
|
|
|
beforeEach(() => {
|
|
resetStores();
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
page?.destroy?.();
|
|
container.remove();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("re-syncs to the real (connected) status instead of counting down to a fake Reconnecting on an update_aborted cancel (OC-0164)", () => {
|
|
const ws = fakeWs();
|
|
page = createMainPage({ ws, api: fakeApi() });
|
|
page.mount(container);
|
|
|
|
// Server announces a real restart countdown.
|
|
ws.emit("server_restart", { reason: "update", delay_seconds: 5 });
|
|
|
|
const banner = container.querySelector(".reconnecting-banner") as HTMLElement;
|
|
expect(banner.classList.contains("visible")).toBe(true);
|
|
expect(banner.textContent).toBe("Server restarting in 5 seconds...");
|
|
|
|
// The staged update then fails to swap; the server cancels the countdown
|
|
// it already announced with a zero-delay update_aborted broadcast
|
|
// (Server/admin/update_handlers.go:181). The socket never actually
|
|
// dropped — uiStore.connectionStatus is still "connected".
|
|
expect(uiStore.getState().connectionStatus).toBe("connected");
|
|
ws.emit("server_restart", { reason: "update_aborted", delay_seconds: 0 });
|
|
|
|
// The banner must re-sync to the real (connected) status — hidden — not
|
|
// count down to a permanent "Reconnecting..." over a healthy connection.
|
|
expect(banner.classList.contains("visible")).toBe(false);
|
|
|
|
// And nothing left running should later flip it to Reconnecting either
|
|
// (the buggy path fed 0 into showRestart's setInterval, which falls
|
|
// through remaining<=0 into showReconnecting on the very next tick).
|
|
vi.advanceTimersByTime(2000);
|
|
expect(banner.textContent).not.toBe("Reconnecting...");
|
|
expect(banner.classList.contains("visible")).toBe(false);
|
|
});
|
|
});
|