Files
Netcatty/application/state/sftp/sameDirectoryCopy.test.ts
T
f83b5f6693 fix(sftp): allow copy and paste within the same pane (#3318)
* fix(#3317): automated AI fix

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix: address Codex review on PR #3318

* fix(sftp): preserve source links during same-pane replacement

* fix(sftp): leave self-directory merge and replace unchanged

* fix(sftp): scope local traversal limits to same-pane copies

* refactor(sftp): centralize same-pane paste filesystem checks

* fix(sftp): preserve source links through bind-mounted parents

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 12:25:18 +08:00

106 lines
6.1 KiB
TypeScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
for (const action of ["merge", "replace", "duplicate"] as const) {
test(`same-directory ${action} preserves source links through the real transfer hook`, async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".same-directory-copy-"));
const ownerId = `self-copy-${action}-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let writes = 0;
let listings = 0;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
const names = ["one", "two"];
for (const name of names) {
await fs.mkdir(path.join(dir, name));
await fs.writeFile(path.join(dir, name, "file"), "original data");
await fs.symlink("file", path.join(dir, name, "link"));
}
const pane = createEmptyPane("self-copy-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { writes++; await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { writes++; await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { writes++; await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
writes++;
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => {
listings++;
return Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
}));
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => { await ops.startTransfer(names.map(name => ({ name, isDirectory: true })), "left", "left"); });
assert.equal(ops.conflicts.length, 2, JSON.stringify(sftpTransferCenterStore.getOwnerTasks(ownerId)));
const firstId = ops.conflicts[0].transferId;
// Apply to all exercises shared batch resolution rather than just one row.
await act(async () => { await ops.resolveConflict(firstId, action, true); });
await act(async () => {
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) break;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
for (const name of names) {
assert.equal(await fs.readlink(path.join(dir, name, "link")), "file");
assert.equal(await fs.readFile(path.join(dir, name, "file"), "utf8"), "original data");
}
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.equal(roots.length, 2);
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
if (action === "duplicate") {
assert.ok(writes > 0);
assert.ok(listings > 0);
for (const name of names) assert.equal(await fs.readFile(path.join(dir, `${name} (copy)`, "file"), "utf8"), "original data");
} else {
assert.equal(writes, 0, "self merge/replace must never write or unlink anything");
assert.equal(listings, 0, "self merge/replace must not walk the source tree");
}
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
}