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
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:
+54
@@ -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<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() {
|
||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+5
@@ -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")) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
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"] {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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::<Mutex<Connection>>();
|
||||
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::<Mutex<Connection>>();
|
||||
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::Wry> {
|
||||
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 {
|
||||
|
||||
@@ -37,6 +37,12 @@ pub fn validate_url(input: &str) -> Result<Url, &'static str> {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn dashboard_url(&self) -> Url {
|
||||
self.url
|
||||
.join("dashboard")
|
||||
.expect("Validated installation URL")
|
||||
}
|
||||
|
||||
pub fn new(url: Url) -> io::Result<Self> {
|
||||
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();
|
||||
|
||||
@@ -11,9 +11,26 @@ fn main() {
|
||||
let page_stage = stage.clone();
|
||||
let result_passed = passed.clone();
|
||||
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);")
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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, /<main id="workspace-settings" hidden>/);
|
||||
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);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
<script type="module" src="./main.js"></script>
|
||||
</head>
|
||||
<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" />
|
||||
<p class="eyebrow" data-i18n="appName"></p>
|
||||
<h1 data-i18n="title"></h1>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user