CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
282 lines
17 KiB
Rust
282 lines
17 KiB
Rust
#[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::<tauri::Wry>::new("macos-smoke")
|
|
.setup(move |app, _| {
|
|
let directory = app.path().app_config_dir()?;
|
|
std::fs::create_dir_all(&directory)?;
|
|
std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
|
|
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 Promise.race([window.__TAURI_INTERNALS__.invoke(cmd), new Promise((_, reject) => setTimeout(() => reject(new Error('IPC timeout')), 3000))]); denied = false; }
|
|
catch (error) { denied = denied && String(error).includes('not allowed'); }
|
|
}
|
|
const api = window.kanbanNative;
|
|
if (api?.version !== 1) throw new Error('Versioned settings bridge missing');
|
|
const initial = await api.request('getState');
|
|
if (initial.platform !== 'macos' || initial.workspace !== 'https://kanbangit.de/' || !initial.capabilities.includes('updates')) throw new Error('Incorrect native settings');
|
|
await api.request('setUpdatePreferences', {automatic:false, intervalHours:24});
|
|
const changed = await api.request('getState');
|
|
if (changed.updates.preferences.automatic || changed.updates.preferences.intervalHours !== 24) throw new Error('Update preferences did not persist');
|
|
const invalid = await Promise.race([window.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:5,action:'shell',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Raw bridge timeout')), 3000))]);
|
|
if (!JSON.parse(invalid).error) throw new Error('Unknown native action accepted');
|
|
const frame = document.createElement('iframe');
|
|
document.body.append(frame);
|
|
if (!frame.contentWindow.webkit?.messageHandlers.kanbanSettings) throw new Error('Frame bridge fixture missing');
|
|
let frameDenied = false;
|
|
try { await Promise.race([frame.contentWindow.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:6,action:'getState',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame bridge timeout')), 3000))]); }
|
|
catch (error) { frameDenied = String(error).includes('untrustedFrame'); }
|
|
if (!frameDenied) throw new Error('Subframe settings access accepted');
|
|
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');
|
|
if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
|
|
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
|
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
|
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
|
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
|
|
"#).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();
|
|
window.eval(r#"
|
|
window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
|
|
location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
|
|
});
|
|
"#).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"
|
|
);
|
|
if online {
|
|
assert!(std::fs::read_to_string(config_dir.join("active-server"))
|
|
.unwrap()
|
|
.starts_with("https://kanbangit.de/login/\n"));
|
|
let preferences: serde_json::Value = serde_json::from_str(
|
|
&std::fs::read_to_string(config_dir.join("update-preferences.json")).unwrap(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
preferences,
|
|
serde_json::json!({"automatic": false, "intervalHours": 24})
|
|
);
|
|
}
|
|
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");
|
|
}
|