mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
460 lines
19 KiB
Swift
460 lines
19 KiB
Swift
import Foundation
|
|
import OpenClawKit
|
|
import Testing
|
|
@testable import OpenClawChatUI
|
|
|
|
#if os(macOS) && canImport(WebKit)
|
|
import AppKit
|
|
import WebKit
|
|
#endif
|
|
|
|
struct ChatInlineWidgetTests {
|
|
@Test func `sanitizes widget export filenames`() {
|
|
#expect(ChatInlineWidgetExport.filename(title: " Sales / Q3\\Summary\u{0007} ") ==
|
|
"Sales Q3Summary.png")
|
|
#expect(ChatInlineWidgetExport.filename(title: "\n\t") == "widget.png")
|
|
#expect(ChatInlineWidgetExport.filename(title: nil) == "widget.png")
|
|
}
|
|
|
|
@Test func `decodes projected canvas widget block`() throws {
|
|
let data = Data(#"""
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Done"},
|
|
{
|
|
"type": "canvas",
|
|
"preview": {
|
|
"kind": "canvas",
|
|
"surface": "assistant_message",
|
|
"render": "url",
|
|
"title": "Status",
|
|
"preferredHeight": 240,
|
|
"url": "/__openclaw__/canvas/documents/widget-1/index.html",
|
|
"sandbox": "scripts"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""#.utf8)
|
|
|
|
let message = try JSONDecoder().decode(OpenClawChatMessage.self, from: data)
|
|
let preview = try #require(message.content.last?.preview)
|
|
#expect(preview.inlineWidgetPath == "/__openclaw__/canvas/documents/widget-1/index.html")
|
|
#expect(preview.inlineWidgetHeight == 240)
|
|
|
|
let unsafe = OpenClawChatCanvasPreview(
|
|
kind: "canvas",
|
|
surface: "assistant_message",
|
|
render: "url",
|
|
title: nil,
|
|
preferredHeight: nil,
|
|
url: "https://attacker.example/widget.html",
|
|
viewId: nil,
|
|
sandbox: "scripts")
|
|
#expect(unsafe.inlineWidgetPath == nil)
|
|
}
|
|
|
|
@Test func `resolves only capability scoped widget documents`() {
|
|
let surface = "https://gateway.example/__openclaw__/cap/token"
|
|
let target = "/__openclaw__/canvas/documents/widget-1/index.html"
|
|
|
|
#expect(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: surface,
|
|
target: target)?.absoluteString ==
|
|
"https://gateway.example/__openclaw__/cap/token/__openclaw__/canvas/documents/widget-1/index.html")
|
|
#expect(OpenClawChatWidgetURLResolver.resolve(surfaceURL: "https://gateway.example", target: target) == nil)
|
|
#expect(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: surface,
|
|
target: "https://attacker.example/widget.html") == nil)
|
|
#expect(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: surface,
|
|
target: "/__openclaw__/a2ui/index.html") == nil)
|
|
#expect(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: surface,
|
|
target: "/__openclaw__/canvas/documents/%252e%252e/index.html") == nil)
|
|
#expect(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: surface,
|
|
target: "/__openclaw__/canvas/documents/%2525252525252525252525252525252525/index.html") == nil)
|
|
}
|
|
|
|
@Test func `resource equality ignores internal recovery metadata`() throws {
|
|
let url = try #require(URL(string: "https://gateway.example/widget"))
|
|
let publicResource = OpenClawChatWidgetResource(url: url)
|
|
let trackedResource = OpenClawChatWidgetResource(
|
|
url: url,
|
|
tlsFingerprintSHA256: nil,
|
|
surfaceRole: .node,
|
|
attemptedSurfaceRoles: [.node])
|
|
|
|
#expect(publicResource == trackedResource)
|
|
}
|
|
|
|
@Test func `uses replacement route after capability refresh loses its lease`() async throws {
|
|
let target = "/__openclaw__/canvas/documents/widget-1/index.html"
|
|
let oldSurface = "https://gateway.example/__openclaw__/cap/old"
|
|
let newSurface = "https://gateway.example/__openclaw__/cap/new"
|
|
let failedURL = try #require(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: oldSurface,
|
|
target: target))
|
|
let failedResource = OpenClawChatWidgetResource(url: failedURL)
|
|
let probe = ChatWidgetRouteReconnectProbe(
|
|
route: GatewayCanvasHostRoute(url: oldSurface, tlsFingerprintSHA256: nil),
|
|
replacement: GatewayCanvasHostRoute(url: newSurface, tlsFingerprintSHA256: nil))
|
|
|
|
let resolved = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: failedResource,
|
|
currentSurfaceRoutes: { await probe.current() },
|
|
refreshNodeSurfaceRoute: { observed in await probe.reconnect(observed: observed) },
|
|
refreshOperatorSurfaceRoute: { _ in nil })
|
|
|
|
#expect(resolved?.url == OpenClawChatWidgetURLResolver.resolve(surfaceURL: newSurface, target: target))
|
|
#expect(await probe.refreshCount == 1)
|
|
}
|
|
|
|
@Test func `accepts a same URL widget route when it acquires a TLS pin`() async throws {
|
|
let target = "/__openclaw__/canvas/documents/widget-1/index.html"
|
|
let surface = "https://gateway.example/__openclaw__/cap/token"
|
|
let newPin = String(repeating: "bb", count: 32)
|
|
let failedURL = try #require(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: surface,
|
|
target: target))
|
|
let probe = ChatWidgetRouteReconnectProbe(
|
|
route: GatewayCanvasHostRoute(url: surface, tlsFingerprintSHA256: nil),
|
|
replacement: GatewayCanvasHostRoute(url: surface, tlsFingerprintSHA256: newPin))
|
|
let initialResource = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: nil,
|
|
currentSurfaceRoutes: { await probe.current() },
|
|
refreshNodeSurfaceRoute: { _ in nil },
|
|
refreshOperatorSurfaceRoute: { _ in nil })
|
|
|
|
let resolved = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: initialResource,
|
|
currentSurfaceRoutes: { await probe.current() },
|
|
refreshNodeSurfaceRoute: { observed in await probe.reconnect(observed: observed) },
|
|
refreshOperatorSurfaceRoute: { _ in nil })
|
|
|
|
#expect(resolved?.url == failedURL)
|
|
#expect(resolved?.tlsFingerprintSHA256 == newPin)
|
|
#expect(await probe.refreshCount == 1)
|
|
}
|
|
|
|
@Test func `refreshes node once before trying the operator fallback`() async {
|
|
let target = "/__openclaw__/canvas/documents/widget-1/index.html"
|
|
let oldSurface = "https://gateway.example/__openclaw__/cap/old"
|
|
let newSurface = "https://gateway.example/__openclaw__/cap/new"
|
|
let fallbackSurface = "https://operator.example/__openclaw__/cap/fallback"
|
|
let probe = ChatWidgetRouteReconnectProbe(
|
|
route: GatewayCanvasHostRoute(url: oldSurface, tlsFingerprintSHA256: nil),
|
|
replacement: GatewayCanvasHostRoute(url: newSurface, tlsFingerprintSHA256: nil),
|
|
operatorRoute: GatewayCanvasHostRoute(url: fallbackSurface, tlsFingerprintSHA256: nil))
|
|
let initialNode = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: nil,
|
|
currentSurfaceRoutes: { await probe.current() },
|
|
refreshNodeSurfaceRoute: { _ in nil },
|
|
refreshOperatorSurfaceRoute: { _ in nil })
|
|
|
|
let refreshedNode = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: initialNode,
|
|
currentSurfaceRoutes: { await probe.current() },
|
|
refreshNodeSurfaceRoute: { observed in await probe.reconnect(observed: observed) },
|
|
refreshOperatorSurfaceRoute: { _ in nil })
|
|
|
|
#expect(refreshedNode?.url == OpenClawChatWidgetURLResolver.resolve(surfaceURL: newSurface, target: target))
|
|
|
|
let fallback = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: refreshedNode,
|
|
currentSurfaceRoutes: { await probe.current() },
|
|
refreshNodeSurfaceRoute: { observed in await probe.reconnect(observed: observed) },
|
|
refreshOperatorSurfaceRoute: { _ in nil })
|
|
|
|
#expect(fallback?.url == OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: fallbackSurface,
|
|
target: target))
|
|
#expect(await probe.refreshCount == 1)
|
|
}
|
|
|
|
@Test func `refreshes the operator capability when the node route is unavailable`() async throws {
|
|
let target = "/__openclaw__/canvas/documents/widget-1/index.html"
|
|
let oldSurface = "https://operator.example/__openclaw__/cap/old"
|
|
let newSurface = "https://operator.example/__openclaw__/cap/new"
|
|
let failedURL = try #require(OpenClawChatWidgetURLResolver.resolve(
|
|
surfaceURL: oldSurface,
|
|
target: target))
|
|
let failedResource = OpenClawChatWidgetResource(url: failedURL)
|
|
let probe = ChatWidgetOperatorRouteRefreshProbe(
|
|
route: GatewayCanvasHostRoute(url: oldSurface, tlsFingerprintSHA256: nil),
|
|
replacement: GatewayCanvasHostRoute(url: newSurface, tlsFingerprintSHA256: nil))
|
|
|
|
let resolved = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: failedResource,
|
|
currentSurfaceRoutes: { await probe.current() },
|
|
refreshNodeSurfaceRoute: { _ in nil },
|
|
refreshOperatorSurfaceRoute: { observed in await probe.refresh(observed: observed) })
|
|
|
|
#expect(resolved?.url == OpenClawChatWidgetURLResolver.resolve(surfaceURL: newSurface, target: target))
|
|
#expect(await probe.refreshCount == 1)
|
|
}
|
|
|
|
@Test func `rejects a TLS pin on a cleartext widget route`() async {
|
|
let target = "/__openclaw__/canvas/documents/widget-1/index.html"
|
|
let route = GatewayCanvasHostRoute(
|
|
url: "http://gateway.example/__openclaw__/cap/token",
|
|
tlsFingerprintSHA256: String(repeating: "aa", count: 32))
|
|
|
|
let resolved = await OpenClawChatWidgetURLResolver.resolveResource(
|
|
target: target,
|
|
replacing: nil,
|
|
currentSurfaceRoutes: { (node: route, operatorSurface: nil) },
|
|
refreshNodeSurfaceRoute: { _ in nil },
|
|
refreshOperatorSurfaceRoute: { _ in nil })
|
|
|
|
#expect(resolved == nil)
|
|
}
|
|
|
|
#if canImport(WebKit) && (os(iOS) || os(macOS))
|
|
#if os(macOS)
|
|
@Test @MainActor func `invalidated widget snapshots cannot publish a late completion`() throws {
|
|
let fixture = try ChatInlineWidgetSnapshotFixture()
|
|
fixture.capture(fixture.request())
|
|
|
|
fixture.owner.invalidate()
|
|
try fixture.complete()
|
|
|
|
#expect(fixture.outcomes.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor func `widget snapshots reject a changed document or generation before capture`() throws {
|
|
let fixture = try ChatInlineWidgetSnapshotFixture()
|
|
let replacement = try OpenClawChatWidgetResource(
|
|
url: #require(URL(string: "https://gateway.example/replacement")))
|
|
let changedPin = OpenClawChatWidgetResource(
|
|
url: fixture.resource.url,
|
|
tlsFingerprintSHA256: String(repeating: "ab", count: 32))
|
|
|
|
fixture.capture(fixture.request(), resource: replacement)
|
|
fixture.capture(fixture.request(), resource: changedPin)
|
|
fixture.capture(fixture.request(generation: UUID()))
|
|
|
|
#expect(fixture.webView.completions.isEmpty)
|
|
#expect(fixture.outcomes.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor func `widget snapshot completions cannot outlive document or TLS replacements`() throws {
|
|
let oldURL = try #require(URL(string: "https://gateway.example/old"))
|
|
let newURL = try #require(URL(string: "https://gateway.example/replacement"))
|
|
let replacements = [
|
|
OpenClawChatWidgetResource(url: newURL),
|
|
OpenClawChatWidgetResource(url: oldURL, tlsFingerprintSHA256: String(repeating: "ab", count: 32)),
|
|
]
|
|
|
|
for replacement in replacements {
|
|
let fixture = try ChatInlineWidgetSnapshotFixture()
|
|
fixture.capture(fixture.request())
|
|
fixture.capture(nil, resource: replacement)
|
|
|
|
try fixture.complete()
|
|
|
|
#expect(fixture.outcomes.isEmpty)
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func `widget snapshot completions cannot outlive a reset generation`() throws {
|
|
let fixture = try ChatInlineWidgetSnapshotFixture()
|
|
fixture.capture(fixture.request())
|
|
fixture.capture(nil, generation: UUID())
|
|
|
|
try fixture.complete()
|
|
|
|
#expect(fixture.outcomes.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor func `widget snapshot completions cannot migrate to a replacement WebView`() throws {
|
|
let fixture = try ChatInlineWidgetSnapshotFixture()
|
|
let replacement = ChatInlineWidgetDeferredSnapshotWebView(
|
|
frame: .zero,
|
|
configuration: WKWebViewConfiguration())
|
|
fixture.capture(fixture.request())
|
|
fixture.capture(nil, from: replacement)
|
|
|
|
try fixture.complete()
|
|
|
|
#expect(fixture.outcomes.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor func `newer widget snapshots supersede older asynchronous completions`() throws {
|
|
let fixture = try ChatInlineWidgetSnapshotFixture()
|
|
let first = fixture.request(action: .copy)
|
|
let second = fixture.request(action: .save)
|
|
fixture.capture(first)
|
|
fixture.capture(second)
|
|
|
|
try fixture.complete(at: 0)
|
|
#expect(fixture.outcomes.isEmpty)
|
|
|
|
try fixture.complete(at: 1)
|
|
guard case let .success(request, _) = try #require(fixture.outcomes.first) else {
|
|
Issue.record("The current widget snapshot did not produce an image")
|
|
return
|
|
}
|
|
#expect(request == second)
|
|
}
|
|
|
|
@Test @MainActor func `current widget snapshots publish one image for the exact document`() throws {
|
|
let fixture = try ChatInlineWidgetSnapshotFixture()
|
|
let request = fixture.request()
|
|
fixture.capture(request)
|
|
fixture.capture(request)
|
|
|
|
#expect(fixture.webView.completions.count == 1)
|
|
try fixture.complete()
|
|
|
|
guard case let .success(completed, _) = try #require(fixture.outcomes.first) else {
|
|
Issue.record("The current widget snapshot did not produce an image")
|
|
return
|
|
}
|
|
#expect(completed == request)
|
|
#expect(fixture.outcomes.count == 1)
|
|
}
|
|
#endif
|
|
|
|
@Test func `bounds WebKit content process recovery per document`() {
|
|
var recovery = ChatInlineWidgetContentProcessRecovery()
|
|
|
|
#expect(recovery.nextAction() == .reload)
|
|
#expect(recovery.nextAction() == .fail)
|
|
#expect(recovery.nextAction() == .fail)
|
|
|
|
recovery.reset()
|
|
#expect(recovery.nextAction() == .reload)
|
|
}
|
|
|
|
@Test func `normalizes exact SHA-256 widget certificate pins`() {
|
|
let fingerprint = String(repeating: "aB", count: 32)
|
|
#expect(ChatInlineWidgetTLSPin.normalize("SHA-256: \(fingerprint)") == fingerprint.lowercased())
|
|
#expect(ChatInlineWidgetTLSPin.normalize("abc") == nil)
|
|
#expect(ChatInlineWidgetTLSPin.fingerprint(certificateData: Data("abc".utf8)) ==
|
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS) && canImport(WebKit)
|
|
@MainActor
|
|
private final class ChatInlineWidgetSnapshotFixture {
|
|
let resource: OpenClawChatWidgetResource
|
|
let generation = UUID()
|
|
let webView = ChatInlineWidgetDeferredSnapshotWebView(
|
|
frame: .zero,
|
|
configuration: WKWebViewConfiguration())
|
|
let owner = ChatInlineWidgetSnapshotCapture()
|
|
var outcomes: [ChatInlineWidgetSnapshotOutcome] = []
|
|
|
|
init() throws {
|
|
self.resource = try OpenClawChatWidgetResource(
|
|
url: #require(URL(string: "https://gateway.example/old")))
|
|
}
|
|
|
|
func request(
|
|
action: ChatInlineWidgetSnapshotRequest.Action = .copy,
|
|
generation: UUID? = nil,
|
|
resource: OpenClawChatWidgetResource? = nil) -> ChatInlineWidgetSnapshotRequest
|
|
{
|
|
ChatInlineWidgetSnapshotRequest(
|
|
action: action,
|
|
generation: generation ?? self.generation,
|
|
resource: resource ?? self.resource)
|
|
}
|
|
|
|
func capture(
|
|
_ request: ChatInlineWidgetSnapshotRequest?,
|
|
from webView: WKWebView? = nil,
|
|
generation: UUID? = nil,
|
|
resource: OpenClawChatWidgetResource? = nil)
|
|
{
|
|
self.owner.capture(
|
|
request,
|
|
from: webView ?? self.webView,
|
|
generation: generation ?? self.generation,
|
|
resource: resource ?? self.resource)
|
|
{ [weak self] outcome in
|
|
self?.outcomes.append(outcome)
|
|
}
|
|
}
|
|
|
|
func complete(at index: Int = 0) throws {
|
|
let completion = try #require(self.webView.completions.indices.contains(index)
|
|
? self.webView.completions[index]
|
|
: nil)
|
|
completion(NSImage(size: NSSize(width: 1, height: 1)), nil)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class ChatInlineWidgetDeferredSnapshotWebView: WKWebView {
|
|
var completions: [@MainActor (NSImage?, (any Error)?) -> Void] = []
|
|
|
|
override func takeSnapshot(
|
|
with _: WKSnapshotConfiguration?,
|
|
completionHandler: @escaping @MainActor (NSImage?, (any Error)?) -> Void)
|
|
{
|
|
self.completions.append(completionHandler)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private actor ChatWidgetOperatorRouteRefreshProbe {
|
|
private var route: GatewayCanvasHostRoute
|
|
private let replacement: GatewayCanvasHostRoute
|
|
private(set) var refreshCount = 0
|
|
|
|
init(route: GatewayCanvasHostRoute, replacement: GatewayCanvasHostRoute) {
|
|
self.route = route
|
|
self.replacement = replacement
|
|
}
|
|
|
|
func current() -> (node: GatewayCanvasHostRoute?, operatorSurface: GatewayCanvasHostRoute?) {
|
|
(node: nil, operatorSurface: self.route)
|
|
}
|
|
|
|
func refresh(observed _: GatewayCanvasHostRoute?) -> GatewayCanvasHostRoute? {
|
|
self.refreshCount += 1
|
|
self.route = self.replacement
|
|
return self.replacement
|
|
}
|
|
}
|
|
|
|
private actor ChatWidgetRouteReconnectProbe {
|
|
private var route: GatewayCanvasHostRoute?
|
|
private let replacement: GatewayCanvasHostRoute
|
|
private let operatorRoute: GatewayCanvasHostRoute?
|
|
private(set) var refreshCount = 0
|
|
|
|
init(
|
|
route: GatewayCanvasHostRoute,
|
|
replacement: GatewayCanvasHostRoute,
|
|
operatorRoute: GatewayCanvasHostRoute? = nil)
|
|
{
|
|
self.route = route
|
|
self.replacement = replacement
|
|
self.operatorRoute = operatorRoute
|
|
}
|
|
|
|
func current() -> (node: GatewayCanvasHostRoute?, operatorSurface: GatewayCanvasHostRoute?) {
|
|
(node: self.route, operatorSurface: self.operatorRoute)
|
|
}
|
|
|
|
func reconnect(observed _: GatewayCanvasHostRoute?) -> GatewayCanvasHostRoute? {
|
|
self.refreshCount += 1
|
|
self.route = self.replacement
|
|
return nil
|
|
}
|
|
}
|