Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import OpenClawProtocol
|
||||
|
||||
struct AnyCodableTests {
|
||||
@Test
|
||||
func encodesNSNumberBooleansAsJSONBooleans() throws {
|
||||
let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true)))
|
||||
let falseData = try JSONEncoder().encode(AnyCodable(NSNumber(value: false)))
|
||||
|
||||
#expect(String(data: trueData, encoding: .utf8) == "true")
|
||||
#expect(String(data: falseData, encoding: .utf8) == "false")
|
||||
}
|
||||
|
||||
@Test
|
||||
func preservesBooleanLiteralsFromJSONSerializationBridge() throws {
|
||||
let raw = try #require(
|
||||
JSONSerialization.jsonObject(with: Data(#"{"enabled":true,"nested":{"active":false}}"#.utf8))
|
||||
as? [String: Any]
|
||||
)
|
||||
let enabled = try #require(raw["enabled"])
|
||||
let nested = try #require(raw["nested"])
|
||||
|
||||
struct RequestEnvelope: Codable {
|
||||
let params: [String: AnyCodable]
|
||||
}
|
||||
|
||||
let envelope = RequestEnvelope(
|
||||
params: [
|
||||
"enabled": AnyCodable(enabled),
|
||||
"nested": AnyCodable(nested),
|
||||
]
|
||||
)
|
||||
let data = try JSONEncoder().encode(envelope)
|
||||
let json = try #require(String(data: data, encoding: .utf8))
|
||||
|
||||
#expect(json.contains(#""enabled":true"#))
|
||||
#expect(json.contains(#""active":false"#))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite struct AssistantTextParserTests {
|
||||
@Test func splitsThinkAndFinalSegments() {
|
||||
let segments = AssistantTextParser.segments(
|
||||
from: "<think>internal</think>\n\n<final>Hello there</final>")
|
||||
|
||||
#expect(segments.count == 2)
|
||||
#expect(segments[0].kind == .thinking)
|
||||
#expect(segments[0].text == "internal")
|
||||
#expect(segments[1].kind == .response)
|
||||
#expect(segments[1].text == "Hello there")
|
||||
}
|
||||
|
||||
@Test func keepsTextWithoutTags() {
|
||||
let segments = AssistantTextParser.segments(from: "Just text.")
|
||||
|
||||
#expect(segments.count == 1)
|
||||
#expect(segments[0].kind == .response)
|
||||
#expect(segments[0].text == "Just text.")
|
||||
}
|
||||
|
||||
@Test func ignoresThinkingLikeTags() {
|
||||
let raw = "<thinking>example</thinking>\nKeep this."
|
||||
let segments = AssistantTextParser.segments(from: raw)
|
||||
|
||||
#expect(segments.count == 1)
|
||||
#expect(segments[0].kind == .response)
|
||||
#expect(segments[0].text == raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
@Test func dropsEmptyTaggedContent() {
|
||||
let segments = AssistantTextParser.segments(from: "<think></think>")
|
||||
#expect(segments.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct BonjourEscapesTests {
|
||||
@Test func decodePassThrough() {
|
||||
#expect(BonjourEscapes.decode("hello") == "hello")
|
||||
#expect(BonjourEscapes.decode("") == "")
|
||||
}
|
||||
|
||||
@Test func decodeSpaces() {
|
||||
#expect(BonjourEscapes.decode("OpenClaw\\032Gateway") == "OpenClaw Gateway")
|
||||
}
|
||||
|
||||
@Test func decodeMultipleEscapes() {
|
||||
#expect(BonjourEscapes.decode("A\\038B\\047C\\032D") == "A&B/C D")
|
||||
}
|
||||
|
||||
@Test func decodeIgnoresInvalidEscapeSequences() {
|
||||
#expect(BonjourEscapes.decode("Hello\\03World") == "Hello\\03World")
|
||||
#expect(BonjourEscapes.decode("Hello\\XYZWorld") == "Hello\\XYZWorld")
|
||||
}
|
||||
|
||||
@Test func decodeUsesDecimalUnicodeScalarValue() {
|
||||
#expect(BonjourEscapes.decode("Hello\\065World") == "HelloAWorld")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasA2UIActionTests {
|
||||
@Test func sanitizeTagValueIsStable() {
|
||||
#expect(OpenClawCanvasA2UIAction.sanitizeTagValue("Hello World!") == "Hello_World_")
|
||||
#expect(OpenClawCanvasA2UIAction.sanitizeTagValue(" ") == "-")
|
||||
#expect(OpenClawCanvasA2UIAction.sanitizeTagValue("macOS 26.2") == "macOS_26.2")
|
||||
}
|
||||
|
||||
@Test func extractActionNameAcceptsNameOrAction() {
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["name": "Hello"]) == "Hello")
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["action": "Wave"]) == "Wave")
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["name": " ", "action": "Fallback"]) == "Fallback")
|
||||
#expect(OpenClawCanvasA2UIAction.extractActionName(["action": " "]) == nil)
|
||||
}
|
||||
|
||||
@Test func formatAgentMessageIsTokenEfficientAndUnambiguous() {
|
||||
let messageContext = OpenClawCanvasA2UIAction.AgentMessageContext(
|
||||
actionName: "Get Weather",
|
||||
session: .init(key: "main", surfaceId: "main"),
|
||||
component: .init(id: "btnWeather", host: "Peter’s iPad", instanceId: "ipad16,6"),
|
||||
contextJSON: "{\"city\":\"Vienna\"}")
|
||||
let msg = OpenClawCanvasA2UIAction.formatAgentMessage(messageContext)
|
||||
|
||||
#expect(msg.contains("CANVAS_A2UI "))
|
||||
#expect(msg.contains("action=Get_Weather"))
|
||||
#expect(msg.contains("session=main"))
|
||||
#expect(msg.contains("surface=main"))
|
||||
#expect(msg.contains("component=btnWeather"))
|
||||
#expect(msg.contains("host=Peter_s_iPad"))
|
||||
#expect(msg.contains("instance=ipad16_6 ctx={\"city\":\"Vienna\"}"))
|
||||
#expect(msg.hasSuffix(" default=update_canvas"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasA2UITests {
|
||||
@Test func commandStringsAreStable() {
|
||||
#expect(OpenClawCanvasA2UICommand.push.rawValue == "canvas.a2ui.push")
|
||||
#expect(OpenClawCanvasA2UICommand.pushJSONL.rawValue == "canvas.a2ui.pushJSONL")
|
||||
#expect(OpenClawCanvasA2UICommand.reset.rawValue == "canvas.a2ui.reset")
|
||||
}
|
||||
|
||||
@Test func jsonlDecodesAndValidatesV0_8() throws {
|
||||
let jsonl = """
|
||||
{"beginRendering":{"surfaceId":"main","timestamp":1}}
|
||||
{"surfaceUpdate":{"surfaceId":"main","ops":[]}}
|
||||
{"dataModelUpdate":{"dataModel":{"title":"Hello"}}}
|
||||
{"deleteSurface":{"surfaceId":"main"}}
|
||||
"""
|
||||
|
||||
let messages = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||
#expect(messages.count == 4)
|
||||
}
|
||||
|
||||
@Test func jsonlRejectsV0_9CreateSurface() {
|
||||
let jsonl = """
|
||||
{"createSurface":{"surfaceId":"main"}}
|
||||
"""
|
||||
|
||||
#expect(throws: Error.self) {
|
||||
_ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func jsonlRejectsUnknownShape() {
|
||||
let jsonl = """
|
||||
{"wat":{"nope":1}}
|
||||
"""
|
||||
|
||||
#expect(throws: Error.self) {
|
||||
_ = try OpenClawCanvasA2UIJSONL.decodeMessagesFromJSONL(jsonl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct CanvasSnapshotFormatTests {
|
||||
@Test func acceptsJpgAlias() throws {
|
||||
struct Wrapper: Codable {
|
||||
var format: OpenClawCanvasSnapshotFormat
|
||||
}
|
||||
|
||||
let data = try #require("{\"format\":\"jpg\"}".data(using: .utf8))
|
||||
let decoded = try JSONDecoder().decode(Wrapper.self, from: data)
|
||||
#expect(decoded.format == .jpeg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite("ChatMarkdownPreprocessor")
|
||||
struct ChatMarkdownPreprocessorTests {
|
||||
@Test func extractsDataURLImages() {
|
||||
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
|
||||
let markdown = """
|
||||
Hello
|
||||
|
||||
)
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == "Hello")
|
||||
#expect(result.images.count == 1)
|
||||
#expect(result.images.first?.image != nil)
|
||||
}
|
||||
|
||||
@Test func stripsInboundUntrustedContextBlocks() {
|
||||
let markdown = """
|
||||
Conversation info (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"message_id": "123",
|
||||
"sender": "openclaw-ios"
|
||||
}
|
||||
```
|
||||
|
||||
Sender (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"label": "Razor"
|
||||
}
|
||||
```
|
||||
|
||||
Razor?
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == "Razor?")
|
||||
}
|
||||
|
||||
@Test func stripsSingleConversationInfoBlock() {
|
||||
let text = """
|
||||
Conversation info (untrusted metadata):
|
||||
```json
|
||||
{"x": 1}
|
||||
```
|
||||
|
||||
User message
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: text)
|
||||
|
||||
#expect(result.cleaned == "User message")
|
||||
}
|
||||
|
||||
@Test func stripsAllKnownInboundMetadataSentinels() {
|
||||
let sentinels = [
|
||||
"Conversation info (untrusted metadata):",
|
||||
"Sender (untrusted metadata):",
|
||||
"Thread starter (untrusted, for context):",
|
||||
"Replied message (untrusted, for context):",
|
||||
"Forwarded message context (untrusted metadata):",
|
||||
"Chat history since last reply (untrusted, for context):",
|
||||
]
|
||||
|
||||
for sentinel in sentinels {
|
||||
let markdown = """
|
||||
\(sentinel)
|
||||
```json
|
||||
{"x": 1}
|
||||
```
|
||||
|
||||
User content
|
||||
"""
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
#expect(result.cleaned == "User content")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func preservesNonMetadataJsonFence() {
|
||||
let markdown = """
|
||||
Here is some json:
|
||||
```json
|
||||
{"x": 1}
|
||||
```
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
@Test func stripsLeadingTimestampPrefix() {
|
||||
let markdown = """
|
||||
[Fri 2026-02-20 18:45 GMT+1] How's it going?
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == "How's it going?")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private func luminance(_ color: NSColor) throws -> CGFloat {
|
||||
let rgb = try #require(color.usingColorSpace(.deviceRGB))
|
||||
return 0.2126 * rgb.redComponent + 0.7152 * rgb.greenComponent + 0.0722 * rgb.blueComponent
|
||||
}
|
||||
#endif
|
||||
|
||||
@Suite struct ChatThemeTests {
|
||||
@Test func assistantBubbleResolvesForLightAndDark() throws {
|
||||
#if os(macOS)
|
||||
let lightAppearance = try #require(NSAppearance(named: .aqua))
|
||||
let darkAppearance = try #require(NSAppearance(named: .darkAqua))
|
||||
|
||||
let lightResolved = OpenClawChatTheme.resolvedAssistantBubbleColor(for: lightAppearance)
|
||||
let darkResolved = OpenClawChatTheme.resolvedAssistantBubbleColor(for: darkAppearance)
|
||||
#expect(try luminance(lightResolved) > luminance(darkResolved))
|
||||
#else
|
||||
#expect(Bool(true))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
private struct TimeoutError: Error, CustomStringConvertible {
|
||||
let label: String
|
||||
var description: String { "Timeout waiting for: \(self.label)" }
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
_ label: String,
|
||||
timeoutSeconds: Double = 2.0,
|
||||
pollMs: UInt64 = 10,
|
||||
_ condition: @escaping @Sendable () async -> Bool) async throws
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
while Date() < deadline {
|
||||
if await condition() {
|
||||
return
|
||||
}
|
||||
try await Task.sleep(nanoseconds: pollMs * 1_000_000)
|
||||
}
|
||||
throw TimeoutError(label: label)
|
||||
}
|
||||
|
||||
private actor TestChatTransportState {
|
||||
var historyCallCount: Int = 0
|
||||
var sessionsCallCount: Int = 0
|
||||
var sentRunIds: [String] = []
|
||||
var abortedRunIds: [String] = []
|
||||
}
|
||||
|
||||
private final class TestChatTransport: @unchecked Sendable, OpenClawChatTransport {
|
||||
private let state = TestChatTransportState()
|
||||
private let historyResponses: [OpenClawChatHistoryPayload]
|
||||
private let sessionsResponses: [OpenClawChatSessionsListResponse]
|
||||
|
||||
private let stream: AsyncStream<OpenClawChatTransportEvent>
|
||||
private let continuation: AsyncStream<OpenClawChatTransportEvent>.Continuation
|
||||
|
||||
init(
|
||||
historyResponses: [OpenClawChatHistoryPayload],
|
||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [])
|
||||
{
|
||||
self.historyResponses = historyResponses
|
||||
self.sessionsResponses = sessionsResponses
|
||||
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
|
||||
self.stream = AsyncStream { c in
|
||||
cont = c
|
||||
}
|
||||
self.continuation = cont
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
self.stream
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
let idx = await self.state.historyCallCount
|
||||
await self.state.setHistoryCallCount(idx + 1)
|
||||
if idx < self.historyResponses.count {
|
||||
return self.historyResponses[idx]
|
||||
}
|
||||
return self.historyResponses.last ?? OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: nil,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
await self.state.sentRunIdsAppend(idempotencyKey)
|
||||
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
|
||||
}
|
||||
|
||||
func abortRun(sessionKey _: String, runId: String) async throws {
|
||||
await self.state.abortedRunIdsAppend(runId)
|
||||
}
|
||||
|
||||
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
let idx = await self.state.sessionsCallCount
|
||||
await self.state.setSessionsCallCount(idx + 1)
|
||||
if idx < self.sessionsResponses.count {
|
||||
return self.sessionsResponses[idx]
|
||||
}
|
||||
return self.sessionsResponses.last ?? OpenClawChatSessionsListResponse(
|
||||
ts: nil,
|
||||
path: nil,
|
||||
count: 0,
|
||||
defaults: nil,
|
||||
sessions: [])
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func emit(_ evt: OpenClawChatTransportEvent) {
|
||||
self.continuation.yield(evt)
|
||||
}
|
||||
|
||||
func lastSentRunId() async -> String? {
|
||||
let ids = await self.state.sentRunIds
|
||||
return ids.last
|
||||
}
|
||||
|
||||
func abortedRunIds() async -> [String] {
|
||||
await self.state.abortedRunIds
|
||||
}
|
||||
}
|
||||
|
||||
extension TestChatTransportState {
|
||||
fileprivate func setHistoryCallCount(_ v: Int) {
|
||||
self.historyCallCount = v
|
||||
}
|
||||
|
||||
fileprivate func setSessionsCallCount(_ v: Int) {
|
||||
self.sessionsCallCount = v
|
||||
}
|
||||
|
||||
fileprivate func sentRunIdsAppend(_ v: String) {
|
||||
self.sentRunIds.append(v)
|
||||
}
|
||||
|
||||
fileprivate func abortedRunIdsAppend(_ v: String) {
|
||||
self.abortedRunIds.append(v)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct ChatViewModelTests {
|
||||
@Test func streamsAssistantAndClearsOnFinal() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "final answer"]],
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "hi"
|
||||
vm.send()
|
||||
}
|
||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
runId: sessionId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||
data: ["text": AnyCodable("streaming…")])))
|
||||
|
||||
try await waitUntil("assistant stream visible") {
|
||||
await MainActor.run { vm.streamingAssistantText == "streaming…" }
|
||||
}
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
runId: sessionId,
|
||||
seq: 2,
|
||||
stream: "tool",
|
||||
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||
data: [
|
||||
"phase": AnyCodable("start"),
|
||||
"name": AnyCodable("demo"),
|
||||
"toolCallId": AnyCodable("t1"),
|
||||
"args": AnyCodable(["x": 1]),
|
||||
])))
|
||||
|
||||
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
|
||||
|
||||
let runId = try #require(await transport.lastSentRunId())
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: runId,
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
|
||||
try await waitUntil("history refresh") {
|
||||
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
|
||||
}
|
||||
#expect(await MainActor.run { vm.streamingAssistantText } == nil)
|
||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||
}
|
||||
|
||||
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "from history"]],
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "hi"
|
||||
vm.send()
|
||||
}
|
||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||
|
||||
let runId = try #require(await transport.lastSentRunId())
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: runId,
|
||||
sessionKey: "agent:main:main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
|
||||
try await waitUntil("history refresh") {
|
||||
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "first"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "first"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "from external run"]],
|
||||
"timestamp": now + 1,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
|
||||
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: "external-run",
|
||||
sessionKey: "agent:main:main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("history refresh after canonical external event") {
|
||||
await MainActor.run { vm.messages.count == 2 }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func preservesMessageIDsAcrossHistoryRefreshes() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "hello"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "hello"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "world"]],
|
||||
"timestamp": now + 1,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
|
||||
let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id })
|
||||
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: "other-run",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } }
|
||||
let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id })
|
||||
#expect(firstIdAfter == firstIdBefore)
|
||||
}
|
||||
|
||||
@Test func clearsStreamingOnExternalFinalEvent() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let transport = TestChatTransport(historyResponses: [history, history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
runId: sessionId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||
data: ["text": AnyCodable("external stream")])))
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
runId: sessionId,
|
||||
seq: 2,
|
||||
stream: "tool",
|
||||
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||
data: [
|
||||
"phase": AnyCodable("start"),
|
||||
"name": AnyCodable("demo"),
|
||||
"toolCallId": AnyCodable("t1"),
|
||||
"args": AnyCodable(["x": 1]),
|
||||
])))
|
||||
|
||||
try await waitUntil("streaming active") {
|
||||
await MainActor.run { vm.streamingAssistantText == "external stream" }
|
||||
}
|
||||
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
|
||||
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: "other-run",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
|
||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||
}
|
||||
|
||||
@Test func seqGapClearsPendingRunsAndAutoRefreshesHistory() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "resynced after gap"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "hello"
|
||||
vm.send()
|
||||
}
|
||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||
|
||||
transport.emit(.seqGap)
|
||||
|
||||
try await waitUntil("pending run clears on seqGap") {
|
||||
await MainActor.run { vm.pendingRunCount == 0 }
|
||||
}
|
||||
try await waitUntil("history refreshes on seqGap") {
|
||||
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
|
||||
}
|
||||
#expect(await MainActor.run { vm.errorText == nil })
|
||||
}
|
||||
|
||||
@Test func sessionChoicesPreferMainAndRecent() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let recent = now - (2 * 60 * 60 * 1000)
|
||||
let recentOlder = now - (5 * 60 * 60 * 1000)
|
||||
let stale = now - (26 * 60 * 60 * 1000)
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
count: 4,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
OpenClawChatSessionEntry(
|
||||
key: "recent-1",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recent,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
OpenClawChatSessionEntry(
|
||||
key: "main",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: stale,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
OpenClawChatSessionEntry(
|
||||
key: "recent-2",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recentOlder,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
OpenClawChatSessionEntry(
|
||||
key: "old-1",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: stale,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
])
|
||||
|
||||
let transport = TestChatTransport(
|
||||
historyResponses: [history],
|
||||
sessionsResponses: [sessions])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
|
||||
|
||||
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
|
||||
#expect(keys == ["main", "recent-1", "recent-2"])
|
||||
}
|
||||
|
||||
@Test func sessionChoicesIncludeCurrentWhenMissing() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let recent = now - (30 * 60 * 1000)
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
sessionKey: "custom",
|
||||
sessionId: "sess-custom",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let sessions = OpenClawChatSessionsListResponse(
|
||||
ts: now,
|
||||
path: nil,
|
||||
count: 1,
|
||||
defaults: nil,
|
||||
sessions: [
|
||||
OpenClawChatSessionEntry(
|
||||
key: "main",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recent,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: nil,
|
||||
verboseLevel: nil,
|
||||
inputTokens: nil,
|
||||
outputTokens: nil,
|
||||
totalTokens: nil,
|
||||
model: nil,
|
||||
contextTokens: nil),
|
||||
])
|
||||
|
||||
let transport = TestChatTransport(
|
||||
historyResponses: [history],
|
||||
sessionsResponses: [sessions])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "custom", transport: transport) }
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
|
||||
|
||||
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
|
||||
#expect(keys == ["main", "custom"])
|
||||
}
|
||||
|
||||
@Test func clearsStreamingOnExternalErrorEvent() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let transport = TestChatTransport(historyResponses: [history, history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
|
||||
|
||||
transport.emit(
|
||||
.agent(
|
||||
OpenClawAgentEventPayload(
|
||||
runId: sessionId,
|
||||
seq: 1,
|
||||
stream: "assistant",
|
||||
ts: Int(Date().timeIntervalSince1970 * 1000),
|
||||
data: ["text": AnyCodable("external stream")])))
|
||||
|
||||
try await waitUntil("streaming active") {
|
||||
await MainActor.run { vm.streamingAssistantText == "external stream" }
|
||||
}
|
||||
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: "other-run",
|
||||
sessionKey: "main",
|
||||
state: "error",
|
||||
message: nil,
|
||||
errorMessage: "boom")))
|
||||
|
||||
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
|
||||
}
|
||||
|
||||
@Test func stripsInboundMetadataFromHistoryMessages() async throws {
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": """
|
||||
Conversation info (untrusted metadata):
|
||||
```json
|
||||
{ \"sender\": \"openclaw-ios\" }
|
||||
```
|
||||
|
||||
Hello?
|
||||
"""]],
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
let transport = TestChatTransport(historyResponses: [history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("history loaded") { await MainActor.run { !vm.messages.isEmpty } }
|
||||
|
||||
let sanitized = await MainActor.run { vm.messages.first?.content.first?.text }
|
||||
#expect(sanitized == "Hello?")
|
||||
}
|
||||
|
||||
@Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: sessionId,
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let transport = TestChatTransport(historyResponses: [history, history])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "hi"
|
||||
vm.send()
|
||||
}
|
||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||
|
||||
let runId = try #require(await transport.lastSentRunId())
|
||||
await MainActor.run { vm.abort() }
|
||||
|
||||
try await waitUntil("abortRun called") {
|
||||
let ids = await transport.abortedRunIds()
|
||||
return ids == [runId]
|
||||
}
|
||||
|
||||
// Pending remains until the gateway broadcasts an aborted/final chat event.
|
||||
#expect(await MainActor.run { vm.pendingRunCount } == 1)
|
||||
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: runId,
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct DeepLinksSecurityTests {
|
||||
@Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() {
|
||||
let url = URL(
|
||||
string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")!
|
||||
#expect(DeepLinkParser.parse(url) == nil)
|
||||
}
|
||||
|
||||
@Test func gatewayDeepLinkRejectsInsecurePrefixBypassHost() {
|
||||
let url = URL(
|
||||
string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")!
|
||||
#expect(DeepLinkParser.parse(url) == nil)
|
||||
}
|
||||
|
||||
@Test func gatewayDeepLinkAllowsLoopbackWs() {
|
||||
let url = URL(
|
||||
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .gateway(
|
||||
.init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil)))
|
||||
}
|
||||
|
||||
@Test func setupCodeRejectsInsecureNonLoopbackWs() {
|
||||
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
#expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil)
|
||||
}
|
||||
|
||||
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
|
||||
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
#expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil)
|
||||
}
|
||||
|
||||
@Test func setupCodeAllowsLoopbackWs() {
|
||||
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
#expect(
|
||||
GatewayConnectDeepLink.fromSetupCode(encoded) == .init(
|
||||
host: "127.0.0.1",
|
||||
port: 18789,
|
||||
tls: false,
|
||||
token: "tok",
|
||||
password: nil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
@Suite("DeviceAuthPayload")
|
||||
struct DeviceAuthPayloadTests {
|
||||
@Test("builds canonical v3 payload vector")
|
||||
func buildsCanonicalV3PayloadVector() {
|
||||
let payload = GatewayDeviceAuthPayload.buildV3(
|
||||
deviceId: "dev-1",
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "ui",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
signedAtMs: 1_700_000_000_000,
|
||||
token: "tok-123",
|
||||
nonce: "nonce-abc",
|
||||
platform: " IOS ",
|
||||
deviceFamily: " iPhone ")
|
||||
#expect(
|
||||
payload
|
||||
== "v3|dev-1|openclaw-macos|ui|operator|operator.admin,operator.read|1700000000000|tok-123|nonce-abc|ios|iphone")
|
||||
}
|
||||
|
||||
@Test("normalizes metadata with ASCII-only lowercase")
|
||||
func normalizesMetadataWithAsciiLowercase() {
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" İOS ") == "İos")
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(" MAC ") == "mac")
|
||||
#expect(GatewayDeviceAuthPayload.normalizeMetadataField(nil) == "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
final class ElevenLabsTTSValidationTests: XCTestCase {
|
||||
func testValidatedOutputFormatAllowsOnlyMp3Presets() {
|
||||
XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128"), "mp3_44100_128")
|
||||
XCTAssertEqual(ElevenLabsTTSClient.validatedOutputFormat("pcm_16000"), "pcm_16000")
|
||||
}
|
||||
|
||||
func testValidatedLanguageAcceptsTwoLetterCodes() {
|
||||
XCTAssertEqual(ElevenLabsTTSClient.validatedLanguage("EN"), "en")
|
||||
XCTAssertNil(ElevenLabsTTSClient.validatedLanguage("eng"))
|
||||
}
|
||||
|
||||
func testValidatedNormalizeAcceptsKnownValues() {
|
||||
XCTAssertEqual(ElevenLabsTTSClient.validatedNormalize("AUTO"), "auto")
|
||||
XCTAssertNil(ElevenLabsTTSClient.validatedNormalize("maybe"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
private struct TimeoutError: Error, CustomStringConvertible {
|
||||
let label: String
|
||||
var description: String { "Timeout waiting for: \(self.label)" }
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
_ label: String,
|
||||
timeoutSeconds: Double = 3.0,
|
||||
pollMs: UInt64 = 10,
|
||||
_ condition: @escaping @Sendable () async -> Bool) async throws
|
||||
{
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
while Date() < deadline {
|
||||
if await condition() {
|
||||
return
|
||||
}
|
||||
try await Task.sleep(nanoseconds: pollMs * 1_000_000)
|
||||
}
|
||||
throw TimeoutError(label: label)
|
||||
}
|
||||
|
||||
private extension NSLock {
|
||||
func withLock<T>(_ body: () -> T) -> T {
|
||||
self.lock()
|
||||
defer { self.unlock() }
|
||||
return body()
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _state: URLSessionTask.State = .suspended
|
||||
private var connectRequestId: String?
|
||||
private var receivePhase = 0
|
||||
private var pendingReceiveHandler:
|
||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
|
||||
var state: URLSessionTask.State {
|
||||
get { self.lock.withLock { self._state } }
|
||||
set { self.lock.withLock { self._state = newValue } }
|
||||
}
|
||||
|
||||
func resume() {
|
||||
self.state = .running
|
||||
}
|
||||
|
||||
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||
_ = (closeCode, reason)
|
||||
self.state = .canceling
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
|
||||
defer { self.pendingReceiveHandler = nil }
|
||||
return self.pendingReceiveHandler
|
||||
}
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
let data: Data? = switch message {
|
||||
case let .data(d): d
|
||||
case let .string(s): s.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else { return }
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
obj["type"] as? String == "req",
|
||||
obj["method"] as? String == "connect",
|
||||
let id = obj["id"] as? String
|
||||
{
|
||||
self.lock.withLock { self.connectRequestId = id }
|
||||
}
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
|
||||
pongReceiveHandler(nil)
|
||||
}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
let phase = self.lock.withLock { () -> Int in
|
||||
let current = self.receivePhase
|
||||
self.receivePhase += 1
|
||||
return current
|
||||
}
|
||||
if phase == 0 {
|
||||
return .data(Self.connectChallengeData(nonce: "nonce-1"))
|
||||
}
|
||||
for _ in 0..<50 {
|
||||
let id = self.lock.withLock { self.connectRequestId }
|
||||
if let id {
|
||||
return .data(Self.connectOkData(id: id))
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
return .data(Self.connectOkData(id: "connect"))
|
||||
}
|
||||
|
||||
func receive(
|
||||
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||
{
|
||||
self.lock.withLock { self.pendingReceiveHandler = completionHandler }
|
||||
}
|
||||
|
||||
func emitReceiveFailure() {
|
||||
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
|
||||
self._state = .canceling
|
||||
defer { self.pendingReceiveHandler = nil }
|
||||
return self.pendingReceiveHandler
|
||||
}
|
||||
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
|
||||
}
|
||||
|
||||
private static func connectChallengeData(nonce: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "event",
|
||||
"event": "connect.challenge",
|
||||
"payload": { "nonce": "\(nonce)" }
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let json = """
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": { "version": "test", "connId": "test" },
|
||||
"features": { "methods": [], "events": [] },
|
||||
"snapshot": {
|
||||
"presence": [ { "ts": 1 } ],
|
||||
"health": {},
|
||||
"stateVersion": { "presence": 0, "health": 0 },
|
||||
"uptimeMs": 0
|
||||
},
|
||||
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
|
||||
}
|
||||
}
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var tasks: [FakeGatewayWebSocketTask] = []
|
||||
private var makeCount = 0
|
||||
|
||||
func snapshotMakeCount() -> Int {
|
||||
self.lock.withLock { self.makeCount }
|
||||
}
|
||||
|
||||
func latestTask() -> FakeGatewayWebSocketTask? {
|
||||
self.lock.withLock { self.tasks.last }
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
_ = url
|
||||
return self.lock.withLock {
|
||||
self.makeCount += 1
|
||||
let task = FakeGatewayWebSocketTask()
|
||||
self.tasks.append(task)
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor SeqGapProbe {
|
||||
private var saw = false
|
||||
func mark() { self.saw = true }
|
||||
func value() -> Bool { self.saw }
|
||||
}
|
||||
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 50,
|
||||
onInvoke: { req in
|
||||
#expect(req.id == "1")
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
#expect(response.payloadJSON == "{}")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsTimeoutError() async {
|
||||
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 10,
|
||||
onInvoke: { _ in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
||||
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message.contains("timed out") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 0,
|
||||
onInvoke: { req in
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws {
|
||||
let session = FakeGatewayWebSocketSession()
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "ui",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: false)
|
||||
|
||||
let stream = await gateway.subscribeServerEvents(bufferingNewest: 32)
|
||||
let probe = SeqGapProbe()
|
||||
let listenTask = Task {
|
||||
for await evt in stream {
|
||||
if evt.event == "seqGap" {
|
||||
await probe.mark()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "ws://example.invalid")!,
|
||||
token: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let firstTask = try #require(session.latestTask())
|
||||
firstTask.emitReceiveFailure()
|
||||
|
||||
try await waitUntil("reconnect socket created") {
|
||||
session.snapshotMakeCount() >= 2
|
||||
}
|
||||
try await waitUntil("synthetic seqGap broadcast") {
|
||||
await probe.value()
|
||||
}
|
||||
|
||||
listenTask.cancel()
|
||||
await gateway.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import OpenClawKit
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@Suite struct JPEGTranscoderTests {
|
||||
private func makeSolidJPEG(width: Int, height: Int, orientation: Int? = nil) throws -> Data {
|
||||
let cs = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
guard
|
||||
let ctx = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: cs,
|
||||
bitmapInfo: bitmapInfo)
|
||||
else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 1)
|
||||
}
|
||||
|
||||
ctx.setFillColor(red: 1, green: 0, blue: 0, alpha: 1)
|
||||
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let img = ctx.makeImage() else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 5)
|
||||
}
|
||||
|
||||
let out = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 2)
|
||||
}
|
||||
|
||||
var props: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: 1.0,
|
||||
]
|
||||
if let orientation {
|
||||
props[kCGImagePropertyOrientation] = orientation
|
||||
}
|
||||
|
||||
CGImageDestinationAddImage(dest, img, props as CFDictionary)
|
||||
guard CGImageDestinationFinalize(dest) else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 3)
|
||||
}
|
||||
|
||||
return out as Data
|
||||
}
|
||||
|
||||
private func makeNoiseJPEG(width: Int, height: Int) throws -> Data {
|
||||
let bytesPerPixel = 4
|
||||
let byteCount = width * height * bytesPerPixel
|
||||
var data = Data(count: byteCount)
|
||||
let cs = CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
|
||||
let out = try data.withUnsafeMutableBytes { rawBuffer -> Data in
|
||||
guard let base = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 6)
|
||||
}
|
||||
for idx in 0..<byteCount {
|
||||
base[idx] = UInt8.random(in: 0...255)
|
||||
}
|
||||
|
||||
guard
|
||||
let ctx = CGContext(
|
||||
data: base,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * bytesPerPixel,
|
||||
space: cs,
|
||||
bitmapInfo: bitmapInfo)
|
||||
else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 7)
|
||||
}
|
||||
|
||||
guard let img = ctx.makeImage() else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 8)
|
||||
}
|
||||
|
||||
let encoded = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(encoded, UTType.jpeg.identifier as CFString, 1, nil)
|
||||
else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 9)
|
||||
}
|
||||
CGImageDestinationAddImage(dest, img, nil)
|
||||
guard CGImageDestinationFinalize(dest) else {
|
||||
throw NSError(domain: "JPEGTranscoderTests", code: 10)
|
||||
}
|
||||
return encoded as Data
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@Test func downscalesToMaxWidthPx() throws {
|
||||
let input = try makeSolidJPEG(width: 2000, height: 1000)
|
||||
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
|
||||
#expect(out.widthPx == 1600)
|
||||
#expect(abs(out.heightPx - 800) <= 1)
|
||||
#expect(out.data.count > 0)
|
||||
}
|
||||
|
||||
@Test func doesNotUpscaleWhenSmallerThanMaxWidthPx() throws {
|
||||
let input = try makeSolidJPEG(width: 800, height: 600)
|
||||
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
|
||||
#expect(out.widthPx == 800)
|
||||
#expect(out.heightPx == 600)
|
||||
}
|
||||
|
||||
@Test func normalizesOrientationAndUsesOrientedWidthForMaxWidthPx() throws {
|
||||
// Encode a landscape image but mark it rotated 90° (orientation 6). Oriented width becomes 1000.
|
||||
let input = try makeSolidJPEG(width: 2000, height: 1000, orientation: 6)
|
||||
let out = try JPEGTranscoder.transcodeToJPEG(imageData: input, maxWidthPx: 1600, quality: 0.9)
|
||||
#expect(out.widthPx == 1000)
|
||||
#expect(out.heightPx == 2000)
|
||||
}
|
||||
|
||||
@Test func respectsMaxBytes() throws {
|
||||
let input = try makeNoiseJPEG(width: 1600, height: 1200)
|
||||
let out = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: input,
|
||||
maxWidthPx: 1600,
|
||||
quality: 0.95,
|
||||
maxBytes: 180_000)
|
||||
#expect(out.data.count <= 180_000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
final class TalkDirectiveTests: XCTestCase {
|
||||
func testParsesDirectiveAndStripsLine() {
|
||||
let text = """
|
||||
{"voice":"abc123","once":true}
|
||||
Hello there.
|
||||
"""
|
||||
let result = TalkDirectiveParser.parse(text)
|
||||
XCTAssertEqual(result.directive?.voiceId, "abc123")
|
||||
XCTAssertEqual(result.directive?.once, true)
|
||||
XCTAssertEqual(result.stripped, "Hello there.")
|
||||
}
|
||||
|
||||
func testIgnoresNonDirective() {
|
||||
let text = "Hello world."
|
||||
let result = TalkDirectiveParser.parse(text)
|
||||
XCTAssertNil(result.directive)
|
||||
XCTAssertEqual(result.stripped, text)
|
||||
}
|
||||
|
||||
func testKeepsDirectiveLineIfNoRecognizedFields() {
|
||||
let text = """
|
||||
{"unknown":"value"}
|
||||
Hello.
|
||||
"""
|
||||
let result = TalkDirectiveParser.parse(text)
|
||||
XCTAssertNil(result.directive)
|
||||
XCTAssertEqual(result.stripped, text)
|
||||
}
|
||||
|
||||
func testParsesExtendedOptions() {
|
||||
let text = """
|
||||
{"voice_id":"v1","model_id":"m1","rate":200,"stability":0.5,"similarity":0.8,"style":0.2,"speaker_boost":true,"seed":1234,"normalize":"auto","lang":"en","output_format":"mp3_44100_128"}
|
||||
Hello.
|
||||
"""
|
||||
let result = TalkDirectiveParser.parse(text)
|
||||
XCTAssertEqual(result.directive?.voiceId, "v1")
|
||||
XCTAssertEqual(result.directive?.modelId, "m1")
|
||||
XCTAssertEqual(result.directive?.rateWPM, 200)
|
||||
XCTAssertEqual(result.directive?.stability, 0.5)
|
||||
XCTAssertEqual(result.directive?.similarity, 0.8)
|
||||
XCTAssertEqual(result.directive?.style, 0.2)
|
||||
XCTAssertEqual(result.directive?.speakerBoost, true)
|
||||
XCTAssertEqual(result.directive?.seed, 1234)
|
||||
XCTAssertEqual(result.directive?.normalize, "auto")
|
||||
XCTAssertEqual(result.directive?.language, "en")
|
||||
XCTAssertEqual(result.directive?.outputFormat, "mp3_44100_128")
|
||||
XCTAssertEqual(result.stripped, "Hello.")
|
||||
}
|
||||
|
||||
func testSkipsLeadingEmptyLinesWhenParsingDirective() {
|
||||
let text = """
|
||||
|
||||
|
||||
{"voice":"abc123"}
|
||||
Hello there.
|
||||
"""
|
||||
let result = TalkDirectiveParser.parse(text)
|
||||
XCTAssertEqual(result.directive?.voiceId, "abc123")
|
||||
XCTAssertEqual(result.stripped, "Hello there.")
|
||||
}
|
||||
|
||||
func testTracksUnknownKeys() {
|
||||
let text = """
|
||||
{"voice":"abc","mystery":"value","extra":1}
|
||||
Hi.
|
||||
"""
|
||||
let result = TalkDirectiveParser.parse(text)
|
||||
XCTAssertEqual(result.directive?.voiceId, "abc")
|
||||
XCTAssertEqual(result.unknownKeys, ["extra", "mystery"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
final class TalkHistoryTimestampTests: XCTestCase {
|
||||
func testSecondsTimestampsAreAcceptedWithSmallTolerance() {
|
||||
XCTAssertTrue(TalkHistoryTimestamp.isAfter(999.6, sinceSeconds: 1000))
|
||||
XCTAssertFalse(TalkHistoryTimestamp.isAfter(999.4, sinceSeconds: 1000))
|
||||
}
|
||||
|
||||
func testMillisecondsTimestampsAreAcceptedWithSmallTolerance() {
|
||||
let sinceSeconds = 1_700_000_000.0
|
||||
let sinceMs = sinceSeconds * 1000
|
||||
XCTAssertTrue(TalkHistoryTimestamp.isAfter(sinceMs - 500, sinceSeconds: sinceSeconds))
|
||||
XCTAssertFalse(TalkHistoryTimestamp.isAfter(sinceMs - 501, sinceSeconds: sinceSeconds))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
final class TalkPromptBuilderTests: XCTestCase {
|
||||
func testBuildIncludesTranscript() {
|
||||
let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil)
|
||||
XCTAssertTrue(prompt.contains("Talk Mode active."))
|
||||
XCTAssertTrue(prompt.hasSuffix("\n\nHello"))
|
||||
}
|
||||
|
||||
func testBuildIncludesInterruptionLineWhenProvided() {
|
||||
let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234)
|
||||
XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s."))
|
||||
}
|
||||
|
||||
func testBuildIncludesVoiceDirectiveHintByDefault() {
|
||||
let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil)
|
||||
XCTAssertTrue(prompt.contains("ElevenLabs voice"))
|
||||
}
|
||||
|
||||
func testBuildExcludesVoiceDirectiveHintWhenDisabled() {
|
||||
let prompt = TalkPromptBuilder.build(
|
||||
transcript: "Hello",
|
||||
interruptedAtSeconds: nil,
|
||||
includeVoiceDirectiveHint: false)
|
||||
XCTAssertFalse(prompt.contains("ElevenLabs voice"))
|
||||
XCTAssertTrue(prompt.contains("Talk Mode active."))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct ToolDisplayRegistryTests {
|
||||
@Test func loadsToolDisplayConfigFromBundle() {
|
||||
let url = OpenClawKitResources.bundle.url(forResource: "tool-display", withExtension: "json")
|
||||
#expect(url != nil)
|
||||
}
|
||||
|
||||
@Test func resolvesKnownToolFromConfig() {
|
||||
let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil)
|
||||
#expect(summary.emoji == "🛠️")
|
||||
#expect(summary.title == "Bash")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Testing
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
@Suite("ToolResultTextFormatter")
|
||||
struct ToolResultTextFormatterTests {
|
||||
@Test func leavesPlainTextUntouched() {
|
||||
let result = ToolResultTextFormatter.format(text: "All good", toolName: "nodes")
|
||||
#expect(result == "All good")
|
||||
}
|
||||
|
||||
@Test func summarizesNodesListJSON() {
|
||||
let json = """
|
||||
{
|
||||
"ts": 1771610031380,
|
||||
"nodes": [
|
||||
{
|
||||
"displayName": "iPhone 16 Pro Max",
|
||||
"connected": true,
|
||||
"platform": "ios"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
|
||||
#expect(result.contains("1 node found."))
|
||||
#expect(result.contains("iPhone 16 Pro Max"))
|
||||
#expect(result.contains("connected"))
|
||||
}
|
||||
|
||||
@Test func summarizesErrorJSONAndDropsAgentPrefix() {
|
||||
let json = """
|
||||
{
|
||||
"status": "error",
|
||||
"tool": "nodes",
|
||||
"error": "agent=main node=iPhone gateway=default action=invoke: pairing required"
|
||||
}
|
||||
"""
|
||||
|
||||
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
|
||||
#expect(result == "Error: pairing required")
|
||||
}
|
||||
|
||||
@Test func suppressesUnknownStructuredPayload() {
|
||||
let json = """
|
||||
{
|
||||
"foo": "bar"
|
||||
}
|
||||
"""
|
||||
|
||||
let result = ToolResultTextFormatter.format(text: json, toolName: "nodes")
|
||||
#expect(result.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user