feat: finish the web-endpoint plugin, add ctx.desktop (B8)

This commit is contained in:
LukeGus committed 2026-09-23 18:36:41 -05:00
1 parent 1682861853
commit 535126fd1b
50 files changed
+2018 -898

No files matched your search

@@ -0,0 +1,130 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
isElectronIpcAvailable,
requestFromElectronMain,
} from "../../utils/electron-ipc-bridge.js";
const originalEnv = process.env.ELECTRON_EMBEDDED;
const originalSend = process.send;
beforeEach(() => {
process.env.ELECTRON_EMBEDDED = "true";
});
afterEach(() => {
process.env.ELECTRON_EMBEDDED = originalEnv;
process.send = originalSend;
});
describe("isElectronIpcAvailable", () => {
it("is false outside the embedded desktop backend", () => {
delete process.env.ELECTRON_EMBEDDED;
process.send = (() => true) as typeof process.send;
expect(isElectronIpcAvailable()).toBe(false);
});
it("is false when there is no fork IPC channel", () => {
process.send = undefined;
expect(isElectronIpcAvailable()).toBe(false);
});
it("is true when embedded with a live channel", () => {
process.send = (() => true) as typeof process.send;
expect(isElectronIpcAvailable()).toBe(true);
});
});
describe("requestFromElectronMain", () => {
it("refuses when Electron IPC is unavailable", async () => {
process.send = undefined;
await expect(
requestFromElectronMain("open-isolated-window", {}),
).rejects.toThrow(/desktop app/);
});
// The module attaches its "message" listener to the real process.on only
// once, the first time requestFromElectronMain actually reaches that point
// (ensureListening's module-scoped flag mirrors how a real forked
// process's IPC channel is a single persistent thing it listens on for the
// process lifetime). vi.spyOn(process, "on") does not reliably intercept
// that call in this environment, so this replaces process.on directly for
// the one call the module makes and hands the captured listener a plain
// function this file can invoke to simulate a message from main -
// simpler and more direct than getting a spy to survive across a Node
// global.
let deliver: (msg: unknown) => void = () => {};
beforeAll(() => {
const originalOn = process.on.bind(process);
process.on = ((event: string, listener: (...args: unknown[]) => void) => {
if (event === "message") {
deliver = listener;
return process;
}
return originalOn(event, listener);
}) as typeof process.on;
});
let sent: unknown[] = [];
beforeEach(() => {
sent = [];
process.send = ((msg: unknown) => {
sent.push(msg);
return true;
}) as typeof process.send;
});
it("resolves with main's response, matched by request id", async () => {
const pending = requestFromElectronMain<{ success: true }>(
"open-isolated-window",
{ url: "https://example.test" },
);
const [message] = sent as Array<{
type: string;
id: string;
channel: string;
payload: unknown;
}>;
expect(message).toMatchObject({
type: "backend-request",
channel: "open-isolated-window",
payload: { url: "https://example.test" },
});
deliver({
type: "backend-response",
id: message.id,
ok: true,
result: { success: true },
});
await expect(pending).resolves.toEqual({ success: true });
});
it("rejects with main's error", async () => {
const pending = requestFromElectronMain("open-isolated-window", {});
const [message] = sent as Array<{ id: string }>;
deliver({
type: "backend-response",
id: message.id,
ok: false,
error: "refused",
});
await expect(pending).rejects.toThrow(/refused/);
});
it("ignores a response for a different request id", async () => {
const pending = requestFromElectronMain("open-isolated-window", {});
const [message] = sent as Array<{ id: string }>;
deliver({ type: "backend-response", id: "other", ok: true, result: {} });
deliver({
type: "backend-response",
id: message.id,
ok: true,
result: { success: true },
});
await expect(pending).resolves.toEqual({ success: true });
});
});
@@ -0,0 +1,184 @@
/**
* The web endpoint host-columns migration.
*
* An upgrade must be lossless: a host with web endpoints on and a saved
* config must keep both once the ssh_data columns are dropped. Running it
* twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_web_ui: boolean;
web_ui_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
}),
}));
const { runWebEndpointSettingsMigration } =
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const endpoint = {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runWebEndpointSettingsMigration", () => {
it("moves a host's switch and saved config into plugin settings", async () => {
hostRows.push({
id: 1,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableWebUi")).toBe(true);
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved config and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_web_ui: false,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await runWebEndpointSettingsMigration();
expect(storedValue(3, "enableWebUi")).toBe(false);
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
});
it("stores an unreadable config as null rather than failing", async () => {
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "webUiConfig")).toBeNull();
});
it("does nothing on a fresh install", async () => {
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
await runWebEndpointSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runWebEndpointSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await pluginSettingsRepository.set(
"web-endpoint",
"host",
"5",
"enableWebUi",
"false",
);
const result = await runWebEndpointSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableWebUi")).toBe(false);
expect(storedValue(5, "webUiConfig")).toBeUndefined();
});
});