mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user