* Pre-launch app for browser UI test on headless CI runners
XCUIApplication.launch() blocks ~60s then fails on headless WarpBuild
runners because foreground activation requires a GUI login session.
Apply the same pre-launch strategy used for the display resolution test:
- CI shell launches the app with env vars before running xcodebuild
- Test detects pre-launched app via manifest, uses activate() instead of
launch() to avoid killing and relaunching the app
- Falls back to clicking the window for focus via accessibility framework
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Revert "Pre-launch app for browser UI test on headless CI runners"
This reverts commit a540e2fd99aaa1395b91a8d50caa797cdd7551b8.
* feat: cmux.json for custom commands
* tests: add cmux json tests
* fix: pr review feedback: validation, translations, input handling, and palette improvements
- Fix Danish ("Overfladedef inition") and Norwegian ("rotmapp") translation typos
- Add empty-string check for baseCwd fallback in command palette handlers
- Coalesce \r\n into single Return keypress in sendInput
- Redact command text from timeout log to prevent secret leakage
- Add decode-time validation: reject hybrid/empty commands, ambiguous layout
nodes, wrong split children count, and empty pane surfaces
- Namespace custom command IDs with "cmux.config.command." prefix
- Forward command description to palette subtitle when available
- Update tests for new validation rules and ID prefix
* fix: address PR review feedback — per-window config isolation, blank validation, ancestor walk,
palette sanitization
* fix: fallback to current dir cmux.json watching if no any cmux.json found in full acesor walk
* ci: trigger CI for fork PR
* Add directory trust for cmux.json command confirmation
The confirm dialog now shows the actual command text and has an "Always
trust commands from this folder" checkbox. When checked, future confirm
commands from that directory skip the dialog.
Trust is scoped to the git repo root if the cmux.json is inside a repo,
so trusting once covers all subdirectories. Non-git directories are
trusted by exact path. Global config is always trusted.
Trusted directories are persisted in ~/Library/Application Support/cmux/
trusted-directories.json.
* Add trusted directories section to Settings
Shows all trusted directories with per-directory revoke buttons and a
Clear All option. Placed in a "Custom Commands" section between
Automation and Browser in Settings.
* Replace trusted directories list with editable textarea
One path per line, with a Save button that activates on changes.
Users can add, remove, or edit paths directly.
* Auto-save trusted directories on edit, remove Save button
Matches the behavior of other textarea settings (browser host
whitelist, external URL patterns) which auto-save via @AppStorage.
* Sanitize command text in confirm dialog against BiDi attacks
Strip zero-width and BiDi override characters from the command preview
so the dialog shows exactly what will be executed.
---------
Co-authored-by: austinpower1258 <austinwang115@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
692 lines
20 KiB
Swift
692 lines
20 KiB
Swift
import XCTest
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
// MARK: - JSON Decoding
|
|
|
|
final class CmuxConfigDecodingTests: XCTestCase {
|
|
|
|
private func decode(_ json: String) throws -> CmuxConfigFile {
|
|
let data = json.data(using: .utf8)!
|
|
return try JSONDecoder().decode(CmuxConfigFile.self, from: data)
|
|
}
|
|
|
|
// MARK: Simple commands
|
|
|
|
func testDecodeSimpleCommand() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "Run tests",
|
|
"command": "npm test"
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
XCTAssertEqual(config.commands.count, 1)
|
|
XCTAssertEqual(config.commands[0].name, "Run tests")
|
|
XCTAssertEqual(config.commands[0].command, "npm test")
|
|
XCTAssertNil(config.commands[0].workspace)
|
|
}
|
|
|
|
func testDecodeSimpleCommandWithAllFields() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "Deploy",
|
|
"description": "Deploy to production",
|
|
"keywords": ["ship", "release"],
|
|
"command": "make deploy",
|
|
"confirm": true
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
let cmd = config.commands[0]
|
|
XCTAssertEqual(cmd.name, "Deploy")
|
|
XCTAssertEqual(cmd.description, "Deploy to production")
|
|
XCTAssertEqual(cmd.keywords, ["ship", "release"])
|
|
XCTAssertEqual(cmd.command, "make deploy")
|
|
XCTAssertEqual(cmd.confirm, true)
|
|
}
|
|
|
|
func testDecodeMultipleCommands() throws {
|
|
let json = """
|
|
{
|
|
"commands": [
|
|
{ "name": "Build", "command": "make build" },
|
|
{ "name": "Test", "command": "make test" },
|
|
{ "name": "Lint", "command": "make lint" }
|
|
]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
XCTAssertEqual(config.commands.count, 3)
|
|
XCTAssertEqual(config.commands.map(\.name), ["Build", "Test", "Lint"])
|
|
}
|
|
|
|
func testDecodeEmptyCommandsArray() throws {
|
|
let json = """
|
|
{ "commands": [] }
|
|
"""
|
|
let config = try decode(json)
|
|
XCTAssertTrue(config.commands.isEmpty)
|
|
}
|
|
|
|
// MARK: Workspace commands
|
|
|
|
func testDecodeWorkspaceCommand() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "Dev env",
|
|
"workspace": {
|
|
"name": "Development",
|
|
"cwd": "~/projects/app",
|
|
"color": "#FF5733"
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
let ws = config.commands[0].workspace
|
|
XCTAssertNotNil(ws)
|
|
XCTAssertEqual(ws?.name, "Development")
|
|
XCTAssertEqual(ws?.cwd, "~/projects/app")
|
|
XCTAssertEqual(ws?.color, "#FF5733")
|
|
}
|
|
|
|
func testDecodeRestartBehaviors() throws {
|
|
for behavior in ["recreate", "ignore", "confirm"] {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "test",
|
|
"restart": "\(behavior)",
|
|
"workspace": { "name": "ws" }
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
XCTAssertEqual(config.commands[0].restart?.rawValue, behavior)
|
|
}
|
|
}
|
|
|
|
// MARK: Layout tree
|
|
|
|
func testDecodePaneNode() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "layout",
|
|
"workspace": {
|
|
"layout": {
|
|
"pane": {
|
|
"surfaces": [
|
|
{ "type": "terminal", "name": "shell" }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
let layout = config.commands[0].workspace!.layout!
|
|
if case .pane(let pane) = layout {
|
|
XCTAssertEqual(pane.surfaces.count, 1)
|
|
XCTAssertEqual(pane.surfaces[0].type, .terminal)
|
|
XCTAssertEqual(pane.surfaces[0].name, "shell")
|
|
} else {
|
|
XCTFail("Expected pane node")
|
|
}
|
|
}
|
|
|
|
func testDecodeSplitNode() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "layout",
|
|
"workspace": {
|
|
"layout": {
|
|
"direction": "horizontal",
|
|
"split": 0.3,
|
|
"children": [
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } },
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } }
|
|
]
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
let layout = config.commands[0].workspace!.layout!
|
|
if case .split(let split) = layout {
|
|
XCTAssertEqual(split.direction, .horizontal)
|
|
XCTAssertEqual(split.split, 0.3)
|
|
XCTAssertEqual(split.children.count, 2)
|
|
} else {
|
|
XCTFail("Expected split node")
|
|
}
|
|
}
|
|
|
|
func testDecodeNestedSplits() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "nested",
|
|
"workspace": {
|
|
"layout": {
|
|
"direction": "horizontal",
|
|
"children": [
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } },
|
|
{
|
|
"direction": "vertical",
|
|
"children": [
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } },
|
|
{ "pane": { "surfaces": [{ "type": "browser", "url": "http://localhost:3000" }] } }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
let layout = config.commands[0].workspace!.layout!
|
|
if case .split(let outer) = layout {
|
|
XCTAssertEqual(outer.direction, .horizontal)
|
|
if case .split(let inner) = outer.children[1] {
|
|
XCTAssertEqual(inner.direction, .vertical)
|
|
if case .pane(let browserPane) = inner.children[1] {
|
|
XCTAssertEqual(browserPane.surfaces[0].type, .browser)
|
|
XCTAssertEqual(browserPane.surfaces[0].url, "http://localhost:3000")
|
|
} else {
|
|
XCTFail("Expected pane node for inner second child")
|
|
}
|
|
} else {
|
|
XCTFail("Expected split node for outer second child")
|
|
}
|
|
} else {
|
|
XCTFail("Expected split node")
|
|
}
|
|
}
|
|
|
|
// MARK: Surface definitions
|
|
|
|
func testDecodeTerminalSurfaceAllFields() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "test",
|
|
"workspace": {
|
|
"layout": {
|
|
"pane": {
|
|
"surfaces": [{
|
|
"type": "terminal",
|
|
"name": "server",
|
|
"command": "npm start",
|
|
"cwd": "./backend",
|
|
"env": { "NODE_ENV": "development", "PORT": "3000" },
|
|
"focus": true
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
let surface = config.commands[0].workspace!.layout!
|
|
if case .pane(let pane) = surface {
|
|
let s = pane.surfaces[0]
|
|
XCTAssertEqual(s.type, .terminal)
|
|
XCTAssertEqual(s.name, "server")
|
|
XCTAssertEqual(s.command, "npm start")
|
|
XCTAssertEqual(s.cwd, "./backend")
|
|
XCTAssertEqual(s.env, ["NODE_ENV": "development", "PORT": "3000"])
|
|
XCTAssertEqual(s.focus, true)
|
|
XCTAssertNil(s.url)
|
|
} else {
|
|
XCTFail("Expected pane node")
|
|
}
|
|
}
|
|
|
|
func testDecodeBrowserSurface() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "test",
|
|
"workspace": {
|
|
"layout": {
|
|
"pane": {
|
|
"surfaces": [{
|
|
"type": "browser",
|
|
"name": "Preview",
|
|
"url": "http://localhost:8080"
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
if case .pane(let pane) = config.commands[0].workspace!.layout! {
|
|
let s = pane.surfaces[0]
|
|
XCTAssertEqual(s.type, .browser)
|
|
XCTAssertEqual(s.url, "http://localhost:8080")
|
|
} else {
|
|
XCTFail("Expected pane node")
|
|
}
|
|
}
|
|
|
|
func testDecodeMultipleSurfacesInPane() throws {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "test",
|
|
"workspace": {
|
|
"layout": {
|
|
"pane": {
|
|
"surfaces": [
|
|
{ "type": "terminal", "name": "shell1" },
|
|
{ "type": "terminal", "name": "shell2" },
|
|
{ "type": "browser", "name": "web" }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let config = try decode(json)
|
|
if case .pane(let pane) = config.commands[0].workspace!.layout! {
|
|
XCTAssertEqual(pane.surfaces.count, 3)
|
|
XCTAssertEqual(pane.surfaces.map(\.name), ["shell1", "shell2", "web"])
|
|
} else {
|
|
XCTFail("Expected pane node")
|
|
}
|
|
}
|
|
|
|
// MARK: Decoding errors
|
|
|
|
func testDecodeInvalidLayoutNodeThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "bad",
|
|
"workspace": {
|
|
"layout": { "invalid": true }
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeMissingCommandsKeyThrows() {
|
|
let json = """
|
|
{ "notCommands": [] }
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeInvalidSurfaceTypeThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "test",
|
|
"workspace": {
|
|
"layout": {
|
|
"pane": {
|
|
"surfaces": [{ "type": "invalidType" }]
|
|
}
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
// MARK: Command validation
|
|
|
|
func testDecodeCommandWithNeitherWorkspaceNorCommandThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "empty"
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeCommandWithBothWorkspaceAndCommandThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "hybrid",
|
|
"command": "echo hi",
|
|
"workspace": { "name": "ws" }
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
// MARK: Layout validation
|
|
|
|
func testDecodeLayoutNodeWithBothPaneAndDirectionThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "ambiguous",
|
|
"workspace": {
|
|
"layout": {
|
|
"pane": { "surfaces": [{ "type": "terminal" }] },
|
|
"direction": "horizontal",
|
|
"children": [
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } },
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } }
|
|
]
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeSplitWithWrongChildrenCountThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "bad-split",
|
|
"workspace": {
|
|
"layout": {
|
|
"direction": "horizontal",
|
|
"children": [
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } }
|
|
]
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeSplitWithThreeChildrenThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "bad-split",
|
|
"workspace": {
|
|
"layout": {
|
|
"direction": "vertical",
|
|
"children": [
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } },
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } },
|
|
{ "pane": { "surfaces": [{ "type": "terminal" }] } }
|
|
]
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodePaneWithEmptySurfacesThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "empty-pane",
|
|
"workspace": {
|
|
"layout": {
|
|
"pane": { "surfaces": [] }
|
|
}
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeBlankNameThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "",
|
|
"command": "echo hi"
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeWhitespaceOnlyNameThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": " ",
|
|
"command": "echo hi"
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeBlankCommandThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "test",
|
|
"command": ""
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
|
|
func testDecodeWhitespaceOnlyCommandThrows() {
|
|
let json = """
|
|
{
|
|
"commands": [{
|
|
"name": "test",
|
|
"command": " "
|
|
}]
|
|
}
|
|
"""
|
|
XCTAssertThrowsError(try decode(json))
|
|
}
|
|
}
|
|
|
|
// MARK: - Command identity
|
|
|
|
final class CmuxCommandIdentityTests: XCTestCase {
|
|
|
|
func testCommandIdIsDeterministic() {
|
|
let cmd = CmuxCommandDefinition(name: "Run tests", command: "test")
|
|
XCTAssertEqual(cmd.id, "cmux.config.command.Run%20tests")
|
|
}
|
|
|
|
func testCommandIdEncodesSpecialCharacters() {
|
|
let cmd = CmuxCommandDefinition(name: "build & deploy", command: "make")
|
|
XCTAssertTrue(cmd.id.hasPrefix("cmux.config.command."))
|
|
XCTAssertFalse(cmd.id.contains("&"))
|
|
XCTAssertFalse(cmd.id.contains(" "))
|
|
}
|
|
|
|
func testCommandIdIsUniqueForDifferentNames() {
|
|
let cmd1 = CmuxCommandDefinition(name: "build", command: "make build")
|
|
let cmd2 = CmuxCommandDefinition(name: "test", command: "make test")
|
|
XCTAssertNotEqual(cmd1.id, cmd2.id)
|
|
}
|
|
|
|
func testCommandIdDoesNotCollideWithBuiltinPrefix() {
|
|
let cmd = CmuxCommandDefinition(name: "palette.newWorkspace", command: "echo")
|
|
XCTAssertTrue(cmd.id.hasPrefix("cmux.config.command."))
|
|
XCTAssertNotEqual(cmd.id, "palette.newWorkspace")
|
|
}
|
|
}
|
|
|
|
// MARK: - Split clamping
|
|
|
|
final class CmuxSplitDefinitionTests: XCTestCase {
|
|
|
|
func testClampedSplitPositionDefaultsToHalf() {
|
|
let split = CmuxSplitDefinition(direction: .horizontal, split: nil, children: [])
|
|
XCTAssertEqual(split.clampedSplitPosition, 0.5)
|
|
}
|
|
|
|
func testClampedSplitPositionPassesThroughValidValue() {
|
|
let split = CmuxSplitDefinition(direction: .vertical, split: 0.3, children: [])
|
|
XCTAssertEqual(split.clampedSplitPosition, 0.3, accuracy: 0.001)
|
|
}
|
|
|
|
func testClampedSplitPositionClampsLow() {
|
|
let split = CmuxSplitDefinition(direction: .horizontal, split: 0.01, children: [])
|
|
XCTAssertEqual(split.clampedSplitPosition, 0.1, accuracy: 0.001)
|
|
}
|
|
|
|
func testClampedSplitPositionClampsHigh() {
|
|
let split = CmuxSplitDefinition(direction: .horizontal, split: 0.99, children: [])
|
|
XCTAssertEqual(split.clampedSplitPosition, 0.9, accuracy: 0.001)
|
|
}
|
|
|
|
func testClampedSplitPositionClampsNegative() {
|
|
let split = CmuxSplitDefinition(direction: .horizontal, split: -1.0, children: [])
|
|
XCTAssertEqual(split.clampedSplitPosition, 0.1, accuracy: 0.001)
|
|
}
|
|
|
|
func testClampedSplitPositionClampsAboveOne() {
|
|
let split = CmuxSplitDefinition(direction: .horizontal, split: 2.0, children: [])
|
|
XCTAssertEqual(split.clampedSplitPosition, 0.9, accuracy: 0.001)
|
|
}
|
|
|
|
func testSplitOrientationHorizontal() {
|
|
let split = CmuxSplitDefinition(direction: .horizontal, split: nil, children: [])
|
|
XCTAssertEqual(split.splitOrientation, .horizontal)
|
|
}
|
|
|
|
func testSplitOrientationVertical() {
|
|
let split = CmuxSplitDefinition(direction: .vertical, split: nil, children: [])
|
|
XCTAssertEqual(split.splitOrientation, .vertical)
|
|
}
|
|
}
|
|
|
|
// MARK: - CWD resolution
|
|
|
|
@MainActor
|
|
final class CmuxConfigCwdResolutionTests: XCTestCase {
|
|
|
|
private let baseCwd = "/Users/test/project"
|
|
|
|
func testNilCwdReturnsBase() {
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd(nil, relativeTo: baseCwd),
|
|
baseCwd
|
|
)
|
|
}
|
|
|
|
func testEmptyCwdReturnsBase() {
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd("", relativeTo: baseCwd),
|
|
baseCwd
|
|
)
|
|
}
|
|
|
|
func testDotCwdReturnsBase() {
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd(".", relativeTo: baseCwd),
|
|
baseCwd
|
|
)
|
|
}
|
|
|
|
func testAbsolutePathReturnedAsIs() {
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd("/tmp/other", relativeTo: baseCwd),
|
|
"/tmp/other"
|
|
)
|
|
}
|
|
|
|
func testRelativePathJoinedToBase() {
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd("backend/src", relativeTo: baseCwd),
|
|
"/Users/test/project/backend/src"
|
|
)
|
|
}
|
|
|
|
func testTildeExpandsToHome() {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd("~", relativeTo: baseCwd),
|
|
home
|
|
)
|
|
}
|
|
|
|
func testTildeSlashExpandsToHomePlusPath() {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd("~/Documents/work", relativeTo: baseCwd),
|
|
(home as NSString).appendingPathComponent("Documents/work")
|
|
)
|
|
}
|
|
|
|
func testSingleSubdirectory() {
|
|
XCTAssertEqual(
|
|
CmuxConfigStore.resolveCwd("src", relativeTo: baseCwd),
|
|
"/Users/test/project/src"
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout encoding round-trip
|
|
|
|
final class CmuxLayoutEncodingTests: XCTestCase {
|
|
|
|
func testPaneNodeRoundTrips() throws {
|
|
let original = CmuxLayoutNode.pane(CmuxPaneDefinition(surfaces: [
|
|
CmuxSurfaceDefinition(type: .terminal, name: "shell")
|
|
]))
|
|
let data = try JSONEncoder().encode(original)
|
|
let decoded = try JSONDecoder().decode(CmuxLayoutNode.self, from: data)
|
|
|
|
if case .pane(let pane) = decoded {
|
|
XCTAssertEqual(pane.surfaces.count, 1)
|
|
XCTAssertEqual(pane.surfaces[0].name, "shell")
|
|
} else {
|
|
XCTFail("Expected pane node after round-trip")
|
|
}
|
|
}
|
|
|
|
func testSplitNodeRoundTrips() throws {
|
|
let original = CmuxLayoutNode.split(CmuxSplitDefinition(
|
|
direction: .vertical,
|
|
split: 0.7,
|
|
children: [
|
|
.pane(CmuxPaneDefinition(surfaces: [CmuxSurfaceDefinition(type: .terminal)])),
|
|
.pane(CmuxPaneDefinition(surfaces: [CmuxSurfaceDefinition(type: .browser, url: "http://localhost")]))
|
|
]
|
|
))
|
|
let data = try JSONEncoder().encode(original)
|
|
let decoded = try JSONDecoder().decode(CmuxLayoutNode.self, from: data)
|
|
|
|
if case .split(let split) = decoded {
|
|
XCTAssertEqual(split.direction, .vertical)
|
|
XCTAssertEqual(split.split, 0.7)
|
|
XCTAssertEqual(split.children.count, 2)
|
|
} else {
|
|
XCTFail("Expected split node after round-trip")
|
|
}
|
|
}
|
|
}
|