Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CLI/CMUXCLI+DocsSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,9 +370,9 @@ extension CMUXCLI {

Targets:
account, app, terminal, networking, sidebar-appearance,
custom-sidebars, automation, browser, browser-import,
global-hotkey, keyboard-shortcuts, shortcuts, workspace-colors,
cmux-json, json, reset
custom-sidebars, beta-features, automation, browser,
browser-import, global-hotkey, keyboard-shortcuts, shortcuts,
workspace-colors, cmux-json, json, reset

Config file:
\(Self.primarySettingsDisplayPath)
Expand Down Expand Up @@ -407,6 +407,8 @@ extension CMUXCLI {
return "sidebarAppearance"
case "custom-sidebars", "customsidebars":
return "customSidebars"
case "beta-features", "betafeatures", "beta":
return "betaFeatures"
case "automation":
return "automation"
case "browser":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ extension ControlCommandCoordinator {
return debugShortcutSimulate(request.params)
case "debug.type":
return debugType(request.params)
case "debug.beta_remote_defaults.get":
return debugBetaRemoteDefaultGet(request.params)
case "debug.beta_remote_defaults.set":
return debugBetaRemoteDefaultSet(request.params)
case "debug.textbox.inline_fixture":
return debugTextBoxInlineFixture(request.params)
case "debug.textbox.interact":
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#if DEBUG
extension ControlCommandCoordinator {
func debugBetaRemoteDefaultGet(
_ params: [String: JSONValue]
) -> ControlCallResult {
guard let debugContext else {
return .err(code: "unavailable", message: Self.debugContextUnavailableResponse, data: nil)
}
let strings = debugContext.controlDebugBetaRemoteDefaultStrings()
guard let key = string(params, "key") else {
return .err(code: "invalid_params", message: strings.missingKey, data: nil)
}
guard let snapshot = debugContext.controlDebugBetaRemoteDefaultSnapshot(
identifier: key
) else {
return .err(code: "not_found", message: strings.notFound, data: nil)
}
return debugBetaRemoteDefaultResult(snapshot)
}

func debugBetaRemoteDefaultSet(
_ params: [String: JSONValue]
) -> ControlCallResult {
guard let debugContext else {
return .err(code: "unavailable", message: Self.debugContextUnavailableResponse, data: nil)
}
let strings = debugContext.controlDebugBetaRemoteDefaultStrings()
guard let key = string(params, "key") else {
return .err(code: "invalid_params", message: strings.missingKey, data: nil)
}
guard let rawValue = params["value"] else {
return .err(code: "invalid_params", message: strings.missingValue, data: nil)
}
let value: Bool?
switch rawValue {
case .bool(let enabled):
value = enabled
case .null:
value = nil
default:
return .err(
code: "invalid_params",
message: strings.invalidValue,
data: nil
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
guard let snapshot = debugContext.controlDebugSetBetaRemoteDefault(
identifier: key,
value: value
) else {
return .err(code: "not_found", message: strings.notFound, data: nil)
}
return debugBetaRemoteDefaultResult(snapshot)
}

private func debugBetaRemoteDefaultResult(
_ snapshot: ControlDebugBetaRemoteDefaultSnapshot
) -> ControlCallResult {
.ok(.object([
"setting_id": .string(snapshot.settingID),
"flag_key": .string(snapshot.flagKey),
"user_key_present": .bool(snapshot.userKeyPresent),
"user_value": snapshot.userValue.map { .bool($0) } ?? .null,
"remote_default": snapshot.remoteDefault.map { .bool($0) } ?? .null,
"effective_value": .bool(snapshot.effectiveValue),
"source": .string(snapshot.source),
]))
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#if DEBUG
/// A DEBUG readback of one Beta Features remote-default resolution.
public struct ControlDebugBetaRemoteDefaultSnapshot: Sendable, Equatable {
public let settingID: String
public let flagKey: String
public let userKeyPresent: Bool
public let userValue: Bool?
public let remoteDefault: Bool?
public let effectiveValue: Bool
public let source: String

public init(
settingID: String,
flagKey: String,
userKeyPresent: Bool,
userValue: Bool?,
remoteDefault: Bool?,
effectiveValue: Bool,
source: String
) {
self.settingID = settingID
self.flagKey = flagKey
self.userKeyPresent = userKeyPresent
self.userValue = userValue
self.remoteDefault = remoteDefault
self.effectiveValue = effectiveValue
self.source = source
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// App-bundle-resolved validation messages for the beta remote-default debug
/// RPCs. The coordinator package has no copy of the app's localization catalog,
/// so its context supplies these strings.
public struct ControlDebugBetaRemoteDefaultStrings: Sendable, Equatable {
public let missingKey: String
public let notFound: String
public let missingValue: String
public let invalidValue: String

public init(
missingKey: String,
notFound: String,
missingValue: String,
invalidValue: String
) {
self.missingKey = missingKey
self.notFound = notFound
self.missingValue = missingValue
self.invalidValue = invalidValue
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ public protocol ControlDebugContext: AnyObject {

// MARK: - Live app/UI state

/// App-bundle-localized validation messages for the beta remote-default
/// debug RPCs.
func controlDebugBetaRemoteDefaultStrings() -> ControlDebugBetaRemoteDefaultStrings

/// Reads one beta remote-default setting by setting id or PostHog key.
func controlDebugBetaRemoteDefaultSnapshot(
identifier: String
) -> ControlDebugBetaRemoteDefaultSnapshot?

/// Changes only one beta setting's remote-default cache layer, then reads
/// back its effective value and provenance.
func controlDebugSetBetaRemoteDefault(
identifier: String,
value: Bool?
) -> ControlDebugBetaRemoteDefaultSnapshot?

/// Inserts text at the key window's first responder for `debug.type`
/// (activating the app first when the focus policy allows it).
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ extension ControlDebugContext {
func controlDebugShowCanvasCommandScrollHint(
routing: ControlRoutingSelectors
) -> ControlCanvasActionResolution { .tabManagerUnavailable }
func controlDebugBetaRemoteDefaultStrings() -> ControlDebugBetaRemoteDefaultStrings {
ControlDebugBetaRemoteDefaultStrings(
missingKey: "Missing key",
notFound: "Beta remote default not found",
missingValue: "Missing value",
invalidValue: "value must be a bool or null"
)
}
func controlDebugBetaRemoteDefaultSnapshot(
identifier: String
) -> ControlDebugBetaRemoteDefaultSnapshot? { nil }
func controlDebugSetBetaRemoteDefault(
identifier: String,
value: Bool?
) -> ControlDebugBetaRemoteDefaultSnapshot? { nil }
func controlDebugTypeText(_ text: String) -> ControlDebugTypeResolution { .noWindow }
func controlDebugTabManagerAvailable() -> Bool { false }
func controlDebugTextBoxInlineFixture(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import Foundation
import Testing

@testable import CmuxControlSocket

#if DEBUG
@MainActor
@Suite("ControlCommandCoordinator beta remote-default debug dispatch")
struct ControlCommandCoordinatorDebugBetaRemoteDefaultsTests {
@Test func getReturnsTypedResolutionReadback() {
let context = FakeBetaRemoteDefaultControlCommandContext()
let coordinator = ControlCommandCoordinator(context: context)

let result = coordinator.handle(
ControlRequest(
id: .int(1),
method: "debug.beta_remote_defaults.get",
params: ["key": .string("tests.beta.enabled")]
)
)

#expect(result == .ok(.object([
"setting_id": .string("tests.beta.enabled"),
"flag_key": .string("tests-beta-default-experiment"),
"user_key_present": .bool(false),
"user_value": .null,
"remote_default": .bool(true),
"effective_value": .bool(true),
"source": .string("remoteDefault"),
])))
}

@Test func setAcceptsNullAsCacheClearAndRejectsOtherTypes() {
let context = FakeBetaRemoteDefaultControlCommandContext()
let coordinator = ControlCommandCoordinator(context: context)

let cleared = coordinator.handle(
ControlRequest(
id: .int(1),
method: "debug.beta_remote_defaults.set",
params: [
"key": .string("tests.beta.enabled"),
"value": .null,
]
)
)
#expect(context.lastValueWasSet)
#expect(context.lastValue == nil)
guard case .ok = cleared else {
Issue.record("expected cache clear to succeed")
return
}

guard case .err(let code, _, let data) = coordinator.handle(
ControlRequest(
id: .int(2),
method: "debug.beta_remote_defaults.set",
params: [
"key": .string("tests.beta.enabled"),
"value": .string("true"),
]
)
) else {
Issue.record("expected invalid value to fail")
return
}
#expect(code == "invalid_params")
#expect(data == nil)
}

@Test func validationErrorsUseContextProvidedStrings() {
let context = FakeBetaRemoteDefaultControlCommandContext()
context.strings = ControlDebugBetaRemoteDefaultStrings(
missingKey: "localized missing key",
notFound: "localized not found",
missingValue: "localized missing value",
invalidValue: "localized invalid value"
)
let coordinator = ControlCommandCoordinator(context: context)

guard case .err(_, let missingKey, _) = coordinator.handle(
ControlRequest(
id: .int(1),
method: "debug.beta_remote_defaults.get",
params: [:]
)
) else {
Issue.record("expected missing-key error")
return
}
guard case .err(_, let invalidValue, _) = coordinator.handle(
ControlRequest(
id: .int(2),
method: "debug.beta_remote_defaults.set",
params: [
"key": .string("tests.beta.enabled"),
"value": .string("true"),
]
)
) else {
Issue.record("expected invalid-value error")
return
}
#expect(missingKey == "localized missing key")
#expect(invalidValue == "localized invalid value")
}
}

@MainActor
private final class FakeBetaRemoteDefaultControlCommandContext: ControlCommandContext {
var lastValueWasSet = false
var lastValue: Bool?
var strings = ControlDebugBetaRemoteDefaultStrings(
missingKey: "Missing key",
notFound: "Beta remote default not found",
missingValue: "Missing value",
invalidValue: "value must be a bool or null"
)

func controlDebugBetaRemoteDefaultStrings() -> ControlDebugBetaRemoteDefaultStrings {
strings
}

func controlDebugBetaRemoteDefaultSnapshot(
identifier: String
) -> ControlDebugBetaRemoteDefaultSnapshot? {
snapshot(remoteDefault: true)
}

func controlDebugSetBetaRemoteDefault(
identifier: String,
value: Bool?
) -> ControlDebugBetaRemoteDefaultSnapshot? {
lastValueWasSet = true
lastValue = value
return snapshot(remoteDefault: value)
}

private func snapshot(
remoteDefault: Bool?
) -> ControlDebugBetaRemoteDefaultSnapshot {
ControlDebugBetaRemoteDefaultSnapshot(
settingID: "tests.beta.enabled",
flagKey: "tests-beta-default-experiment",
userKeyPresent: false,
userValue: nil,
remoteDefault: remoteDefault,
effectiveValue: remoteDefault ?? false,
source: remoteDefault == nil ? "compileDefault" : "remoteDefault"
)
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ public protocol SettingCodable: Sendable, Equatable {
/// - Parameter raw: A value returned by `UserDefaults.object(forKey:)`, or
/// `nil` when the key has no override.
/// - Returns: The decoded value, or `nil` when `raw` is `nil` or has an
/// unexpected shape. The store falls back to the key's default value on
/// `nil`.
/// unexpected shape. `DefaultsKey` then continues through inherited
/// remote-default and compile-default resolution.
static func decodeFromUserDefaults(_ raw: Any?) -> Self?

/// Encodes a value for storage in `UserDefaults`.
Expand Down
Loading
Loading