feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

This commit is contained in:
LukeGus committed 2026-09-26 00:41:25 -05:00
1 parent 7c02daccf4
commit 6cd9a2846d
242 files changed
+39127 -8558

No files matched your search

+12 -11
View File
@@ -23,6 +23,13 @@ vi.mock("@/lib/connection-origin", () => ({
resolveConnectionOrigin: resolveConnectionOriginMock,
}));
vi.mock("@/lib/linked-server", () => ({
getLinkedSession: async () => ({
serverUrl: "https://termix.example.test",
token: "t",
}),
}));
import {
getAllServerStatuses,
refreshServerPolling,
@@ -40,23 +47,17 @@ function hostsAndStatuses(hosts: unknown[]) {
beforeEach(() => {
vi.clearAllMocks();
window.electronAPI = {
invoke: vi.fn(async (channel: string) =>
channel === "get-remote-sync-config"
? { serverUrl: "https://termix.example.test" }
: null,
),
} as unknown as NonNullable<typeof window.electronAPI>;
// The server keys statuses by sync id; its own ids mean nothing here.
remoteCoreApiMock.get.mockResolvedValue({
data: { 2: { status: "reachable" } },
data: { "sync-2": { status: "reachable" }, "sync-9": { status: "x" } },
});
});
describe("status check origin routing", () => {
it("only asks the embedded backend to poll local-origin hosts", async () => {
hostsAndStatuses([
{ id: 1, connectionOrigin: "local" },
{ id: 2, connectionOrigin: "remote" },
{ id: 1, connectionOrigin: "local", syncId: "sync-1" },
{ id: 2, connectionOrigin: "remote", syncId: "sync-2" },
]);
resolveConnectionOriginMock.mockImplementation(async (host) =>
host.connectionOrigin === "local" ? "local" : "remote",
@@ -73,7 +74,7 @@ describe("status check origin routing", () => {
__silentRetry: true,
});
expect(remoteCoreApiMock.get).toHaveBeenCalledWith(
"/host/status",
"/sync/v2/host-status",
expect.objectContaining({ __silentRetry: true }),
);
});
@@ -1,66 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const isElectronMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("@/lib/electron", () => ({ isElectron: isElectronMock }));
vi.mock("@/lib/base-path", () => ({ getBasePath: () => "" }));
vi.mock("@/shell/TabContext", () => ({ clearTermixSessionStorage: vi.fn() }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
const { getRemoteSyncUserInfo } = await import("../../main-axios");
const invoke = vi.fn();
beforeEach(() => {
isElectronMock.mockReturnValue(true);
invoke.mockReset();
(window as unknown as { electronAPI?: unknown }).electronAPI = { invoke };
});
afterEach(() => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
describe("getRemoteSyncUserInfo", () => {
it("returns the identity reported by the main process", async () => {
const identity = {
userId: "u-1",
username: "alice",
is_admin: true,
is_oidc: false,
totp_enabled: false,
roles: [{ roleId: 1, roleDisplayName: "Admin" }],
};
invoke.mockResolvedValueOnce(identity);
await expect(getRemoteSyncUserInfo()).resolves.toEqual(identity);
expect(invoke).toHaveBeenCalledWith("get-remote-sync-user-info");
});
it("returns null in the browser without calling the bridge", async () => {
isElectronMock.mockReturnValue(false);
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
expect(invoke).not.toHaveBeenCalled();
});
it("returns null when no remote sync identity is available", async () => {
// The main process answers null when the server is unconfigured, the JWT is
// missing, or it has expired.
invoke.mockResolvedValueOnce(null);
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
});
it("returns null instead of propagating a bridge failure", async () => {
invoke.mockRejectedValueOnce(new Error("IPC channel closed"));
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
});
it("returns null when the preload bridge is absent", async () => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
await expect(getRemoteSyncUserInfo()).resolves.toBeNull();
});
});
@@ -1,132 +0,0 @@
import Module from "node:module";
import { createRequire } from "node:module";
import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
// remote-sync.cjs is CommonJS and pulls in `electron` at load time, which does
// not exist outside the Electron runtime. vi.mock cannot reach a require() made
// through createRequire, so the stub is injected into the CJS loader itself.
const electronStub = {
app: { getPath: () => os.tmpdir() },
safeStorage: { isEncryptionAvailable: () => false },
ipcMain: { handle: () => {} },
};
const loader = Module as unknown as {
_load: (request: string, parent: unknown, isMain: boolean) => unknown;
};
const originalLoad = loader._load;
loader._load = (request, parent, isMain) =>
request === "electron" ? electronStub : originalLoad(request, parent, isMain);
const require = createRequire(import.meta.url);
type Engine = {
stop: () => void;
pullSide: (
baseUrl: string,
token: string,
entityType: string,
since: string | null,
) => Promise<unknown[]>;
pullTombstones: (
baseUrl: string,
token: string,
entityType: string,
since: string | null,
) => Promise<unknown[]>;
pushRow: (...args: unknown[]) => Promise<void>;
pushTombstone: (
baseUrl: string,
token: string,
entityType: string,
syncId: string,
) => Promise<void>;
syncEntity: (args: {
entityType: string;
remoteBaseUrl: string;
remoteJwt: string;
since: string | null;
}) => Promise<{ syncedAt: string }>;
};
const REMOTE = "https://server.example";
const CURSOR = "2026-07-29T10:24:44.501Z";
describe("remote sync tombstone application", () => {
let engine: Engine;
let pushed: Array<{ baseUrl: string; syncId: string }>;
let fullPulls: string[];
beforeEach(() => {
const { initRemoteSync } = require("../../../../electron/remote-sync.cjs");
engine = initRemoteSync(() => null) as Engine;
engine.stop();
pushed = [];
fullPulls = [];
engine.pushRow = async () => {};
engine.pushTombstone = async (baseUrl, _token, _entityType, syncId) => {
pushed.push({ baseUrl, syncId });
};
});
/**
* The row deleted on the remote is untouched locally, so it is absent from
* the incremental window on both sides -- the shape every ordinary deletion
* takes once the two sides have converged.
*/
function stubSides(localHolds: string[], remoteTombstoneIds: string[]) {
engine.pullSide = async (baseUrl, _token, _entityType, since) => {
if (since) return [];
fullPulls.push(baseUrl);
return baseUrl === REMOTE
? []
: localHolds.map((syncId) => ({ syncId, updatedAt: CURSOR }));
};
engine.pullTombstones = async (baseUrl) =>
baseUrl === REMOTE
? remoteTombstoneIds.map((syncId) => ({ syncId }))
: [];
}
it("applies a remote deletion to a local row that is outside the incremental window", async () => {
stubSides(["cred-1"], ["cred-1"]);
await engine.syncEntity({
entityType: "sshCredentials",
remoteBaseUrl: REMOTE,
remoteJwt: "remote-jwt",
since: CURSOR,
});
expect(pushed).toEqual([
{ baseUrl: "http://127.0.0.1:30001", syncId: "cred-1" },
]);
});
it("does not re-push a deletion once the row is gone, so tombstones cannot ping-pong", async () => {
stubSides([], ["cred-1"]);
await engine.syncEntity({
entityType: "sshCredentials",
remoteBaseUrl: REMOTE,
remoteJwt: "remote-jwt",
since: CURSOR,
});
expect(pushed).toEqual([]);
});
it("does not pay for a presence check when nothing was deleted", async () => {
stubSides(["cred-1"], []);
await engine.syncEntity({
entityType: "sshCredentials",
remoteBaseUrl: REMOTE,
remoteJwt: "remote-jwt",
since: CURSOR,
});
expect(pushed).toEqual([]);
expect(fullPulls).toEqual([]);
});
});
-14
View File
@@ -1,14 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { SYNCED_ENTITY_TYPES } =
require("../../../../electron/remote-sync-entities.cjs") as {
SYNCED_ENTITY_TYPES: readonly string[];
};
describe("desktop remote sync entities", () => {
it("retains user preferences in the current sync protocol", () => {
expect(SYNCED_ENTITY_TYPES).toContain("userPreferences");
});
});
+7 -9
View File
@@ -1,4 +1,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
const getLinkedSession = vi.hoisted(() => vi.fn(async () => null));
vi.mock("@/lib/linked-server", () => ({ getLinkedSession }));
import {
buildOriginWsUrl,
resolveConnectionOrigin,
@@ -166,15 +169,10 @@ describe("buildOriginWsUrl", () => {
});
it("does not duplicate the Guacamole token on remote connections", async () => {
win.electronAPI = {
invoke: async (channel: string) => {
if (channel === "get-remote-sync-config") {
return { serverUrl: "https://termix.example" };
}
if (channel === "get-remote-sync-jwt") return "remote-jwt";
return null;
},
};
getLinkedSession.mockResolvedValueOnce({
serverUrl: "https://termix.example",
token: "remote-jwt",
} as never);
const target = await buildOriginWsUrl({
origin: "remote",
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const isElectron = vi.hoisted(() => vi.fn(() => true));
const get = vi.hoisted(() => vi.fn());
vi.mock("@/lib/electron", () => ({ isElectron }));
vi.mock("@/main-axios", () => ({ authApi: { get } }));
const { getLinkedSession, notifySyncChanged } =
await import("@/lib/linked-server");
beforeEach(() => {
notifySyncChanged();
get.mockReset();
isElectron.mockReturnValue(true);
});
describe("linked session", () => {
it("reads the session from the embedded backend and caches it", async () => {
get.mockResolvedValue({
data: { serverUrl: "https://termix.example/", token: "t" },
});
await expect(getLinkedSession()).resolves.toEqual({
serverUrl: "https://termix.example",
token: "t",
});
await getLinkedSession();
expect(get).toHaveBeenCalledTimes(1);
expect(get).toHaveBeenCalledWith("/sync/link/session");
});
it("reads it again after a sync change", async () => {
get.mockResolvedValue({ data: { serverUrl: null, token: null } });
await expect(getLinkedSession()).resolves.toBeNull();
notifySyncChanged();
get.mockResolvedValue({ data: { serverUrl: "https://x", token: "t" } });
await expect(getLinkedSession()).resolves.toEqual({
serverUrl: "https://x",
token: "t",
});
});
it("is null on the web and when the backend cannot answer", async () => {
isElectron.mockReturnValue(false);
await expect(getLinkedSession()).resolves.toBeNull();
expect(get).not.toHaveBeenCalled();
isElectron.mockReturnValue(true);
get.mockRejectedValue(new Error("down"));
await expect(getLinkedSession()).resolves.toBeNull();
});
});
+20
View File
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it } from "vitest";
import { rem, remScale } from "@/lib/rem";
afterEach(() => {
document.documentElement.style.fontSize = "";
});
describe("rem", () => {
it("states a Normal-size pixel length in rem", () => {
expect(rem(14)).toBe("1rem");
expect(rem(160)).toBe(`${160 / 14}rem`);
});
it("reports how much the interface size scales a pixel", () => {
document.documentElement.style.fontSize = "14px";
expect(remScale()).toBe(1);
document.documentElement.style.fontSize = "17.5px";
expect(remScale()).toBe(1.25);
});
});
+24 -83
View File
@@ -1,112 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SSHHost } from "@/types/index";
const isElectron = vi.hoisted(() => vi.fn());
const getLinkedSession = vi.hoisted(() => vi.fn());
const remoteApi = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/lib/electron", () => ({ isElectron }));
vi.mock("@/lib/linked-server", () => ({ getLinkedSession }));
vi.mock("@/main-axios", () => ({ getRemoteCoreApi: () => remoteApi }));
import {
getConnectedRemoteApi,
hydrateLocalSharedHostAuth,
markRemoteSharedHosts,
resolveRemoteHostId,
} from "@/lib/remote-server-api";
describe("remote server API", () => {
const invoke = vi.fn();
const LINKED = { serverUrl: "https://termix.example", token: "t" };
describe("remote server API", () => {
beforeEach(() => {
isElectron.mockReset();
getLinkedSession.mockReset();
remoteApi.get.mockReset();
invoke.mockReset();
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke },
});
});
it("is available only for a configured Electron sync server", async () => {
isElectron.mockReturnValue(false);
it("is available only while this desktop is linked", async () => {
getLinkedSession.mockResolvedValueOnce(null);
await expect(getConnectedRemoteApi()).resolves.toBeNull();
isElectron.mockReturnValue(true);
invoke.mockResolvedValueOnce({ serverUrl: "https://termix.example" });
getLinkedSession.mockResolvedValueOnce(LINKED);
await expect(getConnectedRemoteApi()).resolves.toBe(remoteApi);
});
it("maps a local host to the remote numeric id by syncId", async () => {
isElectron.mockReturnValue(true);
invoke.mockResolvedValue({ serverUrl: "https://termix.example" });
remoteApi.get.mockResolvedValue({
data: { rows: [{ id: 41, syncId: "host-a" }] },
});
it("asks the linked server for its id of a host by sync id", async () => {
getLinkedSession.mockResolvedValue(LINKED);
remoteApi.get.mockResolvedValueOnce({ data: { id: 41, name: "web" } });
await expect(resolveRemoteHostId("host-a")).resolves.toBe(41);
await expect(resolveRemoteHostId("host a")).resolves.toBe(41);
expect(remoteApi.get).toHaveBeenCalledWith("/sync/v2/hosts/host%20a");
});
it("answers null for an unknown host, no sync id, or no link", async () => {
getLinkedSession.mockResolvedValue(LINKED);
remoteApi.get.mockRejectedValueOnce(new Error("404"));
await expect(resolveRemoteHostId("missing")).resolves.toBeNull();
expect(remoteApi.get).toHaveBeenCalledWith("/sync/hosts");
await expect(resolveRemoteHostId(null)).resolves.toBeNull();
getLinkedSession.mockResolvedValue(null);
await expect(resolveRemoteHostId("host-a")).resolves.toBeNull();
});
it("keeps only shared remote hosts and gives them collision-free ids", () => {
const rows = markRemoteSharedHosts([
{ id: 4, isShared: false },
{
id: 9,
isShared: true,
syncId: "shared-host",
connectionOrigin: "local",
},
] as SSHHost[]);
expect(rows).toEqual([
expect.objectContaining({
id: -9,
syncId: "shared-host",
connectionOrigin: "local",
}),
]);
});
it("hydrates a remote shared host for a local connection without persisting remote ids", async () => {
isElectron.mockReturnValue(true);
invoke.mockResolvedValue({ serverUrl: "https://termix.example" });
remoteApi.get.mockResolvedValue({
data: {
username: "recipient",
authType: "key",
key: "PRIVATE KEY",
keyPassword: "passphrase",
keyType: "ed25519",
},
});
const result = await hydrateLocalSharedHostAuth({
id: -9,
isShared: true,
syncId: "shared-host",
credentialId: 77,
username: "owner",
authType: "key",
});
expect(remoteApi.get).toHaveBeenCalledWith(
"/host/db/host/9/local-connection-auth",
);
expect(result).toEqual(
expect.objectContaining({
id: -9,
syncId: null,
credentialId: undefined,
username: "recipient",
key: "PRIVATE KEY",
keyPassword: "passphrase",
}),
);
});
it("leaves local and owned hosts untouched", async () => {
const host = { id: 9, isShared: false, username: "root" };
it("leaves hosts as they are, since shared copies carry their own auth", async () => {
const host = { id: 9, isShared: true, username: "root" };
await expect(hydrateLocalSharedHostAuth(host)).resolves.toBe(host);
expect(remoteApi.get).not.toHaveBeenCalled();
});
@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const isElectron = vi.hoisted(() => vi.fn());
vi.mock("@/lib/electron", () => ({ isElectron }));
import { requestRemoteSync } from "@/lib/remote-sync-trigger";
describe("requestRemoteSync", () => {
const invoke = vi.fn();
beforeEach(() => {
isElectron.mockReset();
invoke.mockReset();
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke },
});
});
it("starts a sync when the desktop is connected to a server", async () => {
isElectron.mockReturnValue(true);
invoke.mockResolvedValueOnce({ serverUrl: "https://termix.example" });
await requestRemoteSync();
expect(invoke).toHaveBeenNthCalledWith(1, "get-remote-sync-config");
expect(invoke).toHaveBeenNthCalledWith(2, "remote-sync-now");
});
it("does nothing outside Electron or without a configured server", async () => {
isElectron.mockReturnValue(false);
await requestRemoteSync();
expect(invoke).not.toHaveBeenCalled();
isElectron.mockReturnValue(true);
invoke.mockResolvedValueOnce(null);
await requestRemoteSync();
expect(invoke).toHaveBeenCalledOnce();
});
});
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getSyncStatus = vi.hoisted(() => vi.fn());
const notifySyncChanged = vi.hoisted(() => vi.fn());
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
vi.mock("@/api/sync-api", () => ({ getSyncStatus }));
vi.mock("@/lib/linked-server", () => ({ notifySyncChanged }));
const store = await import("@/lib/sync-status");
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("sync status store", () => {
let unsubscribe: () => void = () => {};
const events: string[] = [];
const record = (event: Event) => events.push(event.type);
beforeEach(() => {
events.length = 0;
for (const type of ["hosts:refresh", "termix:hosts-changed"]) {
window.addEventListener(type, record);
}
});
afterEach(() => {
unsubscribe();
for (const type of ["hosts:refresh", "termix:hosts-changed"]) {
window.removeEventListener(type, record);
}
});
it("polls while subscribed and tells views to reload after a pass", async () => {
getSyncStatus.mockResolvedValue({
linked: true,
lastSyncAt: "2026-09-25T10:00:00Z",
entities: [],
});
const listener = vi.fn();
unsubscribe = store.subscribeSyncStatus(listener);
await flush();
expect(store.getSyncStatusSnapshot()?.linked).toBe(true);
expect(listener).toHaveBeenCalled();
// The first read only sets the baseline.
expect(events).toEqual([]);
store.setSyncStatus({
linked: true,
lastSyncAt: "2026-09-25T10:05:00Z",
entities: [],
});
expect(events).toEqual(["hosts:refresh", "termix:hosts-changed"]);
expect(notifySyncChanged).toHaveBeenCalled();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { applyFontSize } from "@/lib/theme";
type ElectronWindow = { electronAPI?: unknown };
afterEach(() => {
delete (window as unknown as ElectronWindow).electronAPI;
document.documentElement.className = "";
localStorage.clear();
});
describe("interface size", () => {
it("sets the root size class and remembers it", () => {
applyFontSize("lg");
expect(document.documentElement.classList.contains("fs-lg")).toBe(true);
applyFontSize("sm");
expect(document.documentElement.className).toBe("fs-sm");
expect(localStorage.getItem("termix-font-size")).toBe("sm");
});
it("takes back the window zoom an earlier desktop build set", () => {
const setZoomFactor = vi.fn();
(window as unknown as ElectronWindow).electronAPI = { setZoomFactor };
applyFontSize("xl");
expect(setZoomFactor).toHaveBeenCalledWith(1);
expect(document.documentElement.classList.contains("fs-xl")).toBe(true);
});
it("falls back to Normal for an unknown size", () => {
applyFontSize("huge" as never);
expect(document.documentElement.classList.contains("fs-md")).toBe(true);
expect(localStorage.getItem("termix-font-size")).toBe("md");
});
});
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { ONBOARDING_STEPS, relevantSteps } from "@/onboarding/onboarding-steps";
import { UI_AREA_KEYS, PRESETS } from "@/types/ui-preferences";
import en from "@/locales/en.json";
const isElectron = vi.hoisted(() => vi.fn(() => false));
vi.mock("@/lib/electron", () => ({ isElectron }));
function lookup(key: string): unknown {
return key
.split(".")
@@ -34,6 +37,14 @@ describe("ONBOARDING_STEPS", () => {
expect(ids).not.toContain("first-host");
});
it("asks how the desktop app is used only on the desktop", () => {
isElectron.mockReturnValue(false);
expect(relevantSteps({}).map((s) => s.id)).not.toContain("desktop-sync");
isElectron.mockReturnValue(true);
expect(relevantSteps({}).map((s) => s.id)).toContain("desktop-sync");
isElectron.mockReturnValue(false);
});
it("ends on the done step", () => {
const ids = relevantSteps({}).map((s) => s.id);
expect(ids[ids.length - 1]).toBe("done");
@@ -1,87 +0,0 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { toast, invoke } = vi.hoisted(() => ({
toast: { error: vi.fn(), success: vi.fn() },
invoke: vi.fn(),
}));
vi.mock("sonner", () => ({ toast }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/auth/ElectronLoginForm.tsx", () => ({
ElectronLoginForm: ({ serverUrl }: { serverUrl: string }) => (
<div data-testid="remote-login">{serverUrl}</div>
),
}));
import { RemoteSyncPanel } from "../../settings/RemoteSyncPanel";
const config = {
serverUrl: "http://192.168.3.175:6060",
connectedAt: "2026-08-27T00:00:00.000Z",
};
const status = {
connected: true,
syncing: false,
lastSyncedAt: null,
lastError: "Remote session expired",
needsReauth: true,
};
beforeEach(() => {
invoke.mockImplementation((channel: string) => {
if (channel === "get-remote-sync-config") return Promise.resolve(config);
if (channel === "get-remote-sync-status") return Promise.resolve(status);
if (channel === "get-desktop-settings") {
return Promise.resolve({ defaultConnectionOrigin: "local" });
}
if (channel === "remote-sync-now") return Promise.resolve(status);
return Promise.resolve(null);
});
Object.defineProperty(window, "electronAPI", {
configurable: true,
value: { invoke, onRemoteSyncStatusChanged: vi.fn() },
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("RemoteSyncPanel", () => {
it("opens the configured server login when the banner requests reconnect", async () => {
const onHandled = vi.fn();
render(
<RemoteSyncPanel
reconnectRequested
onReconnectRequestHandled={onHandled}
/>,
);
expect((await screen.findByTestId("remote-login")).textContent).toBe(
config.serverUrl,
);
expect(onHandled).toHaveBeenCalledOnce();
});
it("shows the sync failure returned by the main process", async () => {
render(<RemoteSyncPanel />);
const syncButton = await screen.findByRole("button", {
name: /remoteSync.syncNowButton/,
});
fireEvent.click(syncButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Remote session expired");
});
});
});
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldForceLocalPreferenceStorage } from "../../settings/remote-sync-state";
describe("remote sync preference storage state", () => {
it("does not overwrite cloud mode while desktop sync config is loading", () => {
expect(shouldForceLocalPreferenceStorage(true, null, "cloud")).toBe(false);
});
it("forces local mode only after desktop sync is confirmed unconfigured", () => {
expect(shouldForceLocalPreferenceStorage(true, false, "cloud")).toBe(true);
expect(shouldForceLocalPreferenceStorage(true, true, "cloud")).toBe(false);
expect(shouldForceLocalPreferenceStorage(false, false, "cloud")).toBe(
false,
);
});
});
@@ -0,0 +1,127 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SyncStatus } from "@/api/sync-api";
const status = vi.hoisted(() => ({ value: null as SyncStatus | null }));
const api = vi.hoisted(() => ({
getSyncConflicts: vi.fn(async () => []),
getSyncErrors: vi.fn(async () => []),
updateSyncSettings: vi.fn(),
syncNow: vi.fn(),
unlinkServer: vi.fn(),
retrySyncErrors: vi.fn(),
settleSyncConflict: vi.fn(),
probeServer: vi.fn(),
getLinkPreview: vi.fn(),
completeLink: vi.fn(),
reloginLink: vi.fn(),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => status.value,
}));
vi.mock("@/lib/sync-status", () => ({
setSyncStatus: vi.fn(),
refreshSyncStatus: vi.fn(),
}));
vi.mock("@/lib/linked-server", () => ({ notifySyncChanged: vi.fn() }));
vi.mock("@/lib/hosts-request-cache", () => ({
invalidateServerStatusCache: vi.fn(),
}));
vi.mock("@/api/sync-api", () => api);
vi.mock("@/auth/ElectronLoginForm", () => ({ ElectronLoginForm: () => null }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const { SyncPanel } = await import("@/settings/sync/SyncPanel");
beforeEach(() => {
(window as unknown as { electronAPI: unknown }).electronAPI = {
invoke: vi.fn(async () => null),
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const LINKED: SyncStatus = {
linked: true,
serverUrl: "https://termix.example",
serverName: "Home",
account: { username: "luke" },
status: "idle",
lastSyncAt: new Date().toISOString(),
pending: 0,
conflicts: 0,
errors: 0,
entities: [
{
type: "hosts",
owner: "core",
ownerName: null,
readOnly: false,
onServer: true,
onDevice: true,
enabled: true,
},
{
type: "snippets",
owner: "snippets",
ownerName: "Snippets",
readOnly: false,
onServer: true,
onDevice: true,
enabled: false,
},
],
};
describe("SyncPanel", () => {
it("explains this-device-only use and offers to link", () => {
status.value = { linked: false, entities: [] };
render(<SyncPanel />);
expect(screen.getByText("sync.unlinkedTitle")).toBeTruthy();
fireEvent.click(screen.getByText("sync.linkButton"));
expect(screen.getByText("sync.wizard.title")).toBeTruthy();
});
it("shows the account, server and what syncs, grouped by feature", () => {
status.value = LINKED;
render(<SyncPanel />);
expect(screen.getByText("Home")).toBeTruthy();
expect(screen.getByText("luke")).toBeTruthy();
expect(screen.getByText("sync.coreGroup")).toBeTruthy();
expect(screen.getByText("Snippets")).toBeTruthy();
});
it("switches a kind of data off, keeping the others as they are", async () => {
status.value = LINKED;
api.updateSyncSettings.mockResolvedValue(LINKED);
render(<SyncPanel />);
const switches = screen.getAllByRole("switch");
fireEvent.click(switches[0]);
await waitFor(() =>
expect(api.updateSyncSettings).toHaveBeenCalledWith({
disabledTypes: expect.arrayContaining(["snippets", "hosts"]),
}),
);
});
it("asks to sign in again when the server ended the session", () => {
status.value = { ...LINKED, status: "signed_out" };
render(<SyncPanel />);
expect(screen.getByText("sync.state.signedOut")).toBeTruthy();
expect(screen.getByText("sync.signInAgain")).toBeTruthy();
expect(screen.queryByText("sync.syncNow")).toBeNull();
});
});
@@ -103,8 +103,8 @@ describe("HostAuthOverrideModal", () => {
fireEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(api.getHostAuthOverride).toHaveBeenCalledWith(42, "ssh");
expect(api.setHostAuthOverride).toHaveBeenCalledWith(42, "ssh", 8);
expect(api.getHostAuthOverride).toHaveBeenCalledWith(42, "ssh", null);
expect(api.setHostAuthOverride).toHaveBeenCalledWith(42, "ssh", 8, null);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
@@ -128,7 +128,12 @@ describe("HostAuthOverrideModal", () => {
fireEvent.click(screen.getByText("common.save"));
await waitFor(() => {
expect(api.setHostAuthOverride).toHaveBeenCalledWith(42, "ssh", null);
expect(api.setHostAuthOverride).toHaveBeenCalledWith(
42,
"ssh",
null,
null,
);
});
});
@@ -160,7 +165,7 @@ describe("HostAuthOverrideModal", () => {
).toBeTruthy();
});
it("loads credentials and overrides from the remote server for remote-only shared hosts", async () => {
it("loads credentials and overrides from the server for a synced shared copy", async () => {
remote.get.mockResolvedValue({
data: [
{
@@ -177,7 +182,7 @@ describe("HostAuthOverrideModal", () => {
<HostAuthOverrideModal
open
onOpenChange={() => {}}
host={{ ...host, id: "-42" }}
host={{ ...host, sharedCopy: true, syncId: "host-sync-42" } as Host}
protocol="ssh"
/>,
);
@@ -188,7 +193,11 @@ describe("HostAuthOverrideModal", () => {
expect((select as HTMLSelectElement).value).toBe("19");
expect(remote.get).toHaveBeenCalledWith("/credentials");
expect(api.getCredentials).not.toHaveBeenCalled();
expect(api.getHostAuthOverride).toHaveBeenCalledWith(-42, "ssh", true);
expect(api.getHostAuthOverride).toHaveBeenCalledWith(
42,
"ssh",
"host-sync-42",
);
});
it("renders empty and load-error states", async () => {
@@ -22,8 +22,8 @@ vi.mock("react-i18next", () => ({
// Panel-level dependencies that are irrelevant to AccordionSection but would
// otherwise be pulled in by importing the module.
vi.mock("@/settings/RemoteSyncPanel.tsx", () => ({
RemoteSyncPanel: () => null,
vi.mock("@/plugin-host/shell-bridge", () => ({
shell: { openRailView: vi.fn() },
}));
vi.mock("@/i18n/i18n", () => ({
changeAppLanguage: vi.fn(),
+1
View File
@@ -61,6 +61,7 @@ describe("RAIL_ITEMS", () => {
"ssh-tools",
"macros",
"split-screen",
"sync",
]);
});
@@ -1,204 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
const mainAxios = vi.hoisted(() => ({
checkElectronUpdate: vi.fn(),
}));
vi.mock("@/main-axios.ts", () => mainAxios);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
// The whole component is a no-op outside Electron, so every test here needs the
// desktop build.
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
import { ElectronVersionCheck } from "../../user/ElectronVersionCheck";
const DISMISS_KEY = "electron-version-check-dismissed";
type ElectronTestWindow = Window &
typeof globalThis & {
electronAPI?: { getAppVersion?: () => Promise<string | undefined> };
};
function updateAvailable(localVersion: string, remoteVersion: string) {
return {
success: true,
status: "requires_update" as const,
localVersion,
remoteVersion,
latest_release: {
tag_name: `v${remoteVersion}`,
name: `Termix ${remoteVersion}`,
published_at: "2026-07-01T00:00:00Z",
html_url: `https://github.com/Termix-SSH/Termix/releases/tag/v${remoteVersion}`,
body: "",
},
};
}
function upToDate(version: string) {
return {
success: true,
status: "up_to_date" as const,
localVersion: version,
remoteVersion: version,
};
}
// Renders and waits until the check has resolved and the modal body is on
// screen, so that a later `not.toHaveBeenCalled()` is not vacuously true.
async function renderAndSettle(onContinue: () => void) {
render(<ElectronVersionCheck onContinue={onContinue} />);
await waitFor(() => {
expect(mainAxios.checkElectronUpdate).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByText("common.continue")).toBeTruthy();
});
}
beforeEach(() => {
localStorage.clear();
mainAxios.checkElectronUpdate.mockReset();
(window as ElectronTestWindow).electronAPI = {
getAppVersion: vi.fn(async () => "2.6.0"),
} as unknown as ElectronTestWindow["electronAPI"];
});
afterEach(() => {
cleanup();
localStorage.clear();
delete (window as ElectronTestWindow).electronAPI;
});
describe("ElectronVersionCheck - dismissal is keyed on the offered version", () => {
it("still prompts when the stored dismissal is the local version and a newer release exists", async () => {
// Regression: the dismissal used to store the LOCAL version, and the
// up-to-date branch wrote it with no user action at all. A user who ran
// 2.6.0 while it was current had "2.6.0" stored, so once 2.6.1 shipped the
// modal was skipped on every launch - suppressed for exactly the users who
// had not updated yet.
localStorage.setItem(DISMISS_KEY, "2.6.0");
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.6.1"),
);
const onContinue = vi.fn();
await renderAndSettle(onContinue);
expect(onContinue).not.toHaveBeenCalled();
expect(screen.getByText("versionCheck.updateRequired")).toBeTruthy();
});
it("stores the remote version when the user dismisses the prompt", async () => {
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.6.1"),
);
const onContinue = vi.fn();
await renderAndSettle(onContinue);
fireEvent.click(screen.getByText("common.continue"));
await waitFor(() => {
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
it("skips the prompt on a later launch offering the same remote version", async () => {
localStorage.setItem(DISMISS_KEY, "2.6.1");
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.6.1"),
);
const onContinue = vi.fn();
render(<ElectronVersionCheck onContinue={onContinue} />);
await waitFor(() => {
expect(onContinue).toHaveBeenCalledTimes(1);
});
});
it("prompts again once a newer release than the dismissed one appears", async () => {
localStorage.setItem(DISMISS_KEY, "2.6.1");
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.7.0"),
);
const onContinue = vi.fn();
await renderAndSettle(onContinue);
expect(onContinue).not.toHaveBeenCalled();
expect(screen.getByText("versionCheck.updateRequired")).toBeTruthy();
});
it("keys a dismissed beta prompt on the remote version too", async () => {
// Local ahead of the published release: dismissing must still record what
// was offered, so the next release re-prompts.
mainAxios.checkElectronUpdate.mockResolvedValue({
success: true,
status: "beta" as const,
localVersion: "2.7.0-beta",
remoteVersion: "2.6.1",
});
const onContinue = vi.fn();
await renderAndSettle(onContinue);
// Both the modal title and the alert heading use this key.
expect(screen.getAllByText("versionCheck.betaVersion").length).toBe(2);
fireEvent.click(screen.getByText("common.continue"));
await waitFor(() => {
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
});
});
it("continues without prompting when up to date, recording the offered version", async () => {
mainAxios.checkElectronUpdate.mockResolvedValue(upToDate("2.6.1"));
const onContinue = vi.fn();
render(<ElectronVersionCheck onContinue={onContinue} />);
await waitFor(() => {
expect(onContinue).toHaveBeenCalledTimes(1);
});
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
});
it("does not record a dismissal when the check itself fails", async () => {
mainAxios.checkElectronUpdate.mockResolvedValue({
success: false,
error: "Check failed",
});
const onContinue = vi.fn();
await renderAndSettle(onContinue);
// No offered version exists, so nothing may be written automatically and
// nothing may be suppressed.
expect(localStorage.getItem(DISMISS_KEY)).toBeNull();
expect(onContinue).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("common.continue"));
// Dismissing falls back to the local version, which cannot match a later
// release and so cannot suppress its prompt.
await waitFor(() => {
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.0");
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
});