mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 14:11:37 +00:00
* feat(macos): replace browser cookie-import modal with dashboard banner * fix(macos): cross-browser menu identity, banner pane ownership, docs map * chore: refresh native i18n inventory * fix(macos): stale import status polls no longer clobber fresher banner state * fix(macos): generation-guard stale import polls; drop dashboard-show poll trigger * fix(macos): session-sticky dismissal and localized banner copy
323 lines
12 KiB
Swift
323 lines
12 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@MainActor
|
|
private final class ContinuationBox {
|
|
var continuation: CheckedContinuation<Void, Never>?
|
|
}
|
|
|
|
@MainActor
|
|
private final class BrowserImportTransportStub {
|
|
struct StubError: Error, LocalizedError {
|
|
var errorDescription: String? {
|
|
"boom"
|
|
}
|
|
}
|
|
|
|
var requests: [BrowserProfileImportRequest] = []
|
|
var failingPaths: Set<String> = []
|
|
var beforeStatusResponse: (@MainActor () async -> Void)?
|
|
var statusJSON = """
|
|
{
|
|
"enabled": true,
|
|
"systemProfiles": [
|
|
{ "browser": "chrome", "id": "Default", "name": "Personal", "hasCookies": true }
|
|
],
|
|
"state": null,
|
|
"suggestedTarget": "imported"
|
|
}
|
|
"""
|
|
|
|
func makeModel(isLocalMode: @escaping @MainActor () -> Bool = { true }) -> BrowserProfileImportModel {
|
|
BrowserProfileImportModel(
|
|
transport: { [weak self] request in
|
|
guard let self else { throw StubError() }
|
|
self.requests.append(request)
|
|
if request.path == "/system-profile-import/status", let hook = self.beforeStatusResponse {
|
|
self.beforeStatusResponse = nil
|
|
await hook()
|
|
}
|
|
if self.failingPaths.contains(request.path) {
|
|
throw StubError()
|
|
}
|
|
switch request.path {
|
|
case "/system-profile-import/status":
|
|
return Data(self.statusJSON.utf8)
|
|
case "/profiles/import":
|
|
return Data(#"{"into":"imported","cookies":{"total":412,"imported":409}}"#.utf8)
|
|
default:
|
|
return Data(#"{"ok":true}"#.utf8)
|
|
}
|
|
},
|
|
isOnboarded: { true },
|
|
isLocalMode: isLocalMode)
|
|
}
|
|
|
|
func requests(for path: String) -> [BrowserProfileImportRequest] {
|
|
self.requests.filter { $0.path == path }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct BrowserProfileImportModelTests {
|
|
private static func status(
|
|
profiles: [BrowserSystemProfile],
|
|
enabled: Bool = true,
|
|
state: BrowserProfileImportOutcome? = nil) -> BrowserProfileImportStatus
|
|
{
|
|
BrowserProfileImportStatus(
|
|
enabled: enabled,
|
|
systemProfiles: profiles,
|
|
state: state,
|
|
suggestedTarget: "imported")
|
|
}
|
|
|
|
private static let chromeProfile = BrowserSystemProfile(
|
|
browser: "chrome",
|
|
id: "Default",
|
|
name: "Personal",
|
|
hasCookies: true)
|
|
|
|
@Test func `automatic offer requires importable cookies and no prior outcome`() {
|
|
let fresh = Self.status(profiles: [Self.chromeProfile])
|
|
#expect(BrowserProfileImportModel.shouldOffer(status: fresh, force: false))
|
|
|
|
let dismissed = Self.status(
|
|
profiles: [Self.chromeProfile],
|
|
state: BrowserProfileImportOutcome(status: .dismissed))
|
|
#expect(!BrowserProfileImportModel.shouldOffer(status: dismissed, force: false))
|
|
#expect(BrowserProfileImportModel.shouldOffer(status: dismissed, force: true))
|
|
}
|
|
|
|
@Test func `profiles without cookies do not trigger import`() {
|
|
let status = Self.status(profiles: [
|
|
BrowserSystemProfile(browser: "brave", id: "Default", name: "Default", hasCookies: false),
|
|
])
|
|
#expect(!BrowserProfileImportModel.shouldOffer(status: status, force: true))
|
|
}
|
|
|
|
@Test func `disabled import never offers`() {
|
|
let status = Self.status(profiles: [Self.chromeProfile], enabled: false)
|
|
#expect(!BrowserProfileImportModel.shouldOffer(status: status, force: true))
|
|
}
|
|
|
|
@Test func `refresh publishes the offer banner`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
let outcome = await model.refresh(force: false)
|
|
#expect(outcome == .offering)
|
|
guard case let .offering(status) = model.phase else {
|
|
Issue.record("expected offering phase, got \(model.phase)")
|
|
return
|
|
}
|
|
#expect(status.importableProfiles == [Self.chromeProfile])
|
|
}
|
|
|
|
@Test func `remote gateways hide the banner and report local-mode requirement`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel(isLocalMode: { false })
|
|
let outcome = await model.refresh(force: true)
|
|
#expect(outcome == .unavailable(
|
|
title: "Browser import requires Local mode",
|
|
message: "Switch this Mac app to a local Gateway before importing browser cookies."))
|
|
#expect(model.phase == .hidden)
|
|
#expect(stub.requests.isEmpty)
|
|
}
|
|
|
|
@Test func `import success records counts and target`() async throws {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
await model.refresh(force: false)
|
|
await model.importProfile(Self.chromeProfile)
|
|
|
|
let expected = BrowserProfileImportResult(
|
|
into: "imported",
|
|
cookies: BrowserProfileImportResult.Counts(total: 412, imported: 409))
|
|
#expect(model.phase == .imported(expected))
|
|
|
|
let importRequest = try #require(stub.requests(for: "/profiles/import").first)
|
|
#expect(importRequest.method == "POST")
|
|
#expect(importRequest.timeoutMs == 120_000)
|
|
let body = try #require(importRequest.body)
|
|
let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any]
|
|
#expect(object?["browser"] as? String == "chrome")
|
|
#expect(object?["systemProfile"] as? String == "Default")
|
|
#expect(object?["into"] as? String == "imported")
|
|
#expect(object?["makeDefault"] as? Bool == true)
|
|
}
|
|
|
|
@Test func `import failure keeps a retry path`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
stub.failingPaths = ["/profiles/import"]
|
|
let model = stub.makeModel()
|
|
await model.refresh(force: false)
|
|
await model.importProfile(Self.chromeProfile)
|
|
|
|
guard case let .failed(message, retry) = model.phase else {
|
|
Issue.record("expected failed phase, got \(model.phase)")
|
|
return
|
|
}
|
|
#expect(message == "boom")
|
|
#expect(retry.suggestedTarget == "imported")
|
|
|
|
model.retry()
|
|
#expect(model.phase == .offering(retry))
|
|
}
|
|
|
|
@Test func `dismissing an offer persists the dismissal`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
await model.refresh(force: false)
|
|
model.dismiss()
|
|
#expect(model.phase == .hidden)
|
|
for _ in 0..<200 where stub.requests(for: "/system-profile-import/dismiss").isEmpty {
|
|
await Task.yield()
|
|
}
|
|
let dismissals = stub.requests(for: "/system-profile-import/dismiss")
|
|
#expect(dismissals.count == 1)
|
|
#expect(dismissals.first?.method == "POST")
|
|
}
|
|
|
|
@Test func `dismissing a result banner does not overwrite the recorded outcome`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
await model.refresh(force: false)
|
|
await model.importProfile(Self.chromeProfile)
|
|
model.dismiss()
|
|
#expect(model.phase == .hidden)
|
|
for _ in 0..<50 {
|
|
await Task.yield()
|
|
}
|
|
#expect(stub.requests(for: "/system-profile-import/dismiss").isEmpty)
|
|
}
|
|
|
|
@Test func `stale idle poll never clobbers a phase set while it was in flight`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
let gate = ContinuationBox()
|
|
stub.beforeStatusResponse = {
|
|
await withCheckedContinuation { gate.continuation = $0 }
|
|
}
|
|
|
|
let idle = Task { await model.refreshIfIdle() }
|
|
while gate.continuation == nil {
|
|
await Task.yield()
|
|
}
|
|
|
|
// A forced Settings offer lands while the idle poll is suspended.
|
|
let forced = Self.status(
|
|
profiles: [Self.chromeProfile],
|
|
state: BrowserProfileImportOutcome(status: .dismissed))
|
|
model._testSetPhase(.offering(forced))
|
|
|
|
gate.continuation?.resume()
|
|
_ = await idle.value
|
|
#expect(model.phase == .offering(forced))
|
|
}
|
|
|
|
@Test func `dismissal suppresses automatic re-offers for the session`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
await model.refresh(force: false)
|
|
model.dismiss()
|
|
#expect(model.phase == .hidden)
|
|
|
|
// The persistence write may still be pending or lost; the server keeps
|
|
// reporting an importable profile, but automatic polls must not re-offer.
|
|
let statusPolls = stub.requests(for: "/system-profile-import/status").count
|
|
let refreshed = await model.refreshIfIdle()
|
|
#expect(!refreshed)
|
|
#expect(stub.requests(for: "/system-profile-import/status").count == statusPolls)
|
|
#expect(model.phase == .hidden)
|
|
|
|
// Settings force-refresh still overrides the session suppression.
|
|
let outcome = await model.refresh(force: true)
|
|
#expect(outcome == .offering)
|
|
}
|
|
|
|
@Test func `stale idle poll does not resurrect a dismissed offer`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
let gate = ContinuationBox()
|
|
stub.beforeStatusResponse = {
|
|
await withCheckedContinuation { gate.continuation = $0 }
|
|
}
|
|
|
|
let idle = Task { await model.refreshIfIdle() }
|
|
while gate.continuation == nil {
|
|
await Task.yield()
|
|
}
|
|
|
|
// A faster poll offered the banner and the user dismissed it while the
|
|
// slow poll was still waiting on its (pre-dismissal) status payload.
|
|
model._testSetPhase(.offering(Self.status(profiles: [Self.chromeProfile])))
|
|
model.dismiss()
|
|
#expect(model.phase == .hidden)
|
|
|
|
gate.continuation?.resume()
|
|
_ = await idle.value
|
|
#expect(model.phase == .hidden)
|
|
}
|
|
|
|
@Test func `idle refresh never clobbers a visible outcome`() async {
|
|
let stub = BrowserImportTransportStub()
|
|
let model = stub.makeModel()
|
|
await model.refresh(force: false)
|
|
await model.importProfile(Self.chromeProfile)
|
|
let requestCount = stub.requests.count
|
|
|
|
let refreshed = await model.refreshIfIdle()
|
|
#expect(!refreshed)
|
|
#expect(stub.requests.count == requestCount)
|
|
guard case .imported = model.phase else {
|
|
Issue.record("expected imported phase, got \(model.phase)")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct BrowserProfileImportBannerContentTests {
|
|
@Test func `offering banner lists distinct browsers and offers all profiles`() throws {
|
|
let profiles = [
|
|
BrowserSystemProfile(browser: "chrome", id: "Default", name: "Personal", hasCookies: true),
|
|
BrowserSystemProfile(browser: "chrome", id: "Profile 1", name: "Work", hasCookies: true),
|
|
BrowserSystemProfile(browser: "brave", id: "Default", name: "Default", hasCookies: true),
|
|
]
|
|
let status = BrowserProfileImportStatus(
|
|
enabled: true,
|
|
systemProfiles: profiles,
|
|
state: nil,
|
|
suggestedTarget: "imported")
|
|
let content = try #require(BrowserProfileImportBannerContent.content(for: .offering(status)))
|
|
#expect(content.title == "Use your browser logins")
|
|
#expect(content.subtitle.contains("Chrome and Brave"))
|
|
#expect(content.badge == .globe)
|
|
#expect(content.action == .importProfiles(profiles))
|
|
}
|
|
|
|
@Test func `result banners carry counts and errors`() throws {
|
|
let result = BrowserProfileImportResult(
|
|
into: "imported",
|
|
cookies: BrowserProfileImportResult.Counts(total: 10, imported: 9))
|
|
let success = try #require(BrowserProfileImportBannerContent.content(for: .imported(result)))
|
|
#expect(success.subtitle.contains("9 of 10 cookies"))
|
|
#expect(success.badge == .success)
|
|
#expect(success.action == .none)
|
|
|
|
let status = BrowserProfileImportStatus(
|
|
enabled: true,
|
|
systemProfiles: [],
|
|
state: nil,
|
|
suggestedTarget: "imported")
|
|
let failure = try #require(BrowserProfileImportBannerContent.content(
|
|
for: .failed(message: "boom", retry: status)))
|
|
#expect(failure.subtitle == "boom")
|
|
#expect(failure.badge == .failure)
|
|
#expect(failure.action == .retry)
|
|
|
|
#expect(BrowserProfileImportBannerContent.content(for: .hidden) == nil)
|
|
}
|
|
}
|