use glacier_ui::GlacierUI;
fn boot() -> GlacierUI {
let crate_dir = env!("CARGO_MANIFEST_DIR");
let ws_root = std::path::Path::new(crate_dir);
std::env::set_current_dir(ws_root).expect("cd workspace root");
let mut m = GlacierUI::new();
m.register_app("views/app.gvb")
.expect("app.gvb + imports must register (includes app.gss parsing — an unknown property drops the whole sheet)");
m.set_initial_screen("app");
m
}
fn textos_avaliados(n: &glacier_ui::UiNode) -> Vec<String> {
let mut out = Vec::new();
if let glacier_ui::parser::NodeType::Text { content, .. } = &n.kind {
out.push(content.clone());
}
for f in n.children.iter() {
out.extend(textos_avaliados(f));
}
out
}
fn botoes_avaliados(n: &glacier_ui::UiNode) -> Vec<String> {
let mut out = Vec::new();
if let glacier_ui::parser::NodeType::Button { text, .. } = &n.kind {
out.push(text.clone());
}
for f in n.children.iter() {
out.extend(botoes_avaliados(f));
}
out
}
fn cd_ws_root() {
let crate_dir = env!("CARGO_MANIFEST_DIR");
let ws_root = std::path::Path::new(crate_dir);
std::env::set_current_dir(ws_root).expect("cd workspace root");
}
#[test]
fn new_project_form_window_renders() {
cd_ws_root();
let mut m = GlacierUI::new();
m.define_data("api_url", "http://localhost");
m.define_data("api_token", "t");
m.register_app_screen("views/app.gvb", "new_project")
.expect("new_project_form.gvb must register");
m.set_initial_screen("new_project");
m.reevaluate_all().expect("eval new_project_form");
assert!(
m.render("new_project").is_ok(),
"render new_project_form"
);
fn find_control<'a>(
node: &'a glacier_ui::parser::UiNode,
name: &str,
) -> Option<&'a glacier_ui::parser::UiNode> {
if node.form_control() == Some(name) {
return Some(node);
}
node.children.iter().find_map(|c| find_control(c, name))
}
let ast = m.evaluated("new_project").expect("evaluated");
let new_project_name = find_control(ast, "new_project_name")
.expect("o form_control \"new_project_name\" deve existir na árvore avaliada");
assert!(
new_project_name.rules().is_some_and(|r| r.contains("required")),
"new_project_name deve carregar rules=\"required\", achei {:?}",
new_project_name.rules()
);
assert_eq!(
new_project_name
.form
.as_ref()
.and_then(|f| f.form_error_action.as_deref()),
Some("new_project_show_validation_errors"),
"o on_validation_error do <form> deve chegar no campo (form_error_action)"
);
}
#[test]
fn new_job_window_renders() {
cd_ws_root();
let mut m = GlacierUI::new();
m.define_data("api_url", "http://localhost");
m.define_data("api_token", "t");
m.define_data("new_job_projects", r#"[{"id":"prj_1","name":"acme"}]"#);
m.define_data(
"new_job_services",
r#"[{"id":"svc_1","name":"web","project_id":"prj_1"}]"#,
);
m.register_app_screen("views/app.gvb", "new_job")
.expect("new_job_window.gvb must register");
m.set_initial_screen("new_job");
m.define_data("new_job_step", "pick_project");
m.reevaluate_all()
.expect("eval new_job_window/pick_project");
assert!(
m.render("new_job").is_ok(),
"render new_job_window/pick_project"
);
m.define_data("new_job_step", "pick_service");
m.define_data("new_job_project_name", "acme");
m.define_data(
"new_job_services_filtered",
r#"[{"id":"svc_1","name":"web","project_id":"prj_1"}]"#,
);
m.reevaluate_all()
.expect("eval new_job_window/pick_service");
assert!(
m.render("new_job").is_ok(),
"render new_job_window/pick_service"
);
m.define_data("new_job_step", "form");
m.define_data("new_job_service_name", "web");
m.define_data("new_job_time", "03:00");
m.define_data(
"weekdays",
r#"[{"id":"0","label":"Seg"},{"id":"1","label":"Ter"},{"id":"2","label":"Qua"},{"id":"3","label":"Qui"},{"id":"4","label":"Sex"},{"id":"5","label":"Sáb"},{"id":"6","label":"Dom"}]"#,
);
for kind in ["manual", "interval", "daily", "weekly"] {
m.define_data("new_job_kind", kind);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval new_job_window/form {kind}: {e}"));
assert!(
m.render("new_job").is_ok(),
"render new_job_window/form {kind}"
);
}
}
#[test]
fn log_window_renders() {
cd_ws_root();
let mut m = GlacierUI::new();
m.define_data("api_url", "http://localhost");
m.define_data("api_token", "t");
m.define_data("log_window_title", "Logs · api");
m.define_data("log_window_stream_url", "/api/services/svc1/logs");
m.define_data(
"log_window_seed",
r#"[{"stream":"Stdout","line":"hello","timestamp":"2026-07-10T23:00:00Z"}]"#,
);
m.register_app_screen("views/app.gvb", "log")
.expect("log_window.gvb must register");
m.set_initial_screen("log");
m.reevaluate_all().expect("eval log_window");
assert!(m.render("log").is_ok(), "render log_window");
}
#[test]
fn new_service_wizard_window_renders() {
cd_ws_root();
let mut m = GlacierUI::new();
m.define_data("api_url", "http://localhost");
m.define_data("api_token", "t");
m.define_data("selected_project_id", "p1");
m.define_data("project_name", "demo");
m.register_app_screen("views/app.gvb", "new_service")
.expect("new_service_window.gvb must register");
m.set_initial_screen("new_service");
m.define_data("new_service_database_has_dbname", "true");
m.define_data("new_service_database_has_user", "true");
m.define_data("new_service_database_has_rootpw", "true");
m.define_data("new_service_database_has_replica", "true");
m.define_data(
"new_service_databases",
r#"[{"id":"postgres","label":"PostgreSQL","image":"postgres:18"}]"#,
);
m.define_data(
"new_service_templates",
r#"[{"id":"forgejo","name":"Forgejo","description":"git","logo":"assets/blueprint-logos/forgejo/forgejo.svg","logo_kind":"svg"},{"id":"wordpress","name":"WordPress","description":"cms","logo":"assets/blueprint-logos/wordpress/wordpress.png","logo_kind":"img"}]"#,
);
m.define_data(
"new_service_template_vars",
r#"[{"idx":"0","label":"Domínio","placeholder":"x"}]"#,
);
for step in [
"pick_type",
"pick_db",
"app_form",
"db_form",
"compose_form",
"pick_template",
"template_form",
"import_form",
] {
m.define_data("new_service_step", step);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval new_service/{step}: {e}"));
assert!(
m.render("new_service").is_ok(),
"render new_service/{step}"
);
}
}
#[test]
fn all_screens_and_service_tabs_render() {
let mut m = boot();
m.define_data("settings_tabs", r#"[{"id":"web","label":"Web Server"},{"id":"git","label":"Git"},{"id":"iac","label":"Infra as Code"},{"id":"maintenance","label":"Manutenção"}]"#);
m.define_data("docker_cleanup_kinds", r#"[{"id":"interval","label":"A cada N horas"},{"id":"daily","label":"Todo dia"},{"id":"weekly","label":"Toda semana"}]"#);
m.define_data("git_provider_modes", r#"[{"id":"oauth","label":"OAuth2"},{"id":"pat","label":"Token (PAT)"}]"#);
m.define_data("healthcheck_kinds", r#"[{"id":"none","label":"None"},{"id":"tcp","label":"Tcp"},{"id":"http","label":"Http"},{"id":"docker","label":"Docker"}]"#);
m.reevaluate_all().expect("eval login");
assert!(m.render("app").is_ok(), "login render");
for view in [
"deployments",
"projects",
"service",
"monitoring",
"ingress",
"docker",
"settings",
"schedules",
"support",
] {
m.define_data("screen", "shell");
m.define_data("view", view);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval view {view}: {e}"));
assert!(m.render("app").is_ok(), "render view {view}");
}
m.define_data("view", "deploy_engine");
m.define_data("deploy_engine_tab", "fila");
m.define_data("deploy_engine_queued_count", "2");
m.define_data("deploy_engine_paused", "true");
m.define_data(
"deploy_engine_queued",
r#"[{"deployment_id":"dep_1","pos":"1","service":"api","project":"acme"},{"deployment_id":"dep_2","pos":"2","service":"worker","project":"acme"}]"#,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval deploy_engine: {e}"));
assert!(m.render("app").is_ok(), "render deploy_engine com fila");
m.define_data("deploy_engine_tab", "executando");
m.define_data("deploy_engine_active_count", "1");
m.define_data(
"deploy_engine_active",
r#"[{"service":"api","project":"acme","state_label":"BUILDING","state_kind":"info","steps":[{"text":"✓ Fila","status":"done"},{"text":"● Obter","status":"current"},{"text":"○ Live","status":"pending"}],"total":"10s","phase":"4s","service_id":"svc_1"}]"#,
);
m.define_data("deploy_engine_detail_open", "true");
m.define_data("deploy_engine_detail_title", "api");
m.define_data(
"deploy_engine_detail_steps",
r#"[{"text":"✓ Fila","status":"done"},{"text":"✕ Obter","status":"failed"}]"#,
);
m.define_data(
"deploy_engine_detail_rows",
r#"[{"label":"Pending","kind":"ok","dur":"2s","msg":""},{"label":"Failed","kind":"bad","dur":"—","msg":"build quebrou"}]"#,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval deploy_engine ativo+detalhe: {e}"));
assert!(m.render("app").is_ok(), "render deploy_engine com stepper e popup");
m.define_data("deploy_engine_tab", "historico");
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval deploy_engine histórico: {e}"));
assert!(m.render("app").is_ok(), "render deploy_engine histórico");
m.define_data("view", "ingress");
m.define_data("host_ports_count", "1");
m.define_data(
"host_ports",
r#"[{"service":"web","project":"acme","host_port":"8081","container_port":"80"}]"#,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval ingress/host_ports: {e}"));
assert!(m.render("app").is_ok(), "render ingress/host_ports");
m.define_data("view", "docker");
m.define_data("docker_containers_count", "1");
m.define_data(
"docker_containers",
r#"[{"id_full":"c1","name":"rp_api_live","image":"acme/api:latest","owner":"acme / api","state_kind":"ok","state_label":"running","can_remove":"0"}]"#,
);
m.define_data("docker_images_count", "1");
m.define_data(
"docker_images",
r#"[{"id_full":"i1","tags":"acme/api:latest","owner":"acme / api","size":"120 MB","created":"12/07","in_use_kind":"ok","in_use_label":"EM USO"}]"#,
);
m.define_data("docker_volumes_count", "1");
m.define_data(
"docker_volumes",
r#"[{"name":"pgdata","owner":"—","size":"1.2 GB","in_use_kind":"ok","in_use_label":"EM USO"}]"#,
);
m.define_data("docker_networks_count", "1");
m.define_data(
"docker_networks",
r#"[{"name":"rp_net_acme","owner":"acme","in_use_kind":"ok","in_use_label":"EM USO"}]"#,
);
m.define_data("registry_status_label", "ativo em 127.0.0.1:5100");
m.define_data("registry_status_enabled", "true");
m.define_data("registry_storage_human", "340 MB");
m.define_data("registry_repos_count", "1");
m.define_data(
"registry_repos",
r#"[{"name":"acme/api","tag_count":"3","size":"340 MB"}]"#,
);
m.define_data("registry_tokens_count", "1");
m.define_data(
"registry_tokens",
r#"[{"name":"ci","scope":"pull","created":"12/07"}]"#,
);
m.define_data("registry_selected_repo", "");
m.define_data("registry_tags_loading", "false");
m.define_data("registry_tags_count", "0");
m.define_data("registry_tags", "[]");
for tab in ["containers", "images", "volumes", "networks", "registry"] {
m.define_data("docker_tab", tab);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval docker/{tab}: {e}"));
assert!(m.render("app").is_ok(), "render docker/{tab}");
}
m.define_data("docker_tab", "containers");
m.reevaluate_all().expect("eval docker/containers");
let textos = textos_avaliados(m.evaluated("app").expect("app avaliado"));
assert!(
textos.iter().any(|t| t == "rp_api_live"),
"a célula NOME da linha não foi avaliada: {textos:?}"
);
m.define_data("registry_selected_repo", "acme/api");
m.define_data("registry_tags_count", "1");
m.define_data(
"registry_tags",
r#"[{"tag":"latest","size":"120 MB","created":"12/07","digest_short":"sha256:abcd1234"}]"#,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval docker/registry com repo selecionado: {e}"));
assert!(
m.render("app").is_ok(),
"render docker/registry com repo selecionado"
);
m.define_data("view", "schedules");
m.define_data("jobs_count", "1");
m.define_data(
"jobs_summary",
r#"[{"id":"job_1","name":"backup-db","name_full":"backup-db","owner":"acme / postgres","recurrence":"a cada 6h","enabled":true,"enabled_label":"Pausar","running":false,"last_run_label":"ok","last_run_kind":"ok","last_run_id":"jrun_1","next_run_at":"12/07 03:00","is_warm":false,"is_warming":false,"warm_label":"frio","warm_kind":"muted","warm_reason":"nunca aquecido","warmed_at_label":""}]"#,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval schedules: {e}"));
assert!(m.render("app").is_ok(), "render schedules com dados");
let textos = textos_avaliados(m.evaluated("app").expect("app avaliado"));
let botoes = botoes_avaliados(m.evaluated("app").expect("app avaliado"));
for esperado in ["frio", "nunca aquecido"] {
assert!(
textos.iter().any(|t| t == esperado),
"schedules: faltou o texto `{esperado}` no painel do job: {textos:?}"
);
}
for esperado in [
"▶ Rodar agora",
"◐ Aquecer",
"≡ Ver logs",
"‖ Pausar",
"✎ Editar",
"✕ Remover",
] {
assert!(
botoes.iter().any(|t| t == esperado),
"schedules: faltou o botão `{esperado}` no painel de ações do job: {botoes:?}"
);
}
assert!(
!botoes.iter().any(|t| t == "■ Cancelar execução"),
"job parado não oferece cancelar"
);
m.define_data(
"jobs_summary",
r#"[{"id":"job_1","name":"backup-db","name_full":"backup-db","owner":"acme / postgres","recurrence":"a cada 6h","enabled":false,"enabled_label":"Ativar","running":true,"last_run_label":"rodando…","last_run_kind":"warn","last_run_id":"jrun_2","next_run_at":"—","is_warm":true,"is_warming":false,"warm_label":"quente","warm_kind":"ok","warm_reason":"","warmed_at_label":"12/07 03:00"}]"#,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval schedules rodando: {e}"));
let textos = textos_avaliados(m.evaluated("app").expect("app avaliado"));
let botoes = botoes_avaliados(m.evaluated("app").expect("app avaliado"));
for esperado in ["quente", "aquecido em 12/07 03:00"] {
assert!(
textos.iter().any(|t| t == esperado),
"schedules rodando: faltou o texto `{esperado}`: {textos:?}"
);
}
for esperado in ["■ Cancelar execução", "‖ Ativar"] {
assert!(
botoes.iter().any(|t| t == esperado),
"schedules rodando: faltou o botão `{esperado}`: {botoes:?}"
);
}
assert!(
!botoes.iter().any(|t| t == "▶ Rodar agora"),
"job rodando não oferece rodar de novo"
);
m.define_data(
"project_env",
r##"[{"key":"__c0","value":"# comentário","kind":"comment"},{"key":"A_VERY_LONG_ENVIRONMENT_VARIABLE_NAME_THAT_SHOULD_BE_TRUNCATED","key_display":"A_VERY_LONG_ENVIRONMENT_VARIABLE_NAME_TH…","value":"x","kind":"plain"}]"##,
);
m.define_data("project_jobs_count", "1");
m.define_data(
"project_jobs",
r#"[{"id":"job_1","name":"backup-db","name_full":"backup-db","recurrence":"a cada 6h","enabled":true,"enabled_label":"Pausar","running":false,"last_run_label":"ok","last_run_kind":"ok","last_run_id":"jrun_1","next_run_at":"12/07 03:00","is_warm":false,"is_warming":false,"warm_label":"frio","warm_kind":"muted","warm_reason":"nunca aquecido","warmed_at_label":""}]"#,
);
m.define_data("project_secrets_count", "1");
m.define_data(
"project_secrets",
r#"[{"name":"GITHUB_TOKEN","name_display":"GITHUB_TOKEN"}]"#,
);
for project_tab in ["services", "env", "secrets", "jobs"] {
m.define_data("view", "project_services");
m.define_data("project_tab", project_tab);
m.define_data("project_loading", "false");
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval project_services/{project_tab}: {e}"));
assert!(
m.render("app").is_ok(),
"render project_services/{project_tab}"
);
if project_tab == "jobs" {
let textos = textos_avaliados(m.evaluated("app").expect("app avaliado"));
let botoes = botoes_avaliados(m.evaluated("app").expect("app avaliado"));
assert!(
textos.iter().any(|t| t == "frio"),
"aba Jobs do projeto: faltou o estado `frio`: {textos:?}"
);
for esperado in ["◐ Aquecer", "✎ Editar", "✕ Remover"] {
assert!(
botoes.iter().any(|t| t == esperado),
"aba Jobs do projeto: faltou o botão `{esperado}`: {botoes:?}"
);
}
}
}
m.define_data("project_tab", "env");
m.define_data("project_env_new_is_secret", "true");
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval project_services/env secret: {e}"));
assert!(
m.render("app").is_ok(),
"render project_services/env modo secret"
);
m.define_data("project_secrets_count", "0");
m.define_data("project_secrets", "[]");
for project_tab in ["env", "secrets"] {
m.define_data("project_tab", project_tab);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval {project_tab} sem secrets: {e}"));
assert!(m.render("app").is_ok(), "render {project_tab} sem secrets");
}
m.define_data("project_env_new_is_secret", "false");
m.define_data("view", "settings");
m.define_data("gitea_count", "1");
for mode in ["oauth", "pat"] {
m.define_data("settings_tab", "git");
m.define_data("git_provider_mode", mode);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval settings/git {mode}: {e}"));
assert!(m.render("app").is_ok(), "render settings/git {mode}");
}
m.define_data("settings_tab", "web");
m.define_data("server_settings_public_base", "https://rustploy.meusite.com");
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval settings/web: {e}"));
assert!(m.render("app").is_ok(), "render settings/web");
m.define_data("settings_tab", "iac");
m.define_data("manifest_has_export", "true");
m.define_data("manifest_yaml", "apiVersion: rustploy/v1\nprojects: []\n");
m.define_data("manifest_dotenv", "[project.acme.env]\nLOG_LEVEL = \"info\"\n");
m.define_data("manifest_has_missing", "true");
m.define_data("manifest_missing_vars", "DB_PASS, API_TOKEN");
m.define_data("manifest_has_report", "true");
m.define_data(
"manifest_report_lines",
r#"["[created] project acme","[updated] service acme/web"]"#,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval settings/iac: {e}"));
assert!(m.render("app").is_ok(), "render settings/iac");
m.define_data("settings_tab", "maintenance");
m.define_data("docker_cleanup_enabled", "true");
m.define_data("docker_cleanup_hours", "6");
m.define_data("docker_cleanup_time", "03:00");
m.define_data("docker_cleanup_weekday", "0");
m.define_data(
"weekdays",
r#"[{"id":"0","label":"Seg"},{"id":"1","label":"Ter"},{"id":"2","label":"Qua"},{"id":"3","label":"Qui"},{"id":"4","label":"Sex"},{"id":"5","label":"Sáb"},{"id":"6","label":"Dom"}]"#,
);
m.define_data("docker_cleanup_containers", "true");
m.define_data("docker_cleanup_images", "true");
m.define_data("docker_cleanup_images_all", "false");
m.define_data("docker_cleanup_volumes", "false");
m.define_data("docker_cleanup_volumes_all", "false");
m.define_data("docker_cleanup_networks", "true");
m.define_data("docker_cleanup_build_cache", "true");
m.define_data("docker_cleanup_build_cache_older_than_hours", "168");
m.define_data("docker_cleanup_images_older_than_hours", "168");
m.define_data("docker_cleanup_next_run_label", "hoje às 03:00");
m.define_data(
"docker_cleanup_last_run_text",
"12/07 03:00 · 3 removidos · 120 MB liberados",
);
m.define_data("docker_cleanup_running", "false");
m.define_data("docker_cleanup_message", "");
for kind in ["interval", "daily", "weekly"] {
m.define_data("docker_cleanup_kind", kind);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval settings/maintenance {kind}: {e}"));
assert!(
m.render("app").is_ok(),
"render settings/maintenance {kind}"
);
}
for tab in [
"general",
"connection",
"environment",
"domains",
"deployments",
"healthcheck",
"logs",
"advanced",
] {
m.define_data("screen", "shell");
m.define_data("view", "service");
m.define_data("tab", tab);
m.define_data("env_text_open", "true");
m.define_data(
"service_env",
r##"[{"key":"__c0","value":"# comentário","kind":"comment"},{"key":"OLA","key_display":"OLA","value":"mundo","kind":"plain"},{"key":"A_VERY_LONG_ENVIRONMENT_VARIABLE_NAME_THAT_SHOULD_BE_TRUNCATED","key_display":"A_VERY_LONG_ENVIRONMENT_VARIABLE_NAME_TH…","value":"x","kind":"plain"}]"##,
);
m.define_data("deployment_selected", "abc123");
m.define_data("service_webhook_supported", "true");
m.define_data(
"service_webhook_url",
"https://rustploy.meusite.com/webhook/svc_01ABC/f4b53d4d9d574a55",
);
m.define_data(
"service_webhook_url_short",
"https://rustploy.meusite.com/webhook/svc_01ABC…",
);
m.define_data("gitea_count", "1");
m.define_data("provider_tab", "gitea");
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval tab {tab}: {e}"));
assert!(m.render("app").is_ok(), "render tab {tab}");
}
m.define_data("tab", "general");
m.define_data("service_source_kind", "Git");
m.define_data("error_service_form_general_port", "");
for prov in ["git", "zip"] {
m.define_data("provider_tab", prov);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval general/provider_tab={prov}: {e}"));
assert!(m.render("app").is_ok(), "render general/provider_tab={prov}");
}
m.define_data("service_source_kind", "Compose");
m.define_data("service_compose", "services:\n web:\n image: nginx\n");
m.define_data("service_compose_original", "services:\n web:\n image: nginx\n");
m.reevaluate_all().expect("eval general/compose");
assert!(m.render("app").is_ok(), "render general/compose");
m.define_data("tab", "deployments");
m.define_data("service_webhook_url", "");
m.reevaluate_all()
.expect("eval deployments/webhook sem token");
assert!(
m.render("app").is_ok(),
"render deployments/webhook sem token"
);
m.define_data("service_webhook_supported", "false");
m.reevaluate_all()
.expect("eval deployments/webhook compose");
assert!(
m.render("app").is_ok(),
"render deployments/webhook compose"
);
}
#[test]
fn sidebar_is_a_drawer_bound_to_menu_key() {
use glacier_ui::widget::EngineMessage;
let mut m = boot();
m.define_data("screen", "shell");
m.define_data("view", "deployments");
m.define_data("menu", "true");
m.reevaluate_all().expect("eval shell");
let _ = m.dispatch(&EngineMessage::Viewport {
width: 731.0,
height: 680.0,
});
fn find<'a>(
node: &'a glacier_ui::parser::UiNode,
pred: &dyn Fn(&glacier_ui::parser::UiNode) -> bool,
out: &mut Vec<&'a glacier_ui::parser::UiNode>,
) {
if pred(node) {
out.push(node);
}
for child in &node.children {
find(child, pred, out);
}
}
let ast = m.evaluated("app").expect("app evaluated");
let mut triggers = Vec::new();
find(
ast,
&|n| {
matches!(
&n.kind,
glacier_ui::parser::NodeType::Button { on_click: Some(a), .. }
if a == "drawer::toggle:menu"
)
},
&mut triggers,
);
assert_eq!(
triggers.len(),
1,
"esperava um botão on_click=\"drawer::toggle:menu\" (o ☰ da topbar)"
);
let mut reveals = Vec::new();
find(
ast,
&|n| {
matches!(
&n.kind,
glacier_ui::parser::NodeType::Reveal { horizontal: true, open, .. }
if open == "true"
)
},
&mut reveals,
);
assert!(
!reveals.is_empty(),
"esperava o <Reveal axis=\"x\"> da <drawer> com open=\"true\" quando menu=true"
);
let mut labels = Vec::new();
find(
ast,
&|n| {
matches!(
&n.kind,
glacier_ui::parser::NodeType::Text { content, .. }
if content == "Deploy Engine" || content.starts_with("Projects (")
)
},
&mut labels,
);
assert_eq!(
labels.len(),
2,
"esperava os rótulos \"Deploy Engine\" e \"Projects (N)\" na gaveta aberta"
);
for n in &labels {
assert_ne!(
n.hidden,
Some(true),
"rótulo {:?} não deve ser forçado a hidden — a gaveta aberta mostra tudo",
n.kind
);
}
m.define_data("menu", "");
m.reevaluate_all().expect("eval shell menu fechado");
let ast = m.evaluated("app").expect("app evaluated");
let mut closed = Vec::new();
find(
ast,
&|n| {
matches!(
&n.kind,
glacier_ui::parser::NodeType::Reveal { horizontal: true, open, .. }
if open.is_empty()
)
},
&mut closed,
);
assert!(
!closed.is_empty(),
"esperava o <Reveal axis=\"x\"> da <drawer> com open vazio quando menu=\"\""
);
}
#[test]
fn service_actions_collapse_to_icons_when_narrow() {
use glacier_ui::widget::EngineMessage;
fn count_visible(
node: &glacier_ui::parser::UiNode,
full: &mut u32,
compact: &mut u32,
ancestor_hidden: bool,
) {
let hidden = ancestor_hidden || node.hidden == Some(true);
if let glacier_ui::parser::NodeType::Button { text, .. } = &node.kind {
if !hidden {
if matches!(text.as_str(), "Deploy" | "Reload" | "Rebuild" | "Stop") {
*full += 1;
}
if matches!(text.as_str(), "▶" | "⟳" | "⚙" | "■") {
*compact += 1;
}
}
}
for child in &node.children {
count_visible(child, full, compact, hidden);
}
}
let mut m = boot();
m.define_data("screen", "shell");
m.define_data("view", "service");
m.define_data("tab", "general");
m.reevaluate_all().expect("eval service");
let _ = m.dispatch(&EngineMessage::Viewport {
width: 1400.0,
height: 820.0,
});
let (mut full, mut compact) = (0, 0);
count_visible(
m.evaluated("app").expect("app"),
&mut full,
&mut compact,
false,
);
assert_eq!(
(full, compact),
(4, 0),
"em 1400px espera 4 botões de texto e 0 ícones"
);
let _ = m.dispatch(&EngineMessage::Viewport {
width: 980.0,
height: 820.0,
});
let (mut full, mut compact) = (0, 0);
count_visible(
m.evaluated("app").expect("app"),
&mut full,
&mut compact,
false,
);
assert_eq!(
(full, compact),
(0, 4),
"em 980px espera 0 botões de texto e 4 ícones"
);
}
#[test]
fn only_the_active_screen_is_evaluated() {
let m = boot();
for importado in ["Login", "Shell"] {
assert!(
m.is_registered(importado),
"{importado} deveria ter sido importado por app.gvb"
);
}
assert!(m.render("app").is_ok(), "a tela ativa renderiza");
assert!(
matches!(
m.render("Login"),
Err(glacier_ui::GlacierError::NotEvaluated(_))
),
"uma view importada não deve ficar avaliada como raiz por conta própria"
);
}
#[test]
fn disconnect_clears_the_session_context() {
let mut m = boot();
for (k, v) in [
("connected", "true"),
("screen", "shell"),
("api_url", "https://rustploy.example"),
("api_token", "token-secreto"),
("projects_count", "7"),
("project_name", "acme"),
(
"project_secrets",
r#"[{"name":"GITHUB_TOKEN","name_display":"GITHUB_TOKEN"}]"#,
),
(
"service_env",
r#"[{"key":"API_KEY","value":"secret:API_KEY","kind":"secret"}]"#,
),
("selected_project_id", "prj_1"),
] {
m.define_data(k, v);
}
m.reevaluate_all().expect("eval sessão conectada");
let _ = m.dispatch(&glacier_ui::EngineMessage::UiClick("disconnect".into()));
let ctx = m.context();
for k in [
"api_url",
"api_token",
"project_name",
"project_secrets",
"service_env",
"selected_project_id",
] {
assert!(
ctx.get(k).is_none(),
"ctx.{k} sobreviveu ao logout: {:?}",
ctx.get(k)
);
}
assert_eq!(ctx.get("connected").map(String::as_str), Some("false"));
assert_eq!(ctx.get("screen").map(String::as_str), Some("login"));
assert_eq!(
ctx.get("projects_count").map(String::as_str),
Some("…"),
"contador deve voltar a 'carregando', não a um 0 mentiroso"
);
}
#[test]
fn nav_item_projects_stays_lit_on_sub_screens() {
use glacier_ui::parser::NodeType;
fn projects_nav_button_lit<'a>(node: &'a glacier_ui::parser::UiNode) -> Option<bool> {
if let NodeType::Button {
on_click, color, ..
} = &node.kind
&& on_click.as_deref() == Some("NavItem::nav_projects")
{
return Some(color.as_deref() == Some("#1F6FEB"));
}
node.children.iter().find_map(projects_nav_button_lit)
}
let mut m = boot();
for (view, expected_lit) in [
("deployments", false),
("projects", true),
("project_services", true),
("service", true),
("settings", false),
] {
m.define_data("screen", "shell");
m.define_data("view", view);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval view {view}: {e}"));
let ast = m.evaluated("app").expect("app evaluated");
let aceso = projects_nav_button_lit(ast).expect("item Projects deveria existir na sidebar");
assert_eq!(
aceso,
expected_lit,
"view={view}: item Projects deveria estar {} ",
if expected_lit { "aceso" } else { "apagado" }
);
}
}
#[test]
fn lit_nav_item_recolors_children_via_combinator() {
use glacier_ui::parser::{NodeType, UiNode};
fn nav_cores(node: &UiNode) -> Option<Vec<Option<String>>> {
if let NodeType::Button { on_click, .. } = &node.kind
&& on_click.as_deref() == Some("NavItem::nav_projects")
{
return Some(
node.children
.iter()
.map(|c| match &c.kind {
NodeType::Text { color, .. } => color.clone(),
_ => None,
})
.collect(),
);
}
node.children.iter().find_map(nav_cores)
}
let mut m = boot();
for (view, icone, rotulo) in [
("projects", "#E6EDF3", "#E6EDF3"),
("deployments", "#6E7681", "#8B949E"),
] {
m.define_data("screen", "shell");
m.define_data("view", view);
m.reevaluate_all().expect("eval");
let cores = nav_cores(m.evaluated("app").expect("app evaluated"))
.expect("item Projects deveria existir na sidebar");
assert_eq!(
cores,
vec![Some(icone.to_string()), Some(rotulo.to_string())],
"view={view}: cores de ícone e rótulo do item Projects"
);
}
}
#[test]
fn windows_declare_title_and_size() {
cd_ws_root();
let mut m = GlacierUI::new();
m.register_app("views/app.gvb").expect("app.gvb deve registrar");
let janela = m.main_window_meta().expect("o app declara a janela principal");
assert_eq!(janela.title.as_deref(), Some("Rustploy"), "título da principal");
assert_eq!(janela.size, Some((1280.0, 820.0)), "tamanho da principal");
assert_eq!(janela.min_size, Some((480.0, 680.0)), "o min_size da principal");
assert_eq!(janela.decorations, Some(false), "a principal é borderless");
assert!(janela.icon.is_some(), "o app declara o ícone");
let filhas = [
("new_project", Some("Novo projeto — Rustploy"), "460 340", "handlers/projects.luau"),
("new_job", Some("Novo job — Rustploy"), "560 700", "handlers/jobs.luau"),
("new_service", Some("Novo serviço — Rustploy"), "560 700", "handlers/wizard.luau"),
("new_registry_token", Some("Novo token — Rustploy"), "480 420", "handlers/registry.luau"),
("service_export", Some("Exportar serviço — Rustploy"), "620 720", "handlers/bundle.luau"),
("log", None, "900 560", "handlers/services.luau"),
];
for (tela, titulo, tamanho, handler) in filhas {
let mut c = GlacierUI::new();
c.register_app_screen("views/app.gvb", tela)
.unwrap_or_else(|e| panic!("a tela {tela} deve registrar: {e}"));
let meta = c.current_screen_meta().cloned().unwrap_or_default();
assert_eq!(meta.title.as_deref(), titulo, "título de {tela}");
assert!(meta.size.is_none(), "{tela}: o tamanho não mora na screen");
let src = std::fs::read_to_string(format!("views/scripts/{handler}"))
.unwrap_or_else(|e| panic!("ler {handler}: {e}"));
let chamada = format!("component = \"{tela}\", size = \"{tamanho}\", decorations = false");
assert!(src.contains(&chamada), "{handler} deve abrir `{tela}` com `{chamada}`");
}
}
#[test]
fn app_declares_fonts_and_daemon_settings() {
cd_ws_root();
let src = std::fs::read_to_string("views/app.gvb").expect("ler app.gvb");
let xml = glacier_ui::gvb::desugar(&src).expect("desugar app.gvb");
let m = glacier_ui::parse_app_manifest(&xml, Some("views/app.gvb"))
.expect("app.gvb parseia")
.expect("app.gvb tem raiz app");
assert_eq!(m.app.font.as_deref(), Some("JetBrains Mono"));
assert_eq!(m.app.antialiasing, Some(false));
assert_eq!(m.app.toast_period, Some(250));
assert_eq!(m.app.application_id.as_deref(), Some("rustploy-gui"));
assert_eq!(m.app.fonts.len(), 2, "regular + bold");
for f in &m.app.fonts {
assert!(std::path::Path::new(&f.src).is_file(), "a fonte {} deve existir", f.src);
}
}
fn comments_outside(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut resto = src;
while let Some(i) = resto.find("/*") {
out.push_str(&resto[..i]);
resto = match resto[i..].find("*/") {
Some(j) => &resto[i + j + 2..],
None => "",
};
}
out.push_str(resto);
out.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn every_template_starts_with_a_header() {
cd_ws_root();
let raiz = std::path::Path::new("views");
let mut vistos = 0;
for (dir, so_component) in [(raiz.to_path_buf(), false), (raiz.join("components"), true)] {
let mut arquivos: Vec<_> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("ler {}: {e}", dir.display()))
.map(|e| e.expect("entry").path())
.filter(|p| p.extension().is_some_and(|e| e == "gvb"))
.collect();
arquivos.sort();
for caminho in arquivos {
let src = std::fs::read_to_string(&caminho)
.unwrap_or_else(|e| panic!("ler {}: {e}", caminho.display()));
let without_comments = comments_outside(&src);
let primeira: String = without_comments
.trim_start()
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
let e_app = primeira == "app";
let e_screen = primeira == "screen";
let e_component = primeira == "component";
assert!(
e_app || e_screen || e_component,
"{}: todo .gvb começa com app (o manifesto), screen (uma tela) ou component \
(o resto) — o motor aceita a forma sem cabeçalho, então ninguém além deste \
teste avisaria",
caminho.display()
);
assert!(
!(so_component && !e_component),
"{}: um arquivo em views/components/ não é tela — component",
caminho.display()
);
assert!(
!e_app || caminho.file_name().is_some_and(|n| n == "app.gvb"),
"{}: só views/app.gvb é o manifesto (raiz app)",
caminho.display()
);
vistos += 1;
}
}
assert!(
vistos >= 21,
"o teste não achou os templates: {vistos} arquivos varridos"
);
}
#[test]
fn card_grids_render_with_spread() {
let mut m = boot();
m.define_data("screen", "shell");
m.define_data("data_loading", "false");
m.define_data("view", "projects");
m.define_data(
"project_rows",
r##"[{"cards":[
{"filler":"0","id":"prj_1","name":"acme","description":"loja",
"service_count":"3","running_count":"2","can_delete":"0"},
{"filler":"1","id":"","name":"","description":"","service_count":"",
"running_count":"","can_delete":"","port":"","status_label":"",
"status_color":"","cpu":"","mem":"","container_name":"",
"container_id":"","container_extra":"","project":""}
]}]"##,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval grade de projetos: {e}"));
assert!(m.render("app").is_ok(), "render grade de projetos");
let arv = format!("{:?}", m.evaluated("app").unwrap());
assert!(
arv.contains("acme"),
"a grade tem que ter renderizado o card"
);
m.define_data("view", "project_services");
m.define_data("project_loading", "false");
m.define_data(
"project_services",
r##"[{"cards":[
{"filler":"0","id":"svc_1","name":"api","project":"acme","port":"8080",
"status_label":"Rodando","status_color":"#A6E3A1","cpu":"1.2%","mem":"64 MB",
"container_name":"acme-api","container_id":"abc123","container_extra":"+1"},
{"filler":"1","id":"","name":"","description":"","service_count":"",
"running_count":"","can_delete":"","port":"","status_label":"",
"status_color":"","cpu":"","mem":"","container_name":"",
"container_id":"","container_extra":"","project":""}
]}]"##,
);
m.reevaluate_all()
.unwrap_or_else(|e| panic!("eval grade de serviços: {e}"));
assert!(m.render("app").is_ok(), "render grade de serviços");
}
#[test]
fn action_in_progress_locks_the_button() {
let mut m = boot();
m.reevaluate_all().expect("eval inicial");
assert_eq!(
m.context().get("busy_connect").map(String::as_str),
Some("false"),
"init() deve semear o flag de cada ação instalada"
);
m.define_data("access_key", "MTI3LjAuMC4xOjk6Ong=");
let _ = m.dispatch(&glacier_ui::EngineMessage::UiClick("connect".into()));
assert_eq!(
m.context().get("busy_connect").map(String::as_str),
Some("true"),
"ação suspensa no fetch deve manter o botão bloqueado"
);
}
#[test]
fn service_export_window_renders_and_toggles() {
cd_ws_root();
let mut m = GlacierUI::new();
m.define_data("api_url", "http://localhost");
m.define_data("api_token", "t");
m.define_data("export_service_id", "svc_1");
m.define_data(
"export_plan",
r#"{"service_name":"api","project_name":"Flow","blocked":null,
"service_env":[{"key":"CACHE","is_secret":false,"suggested":false}],
"project_env":[{"key":"REDIS_URL","is_secret":false,"suggested":true},
{"key":"NAO_LEVAR","is_secret":false,"suggested":false},
{"key":"SENTRY","is_secret":true,"suggested":false}]}"#,
);
m.register_app_screen("views/app.gvb", "service_export")
.expect("service_export_window.gvb must register");
m.set_initial_screen("service_export");
m.reevaluate_all().expect("eval service_export");
assert!(m.render("service_export").is_ok(), "render service_export");
let rows = |m: &GlacierUI| -> Vec<(String, String)> {
let v: serde_json::Value =
serde_json::from_str(m.context().get("export_vars").expect("export_vars")).unwrap();
v.as_array()
.unwrap()
.iter()
.map(|r| (r["key"].as_str().unwrap().into(), r["mark"].as_str().unwrap().into()))
.collect()
};
assert_eq!(
rows(&m),
vec![
("REDIS_URL".to_string(), "☑".to_string()),
("NAO_LEVAR".to_string(), "☐".to_string()),
("SENTRY".to_string(), "☐".to_string()),
],
"só a citada pelo serviço vem marcada"
);
assert_eq!(m.context().get("export_picked_count").map(String::as_str), Some("1"));
let _ = m.dispatch(&glacier_ui::EngineMessage::UiClick("export_toggle:NAO_LEVAR".into()));
assert_eq!(rows(&m)[1].1, "☑", "toggle marca");
let _ = m.dispatch(&glacier_ui::EngineMessage::UiClick("export_none".into()));
assert!(rows(&m).iter().all(|(_, mark)| mark == "☐"));
let _ = m.dispatch(&glacier_ui::EngineMessage::UiClick("export_all".into()));
assert!(rows(&m).iter().all(|(_, mark)| mark == "☑"));
}
#[test]
fn new_service_import_report_renders() {
cd_ws_root();
let mut m = GlacierUI::new();
for (k, v) in [
("api_url", "http://localhost"),
("api_token", "t"),
("selected_project_id", "p1"),
("project_name", "demo"),
("new_service_step", "import_form"),
("import_report", "true"),
("import_file", "api.rustploy-service.yml"),
("import_name", "api"),
("import_conflict", "true"),
("import_has_warnings", "true"),
("import_warnings", r#"[{"text":"• Domínios trazidos: a.tech."}]"#),
("import_has_service_missing", "true"),
("import_service_text", "CACHE=\nOWN="),
("import_has_project_missing", "true"),
("import_project_text", "REDIS_URL="),
("import_has_secret_missing", "true"),
("import_secret_text", "SENTRY_DSN="),
("import_missing_provider", "github-acme"),
("import_providers", r#"[{"id":"gp_1","label":"github","mark":"○"}]"#),
("import_has_project_env", "true"),
(
"import_project_env",
r#"[{"key":"REDIS_URL","info":"em conflito","can_choose":"true","choice":"manter a do projeto"},{"key":"SENTRY","info":"secret · igual","can_choose":"false","choice":"x"}]"#,
),
("import_drop_domains", "false"),
("import_deploy", "false"),
] {
m.define_data(k, v);
}
m.register_app_screen("views/app.gvb", "new_service")
.expect("new_service_window.gvb must register");
m.set_initial_screen("new_service");
m.reevaluate_all().expect("eval import_form com relatório");
assert!(m.render("new_service").is_ok(), "render import_form com relatório");
}