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: "")) 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 testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() 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: """

Workspace

""") try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'") XCTAssertFalse(webview.canGoBack) controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) try await waitFor(webview, "location.pathname === '/dashboard'") for index in 1...3 { _ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')") } _ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)") let edge = TestEdgeGesture() edge.edges = .left edge.testVelocity = CGPoint(x: 800, y: 0) XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch())) var allowed = false for _ in 0..<50 { if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break } try await Task.sleep(for: .milliseconds(20)) } XCTAssertTrue(allowed) edge.testState = .cancelled edge.testTranslation = CGPoint(x: 200, y: 0) controller.perform(NSSelectorFromString("navigateGesture:"), with: edge) try await Task.sleep(for: .milliseconds(100)) try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0") edge.testState = .ended edge.testTranslation = CGPoint(x: 10, y: 0) controller.perform(NSSelectorFromString("navigateGesture:"), with: edge) try await Task.sleep(for: .milliseconds(100)) try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0") edge.testTranslation = CGPoint(x: 200, y: 0) controller.perform(NSSelectorFromString("navigateGesture:"), with: edge) try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1") controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2") controller.navigateHistory(1, from: CGPoint(x: 1, y: 1)) try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3") let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value") XCTAssertEqual(draft as? String, "unsaved") XCTAssertFalse(webview.scrollView.bounces) XCTAssertFalse(webview.allowsBackForwardNavigationGestures) XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2) 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") _ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '
Dialog
')") controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1)) try await Task.sleep(for: .milliseconds(200)) try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3") } @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, 1) 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("", 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") } } private final class TestTouch: UITouch { override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) } } private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer { var testState: UIGestureRecognizer.State = .possible var testVelocity = CGPoint.zero var testTranslation = CGPoint.zero override var state: UIGestureRecognizer.State { get { testState } set { testState = newValue } } override func velocity(in view: UIView?) -> CGPoint { testVelocity } override func translation(in view: UIView?) -> CGPoint { testTranslation } }