fix(plugins): Windows: local marketplace paths, localhost connections, tests

- A marketplace index given as a local Windows path (`C:\market\index.json`)
  resolves its packages next to it; only URLs are joined with `/`.
- HTTP requests to `localhost` try IPv4 loopback first, then IPv6: local
  servers such as Ollama usually listen on IPv4, and on Windows a refused
  `::1` cost about two seconds per request.
- Tests that assumed Unix: an absolute program path is the platform's, a
  manifest rewritten by a test may have CRLF line ends, the precompiled-cache
  test replaces the cache file only after the host that maps it is gone, and
  the MyBatis test lets its first call build the index before asserting.
- The icon theme test's time limits guard against work growing faster than
  the icon count rather than measure the runner.
This commit is contained in:
l0ng-ai committed 2026-11-04 02:41:46 +08:00
1 parent 20960c1d96
commit 20281e9b66
4 files changed
+70 -20

No files matched your search

+3 -1
View File
@@ -230,11 +230,13 @@ mod tests {
"net:crates.io",
"net:*.github.com",
"process:cargo",
"process:/usr/bin/git",
"fs.read:/etc",
] {
assert_eq!(s.parse::<Capability>().unwrap().to_string(), s);
}
// An absolute program path is the platform's.
let git = if cfg!(windows) { r"process:C:\Program Files\Git\bin\git.exe" } else { "process:/usr/bin/git" };
assert_eq!(git.parse::<Capability>().unwrap().to_string(), git);
assert_eq!("project-read".parse::<Capability>().unwrap(), Capability::ProjectRead);
assert!("net:".parse::<Capability>().is_err());
assert!("net:a b".parse::<Capability>().is_err());
+17 -5
View File
@@ -56,13 +56,20 @@ pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
Ok(bytes)
}
/// `href` relative to the index at `base`.
/// `href` relative to the index at `base`: a URL, or a local path (with the
/// platform's separators, e.g. `C:\\market\\index.json`).
pub fn resolve(base: &str, href: &str) -> String {
if href.contains("://") || href.starts_with('/') {
if href.contains("://") || href.starts_with('/') || Path::new(href).is_absolute() {
return href.to_owned();
}
match base.rfind('/') {
Some(i) => format!("{}/{href}", &base[..i]),
if base.contains("://") {
return match base.rfind('/') {
Some(i) => format!("{}/{href}", &base[..i]),
None => href.to_owned(),
};
}
match Path::new(base).parent() {
Some(dir) => dir.join(href).to_string_lossy().into_owned(),
None => href.to_owned(),
}
}
@@ -296,7 +303,12 @@ mod tests {
#[test]
fn downloads_resolve_against_the_index() {
assert_eq!(resolve("https://x.dev/market/index.json", "p/a.oiplugin"), "https://x.dev/market/p/a.oiplugin");
assert_eq!(resolve("/srv/market/index.json", "a.oiplugin"), "/srv/market/a.oiplugin");
assert_eq!(
Path::new(&resolve("/srv/market/index.json", "a.oiplugin")),
Path::new("/srv/market").join("a.oiplugin")
);
#[cfg(windows)]
assert_eq!(resolve(r"C:\market\index.json", "a.oiplugin"), r"C:\market\a.oiplugin");
assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a");
}
}
+22 -13
View File
@@ -175,7 +175,9 @@ fn hello_02(text: &str, required: &str) -> String {
/// `hello` with a 0.1 manifest (`openidea-api`, `permissions`, no activation).
fn hello_01(text: &str) -> String {
text.replace("engines.openidea = \"^1.0\"", "openidea-api = \"0.1\"")
// A Windows checkout may have CRLF line ends.
text.replace("\r\n", "\n")
.replace("engines.openidea = \"^1.0\"", "openidea-api = \"0.1\"")
.replace("activation = [\"on-command:*\", \"on-view:greetings\"]\n", "")
.replace(
"[capabilities]\nrequired = [\"editor.read\", \"editor.write\"]",
@@ -1164,19 +1166,19 @@ fn precompiled_components_make_cold_activation_cheap() {
std::fs::write(cache.join("0000-stale.cwasm"), b"junk").unwrap();
again.warm_component_cache();
assert_eq!(cached(), 1);
// A corrupt entry is discarded and recompiled. Replaced, not overwritten:
// the running component maps the file it was loaded from.
// A corrupt entry is discarded and recompiled. The host that loaded it goes
// first: its component maps the file (which Windows won't let anyone replace).
drop(again);
for e in std::fs::read_dir(&cache).unwrap().flatten() {
let junk = e.path().with_extension("junk");
std::fs::write(&junk, b"junk").unwrap();
std::fs::rename(&junk, e.path()).unwrap();
std::fs::write(e.path(), b"junk").unwrap();
}
again.reload("hello").unwrap();
let third = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
third.load_all();
assert!(
again.run_command("hello", "word-count", None, Invocation::default()).is_err(),
third.run_command("hello", "word-count", None, Invocation::default()).is_err(),
"no active file, but it runs"
);
assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Active);
assert_eq!(third.info_of("hello").unwrap().status, PluginStatus::Active);
}
#[test]
@@ -1342,9 +1344,12 @@ fn thousands_of_icons_install_and_reload_within_budget() {
let reload = started.elapsed();
assert_eq!(again.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS);
eprintln!("{ICONS} icons: install {install:?}, load at start-up {reload:?} (from the contribution index)");
// Generous for debug builds; release is several times faster.
assert!(install < Duration::from_secs(5), "install took {install:?}");
assert!(reload < Duration::from_secs(3), "start-up load took {reload:?}");
// Guards against work that grows faster than the icon count, not a
// benchmark: a debug build takes ~1.6 s / ~0.35 s on a laptop; shared CI
// runners running the suite in parallel take several times that (Windows
// ~11 s to install).
assert!(install < Duration::from_secs(30), "install took {install:?}");
assert!(reload < Duration::from_secs(10), "start-up load took {reload:?}");
// Start-up reads the validated declaration from the index, not the SVGs:
// a tampered icon file is not even looked at...
@@ -1814,6 +1819,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
text: text.into(),
};
let jdoc = doc(&java_path, "java", java);
// The first call indexes the XML files; on a slow machine that can run past
// the edit-path budget (the answer is dropped, the index kept).
let _ = f.host.provide_line_markers(&jdoc);
let markers = f.host.provide_line_markers(&jdoc);
assert_eq!(markers.len(), 1, "{markers:?}");
let target = markers[0].1.target.clone().unwrap();
@@ -1831,6 +1839,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
);
// Warm (index cached): well inside the edit-path budget.
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
let mut times = Vec::new();
for _ in 0..20 {
let started = Instant::now();
@@ -1840,7 +1849,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
times.sort();
eprintln!("mybatis line markers p95: {:?}", times[18]);
assert!(times[18] < Duration::from_millis(50), "{times:?}");
assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, 0);
assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, slow);
}
/// CSV Editor: a custom editor renders the document as a table (the
+28 -1
View File
@@ -35,8 +35,16 @@ pub fn build(proxy: &ProxySettings) -> IdeResult<reqwest::Client> {
.with_platform_verifier()
.map_err(|e| fail(e.to_string()))?
.with_no_client_auth();
// `localhost` is tried on IPv4 first: local servers (Ollama, dev servers)
// usually listen there, and on Windows a refused `::1` costs ~2 s before
// the system resolver's order falls back. Ports come from the URL.
let loopback = [
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0)),
std::net::SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 0)),
];
let builder = reqwest::Client::builder()
.tls_backend_preconfigured(tls)
.resolve_to_addrs("localhost", &loopback)
.connect_timeout(Duration::from_secs(30))
.user_agent(concat!("OpenIDEA-HttpClient/", env!("CARGO_PKG_VERSION")));
with_proxy(builder, proxy).build().map_err(|e| fail(e.to_string()))
@@ -113,7 +121,11 @@ mod tests {
/// A one-shot HTTP server on loopback: answers `200` and hands back the request head.
fn server() -> (u16, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
server_on("127.0.0.1:0")
}
fn server_on(addr: &str) -> (u16, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind(addr).unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
@@ -141,6 +153,21 @@ mod tests {
check_connection(proxy, url, &CancellationToken::new()).await
}
/// `localhost` reaches a server on either loopback address.
#[tokio::test]
async fn localhost_reaches_ipv4_and_ipv6_servers() {
let direct = ProxySettings::default();
let (port, v4) = server();
assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK");
v4.join().unwrap();
// Hosts without IPv6 loopback skip this half.
if TcpListener::bind("[::1]:0").is_ok() {
let (port, v6) = server_on("[::1]:0");
assert_eq!(get(&direct, &format!("http://localhost:{port}/")).await.unwrap(), "200 OK");
v6.join().unwrap();
}
}
#[tokio::test]
async fn builds_for_every_mode() {
for mode in [ProxyMode::None, ProxyMode::Auto, ProxyMode::Manual] {