343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
const storage = new Map<string, string>();
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => storage.set(key, value),
|
|
removeItem: (key: string) => storage.delete(key),
|
|
},
|
|
});
|
|
Object.defineProperty(globalThis, "requestAnimationFrame", {
|
|
configurable: true,
|
|
value: (callback: (time: number) => void) => setTimeout(() => callback(Date.now()), 0) as unknown as number,
|
|
});
|
|
|
|
const {
|
|
computeHostTreeTabGutter,
|
|
resolveWorkspaceSessionTabDropTarget,
|
|
shouldKeepHostTreeToggleSurface,
|
|
shouldShowHostTreeToggle,
|
|
} = await import("./TopTabs.tsx");
|
|
const {
|
|
WORKSPACE_SESSION_DRAG_TYPE,
|
|
dataTransferHasType,
|
|
getTopTabInsertionTarget,
|
|
getWorkspaceSessionDragId,
|
|
hasWorkspaceSessionDrag,
|
|
isPointInsideRect,
|
|
} = await import("../application/state/terminalDragData.ts");
|
|
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
|
|
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
|
|
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
|
|
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
|
|
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
|
|
|
|
test("host tree tab gutter fills the remaining sidebar width", () => {
|
|
assert.equal(computeHostTreeTabGutter(280, 120), 160);
|
|
});
|
|
|
|
test("host tree tab gutter never goes negative", () => {
|
|
assert.equal(computeHostTreeTabGutter(120, 280), 0);
|
|
});
|
|
|
|
test("host tree tab surface stays mounted when root pages are active", () => {
|
|
assert.equal(shouldKeepHostTreeToggleSurface({
|
|
enabled: true,
|
|
activeWorkTabCount: 2,
|
|
}), true);
|
|
});
|
|
|
|
test("host tree tab surface is hidden without work tabs", () => {
|
|
assert.equal(shouldKeepHostTreeToggleSurface({
|
|
enabled: true,
|
|
activeWorkTabCount: 0,
|
|
}), false);
|
|
});
|
|
|
|
test("host tree tab layout transitions match the sidebar timing", () => {
|
|
const hostTreeCss = [
|
|
".top-tab-root-label",
|
|
".top-tab-host-tree-toggle-slot",
|
|
].map((selector) => {
|
|
const start = indexCss.indexOf(selector);
|
|
assert.notEqual(start, -1);
|
|
const end = indexCss.indexOf("}", start);
|
|
return indexCss.slice(start, end);
|
|
}).join("\n");
|
|
const gutterStart = indexCss.indexOf(".top-tab-host-tree-gutter");
|
|
assert.notEqual(gutterStart, -1);
|
|
const gutterEnd = indexCss.indexOf("}", gutterStart);
|
|
const gutterCss = indexCss.slice(gutterStart, gutterEnd);
|
|
|
|
assert.match(hostTreeCss, /width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
|
|
assert.match(hostTreeCss, /max-width 220ms cubic-bezier\(0\.4, 0, 0\.2, 1\)/);
|
|
assert.doesNotMatch(hostTreeCss, /transition:\s*none/);
|
|
assert.doesNotMatch(hostTreeCss, /280ms/);
|
|
assert.doesNotMatch(gutterCss, /transition/);
|
|
assert.match(indexCss, /\.top-tab-host-tree-gutter-exit[\s\S]*transition: width 220ms/);
|
|
});
|
|
|
|
test("host tree toggle appears with opacity only and no bounce animation", () => {
|
|
assert.doesNotMatch(indexCss, /top-tab-host-tree-toggle-pop/);
|
|
assert.doesNotMatch(indexCss, /@keyframes\s+pop-in/);
|
|
|
|
const start = indexCss.indexOf(".top-tab-host-tree-toggle-slot");
|
|
assert.notEqual(start, -1);
|
|
const end = indexCss.indexOf("}", start);
|
|
const toggleSlotCss = indexCss.slice(start, end);
|
|
|
|
assert.match(toggleSlotCss, /opacity 220ms ease/);
|
|
assert.doesNotMatch(toggleSlotCss, /transform/);
|
|
assert.doesNotMatch(toggleSlotCss, /scale/);
|
|
});
|
|
|
|
test("host tree toggle exposes a custom CSS hook", () => {
|
|
assert.match(topTabsSource, /data-section="top-tabs-host-tree-toggle"/);
|
|
});
|
|
|
|
test("quick switcher plus button exposes a custom CSS hook", () => {
|
|
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
|
|
});
|
|
|
|
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
|
|
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
|
assert.match(source, /resolveHostIconAppearance\(host\)/);
|
|
assert.ok(
|
|
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
|
|
"custom host icon should be checked before distro fallback",
|
|
);
|
|
});
|
|
|
|
test("workspace session drag data is recognized with a dedicated drag type", () => {
|
|
const data = new Map([
|
|
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
|
|
["session-id", "fallback-session"],
|
|
]);
|
|
const transfer = {
|
|
types: [WORKSPACE_SESSION_DRAG_TYPE, "text/plain"],
|
|
getData: (format: string) => data.get(format) ?? "",
|
|
};
|
|
|
|
assert.equal(hasWorkspaceSessionDrag(transfer), true);
|
|
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
|
|
});
|
|
|
|
test("workspace session drag id falls back to the legacy session id", () => {
|
|
const transfer = {
|
|
types: ["session-id"],
|
|
getData: (format: string) => (format === "session-id" ? "session-2" : ""),
|
|
};
|
|
|
|
assert.equal(dataTransferHasType(transfer, "session-id"), true);
|
|
assert.equal(hasWorkspaceSessionDrag(transfer), false);
|
|
assert.equal(getWorkspaceSessionDragId(transfer), "session-2");
|
|
});
|
|
|
|
test("point-in-rect detects pointer release inside the top tab bar", () => {
|
|
const rect = { left: 10, right: 110, top: 20, bottom: 60 };
|
|
|
|
assert.equal(isPointInsideRect({ clientX: 10, clientY: 20 }, rect), true);
|
|
assert.equal(isPointInsideRect({ clientX: 70, clientY: 40 }, rect), true);
|
|
assert.equal(isPointInsideRect({ clientX: 111, clientY: 40 }, rect), false);
|
|
assert.equal(isPointInsideRect({ clientX: 70, clientY: 61 }, rect), false);
|
|
});
|
|
|
|
test("top tab insertion target ignores fixed root tabs", () => {
|
|
const makeTab = (id: string, type: string, left: number, right: number) => ({
|
|
dataset: { tabId: id, tabType: type },
|
|
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
|
|
});
|
|
const root = {
|
|
getBoundingClientRect: () => ({ left: 0, right: 400, top: 0, bottom: 80, width: 400, height: 80 }),
|
|
querySelectorAll: () => [
|
|
makeTab("vault", "root", 0, 80),
|
|
makeTab("workspace-1", "workspace", 90, 210),
|
|
makeTab("session-1", "session", 210, 330),
|
|
],
|
|
} as unknown as HTMLElement;
|
|
|
|
assert.deepEqual(getTopTabInsertionTarget({ clientX: 20, clientY: 40 }, root), {
|
|
tabId: "workspace-1",
|
|
position: "before",
|
|
});
|
|
assert.deepEqual(getTopTabInsertionTarget({ clientX: 180, clientY: 40 }, root), {
|
|
tabId: "workspace-1",
|
|
position: "after",
|
|
});
|
|
assert.deepEqual(getTopTabInsertionTarget({ clientX: 380, clientY: 40 }, root), {
|
|
tabId: "session-1",
|
|
position: "after",
|
|
});
|
|
assert.equal(getTopTabInsertionTarget({ clientX: 180, clientY: 120 }, root), null);
|
|
});
|
|
|
|
test("workspace session tab drop forwards the requested insertion target", () => {
|
|
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
|
targetTabId: "session-3",
|
|
position: "after",
|
|
draggedSessionId: "session-1",
|
|
draggedWorkspaceId: "workspace-1",
|
|
workspaces: [],
|
|
}), {
|
|
tabId: "session-3",
|
|
position: "after",
|
|
additionalTabIds: ["session-1", "session-3"],
|
|
});
|
|
});
|
|
|
|
test("workspace session tab drop targets the remaining terminal when its workspace dissolves", () => {
|
|
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
|
targetTabId: "workspace-1",
|
|
position: "before",
|
|
draggedSessionId: "session-1",
|
|
draggedWorkspaceId: "workspace-1",
|
|
workspaces: [{
|
|
id: "workspace-1",
|
|
title: "Workspace",
|
|
focusedSessionId: "session-1",
|
|
root: {
|
|
id: "split-1",
|
|
type: "split",
|
|
direction: "horizontal",
|
|
children: [
|
|
{ id: "pane-1", type: "pane", sessionId: "session-1" },
|
|
{ id: "pane-2", type: "pane", sessionId: "session-2" },
|
|
],
|
|
sizes: [1, 1],
|
|
},
|
|
}],
|
|
}), {
|
|
tabId: "session-2",
|
|
position: "before",
|
|
additionalTabIds: ["session-1", "session-2"],
|
|
});
|
|
});
|
|
|
|
test("workspace session tab-bar blank drop inserts after the last work tab", () => {
|
|
const makeTab = (id: string, type: string, left: number, right: number) => ({
|
|
dataset: { tabId: id, tabType: type },
|
|
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
|
|
});
|
|
const root = {
|
|
getBoundingClientRect: () => ({ left: 0, right: 500, top: 0, bottom: 80, width: 500, height: 80 }),
|
|
querySelectorAll: () => [
|
|
makeTab("vault", "root", 0, 80),
|
|
makeTab("workspace-1", "workspace", 90, 210),
|
|
makeTab("session-3", "session", 210, 330),
|
|
],
|
|
} as unknown as HTMLElement;
|
|
const insertionTarget = getTopTabInsertionTarget({ clientX: 460, clientY: 40 }, root);
|
|
|
|
assert.deepEqual(insertionTarget, { tabId: "session-3", position: "after" });
|
|
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
|
|
targetTabId: insertionTarget!.tabId,
|
|
position: insertionTarget!.position,
|
|
draggedSessionId: "session-1",
|
|
draggedWorkspaceId: "workspace-1",
|
|
workspaces: [],
|
|
}), {
|
|
tabId: "session-3",
|
|
position: "after",
|
|
additionalTabIds: ["session-1", "session-3"],
|
|
});
|
|
});
|
|
|
|
test("terminal top bar hides server stats before they crowd the host title", () => {
|
|
assert.match(indexCss, /\.terminal-topbar\s*\{[\s\S]*container-type: inline-size/);
|
|
assert.match(indexCss, /@container \(max-width: 760px\) \{[\s\S]*\.terminal-server-stats\s*\{[\s\S]*display: none/);
|
|
assert.match(terminalViewSource, /terminal-topbar/);
|
|
assert.match(terminalViewSource, /terminal-title-cluster/);
|
|
assert.match(terminalViewSource, /onPointerDown=\{onDetachPointerDown\}/);
|
|
});
|
|
|
|
test("workspace session drag no longer uses a full tab-bar drop zone", () => {
|
|
assert.doesNotMatch(topTabsSource, /top-tabs-workspace-detach-drop-zone/);
|
|
});
|
|
|
|
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
|
|
assert.match(topTabsSource, /hostTreeChromeReady/);
|
|
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);
|
|
assert.match(topTabsSource, /scheduleChromeLayoutAnimation\(\(\) => \{\s*cancelRootTabsCompactRef\.current = null;\s*setRootTabsCompact\(true\);/);
|
|
assert.match(topTabsSource, /compact=\{rootTabsCompact\}/);
|
|
assert.match(topTabsSource, /data-visible=\{effectiveShowHostTreeToggle \? 'true' : 'false'\}/);
|
|
});
|
|
|
|
test("host tree chrome exits before root labels expand back on vault", () => {
|
|
assert.match(topTabsSource, /cancelChromeExitRef/);
|
|
assert.match(topTabsSource, /hostTreeGutterExiting/);
|
|
assert.match(topTabsSource, /setRootTabsCompact\(false\)/);
|
|
assert.match(topTabsSource, /top-tab-host-tree-gutter-exit/);
|
|
assert.match(topTabsSource, /effectiveShowHostTreeToggle = hostTreeChromeReady/);
|
|
});
|
|
|
|
test("host tree toggle is shown for an active editor tab", () => {
|
|
assert.equal(shouldShowHostTreeToggle({
|
|
enabled: true,
|
|
activeTabId: "editor:file-1",
|
|
orderedTabs: ["session-1", "editor:file-1"],
|
|
sessionIds: new Set(["session-1"]),
|
|
workspaceIds: new Set(),
|
|
}), true);
|
|
});
|
|
|
|
test("host tree toggle is shown for log tabs", () => {
|
|
assert.equal(shouldShowHostTreeToggle({
|
|
enabled: true,
|
|
activeTabId: "log-1",
|
|
logViewIds: new Set(["log-1"]),
|
|
orderedTabs: ["session-1", "log-1"],
|
|
sessionIds: new Set(["session-1"]),
|
|
workspaceIds: new Set(),
|
|
}), true);
|
|
});
|
|
|
|
test("host tree toggle is shown for log tabs before tab ordering catches up", () => {
|
|
assert.equal(shouldShowHostTreeToggle({
|
|
enabled: true,
|
|
activeTabId: "log-1",
|
|
logViewIds: new Set(["log-1"]),
|
|
orderedTabs: [],
|
|
sessionIds: new Set(),
|
|
workspaceIds: new Set(),
|
|
}), true);
|
|
});
|
|
|
|
test("clicking a log tab activates the shared work-tab surface", () => {
|
|
activeTabStore.setActiveTabId("vault");
|
|
|
|
activateLogViewTab("log-1");
|
|
|
|
assert.equal(activeTabStore.getActiveTabId(), "log-1");
|
|
});
|
|
|
|
test("host tree toggle is hidden when host sidebar is disabled", () => {
|
|
assert.equal(shouldShowHostTreeToggle({
|
|
enabled: false,
|
|
activeTabId: "session-1",
|
|
orderedTabs: ["session-1"],
|
|
sessionIds: new Set(["session-1"]),
|
|
workspaceIds: new Set(),
|
|
}), false);
|
|
});
|
|
|
|
test("host tree toggle is hidden on root pages", () => {
|
|
assert.equal(shouldShowHostTreeToggle({
|
|
enabled: true,
|
|
activeTabId: "vault",
|
|
orderedTabs: ["session-1", "editor:file-1"],
|
|
sessionIds: new Set(["session-1"]),
|
|
workspaceIds: new Set(),
|
|
}), false);
|
|
assert.equal(shouldShowHostTreeToggle({
|
|
enabled: true,
|
|
activeTabId: "sftp",
|
|
orderedTabs: ["session-1", "editor:file-1"],
|
|
sessionIds: new Set(["session-1"]),
|
|
workspaceIds: new Set(),
|
|
}), false);
|
|
});
|