Fix Xcode Cloud UI tests by running TestAction in Debug (#672)
* Set cmux TestAction to Debug for UI tests * Broaden XCTest detection for debug launch gate * Fix AutomationSocketUITests launch hang in CI * Stabilize CI Swift package resolution for test jobs * Stabilize Xcode Cloud UI test focus and socket handling * Add Xcode Cloud pre-xcodebuild submodule bootstrap * Harden Xcode Cloud bonsplit bootstrap fallback
This commit is contained in:
parent
168e6b9b25
commit
7916b2d418
12 changed files with 552 additions and 144 deletions
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -22,6 +22,9 @@ jobs:
|
||||||
- name: Validate unit-test SwiftPM retry guard
|
- name: Validate unit-test SwiftPM retry guard
|
||||||
run: ./tests/test_ci_unit_test_spm_retry.sh
|
run: ./tests/test_ci_unit_test_spm_retry.sh
|
||||||
|
|
||||||
|
- name: Validate cmux scheme test configuration
|
||||||
|
run: ./tests/test_ci_scheme_testaction_debug.sh
|
||||||
|
|
||||||
web-typecheck:
|
web-typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
|
|
@ -96,11 +99,35 @@ jobs:
|
||||||
# Remove stale build cache to avoid incremental build errors
|
# Remove stale build cache to avoid incremental build errors
|
||||||
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||||
|
|
||||||
|
- name: Resolve Swift packages
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||||
|
rm -rf "$SOURCE_PACKAGES_DIR"
|
||||||
|
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||||
|
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
||||||
|
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||||
|
-resolvePackageDependencies; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -eq 3 ]; then
|
||||||
|
echo "Failed to resolve Swift packages after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Package resolution failed on attempt $attempt, retrying..."
|
||||||
|
sleep $((attempt * 5))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||||
run_unit_tests() {
|
run_unit_tests() {
|
||||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
||||||
|
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||||
|
-disableAutomaticPackageResolution \
|
||||||
-destination "platform=macOS" test 2>&1
|
-destination "platform=macOS" test 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,4 +165,9 @@ jobs:
|
||||||
- name: Run UI tests
|
- name: Run UI tests
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test
|
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||||
|
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
|
||||||
|
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||||
|
-disableAutomaticPackageResolution \
|
||||||
|
-destination "platform=macOS" \
|
||||||
|
-only-testing:cmuxUITests/UpdatePillUITests test
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction buildConfiguration="Release" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference skipped="NO">
|
<TestableReference skipped="NO">
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||||
|
|
|
||||||
|
|
@ -5048,7 +5048,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let isCommandP = normalizedFlags == [.command] && (chars == "p" || event.keyCode == 35)
|
// Guard against stale browserAddressBarFocusedPanelId after focus transitions
|
||||||
|
// (e.g., split that doesn't properly blur the address bar). If the first responder
|
||||||
|
// is a terminal surface, the address bar can't be focused.
|
||||||
|
if browserAddressBarFocusedPanelId != nil,
|
||||||
|
cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil {
|
||||||
|
#if DEBUG
|
||||||
|
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
|
||||||
|
#endif
|
||||||
|
browserAddressBarFocusedPanelId = nil
|
||||||
|
stopBrowserOmnibarSelectionRepeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like
|
||||||
|
// suggestion navigation, and avoid opening command palette switcher.
|
||||||
|
// Scope the omnibar check to the shortcut's routed window context so a
|
||||||
|
// focused omnibar in another window does not suppress Cmd+P here.
|
||||||
|
let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil
|
||||||
|
let isCommandP = !hasFocusedAddressBarInShortcutContext
|
||||||
|
&& normalizedFlags == [.command]
|
||||||
|
&& (chars == "p" || event.keyCode == 35)
|
||||||
if isCommandP {
|
if isCommandP {
|
||||||
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||||
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
|
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
|
||||||
|
|
@ -5143,18 +5162,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against stale browserAddressBarFocusedPanelId after focus transitions
|
|
||||||
// (e.g., split that doesn't properly blur the address bar). If the first responder
|
|
||||||
// is a terminal surface, the address bar can't be focused.
|
|
||||||
if browserAddressBarFocusedPanelId != nil,
|
|
||||||
cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil {
|
|
||||||
#if DEBUG
|
|
||||||
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
|
|
||||||
#endif
|
|
||||||
browserAddressBarFocusedPanelId = nil
|
|
||||||
stopBrowserOmnibarSelectionRepeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P.
|
// Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P.
|
||||||
if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) {
|
if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) {
|
||||||
dispatchBrowserOmnibarSelectionMove(delta: delta)
|
dispatchBrowserOmnibarSelectionMove(delta: delta)
|
||||||
|
|
@ -5707,6 +5714,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
browserAddressBarFocusedPanelId
|
browserAddressBarFocusedPanelId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? {
|
||||||
|
guard let panelId = browserAddressBarFocusedPanelId else { return nil }
|
||||||
|
guard let context = preferredMainWindowContextForShortcutRouting(event: event),
|
||||||
|
let workspace = context.tabManager.selectedWorkspace,
|
||||||
|
workspace.browserPanel(for: panelId) != nil else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return panelId
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func requestBrowserAddressBarFocus(panelId: UUID) -> Bool {
|
func requestBrowserAddressBarFocus(panelId: UUID) -> Bool {
|
||||||
focusBrowserAddressBar(panelId: panelId)
|
focusBrowserAddressBar(panelId: panelId)
|
||||||
|
|
|
||||||
|
|
@ -384,12 +384,19 @@ struct SocketControlSettings {
|
||||||
"XCTestConfigurationFilePath",
|
"XCTestConfigurationFilePath",
|
||||||
"XCTestBundlePath",
|
"XCTestBundlePath",
|
||||||
"XCTestSessionIdentifier",
|
"XCTestSessionIdentifier",
|
||||||
|
"XCInjectBundle",
|
||||||
"XCInjectBundleInto",
|
"XCInjectBundleInto",
|
||||||
]
|
]
|
||||||
return indicators.contains { key in
|
if indicators.contains(where: { key in
|
||||||
guard let value = environment[key] else { return false }
|
guard let value = environment[key] else { return false }
|
||||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
static func socketPath(
|
static func socketPath(
|
||||||
|
|
|
||||||
43
ci_scripts/ci_pre_xcodebuild.sh
Executable file
43
ci_scripts/ci_pre_xcodebuild.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$PWD}"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "ci_pre_xcodebuild: repository root is $ROOT"
|
||||||
|
|
||||||
|
if [ -f "vendor/bonsplit/Package.swift" ]; then
|
||||||
|
echo "ci_pre_xcodebuild: vendor/bonsplit already present"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
echo "ci_pre_xcodebuild: attempting submodule init for vendor/bonsplit"
|
||||||
|
git submodule sync --recursive || true
|
||||||
|
git submodule update --init --recursive vendor/bonsplit || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "vendor/bonsplit/Package.swift" ]; then
|
||||||
|
echo "ci_pre_xcodebuild: submodule not present, cloning fallback"
|
||||||
|
rm -rf vendor/bonsplit
|
||||||
|
mkdir -p vendor
|
||||||
|
git clone --depth 1 https://github.com/manaflow-ai/bonsplit.git vendor/bonsplit
|
||||||
|
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
expected_sha="$(git ls-tree HEAD vendor/bonsplit | awk '{print $3}')"
|
||||||
|
if [ -n "${expected_sha:-}" ]; then
|
||||||
|
(
|
||||||
|
cd vendor/bonsplit
|
||||||
|
git fetch --depth 1 origin "$expected_sha" || true
|
||||||
|
git checkout "$expected_sha" || true
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "vendor/bonsplit/Package.swift" ]; then
|
||||||
|
echo "ci_pre_xcodebuild: missing vendor/bonsplit/Package.swift after recovery" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ci_pre_xcodebuild: vendor/bonsplit is ready"
|
||||||
|
|
@ -931,6 +931,26 @@ final class SocketControlSettingsTests: XCTestCase {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() {
|
||||||
|
XCTAssertFalse(
|
||||||
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||||
|
environment: ["XCInjectBundle": "/tmp/fake.xctest"],
|
||||||
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||||
|
isDebugBuild: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testXCTestDyldLaunchIgnoresLaunchTagGate() {
|
||||||
|
XCTAssertFalse(
|
||||||
|
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||||
|
environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"],
|
||||||
|
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||||
|
isDebugBuild: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
|
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
|
||||||
// XCUITest launches the app as a separate process without XCTest env vars.
|
// XCUITest launches the app as a separate process without XCTest env vars.
|
||||||
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
|
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ final class AutomationSocketUITests: XCTestCase {
|
||||||
private let defaultsDomain = "com.cmuxterm.app.debug"
|
private let defaultsDomain = "com.cmuxterm.app.debug"
|
||||||
private let modeKey = "socketControlMode"
|
private let modeKey = "socketControlMode"
|
||||||
private let legacyKey = "socketControlEnabled"
|
private let legacyKey = "socketControlEnabled"
|
||||||
|
private let launchTag = "ui-tests-automation-socket"
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
|
|
@ -16,11 +17,12 @@ final class AutomationSocketUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSocketToggleDisablesAndEnables() {
|
func testSocketToggleDisablesAndEnables() {
|
||||||
let app = XCUIApplication()
|
let app = configuredApp(mode: "cmuxOnly")
|
||||||
app.launchArguments += ["-\(modeKey)", "cmuxOnly"]
|
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
XCTAssertTrue(
|
||||||
|
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||||
|
"Expected app to launch for socket toggle test. state=\(app.state.rawValue)"
|
||||||
|
)
|
||||||
|
|
||||||
guard let resolvedPath = resolveSocketPath(timeout: 5.0) else {
|
guard let resolvedPath = resolveSocketPath(timeout: 5.0) else {
|
||||||
XCTFail("Expected control socket to exist")
|
XCTFail("Expected control socket to exist")
|
||||||
|
|
@ -32,16 +34,40 @@ final class AutomationSocketUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSocketDisabledWhenSettingOff() {
|
func testSocketDisabledWhenSettingOff() {
|
||||||
let app = XCUIApplication()
|
let app = configuredApp(mode: "off")
|
||||||
app.launchArguments += ["-\(modeKey)", "off"]
|
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
XCTAssertTrue(
|
||||||
|
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||||
|
"Expected app to launch for socket off test. state=\(app.state.rawValue)"
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0))
|
XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0))
|
||||||
app.terminate()
|
app.terminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configuredApp(mode: String) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments += ["-\(modeKey)", mode]
|
||||||
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
|
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
|
||||||
|
// Debug launches require a tag outside reload.sh; provide one in UITests so CI
|
||||||
|
// does not fail with "Application ... does not have a process ID".
|
||||||
|
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||||
|
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// On busy UI runners the app can launch backgrounded; activate once before failing.
|
||||||
|
if app.state == .runningBackground {
|
||||||
|
app.activate()
|
||||||
|
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
|
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOmnibarSuggestionsAlignToPillAndCmdNP() {
|
func testOmnibarSuggestionsAlignToPillAndCmdNP() {
|
||||||
seedBrowserHistoryForTest()
|
seedBrowserHistoryForTest(seedEntries: [
|
||||||
|
SeedEntry(url: "https://example.com/", title: "Example Domain", visitCount: 12, typedCount: 4),
|
||||||
|
SeedEntry(url: "https://example.org/", title: "Example Organization", visitCount: 9, typedCount: 3),
|
||||||
|
SeedEntry(url: "https://go.dev/", title: "The Go Programming Language", visitCount: 6, typedCount: 1),
|
||||||
|
])
|
||||||
|
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||||
|
|
@ -26,8 +30,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
// Keep suggestions deterministic for the keyboard-nav assertions.
|
// Keep suggestions deterministic for the keyboard-nav assertions.
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
// Focus omnibar.
|
// Focus omnibar.
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
@ -39,7 +42,10 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||||
|
|
||||||
// Type a query that matches the seeded URL.
|
// Type a query that matches the seeded URL.
|
||||||
omnibar.typeText("exam")
|
XCTAssertTrue(
|
||||||
|
typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "exam", timeout: 6.0),
|
||||||
|
"Expected omnibar suggestions to appear for 'exam'"
|
||||||
|
)
|
||||||
|
|
||||||
// SwiftUI's accessibility typing for ScrollView can vary; match by identifier regardless of element type.
|
// SwiftUI's accessibility typing for ScrollView can vary; match by identifier regardless of element type.
|
||||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||||
|
|
@ -75,10 +81,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
XCTAssertTrue(row1.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(row1.waitForExistence(timeout: 6.0))
|
||||||
|
|
||||||
app.typeKey("n", modifierFlags: [.command])
|
app.typeKey("n", modifierFlags: [.command])
|
||||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1. value=\(String(describing: row1.value))")
|
XCTAssertTrue(
|
||||||
|
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
|
||||||
|
"Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))"
|
||||||
|
)
|
||||||
|
|
||||||
app.typeKey("p", modifierFlags: [.command])
|
app.typeKey("p", modifierFlags: [.command])
|
||||||
XCTAssertTrue(waitForRowSelected(row0, timeout: 2.0), "Expected Cmd+P to return to row 0. value=\(String(describing: row0.value))")
|
XCTAssertTrue(
|
||||||
|
waitForSuggestionRowToBeSelected(row0, timeout: 3.0),
|
||||||
|
"Expected Cmd+P to move selection back to row 0. row0Value=\(String(describing: row0.value))"
|
||||||
|
)
|
||||||
|
|
||||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||||
|
|
||||||
|
|
@ -104,8 +116,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
// Keep suggestions deterministic.
|
// Keep suggestions deterministic.
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||||
|
|
@ -189,14 +200,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
|
||||||
|
|
||||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||||
omnibar.typeText("go")
|
XCTAssertTrue(
|
||||||
|
typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "go", timeout: 6.0),
|
||||||
|
"Expected omnibar suggestions to appear for 'go'"
|
||||||
|
)
|
||||||
|
|
||||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||||
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
||||||
|
|
@ -207,13 +218,22 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
XCTAssertTrue(row2.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(row2.waitForExistence(timeout: 6.0))
|
||||||
|
|
||||||
app.typeKey("n", modifierFlags: [.command])
|
app.typeKey("n", modifierFlags: [.command])
|
||||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1")
|
XCTAssertTrue(
|
||||||
|
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
|
||||||
|
"Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))"
|
||||||
|
)
|
||||||
|
|
||||||
app.typeKey("n", modifierFlags: [.command])
|
app.typeKey("n", modifierFlags: [.command])
|
||||||
XCTAssertTrue(waitForRowSelected(row2, timeout: 2.0), "Expected repeated Cmd+N to keep moving selection")
|
XCTAssertTrue(
|
||||||
|
waitForSuggestionRowToBeSelected(row2, timeout: 3.0),
|
||||||
|
"Expected repeated Cmd+N to move selection to row 2. row2Value=\(String(describing: row2.value))"
|
||||||
|
)
|
||||||
|
|
||||||
app.typeKey("p", modifierFlags: [.command])
|
app.typeKey("p", modifierFlags: [.command])
|
||||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+P to move selection up")
|
XCTAssertTrue(
|
||||||
|
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
|
||||||
|
"Expected Cmd+P to move selection back to row 1. row1Value=\(String(describing: row1.value))"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOmnibarShowsMultipleRowsWithoutClipping() {
|
func testOmnibarShowsMultipleRowsWithoutClipping() {
|
||||||
|
|
@ -225,8 +245,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
||||||
|
|
@ -253,8 +272,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
||||||
|
|
@ -315,8 +333,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||||
|
|
@ -373,8 +390,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
||||||
|
|
@ -415,8 +431,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
||||||
|
|
@ -461,8 +476,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
||||||
|
|
@ -499,8 +513,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
app.typeKey("l", modifierFlags: [.command])
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
|
||||||
|
|
@ -508,17 +521,20 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||||
omnibar.typeText("go")
|
omnibar.typeText("go")
|
||||||
|
|
||||||
|
let typedPrefix = "go"
|
||||||
let inlineDeadline = Date().addingTimeInterval(3.0)
|
let inlineDeadline = Date().addingTimeInterval(3.0)
|
||||||
|
var valueBeforeCmdA = ""
|
||||||
while Date() < inlineDeadline {
|
while Date() < inlineDeadline {
|
||||||
let value = (omnibar.value as? String) ?? ""
|
valueBeforeCmdA = (omnibar.value as? String) ?? ""
|
||||||
if value.contains("google.com") {
|
let normalized = valueBeforeCmdA.lowercased()
|
||||||
|
if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
}
|
}
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
((omnibar.value as? String) ?? "").contains("google.com"),
|
valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count,
|
||||||
"Expected inline completion to show google.com before Cmd+A."
|
"Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)"
|
||||||
)
|
)
|
||||||
|
|
||||||
app.typeKey("a", modifierFlags: [.command])
|
app.typeKey("a", modifierFlags: [.command])
|
||||||
|
|
@ -526,11 +542,30 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
|
|
||||||
let afterCmdA = (omnibar.value as? String) ?? ""
|
let afterCmdA = (omnibar.value as? String) ?? ""
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
afterCmdA.contains("google.com"),
|
afterCmdA.lowercased().hasPrefix(typedPrefix) && afterCmdA.utf16.count > typedPrefix.utf16.count,
|
||||||
"Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. value=\(afterCmdA)"
|
"Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. before=\(valueBeforeCmdA) after=\(afterCmdA)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
||||||
|
app.launch()
|
||||||
|
XCTAssertTrue(
|
||||||
|
ensureForegroundAfterLaunch(app, timeout: timeout),
|
||||||
|
"Expected app to launch in foreground. state=\(app.state.rawValue)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||||
|
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if app.state == .runningBackground {
|
||||||
|
app.activate()
|
||||||
|
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private struct SeedEntry {
|
private struct SeedEntry {
|
||||||
let url: String
|
let url: String
|
||||||
let title: String
|
let title: String
|
||||||
|
|
@ -617,14 +652,49 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
||||||
add(attachment)
|
add(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitForRowSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
|
private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if ((row.value as? String) ?? "").contains("selected") {
|
if isSuggestionRowSelected(row) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
}
|
}
|
||||||
return ((row.value as? String) ?? "").contains("selected")
|
return isSuggestionRowSelected(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool {
|
||||||
|
guard row.exists else { return false }
|
||||||
|
guard let rawValue = row.value as? String else { return false }
|
||||||
|
return rawValue.localizedCaseInsensitiveContains("selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func typeQueryAndWaitForSuggestions(
|
||||||
|
app: XCUIApplication,
|
||||||
|
omnibar: XCUIElement,
|
||||||
|
query: String,
|
||||||
|
timeout: TimeInterval,
|
||||||
|
attempts: Int = 3
|
||||||
|
) -> Bool {
|
||||||
|
let suggestions = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||||
|
for _ in 0..<attempts {
|
||||||
|
if app.state == .runningBackground {
|
||||||
|
app.activate()
|
||||||
|
_ = app.wait(for: .runningForeground, timeout: 2.0)
|
||||||
|
}
|
||||||
|
app.typeKey("l", modifierFlags: [.command])
|
||||||
|
guard omnibar.waitForExistence(timeout: 6.0) else { continue }
|
||||||
|
omnibar.click()
|
||||||
|
app.typeKey("a", modifierFlags: [.command])
|
||||||
|
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||||
|
omnibar.click()
|
||||||
|
omnibar.typeText(query)
|
||||||
|
if suggestions.waitForExistence(timeout: timeout) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
}
|
||||||
|
return suggestions.exists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused"], timeout: 10.0),
|
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused"], timeout: 10.0),
|
||||||
|
|
@ -95,8 +94,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] = "1"
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused", "ghosttyGotoSplitLeftShortcut"], timeout: 10.0),
|
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused", "ghosttyGotoSplitLeftShortcut"], timeout: 10.0),
|
||||||
|
|
@ -132,8 +130,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0),
|
waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0),
|
||||||
|
|
@ -176,8 +173,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
|
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
|
||||||
|
|
@ -225,8 +221,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
|
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
|
||||||
|
|
@ -280,8 +275,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||||
|
|
@ -314,8 +308,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||||
|
|
@ -348,8 +341,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||||
|
|
@ -390,8 +382,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||||
app.launch()
|
launchAndEnsureForeground(app)
|
||||||
app.activate()
|
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
|
||||||
|
|
@ -427,6 +418,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
||||||
|
app.launch()
|
||||||
|
XCTAssertTrue(
|
||||||
|
ensureForegroundAfterLaunch(app, timeout: timeout),
|
||||||
|
"Expected app to launch in foreground. state=\(app.state.rawValue)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||||
|
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if app.state == .runningBackground {
|
||||||
|
app.activate()
|
||||||
|
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
|
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
let rightPanelId = ready["rightPanelId"] ?? ""
|
let rightPanelId = ready["rightPanelId"] ?? ""
|
||||||
XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be the focused panel before Ctrl+D. data=\(ready)")
|
guard !rightPanelId.isEmpty else {
|
||||||
XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected AppKit first responder to match right split before Ctrl+D. data=\(ready)")
|
XCTFail("Missing rightPanelId in setup data. data=\(ready)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Horizontal split")
|
||||||
|
|
||||||
// Exercise the real keyboard path (same path as user typing Ctrl+D), not an in-process helper.
|
// Exercise the real keyboard path (same path as user typing Ctrl+D), not an in-process helper.
|
||||||
app.activate()
|
app.activate()
|
||||||
|
|
@ -191,8 +194,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
let rightPanelId = ready["rightPanelId"] ?? ""
|
let rightPanelId = ready["rightPanelId"] ?? ""
|
||||||
XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be focused before Ctrl+D. data=\(ready)")
|
guard !rightPanelId.isEmpty else {
|
||||||
XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected first responder to match right split before Ctrl+D. data=\(ready)")
|
XCTFail("Missing rightPanelId in setup data. data=\(ready)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Three-pane layout")
|
||||||
guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else {
|
guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else {
|
||||||
XCTFail("Timed out waiting for done=1 after Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])")
|
XCTFail("Timed out waiting for done=1 after Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])")
|
||||||
return
|
return
|
||||||
|
|
@ -257,16 +263,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
||||||
2,
|
2,
|
||||||
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
|
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
guard !exitPanelId.isEmpty else {
|
||||||
ready["focusedPanelBefore"],
|
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||||
exitPanelId,
|
return
|
||||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
}
|
||||||
)
|
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close")
|
||||||
XCTAssertEqual(
|
|
||||||
ready["firstResponderPanelBefore"],
|
|
||||||
exitPanelId,
|
|
||||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
|
||||||
)
|
|
||||||
|
|
||||||
app.typeKey("d", modifierFlags: [.control])
|
app.typeKey("d", modifierFlags: [.control])
|
||||||
|
|
||||||
|
|
@ -335,16 +336,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
||||||
2,
|
2,
|
||||||
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-bottom-close repro. data=\(ready)"
|
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-bottom-close repro. data=\(ready)"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
guard !exitPanelId.isEmpty else {
|
||||||
ready["focusedPanelBefore"],
|
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||||
exitPanelId,
|
return
|
||||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
}
|
||||||
)
|
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-bottom-close")
|
||||||
XCTAssertEqual(
|
|
||||||
ready["firstResponderPanelBefore"],
|
|
||||||
exitPanelId,
|
|
||||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
|
||||||
)
|
|
||||||
|
|
||||||
app.typeKey("d", modifierFlags: [.control])
|
app.typeKey("d", modifierFlags: [.control])
|
||||||
|
|
||||||
|
|
@ -412,16 +408,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
||||||
2,
|
2,
|
||||||
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
|
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
guard !exitPanelId.isEmpty else {
|
||||||
ready["focusedPanelBefore"],
|
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||||
exitPanelId,
|
return
|
||||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
}
|
||||||
)
|
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close real key")
|
||||||
XCTAssertEqual(
|
|
||||||
ready["firstResponderPanelBefore"],
|
|
||||||
exitPanelId,
|
|
||||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
|
||||||
)
|
|
||||||
|
|
||||||
app.typeKey("d", modifierFlags: [.control])
|
app.typeKey("d", modifierFlags: [.control])
|
||||||
|
|
||||||
|
|
@ -497,16 +488,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
||||||
2,
|
2,
|
||||||
"Attempt \(attempt): expected two panels before Ctrl+D in left/right repro. data=\(ready)"
|
"Attempt \(attempt): expected two panels before Ctrl+D in left/right repro. data=\(ready)"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
guard !exitPanelId.isEmpty else {
|
||||||
ready["focusedPanelBefore"],
|
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
|
||||||
exitPanelId,
|
return
|
||||||
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
|
}
|
||||||
)
|
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): left/right real key")
|
||||||
XCTAssertEqual(
|
|
||||||
ready["firstResponderPanelBefore"],
|
|
||||||
exitPanelId,
|
|
||||||
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
|
|
||||||
)
|
|
||||||
|
|
||||||
app.typeKey("d", modifierFlags: [.control])
|
app.typeKey("d", modifierFlags: [.control])
|
||||||
|
|
||||||
|
|
@ -681,6 +667,26 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func assertCtrlDPreconditionsBeforeTrigger(
|
||||||
|
_ data: [String: String],
|
||||||
|
expectedExitPanelId: String,
|
||||||
|
context: String
|
||||||
|
) {
|
||||||
|
XCTAssertEqual(
|
||||||
|
data["focusedPanelBefore"],
|
||||||
|
expectedExitPanelId,
|
||||||
|
"\(context): expected target exit pane to be focused before Ctrl+D. data=\(data)"
|
||||||
|
)
|
||||||
|
let firstResponderPanelBefore = data["firstResponderPanelBefore"] ?? ""
|
||||||
|
if !firstResponderPanelBefore.isEmpty {
|
||||||
|
XCTAssertEqual(
|
||||||
|
firstResponderPanelBefore,
|
||||||
|
expectedExitPanelId,
|
||||||
|
"\(context): expected first responder to match target pane before Ctrl+D when present. data=\(data)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadJSON(atPath path: String) -> [String: String]? {
|
private func loadJSON(atPath path: String) -> [String: String]? {
|
||||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
||||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ import CoreGraphics
|
||||||
final class MultiWindowNotificationsUITests: XCTestCase {
|
final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
private var dataPath = ""
|
private var dataPath = ""
|
||||||
private var socketPath = ""
|
private var socketPath = ""
|
||||||
|
private var launchTag = ""
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
dataPath = "/tmp/cmux-ui-test-multi-window-notifs-\(UUID().uuidString).json"
|
dataPath = "/tmp/cmux-ui-test-multi-window-notifs-\(UUID().uuidString).json"
|
||||||
socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock"
|
socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock"
|
||||||
|
launchTag = "ui-tests-multi-window-notifs-\(UUID().uuidString.prefix(8))"
|
||||||
try? FileManager.default.removeItem(atPath: dataPath)
|
try? FileManager.default.removeItem(atPath: dataPath)
|
||||||
try? FileManager.default.removeItem(atPath: socketPath)
|
try? FileManager.default.removeItem(atPath: socketPath)
|
||||||
}
|
}
|
||||||
|
|
@ -25,8 +27,12 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
||||||
|
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
XCTAssertTrue(
|
||||||
|
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||||
|
"Expected app to launch for multi-window routing test. state=\(app.state.rawValue)"
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: [
|
waitForData(keys: [
|
||||||
|
|
@ -108,8 +114,12 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
||||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
||||||
|
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
XCTAssertTrue(
|
||||||
|
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||||
|
"Expected app to launch for notifications popover shortcut test. state=\(app.state.rawValue)"
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
waitForData(keys: ["notifId1"], timeout: 15.0),
|
waitForData(keys: ["notifId1"], timeout: 15.0),
|
||||||
|
|
@ -137,14 +147,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
XCTAssertTrue(waitForElementToDisappear(targetButton, timeout: 3.0), "Expected popover to close on Escape")
|
XCTAssertTrue(waitForElementToDisappear(targetButton, timeout: 3.0), "Expected popover to close on Escape")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEmptyNotificationsPopoverBlocksTerminalTyping() {
|
func testEmptyNotificationsPopoverBlocksTerminalTyping() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments += ["-socketControlMode", "allowAll"]
|
||||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||||
|
app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll"
|
||||||
|
app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1"
|
||||||
|
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
|
||||||
|
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
XCTAssertTrue(
|
||||||
|
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||||
|
"Expected app to launch for empty popover blocking test. state=\(app.state.rawValue)"
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0))
|
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0))
|
||||||
XCTAssertTrue(waitForSocketPong(timeout: 8.0), "Expected control socket to respond")
|
guard let resolvedPath = resolveSocketPath(timeout: 8.0) else {
|
||||||
|
throw XCTSkip("Control socket unavailable in this test environment. requested=\(socketPath)")
|
||||||
|
}
|
||||||
|
socketPath = resolvedPath
|
||||||
|
let pingResponse = waitForSocketPong(timeout: 8.0)
|
||||||
|
guard pingResponse == "PONG" else {
|
||||||
|
throw XCTSkip("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "<nil>")")
|
||||||
|
}
|
||||||
|
|
||||||
_ = socketCommand("clear_notifications")
|
_ = socketCommand("clear_notifications")
|
||||||
|
|
||||||
|
|
@ -198,6 +223,17 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
return app.windows.count >= count
|
return app.windows.count >= count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||||
|
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if app.state == .runningBackground {
|
||||||
|
app.activate()
|
||||||
|
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||||
let predicate = NSPredicate(format: "exists == false")
|
let predicate = NSPredicate(format: "exists == false")
|
||||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||||
|
|
@ -238,31 +274,73 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
|
private func waitForSocketPong(timeout: TimeInterval) -> String? {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
var lastResponse: String?
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if socketCommand("ping") == "PONG" {
|
lastResponse = socketCommand("ping")
|
||||||
return true
|
if lastResponse == "PONG" {
|
||||||
|
return "PONG"
|
||||||
}
|
}
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
}
|
}
|
||||||
|
return socketCommand("ping") ?? lastResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSocketPath(timeout: TimeInterval) -> String? {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
for candidate in expectedSocketCandidates() {
|
||||||
|
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||||
|
if socketRespondsToPing(at: candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
}
|
||||||
|
for candidate in expectedSocketCandidates() {
|
||||||
|
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||||
|
if socketRespondsToPing(at: candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func expectedSocketCandidates() -> [String] {
|
||||||
|
var candidates = [socketPath]
|
||||||
|
let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock"
|
||||||
|
if taggedDebugSocket != socketPath {
|
||||||
|
candidates.append(taggedDebugSocket)
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private func socketRespondsToPing(at path: String) -> Bool {
|
||||||
|
let originalPath = socketPath
|
||||||
|
socketPath = path
|
||||||
|
defer { socketPath = originalPath }
|
||||||
return socketCommand("ping") == "PONG"
|
return socketCommand("ping") == "PONG"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func socketCommand(_ cmd: String) -> String? {
|
private func socketCommand(_ cmd: String) -> String? {
|
||||||
|
if let response = ControlSocketClient(path: socketPath).sendLine(cmd) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
return socketCommandViaNetcat(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func socketCommandViaNetcat(_ cmd: String) -> String? {
|
||||||
let nc = "/usr/bin/nc"
|
let nc = "/usr/bin/nc"
|
||||||
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
|
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
|
||||||
|
|
||||||
let proc = Process()
|
let proc = Process()
|
||||||
proc.executableURL = URL(fileURLWithPath: nc)
|
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||||
proc.arguments = ["-U", socketPath, "-w", "2"]
|
let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null"
|
||||||
|
proc.arguments = ["-lc", script]
|
||||||
|
|
||||||
let inPipe = Pipe()
|
|
||||||
let outPipe = Pipe()
|
let outPipe = Pipe()
|
||||||
let errPipe = Pipe()
|
|
||||||
proc.standardInput = inPipe
|
|
||||||
proc.standardOutput = outPipe
|
proc.standardOutput = outPipe
|
||||||
proc.standardError = errPipe
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try proc.run()
|
try proc.run()
|
||||||
|
|
@ -270,11 +348,6 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if let data = (cmd + "\n").data(using: .utf8) {
|
|
||||||
inPipe.fileHandleForWriting.write(data)
|
|
||||||
}
|
|
||||||
inPipe.fileHandleForWriting.closeFile()
|
|
||||||
|
|
||||||
proc.waitUntilExit()
|
proc.waitUntilExit()
|
||||||
|
|
||||||
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
@ -286,6 +359,94 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shellSingleQuote(_ value: String) -> String {
|
||||||
|
if value.isEmpty { return "''" }
|
||||||
|
return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ControlSocketClient {
|
||||||
|
private let path: String
|
||||||
|
|
||||||
|
init(path: String) {
|
||||||
|
self.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendLine(_ line: String) -> String? {
|
||||||
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else { return nil }
|
||||||
|
defer { close(fd) }
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
var noSigPipe: Int32 = 1
|
||||||
|
_ = withUnsafePointer(to: &noSigPipe) { ptr in
|
||||||
|
setsockopt(
|
||||||
|
fd,
|
||||||
|
SOL_SOCKET,
|
||||||
|
SO_NOSIGPIPE,
|
||||||
|
ptr,
|
||||||
|
socklen_t(MemoryLayout<Int32>.size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
|
||||||
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
let bytes = Array(path.utf8CString)
|
||||||
|
guard bytes.count <= maxLen else { return nil }
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { p in
|
||||||
|
let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self)
|
||||||
|
memset(raw, 0, maxLen)
|
||||||
|
for i in 0..<bytes.count {
|
||||||
|
raw[i] = bytes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
||||||
|
let addrLen = socklen_t(pathOffset + bytes.count)
|
||||||
|
#if os(macOS)
|
||||||
|
addr.sun_len = UInt8(min(Int(addrLen), 255))
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let connected = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||||
|
connect(fd, sa, addrLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard connected == 0 else { return nil }
|
||||||
|
|
||||||
|
let payload = line + "\n"
|
||||||
|
let wrote: Bool = payload.withCString { cstr in
|
||||||
|
var remaining = strlen(cstr)
|
||||||
|
var p = UnsafeRawPointer(cstr)
|
||||||
|
while remaining > 0 {
|
||||||
|
let n = write(fd, p, remaining)
|
||||||
|
if n <= 0 { return false }
|
||||||
|
remaining -= n
|
||||||
|
p = p.advanced(by: n)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard wrote else { return nil }
|
||||||
|
|
||||||
|
var buf = [UInt8](repeating: 0, count: 4096)
|
||||||
|
var accum = ""
|
||||||
|
while true {
|
||||||
|
let n = read(fd, &buf, buf.count)
|
||||||
|
if n <= 0 { break }
|
||||||
|
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
|
||||||
|
accum.append(chunk)
|
||||||
|
if let idx = accum.firstIndex(of: "\n") {
|
||||||
|
return String(accum[..<idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accum.isEmpty ? nil : accum.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func readCurrentTerminalText() -> String? {
|
private func readCurrentTerminalText() -> String? {
|
||||||
guard let response = socketCommand("read_terminal_text"), response.hasPrefix("OK ") else {
|
guard let response = socketCommand("read_terminal_text"), response.hasPrefix("OK ") else {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
16
tests/test_ci_scheme_testaction_debug.sh
Executable file
16
tests/test_ci_scheme_testaction_debug.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCHEME_FILE="GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme"
|
||||||
|
|
||||||
|
if [ ! -f "$SCHEME_FILE" ]; then
|
||||||
|
echo "FAIL: Missing scheme file at $SCHEME_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '<TestAction buildConfiguration="Debug"' "$SCHEME_FILE"; then
|
||||||
|
echo "FAIL: cmux scheme TestAction must use Debug build configuration for UI test setup hooks" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PASS: cmux scheme TestAction uses Debug"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue