Files
shell-rs/tests/ui/docker.rs
T
XuzandClaude Opus 5.5 5514612524 右侧栏七个工具的文案接入多语言
命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控和工作区里它们的确认、
通知改用 t! / tn!,文案在 locales/<工具>.yml 与 locales/tools.yml。容器操作的对象改成
ContainerSubject,每种操作一整句;详情里的字段存键、按键找行,切换语言时跟着变;
搜索框的占位随界面语言重设。

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

510 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")
);
assert_tabs_share_the_width(window, "docker-tabs", 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;
}