Files
OwnCord/Client/tauri-client/tests/unit/server-panel.test.ts
T
J3vbandClaude d3526968bb release: v1.2.0-alpha.2 (#1333)
* docs: add bug-detection improvements plan

Plan for mechanical bug detection alongside the agentic hunt: activate the 14
unused Go fuzz harnesses, the configured-but-never-run Stryker setup, and
browser-mode vitest; encode recurring bug classes as semgrep rules; add
model-based and fault-injected ordering tests; add a persistent seen-ledger
and sibling-sweep lens to the hunt.

All local-only and on demand - fuzz crashers are working reproducers, and this
repo is public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build: add make fuzz target and ignore mutation-test output

`go test ./...` runs each Fuzz* function against its committed seed corpus
only - one pass per seed, zero generated inputs - so the 17 fuzz harnesses in
Server/ have never actually fuzzed. `make fuzz` enumerates every target and
runs each with a time budget (Go fuzzes one target per package per
invocation, hence the loop). Local-only by design: a crasher is a working
reproducer and this repo is public.

Also gitignore Client/tauri-client/.stryker-tmp/ and reports/ - a Stryker run
left 200+ untracked files, and a surviving-mutant report maps exactly which
behaviour nothing tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(client): pin reconnect auth-frame and replay-dedup arming

Stryker found 14 surviving mutants across ws.ts:413/422/428 - the auth frame
built on reconnect. Every condition there could be flipped with all 4777
tests still green: the replay-dedup arming guard, the resume-vs-fresh-connect
ternary, and the conditional active_channel_id spread.

Seven tests through the public send/isReplaying surface, no new exports. Two
isolate each half of the `reconnectAttempt > 0 && lastSeq > 0` AND condition -
the combination no existing test reached, and the one an && -> || mutant
walked straight through.

Verified by flipping the line 413 guard to `if (true)`: 3 of 7 fail, revert
restores green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record two fuzz corpus traps

Interrupting a fuzz run manufactures a false crasher: Go cannot distinguish a
worker that crashed on an input from one killed externally, so it saves the
in-flight input to testdata/fuzz/ as a suspect. It looks exactly like a real
security finding. Replay before believing it.

And committed seed corpus shares the testdata/fuzz/<Target>/ directory with
any false crasher, so clearing one by removing the directory deletes the
seeds too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(client): enforce three prose invariants as ESLint rules

CLAUDE.md documents the voice-supersession, E2EE staleness and dispatcher
invariants in English. English fails no build, and bug hunts keep rediscovering
the same classes. Five rules encode them as an inline flat-config plugin - no
new dependency, and `npx eslint src/` is already a blocking CI gate.

- no-leave-voice-when-superseded: a global leaveVoice() inside a branch that
  already confirmed supersession tears down the newer live session
- e2ee-epoch-needs-keypair-check: a non-key-holder never bumps the epoch, so
  an epoch-only staleness guard cannot see a restarted session
- e2ee-verified-status-literal: keeps "verified" tied to a hand-written call
  site that earned it, never a computed status
- no-identity-scope-fallback: a `?? 0` placeholder scope mints a keypair under
  the wrong account
- no-store-write-in-ws-on: page-local ws.on handlers may read stores, not
  write them

Each rule proven to fire by reintroducing the historical bug shape and
reverting; RuleTester cases cover both the real shapes that must stay clean
and the bug shapes that must not.

A fourth candidate - await-then-stale-snapshot - was declined as not
AST-expressible: whether an await needs a guard, and whether the guard is
sufficient, is intent rather than shape, and the rule would flag most of the
already-correct guard code in livekitSession.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct dispatcher invariant, record Tier 2 as shipped

The client CLAUDE.md claimed ws.on(...) appears only in dispatcher.ts. Eight
handlers across main.ts, MainPage.ts and ChannelController.ts say otherwise -
page-local UI (ringing, overlays, slow-mode timers) legitimately subscribes.
The real invariant is narrower: dispatcher is the single path by which server
events WRITE to domain stores. That is what local/no-store-write-in-ws-on
enforces, and the doc now matches the code.

Also record that Tier 2 shipped as ESLint rules rather than semgrep, and why
the fourth candidate was declined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): move the status-picker dot onto the avatar corner

The corner dot on the user bar avatar was a static hardcoded-green div —
never reflected real status and did nothing on click. Removed it and
relocated the actual StatusPicker trigger dot (real color, opens the
status dropdown) to that same corner instead of its own row. The
"Online"/"Idle"/... text label under the username is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(client): return the saved password over IPC again

The remember-password box saved a password the client could never read
back. Hardening had put #[serde(skip)] on CredentialData::password, so
load_credential returned a record whose password was always absent and
the login form could not prefill it — the box appeared to work and
silently did nothing.

Drop the skip and carry the field through the TS wrapper, which now maps
a non-string password to undefined rather than trusting the payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(client): add an auto-connect checkbox to the login form

Auto-connect already existed end to end — ServerProfile.autoConnect,
setAutoLogin(), and the boot auto-login block with its cancel overlay —
but was only reachable through the zap button on a server card. This
surfaces the same state as a checkbox under Remember password, where
users look for it.

Ticking it forces Remember password on and disables it: boot auto-login
replays the stored token, which saveCredential only writes when the
password is remembered, so the two cannot be set independently without
producing a setting that silently does nothing.

Unticking is guarded. setAutoLogin(null) clears autoConnect on every
profile, so a bare toggle-off would wipe another server's setting; the
clear now only fires when this profile is the current holder. The guard
lives in ensureProfileExists, which all four auth paths already route
through.

Also consume the password restored in the previous commit, so selecting
a saved server prefills it instead of leaving the field blank.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore(release): bump client to 1.2.0-alpha.2

The client version is not derived from the tag — release.yml's
verify-versions job compares the tag against package.json and
tauri.conf.json and fails the release if they drift, so all five
manifests (both lockfiles included) move together.

Also refreshes the literal version in the README and docs build
examples, and closes the Unreleased changelog section as v1.2.0-alpha.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(changelog): record the three bug-hunt sweeps in v1.2.0-alpha.2

PRs #1328, #1331 and #1332 merged to main after v1.2.0-alpha.1 was tagged
and closed 233 verified defects between them, but none of the three left
an entry in the curated changelog — the generated list covers commits,
this file covers behaviour, and nothing bridged the two.

Verified unreleased by ancestry rather than by date (none of the three
merge commits is an ancestor of v1.2.0-alpha.1), so all of it ships for
the first time in alpha.2.

Nine entries grouped by subsystem, leading with the changes an operator
or user would actually notice: the 24h-retention desync, the avatar-
deleting orphan sweep, the zero-byte restore truncation, the six hot-mic
paths, and the TOFU re-pin that would have warned every install at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(client): drop the e2e assertion for the removed user-bar status dot (#1334)

26b46cc removed the hardcoded-green `.status-dot` div from the user bar
avatar and relocated the real StatusPicker trigger dot into that corner,
adding "status picker dot sits on the avatar" to cover the new element.
The old "user bar has status dot" test was left behind and now fails on
an element that no longer exists by design.

The replacement test already asserts the corner dot is present and
visible, so removing the stale one loses no coverage.


Claude-Session: https://claude.ai/code/session_01Rkv9dVo5YEYArqrDRfW41w

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:50:12 +02:00

1021 lines
37 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("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();
});
});
});