From 32a6756419bb76affde4ca481a031daef33502a2 Mon Sep 17 00:00:00 2001 From: Twyco Date: Mon, 28 Sep 2026 21:00:46 +0200 Subject: [PATCH] feat(KAN-69): open saved native workspace directly in dashboard --- .../java/de/kanbangit/desktop/PlatformTest.kt | 54 +++++++++ .../java/de/kanbangit/desktop/MainActivity.kt | 10 +- .../de/kanbangit/desktop/NavigationPolicy.kt | 2 + .../kanbangit/desktop/NavigationPolicyTest.kt | 5 + .../kanbangit-native/WorkspacePolicy.swift | 2 + .../WorkspaceViewController.swift | 24 +++- .../gen/apple/Tests/WorkspaceTests.swift | 65 +++++++++++ .../gen/apple/UITests/WorkspaceUITests.swift | 39 +++++++ native/src-tauri/src/desktop.rs | 105 ++++++++++-------- native/src-tauri/src/server.rs | 21 ++++ native/src-tauri/tests/macos_smoke.rs | 24 +++- native/tests/launcher-browser.mjs | 13 ++- native/tests/launcher.test.js | 5 + native/tests/mobile-launcher.test.js | 12 ++ native/ui/index.html | 3 +- native/ui/main.js | 3 + tests/Feature/Auth/AuthenticationTest.php | 19 ++++ 17 files changed, 339 insertions(+), 67 deletions(-) diff --git a/native/src-tauri/gen/android/app/src/androidTest/java/de/kanbangit/desktop/PlatformTest.kt b/native/src-tauri/gen/android/app/src/androidTest/java/de/kanbangit/desktop/PlatformTest.kt index 9aa4bef..760d71f 100644 --- a/native/src-tauri/gen/android/app/src/androidTest/java/de/kanbangit/desktop/PlatformTest.kt +++ b/native/src-tauri/gen/android/app/src/androidTest/java/de/kanbangit/desktop/PlatformTest.kt @@ -5,6 +5,7 @@ import android.os.SystemClock import android.view.InputDevice import android.view.MotionEvent import android.view.View +import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.WebView import androidx.lifecycle.Lifecycle @@ -59,6 +60,59 @@ class PlatformTest { instrumentation.waitForIdleSync() } + @Test fun coldStartRestoresWorkspaceWithoutSelectionOrLauncherHistory() { + val mode = InstrumentationRegistry.getArguments().getString("startupConfig", "saved") + val preferences = instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE) + preferences.edit().clear().apply { + if (mode == "saved") putString("server", "https://127.0.0.1:9/app/") + if (mode == "invalid") putString("server", "http://invalid.example/") + }.commit() + val scenario = ActivityScenario.launch(MainActivity::class.java) + val launcher = AtomicReference() + fun findLauncher(view: View): WebView? { + if (view is WebView && view.url?.startsWith(NavigationPolicy.LAUNCHER_ORIGIN) == true) return view + if (view is ViewGroup) for (index in 0 until view.childCount) { + findLauncher(view.getChildAt(index))?.let { return it } + } + return null + } + waitFor { + scenario.onActivity { launcher.set(findLauncher(it.window.decorView)) } + launcher.get() != null && evaluate(launcher.get(), "typeof window.updateLauncher === 'function'") == "true" + } + if (mode == "saved") { + waitFor { + val state = evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") + if (state == "\"connecting\"") { + assertEquals("true", evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden")) + } + state == "\"connectionError\"" + } + assertEquals("https://127.0.0.1:9/app/", preferences.getString("server", null)) + assertEquals("\"Try again\"", evaluate(launcher.get(), "document.querySelector('.open-workspace').textContent")) + scenario.onActivity { + assertEquals("https://127.0.0.1:9/app/dashboard", it.workspace?.originalUrl) + assertFalse(it.workspace!!.canGoBack()) + it.openWorkspace("https://127.0.0.1:9/app/") + } + waitFor { evaluate(launcher.get(), "document.querySelector('#connection-status').dataset.state") == "\"connectionError\"" } + } else { + waitFor { evaluate(launcher.get(), "document.querySelector('#workspace-settings').hidden") == "false" } + scenario.onActivity { assertNull(it.workspace) } + } + scenario.onActivity { it.openWorkspace("https://kanbangit.de/") } + val remote = AtomicReference() + waitFor { + var visible = false + scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE } + visible && remote.get() != null + } + assertEquals("\"/login\"", evaluate(remote.get(), "location.pathname")) + assertEquals("1", evaluate(remote.get(), "history.length")) + assertEquals("https://kanbangit.de/", preferences.getString("server", null)) + scenario.moveToState(Lifecycle.State.CREATED) + } + @Test fun workspacePreservesFormsAndIsolatesServers() { instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit() val scenario = ActivityScenario.launch(MainActivity::class.java) diff --git a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt index f19d5e9..0a58de3 100644 --- a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt +++ b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/MainActivity.kt @@ -164,9 +164,9 @@ class MainActivity : TauriActivity() { if (initialized) return initialized = true val saved = preferences.getString("server", null) - server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER - updateLauncher() - if (saved != null) openWorkspace(server) + val restored = saved?.let(NavigationPolicy::installation) + server = restored ?: NavigationPolicy.DEFAULT_SERVER + if (restored != null) openWorkspace(restored) else updateLauncher() } "/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty()) "/action/resume" -> if (ready) showWorkspace() @@ -313,8 +313,8 @@ class MainActivity : TauriActivity() { val firstLoad = !ready ready = true CookieManager.getInstance().flush() - updateLauncher() if (firstLoad) showWorkspace() + updateLauncher() } override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { @@ -333,7 +333,7 @@ class MainActivity : TauriActivity() { remote.visibility = View.GONE content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1)) startTimeout(remote) - remote.loadUrl(server) + remote.loadUrl(NavigationPolicy.dashboard(server)) } private fun startTimeout(view: WebView) { diff --git a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt index fb20ce6..6e64459 100644 --- a/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt +++ b/native/src-tauri/gen/android/app/src/main/java/de/kanbangit/desktop/NavigationPolicy.kt @@ -21,6 +21,8 @@ object NavigationPolicy { "https://${uri.host.lowercase(Locale.ROOT)}$port$path" }.getOrNull() + fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString() + fun allows(server: String, destination: String): Boolean = runCatching { val base = URI(server) val target = URI(destination) diff --git a/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt b/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt index 8244cb8..a8d4b3c 100644 --- a/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt +++ b/native/src-tauri/gen/android/app/src/test/java/de/kanbangit/desktop/NavigationPolicyTest.kt @@ -12,6 +12,11 @@ class NavigationPolicyTest { } } + @Test fun startupTargetsDashboardWithinTheInstallation() { + assertEquals("https://example.com/dashboard", NavigationPolicy.dashboard("https://example.com/")) + assertEquals("https://example.com:8443/kanban/dashboard", NavigationPolicy.dashboard("https://example.com:8443/kanban/")) + } + @Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() { val server = "https://example.com:8443/app/" for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) { diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift index f7f05d0..77d8130 100644 --- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspacePolicy.swift @@ -22,6 +22,8 @@ struct WorkspaceServer: Codable, Equatable { return components.url } + var dashboardURL: URL { url.appendingPathComponent("dashboard") } + func allows(_ destination: URL) -> Bool { destination.scheme?.lowercased() == "https" && destination.host?.lowercased() == url.host?.lowercased() diff --git a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift index 285c231..2d8055b 100644 --- a/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift +++ b/native/src-tauri/gen/apple/Sources/kanbangit-native/WorkspaceViewController.swift @@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU private var connection = WorkspaceConnection() private var pendingNavigation: WKNavigation? private var timeout: DispatchWorkItem? + private let loading = UIActivityIndicatorView(style: .large) private let launcher = UIScrollView() private let stack = UIStackView() private let address = UITextField() @@ -58,6 +59,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU super.viewDidLoad() view.backgroundColor = .systemBackground view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1) + loading.accessibilityIdentifier = "workspaceLoading" + loading.accessibilityLabel = text("connecting") + loading.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(loading) + NSLayoutConstraint.activate([ + loading.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loading.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) configureLauncher() configureToolbar() if let data = defaults.data(forKey: serverKey) { @@ -69,8 +78,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } } address.text = server?.url.absoluteString ?? "https://kanbangit.de/" - showSettings() - if let server { open(server.url) } + if let server { open(server.url) } else { showSettings() } } private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel { @@ -83,6 +91,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } private func configureLauncher() { + launcher.isHidden = true launcher.accessibilityIdentifier = "serverSettings" launcher.keyboardDismissMode = .interactive launcher.alwaysBounceVertical = true @@ -152,6 +161,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } private func configureToolbar() { + toolbar.isHidden = true toolbar.translatesAutoresizingMaskIntoConstraints = false view.addSubview(toolbar) backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack)) @@ -222,8 +232,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } view.endEditing(true) address.text = url.absoluteString + launcher.isHidden = true + toolbar.isHidden = true + loading.startAnimating() beginNavigation() - pendingNavigation = webview.load(URLRequest(url: url)) + pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL)) } private func beginNavigation() { @@ -247,6 +260,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } @objc private func showSettings() { + loading.stopAnimating() view.endEditing(true) launcher.isHidden = false toolbar.isHidden = true @@ -256,6 +270,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU @objc private func showWorkspace() { guard connection.ready else { return } + loading.stopAnimating() view.endEditing(true) launcher.isHidden = true toolbar.isHidden = false @@ -296,7 +311,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard webView === workspace, navigation === pendingNavigation else { return } + guard webView === workspace, navigation === pendingNavigation, connection.connecting, + let url = webView.url, server?.allows(url) == true else { return } let firstLoad = !connection.ready timeout?.cancel() connection.finish() diff --git a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift index 4eb8e70..e89c60b 100644 --- a/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift +++ b/native/src-tauri/gen/apple/Tests/WorkspaceTests.swift @@ -17,6 +17,71 @@ final class WorkspaceTests: XCTestCase { } } + func testDashboardAddressPreservesTheInstallationPath() throws { + let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.com:8443/kanban/")) + XCTAssertEqual(WorkspaceServer(url: url, profile: UUID()).dashboardURL.absoluteString, + "https://example.com:8443/kanban/dashboard") + } + + @MainActor + func testColdStartHidesSelectionUntilFailureAndPreservesSavedWorkspace() throws { + let name = "KAN69-startup-\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let saved = WorkspaceServer(url: URL(string: "https://example.invalid/kanban/")!, profile: UUID()) + let data = try JSONEncoder().encode(saved) + defaults.set(data, forKey: "ios.activeServer") + let controller = WorkspaceViewController(defaults: defaults) + controller.loadViewIfNeeded() + let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }) + let loading = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "workspaceLoading" } as? UIActivityIndicatorView) + XCTAssertTrue(launcher.isHidden) + XCTAssertTrue(loading.isAnimating) + XCTAssertEqual(controller.server, saved) + let webview = try XCTUnwrap(controller.workspace) + XCTAssertFalse(webview.canGoBack) + XCTAssertTrue(webview.backForwardList.backList.isEmpty) + webview.stopLoading() + let navigation = try XCTUnwrap(webview.loadSimulatedRequest(URLRequest(url: saved.dashboardURL), responseHTML: "")) + controller.webView(webview, didStartProvisionalNavigation: navigation) + XCTAssertTrue(launcher.isHidden) + controller.webView(webview, didFailProvisionalNavigation: navigation, + withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)) + XCTAssertFalse(launcher.isHidden) + XCTAssertFalse(loading.isAnimating) + XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: XCTUnwrap(defaults.data(forKey: "ios.activeServer"))), saved) + controller.webView(webview, didFinish: navigation) + XCTAssertFalse(launcher.isHidden, "A late finish must not hide recovery after a failure") + controller.open(saved.url) + XCTAssertTrue(launcher.isHidden) + XCTAssertTrue(loading.isAnimating) + XCTAssertEqual(controller.server?.profile, saved.profile) + } + + @MainActor + func testMissingOrInvalidConfigurationShowsSetupAndServerChangesAreRestored() throws { + let name = "KAN69-setup-\(UUID())" + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + for data in [nil, Data("invalid".utf8), try JSONEncoder().encode(WorkspaceServer(url: URL(string: "http://example.com/")!, profile: UUID()))] { + defaults.set(data, forKey: "ios.activeServer") + let controller = WorkspaceViewController(defaults: defaults) + controller.loadViewIfNeeded() + XCTAssertNil(controller.workspace) + let launcher = try XCTUnwrap(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }) + XCTAssertFalse(launcher.isHidden) + } + let controller = WorkspaceViewController(defaults: defaults) + controller.loadViewIfNeeded() + controller.open(URL(string: "https://first.invalid/")!) + controller.open(URL(string: "https://second.invalid/app/")!) + let restored = WorkspaceViewController(defaults: defaults) + restored.loadViewIfNeeded() + XCTAssertEqual(restored.server, controller.server) + XCTAssertEqual(restored.server?.dashboardURL.absoluteString, "https://second.invalid/app/dashboard") + XCTAssertTrue(restored.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }?.isHidden == true) + } + func testExternalLinksStayOutsideTheSelectedServer() throws { let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID()) for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] { diff --git a/native/src-tauri/gen/apple/UITests/WorkspaceUITests.swift b/native/src-tauri/gen/apple/UITests/WorkspaceUITests.swift index ace0c39..ad31181 100644 --- a/native/src-tauri/gen/apple/UITests/WorkspaceUITests.swift +++ b/native/src-tauri/gen/apple/UITests/WorkspaceUITests.swift @@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase { XCTAssertTrue(element.isHittable) } + @MainActor + func testSavedWorkspaceColdStartNeverShowsSelectionBeforeRecovery() { + continueAfterFailure = false + XCUIDevice.shared.orientation = .portrait + defer { XCUIDevice.shared.orientation = .portrait } + let app = XCUIApplication() + app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO" + app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable" + app.launch() + let settings = app.buttons["showServerSettings"] + if settings.waitForExistence(timeout: 3) { settings.tap() } + let address = app.textFields["serverAddress"] + XCTAssertTrue(address.waitForExistence(timeout: 20)) + address.tap() + if address.buttons.firstMatch.exists { address.buttons.firstMatch.tap() } + address.typeText("https://127.0.0.1:9/") + app.buttons["openWorkspace"].tap() + let confirmation = app.alerts.buttons["Switch server"] + if confirmation.waitForExistence(timeout: 1) { confirmation.tap() } + XCTAssertTrue(app.staticTexts["connectionStatus"].waitForExistence(timeout: 20)) + app.terminate() + app.launch() + // An unreachable saved server may fail immediately; only recovery may reveal setup. + for _ in 0..<30 { + if address.exists { + XCTAssertTrue(app.staticTexts["connectionStatus"].label.contains("server did not finish loading")) + break + } + XCTAssertFalse(app.staticTexts["Choose your workspace."].exists) + Thread.sleep(forTimeInterval: 0.1) + } + XCTAssertTrue(address.waitForExistence(timeout: 20)) + XCTAssertEqual(address.value as? String, "https://127.0.0.1:9/") + XCTAssertTrue(app.buttons["openWorkspace"].label.contains("Try again")) + } + @MainActor func testLauncherKeyboardValidationRotationAndAppearance() { continueAfterFailure = false XCUIDevice.shared.orientation = .portrait + defer { XCUIDevice.shared.orientation = .portrait } let app = XCUIApplication() + app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO" + app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable" app.launch() let settings = app.buttons["showServerSettings"] if settings.waitForExistence(timeout: 3) { settings.tap() } diff --git a/native/src-tauri/src/desktop.rs b/native/src-tauri/src/desktop.rs index 3a3ee98..393764b 100644 --- a/native/src-tauri/src/desktop.rs +++ b/native/src-tauri/src/desktop.rs @@ -169,58 +169,64 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> { let popup_server = server_url.clone(); let popup_handle = app.clone(); let popup_label = label.clone(); - let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url)) - .title(en::APP_NAME) - .inner_size(1280.0, 850.0) - .min_inner_size(375.0, 600.0) - .center() - .visible(false) - .data_store_identifier(server.profile) - .on_navigation(move |destination| { - if !allows_server_navigation(&server_url, destination) { - open_external(destination); - return false; - } - begin_navigation(&handle, &navigation_label); - true - }) - .on_new_window(move |destination, _| { - if allows_server_navigation(&popup_server, &destination) { - if let Some(window) = popup_handle.get_webview_window(&popup_label) { - let _ = window.navigate(destination); + let window = + WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url())) + .title(en::APP_NAME) + .inner_size(1280.0, 850.0) + .min_inner_size(375.0, 600.0) + .center() + .visible(false) + .data_store_identifier(server.profile) + .on_navigation(move |destination| { + if !allows_server_navigation(&server_url, destination) { + open_external(destination); + return false; } - } else { - open_external(&destination); - } - NewWindowResponse::Deny - }) - .on_page_load(|window, payload| { - if payload.event() != PageLoadEvent::Finished { - return; - } - let app = window.app_handle(); - let state = app.state::>(); - let should_show = { - let mut state = state.lock().unwrap(); - if state.label.as_deref() != Some(window.label()) { + begin_navigation(&handle, &navigation_label); + true + }) + .on_new_window(move |destination, _| { + if allows_server_navigation(&popup_server, &destination) { + if let Some(window) = popup_handle.get_webview_window(&popup_label) { + let _ = window.navigate(destination); + } + } else { + open_external(&destination); + } + NewWindowResponse::Deny + }) + .on_page_load(|window, payload| { + if payload.event() != PageLoadEvent::Finished { return; } - let first_load = !state.ready; - state.ready = true; - state.phase = "connected"; - first_load - }; - update_launcher(app); - if should_show { - let _ = window.show(); - let _ = window.set_focus(); - if let Some(launcher) = app.get_webview_window("launcher") { - let _ = launcher.hide(); + let app = window.app_handle(); + let state = app.state::>(); + let should_show = { + let mut state = state.lock().unwrap(); + if state.label.as_deref() != Some(window.label()) + || state.phase != "connecting" + || !state.server.as_ref().is_some_and(|server| { + allows_server_navigation(&server.url, payload.url()) + }) + { + return; + } + let first_load = !state.ready; + state.ready = true; + state.phase = "connected"; + first_load + }; + if should_show { + let _ = window.show(); + let _ = window.set_focus(); + if let Some(launcher) = app.get_webview_window("launcher") { + let _ = launcher.hide(); + } } - } - }) - .build() - .map_err(|_| "connectionError")?; + update_launcher(app); + }) + .build() + .map_err(|_| "connectionError")?; let handle = app.clone(); window.on_window_event(move |event| { if matches!(event, WindowEvent::Destroyed) { @@ -298,7 +304,8 @@ fn about_metadata(version: String) -> AboutMetadata<'static> { pub fn builder() -> tauri::Builder { tauri::Builder::default().setup(|app| { let (server, phase) = match Server::load(&configuration_path(app.handle())?) { - Ok(server) => (server, "idle"), + Ok(Some(server)) => (Some(server), "connecting"), + Ok(None) => (None, "idle"), Err(_) => (None, "storageError"), }; app.manage(Mutex::new(Connection { diff --git a/native/src-tauri/src/server.rs b/native/src-tauri/src/server.rs index 5961765..343a435 100644 --- a/native/src-tauri/src/server.rs +++ b/native/src-tauri/src/server.rs @@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result { } impl Server { + pub fn dashboard_url(&self) -> Url { + self.url + .join("dashboard") + .expect("Validated installation URL") + } + pub fn new(url: Url) -> io::Result { let mut profile = [0; 16]; fs::File::open("/dev/urandom")?.read_exact(&mut profile)?; @@ -111,6 +117,21 @@ mod tests { } } + #[test] + fn startup_targets_dashboard_within_the_installation() { + for (base, expected) in [ + ("https://example.com/", "https://example.com/dashboard"), + ( + "https://example.com:8443/kanban/", + "https://example.com:8443/kanban/dashboard", + ), + ] { + let server = Server::new(validate_url(base).unwrap()).unwrap(); + assert_eq!(server.dashboard_url().as_str(), expected); + assert_eq!(server.url.as_str(), base); + } + } + #[test] fn configuration_round_trip_preserves_only_the_address_and_profile() { let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap(); diff --git a/native/src-tauri/tests/macos_smoke.rs b/native/src-tauri/tests/macos_smoke.rs index 135a399..18aebe0 100644 --- a/native/src-tauri/tests/macos_smoke.rs +++ b/native/src-tauri/tests/macos_smoke.rs @@ -11,9 +11,26 @@ fn main() { let page_stage = stage.clone(); let result_passed = passed.clone(); let plugin = tauri::plugin::Builder::::new("macos-smoke") + .setup(|app, _| { + let directory = app.path().app_config_dir()?; + std::fs::create_dir_all(&directory)?; + std::fs::write(directory.join("active-server"), "https://127.0.0.1:9/\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/__native_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; } @@ -28,7 +45,9 @@ fn main() { throw new Error('Timed out waiting for local state'); }; try { - await wait(() => document.querySelector('.open-workspace')?.textContent === 'Open workspace'); + 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'); @@ -76,7 +95,7 @@ fn main() { 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'; + const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok; })(); "#).unwrap(); @@ -90,6 +109,7 @@ fn main() { }) .on_navigation(move |webview, url| { + eprintln!("Smoke navigation: {} {}", webview.label(), url); if url.path() != "/__native_smoke_result" { return true; } diff --git a/native/tests/launcher-browser.mjs b/native/tests/launcher-browser.mjs index 7bf7655..6c9e20c 100644 --- a/native/tests/launcher-browser.mjs +++ b/native/tests/launcher-browser.mjs @@ -62,6 +62,12 @@ const latestAction = async (name) => { }; try { await page.goto(`http://127.0.0.1:${server.address().port}/`); + assert.equal(await page.locator('#workspace-settings').isVisible(), false); + await state('connecting'); + await page.waitForTimeout(500); + assert.equal(await page.locator('#workspace-settings').isVisible(), false); + assert.equal(await page.locator('#startup-loading').isVisible(), true); + await page.reload(); await state('idle'); for (const width of [320, 390, 1440]) { await page.setViewportSize({ width, height: 900 }); @@ -98,12 +104,7 @@ try { 'https://first.example.com/', ); await state('connecting', false, 'https://first.example.com/'); - assert.equal( - await page - .getByRole('button', { name: 'Open workspace', exact: true }) - .isDisabled(), - true, - ); + assert.equal(await page.locator('.open-workspace').isDisabled(), true); await state('connectionError', false, 'https://first.example.com/'); assert.equal( await page.getByLabel('Server address').inputValue(), diff --git a/native/tests/launcher.test.js b/native/tests/launcher.test.js index 16e5adf..230f1d7 100644 --- a/native/tests/launcher.test.js +++ b/native/tests/launcher.test.js @@ -6,6 +6,11 @@ import { normalizeServer, needsConfirmation } from '../ui/server.js'; const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8'); +test('workspace selection is hidden before native configuration is available', () => { + assert.match(html, /
/); + assert.match(html, /id="startup-loading" role="status"/); +}); + test('every launcher text resolves to a nonempty English translation', () => { for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) { assert.equal(typeof en[key], 'string', key); diff --git a/native/tests/mobile-launcher.test.js b/native/tests/mobile-launcher.test.js index 9cd1a9d..f3ca1b2 100644 --- a/native/tests/mobile-launcher.test.js +++ b/native/tests/mobile-launcher.test.js @@ -47,6 +47,18 @@ test('Android launcher sends only explicit local actions and preserves switch co try { await import('../ui/main.js'); assert.equal(messages.shift().pathname, '/action/ready'); + assert.equal(element('#workspace-settings').hidden, true); + window.updateLauncher( + 'server=https%3A%2F%2Fexample.com%2F&phase=connecting&ready=false', + ); + assert.equal(element('#workspace-settings').hidden, true); + assert.equal(element('#startup-loading').hidden, false); + window.updateLauncher( + 'server=https%3A%2F%2Fexample.com%2F&phase=connectionError&ready=false', + ); + assert.equal(element('#workspace-settings').hidden, false); + assert.equal(element('#startup-loading').hidden, true); + assert.equal(element('.open-workspace').textContent, 'Try again'); window.updateLauncher( 'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android', ); diff --git a/native/ui/index.html b/native/ui/index.html index 80e779c..7f9d86f 100644 --- a/native/ui/index.html +++ b/native/ui/index.html @@ -9,7 +9,8 @@ -
+

+

diff --git a/native/ui/main.js b/native/ui/main.js index 7a20f60..13345a8 100644 --- a/native/ui/main.js +++ b/native/ui/main.js @@ -31,6 +31,9 @@ window.updateLauncher = (query) => { const state = new URLSearchParams(query); const phase = state.get('phase'); const server = state.get('server'); + const loading = phase === 'connecting' && state.get('ready') !== 'true'; + document.querySelector('#startup-loading').hidden = !loading; + document.querySelector('#workspace-settings').hidden = loading; if (!initialized) { input.value = server; initialized = true; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index bd7a0ae..dc8f5b8 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -117,6 +117,25 @@ class AuthenticationTest extends TestCase $this->assertAuthenticatedAs($user); } + public function test_workspace_start_without_a_valid_session_returns_to_dashboard_after_login(): void + { + $user = User::factory()->create(); + + $this->get(route('dashboard')) + ->assertRedirect(route('login')) + ->assertSessionHas('url.intended', route('dashboard')); + + $this->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); + $this->get(route('dashboard')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Dashboard')); + } + public function test_users_are_rate_limited() { $user = User::factory()->create();