mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
8cdc887383
* 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
481 lines
15 KiB
TypeScript
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);
|
|
});
|