CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m2s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m38s
CI/CD / test_and_check_app (pull_request) Successful in 6m47s
CI/CD / Publish immutable image (pull_request) Skipped
323 lines
19 KiB
Swift
323 lines
19 KiB
Swift
import XCTest
|
|
import WebKit
|
|
|
|
final class WorkspaceTests: XCTestCase {
|
|
func testServerValidationAndOriginBoundary() throws {
|
|
let url = try XCTUnwrap(WorkspaceServer.normalize(" HTTPS://Example.com:443/kanban "))
|
|
XCTAssertEqual(url.absoluteString, "https://example.com/kanban/")
|
|
let server = WorkspaceServer(url: url, profile: UUID())
|
|
for value in ["http://example.com/", "https://user:secret@example.com/", "https://example.com/?token=1", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\evil", "file:///etc/passwd", "javascript:alert(1)", "https://example.com:99999/"] {
|
|
XCTAssertNil(WorkspaceServer.normalize(value), value)
|
|
}
|
|
for value in ["https://example.com/login", "https://example.com/two-factor-challenge", "https://example.com/projects/1?ticket=54#details"] {
|
|
XCTAssertTrue(server.allows(try XCTUnwrap(URL(string: value))), value)
|
|
}
|
|
for value in ["http://example.com/", "https://example.com:8443/", "https://evil.example.com/", "https://user@example.com/", "file:///tmp/index.html", "tauri://localhost/action/open"] {
|
|
XCTAssertFalse(server.allows(try XCTUnwrap(URL(string: value))), value)
|
|
}
|
|
}
|
|
|
|
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"] {
|
|
let url = try XCTUnwrap(URL(string: value))
|
|
XCTAssertTrue(WorkspaceServer.allowsExternal(url))
|
|
XCTAssertFalse(server.allows(url))
|
|
}
|
|
for value in ["https://user:secret@git.example.com/", "https://example.com:99999/", "javascript:alert(1)", "file:///etc/passwd", "tauri://localhost/action/open", "mailto:user@example.com", "data:text/html,test"] {
|
|
XCTAssertFalse(WorkspaceServer.allowsExternal(try XCTUnwrap(URL(string: value))), value)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
|
let name = "KAN71-history-\(UUID())"
|
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
|
defer { defaults.removePersistentDomain(forName: name) }
|
|
let controller = WorkspaceViewController(defaults: defaults)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
let url = URL(string: "https://example.invalid/dashboard")!
|
|
controller.open(URL(string: "https://example.invalid/")!)
|
|
let webview = try XCTUnwrap(controller.workspace)
|
|
webview.stopLoading()
|
|
webview.frame = controller.view.bounds
|
|
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
|
|
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
<body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
|
|
""")
|
|
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
|
XCTAssertFalse(webview.canGoBack)
|
|
XCTAssertNil(webview.goBack())
|
|
XCTAssertNil(webview.goForward())
|
|
try await waitFor(webview, "location.pathname === '/dashboard'")
|
|
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
|
|
XCTAssertFalse(webview.scrollView.bounces)
|
|
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
|
|
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
|
|
XCTAssertEqual(customNavigation as? String, "undefined")
|
|
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
|
XCTAssertEqual(draft as? String, "unsaved")
|
|
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
|
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
|
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
|
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
|
|
XCTAssertEqual(scroll as? String, "none")
|
|
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
|
XCTAssertEqual(horizontal as? String, "auto")
|
|
controller.open(URL(string: "https://other.invalid/")!)
|
|
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
|
|
"Changing workspaces must retain native gesture navigation")
|
|
}
|
|
|
|
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
|
for body in [
|
|
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
|
#"{"version":true,"id":1,"action":"getState","payload":{}}"#,
|
|
#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
|
|
#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
|
|
#"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
|
|
#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
|
] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
|
|
XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
|
|
XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
|
|
}
|
|
|
|
@MainActor
|
|
func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
|
|
let name = "KAN70-settings-\(UUID())"
|
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
|
defer { defaults.removePersistentDomain(forName: name) }
|
|
let controller = WorkspaceViewController(defaults: defaults)
|
|
controller.loadViewIfNeeded()
|
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
|
let url = URL(string: "https://example.invalid/")!
|
|
controller.open(url)
|
|
let originalProfile = controller.server!.profile
|
|
let webview = try XCTUnwrap(controller.workspace)
|
|
webview.stopLoading()
|
|
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
|
|
try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
|
|
let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
|
|
XCTAssertEqual(state?["platform"] as? String, "ios")
|
|
XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
|
|
XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
|
|
XCTAssertNil(state?["updates"])
|
|
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
|
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
|
|
let rejected = try await callScript(webview, """
|
|
try {
|
|
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
|
|
return false;
|
|
} catch { return true; }
|
|
""")
|
|
XCTAssertEqual(rejected as? Bool, true)
|
|
XCTAssertFalse(toolbar.isHidden)
|
|
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
|
|
controller.view.layoutIfNeeded()
|
|
XCTAssertTrue(toolbar.isHidden)
|
|
XCTAssertEqual(toolbar.frame.height, 0)
|
|
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
|
|
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
|
|
XCTAssertEqual(invalid as? Bool, true)
|
|
XCTAssertEqual(controller.server?.url, url)
|
|
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
|
|
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
|
|
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
|
|
for _ in 0..<50 {
|
|
if controller.workspace !== webview { break }
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
XCTAssertFalse(controller.workspace === webview)
|
|
XCTAssertNotEqual(controller.server?.profile, originalProfile)
|
|
let restored = WorkspaceViewController(defaults: defaults)
|
|
restored.loadViewIfNeeded()
|
|
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
|
|
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
|
|
XCTAssertEqual(stale as? Bool, true)
|
|
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
|
|
}
|
|
|
|
@MainActor
|
|
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
|
for _ in 0..<100 {
|
|
if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return }
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
}
|
|
XCTFail("WebView condition timed out: \(expression)")
|
|
}
|
|
|
|
func testServerChangesNeverReusePreviousProfile() throws {
|
|
let firstURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/"))
|
|
let otherURL = try XCTUnwrap(WorkspaceServer.normalize("https://example.com/other/"))
|
|
let first = WorkspaceServer.selecting(firstURL, current: nil)
|
|
XCTAssertEqual(WorkspaceServer.selecting(firstURL, current: first), first)
|
|
let second = WorkspaceServer.selecting(otherURL, current: first)
|
|
XCTAssertNotEqual(second.profile, first.profile)
|
|
XCTAssertNotEqual(WorkspaceServer.selecting(firstURL, current: second).profile, first.profile)
|
|
XCTAssertEqual(try JSONDecoder().decode(WorkspaceServer.self, from: JSONEncoder().encode(first)), first)
|
|
}
|
|
|
|
func testTimeoutDoesNotDiscardLoadedWorkspaceOrAffectLaterNavigation() {
|
|
var state = WorkspaceConnection()
|
|
let first = state.begin()
|
|
let second = state.begin()
|
|
XCTAssertFalse(state.fail(first))
|
|
state.finish()
|
|
XCTAssertFalse(state.fail(second))
|
|
let third = state.begin()
|
|
XCTAssertTrue(state.fail(third))
|
|
XCTAssertTrue(state.ready)
|
|
XCTAssertFalse(state.fail(third))
|
|
}
|
|
|
|
@MainActor
|
|
func testWKWebViewPersistsCookiesAcrossViewsAndIsolatesServerChanges() async throws {
|
|
let profile = UUID()
|
|
let first = WKWebViewConfiguration()
|
|
first.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
|
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
|
.domain: "example.com", .path: "/", .name: "laravel_session", .value: "test-session",
|
|
.secure: "TRUE", .expires: Date().addingTimeInterval(3600),
|
|
]))
|
|
await first.websiteDataStore.httpCookieStore.setCookie(cookie)
|
|
let restored = WKWebViewConfiguration()
|
|
restored.websiteDataStore = WKWebsiteDataStore(forIdentifier: profile)
|
|
let restoredCookies = await restored.websiteDataStore.httpCookieStore.allCookies()
|
|
XCTAssertTrue(restoredCookies.contains { $0.name == "laravel_session" && $0.value == "test-session" })
|
|
let other = WKWebsiteDataStore(forIdentifier: UUID())
|
|
let otherCookies = await other.httpCookieStore.allCookies()
|
|
XCTAssertTrue(otherCookies.isEmpty)
|
|
await restored.websiteDataStore.httpCookieStore.deleteCookie(cookie)
|
|
let loggedOut = await first.websiteDataStore.httpCookieStore.allCookies()
|
|
XCTAssertFalse(loggedOut.contains { $0.name == "laravel_session" })
|
|
}
|
|
|
|
@MainActor
|
|
func testControllerRestoresServerAndCreatesIsolatedUnprivilegedWebView() throws {
|
|
let name = "KAN54-tests-\(UUID())"
|
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
|
defer { defaults.removePersistentDomain(forName: name) }
|
|
let controller = WorkspaceViewController(defaults: defaults)
|
|
controller.loadViewIfNeeded()
|
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
|
controller.open(url)
|
|
let server = try XCTUnwrap(controller.server)
|
|
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
|
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
|
|
let restored = WorkspaceViewController(defaults: defaults)
|
|
restored.loadViewIfNeeded()
|
|
XCTAssertEqual(restored.server, server)
|
|
let other = try XCTUnwrap(WorkspaceServer.normalize("https://other.invalid/"))
|
|
restored.open(other)
|
|
XCTAssertNotEqual(restored.server?.profile, server.profile)
|
|
}
|
|
@MainActor
|
|
func testConnectionFailureKeepsLoadedFormAndAllowsResume() async throws {
|
|
let name = "KAN54-form-\(UUID())"
|
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
|
defer { defaults.removePersistentDomain(forName: name) }
|
|
let controller = WorkspaceViewController(defaults: defaults)
|
|
controller.loadViewIfNeeded()
|
|
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
|
controller.open(url)
|
|
let webview = try XCTUnwrap(controller.workspace)
|
|
webview.stopLoading()
|
|
let navigation = try XCTUnwrap(webview.loadHTMLString("<html><body><input id='draft' value='unsaved ticket'></body></html>", baseURL: url))
|
|
var loaded = false
|
|
for _ in 0..<50 {
|
|
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
|
value as? String == "unsaved ticket" { loaded = true; break }
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
XCTAssertTrue(loaded)
|
|
controller.webView(webview, didFinish: navigation)
|
|
controller.webView(webview, didStartProvisionalNavigation: navigation)
|
|
controller.webView(webview, didFailProvisionalNavigation: navigation,
|
|
withError: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))
|
|
XCTAssertTrue(webview.isHidden)
|
|
controller.open(url)
|
|
XCTAssertTrue(controller.workspace === webview)
|
|
XCTAssertFalse(webview.isHidden)
|
|
let value = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
|
XCTAssertEqual(value as? String, "unsaved ticket")
|
|
let bridge = try await webview.evaluateJavaScript("typeof window.__TAURI_INTERNALS__")
|
|
XCTAssertEqual(bridge as? String, "undefined")
|
|
}
|
|
|
|
}
|