mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -0,0 +1,382 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildLayoutPayload as buildWorkspacePayload,
|
||||
buildLayoutTabSnapshots as buildWorkspaceTabSnapshots,
|
||||
remapSlotIds,
|
||||
resolveLayoutTabTarget as resolveWorkspaceTabTarget,
|
||||
snapshotData,
|
||||
} from "@/shell/shell-layout";
|
||||
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
|
||||
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||
|
||||
// Plugin tab types take part in layouts only while registered, so the ones
|
||||
// these cases use are registered the way their plugins would.
|
||||
beforeEach(() => {
|
||||
registerTabType({ id: "terminal", component: () => null, persistent: true });
|
||||
registerTabType({
|
||||
id: "fleet-inventory",
|
||||
component: () => null,
|
||||
singleton: true,
|
||||
hostless: true,
|
||||
});
|
||||
registerTabType({
|
||||
id: "web-endpoint",
|
||||
component: () => null,
|
||||
inLayouts: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetTabTypes();
|
||||
});
|
||||
|
||||
function makeHost(overrides: Partial<Host> = {}): Host {
|
||||
return {
|
||||
id: "1",
|
||||
name: "web-01",
|
||||
username: "root",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
folder: "",
|
||||
online: true,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "2026-01-01T00:00:00.000Z",
|
||||
authType: "password",
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
syncId: "sync-web-01",
|
||||
...overrides,
|
||||
} as Host;
|
||||
}
|
||||
|
||||
function makeTab(overrides: Partial<Tab> = {}): Tab {
|
||||
return {
|
||||
id: "tab-1",
|
||||
instanceId: "instance-1",
|
||||
type: "terminal",
|
||||
label: "web-01",
|
||||
openedAt: 0,
|
||||
...overrides,
|
||||
} as Tab;
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTabSnapshots", () => {
|
||||
it("falls back when randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {});
|
||||
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2" })];
|
||||
|
||||
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
|
||||
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots[0].slotId).toBeTruthy();
|
||||
expect(snapshots[1].slotId).not.toBe(snapshots[0].slotId);
|
||||
});
|
||||
|
||||
it("captures host-bound and singleton tabs with generated slot ids", () => {
|
||||
let counter = 0;
|
||||
const genSlotId = () => `slot-${counter++}`;
|
||||
|
||||
const host = makeHost();
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "terminal", host, label: "web-01" }),
|
||||
makeTab({ id: "t2", type: "tunnel", label: "Tunnels" }),
|
||||
];
|
||||
|
||||
const { snapshots, slotIdByTabId } = buildWorkspaceTabSnapshots(
|
||||
tabs,
|
||||
genSlotId,
|
||||
);
|
||||
|
||||
expect(snapshots).toHaveLength(2);
|
||||
expect(snapshots[0]).toMatchObject({
|
||||
slotId: "slot-0",
|
||||
type: "terminal",
|
||||
hostSyncId: "sync-web-01",
|
||||
hostNameSnapshot: "web-01",
|
||||
});
|
||||
expect(snapshots[1]).toMatchObject({
|
||||
slotId: "slot-1",
|
||||
type: "tunnel",
|
||||
hostSyncId: null,
|
||||
});
|
||||
expect(slotIdByTabId.get("t1")).toBe("slot-0");
|
||||
expect(slotIdByTabId.get("t2")).toBe("slot-1");
|
||||
});
|
||||
|
||||
it("excludes rail-view pseudo tabs (host-manager, user-profile, admin-settings)", () => {
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "host-manager" }),
|
||||
makeTab({ id: "t2", type: "user-profile" }),
|
||||
makeTab({ id: "t3", type: "admin-settings" }),
|
||||
makeTab({ id: "t4", type: "tunnel" }),
|
||||
];
|
||||
|
||||
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0].type).toBe("tunnel");
|
||||
});
|
||||
|
||||
it("excludes the dashboard tab - it's a permanent fallback, not a meaningful part of a saved arrangement", () => {
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "dashboard" }),
|
||||
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
|
||||
];
|
||||
|
||||
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0].type).toBe("terminal");
|
||||
});
|
||||
|
||||
it("captures serialConfig only for serial tabs", () => {
|
||||
const tabs: Tab[] = [
|
||||
makeTab({
|
||||
id: "t1",
|
||||
type: "serial",
|
||||
serialConfig: {
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
},
|
||||
}),
|
||||
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
|
||||
];
|
||||
|
||||
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
|
||||
expect(snapshots[0].serialConfig).toEqual({
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
});
|
||||
expect(snapshots[0].hostSyncId).toBeNull();
|
||||
expect(snapshots[1].serialConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remapSlotIds", () => {
|
||||
it("maps slot ids to live tab ids, preserving nulls and unresolved slots", () => {
|
||||
const map = new Map([
|
||||
["slot-a", "new-tab-1"],
|
||||
["slot-b", "new-tab-2"],
|
||||
]);
|
||||
const result = remapSlotIds(
|
||||
["slot-a", null, "slot-b", "slot-missing"],
|
||||
map,
|
||||
);
|
||||
expect(result).toEqual(["new-tab-1", null, "new-tab-2", null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceTabTarget", () => {
|
||||
const hosts: Host[] = [makeHost({ id: "1", syncId: "sync-web-01" })];
|
||||
|
||||
it("resolves a serial snapshot with a config as 'serial'", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "serial",
|
||||
label: "Serial",
|
||||
serialConfig: {
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 9600,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
},
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "serial",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a serial snapshot missing its config", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "serial",
|
||||
label: "Serial",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a host-bound snapshot by matching syncId", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "terminal",
|
||||
label: "web-01",
|
||||
hostSyncId: "sync-web-01",
|
||||
};
|
||||
const result = resolveWorkspaceTabTarget(snapshot, hosts);
|
||||
expect(result.kind).toBe("host");
|
||||
expect((result as { host: Host }).host.syncId).toBe("sync-web-01");
|
||||
});
|
||||
|
||||
it("skips a host-bound snapshot whose host no longer exists", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "terminal",
|
||||
label: "deleted-host",
|
||||
hostSyncId: "sync-gone",
|
||||
hostNameSnapshot: "deleted-host",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a hostless singleton type without a hostSyncId", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "tunnel",
|
||||
label: "Tunnels",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "singleton",
|
||||
host: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a singleton type WITH a resolved host as singleton+host (e.g. fleet-inventory)", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "fleet-inventory",
|
||||
label: "Fleet",
|
||||
hostSyncId: "sync-web-01",
|
||||
};
|
||||
const result = resolveWorkspaceTabTarget(snapshot, hosts);
|
||||
expect(result.kind).toBe("singleton");
|
||||
expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01");
|
||||
});
|
||||
|
||||
it("skips a non-singleton, non-serial type with no resolvable host", () => {
|
||||
const snapshot: WorkspaceTabSnapshot = {
|
||||
slotId: "s1",
|
||||
type: "files",
|
||||
label: "files",
|
||||
};
|
||||
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
|
||||
kind: "skip",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkspacePayload", () => {
|
||||
it("builds a full payload including pane assignment remapped to slot ids", () => {
|
||||
let counter = 0;
|
||||
const genSlotId = () => `slot-${counter++}`;
|
||||
|
||||
const host = makeHost();
|
||||
const tabs: Tab[] = [
|
||||
makeTab({ id: "t1", type: "terminal", host }),
|
||||
makeTab({ id: "t2", type: "files", host }),
|
||||
];
|
||||
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs,
|
||||
activeTabId: "t1",
|
||||
splitMode: "2-way",
|
||||
paneTabIds: ["t1", "t2", null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[50, 50]],
|
||||
genSlotId,
|
||||
});
|
||||
|
||||
expect(payload.version).toBe(1);
|
||||
expect(payload.activeSlotId).toBe("slot-0");
|
||||
expect(payload.paneTabIds).toEqual([
|
||||
"slot-0",
|
||||
"slot-1",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(payload.splitMode).toBe("2-way");
|
||||
expect(payload.rowSizes).toEqual([100]);
|
||||
expect(payload.rowColSizes).toEqual([[50, 50]]);
|
||||
});
|
||||
|
||||
it("sets activeSlotId to null when the active tab is not capturable", () => {
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs: [makeTab({ id: "t1", type: "host-manager" })],
|
||||
activeTabId: "t1",
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
});
|
||||
expect(payload.activeSlotId).toBeNull();
|
||||
expect(payload.tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("round-trips the sidebar arrangement", () => {
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs: [],
|
||||
activeTabId: "dashboard",
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
sidebar: {
|
||||
left: { view: "hosts", open: true, width: 320 },
|
||||
right: { view: "history", open: true, width: 240 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.sidebar).toEqual({
|
||||
left: { view: "hosts", open: true, width: 320 },
|
||||
right: { view: "history", open: true, width: 240 },
|
||||
});
|
||||
});
|
||||
|
||||
it("omits sidebar when not supplied so older payloads stay unchanged", () => {
|
||||
const payload = buildWorkspacePayload({
|
||||
tabs: [],
|
||||
activeTabId: "dashboard",
|
||||
splitMode: "none",
|
||||
paneTabIds: [null, null, null, null, null, null],
|
||||
rowSizes: [100],
|
||||
rowColSizes: [[100]],
|
||||
});
|
||||
|
||||
expect(payload.sidebar).toBeUndefined();
|
||||
expect(payload.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin tab types in layouts", () => {
|
||||
it("leaves out a tab type that opts out of layouts", () => {
|
||||
const { snapshots } = buildWorkspaceTabSnapshots([
|
||||
makeTab({ id: "w", type: "web-endpoint" }),
|
||||
makeTab({ id: "t", type: "terminal" }),
|
||||
]);
|
||||
expect(snapshots.map((s) => s.type)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("leaves out a plugin tab type nobody registered", () => {
|
||||
const { snapshots } = buildWorkspaceTabSnapshots([
|
||||
makeTab({ id: "x", type: "some-plugin-tab" }),
|
||||
]);
|
||||
expect(snapshots).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries a tab's plugin data and reads the older fleetId field", () => {
|
||||
const { snapshots } = buildWorkspaceTabSnapshots([
|
||||
makeTab({ id: "f", type: "fleet-inventory", data: { fleetId: 3 } }),
|
||||
]);
|
||||
expect(snapshotData(snapshots[0])).toEqual({ fleetId: 3 });
|
||||
expect(
|
||||
snapshotData({
|
||||
slotId: "s",
|
||||
type: "fleet-inventory",
|
||||
label: "",
|
||||
fleetId: 7,
|
||||
}),
|
||||
).toEqual({ fleetId: 7 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user