KAN-69: Open saved native workspace directly in dashboard #166
+54
@@ -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) {
|
||||||
|
|||||||
+2
@@ -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)
|
||||||
|
|||||||
+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() {
|
@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() }
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ 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 =
|
||||||
|
WebviewWindowBuilder::new(app, &label, WebviewUrl::External(server.dashboard_url()))
|
||||||
.title(en::APP_NAME)
|
.title(en::APP_NAME)
|
||||||
.inner_size(1280.0, 850.0)
|
.inner_size(1280.0, 850.0)
|
||||||
.min_inner_size(375.0, 600.0)
|
.min_inner_size(375.0, 600.0)
|
||||||
@@ -202,7 +203,12 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let state = app.state::<Mutex<Connection>>();
|
let state = app.state::<Mutex<Connection>>();
|
||||||
let should_show = {
|
let should_show = {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
if state.label.as_deref() != Some(window.label()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let first_load = !state.ready;
|
let first_load = !state.ready;
|
||||||
@@ -210,7 +216,6 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
state.phase = "connected";
|
state.phase = "connected";
|
||||||
first_load
|
first_load
|
||||||
};
|
};
|
||||||
update_launcher(app);
|
|
||||||
if should_show {
|
if should_show {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
@@ -218,6 +223,7 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
let _ = launcher.hide();
|
let _ = launcher.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
update_launcher(app);
|
||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
.map_err(|_| "connectionError")?;
|
.map_err(|_| "connectionError")?;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user