use bytes::Bytes;
use http_body_util::{BodyExt, Full, combinators::BoxBody};
use hyper::{Response, StatusCode};
type ApiBody = BoxBody<Bytes, std::convert::Infallible>;
pub struct Asset {
pub route: &'static str,
pub content_type: &'static str,
pub etag: &'static str,
pub no_cache: bool,
pub gz: &'static [u8],
}
static ASSETS: &[Asset] = include!(concat!(env!("OUT_DIR"), "/webui_assets.rs"));
pub fn serve(path: &str) -> Option<Response<ApiBody>> {
let asset = ASSETS.iter().find(|a| a.route == path)?;
let cache_control = if asset.no_cache {
"no-cache"
} else {
"public, max-age=31536000, immutable"
};
Some(
Response::builder()
.status(StatusCode::OK)
.header(hyper::header::CONTENT_TYPE, asset.content_type)
.header(hyper::header::CONTENT_ENCODING, "gzip")
.header(hyper::header::CACHE_CONTROL, cache_control)
.header(hyper::header::ETAG, format!("\"{}\"", asset.etag))
.body(Full::new(Bytes::from_static(asset.gz)).boxed())
.expect("resposta estática bem-formada"),
)
}
#[cfg(test)]
mod headless_tests {
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()
.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
{
if 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 todas_as_telas_principais_renderizam() {
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_wrap").await,
"tela de login deveria estar visível antes do login"
);
seed_connected(&page).await;
let views: &[(&str, &str)] = &[
("deployments", "[x-data=\"dashboard\"]"),
("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_renderiza() {
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_e_general_tab_renderizam() {
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=\"fRepoUrl\"]"
)
.await,
"aba General deveria mostrar o form Git (sourceKind=Git)"
);
let prov_tabs: &[(&str, &str)] = &[
(
"git",
"[x-data=\"serviceDetail\"] input[x-model=\"fRepoUrl\"]",
),
(
"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\"]')).setProvTab({tab:?})"
))
.await
.unwrap_or_else(|e| panic!("setProvTab({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 renomear_servico_na_aba_general() {
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', db_kind: '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.toastErr = (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":["Informe o nome do serviço"]}"#,
"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.db_kind, 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"], "serviço renomeado");
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}.connUrl = '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}.connUrl = ''; 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("próximo 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_aparecem_empilham_e_expiram() {
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.toastErr('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 aba_databases_do_servidor_compartilhado() {
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', db_kind: '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 servidor compartilhado");
assert!(
visible(&page, "[x-data=\"serviceDetail\"] input[x-model=\"fMdb.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.fMdb = {{ name: ' rdo ', project: 'prj_a', env: '', limit: '20', timeout: '', overwrite: true }}; await d.createMdb(); 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 aba_migrar_banco_antigo() {
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', db_kind: '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.migDests.map(x => x.id), db: d.fMig.db, st: d.mig.status, steps: d.migSteps.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=\"fMig.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.fMig.dest = 'mdb_1'; d.fMig.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");
}
}