mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
1137 lines
39 KiB
TypeScript
1137 lines
39 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { createConnectPage } from "../../src/pages/ConnectPage";
|
|
import type { ConnectPageCallbacks, SimpleProfile } from "../../src/pages/ConnectPage";
|
|
import { uiStore, setTransientError } from "../../src/stores/ui.store";
|
|
|
|
// Mock credentials module
|
|
const mockLoadCredential = vi.fn().mockResolvedValue(null);
|
|
vi.mock("../../src/lib/credentials", () => ({
|
|
loadCredential: (...args: unknown[]) => mockLoadCredential(...args),
|
|
}));
|
|
|
|
// Mock SettingsOverlay so we don't pull in all its dependencies
|
|
vi.mock("../../src/components/SettingsOverlay", () => ({
|
|
createSettingsOverlay: () => ({
|
|
mount: vi.fn(),
|
|
destroy: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// Mock ui.store actions
|
|
vi.mock("../../src/stores/ui.store", async () => {
|
|
const actual = await vi.importActual<typeof import("../../src/stores/ui.store")>(
|
|
"../../src/stores/ui.store",
|
|
);
|
|
return {
|
|
...actual,
|
|
openSettings: vi.fn(),
|
|
closeSettings: vi.fn(),
|
|
};
|
|
});
|
|
|
|
function makeCallbacks(overrides: Partial<ConnectPageCallbacks> = {}): ConnectPageCallbacks {
|
|
return {
|
|
onLogin: vi.fn().mockResolvedValue(undefined),
|
|
onRegister: vi.fn().mockResolvedValue(undefined),
|
|
onTotpSubmit: vi.fn().mockResolvedValue(undefined),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const testProfiles: SimpleProfile[] = [{ name: "Test Server", host: "localhost:8443" }];
|
|
|
|
describe("ConnectPage", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockLoadCredential.mockReset();
|
|
mockLoadCredential.mockResolvedValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
});
|
|
|
|
it("renders the connect page with form elements", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
expect(container.querySelector(".connect-page")).not.toBeNull();
|
|
expect(container.querySelector(".connect-form")).not.toBeNull();
|
|
expect(container.querySelector("#host")).not.toBeNull();
|
|
expect(container.querySelector("#username")).not.toBeNull();
|
|
expect(container.querySelector("#password")).not.toBeNull();
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("renders server profiles in the server panel", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const serverItems = container.querySelectorAll(".server-item");
|
|
expect(serverItems.length).toBe(1);
|
|
|
|
const serverName = container.querySelector(".srv-name");
|
|
expect(serverName?.textContent).toBe("Test Server");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("fills host input when a server profile is clicked", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const serverItem = container.querySelector(".server-item") as HTMLElement;
|
|
serverItem.click();
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
expect(hostInput.value).toBe("localhost:8443");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows error when submitting empty form", async () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
// Clear any default host value
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
hostInput.value = "";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
// Wait for async handler
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner");
|
|
expect(errorBanner!.classList.contains("visible")).toBe(true);
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows validation error for short password", async () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "short";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner");
|
|
expect(errorBanner!.classList.contains("visible")).toBe(true);
|
|
expect(errorBanner!.textContent).toContain("at least 8 characters");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("calls onLogin with form values on valid submit", async () => {
|
|
const onLogin = vi.fn().mockResolvedValue(undefined);
|
|
const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLogin).toHaveBeenCalledWith("localhost:8443", "testuser", "password123");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("toggles between login and register mode", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
// Initially in login mode — invite group hidden
|
|
const inviteGroup = container.querySelector("#invite")!.closest(".form-group") as HTMLElement;
|
|
expect(inviteGroup.classList.contains("form-group--hidden")).toBe(true);
|
|
|
|
// Click toggle link
|
|
const toggleLink = container.querySelector(".form-switch a") as HTMLElement;
|
|
toggleLink.click();
|
|
|
|
// Now in register mode — invite group visible
|
|
expect(inviteGroup.classList.contains("form-group--hidden")).toBe(false);
|
|
|
|
// Submit button text changes
|
|
const btnText = container.querySelector(".btn-text");
|
|
expect(btnText?.textContent).toBe("Register");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows TOTP overlay when showTotp is called", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const totpOverlay = container.querySelector(".totp-overlay")!;
|
|
expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(true);
|
|
|
|
page.showTotp();
|
|
expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(false);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows error message via showError", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showError("Connection refused");
|
|
|
|
const errorBanner = container.querySelector(".error-banner");
|
|
expect(errorBanner!.classList.contains("visible")).toBe(true);
|
|
expect(errorBanner!.textContent).toBe("Connection refused");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("resets to idle state via resetToIdle", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showError("Some error");
|
|
page.resetToIdle();
|
|
|
|
const errorBanner = container.querySelector(".error-banner");
|
|
expect(errorBanner!.classList.contains("visible")).toBe(false);
|
|
|
|
const submitBtn = container.querySelector(".btn-primary") as HTMLButtonElement;
|
|
expect(submitBtn.disabled).toBe(false);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("disables form inputs during loading state", async () => {
|
|
let resolveLogin: () => void;
|
|
const loginPromise = new Promise<void>((resolve) => {
|
|
resolveLogin = resolve;
|
|
});
|
|
const onLogin = vi.fn().mockReturnValue(loginPromise);
|
|
|
|
const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(hostInput.disabled).toBe(true);
|
|
expect(usernameInput.disabled).toBe(true);
|
|
expect(passwordInput.disabled).toBe(true);
|
|
});
|
|
|
|
resolveLogin!();
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("cleans up on destroy", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
expect(container.querySelector(".connect-page")).not.toBeNull();
|
|
|
|
page.destroy?.();
|
|
expect(container.querySelector(".connect-page")).toBeNull();
|
|
});
|
|
|
|
// --- selectServer / auto-login flow ---
|
|
|
|
it("selectServer sets host and loads credentials asynchronously", async () => {
|
|
mockLoadCredential.mockResolvedValue({
|
|
username: "saveduser",
|
|
token: "tok",
|
|
password: "savedpass",
|
|
});
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.selectServer("localhost:8443", "initialuser");
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
expect(hostInput.value).toBe("localhost:8443");
|
|
|
|
// Wait for async credential loading
|
|
await vi.waitFor(() => {
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
expect(usernameInput.value).toBe("saveduser");
|
|
});
|
|
|
|
// The stored password prefills the field so the user isn't retyping it.
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
expect(passwordInput.value).toBe("savedpass");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("selectServer sets credentials without password when username is provided", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.selectServer("localhost:8443", "myuser");
|
|
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
expect(usernameInput.value).toBe("myuser");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("selectServer without username does not set credentials", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.selectServer("localhost:8443");
|
|
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
expect(usernameInput.value).toBe("");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("selectServer ignores credential load when host has changed", async () => {
|
|
mockLoadCredential.mockImplementation(async () => {
|
|
// Simulate delay
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
return { username: "staleuser", token: "tok", password: "stalepass" };
|
|
});
|
|
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.selectServer("localhost:8443");
|
|
// Immediately change the host to something else
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
hostInput.value = "other-server:8443";
|
|
|
|
// Wait for async to finish
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
// Username should NOT be set because host changed
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
expect(usernameInput.value).toBe("");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("selectServer handles credential load failure gracefully", async () => {
|
|
mockLoadCredential.mockRejectedValue(new Error("Tauri not available"));
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
// Should not throw
|
|
page.selectServer("localhost:8443");
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
expect(hostInput.value).toBe("localhost:8443");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- showConnecting / showAutoConnecting ---
|
|
|
|
it("showConnecting disables form and shows connecting text", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showConnecting();
|
|
|
|
const submitBtn = container.querySelector(".btn-primary") as HTMLButtonElement;
|
|
expect(submitBtn.disabled).toBe(true);
|
|
const btnText = container.querySelector(".btn-text")!;
|
|
expect(btnText.textContent).toContain("Connecting");
|
|
|
|
const statusBar = container.querySelector(".status-bar")!;
|
|
expect(statusBar.classList.contains("visible")).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("showAutoConnecting shows overlay with server name and calls cancel callback", () => {
|
|
const onAutoLoginCancel = vi.fn();
|
|
const page = createConnectPage(makeCallbacks({ onAutoLoginCancel }), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showAutoConnecting("My Server");
|
|
|
|
const overlay = container.querySelector(".auto-connect-overlay")!;
|
|
expect(overlay.classList.contains("auto-connect-overlay--hidden")).toBe(false);
|
|
|
|
const serverName = container.querySelector(".auto-connect-server")!;
|
|
expect(serverName.textContent).toBe("My Server");
|
|
|
|
// Click cancel
|
|
const cancelBtn = container.querySelector(".auto-connect-cancel") as HTMLElement;
|
|
cancelBtn.click();
|
|
|
|
expect(onAutoLoginCancel).toHaveBeenCalledTimes(1);
|
|
expect(overlay.classList.contains("auto-connect-overlay--hidden")).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- refreshProfiles ---
|
|
|
|
it("refreshProfiles re-renders the server profile list", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
expect(container.querySelectorAll(".server-item").length).toBe(1);
|
|
|
|
page.refreshProfiles([
|
|
{ name: "Server A", host: "a.com:8443" },
|
|
{ name: "Server B", host: "b.com:8443" },
|
|
]);
|
|
|
|
expect(container.querySelectorAll(".server-item").length).toBe(2);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- updateHealthStatus ---
|
|
|
|
it("updateHealthStatus updates health elements for a known host", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.updateHealthStatus("localhost:8443", {
|
|
status: "online",
|
|
latencyMs: 42,
|
|
version: null,
|
|
onlineUsers: 5,
|
|
});
|
|
|
|
const dot = container.querySelector(".srv-status-dot")!;
|
|
expect(dot.className).toContain("online");
|
|
const latency = container.querySelector(".srv-latency")!;
|
|
expect(latency.textContent).toBe("42ms");
|
|
const onlineUsers = container.querySelector(".srv-online-users")!;
|
|
expect(onlineUsers.textContent).toBe("5 online");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- getRememberPassword / getPassword ---
|
|
|
|
it("getRememberPassword returns checkbox state", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
expect(page.getRememberPassword()).toBe(false);
|
|
|
|
const checkbox = container.querySelector("#remember-password") as HTMLInputElement;
|
|
checkbox.checked = true;
|
|
expect(page.getRememberPassword()).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- getAutoConnect ---
|
|
|
|
it("getAutoConnect returns checkbox state", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
expect(page.getAutoConnect()).toBe(false);
|
|
|
|
const checkbox = container.querySelector("#auto-connect") as HTMLInputElement;
|
|
checkbox.checked = true;
|
|
expect(page.getAutoConnect()).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("ticking auto-connect forces and disables remember password", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const autoConnectCheckbox = container.querySelector("#auto-connect") as HTMLInputElement;
|
|
const rememberCheckbox = container.querySelector("#remember-password") as HTMLInputElement;
|
|
|
|
autoConnectCheckbox.checked = true;
|
|
autoConnectCheckbox.dispatchEvent(new Event("change"));
|
|
|
|
expect(rememberCheckbox.checked).toBe(true);
|
|
expect(rememberCheckbox.disabled).toBe(true);
|
|
expect(page.getRememberPassword()).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("unticking auto-connect re-enables remember password", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const autoConnectCheckbox = container.querySelector("#auto-connect") as HTMLInputElement;
|
|
const rememberCheckbox = container.querySelector("#remember-password") as HTMLInputElement;
|
|
|
|
autoConnectCheckbox.checked = true;
|
|
autoConnectCheckbox.dispatchEvent(new Event("change"));
|
|
|
|
autoConnectCheckbox.checked = false;
|
|
autoConnectCheckbox.dispatchEvent(new Event("change"));
|
|
|
|
expect(rememberCheckbox.disabled).toBe(false);
|
|
expect(rememberCheckbox.checked).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("getPassword returns password input value", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
passwordInput.value = "mypassword";
|
|
expect(page.getPassword()).toBe("mypassword");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Pending transient error display ---
|
|
|
|
it("shows pending transient error on mount and clears it", () => {
|
|
setTransientError("Already connected from another client");
|
|
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.classList.contains("visible")).toBe(true);
|
|
expect(errorBanner.textContent).toBe("Already connected from another client");
|
|
|
|
// Transient error should be cleared
|
|
expect(uiStore.getState().transientError).toBeNull();
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// B4_conn_ipc-6: a WS auth_error, cert-mismatch reject, or credential-save
|
|
// warning can set transientError AFTER this page is already mounted — a
|
|
// one-time getState() read at mount would silently drop it.
|
|
it("shows a transient error set after mount, not just one already pending at mount time", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
// No error yet at mount time.
|
|
expect(container.querySelector(".error-banner")!.classList.contains("visible")).toBe(false);
|
|
|
|
setTransientError("Your session expired — sign in again.");
|
|
uiStore.flush();
|
|
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.classList.contains("visible")).toBe(true);
|
|
expect(errorBanner.textContent).toBe("Your session expired — sign in again.");
|
|
expect(uiStore.getState().transientError).toBeNull();
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// B4_conn_ipc-15: a transient error set while this page was mounted (e.g. a
|
|
// background credential-save failure fired after a successful login moved
|
|
// on to MainPage) must not survive to resurface at the NEXT mount — which
|
|
// only happens after a later logout, where it would misleadingly read as a
|
|
// fresh login failure.
|
|
it("clears a transient error on destroy so it cannot resurface as a bogus login error later", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
// Set while mounted but not yet observed (no flush before destroy) —
|
|
// mirrors a background failure landing just as the page is torn down.
|
|
setTransientError("Could not save credentials — auto-login won't work");
|
|
page.destroy?.();
|
|
|
|
expect(uiStore.getState().transientError).toBeNull();
|
|
});
|
|
|
|
// --- TOTP overlay interactions ---
|
|
|
|
it("TOTP submit calls onTotpSubmit with 6-digit code", async () => {
|
|
const onTotpSubmit = vi.fn().mockResolvedValue(undefined);
|
|
const page = createConnectPage(makeCallbacks({ onTotpSubmit }), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showTotp();
|
|
|
|
const totpInput = container.querySelector(".totp-overlay input") as HTMLInputElement;
|
|
totpInput.value = "123456";
|
|
|
|
const verifyBtn = container.querySelector(".totp-overlay .btn-primary") as HTMLButtonElement;
|
|
verifyBtn.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onTotpSubmit).toHaveBeenCalledWith("123456");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("TOTP submit rejects invalid code (not 6 digits)", () => {
|
|
const onTotpSubmit = vi.fn().mockResolvedValue(undefined);
|
|
const page = createConnectPage(makeCallbacks({ onTotpSubmit }), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showTotp();
|
|
|
|
const totpInput = container.querySelector(".totp-overlay input") as HTMLInputElement;
|
|
totpInput.value = "abc";
|
|
|
|
const verifyBtn = container.querySelector(".totp-overlay .btn-primary") as HTMLButtonElement;
|
|
verifyBtn.click();
|
|
|
|
expect(onTotpSubmit).not.toHaveBeenCalled();
|
|
expect(totpInput.classList.contains("error")).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("TOTP cancel returns to idle state", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showTotp();
|
|
const totpOverlay = container.querySelector(".totp-overlay")!;
|
|
expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(false);
|
|
|
|
const cancelBtn = container.querySelector(".totp-back") as HTMLElement;
|
|
cancelBtn.click();
|
|
|
|
expect(totpOverlay.classList.contains("totp-overlay--hidden")).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("TOTP Enter key submits the code", async () => {
|
|
const onTotpSubmit = vi.fn().mockResolvedValue(undefined);
|
|
const page = createConnectPage(makeCallbacks({ onTotpSubmit }), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showTotp();
|
|
|
|
const totpInput = container.querySelector(".totp-overlay input") as HTMLInputElement;
|
|
totpInput.value = "654321";
|
|
|
|
totpInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onTotpSubmit).toHaveBeenCalledWith("654321");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("TOTP submit shows error when callback throws", async () => {
|
|
const onTotpSubmit = vi.fn().mockRejectedValue(new Error("Invalid TOTP"));
|
|
const page = createConnectPage(makeCallbacks({ onTotpSubmit }), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showTotp();
|
|
|
|
const totpInput = container.querySelector(".totp-overlay input") as HTMLInputElement;
|
|
totpInput.value = "999999";
|
|
|
|
const verifyBtn = container.querySelector(".totp-overlay .btn-primary") as HTMLButtonElement;
|
|
verifyBtn.click();
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.classList.contains("visible")).toBe(true);
|
|
expect(errorBanner.textContent).toBe("Invalid TOTP");
|
|
});
|
|
|
|
// Verify button should be re-enabled
|
|
expect(verifyBtn.disabled).toBe(false);
|
|
expect(verifyBtn.textContent).toBe("Verify");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Password visibility toggle ---
|
|
|
|
it("toggles password visibility when eye button is clicked", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
expect(passwordInput.type).toBe("password");
|
|
|
|
const toggleBtn = container.querySelector(".password-toggle") as HTMLButtonElement;
|
|
toggleBtn.click();
|
|
expect(passwordInput.type).toBe("text");
|
|
|
|
toggleBtn.click();
|
|
expect(passwordInput.type).toBe("password");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Register mode ---
|
|
|
|
it("calls onRegister when in register mode with valid form", async () => {
|
|
const onRegister = vi.fn().mockResolvedValue(undefined);
|
|
const page = createConnectPage(makeCallbacks({ onRegister }), testProfiles);
|
|
page.mount(container);
|
|
|
|
// Switch to register mode
|
|
const toggleLink = container.querySelector(".form-switch a") as HTMLElement;
|
|
toggleLink.click();
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
const inviteInput = container.querySelector("#invite") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "newuser";
|
|
passwordInput.value = "password123";
|
|
inviteInput.value = "INVITE-CODE";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onRegister).toHaveBeenCalledWith(
|
|
"localhost:8443",
|
|
"newuser",
|
|
"password123",
|
|
"INVITE-CODE",
|
|
);
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows error when register mode missing invite code", async () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const toggleLink = container.querySelector(".form-switch a") as HTMLElement;
|
|
toggleLink.click();
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "newuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.classList.contains("visible")).toBe(true);
|
|
expect(errorBanner.textContent).toContain("Invite code is required");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Error handling in submit ---
|
|
|
|
it("shows error when onLogin throws a string", async () => {
|
|
const onLogin = vi.fn().mockRejectedValue("string error");
|
|
const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.textContent).toBe("string error");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows error when onLogin throws an object with message", async () => {
|
|
const onLogin = vi.fn().mockRejectedValue({ message: "object error" });
|
|
const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.textContent).toBe("object error");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows stringified error when onLogin throws unknown type", async () => {
|
|
const onLogin = vi.fn().mockRejectedValue(42);
|
|
const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.textContent).toBe("42");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Empty username validation ---
|
|
|
|
it("shows error when username is empty", async () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.textContent).toContain("Username is required");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows error when password is empty", async () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.textContent).toContain("Password is required");
|
|
});
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Duplicate submit prevention ---
|
|
|
|
it("ignores submit while already loading", async () => {
|
|
let resolveLogin: () => void;
|
|
const loginPromise = new Promise<void>((resolve) => {
|
|
resolveLogin = resolve;
|
|
});
|
|
const onLogin = vi.fn().mockReturnValue(loginPromise);
|
|
|
|
const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLogin).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Submit again while loading
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
expect(onLogin).toHaveBeenCalledTimes(1);
|
|
|
|
resolveLogin!();
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Toggle mode clears error ---
|
|
|
|
it("toggling mode clears existing error state", async () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
page.showError("Some error");
|
|
const errorBanner = container.querySelector(".error-banner")!;
|
|
expect(errorBanner.classList.contains("visible")).toBe(true);
|
|
|
|
const toggleLink = container.querySelector(".form-switch a") as HTMLElement;
|
|
toggleLink.click();
|
|
|
|
expect(errorBanner.classList.contains("visible")).toBe(false);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Toggle back to login ---
|
|
|
|
it("toggling back to login shows correct text", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const toggleLink = container.querySelector(".form-switch a") as HTMLElement;
|
|
toggleLink.click(); // to register
|
|
toggleLink.click(); // back to login
|
|
|
|
const btnText = container.querySelector(".btn-text");
|
|
expect(btnText?.textContent).toBe("Login");
|
|
expect(toggleLink.textContent).toContain("Need an account?");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Credential loaded guard (host mismatch) ---
|
|
|
|
it("does not apply credentials when host has changed before onCredentialLoaded", async () => {
|
|
mockLoadCredential.mockResolvedValue({ username: "loaded", token: "tok", password: "pass" });
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
// Click server item to trigger credential loading
|
|
const serverItem = container.querySelector(".server-item") as HTMLElement;
|
|
serverItem.click();
|
|
|
|
// Immediately change host
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
hostInput.value = "different-host:8443";
|
|
|
|
// Wait for credential loading to complete
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
|
|
// Username should not be updated due to host mismatch guard
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
// The onServerClick call doesn't have a username in testProfiles, so it stays empty
|
|
// The credential should NOT be applied because host changed
|
|
expect(usernameInput.value).toBe("");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Settings gear ---
|
|
|
|
it("opens settings when gear button is clicked", async () => {
|
|
const { openSettings } = await import("../../src/stores/ui.store");
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const gearBtn = container.querySelector(".settings-gear") as HTMLButtonElement;
|
|
gearBtn.click();
|
|
|
|
expect(openSettings).toHaveBeenCalledTimes(1);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- setCredentials with password sets remember checkbox ---
|
|
|
|
it("setCredentials with password checks the remember password checkbox", async () => {
|
|
mockLoadCredential.mockResolvedValue({ username: "user", token: "tok", password: "pass123" });
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const serverItem = container.querySelector(".server-item") as HTMLElement;
|
|
serverItem.click();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(page.getRememberPassword()).toBe(true);
|
|
});
|
|
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
expect(passwordInput.value).toBe("pass123");
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Status bar states ---
|
|
|
|
it("status bar is hidden on idle/error/totp states and visible on loading/connecting", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const statusBar = container.querySelector(".status-bar")!;
|
|
|
|
// idle
|
|
expect(statusBar.classList.contains("visible")).toBe(false);
|
|
|
|
// connecting
|
|
page.showConnecting();
|
|
expect(statusBar.classList.contains("visible")).toBe(true);
|
|
|
|
// back to idle
|
|
page.resetToIdle();
|
|
expect(statusBar.classList.contains("visible")).toBe(false);
|
|
|
|
// totp
|
|
page.showTotp();
|
|
expect(statusBar.classList.contains("visible")).toBe(false);
|
|
|
|
// error
|
|
page.showError("err");
|
|
expect(statusBar.classList.contains("visible")).toBe(false);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Loading state button text ---
|
|
|
|
it("shows correct button text during loading states", async () => {
|
|
let resolveLogin: () => void;
|
|
const loginPromise = new Promise<void>((resolve) => {
|
|
resolveLogin = resolve;
|
|
});
|
|
const onLogin = vi.fn().mockReturnValue(loginPromise);
|
|
|
|
const page = createConnectPage(makeCallbacks({ onLogin }), testProfiles);
|
|
page.mount(container);
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "testuser";
|
|
passwordInput.value = "password123";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const btnText = container.querySelector(".btn-text")!;
|
|
expect(btnText.textContent).toContain("Logging in");
|
|
});
|
|
|
|
resolveLogin!();
|
|
page.destroy?.();
|
|
});
|
|
|
|
it("shows registering text in register mode during loading", async () => {
|
|
let resolveRegister: () => void;
|
|
const registerPromise = new Promise<void>((resolve) => {
|
|
resolveRegister = resolve;
|
|
});
|
|
const onRegister = vi.fn().mockReturnValue(registerPromise);
|
|
|
|
const page = createConnectPage(makeCallbacks({ onRegister }), testProfiles);
|
|
page.mount(container);
|
|
|
|
// Switch to register mode
|
|
const toggleLink = container.querySelector(".form-switch a") as HTMLElement;
|
|
toggleLink.click();
|
|
|
|
const hostInput = container.querySelector("#host") as HTMLInputElement;
|
|
const usernameInput = container.querySelector("#username") as HTMLInputElement;
|
|
const passwordInput = container.querySelector("#password") as HTMLInputElement;
|
|
const inviteInput = container.querySelector("#invite") as HTMLInputElement;
|
|
|
|
hostInput.value = "localhost:8443";
|
|
usernameInput.value = "newuser";
|
|
passwordInput.value = "password123";
|
|
inviteInput.value = "CODE";
|
|
|
|
const form = container.querySelector(".connect-form") as HTMLFormElement;
|
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
|
|
await vi.waitFor(() => {
|
|
const btnText = container.querySelector(".btn-text")!;
|
|
expect(btnText.textContent).toContain("Registering");
|
|
});
|
|
|
|
resolveRegister!();
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Auto-connect overlay hidden by default ---
|
|
|
|
it("auto-connect overlay is hidden by default", () => {
|
|
const page = createConnectPage(makeCallbacks(), testProfiles);
|
|
page.mount(container);
|
|
|
|
const overlay = container.querySelector(".auto-connect-overlay")!;
|
|
expect(overlay.classList.contains("auto-connect-overlay--hidden")).toBe(true);
|
|
|
|
page.destroy?.();
|
|
});
|
|
|
|
// --- Uses default profiles when none provided ---
|
|
|
|
it("uses default profiles when no initialProfiles provided", () => {
|
|
const page = createConnectPage(makeCallbacks());
|
|
page.mount(container);
|
|
|
|
const serverItems = container.querySelectorAll(".server-item");
|
|
expect(serverItems.length).toBe(1);
|
|
|
|
const serverHost = container.querySelector(".srv-host");
|
|
expect(serverHost?.textContent).toBe("localhost:8443");
|
|
|
|
page.destroy?.();
|
|
});
|
|
});
|