Files
Netcatty/application/state/editorTabStore.test.ts
T
Eric Chan a3f8acd4e0 fix(sftp): remap editor sessions before pane cleanup runs
Reconnect can replace browse connection ids before save completes, so close
handlers that only matched sessionId skipped dirty editors and tore down their
writer. Remap as soon as reconnect is detected and match owners by sftpTabId.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:45:13 +08:00

371 lines
12 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import {
editorTabStore,
EditorTabStore,
useHasEditorTabForSessions,
type EditorTab,
} from "./editorTabStore.ts";
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "worker_processes auto;",
baselineContent: "worker_processes auto;",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
store.updateContent("edt_1", "worker_processes 4;", null);
const tab = store.getTab("edt_1")!;
assert.equal(tab.content, "worker_processes 4;");
assert.equal(store.isDirty("edt_1"), true);
});
test("markSaved moves baseline to current content and clears dirty", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ content: "changed", baselineContent: "orig" }));
assert.equal(store.isDirty("edt_1"), true);
store.markSaved("edt_1", "changed");
assert.equal(store.isDirty("edt_1"), false);
assert.equal(store.getTab("edt_1")!.baselineContent, "changed");
});
test("setWordWrap updates only that tab", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
store.setWordWrap("edt_1", true);
assert.equal(store.getTab("edt_1")!.wordWrap, true);
assert.equal(store.getTab("edt_2")!.wordWrap, false);
});
test("setSavingState transitions and clears error on idle", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
store.setSavingState("edt_1", "saving");
assert.equal(store.getTab("edt_1")!.savingState, "saving");
store.setSavingState("edt_1", "error", "EACCES");
assert.equal(store.getTab("edt_1")!.saveError, "EACCES");
store.setSavingState("edt_1", "idle");
assert.equal(store.getTab("edt_1")!.saveError, null);
});
test("close removes the tab and returns remaining ids in order", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
store.close("edt_1");
assert.equal(store.getTab("edt_1"), undefined);
assert.deepEqual(store.getTabs().map((t) => t.id), ["edt_2"]);
});
test("subscribers fire on change and not on read", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
let count = 0;
const unsub = store.subscribe(() => { count++; });
store.getTab("edt_1");
store.getTabs();
assert.equal(count, 0);
store.updateContent("edt_1", "x", null);
// notifications are microtask-deferred, flush via awaiting a resolved promise
return Promise.resolve().then(() => {
assert.equal(count, 1);
unsub();
});
});
test("promoteFromModal creates a new tab and returns its id", () => {
const store = new EditorTabStore();
const id = store.promoteFromModal({
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "x",
baselineContent: "x",
wordWrap: false,
viewState: null,
});
const tab = store.getTab(id)!;
assert.equal(tab.remotePath, "/etc/nginx/nginx.conf");
assert.equal(tab.fileName, "nginx.conf");
assert.equal(tab.kind, "editor");
});
test("hasTabForSessions identifies the SFTP owner of a promoted editor", () => {
const store = new EditorTabStore();
store.promoteFromModal({
sessionId: "conn_owned",
sftpTabId: "pane_owned",
hostId: "host_1",
remotePath: "/tmp/script.sh",
fileName: "script.sh",
languageId: "shell",
content: "echo changed",
baselineContent: "echo original",
wordWrap: false,
viewState: null,
});
assert.equal(store.hasTabForSessions(new Set(["conn_owned"])), true);
assert.equal(store.hasTabForSessions(new Set(["conn_other"])), false);
});
test("useHasEditorTabForSessions updates when the owning editor opens and closes", 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;
const ownedSessionIdsRef = { current: new Set(["conn_hook_test"]) };
const getOwnedSessionIds = () => ownedSessionIdsRef.current;
let hasOwnedEditorTab = false;
const Probe = () => {
hasOwnedEditorTab = useHasEditorTabForSessions(getOwnedSessionIds);
return null;
};
let renderer: ReactTestRenderer | null = null;
let tabId: string | null = null;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
assert.equal(hasOwnedEditorTab, false);
await act(async () => {
tabId = editorTabStore.promoteFromModal({
sessionId: "conn_hook_test",
sftpTabId: "pane_hook_test",
hostId: "host_1",
remotePath: "/tmp/hook-test.sh",
fileName: "hook-test.sh",
languageId: "shell",
content: "echo changed",
baselineContent: "echo original",
wordWrap: false,
viewState: null,
});
await Promise.resolve();
});
assert.equal(hasOwnedEditorTab, true);
await act(async () => {
editorTabStore.close(tabId!);
await Promise.resolve();
});
assert.equal(hasOwnedEditorTab, false);
} finally {
if (tabId) editorTabStore.close(tabId);
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("promoteFromModal focuses existing tab for same sessionId+normalized path and overrides content", () => {
const store = new EditorTabStore();
const first = store.promoteFromModal({
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/./nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "v1",
baselineContent: "v1",
wordWrap: false,
viewState: null,
});
const second = store.promoteFromModal({
sessionId: "conn_1",
sftpTabId: "pane_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "v2",
baselineContent: "v1",
wordWrap: false,
viewState: null,
});
assert.equal(second, first);
assert.equal(store.getTab(first)!.content, "v2");
assert.equal(store.getTabs().length, 1);
});
test("dedup scope is per-sessionId — same path on different sessions are distinct tabs", () => {
const store = new EditorTabStore();
const a = store.promoteFromModal({
sessionId: "conn_A",
sftpTabId: "pane_a",
hostId: "host_1",
remotePath: "/etc/hosts",
fileName: "hosts",
languageId: "plaintext",
content: "", baselineContent: "", wordWrap: false, viewState: null,
});
const b = store.promoteFromModal({
sessionId: "conn_B",
sftpTabId: "pane_b",
hostId: "host_2",
remotePath: "/etc/hosts",
fileName: "hosts",
languageId: "plaintext",
content: "", baselineContent: "", wordWrap: false, viewState: null,
});
assert.notEqual(a, b);
assert.equal(store.getTabs().length, 2);
});
test("confirmCloseBySession returns true when no tabs match", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const ok = await store.confirmCloseBySession("other_conn", async () => "discard");
assert.equal(ok, true);
assert.equal(store.getTabs().length, 1);
});
test("confirmCloseBySession discards all dirty matching tabs when prompt returns 'discard'", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1", content: "x", baselineContent: "y" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
const ok = await store.confirmCloseBySession("conn_1", async () => "discard");
assert.equal(ok, true);
assert.equal(store.getTabs().length, 0);
});
test("confirmCloseBySession closes clean tabs without prompting; aborts on cancel", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" })); // content == baseline
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
let prompts = 0;
const ok = await store.confirmCloseBySession("conn_1", async () => { prompts++; return "cancel"; });
assert.equal(ok, false);
assert.equal(prompts, 1, "prompt fires only for dirty tab");
// clean tab was closed before the dirty cancel aborted the batch
assert.equal(store.getTab("edt_clean"), undefined);
assert.ok(store.getTab("edt_dirty"));
});
test("confirmCloseBySession invokes save callback for 'save' choice and only closes on save success", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1", content: "new", baselineContent: "old" }));
let saved = false;
const ok = await store.confirmCloseBySession("conn_1", async () => "save", async (id) => {
assert.equal(id, "edt_1");
saved = true;
store.markSaved(id, "new");
});
assert.equal(saved, true);
assert.equal(ok, true);
assert.equal(store.getTab("edt_1"), undefined);
});
test("confirmCloseBySession reports every closed editor tab to cleanup callback", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" }));
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "new", baselineContent: "old" }));
const closed: string[] = [];
const ok = await store.confirmCloseBySession(
"conn_1",
async () => "save",
async (id) => {
const tab = store.getTab(id)!;
store.markSaved(id, tab.content);
},
(id) => closed.push(id),
);
assert.equal(ok, true);
assert.deepEqual(closed, ["edt_clean", "edt_dirty"]);
assert.equal(store.getTabs().length, 0);
});
test("remapSessionId updates editor ownership after browse reconnect", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ sessionId: "conn_old" }));
assert.equal(store.hasTabForSessions(new Set(["conn_old"])), true);
assert.equal(store.hasTabForSessions(new Set(["conn_new"])), false);
const before = store.getPresenceRevision();
store.remapSessionId("conn_old", "conn_new");
assert.equal(store.getTab("edt_1")?.sessionId, "conn_new");
assert.equal(store.hasTabForSessions(new Set(["conn_new"])), true);
assert.equal(store.getPresenceRevision(), before + 1);
});
test("hasOwnedEditorForSftpOwner keeps ownership via pane tab id during reconnect gap", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ sessionId: "conn_old", sftpTabId: "pane_1" }));
assert.equal(store.hasTabForSessions(new Set(["conn_new"])), false);
assert.equal(
store.hasOwnedEditorForSftpOwner({
sessionIds: new Set(["conn_new"]),
sftpTabIds: new Set(["pane_1"]),
}),
true,
);
});
test("confirmCloseByOwner matches editors by stable SFTP pane tab id", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({
sessionId: "conn_old",
sftpTabId: "pane_1",
content: "dirty",
baselineContent: "clean",
}));
let prompted = false;
const ok = await store.confirmCloseByOwner(
{ sessionId: "conn_new", sftpTabId: "pane_1" },
async () => {
prompted = true;
return "cancel";
},
);
assert.equal(prompted, true);
assert.equal(ok, false);
assert.equal(store.getTabs().length, 1);
});
test("forceCloseByOwners closes editors matched by SFTP pane tab id", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ sessionId: "conn_old", sftpTabId: "pane_1" }));
const closed = store.forceCloseByOwners({ sftpTabIds: ["pane_1"] });
assert.deepEqual(closed, ["edt_1"]);
assert.equal(store.getTabs().length, 0);
});
test("updateContent does not bump editor presence revision", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const before = store.getPresenceRevision();
store.updateContent("edt_1", "changed", null);
assert.equal(store.getPresenceRevision(), before);
});