feat(KAN-69): open saved native workspace directly in dashboard
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m17s
CI/CD / test_and_check_app (pull_request) Successful in 6m24s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m27s

This commit is contained in:
2026-09-28 21:00:46 +02:00
parent 2a637affdf
commit 32a6756419
17 changed files with 339 additions and 67 deletions
@@ -5,6 +5,7 @@ import android.os.SystemClock
import android.view.InputDevice import android.view.InputDevice
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebView import android.webkit.WebView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -59,6 +60,59 @@ class PlatformTest {
instrumentation.waitForIdleSync() 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<WebView>()
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<WebView>()
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() { @Test fun workspacePreservesFormsAndIsolatesServers() {
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit() instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
val scenario = ActivityScenario.launch(MainActivity::class.java) val scenario = ActivityScenario.launch(MainActivity::class.java)
@@ -164,9 +164,9 @@ class MainActivity : TauriActivity() {
if (initialized) return if (initialized) return
initialized = true initialized = true
val saved = preferences.getString("server", null) val saved = preferences.getString("server", null)
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER val restored = saved?.let(NavigationPolicy::installation)
updateLauncher() server = restored ?: NavigationPolicy.DEFAULT_SERVER
if (saved != null) openWorkspace(server) if (restored != null) openWorkspace(restored) else updateLauncher()
} }
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty()) "/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
"/action/resume" -> if (ready) showWorkspace() "/action/resume" -> if (ready) showWorkspace()
@@ -313,8 +313,8 @@ class MainActivity : TauriActivity() {
val firstLoad = !ready val firstLoad = !ready
ready = true ready = true
CookieManager.getInstance().flush() CookieManager.getInstance().flush()
updateLauncher()
if (firstLoad) showWorkspace() if (firstLoad) showWorkspace()
updateLauncher()
} }
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
@@ -333,7 +333,7 @@ class MainActivity : TauriActivity() {
remote.visibility = View.GONE remote.visibility = View.GONE
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1)) content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
startTimeout(remote) startTimeout(remote)
remote.loadUrl(server) remote.loadUrl(NavigationPolicy.dashboard(server))
} }
private fun startTimeout(view: WebView) { private fun startTimeout(view: WebView) {
@@ -21,6 +21,8 @@ object NavigationPolicy {
"https://${uri.host.lowercase(Locale.ROOT)}$port$path" "https://${uri.host.lowercase(Locale.ROOT)}$port$path"
}.getOrNull() }.getOrNull()
fun dashboard(server: String): String = URI(requireNotNull(installation(server))).resolve("dashboard").toString()
fun allows(server: String, destination: String): Boolean = runCatching { fun allows(server: String, destination: String): Boolean = runCatching {
val base = URI(server) val base = URI(server)
val target = URI(destination) val target = URI(destination)
@@ -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() { @Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
val server = "https://example.com:8443/app/" val server = "https://example.com:8443/app/"
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) { for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
@@ -22,6 +22,8 @@ struct WorkspaceServer: Codable, Equatable {
return components.url return components.url
} }
var dashboardURL: URL { url.appendingPathComponent("dashboard") }
func allows(_ destination: URL) -> Bool { func allows(_ destination: URL) -> Bool {
destination.scheme?.lowercased() == "https" destination.scheme?.lowercased() == "https"
&& destination.host?.lowercased() == url.host?.lowercased() && destination.host?.lowercased() == url.host?.lowercased()
@@ -32,6 +32,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
private var connection = WorkspaceConnection() private var connection = WorkspaceConnection()
private var pendingNavigation: WKNavigation? private var pendingNavigation: WKNavigation?
private var timeout: DispatchWorkItem? private var timeout: DispatchWorkItem?
private let loading = UIActivityIndicatorView(style: .large)
private let launcher = UIScrollView() private let launcher = UIScrollView()
private let stack = UIStackView() private let stack = UIStackView()
private let address = UITextField() private let address = UITextField()
@@ -58,6 +59,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
view.tintColor = UIColor(red: 0, green: 0.43, blue: 0.18, alpha: 1) 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() configureLauncher()
configureToolbar() configureToolbar()
if let data = defaults.data(forKey: serverKey) { 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/" address.text = server?.url.absoluteString ?? "https://kanbangit.de/"
showSettings() if let server { open(server.url) } else { showSettings() }
if let server { open(server.url) }
} }
private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel { private func label(_ key: String, style: UIFont.TextStyle = .body) -> UILabel {
@@ -83,6 +91,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
private func configureLauncher() { private func configureLauncher() {
launcher.isHidden = true
launcher.accessibilityIdentifier = "serverSettings" launcher.accessibilityIdentifier = "serverSettings"
launcher.keyboardDismissMode = .interactive launcher.keyboardDismissMode = .interactive
launcher.alwaysBounceVertical = true launcher.alwaysBounceVertical = true
@@ -152,6 +161,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
private func configureToolbar() { private func configureToolbar() {
toolbar.isHidden = true
toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(toolbar) view.addSubview(toolbar)
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack)) 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) view.endEditing(true)
address.text = url.absoluteString address.text = url.absoluteString
launcher.isHidden = true
toolbar.isHidden = true
loading.startAnimating()
beginNavigation() beginNavigation()
pendingNavigation = webview.load(URLRequest(url: url)) pendingNavigation = webview.load(URLRequest(url: selected.dashboardURL))
} }
private func beginNavigation() { private func beginNavigation() {
@@ -247,6 +260,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
@objc private func showSettings() { @objc private func showSettings() {
loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = false launcher.isHidden = false
toolbar.isHidden = true toolbar.isHidden = true
@@ -256,6 +270,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
@objc private func showWorkspace() { @objc private func showWorkspace() {
guard connection.ready else { return } guard connection.ready else { return }
loading.stopAnimating()
view.endEditing(true) view.endEditing(true)
launcher.isHidden = true launcher.isHidden = true
toolbar.isHidden = false toolbar.isHidden = false
@@ -296,7 +311,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
} }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 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 let firstLoad = !connection.ready
timeout?.cancel() timeout?.cancel()
connection.finish() connection.finish()
@@ -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: "<html></html>"))
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 { func testExternalLinksStayOutsideTheSelectedServer() throws {
let server = WorkspaceServer(url: URL(string: "https://example.com/")!, profile: UUID()) 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"] { for value in ["https://git.example.com/org/repo/pulls/1#diff", "http://git.example.com/repo", "https://example.com:8443/other"] {
@@ -13,11 +13,50 @@ final class WorkspaceUITests: XCTestCase {
XCTAssertTrue(element.isHittable) 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 @MainActor
func testLauncherKeyboardValidationRotationAndAppearance() { func testLauncherKeyboardValidationRotationAndAppearance() {
continueAfterFailure = false continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait XCUIDevice.shared.orientation = .portrait
defer { XCUIDevice.shared.orientation = .portrait }
let app = XCUIApplication() let app = XCUIApplication()
app.launchEnvironment["OS_ACTIVITY_DT_MODE"] = "NO"
app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable"
app.launch() app.launch()
let settings = app.buttons["showServerSettings"] let settings = app.buttons["showServerSettings"]
if settings.waitForExistence(timeout: 3) { settings.tap() } if settings.waitForExistence(timeout: 3) { settings.tap() }
+56 -49
View File
@@ -169,58 +169,64 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
let popup_server = server_url.clone(); let popup_server = server_url.clone();
let popup_handle = app.clone(); let popup_handle = app.clone();
let popup_label = label.clone(); let popup_label = label.clone();
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.url)) let window =
.title(en::APP_NAME) WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
.inner_size(1280.0, 850.0) .title(en::APP_NAME)
.min_inner_size(375.0, 600.0) .inner_size(1280.0, 850.0)
.center() .min_inner_size(375.0, 600.0)
.visible(false) .center()
.data_store_identifier(server.profile) .visible(false)
.on_navigation(move |destination| { .data_store_identifier(server.profile)
if !allows_server_navigation(&server_url, destination) { .on_navigation(move |destination| {
open_external(destination); if !allows_server_navigation(&server_url, destination) {
return false; 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);
} }
} else { begin_navigation(&handle, &navigation_label);
open_external(&destination); true
} })
NewWindowResponse::Deny .on_new_window(move |destination, _| {
}) if allows_server_navigation(&popup_server, &destination) {
.on_page_load(|window, payload| { if let Some(window) = popup_handle.get_webview_window(&popup_label) {
if payload.event() != PageLoadEvent::Finished { let _ = window.navigate(destination);
return; }
} } else {
let app = window.app_handle(); open_external(&destination);
let state = app.state::<Mutex<Connection>>(); }
let should_show = { NewWindowResponse::Deny
let mut state = state.lock().unwrap(); })
if state.label.as_deref() != Some(window.label()) { .on_page_load(|window, payload| {
if payload.event() != PageLoadEvent::Finished {
return; return;
} }
let first_load = !state.ready; let app = window.app_handle();
state.ready = true; let state = app.state::<Mutex<Connection>>();
state.phase = "connected"; let should_show = {
first_load let mut state = state.lock().unwrap();
}; if state.label.as_deref() != Some(window.label())
update_launcher(app); || state.phase != "connecting"
if should_show { || !state.server.as_ref().is_some_and(|server| {
let _ = window.show(); allows_server_navigation(&server.url, payload.url())
let _ = window.set_focus(); })
if let Some(launcher) = app.get_webview_window("launcher") { {
let _ = launcher.hide(); 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();
}
} }
} update_launcher(app);
}) })
.build() .build()
.map_err(|_| "connectionError")?; .map_err(|_| "connectionError")?;
let handle = app.clone(); let handle = app.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if matches!(event, WindowEvent::Destroyed) { if matches!(event, WindowEvent::Destroyed) {
@@ -298,7 +304,8 @@ fn about_metadata(version: String) -> AboutMetadata<'static> {
pub fn builder() -> tauri::Builder<tauri::Wry> { pub fn builder() -> tauri::Builder<tauri::Wry> {
tauri::Builder::default().setup(|app| { tauri::Builder::default().setup(|app| {
let (server, phase) = match Server::load(&configuration_path(app.handle())?) { 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"), Err(_) => (None, "storageError"),
}; };
app.manage(Mutex::new(Connection { app.manage(Mutex::new(Connection {
+21
View File
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
} }
impl Server { impl Server {
pub fn dashboard_url(&self) -> Url {
self.url
.join("dashboard")
.expect("Validated installation URL")
}
pub fn new(url: Url) -> io::Result<Self> { pub fn new(url: Url) -> io::Result<Self> {
let mut profile = [0; 16]; let mut profile = [0; 16];
fs::File::open("/dev/urandom")?.read_exact(&mut profile)?; 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] #[test]
fn configuration_round_trip_preserves_only_the_address_and_profile() { fn configuration_round_trip_preserves_only_the_address_and_profile() {
let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap(); let server = Server::new(validate_url(DEFAULT_SERVER).unwrap()).unwrap();
+22 -2
View File
@@ -11,9 +11,26 @@ fn main() {
let page_stage = stage.clone(); let page_stage = stage.clone();
let result_passed = passed.clone(); let result_passed = passed.clone();
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke") let plugin = tauri::plugin::Builder::<tauri::Wry>::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);") .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| { .on_page_load(move |webview, payload| {
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
if payload.event() != PageLoadEvent::Finished { if payload.event() != PageLoadEvent::Finished {
return; return;
} }
@@ -28,7 +45,9 @@ fn main() {
throw new Error('Timed out waiting for local state'); throw new Error('Timed out waiting for local state');
}; };
try { 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 input = document.querySelector('#server-url');
const form = document.querySelector('#server-form'); const form = document.querySelector('#server-form');
const status = document.querySelector('#connection-status'); const status = document.querySelector('#connection-status');
@@ -76,7 +95,7 @@ fn main() {
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input); input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax'; document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
localStorage.setItem('kan52_fixture', 'isolated'); 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; location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
})(); })();
"#).unwrap(); "#).unwrap();
@@ -90,6 +109,7 @@ fn main() {
}) })
.on_navigation(move |webview, url| { .on_navigation(move |webview, url| {
eprintln!("Smoke navigation: {} {}", webview.label(), url);
if url.path() != "/__native_smoke_result" { if url.path() != "/__native_smoke_result" {
return true; return true;
} }
+7 -6
View File
@@ -62,6 +62,12 @@ const latestAction = async (name) => {
}; };
try { try {
await page.goto(`http://127.0.0.1:${server.address().port}/`); 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'); await state('idle');
for (const width of [320, 390, 1440]) { for (const width of [320, 390, 1440]) {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
@@ -98,12 +104,7 @@ try {
'https://first.example.com/', 'https://first.example.com/',
); );
await state('connecting', false, 'https://first.example.com/'); await state('connecting', false, 'https://first.example.com/');
assert.equal( assert.equal(await page.locator('.open-workspace').isDisabled(), true);
await page
.getByRole('button', { name: 'Open workspace', exact: true })
.isDisabled(),
true,
);
await state('connectionError', false, 'https://first.example.com/'); await state('connectionError', false, 'https://first.example.com/');
assert.equal( assert.equal(
await page.getByLabel('Server address').inputValue(), await page.getByLabel('Server address').inputValue(),
+5
View File
@@ -6,6 +6,11 @@ import { normalizeServer, needsConfirmation } from '../ui/server.js';
const html = readFileSync(new URL('../ui/index.html', import.meta.url), 'utf8'); 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, /<main id="workspace-settings" hidden>/);
assert.match(html, /id="startup-loading" role="status"/);
});
test('every launcher text resolves to a nonempty English translation', () => { test('every launcher text resolves to a nonempty English translation', () => {
for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) { for (const [, key] of html.matchAll(/data-i18n="([^"]+)"/g)) {
assert.equal(typeof en[key], 'string', key); assert.equal(typeof en[key], 'string', key);
+12
View File
@@ -47,6 +47,18 @@ test('Android launcher sends only explicit local actions and preserves switch co
try { try {
await import('../ui/main.js'); await import('../ui/main.js');
assert.equal(messages.shift().pathname, '/action/ready'); 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( window.updateLauncher(
'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android', 'server=https%3A%2F%2Fexample.com%2F&phase=idle&ready=false&platform=android',
); );
+2 -1
View File
@@ -9,7 +9,8 @@
<script type="module" src="./main.js"></script> <script type="module" src="./main.js"></script>
</head> </head>
<body> <body>
<main> <p id="startup-loading" role="status" aria-live="polite" data-i18n="connecting"></p>
<main id="workspace-settings" hidden>
<img class="brand" src="./mark.svg" alt="" width="48" height="48" /> <img class="brand" src="./mark.svg" alt="" width="48" height="48" />
<p class="eyebrow" data-i18n="appName"></p> <p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1> <h1 data-i18n="title"></h1>
+3
View File
@@ -31,6 +31,9 @@ window.updateLauncher = (query) => {
const state = new URLSearchParams(query); const state = new URLSearchParams(query);
const phase = state.get('phase'); const phase = state.get('phase');
const server = state.get('server'); 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) { if (!initialized) {
input.value = server; input.value = server;
initialized = true; initialized = true;
+19
View File
@@ -117,6 +117,25 @@ class AuthenticationTest extends TestCase
$this->assertAuthenticatedAs($user); $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() public function test_users_are_rate_limited()
{ {
$user = User::factory()->create(); $user = User::factory()->create();