T05: fish search path, PowerShell policy and load failure, encoded OSC 7 paths, kept exit status, install read errors
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 9m9s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 12:39:47 -04:00
1 parent dfaf378418
commit c2da9dd667
6 files changed
+210 -90

No files matched your search

+11 -1
View File
@@ -13,6 +13,16 @@ else
[ -r ~/.bashrc ] && builtin source ~/.bashrc [ -r ~/.bashrc ] && builtin source ~/.bashrc
fi fi
_ely_prompt() { _ely_prompt() {
builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${PWD##*/}" builtin local ret=$? LC_ALL=C char url= i title
for (( i = 0; i < ${#PWD}; i++ )); do
char=${PWD:i:1}
case $char in
[[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;;
esac
url+=$char
done
title=${PWD##*/}
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${title//[[:cntrl:]]/}"
builtin return $ret
} }
PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}" PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
@@ -7,6 +7,6 @@ end
set -e ELY_FISH_XDG_DATA_DIRS set -e ELY_FISH_XDG_DATA_DIRS
if status is-interactive if status is-interactive
function _ely_prompt --on-event fish_prompt function _ely_prompt --on-event fish_prompt
printf '\e]7;file://%s\a' (string replace -a % %25 -- $PWD) printf '\e]7;file://%s\a' (string escape --style=url -- $PWD)
end end
end end
@@ -1,13 +1,15 @@
# ELYTerminal: wrap the prompt to report folder and title. # ELYTerminal: wrap the prompt to report folder and title.
$global:ElyPrompt = $function:prompt $global:ElyPrompt = $function:prompt
function global:prompt { function global:prompt {
$text = & $global:ElyPrompt
$folder = $executionContext.SessionState.Path.CurrentLocation $folder = $executionContext.SessionState.Path.CurrentLocation
if ($folder.Provider.Name -eq 'FileSystem') { if ($folder.Provider.Name -eq 'FileSystem') {
$path = $folder.ProviderPath.Replace('\', '/').Replace('%', '%25') $parts = $folder.ProviderPath.Replace('\', '/') -split '/'
$path = ($parts | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/'
if (-not $path.StartsWith('/')) { $path = "/$path" } if (-not $path.StartsWith('/')) { $path = "/$path" }
$leaf = Split-Path -Leaf $folder.ProviderPath $leaf = (Split-Path -Leaf $folder.ProviderPath) -replace '\p{Cc}', ''
$esc, $bel = [char]27, [char]7 $esc, $bel = [char]27, [char]7
[Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel") [Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel")
} }
& $global:ElyPrompt $text
} }
+9 -1
View File
@@ -8,7 +8,15 @@ fi
[[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv" [[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv"
if [[ -o interactive ]]; then if [[ -o interactive ]]; then
_ely_precmd() { _ely_precmd() {
builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${${PWD:t}:-/}" builtin local ret=$? LC_ALL=C char url=
for char in ${(s::)PWD}; do
case $char in
[[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;;
esac
url+=$char
done
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${${${PWD:t}:-/}//[[:cntrl:]]/}"
builtin return ret
} }
_ely_preexec() { _ely_preexec() {
builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}" builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}"
+18 -6
View File
@@ -1,5 +1,6 @@
use std::{ use std::{
env, fs, env, fs,
io::ErrorKind,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
@@ -50,8 +51,14 @@ impl ShellIntegration {
pub fn install(dir: &Path) -> anyhow::Result<()> { pub fn install(dir: &Path) -> anyhow::Result<()> {
for (name, text) in SCRIPTS { for (name, text) in SCRIPTS {
let path = dir.join(name); let path = dir.join(name);
if fs::read_to_string(&path).is_ok_and(|kept| kept == text) { match fs::read_to_string(&path) {
continue; Ok(kept) if kept == text => continue,
Ok(_) => {}
Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::InvalidData) => {}
Err(error) => {
return Err(error)
.with_context(|| format!("shell integration: cannot read {}", path.display()));
}
} }
let folder = path let folder = path
.parent() .parent()
@@ -98,11 +105,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
} }
"fish" => { "fish" => {
let previous = env::var("XDG_DATA_DIRS").unwrap_or_default(); let previous = env::var("XDG_DATA_DIRS").unwrap_or_default();
let data = if previous.is_empty() { let rest = if previous.is_empty() {
format!("{}:/usr/local/share:/usr/share", script("fish")) "/usr/local/share:/usr/share"
} else { } else {
format!("{}:{previous}", script("fish")) &previous
}; };
let data = format!("{}:{rest}", dir.display());
launch.env.push(("XDG_DATA_DIRS".into(), data)); launch.env.push(("XDG_DATA_DIRS".into(), data));
launch.env.push(("ELY_FISH_XDG_DATA_DIRS".into(), previous)); launch.env.push(("ELY_FISH_XDG_DATA_DIRS".into(), previous));
} }
@@ -113,8 +121,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
vec![ vec![
"-NoLogo".into(), "-NoLogo".into(),
"-NoExit".into(), "-NoExit".into(),
"-ExecutionPolicy".into(),
"Bypass".into(),
"-Command".into(), "-Command".into(),
format!(". '{file}'"), format!(
"try {{ . '{file}' }} catch {{ [Console]::Error.WriteLine(\"ELYTerminal: shell integration failed: $_\"); exit 1 }}"
),
], ],
)); ));
} }
+166 -78
View File
@@ -1,10 +1,13 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use alacritty_terminal::event::Event; use alacritty_terminal::{event::Event, grid::Dimensions, index::Line};
use futures::channel::mpsc::TryRecvError; use futures::channel::mpsc::TryRecvError;
use super::*; use super::*;
use crate::term::{PtyEvent, spawn}; use crate::term::{Pty, PtyEvent, spawn};
/// A folder name with a space, a percent, a bell and a newline.
const INNER: &str = "in ner%\u{7}x\ny";
/// A fresh folder under the temp dir, by its real path. /// A fresh folder under the temp dir, by its real path.
fn folder(name: &str) -> PathBuf { fn folder(name: &str) -> PathBuf {
@@ -21,13 +24,27 @@ fn installed(shell: &str) -> String {
.args(["-c", &format!("command -v {shell}")]) .args(["-c", &format!("command -v {shell}")])
.output() .output()
.expect("sh runs"); .expect("sh runs");
let path = String::from_utf8(found.stdout).expect("utf-8");
assert!(found.status.success(), "{shell} is not installed"); assert!(found.status.success(), "{shell} is not installed");
path.trim().to_string() String::from_utf8(found.stdout)
.expect("utf-8")
.trim()
.to_string()
} }
/// Starts `shell` with integration in a fresh folder, cds once, and returns folders and titles it reported. /// A shell started with integration under a fake HOME, and what it reported.
fn reports(shell: &str) -> (PathBuf, Vec<PathBuf>, Vec<String>) { struct Run {
shell: String,
pty: Pty,
root: PathBuf,
start: PathBuf,
folders: Vec<PathBuf>,
titles: Vec<String>,
deadline: Instant,
}
impl Run {
/// Starts `shell` in a fresh folder; `files` go under its HOME first.
fn start(shell: &str, files: &[(&str, &str)]) -> Self {
crate::testlog::init(); crate::testlog::init();
let root = folder(&format!( let root = folder(&format!(
"shell-{}", "shell-{}",
@@ -38,63 +55,63 @@ fn reports(shell: &str) -> (PathBuf, Vec<PathBuf>, Vec<String>) {
root.join("home"), root.join("home"),
root.join("start"), root.join("start"),
); );
let inner = start.join("in ner%"); fs::create_dir_all(start.join(INNER)).expect("the inner folder");
for made in [&home, &inner] { fs::create_dir_all(&home).expect("home");
fs::create_dir_all(made).expect("a folder"); for (name, text) in files {
let path = home.join(name);
fs::create_dir_all(path.parent().expect("a folder")).expect("a home folder");
fs::write(path, text).expect("a home file");
} }
install(&dir).expect("scripts install"); install(&dir).expect("scripts install");
let mut launch = launch(shell, start.clone(), &dir).expect("a launch"); let mut launch = launch(shell, start.clone(), &dir).expect("a launch");
launch.env.push(("HOME".into(), home.display().to_string())); launch.env.push(("HOME".into(), home.display().to_string()));
let mut pty = spawn(&launch, (80, 24), (8, 16), 100).expect("the shell starts"); let pty = spawn(&launch, (100, 30), (8, 16), 100).expect("the shell starts");
let (mut folders, mut titles) = (Vec::new(), Vec::new()); Self {
let deadline = Instant::now() + Duration::from_secs(20); shell: shell.into(),
let mut asked = false; pty,
while !folders.contains(&inner) { root,
assert!( start,
Instant::now() < deadline, folders: Vec::new(),
"{shell} reported {folders:?} and {titles:?} on\n{}", titles: Vec::new(),
screen(&pty) deadline: Instant::now() + Duration::from_secs(30),
);
match pty.events.try_recv() {
Ok(PtyEvent::Folder(folder)) => folders.push(folder),
Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title),
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()),
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
panic!("{shell} ended, {status}, after {folders:?}")
}
Ok(_) => {}
Err(TryRecvError::Closed) => panic!("{shell}'s events ended"),
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
}
if !asked && folders.contains(&start) {
pty.write(format!("cd '{}'\r", inner.display()).into_bytes());
asked = true;
} }
} }
pty.write(&b"exit\r"[..]);
loop {
assert!(
Instant::now() < deadline,
"{shell} did not exit:\n{}",
screen(&pty)
);
match pty.events.try_recv() {
Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break,
Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title),
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()),
Ok(_) => {}
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
}
}
drop(pty);
fs::remove_dir_all(&root).expect("cleanup");
(start, folders, titles)
}
/// The screen's rows, for a failure's message. /// Reads events, answering the shell's terminal queries, until `done` holds.
fn screen(pty: &crate::term::Pty) -> String { fn until(&mut self, what: &str, done: impl Fn(&Self) -> bool) {
use alacritty_terminal::{grid::Dimensions, index::Line}; while !done(self) {
let term = pty.term.lock(); assert!(
Instant::now() < self.deadline,
"{}: no {what}; folders {:?}, titles {:?}, screen\n{}",
self.shell,
self.folders,
self.titles,
self.screen()
);
match self.pty.events.try_recv() {
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title),
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
panic!("{} ended, {status}:\n{}", self.shell, self.screen())
}
Ok(_) => {}
Err(TryRecvError::Closed) => panic!("{}'s events ended", self.shell),
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
}
}
}
fn send(&self, line: &str) {
self.pty.write(format!("{line}\r").into_bytes());
}
fn shows(&self, text: &str) -> bool {
self.screen().contains(text)
}
fn screen(&self) -> String {
let term = self.pty.term.lock();
(0..term.screen_lines()) (0..term.screen_lines())
.map(|line| { .map(|line| {
let row = &term.grid()[Line(line as i32)]; let row = &term.grid()[Line(line as i32)];
@@ -103,6 +120,39 @@ fn screen(pty: &crate::term::Pty) -> String {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
}
/// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder.
fn walk(&mut self, probe: &str) {
let start = self.start.clone();
self.until("first folder", |run| run.folders.contains(&start));
assert_eq!(self.folders[0], start);
self.send(probe);
self.until("loaded script", |run| run.shows("ELY-LOADED"));
self.send("cd in*");
let inner = start.join(INNER);
self.until("inner folder", |run| run.folders.contains(&inner));
}
fn finish(mut self) {
self.send("exit");
loop {
assert!(
Instant::now() < self.deadline,
"{} did not exit",
self.shell
);
match self.pty.events.try_recv() {
Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break,
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
Ok(_) => {}
Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)),
}
}
let root = self.root.clone();
drop(self);
fs::remove_dir_all(&root).expect("cleanup");
}
} }
#[test] #[test]
@@ -111,46 +161,84 @@ fn the_scripts_install_once_and_mend_a_changed_one() {
install(&dir).expect("installs"); install(&dir).expect("installs");
let zshenv = dir.join("zsh/.zshenv"); let zshenv = dir.join("zsh/.zshenv");
assert_eq!(fs::read_to_string(&zshenv).expect("written"), SCRIPTS[0].1); assert_eq!(fs::read_to_string(&zshenv).expect("written"), SCRIPTS[0].1);
fs::write(&zshenv, "changed").expect("write"); fs::write(&zshenv, [0xff, 0xfe]).expect("write");
install(&dir).expect("installs again"); install(&dir).expect("installs again");
assert_eq!(fs::read_to_string(&zshenv).expect("mended"), SCRIPTS[0].1); assert_eq!(fs::read_to_string(&zshenv).expect("mended"), SCRIPTS[0].1);
fs::remove_dir_all(dir).expect("cleanup"); fs::remove_dir_all(dir).expect("cleanup");
} }
#[test] #[test]
fn zsh_reports_its_folder_and_title() { fn a_script_it_cannot_read_fails_the_install() {
let (start, folders, titles) = reports(&installed("zsh")); use std::os::unix::fs::PermissionsExt;
assert_eq!(folders[0], start); let dir = folder("unreadable");
assert!(titles.contains(&"start".to_string()), "{titles:?}"); install(&dir).expect("installs");
let bash = dir.join("bash/elyterminal.bash");
fs::set_permissions(&bash, fs::Permissions::from_mode(0o200)).expect("200");
let error = install(&dir).expect_err("an unreadable script fails");
fs::set_permissions(&bash, fs::Permissions::from_mode(0o600)).expect("600");
assert!( assert!(
titles.iter().any(|title| title.starts_with("cd ")), format!("{error:#}").contains("bash/elyterminal.bash"),
"{titles:?}" "{error:#}"
); );
fs::remove_dir_all(dir).expect("cleanup");
} }
#[test] #[test]
fn bash_reports_its_folder_and_title() { fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
let (start, folders, titles) = reports(&installed("bash")); let mut run = Run::start(&installed("zsh"), &[]);
assert_eq!(folders[0], start); run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED");
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}"); run.until("title", |run| {
run.titles.iter().any(|title| title == "in ner%xy")
});
assert!(
run.titles.contains(&"cd in*".to_string()),
"{:?}",
run.titles
);
run.finish();
} }
#[test] #[test]
fn fish_reports_its_folder_and_keeps_its_own_title() { fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() {
let (start, folders, titles) = reports(&installed("fish")); let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'"#;
assert_eq!(folders[0], start); let files = [(".bashrc", status), (".bash_profile", status)];
assert!( let mut run = Run::start(&installed("bash"), &files);
titles.iter().any(|title| title.ends_with("start")), run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
"{titles:?}" run.until("title", |run| {
); run.titles.iter().any(|title| title == "in ner%xy")
});
run.send("false");
run.until("kept status", |run| run.shows("USER_STATUS=1"));
run.finish();
}
#[test]
fn fish_loads_its_script_and_restores_its_data_dirs() {
let mut run = Run::start(&installed("fish"), &[]);
run.walk("functions -q _ely_prompt; and echo ELY-LOAD''ED");
let previous = env::var("XDG_DATA_DIRS").unwrap_or_default();
run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#);
let restored = format!("DIRS[][{previous}]");
run.until("restored dirs", |run| run.shows(&restored));
run.finish();
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[test] #[test]
fn powershell_reports_its_folder_and_title() { fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
let (start, folders, titles) = reports(&installed("pwsh")); let prompt = r#"function global:prompt { "USER_SUCCESS=$? > " }"#;
assert_eq!(folders[0], start); let files = [(
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}"); ".config/powershell/Microsoft.PowerShell_profile.ps1",
prompt,
)];
let mut run = Run::start(&installed("pwsh"), &files);
run.walk("if ($global:ElyPrompt) { 'ELY-LOAD' + 'ED' }");
run.until("title", |run| {
run.titles.iter().any(|title| title == "in ner%xy")
});
run.send("/usr/bin/false");
run.until("kept status", |run| run.shows("USER_SUCCESS=False"));
run.finish();
} }
#[test] #[test]