Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
637 lines
25 KiB
TypeScript
637 lines
25 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,
|
|
createTopTabCopyDoubleClickHandler,
|
|
formatSessionTopTabLabel,
|
|
formatSessionTopTabTooltip,
|
|
resolveSessionTabCodingCliIconState,
|
|
stopCloseButtonDoubleClickPropagation,
|
|
} = 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 topTabsQuickControlsSource = readFileSync(new URL("./TopTabsQuickControls.tsx", import.meta.url), "utf8");
|
|
const syncStatusButtonSource = readFileSync(new URL("./SyncStatusButton.tsx", import.meta.url), "utf8");
|
|
const switchSource = readFileSync(new URL("./ui/switch.tsx", import.meta.url), "utf8");
|
|
const appViewSource = readFileSync(new URL("../application/app/AppView.tsx", import.meta.url), "utf8");
|
|
const appSource = [
|
|
readFileSync(new URL("../App.tsx", import.meta.url), "utf8"),
|
|
readFileSync(new URL("../application/app/AppSideEffects.tsx", import.meta.url), "utf8"),
|
|
readFileSync(new URL("../application/app/hosts/TerminalHost.tsx", import.meta.url), "utf8"),
|
|
readFileSync(new URL("../application/app/hosts/ChromeHost.tsx", import.meta.url), "utf8"),
|
|
].join("\n");
|
|
const externalMcpToggleSource = readFileSync(new URL("../application/state/useExternalMcpToggleState.ts", import.meta.url), "utf8");
|
|
const zhTwAiSource = readFileSync(new URL("../application/i18n/locales/zh-TW/ai.ts", import.meta.url), "utf8");
|
|
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.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("top tabs keep cloud sync separate from quick controls", () => {
|
|
assert.match(topTabsSource, /SyncStatusButton/);
|
|
assert.match(topTabsSource, /TopTabsQuickControls/);
|
|
assert.match(topTabsSource, /onOpenSettings=\{onOpenSettings\}/);
|
|
assert.match(topTabsSource, /onSyncNow=\{onSyncNow\}/);
|
|
assert.match(topTabsSource, /theme=\{theme\}/);
|
|
assert.match(topTabsSource, /themePreference=\{themePreference\}/);
|
|
assert.match(topTabsSource, /onThemeChange=\{onThemeChange\}/);
|
|
assert.match(topTabsSource, /externalMcpEnabled=\{externalMcpEnabled\}/);
|
|
assert.match(topTabsSource, /windowOpacity=\{windowOpacity\}/);
|
|
assert.match(topTabsSource, /showExternalMcpToggle=\{showExternalMcpToggle\}/);
|
|
assert.doesNotMatch(topTabsSource, /WindowOpacityButton/);
|
|
assert.doesNotMatch(topTabsSource, /onToggleTheme/);
|
|
const syncButtonUsage = topTabsSource.match(/<SyncStatusButton[\s\S]*?\/>/);
|
|
assert.ok(syncButtonUsage, 'expected a SyncStatusButton usage');
|
|
assert.doesNotMatch(syncButtonUsage[0], /externalMcpEnabled=/);
|
|
assert.doesNotMatch(syncButtonUsage[0], /windowOpacity=/);
|
|
assert.doesNotMatch(syncButtonUsage[0], /onThemeChange=/);
|
|
const quickControlsUsage = topTabsSource.match(/<TopTabsQuickControls[\s\S]*?\/>/);
|
|
assert.ok(quickControlsUsage, 'expected a TopTabsQuickControls usage');
|
|
assert.match(quickControlsUsage[0], /showExternalMcpToggle=\{showExternalMcpToggle\}/);
|
|
assert.match(quickControlsUsage[0], /externalMcpEnabled=\{externalMcpEnabled\}/);
|
|
});
|
|
|
|
test("quick controls panel hosts External MCP, opacity, and theme", () => {
|
|
assert.match(topTabsQuickControlsSource, /data-section="top-tabs-quick-controls"/);
|
|
assert.match(topTabsQuickControlsSource, /className="w-72 p-0 app-no-drag"/);
|
|
assert.match(topTabsQuickControlsSource, /topTabs\.controlPanel/);
|
|
assert.doesNotMatch(topTabsQuickControlsSource, /topTabs\.controlPanel\.description/);
|
|
assert.match(topTabsQuickControlsSource, /externalMcpEnabled/);
|
|
assert.match(topTabsQuickControlsSource, /onToggleExternalMcp/);
|
|
assert.match(topTabsQuickControlsSource, /windowOpacity/);
|
|
assert.match(topTabsQuickControlsSource, /setWindowOpacity/);
|
|
assert.match(topTabsQuickControlsSource, /themePreference/);
|
|
assert.match(topTabsQuickControlsSource, /onThemeChange/);
|
|
assert.match(topTabsQuickControlsSource, /topTabs\.controlPanel\.theme\.system/);
|
|
assert.match(topTabsQuickControlsSource, /aria-pressed=\{themePreference === option\.value\}/);
|
|
assert.match(topTabsQuickControlsSource, /onClick=\{\(\) => onThemeChange\(option\.value\)\}/);
|
|
assert.match(topTabsQuickControlsSource, /<Plug size=\{14\}/);
|
|
assert.match(topTabsQuickControlsSource, /OPACITY_PRESETS/);
|
|
assert.match(topTabsQuickControlsSource, /isOpacityExpanded/);
|
|
assert.match(topTabsQuickControlsSource, /isPresetActive/);
|
|
assert.match(topTabsQuickControlsSource, /type="range"/);
|
|
assert.match(topTabsQuickControlsSource, /aria-labelledby=\{externalMcpLabelId\}/);
|
|
assert.doesNotMatch(topTabsQuickControlsSource, /onOpenAutoFocus/);
|
|
assert.match(topTabsQuickControlsSource, /mt-1 border-t border-border\/60 pt-1/);
|
|
assert.doesNotMatch(topTabsQuickControlsSource, /useCloudSync/);
|
|
assert.doesNotMatch(topTabsQuickControlsSource, /border border-border\/60 bg-background\/70/);
|
|
assert.doesNotMatch(syncStatusButtonSource, /topTabs\.controlPanel/);
|
|
assert.doesNotMatch(syncStatusButtonSource, /externalMcpEnabled/);
|
|
assert.match(switchSource, /aria-label=\{ariaLabel\}/);
|
|
assert.match(switchSource, /role="switch"/);
|
|
});
|
|
|
|
test("External MCP top bar labels exist in Traditional Chinese", () => {
|
|
assert.match(zhTwAiSource, /'topTabs\.externalMcp\.enable':/);
|
|
assert.match(zhTwAiSource, /'topTabs\.externalMcp\.disable':/);
|
|
assert.match(zhTwAiSource, /'topTabs\.controlPanel':/);
|
|
assert.match(zhTwAiSource, /'topTabs\.controlPanel\.externalMcp':/);
|
|
});
|
|
|
|
test("AppView hides External MCP toggle in peer session windows", () => {
|
|
assert.match(appViewSource, /hash\.startsWith\('#\/session-window'\)/);
|
|
assert.match(appViewSource, /showExternalMcpToggle=\{!isPeerSessionWindow\}/);
|
|
});
|
|
|
|
test("External MCP top-bar status sync waits for App startup reconcile", () => {
|
|
assert.match(appSource, /await syncExternalMcpStartupStateOnce\(netcattyBridge\.get\(\)\)/);
|
|
assert.match(appSource, /markExternalMcpStartupReady\(\)/);
|
|
assert.ok(
|
|
appSource.indexOf("await syncExternalMcpStartupStateOnce(netcattyBridge.get())")
|
|
< appSource.indexOf("markExternalMcpStartupReady()"),
|
|
"startup ready must be marked only after await syncExternalMcpStartupStateOnce",
|
|
);
|
|
assert.match(externalMcpToggleSource, /shouldWaitForExternalMcpStartupReady/);
|
|
assert.match(externalMcpToggleSource, /waitForExternalMcpStartupReady\(/);
|
|
assert.match(externalMcpToggleSource, /!status\.enabled && !status\.error/);
|
|
assert.match(externalMcpToggleSource, /if \(isPeerSessionWindow \|\| !enabled\) return;/);
|
|
});
|
|
|
|
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
|
|
assert.match(topTabItemsSource, /resolveHostIconAppearance\(host\)/);
|
|
assert.ok(
|
|
topTabItemsSource.indexOf("resolveHostIconAppearance(host)") < topTabItemsSource.indexOf("getEffectiveHostDistro(host)"),
|
|
"custom host icon should be checked before distro fallback",
|
|
);
|
|
});
|
|
|
|
test("disabling dynamic titles freezes a stored coding CLI icon and stops title fallback", () => {
|
|
assert.deepEqual(
|
|
resolveSessionTabCodingCliIconState({
|
|
codingCliProviderId: "claude",
|
|
dynamicTitle: "⠋ Reading files",
|
|
}, undefined, "off"),
|
|
{
|
|
provider: {
|
|
id: "claude",
|
|
label: "Claude Code",
|
|
command: "claude",
|
|
titleHints: ["claude code", "claude"],
|
|
iconKey: "claude",
|
|
},
|
|
activityPhase: "idle",
|
|
},
|
|
);
|
|
assert.equal(
|
|
resolveSessionTabCodingCliIconState({
|
|
dynamicTitle: "OpenAI Codex",
|
|
}, undefined, "off"),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
resolveSessionTabCodingCliIconState({
|
|
startupCommand: "claude",
|
|
}, undefined, "off")?.provider.id,
|
|
"claude",
|
|
);
|
|
assert.equal(
|
|
resolveSessionTabCodingCliIconState({
|
|
dynamicTitle: "OpenAI Codex",
|
|
}, undefined, "agent")?.provider.id,
|
|
"codex",
|
|
);
|
|
});
|
|
|
|
test("session top tabs copy the session on double click through the existing copy handler", () => {
|
|
const copiedSessionIds: string[] = [];
|
|
const handleDoubleClick = createTopTabCopyDoubleClickHandler(
|
|
(sessionId) => copiedSessionIds.push(sessionId),
|
|
"session-1",
|
|
);
|
|
|
|
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
|
|
|
|
assert.deepEqual(copiedSessionIds, ["session-1"]);
|
|
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
|
|
});
|
|
|
|
test("session close button double click stays on the close button", () => {
|
|
let stopPropagationCount = 0;
|
|
|
|
stopCloseButtonDoubleClickPropagation({
|
|
stopPropagation: () => {
|
|
stopPropagationCount += 1;
|
|
},
|
|
});
|
|
|
|
assert.equal(stopPropagationCount, 1);
|
|
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
|
|
});
|
|
|
|
test("session top tab label only shows the host label for ssh sessions", () => {
|
|
assert.equal(
|
|
formatSessionTopTabLabel({
|
|
hostLabel: "prod-web",
|
|
hostname: "10.1.2.34",
|
|
protocol: "ssh",
|
|
}),
|
|
"prod-web",
|
|
);
|
|
});
|
|
|
|
test("session top tab label treats missing protocol as ssh", () => {
|
|
assert.equal(
|
|
formatSessionTopTabLabel({
|
|
hostLabel: "prod-web",
|
|
hostname: "10.1.2.34",
|
|
}),
|
|
"prod-web",
|
|
);
|
|
assert.equal(
|
|
formatSessionTopTabTooltip({
|
|
username: "root",
|
|
hostname: "10.1.2.34",
|
|
port: 22,
|
|
}),
|
|
"root@10.1.2.34:22",
|
|
);
|
|
});
|
|
|
|
test("session top tab label avoids duplicating the target address", () => {
|
|
assert.equal(
|
|
formatSessionTopTabLabel({
|
|
hostLabel: "10.1.2.34",
|
|
hostname: "10.1.2.34",
|
|
protocol: "ssh",
|
|
}),
|
|
"10.1.2.34",
|
|
);
|
|
});
|
|
|
|
test("session top tab tooltip includes username and port when present", () => {
|
|
assert.equal(
|
|
formatSessionTopTabTooltip({
|
|
username: "root",
|
|
hostname: "db.internal",
|
|
port: 2222,
|
|
protocol: "ssh",
|
|
}),
|
|
"root@db.internal:2222",
|
|
);
|
|
});
|
|
|
|
test("session top tab helpers ignore local and mosh sessions", () => {
|
|
assert.equal(
|
|
formatSessionTopTabLabel({
|
|
hostLabel: "Local shell",
|
|
hostname: "127.0.0.1",
|
|
protocol: "local",
|
|
}),
|
|
"Local shell",
|
|
);
|
|
assert.equal(
|
|
formatSessionTopTabTooltip({
|
|
username: "root",
|
|
hostname: "10.1.2.34",
|
|
port: 22,
|
|
protocol: "ssh",
|
|
moshEnabled: true,
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
|
|
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("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
|
|
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
|
|
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
|
|
assert.match(topTabsSource, /dropEffect = 'copy'/);
|
|
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
|
|
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test("TopTabs applies presentation per session tab, not via global version fan-out", () => {
|
|
// Global presentationVersion remapping was removed to stop sibling title
|
|
// thrash from re-rendering the whole tab bar.
|
|
assert.doesNotMatch(topTabsSource, /presentationVersion/);
|
|
assert.doesNotMatch(topTabsSource, /sessionPresentationStore\.subscribe/);
|
|
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
|
|
assert.match(topTabItemsSource, /usePresentedSession/);
|
|
// Workspace detach menu must also apply presentation per session so labels
|
|
// stay live without remapping TopTabsInner.
|
|
assert.match(topTabItemsSource, /WorkspaceDetachSessionMenuItem/);
|
|
assert.match(topTabsSource, /workspaceSessions=/);
|
|
assert.doesNotMatch(topTabsSource, /workspaceSessionLabels/);
|
|
});
|
|
|
|
test("tab-switch focus calls refocus primitive directly without outer rAF wrap", () => {
|
|
const effectsSource = readFileSync(
|
|
new URL("./terminalLayer/useTerminalLayerEffects.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
const focusBlock = effectsSource.slice(
|
|
effectsSource.indexOf("Restore keyboard focus after switching work tabs"),
|
|
effectsSource.indexOf("When focusedSessionId changes"),
|
|
);
|
|
assert.match(focusBlock, /refocusActiveTerminalSession\(\)/);
|
|
// Outer rAF wrapper was removed — focusTerminalSessionInput already schedules.
|
|
assert.doesNotMatch(focusBlock, /requestAnimationFrame\(\(\) => \{\s*refocusActiveTerminalSession/);
|
|
});
|
|
|
|
test("topTabsAreEqual tracks editorTabs for dirty chrome", () => {
|
|
assert.match(topTabsSource, /prev\.editorTabs === next\.editorTabs/);
|
|
assert.match(topTabsSource, /prev\.onRequestCloseEditorTab === next\.onRequestCloseEditorTab/);
|
|
// Dirty dots come from useEditorTabDirty; structure list is presence-only chrome.
|
|
assert.match(
|
|
readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8"),
|
|
/useEditorTabDirty/,
|
|
);
|
|
});
|
|
|
|
test("App shell uses editor presence chrome list not full content tabs", () => {
|
|
assert.match(appSource, /useEditorTabChromeList/);
|
|
assert.doesNotMatch(appSource, /useEditorTabs\(\)/);
|
|
});
|
|
|
|
test("App shell retains presentation-stable sessions for domain memos", () => {
|
|
assert.match(appSource, /retainStableSessionsIgnoringPresentation/);
|
|
assert.match(appSource, /sessionsForShell/);
|
|
assert.match(appSource, /orphanSessionsForShell/);
|
|
assert.match(appSource, /sessions: sessionsForShell/);
|
|
assert.match(appSource, /orphanSessions: orphanSessionsForShell/);
|
|
});
|