#[cfg(target_os = "macos")] fn main() { use std::sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, }; use tauri::{webview::PageLoadEvent, Manager}; let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default(); let online = startup == "online"; let needs_setup = matches!(startup.as_str(), "missing" | "invalid"); let passed = Arc::new(AtomicBool::new(false)); let stage = Arc::new(AtomicUsize::new(0)); let page_stage = stage.clone(); let result_passed = passed.clone(); let setup_passed = passed.clone(); let plugin = tauri::plugin::Builder::::new("macos-smoke") .setup(move |app, _| { let directory = app.path().app_config_dir()?; std::fs::create_dir_all(&directory)?; if needs_setup { if startup == "invalid" { std::fs::write(directory.join("active-server"), "invalid configuration")?; } return Ok(()); } let server = if online { "https://kanbangit.de/" } else { "https://127.0.0.1:9/" }; std::fs::write(directory.join("active-server"), format!("{server}\n0123456789abcdef0123456789abcdef"))?; Ok(()) }) .js_init_script(r#" if (location.protocol === 'tauri:') { window.__selectionFlashed = false; new MutationObserver(() => { const selection = document.querySelector('#workspace-settings'); const phase = document.querySelector('#connection-status')?.dataset.state; if (selection && !selection.hidden && phase !== 'connectionError') window.__selectionFlashed = true; }).observe(document, { subtree: true, attributes: true, childList: true }); } "#) .js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/?smoke=result&passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);") .on_page_load(move |webview, payload| { eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url()); if payload.event() != PageLoadEvent::Finished { return; } if webview.label() == "launcher" { if needs_setup { let app = webview.app_handle().clone(); let passed = setup_passed.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(200)); let handle = app.clone(); app.run_on_main_thread(move || { let launcher = handle.get_webview_window("launcher").unwrap(); assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup"); assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-"))); passed.store(true, Ordering::SeqCst); launcher.close().unwrap(); }).unwrap(); }); return; } assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window"); if online { return; } webview.eval(r#" (async () => { const wait = async predicate => { for (let attempt = 0; attempt < 220; attempt++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('Timed out waiting for local state'); }; try { await wait(() => document.querySelector('#connection-status')?.dataset.state === 'connectionError'); if (window.__selectionFlashed) throw new Error('Workspace selection flashed during saved startup'); if (document.querySelector('#server-url').value !== 'https://127.0.0.1:9/') throw new Error('Saved workspace was discarded'); const input = document.querySelector('#server-url'); const form = document.querySelector('#server-form'); const status = document.querySelector('#connection-status'); input.value = 'http://example.com'; form.requestSubmit(); if (input.getAttribute('aria-invalid') !== 'true' || !status.textContent.includes('HTTPS')) throw new Error('Invalid address accepted'); for (const [theme, expected] of [['dark', 'rgb(15, 23, 42)'], ['light', 'rgb(247, 249, 251)']]) { const appearance = document.querySelector('#appearance'); appearance.value = theme; appearance.dispatchEvent(new Event('change')); if (getComputedStyle(document.documentElement).backgroundColor !== expected) throw new Error('Theme failed'); } input.value = 'https://127.0.0.1:9/'; form.requestSubmit(); await wait(() => status.dataset.state === 'connecting'); await wait(() => status.dataset.state === 'connectionError'); if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed'); input.value = 'https://kanbangit.de/'; form.requestSubmit(); if (document.querySelector('#switch-confirmation').hidden) throw new Error('Missing switch confirmation'); document.querySelector('#cancel-switch').click(); if (!document.querySelector('#switch-confirmation').hidden) throw new Error('Cancel failed'); form.requestSubmit(); document.querySelector('#confirm-switch').click(); } catch (error) { location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message); } })(); "#).unwrap(); } else if webview.label().starts_with("workspace-") && payload.url().host_str() == Some("kanbangit.de") { let current_stage = page_stage.fetch_add(1, Ordering::SeqCst); if current_stage == 0 { webview.eval(r#" (async () => { for (let attempt = 0; attempt < 100; attempt++) { if (document.querySelector('#app')?.innerText.trim().length > 100) break; await new Promise(resolve => setTimeout(resolve, 100)); } const rendered = document.querySelector('#app')?.innerText.trim().length > 100; let denied = true; for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) { try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; } catch (error) { denied = denied && String(error).includes('not allowed'); } } const input = document.createElement('input'); input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; localStorage.setItem('kan52_fixture', 'isolated'); const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok; })(); "#).unwrap(); } else { webview.eval(r#" const isolated = !document.cookie.includes('kan52_fixture') && localStorage.getItem('kan52_fixture') === null; location.href = '/__native_smoke_result?stage=isolated&passed=' + isolated; "#).unwrap(); } } }) .on_navigation(move |webview, url| { eprintln!("Smoke navigation: {} {}", webview.label(), url); if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") { return true; } let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true"); let loaded = url.query_pairs().any(|(key, value)| key == "stage" && value == "loaded"); println!("macOS WebView smoke test: {}", url.query().unwrap_or_default()); if success && loaded { let app = webview.app_handle().clone(); let label = webview.label().to_string(); std::thread::spawn(move || { let window = app.get_webview_window(&label).unwrap(); // A blocked cross-origin navigation must leave the existing input intact. window.navigate(tauri::Url::parse("https://kanbangit.de:9/unreachable").unwrap()).unwrap(); std::thread::sleep(std::time::Duration::from_secs(1)); window.eval(r#" if (document.querySelector('#kan52-unsaved')?.value !== 'unsaved input') { location.href = '/__native_smoke_result?passed=false&stage=preservation'; } "#).unwrap(); let launcher = app.get_webview_window("launcher").unwrap(); launcher.show().unwrap(); launcher.eval(r#" document.querySelector('#server-url').value = 'https://kanbangit.de/login/'; document.querySelector('#server-form').requestSubmit(); document.querySelector('#confirm-switch').click(); "#).unwrap(); }); } else { result_passed.store(success, Ordering::SeqCst); if success { let app = webview.app_handle().clone(); let label = webview.label().to_string(); let handle = app.clone(); app.run_on_main_thread(move || { assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap()); handle.get_webview_window(&label).unwrap().close().unwrap(); }).unwrap(); } else { webview.app_handle().exit(1); } } false }) .build(); let mut context = kanbangit_native::context(); context.config_mut().identifier = format!("de.kanbangit.smoke.kan52.{}", std::process::id()); let app = kanbangit_native::builder() .plugin(plugin) .build(context) .expect("The macOS application must start"); let visibility_handle = app.handle().clone(); let visibility_stage = stage.clone(); let visibility_check = std::thread::spawn(move || { if needs_setup { return; } for _ in 0..30 { if visibility_stage.load(Ordering::SeqCst) > 0 { break; } if let Some(launcher) = visibility_handle.get_webview_window("launcher") { assert!( !launcher.is_visible().unwrap(), "Launcher flashed while the workspace was loading" ); } std::thread::sleep(std::time::Duration::from_millis(100)); } }); let config_dir = app.path().app_config_dir().unwrap(); let handle = app.handle().clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(60)); eprintln!("macOS smoke test timed out"); handle.exit(1); }); let closed = Arc::new(AtomicBool::new(false)); let closed_result = closed.clone(); let exit_code = app.run_return(move |app, event| { if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event { closed_result.store(true, Ordering::SeqCst); assert!( needs_setup || !app .get_webview_window("launcher") .unwrap() .is_visible() .unwrap(), "Closing the workspace must not reopen settings" ); } }); visibility_check.join().unwrap(); assert_eq!(exit_code, 0); assert!( closed.load(Ordering::SeqCst), "The close button must quit the application" ); let _ = std::fs::remove_dir_all(config_dir); assert!( passed.load(Ordering::SeqCst), "The native smoke test must complete successfully" ); } #[cfg(not(target_os = "macos"))] fn main() { panic!("Run this smoke test on macOS with an active graphical session"); }