Files
Netcatty/components/HostTreeView.test.tsx
T
陈大猫 8cdc887383 [Feature] Optimize large Vault imports and group management (#2540)
* feat(vault): optimize large imports and vault rendering (#2526)

* fix(vault): address import review findings

* fix(vault): preserve filtered selection and import consistency

* fix(vault): complete import review fixes

* fix(vault): make import cancellation concurrency-safe

* fix(vault): preserve concurrent edits during import rollback

* fix(vault): handle concurrent saves and reset import target

* fix(vault): drop empty source groups after targeted import

* fix(vault): preserve sidebar accessibility check

* fix(vault): correct group counts and selection semantics

* fix(vault): preserve imports across concurrent saves

* fix(vault): preserve concurrent group changes during import

* fix(vault): close concurrent import edge cases

* fix(vault): retry rollback and enable tree keyboard selection

* fix(vault): retain concurrent sources and visible selections

* fix(vault): merge imported sources with persisted state

* fix(vault): serialize duplicate managed imports

* fix(vault): keep imports non-blocking and grouped views responsive

* fix(vault): preserve concurrent managed sessions

* fix(vault): preserve concurrent groups and keyboard access

* fix(vault): serialize cross-window import commits

* fix(vault): make import commits atomic

* fix(vault): surface import rollback failures

* fix(vault): recover import transactions safely

* fix(vault): commit imported hosts atomically

* fix(vault): preserve unfinished import recovery

* fix(vault): wait for edits and preserve keyboard focus

* fix(vault): protect group settings and lock fallback

* fix(vault): keep virtual focus aligned

* fix(vault): complete keyboard navigation semantics

* fix(vault): finish keyboard navigation

* fix(vault): close large-vault concurrency gaps

* fix(vault): finish large-vault interaction safety

* fix(vault): harden filtered large-vault flows

* fix(vault): keep managed deletion atomic

* fix(vault): coordinate managed file deletion

* fix(vault): close cross-window managed races

* fix(vault): refresh persisted managed state

* fix(vault): preserve distinct imported sessions

* fix(vault): keep failed progress accurate

* fix(vault): keep search tree collapsible

* fix(vault): close final review gaps

* fix(vault): reveal filtered tree matches

* fix(vault): close final review edge cases

* fix(vault): preserve concurrent import changes

* fix(vault): serialize all vault saves under one cross-window lock

Hold the shared import lock for ordinary host/group/key writes and the
full import commit path so check-then-write can no longer interleave
across windows. Make the lock re-entrant in-window to avoid nested
deadlocks, and announce large tree item positions for screen readers.

* fix(vault): keep concurrent saves serialized and fix tree ARIA siblings

Stop treating same-window concurrent work as nested lock re-entry; nested
helpers skip re-acquire only when the outer critical section already owns
the lock. Announce treeitem position among true siblings at each level.

* fix(vault): require explicit lock handle for nested saves

Independent concurrent vault writes always queue on the shared lock.
Nested import/deletion helpers continue only when they pass the active
lock handle from their outer critical section.

* fix(vault): avoid lock deadlocks with encrypt-then-write saves

Encrypt outside the shared lock and only acquire it for the final write so
importers waiting on pending work cannot deadlock on lock acquisition.
Retry plugin commits outside the lock when concurrent edits intervene, and
rebase imports onto in-memory host edits when disk is still stale.

* fix(vault): release import lock before concurrent-edit retries

Drain encrypt work under the lock and full disk writes only outside it.
When an import or group deletion is superseded by a concurrent edit, release
the shared lock so that edit can finish, then retry. Functional group updates
now base on the latest in-memory snapshot while a write is still pending.

* fix(vault): tidy import dialog destination and SecureCRT prompts

Move import destination settings behind a bottom-left control with its own
step, and keep SecureCRT guidance in a dedicated chooser instead of inside
the format tile.

* test(vault): fix import dialog layout guard test under ESM

* fix(vault): simplify import destination footer control

Replace the heavy two-line destination card with a single quiet text link
that shows the current placement choice.

* fix(vault): simplify import destination settings page

Drop the nested card and duplicate headings. Show only the three placement
choices, finish immediately for preserve, and keep Done only when a group
name is needed.

* fix(vault): simplify SecureCRT chooser and iconify destination options

Remove redundant SecureCRT prompt copy while keeping the two source cards
with icons. Add small folder icons to the import-location choices so that
page is less plain.

* fix(vault): use SecureCRT logo in import chooser header

* fix(vault): drop border around SecureCRT import logo

* fix(vault): clarify import location footer with icon and label

Show a folder icon plus "Import location · current choice" so the control
is recognizable without becoming a heavy card again.

* fix(vault): remove border from import progress status icon
2026-07-28 00:54:03 +08:00

481 lines
15 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { GroupConfig, GroupNode, Host } from "../types.ts";
import {
buildVisibleHostTreeItems,
getHostTreeDisplayDetails,
HostTreeView,
useHostTreeExpandedPaths,
} from "./HostTreeView.tsx";
const baseHost: Host = {
id: "host-1",
label: "Router",
hostname: "router.example.com",
username: "ssh-user",
port: 2222,
protocol: "telnet",
tags: [],
os: "linux",
createdAt: 1,
};
const installLocalStorageMock = () => {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
},
});
};
test("HostTreeView display details include inherited telnet defaults", () => {
const host: Host = {
...baseHost,
group: "network",
username: "ssh-user",
port: 2222,
telnetUsername: undefined,
telnetPort: undefined,
};
const groupConfigs: GroupConfig[] = [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPort: 2325,
}];
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
protocol: "telnet",
username: "group-telnet-user",
port: 2325,
});
});
test("HostTreeView display details keep explicit cleared telnet username", () => {
const host: Host = {
...baseHost,
group: "network",
telnetUsername: "",
};
const groupConfigs: GroupConfig[] = [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPort: 2325,
}];
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
protocol: "telnet",
username: "",
port: 2325,
});
});
test("HostTreeView renders the host edit action beside the host label", () => {
installLocalStorageMock();
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[]}
hosts={[{
...baseHost,
notes: "Maintenance notes",
tags: ["edge"],
}]}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
/>,
);
const labelIndex = markup.indexOf("Router");
const editButtonIndex = markup.indexOf('data-host-tree-host-edit-button="host-1"');
const notesIndex = markup.indexOf('aria-label="Host notes"', labelIndex);
const protocolIndex = markup.indexOf("TELNET", labelIndex);
assert.ok(labelIndex >= 0);
assert.ok(editButtonIndex > labelIndex);
assert.ok(notesIndex > editButtonIndex);
assert.ok(protocolIndex > notesIndex);
});
test("HostTreeView renders the group edit action beside the group label", () => {
installLocalStorageMock();
const groupNode: GroupNode = {
name: "Production",
path: "production",
children: {},
hosts: [],
totalHostCount: 2,
};
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[groupNode]}
hosts={[]}
expandedPaths={new Set<string>()}
onTogglePath={() => undefined}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
/>,
);
const labelIndex = markup.indexOf("Production");
const editButtonIndex = markup.indexOf('data-host-tree-group-edit-button="production"');
const countIndex = markup.indexOf(">2<", editButtonIndex);
assert.ok(labelIndex >= 0);
assert.ok(editButtonIndex > labelIndex);
assert.ok(countIndex > editButtonIndex);
});
test("HostTreeView shows selected groups in multi-select mode", () => {
installLocalStorageMock();
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[{
name: "Production",
path: "production",
children: {},
hosts: [],
}]}
hosts={[]}
expandedPaths={new Set<string>()}
onTogglePath={() => undefined}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
isMultiSelectMode
selectedGroupPaths={new Set(["production"])}
toggleGroupSelection={() => undefined}
/>,
);
assert.match(markup, /data-selected="true"/);
assert.match(markup, /data-group-path="production"/);
assert.match(markup, /role="tree"/);
assert.match(markup, /aria-multiselectable="true"/);
assert.match(markup, /role="treeitem"/);
assert.match(markup, /aria-selected="true"/);
assert.match(markup, /data-tree-item-key="group:production"/);
assert.match(markup, /data-tree-depth="0"/);
assert.match(markup, /aria-level="1"/);
assert.match(markup, /tabindex="0"/);
assert.doesNotMatch(markup, /aria-expanded=/);
assert.doesNotMatch(markup, /data-host-tree-group-edit-button="production"/);
});
test("HostTreeView exposes selected hosts to keyboard and assistive technology", () => {
installLocalStorageMock();
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[]}
hosts={[baseHost]}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
isMultiSelectMode
selectedHostIds={new Set([baseHost.id])}
toggleHostSelection={() => undefined}
/>,
);
assert.match(markup, /role="tree"/);
assert.match(markup, /data-host-id="host-1"[^>]*role="treeitem"/);
assert.match(markup, /data-host-id="host-1"[^>]*aria-selected="true"/);
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-item-key="host:host-1"/);
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
});
test("HostTreeView exposes only one tree item in the tab order", () => {
installLocalStorageMock();
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[{
name: "Production",
path: "production",
children: {},
hosts: [],
}]}
hosts={[baseHost]}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
/>,
);
assert.equal(markup.match(/tabindex="0"/g)?.length, 1);
assert.match(
markup,
/<button(?=[^>]*data-host-tree-group-edit-button="production")(?=[^>]*tabindex="-1")[^>]*>/,
);
});
test("HostTreeView flattens only expanded group descendants", () => {
const childHost = { ...baseHost, id: "child-host", group: "production" };
const groupTree = [{
name: "Production",
path: "production",
children: {},
hosts: [childHost],
}];
assert.deepEqual(
buildVisibleHostTreeItems({
groupTree,
ungroupedHosts: [baseHost],
expandedPaths: new Set(),
sortMode: "az",
groupConfigs: [],
}).map((item) => item.key),
["group:production", "host:host-1"],
);
assert.deepEqual(
buildVisibleHostTreeItems({
groupTree,
ungroupedHosts: [baseHost],
expandedPaths: new Set(["production"]),
sortMode: "az",
groupConfigs: [],
}).map((item) => item.key),
["group:production", "host:child-host", "host:host-1"],
);
});
test("HostTreeView keeps an explicit collapsed state after search auto-expands groups", () => {
installLocalStorageMock();
const childHost = { ...baseHost, id: "child-host", group: "production" };
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[{
name: "Production",
path: "production",
children: {},
hosts: [childHost],
}]}
hosts={[childHost]}
expandedPaths={new Set()}
autoExpandGroupsKey="router"
onTogglePath={() => undefined}
onExpandAll={() => undefined}
onCollapseAll={() => undefined}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
/>,
);
assert.match(markup, /data-group-path="production"[^>]*aria-expanded="false"/);
assert.doesNotMatch(markup, /data-host-id="child-host"/);
});
test("HostTreeView search expansion is temporary and remains collapsible", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
let persistentMutationCount = 0;
let autoExpandGroupsKey: string | undefined = "router";
let allGroupPaths = ["production"];
let expandedPaths = new Set<string>();
let collapseAll = () => undefined;
let renderer: ReactTestRenderer | null = null;
const Probe = () => {
const state = useHostTreeExpandedPaths({
persistentExpandedPaths: new Set(),
allGroupPaths,
autoExpandGroupsKey,
onTogglePath: () => { persistentMutationCount++; },
onExpandAll: () => { persistentMutationCount++; },
onCollapseAll: () => { persistentMutationCount++; },
});
expandedPaths = state.expandedPaths;
collapseAll = state.collapseAll;
return null;
};
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.equal(expandedPaths.has("production"), true);
await act(async () => {
collapseAll();
});
assert.equal(expandedPaths.has("production"), false);
assert.equal(persistentMutationCount, 0);
await act(async () => {
allGroupPaths = ["production", "staging"];
renderer!.update(React.createElement(Probe));
});
assert.equal(expandedPaths.has("production"), false);
assert.equal(expandedPaths.has("staging"), true);
assert.equal(persistentMutationCount, 0);
await act(async () => {
autoExpandGroupsKey = undefined;
renderer!.update(React.createElement(Probe));
});
assert.equal(expandedPaths.has("production"), false);
assert.equal(persistentMutationCount, 0);
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("HostTreeView virtualizes an 8,000-host tree", () => {
installLocalStorageMock();
const hosts = Array.from({ length: 8000 }, (_, index) => ({
...baseHost,
id: `host-${index}`,
label: `Host ${String(index).padStart(4, "0")}`,
}));
const markup = renderToStaticMarkup(
<HostTreeView
groupTree={[]}
hosts={hosts}
onConnect={() => undefined}
onEditHost={() => undefined}
onDuplicateHost={() => undefined}
onDeleteHost={() => undefined}
onCopyCredentials={() => undefined}
onNewGroup={() => undefined}
onRenameGroup={() => undefined}
onEditGroup={() => undefined}
onDeleteGroup={() => undefined}
moveHostToGroup={() => undefined}
moveGroup={() => undefined}
/>,
);
const renderedHosts = markup.match(/data-host-id=/g)?.length ?? 0;
assert.ok(renderedHosts > 0);
assert.ok(renderedHosts < 100);
assert.match(markup, /data-vault-virtual-tree="true"/);
assert.match(markup, /height:320000px/);
// Flat ungrouped hosts share one sibling set at the root level.
assert.match(markup, /aria-setsize="8000"/);
assert.match(markup, /aria-posinset="1"/);
assert.match(markup, /role="treeitem"/);
});
test("HostTreeView announces sibling position within each tree level", () => {
installLocalStorageMock();
const groupTree: GroupNode[] = [{
name: "prod",
path: "prod",
hosts: [
{ ...baseHost, id: "host-a", label: "A", group: "prod" },
{ ...baseHost, id: "host-b", label: "B", group: "prod" },
],
children: {
east: {
name: "east",
path: "prod/east",
hosts: [{ ...baseHost, id: "host-c", label: "C", group: "prod/east" }],
children: {},
},
},
}];
const hosts = [
{ ...baseHost, id: "host-a", label: "A", group: "prod" },
{ ...baseHost, id: "host-b", label: "B", group: "prod" },
{ ...baseHost, id: "host-c", label: "C", group: "prod/east" },
{ ...baseHost, id: "host-root", label: "Root", group: "" },
];
const items = buildVisibleHostTreeItems({
groupTree,
ungroupedHosts: hosts.filter((host) => !host.group),
expandedPaths: new Set(["prod", "prod/east"]),
sortMode: "az",
groupConfigs: [],
});
const byKey = new Map(items.map((item) => [item.key, item]));
// Root siblings: prod group + ungrouped host-root
assert.equal(byKey.get("group:prod")?.posInSet, 1);
assert.equal(byKey.get("group:prod")?.setSize, 2);
assert.equal(byKey.get("host:host-root")?.posInSet, 2);
assert.equal(byKey.get("host:host-root")?.setSize, 2);
// Children of prod: east group + hosts A/B
assert.equal(byKey.get("group:prod/east")?.posInSet, 1);
assert.equal(byKey.get("group:prod/east")?.setSize, 3);
assert.equal(byKey.get("host:host-a")?.setSize, 3);
assert.equal(byKey.get("host:host-b")?.setSize, 3);
// Child of prod/east: only host C
assert.equal(byKey.get("host:host-c")?.posInSet, 1);
assert.equal(byKey.get("host:host-c")?.setSize, 1);
});