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
1170 lines
42 KiB
TypeScript
1170 lines
42 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
|
|
import {
|
|
OSC7_MARKER,
|
|
OSC7_SETUP_OTHER_USER_MARKER,
|
|
OSC7_SETUP_STAGED_MARKER,
|
|
buildOsc7ReloadCommand,
|
|
buildOsc7SetupCommand,
|
|
buildOsc7SetupExecCommand,
|
|
buildOsc7StageScriptCommand,
|
|
buildOsc7TypedSetupCommand,
|
|
getOsc7StagedScriptSha256,
|
|
parseOsc7SetupStagedPath,
|
|
runOsc7SetupAction,
|
|
shouldOfferOsc7SetupAction,
|
|
} from "./osc7Setup";
|
|
|
|
const runSetup = (env: NodeJS.ProcessEnv) => {
|
|
execFileSync("/bin/sh", ["-c", buildOsc7SetupCommand()], {
|
|
env: { ...process.env, ZDOTDIR: "", XDG_CONFIG_HOME: "", ...env },
|
|
stdio: "pipe",
|
|
});
|
|
};
|
|
|
|
const withTempHome = (prefix: string, fn: (home: string) => void) => {
|
|
const home = mkdtempSync(join(tmpdir(), prefix));
|
|
try {
|
|
fn(home);
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
const markerCount = (content: string) => content.split(OSC7_MARKER).length - 1;
|
|
|
|
const existingShells = (paths: string[]) => Array.from(new Set(paths.filter(existsSync)));
|
|
|
|
const supportedShells = () => existingShells(["/bin/bash", "/bin/zsh", "/usr/bin/zsh", "/opt/homebrew/bin/fish", "/usr/bin/fish"]);
|
|
|
|
const quoteShellArg = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
|
|
|
|
const extractOsc7Path = (output: string) => {
|
|
const escape = String.fromCharCode(0x1b);
|
|
const bell = String.fromCharCode(0x07);
|
|
const payloadStart = output.indexOf(`${escape}]7;`);
|
|
assert.notEqual(payloadStart, -1, "expected OSC 7 output");
|
|
|
|
const payload = output.slice(payloadStart + `${escape}]7;`.length);
|
|
const terminators = [payload.indexOf(escape), payload.indexOf(bell)].filter((index) => index >= 0);
|
|
const payloadEnd = terminators.length > 0 ? Math.min(...terminators) : payload.length;
|
|
const fileUrl = payload.slice(0, payloadEnd);
|
|
assert.ok(fileUrl.startsWith("file://"), "expected OSC 7 file URL");
|
|
|
|
return decodeURIComponent(new URL(fileUrl).pathname);
|
|
};
|
|
|
|
const runInteractiveHistoryProbe = ({
|
|
shellPath,
|
|
shellArgs,
|
|
dumpHistoryCommand,
|
|
dumpPath,
|
|
env,
|
|
input = buildOsc7SetupCommand(),
|
|
}: {
|
|
shellPath: string;
|
|
shellArgs: string[];
|
|
dumpHistoryCommand: string;
|
|
dumpPath: string;
|
|
env: NodeJS.ProcessEnv;
|
|
input?: string;
|
|
}) => {
|
|
const result = spawnSync(shellPath, shellArgs, {
|
|
env: { ...process.env, ZDOTDIR: "", XDG_CONFIG_HOME: "", ...env },
|
|
input: `${input.replace(/\r/g, "\n")}\n${dumpHistoryCommand}\nexit\n`,
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.ok(existsSync(dumpPath), result.stderr || result.stdout);
|
|
return readFileSync(dumpPath, "utf8");
|
|
};
|
|
|
|
test("shouldOfferOsc7SetupAction only allows remote shell-style sessions", () => {
|
|
assert.equal(shouldOfferOsc7SetupAction({ protocol: "ssh" }), true);
|
|
assert.equal(shouldOfferOsc7SetupAction({ protocol: "mosh" }), true);
|
|
assert.equal(shouldOfferOsc7SetupAction({ protocol: "et" }), true);
|
|
assert.equal(shouldOfferOsc7SetupAction({ protocol: "telnet" }), false);
|
|
assert.equal(shouldOfferOsc7SetupAction({ protocol: "ssh", isNetworkDevice: true }), false);
|
|
assert.equal(shouldOfferOsc7SetupAction({ protocol: "local", isLocalConnection: true }), false);
|
|
assert.equal(shouldOfferOsc7SetupAction({ protocol: "serial", isSerialConnection: true }), false);
|
|
});
|
|
|
|
test("runOsc7SetupAction configures in the background and only sends a small reload command", async () => {
|
|
const writes: Array<{ sessionId: string; data: string; automated?: boolean }> = [];
|
|
const localData: string[] = [];
|
|
let setupArgs: { sessionId: string; command: string } | null = null;
|
|
|
|
const result = await runOsc7SetupAction({
|
|
status: "connected",
|
|
sessionId: "session-1",
|
|
setupCommand: "printf setup-script",
|
|
setupOsc7Tracking: async (sessionId, command) => {
|
|
setupArgs = { sessionId, command };
|
|
return {
|
|
success: true,
|
|
stdout: "__NETCATTY_OSC7_SETUP_SHELL__=bash\n__NETCATTY_OSC7_SETUP_CONFIG__=/home/me/.bashrc\n\u001b]7;file://host/home/me\u0007",
|
|
stderr: "",
|
|
code: 0,
|
|
};
|
|
},
|
|
writeToSession: (sessionId, data, options) => {
|
|
writes.push({ sessionId, data, automated: options?.automated });
|
|
},
|
|
writeLocalTerminalData: (data) => {
|
|
localData.push(data);
|
|
},
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.deepEqual(setupArgs, { sessionId: "session-1", command: "printf setup-script" });
|
|
assert.equal(writes.length, 1);
|
|
assert.equal(writes[0].sessionId, "session-1");
|
|
assert.equal(writes[0].automated, true);
|
|
assert.match(writes[0].data, /source '\/home\/me\/\.bashrc'/);
|
|
assert.doesNotMatch(writes[0].data, /setup-script/);
|
|
assert.deepEqual(localData, ["\u001b]7;file://host/home/me\u0007"]);
|
|
});
|
|
|
|
test("runOsc7SetupAction stages the script and types a short runner for user-switched shells", async () => {
|
|
const writes: Array<{ sessionId: string; data: string; automated?: boolean }> = [];
|
|
const localData: string[] = [];
|
|
const setupCommands: string[] = [];
|
|
|
|
const result = await runOsc7SetupAction({
|
|
status: "connected",
|
|
sessionId: "session-1",
|
|
setupCommand: "printf setup-script",
|
|
setupOsc7Tracking: async (_sessionId, command) => {
|
|
setupCommands.push(command);
|
|
if (setupCommands.length === 1) {
|
|
return {
|
|
success: false,
|
|
stdout: `${OSC7_SETUP_OTHER_USER_MARKER}bash\n`,
|
|
stderr: "Netcatty OSC 7 setup: the active terminal shell belongs to another user\n",
|
|
code: 5,
|
|
error: "Netcatty OSC 7 setup: the active terminal shell belongs to another user",
|
|
};
|
|
}
|
|
return {
|
|
success: true,
|
|
stdout: `${OSC7_SETUP_STAGED_MARKER}/tmp/.netcatty-osc7-setup.abc123\n`,
|
|
stderr: "",
|
|
code: 0,
|
|
};
|
|
},
|
|
writeToSession: (sessionId, data, options) => {
|
|
writes.push({ sessionId, data, automated: options?.automated });
|
|
},
|
|
writeLocalTerminalData: (data) => {
|
|
localData.push(data);
|
|
},
|
|
});
|
|
|
|
assert.equal(result.success, true);
|
|
assert.equal(result.sentToTerminal, true);
|
|
assert.equal(setupCommands.length, 2);
|
|
assert.match(setupCommands[1], /mktemp/);
|
|
assert.equal(writes.length, 1);
|
|
assert.equal(writes[0].sessionId, "session-1");
|
|
assert.equal(writes[0].automated, true);
|
|
assert.match(writes[0].data, /NETCATTY_OSC7_FORCE_SHELL=bash/);
|
|
assert.match(writes[0].data, /'\/tmp\/\.netcatty-osc7-setup\.abc123'/);
|
|
assert.match(writes[0].data, /\.bashrc/);
|
|
// Single line: one history entry, so the appended bash cleanup deletes it.
|
|
assert.doesNotMatch(writes[0].data.slice(0, -1), /[\r\n]/);
|
|
assert.deepEqual(localData, []);
|
|
});
|
|
|
|
test("runOsc7SetupAction fails when the setup script cannot be staged", async () => {
|
|
const writes: string[] = [];
|
|
let calls = 0;
|
|
|
|
const result = await runOsc7SetupAction({
|
|
status: "connected",
|
|
sessionId: "session-1",
|
|
setupCommand: "printf setup-script",
|
|
setupOsc7Tracking: async () => {
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
return {
|
|
success: false,
|
|
stdout: `${OSC7_SETUP_OTHER_USER_MARKER}bash\n`,
|
|
stderr: "",
|
|
code: 5,
|
|
};
|
|
}
|
|
return { success: false, stdout: "", stderr: "mktemp: failed", code: 1 };
|
|
},
|
|
writeToSession: (_sessionId, data) => {
|
|
writes.push(data);
|
|
},
|
|
});
|
|
|
|
assert.equal(result.success, false);
|
|
assert.match(result.error || "", /mktemp: failed|setup failed/);
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("runOsc7SetupAction reports unsupported user-switched shells instead of typing", async () => {
|
|
const writes: string[] = [];
|
|
|
|
const result = await runOsc7SetupAction({
|
|
status: "connected",
|
|
sessionId: "session-1",
|
|
setupCommand: "printf setup-script",
|
|
setupOsc7Tracking: async () => ({
|
|
success: false,
|
|
stdout: `${OSC7_SETUP_OTHER_USER_MARKER}dash\n`,
|
|
stderr: "",
|
|
code: 5,
|
|
}),
|
|
writeToSession: (_sessionId, data) => {
|
|
writes.push(data);
|
|
},
|
|
});
|
|
|
|
assert.equal(result.success, false);
|
|
assert.match(result.error || "", /does not support/);
|
|
assert.deepEqual(writes, []);
|
|
});
|
|
|
|
test("runOsc7SetupAction fails without reload metadata instead of reporting a partial setup", async () => {
|
|
const writes: string[] = [];
|
|
const localData: string[] = [];
|
|
|
|
const result = await runOsc7SetupAction({
|
|
status: "connected",
|
|
sessionId: "session-1",
|
|
setupCommand: "printf setup-script",
|
|
setupOsc7Tracking: async () => ({
|
|
success: true,
|
|
stdout: "\u001b]7;file://host/home/me\u0007",
|
|
stderr: "",
|
|
code: 0,
|
|
}),
|
|
writeToSession: (_sessionId, data) => {
|
|
writes.push(data);
|
|
},
|
|
writeLocalTerminalData: (data) => {
|
|
localData.push(data);
|
|
},
|
|
});
|
|
|
|
assert.equal(result.success, false);
|
|
assert.match(result.error || "", /reload metadata/);
|
|
assert.deepEqual(writes, []);
|
|
assert.deepEqual(localData, []);
|
|
});
|
|
|
|
test("buildOsc7SetupCommand configures bash once and prompt loading stays idempotent", () => {
|
|
withTempHome("netcatty-osc7-bash-", (home) => {
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.equal(markerCount(bashrc), 2);
|
|
assert.match(bashrc, /PROMPT_COMMAND/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
assert.match(bashrc, /netcatty-osc7-version: 2/);
|
|
assert.match(bashrc, /declare -\[A-Za-z\]\*a|declare -p PROMPT_COMMAND/);
|
|
|
|
const output = execFileSync(
|
|
"/bin/bash",
|
|
[
|
|
"-lc",
|
|
`PROMPT_COMMAND=existing; source ${JSON.stringify(bashrcPath)}; source ${JSON.stringify(bashrcPath)}; printf '%s' "$PROMPT_COMMAND"`,
|
|
],
|
|
{ env: { ...process.env, HOME: home } },
|
|
).toString("utf8");
|
|
|
|
assert.match(output, /existing/);
|
|
// Guarded hook installed once even after double-source.
|
|
assert.equal(output.split("declare -F __netcatty_osc7_prompt").length - 1, 1);
|
|
// Bare v1 hook must not remain in PROMPT_COMMAND (only the function body may mention osc7_cwd).
|
|
assert.doesNotMatch(output, /(^|\n)osc7_cwd(\n|$)/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand upgrades legacy bash snippet in place", () => {
|
|
withTempHome("netcatty-osc7-bash-upgrade-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"# user preamble",
|
|
"",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"osc7_cwd() {",
|
|
" printf 'legacy'\\n",
|
|
"}",
|
|
'PROMPT_COMMAND="osc7_cwd"',
|
|
"# <<< Netcatty OSC 7 cwd tracking <<<",
|
|
"",
|
|
"# user epilogue",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.equal(markerCount(bashrc), 2);
|
|
assert.match(bashrc, /netcatty-osc7-version: 2/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
assert.match(bashrc, /# user preamble/);
|
|
assert.match(bashrc, /# user epilogue/);
|
|
assert.doesNotMatch(bashrc, /printf 'legacy'/);
|
|
assert.equal(bashrc.split("PROMPT_COMMAND=\"osc7_cwd\"").length - 1, 0);
|
|
|
|
// Second run is a no-op once version 2 is present.
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
assert.equal(markerCount(readFileSync(bashrcPath, "utf8")), 2);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand does not truncate bashrc when start marker lacks end", () => {
|
|
withTempHome("netcatty-osc7-bash-incomplete-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"# keep-me-before",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"osc7_cwd() { :; }",
|
|
"# important-user-config-after-open-marker",
|
|
"alias ll='ls -la'",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.match(bashrc, /# keep-me-before/);
|
|
assert.match(bashrc, /# important-user-config-after-open-marker/);
|
|
assert.match(bashrc, /alias ll=/);
|
|
assert.match(bashrc, /netcatty-osc7-version: 2/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
// Open region kept + complete v2 appended (no truncation of user lines).
|
|
assert.equal((bashrc.match(/# >>> Netcatty OSC 7 cwd tracking >>>/g) || []).length, 2);
|
|
assert.equal((bashrc.match(/# <<< Netcatty OSC 7 cwd tracking <<</g) || []).length, 1);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand appends when markers are present but unbalanced", () => {
|
|
withTempHome("netcatty-osc7-bash-unbalanced-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"# orphan end first",
|
|
"# <<< Netcatty OSC 7 cwd tracking <<<",
|
|
"# user config",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"alias ll='ls -la'",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.match(bashrc, /# orphan end first/);
|
|
assert.match(bashrc, /# user config/);
|
|
assert.match(bashrc, /alias ll=/);
|
|
assert.match(bashrc, /netcatty-osc7-version: 2/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
// Original markers kept; complete v2 appended.
|
|
assert.equal((bashrc.match(/# >>> Netcatty OSC 7 cwd tracking >>>/g) || []).length, 2);
|
|
assert.equal((bashrc.match(/# <<< Netcatty OSC 7 cwd tracking <<</g) || []).length, 2);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand recovers from partial v2 write missing end marker", () => {
|
|
withTempHome("netcatty-osc7-bash-partial-v2-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"# keep-me",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"# netcatty-osc7-version: 2",
|
|
"osc7_cwd() { :; }",
|
|
"# interrupted before end marker",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.match(bashrc, /# keep-me/);
|
|
assert.match(bashrc, /# interrupted before end marker/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
assert.match(bashrc, /declare -F __netcatty_osc7_prompt/);
|
|
// Partial open kept + one recovered complete block; second setup is a no-op.
|
|
assert.equal((bashrc.match(/# <<< Netcatty OSC 7 cwd tracking <<</g) || []).length, 1);
|
|
assert.equal((bashrc.match(/# netcatty-osc7-version: 2/g) || []).length, 2);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand preserves user lines after mid-construct interruption", () => {
|
|
withTempHome("netcatty-osc7-bash-mid-construct-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"# keep-me",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"# netcatty-osc7-version: 2",
|
|
"osc7_cwd() {",
|
|
"alias keep_user_alias='yes'",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
// Do not rewrite/truncate even if the partial body is already unusable.
|
|
assert.match(bashrc, /# keep-me/);
|
|
assert.match(bashrc, /alias keep_user_alias=/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand recovers when version line exists with unbalanced markers", () => {
|
|
withTempHome("netcatty-osc7-bash-version-unbalanced-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
// Orphan end + open start with version: grepping version+end alone would
|
|
// falsely treat this as complete; balanced/complete-v2 checks must force append.
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"# <<< Netcatty OSC 7 cwd tracking <<<",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"# netcatty-osc7-version: 2",
|
|
"osc7_cwd() { :; }",
|
|
"# interrupted before end marker",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.match(bashrc, /# interrupted before end marker/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
assert.match(bashrc, /declare -F __netcatty_osc7_prompt/);
|
|
// Orphan end + one recovered complete block; second run no-ops.
|
|
assert.equal((bashrc.match(/# <<< Netcatty OSC 7 cwd tracking <<</g) || []).length, 2);
|
|
assert.equal((bashrc.match(/# netcatty-osc7-version: 2/g) || []).length, 2);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand ignores marker text embedded in echo commands", () => {
|
|
withTempHome("netcatty-osc7-bash-echo-marker-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
'echo "# >>> Netcatty OSC 7 cwd tracking >>>"',
|
|
"alias keep_me='yes'",
|
|
'echo "# <<< Netcatty OSC 7 cwd tracking <<<"',
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.match(bashrc, /alias keep_me=/);
|
|
assert.match(bashrc, /echo "# >>> Netcatty OSC 7 cwd tracking >>>"/);
|
|
assert.match(bashrc, /echo "# <<< Netcatty OSC 7 cwd tracking <<<"/);
|
|
assert.match(bashrc, /netcatty-osc7-version: 2/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand upgrades a legacy block wrapped in control flow in place", () => {
|
|
withTempHome("netcatty-osc7-bash-if-wrap-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"if true; then",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"osc7_cwd() { :; }",
|
|
'PROMPT_COMMAND="osc7_cwd"',
|
|
"# <<< Netcatty OSC 7 cwd tracking <<<",
|
|
"fi",
|
|
"echo after",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.match(bashrc, /if true; then/);
|
|
assert.match(bashrc, /netcatty-osc7-version: 2/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
assert.match(bashrc, /^fi$/m);
|
|
assert.match(bashrc, /echo after/);
|
|
// v2 stays inside the if/fi, not only after it.
|
|
const ifIdx = bashrc.indexOf("if true; then");
|
|
const fiIdx = bashrc.indexOf("\nfi\n");
|
|
const v2Idx = bashrc.indexOf("netcatty-osc7-version: 2");
|
|
assert.ok(ifIdx >= 0 && fiIdx > ifIdx && v2Idx > ifIdx && v2Idx < fiIdx);
|
|
execFileSync("/bin/bash", ["-n", bashrcPath], { stdio: "pipe" });
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand upgrades a read-only legacy bashrc in one atomic write", () => {
|
|
withTempHome("netcatty-osc7-bash-readonly-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
writeFileSync(
|
|
bashrcPath,
|
|
[
|
|
"# before",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"legacy",
|
|
"# <<< Netcatty OSC 7 cwd tracking <<<",
|
|
"# after",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(bashrcPath, 0o444);
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
assert.equal(lstatSync(bashrcPath).mode & 0o777, 0o444);
|
|
const bashrc = readFileSync(bashrcPath, "utf8");
|
|
assert.match(bashrc, /# before/);
|
|
assert.match(bashrc, /# after/);
|
|
assert.match(bashrc, /netcatty-osc7-version: 2/);
|
|
assert.match(bashrc, /__netcatty_osc7_prompt/);
|
|
assert.doesNotMatch(bashrc, /^legacy$/m);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand upgrades through a symlinked bashrc without replacing the link", () => {
|
|
withTempHome("netcatty-osc7-bash-symlink-", (home) => {
|
|
const realPath = join(home, "dotfiles", "bashrc.real");
|
|
const midLink = join(home, "dotfiles", "bashrc.link");
|
|
const bashrcPath = join(home, ".bashrc");
|
|
mkdirSync(join(home, "dotfiles"), { recursive: true });
|
|
writeFileSync(
|
|
realPath,
|
|
[
|
|
"# managed-preamble",
|
|
"# >>> Netcatty OSC 7 cwd tracking >>>",
|
|
"osc7_cwd() {",
|
|
" printf 'legacy'\\n",
|
|
"}",
|
|
'PROMPT_COMMAND="osc7_cwd"',
|
|
"# <<< Netcatty OSC 7 cwd tracking <<<",
|
|
"# managed-epilogue",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
chmodSync(realPath, 0o644);
|
|
// Chain: ~/.bashrc -> dotfiles/bashrc.link -> bashrc.real
|
|
symlinkSync("bashrc.real", midLink);
|
|
symlinkSync(join("dotfiles", "bashrc.link"), bashrcPath);
|
|
assert.ok(lstatSync(bashrcPath).isSymbolicLink());
|
|
assert.ok(lstatSync(midLink).isSymbolicLink());
|
|
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
|
|
assert.ok(lstatSync(bashrcPath).isSymbolicLink(), "bashrc must remain a symlink");
|
|
assert.ok(lstatSync(midLink).isSymbolicLink(), "intermediate link must remain a symlink");
|
|
assert.equal(readlinkSync(bashrcPath), join("dotfiles", "bashrc.link"));
|
|
assert.equal(readlinkSync(midLink), "bashrc.real");
|
|
assert.equal(lstatSync(realPath).mode & 0o777, 0o644, "target mode should be preserved");
|
|
const content = readFileSync(realPath, "utf8");
|
|
assert.match(content, /# managed-preamble/);
|
|
assert.match(content, /# managed-epilogue/);
|
|
assert.match(content, /netcatty-osc7-version: 2/);
|
|
assert.match(content, /__netcatty_osc7_prompt/);
|
|
assert.doesNotMatch(content, /printf 'legacy'/);
|
|
});
|
|
});
|
|
|
|
test("bash snippet does not error when PROMPT_COMMAND is inherited without osc7_cwd", () => {
|
|
withTempHome("netcatty-osc7-bash-su-inherit-", (home) => {
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
const bashrcPath = join(home, ".bashrc");
|
|
|
|
// Simulate non-login su: child gets exported PROMPT_COMMAND but no function defs.
|
|
const result = spawnSync(
|
|
"/bin/bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
[
|
|
`source ${JSON.stringify(bashrcPath)}`,
|
|
"export PROMPT_COMMAND",
|
|
// Child env has PROMPT_COMMAND but not the function (fresh bash -c without rc).
|
|
`env PROMPT_COMMAND="$PROMPT_COMMAND" /bin/bash --noprofile --norc -c 'eval "$PROMPT_COMMAND"; echo OK'`,
|
|
].join("; "),
|
|
],
|
|
{
|
|
env: { ...process.env, HOME: home },
|
|
encoding: "utf8",
|
|
},
|
|
);
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stdout, /OK/);
|
|
assert.doesNotMatch(result.stderr + result.stdout, /command not found/);
|
|
});
|
|
});
|
|
|
|
test("bash snippet still emits OSC 7 when functions are defined", () => {
|
|
withTempHome("netcatty-osc7-bash-emit-", (home) => {
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const output = execFileSync(
|
|
"/bin/bash",
|
|
["-lc", `source ${JSON.stringify(bashrcPath)}; __netcatty_osc7_prompt; true`],
|
|
{ env: { ...process.env, HOME: home, PWD: home }, cwd: home },
|
|
).toString("utf8");
|
|
|
|
assert.ok(output.includes("\u001b]7;file://"), "expected OSC 7 output");
|
|
assert.equal(realpathSync(extractOsc7Path(output)), realpathSync(home));
|
|
});
|
|
});
|
|
|
|
test("bash snippet preserves an intentional PROMPT_COMMAND export", () => {
|
|
withTempHome("netcatty-osc7-bash-export-", (home) => {
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const output = execFileSync(
|
|
"/bin/bash",
|
|
[
|
|
"-lc",
|
|
[
|
|
"export PROMPT_COMMAND=preexisting",
|
|
`source ${JSON.stringify(bashrcPath)}`,
|
|
'if declare -p PROMPT_COMMAND 2>/dev/null | grep -q "declare -x"; then echo EXPORTED; else echo LOCAL; fi',
|
|
].join("; "),
|
|
],
|
|
{ env: { ...process.env, HOME: home } },
|
|
).toString("utf8");
|
|
|
|
assert.match(output, /EXPORTED/);
|
|
});
|
|
});
|
|
|
|
test("bash snippet dedupes hooks across array PROMPT_COMMAND elements", () => {
|
|
withTempHome("netcatty-osc7-bash-array-pc-", (home) => {
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const output = execFileSync(
|
|
"/bin/bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
[
|
|
'PROMPT_COMMAND=("first" "osc7_cwd" "echo KEEP")',
|
|
`source ${JSON.stringify(bashrcPath)}`,
|
|
`source ${JSON.stringify(bashrcPath)}`,
|
|
'declare -p PROMPT_COMMAND',
|
|
].join("; "),
|
|
],
|
|
{ env: { ...process.env, HOME: home } },
|
|
).toString("utf8");
|
|
|
|
assert.match(output, /first/);
|
|
assert.match(output, /echo KEEP/);
|
|
assert.equal(output.split("declare -F __netcatty_osc7_prompt").length - 1, 1);
|
|
assert.doesNotMatch(output, /\[1\]="osc7_cwd"/);
|
|
});
|
|
});
|
|
|
|
test("bash snippet handles exported array PROMPT_COMMAND", () => {
|
|
withTempHome("netcatty-osc7-bash-ax-pc-", (home) => {
|
|
runSetup({ HOME: home, SHELL: "/bin/bash" });
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const output = execFileSync(
|
|
"/bin/bash",
|
|
[
|
|
"--noprofile",
|
|
"--norc",
|
|
"-c",
|
|
[
|
|
'declare -ax PROMPT_COMMAND=("echo one" "osc7_cwd")',
|
|
`source ${JSON.stringify(bashrcPath)}`,
|
|
`source ${JSON.stringify(bashrcPath)}`,
|
|
'declare -p PROMPT_COMMAND',
|
|
].join("; "),
|
|
],
|
|
{ env: { ...process.env, HOME: home } },
|
|
).toString("utf8");
|
|
|
|
assert.match(output, /echo one/);
|
|
assert.equal(output.split("declare -F __netcatty_osc7_prompt").length - 1, 1);
|
|
assert.doesNotMatch(output, /osc7_cwd"/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand preserves setup failure status", () => {
|
|
withTempHome("netcatty-osc7-unsupported-shell-", (home) => {
|
|
const result = spawnSync("/bin/sh", ["-c", buildOsc7SetupCommand()], {
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
SHELL: "/bin/unknown",
|
|
ZDOTDIR: "",
|
|
XDG_CONFIG_HOME: "",
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.equal(result.status, 2, result.stderr || result.stdout);
|
|
assert.match(result.stderr, /unsupported shell unknown/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupExecCommand configures bash through a background exec shell", () => {
|
|
withTempHome("netcatty-osc7-exec-bash-", (home) => {
|
|
const output = execFileSync("/bin/sh", ["-c", buildOsc7SetupExecCommand()], {
|
|
env: { ...process.env, HOME: home, SHELL: "/bin/bash" },
|
|
stdio: "pipe",
|
|
}).toString("utf8");
|
|
|
|
assert.match(output, /__NETCATTY_OSC7_SETUP_SHELL__=bash/);
|
|
assert.match(output, new RegExp(`__NETCATTY_OSC7_SETUP_CONFIG__=${home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/\\.bashrc`));
|
|
const bashrc = readFileSync(join(home, ".bashrc"), "utf8");
|
|
assert.equal(markerCount(bashrc), 2);
|
|
});
|
|
});
|
|
|
|
const withStagedSetupScript = (fn: (scriptPath: string) => void) => {
|
|
const output = execFileSync("/bin/sh", ["-c", buildOsc7StageScriptCommand()], {
|
|
stdio: "pipe",
|
|
}).toString("utf8");
|
|
const scriptPath = parseOsc7SetupStagedPath(output);
|
|
assert.ok(scriptPath, "expected staged script path");
|
|
try {
|
|
fn(scriptPath);
|
|
} finally {
|
|
rmSync(scriptPath, { force: true });
|
|
}
|
|
};
|
|
|
|
test("buildOsc7TypedSetupCommand stays a single line for reliable history cleanup", async () => {
|
|
const sha256 = await getOsc7StagedScriptSha256();
|
|
for (const shell of ["bash", "zsh", "fish"] as const) {
|
|
const command = buildOsc7TypedSetupCommand(shell, "/tmp/.netcatty-osc7-setup.abc123", sha256);
|
|
assert.ok(command.endsWith("\r"), shell);
|
|
assert.doesNotMatch(command.slice(0, -1), /[\r\n]/, shell);
|
|
}
|
|
});
|
|
|
|
test("buildOsc7TypedSetupCommand configures bash and removes the staged script", async () => {
|
|
const sha256 = await getOsc7StagedScriptSha256();
|
|
withTempHome("netcatty-osc7-typed-bash-", (home) => {
|
|
withStagedSetupScript((scriptPath) => {
|
|
const command = buildOsc7TypedSetupCommand("bash", scriptPath, sha256).replace(/\r/g, "\n");
|
|
const output = execFileSync("/bin/bash", ["-c", command], {
|
|
env: { ...process.env, HOME: home, SHELL: "/bin/bash", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
|
|
stdio: "pipe",
|
|
}).toString("utf8");
|
|
|
|
const bashrc = readFileSync(join(home, ".bashrc"), "utf8");
|
|
assert.equal(markerCount(bashrc), 2);
|
|
assert.match(bashrc, /PROMPT_COMMAND/);
|
|
assert.doesNotMatch(output, /__NETCATTY_OSC7_SETUP_SHELL__|__NETCATTY_OSC7_SETUP_CONFIG__/);
|
|
assert.ok(output.includes("\u001b]7;file://"), "expected OSC 7 output");
|
|
assert.equal(existsSync(scriptPath), false, "staged script should be removed");
|
|
});
|
|
});
|
|
});
|
|
|
|
test("buildOsc7TypedSetupCommand refuses to run a tampered staged script", async () => {
|
|
const sha256 = await getOsc7StagedScriptSha256();
|
|
withTempHome("netcatty-osc7-typed-tampered-", (home) => {
|
|
withStagedSetupScript((scriptPath) => {
|
|
writeFileSync(scriptPath, `echo pwned > "$HOME/pwned"\n`);
|
|
const command = buildOsc7TypedSetupCommand("bash", scriptPath, sha256).replace(/\r/g, "\n");
|
|
const result = spawnSync("/bin/bash", ["-c", command], {
|
|
env: { ...process.env, HOME: home, SHELL: "/bin/bash", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(result.stderr, /verification failed/);
|
|
assert.equal(existsSync(join(home, "pwned")), false, "tampered script must not run");
|
|
assert.equal(existsSync(join(home, ".bashrc")), false);
|
|
assert.equal(existsSync(scriptPath), false, "staged script should be removed before verification");
|
|
});
|
|
});
|
|
});
|
|
|
|
test("buildOsc7TypedSetupCommand does not leave the typed runner in bash history", async () => {
|
|
const sha256 = await getOsc7StagedScriptSha256();
|
|
withTempHome("netcatty-osc7-typed-history-bash-", (home) => {
|
|
withStagedSetupScript((scriptPath) => {
|
|
const dumpPath = join(home, "bash-history-dump");
|
|
const output = runInteractiveHistoryProbe({
|
|
shellPath: "/bin/bash",
|
|
shellArgs: ["--noprofile", "--norc", "-i"],
|
|
dumpHistoryCommand: `history > ${quoteShellArg(dumpPath)}`,
|
|
dumpPath,
|
|
env: {
|
|
HOME: home,
|
|
HISTFILE: join(home, ".bash_history"),
|
|
SHELL: "/bin/bash",
|
|
},
|
|
input: `echo keepme\n${buildOsc7TypedSetupCommand("bash", scriptPath, sha256)}`,
|
|
});
|
|
|
|
assert.match(output, /echo keepme/);
|
|
assert.doesNotMatch(output, /NETCATTY_OSC7_FORCE_SHELL|__netcatty_osc7|history -d/);
|
|
});
|
|
});
|
|
});
|
|
|
|
test("buildOsc7TypedSetupCommand stays idempotent for bash", async () => {
|
|
const sha256 = await getOsc7StagedScriptSha256();
|
|
withTempHome("netcatty-osc7-typed-bash-idempotent-", (home) => {
|
|
const env = { ...process.env, HOME: home, SHELL: "/bin/bash", ZDOTDIR: "", XDG_CONFIG_HOME: "" };
|
|
for (let run = 0; run < 2; run += 1) {
|
|
withStagedSetupScript((scriptPath) => {
|
|
const command = buildOsc7TypedSetupCommand("bash", scriptPath, sha256).replace(/\r/g, "\n");
|
|
execFileSync("/bin/bash", ["-c", command], { env, stdio: "pipe" });
|
|
});
|
|
}
|
|
|
|
assert.equal(markerCount(readFileSync(join(home, ".bashrc"), "utf8")), 2);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7TypedSetupCommand honors shell-local unexported zsh ZDOTDIR", async (t) => {
|
|
const zshPath = existingShells(["/bin/zsh", "/usr/bin/zsh"])[0];
|
|
if (!zshPath) {
|
|
t.skip("zsh is not installed on this runner");
|
|
return;
|
|
}
|
|
|
|
const sha256 = await getOsc7StagedScriptSha256();
|
|
withTempHome("netcatty-osc7-typed-zsh-", (home) => {
|
|
const zdotdir = join(home, ".config", "zsh");
|
|
withStagedSetupScript((scriptPath) => {
|
|
const command = buildOsc7TypedSetupCommand("zsh", scriptPath, sha256).replace(/\r/g, "\n");
|
|
// ZDOTDIR is a shell-local (unexported) parameter, like a user setting
|
|
// it in .zshenv without export; the typed wrapper must forward it.
|
|
const output = execFileSync(zshPath, ["-c", `ZDOTDIR=${JSON.stringify(zdotdir)}; ${command}`], {
|
|
env: { ...process.env, HOME: home, SHELL: zshPath, ZDOTDIR: undefined, XDG_CONFIG_HOME: "" },
|
|
stdio: "pipe",
|
|
}).toString("utf8");
|
|
|
|
const zshrc = readFileSync(join(zdotdir, ".zshrc"), "utf8");
|
|
assert.equal(markerCount(zshrc), 2);
|
|
assert.match(zshrc, /precmd_functions/);
|
|
assert.equal(existsSync(join(home, ".zshrc")), false);
|
|
assert.ok(output.includes("\u001b]7;file://"), "expected OSC 7 output");
|
|
});
|
|
});
|
|
});
|
|
|
|
test("buildOsc7TypedSetupCommand configures fish through its typed fallback", async (t) => {
|
|
const fishPath = existingShells(["/opt/homebrew/bin/fish", "/usr/bin/fish"])[0];
|
|
if (!fishPath) {
|
|
t.skip("fish is not installed on this runner");
|
|
return;
|
|
}
|
|
|
|
const sha256 = await getOsc7StagedScriptSha256();
|
|
withTempHome("netcatty-osc7-typed-fish-", (home) => {
|
|
withStagedSetupScript((scriptPath) => {
|
|
const command = buildOsc7TypedSetupCommand("fish", scriptPath, sha256).replace(/\r/g, "\n");
|
|
const output = execFileSync(fishPath, ["-c", command], {
|
|
env: { ...process.env, HOME: home, SHELL: fishPath, ZDOTDIR: "", XDG_CONFIG_HOME: "" },
|
|
stdio: "pipe",
|
|
}).toString("utf8");
|
|
|
|
const fishConfig = readFileSync(join(home, ".config", "fish", "config.fish"), "utf8");
|
|
assert.equal(markerCount(fishConfig), 2);
|
|
assert.match(fishConfig, /fish_prompt/);
|
|
assert.ok(output.includes("\u001b]7;file://"), "expected OSC 7 output");
|
|
});
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupExecCommand carries the expected cwd for current-tab matching", () => {
|
|
const command = buildOsc7SetupExecCommand("/srv/app's cwd");
|
|
|
|
assert.match(command, /NETCATTY_OSC7_EXPECTED_CWD='\/srv\/app'\\''s cwd'/);
|
|
});
|
|
|
|
test("buildOsc7SetupExecCommand honors exported zsh ZDOTDIR fallback", (t) => {
|
|
const zshPath = existingShells(["/bin/zsh", "/usr/bin/zsh"])[0];
|
|
if (!zshPath) {
|
|
t.skip("zsh is not installed on this runner");
|
|
return;
|
|
}
|
|
|
|
withTempHome("netcatty-osc7-exec-zsh-", (home) => {
|
|
const zdotdir = join(home, ".config", "zsh");
|
|
const output = execFileSync("/bin/sh", ["-c", buildOsc7SetupExecCommand()], {
|
|
env: { ...process.env, HOME: home, SHELL: zshPath, ZDOTDIR: zdotdir },
|
|
stdio: "pipe",
|
|
}).toString("utf8");
|
|
|
|
const zshrcPath = join(zdotdir, ".zshrc");
|
|
assert.match(output, /__NETCATTY_OSC7_SETUP_SHELL__=zsh/);
|
|
assert.match(output, new RegExp(`__NETCATTY_OSC7_SETUP_CONFIG__=${zshrcPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
assert.equal(markerCount(readFileSync(zshrcPath, "utf8")), 2);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand honors zsh ZDOTDIR captured from the current shell", (t) => {
|
|
const zshPath = existingShells(["/bin/zsh", "/usr/bin/zsh"])[0];
|
|
if (!zshPath) {
|
|
t.skip("zsh is not installed on this runner");
|
|
return;
|
|
}
|
|
|
|
withTempHome("netcatty-osc7-zsh-", (home) => {
|
|
const zdotdir = join(home, ".config", "zsh");
|
|
runSetup({ HOME: home, SHELL: zshPath, ZDOTDIR: zdotdir });
|
|
runSetup({ HOME: home, SHELL: zshPath, ZDOTDIR: zdotdir });
|
|
|
|
const zshrcPath = join(zdotdir, ".zshrc");
|
|
const zshrc = readFileSync(zshrcPath, "utf8");
|
|
assert.equal(markerCount(zshrc), 2);
|
|
assert.match(zshrc, /precmd_functions/);
|
|
assert.match(zshrc, /netcatty-osc7-version: 2/);
|
|
assert.match(zshrc, /__netcatty_osc7_prompt/);
|
|
assert.equal(existsSync(join(home, ".zshrc")), false);
|
|
|
|
const precmd = execFileSync(
|
|
zshPath,
|
|
["-uc", `source ${JSON.stringify(zshrcPath)}; source ${JSON.stringify(zshrcPath)}; print -r -- "\${precmd_functions[*]}"`],
|
|
{
|
|
env: { ...process.env, HOME: home, ZDOTDIR: zdotdir },
|
|
stdio: "pipe",
|
|
},
|
|
).toString("utf8");
|
|
assert.match(precmd, /__netcatty_osc7_prompt/);
|
|
assert.equal(precmd.trim().split(/\s+/).filter((name) => name === "__netcatty_osc7_prompt").length, 1);
|
|
assert.doesNotMatch(precmd, /(?:^|\s)osc7_cwd(?:\s|$)/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand configures fish once with valid fish syntax", () => {
|
|
withTempHome("netcatty-osc7-fish-", (home) => {
|
|
const fishPath = existingShells(["/opt/homebrew/bin/fish", "/usr/bin/fish"])[0] ?? "/usr/bin/fish";
|
|
runSetup({ HOME: home, SHELL: fishPath });
|
|
runSetup({ HOME: home, SHELL: fishPath });
|
|
|
|
const fishConfigPath = join(home, ".config", "fish", "config.fish");
|
|
const fishConfig = readFileSync(fishConfigPath, "utf8");
|
|
assert.equal(markerCount(fishConfig), 2);
|
|
assert.match(fishConfig, /fish_prompt/);
|
|
|
|
if (existsSync(fishPath)) {
|
|
execFileSync(fishPath, ["-n", fishConfigPath], { stdio: "pipe" });
|
|
execFileSync(fishPath, ["-c", `source ${JSON.stringify(fishConfigPath)}; functions -q __netcatty_osc7_cwd`], {
|
|
env: { ...process.env, HOME: home },
|
|
stdio: "pipe",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand can be pasted into supported shells", () => {
|
|
const shells = supportedShells();
|
|
|
|
if (process.env.CI) {
|
|
assert.ok(shells.some((shellPath) => basename(shellPath) === "fish"), "CI must exercise fish");
|
|
}
|
|
|
|
for (const shellPath of shells) {
|
|
withTempHome(`netcatty-osc7-${basename(shellPath)}-`, (home) => {
|
|
const zdotdir = join(home, "zdot");
|
|
const xdgConfigHome = join(home, "xdg");
|
|
const specialCwd = join(home, "space dir#frag?query%pct");
|
|
mkdirSync(specialCwd, { recursive: true });
|
|
|
|
const output = execFileSync(shellPath, ["-c", buildOsc7SetupCommand()], {
|
|
cwd: specialCwd,
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
SHELL: shellPath,
|
|
ZDOTDIR: zdotdir,
|
|
XDG_CONFIG_HOME: xdgConfigHome,
|
|
},
|
|
stdio: "pipe",
|
|
}).toString("utf8");
|
|
|
|
const shellName = basename(shellPath);
|
|
const configPath = shellName === "bash"
|
|
? join(home, ".bashrc")
|
|
: shellName === "zsh"
|
|
? join(zdotdir, ".zshrc")
|
|
: join(xdgConfigHome, "fish", "config.fish");
|
|
|
|
const config = readFileSync(configPath, "utf8");
|
|
assert.equal(markerCount(config), 2, shellPath);
|
|
assert.equal(realpathSync(extractOsc7Path(output)), realpathSync(specialCwd), shellPath);
|
|
});
|
|
}
|
|
});
|
|
|
|
test("buildOsc7ReloadCommand does not leave reload command in bash history", () => {
|
|
withTempHome("netcatty-osc7-reload-history-bash-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const dumpPath = join(home, "bash-history-dump");
|
|
mkdirSync(home, { recursive: true });
|
|
writeFileSync(bashrcPath, "osc7_cwd(){ :; }\n");
|
|
const reloadCommand = buildOsc7ReloadCommand({ shell: "bash", configPath: bashrcPath });
|
|
|
|
assert.ok(reloadCommand);
|
|
assert.doesNotMatch(reloadCommand.slice(0, -1), /[\r\n]/);
|
|
|
|
const output = runInteractiveHistoryProbe({
|
|
shellPath: "/bin/bash",
|
|
shellArgs: ["--noprofile", "--norc", "-i"],
|
|
dumpHistoryCommand: `history > ${quoteShellArg(dumpPath)}`,
|
|
dumpPath,
|
|
env: {
|
|
HOME: home,
|
|
HISTFILE: join(home, ".bash_history"),
|
|
HISTCONTROL: "ignoreboth",
|
|
SHELL: "/bin/bash",
|
|
},
|
|
input: `echo keepme\n${reloadCommand}`,
|
|
});
|
|
|
|
assert.match(output, /echo keepme/);
|
|
assert.doesNotMatch(output, /osc7_cwd|source .*\.bashrc|__netcatty_osc7|history -d/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7ReloadCommand preserves bash nounset", () => {
|
|
withTempHome("netcatty-osc7-reload-nounset-bash-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const optionDumpPath = join(home, "bash-options-dump");
|
|
mkdirSync(home, { recursive: true });
|
|
writeFileSync(bashrcPath, "osc7_cwd(){ :; }\n");
|
|
|
|
const result = spawnSync("/bin/bash", ["--noprofile", "--norc", "-i"], {
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
HISTFILE: join(home, ".bash_history"),
|
|
SHELL: "/bin/bash",
|
|
},
|
|
input: [
|
|
"set -u",
|
|
(buildOsc7ReloadCommand({ shell: "bash", configPath: bashrcPath }) ?? "").replace(/\r/g, "\n"),
|
|
`set -o | grep nounset > ${quoteShellArg(optionDumpPath)}`,
|
|
"exit",
|
|
].join("\n"),
|
|
encoding: "utf8",
|
|
});
|
|
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
assert.match(readFileSync(optionDumpPath, "utf8"), /\bon\b/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7ReloadCommand does not delete bash history when reload is not recorded", () => {
|
|
withTempHome("netcatty-osc7-reload-ignored-history-bash-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const dumpPath = join(home, "bash-history-dump");
|
|
mkdirSync(home, { recursive: true });
|
|
writeFileSync(bashrcPath, "osc7_cwd(){ :; }\n");
|
|
|
|
const output = runInteractiveHistoryProbe({
|
|
shellPath: "/bin/bash",
|
|
shellArgs: ["--noprofile", "--norc", "-i"],
|
|
dumpHistoryCommand: `history > ${quoteShellArg(dumpPath)}`,
|
|
dumpPath,
|
|
env: {
|
|
HOME: home,
|
|
HISTFILE: join(home, ".bash_history"),
|
|
HISTIGNORE: "*__netcatty_osc7_history_cleanup_marker__=1*",
|
|
SHELL: "/bin/bash",
|
|
},
|
|
input: `echo keepme\n${buildOsc7ReloadCommand({ shell: "bash", configPath: bashrcPath }) ?? ""}`,
|
|
});
|
|
|
|
assert.match(output, /echo keepme/);
|
|
assert.doesNotMatch(output, /osc7_cwd|source .*\.bashrc|__netcatty_osc7|history -d/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7ReloadCommand bypasses custom bash history wrappers", () => {
|
|
withTempHome("netcatty-osc7-reload-wrapped-history-bash-", (home) => {
|
|
const bashrcPath = join(home, ".bashrc");
|
|
const dumpPath = join(home, "bash-history-dump");
|
|
mkdirSync(home, { recursive: true });
|
|
writeFileSync(bashrcPath, "osc7_cwd(){ :; }\n");
|
|
|
|
const output = runInteractiveHistoryProbe({
|
|
shellPath: "/bin/bash",
|
|
shellArgs: ["--noprofile", "--norc", "-i"],
|
|
dumpHistoryCommand: `builtin history > ${quoteShellArg(dumpPath)}`,
|
|
dumpPath,
|
|
env: {
|
|
HOME: home,
|
|
HISTFILE: join(home, ".bash_history"),
|
|
SHELL: "/bin/bash",
|
|
},
|
|
input: [
|
|
'history(){ echo custom; }',
|
|
"echo keepme",
|
|
buildOsc7ReloadCommand({ shell: "bash", configPath: bashrcPath }) ?? "",
|
|
].join("\n"),
|
|
});
|
|
|
|
assert.match(output, /echo keepme/);
|
|
assert.doesNotMatch(output, /osc7_cwd|source .*\.bashrc|__netcatty_osc7|history -d/);
|
|
});
|
|
});
|
|
|
|
test("buildOsc7SetupCommand runs under strict unset-variable mode", () => {
|
|
for (const shellPath of existingShells(["/bin/bash", "/bin/zsh"])) {
|
|
withTempHome(`netcatty-osc7-strict-${basename(shellPath)}-`, (home) => {
|
|
execFileSync(shellPath, ["-uc", buildOsc7SetupCommand()], {
|
|
env: {
|
|
...process.env,
|
|
HOME: home,
|
|
SHELL: shellPath,
|
|
ZDOTDIR: undefined,
|
|
XDG_CONFIG_HOME: undefined,
|
|
},
|
|
stdio: "pipe",
|
|
});
|
|
});
|
|
}
|
|
});
|