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
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { JSDOM } from "jsdom";
|
|
|
|
test("inline bookmark actions navigate and confirm current-path removal", async () => {
|
|
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
|
pretendToBeVisual: true,
|
|
url: "http://localhost",
|
|
});
|
|
const window = dom.window;
|
|
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
|
const installGlobal = (key: string, value: unknown) => {
|
|
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
|
Object.defineProperty(globalThis, key, {
|
|
configurable: true,
|
|
writable: true,
|
|
value,
|
|
});
|
|
};
|
|
|
|
class ResizeObserverStub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
|
|
installGlobal("window", window);
|
|
installGlobal("document", window.document);
|
|
installGlobal("navigator", window.navigator);
|
|
installGlobal("HTMLElement", window.HTMLElement);
|
|
installGlobal("HTMLInputElement", window.HTMLInputElement);
|
|
installGlobal("HTMLTextAreaElement", window.HTMLTextAreaElement);
|
|
installGlobal("Element", window.Element);
|
|
installGlobal("SVGElement", window.SVGElement);
|
|
installGlobal("Node", window.Node);
|
|
installGlobal("NodeFilter", window.NodeFilter);
|
|
installGlobal("MutationObserver", window.MutationObserver);
|
|
installGlobal("CustomEvent", window.CustomEvent);
|
|
installGlobal("Event", window.Event);
|
|
installGlobal("StorageEvent", window.StorageEvent);
|
|
installGlobal("localStorage", window.localStorage);
|
|
installGlobal("sessionStorage", window.sessionStorage);
|
|
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
|
|
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
|
|
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
|
|
installGlobal("ResizeObserver", ResizeObserverStub);
|
|
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
|
|
const { default: React, act } = await import("react");
|
|
const { createRoot } = await import("react-dom/client");
|
|
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
|
|
const { SftpPaneToolbar } = await import("./SftpPaneToolbar.tsx");
|
|
const rootNode = window.document.getElementById("root");
|
|
assert.ok(rootNode);
|
|
const root = createRoot(rootNode);
|
|
const navigatedPaths: string[] = [];
|
|
const deletedBookmarkIds: string[] = [];
|
|
let toggleBookmarkCalls = 0;
|
|
const waitForFocusRestore = async () => {
|
|
await act(async () => {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
|
});
|
|
};
|
|
const setBookmarkPlacement = async (placement: "show" | "collapse") => {
|
|
window.localStorage.setItem("netcatty_sftp_toolbar_layout_v1", JSON.stringify({
|
|
order: ["bookmark"],
|
|
placement: { bookmark: placement },
|
|
}));
|
|
await act(async () => {
|
|
window.dispatchEvent(new window.StorageEvent("storage", {
|
|
key: "netcatty_sftp_toolbar_layout_v1",
|
|
}));
|
|
});
|
|
};
|
|
const findRemovalDialog = () => Array.from(window.document.querySelectorAll("h2"))
|
|
.find((heading) => heading.textContent?.trim() === "Remove bookmark")
|
|
?.closest<HTMLElement>('[role="dialog"]') ?? null;
|
|
|
|
const renderToolbar = async (currentPath: string, currentPathBookmarked: boolean) => {
|
|
await act(async () => {
|
|
const toolbar = React.createElement(SftpPaneToolbar, {
|
|
t: (key, params) => ({
|
|
"sftp.bookmark.list": "Bookmarked paths",
|
|
"sftp.bookmark.remove": "Remove bookmark",
|
|
"sftp.bookmark.removeConfirm": `Remove bookmark ${params?.path ?? ""}?`,
|
|
"sftp.viewMode.switchToTree": "Switch to tree view",
|
|
}[key] ?? key),
|
|
pane: {
|
|
id: "pane-1",
|
|
connection: {
|
|
id: "conn-1",
|
|
hostId: "host-1",
|
|
name: "Example",
|
|
currentPath,
|
|
homeDir: "/home/app",
|
|
isLocal: false,
|
|
},
|
|
files: [],
|
|
loading: false,
|
|
reconnecting: false,
|
|
error: null,
|
|
connectionLogs: [],
|
|
selectedFiles: new Set(),
|
|
filter: "",
|
|
filenameEncoding: "auto",
|
|
showHiddenFiles: false,
|
|
transferMutationToken: 0,
|
|
},
|
|
onNavigateTo: () => {},
|
|
onSetFilter: () => {},
|
|
onSetFilenameEncoding: () => {},
|
|
onRefresh: () => {},
|
|
showFilterBar: false,
|
|
setShowFilterBar: () => {},
|
|
filterInputRef: { current: null },
|
|
isEditingPath: false,
|
|
editingPathValue: "",
|
|
setEditingPathValue: () => {},
|
|
setShowPathSuggestions: () => {},
|
|
showPathSuggestions: false,
|
|
setPathSuggestionIndex: () => {},
|
|
pathSuggestions: [],
|
|
pathSuggestionIndex: -1,
|
|
pathInputRef: { current: null },
|
|
pathDropdownRef: { current: null },
|
|
handlePathBlur: () => {},
|
|
handlePathKeyDown: () => {},
|
|
handlePathDoubleClick: () => {},
|
|
handlePathSubmit: () => {},
|
|
getNextUntitledName: () => "untitled",
|
|
setNewFileName: () => {},
|
|
setFileNameError: () => {},
|
|
setShowNewFileDialog: () => {},
|
|
setShowNewFolderDialog: () => {},
|
|
setNewFolderName: () => {},
|
|
bookmarks: [
|
|
{ id: "bm-current", path: "/home/app", label: "App root" },
|
|
{ id: "bm-1", path: "/srv/www", label: "Web root" },
|
|
],
|
|
isCurrentPathBookmarked: currentPathBookmarked,
|
|
onToggleBookmark: () => {
|
|
toggleBookmarkCalls += 1;
|
|
},
|
|
onAddGlobalBookmark: () => {},
|
|
isCurrentPathGlobalBookmarked: false,
|
|
onNavigateToBookmark: (path) => navigatedPaths.push(path),
|
|
onDeleteBookmark: (bookmark) => deletedBookmarkIds.push(bookmark.id),
|
|
showHiddenFiles: false,
|
|
onToggleShowHiddenFiles: () => {},
|
|
viewMode: "list",
|
|
onSetViewMode: () => {},
|
|
});
|
|
root.render(React.createElement(I18nProvider, { locale: "en" }, toolbar));
|
|
});
|
|
};
|
|
|
|
try {
|
|
await renderToolbar("/home/app", true);
|
|
|
|
let trigger = window.document.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Bookmarked paths"]',
|
|
);
|
|
assert.ok(trigger);
|
|
await act(async () => trigger.click());
|
|
|
|
const bookmark = Array.from(window.document.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Web root"),
|
|
);
|
|
assert.ok(bookmark, "bookmark path should be visible after opening the popover");
|
|
await act(async () => bookmark.click());
|
|
|
|
assert.deepEqual(navigatedPaths, ["/srv/www"]);
|
|
assert.equal(
|
|
Array.from(window.document.querySelectorAll("button")).some((button) =>
|
|
button.textContent?.includes("Web root"),
|
|
),
|
|
false,
|
|
"bookmark popover should close after selecting a path",
|
|
);
|
|
|
|
await act(async () => trigger.click());
|
|
const removeCurrentPath = Array.from(window.document.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Remove bookmark",
|
|
);
|
|
assert.ok(removeCurrentPath, "current-path bookmark removal should be visible");
|
|
removeCurrentPath.focus();
|
|
await act(async () => removeCurrentPath.click());
|
|
|
|
let dialog = findRemovalDialog();
|
|
assert.ok(dialog, "current-path removal should open an in-app dialog");
|
|
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
|
|
assert.equal(
|
|
Array.from(window.document.querySelectorAll("button")).some((button) =>
|
|
button.textContent?.includes("Web root"),
|
|
),
|
|
false,
|
|
"the bookmark popover must close before the dialog opens",
|
|
);
|
|
assert.equal(toggleBookmarkCalls, 0, "opening the dialog must not remove the bookmark");
|
|
|
|
const cancel = Array.from(dialog.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Cancel",
|
|
);
|
|
assert.ok(cancel);
|
|
await setBookmarkPlacement("collapse");
|
|
const overflowTriggerAfterLayoutChange = window.document.querySelector<HTMLButtonElement>(
|
|
'button[data-toolbar-overflow-trigger="true"]',
|
|
);
|
|
assert.ok(overflowTriggerAfterLayoutChange);
|
|
await act(async () => cancel.click());
|
|
await waitForFocusRestore();
|
|
assert.equal(findRemovalDialog(), null);
|
|
assert.equal(toggleBookmarkCalls, 0, "cancelling must preserve the bookmark");
|
|
assert.equal(
|
|
window.document.activeElement,
|
|
overflowTriggerAfterLayoutChange,
|
|
"cancel should fall back to the newly mounted overflow trigger",
|
|
);
|
|
|
|
await setBookmarkPlacement("show");
|
|
trigger = window.document.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Bookmarked paths"]',
|
|
);
|
|
assert.ok(trigger);
|
|
|
|
await act(async () => trigger.click());
|
|
const removeCurrentPathAfterCancel = Array.from(
|
|
window.document.querySelectorAll("button"),
|
|
).find((button) => button.textContent?.trim() === "Remove bookmark");
|
|
assert.ok(removeCurrentPathAfterCancel);
|
|
await act(async () => removeCurrentPathAfterCancel.click());
|
|
dialog = findRemovalDialog();
|
|
assert.ok(dialog, "the removal dialog should reopen after cancellation");
|
|
await renderToolbar("/home/other", false);
|
|
dialog = findRemovalDialog();
|
|
assert.ok(dialog, "changing directory must not replace the pending removal target");
|
|
assert.match(dialog.textContent ?? "", /Remove bookmark \/home\/app\?/);
|
|
assert.doesNotMatch(dialog.textContent ?? "", /\/home\/other/);
|
|
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Remove bookmark",
|
|
);
|
|
assert.ok(confirm);
|
|
await act(async () => confirm.click());
|
|
assert.deepEqual(deletedBookmarkIds, ["bm-current"]);
|
|
assert.equal(toggleBookmarkCalls, 0, "confirmation must not toggle the new current path");
|
|
assert.equal(findRemovalDialog(), null);
|
|
await waitForFocusRestore();
|
|
assert.equal(window.document.activeElement, trigger, "confirm should restore the bookmark trigger");
|
|
|
|
await act(async () => trigger.click());
|
|
const webRootRow = Array.from(
|
|
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
|
|
).find((element) => element.textContent?.includes("Web root"));
|
|
const removeWebRoot = webRootRow?.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Remove bookmark"]',
|
|
);
|
|
assert.ok(removeWebRoot);
|
|
await act(async () => removeWebRoot.click());
|
|
dialog = findRemovalDialog();
|
|
assert.ok(dialog, "row removal should use the shared in-app dialog");
|
|
assert.match(dialog.textContent ?? "", /Remove bookmark \/srv\/www\?/);
|
|
assert.equal(webRootRow.isConnected, false, "row removal must close the bookmark popover");
|
|
const confirmWebRoot = Array.from(dialog.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Remove bookmark",
|
|
);
|
|
assert.ok(confirmWebRoot);
|
|
await act(async () => confirmWebRoot.click());
|
|
assert.deepEqual(deletedBookmarkIds, ["bm-current", "bm-1"]);
|
|
await waitForFocusRestore();
|
|
assert.equal(window.document.activeElement, trigger, "row removal should restore the bookmark trigger");
|
|
|
|
await setBookmarkPlacement("collapse");
|
|
const overflowTrigger = window.document.querySelector<HTMLButtonElement>(
|
|
'button[data-toolbar-overflow-trigger="true"]',
|
|
);
|
|
assert.ok(overflowTrigger, "collapsed bookmark should expose the overflow trigger");
|
|
await act(async () => overflowTrigger.click());
|
|
const nestedBookmarkTrigger = window.document.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Bookmarked paths"]',
|
|
);
|
|
assert.ok(nestedBookmarkTrigger, "overflow should expose the nested bookmark trigger");
|
|
await act(async () => nestedBookmarkTrigger.click());
|
|
const overflowWebRootRow = Array.from(
|
|
window.document.querySelectorAll<HTMLElement>("[data-bookmark-scope]"),
|
|
).find((element) => element.textContent?.includes("Web root"));
|
|
const removeOverflowWebRoot = overflowWebRootRow?.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Remove bookmark"]',
|
|
);
|
|
assert.ok(removeOverflowWebRoot);
|
|
removeOverflowWebRoot.focus();
|
|
await act(async () => removeOverflowWebRoot.click());
|
|
dialog = findRemovalDialog();
|
|
assert.ok(dialog, "overflow row removal should open the shared dialog");
|
|
const cancelOverflowRemoval = Array.from(dialog.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Cancel",
|
|
);
|
|
assert.ok(cancelOverflowRemoval);
|
|
await act(async () => cancelOverflowRemoval.click());
|
|
await waitForFocusRestore();
|
|
assert.equal(
|
|
window.document.activeElement,
|
|
overflowTrigger,
|
|
"overflow removal should restore the persistent overflow trigger",
|
|
);
|
|
await act(async () => {
|
|
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
|
});
|
|
} finally {
|
|
await act(async () => root.unmount());
|
|
dom.window.close();
|
|
for (const [key, descriptor] of previousGlobals) {
|
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
else delete (globalThis as Record<string, unknown>)[key];
|
|
}
|
|
}
|
|
});
|