use super::serve;
use bytes::Bytes;
use chromiumoxide::browser::{Browser, BrowserConfig};
use futures::StreamExt;
use http_body_util::{BodyExt, Full, combinators::BoxBody};
use hyper::{Request, Response, StatusCode, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use std::time::Duration;
use tokio::net::TcpListener;
type ApiBody = BoxBody<Bytes, std::convert::Infallible>;
async fn spawn_static_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind do servidor estático de teste");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
continue;
};
tokio::spawn(async move {
let svc = service_fn(|req: Request<hyper::body::Incoming>| async move {
let resp: Response<ApiBody> = serve(req.uri().path()).unwrap_or_else(|| {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Full::new(Bytes::from_static(b"not found")).boxed())
.expect("404 bem-formado")
});
Ok::<_, std::convert::Infallible>(resp)
});
let _ = http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await;
});
}
});
addr
}
static USER_DATA_DIR_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
async fn launch() -> (Browser, tokio::task::JoinHandle<()>) {
let n = USER_DATA_DIR_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let user_data_dir =
std::env::temp_dir().join(format!("rustploy-webui-test-{}-{n}", std::process::id()));
let config = BrowserConfig::builder()
.chrome_executable("/usr/bin/google-chrome")
.no_sandbox()
.new_headless_mode()
.user_data_dir(user_data_dir)
.build()
.expect("BrowserConfig");
let (browser, mut handler) = Browser::launch(config)
.await
.expect("google-chrome deve estar instalado (ver docs/inventario-paridade-telas.md)");
let h = tokio::spawn(async move {
while let Some(ev) = handler.next().await {
let _ = ev;
}
});
(browser, h)
}
const ERROR_COLLECTOR_JS: &str = r#"
window.__errors = [];
window.addEventListener('error', (e) => window.__errors.push(String(e.message || e.error)));
window.addEventListener('unhandledrejection', (e) => window.__errors.push('rejection: ' + String(e.reason)));
"#;
async fn open_page(browser: &Browser, addr: std::net::SocketAddr) -> chromiumoxide::Page {
let page = browser.new_page("about:blank").await.expect("new_page");
page.evaluate_on_new_document(ERROR_COLLECTOR_JS)
.await
.expect("injeta o coletor de erros");
page.goto(format!("http://{addr}/")).await.expect("goto");
let _ = page.wait_for_navigation().await;
page
}
async fn wait_alpine_ready(page: &chromiumoxide::Page) {
for _ in 0..100 {
if let Ok(r) = page
.evaluate("typeof window.Alpine !== 'undefined' && !!Alpine.store('app')")
.await
&& r.into_value::<bool>().unwrap_or(false)
{
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("Alpine.store('app') não ficou pronto a tempo");
}
const SNAPSHOT_JS: &str = r#"
{
status: { version: '0.0.0-test', uptime_secs: 3600, services_running: 1, services_total: 1 },
deployments: [{ service_name: 'api', project_name: 'acme',
deployment: { state: 'Live', started_at: new Date().toISOString() } }],
projects: [{ id: 'prj_1', name: 'acme', description: '', env_vars: [], env_comments: [], secrets: ['GITHUB_TOKEN'] }],
services: [{ project_name: 'acme', service: {
id: 'svc_1', status: 'Running', live_container_id: 'abc123', containers: [],
spec: { name: 'api', project_id: 'prj_1', port: 8080, replicas: 1, env_vars: [], domains: [],
source: { Git: { url: 'https://github.com/acme/api.git', branch: 'main', root_path: '.',
watch_paths: [], submodules: false, dockerfile_path: 'Dockerfile', build_context: '.',
build_stage: null, credentials: null, username: null, provider_id: null } },
healthcheck: { kind: 'None', interval_secs: 5, timeout_secs: 3, retries: 10, start_period_secs: 5 } } } }],
docker_images: [], docker_volumes: [], docker_networks: [], docker_containers: [],
jobs: [], engine: { paused: false, queued: [], active: [], recent: [] },
registry_status: { enabled: true, domain: '', port: 5100 }, registry_repos: [],
}
"#;
async fn seed_connected(page: &chromiumoxide::Page) {
wait_alpine_ready(page).await;
let js = format!(
r#"(() => {{
const s = Alpine.store('app');
s.connected = true;
s.screen = 'shell';
s.api = {{ baseUrl: location.origin, token: 't',
rpc: async () => ({{ ok: false, error: 'stub' }}),
rpcChecked: async () => ({{ ok: false, error: 'stub' }}) }};
s.applySnapshot({SNAPSHOT_JS});
}})()"#
);
page.evaluate(js).await.expect("seed do Alpine store");
}
async fn eval_str(page: &chromiumoxide::Page, js: &str) -> String {
page.evaluate(js)
.await
.unwrap_or_else(|e| panic!("evaluate falhou: {e}\n{js}"))
.into_value::<String>()
.expect("o JS deveria devolver uma string")
}
async fn visible(page: &chromiumoxide::Page, selector: &str) -> bool {
let js = format!(
"(() => {{ const el = document.querySelector({selector:?}); return !!el && el.offsetParent !== null; }})()"
);
for _ in 0..20 {
let ok = page
.evaluate(js.as_str())
.await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false);
if ok {
return true;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
false
}
async fn errors(page: &chromiumoxide::Page) -> Vec<String> {
page.evaluate("window.__errors || []")
.await
.ok()
.and_then(|r| r.into_value::<Vec<String>>().ok())
.unwrap_or_default()
}
#[tokio::test]
async fn login_with_access_key() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
wait_alpine_ready(&page).await;
page.evaluate(
r#"(() => {
window.__requests = [];
window.fetch = (url, init = {}) => {
const path = new URL(url, location.origin).pathname;
window.__requests.push({ path, body: init.body || null,
auth: (init.headers || {})["Authorization"] || null });
const reply = (status, body) => Promise.resolve(new Response(JSON.stringify(body), { status }));
if (path === "/api/events") return new Promise(() => {});
if (path === "/api/rpc") return reply(200, { DaemonStatus: { version: "t" } });
return reply(404, {});
};
})()"#,
)
.await
.expect("stub do fetch");
eval_str(
&page,
r#"(async () => { const s = Alpine.store('app'); s.accessKey = 'segredo'; await s.connect(); return 'ok'; })()"#,
)
.await;
assert_eq!(
eval_str(&page, "Alpine.store('app').error").await,
"that is not a connection token — paste the whole connection token (base64 of host:port::token), not just the API token"
);
assert_eq!(eval_str(&page, "Alpine.store('app').screen").await, "login");
eval_str(
&page,
r#"(async () => { const s = Alpine.store('app');
s.accessKey = 'MTI3LjAuMC4xOjk6Ong=';
await s.connect(); return 'ok'; })()"#,
)
.await;
assert_eq!(eval_str(&page, "Alpine.store('app').screen").await, "shell");
assert_eq!(
eval_str(
&page,
"window.__requests.find(r => r.path === '/api/rpc').auth"
)
.await,
"Bearer x"
);
assert!(
!eval_str(&page, "JSON.stringify(window.__requests.map(r => r.path))")
.await
.contains("/api/login"),
"a webui não passa mais pelo /api/login"
);
let chave = eval_str(&page, "Alpine.store('app').serverAccessKey").await;
{
use base64::Engine;
let payload = base64::engine::general_purpose::STANDARD
.decode(&chave)
.expect("token de conexão em base64 padrão");
assert_eq!(String::from_utf8(payload).unwrap(), format!("{addr}::x"));
}
let comando = eval_str(&page, "Alpine.store('app').serverMcpCommand").await;
assert!(
comando.contains("/mcp") && comando.contains("Bearer x"),
"{comando}"
);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn all_main_screens_render() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
wait_alpine_ready(&page).await;
assert!(
visible(&page, ".login").await,
"tela de login deveria estar visível antes do login"
);
seed_connected(&page).await;
let views: &[(&str, &str)] = &[
("deployments", "[x-data=\"dashboard\"]"),
("running", "[x-data=\"running\"]"),
("projects", "[x-data=\"projects\"]"),
("monitoring", "[x-data=\"monitoring\"]"),
("ingress", "[x-data=\"ingress\"]"),
("docker", "[x-data=\"docker\"]"),
("schedules", "[x-data=\"schedules\"]"),
("settings", "[x-data=\"settings\"]"),
("deploy_engine", "[x-data=\"deployEngine\"]"),
];
for (view, selector) in views {
page.evaluate(format!("Alpine.store('app').view = {view:?}"))
.await
.unwrap_or_else(|e| panic!("set view={view}: {e}"));
assert!(
visible(&page, selector).await,
"view={view} deveria estar visível (seletor {selector})"
);
}
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn project_detail_renders() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
page.evaluate(
"Alpine.store('app').view = 'project'; Alpine.store('app').selectedProjectId = 'prj_1'",
)
.await
.expect("abre o projeto");
assert!(
visible(&page, "[x-data=\"projectDetail\"]").await,
"project_detail deveria estar visível"
);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn service_detail_and_general_tab_render() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
let open_service = r#"(() => {
const s = Alpine.store('app');
s.view = 'service';
s.selectedServiceId = 'svc_1';
s.serviceTab = 'general';
s.serviceLoading = false;
s.serviceDeployments = [];
s.serviceDetail = {
id: 'svc_1', status: 'Running', live_container_id: 'abc123',
spec: { name: 'api', port: 8080, replicas: 1, project_id: 'prj_1', env_vars: [], domains: [],
source: { Git: { url: 'https://github.com/acme/api.git', branch: 'main', root_path: '.',
watch_paths: ['src'], submodules: false, dockerfile_path: 'Dockerfile', build_context: '.',
build_stage: null, credentials: null, username: null, provider_id: null } },
healthcheck: { kind: 'None', interval_secs: 5, timeout_secs: 3, retries: 10, start_period_secs: 5 } },
};
})()"#;
page.evaluate(open_service).await.expect("abre o serviço");
assert!(
visible(&page, "[x-data=\"serviceDetail\"]").await,
"service_detail deveria estar visível"
);
assert!(
visible(
&page,
"[x-data=\"serviceDetail\"] input[x-model=\"serviceFormRepoUrl\"]"
)
.await,
"aba General deveria mostrar o form Git (sourceKind=Git)"
);
let prov_tabs: &[(&str, &str)] = &[
(
"git",
"[x-data=\"serviceDetail\"] input[x-model=\"serviceFormRepoUrl\"]",
),
(
"gitea",
"[x-data=\"serviceDetail\"] select[x-model=\"giteaProviderId\"]",
),
("zip", "[x-data=\"serviceDetail\"] input[type=\"file\"]"),
];
for (tab, selector) in prov_tabs {
page.evaluate(format!(
"Alpine.$data(document.querySelector('[x-data=\"serviceDetail\"]')).setProviderTab({tab:?})"
))
.await
.unwrap_or_else(|e| panic!("setProviderTab({tab}): {e}"));
assert!(
visible(&page, selector).await,
"prov_tab={tab} deveria mostrar {selector}"
);
}
page.evaluate(
r#"(() => {
const s = Alpine.store('app');
s.serviceDetail = JSON.parse(JSON.stringify(s.serviceDetail));
s.serviceDetail.spec.source = { Compose: { content: 'services:\n web:\n image: nginx\n' } };
Alpine.$data(document.querySelector('[x-data="serviceDetail"]')).initGeneralForm();
})()"#,
)
.await
.expect("troca pra origem Compose");
assert!(
visible(
&page,
"[x-data=\"serviceDetail\"] textarea[x-model=\"composeText\"]"
)
.await,
"origem Compose deveria mostrar o editor de texto"
);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn rename_service_in_general_tab() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
page.evaluate(
r#"(() => {
const s = Alpine.store('app');
s.view = 'service';
s.selectedServiceId = 'svc_1';
s.serviceTab = 'general';
s.serviceLoading = false;
s.serviceDeployments = [];
s.serviceDetail = {
id: 'svc_1', status: 'Running', live_container_id: null,
spec: { name: 'banco', port: 5432, replicas: 1, project_id: 'prj_1', product: 'postgres',
env_vars: [], domains: [],
source: { Compose: { content: 'services:\n # comentário\n\n rp_banco:\n image: postgres:18\n' } },
healthcheck: { kind: 'None', interval_secs: 5, timeout_secs: 3, retries: 10, start_period_secs: 5 } },
};
// Captura o que o rename manda salvar, sem daemon.
window.__saved = [];
s.saveServiceSpec = async (spec, msg) => { window.__saved.push({ spec, msg }); return { ok: true }; };
window.__toasts = [];
s.toastError = (m) => window.__toasts.push(m);
Alpine.$data(document.querySelector('[x-data="serviceDetail"]')).initGeneralForm();
})()"#,
)
.await
.expect("abre o serviço Compose");
assert!(
visible(
&page,
"[x-data=\"serviceDetail\"] input[x-model=\"editName\"]"
)
.await,
"a aba General deveria mostrar o campo de nome"
);
let d = "Alpine.$data(document.querySelector('[x-data=\"serviceDetail\"]'))";
assert_eq!(
eval_str(
&page,
"(() => Alpine.$data(document.querySelector('[x-data=\"serviceDetail\"]')).editName)()"
)
.await,
"banco",
"o campo começa com o nome atual"
);
assert!(
eval_str(&page, "(() => Alpine.$data(document.querySelector('[x-data=\"serviceDetail\"]')).renameNote)()")
.await
.contains("volumes"),
"Compose: o aviso diz que a stack e os volumes não mudam"
);
let _ = eval_str(
&page,
&format!("(async () => {{ const d = {d}; d.editName = 'banco'; await d.renameService(); d.editName = ' '; await d.renameService(); return ''; }})()")
)
.await;
assert_eq!(
eval_str(
&page,
"(() => JSON.stringify({ saved: window.__saved.length, toasts: window.__toasts }))()"
)
.await,
r#"{"saved":0,"toasts":["Enter the service name"]}"#,
"mesmo nome não salva; vazio só avisa"
);
let saved = eval_str(
&page,
&format!("(async () => {{ const d = {d}; d.editName = ' meu-banco '; await d.renameService(); const x = window.__saved[0]; return JSON.stringify({{ n: window.__saved.length, msg: x.msg, name: x.spec.name, port: x.spec.port, kind: x.spec.product, content: x.spec.source.Compose.content, edit: d.editName }}); }})()")
)
.await;
let v: serde_json::Value = serde_json::from_str(&saved).unwrap();
assert_eq!(v["n"], 1);
assert_eq!(v["msg"], "service renamed");
assert_eq!(v["name"], "meu-banco", "o nome é aparado");
assert_eq!(v["port"], 5432);
assert_eq!(v["kind"], "postgres");
assert!(v["content"].as_str().unwrap().contains("rp_banco:"));
assert_eq!(v["edit"], "meu-banco");
let url = eval_str(
&page,
&format!("(() => {{ Alpine.store('app').serviceDetail.spec.name = 'meu-banco'; {d}.connectionUrl = 'postgresql://u:p@rp_banco:5432/db'; return {d}.connectionInfo.internalUrl; }})()")
)
.await;
assert_eq!(url, "postgresql://u:p@rp_banco:5432/db");
let app = eval_str(
&page,
&format!("(() => {{ {d}.connectionUrl = ''; const s = Alpine.store('app'); s.serviceDetail = {{ id: 'svc_2', status: 'Running', live_container_id: null, spec: {{ name: 'meu-api', port: 8080, replicas: 1, project_id: 'prj_1', env_vars: [], domains: [], source: {{ Registry: {{ image: 'nginx' }} }}, healthcheck: {{ kind: 'None', interval_secs: 5, timeout_secs: 3, retries: 10, start_period_secs: 5 }} }} }}; return JSON.stringify({{ note: {d}.renameNote, url: {d}.connectionInfo.internalUrl }}); }})()")
)
.await;
let a: serde_json::Value = serde_json::from_str(&app).unwrap();
assert!(a["note"].as_str().unwrap().contains("next deploy"));
assert_eq!(a["url"], "—", "sem a URL do daemon, não inventa uma");
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn toasts_appear_stack_and_expire() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
page.evaluate(
r#"(() => {
const s = Alpine.store('app');
s.toastOk('salvo');
s.toastError('erro: falhou');
s.toastWarn('confira os campos');
})()"#,
)
.await
.expect("dispara três toasts");
assert!(
visible(&page, ".toast-wrap .toast--success").await,
"toast de sucesso deveria aparecer"
);
for kind in ["success", "error", "warning"] {
assert!(
visible(&page, &format!(".toast-wrap .toast--{kind}")).await,
"toast kind={kind} deveria aparecer"
);
}
let count = page
.evaluate("document.querySelectorAll('.toast-wrap .toast').length")
.await
.expect("conta os toasts")
.into_value::<u32>()
.expect("contagem numérica");
assert_eq!(
count, 3,
"os três toasts deveriam empilhar, não se substituir"
);
page.evaluate("document.querySelector('.toast-wrap .toast--error .toast__close').click()")
.await
.expect("clica no × do toast de erro");
for _ in 0..20 {
let n = page
.evaluate("document.querySelectorAll('.toast-wrap .toast').length")
.await
.ok()
.and_then(|r| r.into_value::<u32>().ok())
.unwrap_or(9);
if n == 2 {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let restantes = page
.evaluate(
"[...document.querySelectorAll('.toast-wrap .toast')].map(e => e.className).join(' ')",
)
.await
.expect("classes restantes")
.into_value::<String>()
.expect("string");
assert!(
!restantes.contains("toast--error"),
"o × deveria remover o toast clicado; restou: {restantes}"
);
assert!(
restantes.contains("toast--success") && restantes.contains("toast--warning"),
"os outros dois deveriam continuar; restou: {restantes}"
);
page.evaluate("Alpine.store('app').toast('some sozinho', 'info', 150)")
.await
.expect("toast curto");
assert!(
visible(&page, ".toast-wrap .toast--info").await,
"toast info deveria aparecer antes de expirar"
);
tokio::time::sleep(Duration::from_millis(400)).await;
let sobrou_info = page
.evaluate("!!document.querySelector('.toast-wrap .toast--info')")
.await
.expect("consulta o toast info")
.into_value::<bool>()
.expect("bool");
assert!(!sobrou_info, "o toast deveria ter expirado sozinho");
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn shared_server_databases_tab() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
page.evaluate(
r#"(() => {
const s = Alpine.store('app');
window.__rpc = [];
s.api.rpc = async (c) => {
window.__rpc.push(c);
if (c === 'ProjectList') return { ok: true, value: { Projects: [{ id: 'prj_a', name: 'app-a' }] } };
if (c.ManagedDatabaseList) return { ok: true, value: { ManagedDatabases: [{ id: 'mdb_1', project_id: 'prj_a', name: 'gestao', env_var: 'DATABASE_URL', connection_url: 'postgresql://gestao:pw@rp-shared-x:5432/gestao' }] } };
return { ok: false, error: 'stub' };
};
s.api.rpcChecked = async (c) => { window.__rpc.push(c); return { ok: true, value: {} }; };
s.view = 'service';
s.selectedServiceId = 'svc_1';
s.serviceTab = 'databases';
s.serviceLoading = false;
s.serviceDeployments = [];
s.toastOk = () => {};
s.serviceDetail = {
id: 'svc_1', status: 'Running', live_container_id: null,
spec: { name: 'pg', port: 5432, replicas: 1, project_id: 'prj_i', product: 'postgres', shared: {},
env_vars: [], domains: [],
source: { Compose: { content: 'services:\n rp_pg:\n image: postgres:18\n' } },
healthcheck: { kind: 'None', interval_secs: 5, timeout_secs: 3, retries: 10, start_period_secs: 5 } },
};
})()"#,
)
.await
.expect("abre o shared server");
assert!(
visible(
&page,
"[x-data=\"serviceDetail\"] input[x-model=\"serviceFormSharedDatabase.name\"]"
)
.await,
"a aba Databases deveria mostrar o formulário"
);
let d = "Alpine.$data(document.querySelector('[x-data=\"serviceDetail\"]'))";
let listed = eval_str(
&page,
&format!("(async () => {{ const d = {d}; await d.loadSharedState(); return JSON.stringify({{ n: d.mdbs.length, proj: d.mdbs[0].project, env: d.mdbs[0].envVar }}); }})()"),
)
.await;
assert_eq!(listed, r#"{"n":1,"proj":"app-a","env":"DATABASE_URL"}"#);
let sent = eval_str(
&page,
&format!("(async () => {{ const d = {d}; window.__rpc = []; d.serviceFormSharedDatabase = {{ name: ' rdo ', project: 'prj_a', env: '', limit: '20', timeout: '', overwrite: true }}; await d.createSharedDatabase(); return JSON.stringify(window.__rpc.find(c => c.ManagedDatabaseCreate).ManagedDatabaseCreate); }})()"),
)
.await;
let v: serde_json::Value = serde_json::from_str(&sent).unwrap();
assert_eq!(v["name"], "rdo", "o nome é aparado");
assert_eq!(v["server_service_id"], "svc_1");
assert_eq!(v["connection_limit"], 20);
assert!(v["statement_timeout_ms"].is_null(), "vazio vira null");
assert_eq!(v["overwrite_env"], true);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn migrate_old_database_tab() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
page.evaluate(
r#"(() => {
const s = Alpine.store('app');
window.__rpc = [];
s.api.rpc = async (c) => {
window.__rpc.push(c);
if (c === 'ManagedDatabaseListAll') return { ok: true, value: { ManagedDatabases: [
{ id: 'mdb_1', project_id: 'prj_a', name: 'rdo', env_var: 'DATABASE_URL' },
{ id: 'mdb_2', project_id: 'prj_outro', name: 'x', env_var: 'DATABASE_URL' }] } };
if (c.MigrationList) return { ok: true, value: { Migrations: [{ id: 'mig_1', source_service_id: 'svc_old', status: 'Completed',
steps: [{ name: 'Pré-checagem', state: 'ok', detail: '' }, { name: 'Dump + restore', state: 'ok', detail: '' }] }] } };
return { ok: false, error: 'stub' };
};
s.api.rpcChecked = async (c) => { window.__rpc.push(c); return { ok: true, value: {} }; };
s.toastOk = () => {};
s.view = 'service';
s.selectedServiceId = 'svc_old';
s.serviceTab = 'migrar';
s.serviceLoading = false;
s.serviceDeployments = [];
s.serviceDetail = {
id: 'svc_old', status: 'Running', live_container_id: null,
spec: { name: 'banco', port: 5432, replicas: 1, project_id: 'prj_a', product: 'postgres',
env_vars: [{ key: 'POSTGRES_DB', value: { Plain: 'meu_db' } }], domains: [],
source: { Compose: { content: 'services:\n rp_banco:\n image: postgres:18\n' } },
healthcheck: { kind: 'None', interval_secs: 5, timeout_secs: 3, retries: 10, start_period_secs: 5 } },
};
})()"#,
)
.await
.expect("abre o banco antigo");
let d = "Alpine.$data(document.querySelector('[x-data=\"serviceDetail\"]'))";
let loaded = eval_str(
&page,
&format!("(async () => {{ const d = {d}; await d.loadMigration(); return JSON.stringify({{ dests: d.migrationDestinations.map(x => x.id), db: d.serviceFormMigration.db, st: d.mig.status, steps: d.migrationSteps.map(x => x.icon).join('') }}); }})()"),
)
.await;
assert_eq!(
loaded, r#"{"dests":["mdb_1"],"db":"meu_db","st":"Completed","steps":"✓✓"}"#,
"só destinos do mesmo projeto; origem preenchida do POSTGRES_DB"
);
assert!(
visible(
&page,
"[x-data=\"serviceDetail\"] input[x-model=\"serviceFormMigration.db\"]"
)
.await,
"a aba Migrar deveria mostrar o formulário"
);
let sent = eval_str(
&page,
&format!("(async () => {{ window.confirm = () => true; const d = {d}; window.__rpc = []; d.serviceFormMigration.dest = 'mdb_1'; d.serviceFormMigration.env = ' URL_BANCO '; await d.startMigration(); return JSON.stringify(window.__rpc.find(c => c.MigrationStart).MigrationStart); }})()"),
)
.await;
let v: serde_json::Value = serde_json::from_str(&sent).unwrap();
assert_eq!(v["source_service_id"], "svc_old");
assert_eq!(v["source_database"], "meu_db");
assert_eq!(v["dest_database_id"], "mdb_1");
assert_eq!(v["env_var"], "URL_BANCO", "a env var é aparada");
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn job_actions_window_lists_actions_and_warm_status() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
let seeded = eval_str(
&page,
&format!(
r#"(() => {{
const s = Alpine.store('app');
const snapshot = {SNAPSHOT_JS};
const job = {{ id: 'job_1', project_id: 'prj_1', trigger_service_id: 'svc_1', name: 'migrator',
compose: 'services: {{}}', main_service: 'main', env_vars: [], env_comments: [], enabled: true,
recurrence: null, last_run_at: null, next_run_at: null }};
snapshot.jobs = [{{ job, project_name: 'acme', trigger_service_name: 'api', last_run: null,
warm: {{ job_id: 'job_1', warm: false, warming: false, reason: 'nunca aquecido', warmed_at: null }} }}];
s.applySnapshot(snapshot);
window.__commands = [];
s.api.rpcChecked = async (command) => {{
window.__commands.push(command);
return {{ ok: true, value: {{ JobWarmRequested: {{ already_warm: true }} }} }};
}};
s.view = 'schedules';
return 'ok';
}})()"#
),
)
.await;
assert_eq!(seeded, "ok");
assert!(
visible(&page, ".table__col-warm.state-cell--muted").await,
"a linha deveria mostrar o job frio, com o estado vindo do daemon"
);
assert!(
!visible(&page, ".job-actions").await,
"a janela de ações começa fechada"
);
eval_str(
&page,
"(() => { document.querySelector('[title=\"Job actions\"]').click(); return 'ok'; })()",
)
.await;
assert!(
visible(&page, ".job-actions").await,
"o ⋯ da linha deveria abrir a janela de ações"
);
let items = eval_str(
&page,
"JSON.stringify([...document.querySelectorAll('.job-actions__item')].filter(el => el.offsetParent !== null).map(el => el.textContent.trim()))",
)
.await;
assert_eq!(
items, r#"["▶ Run now","◐ Warm","‖ Pause","✎ Edit","✕ Remove"]"#,
"job parado e sem execução: sem cancelar nem logs"
);
assert!(
eval_str(
&page,
"document.querySelector('.job-actions__status').textContent"
)
.await
.contains("nunca aquecido"),
"a janela mostra por que o job está frio"
);
eval_str(
&page,
r#"(() => { Alpine.store('app').snap.jobs[0].warm = { job_id: 'job_1', warm: true, warming: false, reason: null, warmed_at: new Date().toISOString() }; return 'ok'; })()"#,
)
.await;
assert!(
visible(&page, ".job-actions .state-cell--ok").await,
"a janela relê o estado do snapshot ao vivo"
);
eval_str(
&page,
"(() => { [...document.querySelectorAll('.job-actions__item')].find(el => el.textContent.includes('Warm')).click(); return 'ok'; })()",
)
.await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
eval_str(&page, "JSON.stringify(window.__commands)").await,
r#"[{"JobWarm":{"job_id":"job_1"}}]"#
);
assert!(
eval_str(
&page,
"JSON.stringify(Alpine.store('app').toasts.map(t => t.message))"
)
.await
.contains("job is already warm"),
"job quente: o toast avisa que já estava aquecido"
);
eval_str(
&page,
"(() => { window.confirm = () => true; window.__commands = []; [...document.querySelectorAll('.job-actions__item')].find(el => el.textContent.includes('Rebuild')).click(); return 'ok'; })()",
)
.await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
eval_str(&page, "JSON.stringify(window.__commands)").await,
r#"[{"JobWarm":{"job_id":"job_1","force":true}}]"#
);
assert!(
eval_str(
&page,
"JSON.stringify(Alpine.store('app').toasts.map(t => t.message))"
)
.await
.contains("rebuild started"),
"o toast confirma que a reconstrução começou"
);
eval_str(
&page,
r#"(() => { Alpine.store('app').snap.jobs[0].last_run = { id: 'jrun_1', success: null }; return 'ok'; })()"#,
)
.await;
tokio::time::sleep(Duration::from_millis(300)).await;
let items = eval_str(
&page,
"JSON.stringify([...document.querySelectorAll('.job-actions__item')].filter(el => el.offsetParent !== null).map(el => el.textContent.trim()))",
)
.await;
assert_eq!(
items,
r#"["■ Cancel run","◐ Warm","↻ Rebuild (no cache)","≡ View logs","‖ Pause","✎ Edit","✕ Remove"]"#
);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn infra_download_zip() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
eval_str(
&page,
r#"(async () => {
const s = Alpine.store('app');
window.__downloads = [];
const click = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () {
if (this.download) { window.__downloads.push({ name: this.download, href: this.href }); return; }
return click.call(this);
};
s.api = { baseUrl: location.origin, token: 't',
rpcChecked: async (cmd) => cmd === 'InfraDownload'
? { ok: true, value: { InfraFiles: [
{ path: 'MANIFEST.json', content: '{"schema":1}\n' },
{ path: 'projects/prj_1/project.yml', content: 'name: loja\n' } ] } }
: { ok: false, error: 'stub' },
rpc: async () => ({ ok: false, error: 'stub' }) };
s.view = 'settings'; s.settingsTab = 'gitsync';
return 'ok';
})()"#,
)
.await;
assert!(
visible(&page, "[x-data=\"settings\"]").await,
"Settings visível"
);
assert_eq!(
eval_str(
&page,
"String([...document.querySelectorAll('.tabs__tab')].some(b => b.textContent.includes('Infra as Code')))"
)
.await,
"false",
"a aba Infra as Code saiu"
);
assert_eq!(
eval_str(
&page,
"String([...document.querySelectorAll('button')].some(b => b.textContent.trim() === 'Download infra'))"
)
.await,
"true",
"o botão Baixar infra está na aba Sync"
);
let out = eval_str(
&page,
r#"(async () => {
await Alpine.store('app').infraDownload();
const d = window.__downloads[0];
const bytes = new Uint8Array(await (await fetch(d.href)).arrayBuffer());
const text = new TextDecoder('latin1').decode(bytes);
return JSON.stringify({ name: d.name, pk: text.startsWith('PK'),
tem_manifest: text.includes('MANIFEST.json'), tem_projeto: text.includes('projects/prj_1/project.yml'),
tem_conteudo: text.includes('name: loja'), msg: Alpine.store('app').infraDownloadMessage });
})()"#,
)
.await;
assert_eq!(
out,
r##"{"name":"rustploy-infra.zip","pk":true,"tem_manifest":true,"tem_projeto":true,"tem_conteudo":true,"msg":"downloaded as rustploy-infra.zip"}"##
);
let err = eval_str(
&page,
r#"(async () => {
const s = Alpine.store('app');
window.__downloads = [];
s.api.rpcChecked = async () => ({ ok: false, error: 'há 1 valor(es) em claro' });
await s.infraDownload();
return JSON.stringify({ n: window.__downloads.length, msg: s.infraDownloadMessage });
})()"#,
)
.await;
assert_eq!(err, r##"{"n":0,"msg":"erro: há 1 valor(es) em claro"}"##);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn service_logs_start_from_history_and_load_older() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
let out = eval_str(
&page,
r#"(async () => {
const s = Alpine.store('app');
const calls = [];
const e = (t, c, line, stream = 'Stdout') => ({ timestamp: t, container_id: c, stream, line });
s.api = { baseUrl: location.origin, token: 't',
rpcChecked: async (cmd) => {
const a = cmd.LogsHistory;
calls.push(['older', a.before]);
return { ok: true, value: { LogsHistory: {
entries: [e('2026-11-05T01:00:00Z', 'aaaaaaaaaaaa1234', 'velho 1'), e('2026-11-05T01:00:01Z', 'aaaaaaaaaaaa1234', 'velho 2')],
next_before: null } } };
},
rpc: async (cmd) => {
if (cmd.LogsHistory) {
calls.push(['seed', cmd.LogsHistory.before ?? null]);
return { ok: true, value: { LogsHistory: {
entries: [e('2026-11-05T02:00:00Z', 'bbbbbbbbbbbb9999', 'novo 1'), e('2026-11-05T02:00:01Z', 'bbbbbbbbbbbb9999', 'novo erro', 'Stderr')],
next_before: '2026-11-05T02:00:00Z' } } };
}
return { ok: false, error: 'stub' };
} };
s.selectedServiceId = 'svc_1'; s.serviceTab = 'logs';
await s.startServiceLogs();
const seeded = s.serviceLogLines.map(l => l.line);
const more1 = s.serviceLogMore;
await s.serviceLogLoadOlder();
return JSON.stringify({ seeded, more1, after: s.serviceLogLines.map(l => l.line),
more2: s.serviceLogMore, calls });
})()"#,
)
.await;
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["seeded"], serde_json::json!(["novo 1", "novo erro"]));
assert_eq!(v["more1"], "2026-11-05T02:00:00Z", "há mais log guardado");
assert_eq!(
v["after"],
serde_json::json!([
"velho 1",
"velho 2",
"— other container: bbbbbbbbbbbb —",
"novo 1",
"novo erro"
]),
"o mais velho vem por cima, com separador onde o container mudou"
);
assert_eq!(v["more2"], serde_json::Value::Null, "acabou o histórico");
assert_eq!(
v["calls"][1],
serde_json::json!(["older", "2026-11-05T02:00:00Z"])
);
let fallback = eval_str(
&page,
r#"(async () => {
const s = Alpine.store('app');
s.api = { baseUrl: location.origin, token: 't',
rpcChecked: async () => ({ ok: false, error: 'stub' }),
rpc: async (cmd) => cmd.LogsGet
? { ok: true, value: { Logs: [{ stream: 'Stdout', line: 'ao vivo', timestamp: '2026-11-05T03:00:00Z' }] } }
: { ok: false, error: 'LogsDisabled' } };
s.serviceLogLines = [];
await s.startServiceLogs();
return JSON.stringify({ lines: s.serviceLogLines.map(l => l.line), more: s.serviceLogMore });
})()"#,
)
.await;
assert_eq!(fallback, r##"{"lines":["ao vivo"],"more":null}"##);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn running_screen_lists_running_services() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
let seed = r#"(() => {
const s = Alpine.store('app');
s.snap.dashboard = [{ project_id: 'prj_1', project_name: 'acme', services: [{
service_id: 'svc_1', name: 'api', status: 'Running', kind: 'git', kind_label: 'App · Git',
branch: 'release', repo_url: 'https://github.com/acme/api.git', image: null, engine: null,
port: 8080, domains: ['api.acme.dev'], webhook: 'https://h/webhook/svc_1/segredo',
pre_deploy_checks: [{ job_id: 'j1', name: 'migrate' }, { job_id: 'j2', name: 'seed' }],
warm: { service_id: 'svc_1', warm: false, warming: false, reason: 'nunca aquecido', warmed_at: null } }] }];
s.view = 'running';
return 'ok';
})()"#;
assert!(
!eval_str(&page, &format!("(() => {seed})()"))
.await
.is_empty()
);
assert!(
visible(&page, "[x-data=\"running\"]").await,
"tela running visível"
);
let text = eval_str(
&page,
"document.querySelector('[x-data=\"running\"]').innerText",
)
.await;
for esperado in [
"acme",
"api",
"release",
"migrate",
"seed",
"Deploy",
"Open service",
"Open project",
] {
assert!(
text.contains(esperado),
"faltou {esperado:?} na tela: {text}"
);
}
let url = eval_str(
&page,
r#"document.querySelector('[x-data="running"] .webhook__url').value"#,
)
.await;
assert_eq!(url, "https://h/webhook/svc_1/••••••••");
assert!(
!text.contains("segredo"),
"o token do webhook não pode aparecer: {text}"
);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn update_badge_lights_up_from_the_snapshot() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
assert!(
!visible(&page, ".nav-item__badge").await,
"sem `update` no snapshot o selo fica apagado"
);
let applied = eval_str(
&page,
&format!(
r#"(() => {{
const s = Alpine.store('app');
const snapshot = {SNAPSHOT_JS};
snapshot.update = {{ current_version: '0.1.14', latest_version: '0.1.15',
is_update_available: true, release_url: 'https://github.com/o/r/releases/tag/v0.1.21',
published_at: null, checked_at: '2026-10-27T10:00:00Z' }};
s.applySnapshot(snapshot);
return JSON.stringify([s.updateIsAvailable, s.updateLatestVersion, s.updateMessage]);
}})()"#
),
)
.await;
let v: serde_json::Value = serde_json::from_str(&applied).unwrap();
assert_eq!(v[0], true);
assert_eq!(v[1], "0.1.15");
assert_eq!(v[2], "There is a new version: 0.1.15");
assert!(
visible(&page, ".nav-item__badge").await,
"o selo do Settings deveria acender"
);
let after = eval_str(
&page,
&format!(
r#"(() => {{
const s = Alpine.store('app');
const snapshot = {SNAPSHOT_JS};
snapshot.update = {{ current_version: '0.1.14', latest_version: '0.1.15',
is_update_available: true, release_url: '', published_at: null,
checked_at: '2026-10-27T10:00:00Z' }};
s.updateMessage = 'mensagem da tela';
s.applySnapshot(snapshot);
const kept = s.updateMessage;
snapshot.update = {{ ...snapshot.update, latest_version: '0.1.14',
is_update_available: false, checked_at: '2026-10-27T16:00:00Z' }};
s.applySnapshot(snapshot);
return JSON.stringify([kept, s.updateIsAvailable]);
}})()"#
),
)
.await;
let v: serde_json::Value = serde_json::from_str(&after).unwrap();
assert_eq!(
v[0], "mensagem da tela",
"mesmo checked_at: a aba não é reescrita"
);
assert_eq!(v[1], false, "checked_at novo: a aba segue a consulta nova");
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}
#[tokio::test]
async fn opening_settings_loads_the_git_provider_list() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
let out = eval_str(
&page,
r#"(async () => {
const s = Alpine.store('app');
const calls = [];
s.api = { rpc: async (c) => {
calls.push(typeof c === 'string' ? c : Object.keys(c)[0]);
return { ok: true, value: { GitProviders: [{ id: 'gp_1', name: 'GitHub', kind: 'Github' }] } };
} };
s.gitProviders = [];
s.nav('settings');
await new Promise(r => setTimeout(r, 100));
return JSON.stringify([calls.includes('GitProviderList'), s.gitProviders.length]);
})()"#,
)
.await;
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v[0], true, "nav('settings') pede GitProviderList");
assert_eq!(v[1], 1, "a lista chega ao estado");
}
#[tokio::test]
async fn git_sync_screens_render_from_the_snapshot() {
let addr = spawn_static_server().await;
let (browser, _handler) = launch().await;
let page = open_page(&browser, addr).await;
seed_connected(&page).await;
assert!(
!eval_str(
&page,
"String(Alpine.store('app').stateGitAlertMessage === '')"
)
.await
.is_empty()
);
assert_eq!(
eval_str(&page, "Alpine.store('app').stateGitAlertMessage").await,
""
);
let seeded = eval_str(
&page,
&format!(
r#"(() => {{
const s = Alpine.store('app');
const snapshot = {SNAPSHOT_JS};
snapshot.state_git = {{
mode: 'sync', remote: 'https://github.com/o/estado.git', branch: 'main', provider_id: null,
git_available: true, recovery_configured: false,
last_commit: {{ sha: 'da3da09aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', at: '2026-11-02T06:28:44Z', subject: 'Atualiza projeto E2E' }},
last_push_ok: true, last_push_at: '2026-11-02T06:28:50Z', last_fetch_at: '2026-11-02T06:29:00Z',
last_error: null, blocked_by_plaintext: 0, needs_push: false, ahead: 1, behind: 1,
paused_reason: 'o histórico divergiu',
incoming_rejected: {{ sha: '40c22c1240580bde3cac6512dab507c663335a85', reason: 'traz 1 valor(es) em claro' }},
removed_in_git: [{{ id: 'serviço:svc_web', kind: 'serviço', name: 'web', project: 'E2E',
sha: 'da3da09aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', at: '2026-11-02T06:28:44Z' }}],
pending_deploy: ['svc_pendente'],
}};
snapshot.infra_audit = [
{{ definite: false, allowed: false, project: 'E2E', scope: 'projeto', what: 'env PASSWORD_MIN_LENGTH' }},
{{ definite: true, allowed: false, project: 'E2E', scope: 'serviço api', what: 'token de webhook sem cifra' }},
];
snapshot.docker_containers = [
{{ id: 'a'.repeat(64), name: 'orfao_vivo', image: 'debian', state: 'running', status: 'Up',
managed: true, project: null, service: null, removed_in_git: 'web' }},
{{ id: 'b'.repeat(64), name: 'servico_vivo', image: 'debian', state: 'running', status: 'Up',
managed: true, project: 'E2E', service: 'api', removed_in_git: null }},
];
s.applySnapshot(snapshot);
return JSON.stringify([s.stateGitAlertMessage, s.stateGitPendingDeploy('svc_pendente'),
s.stateGitPendingDeploy('outro'), s.stateGitAuditFindings.length]);
}})()"#
),
)
.await;
let v: serde_json::Value = serde_json::from_str(&seeded).unwrap();
assert!(
v[0].as_str().unwrap().contains("paused"),
"a pausa tem prioridade no aviso: {}",
v[0]
);
assert_eq!(v[1], true, "serviço com mudança pendente de deploy");
assert_eq!(v[2], false);
assert_eq!(v[3], 2, "a auditoria vem no snapshot");
assert!(
visible(&page, ".banner--warn").await,
"o aviso do topo aparece (pausada)"
);
let page_text = eval_str(
&page,
r#"(() => {
const s = Alpine.store('app');
s.nav('settings');
s.settingsTab = 'gitsync';
s.stateGitFormLoad();
return 'ok';
})()"#,
)
.await;
assert!(!page_text.is_empty());
assert!(
visible(&page, "[x-data=\"settings\"]").await,
"Settings visível"
);
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let text = eval_str(
&page,
"document.querySelector('[x-data=\"settings\"]').innerText",
)
.await;
for esperado in [
"GIT SYNC",
"DIVERGED HISTORY",
"Use the repository's",
"Use the server's",
"Resume",
"REPOSITORY COMMIT REJECTED",
"REMOVED IN GIT",
"Dismiss",
"PLAINTEXT VALUES (2)",
"env PASSWORD_MIN_LENGTH",
"Not a secret",
"da3da09 · Atualiza projeto E2E",
] {
assert!(
text.contains(esperado),
"faltou {esperado:?} na aba Sync:\n{text}"
);
}
assert_eq!(
eval_str(&page, "Alpine.store('app').stateGitRemote").await,
"https://github.com/o/estado.git"
);
let buttons = eval_str(
&page,
r#"JSON.stringify([...document.querySelectorAll('[x-data="settings"] button')]
.filter(b => b.getClientRects().length > 0).map(b => b.innerText.trim()))"#,
)
.await;
let buttons: Vec<String> = serde_json::from_str(&buttons).unwrap();
assert_eq!(
buttons
.iter()
.filter(|b| b.as_str() == "Not a secret")
.count(),
1,
"só a suspeita tem o botão; botões visíveis: {buttons:?}"
);
eval_str(
&page,
"(() => { Alpine.store('app').nav('docker'); return 'ok'; })()",
)
.await;
assert!(
visible(&page, "[x-data=\"docker\"]").await,
"Runtime visível"
);
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let rows = eval_str(
&page,
"document.querySelector('[x-data=\"docker\"]').innerText",
)
.await;
assert!(
rows.contains("removed in git (web)"),
"dono do órfão:\n{rows}"
);
let docker_buttons = eval_str(
&page,
r#"JSON.stringify([...document.querySelectorAll('[x-data="docker"] button')]
.filter(b => b.getClientRects().length > 0).map(b => b.innerText.trim()))"#,
)
.await;
let docker_buttons: Vec<String> = serde_json::from_str(&docker_buttons).unwrap();
assert_eq!(
docker_buttons
.iter()
.filter(|b| b.as_str() == "Stop")
.count(),
1,
"só o órfão tem o botão Stop; botões visíveis: {docker_buttons:?}"
);
assert_eq!(
errors(&page).await,
Vec::<String>::new(),
"sem erros de JS no console"
);
}