refactor(webchat): SwiftUI-only WebChat UI

# Conflicts:
#	apps/macos/Package.swift
This commit is contained in:
Peter Steinberger
2025-12-17 23:05:28 +01:00
parent ca85d217ec
commit 875cf9a054
7452 changed files with 218086 additions and 776630 deletions

View File

@@ -0,0 +1,24 @@
import Testing
@testable import Clawdis
@Suite struct GatewayAgentChannelTests {
@Test func shouldDeliverBlocksWebChat() {
#expect(GatewayAgentChannel.webchat.shouldDeliver(true) == false)
#expect(GatewayAgentChannel.webchat.shouldDeliver(false) == false)
}
@Test func shouldDeliverAllowsLastAndProviderChannels() {
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
@Test func initRawNormalizesAndFallsBackToLast() {
#expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "unknown") == .last)
}
}

View File

@@ -5,13 +5,13 @@ import Testing
import Darwin
import Foundation
@Suite struct WebChatTunnelTests {
@Suite struct RemotePortTunnelTests {
@Test func drainStderrDoesNotCrashWhenHandleClosed() {
let pipe = Pipe()
let handle = pipe.fileHandleForReading
try? handle.close()
let drained = WebChatTunnel._testDrainStderr(handle)
let drained = RemotePortTunnel._testDrainStderr(handle)
#expect(drained.isEmpty)
}
@@ -52,7 +52,7 @@ import Foundation
guard got == 0 else { return }
let port = UInt16(bigEndian: name.sin_port)
#expect(WebChatTunnel._testPortIsFree(port) == false)
#expect(RemotePortTunnel._testPortIsFree(port) == false)
_ = Darwin.close(fd)
fd = -1
@@ -62,7 +62,7 @@ import Foundation
let deadline = Date().addingTimeInterval(0.5)
var free = false
while Date() < deadline {
if WebChatTunnel._testPortIsFree(port) {
if RemotePortTunnel._testPortIsFree(port) {
free = true
break
}

View File

@@ -0,0 +1,10 @@
import Testing
@testable import Clawdis
@Suite(.serialized)
@MainActor
struct WebChatManagerTests {
@Test func preferredSessionKeyIsMain() {
#expect(WebChatManager.shared.preferredSessionKey() == "main")
}
}

View File

@@ -1,112 +0,0 @@
import Foundation
import Testing
@testable import Clawdis
@Suite(.serialized)
struct WebChatServerTests {
private func waitForBaseURL(server: WebChatServer, timeoutSeconds: TimeInterval = 2.0) async throws -> URL {
let deadline = Date().addingTimeInterval(timeoutSeconds)
while Date() < deadline {
if let url = server.baseURL() { return url }
try await Task.sleep(nanoseconds: 25_000_000) // 25ms
}
throw NSError(domain: "WebChatServerTests", code: 1, userInfo: [
NSLocalizedDescriptionKey: "server did not become ready",
])
}
private func request(_ method: String, url: URL) async throws -> (status: Int, data: Data, headers: [AnyHashable: Any]) {
var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 2)
req.httpMethod = method
let config = URLSessionConfiguration.ephemeral
config.waitsForConnectivity = false
let session = URLSession(configuration: config)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw NSError(domain: "WebChatServerTests", code: 2, userInfo: [
NSLocalizedDescriptionKey: "expected HTTPURLResponse",
])
}
return (status: http.statusCode, data: data, headers: http.allHeaderFields)
}
@Test func servesIndexAtWebChatRoot() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("<html>ok</html>".utf8).write(to: root.appendingPathComponent("index.html"))
let server = WebChatServer()
server.start(root: root, preferredPort: nil)
defer { server.stop() }
let base = try await waitForBaseURL(server: server)
let res = try await request("GET", url: base)
#expect(res.status == 200)
#expect(String(data: res.data, encoding: .utf8)?.contains("ok") == true)
}
@Test func headOmitsBody() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("hello".utf8).write(to: root.appendingPathComponent("asset.txt"))
let server = WebChatServer()
server.start(root: root, preferredPort: nil)
defer { server.stop() }
let base = try await waitForBaseURL(server: server)
let url = URL(string: "asset.txt", relativeTo: base)!
let head = try await request("HEAD", url: url)
#expect(head.status == 200)
#expect(head.data.isEmpty == true)
#expect((head.headers["Content-Length"] as? String) == "5")
}
@Test func returns404ForMissing() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("<html>ok</html>".utf8).write(to: root.appendingPathComponent("index.html"))
let server = WebChatServer()
server.start(root: root, preferredPort: nil)
defer { server.stop() }
let base = try await waitForBaseURL(server: server)
let url = URL(string: "missing.txt", relativeTo: base)!
let res = try await request("GET", url: url)
#expect(res.status == 404)
}
@Test func forbidsTraversalOutsideRoot() async throws {
let tmp = FileManager.default.temporaryDirectory
let root = tmp.appendingPathComponent("clawdis-webchat-test-root-\(UUID().uuidString)")
let outside = tmp.appendingPathComponent("clawdis-webchat-test-outside-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: root)
try? FileManager.default.removeItem(at: outside)
}
try Data("<html>ok</html>".utf8).write(to: root.appendingPathComponent("index.html"))
try Data("secret".utf8).write(to: outside.appendingPathComponent("secret.txt"))
let server = WebChatServer()
server.start(root: root, preferredPort: nil)
defer { server.stop() }
let base = try await waitForBaseURL(server: server)
// Avoid `URL` normalizing away the `/webchat/../` segment by setting the encoded path directly.
var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)!
comps.percentEncodedPath = "/webchat/../\(outside.lastPathComponent)/secret.txt"
let url = comps.url!
let res = try await request("GET", url: url)
#expect(res.status == 403)
}
}

View File

@@ -1,133 +0,0 @@
import AppKit
import Foundation
import Testing
@testable import Clawdis
@Suite(.serialized)
@MainActor
struct WebChatWindowSmokeTests {
private struct DefaultsSnapshot {
var connectionMode: Any?
var webChatPort: Any?
var webChatEnabled: Any?
var webChatSwiftUIEnabled: Any?
init() {
let d = UserDefaults.standard
self.connectionMode = d.object(forKey: connectionModeKey)
self.webChatPort = d.object(forKey: webChatPortKey)
self.webChatEnabled = d.object(forKey: webChatEnabledKey)
self.webChatSwiftUIEnabled = d.object(forKey: webChatSwiftUIEnabledKey)
}
func restore() {
let d = UserDefaults.standard
if let connectionMode { d.set(connectionMode, forKey: connectionModeKey) } else { d.removeObject(forKey: connectionModeKey) }
if let webChatPort { d.set(webChatPort, forKey: webChatPortKey) } else { d.removeObject(forKey: webChatPortKey) }
if let webChatEnabled { d.set(webChatEnabled, forKey: webChatEnabledKey) } else { d.removeObject(forKey: webChatEnabledKey) }
if let webChatSwiftUIEnabled { d.set(webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } else { d.removeObject(forKey: webChatSwiftUIEnabledKey) }
}
}
private func waitForBaseURL(server: WebChatServer, timeoutSeconds: TimeInterval = 2.0) async throws -> URL {
let deadline = Date().addingTimeInterval(timeoutSeconds)
while Date() < deadline {
if let url = server.baseURL() { return url }
try await Task.sleep(nanoseconds: 25_000_000) // 25ms
}
throw NSError(domain: "WebChatWindowSmokeTests", code: 1, userInfo: [
NSLocalizedDescriptionKey: "server did not become ready",
])
}
private func makeLocalHTTPServerWithIndex(booted: Bool) async throws -> (server: WebChatServer, port: Int, root: URL) {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-webchat-win-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let html = booted
? "<html><body><div id='app' data-booted='1'></div></body></html>"
: "<html><body><div id='app'></div></body></html>"
try Data(html.utf8).write(to: root.appendingPathComponent("index.html"))
let server = WebChatServer()
server.start(root: root, preferredPort: nil)
let base = try await waitForBaseURL(server: server)
guard let port = base.port else {
throw NSError(domain: "WebChatWindowSmokeTests", code: 2, userInfo: [
NSLocalizedDescriptionKey: "server baseURL missing port",
])
}
return (server: server, port: port, root: root)
}
@Test func windowControllerBootstrapsInLocalModeWhenReachable() async throws {
let snapshot = DefaultsSnapshot()
defer { snapshot.restore() }
let serverInfo = try await makeLocalHTTPServerWithIndex(booted: true)
defer {
serverInfo.server.stop()
try? FileManager.default.removeItem(at: serverInfo.root)
}
let d = UserDefaults.standard
d.set("local", forKey: connectionModeKey)
d.set(true, forKey: webChatEnabledKey)
d.set(serverInfo.port, forKey: webChatPortKey)
d.set(false, forKey: webChatSwiftUIEnabledKey)
let controller = WebChatWindowController(sessionKey: "main", presentation: .window)
try await Task.sleep(nanoseconds: 150_000_000) // allow bootstrap + reachability
controller.shutdown()
controller.close()
}
@Test func panelControllerCanPresentAndDismiss() async throws {
let snapshot = DefaultsSnapshot()
defer { snapshot.restore() }
let serverInfo = try await makeLocalHTTPServerWithIndex(booted: true)
defer {
serverInfo.server.stop()
try? FileManager.default.removeItem(at: serverInfo.root)
}
let d = UserDefaults.standard
d.set("local", forKey: connectionModeKey)
d.set(true, forKey: webChatEnabledKey)
d.set(serverInfo.port, forKey: webChatPortKey)
let controller = WebChatWindowController(
sessionKey: "main",
presentation: .panel(anchorProvider: { NSRect(x: 200, y: 400, width: 40, height: 40) }))
controller.presentAnchoredPanel(anchorProvider: { NSRect(x: 200, y: 400, width: 40, height: 40) })
controller.windowDidResignKey(Notification(name: NSWindow.didResignKeyNotification))
controller.windowWillClose(Notification(name: NSWindow.willCloseNotification))
controller.shutdown()
controller.close()
}
@Test func managerShowAndTogglePanelDoNotCrash() async throws {
let snapshot = DefaultsSnapshot()
defer { snapshot.restore() }
let serverInfo = try await makeLocalHTTPServerWithIndex(booted: true)
defer {
serverInfo.server.stop()
try? FileManager.default.removeItem(at: serverInfo.root)
}
let d = UserDefaults.standard
d.set("local", forKey: connectionModeKey)
d.set(true, forKey: webChatEnabledKey)
d.set(false, forKey: webChatSwiftUIEnabledKey)
d.set(serverInfo.port, forKey: webChatPortKey)
WebChatManager.shared.resetTunnels()
WebChatManager.shared.show(sessionKey: "main")
WebChatManager.shared.togglePanel(sessionKey: "main", anchorProvider: { NSRect(x: 220, y: 380, width: 20, height: 20) })
WebChatManager.shared.togglePanel(sessionKey: "main", anchorProvider: { NSRect(x: 220, y: 380, width: 20, height: 20) })
WebChatManager.shared.close()
}
}