mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
fix: home tab, layout switch and missing layout fallbacks for looks
This commit is contained in:
1 parent
4c235bb23f
commit
ec3cc9cf18
14 files changed
+761
-11
No files matched your search
@@ -0,0 +1,292 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
// The repair drill: a broken layout, the real repair view, the real look
|
||||
// store. Only the server, the plugin loader and the Plugins tab flows are fakes.
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options && typeof options === "object" && !("defaultValue" in options)
|
||||
? `${key}:${JSON.stringify(options)}`
|
||||
: ((options?.defaultValue as string | undefined) ?? key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
const permissions = vi.hoisted(() => ({ isAdmin: true }));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({
|
||||
isAdmin: permissions.isAdmin,
|
||||
permissions: [],
|
||||
loaded: true,
|
||||
has: () => permissions.isAdmin,
|
||||
}),
|
||||
}));
|
||||
|
||||
const lookApi = vi.hoisted(() => ({
|
||||
getLook: vi.fn(),
|
||||
saveUserLook: vi.fn(async (_patch: Record<string, unknown>) => {}),
|
||||
saveDefaultLook: vi.fn(async (defaults: Record<string, unknown>) => ({
|
||||
layout: null,
|
||||
theme: null,
|
||||
...defaults,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/api/look-api", () => lookApi);
|
||||
|
||||
const loader = vi.hoisted(() => ({
|
||||
retryPluginFrontend: vi.fn(async () => {}),
|
||||
syncPlugins: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/plugin-host/loader", () => loader);
|
||||
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false }));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
const manager = vi.hoisted(() => ({
|
||||
requestUpdate: vi.fn(),
|
||||
requestInstall: vi.fn(),
|
||||
toggle: vi.fn(),
|
||||
setConsent: vi.fn(),
|
||||
confirmConsent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/plugins/use-plugins-manager", () => ({
|
||||
usePluginsManager: () => ({
|
||||
...manager,
|
||||
loading: false,
|
||||
registryError: null,
|
||||
consent: null,
|
||||
busy: new Set<string>(),
|
||||
plugins: [
|
||||
{
|
||||
id: "fancy-layout",
|
||||
name: "Fancy",
|
||||
installed: true,
|
||||
enabled: true,
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.1.0",
|
||||
managedByServer: false,
|
||||
bundled: false,
|
||||
versions: [],
|
||||
},
|
||||
{
|
||||
id: "paper-themes",
|
||||
name: "Paper themes",
|
||||
description: "Light themes",
|
||||
installed: false,
|
||||
enabled: false,
|
||||
updateAvailable: false,
|
||||
latestVersion: "1.0.0",
|
||||
versions: [{ version: "1.0.0", capabilities: ["ui:theme"] }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { LayoutHost } from "@/shell/layout/LayoutHost";
|
||||
import {
|
||||
registerLayout,
|
||||
resetRegisteredLayouts,
|
||||
setPluginLayouts,
|
||||
} from "@/look/layout-registry";
|
||||
import { loadLook, resetLook } from "@/look/look-store";
|
||||
import {
|
||||
markPluginsSettled,
|
||||
resetPluginStore,
|
||||
setFrontendState,
|
||||
setPluginSummaries,
|
||||
} from "@/plugin-host/plugin-store";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
|
||||
const fancyPlugin: PluginSummary = {
|
||||
id: "fancy-layout",
|
||||
name: "Fancy",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
frontend: true,
|
||||
contributes: { layouts: [{ id: "fancy", label: "Fancy" }] },
|
||||
};
|
||||
|
||||
async function start(look: {
|
||||
layoutId?: string | null;
|
||||
defaultLayout?: string | null;
|
||||
summaries?: PluginSummary[];
|
||||
}) {
|
||||
lookApi.getLook.mockResolvedValue({
|
||||
layoutId: look.layoutId ?? null,
|
||||
themeId: null,
|
||||
defaults: { layout: look.defaultLayout ?? null, theme: null },
|
||||
defaultLook: { layout: "classic", theme: "dark" },
|
||||
safeLayout: false,
|
||||
unavailable: [],
|
||||
});
|
||||
await loadLook();
|
||||
setPluginSummaries(look.summaries ?? []);
|
||||
markPluginsSettled();
|
||||
}
|
||||
|
||||
function Broken(): never {
|
||||
throw new Error("draw exploded");
|
||||
}
|
||||
// React reports crashes it caught; these are expected here.
|
||||
const expectedCrash = (event: ErrorEvent) => event.preventDefault();
|
||||
|
||||
beforeEach(() => {
|
||||
permissions.isAdmin = true;
|
||||
resetLook();
|
||||
resetPluginStore();
|
||||
resetRegisteredLayouts();
|
||||
for (const fn of [
|
||||
...Object.values(manager),
|
||||
lookApi.saveUserLook,
|
||||
lookApi.saveDefaultLook,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
setPluginLayouts([
|
||||
{ id: "fancy", label: "Fancy", pluginId: "fancy-layout" },
|
||||
{ id: "classic", label: "Classic", pluginId: "classic-layout" },
|
||||
]);
|
||||
registerLayout({ id: "classic", component: () => <p>classic layout</p> });
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
window.addEventListener("error", expectedCrash);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.removeEventListener("error", expectedCrash);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("layout repair drill", () => {
|
||||
it("a layout that throws while drawing ends in the repair view, and switching gets out", async () => {
|
||||
registerLayout({ id: "fancy", component: Broken });
|
||||
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
|
||||
render(<LayoutHost />);
|
||||
|
||||
expect(await screen.findByText("draw exploded")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Classic"));
|
||||
await waitFor(() =>
|
||||
expect(lookApi.saveUserLook).toHaveBeenCalledWith({
|
||||
layoutId: "classic",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("classic layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a layout whose activate threw shows the error, and reset brings the default look back", async () => {
|
||||
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
|
||||
setFrontendState("fancy-layout", "failed", "activate exploded");
|
||||
render(<LayoutHost />);
|
||||
|
||||
expect(await screen.findByText("activate exploded")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("looks.repair.reset"));
|
||||
await waitFor(() =>
|
||||
expect(lookApi.saveUserLook).toHaveBeenCalledWith({ layoutId: null }),
|
||||
);
|
||||
expect(lookApi.saveUserLook).toHaveBeenCalledWith({ themeId: null });
|
||||
expect(await screen.findByText("classic layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("an admin updates the broken plugin when the registry has a newer one", async () => {
|
||||
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
|
||||
setFrontendState("fancy-layout", "failed", "activate exploded");
|
||||
render(<LayoutHost />);
|
||||
|
||||
fireEvent.click(await screen.findByText(/looks\.repair\.update/));
|
||||
expect(manager.requestUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "fancy-layout" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("an admin installs a layout or theme from the registries", async () => {
|
||||
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
|
||||
setFrontendState("fancy-layout", "failed", "activate exploded");
|
||||
render(<LayoutHost />);
|
||||
|
||||
fireEvent.click(await screen.findByText("looks.repair.install"));
|
||||
expect(await screen.findByText("Paper themes")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("looks.repair.installButton"));
|
||||
expect(manager.requestInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "paper-themes" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("someone who is not an admin gets switch and reset, and a note to ask an admin", async () => {
|
||||
permissions.isAdmin = false;
|
||||
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
|
||||
setFrontendState("fancy-layout", "failed", "activate exploded");
|
||||
render(<LayoutHost />);
|
||||
|
||||
expect(await screen.findByText("looks.repair.askAdmin")).toBeTruthy();
|
||||
expect(screen.queryByText(/looks\.repair\.update/)).toBeNull();
|
||||
expect(screen.queryByText("looks.repair.install")).toBeNull();
|
||||
expect(screen.getByText("looks.repair.reset")).toBeTruthy();
|
||||
expect(screen.getByText("Classic")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("the user's own layout wins over the admin default", async () => {
|
||||
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
|
||||
await start({
|
||||
layoutId: "fancy",
|
||||
defaultLayout: "classic",
|
||||
summaries: [fancyPlugin],
|
||||
});
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("fancy layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a layout the server dropped as uninstalled falls back to the admin default", async () => {
|
||||
// The server answers null for a choice no installed plugin brings.
|
||||
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
|
||||
await start({ layoutId: null, defaultLayout: "fancy" });
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("fancy layout")).toBeTruthy();
|
||||
|
||||
cleanup();
|
||||
resetLook();
|
||||
await start({ layoutId: null, defaultLayout: null });
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("classic layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("?safe-layout=1 opens the repair view over a working layout, and leaving it draws the layout", async () => {
|
||||
window.history.replaceState(null, "", "/?safe-layout=1");
|
||||
await start({ layoutId: "classic" });
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("looks.repair.safeTitle")).toBeTruthy();
|
||||
expect(screen.queryByText("looks.repair.retry")).toBeNull();
|
||||
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("classic layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("TERMIX_SAFE_LAYOUT on the server opens the repair view for everyone", async () => {
|
||||
lookApi.getLook.mockResolvedValue({
|
||||
layoutId: "classic",
|
||||
themeId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: "classic", theme: "dark" },
|
||||
safeLayout: true,
|
||||
unavailable: [],
|
||||
});
|
||||
await act(async () => {
|
||||
await loadLook();
|
||||
});
|
||||
setPluginSummaries([]);
|
||||
markPluginsSettled();
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("looks.repair.safeTitle")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user