Files
NetMesh/electron/bridges/sessionLogStreamManager.test.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

907 lines
33 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
appendData,
hasStream,
registerProgrammaticCommandLogRewrite,
registerSudoAutofillInput,
startStream,
startStreamToFile,
stopStream,
} = require("./sessionLogStreamManager.cjs");
const TEMP_ROOT = path.join(__dirname, ".tmp-session-log-stream-tests");
test("txt stream live snapshots include pending ED2 cleared screens", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
appendData(sessionId, "before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
const filePath = await waitForFileContent(directory, "before tui\n\nframe one\n\nframe two");
assert.equal(fs.readFileSync(filePath, "utf8"), "before tui\n\nframe one\n\nframe two");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream finalization commits pending ED2 cleared screens", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
appendData(sessionId, "before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
const filePath = await stopStream(sessionId);
assert.equal(fs.readFileSync(filePath, "utf8"), "before tui\n\nframe one\n\nframe two");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStream returns a token; stopStream with stale token leaves the active stream alone (issue #916)", async () => {
// Reproduces the bug shape where the user clicks "Restart" after a
// session disconnect. The renderer reuses the same sessionId, so the
// bridges call startStream(sessionId, ...) again. If a stale close
// handler from the previous incarnation later fires
// stopStream(sessionId), it must NOT clobber the freshly-started stream.
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
// First connection.
const firstToken = startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
assert.ok(firstToken, "first startStream should return a token");
appendData(sessionId, "first-session\n");
const firstPath = await stopStream(sessionId, firstToken);
assert.ok(firstPath, "first stopStream should return its file path");
// User clicks "Restart" - same sessionId, fresh stream.
const secondToken = startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 10),
});
assert.ok(secondToken, "second startStream should return a token");
assert.notEqual(firstToken, secondToken, "tokens must be unique across starts");
appendData(sessionId, "second-session-before-stale\n");
// SIMULATE THE BUG: a stale close handler from the previous SSH
// connection finally fires and calls stopStream with the OLD token.
// The current implementation must ignore it.
const staleResult = await stopStream(sessionId, firstToken);
assert.equal(staleResult, null, "stale stopStream must be a no-op");
assert.equal(hasStream(sessionId), true, "second stream must still be active after stale stop");
// More output for the new session — must reach the file.
appendData(sessionId, "second-session-after-stale\n");
const secondPath = await stopStream(sessionId, secondToken);
assert.ok(secondPath, "second stopStream should return its file path");
assert.notEqual(firstPath, secondPath, "second connection should write to a new file");
// Both files exist and contain the expected output.
assert.equal(fs.readFileSync(firstPath, "utf8"), "first-session\n");
assert.equal(
fs.readFileSync(secondPath, "utf8"),
"second-session-before-stale\nsecond-session-after-stale\n",
);
} finally {
// Belt-and-suspenders cleanup; both streams are normally already stopped.
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("stopStream without a token still tears down the current stream (back-compat)", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
appendData(sessionId, "data\n");
const finalPath = await stopStream(sessionId);
assert.ok(finalPath);
assert.equal(fs.readFileSync(finalPath, "utf8"), "data\n");
assert.equal(hasStream(sessionId), false);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("token-required explicit file streams ignore tokenless stale stops", 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)}`;
let token;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "raw",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
stopRequiresToken: true,
});
assert.equal(result.ok, true);
token = result.token;
appendData(sessionId, "before-stale\n");
const staleResult = await stopStream(sessionId);
assert.equal(staleResult, null);
assert.equal(hasStream(sessionId), true);
appendData(sessionId, "after-stale\n");
const finalPath = await stopStream(sessionId, token);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "before-stale\nafter-stale\n");
} finally {
if (hasStream(sessionId)) {
await stopStream(sessionId, token);
}
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile writes to an explicit raw log file path", async () => {
const directory = path.join(TEMP_ROOT, `manual-${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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "raw",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
initialLine: "header\n",
});
assert.equal(result.ok, true);
appendData(sessionId, "body\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "header\nbody\n");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile keeps unterminated initial line joined to ordinary raw output", async () => {
const directory = path.join(TEMP_ROOT, `manual-ordinary-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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "raw",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
initialLine: "root@host:~# ",
separateInitialLineBeforeLeadingCarriageReturn: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "ls\r\nfile\r\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "root@host:~# ls\r\nfile\r\n");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile separates unterminated initial line before leading carriage return", async () => {
const directory = path.join(TEMP_ROOT, `manual-leading-cr-${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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "raw",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
initialLine: "H3C>",
separateInitialLineBeforeLeadingCarriageReturn: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "\rdisplay version\r\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "H3C>\n\rdisplay version\r\n");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile does not add a separator before leading CRLF", async () => {
const directory = path.join(TEMP_ROOT, `manual-leading-crlf-${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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "raw",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
initialLine: "root@host:~# command",
separateInitialLineBeforeLeadingCarriageReturn: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "\r\noutput\r\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "root@host:~# command\r\noutput\r\n");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile does not add a separator before split leading CRLF", async () => {
const directory = path.join(TEMP_ROOT, `manual-split-leading-crlf-${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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "raw",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
initialLine: "root@host:~# command",
separateInitialLineBeforeLeadingCarriageReturn: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "\r");
appendData(sessionId, "\noutput\r\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "root@host:~# command\r\noutput\r\n");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile flushes a pending leading carriage return on stop", async () => {
const directory = path.join(TEMP_ROOT, `manual-pending-leading-cr-${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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "raw",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
initialLine: "H3C>",
separateInitialLineBeforeLeadingCarriageReturn: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "\r");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "H3C>\n\r");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile can write human-readable text logs from ANSI terminal output", async () => {
const directory = path.join(TEMP_ROOT, `manual-txt-${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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "txt",
hostLabel: "manual",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
assert.equal(result.ok, true);
appendData(
sessionId,
"\x1b[01;32mroot@MyNAS\x1b[00m:\x1b[01;34m~\x1b[00m# ip a\r\n1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\r\n\x1b[01;32mroot@MyNAS\x1b[00m:\x1b[01;34m~\x1b[00m# ",
);
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(
fs.readFileSync(filePath, "utf8"),
"root@MyNAS:~# ip a\n1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\nroot@MyNAS:~#",
);
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStreamToFile seeds alternate-screen omission for late-started txt logs", async () => {
const directory = path.join(TEMP_ROOT, `manual-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)}`;
try {
const result = startStreamToFile(sessionId, {
filePath,
format: "txt",
hostLabel: "manual",
alternateScreenActive: true,
});
assert.equal(result.ok, true);
appendData(sessionId, "~\nstatus line\n\x1b[?1049l$ done\n");
const finalPath = await stopStream(sessionId);
assert.equal(finalPath, filePath);
assert.equal(fs.readFileSync(filePath, "utf8"), "$ done");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream hides sudo autofill prompt markers and rewritten command echoes", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const marker = "__NETCATTY_SUDO_test__";
const prepared = `sudo -p '[sudo] password for %p: ${marker}' whoami`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerSudoAutofillInput(sessionId, `\x15${prepared}\r`);
appendData(sessionId, `${prepared}\r\n`);
appendData(sessionId, `[sudo] password for alice: ${marker}`);
appendData(sessionId, "\r\nroot\n");
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, "sudo whoami\r\n[sudo] password for alice: \r\nroot\n");
assert.ok(!content.includes("NETCATTY_SUDO"));
assert.ok(!content.includes("sudo -p"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream rewrites protected snippet command echoes", async () => {
const directory = path.join(TEMP_ROOT, `stream-protected-snippet-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sentCommand = "sh -c 'private setup' && eval 'wrapped command'";
const displayCommand = "sudo apt update && sudo apt upgrade -y";
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerProgrammaticCommandLogRewrite(sessionId, { sentCommand, displayCommand });
appendData(sessionId, sentCommand.slice(0, 12));
appendData(sessionId, `${sentCommand.slice(12)}\r\nok\r\n`);
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, `${displayCommand}\r\nok\r\n`);
assert.ok(!content.includes("private setup"));
assert.ok(!content.includes("wrapped command"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream keeps programmatic command rewrites isolated per session", async () => {
const directory = path.join(TEMP_ROOT, `stream-protected-snippet-isolated-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const rewrittenSessionId = `session-a-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const plainSessionId = `session-b-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sentCommand = "sh -c 'private setup' && eval 'wrapped command'";
const displayCommand = "sudo apt update && sudo apt upgrade -y";
try {
startStream(rewrittenSessionId, {
hostLabel: "host-a",
hostname: "host-a.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
startStream(plainSessionId, {
hostLabel: "host-b",
hostname: "host-b.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 6),
});
registerProgrammaticCommandLogRewrite(rewrittenSessionId, { sentCommand, displayCommand });
appendData(rewrittenSessionId, `${sentCommand}\r\nok-a\r\n`);
appendData(plainSessionId, `${sentCommand}\r\nok-b\r\n`);
const rewrittenPath = await stopStream(rewrittenSessionId);
const plainPath = await stopStream(plainSessionId);
const rewrittenContent = fs.readFileSync(rewrittenPath, "utf8");
const plainContent = fs.readFileSync(plainPath, "utf8");
assert.equal(rewrittenContent, `${displayCommand}\r\nok-a\r\n`);
assert.equal(plainContent, `${sentCommand}\r\nok-b\r\n`);
} finally {
await stopStream(rewrittenSessionId);
await stopStream(plainSessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream hides split sudo autofill markers", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-split-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const marker = "__NETCATTY_SUDO_split__";
const prepared = `sudo -p '[sudo] password for %p: ${marker}' id`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerSudoAutofillInput(sessionId, `\x15${prepared}\r`);
appendData(sessionId, "sudo -p '[sudo] password for %p: __NET");
appendData(sessionId, "CATTY_SUDO_split__' id\r\n[sudo] password for alice: __NET");
appendData(sessionId, "CATTY_SUDO_split__\r\nuid=0\n");
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, "sudo id\r\n[sudo] password for alice: \r\nuid=0\n");
assert.ok(!content.includes("NETCATTY_SUDO"));
assert.ok(!content.includes("sudo -p"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream hides sudo autofill rewrites for long commands", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-long-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const marker = "__NETCATTY_SUDO_long__";
const longArg = "a".repeat(6000);
const original = `sudo printf '${longArg}'`;
const prepared = `sudo -p '[sudo] password for %p: ${marker}' printf '${longArg}'`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerSudoAutofillInput(sessionId, `\x15${prepared}\r`);
appendData(sessionId, prepared.slice(0, 2500));
appendData(sessionId, prepared.slice(2500, 5200));
appendData(sessionId, `${prepared.slice(5200)}\r\n`);
appendData(sessionId, `[sudo] password for alice: ${marker}\r\n`);
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, `${original}\r\n[sudo] password for alice: \r\n`);
assert.ok(!content.includes("NETCATTY_SUDO"));
assert.ok(!content.includes("sudo -p"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream releases non-prompt output after sudo autofill rewrite", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-warm-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const marker = "__NETCATTY_SUDO_warm__";
const prepared = `sudo -p '[sudo] password for %p: ${marker}' printf ok`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerSudoAutofillInput(sessionId, `\x15${prepared}\r`);
appendData(sessionId, `${prepared}\r\n`);
appendData(sessionId, "ok");
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, "sudo printf ok\r\nok");
assert.ok(!content.includes("NETCATTY_SUDO"));
assert.ok(!content.includes("sudo -p"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream keeps sudo autofill rewrite after ordinary output before prompt", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-notice-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const marker = "__NETCATTY_SUDO_notice__";
const prepared = `sudo -p '[sudo] password for %p: ${marker}' whoami`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerSudoAutofillInput(sessionId, `\x15${prepared}\r`);
appendData(sessionId, "sudo: this is the first time notice");
appendData(sessionId, `[sudo] password for alice: ${marker}\r\nroot\n`);
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, "sudo: this is the first time notice[sudo] password for alice: \r\nroot\n");
assert.ok(!content.includes("NETCATTY_SUDO"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream releases prompt-shaped warm sudo output", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-warm-prompt-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const marker = "__NETCATTY_SUDO_warm_prompt__";
const prepared = `sudo -p '[sudo] password for %p: ${marker}' printf '[sudo] password for alice: '`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerSudoAutofillInput(sessionId, `\x15${prepared}\r`);
appendData(sessionId, "[sudo] password for alice: ");
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, "[sudo] password for alice: ");
assert.ok(!content.includes("NETCATTY_SUDO"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream sanitizes later shell-history echoes after sudo autofill completes", async () => {
const directory = path.join(TEMP_ROOT, `stream-sudo-history-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const marker = "__NETCATTY_SUDO_history__";
const prepared = `sudo -p '[sudo] password for %p: ${marker}' whoami`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
registerSudoAutofillInput(sessionId, `\x15${prepared}\r`);
appendData(sessionId, `[sudo] password for alice: ${marker}\r\nroot\n`);
appendData(sessionId, `${prepared}\r\n`);
const finalPath = await stopStream(sessionId);
const content = fs.readFileSync(finalPath, "utf8");
assert.equal(content, "[sudo] password for alice: \r\nroot\nsudo whoami\r\n");
assert.ok(!content.includes("NETCATTY_SUDO"));
assert.ok(!content.includes("sudo -p"));
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("startStream host directory preserves valid Unicode labels and replaces path-unsafe characters", async () => {
const directory = path.join(TEMP_ROOT, `stream-unicode-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
const token = startStream(sessionId, {
hostLabel: "生产/服务器:东京*?<>|\0",
hostname: "fallback.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
assert.ok(token, "startStream should return a token");
appendData(sessionId, "data\n");
const finalPath = await stopStream(sessionId, token);
assert.equal(path.basename(path.dirname(finalPath)), "生产_服务器_东京______");
assert.equal(fs.readFileSync(finalPath, "utf8"), "data\n");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream timestamps complete lines without duplicating split chunks", async () => {
const directory = path.join(TEMP_ROOT, `stream-timestamps-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const times = [
new Date(2026, 0, 2, 3, 4, 5).getTime(),
new Date(2026, 0, 2, 3, 4, 6).getTime(),
new Date(2026, 0, 2, 3, 4, 7).getTime(),
];
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
timestampsEnabled: true,
timestampProvider: () => times.shift(),
});
appendData(sessionId, "first ");
appendData(sessionId, "line\nsecond line\npartial");
const filePath = await stopStream(sessionId);
assert.equal(
fs.readFileSync(filePath, "utf8"),
"[2026-01-02 03:04:05] first line\n[2026-01-02 03:04:06] second line\n[2026-01-02 03:04:07] partial",
);
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream timestamps rendered lines after carriage-return rewrites", async () => {
const directory = path.join(TEMP_ROOT, `stream-timestamps-cr-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
timestampsEnabled: true,
timestampProvider: () => new Date(2026, 0, 2, 3, 4, 5).getTime(),
});
appendData(sessionId, "old prompt\rdocker denied\n");
const filePath = await stopStream(sessionId);
assert.equal(
fs.readFileSync(filePath, "utf8"),
"[2026-01-02 03:04:05] docker denied",
);
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream updates a line timestamp when a later snapshot rewrites that line", async () => {
const directory = path.join(TEMP_ROOT, `stream-timestamps-live-cr-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const times = [
new Date(2026, 0, 2, 3, 4, 5).getTime(),
new Date(2026, 0, 2, 3, 4, 6).getTime(),
];
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
timestampsEnabled: true,
timestampProvider: () => times.shift(),
});
appendData(sessionId, "old prompt");
await waitForFileContent(directory, "[2026-01-02 03:04:05] old prompt");
appendData(sessionId, "\rdocker denied");
const filePath = await stopStream(sessionId);
assert.equal(
fs.readFileSync(filePath, "utf8"),
"[2026-01-02 03:04:06] docker denied",
);
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("html stream includes line timestamps in rendered content", async () => {
const directory = path.join(TEMP_ROOT, `stream-html-timestamps-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "html",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
timestampsEnabled: true,
timestampProvider: () => new Date(2026, 0, 2, 3, 4, 5).getTime(),
});
appendData(sessionId, "line\n");
const filePath = await stopStream(sessionId);
const html = fs.readFileSync(filePath, "utf8");
assert.match(html, /\[2026-01-02 03:04:05\] line/);
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("html stream timestamps rendered lines after carriage-return rewrites", async () => {
const directory = path.join(TEMP_ROOT, `stream-html-timestamps-cr-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "html",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
timestampsEnabled: true,
timestampProvider: () => new Date(2026, 0, 2, 3, 4, 5).getTime(),
});
appendData(sessionId, "old prompt\rdocker denied\n");
const filePath = await stopStream(sessionId);
const html = fs.readFileSync(filePath, "utf8");
assert.match(html, /\[2026-01-02 03:04:05\] docker denied/);
assert.doesNotMatch(html, /old prompt/);
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("raw stream keeps original bytes when timestamps are enabled", async () => {
const directory = path.join(TEMP_ROOT, `stream-raw-timestamps-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "raw",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
timestampsEnabled: true,
timestampProvider: () => new Date(2026, 0, 2, 3, 4, 5).getTime(),
});
appendData(sessionId, "line\n");
const filePath = await stopStream(sessionId);
assert.equal(fs.readFileSync(filePath, "utf8"), "line\n");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
async function waitForFileContent(directory, expectedContent) {
const deadline = Date.now() + 3000;
let lastContent = "";
while (Date.now() < deadline) {
const filePath = findFirstTxtFile(directory);
if (filePath && fs.existsSync(filePath)) {
lastContent = fs.readFileSync(filePath, "utf8");
if (lastContent === expectedContent) return filePath;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.fail(`Timed out waiting for live snapshot content. Last content: ${JSON.stringify(lastContent)}`);
}
function findFirstTxtFile(directory) {
if (!fs.existsSync(directory)) return null;
for (const hostDirName of fs.readdirSync(directory)) {
const hostDir = path.join(directory, hostDirName);
if (!fs.statSync(hostDir).isDirectory()) continue;
const fileName = fs.readdirSync(hostDir).find((name) => name.endsWith(".txt"));
if (fileName) return path.join(hostDir, fileName);
}
return null;
}