Files
NetMesh/electron/bridges/sessionLogsBridge.test.cjs

964 lines
39 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const Module = require("node:module");
const TEMP_ROOT = path.join(__dirname, ".tmp-session-logs-bridge-tests");
async function waitForPath(targetPath, { timeoutMs = 2000 } = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (fs.existsSync(targetPath)) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timed out waiting for path: ${targetPath}`);
}
function loadBridgeWithDialog(dialogMock) {
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "electron") {
return { dialog: dialogMock };
}
return originalLoad.call(this, request, parent, isMain);
};
try {
const bridgePath = require.resolve("./sessionLogsBridge.cjs");
delete require.cache[bridgePath];
return require("./sessionLogsBridge.cjs");
} finally {
Module._load = originalLoad;
}
}
test("manual export default filename preserves valid Unicode host labels and replaces dangerous characters", async () => {
let defaultPath = "";
const dialogMock = {
showSaveDialog: async (options) => {
defaultPath = options.defaultPath;
return { canceled: true };
},
};
const { exportSessionLog } = loadBridgeWithDialog(dialogMock);
const result = await exportSessionLog(null, {
terminalData: "hello\n",
hostLabel: "生产/服务器:东京*?<>|\0",
hostname: "fallback.example",
startTime: new Date(2026, 0, 2, 3, 4, 5).getTime(),
format: "txt",
});
assert.deepEqual(result, { success: false, canceled: true });
assert.equal(defaultPath, "生产_服务器_东京_______2026-01-02T03-04-05.txt");
assert.equal(defaultPath.includes("/"), false);
assert.equal(defaultPath.includes(":"), false);
assert.equal(defaultPath.includes("\0"), false);
});
test("safe path segments replace invisible control characters and protected names", () => {
const { safePathSegment } = loadBridgeWithDialog({});
assert.equal(safePathSegment("\t生产服务器\n", "fallback"), "_生产服务器_");
assert.equal(safePathSegment("生产\u0085服务器\u009b", "fallback"), "生产_服务器_");
assert.equal(safePathSegment("../name", "fallback"), ".._name");
assert.equal(safePathSegment("CON", "fallback"), "CON_");
assert.equal(safePathSegment("COM¹", "fallback"), "COM¹_");
assert.equal(safePathSegment("LPT².txt", "fallback"), "LPT².txt_");
assert.equal(safePathSegment("prod.", "fallback"), "prod_");
assert.equal(safePathSegment("prod..", "fallback"), "prod__");
});
test("auto-save host directory preserves valid Unicode labels and replaces path-unsafe characters", async () => {
const directory = path.join(TEMP_ROOT, `auto-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { autoSaveSessionLog } = loadBridgeWithDialog({});
try {
const result = await autoSaveSessionLog(null, {
terminalData: "hello\n",
hostLabel: "生产/服务器:东京*?<>|\0",
hostname: "fallback.example",
hostId: "host-id",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
format: "raw",
directory,
});
assert.equal(result.success, true);
assert.equal(path.basename(path.dirname(result.filePath)), "生产_服务器_东京______");
assert.equal(fs.readFileSync(result.filePath, "utf8"), "hello\n");
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("auto-save host directory falls back when the sanitized host label is empty", async () => {
const directory = path.join(TEMP_ROOT, `auto-empty-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { autoSaveSessionLog } = loadBridgeWithDialog({});
try {
const result = await autoSaveSessionLog(null, {
terminalData: "hello\n",
hostLabel: " ",
hostname: "",
hostId: "",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
format: "txt",
directory,
});
assert.equal(result.success, true);
assert.equal(path.basename(path.dirname(result.filePath)), "unknown");
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("isSessionLogArtifactName matches auto-save and labeled export filenames", () => {
const { isSessionLogArtifactName } = loadBridgeWithDialog({});
assert.equal(isSessionLogArtifactName("2026-01-01T00-00-00.txt"), true);
assert.equal(isSessionLogArtifactName("2026-01-01T00-00-00.log"), true);
assert.equal(isSessionLogArtifactName("2026-01-01T00-00-00.html"), true);
assert.equal(isSessionLogArtifactName("my-host_2026-01-01T00-00-00.txt"), true);
assert.equal(isSessionLogArtifactName("生产_服务器_2026-08-05T15-33-00.log"), true);
assert.equal(isSessionLogArtifactName("stray.log"), false);
assert.equal(isSessionLogArtifactName("notes.txt"), false);
assert.equal(isSessionLogArtifactName("2026-01-01.txt"), false);
assert.equal(isSessionLogArtifactName("readme.md"), false);
});
test("clearSessionLogsDir deletes host log folders and labeled exports only", async () => {
const directory = path.join(TEMP_ROOT, `clear-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { clearSessionLogsDir } = loadBridgeWithDialog({});
try {
const hostDir = path.join(directory, "my-host");
fs.mkdirSync(hostDir, { recursive: true });
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.txt"), "hello\n");
fs.writeFileSync(path.join(hostDir, "2026-01-02T12-30-00.log"), "raw\n");
// Labeled export-style artifact at the save-dir root (manual export / continuous log).
fs.writeFileSync(path.join(directory, "my-host_2026-01-03T01-02-03.txt"), "export\n");
// Unrelated user content in a shared save directory must survive clear-all.
fs.writeFileSync(path.join(directory, "stray.log"), "stray\n");
fs.writeFileSync(path.join(directory, "notes.txt"), "keep me\n");
fs.mkdirSync(path.join(directory, "Photos"), { recursive: true });
fs.writeFileSync(path.join(directory, "Photos", "vacation.jpg"), "jpeg");
const result = await clearSessionLogsDir(null, { directory });
assert.equal(result.success, true);
assert.equal(result.deletedCount, 2); // pure host folder + labeled export file
assert.equal(result.failedCount, 0);
assert.deepEqual(fs.readdirSync(directory).sort(), ["Photos", "notes.txt", "stray.log"]);
assert.equal(fs.readFileSync(path.join(directory, "stray.log"), "utf8"), "stray\n");
assert.equal(fs.readFileSync(path.join(directory, "Photos", "vacation.jpg"), "utf8"), "jpeg");
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("clearSessionLogsDir only removes log files inside mixed host directories", async () => {
const directory = path.join(TEMP_ROOT, `clear-mixed-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { clearSessionLogsDir } = loadBridgeWithDialog({});
try {
const hostDir = path.join(directory, "shared-host-folder");
fs.mkdirSync(hostDir, { recursive: true });
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.txt"), "log\n");
fs.writeFileSync(path.join(hostDir, "user-notes.md"), "do not delete\n");
const result = await clearSessionLogsDir(null, { directory });
assert.equal(result.success, true);
assert.equal(result.deletedCount, 1);
assert.equal(result.failedCount, 0);
assert.deepEqual(fs.readdirSync(directory), ["shared-host-folder"]);
assert.deepEqual(fs.readdirSync(hostDir), ["user-notes.md"]);
assert.equal(fs.readFileSync(path.join(hostDir, "user-notes.md"), "utf8"), "do not delete\n");
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("clearSessionLogsDir preserves active auto-save stream files and host dirs", async () => {
const directory = path.join(TEMP_ROOT, `clear-active-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-active-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const { clearSessionLogsDir } = loadBridgeWithDialog({});
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
const startToken = sessionLogStreamManager.startStream(sessionId, {
hostLabel: "live-host",
hostname: "live.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
assert.ok(startToken);
sessionLogStreamManager.appendData(sessionId, "live body\n");
// Prefer stream manager paths: createWriteStream open is async, so the
// file may not appear in readdir until the open/write settles.
const activePaths = sessionLogStreamManager.getActiveLogPaths();
assert.equal(activePaths.length, 1);
const activePath = activePaths[0];
const hostDir = path.dirname(activePath);
assert.equal(path.basename(hostDir), "live-host");
// Wait for the live target to materialize on disk (async open).
await waitForPath(activePath);
// Stale sibling log in the same host folder should still be cleared.
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.log"), "stale\n");
// Inactive pure host folder should still be removed.
const staleHostDir = path.join(directory, "stale-host");
fs.mkdirSync(staleHostDir, { recursive: true });
fs.writeFileSync(path.join(staleHostDir, "2026-01-03T00-00-00.txt"), "old\n");
const result = await clearSessionLogsDir(null, { directory });
assert.equal(result.success, true);
assert.equal(result.failedCount, 0);
assert.equal(result.deletedCount, 2); // stale sibling log + pure stale-host folder
assert.deepEqual(fs.readdirSync(directory).sort(), ["live-host"]);
assert.ok(fs.existsSync(activePath), "active stream path must survive clear-all");
assert.deepEqual(fs.readdirSync(hostDir), [path.basename(activePath)]);
// Stream must still accept writes after clear-all (not unlinked/orphaned).
sessionLogStreamManager.appendData(sessionId, "after clear\n");
assert.equal(sessionLogStreamManager.hasStream(sessionId), true);
const stoppedPath = await sessionLogStreamManager.stopStream(sessionId, startToken);
assert.equal(path.resolve(stoppedPath), path.resolve(activePath));
const content = fs.readFileSync(activePath, "utf8");
assert.match(content, /live body/);
assert.match(content, /after clear/);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("clearSessionLogsDir unions worker-owned active paths with main-process streams", async () => {
const directory = path.join(TEMP_ROOT, `clear-worker-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { clearSessionLogsDir } = loadBridgeWithDialog({});
try {
const hostDir = path.join(directory, "worker-host");
fs.mkdirSync(hostDir, { recursive: true });
// Simulate a live worker-owned auto-save file (invisible to main manager).
const workerLivePath = path.join(hostDir, "2026-01-05T06-07-08.log");
fs.writeFileSync(workerLivePath, "worker live body\n");
// Stale sibling in the same host dir should still be cleared.
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.log"), "stale\n");
// Unrelated pure host folder still cleared.
const staleHostDir = path.join(directory, "stale-worker-host");
fs.mkdirSync(staleHostDir, { recursive: true });
fs.writeFileSync(path.join(staleHostDir, "2026-01-03T00-00-00.txt"), "old\n");
let requestedChannel = null;
const terminalWorkerManager = {
isRunning: () => true,
request: async (channel) => {
requestedChannel = channel;
return [workerLivePath];
},
};
const result = await clearSessionLogsDir(null, { directory }, terminalWorkerManager);
assert.equal(requestedChannel, "netcatty:sessionLogs:getActivePaths");
assert.equal(result.success, true);
assert.equal(result.failedCount, 0);
assert.equal(result.deletedCount, 2); // stale sibling + pure stale host
assert.deepEqual(fs.readdirSync(directory).sort(), ["worker-host"]);
assert.ok(fs.existsSync(workerLivePath), "worker-owned active stream must survive clear-all");
assert.deepEqual(fs.readdirSync(hostDir), [path.basename(workerLivePath)]);
assert.equal(fs.readFileSync(workerLivePath, "utf8"), "worker live body\n");
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("clearSessionLogsDir does not cold-start a stopped terminal worker", async () => {
const directory = path.join(TEMP_ROOT, `clear-no-worker-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { clearSessionLogsDir } = loadBridgeWithDialog({});
try {
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "host_2026-01-01T00-00-00.txt"), "export\n");
let requestCalls = 0;
const terminalWorkerManager = {
isRunning: () => false,
request: async () => {
requestCalls += 1;
return [];
},
};
const result = await clearSessionLogsDir(null, { directory }, terminalWorkerManager);
assert.equal(requestCalls, 0, "must not request worker paths when worker is stopped");
assert.equal(result.success, true);
assert.equal(result.deletedCount, 1);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("clearSessionLogsDir preserves snapshot-only host dirs before the first file exists", async () => {
const directory = path.join(TEMP_ROOT, `clear-snap-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-snap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const { clearSessionLogsDir } = loadBridgeWithDialog({});
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
// txt streams keep renderer state in memory and only materialize the file
// on snapshot flush — clear-all must still treat the registered path as live.
const startToken = sessionLogStreamManager.startStream(sessionId, {
hostLabel: "snap-host",
hostname: "snap.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 4, 5, 6, 7),
});
assert.ok(startToken);
const activePaths = sessionLogStreamManager.getActiveLogPaths();
assert.equal(activePaths.length, 1);
const activePath = activePaths[0];
const hostDir = path.dirname(activePath);
assert.equal(path.basename(hostDir), "snap-host");
assert.equal(fs.existsSync(activePath), false, "snapshot file must not exist yet");
// Sibling inactive log would make the host dir look like a pure log folder
// if we only inspected readdir and missed the not-yet-created active path.
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.txt"), "stale\n");
const staleHostDir = path.join(directory, "stale-snap-host");
fs.mkdirSync(staleHostDir, { recursive: true });
fs.writeFileSync(path.join(staleHostDir, "2026-01-03T00-00-00.txt"), "old\n");
const result = await clearSessionLogsDir(null, { directory });
assert.equal(result.success, true);
assert.equal(result.failedCount, 0);
assert.equal(result.deletedCount, 2); // stale sibling + pure stale host
assert.deepEqual(fs.readdirSync(directory).sort(), ["snap-host"]);
assert.ok(fs.existsSync(hostDir), "active snapshot host dir must survive clear-all");
assert.deepEqual(fs.readdirSync(hostDir), []);
// Stream must still be able to write its first snapshot after clear-all.
sessionLogStreamManager.appendData(sessionId, "first snapshot body\n");
const stoppedPath = await sessionLogStreamManager.stopStream(sessionId, startToken);
assert.equal(path.resolve(stoppedPath), path.resolve(activePath));
await waitForPath(activePath);
assert.match(fs.readFileSync(activePath, "utf8"), /first snapshot body/);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("clearSessionLogsDir leaves unrelated empty or non-log folders alone", async () => {
const directory = path.join(TEMP_ROOT, `clear-unrelated-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { clearSessionLogsDir } = loadBridgeWithDialog({});
try {
fs.mkdirSync(path.join(directory, "empty-folder"), { recursive: true });
fs.mkdirSync(path.join(directory, "docs"), { recursive: true });
fs.writeFileSync(path.join(directory, "docs", "readme.md"), "hi\n");
const result = await clearSessionLogsDir(null, { directory });
assert.equal(result.success, true);
assert.equal(result.deletedCount, 0);
assert.equal(result.failedCount, 0);
assert.deepEqual(fs.readdirSync(directory).sort(), ["docs", "empty-folder"]);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("clearSessionLogsDir is a no-op when the directory does not exist", async () => {
const directory = path.join(TEMP_ROOT, `clear-missing-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const { clearSessionLogsDir } = loadBridgeWithDialog({});
const result = await clearSessionLogsDir(null, { directory });
assert.deepEqual(result, { success: true, deletedCount: 0, failedCount: 0 });
});
test("clearSessionLogsDir reports an error when no directory is specified", async () => {
const { clearSessionLogsDir } = loadBridgeWithDialog({});
const result = await clearSessionLogsDir(null, {});
assert.equal(result.success, false);
assert.equal(result.deletedCount, 0);
assert.equal(result.failedCount, 0);
assert.ok(result.error);
});
test("manual session logs survive tokenless stale stops and stop through the bridge", async () => {
const directory = path.join(TEMP_ROOT, `manual-token-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.log");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath }),
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "H3C switch",
preferredDirectory: directory,
initialLine: "started\n",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
sessionLogStreamManager.appendData(sessionId, "before-stale\n");
const staleResult = await sessionLogStreamManager.stopStream(sessionId);
assert.equal(staleResult, null);
assert.equal(sessionLogStreamManager.hasStream(sessionId), true);
sessionLogStreamManager.appendData(sessionId, "after-stale\n");
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.success, true);
assert.equal(stopResult.stopped, true);
assert.equal(stopResult.filePath, filePath);
assert.equal(sessionLogStreamManager.hasStream(sessionId), false);
const content = fs.readFileSync(filePath, "utf8");
assert.match(content, /started/);
assert.match(content, /before-stale/);
assert.match(content, /after-stale/);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session logs preserve carriage-return output as a raw session stream", async () => {
const directory = path.join(TEMP_ROOT, `manual-raw-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.log");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath }),
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "H3C switch",
preferredDirectory: directory,
initialLine: "H3C>",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
sessionLogStreamManager.appendData(sessionId, "\rdisplay version\r\nComware Software\r\nH3C>");
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.success, true);
assert.equal(stopResult.stopped, true);
assert.equal(
fs.readFileSync(filePath, "utf8"),
"H3C>\n\rdisplay version\r\nComware Software\r\nH3C>",
);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session logs keep prompt and normal command echo on one line", async () => {
const directory = path.join(TEMP_ROOT, `manual-normal-echo-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.log");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath }),
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "Linux host",
preferredDirectory: directory,
initialLine: "root@host:~# ",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
sessionLogStreamManager.appendData(sessionId, "ls\r\nfile\r\nroot@host:~# ");
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.success, true);
assert.equal(stopResult.stopped, true);
assert.equal(
fs.readFileSync(filePath, "utf8"),
"root@host:~# ls\r\nfile\r\nroot@host:~# ",
);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session log save dialog only offers log files and normalizes extension", async () => {
const directory = path.join(TEMP_ROOT, `manual-log-ext-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const selectedPath = path.join(directory, "manual.txt");
const expectedPath = `${selectedPath}.log`;
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let dialogOptions;
const dialogMock = {
showSaveDialog: async (options) => {
dialogOptions = options;
return { canceled: false, filePath: selectedPath };
},
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "H3C switch",
preferredDirectory: directory,
initialLine: "",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
assert.equal(startResult.filePath, expectedPath);
assert.deepEqual(
dialogOptions.filters.map((filter) => filter.name),
["Log Files", "All Files"],
);
sessionLogStreamManager.appendData(sessionId, "body\r\n");
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.filePath, expectedPath);
assert.equal(fs.readFileSync(expectedPath, "utf8"), "body\r\n");
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session logs honor the configured plain-text format", async () => {
const directory = path.join(TEMP_ROOT, `manual-txt-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const selectedPath = path.join(directory, "manual");
const expectedPath = `${selectedPath}.txt`;
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let dialogOptions;
const dialogMock = {
showSaveDialog: async (options) => {
dialogOptions = options;
return { canceled: false, filePath: selectedPath };
},
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "Linux host",
preferredDirectory: directory,
format: "txt",
initialLine: "root@host:~# ",
});
assert.equal(startResult.success, true);
assert.equal(startResult.filePath, expectedPath);
assert.match(dialogOptions.defaultPath, /\.txt$/);
assert.deepEqual(dialogOptions.filters[0], { name: "Text Files", extensions: ["txt"] });
sessionLogStreamManager.appendData(
sessionId,
"\u001b[31mdisplay\u001b[0m\r\npage 1\r\n--More--\r \rpage 2\r\n",
);
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.filePath, expectedPath);
assert.equal(fs.readFileSync(expectedPath, "utf8"), "root@host:~# display\npage 1\npage 2");
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session logs honor HTML format and timestamps", async () => {
const directory = path.join(TEMP_ROOT, `manual-html-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.html");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath }),
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "HTML / host:22",
preferredDirectory: directory,
format: "html",
timestampsEnabled: true,
initialLine: "",
});
assert.equal(startResult.success, true);
sessionLogStreamManager.appendData(sessionId, "ready\r\n");
await stopManualSessionLog(null, { sessionId });
const content = fs.readFileSync(filePath, "utf8");
assert.match(content, /<!DOCTYPE html>/);
assert.match(content, /HTML \/ host:22/);
assert.doesNotMatch(content, /HTML _ host_22/);
assert.match(content, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] ready/);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual rendered logs report final write failures", async () => {
const directory = path.join(TEMP_ROOT, `manual-write-failure-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath }),
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const originalWriteFile = fs.promises.writeFile;
try {
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "failing host",
preferredDirectory: directory,
format: "txt",
initialLine: "",
});
assert.equal(startResult.success, true);
sessionLogStreamManager.appendData(sessionId, "body\r\n");
fs.promises.writeFile = async () => {
throw new Error("disk full");
};
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.deepEqual(stopResult, {
success: false,
stopped: true,
error: "Failed to finalize session log",
});
} finally {
fs.promises.writeFile = originalWriteFile;
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("registerHandlers taps terminal worker output into main-process manual session logs", async () => {
const directory = path.join(TEMP_ROOT, `manual-worker-tap-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.log");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath }),
};
const bridge = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const handlers = new Map();
const ipcMainMock = {
handle(channel, handler) {
handlers.set(channel, handler);
},
};
let outputTap = null;
bridge.registerHandlers(ipcMainMock, {
terminalWorkerManager: {
addOutputTap(listener) {
outputTap = listener;
return () => {};
},
},
});
assert.equal(typeof outputTap, "function");
try {
const startResult = await handlers.get("netcatty:sessionLog:manualStart")(null, {
sessionId,
sessionName: "worker host",
preferredDirectory: directory,
initialLine: "root@host:~# ",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
// Terminal output produced in the worker process reaches the main
// process only through the output tap.
outputTap(sessionId, "ls\r\nfile\r\n");
outputTap("other-session", "ignored\r\n");
outputTap(sessionId, undefined);
const stopResult = await handlers.get("netcatty:sessionLog:manualStop")(null, { sessionId });
assert.equal(stopResult.success, true);
assert.equal(stopResult.stopped, true);
assert.equal(fs.readFileSync(filePath, "utf8"), "root@host:~# ls\r\nfile\r\n");
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session log canceling normalized overwrite keeps existing file", async () => {
const directory = path.join(TEMP_ROOT, `manual-log-overwrite-cancel-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const selectedPath = path.join(directory, "manual.txt");
const expectedPath = `${selectedPath}.log`;
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let messageOptions;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath: selectedPath }),
showMessageBox: async (options) => {
messageOptions = options;
return { response: 1 };
},
};
const { startManualSessionLog } = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(expectedPath, "old body", "utf8");
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "H3C switch",
preferredDirectory: directory,
initialLine: "",
});
assert.deepEqual(startResult, { success: true, started: false, canceled: true });
assert.equal(sessionLogStreamManager.hasStream(sessionId), false);
assert.equal(fs.readFileSync(expectedPath, "utf8"), "old body");
assert.deepEqual(messageOptions.buttons, ["Overwrite", "Cancel"]);
assert.equal(messageOptions.cancelId, 1);
assert.match(messageOptions.message, /manual\.txt\.log/);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session log confirmed normalized overwrite replaces existing file", async () => {
const directory = path.join(TEMP_ROOT, `manual-log-overwrite-confirm-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const selectedPath = path.join(directory, "manual.txt");
const expectedPath = `${selectedPath}.log`;
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let messageShown = false;
const dialogMock = {
showSaveDialog: async () => ({ canceled: false, filePath: selectedPath }),
showMessageBox: async () => {
messageShown = true;
return { response: 0 };
},
};
const {
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(expectedPath, "old body", "utf8");
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "H3C switch",
preferredDirectory: directory,
initialLine: "",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
assert.equal(startResult.filePath, expectedPath);
assert.equal(messageShown, true);
sessionLogStreamManager.appendData(sessionId, "new body\r\n");
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.filePath, expectedPath);
assert.equal(fs.readFileSync(expectedPath, "utf8"), "new body\r\n");
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session log two-phase start re-samples alternate-screen after dialog", async () => {
const directory = path.join(TEMP_ROOT, `manual-two-phase-alt-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "manual.txt");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let dialogCalls = 0;
const dialogMock = {
showSaveDialog: async () => {
dialogCalls += 1;
return { canceled: false, filePath };
},
};
const {
chooseManualSessionLogPath,
startManualSessionLog,
stopManualSessionLog,
} = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
fs.mkdirSync(directory, { recursive: true });
const chooseResult = await chooseManualSessionLogPath(null, {
sessionId,
sessionName: "host",
preferredDirectory: directory,
format: "txt",
});
assert.equal(chooseResult.success, true);
assert.equal(chooseResult.canceled, false);
assert.equal(chooseResult.filePath, filePath);
assert.equal(typeof chooseResult.selectionToken, "string");
assert.equal(dialogCalls, 1);
// Simulated post-dialog state: TUI is active when the stream actually starts.
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "host",
selectionToken: chooseResult.selectionToken,
format: "txt",
alternateScreenActive: true,
initialLine: "",
});
assert.equal(startResult.success, true);
assert.equal(startResult.started, true);
// Starting with a selection token must not reopen the save dialog.
assert.equal(dialogCalls, 1);
sessionLogStreamManager.appendData(sessionId, "~\nstatus line\n\x1b[?1049l$ done\n");
const stopResult = await stopManualSessionLog(null, { sessionId });
assert.equal(stopResult.stopped, true);
assert.equal(fs.readFileSync(filePath, "utf8"), "$ done");
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("manual session log rejects renderer-supplied filePath without selection token", async () => {
const directory = path.join(TEMP_ROOT, `manual-path-reject-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const filePath = path.join(directory, "evil.txt");
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let dialogCalls = 0;
const dialogMock = {
showSaveDialog: async () => {
dialogCalls += 1;
return { canceled: false, filePath };
},
};
const { startManualSessionLog } = loadBridgeWithDialog(dialogMock);
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
try {
fs.mkdirSync(directory, { recursive: true });
const startResult = await startManualSessionLog(null, {
sessionId,
sessionName: "host",
filePath,
format: "txt",
initialLine: "",
});
assert.equal(startResult.success, false);
assert.equal(startResult.started, false);
assert.match(startResult.error || "", /save dialog/i);
assert.equal(dialogCalls, 0);
assert.equal(sessionLogStreamManager.hasStream(sessionId), false);
assert.equal(fs.existsSync(filePath), false);
} finally {
await sessionLogStreamManager.cleanupAll();
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("html wrapper adapts to light/dark browser color scheme", () => {
const bridge = loadBridgeWithDialog({});
const html = bridge.wrapTerminalHtmlContent("hello", "host-1", 0);
// Day palette is the default; dark applies only via prefers-color-scheme.
assert.match(html, /--term-default-bg:\s*#ffffff/);
assert.match(html, /color-scheme:\s*light dark/);
assert.match(html, /<meta name="color-scheme" content="light dark">/);
assert.match(html, /@media \(prefers-color-scheme: dark\)/);
assert.match(html, /--term-default-bg:\s*#1e1e1e/);
// Fonts remain monospace with a platform-appropriate stack.
assert.match(html, /font-family:[^;]*ui-monospace[^;]*monospace/);
});
test("all light-mode ANSI foregrounds remain readable on the default background", () => {
const { terminalDataToHtml } = loadBridgeWithDialog({});
let data = "";
for (let index = 0; index < 256; index++) data += `\x1b[38;5;${index}mcolor ${index}\x1b[0m\n`;
data += "\x1b[38;2;255;255;255mwhite\x1b[0m\n\x1b[38;2;255;255;0myellow\x1b[0m\n\x1b[38;2;18;52;86mdark\x1b[0m";
const html = terminalDataToHtml(data, "host", 0);
const [lightCss, darkCss] = html.split("@media");
const luminance = (hex) => {
const channels = hex.match(/[a-f0-9]{2}/gi).map((channel) => {
const value = parseInt(channel, 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
const background = luminance(lightCss.match(/--term-default-bg:\s*(#[a-f0-9]{6})/i)[1]);
const palette = [...lightCss.matchAll(/--(ansi-\d+|term-custom-[a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)];
assert.equal(palette.length, 257);
for (const [, index, color] of palette) {
const foreground = luminance(color);
const contrast = (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05);
assert.ok(contrast >= 4.5, `ANSI ${index} (${color}) contrast ${contrast.toFixed(2)} is below 4.5:1`);
}
for (const [, hex, color] of lightCss.matchAll(/--term-custom-([a-f0-9]{6}):\s*(#[a-f0-9]{6})/gi)) {
assert.ok(darkCss.includes(`--term-custom-${hex}: #${hex};`));
if (1.05 / (luminance(hex) + 0.05) >= 4.5) assert.equal(color, `#${hex}`);
}
assert.match(html, /color: var\(--term-custom-ffffff, #ffffff\)[^>]*>white/);
});