mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* fix(identity): 1 defect(s) (OC-0151)
* fix(ws): 1 defect(s) (OC-0152)
* fix(admin): 1 defect(s) (OC-0153)
* fix(admin): 1 defect(s) (OC-0154)
* fix(voice): 2 defect(s) (OC-0155, OC-0167)
Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.
- OC-0155: back-to-back rotations (the second run immediately by
drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
entirely, letting a key holder joining a large ongoing call burst every
queued announce's offer unpaced.
The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).
* fix(client): 1 defect(s) (OC-0156)
createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.
* fix(client): 2 defect(s) (OC-0160, OC-0163)
OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.
OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.
* fix(voice): 1 defect(s) (OC-0162)
updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.
* fix(client): 1 defect(s) (OC-0164)
* fix(plugin): 1 defect(s) (OC-0165)
scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.
* fix(ws): 1 defect(s) (OC-0166)
Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.
* fix(db): 1 defect(s) (OC-0168)
* fix(client): 1 defect(s) (OC-0169)
* fix(client): 1 defect(s) (OC-0171)
addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.
* fix(voice): 1 defect(s) (OC-0172)
* fix(client): 1 defect(s) (OC-0174)
* fix(ws): 1 defect(s) (OC-0175)
* fix(client): 1 defect(s) (OC-0177)
* fix(client): 1 defect(s) (OC-0178)
* fix(voice): 1 defect(s) (OC-0179)
Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.
* fix(client): 1 defect(s) (OC-0182)
* fix(plugin): 1 defect(s) (OC-0183)
* fix(client): 1 defect(s) (OC-0184)
Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.
* fix(client): 1 defect(s) (OC-0185)
Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): 1 defect(s) (OC-0186)
* fix(client): 1 defect(s) (OC-0187)
The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.
* fix(client): 1 defect(s) (OC-0189)
DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.
* fix(client): 1 defect(s) (OC-0190)
* fix(client): 1 defect(s) (OC-0191)
* fix(client): 2 defect(s) (OC-0157, OC-0176)
* fix(client): 1 defect(s) (OC-0161)
confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.
* fix(admin): 1 defect(s) (OC-0173)
* fix(identity): 1 defect(s) (OC-0180)
* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)
handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.
Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.
Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(admin): role changes commit with no client ever notified (OC-0170)
broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.
Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.
Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): username rename stomps the profile card header (OC-0188)
The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.
Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): settings overlay never focuses when mounted already-open (OC-0181)
mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.
Attach root before syncing initial state so focusDialog() runs against a
connected subtree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore: satisfy the CI gates for this fix batch
The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.
golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
and OC-0151 the last use of api's sanitizer, leaving both package-level
bluemonday vars unused. Remove them along with the now-unused imports,
and reword the comments that named them so they still explain why the
fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.
tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
must supply typingUsers.
prettier: reformat the five files this batch touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore(ledger): record the 2026-08-19 hunt and its fixes
Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".
"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.
OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* test(e2e): make the voice-roster join fixture self-consistent
The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.
The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.
Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.
Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
---------
Co-authored-by: Claude <noreply@anthropic.com>
1059 lines
39 KiB
TypeScript
1059 lines
39 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
createServerPanel,
|
|
type ServerPanelOptions,
|
|
type SimpleProfile,
|
|
} from "@pages/connect-page/ServerPanel";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// loadCredential returns null by default (non-Tauri environment)
|
|
vi.mock("@lib/credentials", () => ({
|
|
loadCredential: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
// Icons mock — return a real SVG element so the DOM tests work
|
|
vi.mock("@lib/icons", () => ({
|
|
createIcon: vi.fn((_name: string, _size?: number) => {
|
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
svg.setAttribute("data-icon", _name);
|
|
return svg;
|
|
}),
|
|
}));
|
|
|
|
import { loadCredential } from "@lib/credentials";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeAc(): AbortController {
|
|
return new AbortController();
|
|
}
|
|
|
|
function makeOpts(overrides: Partial<ServerPanelOptions> = {}): ServerPanelOptions {
|
|
return {
|
|
signal: overrides.signal ?? makeAc().signal,
|
|
onServerClick: overrides.onServerClick ?? vi.fn(),
|
|
onCredentialLoaded: overrides.onCredentialLoaded ?? vi.fn(),
|
|
onAddProfile: overrides.onAddProfile ?? vi.fn(),
|
|
onDeleteProfile: overrides.onDeleteProfile ?? vi.fn(),
|
|
onToggleAutoLogin: overrides.onToggleAutoLogin ?? vi.fn(),
|
|
};
|
|
}
|
|
|
|
const SIMPLE_PROFILES: readonly SimpleProfile[] = [
|
|
{ name: "Test Server", host: "localhost:8443" },
|
|
{ name: "Another Server", host: "remote.example.com:9443" },
|
|
];
|
|
|
|
/** Full profile shape with id/username/autoConnect for auto-login + delete tests. */
|
|
function fullProfile(overrides: Record<string, unknown> = {}): SimpleProfile {
|
|
return {
|
|
name: "Full Server",
|
|
host: "full.example.com:8443",
|
|
id: "profile-1",
|
|
username: "testuser",
|
|
autoConnect: false,
|
|
rememberPassword: true,
|
|
color: "#5865F2",
|
|
lastConnected: null,
|
|
...overrides,
|
|
} as unknown as SimpleProfile;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("ServerPanel", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
// Wrap in a connect-page root so handleAddServer can mount the modal
|
|
container.classList.add("connect-page");
|
|
document.body.appendChild(container);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Basic rendering
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("rendering", () => {
|
|
it("renders a server-panel element", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
expect(container.querySelector(".server-panel")).not.toBeNull();
|
|
});
|
|
|
|
it("renders a heading with text 'Servers'", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const heading = container.querySelector("h2");
|
|
expect(heading?.textContent).toBe("Servers");
|
|
});
|
|
|
|
it("renders one server-item per profile", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const items = container.querySelectorAll(".server-item");
|
|
expect(items.length).toBe(2);
|
|
});
|
|
|
|
it("renders server name and host", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
expect(container.querySelector(".srv-name")?.textContent).toBe("Test Server");
|
|
expect(container.querySelector(".srv-host")?.textContent).toBe("localhost:8443");
|
|
});
|
|
|
|
it("renders an icon with the first letter of the server name", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const icon = container.querySelector(".srv-icon");
|
|
expect(icon?.textContent).toBe("T");
|
|
});
|
|
|
|
it("renders an icon with a background color from the palette", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const icon = container.querySelector(".srv-icon") as HTMLElement;
|
|
expect(icon.style.background).toBeTruthy();
|
|
});
|
|
|
|
it("renders data-host attribute on each server-item", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const items = container.querySelectorAll(".server-item");
|
|
expect(items[0]?.getAttribute("data-host")).toBe("localhost:8443");
|
|
expect(items[1]?.getAttribute("data-host")).toBe("remote.example.com:9443");
|
|
});
|
|
|
|
it("renders a status dot with unknown class initially", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const dot = container.querySelector(".srv-status-dot");
|
|
expect(dot?.classList.contains("unknown")).toBe(true);
|
|
});
|
|
|
|
it("renders the '+ Add Server' button in the footer", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server");
|
|
expect(addBtn?.textContent).toBe("+ Add Server");
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Username display for full profiles
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("username display", () => {
|
|
it("renders username in meta when profile has one", () => {
|
|
const fp = fullProfile();
|
|
const panel = createServerPanel(makeOpts(), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const hosts = container.querySelectorAll(".srv-host");
|
|
// First is the host address, second is the username
|
|
expect(hosts.length).toBe(2);
|
|
expect(hosts[1]?.textContent).toBe("testuser");
|
|
});
|
|
|
|
it("does NOT render extra username span for simple profiles", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const hosts = container.querySelectorAll(".srv-host");
|
|
expect(hosts.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Server click
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("server click", () => {
|
|
it("calls onServerClick with host when a server item is clicked", () => {
|
|
const onServerClick = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onServerClick }), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const item = container.querySelector(".server-item") as HTMLElement;
|
|
item.click();
|
|
|
|
expect(onServerClick).toHaveBeenCalledWith("localhost:8443", undefined, false);
|
|
});
|
|
|
|
it("calls onServerClick with host AND username for full profiles", () => {
|
|
const onServerClick = vi.fn();
|
|
const fp = fullProfile();
|
|
const panel = createServerPanel(makeOpts({ onServerClick }), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const item = container.querySelector(".server-item") as HTMLElement;
|
|
item.click();
|
|
|
|
expect(onServerClick).toHaveBeenCalledWith("full.example.com:8443", "testuser", false);
|
|
});
|
|
|
|
it("calls onServerClick with autoConnect true when the profile has it enabled", () => {
|
|
const onServerClick = vi.fn();
|
|
const fp = fullProfile({ autoConnect: true });
|
|
const panel = createServerPanel(makeOpts({ onServerClick }), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const item = container.querySelector(".server-item") as HTMLElement;
|
|
item.click();
|
|
|
|
expect(onServerClick).toHaveBeenCalledWith("full.example.com:8443", "testuser", true);
|
|
});
|
|
|
|
it("calls onServerClick with autoConnect false when the profile has it disabled", () => {
|
|
const onServerClick = vi.fn();
|
|
const fp = fullProfile({ autoConnect: false });
|
|
const panel = createServerPanel(makeOpts({ onServerClick }), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const item = container.querySelector(".server-item") as HTMLElement;
|
|
item.click();
|
|
|
|
expect(onServerClick).toHaveBeenCalledWith("full.example.com:8443", "testuser", false);
|
|
});
|
|
|
|
it("attempts to load credentials from credential store on click", async () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const item = container.querySelector(".server-item") as HTMLElement;
|
|
item.click();
|
|
|
|
// Wait for the async loadCredential call
|
|
await vi.waitFor(() => {
|
|
expect(loadCredential).toHaveBeenCalledWith("localhost:8443");
|
|
});
|
|
});
|
|
|
|
it("calls onCredentialLoaded when credentials are found", async () => {
|
|
const onCredentialLoaded = vi.fn();
|
|
vi.mocked(loadCredential).mockResolvedValueOnce({
|
|
username: "saveduser",
|
|
token: "tok",
|
|
});
|
|
|
|
const panel = createServerPanel(makeOpts({ onCredentialLoaded }), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const item = container.querySelector(".server-item") as HTMLElement;
|
|
item.click();
|
|
|
|
// Password is no longer returned from credential store over IPC (security hardening)
|
|
await vi.waitFor(() => {
|
|
expect(onCredentialLoaded).toHaveBeenCalledWith("localhost:8443", "saveduser", undefined);
|
|
});
|
|
});
|
|
|
|
it("does NOT call onCredentialLoaded when no credentials found", async () => {
|
|
const onCredentialLoaded = vi.fn();
|
|
vi.mocked(loadCredential).mockResolvedValueOnce(null);
|
|
|
|
const panel = createServerPanel(makeOpts({ onCredentialLoaded }), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const item = container.querySelector(".server-item") as HTMLElement;
|
|
item.click();
|
|
|
|
// Wait for loadCredential to resolve
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(onCredentialLoaded).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Auto-login toggle
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("auto-login toggle", () => {
|
|
it("renders auto-login button for full profiles with onToggleAutoLogin", () => {
|
|
const panel = createServerPanel(makeOpts(), [fullProfile()]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login");
|
|
expect(autoLoginBtn).not.toBeNull();
|
|
});
|
|
|
|
it("does NOT render auto-login button for simple profiles", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login");
|
|
expect(autoLoginBtn).toBeNull();
|
|
});
|
|
|
|
it("does NOT render auto-login button when onToggleAutoLogin not provided", () => {
|
|
const ac = new AbortController();
|
|
const opts: ServerPanelOptions = {
|
|
signal: ac.signal,
|
|
onServerClick: vi.fn(),
|
|
onCredentialLoaded: vi.fn(),
|
|
// onToggleAutoLogin intentionally omitted
|
|
};
|
|
const panel = createServerPanel(opts, [fullProfile()]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login");
|
|
expect(autoLoginBtn).toBeNull();
|
|
});
|
|
|
|
it("renders active class and correct aria-label when autoConnect is true", () => {
|
|
const fp = fullProfile({ autoConnect: true });
|
|
const panel = createServerPanel(makeOpts(), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login") as HTMLElement;
|
|
expect(autoLoginBtn.classList.contains("active")).toBe(true);
|
|
expect(autoLoginBtn.getAttribute("aria-label")).toBe("Disable auto-login");
|
|
expect(autoLoginBtn.getAttribute("title")).toBe("Auto-login enabled");
|
|
});
|
|
|
|
it("renders without active class when autoConnect is false", () => {
|
|
const fp = fullProfile({ autoConnect: false });
|
|
const panel = createServerPanel(makeOpts(), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login") as HTMLElement;
|
|
expect(autoLoginBtn.classList.contains("active")).toBe(false);
|
|
expect(autoLoginBtn.getAttribute("aria-label")).toBe("Enable auto-login");
|
|
expect(autoLoginBtn.getAttribute("title")).toBe("Enable auto-login");
|
|
});
|
|
|
|
it("calls onToggleAutoLogin with profile id and toggled state", () => {
|
|
const onToggleAutoLogin = vi.fn();
|
|
const fp = fullProfile({ autoConnect: false });
|
|
const panel = createServerPanel(makeOpts({ onToggleAutoLogin }), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login") as HTMLElement;
|
|
autoLoginBtn.click();
|
|
|
|
expect(onToggleAutoLogin).toHaveBeenCalledWith("profile-1", true);
|
|
});
|
|
|
|
it("stops event propagation so server click is not also triggered", () => {
|
|
const onServerClick = vi.fn();
|
|
const onToggleAutoLogin = vi.fn();
|
|
const fp = fullProfile();
|
|
const panel = createServerPanel(makeOpts({ onServerClick, onToggleAutoLogin }), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login") as HTMLElement;
|
|
autoLoginBtn.click();
|
|
|
|
expect(onToggleAutoLogin).toHaveBeenCalled();
|
|
expect(onServerClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders the zap icon inside the auto-login button", () => {
|
|
const panel = createServerPanel(makeOpts(), [fullProfile()]);
|
|
container.appendChild(panel.element);
|
|
|
|
const autoLoginBtn = container.querySelector(".auto-login") as HTMLElement;
|
|
const svg = autoLoginBtn.querySelector("svg");
|
|
expect(svg?.getAttribute("data-icon")).toBe("zap");
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Delete button
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("delete button", () => {
|
|
it("renders delete button for full profiles with onDeleteProfile", () => {
|
|
const panel = createServerPanel(makeOpts(), [fullProfile()]);
|
|
container.appendChild(panel.element);
|
|
|
|
const deleteBtn = container.querySelector(".srv-btn.danger");
|
|
expect(deleteBtn).not.toBeNull();
|
|
});
|
|
|
|
it("does NOT render delete button for simple profiles", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
const deleteBtn = container.querySelector(".srv-btn.danger");
|
|
expect(deleteBtn).toBeNull();
|
|
});
|
|
|
|
it("does NOT render delete button when onDeleteProfile not provided", () => {
|
|
const ac = new AbortController();
|
|
const opts: ServerPanelOptions = {
|
|
signal: ac.signal,
|
|
onServerClick: vi.fn(),
|
|
onCredentialLoaded: vi.fn(),
|
|
// onDeleteProfile intentionally omitted
|
|
};
|
|
const panel = createServerPanel(opts, [fullProfile()]);
|
|
container.appendChild(panel.element);
|
|
|
|
const deleteBtn = container.querySelector(".srv-btn.danger");
|
|
expect(deleteBtn).toBeNull();
|
|
});
|
|
|
|
it("calls onDeleteProfile with profile id on click", () => {
|
|
const onDeleteProfile = vi.fn();
|
|
const fp = fullProfile();
|
|
const panel = createServerPanel(makeOpts({ onDeleteProfile }), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const deleteBtn = container.querySelector(".srv-btn.danger") as HTMLElement;
|
|
deleteBtn.click();
|
|
|
|
expect(onDeleteProfile).toHaveBeenCalledWith("profile-1");
|
|
});
|
|
|
|
it("stops event propagation so server click is not also triggered", () => {
|
|
const onServerClick = vi.fn();
|
|
const onDeleteProfile = vi.fn();
|
|
const fp = fullProfile();
|
|
const panel = createServerPanel(makeOpts({ onServerClick, onDeleteProfile }), [fp]);
|
|
container.appendChild(panel.element);
|
|
|
|
const deleteBtn = container.querySelector(".srv-btn.danger") as HTMLElement;
|
|
deleteBtn.click();
|
|
|
|
expect(onDeleteProfile).toHaveBeenCalled();
|
|
expect(onServerClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders the x icon inside the delete button", () => {
|
|
const panel = createServerPanel(makeOpts(), [fullProfile()]);
|
|
container.appendChild(panel.element);
|
|
|
|
const deleteBtn = container.querySelector(".srv-btn.danger") as HTMLElement;
|
|
const svg = deleteBtn.querySelector("svg");
|
|
expect(svg?.getAttribute("data-icon")).toBe("x");
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Health status updates
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("updateHealthStatus", () => {
|
|
it("updates the status dot class to online", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 42,
|
|
version: "1.0.0",
|
|
onlineUsers: 5,
|
|
});
|
|
|
|
const dot = container.querySelector(".srv-status-dot");
|
|
expect(dot?.className).toBe("srv-status-dot online");
|
|
});
|
|
|
|
it("updates latency text and class for good latency", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 42,
|
|
version: "1.0.0",
|
|
onlineUsers: 3,
|
|
});
|
|
|
|
const latency = container.querySelector(".srv-latency");
|
|
expect(latency?.textContent).toBe("42ms");
|
|
expect(latency?.className).toBe("srv-latency good");
|
|
});
|
|
|
|
it("applies warn class for moderate latency (100-500ms)", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "slow",
|
|
latencyMs: 250,
|
|
version: "1.0.0",
|
|
onlineUsers: 1,
|
|
});
|
|
|
|
const latency = container.querySelector(".srv-latency");
|
|
expect(latency?.textContent).toBe("250ms");
|
|
expect(latency?.className).toBe("srv-latency warn");
|
|
});
|
|
|
|
it("applies bad class for high latency (>500ms)", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "slow",
|
|
latencyMs: 750,
|
|
version: "1.0.0",
|
|
onlineUsers: 0,
|
|
});
|
|
|
|
const latency = container.querySelector(".srv-latency");
|
|
expect(latency?.textContent).toBe("750ms");
|
|
expect(latency?.className).toBe("srv-latency bad");
|
|
});
|
|
|
|
it("clears latency text when latencyMs is null", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
// First set a latency
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 42,
|
|
version: "1.0.0",
|
|
onlineUsers: 3,
|
|
});
|
|
|
|
// Then clear it
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "offline",
|
|
latencyMs: null,
|
|
version: null,
|
|
onlineUsers: null,
|
|
});
|
|
|
|
const latency = container.querySelector(".srv-latency");
|
|
expect(latency?.textContent).toBe("");
|
|
expect(latency?.className).toBe("srv-latency");
|
|
});
|
|
|
|
it("displays online users count", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 30,
|
|
version: "1.0.0",
|
|
onlineUsers: 5,
|
|
});
|
|
|
|
const onlineUsers = container.querySelector(".srv-online-users");
|
|
expect(onlineUsers?.textContent).toBe("5 online");
|
|
expect(onlineUsers?.classList.contains("has-users")).toBe(true);
|
|
});
|
|
|
|
it("displays online users without has-users class when count is 0", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 30,
|
|
version: "1.0.0",
|
|
onlineUsers: 0,
|
|
});
|
|
|
|
const onlineUsers = container.querySelector(".srv-online-users");
|
|
expect(onlineUsers?.textContent).toBe("0 online");
|
|
expect(onlineUsers?.classList.contains("has-users")).toBe(false);
|
|
});
|
|
|
|
it("clears online users text when onlineUsers is null", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "offline",
|
|
latencyMs: null,
|
|
version: null,
|
|
onlineUsers: null,
|
|
});
|
|
|
|
const onlineUsers = container.querySelector(".srv-online-users");
|
|
expect(onlineUsers?.textContent).toBe("");
|
|
expect(onlineUsers?.className).toBe("srv-online-users");
|
|
});
|
|
|
|
it("ignores updates for unknown hosts", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
// Should not throw
|
|
panel.updateHealthStatus("unknown.host:9999", {
|
|
status: "online",
|
|
latencyMs: 10,
|
|
version: "1.0.0",
|
|
onlineUsers: 1,
|
|
});
|
|
|
|
// Original dot should still be unknown
|
|
const dot = container.querySelector(".srv-status-dot");
|
|
expect(dot?.classList.contains("unknown")).toBe(true);
|
|
});
|
|
|
|
it("updates status dot to offline class", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "offline",
|
|
latencyMs: null,
|
|
version: null,
|
|
onlineUsers: null,
|
|
});
|
|
|
|
const dot = container.querySelector(".srv-status-dot");
|
|
expect(dot?.className).toBe("srv-status-dot offline");
|
|
});
|
|
|
|
it("updates status dot to checking class", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "checking",
|
|
latencyMs: null,
|
|
version: null,
|
|
onlineUsers: null,
|
|
});
|
|
|
|
const dot = container.querySelector(".srv-status-dot");
|
|
expect(dot?.className).toBe("srv-status-dot checking");
|
|
});
|
|
|
|
it("clears online users when onlineUsers is negative", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 30,
|
|
version: "1.0.0",
|
|
onlineUsers: -1,
|
|
});
|
|
|
|
const onlineUsers = container.querySelector(".srv-online-users");
|
|
expect(onlineUsers?.textContent).toBe("");
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// renderProfiles (re-render)
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("renderProfiles", () => {
|
|
it("replaces existing profiles with new ones", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
expect(container.querySelectorAll(".server-item").length).toBe(1);
|
|
|
|
panel.renderProfiles(SIMPLE_PROFILES);
|
|
expect(container.querySelectorAll(".server-item").length).toBe(2);
|
|
});
|
|
|
|
it("clears health elements so old hosts no longer receive updates", () => {
|
|
const panel = createServerPanel(makeOpts(), [SIMPLE_PROFILES[0]!]);
|
|
container.appendChild(panel.element);
|
|
|
|
// Re-render with different profiles
|
|
panel.renderProfiles([SIMPLE_PROFILES[1]!]);
|
|
|
|
// Updating the old host should not throw and should have no effect
|
|
panel.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 10,
|
|
version: "1.0.0",
|
|
onlineUsers: 1,
|
|
});
|
|
|
|
// The new profile's dot should still be unknown
|
|
const dot = container.querySelector(".srv-status-dot");
|
|
expect(dot?.classList.contains("unknown")).toBe(true);
|
|
});
|
|
|
|
it("renders with empty array (no profiles)", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
panel.renderProfiles([]);
|
|
expect(container.querySelectorAll(".server-item").length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Add Server modal
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("Add Server modal", () => {
|
|
it("opens a modal when the Add Server button is clicked", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const overlay = container.querySelector(".modal-overlay");
|
|
expect(overlay).not.toBeNull();
|
|
expect(overlay?.classList.contains("visible")).toBe(true);
|
|
});
|
|
|
|
it("does nothing if onAddProfile is not provided", () => {
|
|
const ac = new AbortController();
|
|
const opts: ServerPanelOptions = {
|
|
signal: ac.signal,
|
|
onServerClick: vi.fn(),
|
|
onCredentialLoaded: vi.fn(),
|
|
// onAddProfile intentionally omitted
|
|
};
|
|
const panel = createServerPanel(opts, SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const overlay = container.querySelector(".modal-overlay");
|
|
expect(overlay).toBeNull();
|
|
});
|
|
|
|
it("modal has name and host input fields", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input");
|
|
expect(inputs.length).toBe(2);
|
|
expect((inputs[0] as HTMLInputElement).placeholder).toBe("My Server");
|
|
expect((inputs[1] as HTMLInputElement).placeholder).toBe("example.com:8443");
|
|
});
|
|
|
|
it("modal has Cancel and Add Server buttons", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
expect(container.querySelector(".btn-ghost")?.textContent).toBe("Cancel");
|
|
expect(container.querySelector(".modal-footer .btn-primary")?.textContent).toBe("Add Server");
|
|
});
|
|
|
|
it("closes modal when Cancel is clicked", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
expect(container.querySelector(".modal-overlay")).not.toBeNull();
|
|
|
|
const cancelBtn = container.querySelector(".btn-ghost") as HTMLElement;
|
|
cancelBtn.click();
|
|
|
|
expect(container.querySelector(".modal-overlay")).toBeNull();
|
|
});
|
|
|
|
it("closes modal when the X close button is clicked", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const closeBtn = container.querySelector(".modal-close") as HTMLElement;
|
|
closeBtn.click();
|
|
|
|
expect(container.querySelector(".modal-overlay")).toBeNull();
|
|
});
|
|
|
|
it("closes modal when clicking the backdrop", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const overlay = container.querySelector(".modal-overlay") as HTMLElement;
|
|
// Simulate clicking the overlay itself (not the modal content)
|
|
overlay.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
|
|
expect(container.querySelector(".modal-overlay")).toBeNull();
|
|
});
|
|
|
|
it("does NOT close modal when clicking inside the modal body", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const modal = container.querySelector(".modal") as HTMLElement;
|
|
modal.click();
|
|
|
|
expect(container.querySelector(".modal-overlay")).not.toBeNull();
|
|
});
|
|
|
|
it("calls onAddProfile and closes modal on valid save", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "My New Server";
|
|
inputs[1]!.value = "newserver.com:8443";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).toHaveBeenCalledWith("My New Server", "newserver.com:8443");
|
|
expect(container.querySelector(".modal-overlay")).toBeNull();
|
|
});
|
|
|
|
it("does NOT call onAddProfile when name is empty", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "";
|
|
inputs[1]!.value = "host.com:8443";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).not.toHaveBeenCalled();
|
|
// Modal stays open
|
|
expect(container.querySelector(".modal-overlay")).not.toBeNull();
|
|
});
|
|
|
|
it("does NOT call onAddProfile when host is empty", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "Some Server";
|
|
inputs[1]!.value = "";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects invalid host addresses with special characters", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "Bad Server";
|
|
inputs[1]!.value = "http://evil.com/path";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).not.toHaveBeenCalled();
|
|
expect(container.querySelector(".modal-overlay")).not.toBeNull();
|
|
});
|
|
|
|
it("accepts host without port", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "Port-less";
|
|
inputs[1]!.value = "myserver.example.com";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).toHaveBeenCalledWith("Port-less", "myserver.example.com");
|
|
});
|
|
|
|
it("accepts a bracketed IPv6 host with port (OC-0187)", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "v6";
|
|
inputs[1]!.value = "[::1]:8443";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).toHaveBeenCalledWith("v6", "[::1]:8443");
|
|
expect(container.querySelector(".modal-overlay")).toBeNull();
|
|
});
|
|
|
|
it("accepts a bare (unbracketed) IPv6 host without port (OC-0187)", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "v6-bare";
|
|
inputs[1]!.value = "2001:db8::1";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).toHaveBeenCalledWith("v6-bare", "2001:db8::1");
|
|
expect(container.querySelector(".modal-overlay")).toBeNull();
|
|
});
|
|
|
|
it("submits on Enter key in host input", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = "Enter Server";
|
|
inputs[1]!.value = "enter.example.com:8443";
|
|
|
|
// Dispatch Enter keydown on the host input
|
|
inputs[1]!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
|
|
expect(onAddProfile).toHaveBeenCalledWith("Enter Server", "enter.example.com:8443");
|
|
});
|
|
|
|
it("trims whitespace from name and host values", () => {
|
|
const onAddProfile = vi.fn();
|
|
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
|
inputs[0]!.value = " Trimmed Server ";
|
|
inputs[1]!.value = " trimmed.com:8443 ";
|
|
|
|
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
|
saveBtn.click();
|
|
|
|
expect(onAddProfile).toHaveBeenCalledWith("Trimmed Server", "trimmed.com:8443");
|
|
});
|
|
|
|
it("focuses the name input on modal open", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
|
addBtn.click();
|
|
|
|
const nameInput = container.querySelectorAll(".form-input")[0] as HTMLInputElement;
|
|
expect(document.activeElement).toBe(nameInput);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// destroy()
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("destroy", () => {
|
|
it("destroy() can be called without errors", () => {
|
|
const panel = createServerPanel(makeOpts(), SIMPLE_PROFILES);
|
|
container.appendChild(panel.element);
|
|
|
|
expect(() => panel.destroy()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Edge cases — icon color & initials
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe("icon generation", () => {
|
|
it("handles single-character server names", () => {
|
|
const panel = createServerPanel(makeOpts(), [{ name: "X", host: "x.com:443" }]);
|
|
container.appendChild(panel.element);
|
|
|
|
const icon = container.querySelector(".srv-icon");
|
|
expect(icon?.textContent).toBe("X");
|
|
});
|
|
|
|
it("handles lowercase names by uppercasing the initial", () => {
|
|
const panel = createServerPanel(makeOpts(), [{ name: "lowercase", host: "lower.com:443" }]);
|
|
container.appendChild(panel.element);
|
|
|
|
const icon = container.querySelector(".srv-icon");
|
|
expect(icon?.textContent).toBe("L");
|
|
});
|
|
|
|
it("generates consistent colors for the same name", () => {
|
|
const panel1 = createServerPanel(makeOpts(), [{ name: "Consistent", host: "a.com:443" }]);
|
|
container.appendChild(panel1.element);
|
|
const color1 = (container.querySelector(".srv-icon") as HTMLElement).style.background;
|
|
|
|
container.innerHTML = "";
|
|
const panel2 = createServerPanel(makeOpts(), [{ name: "Consistent", host: "b.com:443" }]);
|
|
container.appendChild(panel2.element);
|
|
const color2 = (container.querySelector(".srv-icon") as HTMLElement).style.background;
|
|
|
|
expect(color1).toBe(color2);
|
|
});
|
|
|
|
it("generates different colors for different names", () => {
|
|
const panel = createServerPanel(makeOpts(), [
|
|
{ name: "Alpha", host: "a.com:443" },
|
|
{ name: "Zulu", host: "z.com:443" },
|
|
]);
|
|
container.appendChild(panel.element);
|
|
|
|
const icons = container.querySelectorAll(".srv-icon") as NodeListOf<HTMLElement>;
|
|
// Not guaranteed to be different for all names, but for these two it should be
|
|
// (given the hash algorithm). Just check they both have a background set.
|
|
expect(icons[0]!.style.background).toBeTruthy();
|
|
expect(icons[1]!.style.background).toBeTruthy();
|
|
});
|
|
});
|
|
});
|