Files
Netcatty/application/state/sftp/externalFileWatchLifecycle.test.ts
T
9ac4c46df9 fix(sftp): keep external-edit temps across terminal tab switches (#2730)
* fix(#2727): automated Cursor CLI fix

* fix(sftp): drop external-edit retainers when browse sessions close

closeSftp already deletes registered temps on disconnect/reconnect; clear
the matching renderer retainers so hasActiveWork cannot stick on a gone file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): clear external-edit retainers when watches force-stop

When the temp file disappears, fileWatcherBridge force-stops the watch
with localPath; drop the matching retainer so hasActiveWork cannot stick.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address Codex review on PR #2730

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:32:51 +08:00

182 lines
6.0 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
cleanupFailedExternalOpenTemp,
useExternalFileWatchLifecycle,
type ExternalFileWatchLifecycle,
} from "./externalFileWatchLifecycle";
test("failed external app launch unregisters and deletes its remote temp file", async () => {
const calls: string[] = [];
await cleanupFailedExternalOpenTemp({
unregisterTempFile: async (sftpId, localPath) => {
calls.push(`${sftpId}:${localPath}`);
return { success: true };
},
}, "sftp-1", "/tmp/edit.txt");
assert.deepEqual(calls, ["sftp-1:/tmp/edit.txt"]);
});
test("renderer tracks a reused watch once and releases it on unmount", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
await act(async () => {
lifecycle!.remember("watch-reused");
lifecycle!.remember("watch-reused");
});
assert.equal(lifecycle!.activeCountRef.current, 1);
await act(async () => renderer!.unmount());
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [{ watchId: "watch-reused", cleanupTempFile: false }]);
});
test("explicit SFTP lifecycle cleanup releases every tracked watch and temp file", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
lifecycle!.remember("watch-a");
lifecycle!.remember("watch-b");
await lifecycle!.releaseAll(true);
assert.equal(lifecycle!.activeCountRef.current, 0);
assert.deepEqual(stopped, [
{ watchId: "watch-a", cleanupTempFile: true },
{ watchId: "watch-b", cleanupTempFile: true },
]);
await act(async () => renderer!.unmount());
});
test("a watch that starts after unmount is stopped instead of being retained", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
const lateLifecycle = lifecycle!;
await act(async () => renderer!.unmount());
lateLifecycle.remember("watch-started-after-unmount");
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [{
watchId: "watch-started-after-unmount",
cleanupTempFile: false,
}]);
assert.equal(lateLifecycle.activeCountRef.current, 0);
});
test("release invalidates pending watch starts while a new generation remains usable", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
function Probe() {
lifecycle = useExternalFileWatchLifecycle(async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
});
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
const oldGeneration = lifecycle!.captureGeneration();
await lifecycle!.releaseAll(true);
lifecycle!.remember("late-old-watch", oldGeneration);
const currentGeneration = lifecycle!.captureGeneration();
lifecycle!.remember("fresh-watch", currentGeneration);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [{ watchId: "late-old-watch", cleanupTempFile: true }]);
assert.equal(lifecycle!.activeCountRef.current, 1);
await lifecycle!.releaseAll(false);
assert.deepEqual(stopped, [
{ watchId: "late-old-watch", cleanupTempFile: true },
{ watchId: "fresh-watch", cleanupTempFile: false },
]);
await act(async () => renderer!.unmount());
});
test("backend session cleanup removes a stopped watch from renderer ownership", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let onBackendStopped: ((payload: {
watchId: string;
localPath?: string;
}) => void) | null = null;
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
const forgottenPaths: string[] = [];
function Probe() {
lifecycle = useExternalFileWatchLifecycle(
async (watchId, cleanupTempFile) => {
stopped.push({ watchId, cleanupTempFile });
},
(callback) => {
onBackendStopped = callback;
return () => { onBackendStopped = null; };
},
(localPath) => { forgottenPaths.push(localPath); },
);
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
lifecycle!.remember("watch-closed-with-session");
assert.equal(lifecycle!.activeCountRef.current, 1);
await act(async () => {
onBackendStopped?.({
watchId: "watch-closed-with-session",
localPath: "/tmp/externally-deleted.txt",
});
});
assert.equal(lifecycle!.activeCountRef.current, 0);
assert.deepEqual(forgottenPaths, ["/tmp/externally-deleted.txt"]);
await act(async () => renderer!.unmount());
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(stopped, [], "backend-owned cleanup must not issue a duplicate stop");
});