Files
shell-rs/tests/ui/docker.rs
T
XuzandClaude Opus 5.5 7b0dad9b41 Docker 的标签改用可横向滚动的下划线标签,与系统服务一致
均分宽度放不下英文的 "Containers 12"。不管什么语言,标签都按文字和数量的宽度排,
放不下时横向滚动。去掉只给 Docker 用的 count_tabs 和分段控件的计数。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:42:23 +08:00

509 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Docker: the right sidebar's tool that lists the SSH terminal's host's
//! containers by compose project, with its volumes, images and networks,
//! over the terminal's own connection.
use gpui_kit::{SharedString, Window};
use crate::support::*;
/// A host with a running compose project, a stopped one, and two
/// containers of no project.
const READING: &str = r#"@@version
26.0.0
@@compose
v2.25.0
@@containers
{"ID":"aaa111","Image":"php:5.6-fpm","Labels":"com.docker.compose.project=php-56,com.docker.compose.project.working_dir=/srv/php-5.6,com.docker.compose.service=php56","Mounts":"","Names":"php56","Networks":"php-56_default","Ports":"0.0.0.0:56000->9000/tcp, :::56000->9000/tcp","State":"running","Status":"Up 3 days"}
{"ID":"bbb222","Image":"vaultwarden/server:latest","Labels":"com.docker.compose.project=vaultwarden,com.docker.compose.project.working_dir=/srv/vaultwarden","Mounts":"vw-data","Names":"vaultwarden","Networks":"vaultwarden_default","Ports":"","State":"exited","Status":"Exited (0) 2 days ago"}
{"ID":"ccc333","Image":"forex-web:latest","Labels":"","Mounts":"","Names":"forex-web","Networks":"bridge","Ports":"127.0.0.1:4321->4321/tcp","State":"running","Status":"Up 2 hours"}
{"ID":"ddd444","Image":"hello-world","Labels":"","Mounts":"","Names":"exciting_tesla","Networks":"bridge","Ports":"","State":"exited","Status":"Exited (0) 3 weeks ago"}
@@volumes
{"Driver":"local","Mountpoint":"/var/lib/docker/volumes/vw-data/_data","Name":"vw-data"}
{"Driver":"local","Mountpoint":"/var/lib/docker/volumes/old/_data","Name":"old"}
@@images
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"4a3b5c6d7e8f","Repository":"hello-world","Size":"13.3kB","Tag":"latest"}
@@networks
{"Driver":"bridge","ID":"n1","Name":"bridge","Scope":"local"}
"#;
const INSPECT: &str = r#"[{"Id":"ccc333","Name":"/forex-web","Created":"2026-09-07T10:59:16.371325789Z",
"State":{"Status":"running"},
"Config":{"Image":"forex-web:latest","Entrypoint":["docker-entrypoint.sh"],"Cmd":["node","./dist/server/entry.mjs"],
"Env":["TZ=Asia/Shanghai","PORT=4321"],"Labels":{}},
"NetworkSettings":{"Ports":{"4321/tcp":[{"HostIp":"127.0.0.1","HostPort":"4321"}]}},
"Mounts":[]}]"#;
const OUTPUT: &str = "2026-09-07T10:59:17Z Listening on http://0.0.0.0:4321\n";
fn open_docker(cx: &mut TestAppContext, factory: Arc<FakeTerminalFactory>) -> WindowHandle<Root> {
let (handle, _) = open_workspace_with_remote_factory(cx, HostStore::seed(), factory);
in_frame(cx, handle, |window, cx| {
window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx)
});
in_frame(cx, handle, |window, cx| window.click("tool-docker", cx));
handle
}
async fn wait_for_label(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
id: &str,
label: &str,
) {
let id = SharedString::from(id.to_owned());
cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| {
window
.try_find(id.clone())
.is_some_and(|element| element.label() == Some(label))
})
.await;
}
async fn wait_for_reading(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
wait_for_label(
cx,
handle,
"docker-summary",
"Docker 26.0.0 · Compose v2.25.0",
)
.await;
}
/// The lines from the top of the list down, of those named.
fn listed(window: &mut Window, ids: &[&str]) -> Vec<String> {
let mut lines: Vec<(f32, String)> = ids
.iter()
.filter_map(|id| {
let element = window.try_find(SharedString::from(id.to_string()))?;
Some((f32::from(element.bounds().origin.y), id.to_string()))
})
.collect();
lines.sort_by(|a, b| a.0.total_cmp(&b.0));
lines.into_iter().map(|(_, id)| id).collect()
}
const LINES: [&str; 6] = [
"docker-project:php-56",
"docker-container:php56",
"docker-project:vaultwarden",
"docker-container:vaultwarden",
"docker-container:exciting_tesla",
"docker-container:forex-web",
];
#[gpui_kit::test]
async fn containers_show_by_project_and_the_rest_on_their_own(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[READING]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(),
Some("Docker")
);
// The running project of one container is unfolded; the stopped one
// is not.
assert_eq!(
listed(window, &LINES),
[
"docker-project:php-56",
"docker-container:php56",
"docker-project:vaultwarden",
"docker-container:exciting_tesla",
"docker-container:forex-web",
]
);
assert_eq!(
window.find("docker-project:php-56").label(),
Some("php-56 · 1/1 运行中")
);
assert_eq!(
window.find("docker-container:php56").label(),
Some("php56 · 运行中 · 56000→9000/tcp, :::56000→9000/tcp")
);
assert_eq!(
window.find("docker-container:exciting_tesla").label(),
Some("exciting_tesla · 已停止 · hello-world")
);
assert!(window.try_find("docker-stop:project:php-56").is_some());
assert!(
window
.try_find("docker-start:project:vaultwarden")
.is_some()
);
assert_eq!(
window.within("docker-tabs").find(0usize).label(),
Some("容器 4")
);
});
// Unfold the stopped project, fold the running one.
in_frame(cx, handle, |window, cx| {
window.click("docker-project-line:vaultwarden", cx)
});
in_frame(cx, handle, |window, cx| {
window.click("docker-project-line:php-56", cx)
});
in_frame(cx, handle, |window, _| {
assert_eq!(
listed(window, &LINES),
[
"docker-project:php-56",
"docker-project:vaultwarden",
"docker-container:vaultwarden",
"docker-container:exciting_tesla",
"docker-container:forex-web",
]
);
});
// Read once: nothing changes on a timer.
cx.executor().advance_clock(Duration::from_secs(60));
cx.run_until_parked();
assert_eq!(factory.exec_count(), 1);
}
#[gpui_kit::test]
async fn the_other_tabs_list_volumes_images_and_networks(cx: &mut TestAppContext) {
let handle = open_docker(cx, Arc::new(FakeTerminalFactory::answering(&[READING])));
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(1usize, cx)
});
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("docker-volume:vw-data").label(),
Some("vw-data · local · /var/lib/docker/volumes/vw-data/_data · 使用中")
);
// What a container uses cannot be removed; what nothing uses can.
assert!(
window
.find("docker-volume:vw-data-remove")
.focused()
.is_none()
);
assert!(window.find("docker-volume:old-remove").focused().is_some());
assert!(window.try_find("docker-container:php56").is_none());
});
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(2usize, cx)
});
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("docker-image:hello-world:latest").label(),
Some("hello-world:latest · 4a3b5c6d7e8f · 13.3 kB · 2026-09-07 · 使用中")
);
});
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(3usize, cx)
});
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("docker-network:bridge").label(),
Some("bridge · bridge · local · 内置")
);
});
}
#[gpui_kit::test]
async fn stopping_a_project_asks_first_and_starting_a_container_does_not(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[
READING,
"@@status 0\n",
READING,
"@@status 0\n",
READING,
]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.click("docker-stop:project:php-56", cx)
});
assert_eq!(factory.exec_count(), 1);
in_frame(cx, handle, |window, cx| window.click("ok", cx));
cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| {
factory.exec_count() == 3
})
.await;
assert!(factory.exec_commands()[1].contains("$d stop \"aaa111\""));
in_frame(cx, handle, |window, cx| {
window.click("docker-start:exciting_tesla", cx)
});
cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| {
factory.exec_count() == 5
})
.await;
assert!(factory.exec_commands()[3].contains("$d start \"ddd444\""));
}
#[gpui_kit::test]
async fn removing_a_volume_asks_first(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[
READING,
"@@status 0\n",
READING,
]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(1usize, cx)
});
in_frame(cx, handle, |window, cx| {
window.click("docker-volume:old-remove", cx)
});
assert_eq!(factory.exec_count(), 1);
in_frame(cx, handle, |window, cx| window.click("ok", cx));
cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| {
factory.exec_count() == 3
})
.await;
assert!(factory.exec_commands()[1].contains("$d volume rm \"old\""));
}
#[gpui_kit::test]
async fn a_click_on_a_container_shows_its_details_and_output(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[READING, INSPECT, OUTPUT]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.click("docker-container:forex-web", cx)
});
wait_for_label(
cx,
handle,
"container-basics:command",
"node ./dist/server/entry.mjs",
)
.await;
in_frame(cx, handle, |window, _| {
let row = |id: &str| {
window
.find(SharedString::from(id.to_owned()))
.label()
.map(str::to_owned)
};
assert_eq!(row("container-basics:name").as_deref(), Some("forex-web"));
assert_eq!(
row("container-basics:entrypoint").as_deref(),
Some("docker-entrypoint.sh")
);
assert_eq!(
row("container-ports:4321/tcp").as_deref(),
Some("127.0.0.1:4321")
);
assert_eq!(
row("container-environment").as_deref(),
Some("TZ=Asia/Shanghai\nPORT=4321")
);
// Running: it stops, and is not removed.
assert!(window.try_find("container-dialog-stop").is_some());
assert!(window.try_find("container-dialog-remove").is_none());
});
assert!(factory.exec_commands()[1].contains("$d inspect \"ccc333\""));
in_frame(cx, handle, |window, cx| {
window.within("container-tabs").click(1usize, cx)
});
wait_for_label(cx, handle, "container-output", OUTPUT.trim_end()).await;
assert!(factory.exec_commands()[2].contains("$d logs --tail 200"));
}
const VOLUME: &str = r#"[{"CreatedAt":"2026-09-01T08:00:00+08:00","Driver":"local","Labels":{},
"Mountpoint":"/var/lib/docker/volumes/vw-data/_data","Name":"vw-data","Options":null,"Scope":"local"}]"#;
const IMAGE: &str = r#"[{"Id":"sha256:4a3b5c6d7e8f","RepoTags":["hello-world:latest"],"RepoDigests":[],
"Created":"2025-08-09T10:00:00Z","Size":13312,"Os":"linux","Architecture":"amd64",
"Config":{"Cmd":["/hello"],"Env":["PATH=/usr/bin"]},"RootFS":{"Layers":["sha256:a"]}}]"#;
const NETWORK: &str = r#"[{"Name":"bridge","Id":"n1","Driver":"bridge","Scope":"local",
"IPAM":{"Config":[{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]},
"Containers":{"ccc333":{"Name":"forex-web","IPv4Address":"172.17.0.2/16"}}}]"#;
/// One image under two tags, and one without a tag.
const TAGGED_TWICE: &str = r#"@@version
26.0.0
@@compose
v2.25.0
@@containers
@@volumes
@@images
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"latest"}
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"1.27"}
{"CreatedAt":"2026-08-01 08:00:00 +0800 CST","ID":"ccc333ddd444","Repository":"nginx","Size":"180MB","Tag":"<none>"}
@@networks
"#;
#[gpui_kit::test]
async fn an_image_under_two_tags_is_two_cards_each_removed_by_its_tag(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[
TAGGED_TWICE,
"Untagged: nginx:1.27\n@@status 0\n",
TAGGED_TWICE,
]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(2usize, cx)
});
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("docker-image:nginx:1.27").label(),
Some("nginx:1.27 · aaa111bbb222 · 187 MB · 2026-09-07")
);
assert_eq!(
window.find("docker-image:nginx:latest").label(),
Some("nginx:latest · aaa111bbb222 · 187 MB · 2026-09-07")
);
// Without a tag, by its ID.
assert_eq!(
window.find("docker-image:ccc333ddd444").label(),
Some("nginx:<none> · ccc333ddd444 · 180 MB · 2026-08-01")
);
});
in_frame(cx, handle, |window, cx| {
window.click("docker-image:nginx:1.27-remove", cx)
});
in_frame(cx, handle, |window, cx| window.click("ok", cx));
cx.wait_for(handle.into(), Duration::from_secs(3), |_, _| {
factory.exec_count() >= 2
})
.await;
assert!(
factory.exec_commands()[1].contains("$d rmi \"nginx:1.27\""),
"{:?}",
factory.exec_commands()
);
}
#[gpui_kit::test]
async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[
READING, VOLUME, IMAGE, NETWORK,
]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(1usize, cx)
});
in_frame(cx, handle, |window, cx| {
window.click("docker-volume:vw-data", cx)
});
wait_for_label(
cx,
handle,
"volume-basics:mountpoint",
"/var/lib/docker/volumes/vw-data/_data",
)
.await;
in_frame(cx, handle, |window, _| {
// Who uses it comes from the list: Docker's inspect does not say.
assert!(window.try_find("volume-users:vaultwarden").is_some());
// In use, so not to be removed.
assert!(window.find("docker-dialog-remove").focused().is_none());
});
assert!(factory.exec_commands()[1].contains("$d volume inspect \"vw-data\""));
in_frame(cx, handle, |window, cx| window.press("escape", cx));
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(2usize, cx)
});
in_frame(cx, handle, |window, cx| {
window.click("docker-image:hello-world:latest", cx)
});
wait_for_label(cx, handle, "image-basics:platform", "linux/amd64").await;
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("image-basics:size").label(), Some("13.00 KB"));
assert!(window.try_find("image-users:exciting_tesla").is_some());
assert_eq!(
window.find("image-environment").label(),
Some("PATH=/usr/bin")
);
});
assert!(factory.exec_commands()[2].contains("$d image inspect \"hello-world:latest\""));
in_frame(cx, handle, |window, cx| window.press("escape", cx));
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(3usize, cx)
});
in_frame(cx, handle, |window, cx| {
window.click("docker-network:bridge", cx)
});
wait_for_label(
cx,
handle,
"network-subnets:172.17.0.0/16",
"网关 172.17.0.1",
)
.await;
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("network-containers:forex-web").label(),
Some("172.17.0.2/16")
);
});
assert!(factory.exec_commands()[3].contains("$d network inspect \"n1\""));
}
#[gpui_kit::test]
async fn an_unused_volume_is_removed_from_its_details_after_asking(cx: &mut TestAppContext) {
let unused = VOLUME.replace("vw-data", "old");
let factory = Arc::new(FakeTerminalFactory::answering(&[
READING,
&unused,
"@@status 0\n",
READING,
]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(1usize, cx)
});
in_frame(cx, handle, |window, cx| {
window.click("docker-volume:old", cx)
});
wait_for_label(cx, handle, "volume-basics:name", "old").await;
in_frame(cx, handle, |window, cx| {
window.click("docker-dialog-remove", cx)
});
// The details give way to the question.
in_frame(cx, handle, |window, cx| {
assert!(window.try_find("docker-details").is_none());
window.click("ok", cx)
});
cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| {
factory.exec_count() == 4
})
.await;
assert!(factory.exec_commands()[2].contains("$d volume rm \"old\""));
}
#[gpui_kit::test]
async fn docker_says_when_it_will_not_answer(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[
"@@version\nCannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\n",
]));
let handle = open_docker(cx, factory);
wait_for_label(
cx,
handle,
"docker-message",
"无法读取 Docker:Docker 没有运行",
)
.await;
}
#[gpui_kit::test]
async fn docker_says_when_the_host_has_none(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&["@@missing\nLinux\n"]));
let handle = open_docker(cx, factory);
wait_for_label(cx, handle, "docker-message", "这台主机没有安装 Docker。").await;
}