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
+230 -110

No files matched your search

+11 -1
View File
@@ -13,6 +13,16 @@ else
[ -r ~/.bashrc ] && builtin source ~/.bashrc
fi
_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}"
@@ -7,6 +7,6 @@ end
set -e ELY_FISH_XDG_DATA_DIRS
if status is-interactive
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
@@ -1,13 +1,15 @@
# ELYTerminal: wrap the prompt to report folder and title.
$global:ElyPrompt = $function:prompt
function global:prompt {
$text = & $global:ElyPrompt
$folder = $executionContext.SessionState.Path.CurrentLocation
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" }
$leaf = Split-Path -Leaf $folder.ProviderPath
$leaf = (Split-Path -Leaf $folder.ProviderPath) -replace '\p{Cc}', ''
$esc, $bel = [char]27, [char]7
[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"
if [[ -o interactive ]]; then
_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() {
builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}"
+18 -6
View File
@@ -1,5 +1,6 @@
use std::{
env, fs,
io::ErrorKind,
path::{Path, PathBuf},
};
@@ -50,8 +51,14 @@ impl ShellIntegration {
pub fn install(dir: &Path) -> anyhow::Result<()> {
for (name, text) in SCRIPTS {
let path = dir.join(name);
if fs::read_to_string(&path).is_ok_and(|kept| kept == text) {
continue;
match fs::read_to_string(&path) {
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
.parent()
@@ -98,11 +105,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
}
"fish" => {
let previous = env::var("XDG_DATA_DIRS").unwrap_or_default();
let data = if previous.is_empty() {
format!("{}:/usr/local/share:/usr/share", script("fish"))
let rest = if previous.is_empty() {
"/usr/local/share:/usr/share"
} else {
format!("{}:{previous}", script("fish"))
&previous
};
let data = format!("{}:{rest}", dir.display());
launch.env.push(("XDG_DATA_DIRS".into(), data));
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![
"-NoLogo".into(),
"-NoExit".into(),
"-ExecutionPolicy".into(),
"Bypass".into(),
"-Command".into(),
format!(". '{file}'"),
format!(
"try {{ . '{file}' }} catch {{ [Console]::Error.WriteLine(\"ELYTerminal: shell integration failed: $_\"); exit 1 }}"
),
],
));
}
+186 -98
View File
@@ -1,10 +1,13 @@
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 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.
fn folder(name: &str) -> PathBuf {
@@ -21,88 +24,135 @@ fn installed(shell: &str) -> String {
.args(["-c", &format!("command -v {shell}")])
.output()
.expect("sh runs");
let path = String::from_utf8(found.stdout).expect("utf-8");
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.
fn reports(shell: &str) -> (PathBuf, Vec<PathBuf>, Vec<String>) {
crate::testlog::init();
let root = folder(&format!(
"shell-{}",
shell.rsplit('/').next().expect("a name")
));
let (dir, home, start) = (
root.join("integration"),
root.join("home"),
root.join("start"),
);
let inner = start.join("in ner%");
for made in [&home, &inner] {
fs::create_dir_all(made).expect("a folder");
}
install(&dir).expect("scripts install");
let mut launch = launch(shell, start.clone(), &dir).expect("a launch");
launch.env.push(("HOME".into(), home.display().to_string()));
let mut pty = spawn(&launch, (80, 24), (8, 16), 100).expect("the shell starts");
let (mut folders, mut titles) = (Vec::new(), Vec::new());
let deadline = Instant::now() + Duration::from_secs(20);
let mut asked = false;
while !folders.contains(&inner) {
assert!(
Instant::now() < deadline,
"{shell} reported {folders:?} and {titles:?} on\n{}",
screen(&pty)
/// A shell started with integration under a fake HOME, and what it reported.
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();
let root = folder(&format!(
"shell-{}",
shell.rsplit('/').next().expect("a name")
));
let (dir, home, start) = (
root.join("integration"),
root.join("home"),
root.join("start"),
);
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:?}")
fs::create_dir_all(start.join(INNER)).expect("the inner folder");
fs::create_dir_all(&home).expect("home");
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");
let mut launch = launch(shell, start.clone(), &dir).expect("a launch");
launch.env.push(("HOME".into(), home.display().to_string()));
let pty = spawn(&launch, (100, 30), (8, 16), 100).expect("the shell starts");
Self {
shell: shell.into(),
pty,
root,
start,
folders: Vec::new(),
titles: Vec::new(),
deadline: Instant::now() + Duration::from_secs(30),
}
}
/// Reads events, answering the shell's terminal queries, until `done` holds.
fn until(&mut self, what: &str, done: impl Fn(&Self) -> bool) {
while !done(self) {
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)),
}
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.
fn screen(pty: &crate::term::Pty) -> String {
use alacritty_terminal::{grid::Dimensions, index::Line};
let term = pty.term.lock();
(0..term.screen_lines())
.map(|line| {
let row = &term.grid()[Line(line as i32)];
let text: String = row.into_iter().map(|cell| cell.c).collect();
text.trim_end().to_string()
})
.collect::<Vec<_>>()
.join("\n")
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())
.map(|line| {
let row = &term.grid()[Line(line as i32)];
let text: String = row.into_iter().map(|cell| cell.c).collect();
text.trim_end().to_string()
})
.collect::<Vec<_>>()
.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]
@@ -111,46 +161,84 @@ fn the_scripts_install_once_and_mend_a_changed_one() {
install(&dir).expect("installs");
let zshenv = dir.join("zsh/.zshenv");
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");
assert_eq!(fs::read_to_string(&zshenv).expect("mended"), SCRIPTS[0].1);
fs::remove_dir_all(dir).expect("cleanup");
}
#[test]
fn zsh_reports_its_folder_and_title() {
let (start, folders, titles) = reports(&installed("zsh"));
assert_eq!(folders[0], start);
assert!(titles.contains(&"start".to_string()), "{titles:?}");
fn a_script_it_cannot_read_fails_the_install() {
use std::os::unix::fs::PermissionsExt;
let dir = folder("unreadable");
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!(
titles.iter().any(|title| title.starts_with("cd ")),
"{titles:?}"
format!("{error:#}").contains("bash/elyterminal.bash"),
"{error:#}"
);
fs::remove_dir_all(dir).expect("cleanup");
}
#[test]
fn bash_reports_its_folder_and_title() {
let (start, folders, titles) = reports(&installed("bash"));
assert_eq!(folders[0], start);
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}");
fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
let mut run = Run::start(&installed("zsh"), &[]);
run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED");
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]
fn fish_reports_its_folder_and_keeps_its_own_title() {
let (start, folders, titles) = reports(&installed("fish"));
assert_eq!(folders[0], start);
assert!(
titles.iter().any(|title| title.ends_with("start")),
"{titles:?}"
);
fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() {
let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'"#;
let files = [(".bashrc", status), (".bash_profile", status)];
let mut run = Run::start(&installed("bash"), &files);
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
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")]
#[test]
fn powershell_reports_its_folder_and_title() {
let (start, folders, titles) = reports(&installed("pwsh"));
assert_eq!(folders[0], start);
assert!(titles.contains(&"in ner%".to_string()), "{titles:?}");
fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
let prompt = r#"function global:prompt { "USER_SUCCESS=$? > " }"#;
let files = [(
".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]