964 lines
39 KiB
JavaScript
964 lines
39 KiB
JavaScript
|
|
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/);
|
||
|
|
});
|