mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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
@@ -9,6 +9,8 @@
|
|||||||
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
|
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
|
||||||
- Hovering a theme in Settings previews it
|
- Hovering a theme in Settings previews it
|
||||||
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
|
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
|
||||||
|
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
|
||||||
|
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
LAYOUT_SETTING,
|
LAYOUT_SETTING,
|
||||||
LOOK_ID_PATTERN,
|
LOOK_ID_PATTERN,
|
||||||
THEME_SETTING,
|
THEME_SETTING,
|
||||||
|
installedLayoutIds,
|
||||||
resolveDefaultLook,
|
resolveDefaultLook,
|
||||||
safeLayoutForced,
|
safeLayoutForced,
|
||||||
unavailableLooks,
|
unavailableLooks,
|
||||||
@@ -35,7 +36,7 @@ async function adminDefaults() {
|
|||||||
* /user-preferences/look:
|
* /user-preferences/look:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get the layout and theme to show the current user
|
* summary: Get the layout and theme to show the current user
|
||||||
* description: The user's own choice, the admin defaults, the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||||
* tags:
|
* tags:
|
||||||
* - User Preferences
|
* - User Preferences
|
||||||
* responses:
|
* responses:
|
||||||
@@ -101,10 +102,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
const preferences =
|
const preferences =
|
||||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
|
const installed = installedLayoutIds(loader);
|
||||||
|
const known = (id: string | null | undefined) =>
|
||||||
|
id && installed.has(id) ? id : null;
|
||||||
|
const defaults = await adminDefaults();
|
||||||
res.json({
|
res.json({
|
||||||
layoutId: preferences?.layoutId ?? null,
|
layoutId: known(preferences?.layoutId),
|
||||||
themeId: preferences?.themeId ?? null,
|
themeId: preferences?.themeId ?? null,
|
||||||
defaults: await adminDefaults(),
|
defaults: { ...defaults, layout: known(defaults.layout) },
|
||||||
defaultLook: resolveDefaultLook(loader),
|
defaultLook: resolveDefaultLook(loader),
|
||||||
safeLayout: safeLayoutForced(),
|
safeLayout: safeLayoutForced(),
|
||||||
unavailable: unavailableLooks(loader),
|
unavailable: unavailableLooks(loader),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface DefaultLook {
|
|||||||
|
|
||||||
interface LookLoader {
|
interface LookLoader {
|
||||||
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
|
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
|
||||||
|
list(): Array<Pick<LoadedPlugin, "manifest">>;
|
||||||
skipped(): SkippedPlugin[];
|
skipped(): SkippedPlugin[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,24 @@ export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
|
|||||||
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
|
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout ids some installed plugin brings, whether it runs, is turned off or
|
||||||
|
* failed to load. A choice outside this set was uninstalled, so the next
|
||||||
|
* choice down applies instead of the repair view.
|
||||||
|
*/
|
||||||
|
export function installedLayoutIds(loader: LookLoader): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const plugin of loader.list()) {
|
||||||
|
for (const layout of plugin.manifest.contributes?.layouts ?? []) {
|
||||||
|
ids.add(layout.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const plugin of loader.skipped()) {
|
||||||
|
for (const id of plugin.layouts) ids.add(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
|
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
|
||||||
export function safeLayoutForced(): boolean {
|
export function safeLayoutForced(): boolean {
|
||||||
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
|
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
|
|||||||
audits: [] as unknown[],
|
audits: [] as unknown[],
|
||||||
skipped: [] as unknown[],
|
skipped: [] as unknown[],
|
||||||
defaultLookIds: [] as string[],
|
defaultLookIds: [] as string[],
|
||||||
|
installed: [] as string[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../utils/logger.js", () => ({
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
@@ -69,6 +70,15 @@ vi.mock("../../../plugins/index.js", () => ({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
list: () => [
|
||||||
|
{
|
||||||
|
manifest: {
|
||||||
|
contributes: {
|
||||||
|
layouts: state.installed.map((id) => ({ id, label: id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
skipped: () => state.skipped,
|
skipped: () => state.skipped,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -90,6 +100,7 @@ beforeEach(async () => {
|
|||||||
state.audits = [];
|
state.audits = [];
|
||||||
state.skipped = [];
|
state.skipped = [];
|
||||||
state.defaultLookIds = [];
|
state.defaultLookIds = [];
|
||||||
|
state.installed = [];
|
||||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -133,6 +144,7 @@ describe("GET /user-preferences/look", () => {
|
|||||||
|
|
||||||
it("returns the user's pick, the admin defaults and the default look", async () => {
|
it("returns the user's pick, the admin defaults and the default look", async () => {
|
||||||
state.prefs = { layoutId: "mine", themeId: "nord" };
|
state.prefs = { layoutId: "mine", themeId: "nord" };
|
||||||
|
state.installed = ["mine", "admin-layout"];
|
||||||
state.settings.set("ui.layout", "admin-layout");
|
state.settings.set("ui.layout", "admin-layout");
|
||||||
state.settings.set("ui.theme", "gruvbox");
|
state.settings.set("ui.theme", "gruvbox");
|
||||||
state.defaultLookIds = ["shipped-look"];
|
state.defaultLookIds = ["shipped-look"];
|
||||||
@@ -162,6 +174,31 @@ describe("GET /user-preferences/look", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /user-preferences/look with an uninstalled layout", () => {
|
||||||
|
it("drops a choice no installed plugin brings, so the next one applies", async () => {
|
||||||
|
state.prefs = { layoutId: "deleted", themeId: null };
|
||||||
|
state.settings.set("ui.layout", "also-deleted");
|
||||||
|
state.installed = ["kept"];
|
||||||
|
const { body } = await getLook();
|
||||||
|
expect(body.layoutId).toBeNull();
|
||||||
|
expect(body.defaults.layout).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a choice whose plugin failed to load, for the repair view", async () => {
|
||||||
|
state.prefs = { layoutId: "b", themeId: null };
|
||||||
|
state.skipped = [
|
||||||
|
{
|
||||||
|
id: "broken",
|
||||||
|
version: "1.0.0",
|
||||||
|
layouts: ["b"],
|
||||||
|
themes: [],
|
||||||
|
error: "x",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect((await getLook()).body.layoutId).toBe("b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PUT /user-preferences/look/defaults", () => {
|
describe("PUT /user-preferences/look/defaults", () => {
|
||||||
it("is for admins only", async () => {
|
it("is for admins only", async () => {
|
||||||
const { status } = await putDefaults({ layout: "x" });
|
const { status } = await putDefaults({ layout: "x" });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ vi.mock("../../plugins/bundled-index.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
installedLayoutIds,
|
||||||
resolveDefaultLook,
|
resolveDefaultLook,
|
||||||
safeLayoutForced,
|
safeLayoutForced,
|
||||||
unavailableLooks,
|
unavailableLooks,
|
||||||
@@ -23,6 +24,10 @@ function loaderWith(
|
|||||||
plugins[id]
|
plugins[id]
|
||||||
? ({ manifest: { contributes: plugins[id] } } as never)
|
? ({ manifest: { contributes: plugins[id] } } as never)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
list: () =>
|
||||||
|
Object.values(plugins).map(
|
||||||
|
(contributes) => ({ manifest: { contributes } }) as never,
|
||||||
|
),
|
||||||
skipped: () => skipped,
|
skipped: () => skipped,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -103,3 +108,14 @@ describe("safeLayoutForced", () => {
|
|||||||
expect(safeLayoutForced()).toBe(false);
|
expect(safeLayoutForced()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("installedLayoutIds", () => {
|
||||||
|
it("lists layouts of loaded and skipped plugins", () => {
|
||||||
|
const ids = installedLayoutIds(
|
||||||
|
loaderWith({ a: { layouts: [{ id: "one" }], themes: [{ id: "t" }] } }, [
|
||||||
|
{ id: "b", version: null, layouts: ["two"], themes: [], error: "x" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect([...ids].sort()).toEqual(["one", "two"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,9 +68,10 @@ export function useTabStageStore(): TabStageStore {
|
|||||||
* renders it inside the scope its tab-close questions should show in.
|
* renders it inside the scope its tab-close questions should show in.
|
||||||
*/
|
*/
|
||||||
export function ShellConfirmCapture(): null {
|
export function ShellConfirmCapture(): null {
|
||||||
const { confirmRef } = useShellController();
|
// Optional, so the repair view still draws when the shell itself is broken.
|
||||||
|
const controller = useOptionalShellController();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
confirmRef.current = confirm;
|
if (controller) controller.confirmRef.current = confirm;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,13 +51,22 @@ export function usePluginShell(pluginId: string): PluginShellController | null {
|
|||||||
() => (hosts ?? []).map((host) => toPluginHostRecord(host, pluginId)),
|
() => (hosts ?? []).map((host) => toPluginHostRecord(host, pluginId)),
|
||||||
[hosts, pluginId],
|
[hosts, pluginId],
|
||||||
);
|
);
|
||||||
|
// The shell always keeps a home tab. A layout that registers no tab type
|
||||||
|
// for it never sees it, so it has no dead tab to draw.
|
||||||
|
const hasHome = useTabTypes().some((type) => type.id === "dashboard");
|
||||||
|
|
||||||
if (!shell) return null;
|
if (!shell) return null;
|
||||||
const { tabs, split, regions } = shell;
|
const { tabs, split, regions } = shell;
|
||||||
|
const shown = (list: typeof tabs.list) =>
|
||||||
|
(hasHome
|
||||||
|
? list
|
||||||
|
: list.filter(
|
||||||
|
(tab) => tab.type !== "dashboard",
|
||||||
|
)) as unknown as ShellTabState[];
|
||||||
return {
|
return {
|
||||||
tabs: {
|
tabs: {
|
||||||
list: tabs.list as unknown as ShellTabState[],
|
list: shown(tabs.list),
|
||||||
topLevel: tabs.topLevel as unknown as ShellTabState[],
|
topLevel: shown(tabs.topLevel),
|
||||||
activeId: tabs.activeId,
|
activeId: tabs.activeId,
|
||||||
ready: tabs.ready,
|
ready: tabs.ready,
|
||||||
showNumbers: tabs.showNumbers,
|
showNumbers: tabs.showNumbers,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component, useMemo, useState, type ReactNode } from "react";
|
import {
|
||||||
|
Component,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
@@ -12,6 +19,7 @@ import { layoutStatus } from "@/look/layout-status";
|
|||||||
import { safeLayoutInUrl } from "@/look/resolve-look";
|
import { safeLayoutInUrl } from "@/look/resolve-look";
|
||||||
import { useLookTheme } from "@/look/use-look-theme";
|
import { useLookTheme } from "@/look/use-look-theme";
|
||||||
import { RepairView } from "@/look/RepairView";
|
import { RepairView } from "@/look/RepairView";
|
||||||
|
import { useOptionalShellController } from "@/shell/controller/ShellControllerProvider";
|
||||||
|
|
||||||
/** Catches a crash while a layout draws, so the repair view can take over. */
|
/** Catches a crash while a layout draws, so the repair view can take over. */
|
||||||
class LayoutErrorBoundary extends Component<
|
class LayoutErrorBoundary extends Component<
|
||||||
@@ -34,6 +42,39 @@ class LayoutErrorBoundary extends Component<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the chosen layout plugin runs, so the tabs the old one brought (its
|
||||||
|
* settings screen, say) cannot draw after a switch. Close them; the home tab
|
||||||
|
* stays, since core keeps it in every layout.
|
||||||
|
*/
|
||||||
|
function useCloseLeftoverTabs(layoutId: string, tabTypes: string[]) {
|
||||||
|
const shell = useOptionalShellController();
|
||||||
|
const previous = useRef<{ layoutId: string; tabTypes: string[] } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const current = { layoutId, tabTypes };
|
||||||
|
const latest = useRef(current);
|
||||||
|
latest.current = current;
|
||||||
|
const shellRef = useRef(shell);
|
||||||
|
shellRef.current = shell;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const before = previous.current;
|
||||||
|
previous.current = latest.current;
|
||||||
|
if (!before || before.layoutId === layoutId || !shellRef.current) return;
|
||||||
|
const kept = new Set(latest.current.tabTypes);
|
||||||
|
for (const tab of shellRef.current.tabs.list) {
|
||||||
|
if (
|
||||||
|
tab.id !== "dashboard" &&
|
||||||
|
before.tabTypes.includes(tab.type) &&
|
||||||
|
!kept.has(tab.type)
|
||||||
|
) {
|
||||||
|
void shellRef.current.tabs.close(tab.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [layoutId]);
|
||||||
|
}
|
||||||
|
|
||||||
interface CrashState {
|
interface CrashState {
|
||||||
layoutId: string | null;
|
layoutId: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -64,6 +105,10 @@ export function LayoutHost() {
|
|||||||
const owner = [...store.records.values()].find((record) =>
|
const owner = [...store.records.values()].find((record) =>
|
||||||
record.summary.contributes?.layouts?.some((l) => l.id === layoutId),
|
record.summary.contributes?.layouts?.some((l) => l.id === layoutId),
|
||||||
);
|
);
|
||||||
|
useCloseLeftoverTabs(
|
||||||
|
layoutId,
|
||||||
|
owner?.summary.contributes?.tabs?.map((tab) => tab.id) ?? [],
|
||||||
|
);
|
||||||
const status = layoutStatus({
|
const status = layoutStatus({
|
||||||
layoutId,
|
layoutId,
|
||||||
safeFromUrl: safeLayoutInUrl(window.location.search),
|
safeFromUrl: safeLayoutInUrl(window.location.search),
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
|||||||
|
|
||||||
default: {
|
default: {
|
||||||
const def = getTabType(tab.type);
|
const def = getTabType(tab.type);
|
||||||
|
// The home tab is empty until the layout gives it a tab type.
|
||||||
|
if (!def && tab.type === "dashboard") return null;
|
||||||
if (!def) {
|
if (!def) {
|
||||||
const panel = getPanel(tab.type);
|
const panel = getPanel(tab.type);
|
||||||
if (panel) {
|
if (panel) {
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ vi.mock("@/hooks/use-permissions", () => ({
|
|||||||
|
|
||||||
import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
|
import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
|
||||||
import { usePluginShell } from "@/shell/controller/plugin-shell";
|
import { usePluginShell } from "@/shell/controller/plugin-shell";
|
||||||
|
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
resetTabTypes();
|
||||||
|
});
|
||||||
|
|
||||||
let shell: PluginShell | null = null;
|
let shell: PluginShell | null = null;
|
||||||
function Capture() {
|
function Capture() {
|
||||||
@@ -67,4 +71,22 @@ describe("usePluginShell", () => {
|
|||||||
act(() => shell!.palette.open());
|
act(() => shell!.palette.open());
|
||||||
expect(shell!.palette.isOpen).toBe(true);
|
expect(shell!.palette.isOpen).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides the home tab from a layout that gives it no tab type", async () => {
|
||||||
|
render(
|
||||||
|
<ShellControllerProvider username="ada" onLogout={() => {}}>
|
||||||
|
<Capture />
|
||||||
|
</ShellControllerProvider>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(shell).not.toBeNull());
|
||||||
|
expect(shell!.tabs.list.map((tab) => tab.type)).not.toContain("dashboard");
|
||||||
|
expect(shell!.tabs.topLevel).toEqual([]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
registerTabType({ id: "dashboard", component: () => null });
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(shell!.tabs.topLevel.map((tab) => tab.id)).toEqual(["dashboard"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("@/main-axios", () => ({
|
||||||
|
getUserInfo: vi.fn(async () => ({ is_admin: false })),
|
||||||
|
getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })),
|
||||||
|
saveUserPreferences: vi.fn(async () => {}),
|
||||||
|
getSSHHosts: vi.fn(async () => []),
|
||||||
|
getSSHFolders: vi.fn(async () => []),
|
||||||
|
getOpenTabs: vi.fn(async () => []),
|
||||||
|
getActiveSessions: vi.fn(async () => []),
|
||||||
|
addOpenTab: vi.fn(async () => {}),
|
||||||
|
deleteOpenTab: vi.fn(async () => {}),
|
||||||
|
patchOpenTab: vi.fn(async () => {}),
|
||||||
|
createSSHHost: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/use-sync-status", () => ({
|
||||||
|
useSyncStatus: () => undefined,
|
||||||
|
useSyncAttentionCount: () => 0,
|
||||||
|
}));
|
||||||
|
|
||||||
const lookApi = vi.hoisted(() => ({
|
const lookApi = vi.hoisted(() => ({
|
||||||
getLook: vi.fn(),
|
getLook: vi.fn(),
|
||||||
@@ -36,7 +54,13 @@ vi.mock("@/look/RepairView", () => ({
|
|||||||
|
|
||||||
import { LayoutHost } from "@/shell/layout/LayoutHost";
|
import { LayoutHost } from "@/shell/layout/LayoutHost";
|
||||||
import { registerLayout, resetRegisteredLayouts } from "@/look/layout-registry";
|
import { registerLayout, resetRegisteredLayouts } from "@/look/layout-registry";
|
||||||
import { loadLook, resetLook } from "@/look/look-store";
|
import { chooseLayout, loadLook, resetLook } from "@/look/look-store";
|
||||||
|
import {
|
||||||
|
ShellControllerProvider,
|
||||||
|
useShellController,
|
||||||
|
} from "@/shell/controller/ShellControllerProvider";
|
||||||
|
import type { ShellController } from "@/shell/controller/types";
|
||||||
|
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
|
||||||
import {
|
import {
|
||||||
markPluginsSettled,
|
markPluginsSettled,
|
||||||
resetPluginStore,
|
resetPluginStore,
|
||||||
@@ -196,4 +220,65 @@ describe("LayoutHost", () => {
|
|||||||
expect(loader.retryPluginFrontend).toHaveBeenCalledWith("fancy-layout"),
|
expect(loader.retryPluginFrontend).toHaveBeenCalledWith("fancy-layout"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("closes the tabs the old layout plugin brought when the layout changes", async () => {
|
||||||
|
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
|
||||||
|
registerLayout({ id: "plain", component: () => <p>plain layout</p> });
|
||||||
|
registerTabType({
|
||||||
|
id: "fancy-settings",
|
||||||
|
component: () => null,
|
||||||
|
hostless: true,
|
||||||
|
singleton: true,
|
||||||
|
});
|
||||||
|
registerTabType({
|
||||||
|
id: "notes",
|
||||||
|
component: () => null,
|
||||||
|
hostless: true,
|
||||||
|
multiInstance: true,
|
||||||
|
});
|
||||||
|
await start("fancy", [
|
||||||
|
plugin({
|
||||||
|
contributes: {
|
||||||
|
layouts: [{ id: "fancy", label: "Fancy" }],
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
id: "fancy-settings",
|
||||||
|
titleKey: "x",
|
||||||
|
icon: "Settings",
|
||||||
|
openFrom: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
plugin({
|
||||||
|
id: "plain-layout",
|
||||||
|
contributes: { layouts: [{ id: "plain", label: "Plain" }] },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let shell: ShellController | null = null;
|
||||||
|
function Capture() {
|
||||||
|
shell = useShellController();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<ShellControllerProvider username="ada" onLogout={() => {}}>
|
||||||
|
<Capture />
|
||||||
|
<LayoutHost />
|
||||||
|
</ShellControllerProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByText("fancy layout");
|
||||||
|
act(() => {
|
||||||
|
shell!.tabs.openSingleton("fancy-settings");
|
||||||
|
shell!.tabs.openMultiInstance("notes");
|
||||||
|
});
|
||||||
|
const types = () => shell!.tabs.list.map((tab) => tab.type).sort();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(types()).toEqual(["dashboard", "fancy-settings", "notes"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(() => chooseLayout("plain"));
|
||||||
|
await screen.findByText("plain layout");
|
||||||
|
await waitFor(() => expect(types()).toEqual(["dashboard", "notes"]));
|
||||||
|
resetTabTypes();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
vi.mock("@/main-axios", () => ({
|
||||||
|
getUserInfo: vi.fn(async () => ({ is_admin: false })),
|
||||||
|
getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })),
|
||||||
|
saveUserPreferences: vi.fn(async () => {}),
|
||||||
|
getSSHHosts: vi.fn(async () => []),
|
||||||
|
getSSHFolders: vi.fn(async () => []),
|
||||||
|
getOpenTabs: vi.fn(async () => []),
|
||||||
|
getActiveSessions: vi.fn(async () => []),
|
||||||
|
addOpenTab: vi.fn(async () => {}),
|
||||||
|
deleteOpenTab: vi.fn(async () => {}),
|
||||||
|
patchOpenTab: vi.fn(async () => {}),
|
||||||
|
createSSHHost: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/use-sync-status", () => ({
|
||||||
|
useSyncStatus: () => undefined,
|
||||||
|
useSyncAttentionCount: () => 0,
|
||||||
|
}));
|
||||||
|
const lookApi = vi.hoisted(() => ({
|
||||||
|
getLook: vi.fn(),
|
||||||
|
saveUserLook: vi.fn(async () => {}),
|
||||||
|
saveDefaultLook: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/api/look-api", () => lookApi);
|
||||||
|
vi.mock("@/plugin-host/loader", () => ({
|
||||||
|
retryPluginFrontend: vi.fn(async () => {}),
|
||||||
|
syncPlugins: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false }));
|
||||||
|
|
||||||
|
import type { TabRenderProps } from "@/shell/tab-registry";
|
||||||
|
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
|
||||||
|
import {
|
||||||
|
ShellControllerProvider,
|
||||||
|
useShellController,
|
||||||
|
} from "@/shell/controller/ShellControllerProvider";
|
||||||
|
import type { ShellController } from "@/shell/controller/types";
|
||||||
|
import { TabPortals } from "@/shell/stage/TabPortals";
|
||||||
|
import { TabStage } from "@/shell/stage/TabStage";
|
||||||
|
import { LayoutHost } from "@/shell/layout/LayoutHost";
|
||||||
|
import { registerLayout, resetRegisteredLayouts } from "@/look/layout-registry";
|
||||||
|
import { chooseLayout, loadLook, resetLook } from "@/look/look-store";
|
||||||
|
import {
|
||||||
|
markPluginsSettled,
|
||||||
|
resetPluginStore,
|
||||||
|
setPluginSummaries,
|
||||||
|
} from "@/plugin-host/plugin-store";
|
||||||
|
|
||||||
|
const mounts = { count: 0 };
|
||||||
|
|
||||||
|
/** Stands in for a terminal: a session id and scrollback that a remount would lose. */
|
||||||
|
function FakeTerminal({ tab }: TabRenderProps) {
|
||||||
|
const session = useRef(`session-${Math.random().toString(36).slice(2)}`);
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
mounts.count += 1;
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div data-testid={`term-${tab.id}`} data-session={session.current}>
|
||||||
|
<button type="button" onClick={() => setLines((l) => [...l, "line"])}>
|
||||||
|
write {tab.id}
|
||||||
|
</button>
|
||||||
|
<pre>{lines.join("\n")}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shell: ShellController | null = null;
|
||||||
|
function Capture() {
|
||||||
|
shell = useShellController();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two layouts that put the stage in very different places.
|
||||||
|
function RailLayout() {
|
||||||
|
return (
|
||||||
|
<div data-testid="rail-layout" className="flex">
|
||||||
|
<nav>rail</nav>
|
||||||
|
<main>
|
||||||
|
<TabStage />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function TopBarLayout() {
|
||||||
|
return (
|
||||||
|
<div data-testid="topbar-layout">
|
||||||
|
<header>bar</header>
|
||||||
|
<section>
|
||||||
|
<div>
|
||||||
|
<TabStage />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ShellControllerProvider username="ada" onLogout={() => {}}>
|
||||||
|
<Capture />
|
||||||
|
<LayoutHost />
|
||||||
|
<TabPortals />
|
||||||
|
</ShellControllerProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mounts.count = 0;
|
||||||
|
shell = null;
|
||||||
|
resetLook();
|
||||||
|
resetPluginStore();
|
||||||
|
resetRegisteredLayouts();
|
||||||
|
registerTabType({
|
||||||
|
id: "fake-terminal",
|
||||||
|
component: FakeTerminal,
|
||||||
|
hostless: true,
|
||||||
|
multiInstance: true,
|
||||||
|
});
|
||||||
|
registerLayout({ id: "rail", component: RailLayout });
|
||||||
|
registerLayout({ id: "topbar", component: TopBarLayout });
|
||||||
|
lookApi.getLook.mockResolvedValue({
|
||||||
|
layoutId: "rail",
|
||||||
|
themeId: null,
|
||||||
|
defaults: { layout: null, theme: null },
|
||||||
|
defaultLook: { layout: null, theme: null },
|
||||||
|
safeLayout: false,
|
||||||
|
unavailable: [],
|
||||||
|
});
|
||||||
|
await loadLook();
|
||||||
|
setPluginSummaries([]);
|
||||||
|
markPluginsSettled();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
resetTabTypes();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TabPortals", () => {
|
||||||
|
it("keeps three sessions and their scrollback through a split and a layout switch", async () => {
|
||||||
|
render(<App />);
|
||||||
|
await screen.findByTestId("rail-layout");
|
||||||
|
await waitFor(() => expect(shell).not.toBeNull());
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
act(() => {
|
||||||
|
ids.push(shell!.tabs.openMultiInstance("fake-terminal"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const terms = await Promise.all(
|
||||||
|
ids.map((id) => screen.findByTestId(`term-${id}`)),
|
||||||
|
);
|
||||||
|
const sessions = terms.map((el) => el.dataset.session);
|
||||||
|
expect(mounts.count).toBe(3);
|
||||||
|
|
||||||
|
// Some output in every terminal.
|
||||||
|
for (const id of ids) act(() => screen.getByText(`write ${id}`).click());
|
||||||
|
const scrollback = () =>
|
||||||
|
terms.map((el) => el.querySelector("pre")!.textContent);
|
||||||
|
expect(scrollback()).toEqual(["line", "line", "line"]);
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
ids.forEach((id, index) => {
|
||||||
|
const el = screen.getByTestId(`term-${id}`);
|
||||||
|
expect(el).toBe(terms[index]);
|
||||||
|
expect(el.dataset.session).toBe(sessions[index]);
|
||||||
|
});
|
||||||
|
expect(scrollback()).toEqual(["line", "line", "line"]);
|
||||||
|
expect(mounts.count).toBe(3);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The second one into a split, then back out.
|
||||||
|
const moved = ids[1];
|
||||||
|
act(() => {
|
||||||
|
shell!.split.handleAction({ kind: "split", tabId: moved, edge: "right" });
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(terms[1].closest("[data-split-pane-id]")).not.toBeNull(),
|
||||||
|
);
|
||||||
|
check();
|
||||||
|
const splitId = shell!.split.active!.id;
|
||||||
|
act(() => {
|
||||||
|
shell!.split.handleAction({ kind: "unsplit", splitTabId: splitId });
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(terms[1].closest("[data-split-pane-id]")).toBeNull(),
|
||||||
|
);
|
||||||
|
check();
|
||||||
|
|
||||||
|
// Switch layouts and back.
|
||||||
|
await act(() => chooseLayout("topbar"));
|
||||||
|
const topbar = await screen.findByTestId("topbar-layout");
|
||||||
|
await waitFor(() =>
|
||||||
|
terms.forEach((el) => expect(topbar.contains(el)).toBe(true)),
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("rail-layout")).toBeNull();
|
||||||
|
check();
|
||||||
|
|
||||||
|
await act(() => chooseLayout("rail"));
|
||||||
|
const rail = await screen.findByTestId("rail-layout");
|
||||||
|
await waitFor(() =>
|
||||||
|
terms.forEach((el) => expect(rail.contains(el)).toBe(true)),
|
||||||
|
);
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,10 @@ describe("renderTabContent", () => {
|
|||||||
expect(screen.getByText("tab")).toBeInTheDocument();
|
expect(screen.getByText("tab")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("panel")).toBeNull();
|
expect(screen.queryByText("panel")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("draws nothing for the home tab when no layout gave it a type", () => {
|
||||||
|
expect(renderTabContent(tabOf("dashboard"), { shell })).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("promotableIds", () => {
|
describe("promotableIds", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user