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', '